energy-plot

This commit is contained in:
Patrick Haßel 2025-11-03 11:27:33 +01:00
parent 5bc68ca0c3
commit eebb917a6d
24 changed files with 396 additions and 144 deletions

View File

@ -31,7 +31,8 @@ export function validateString(value: any): string {
}
export function validateDate(value: any): Date {
return new Date(Date.parse(validateString(value)));
const parsed = Date.parse(validateString(value));
return new Date(parsed);
}
export function validateList<T>(value: any, fromJson: FromJson<T>): T[] {

View File

@ -2,9 +2,9 @@
<app-location-power [location]="location"></app-location-power>
<app-series-history [location]="location" [interval]="Interval.DAY" heading="Heute"></app-series-history>
<app-location-energy [location]="location" [interval]="Interval.DAY" heading="Heute"></app-location-energy>
<app-series-history [location]="location" [interval]="Interval.DAY" [offset]="offset">
<app-location-energy [location]="location" [interval]="Interval.DAY" [offset]="offset">
<ng-content #SeriesHistoryHeading>
<div style="display: flex; width: 100%">
&nbsp;
@ -15,7 +15,7 @@
<div style="flex: 1">{{ offsetDayTitle() }}</div>
</div>
</ng-content>
</app-series-history>
</app-location-energy>
<div class="Section">
<div class="SectionHeading">

View File

@ -6,7 +6,7 @@ import {Text} from '../../shared/text/text';
import {Number} from '../../shared/number/number';
import {SeriesSelect} from '../../series/select/series-select';
import {Subscription} from 'rxjs';
import {LocationEnergy} from '../electricity/location-energy';
import {LocationEnergy} from '../energy/location-energy';
import {Interval} from '../../series/Interval';
import {MenuService} from '../../menu-service';
import {DatePipe} from '@angular/common';

View File

@ -11,7 +11,7 @@
<div class="SectionHeadingText">
Bezug
</div>
<div class="SectionBody purchase">
<div class="SectionBody COLOR_FONT_PURCHASE">
{{ purchase.toValueString(interval ? null : now) }}
</div>
</div>
@ -20,7 +20,7 @@
<div class="SectionHeadingText">
Solar
</div>
<div class="SectionBody produce">
<div class="SectionBody COLOR_FONT_PRODUCE">
{{ produce.toValueString(interval ? null : now) }}
</div>
</div>
@ -29,7 +29,7 @@
<div class="SectionHeadingText">
Verbrauch
</div>
<div class="SectionBody consume">
<div class="SectionBody COLOR_FONT_CONSUME">
{{ consume.toValueString(interval ? null : now) }}
</div>
</div>
@ -38,15 +38,13 @@
<div class="SectionHeadingText">
Einspeisung
</div>
<div class="SectionBody deliver">
<div class="SectionBody COLOR_FONT_DELIVER">
{{ deliver.toValueString(interval ? null : now) }}
</div>
</div>
</div>
@if (interval) {
<!-- <app-series-history-graph></app-series-history-graph>-->
}
<app-energy-plot [location]="location" [interval]="interval" [offset]="offset"></app-energy-plot>
</div>

View File

@ -7,10 +7,13 @@ import {PointService} from '../../point/point-service';
import {SeriesService} from '../../series/series-service';
import {Subscription} from 'rxjs';
import {Value} from '../../series/Value';
import {EnergyPlot} from './plot/energy-plot';
@Component({
selector: 'app-series-history',
imports: [],
selector: 'app-location-energy',
imports: [
EnergyPlot
],
templateUrl: './location-energy.html',
styleUrl: './location-energy.less',
})
@ -98,7 +101,7 @@ export class LocationEnergy implements OnInit, AfterViewInit, OnDestroy {
return
}
if (this.interval) {
this.pointService.relative([series], this.interval, this.offset, 1, response => callNextAndUpdateConsume(Value.ofPoint(response, 0, 0, 1)));
this.pointService.relative([series], this.interval, this.offset, 1, this.interval, response => callNextAndUpdateConsume(Value.ofPoint(response, 0, 0, 1)));
} else {
callNextAndUpdateConsume(series.value);
}

View File

@ -0,0 +1,98 @@
export class EnergyPoint {
readonly epochSeconds: number;
private _purchase: number | null = null;
private _produce: number | null = null;
private _deliver: number | null = null;
private _self: number | null = null;
private _consume: number | null = null;
private _purchaseY: number | null = null;
private _selfY: number | null = null;
private _deliverY: number | null = null;
getPurchaseY(yFactor: number): number {
return this.getPurchaseH(yFactor) + this.getSelfH(yFactor);
}
getPurchaseH(yFactor: number): number {
if (this._purchaseY === null) {
this._purchaseY = (this.purchase || 0) * yFactor;
}
return this._purchaseY;
}
getSelfY(yFactor: number): number {
return this.getSelfH(yFactor);
}
getSelfH(yFactor: number): number {
if (this._selfY === null) {
this._selfY = (this.self || 0) * yFactor;
}
return this._selfY;
}
getDeliverH(yFactor: number): number {
if (this._deliverY === null) {
this._deliverY = (this.deliver || 0) * yFactor;
}
return this._deliverY;
}
constructor(
point: number[],
) {
this.epochSeconds = point[0];
}
set deliver(value: number | null) {
this._deliver = value;
this.update();
}
set produce(value: number | null) {
this._produce = value;
this.update();
}
set purchase(value: number | null) {
this._purchase = value;
this.update();
}
private update() {
if (this._purchase !== null && this._produce !== null && this._deliver !== null) {
this._self = Math.max(0, this._produce - this._deliver);
this._consume = Math.max(0, this._purchase + this._self);
}
}
get consume(): number | null {
return this._consume;
}
get self(): number | null {
return this._self;
}
get deliver(): number | null {
return this._deliver;
}
get produce(): number | null {
return this._produce;
}
get purchase(): number | null {
return this._purchase;
}
}

View File

@ -0,0 +1,33 @@
<svg [attr.viewBox]="`0 0 ${widthPx} ${heightPx}`" [style.background-color]="'#eee'">
@for (point of points; track point.epochSeconds) {
<rect
[attr.x]="(point.epochSeconds - xMin) * xFactor"
[attr.y]="heightPx - 1 + yMinPx - point.getPurchaseY(yFactor)"
[attr.width]="xWidthPx"
[attr.height]="point.getPurchaseH(yFactor)"
class="COLOR_BACK_PURCHASE"
></rect>
<rect
[attr.x]="(point.epochSeconds - xMin) * xFactor"
[attr.y]="heightPx - 1 + yMinPx - point.getSelfY(yFactor)"
[attr.width]="xWidthPx"
[attr.height]="point.getSelfH(yFactor)"
class="COLOR_BACK_SELF"
></rect>
<rect
[attr.x]="(point.epochSeconds - xMin) * xFactor"
[attr.y]="heightPx + yMinPx"
[attr.width]="xWidthPx"
[attr.height]="point.getDeliverH(yFactor)"
class="COLOR_BACK_DELIVER"
></rect>
<line
x1="0"
[attr.y1]="heightPx - 1 + yMinPx"
[attr.x2]="widthPx"
[attr.y2]="heightPx - 1 + yMinPx"
stroke="#aaaaaa"
stroke-width="1"
></line>
}
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

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

View File

@ -0,0 +1,135 @@
import {AfterViewInit, Component, Input} from '@angular/core';
import {Location} from '../../Location';
import {Interval} from '../../../series/Interval';
import {PointService} from '../../../point/point-service';
import {PointResponse} from '../../../point/PointResponse';
import {PointSeries} from '../../../point/PointSeries';
import {EnergyPoint} from './EnergyPoint';
@Component({
selector: 'app-energy-plot',
imports: [],
templateUrl: './energy-plot.html',
styleUrl: './energy-plot.less',
})
export class EnergyPlot implements AfterViewInit {
readonly widthPx = 800;
readonly heightPx = 100;
private _location!: Location;
private _interval: Interval = Interval.FIVE;
private _offset: number = 0;
private _count: number = 1;
protected points: EnergyPoint[] = [];
protected yMin: number = 0;
protected yMinPx: number = 0;
protected yMax: number = 0;
protected yFactor: number = 0;
protected xMin: number = 0;
protected xMax: number = 0;
protected xFactor: number = 0;
protected xWidthPx: number = 0;
constructor(
readonly pointService: PointService,
) {
//
}
ngAfterViewInit(): void {
if (!this._location.energyPurchase) {
return;
}
if (!this._location.energyProduce) {
return;
}
if (!this._location.energyDeliver) {
return;
}
const series = [this._location.energyPurchase, this._location.energyProduce, this._location.energyDeliver];
this.pointService.relative(series, this._interval, this._offset, this._count, this._interval.inner, this.update);
}
public readonly update = (response: PointResponse): void => {
this.points.length = 0;
this.add(response.series[0], (p, v) => p.purchase = v);
this.add(response.series[1], (p, v) => p.produce = v);
this.add(response.series[2], (p, v) => p.deliver = v);
this.yMax = -Infinity;
this.yMin = Infinity;
for (let point of this.points) {
this.yMax = Math.max(this.yMax, point.consume || 0);
this.yMin = Math.min(this.yMin, -(point.deliver || 0));
}
this.yMinPx = this.yMin * this.yFactor;
this.yFactor = this.heightPx / (this.yMax - this.yMin);
this.xMin = response.begin.getTime() / 1000;
this.xMax = response.end.getTime() / 1000;
this.xFactor = this.widthPx / (this.xMax - this.xMin);
this.xWidthPx = this.widthPx / response.expectedCount;
};
private add(series: PointSeries, setter: (p: EnergyPoint, v: number) => void): void {
for (const point of series.points) {
const index = this.insert(point, setter);
if (index >= 0) {
const fresh = new EnergyPoint(point);
setter(fresh, point[1])
this.points.splice(index, 0, fresh);
}
}
}
private insert(point: number[], setter: (p: EnergyPoint, v: number) => any): number {
let index = 0;
for (let old of this.points) {
const age = old.epochSeconds - point[0];
if (age === 0) {
setter(old, point[1])
return -1;
} else if (age < 0) {
return index;
}
index++;
}
return index;
}
@Input()
set location(value: Location) {
this._location = value;
this.ngAfterViewInit();
}
@Input()
set interval(value: Interval) {
this._interval = value;
this.ngAfterViewInit();
}
@Input()
set offset(value: number) {
this._offset = value;
this.ngAfterViewInit();
}
@Input()
set count(value: number) {
this._count = value;
this.ngAfterViewInit();
}
}

View File

@ -1,9 +0,0 @@
<div class="segments">
@for (segment of segments; track segment) {
<div class="segment">
<!-- @for (graph of graphs; track graph) {-->
<!-- <div class="" [style.background-color]="graph[0]" [style.height.%]="graph[1][segment] / total(segment) * 100"></div>-->
<!-- }-->
</div>
}
</div>

View File

@ -1,9 +0,0 @@
.segments {
display: flex;
height: 4em;
.segment {
flex: 1;
height: 100%;
}
}

View File

@ -1,70 +0,0 @@
import {AfterViewInit, Component, Input} from '@angular/core';
import {Location} from '../Location';
import {Interval} from '../../series/Interval';
import {PointService} from '../../point/point-service';
@Component({
selector: 'app-series-history-graph',
imports: [],
templateUrl: './simple-plot.html',
styleUrl: './simple-plot.less',
})
export class SeriesHistoryGraph implements AfterViewInit {
protected segments = Array.from(Array(288).keys());
protected totals: number[] = [];
protected historyEnergyPurchase: number[] | null = null;
protected historyEnergyDeliver: number[] | null = null;
protected historyEnergyProduce: number[] | null = null;
protected readonly Interval = Interval;
@Input()
heading!: string;
@Input()
date!: Date;
@Input()
interval!: Interval;
@Input()
location!: Location;
constructor(
readonly pointService: PointService,
) {
//
}
ngAfterViewInit(): void {
// this.history(this.location?.energyPurchase, history => this.historyEnergyPurchase = history);
// this.history(this.location?.energyDeliver, history => this.historyEnergyDeliver = history);
// this.history(this.location?.energyProduce, history => this.historyEnergyProduce = history);
}
// public readonly updateSeries = (fresh: Series): void => {
// if (fresh.id === this.location?.energyPurchase?.id) {
// this.history(this.location?.energyPurchase, history => this.historyEnergyPurchase = history);
// }
// if (fresh.id === this.location?.energyDeliver?.id) {
// this.history(this.location?.energyDeliver, history => this.historyEnergyDeliver = history);
// }
// if (fresh.id === this.location?.energyProduce?.id) {
// this.history(this.location?.energyProduce, history => this.historyEnergyProduce = history);
// }
// };
//
// private history(series: Series | null | undefined, next: Next<number[] | null>) {
// if (!series || !this.interval) {
// next(null);
// return
// }
// this.pointService.points(series, this.date, this.interval, next);
// }
}

View File

@ -10,7 +10,7 @@
<div class="SectionHeadingText">
Bezug
</div>
<div class="SectionBody purchase">
<div class="SectionBody COLOR_FONT_PURCHASE">
{{ location.powerPurchase?.value?.toValueString(dateService.now) }}
</div>
</div>
@ -19,7 +19,7 @@
<div class="SectionHeadingText">
Solar
</div>
<div class="SectionBody produce">
<div class="SectionBody COLOR_FONT_PRODUCE">
{{ location.powerProduce?.value?.toValueString(dateService.now) }}
</div>
</div>
@ -28,7 +28,7 @@
<div class="SectionHeadingText">
Verbrauch
</div>
<div class="SectionBody consume">
<div class="SectionBody COLOR_FONT_CONSUME">
{{ location.powerConsume?.toValueString(dateService.now) }}
</div>
</div>
@ -37,7 +37,7 @@
<div class="SectionHeadingText">
Einspeisung
</div>
<div class="SectionBody deliver">
<div class="SectionBody COLOR_FONT_DELIVER">
{{ location.powerDeliver?.value?.toValueString(dateService.now) }}
</div>
</div>

View File

@ -1,4 +1,4 @@
import {validateDate, validateList} from "../common";
import {validateDate, validateList, validateNumber} from "../common";
import {PointSeries} from './PointSeries';
@ -8,6 +8,7 @@ export class PointResponse {
readonly begin: Date,
readonly end: Date,
readonly series: PointSeries[],
readonly expectedCount: number,
) {
//
}
@ -17,6 +18,7 @@ export class PointResponse {
validateDate(json.begin),
validateDate(json.end),
validateList(json.series, PointSeries.fromJson),
validateNumber(json.expectedCount),
);
}

View File

@ -16,12 +16,13 @@ export class PointService extends CrudService<PointResponse> {
super(api, ws, ['Point'], PointResponse.fromJson);
}
relative(series: Series[], interval: Interval, offset: number, count: number, next: Next<PointResponse>): void {
relative(series: Series[], outer: Interval, offset: number, count: number, interval: Interval, next: Next<PointResponse>): void {
const request = {
ids: series.map(s => s.id),
interval: interval,
outerInterval: outer.name,
offset: offset,
count: count,
interval: interval.name,
};
this.postSingle(['relative'], request, next);
}

View File

@ -1,8 +1,22 @@
export enum Interval {
FIVE = 'FIVE',
HOUR = 'HOUR',
DAY = 'DAY',
WEEK = 'WEEK',
MONTH = 'MONTH',
YEAR = 'YEAR',
export class Interval {
static readonly FIVE = new Interval('FIVE');
static readonly HOUR = new Interval('HOUR', this.FIVE);
static readonly DAY = new Interval('DAY', this.FIVE);
static readonly WEEK = new Interval('WEEK', this.HOUR);
static readonly MONTH = new Interval('MONTH', this.HOUR);
static readonly YEAR = new Interval('YEAR', this.DAY);
constructor(
readonly name: string,
readonly inner: Interval = this,
) {
//
}
}

View File

@ -83,6 +83,13 @@ export class Value {
return ageSeconds > this.seconds * 2.1;
}
onlyPositive(): Value {
if (this.value < 0) {
return Value.ZERO;
}
return this;
}
}
export class BiValue extends Value {

View File

@ -1,23 +1,60 @@
@empty: gray;
@purchase: red;
@deliver: magenta;
@produce: #0095ff;
@consume: #ff8800;
.purchase {
color: @purchase;
@COLOR_FONT_PURCHASE: red;
@COLOR_FONT_DELIVER: magenta;
@COLOR_FONT_PRODUCE: #0095ff;
@COLOR_FONT_SELF: #0095ff;
@COLOR_FONT_CONSUME: #ff8800;
@COLOR_BACK_PURCHASE: #ffa7a7;
@COLOR_BACK_DELIVER: #ff00ff;
@COLOR_BACK_PRODUCE: #5cbcff;
@COLOR_BACK_SELF: #00ff69;
@COLOR_BACK_CONSUME: #ffc07a;
.COLOR_FONT_PURCHASE {
color: @COLOR_FONT_PURCHASE;
}
.deliver {
color: @deliver;
.COLOR_FONT_DELIVER {
color: @COLOR_FONT_DELIVER;
}
.produce {
color: @produce;
.COLOR_FONT_PRODUCE {
color: @COLOR_FONT_PRODUCE;
}
.consume {
color: @consume;
.COLOR_FONT_SELF {
color: @COLOR_FONT_SELF;
}
.COLOR_FONT_CONSUME {
color: @COLOR_FONT_CONSUME;
}
.COLOR_BACK_PURCHASE {
color: @COLOR_BACK_PURCHASE;
fill: @COLOR_BACK_PURCHASE;
}
.COLOR_BACK_DELIVER {
color: @COLOR_BACK_DELIVER;
fill: @COLOR_BACK_DELIVER;
}
.COLOR_BACK_PRODUCE {
color: @COLOR_BACK_PRODUCE;
fill: @COLOR_BACK_PRODUCE;
}
.COLOR_BACK_SELF {
color: @COLOR_BACK_SELF;
fill: @COLOR_BACK_SELF;
}
.COLOR_BACK_CONSUME {
color: @COLOR_BACK_CONSUME;
fill: @COLOR_BACK_CONSUME;
}
.empty {

View File

@ -51,7 +51,7 @@ public class Plot {
@Setter
@Column(nullable = false)
private long duration = 288;
private long duration = 1;
@Setter
@Column(nullable = false)

View File

@ -15,13 +15,15 @@ public interface IPointRequest {
@NonNull
List<Long> getIds();
@NonNull
Interval getInterval();
@NonNull
ZonedDateTime getBegin();
@NonNull
ZonedDateTime getEnd();
@NonNull
Interval getInterval();
long getExpectedCount();
}

View File

@ -5,34 +5,40 @@ import lombok.Data;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.stream.Stream;
@Data
public class PointRequestRelative implements IPointRequest {
public final List<Long> ids;
public final Interval interval;
public final Interval outerInterval;
public final long offset;
public final long count;
public final Interval interval;
public final ZonedDateTime begin;
public final ZonedDateTime end;
public PointRequestRelative(
final List<Long> ids,
final Interval interval,
final long offset,
final long count
) {
public final long expectedCount;
public PointRequestRelative(final List<Long> ids, final Interval outerInterval, final long offset, final long count, final Interval interval) {
this.ids = ids;
this.interval = interval;
this.outerInterval = outerInterval;
this.offset = offset;
this.count = count;
this.end = interval.align.apply(ZonedDateTime.now()).minus(interval.amount * (offset - 1), interval.unit);
this.begin = this.end.minus(interval.amount * count, interval.unit);
this.interval = interval;
this.end = outerInterval.align.apply(ZonedDateTime.now()).minus(outerInterval.amount * (offset - 1), outerInterval.unit);
this.begin = this.end.minus(outerInterval.amount * count, outerInterval.unit);
this.expectedCount = calculateExpectedCount();
}
private long calculateExpectedCount() {
return Stream.iterate(begin, d -> d.isBefore(end), d -> d.plus(interval.amount, interval.unit)).count();
}
}

View File

@ -14,4 +14,6 @@ public class PointResponse {
public final List<PointSeries> series;
public final long expectedCount;
}

View File

@ -30,7 +30,7 @@ public class PointService {
@NonNull
public PointResponse points(@NonNull final IPointRequest request) {
final List<PointSeries> series = request.getIds().stream().map(s -> points(s, request)).toList();
return new PointResponse(request.getBegin(), request.getEnd(), series);
return new PointResponse(request.getBegin(), request.getEnd(), series, request.getExpectedCount());
}
@NonNull