Compare commits

..

30 Commits

Author SHA1 Message Date
15b34a2296 chart stubs 2025-05-06 15:13:16 +02:00
4e678d9c65 menubar live+daily values 2025-05-06 15:12:58 +02:00
54e1487300 View backend + readonly frontend 2025-05-06 14:09:43 +02:00
59b9a5f44f frontend naming 2025-05-05 15:58:05 +02:00
83d8dec332 FIX: Graph used wrong outerAlignment instead of inner 2025-05-05 15:54:35 +02:00
f5e9fd3679 app-percent-bar in electro-power FIX 2025-05-05 15:27:37 +02:00
22e5f96d1d Cistern numberTable 2025-05-05 15:06:27 +02:00
2c66314941 big cleanup + refactor: live+history, enhanced Graph 2025-05-05 12:46:51 +02:00
9ee8060f05 Cistern naming FIX 2025-04-17 16:19:13 +02:00
b6dfdd5686 Cistern Graph image url FIX 2025-04-17 12:26:40 +02:00
6aac9b2662 Cistern 2025-04-17 12:18:43 +02:00
d7ee5062e4 PatrixJsonHandler + Unit.VOLUME_L + Unit.LENGTH_CM 2025-04-17 11:55:25 +02:00
5358f1b9f6 WeatherDiagram: Temperature, Weekday-names, Legend 2025-03-25 09:39:48 +01:00
d81034e6c4 BrightSky nullable 2025-03-04 12:12:07 +01:00
49ce44eff9 BOOLEAN, DOOR_BOOLEAN, WINDOW_BOOLEAN, LIGHT_BOOLEAN 2025-03-04 12:08:33 +01:00
85a749f199 Electro/Energie -> Energy 2025-03-03 15:17:10 +01:00
63548dc3ff UI: Greenhouse + ROUTING constants 2025-03-03 15:12:53 +01:00
0561940861 FIX: alignment + offset text display 2025-02-28 13:40:02 +01:00
f0f68f3285 including 'ng build' into 'deploy' 2025-02-28 13:30:19 +01:00
b4a8b25ae7 FIX: percent-bar flex layout 2025-02-28 13:29:02 +01:00
6c49f4738a code clean 2025-02-28 13:03:57 +01:00
906be87e50 Weather 2025-02-28 12:06:17 +01:00
2882184a82 refactored big AppComponent into Dashboard and parts 2025-02-28 09:04:16 +01:00
9d12986720 Alignments offset text fix for Five 2025-02-28 08:44:59 +01:00
5c3338fd9d api DEV_TO_PROD 2025-02-28 08:44:12 +01:00
088f086cef deployment scripts 2025-02-28 08:33:32 +01:00
f9e94357c8 powerConsumption missed subtraction of powerDelivery 2025-02-27 15:46:57 +01:00
ac50440215 powerSelf.notNegative 2025-02-27 15:44:48 +01:00
f495ad9af1 dark theme + percent-bar + Value.locale 2025-02-27 15:03:02 +01:00
f5cbf9cf43 ui: less opacity for zero values + code clean 2025-02-27 12:45:23 +01:00
98 changed files with 3252 additions and 615 deletions

5
deploy-backend.sh Normal file
View File

@ -0,0 +1,5 @@
#!/bin/bash
cd "$(dirname "$0")" || exit 1
scp -P2222 -r ./target/Data.jar root@10.255.0.1:/srv/Data/update/ && git tag "DEPLOY-BACK---$(date +'%F---%H-%M-%S')" && ssh -p2222 root@10.255.0.1 systemctl restart Data.service

View File

@ -18,7 +18,7 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.2</version> <version>3.4.4</version>
</parent> </parent>
<dependencies> <dependencies>

View File

@ -0,0 +1,57 @@
/* bright theme */
@foreground: gray;
@background: white;
@FONT_SELECTABLE: white;
@consumption: orange;
@purchase: orangered;
@production: dodgerblue;
@cistern: #0760ff;
@self: forestgreen;
@delivery: magenta;
@consumptionBack: @consumption;
@purchaseBack: @purchase;
@productionBack: @production;
@selfBack: @self;
@deliveryBack: @delivery;
/* dark theme */
@foreground: gray;
@background: #11171b;
@consumptionBack: #856938;
@purchaseBack: #71361d;
@productionBack: #2d4255;
@selfBack: #2b4e2b;
@deliveryBack: #753475;
.consumption {
color: @consumption;
}
.purchase {
color: @purchase;
}
.production {
color: @production;
}
.self {
color: @self;
}
.delivery {
color: @delivery;
}
.cistern {
color: @cistern;
}
.zero {
filter: opacity(20%);
}

View File

@ -0,0 +1,49 @@
@import "./colors.less";
.numberTable {
.arrowLeft {
float: left;
}
.arrowRight {
float: right;
}
.title {
clear: left;
font-weight: bold;
text-align: center;
}
.option {
clear: left;
text-align: center;
}
.content {
.entry {
clear: left;
.name {
float: left;
}
.value {
float: right;
}
.percent {
float: right;
padding-top: 0.5em;
font-size: 60%;
width: 4.5em;
text-align: right;
}
}
}
}

View File

@ -7,7 +7,7 @@
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test", "test": "ng test",
"deploy": "scp -P2222 -r dist/angular/browser/* root@10.255.0.1:/srv/Data/www/" "deploy": "ng build && scp -P2222 -r dist/angular/browser/* root@10.255.0.1:/srv/Data/www/ && git tag \"DEPLOY-FRONT---$(date +'%F---%H-%M-%S')\""
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {

View File

@ -0,0 +1,155 @@
import {Series} from '../series/Series';
import {validateNumber, validateString} from '../core/validators';
export abstract class View {
protected constructor(
readonly _type_: string,
readonly uuid: string,
readonly name: string,
) {
//
}
static fromJson(json: any, locale: string): View {
const type = validateString(json._type_);
switch (type) {
case 'literal':
return ViewLiteral.fromJson2(json);
case 'series':
return ViewSeries.fromJson2(json, locale);
case 'unary':
return ViewUnary.fromJson2(json, locale);
case 'binary':
return ViewBinary.fromJson2(json, locale);
default:
throw new Error(`View type '${type}' not implemented.`);
}
}
}
export class ViewLiteral extends View {
constructor(
_type_: string,
uuid: string,
name: string,
readonly value: number,
) {
super(_type_, uuid, name);
}
static fromJson2(json: any): ViewLiteral {
return new ViewLiteral(
validateString(json._type_),
validateString(json.uuid),
validateString(json.name),
validateNumber(json.value),
);
}
static cast(view: View): ViewLiteral {
return view as ViewLiteral;
}
}
export class ViewSeries extends View {
constructor(
_type_: string,
uuid: string,
name: string,
readonly series: Series,
) {
super(_type_, uuid, name);
}
static fromJson2(json: any, locale: string): ViewSeries {
return new ViewSeries(
validateString(json._type_),
validateString(json.uuid),
validateString(json.name),
Series.fromJson(json.series, locale),
);
}
static cast(view: View): ViewSeries {
return view as ViewSeries;
}
}
export enum ViewUnaryOperator {
NEG = "NEG",
REC = "REC",
NOT_NEG = "NOT_NEG",
}
export class ViewUnary extends View {
constructor(
_type_: string,
uuid: string,
name: string,
readonly operation: ViewUnaryOperator,
readonly view: View,
) {
super(_type_, uuid, name);
}
static fromJson2(json: any, locale: string): ViewUnary {
return new ViewUnary(
validateString(json._type_),
validateString(json.uuid),
validateString(json.name),
validateString(json.operation) as ViewUnaryOperator,
View.fromJson(json.view, locale),
);
}
static cast(view: View): ViewUnary {
return view as ViewUnary;
}
}
export enum ViewBinaryOperator {
PLUS = 'PLUS',
MINUS = 'MINUS',
MULTIPLY = 'MULTIPLY',
DIVIDE = 'DIVIDE',
MODULO = 'MODULO',
PERCENT = 'PERCENT',
}
export class ViewBinary extends View {
constructor(
_type_: string,
uuid: string,
name: string,
readonly operation: ViewBinaryOperator,
readonly view0: View,
readonly view1: View,
) {
super(_type_, uuid, name);
}
static fromJson2(json: any, locale: string): ViewBinary {
return new ViewBinary(
validateString(json._type_),
validateString(json.uuid),
validateString(json.name),
validateString(json.operation) as ViewBinaryOperator,
View.fromJson(json.view0, locale),
View.fromJson(json.view1, locale),
);
}
static cast(view: View): ViewBinary {
return view as ViewBinary;
}
}

View File

@ -0,0 +1,49 @@
<ng-container *ngIf="view && seriesList">
<div [ngSwitch]="view._type_">
<ng-container *ngSwitchCase="'literal'">
<input type="number" [ngModel]="ViewLiteral.cast(view).value">
</ng-container>
<ng-container *ngSwitchCase="'series'">
<select [ngModel]="ViewSeries.cast(view).series.id">
<option *ngFor="let series of seriesList" [ngValue]="series.id">{{ series.name }}</option>
</select>
</ng-container>
<ng-container *ngSwitchCase="'unary'">
<select [ngModel]="ViewUnary.cast(view).operation">
<option [ngValue]="ViewUnaryOperator.NEG">negiere</option>
<option [ngValue]="ViewUnaryOperator.NOT_NEG">nicht negativ</option>
<option [ngValue]="ViewUnaryOperator.REC">Kehrwert</option>
</select>
<div class="children">
<div class="child">
<app-view-body [view]="ViewUnary.cast(view).view" [seriesList]="seriesList"></app-view-body>
</div>
</div>
</ng-container>
<ng-container *ngSwitchCase="'binary'">
<div class="children">
<div class="child">
<app-view-body [view]="ViewBinary.cast(view).view0" [seriesList]="seriesList"></app-view-body>
</div>
<select class="binary" [ngModel]="ViewBinary.cast(view).operation">
<option [ngValue]="ViewBinaryOperator.PLUS">plus</option>
<option [ngValue]="ViewBinaryOperator.MINUS">minus</option>
<option [ngValue]="ViewBinaryOperator.MULTIPLY">mal</option>
<option [ngValue]="ViewBinaryOperator.DIVIDE">geteilt</option>
<option [ngValue]="ViewBinaryOperator.MODULO">mod</option>
<option [ngValue]="ViewBinaryOperator.PERCENT">Prozent</option>
</select>
<div class="child">
<app-view-body [view]="ViewBinary.cast(view).view1" [seriesList]="seriesList"></app-view-body>
</div>
</div>
</ng-container>
</div>
</ng-container>

View File

@ -0,0 +1,19 @@
@import "../../../../colors";
.children {
margin-left: 0.5em;
border-left: 0.1em solid green;
overflow: visible;
.child {
padding-top: 0.5em;
padding-bottom: 0.5em;
padding-left: 0.5em;
}
}
select.binary {
background-color: @background;
margin-left: -0.5em;
}

View File

@ -0,0 +1,40 @@
import {Component, Input} from '@angular/core';
import {NgForOf, NgIf, NgSwitch, NgSwitchCase} from '@angular/common';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {View, ViewBinary, ViewBinaryOperator, ViewLiteral, ViewSeries, ViewUnary, ViewUnaryOperator} from '../View';
import {Series} from '../../series/Series';
@Component({
selector: 'app-view-body',
imports: [
NgForOf,
NgSwitchCase,
ReactiveFormsModule,
NgSwitch,
FormsModule,
NgIf
],
templateUrl: './view-body.component.html',
styleUrl: './view-body.component.less'
})
export class ViewBodyComponent {
protected readonly ViewSeries = ViewSeries;
protected readonly ViewLiteral = ViewLiteral;
protected readonly ViewUnary = ViewUnary;
protected readonly ViewUnaryOperator = ViewUnaryOperator;
protected readonly ViewBinaryOperator = ViewBinaryOperator;
protected readonly ViewBinary = ViewBinary;
@Input()
view?: View;
@Input()
seriesList?: Series[] = [];
}

View File

@ -0,0 +1,11 @@
<div class="list">
<div class="view" *ngFor="let view of rootList">
<div class="labelPair">
<div class="name">Name:</div>
<input [ngModel]="view.name" placeholder="Name">
</div>
<div class="body">
<app-view-body [view]="view" [seriesList]="seriesList"></app-view-body>
</div>
</div>
</div>

View File

@ -0,0 +1,24 @@
.view {
font-size: 80%;
.labelPair {
display: flex;
white-space: nowrap;
margin: 0.5em;
.name {
padding-right: 0.5em;
}
input {
flex-grow: 1;
width: 0;
}
}
.body {
margin: 0.5em;
}
}

View File

@ -0,0 +1,46 @@
import {Component, OnInit} from '@angular/core';
import {ViewService} from '../view.service';
import {View, ViewLiteral, ViewSeries, ViewUnary, ViewUnaryOperator} from '../View';
import {NgForOf} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {Series} from '../../series/Series';
import {SeriesService} from '../../series/series.service';
import {ViewBodyComponent} from '../view-body/view-body.component';
@Component({
selector: 'app-view-list',
imports: [
NgForOf,
FormsModule,
ViewBodyComponent
],
templateUrl: './view-list.component.html',
styleUrl: './view-list.component.less'
})
export class ViewListComponent implements OnInit {
protected readonly ViewUnary = ViewUnary;
protected readonly ViewUnaryOperator = ViewUnaryOperator;
protected readonly ViewLiteral = ViewLiteral;
protected readonly ViewSeries = ViewSeries;
protected seriesList: Series[] = [];
protected rootList: View[] = [];
constructor(
readonly viewService: ViewService,
readonly seriesService: SeriesService,
) {
//
}
ngOnInit(): void {
this.viewService.list(list => this.rootList = list);
this.seriesService.all(list => this.seriesList = list);
}
}

View File

@ -0,0 +1,28 @@
import {Inject, Injectable, LOCALE_ID} from '@angular/core';
import {ApiService} from '../core/api.service';
import {FromJson, Next} from '../core/types';
import {View} from './View';
@Injectable({
providedIn: 'root'
})
export class ViewService {
readonly fromJson: FromJson<View> = json => View.fromJson(json, this.locale);
constructor(
readonly api: ApiService,
@Inject(LOCALE_ID) readonly locale: string,
) {
//
}
list(next: Next<View[]>) {
return this.api.getList(['View', 'rootList'], this.fromJson, next);
}
byUuid(uuid: string, next: Next<View>) {
return this.api.getSingle(['View', 'byUuid', uuid], this.fromJson, next);
}
}

View File

@ -1,85 +1,31 @@
<div class="section"> <div class="menubar">
<div class="title"> <div class="menuitem menuitemLeft" *ngFor="let route of menubar()" [routerLink]="[route.routerLink]" routerLinkActive="menuitemActive">{{ route.title }}</div>
Leistung
<div class="menuitem electricity">
<ng-container *ngIf="isDelivering">
<div class="halfLine">
<div class="delivery">{{ powerDelivery?.formatted2 }}</div>
<div>&nbsp;+&nbsp;</div>
<div class="self">{{ powerSelf?.formatted2 }}</div>
</div>
</ng-container>
<ng-container *ngIf="!isDelivering">
<div class="halfLine">
<div class="self">{{ powerProduction?.formatted2 }}</div>
<div>&nbsp;+&nbsp;</div>
<div class="purchase">{{ powerPurchase?.formatted2 }}</div>
</div>
</ng-container>
<div class="halfLine">
<div class="delivery">{{ aggregations.energyDeliveredPercent?.formatted2 }}</div>
<div>&nbsp;/&nbsp;</div>
<div class="purchase">{{ aggregations.energyPurchasedPercent?.formatted2 }}</div>
</div> </div>
<div class="content">
<div class="entry consumption">
<div class="name">Verbrauch</div>
<div class="percent">&nbsp;</div>
<div class="value">{{ powerConsumption?.formatted }}</div>
</div>
<div class="entry purchase">
<div class="name">Bezug</div>
<div class="percent">{{ powerPurchasePercent?.formatted }}</div>
<div class="value">{{ powerPurchase?.formatted }}</div>
</div>
<div class="entry production">
<div class="name">Produktion</div>
<div class="percent">{{ powerProducedPercent?.formatted }}</div>
<div class="value">{{ powerProduced?.formatted }}</div>
</div>
<div class="entry self">
<div class="name">Eigenverbrauch</div>
<div class="percent">{{ powerSelfPercent?.formatted }}</div>
<div class="value">{{ powerSelf?.formatted }}</div>
</div>
<div class="entry delivery">
<div class="name">Einspeisung</div>
<div class="percent">{{ powerDeliveryPercent?.formatted }}</div>
<div class="value">{{ powerDelivery?.formatted }}</div>
</div>
</div>
</div>
<div class="section">
<div class="title">
Energie
<span class="live" *ngIf="interval">Live</span>
<span class="archive" *ngIf="!interval">Archiv</span>
</div>
<div class="option">
<button class="back" (click)="shiftAlignment(+1)">&larr;</button>
{{ alignment.display }}
<button class="next" (click)="shiftAlignment(-1)">&rarr;</button>
</div>
<div class="option">
<button class="back" (click)="shiftOffset(+1)">&larr;</button>
{{ alignment.offsetTitle(offset, locale) }}
<button class="next" (click)="shiftOffset(-1)" [disabled]="offset === 0">&rarr;</button>
</div>
<div class="content">
<div class="entry consumption">
<div class="name">Verbrauch</div>
<div class="percent">&nbsp;</div>
<div class="value">{{ aggregations.energyConsumed?.formatted }}</div>
</div>
<div class="entry purchase">
<div class="name">Bezug</div>
<div class="percent">{{ aggregations.energyPurchasedPercent?.formatted }}</div>
<div class="value">{{ aggregations.energyPurchased?.delta?.formatted }}</div>
</div>
<div class="entry production">
<div class="name">Produktion</div>
<div class="percent">{{ aggregations.energyProducedPercent?.formatted }}</div>
<div class="value">{{ aggregations.energyProduced?.delta?.formatted }}</div>
</div>
<div class="entry self">
<div class="name">Eigenverbrauch</div>
<div class="percent">{{ aggregations.energySelfPercent?.formatted }}</div>
<div class="value">{{ aggregations.energySelf?.formatted }}</div>
</div>
<div class="entry delivery">
<div class="name">Eingespeist</div>
<div class="percent">{{ aggregations.energyDeliveredPercent?.formatted }}</div>
<div class="value">{{ aggregations.energyDelivered?.delta?.formatted }}</div>
</div>
</div> </div>
</div> </div>

View File

@ -1,82 +1,38 @@
.section { .menubar {
margin-bottom: 1em; border-bottom: 0.05em solid black;
background-color: #303d47;
.back { .menuitem {
padding: 0.1em 0.25em;
}
.menuitemLeft {
float: left; float: left;
border-right: 0.05em solid black;
} }
.next { .menuitemRight {
float: right;
border-left: 0.05em solid black;
}
.menuitemActive {
color: white;
background-color: #006ebc;
}
.electricity {
float: right;
padding-right: 0.25em;
font-size: 55%;
.halfLine {
div {
float: right; float: right;
} }
.title {
clear: left;
font-weight: bold;
font-size: 120%;
text-align: center;
} }
.option {
clear: left;
text-align: center;
}
.content {
.entry {
clear: left;
.name {
float: left;
font-weight: bold;
}
.value {
float: right;
}
.percent {
float: right;
padding-top: 0.5em;
font-size: 60%;
width: 3.5em;
text-align: right;
}
}
} }
} }
.live {
font-size: 40%;
color: green;
vertical-align: top;
}
.archive {
font-size: 40%;
color: darkred;
vertical-align: top;
}
.consumption {
color: orange;
}
.purchase {
color: orangered;
}
.production {
color: dodgerblue;
}
.self {
color: forestgreen;
}
.delivery {
color: magenta;
}

View File

@ -1,31 +1,47 @@
import {Component, Inject, LOCALE_ID, OnDestroy, OnInit} from '@angular/core'; import {Component, OnDestroy, OnInit} from '@angular/core';
import {RouterOutlet} from '@angular/router'; import {RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router';
import {Alignment} from './series/Alignment'; import {menubar} from './app.routes';
import {AggregationWrapperDto} from './series/AggregationWrapperDto'; import {NgForOf, NgIf} from '@angular/common';
import {SeriesService} from './series/series.service'; import {SeriesService} from './series/series.service';
import {Subscription} from 'rxjs'; import {Subscription, timer} from 'rxjs';
import {Value} from './value/Value'; import {Value} from './series/value/Value';
import {NgIf} from '@angular/common'; import {AggregationWrapperDto} from './series/AggregationWrapperDto';
import {Alignment} from './series/Alignment';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, NgIf], imports: [RouterOutlet, RouterLink, NgForOf, RouterLinkActive, NgIf],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.less' styleUrl: './app.component.less'
}) })
export class AppComponent implements OnInit, OnDestroy { export class AppComponent implements OnInit, OnDestroy {
protected readonly menubar = menubar;
protected aggregations: AggregationWrapperDto = AggregationWrapperDto.EMPTY; protected aggregations: AggregationWrapperDto = AggregationWrapperDto.EMPTY;
protected alignment: Alignment = Alignment.DAY; private subs: Subscription[] = [];
protected offset: number = 0; constructor(
readonly seriesService: SeriesService,
) {
//
}
protected interval: any; ngOnInit(): void {
this.subs.push(this.seriesService.subscribeAny());
this.subs.push(timer(0, 5000).subscribe(() => this.fetch()));
}
private readonly subs: Subscription[] = []; ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
get powerProduced(): Value | undefined { get isDelivering(): boolean {
return (this.powerDelivery?.value || 0) > 0;
}
get powerProduction(): Value | undefined {
return this.seriesService.powerProduced.series?.lastValue; return this.seriesService.powerProduced.series?.lastValue;
} }
@ -33,6 +49,10 @@ export class AppComponent implements OnInit, OnDestroy {
return this.seriesService.powerBalance.series?.lastValue; return this.seriesService.powerBalance.series?.lastValue;
} }
get powerSelf(): Value | undefined {
return this.powerProduction?.minus(this.powerDelivery)?.notNegative();
}
get powerPurchase(): Value | undefined { get powerPurchase(): Value | undefined {
return this.powerBalance?.notNegative(); return this.powerBalance?.notNegative();
} }
@ -41,73 +61,8 @@ export class AppComponent implements OnInit, OnDestroy {
return this.powerBalance?.negate()?.notNegative(); return this.powerBalance?.negate()?.notNegative();
} }
get powerSelf(): Value | undefined {
return this.powerProduced?.minus(this.powerDelivery);
}
get powerConsumption(): Value | undefined {
return this.powerBalance?.plus(this.powerProduced);
}
get powerProducedPercent(): Value | undefined {
return this.powerProduced?.percent(this.powerConsumption);
}
get powerSelfPercent(): Value | undefined {
return this.powerSelf?.percent(this.powerProduced);
}
get powerPurchasePercent(): Value | undefined {
return this.powerPurchase?.percent(this.powerConsumption);
}
get powerDeliveryPercent(): Value | undefined {
return this.powerDelivery?.percent(this.powerProduced);
}
constructor(
@Inject(LOCALE_ID) public locale: string,
protected readonly seriesService: SeriesService,
) {
//
}
ngOnInit(): void {
this.fetch();
this.subs.push(this.seriesService.subscribeAny());
}
ngOnDestroy(): void {
this.intervalStop();
this.subs.forEach(sub => sub.unsubscribe());
}
shiftOffset(delta: number) {
this.offset = Math.max(0, this.offset + delta);
this.fetch();
}
shiftAlignment(delta: number) {
this.alignment = this.alignment.plus(delta)
this.fetch();
}
private fetch() { private fetch() {
if (this.offset === 0) { this.seriesService.aggregations(Alignment.DAY, 0, aggregations => this.aggregations = aggregations);
if (!this.interval) {
this.interval = setInterval(() => this.fetch(), 5000);
}
} else {
this.intervalStop();
}
this.seriesService.aggregations(this.alignment, this.offset, aggregations => this.aggregations = aggregations);
}
private intervalStop() {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
} }
} }

View File

@ -1,3 +1,40 @@
import {Routes} from '@angular/router'; import {Routes} from '@angular/router';
import {LiveComponent} from './live/live.component';
import {GreenhouseComponent} from './live/greenhouse/greenhouse/greenhouse.component';
import {HistoryComponent} from './history/history.component';
import {ViewListComponent} from './View/view-list/view-list.component';
export const routes: Routes = []; export class Path {
constructor(
readonly path: string,
readonly title: string,
readonly menu: boolean,
) {
//
}
get routerLink(): string {
return `/${this.path}`;
}
}
export const ROUTING = {
LIVE: new Path('Live', 'Live', true),
HISTORY: new Path('History', 'History', true),
VIEW_LIST: new Path('ViewList', 'Views', true),
GREENHOUSE: new Path('Greenhouse', 'Gewächshaus', false),
}
export function menubar(): Path[] {
return Object.values(ROUTING).filter(v => v.menu);
}
export const routes: Routes = [
{path: ROUTING.LIVE.path, component: LiveComponent},
{path: ROUTING.HISTORY.path, component: HistoryComponent},
{path: ROUTING.GREENHOUSE.path, component: GreenhouseComponent},
{path: ROUTING.VIEW_LIST.path, component: ViewListComponent},
{path: '**', redirectTo: ROUTING.LIVE.path},
];

View File

@ -0,0 +1,91 @@
import {Subscription} from "rxjs";
import {Next} from "./types";
import {Series} from "../series/Series";
import {SeriesWrapper} from "../series/SeriesWrapper";
import {Inject, LOCALE_ID} from "@angular/core";
import {ApiService} from "./api.service";
export abstract class AbstractRepositoryService {
private readonly clientSubscriptions: Subscription[] = [];
private readonly subs: Subscription[] = [];
private readonly clientCallbacks: Next<Series>[] = [];
protected abstract get liveValues(): SeriesWrapper[];
constructor(
@Inject(LOCALE_ID) readonly locale: string,
protected readonly api: ApiService,
) {
//
}
protected onSubscribe(subscription: Subscription): Subscription {
this.clientSubscriptions.push(subscription);
this.ensureApiSubscribed();
return subscription;
}
protected onUnsubscribe(subscription: Subscription): Subscription {
this.clientSubscriptions.splice(this.clientSubscriptions.indexOf(subscription), 1);
if (this.clientSubscriptions.length === 0) {
this.ensureApiUnsubscribed();
}
return subscription;
}
private ensureApiSubscribed() {
if (this.subs.length !== 0) {
return;
}
this.subs.push(this.api.subscribe(['Series'], j => Series.fromJson(j, this.locale), series => this.update(series)));
this.subs.push(this.api.subscribeConnection(connected => {
if (connected) {
this.all();
} else {
this.liveValues.forEach(liveValue => liveValue.series = null);
}
}));
}
private ensureApiUnsubscribed() {
if (this.subs.length <= 0) {
return;
}
this.subs.forEach(sub => sub.unsubscribe());
this.subs.length = 0;
}
private update(series: Series) {
this.liveValues
.filter(liveValue => liveValue.name === series.name)
.forEach(liveValue => liveValue.series = series);
this.clientCallbacks.forEach(next => next(series));
}
all(next?: Next<Series[]>) {
this.api.getList(['Series', 'all'], j => Series.fromJson(j, this.locale), list => {
list.forEach(item => this.update(item));
if (next) {
next(list);
}
});
}
subscribeAny(next?: Next<Series>): Subscription {
const wrapper: Next<Series> = series => { // to let clientCallbacks only contain unique instances
if (next) {
next(series);
}
};
this.clientCallbacks.push(wrapper);
const subscription = new Subscription(() => {
this.onUnsubscribe(subscription);
this.clientCallbacks.splice(this.clientCallbacks.indexOf(wrapper), 1);
});
return this.onSubscribe(subscription);
}
}

View File

@ -4,6 +4,8 @@ import {map, Subscription} from 'rxjs';
import {StompService} from '@stomp/ng2-stompjs'; import {StompService} from '@stomp/ng2-stompjs';
import {FromJson, Next} from './types'; import {FromJson, Next} from './types';
const DEV_TO_PROD = false;
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
@ -40,6 +42,9 @@ export class ApiService {
} }
static url(protocol: string, path: any[]) { static url(protocol: string, path: any[]) {
return `${protocol}${location.protocol.endsWith('s') ? 's' : ''}://${location.hostname}:8081/${path.join('/')}`; const host = DEV_TO_PROD ? '10.255.0.1' : location.hostname;
const port = 8081;
return `${protocol}${location.protocol.endsWith('s') ? 's' : ''}://${host}:${port}/${path.join('/')}`;
} }
} }

View File

@ -1,3 +1,5 @@
import {FromJson} from './types';
export function validateString(value: any): string { export function validateString(value: any): string {
if (typeof value !== 'string') { if (typeof value !== 'string') {
throw new Error('Not a string: ' + value); throw new Error('Not a string: ' + value);
@ -11,3 +13,11 @@ export function validateNumber(value: any): number {
} }
return value as 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

@ -0,0 +1,64 @@
<app-tile>
<div tile-body>
<div class="numberTable">
<div class="option">
<button class="arrowLeft" (click)="shiftAlignment(+1)">&larr;</button>
{{ offset > 0 ? -offset : '' }}{{ alignment === Alignment.FIVE && offset > 0 ? 'x' : '' }} {{ alignment.display }}{{ offset > 1 ? alignment.plural : '' }}
<button class="arrowRight" (click)="shiftAlignment(-1)">&rarr;</button>
</div>
<div class="option">
<button class="arrowLeft" (click)="shiftOffset(+1)">&larr;</button>
{{ alignment.offsetTitle(offset, locale) }}
<button class="arrowRight" (click)="shiftOffset(-1)" [disabled]="offset === 0">&rarr;</button>
</div>
</div>
</div>
</app-tile>
<app-tile>
<div tile-head>
Energie
</div>
<div tile-body>
<div class="numberTable">
<div class="content">
<div class="entry consumption" [class.zero]="aggregations.energyConsumed?.zero">
<div class="name">Verbraucht</div>
<div class="percent">&nbsp;</div>
<div class="value">{{ aggregations.energyConsumed?.formatted }}</div>
</div>
<div class="entry purchase" [class.zero]="aggregations.energyPurchased?.delta?.zero">
<div class="name">Bezogen</div>
<div class="percent">{{ aggregations.energyPurchasedPercent?.formatted }}</div>
<div class="value">{{ aggregations.energyPurchased?.delta?.formatted }}</div>
</div>
<div class="entry production" [class.zero]="aggregations.energyProduced?.delta?.zero">
<div class="name">Produziert</div>
<div class="percent">{{ aggregations.energyProducedPercent?.formatted }}</div>
<div class="value">{{ aggregations.energyProduced?.delta?.formatted }}</div>
</div>
<div class="entry self" [class.zero]="aggregations.energySelf?.zero">
<div class="name">Selbst verbr.</div>
<div class="percent">{{ aggregations.energySelfPercent?.formatted }}</div>
<div class="value">{{ aggregations.energySelf?.formatted }}</div>
</div>
<div class="entry delivery" [class.zero]="aggregations.energyDelivered?.delta?.zero">
<div class="name">Eingespeist</div>
<div class="percent">{{ aggregations.energyDeliveredPercent?.formatted }}</div>
<div class="value">{{ aggregations.energyDelivered?.delta?.formatted }}</div>
</div>
</div>
</div>
<app-percent-bar [produktion]="aggregations.energyProduced?.delta" [self]="aggregations.energySelf" [purchase]="aggregations.energyPurchased?.delta" [delivery]="aggregations.energyDelivered?.delta"></app-percent-bar>
</div>
</app-tile>
<app-tile *ngIf="alignment.inner">
<div tile-head>
Zisterne
</div>
<div tile-body>
<img width="100%" *ngIf="seriesService.cisternVolume.series" [src]="seriesService.graph(seriesService.cisternVolume.series, 600, 200, alignment, offset, alignment.inner, 1)" [alt]="seriesService.cisternVolume.series.title">
</div>
</app-tile>

View File

@ -0,0 +1 @@
@import "../../../colors.less";

View File

@ -0,0 +1,79 @@
import {Component, Inject, LOCALE_ID, OnDestroy, OnInit} from '@angular/core';
import {PercentBarComponent} from '../shared/percent-bar/percent-bar.component';
import {AggregationWrapperDto} from '../series/AggregationWrapperDto';
import {Alignment} from '../series/Alignment';
import {Subscription} from 'rxjs';
import {SeriesService} from '../series/series.service';
import {NgIf} from "@angular/common";
import {TileComponent} from '../shared/tile/tile.component';
@Component({
selector: 'app-history',
imports: [
PercentBarComponent,
NgIf,
TileComponent
],
templateUrl: './history.component.html',
styleUrl: './history.component.less'
})
export class HistoryComponent implements OnInit, OnDestroy {
protected readonly Alignment = Alignment;
protected aggregations: AggregationWrapperDto = AggregationWrapperDto.EMPTY;
protected alignment: Alignment = Alignment.DAY;
protected offset: number = 0;
protected interval: any;
private readonly subs: Subscription[] = [];
constructor(
@Inject(LOCALE_ID) public locale: string,
protected readonly seriesService: SeriesService,
) {
//
}
ngOnInit(): void {
this.fetch();
this.subs.push(this.seriesService.subscribeAny());
}
ngOnDestroy(): void {
this.intervalStop();
this.subs.forEach(sub => sub.unsubscribe());
}
shiftOffset(delta: number) {
this.offset = Math.max(0, this.offset + delta);
this.fetch();
}
shiftAlignment(delta: number) {
this.alignment = this.alignment.plus(delta)
this.fetch();
}
private fetch() {
if (this.offset === 0) {
if (!this.interval) {
this.interval = setInterval(() => this.fetch(), 5000);
}
} else {
this.intervalStop();
}
this.seriesService.aggregations(this.alignment, this.offset, aggregations => this.aggregations = aggregations);
}
private intervalStop() {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
}

View File

@ -0,0 +1,23 @@
<app-tile>
<div tile-head>
Zisterne
</div>
<div tile-body>
<div class="numberTable">
<div class="content">
<div class="entry cistern">
<div class="name">Volumen</div>
<div class="percent">{{ seriesService.cisternVolume.series?.lastValue?.percent(2000)?.localeString }} %</div>
<div class="value">{{ seriesService.cisternVolume.series?.lastValue?.localeString }} {{ seriesService.cisternVolume.series?.unit?.unit }}</div>
</div>
</div>
</div>
<img width="100%" *ngIf="seriesService.cisternVolume.series" [src]="graph(seriesService.cisternVolume.series)" [alt]="seriesService.cisternVolume.series.title">
</div>
</app-tile>

View File

@ -0,0 +1,39 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {SeriesService} from '../../series/series.service';
import {Subscription} from 'rxjs';
import {NgIf} from '@angular/common';
import {Series} from '../../series/Series';
import {TileComponent} from '../../shared/tile/tile.component';
import {Alignment} from '../../series/Alignment';
@Component({
selector: 'app-cistern',
imports: [
NgIf,
TileComponent
],
templateUrl: './cistern.component.html',
styleUrl: './cistern.component.less'
})
export class CisternComponent implements OnInit, OnDestroy {
private subs: Subscription[] = [];
constructor(
readonly seriesService: SeriesService,
) {
}
ngOnInit(): void {
this.subs.push(this.seriesService.subscribeAny());
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
graph(series: Series) {
return this.seriesService.graph(series, 600, 200, Alignment.FIVE, 0, Alignment.FIVE, 24 * 12);
}
}

View File

@ -0,0 +1,22 @@
<table class="vertical">
<tr>
<th>Temperatur</th>
<td class="valueInteger">{{seriesService.greenhouseTemperature.series?.lastValue?.localeString}}</td>
<td class="unit">{{seriesService.greenhouseTemperature.series?.lastValue?.unit?.unit}}</td>
</tr>
<tr>
<th>Relative Luftfeuchte</th>
<td class="valueInteger">{{seriesService.greenhouseHumidityRelative.series?.lastValue?.localeString}}</td>
<td class="unit">{{seriesService.greenhouseHumidityRelative.series?.lastValue?.unit?.unit}}</td>
</tr>
<tr>
<th>Absolute Luftfeuchte</th>
<td class="valueInteger">{{seriesService.greenhouseHumidityAbsolute.series?.lastValue?.localeString}}</td>
<td class="unit">{{seriesService.greenhouseHumidityAbsolute.series?.lastValue?.unit?.unit}}</td>
</tr>
<tr>
<th>Beleuchtungsstärke</th>
<td class="valueInteger">{{seriesService.greenhouseIlluminance.series?.lastValue?.localeString}}</td>
<td class="unit">{{seriesService.greenhouseIlluminance.series?.lastValue?.unit?.unit}}</td>
</tr>
</table>

View File

@ -0,0 +1,28 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {SeriesService} from '../../../series/series.service';
import {Subscription} from 'rxjs';
@Component({
selector: 'app-greenhouse',
imports: [],
templateUrl: './greenhouse.component.html',
styleUrl: './greenhouse.component.less'
})
export class GreenhouseComponent implements OnInit, OnDestroy {
private subs: Subscription[] = [];
constructor(
readonly seriesService: SeriesService,
) {
}
ngOnInit(): void {
this.subs.push(this.seriesService.subscribeAny());
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
}

View File

@ -0,0 +1,5 @@
<app-weather-diagram></app-weather-diagram>
<app-electro-power></app-electro-power>
<app-cistern></app-cistern>

View File

@ -0,0 +1,18 @@
import {Component} from '@angular/core';
import {ElectroPowerComponent} from "./power/electro-power.component";
import {WeatherDiagramComponent} from './weather/weather-diagram/weather-diagram.component';
import {CisternComponent} from './cistern/cistern.component';
@Component({
selector: 'app-live',
imports: [
ElectroPowerComponent,
WeatherDiagramComponent,
CisternComponent
],
templateUrl: './live.component.html',
styleUrl: './live.component.less'
})
export class LiveComponent {
}

View File

@ -0,0 +1,42 @@
<app-tile>
<div tile-head>
Leistung
</div>
<div tile-body>
<div class="numberTable">
<div class="content">
<div class="entry consumption" [class.zero]="powerConsumption?.zero">
<div class="name">Verbrauch</div>
<div class="percent">&nbsp;</div>
<div class="value">{{ powerConsumption?.formatted }}</div>
</div>
<div class="entry purchase" [class.zero]="powerPurchase?.zero">
<div class="name">Bezug</div>
<div class="percent">{{ powerPurchasePercent?.formatted }}</div>
<div class="value">{{ powerPurchase?.formatted }}</div>
</div>
<div class="entry production" [class.zero]="powerProduction?.zero">
<div class="name">Produktion</div>
<div class="percent">{{ powerProducedPercent?.formatted }}</div>
<div class="value">{{ powerProduction?.formatted }}</div>
</div>
<div class="entry self" [class.zero]="powerSelf?.zero">
<div class="name">Eigenverbrauch</div>
<div class="percent">{{ powerSelfPercent?.formatted }}</div>
<div class="value">{{ powerSelf?.formatted }}</div>
</div>
<div class="entry delivery" [class.zero]="powerDelivery?.zero">
<div class="name">Einspeisung</div>
<div class="percent">{{ powerDeliveryPercent?.formatted }}</div>
<div class="value">{{ powerDelivery?.formatted }}</div>
</div>
</div>
</div>
<app-percent-bar [produktion]="powerProduction" [self]="powerSelf" [purchase]="powerPurchase" [delivery]="powerDelivery"></app-percent-bar>
</div>
</app-tile>

View File

@ -0,0 +1 @@
@import "../../../../colors.less";

View File

@ -0,0 +1,74 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {PercentBarComponent} from "../../shared/percent-bar/percent-bar.component";
import {Value} from '../../series/value/Value';
import {SeriesService} from '../../series/series.service';
import {Subscription} from 'rxjs';
import {TileComponent} from '../../shared/tile/tile.component';
@Component({
selector: 'app-electro-power',
imports: [
PercentBarComponent,
TileComponent
],
templateUrl: './electro-power.component.html',
styleUrl: './electro-power.component.less'
})
export class ElectroPowerComponent implements OnInit, OnDestroy {
private subs: Subscription[] = [];
constructor(
readonly seriesService: SeriesService,
) {
}
ngOnInit(): void {
this.subs.push(this.seriesService.subscribeAny());
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
get powerProduction(): Value | undefined {
return this.seriesService.powerProduced.series?.lastValue;
}
get powerBalance(): Value | undefined {
return this.seriesService.powerBalance.series?.lastValue;
}
get powerPurchase(): Value | undefined {
return this.powerBalance?.notNegative();
}
get powerDelivery(): Value | undefined {
return this.powerBalance?.negate()?.notNegative();
}
get powerSelf(): Value | undefined {
return this.powerProduction?.minus(this.powerDelivery)?.notNegative();
}
get powerConsumption(): Value | undefined {
return this.powerPurchase?.plus(this.powerProduction)?.minus(this.powerDelivery);
}
get powerProducedPercent(): Value | undefined {
return this.powerProduction?.percent(this.powerConsumption);
}
get powerSelfPercent(): Value | undefined {
return this.powerSelf?.percent(this.powerProduction);
}
get powerPurchasePercent(): Value | undefined {
return this.powerPurchase?.percent(this.powerConsumption);
}
get powerDeliveryPercent(): Value | undefined {
return this.powerDelivery?.percent(this.powerProduction);
}
}

View File

@ -0,0 +1,27 @@
import {Value} from "../../../series/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,26 @@
import {Value} from '../../../series/value/Value';
import {validateDate} from '../../../core/validators';
export class WeatherHour {
constructor(
readonly date: Date,
readonly clouds: Value,
readonly irradiation: Value,
readonly precipitation: Value,
readonly temperature: 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),
Value.fromJson2(json['temperature'], locale),
);
}
}

View File

@ -0,0 +1,26 @@
<app-tile [padding]="false">
<div tile-body>
<div class="day">
<div class="hour" *ngFor="let hour of hours">
<div class="bar weekdayHolder" *ngIf="hour.date.getHours() === 8">
{{ dateFormat(hour.date | date:'E') }}
</div>
<div class="bar clouds" [style.height]="clouds(hour)"></div>
<div class="bar irradiation" [style.height]="irradiation(hour)"></div>
<div class="bar precipitation" [style.height]="precipitation(hour)"></div>
<div class="bar temperature" [style.height]="temperature(hour)" [ngClass]="temperatureClasses(hour)"></div>
</div>
</div>
<div class="legend">
<span class="line temperatureGTE30">&ge;30°C</span>
<span class="line temperatureGTE20">&ge;20°C</span>
<span class="line temperatureGTE10">&ge;10°C</span>
<span class="line temperatureGT0">&gt;0°C</span>
<span class="line temperatureNegative">&le;0°C</span>
<span class="line">&nbsp;</span>
<span class="line">Niederschlag 100% = {{ PRECIPITATION_MAX_MM }}mm</span>
</div>
</div>
</app-tile>

View File

@ -0,0 +1,98 @@
.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)
overflow: visible;
.bar {
position: absolute;
width: 100%;
opacity: 0.5;
}
.clouds {
background-color: white;
}
.irradiation {
background-color: yellow;
}
.precipitation {
background-color: blue;
opacity: 0.75;
}
.temperature {
border-top: 0.06em solid black;
opacity: 0.75;
}
.temperatureGTE30 {
border-top-color: red;
}
.temperatureGTE20 {
border-top-color: orange;
}
.temperatureGTE10 {
border-top-color: yellow;
}
.temperatureGT0 {
border-top-color: blue;
}
.temperatureNegative {
border-top-color: white;
}
.weekdayHolder {
overflow: visible;
opacity: 1;
height: 100%;
}
}
}
.legend {
display: flex;
width: 100%;
flex-direction: row;
justify-content: center;
.line {
padding: 0 0.25em;
font-size: 50%;
}
.temperatureGTE30 {
color: red;
}
.temperatureGTE20 {
color: orange;
}
.temperatureGTE10 {
color: yellow;
}
.temperatureGT0 {
color: blue;
}
.temperatureNegative {
color: white;
}
}

View File

@ -0,0 +1,110 @@
import {Component, OnInit} from '@angular/core';
import {DatePipe, NgClass, NgForOf, NgIf} from '@angular/common';
import {WeatherHour} from './WeatherHour';
import {WeatherService} from './weather.service';
import {WeatherDay} from './WeatherDay';
import {TileComponent} from '../../../shared/tile/tile.component';
const PAST_HOURS_COUNT = 0;
const DAY_COUNT = 7;
@Component({
selector: 'app-weather-diagram',
imports: [
NgForOf,
NgIf,
DatePipe,
NgClass,
TileComponent
],
templateUrl: './weather-diagram.component.html',
styleUrl: './weather-diagram.component.less'
})
export class WeatherDiagramComponent implements OnInit {
protected readonly PRECIPITATION_MAX_MM = 15;
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(this.PRECIPITATION_MAX_MM)?.value || 0) + '%';
}
temperature(hour: WeatherHour) {
return (hour.temperature.plus(10)?.percent(50)?.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;
}
}
}
}
}
dateFormat(date: string | null) {
if (!date) {
return "";
}
return date.substring(0, 2);
}
temperatureClasses(hour: WeatherHour): {} {
const temperatureGTE30 = hour.temperature.gte(30);
const temperatureGTE20 = hour.temperature.gte(20);
const temperatureGTE10 = hour.temperature.gte(10);
const temperatureGT0 = hour.temperature.gt(0);
const temperatureNegative = hour.temperature.lte(0);
return {
"temperatureGTE30": temperatureGTE30,
"temperatureGTE20": temperatureGTE20 && !temperatureGTE30,
"temperatureGTE10": temperatureGTE10 && !temperatureGTE20,
"temperatureGT0": temperatureGT0 && !temperatureGTE10,
"temperatureNegative": temperatureNegative
};
}
}

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

@ -4,7 +4,7 @@ import {MeterAggregate} from './meter/MeterAggregate';
import {Aggregate} from './Aggregate'; import {Aggregate} from './Aggregate';
import {VaryingAggregate} from './varying/VaryingAggregate'; import {VaryingAggregate} from './varying/VaryingAggregate';
import {Value} from '../value/Value'; import {Value} from './value/Value';
export class AggregationWrapperDto { export class AggregationWrapperDto {
@ -48,11 +48,11 @@ export class AggregationWrapperDto {
this.energySelf = this.energyProduced?.delta.minus(this.energyDelivered?.delta); this.energySelf = this.energyProduced?.delta.minus(this.energyDelivered?.delta);
} }
static fromJson(json: any): AggregationWrapperDto { static fromJson(json: any, locale: string): AggregationWrapperDto {
return new AggregationWrapperDto( return new AggregationWrapperDto(
json['alignment'] as Alignment, json['alignment'] as Alignment,
new Date(json['date']), new Date(json['date']),
(json['aggregations'] as any[]).map(a => a.hasOwnProperty('delta') ? MeterAggregate.fromJson(a) : VaryingAggregate.fromJson(a)), (json['aggregations'] as any[]).map(a => a.hasOwnProperty('delta') ? MeterAggregate.fromJson(a, locale) : VaryingAggregate.fromJson(a, locale)),
); );
} }

View File

@ -4,22 +4,25 @@ export class Alignment {
private static readonly values: Alignment[] = []; private static readonly values: Alignment[] = [];
static readonly FIVE = new Alignment('FIVE', '5 Minuten', Alignment.offsetTitleFive); static readonly FIVE = new Alignment('FIVE', '5 Minuten', '', Alignment.offsetTitleFive, null, 0);
static readonly HOUR = new Alignment('HOUR', 'Stunde', Alignment.offsetTitleHour); static readonly HOUR = new Alignment('HOUR', 'Stunde', 'n', Alignment.offsetTitleHour, Alignment.FIVE, 12);
static readonly DAY = new Alignment('DAY', 'Tag', Alignment.offsetTitleDay); static readonly DAY = new Alignment('DAY', 'Tag', 'e', Alignment.offsetTitleDay, Alignment.FIVE, 24 * 12);
static readonly WEEK = new Alignment('WEEK', 'Woche', Alignment.offsetTitleWeek); static readonly WEEK = new Alignment('WEEK', 'Woche', 'n', Alignment.offsetTitleWeek, Alignment.HOUR, 7 * 24);
static readonly MONTH = new Alignment('MONTH', 'Monat', Alignment.offsetTitleMonth); static readonly MONTH = new Alignment('MONTH', 'Monat', 'e', Alignment.offsetTitleMonth, Alignment.HOUR, 30 * 24);
static readonly YEAR = new Alignment('YEAR', 'Jahr', Alignment.offsetTitleYear); static readonly YEAR = new Alignment('YEAR', 'Jahr', 'e', Alignment.offsetTitleYear, Alignment.DAY, 365);
constructor( constructor(
readonly name: string, readonly name: string,
readonly display: string, readonly display: string,
readonly offsetTitle: (offset: number, locale: string) => string readonly plural: string,
readonly offsetTitle: (offset: number, locale: string) => string,
readonly inner: Alignment | null,
readonly innerCount: number,
) { ) {
Alignment.values.push(this); Alignment.values.push(this);
} }
@ -38,12 +41,26 @@ export class Alignment {
} else if (offset === 1) { } else if (offset === 1) {
return "Letzte 5 Minuten"; return "Letzte 5 Minuten";
} }
const date = new Date();
date.setHours(date.getHours() - offset); const today = new Date();
if (offset < 7) { today.setMinutes(today.getMinutes() - today.getMinutes() % 5);
return `${formatDate(date, "EEEE", locale)}`;
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const yesterday2 = new Date(today);
yesterday2.setDate(yesterday2.getDate() - 1);
const date = new Date(today);
date.setMinutes(date.getMinutes() - offset * 5);
if (date.getDay() === today.getDay()) {
return `${formatDate(date, "HH:mm", locale)}`;
} else if (date.getDay() === yesterday.getDay()) {
return `Gestern ${formatDate(date, "HH:mm", locale)}`;
} else if (date.getDay() === yesterday2.getDay()) {
return `Gestern ${formatDate(date, "HH:mm", locale)}`;
} }
return `${formatDate(date, "EE", locale)} ${formatDate(date, "dd.MM.yyyy", locale)}`; return `${formatDate(date, "EE", locale)} ${formatDate(date, "dd.MM.yyyy", locale)} ${formatDate(date, "HH:mm", locale)}`;
} }
static offsetTitleHour(offset: number, locale: string): string { static offsetTitleHour(offset: number, locale: string): string {
@ -124,4 +141,8 @@ export class Alignment {
return `${formatDate(date, "yyyy", locale)}`; return `${formatDate(date, "yyyy", locale)}`;
} }
toString(): string {
return this.name;
}
} }

View File

@ -1,6 +1,6 @@
import {Unit} from '../value/Unit'; import {Unit} from './value/Unit';
import {validateNumber, validateString} from '../core/validators'; import {validateNumber, validateString} from '../core/validators';
import {Value} from '../value/Value'; import {Value} from './value/Value';
export class Series { export class Series {
@ -15,7 +15,7 @@ export class Series {
// //
} }
static fromJson(json: any): Series { static fromJson(json: any, locale: string): Series {
const decimals = validateNumber(json['decimals']); const decimals = validateNumber(json['decimals']);
const unit = Unit.fromJson(json['unit']); const unit = Unit.fromJson(json['unit']);
return new Series( return new Series(
@ -24,7 +24,7 @@ export class Series {
validateString(json['title']), validateString(json['title']),
decimals, decimals,
unit, unit,
Value.fromJson(json['lastValue'], json['unit'], unit, decimals), Value.fromJson(json['lastValue'], unit, decimals, locale),
); );
} }

View File

@ -1,6 +1,6 @@
import {Series} from '../Series'; import {Series} from '../Series';
import {Aggregate} from '../Aggregate'; import {Aggregate} from '../Aggregate';
import {Value} from '../../value/Value'; import {Value} from '../value/Value';
export class MeterAggregate extends Aggregate { export class MeterAggregate extends Aggregate {
@ -11,11 +11,11 @@ export class MeterAggregate extends Aggregate {
super(series); super(series);
} }
static fromJson(json: any): MeterAggregate { static fromJson(json: any, locale: string): MeterAggregate {
const series = Series.fromJson(json['series']); const series = Series.fromJson(json['series'], locale);
return new MeterAggregate( return new MeterAggregate(
series, series,
Value.fromJson(json['delta'], series, series.unit, series.decimals), Value.fromJson(json['delta'], series.unit, series.decimals, locale),
); );
} }

View File

@ -1,22 +1,16 @@
import {Injectable} from '@angular/core'; import {Inject, Injectable, LOCALE_ID} from '@angular/core';
import {ApiService} from '../core/api.service'; import {ApiService} from '../core/api.service';
import {Alignment} from './Alignment'; import {Alignment} from './Alignment';
import {AggregationWrapperDto} from './AggregationWrapperDto'; import {AggregationWrapperDto} from './AggregationWrapperDto';
import {Subscription} from 'rxjs';
import {Series} from './Series'; import {Series} from './Series';
import {SeriesWrapper} from './SeriesWrapper'; import {SeriesWrapper} from './SeriesWrapper';
import {Next} from '../core/types'; import {Next} from '../core/types';
import {AbstractRepositoryService} from '../core/AbstractRepositoryService';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class SeriesService { export class SeriesService extends AbstractRepositoryService {
private readonly clientSubscriptions: Subscription[] = [];
private readonly subs: Subscription[] = [];
private readonly clientCallbacks: Next<Series>[] = [];
readonly powerConsumed: SeriesWrapper = new SeriesWrapper("power/consumed", this.onSubscribe, this.onUnsubscribe); readonly powerConsumed: SeriesWrapper = new SeriesWrapper("power/consumed", this.onSubscribe, this.onUnsubscribe);
@ -26,89 +20,49 @@ export class SeriesService {
readonly powerBalance: SeriesWrapper = new SeriesWrapper("power/balance", this.onSubscribe, this.onUnsubscribe); readonly powerBalance: SeriesWrapper = new SeriesWrapper("power/balance", this.onSubscribe, this.onUnsubscribe);
readonly liveValues: SeriesWrapper[] = [ readonly greenhouseTemperature: SeriesWrapper = new SeriesWrapper("greenhouse/temperature", this.onSubscribe, this.onUnsubscribe);
readonly greenhouseHumidityRelative: SeriesWrapper = new SeriesWrapper("greenhouse/humidity/relative", this.onSubscribe, this.onUnsubscribe);
readonly greenhouseHumidityAbsolute: SeriesWrapper = new SeriesWrapper("greenhouse/humidity/absolute", this.onSubscribe, this.onUnsubscribe);
readonly greenhouseIlluminance: SeriesWrapper = new SeriesWrapper("greenhouse/illuminance", this.onSubscribe, this.onUnsubscribe);
readonly cisternHeight: SeriesWrapper = new SeriesWrapper("cistern/height", this.onSubscribe, this.onUnsubscribe);
readonly cisternVolume: SeriesWrapper = new SeriesWrapper("cistern/volume", this.onSubscribe, this.onUnsubscribe);
protected get liveValues(): SeriesWrapper[] {
return [
this.powerConsumed, this.powerConsumed,
this.powerProduced, this.powerProduced,
this.powerSelf, this.powerSelf,
this.powerBalance, this.powerBalance,
] this.greenhouseTemperature,
this.greenhouseHumidityRelative,
this.greenhouseHumidityAbsolute,
this.greenhouseIlluminance,
this.cisternHeight,
this.cisternVolume,
];
}
constructor( constructor(
protected readonly api: ApiService, @Inject(LOCALE_ID) locale: string,
api: ApiService,
) { ) {
// super(locale, api);
}
private onSubscribe(subscription: Subscription): Subscription {
this.clientSubscriptions.push(subscription);
this.ensureApiSubscribed();
return subscription;
}
private onUnsubscribe(subscription: Subscription): Subscription {
this.clientSubscriptions.splice(this.clientSubscriptions.indexOf(subscription), 1);
if (this.clientSubscriptions.length === 0) {
this.ensureApiUnsubscribed();
}
return subscription;
}
private ensureApiSubscribed() {
if (this.subs.length !== 0) {
return;
}
this.subs.push(this.api.subscribe(['Series'], Series.fromJson, series => this.update(series)));
this.subs.push(this.api.subscribeConnection(connected => {
if (connected) {
console.log("connected");
this.all();
} else {
console.log("disconnected");
this.liveValues.forEach(liveValue => liveValue.series = null);
}
}));
}
private ensureApiUnsubscribed() {
if (this.subs.length <= 0) {
return;
}
this.subs.forEach(sub => sub.unsubscribe());
this.subs.length = 0;
}
private update(series: Series) {
this.liveValues
.filter(liveValue => liveValue.name === series.name)
.forEach(liveValue => liveValue.series = series);
this.clientCallbacks.forEach(next => next(series));
}
subscribeAny(next?: Next<Series>): Subscription {
const wrapper: Next<Series> = series => { // to let clientCallbacks only contain unique instances
if (next) {
next(series);
}
};
this.clientCallbacks.push(wrapper);
const subscription = new Subscription(() => {
this.onUnsubscribe(subscription);
this.clientCallbacks.splice(this.clientCallbacks.indexOf(wrapper), 1);
});
return this.onSubscribe(subscription);
}
all(next?: Next<Series[]>) {
this.api.getList(['Series', 'all'], Series.fromJson, list => {
list.forEach(item => this.update(item));
if (next) {
next(list);
}
});
} }
aggregations(alignment: Alignment, offset: number, next: Next<AggregationWrapperDto>) { aggregations(alignment: Alignment, offset: number, next: Next<AggregationWrapperDto>) {
this.api.getSingle(['Series', 'agg', 'all', alignment.name, 'offset', offset], AggregationWrapperDto.fromJson, next); this.api.getSingle(['Series', 'agg', 'all', alignment.name, 'offset', offset], j => AggregationWrapperDto.fromJson(j, this.locale), next);
}
graph(series: Series, width: number, height: number, alignment: Alignment, offset: number, innerAlignment: Alignment | null, count: number): any {
if (innerAlignment === null) {
return null;
}
return ApiService.url('http', ['Series', 'Graph', series.id, width, height, alignment, offset, count, innerAlignment]);
} }
} }

View File

@ -0,0 +1,55 @@
import {validateString} from '../../core/validators';
export class Unit {
private static readonly values: Unit[] = [];
static readonly _UNKNOWN_ = new Unit('_UNKNOWN_', "?");
static readonly POWER_W = new Unit('POWER_W', "W");
static readonly ENERGY_KWH = new Unit('ENERGY_KWH', "kWh");
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');
static readonly TEMPERATURE_C = new Unit('TEMPERATURE_C', '°C');
static readonly HUMIDITY_RELATIVE_PERCENT = new Unit('HUMIDITY_RELATIVE_PERCENT', '%');
static readonly HUMIDITY_ABSOLUTE_GM3 = new Unit('HUMIDITY_ABSOLUTE_GM3', 'g/m³');
static readonly ILLUMINANCE_LUX = new Unit('ILLUMINANCE_LUX', 'lux');
static readonly BOOLEAN = new Unit('BOOLEAN', '');
static readonly DOOR_BOOLEAN = new Unit('DOOR_BOOLEAN', '');
static readonly WINDOW_BOOLEAN = new Unit('WINDOW_BOOLEAN', '');
static readonly LIGHT_BOOLEAN = new Unit('LIGHT_BOOLEAN', '');
static readonly VOLUME_L = new Unit('VOLUME_L', 'l');
static readonly LENGTH_CM = new Unit('LENGTH_CM', 'cm');
private constructor(
readonly name: string,
readonly unit: string,
) {
Unit.values.push(this);
}
static fromJson(json: any): Unit {
const name: string = validateString(json);
return this.values.filter(unit => unit.name === name)[0] || this._UNKNOWN_;
}
}

View File

@ -0,0 +1,119 @@
import {Unit} from "./Unit";
import {validateNumber} from "../../core/validators";
export class Value {
readonly localeString: string;
readonly valueInteger: string;
readonly valueFraction: string;
constructor(
readonly value: number,
readonly unit: Unit,
readonly decimals: number,
readonly locale: string,
) {
this.localeString = this.value.toLocaleString(this.locale, {maximumFractionDigits: this.decimals, minimumFractionDigits: this.decimals});
this.valueInteger = this.localeString.split(/[,.]/)[0];
this.valueFraction = this.localeString.split(/[,.]/)[1];
}
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
);
}
get zero(): boolean {
return this.value === 0;
}
get formatted(): string {
return `${(this.localeString)} ${this.unit.unit}`;
}
get formatted2(): string {
return `${(this.localeString)}${this.unit.unit}`;
}
negate() {
return new Value(-this.value, this.unit, this.decimals, this.locale);
}
plus(other: Value | number | undefined | null): Value | undefined {
const v = this.extractValue(other);
return v === undefined ? undefined : new Value(this.value + v, this.unit, this.decimals, this.locale);
}
minus(other: Value | number | undefined | null): Value | undefined {
const v = this.extractValue(other);
return v === undefined ? undefined : new Value(this.value - v, this.unit, this.decimals, this.locale);
}
gte(other: Value | number | undefined | null): boolean | undefined {
const v = this.extractValue(other);
return v === undefined ? undefined : this.value >= v;
}
gt(other: Value | number | undefined | null): boolean | undefined {
const v = this.extractValue(other);
return v === undefined ? undefined : this.value > v;
}
lte(other: Value | number | undefined | null): boolean | undefined {
const v = this.extractValue(other);
return v === undefined ? undefined : this.value <= v;
}
lt(other: Value | number | undefined | null): boolean | undefined {
const v = this.extractValue(other);
return v === undefined ? undefined : this.value < v;
}
extractValue(other: Value | number | undefined | null): number | undefined {
if (other === undefined || other === null) {
return undefined;
}
if (other instanceof Value && other.unit !== this.unit) {
throw new Error(`Unit mismatch: this=${JSON.stringify(this)}, other=${JSON.stringify(other)}`);
}
return typeof other === "number" ? other : other.value;
}
notNegative(): Value {
if (this.value < 0) {
return this.toZero();
}
return this;
}
toZero(): Value {
if (this.value === 0) {
return this;
}
return new Value(0, this.unit, this.decimals, this.locale);
}
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 / v * 100, Unit.PERCENT, 0, this.locale);
}
}

View File

@ -12,9 +12,9 @@ export class VaryingAggregate extends Aggregate {
super(series); super(series);
} }
static fromJson(json: any): VaryingAggregate { static fromJson(json: any, locale: string): VaryingAggregate {
return new VaryingAggregate( return new VaryingAggregate(
Series.fromJson(json['series']), Series.fromJson(json['series'], locale),
json['min'] as number, json['min'] as number,
json['avg'] as number, json['avg'] as number,
json['max'] as number, json['max'] as number,

View File

@ -0,0 +1,20 @@
<div class="bar">
<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.formatted2">
<div class="text">
{{ _self?.formatted }}<br>
{{ selfPercent?.formatted }}
</div>
</div>
<div class="part delivery" *ngIf="barDeliveryPercent" [style.width]="barDeliveryPercent.formatted2">
<div class="text">
{{ _delivery?.formatted }}<br>
{{ deliveryPercent?.formatted }}
</div>
</div>
</div>

View File

@ -0,0 +1,34 @@
@import "../../../../colors.less";
.bar {
display: flex;
border-radius: 0.5em;
.part {
white-space: nowrap;
font-size: 40%;
.text {
padding-left: 0.4em;
}
}
.part:first-child {
.text:first-child {
padding-left: 0.8em;
}
}
.self {
background-color: @selfBack;
}
.purchase {
background-color: @purchaseBack;
}
.delivery {
background-color: @deliveryBack;
}
}

View File

@ -0,0 +1,78 @@
import {Component, Input} from '@angular/core';
import {Value} from '../../series/value/Value';
import {NgIf} from '@angular/common';
@Component({
selector: 'app-percent-bar',
imports: [
NgIf
],
templateUrl: './percent-bar.component.html',
styleUrl: './percent-bar.component.less'
})
export class PercentBarComponent {
protected _produktion: Value | undefined;
@Input()
set produktion(produktion: Value | undefined) {
this._produktion = produktion;
this.update();
}
protected _self: Value | undefined;
@Input()
set self(self: Value | undefined) {
this._self = self;
this.update();
}
protected _purchase: Value | undefined;
@Input()
set purchase(purchase: Value | undefined) {
this._purchase = purchase;
this.update();
}
protected _delivery: Value | undefined;
@Input()
set delivery(delivery: Value | undefined) {
this._delivery = delivery;
this.update();
}
@Input()
percent: boolean = false;
protected consumption: Value | undefined;
protected barSum: Value | undefined;
protected selfPercent: Value | undefined;
protected purchasePercent: Value | undefined;
protected deliveryPercent: Value | undefined;
protected barSelfPercent: Value | undefined;
protected barPurchasePercent: Value | undefined;
protected barDeliveryPercent: Value | undefined;
private update() {
this.consumption = this._self?.plus(this._purchase);
this.selfPercent = this._self?.percent(this.consumption);
this.purchasePercent = this._purchase?.percent(this.consumption);
this.deliveryPercent = this._delivery?.percent(this._produktion);
this.barSum = this.consumption?.plus(this._delivery);
this.barSelfPercent = this._self?.percent(this.barSum);
this.barPurchasePercent = this._purchase?.percent(this.barSum);
this.barDeliveryPercent = this._delivery?.percent(this.barSum);
}
}

View File

@ -0,0 +1,10 @@
<div class="tile">
<div class="tileInner" [class.tilePadding]="padding">
<div class="tileHead">
<ng-content select="[tile-head]"></ng-content>
</div>
<div class="tileBody">
<ng-content select="[tile-body]"></ng-content>
</div>
</div>
</div>

View File

@ -0,0 +1,16 @@
.tile {
float: left;
margin-bottom: 1em;
}
.tilePadding {
padding: 0.5em;
}
.tileHead {
font-size: 70%;
font-style: italic;
text-align: center;
text-decoration: underline;
margin-bottom: 0.1em;
}

View File

@ -0,0 +1,14 @@
import {Component, Input} from '@angular/core';
@Component({
selector: 'app-tile',
imports: [],
templateUrl: './tile.component.html',
styleUrl: './tile.component.less'
})
export class TileComponent {
@Input()
padding: boolean = true;
}

View File

@ -1,27 +0,0 @@
import {validateString} from '../core/validators';
export class Unit {
private static readonly values: Unit[] = [];
static readonly _UNKNOWN_ = new Unit('_UNKNOWN_', "?");
static readonly POWER_W = new Unit('POWER_W', "W");
static readonly ENERGY_KWH = new Unit('ENERGY_KWH', "kWh");
static readonly PERCENT = new Unit('PERCENT', "%");
private constructor(
readonly name: string,
readonly unit: string,
) {
Unit.values.push(this);
}
static fromJson(json: any): Unit {
const name: string = validateString(json);
return this.values.filter(unit => unit.name === name)[0] || this._UNKNOWN_;
}
}

View File

@ -1,68 +0,0 @@
import {Unit} from "./Unit";
import {validateNumber} from "../core/validators";
import {Series} from "../series/Series";
export class Value {
static readonly EMPTY: Value = new Value(0, Unit._UNKNOWN_, 1);
constructor(
readonly value: number,
readonly unit: Unit,
readonly decimals: number,
) {
//
}
static fromJson(value: any, series: Series, unit: Unit, decimals: number): Value {
return new Value(
validateNumber(value),
unit,
decimals,
);
}
get formatted(): string {
return `${this.value.toFixed(this.decimals)} ${this.unit.unit}`;
}
negate() {
return new Value(-this.value, this.unit, this.decimals);
}
plus(other: Value | undefined): Value | undefined {
if (!other) {
return undefined;
}
return new Value(this.value + other.value, this.unit, this.decimals);
}
minus(other: Value | undefined): Value | undefined {
if (!other) {
return undefined;
}
return new Value(this.value - other.value, this.unit, this.decimals);
}
notNegative(): Value {
if (this.value < 0) {
return this.toZero();
}
return this;
}
toZero(): Value {
if (this.value === 0) {
return this;
}
return new Value(0, this.unit, this.decimals);
}
percent(other: Value | undefined): Value | undefined {
if (!other) {
return undefined;
}
return new Value(this.value / other.value * 100, Unit.PERCENT, 0);
}
}

View File

@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>Data</title> <title>Data</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, user-scalable=no"> <meta name="viewport" content="width=device-width, user-scalable=yes">
<!--suppress HtmlUnknownTarget --> <!--suppress HtmlUnknownTarget -->
<link rel="icon" type="image/x-icon" href="favicon.svg"> <link rel="icon" type="image/x-icon" href="favicon.svg">
</head> </head>

View File

@ -1,13 +1,85 @@
@import "../colors.less";
@import "../numberTable.less";
body { body {
font-family: sans-serif; font-family: sans-serif;
font-size: 6vw; font-size: 6vw;
user-select: none; user-select: none;
background-color: @background;
color: @foreground;
margin: 0;
} }
button, input, select { input, select {
background-color: transparent;
font-size: inherit; font-size: inherit;
color: @FONT_SELECTABLE;
border: 0.05em solid gray;
border-radius: 0.2em;
}
button {
all: unset;
font-size: inherit;
padding: 0 0.25em;
background-color: #002433;
color: #008fca;
border: unset;
border-radius: 10%;
} }
div { div {
overflow: hidden; overflow: hidden;
} }
table.vertical {
width: 100%;
th {
text-align: left;
}
td.valueInteger {
width: 0;
white-space: nowrap;
text-align: right;
}
td.valueDelimiter {
width: 0;
white-space: nowrap;
text-align: center;
}
td.valueFraction {
width: 0;
white-space: nowrap;
text-align: left;
}
td.unit {
width: 0;
white-space: nowrap;
text-align: left;
}
}
@tile-width: 400px;
@font-base: 6vw;
.generate-tiles(@i, @max) when (@i =< @max) {
@media (min-width: @tile-width * @i) {
body {
font-size: calc(@font-base / @i);
}
.tile {
width: calc(100% / @i);
}
}
.generate-tiles(@i + 1, @max);
}
.generate-tiles(1, 10);

View File

@ -0,0 +1,26 @@
package de.ph87.data.chart;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.ToString;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Chart {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Version
private long version;
@NonNull
@Column(nullable = false)
private String name = "";
}

View File

@ -0,0 +1,35 @@
package de.ph87.data.chart.axis;
import de.ph87.data.chart.Chart;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.ToString;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Axis {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Version
private long version;
@NonNull
@ManyToOne(optional = false)
private Chart chart;
@NonNull
@Column(nullable = false)
private String name = "";
public Axis(@NonNull final Chart chart) {
this.chart = chart;
}
}

View File

@ -0,0 +1,41 @@
package de.ph87.data.chart.axis.graph;
import de.ph87.data.chart.axis.Axis;
import de.ph87.data.series.Series;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.ToString;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Graph {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Version
private long version;
@NonNull
@ManyToOne(optional = false)
private Axis axis;
@NonNull
@Column(nullable = false)
private String name = "";
@NonNull
@ManyToOne(optional = false)
private Series series;
public Graph(@NonNull final Axis axis, @NonNull final Series series) {
this.axis = axis;
this.series = series;
}
}

View File

@ -1,17 +1,20 @@
package de.ph87.data.series.graph; package de.ph87.data.graph;
import de.ph87.data.series.*; import de.ph87.data.point.Point;
import de.ph87.data.value.*; import de.ph87.data.point.PointRequest;
import jakarta.annotation.*; import de.ph87.data.series.SeriesDto;
import lombok.*; import de.ph87.data.series.SeriesType;
import de.ph87.data.value.Autoscale;
import jakarta.annotation.Nullable;
import lombok.NonNull;
import java.awt.*; import java.awt.*;
import java.awt.geom.*; import java.awt.geom.Line2D;
import java.awt.image.*; import java.awt.image.BufferedImage;
import java.util.List; import java.util.List;
import java.util.function.*; import java.util.function.Function;
import static java.lang.Math.*; import static java.lang.Math.max;
public class Graph { public class Graph {
@ -23,10 +26,7 @@ public class Graph {
public final SeriesDto series; public final SeriesDto series;
@NonNull @NonNull
public final Aligned begin; public final PointRequest request;
@NonNull
public final Aligned end;
public final int width; public final int width;
@ -34,7 +34,7 @@ public class Graph {
public final int border; public final int border;
public final List<Point> points; public final List<java.awt.Point> points;
public final long minuteMin; public final long minuteMin;
@ -60,21 +60,20 @@ public class Graph {
public final int maxLabelWidth; public final int maxLabelWidth;
private final Autoscale autoscale; public final Autoscale autoscale;
public Graph(@NonNull final SeriesDto series, @NonNull final List<GraphPoint> points, @NonNull final Aligned begin, @NonNull final Aligned end, final int width, final int height, final int border) { public Graph(@NonNull final SeriesDto series, @NonNull final List<Point> points, @NonNull final PointRequest request, final int width, final int height, final int border) {
this.series = series; this.series = series;
this.begin = begin; this.request = request;
this.end = end;
this.width = width; this.width = width;
this.height = height; this.height = height;
this.border = border; this.border = border;
// find bounds // find bounds
double vSum = 0; double vSum = 0;
double vMin = series.isGraphZero() ? 0.0 : Double.MAX_VALUE; double vMin = series.getYMin() == null || Double.isNaN(series.getYMin()) ? Double.MIN_VALUE : series.getYMin();
double vMax = series.isGraphZero() ? 0.0 : Double.MIN_VALUE; double vMax = series.getYMax() == null || Double.isNaN(series.getYMax()) ? Double.MAX_VALUE : series.getYMax();
for (final GraphPoint point : points) { for (final Point point : points) {
vMin = Math.min(vMin, point.getValue()); vMin = Math.min(vMin, point.getValue());
vMax = max(vMax, point.getValue()); vMax = max(vMax, point.getValue());
vSum += point.getValue(); vSum += point.getValue();
@ -87,9 +86,9 @@ public class Graph {
vSum *= autoscale.factor; vSum *= autoscale.factor;
// find max label width // find max label width
int __maxLabelWidth = 80; int __maxLabelWidth = 0;
final FontMetrics fontMetrics = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB).getGraphics().getFontMetrics(); final FontMetrics fontMetrics = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB).getGraphics().getFontMetrics();
for (final GraphPoint point : points) { for (final Point point : points) {
__maxLabelWidth = max(__maxLabelWidth, fontMetrics.stringWidth(autoscale.format(point.getValue() * autoscale.factor))); __maxLabelWidth = max(__maxLabelWidth, fontMetrics.stringWidth(autoscale.format(point.getValue() * autoscale.factor)));
} }
this.maxLabelWidth = __maxLabelWidth; this.maxLabelWidth = __maxLabelWidth;
@ -97,10 +96,10 @@ public class Graph {
widthInner = width - 3 * border - this.maxLabelWidth; widthInner = width - 3 * border - this.maxLabelWidth;
heightInner = height - 2 * border; heightInner = height - 2 * border;
minuteMin = begin.date.toEpochSecond() / 60; minuteMin = request.begin.date.toEpochSecond() / 60;
minuteMax = end.date.toEpochSecond() / 60; minuteMax = request.end.date.toEpochSecond() / 60;
minuteRange = minuteMax - minuteMin; minuteRange = minuteMax - minuteMin;
minuteScale = (double) widthInner / (minuteRange + begin.alignment.maxDuration.toMinutes()); minuteScale = (double) widthInner / (minuteRange + request.inner.maxDuration.toMinutes());
valueMin = vMin; valueMin = vMin;
valueMax = vMax; valueMax = vMax;
@ -115,35 +114,29 @@ public class Graph {
public BufferedImage draw() { public BufferedImage draw() {
final BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); final BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
final Graphics2D g = (Graphics2D) image.getGraphics(); final Graphics2D g = (Graphics2D) image.getGraphics();
final int fontH3_4 = (int) Math.round(g.getFontMetrics().getHeight() * 0.75);
g.setColor(Color.gray); yLabel(g, valueMax, Color.red);
final String string = "%s [%s]".formatted(series.getTitle(), autoscale.unit); yLabel(g, valueAvg, new Color(0, 255, 0));
g.drawString(string, border, border + fontH3_4); yLabel(g, valueMin, new Color(64, 128, 255));
yLabel(g, valueMax, DASHED, Color.red);
yLabel(g, valueAvg, DASHED, new Color(0, 127, 0));
yLabel(g, 0, NORMAL, Color.BLACK);
yLabel(g, valueMin, DASHED, Color.blue);
g.translate(border, height - border); g.translate(border, height - border);
g.scale(1, -1); g.scale(1, -1);
// y-axis
g.setStroke(NORMAL); g.setStroke(NORMAL);
g.setColor(Color.BLACK); g.setColor(Color.GRAY);
g.drawLine(widthInner, 0, widthInner, heightInner); // y-axis g.drawLine(widthInner, 0, widthInner, heightInner);
g.setColor(Color.WHITE);
if (series.type == SeriesType.METER) { if (series.type == SeriesType.METER) {
g.setColor(Color.PINK); final int space = (int) (minuteScale * request.inner.maxDuration.toMinutes());
final int space = (int) (minuteScale * begin.alignment.maxDuration.toMinutes());
final int width = (int) (space * 0.95); final int width = (int) (space * 0.95);
for (final Point point : points) { for (final java.awt.Point point : points) {
g.fillRect(point.x + (space - width), 0, width, point.y); g.fillRect(point.x + (space - width), 0, width, point.y);
} }
} else { } else {
g.setColor(Color.RED); java.awt.Point last = null;
Point last = null; for (final java.awt.Point current : points) {
for (final Point current : points) {
if (last != null) { if (last != null) {
g.drawLine(last.x, last.y, current.x, current.y); g.drawLine(last.x, last.y, current.x, current.y);
} }
@ -153,20 +146,20 @@ public class Graph {
return image; return image;
} }
private void yLabel(@NonNull final Graphics2D g, final double value, @Nullable final Stroke stroke, @Nullable final Color color) { private void yLabel(@NonNull final Graphics2D g, final double value, @Nullable final Color color) {
final String string = autoscale.format(value); final String string = autoscale.format(value);
final int offset = maxLabelWidth - g.getFontMetrics().stringWidth(string); final int offset = maxLabelWidth - g.getFontMetrics().stringWidth(string);
final int y = height - ((int) Math.round((value - valueMin) * valueScale) + border); final int y = height - ((int) Math.round((value - valueMin) * valueScale) + border);
g.setColor(color); g.setColor(color);
g.drawString(string, widthInner + 2 * border + offset, y + (int) Math.round(g.getFontMetrics().getHeight() * 0.25)); g.drawString(string, widthInner + 2 * border + offset, y + (int) Math.round(g.getFontMetrics().getHeight() * 0.25));
if (stroke != null && color != null) { if (color != null) {
g.setStroke(stroke); g.setStroke(Graph.DASHED);
g.draw(new Line2D.Double(border, y, width - maxLabelWidth - border * 1.5, y)); g.draw(new Line2D.Double(border, y, width - maxLabelWidth - border * 1.5, y));
} }
} }
@NonNull @NonNull
private Function<GraphPoint, Point> toPoint() { private Function<Point, java.awt.Point> toPoint() {
return point -> { return point -> {
final long minuteEpoch = point.getDate().toEpochSecond() / 60; final long minuteEpoch = point.getDate().toEpochSecond() / 60;
final long minuteRelative = minuteEpoch - minuteMin; final long minuteRelative = minuteEpoch - minuteMin;
@ -177,7 +170,7 @@ public class Graph {
final double valueScaled = valueRelative * valueScale; final double valueScaled = valueRelative * valueScale;
final int y = (int) Math.round(valueScaled); final int y = (int) Math.round(valueScaled);
return new Point(x, y); return new java.awt.Point(x, y);
}; };
} }

View File

@ -0,0 +1,49 @@
package de.ph87.data.graph;
import de.ph87.data.point.Point;
import de.ph87.data.point.PointRequest;
import de.ph87.data.point.PointService;
import de.ph87.data.series.Alignment;
import de.ph87.data.series.Series;
import de.ph87.data.series.SeriesDto;
import de.ph87.data.series.SeriesRepository;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.List;
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("Series/Graph")
public class GraphController {
private final SeriesRepository seriesRepository;
private final PointService pointService;
@GetMapping(path = "{seriesId}/{width}/{height}/{outerName}/{offset}/{duration}/{innerName}", produces = "image/png")
public void graph(@PathVariable final long seriesId, final HttpServletResponse response, @PathVariable final int width, @PathVariable final int height, @PathVariable final String outerName, @PathVariable final long offset, @PathVariable final long duration, @PathVariable final String innerName) throws IOException {
final Alignment outer = Alignment.valueOf(outerName);
final Alignment inner = Alignment.valueOf(innerName);
final PointRequest request = new PointRequest(outer, offset, duration, inner);
final Series series = seriesRepository.findById(seriesId).orElseThrow();
final List<Point> points = pointService.getPoints(series, request);
final Graph graph = new Graph(new SeriesDto(series), points, request, width, height, 10);
final BufferedImage image = graph.draw();
response.setContentType("image/png");
ImageIO.write(image, "PNG", response.getOutputStream());
response.getOutputStream().flush();
}
}

View File

@ -0,0 +1,73 @@
package de.ph87.data.message.handler;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.ph87.data.message.IMessageHandler;
import de.ph87.data.message.Message;
import de.ph87.data.series.meter.MeterInbound;
import de.ph87.data.series.varying.VaryingInbound;
import de.ph87.data.value.Unit;
import de.ph87.data.value.Value;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
@Slf4j
@Service
@RequiredArgsConstructor
public class PatrixJsonHandler implements IMessageHandler {
private final ObjectMapper objectMapper;
private final ApplicationEventPublisher applicationEventPublisher;
@Override
public void handle(@NonNull final Message message) throws Exception {
if (!message.topic.endsWith("/PatrixJson")) {
return;
}
final Inbound inbound = objectMapper.readValue(message.payload, Inbound.class);
applicationEventPublisher.publishEvent(inbound);
}
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(name = "VARYING", value = Varying.class),
@JsonSubTypes.Type(name = "METER", value = Meter.class),
})
private interface Inbound {
enum Type {
VARYING,
METER,
}
}
@ToString(callSuper = true)
public static class Varying extends VaryingInbound implements Inbound {
protected Varying(@NonNull final String name, final long date, final double value, @NonNull final Unit unit) {
super(name, ZonedDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneId.systemDefault()), new Value(value, unit));
}
}
@ToString(callSuper = true)
public static class Meter extends MeterInbound implements Inbound {
protected Meter(@NonNull final String name, @NonNull final String number, final long date, final double value, @NonNull final Unit unit) {
super(name, number, ZonedDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneId.systemDefault()), new Value(value, unit));
}
}
}

View File

@ -1,22 +1,22 @@
package de.ph87.data.series.graph; package de.ph87.data.point;
import lombok.*; import lombok.*;
import java.time.*; import java.time.*;
@Data @Data
public class GraphPoint { public class Point {
public final ZonedDateTime date; public final ZonedDateTime date;
public final double value; public final double value;
@NonNull @NonNull
public GraphPoint plus(@NonNull final GraphPoint other) { public Point plus(@NonNull final Point other) {
if (this.date.compareTo(other.date) != 0) { if (this.date.compareTo(other.date) != 0) {
throw new RuntimeException("Cannot 'add' GraphPoints with different dates: this=%s, other=%s".formatted(this, other)); throw new RuntimeException("Cannot 'add' GraphPoints with different dates: this=%s, other=%s".formatted(this, other));
} }
return new GraphPoint(date, value + other.value); return new Point(date, value + other.value);
} }
} }

View File

@ -0,0 +1,26 @@
package de.ph87.data.point;
import de.ph87.data.view.ViewPointRequest;
import de.ph87.data.view.ViewService;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("points")
public class PointController {
private final ViewService viewService;
@PostMapping("fetch")
public List<Point> fetch(@RequestBody @NonNull final ViewPointRequest request) {
return viewService.getPoints(request);
}
}

View File

@ -0,0 +1,41 @@
package de.ph87.data.point;
import com.fasterxml.jackson.annotation.JsonIgnore;
import de.ph87.data.series.Aligned;
import de.ph87.data.series.Alignment;
import lombok.Data;
import lombok.NonNull;
import java.time.ZonedDateTime;
@Data
public class PointRequest {
@NonNull
public final Alignment outer;
public final long outerOffset;
public final long outerCount;
@NonNull
public final Alignment inner;
@NonNull
@JsonIgnore
public final Aligned begin;
@NonNull
@JsonIgnore
public final Aligned end;
public PointRequest(@NonNull final Alignment outer, final long outerOffset, final long outerCount, @NonNull final Alignment inner) {
this.outer = outer;
this.outerOffset = outerOffset;
this.outerCount = outerCount;
this.inner = inner;
this.end = outer.align(ZonedDateTime.now()).plus(1).minus(outerOffset);
this.begin = end.minus(outerCount);
}
}

View File

@ -0,0 +1,30 @@
package de.ph87.data.point;
import de.ph87.data.series.Series;
import de.ph87.data.series.meter.MeterService;
import de.ph87.data.series.varying.VaryingService;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class PointService {
private final VaryingService varyingService;
private final MeterService meterService;
@NonNull
public List<Point> getPoints(@NonNull final Series series, @NonNull final PointRequest pointRequest) {
return switch (series.getType()) {
case METER -> meterService.getPoints(series, pointRequest);
case VARYING -> varyingService.getPoints(series, pointRequest);
};
}
}

View File

@ -1,10 +1,11 @@
package de.ph87.data.series; package de.ph87.data.series;
import de.ph87.data.value.*; import de.ph87.data.value.Unit;
import jakarta.annotation.Nullable;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import java.time.*; import java.time.ZonedDateTime;
@Entity @Entity
@Getter @Getter
@ -39,8 +40,13 @@ public class Series {
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private SeriesType type; private SeriesType type;
@Column(nullable = false) @Column
private boolean graphZero = false; @Nullable
public final Double yMin = null;
@Column
@Nullable
public final Double yMax = null;
@Column(nullable = false) @Column(nullable = false)
private boolean autoscale = false; private boolean autoscale = false;

View File

@ -1,16 +1,21 @@
package de.ph87.data.series; package de.ph87.data.series;
import de.ph87.data.value.*; import com.fasterxml.jackson.annotation.JsonIgnore;
import de.ph87.data.web.*; import de.ph87.data.value.Unit;
import lombok.*; import de.ph87.data.web.IWebSocketMessage;
import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import java.time.*; import java.time.ZonedDateTime;
import java.util.*; import java.util.List;
@Getter @Getter
@ToString @ToString
public class SeriesDto implements IWebSocketMessage { public class SeriesDto implements IWebSocketMessage {
@JsonIgnore
public final List<Object> websocketTopic = List.of("Series"); public final List<Object> websocketTopic = List.of("Series");
public final long id; public final long id;
@ -25,7 +30,11 @@ public class SeriesDto implements IWebSocketMessage {
public final SeriesType type; public final SeriesType type;
public final boolean graphZero; @Nullable
public final Double yMin;
@Nullable
public final Double yMax;
public final boolean autoscale; public final boolean autoscale;
@ -49,7 +58,8 @@ public class SeriesDto implements IWebSocketMessage {
this.unit = series.getUnit(); this.unit = series.getUnit();
this.decimals = series.getDecimals(); this.decimals = series.getDecimals();
this.type = series.getType(); this.type = series.getType();
this.graphZero = series.isGraphZero(); this.yMin = series.getYMin();
this.yMax = series.getYMax();
this.autoscale = series.isAutoscale(); this.autoscale = series.isAutoscale();
this.min = series.getMin(); this.min = series.getMin();
this.max = series.getMax(); this.max = series.getMax();

View File

@ -1,14 +1,16 @@
package de.ph87.data.series; package de.ph87.data.series;
import de.ph87.data.*; import de.ph87.data.Action;
import lombok.*; import lombok.NonNull;
import lombok.extern.slf4j.*; import lombok.RequiredArgsConstructor;
import org.springframework.context.*; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.*; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.transaction.annotation.*; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*; import java.util.List;
import java.util.function.*; import java.util.function.Consumer;
import java.util.function.Function;
@Slf4j @Slf4j
@Service @Service
@ -39,11 +41,6 @@ public class SeriesService {
return seriesRepository.findById(id).orElseThrow(); return seriesRepository.findById(id).orElseThrow();
} }
@NonNull
public SeriesDto getDtoById(final long id) {
return toDto(getById(id));
}
@NonNull @NonNull
private SeriesDto publish(@NonNull final Series series, @NonNull final Action action) { private SeriesDto publish(@NonNull final Series series, @NonNull final Action action) {
final SeriesDto dto = toDto(series); final SeriesDto dto = toDto(series);

View File

@ -1,34 +0,0 @@
package de.ph87.data.series.graph;
import de.ph87.data.series.*;
import jakarta.servlet.http.*;
import lombok.*;
import lombok.extern.slf4j.*;
import org.springframework.web.bind.annotation.*;
import javax.imageio.*;
import java.awt.image.*;
import java.io.*;
import java.time.*;
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("Series/Graph")
public class GraphController {
private final GraphService graphService;
@GetMapping(path = "{seriesId}/{width}/{height}/{alignmentName}/{offset}/{duration}", produces = "image/png")
public void graph(@PathVariable final long seriesId, final HttpServletResponse response, @PathVariable final int width, @PathVariable final int height, @PathVariable final String alignmentName, @PathVariable final long offset, @PathVariable final long duration) throws IOException {
final Alignment alignment = Alignment.valueOf(alignmentName);
final Aligned end = alignment.align(ZonedDateTime.now()).minus(offset);
final Aligned begin = end.minus(duration - 1);
final Graph graph = graphService.getGraph(seriesId, begin, end, width, height, 10);
final BufferedImage image = graph.draw();
response.setContentType("image/png");
ImageIO.write(image, "PNG", response.getOutputStream());
response.getOutputStream().flush();
}
}

View File

@ -1,33 +0,0 @@
package de.ph87.data.series.graph;
import de.ph87.data.series.*;
import de.ph87.data.series.meter.*;
import de.ph87.data.series.varying.*;
import lombok.*;
import lombok.extern.slf4j.*;
import org.springframework.stereotype.*;
import java.util.*;
@Slf4j
@Service
@RequiredArgsConstructor
public class GraphService {
private final SeriesService seriesService;
private final VaryingService varyingService;
private final MeterService meterService;
@NonNull
public Graph getGraph(final long seriesId, @NonNull final Aligned begin, @NonNull final Aligned end, final int width, final int height, final int border) {
final SeriesDto series = seriesService.getDtoById(seriesId);
final List<GraphPoint> entries = switch (series.getType()) {
case METER -> meterService.getPoints(series, begin, end);
case VARYING -> varyingService.getPoints(series, begin, end);
};
return new Graph(series, entries, begin, end, width, height, border);
}
}

View File

@ -1,22 +1,31 @@
package de.ph87.data.series.meter; package de.ph87.data.series.meter;
import de.ph87.data.point.Point;
import de.ph87.data.point.PointRequest;
import de.ph87.data.series.*; import de.ph87.data.series.*;
import de.ph87.data.series.graph.*; import de.ph87.data.series.meter.day.MeterDay;
import de.ph87.data.series.meter.day.*; import de.ph87.data.series.meter.day.MeterDayRepository;
import de.ph87.data.series.meter.five.*; import de.ph87.data.series.meter.five.MeterFive;
import de.ph87.data.series.meter.hour.*; import de.ph87.data.series.meter.five.MeterFiveRepository;
import de.ph87.data.series.meter.month.*; import de.ph87.data.series.meter.hour.MeterHour;
import de.ph87.data.series.meter.week.*; import de.ph87.data.series.meter.hour.MeterHourRepository;
import de.ph87.data.series.meter.year.*; import de.ph87.data.series.meter.month.MeterMonth;
import lombok.*; import de.ph87.data.series.meter.month.MeterMonthRepository;
import lombok.extern.slf4j.*; import de.ph87.data.series.meter.week.MeterWeek;
import de.ph87.data.series.meter.week.MeterWeekRepository;
import de.ph87.data.series.meter.year.MeterYear;
import de.ph87.data.series.meter.year.MeterYearRepository;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
import org.springframework.stereotype.*; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*; import org.springframework.transaction.annotation.Transactional;
import java.util.*; import java.util.LinkedList;
import java.util.function.*; import java.util.List;
import java.util.stream.*; import java.util.function.Consumer;
import java.util.stream.Collectors;
@Slf4j @Slf4j
@Service @Service
@ -80,13 +89,13 @@ public class MeterService {
} }
@NonNull @NonNull
public List<GraphPoint> getPoints(@NonNull final SeriesDto series, @NonNull final Aligned begin, @NonNull final Aligned end) { public List<Point> getPoints(final @NonNull Series series, @NonNull final PointRequest pointRequest) {
final List<? extends MeterValue> graphPoints = findRepository(begin.alignment).findAllByIdMeterSeriesIdAndIdDateGreaterThanEqualAndIdDateLessThanEqualOrderByIdDate(series.id, begin.date, end.date); final List<? extends MeterValue> graphPoints = findRepository(pointRequest.inner).findAllByIdMeterSeriesIdAndIdDateGreaterThanEqualAndIdDateLessThanEqualOrderByIdDate(series.getId(), pointRequest.begin.date, pointRequest.end.date);
final List<GraphPoint> points = graphPoints.stream().map(meterValue -> new GraphPoint(meterValue.getId().getDate(), meterValue.getMax() - meterValue.getMin())).collect(Collectors.toCollection(LinkedList::new)); final List<Point> points = graphPoints.stream().map(meterValue -> new Point(meterValue.getId().getDate(), meterValue.getMax() - meterValue.getMin())).collect(Collectors.toCollection(LinkedList::new));
for (int i = 0; i < points.size() - 1; i++) { for (int i = 0; i < points.size() - 1; i++) {
if (points.get(i).date.compareTo(points.get(i + 1).date) == 0) { if (points.get(i).date.compareTo(points.get(i + 1).date) == 0) {
final GraphPoint first = points.remove(i); final Point first = points.remove(i);
final GraphPoint second = points.remove(i + 1); final Point second = points.remove(i + 1);
points.add(i, first.plus(second)); points.add(i, first.plus(second));
} }
} }

View File

@ -1,20 +1,28 @@
package de.ph87.data.series.varying; package de.ph87.data.series.varying;
import de.ph87.data.point.Point;
import de.ph87.data.point.PointRequest;
import de.ph87.data.series.*; import de.ph87.data.series.*;
import de.ph87.data.series.graph.*; import de.ph87.data.series.varying.day.VaryingDay;
import de.ph87.data.series.varying.day.*; import de.ph87.data.series.varying.day.VaryingDayRepository;
import de.ph87.data.series.varying.five.*; import de.ph87.data.series.varying.five.VaryingFive;
import de.ph87.data.series.varying.hour.*; import de.ph87.data.series.varying.five.VaryingFiveRepository;
import de.ph87.data.series.varying.month.*; import de.ph87.data.series.varying.hour.VaryingHour;
import de.ph87.data.series.varying.week.*; import de.ph87.data.series.varying.hour.VaryingHourRepository;
import de.ph87.data.series.varying.year.*; import de.ph87.data.series.varying.month.VaryingMonth;
import lombok.*; import de.ph87.data.series.varying.month.VaryingMonthRepository;
import lombok.extern.slf4j.*; import de.ph87.data.series.varying.week.VaryingWeek;
import de.ph87.data.series.varying.week.VaryingWeekRepository;
import de.ph87.data.series.varying.year.VaryingYear;
import de.ph87.data.series.varying.year.VaryingYearRepository;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
import org.springframework.stereotype.*; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*; import org.springframework.transaction.annotation.Transactional;
import java.util.*; import java.util.List;
@Slf4j @Slf4j
@Service @Service
@ -65,11 +73,11 @@ public class VaryingService {
} }
@NonNull @NonNull
public List<GraphPoint> getPoints(@NonNull final SeriesDto series, @NonNull final Aligned begin, @NonNull final Aligned end) { public List<Point> getPoints(final @NonNull Series series, @NonNull final PointRequest pointRequest) {
return findRepository(begin.alignment) return findRepository(pointRequest.inner)
.findAllByIdSeriesIdAndIdDateGreaterThanEqualAndIdDateLessThanEqual(series.id, begin.date, end.date) .findAllByIdSeriesIdAndIdDateGreaterThanEqualAndIdDateLessThanEqual(series.getId(), pointRequest.begin.date, pointRequest.end.date)
.stream() .stream()
.map(v -> new GraphPoint(v.getId().getDate(), v.getAvg())) .map(v -> new Point(v.getId().getDate(), v.getAvg()))
.toList(); .toList();
} }

View File

@ -1,11 +1,13 @@
package de.ph87.data.value; package de.ph87.data.value;
import com.fasterxml.jackson.core.*; import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.DeserializationContext;
import lombok.*; import com.fasterxml.jackson.databind.JsonDeserializer;
import lombok.NonNull;
import java.io.*; import java.io.IOException;
import java.util.*; import java.util.Arrays;
import java.util.List;
public enum Unit { public enum Unit {
TEMPERATURE_C("°C"), TEMPERATURE_C("°C"),
@ -38,7 +40,21 @@ public enum Unit {
SUN_DC("Δ°C"), SUN_DC("Δ°C"),
UNIT_PERCENT("%"), UNIT_PERCENT("%"),
;
CLOUD_COVER_PERCENT("%"),
IRRADIATION_WH_M2("Wh/m²"),
IRRADIATION_KWH_M2("kWh/m²", 1000, IRRADIATION_WH_M2),
PRECIPITATION_MM("mm"),
BOOLEAN(""),
DOOR_BOOLEAN("", 1, BOOLEAN),
WINDOW_BOOLEAN("", 1, BOOLEAN),
LIGHT_BOOLEAN("", 1, BOOLEAN),
VOLUME_L("l"),
LENGTH_CM("cm");
public final String unit; public final String unit;

View File

@ -2,17 +2,31 @@ package de.ph87.data.value;
import lombok.*; import lombok.*;
import java.util.*;
import java.util.function.*;
@Data
public class Value { public class Value {
public final double value; public final double value;
public final Unit unit; public final Unit unit;
public final int decimals;
public Value(final double value, @NonNull final Unit unit) { public Value(final double value, @NonNull final Unit unit) {
this.value = value; this.value = value;
this.unit = unit; 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) { public Value as(@NonNull final Unit target) {
if (this.unit == target) { if (this.unit == target) {
return this; return this;
@ -23,4 +37,21 @@ public class Value {
return new Value(value * this.unit.factor / target.factor, target); 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,23 @@
package de.ph87.data.view;
import de.ph87.data.view.tree.ViewDto;
import lombok.RequiredArgsConstructor;
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;
@RestController
@RequiredArgsConstructor
@RequestMapping("View")
public class ViewController {
private final ViewRepository viewRepository;
@GetMapping("rootList")
public List<ViewDto> rootList() {
return viewRepository.findAllDtoByNameNotEmpty();
}
}

View File

@ -0,0 +1,20 @@
package de.ph87.data.view;
import de.ph87.data.point.PointRequest;
import de.ph87.data.series.Alignment;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
@Getter
@ToString(callSuper = true)
public class ViewPointRequest extends PointRequest {
public final String viewUuid;
public ViewPointRequest(final @NonNull Alignment outer, final int outerOffset, final int outerCount, final @NonNull Alignment inner, @NonNull final String viewUuid) {
super(outer, outerOffset, outerCount, inner);
this.viewUuid = viewUuid;
}
}

View File

@ -0,0 +1,20 @@
package de.ph87.data.view;
import de.ph87.data.view.tree.View;
import de.ph87.data.view.tree.ViewDto;
import lombok.NonNull;
import org.springframework.data.repository.ListCrudRepository;
import java.util.List;
public interface ViewRepository extends ListCrudRepository<View, String> {
boolean existsByName(@NonNull String name);
List<View> findAllByNameNot(@NonNull String name);
default List<ViewDto> findAllDtoByNameNotEmpty() {
return findAllByNameNot("").stream().map(ViewDto::map).toList();
}
}

View File

@ -0,0 +1,46 @@
package de.ph87.data.view;
import de.ph87.data.point.Point;
import de.ph87.data.point.PointRequest;
import de.ph87.data.point.PointService;
import de.ph87.data.series.Series;
import lombok.Data;
import lombok.NonNull;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Data
public class ViewScope {
private final PointService pointService;
private final PointRequest request;
private final Map<Long, List<Point>> seriesPoints = new HashMap<>();
private final Map<Double, List<Point>> literalPoints = new HashMap<>();
@NonNull
public List<Point> getPoints(@NonNull final Series series) {
return seriesPoints.computeIfAbsent(series.getId(), ignore -> pointService.getPoints(series, request));
}
@NonNull
public List<Point> getLiteral(final double value) {
return literalPoints.computeIfAbsent(value, this::_generateLiteral);
}
@NonNull
private List<Point> _generateLiteral(final double value) {
final List<Point> points = new ArrayList<>();
for (ZonedDateTime date = request.begin.date; !date.isAfter(request.end.date); date = request.inner.plus(date, 1)) {
points.add(new Point(date, value));
}
return points;
}
}

View File

@ -0,0 +1,58 @@
package de.ph87.data.view;
import de.ph87.data.point.Point;
import de.ph87.data.point.PointService;
import de.ph87.data.series.SeriesRepository;
import de.ph87.data.view.tree.View;
import de.ph87.data.view.tree.ViewBinary;
import de.ph87.data.view.tree.ViewSeries;
import de.ph87.data.view.tree.ViewUnary;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class ViewService {
private static final long DEMO_SERIES_POWER_BALANCE = 8L;
private static final long DEMO_SERIES_POWER_PRODUCED = 26L;
private static final String DEMO_POWER_CONSUMED = "__DEMO_POWER_CONSUMED__";
private final ViewRepository viewRepository;
private final PointService pointService;
private final SeriesRepository seriesRepository;
@Transactional
@EventListener(ApplicationReadyEvent.class)
public void init() {
if (!viewRepository.existsByName(DEMO_POWER_CONSUMED)) {
final View powerBalance = new ViewSeries(seriesRepository.findById(DEMO_SERIES_POWER_BALANCE).orElseThrow());
final View powerBalance2 = new ViewBinary(ViewBinary.Operation.PLUS, powerBalance, powerBalance);
final View powerPurchased = new ViewUnary(ViewUnary.Operation.NOT_NEG, powerBalance2);
final View powerProduced = new ViewSeries(seriesRepository.findById(DEMO_SERIES_POWER_PRODUCED).orElseThrow());
final View powerConsumed = viewRepository.save(new ViewBinary(ViewBinary.Operation.PLUS, powerPurchased, powerProduced));
powerConsumed.setName(DEMO_POWER_CONSUMED);
log.warn("DEMO VIEW CREATED: {} \"{}\"", powerConsumed.getUuid(), powerConsumed.getName());
}
}
@Transactional(readOnly = true)
public List<Point> getPoints(@NonNull final ViewPointRequest request) {
final ViewScope scope = new ViewScope(pointService, request);
final View view = viewRepository.findById(request.viewUuid).orElseThrow();
return view.getPoints(scope);
}
}

View File

@ -0,0 +1,37 @@
package de.ph87.data.view.tree;
import de.ph87.data.point.Point;
import de.ph87.data.view.ViewScope;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NonNull;
import lombok.Setter;
import lombok.ToString;
import java.util.List;
import java.util.UUID;
@Entity
@Getter
@ToString
@DiscriminatorColumn(name = "_type_")
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class View {
@Id
private String uuid = UUID.randomUUID().toString();
@Version
private int version;
@Column(insertable = false, updatable = false)
private String _type_;
@Setter
@NonNull
@Column(nullable = false)
private String name = "";
public abstract List<Point> getPoints(final @NonNull ViewScope scope);
}

View File

@ -0,0 +1,84 @@
package de.ph87.data.view.tree;
import de.ph87.data.point.Point;
import de.ph87.data.view.ViewScope;
import jakarta.persistence.*;
import lombok.*;
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiFunction;
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@DiscriminatorValue("binary")
public class ViewBinary extends View {
@NonNull
@Enumerated(EnumType.STRING)
private Operation operation;
@NonNull
@OneToOne(optional = false, orphanRemoval = true, cascade = CascadeType.ALL)
private View view0;
@NonNull
@OneToOne(optional = false, orphanRemoval = true, cascade = CascadeType.ALL)
private View view1;
public ViewBinary(@NonNull final Operation operation, @NonNull final View view0, @NonNull final View view1) {
this.operation = operation;
this.view0 = view0;
this.view1 = view1;
}
public enum Operation {
PLUS(Double::sum),
MINUS((a, b) -> a - b),
MULTIPLY((a, b) -> a * b),
DIVIDE((a, b) -> a / b),
MODULO((a, b) -> a % b),
PERCENT((a, b) -> a / b * 100),
;
public final BiFunction<Double, Double, Double> function;
Operation(final BiFunction<Double, Double, Double> function) {
this.function = function;
}
@NonNull
public Point apply(@NonNull final Point a, @NonNull final Point b) {
return new Point(a.date, function.apply(a.value, b.value));
}
}
@Override
public List<Point> getPoints(final @NonNull ViewScope scope) {
final List<Point> pointsA = view0.getPoints(scope);
final List<Point> pointsB = view1.getPoints(scope);
final List<Point> result = new ArrayList<>(pointsA.size() + pointsB.size());
int indexA = 0;
int indexB = 0;
while (indexA < pointsA.size() && indexB < pointsB.size()) {
final Point pointA = pointsA.get(indexA);
final Point pointB = pointsB.get(indexB);
int cmp = pointA.date.compareTo(pointB.date);
if (cmp < 0) {
indexA++;
} else if (cmp > 0) {
indexB++;
} else {
result.add(operation.apply(pointA, pointB));
indexA++;
indexB++;
}
}
return result;
}
}

View File

@ -0,0 +1,27 @@
package de.ph87.data.view.tree;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
@Getter
@ToString(callSuper = true)
public class ViewBinaryDto extends ViewDto {
@NonNull
public final ViewBinary.Operation operation;
@NonNull
public final ViewDto view0;
@NonNull
public final ViewDto view1;
public ViewBinaryDto(@NonNull final ViewBinary viewBinary) {
super(viewBinary);
this.operation = viewBinary.getOperation();
this.view0 = ViewDto.map(viewBinary.getView0());
this.view1 = ViewDto.map(viewBinary.getView1());
}
}

View File

@ -0,0 +1,34 @@
package de.ph87.data.view.tree;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
@Getter
@ToString
public abstract class ViewDto {
public final String _type_;
public final String uuid;
public final String name;
protected ViewDto(@NonNull final View view) {
this._type_ = view.get_type_();
this.uuid = view.getUuid();
this.name = view.getName();
}
@NonNull
public static ViewDto map(@NonNull final View view) {
return switch (view) {
case final ViewLiteral viewLiteral -> new ViewLiteralDto(viewLiteral);
case final ViewSeries viewSeries -> new ViewSeriesDto(viewSeries);
case final ViewUnary viewUnary -> new ViewUnaryDto(viewUnary);
case final ViewBinary viewBinary -> new ViewBinaryDto(viewBinary);
default -> throw new RuntimeException("DTO mapping of View type not implemented: " + view.getClass());
};
}
}

View File

@ -0,0 +1,32 @@
package de.ph87.data.view.tree;
import de.ph87.data.point.Point;
import de.ph87.data.view.ViewScope;
import jakarta.persistence.Column;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Entity;
import lombok.*;
import java.util.List;
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@DiscriminatorValue("literal")
public class ViewLiteral extends View {
@Column(name = "`value`", nullable = false)
private double value;
public ViewLiteral(final double value) {
this.value = value;
}
@Override
public List<Point> getPoints(final @NonNull ViewScope scope) {
return scope.getLiteral(value);
}
}

View File

@ -0,0 +1,18 @@
package de.ph87.data.view.tree;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
@Getter
@ToString(callSuper = true)
public class ViewLiteralDto extends ViewDto {
public final double value;
public ViewLiteralDto(@NonNull final ViewLiteral viewLiteral) {
super(viewLiteral);
this.value = viewLiteral.getValue();
}
}

View File

@ -0,0 +1,34 @@
package de.ph87.data.view.tree;
import de.ph87.data.point.Point;
import de.ph87.data.series.Series;
import de.ph87.data.view.ViewScope;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Entity;
import jakarta.persistence.ManyToOne;
import lombok.*;
import java.util.List;
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@DiscriminatorValue("series")
public class ViewSeries extends View {
@NonNull
@ManyToOne(optional = false)
private Series series;
public ViewSeries(@NonNull final Series series) {
this.series = series;
}
@Override
public List<Point> getPoints(final @NonNull ViewScope scope) {
return scope.getPoints(series);
}
}

View File

@ -0,0 +1,20 @@
package de.ph87.data.view.tree;
import de.ph87.data.series.SeriesDto;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
@Getter
@ToString(callSuper = true)
public class ViewSeriesDto extends ViewDto {
@NonNull
public final SeriesDto series;
public ViewSeriesDto(@NonNull final ViewSeries viewSeries) {
super(viewSeries);
this.series = new SeriesDto(viewSeries.getSeries());
}
}

View File

@ -0,0 +1,56 @@
package de.ph87.data.view.tree;
import de.ph87.data.point.Point;
import de.ph87.data.view.ViewScope;
import jakarta.persistence.*;
import lombok.*;
import java.util.List;
import java.util.function.Function;
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@DiscriminatorValue("unary")
public class ViewUnary extends View {
@NonNull
@Enumerated(EnumType.STRING)
private Operation operation;
@NonNull
@OneToOne(optional = false, orphanRemoval = true, cascade = CascadeType.ALL)
private View view;
public ViewUnary(@NonNull final Operation operation, @NonNull final View view) {
this.operation = operation;
this.view = view;
}
public enum Operation {
NEG(a -> -a),
REC(a -> 1 / a),
NOT_NEG(a -> a < 0 ? 0 : a),
;
public final Function<Double, Double> function;
Operation(final Function<Double, Double> function) {
this.function = function;
}
@NonNull
public Point apply(@NonNull final Point point) {
return new Point(point.date, function.apply(point.value));
}
}
@Override
public List<Point> getPoints(final @NonNull ViewScope scope) {
return view.getPoints(scope).stream().map(operation::apply).toList();
}
}

View File

@ -0,0 +1,23 @@
package de.ph87.data.view.tree;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
@Getter
@ToString(callSuper = true)
public class ViewUnaryDto extends ViewDto {
@NonNull
public final ViewUnary.Operation operation;
@NonNull
public final ViewDto view;
public ViewUnaryDto(@NonNull final ViewUnary viewUnary) {
super(viewUnary);
this.operation = viewUnary.getOperation();
this.view = ViewDto.map(viewUnary.getView());
}
}

View File

@ -0,0 +1,77 @@
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());
}
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;
}
}
}

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,88 @@
package de.ph87.data.weather;
import de.ph87.data.value.Unit;
import de.ph87.data.value.Value;
import lombok.Data;
import lombok.NonNull;
import lombok.ToString;
import java.io.IOException;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.stream.Collectors;
@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;
@NonNull
public final Value temperature;
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);
temperature = new Value((dto.getTemperature()), Unit.TEMPERATURE_C);
}
}
}

View File

@ -0,0 +1,83 @@
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<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 (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);
}
}