Mqtt, Series, Period, Consumption, ReSlice, Photovoltaic, Oil

This commit is contained in:
Patrick Haßel 2024-10-08 14:24:48 +02:00
commit ac4b1283df
28 changed files with 1352 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
/target/
/.idea/
/*.iws
/*.iml
/*.ipr
/*.db

8
application.properties Normal file
View File

@ -0,0 +1,8 @@
#logging.level.de.ph87=DEBUG
#-
spring.datasource.url=jdbc:h2:./database;AUTO_SERVER=TRUE
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
#-
spring.jpa.hibernate.ddl-auto=create

55
pom.xml Normal file
View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.ph87</groupId>
<artifactId>Data</artifactId>
<version>0.1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
<version>1.2.5</version>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,13 @@
package de.ph87.data;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Backend {
public static void main(String[] args) {
SpringApplication.run(Backend.class, args);
}
}

View File

@ -0,0 +1,90 @@
package de.ph87.data.demo;
import de.ph87.data.series.Series;
import de.ph87.data.series.SeriesMode;
import de.ph87.data.series.SeriesRepository;
import de.ph87.data.series.measure.MeasureEvent;
import de.ph87.data.series.period.PeriodRepository;
import de.ph87.data.series.period.consumption.ConsumptionRepository;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import static de.ph87.data.oil.OilController.OIL_SERIES_NAME;
import static de.ph87.data.photovoltaic.PhotovoltaicMqttReceiver.PHOTOVOLTAIC_ENERGY_SERIES_NAME;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
@SuppressWarnings("SameParameterValue")
public class DemoService {
private final ApplicationEventPublisher applicationEventPublisher;
private final SeriesRepository seriesRepository;
private final PeriodRepository periodRepository;
private final ConsumptionRepository consumptionRepository;
@EventListener(ApplicationStartedEvent.class)
public void startup() {
series(PHOTOVOLTAIC_ENERGY_SERIES_NAME, SeriesMode.INCREASING);
final Series oil = series(OIL_SERIES_NAME, SeriesMode.DECREASING);
oil.setPeriod(null);
consumptionRepository.deleteAllByIdPeriodSeries(oil);
periodRepository.deleteAllBySeries(oil);
oil(2020, 8, 10, 189, 4500);
oil(2020, 8, 22, 4275);
oil(2021, 5, 27, 1050, 4500);
oil(2021, 11, 25, 3213);
oil(2022, 3, 3, 999, 2664);
oil(2022, 3, 7, 2550);
oil(2022, 3, 11, 2439);
oil(2022, 4, 12, 1968);
oil(2022, 5, 6, 1767, 4464);
oil(2022, 7, 20, 4275);
oil(2022, 9, 5, 4200);
oil(2022, 9, 27, 4071);
oil(2022, 11, 22, 3675);
oil(2022, 12, 4, 3513);
oil(2022, 12, 15, 3342);
oil(2022, 12, 19, 3213);
oil(2023, 1, 8, 2964);
oil(2023, 2, 14, 2334);
oil(2023, 4, 3, 1734);
oil(2023, 4, 12, 1500, 4500);
oil(2023, 5, 8, 4389);
oil(2023, 9, 24, 4071);
oil(2024, 9, 12, 1275, 4575);
}
private Series series(@NonNull final String name, @NonNull final SeriesMode mode) {
return seriesRepository.findByNameOrAliasesContains(name, name).orElseGet(() -> seriesRepository.save(new Series(name, mode)));
}
private void oil(final int year, final int month, final int day, final int beforeRefill, final int afterRefill) {
oil2(year, month, day, 0, beforeRefill);
oil2(year, month, day, 1, afterRefill);
}
private void oil(final int year, final int month, final int day, final int value) {
oil2(year, month, day, 0, value);
}
private void oil2(final int year, final int month, final int day, final int second, final int value) {
final ZonedDateTime date = ZonedDateTime.of(year, month, day, 12, 0, second, 0, ZoneId.systemDefault());
applicationEventPublisher.publishEvent(new MeasureEvent(OIL_SERIES_NAME, "", date, value));
}
}

View File

@ -0,0 +1,44 @@
package de.ph87.data.mqtt;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Data
@Component
@ConfigurationProperties(prefix = "de.ph87.data.mqtt")
public class MqttConfig {
private Integer port = 1883;
private int connectTimeoutSec = 3;
private String clientId;
private String clientIdRandom = UUID.randomUUID().toString();
private String schema = "tcp";
private String host = "10.0.0.50";
private String topic = "#";
public String getUrl() {
return schema + "://" + host + ":" + port;
}
public String getClientId() {
if (clientId == null || clientId.isEmpty()) {
return clientIdRandom;
} else {
return clientId;
}
}
public boolean isCleanSession() {
return clientId == null || clientId.isEmpty();
}
}

View File

@ -0,0 +1,22 @@
package de.ph87.data.mqtt;
import lombok.NonNull;
public class MqttEvent {
@NonNull
public final String topic;
@NonNull
public final String payload;
@NonNull
public final String payloadLoggable;
public MqttEvent(@NonNull final String topic, @NonNull final String payload, @NonNull final String payloadLoggable) {
this.topic = topic;
this.payload = payload;
this.payloadLoggable = payloadLoggable;
}
}

View File

@ -0,0 +1,85 @@
package de.ph87.data.mqtt;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.paho.client.mqttv3.*;
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class MqttService {
private final ApplicationEventPublisher applicationEventPublisher;
private boolean stop = false;
private final MqttConfig config;
private IMqttClient client;
@EventListener(ApplicationStartedEvent.class)
public void connect() throws MqttException {
log.info("Connecting MQTT clientId={}, cleanSession={}", config.getClientId(), config.isCleanSession());
client = new MqttClient(config.getSchema() + "://" + config.getHost() + ":" + config.getPort(), config.getClientId(), new MemoryPersistence());
final MqttConnectOptions options = new MqttConnectOptions();
options.setAutomaticReconnect(true);
options.setCleanSession(config.isCleanSession());
options.setConnectionTimeout(config.getConnectTimeoutSec());
client.connect(options);
client.subscribe("#", this::safeReceive);
client.setCallback(new Callback());
}
private void safeReceive(final String topic, final MqttMessage message) {
final String payload = new String(message.getPayload());
final String payloadLoggable = payload.replace("\\n", "\\\\n");
log.debug("Message received: topic={}, retained={}, duplicate={}, qos={}, length={}", topic, message.isRetained(), message.isDuplicate(), message.getQos(), message.getPayload().length);
applicationEventPublisher.publishEvent(new MqttEvent(topic, payload, payloadLoggable));
}
@PreDestroy
public void preDestroy() {
try {
stop = true;
if (client.isConnected()) {
log.info("Disconnecting MQTT...");
client.disconnect();
}
} catch (MqttException e) {
log.error(e.getMessage());
}
}
private final class Callback implements MqttCallback {
@Override
public void connectionLost(final Throwable throwable) {
log.warn("MQTT disconnected", throwable);
try {
if (!stop) {
connect();
}
} catch (MqttException e) {
log.error("Failed to reconnect MQTT: {}", e.toString());
}
}
@Override
public void messageArrived(final String s, final MqttMessage mqttMessage) {
}
@Override
public void deliveryComplete(final IMqttDeliveryToken iMqttDeliveryToken) {
}
}
}

View File

@ -0,0 +1,16 @@
package de.ph87.data.oil;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@CrossOrigin
@RestController
@RequiredArgsConstructor
@RequestMapping("Oil")
public class OilController {
public static final String OIL_SERIES_NAME = "oil";
}

View File

@ -0,0 +1,71 @@
package de.ph87.data.photovoltaic;
import de.ph87.data.mqtt.MqttEvent;
import de.ph87.data.series.measure.MeasureEvent;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import java.time.ZonedDateTime;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static de.ph87.data.series.period.consumption.ConsumptionController.ZDT;
@Slf4j
@Service
@RequiredArgsConstructor
public class PhotovoltaicMqttReceiver {
public static final String PHOTOVOLTAIC_ENERGY_SERIES_NAME = "photovoltaic.energyKWh";
private static final Pattern REGEX = Pattern.compile("^(?<serial>\\S+) (?<epochSeconds>\\d+) (?<power>\\d+(:?\\.\\d+)?)(?<powerUnit>\\S+) (?<energy>\\d+(:?\\.\\d+)?)(?<energyUnit>\\S+)$");
private final ApplicationEventPublisher applicationEventPublisher;
@EventListener(MqttEvent.class)
public void onEvent(@NonNull final MqttEvent event) {
if (!event.topic.equals("OpenDtuFetcher/total")) {
return;
}
final Matcher matcher = REGEX.matcher(event.payload);
if (!matcher.find()) {
log.error("Failed to match OpenDtuFetcher payload: {}", event.payloadLoggable);
return;
}
final String serial = matcher.group("serial");
final ZonedDateTime date = ZDT(Long.parseLong(matcher.group("epochSeconds")));
final double energy = Double.parseDouble(matcher.group("energy"));
final String energyUnit = matcher.group("energyUnit");
final double energyKWh = energyToKWh(energy, energyUnit);
applicationEventPublisher.publishEvent(new MeasureEvent(PHOTOVOLTAIC_ENERGY_SERIES_NAME, serial, date, energyKWh));
}
public static double energyToKWh(final double energy, final String energyUnit) {
return switch (energyUnit) {
case "mWh" -> energy / 1000000;
case "Wh" -> energy / 1000;
case "kWh" -> energy;
case "MWh" -> energy * 1000;
case "GWh" -> energy * 1000000;
default -> throw new RuntimeException();
};
}
@SuppressWarnings("unused")
public static double powerToW(final double power, final String powerUnit) {
return switch (powerUnit) {
case "mW" -> power / 1000;
case "W" -> power;
case "kW" -> power * 1000;
case "MW" -> power * 1000000;
case "GW" -> power * 1000000000;
default -> throw new RuntimeException();
};
}
}

View File

@ -0,0 +1,44 @@
package de.ph87.data.series;
import de.ph87.data.series.period.Period;
import jakarta.annotation.Nullable;
import jakarta.persistence.*;
import lombok.*;
import java.util.HashSet;
import java.util.Set;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Series {
@Id
@GeneratedValue
private long id;
@NonNull
@Column(nullable = false, unique = true)
private String name;
@NonNull
@Enumerated(EnumType.STRING)
private SeriesMode mode;
@Setter
@Nullable
@OneToOne
@ToString.Exclude
private Period period;
@NonNull
@ElementCollection
private Set<String> aliases = new HashSet<>();
public Series(@NonNull final String name, @NonNull final SeriesMode mode) {
this.name = name;
this.mode = mode;
}
}

View File

@ -0,0 +1,23 @@
package de.ph87.data.series;
import lombok.NonNull;
import java.util.function.BiFunction;
public enum SeriesMode {
INCREASING((first, second) -> second - first),
DECREASING((first, second) -> first - second),
;
@NonNull
private final BiFunction<Double, Double, Double> delta;
SeriesMode(@NonNull final BiFunction<Double, Double, Double> delta) {
this.delta = delta;
}
public double getDelta(final double first, final double second) {
return delta.apply(first, second);
}
}

View File

@ -0,0 +1,13 @@
package de.ph87.data.series;
import lombok.NonNull;
import org.springframework.data.repository.ListCrudRepository;
import java.util.Optional;
public interface SeriesRepository extends ListCrudRepository<Series, Long> {
@NonNull
Optional<Series> findByNameOrAliasesContains(@NonNull String name, @NonNull String alias);
}

View File

@ -0,0 +1,43 @@
package de.ph87.data.series;
import de.ph87.data.series.measure.MeasureEvent;
import de.ph87.data.series.measure.MeasureEventTooOld;
import de.ph87.data.series.period.PeriodService;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class SeriesService {
private final SeriesRepository seriesRepository;
private final PeriodService periodService;
@EventListener(MeasureEvent.class)
public void onMeasureEvent(@NonNull final MeasureEvent event) {
log.debug("Handling MeasureEvent: {}", event);
final Optional<Series> seriesOptional = seriesRepository.findByNameOrAliasesContains(event.getName(), event.getName());
if (seriesOptional.isEmpty()) {
log.debug("No series found with name or alias: \"{}\"", event.getName());
return;
}
final Series series = seriesOptional.get();
log.debug("Series found: {}", series);
try {
periodService.onMeasureEvent(series, event);
} catch (MeasureEventTooOld e) {
log.warn(e.toString());
}
}
}

View File

@ -0,0 +1,26 @@
package de.ph87.data.series.measure;
import lombok.Getter;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import java.time.ZonedDateTime;
@Getter
@ToString
@RequiredArgsConstructor
public class MeasureEvent {
@NonNull
private final String name;
@NonNull
private final String periodName;
@NonNull
private final ZonedDateTime date;
private final double value;
}

View File

@ -0,0 +1,12 @@
package de.ph87.data.series.measure;
import de.ph87.data.series.period.Period;
import lombok.NonNull;
public class MeasureEventTooOld extends Exception {
public MeasureEventTooOld(@NonNull final Period period, @NonNull final MeasureEvent event) {
super("Date of received MeasureEvent older than last stored one: event=%s, period=%s".formatted(event, period));
}
}

View File

@ -0,0 +1,59 @@
package de.ph87.data.series.period;
import de.ph87.data.series.Series;
import de.ph87.data.series.measure.MeasureEvent;
import jakarta.persistence.*;
import lombok.*;
import java.time.ZonedDateTime;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Period {
@Id
@GeneratedValue
private long id;
@NonNull
@ToString.Exclude
@ManyToOne(optional = false)
private Series series;
@ToString.Include
public long series() {
return series.getId();
}
@NonNull
@Column(nullable = false)
private String name;
@NonNull
@Column(nullable = false, updatable = false)
private ZonedDateTime firstDate;
@Column(nullable = false)
private double firstValue;
@Setter
@NonNull
@Column(nullable = false)
private ZonedDateTime lastDate;
@Setter
@Column(nullable = false)
private double lastValue;
public Period(@NonNull final Series series, @NonNull final MeasureEvent event) {
this.series = series;
this.name = event.getPeriodName();
this.firstDate = event.getDate();
this.firstValue = event.getValue();
this.lastDate = event.getDate();
this.lastValue = event.getValue();
}
}

View File

@ -0,0 +1,16 @@
package de.ph87.data.series.period;
import de.ph87.data.series.Series;
import lombok.NonNull;
import org.springframework.data.repository.CrudRepository;
import java.time.ZonedDateTime;
import java.util.List;
public interface PeriodRepository extends CrudRepository<Period, Long> {
List<Period> findAllBySeriesIdAndLastDateGreaterThanAndFirstDateLessThan(long seriesId, @NonNull ZonedDateTime wantedEnd, @NonNull ZonedDateTime wantedBegin);
void deleteAllBySeries(Series oil);
}

View File

@ -0,0 +1,85 @@
package de.ph87.data.series.period;
import de.ph87.data.series.Series;
import de.ph87.data.series.SeriesMode;
import de.ph87.data.series.measure.MeasureEvent;
import de.ph87.data.series.measure.MeasureEventTooOld;
import de.ph87.data.series.period.consumption.ConsumptionService;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class PeriodService {
private final PeriodRepository periodRepository;
private final ConsumptionService consumptionService;
public void onMeasureEvent(@NonNull final Series series, @NonNull final MeasureEvent event) throws MeasureEventTooOld {
final Period period = getOrCreatePeriod(series, event);
period.setLastDate(event.getDate());
period.setLastValue(event.getValue());
consumptionService.onMeasureEvent(period, event);
}
@NonNull
private Period getOrCreatePeriod(@NonNull final Series series, @NonNull final MeasureEvent event) throws MeasureEventTooOld {
if (series.getPeriod() != null) {
log.debug("Last Period exists: {}", series.getPeriod());
if (isEventTooOld(series.getPeriod(), event)) {
throw new MeasureEventTooOld(series.getPeriod(), event);
}
if (isPeriodValid(series.getPeriod(), event)) {
log.debug("Last Period still VALID.");
return series.getPeriod();
}
} else {
log.debug("NO LAST Period found.");
}
final Period newPeriod = periodRepository.save(new Period(series, event));
log.info("New Period created: {}", newPeriod);
series.setPeriod(newPeriod);
return newPeriod;
}
private static boolean isEventTooOld(@NonNull final Period period, @NonNull final MeasureEvent event) {
return !period.getLastDate().isBefore(event.getDate());
}
private boolean isPeriodValid(@NonNull final Period period, @NonNull final MeasureEvent event) {
if (!period.getName().equals(event.getPeriodName())) {
log.debug("Period name changed: old={}, new={}", period.getName(), event.getPeriodName());
return false;
}
final Series series = period.getSeries();
final SeriesMode mode = series.getMode();
switch (mode) {
case INCREASING:
if (period.getLastValue() <= event.getValue()) {
log.debug("Mode increasing VALID: old={}, new={}", period.getLastValue(), event.getValue());
return true;
} else {
log.debug("Mode increasing INVALID: old={}, new={}", period.getLastValue(), event.getValue());
return false;
}
case DECREASING:
if (period.getLastValue() >= event.getValue()) {
log.debug("Mode decreasing VALID: old={}, new={}", period.getLastValue(), event.getValue());
return true;
} else {
log.debug("Mode decreasing INVALID: old={}, new={}", period.getLastValue(), event.getValue());
return false;
}
default:
throw new RuntimeException("SeriesMode not implemented: mode=%s, series=%s".formatted(mode, series));
}
}
}

View File

@ -0,0 +1,115 @@
package de.ph87.data.series.period.consumption;
import de.ph87.data.series.period.Period;
import de.ph87.data.series.period.consumption.unit.Unit;
import jakarta.annotation.Nullable;
import jakarta.persistence.*;
import lombok.*;
import java.io.Serializable;
import java.time.Duration;
import java.time.ZonedDateTime;
import static de.ph87.data.series.period.consumption.slice.SliceService.DL;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Consumption {
@NonNull
@EmbeddedId
@ToString.Exclude
private Id id;
@ToString.Include
public long series() {
return id.period.getSeries().getId();
}
@ToString.Include
public long period() {
return id.period.getId();
}
@ToString.Include
@SuppressWarnings("unused") // toString
public Unit unit() {
return id.unit;
}
@ToString.Include
@SuppressWarnings("unused") // toString
public String aligned() {
return DL(id.unit, id.aligned);
}
@NonNull
@Column(nullable = false)
private ZonedDateTime firstDate;
@Column(nullable = false)
private double firstValue;
@Setter
@NonNull
@Column(nullable = false)
private ZonedDateTime lastDate;
@Setter
@Column(nullable = false)
private double lastValue;
@Column
@Nullable
private Double beginValue;
@Column
@Nullable
private Double endValue;
public Consumption(@NonNull final Period period, @NonNull final Unit unit, @NonNull final ZonedDateTime aligned, @NonNull final ZonedDateTime date, final double value, @Nullable final Consumption previous) {
this.id = new Id(period, unit, aligned);
this.firstDate = date;
this.firstValue = value;
this.lastDate = date;
this.lastValue = value;
if (previous != null) {
final double totalDelta = this.firstValue - previous.getLastValue();
final long totalMillis = Duration.between(previous.getLastDate(), this.firstDate).toMillis();
final double deltaPerMilli = totalDelta / totalMillis;
final long deltaBeginMillis = Duration.between(this.id.aligned, this.firstDate).toMillis();
final double deltaBeginValue = deltaPerMilli * deltaBeginMillis;
this.beginValue = this.firstValue - deltaBeginValue;
final long deltaEndMillis = Duration.between(this.id.aligned, this.firstDate).toMillis();
final double deltaEndValue = deltaPerMilli * deltaEndMillis;
previous.endValue = previous.lastValue + deltaEndValue;
}
}
@Getter
@ToString
@Embeddable
@EqualsAndHashCode
@NoArgsConstructor
@RequiredArgsConstructor
public static class Id implements Serializable {
@NonNull
@ManyToOne(optional = false)
private Period period;
@NonNull
@Column(nullable = false, updatable = false, columnDefinition = "CHAR(1)")
private Unit unit;
@NonNull
@Column(nullable = false, updatable = false)
private ZonedDateTime aligned;
}
}

View File

@ -0,0 +1,86 @@
package de.ph87.data.series.period.consumption;
import de.ph87.data.series.period.consumption.slice.Slice;
import de.ph87.data.series.period.consumption.slice.SliceService;
import de.ph87.data.series.period.consumption.unit.Unit;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@CrossOrigin
@RestController
@RequiredArgsConstructor
@RequestMapping("Consumption")
public class ConsumptionController {
private static final int MAX_COUNT = 1500;
private final SliceService sliceService;
@NonNull
@GetMapping("{seriesId}/{unitName}/last/{count}")
public List<List<Number>> latest(@PathVariable final long seriesId, @PathVariable final String unitName, @PathVariable final int count) {
return offset(seriesId, unitName, count, 0);
}
@NonNull
@GetMapping("{seriesId}/{unitName}/last/{count}/{offset}")
public List<List<Number>> offset(@PathVariable final long seriesId, @PathVariable final String unitName, @PathVariable final int count, @PathVariable final int offset) {
if (count <= 0) {
log.error("'count' must at least be 1");
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
if (count > MAX_COUNT) {
log.error("'count' must at most be {}", MAX_COUNT);
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
final Unit unit = Unit.valueOf(unitName);
final ZonedDateTime end = unit.plus(unit.align(ZonedDateTime.now()), -offset);
final ZonedDateTime begin = unit.plus(end, -(count - 1));
return between(seriesId, unit, begin, end);
}
@NonNull
@GetMapping("{seriesId}/{unitName}/between/{beginEpochSeconds}/{endEpochSeconds}")
public List<List<Number>> between(@PathVariable final long seriesId, @PathVariable final String unitName, @PathVariable final long beginEpochSeconds, @PathVariable final long endEpochSeconds) {
return between(seriesId, Unit.valueOf(unitName), ZDT(beginEpochSeconds), ZDT(endEpochSeconds));
}
@NonNull
private List<List<Number>> between(final long seriesId, @NonNull final Unit unit, @NonNull final ZonedDateTime begin, @NonNull final ZonedDateTime end) {
final long estimatedCount = unit.estimateCount(begin, end);
log.debug("estimatedCount: {}", estimatedCount);
if (estimatedCount > MAX_COUNT) {
log.error("'estimatedCount' must at most be {} but is {}", MAX_COUNT, estimatedCount);
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
return sliceService.slice(seriesId, unit, begin, end)
.stream()
.map(this::map)
.toList();
}
@NonNull
private List<Number> map(@NonNull final Slice slice) {
final ArrayList<Number> numbers = new ArrayList<>();
numbers.add(slice.begin.toEpochSecond());
numbers.add(Double.isNaN(slice.getDelta()) ? null : slice.getDelta());
return numbers;
}
@NonNull
public static ZonedDateTime ZDT(final long epochSeconds) {
return ZonedDateTime.ofInstant(Instant.ofEpochSecond(epochSeconds), ZoneId.systemDefault());
}
}

View File

@ -0,0 +1,27 @@
package de.ph87.data.series.period.consumption;
import de.ph87.data.series.Series;
import de.ph87.data.series.period.Period;
import de.ph87.data.series.period.consumption.unit.Unit;
import lombok.NonNull;
import org.springframework.data.repository.CrudRepository;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Optional;
public interface ConsumptionRepository extends CrudRepository<Consumption, Consumption.Id> {
Optional<Consumption> findByIdPeriodAndIdUnitAndIdAligned(@NonNull Period period, @NonNull Unit unit, @NonNull ZonedDateTime aligned);
Optional<Consumption> findFirstByIdPeriodAndIdUnitAndIdAlignedBeforeOrderByIdAlignedDesc(@NonNull Period period, @NonNull Unit unit, @NonNull ZonedDateTime aligned);
Optional<Consumption> findFirstByIdPeriodAndIdUnitAndIdAlignedLessThanOrderByIdAlignedDesc(@NonNull Period period, @NonNull Unit unit, @NonNull ZonedDateTime begin);
Optional<Consumption> findFirstByIdPeriodAndIdUnitAndIdAlignedGreaterThanOrderByIdAlignedAsc(@NonNull Period period, @NonNull Unit unit, @NonNull ZonedDateTime begin);
List<Consumption> findAllByIdPeriodAndIdUnitAndIdAlignedGreaterThanEqualAndIdAlignedLessThanEqualOrderByIdAlignedAsc(@NonNull Period period, @NonNull Unit unit, @NonNull ZonedDateTime begin, @NonNull ZonedDateTime end);
void deleteAllByIdPeriodSeries(Series oil);
}

View File

@ -0,0 +1,40 @@
package de.ph87.data.series.period.consumption;
import de.ph87.data.series.measure.MeasureEvent;
import de.ph87.data.series.period.Period;
import de.ph87.data.series.period.consumption.unit.Unit;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.ZonedDateTime;
import java.util.Optional;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class ConsumptionService {
private final ConsumptionRepository consumptionRepository;
public void onMeasureEvent(@NonNull final Period period, @NonNull final MeasureEvent event) {
for (final Unit unit : Unit.values()) {
final ZonedDateTime aligned = unit.align(event.getDate());
final Optional<Consumption> existingOptional = consumptionRepository.findByIdPeriodAndIdUnitAndIdAligned(period, unit, aligned);
if (existingOptional.isPresent()) {
final Consumption existing = existingOptional.get();
existing.setLastDate(event.getDate());
existing.setLastValue(event.getValue());
log.debug("Existing Consumption updated: {}", existing);
} else {
final Consumption previous = consumptionRepository.findFirstByIdPeriodAndIdUnitAndIdAlignedBeforeOrderByIdAlignedDesc(period, unit, aligned).orElse(null);
final Consumption created = consumptionRepository.save(new Consumption(period, unit, aligned, event.getDate(), event.getValue(), previous));
log.debug("New Consumption created: created={}, previous={}", created, previous);
}
}
}
}

View File

@ -0,0 +1,71 @@
package de.ph87.data.series.period.consumption.slice;
import de.ph87.data.series.period.consumption.Consumption;
import de.ph87.data.series.period.consumption.unit.Unit;
import lombok.Getter;
import lombok.NonNull;
import lombok.Setter;
import lombok.ToString;
import java.time.Duration;
import java.time.ZonedDateTime;
@Getter
@ToString
public class Slice {
@NonNull
public final ZonedDateTime begin;
@NonNull
public final ZonedDateTime end;
@Setter
private double delta;
public Slice(@NonNull final Consumption consumption) {
this(consumption.getFirstDate(), consumption.getLastDate(), consumption.getId().getPeriod().getSeries().getMode().getDelta(consumption.getFirstValue(), consumption.getLastValue()));
}
public Slice(@NonNull final Consumption first, @NonNull final Consumption second) {
this(first.getLastDate(), second.getFirstDate(), first.getId().getPeriod().getSeries().getMode().getDelta(first.getLastValue(), second.getFirstValue()));
}
private Slice(@NonNull final ZonedDateTime begin, @NonNull final ZonedDateTime end, final double delta) {
this.begin = begin;
this.end = end;
this.delta = delta;
}
public Slice(@NonNull final ZonedDateTime begin, @NonNull final Unit unit) {
this.begin = begin;
this.end = unit.plus(begin, 1);
this.delta = Double.NaN;
}
public double getDeltaPerMilli() {
return delta / Duration.between(begin, end).toMillis();
}
public void merge(@NonNull final Slice other) {
if (!this.begin.equals(other.begin)) {
throw new RuntimeException();
}
if (!this.end.equals(other.end)) {
throw new RuntimeException();
}
add(other.delta);
}
public void add(final double addDelta) {
if (Double.isNaN(addDelta)) {
return;
}
if (Double.isNaN(this.delta)) {
this.delta = addDelta;
} else {
this.delta += addDelta;
}
}
}

View File

@ -0,0 +1,191 @@
package de.ph87.data.series.period.consumption.slice;
import de.ph87.data.series.period.Period;
import de.ph87.data.series.period.PeriodRepository;
import de.ph87.data.series.period.consumption.Consumption;
import de.ph87.data.series.period.consumption.ConsumptionRepository;
import de.ph87.data.series.period.consumption.unit.Unit;
import jakarta.annotation.Nullable;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class SliceService {
private final ConsumptionRepository consumptionRepository;
private final PeriodRepository periodRepository;
@NonNull
public List<Slice> slice(final long seriesId, @NonNull final Unit unit, @NonNull final ZonedDateTime begin, @NonNull final ZonedDateTime end) {
log.debug("slice:");
log.debug(" seriesId: {}", seriesId);
log.debug(" unit: {}", unit);
final ZonedDateTime wantedFirst = unit.align(begin);
final ZonedDateTime wantedLast = unit.align(end);
log.debug(" wantedFirst: {}", DL(unit, wantedFirst));
log.debug(" wantedLast: {}", DL(unit, wantedLast));
final List<Period> periods = periodRepository.findAllBySeriesIdAndLastDateGreaterThanAndFirstDateLessThan(seriesId, wantedFirst, unit.plus(wantedLast, 1));
log.debug(" periods: {}", periods.size());
final List<Slice> totalSlices = new ArrayList<>();
for (final Period period : periods) {
log.debug(" {}", period);
log.debug(" firstDate: {}", DL(unit, period.getFirstDate()));
log.debug(" lastDate: {}", DL(unit, period.getLastDate()));
final List<Slice> periodSlices = reslicePeriod(unit, period, wantedFirst, wantedLast);
print("periodSlices", periodSlices, 3);
periodSlices.forEach(merge -> merge(totalSlices, merge));
print("totalSlices", totalSlices, 3);
}
return totalSlices;
}
private static void merge(@NonNull final List<Slice> resultList, @NonNull final Slice merge) {
for (int resultIndex = 0; resultIndex < resultList.size(); resultIndex++) {
final Slice result = resultList.get(resultIndex);
final long compare = result.begin.toEpochSecond() - merge.begin.toEpochSecond();
if (compare == 0) {
result.merge(merge);
return;
}
if (compare > 0) {
resultList.add(resultIndex, merge);
return;
}
}
resultList.add(merge);
}
@NonNull
private List<Slice> reslicePeriod(@NonNull final Unit unit, @NonNull final Period period, @NonNull final ZonedDateTime firstBegin, @NonNull final ZonedDateTime lastBegin) {
final ZonedDateTime lastEnd = unit.plus(lastBegin, 1);
final List<Slice> sourceList = slicePeriod(period, unit, firstBegin, lastBegin);
final List<Slice> resultList = new ArrayList<>();
ZonedDateTime date = firstBegin;
Slice result = firstResult(firstBegin, unit, resultList);
Slice source = nextSourceIfNeeded(date, null, sourceList);
while (date.isBefore(lastEnd)) {
source = nextSourceIfNeeded(date, source, sourceList);
result = nextResultIfNeeded(date, result, resultList, unit, lastEnd);
if (source == null) {
date = result.end;
} else {
final ZonedDateTime earliestEnd = source.end.isBefore(result.end) ? source.end : result.end;
if (hasOverlap(source, earliestEnd, date)) {
final ZonedDateTime latestBegin = source.begin.isAfter(date) ? source.begin : date;
final long millis = Duration.between(latestBegin, earliestEnd).toMillis();
result.add(millis * source.getDeltaPerMilli());
}
date = earliestEnd;
}
}
return resultList;
}
private static boolean hasOverlap(@NonNull final Slice source, @NonNull final ZonedDateTime earliestEnd, @NonNull final ZonedDateTime date) {
return source.begin.isBefore(earliestEnd) && source.end.isAfter(date);
}
@NonNull
private static Slice firstResult(@NonNull final ZonedDateTime begin, @NonNull final Unit unit, @NonNull final List<Slice> resultList) {
final Slice newWanted = new Slice(begin, unit);
resultList.add(newWanted);
return newWanted;
}
@Nullable
private static Slice nextSourceIfNeeded(@NonNull final ZonedDateTime date, @Nullable final Slice source, @NonNull final List<Slice> sourceList) {
if (source == null || !date.isBefore(source.end)) {
return sourceList.isEmpty() ? null : sourceList.remove(0);
}
return source;
}
@NonNull
private static Slice nextResultIfNeeded(@NonNull final ZonedDateTime date, @NonNull final Slice result, @NonNull final List<Slice> resultList, @NonNull final Unit unit, @NonNull final ZonedDateTime lastEnd) {
if (date.isBefore(lastEnd) && !date.isBefore(result.end)) {
final Slice slice = new Slice(result.end, unit);
resultList.add(slice);
return slice;
}
return result;
}
@NonNull
private List<Slice> slicePeriod(@NonNull final Period period, @NonNull final Unit unit, @NonNull final ZonedDateTime wantedFirst, @NonNull final ZonedDateTime wantedLast) {
final Optional<Consumption> firstOptional = consumptionRepository.findFirstByIdPeriodAndIdUnitAndIdAlignedLessThanOrderByIdAlignedDesc(period, unit, wantedFirst)
.or(() -> consumptionRepository.findFirstByIdPeriodAndIdUnitAndIdAlignedGreaterThanOrderByIdAlignedAsc(period, unit, wantedFirst));
if (firstOptional.isEmpty()) {
log.error(" No first Consumption for Period: {}", period);
return Collections.emptyList();
}
final Optional<Consumption> lastOptional = consumptionRepository.findFirstByIdPeriodAndIdUnitAndIdAlignedGreaterThanOrderByIdAlignedAsc(period, unit, wantedLast)
.or(() -> consumptionRepository.findFirstByIdPeriodAndIdUnitAndIdAlignedLessThanOrderByIdAlignedDesc(period, unit, wantedLast));
if (lastOptional.isEmpty()) {
log.error(" No last Consumption for Period: {}", period);
return Collections.emptyList();
}
final Consumption firstToFetch = firstOptional.get();
final Consumption lastToFetch = lastOptional.get();
final List<Consumption> consumptions = consumptionRepository.findAllByIdPeriodAndIdUnitAndIdAlignedGreaterThanEqualAndIdAlignedLessThanEqualOrderByIdAlignedAsc(period, unit, firstToFetch.getId().getAligned(), lastToFetch.getId().getAligned());
print("consumptions", consumptions, 3);
Consumption last = null;
final List<Slice> slices = new ArrayList<>();
for (final Consumption consumption : consumptions) {
if (last != null) {
slices.add(new Slice(last, consumption));
}
if (!consumption.getFirstDate().equals(consumption.getLastDate())) {
slices.add(new Slice(consumption));
}
last = consumption;
}
print("sourceSlices", slices, 3);
return slices;
}
@NonNull
@SuppressWarnings("SuspiciousDateFormat")
public static String DL(@NonNull final Unit unit, @NonNull final ZonedDateTime date) {
return switch (unit) {
case Quarterhour, Hour -> date.toLocalDateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
case Day -> date.toLocalDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
case Week -> date.toLocalDate().format(DateTimeFormatter.ofPattern("YYYY-'KW'w"));
case Month -> date.toLocalDate().format(DateTimeFormatter.ofPattern("yyyy-LLLL"));
case Year -> date.toLocalDate().format(DateTimeFormatter.ofPattern("yyyy"));
};
}
@SuppressWarnings("SameParameterValue")
private static void print(@NonNull final String name, @NonNull final List<?> list, final int indent) {
final String indentStr = " ".repeat(indent * 2);
log.debug("{}{}: {}", indentStr, name, list.size());
list.forEach(item -> log.debug("{}{}", indentStr + " ", item.toString()));
}
}

View File

@ -0,0 +1,54 @@
package de.ph87.data.series.period.consumption.unit;
import lombok.NonNull;
import java.time.Duration;
import java.time.Period;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.function.BiFunction;
import java.util.function.Function;
public enum Unit {
Quarterhour("q", t -> t.truncatedTo(ChronoUnit.MINUTES).minusMinutes(t.getMinute() % 15), (t, count) -> t.plusMinutes(15 * count), (a, b) -> Duration.between(a, b).toMinutes() / 15),
Hour("h", t -> t.truncatedTo(ChronoUnit.HOURS), ZonedDateTime::plusHours, (a, b) -> Duration.between(a, b).toHours()),
Day("d", t -> t.truncatedTo(ChronoUnit.DAYS), ZonedDateTime::plusDays, (a, b) -> Duration.between(a, b).toDays()),
Week("w", t -> t.truncatedTo(ChronoUnit.DAYS).minusDays(t.getDayOfWeek().getValue() - 1), ZonedDateTime::plusWeeks, (a, b) -> Duration.between(a, b).toDays() / 7),
Month("m", t -> t.truncatedTo(ChronoUnit.DAYS).minusDays(t.getDayOfMonth() - 1), ZonedDateTime::plusMonths, (a, b) -> Period.between(a.toLocalDate(), b.toLocalDate()).toTotalMonths()),
Year("y", t -> t.truncatedTo(ChronoUnit.DAYS).minusDays(t.getDayOfYear() - 1), ZonedDateTime::plusYears, (a, b) -> (long) Period.between(a.toLocalDate(), b.toLocalDate()).getYears()),
;
@NonNull
public final String code;
@NonNull
private final Function<ZonedDateTime, ZonedDateTime> align;
@NonNull
private final BiFunction<ZonedDateTime, Long, ZonedDateTime> offset;
@NonNull
private final BiFunction<ZonedDateTime, ZonedDateTime, Long> estimateCount;
Unit(@NonNull final String code, @NonNull final Function<ZonedDateTime, ZonedDateTime> align, @NonNull final BiFunction<ZonedDateTime, Long, ZonedDateTime> offset, @NonNull final BiFunction<ZonedDateTime, ZonedDateTime, Long> estimateCount) {
this.code = code;
this.align = align;
this.offset = offset;
this.estimateCount = estimateCount;
}
@NonNull
public ZonedDateTime align(@NonNull final ZonedDateTime date) {
return align.apply(date);
}
@NonNull
public ZonedDateTime plus(@NonNull final ZonedDateTime date, final long count) {
return offset.apply(date, count);
}
public long estimateCount(@NonNull final ZonedDateTime begin, @NonNull final ZonedDateTime end) {
return estimateCount.apply(begin, end);
}
}

View File

@ -0,0 +1,27 @@
package de.ph87.data.series.period.consumption.unit;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Arrays;
@Converter(autoApply = true)
public class UnitJpaConverter implements AttributeConverter<Unit, String> {
@Override
public String convertToDatabaseColumn(final Unit unit) {
if (unit == null) {
return null;
}
return unit.code;
}
@Override
public Unit convertToEntityAttribute(final String code) {
if (code == null) {
return null;
}
return Arrays.stream(Unit.values()).filter(u -> u.code.equals(code)).findFirst().orElse(null);
}
}

View File

@ -0,0 +1,10 @@
logging.level.root=WARN
logging.level.de.ph87=INFO
#-
spring.jpa.hibernate.naming.implicit-strategy=org.hibernate.boot.model.naming.ImplicitNamingStrategyComponentPathImpl
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
#-
spring.jackson.serialization.indent_output=true
#-
spring.main.banner-mode=off