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