Mqtt, Series, Period, Consumption, ReSlice, Photovoltaic, Oil
This commit is contained in:
commit
ac4b1283df
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/target/
|
||||||
|
/.idea/
|
||||||
|
/*.iws
|
||||||
|
/*.iml
|
||||||
|
/*.ipr
|
||||||
|
/*.db
|
||||||
8
application.properties
Normal file
8
application.properties
Normal 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
55
pom.xml
Normal 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>
|
||||||
13
src/main/java/de/ph87/data/Backend.java
Normal file
13
src/main/java/de/ph87/data/Backend.java
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
90
src/main/java/de/ph87/data/demo/DemoService.java
Normal file
90
src/main/java/de/ph87/data/demo/DemoService.java
Normal 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
44
src/main/java/de/ph87/data/mqtt/MqttConfig.java
Normal file
44
src/main/java/de/ph87/data/mqtt/MqttConfig.java
Normal 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
22
src/main/java/de/ph87/data/mqtt/MqttEvent.java
Normal file
22
src/main/java/de/ph87/data/mqtt/MqttEvent.java
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
85
src/main/java/de/ph87/data/mqtt/MqttService.java
Normal file
85
src/main/java/de/ph87/data/mqtt/MqttService.java
Normal 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) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
16
src/main/java/de/ph87/data/oil/OilController.java
Normal file
16
src/main/java/de/ph87/data/oil/OilController.java
Normal 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";
|
||||||
|
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
44
src/main/java/de/ph87/data/series/Series.java
Normal file
44
src/main/java/de/ph87/data/series/Series.java
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
23
src/main/java/de/ph87/data/series/SeriesMode.java
Normal file
23
src/main/java/de/ph87/data/series/SeriesMode.java
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
13
src/main/java/de/ph87/data/series/SeriesRepository.java
Normal file
13
src/main/java/de/ph87/data/series/SeriesRepository.java
Normal 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);
|
||||||
|
|
||||||
|
}
|
||||||
43
src/main/java/de/ph87/data/series/SeriesService.java
Normal file
43
src/main/java/de/ph87/data/series/SeriesService.java
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
26
src/main/java/de/ph87/data/series/measure/MeasureEvent.java
Normal file
26
src/main/java/de/ph87/data/series/measure/MeasureEvent.java
Normal 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;
|
||||||
|
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
59
src/main/java/de/ph87/data/series/period/Period.java
Normal file
59
src/main/java/de/ph87/data/series/period/Period.java
Normal 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
85
src/main/java/de/ph87/data/series/period/PeriodService.java
Normal file
85
src/main/java/de/ph87/data/series/period/PeriodService.java
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
10
src/main/resources/application.properties
Normal file
10
src/main/resources/application.properties
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user