commit ac4b1283df9ff5ecef194b0ab3de29905a5d1934 Author: Patrick Haßel Date: Tue Oct 8 14:24:48 2024 +0200 Mqtt, Series, Period, Consumption, ReSlice, Photovoltaic, Oil diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..235390f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target/ +/.idea/ +/*.iws +/*.iml +/*.ipr +/*.db diff --git a/application.properties b/application.properties new file mode 100644 index 0000000..ddc3400 --- /dev/null +++ b/application.properties @@ -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 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..05c9cd9 --- /dev/null +++ b/pom.xml @@ -0,0 +1,55 @@ + + + 4.0.0 + + de.ph87 + Data + 0.1.0-SNAPSHOT + + + 21 + 21 + UTF-8 + + + + org.springframework.boot + spring-boot-starter-parent + 3.3.4 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-websocket + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.projectlombok + lombok + + + com.h2database + h2 + + + org.postgresql + postgresql + + + org.eclipse.paho + org.eclipse.paho.client.mqttv3 + 1.2.5 + + + + \ No newline at end of file diff --git a/src/main/java/de/ph87/data/Backend.java b/src/main/java/de/ph87/data/Backend.java new file mode 100644 index 0000000..ad19ca1 --- /dev/null +++ b/src/main/java/de/ph87/data/Backend.java @@ -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); + } + +} \ No newline at end of file diff --git a/src/main/java/de/ph87/data/demo/DemoService.java b/src/main/java/de/ph87/data/demo/DemoService.java new file mode 100644 index 0000000..fdc8f5f --- /dev/null +++ b/src/main/java/de/ph87/data/demo/DemoService.java @@ -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)); + } + +} diff --git a/src/main/java/de/ph87/data/mqtt/MqttConfig.java b/src/main/java/de/ph87/data/mqtt/MqttConfig.java new file mode 100644 index 0000000..7663234 --- /dev/null +++ b/src/main/java/de/ph87/data/mqtt/MqttConfig.java @@ -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(); + } + +} diff --git a/src/main/java/de/ph87/data/mqtt/MqttEvent.java b/src/main/java/de/ph87/data/mqtt/MqttEvent.java new file mode 100644 index 0000000..5b3b770 --- /dev/null +++ b/src/main/java/de/ph87/data/mqtt/MqttEvent.java @@ -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; + } + +} diff --git a/src/main/java/de/ph87/data/mqtt/MqttService.java b/src/main/java/de/ph87/data/mqtt/MqttService.java new file mode 100644 index 0000000..b2b4555 --- /dev/null +++ b/src/main/java/de/ph87/data/mqtt/MqttService.java @@ -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) { + + } + + } + +} diff --git a/src/main/java/de/ph87/data/oil/OilController.java b/src/main/java/de/ph87/data/oil/OilController.java new file mode 100644 index 0000000..dea8803 --- /dev/null +++ b/src/main/java/de/ph87/data/oil/OilController.java @@ -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"; + +} diff --git a/src/main/java/de/ph87/data/photovoltaic/PhotovoltaicMqttReceiver.java b/src/main/java/de/ph87/data/photovoltaic/PhotovoltaicMqttReceiver.java new file mode 100644 index 0000000..6eabf35 --- /dev/null +++ b/src/main/java/de/ph87/data/photovoltaic/PhotovoltaicMqttReceiver.java @@ -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("^(?\\S+) (?\\d+) (?\\d+(:?\\.\\d+)?)(?\\S+) (?\\d+(:?\\.\\d+)?)(?\\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(); + }; + } + +} diff --git a/src/main/java/de/ph87/data/series/Series.java b/src/main/java/de/ph87/data/series/Series.java new file mode 100644 index 0000000..6fe1695 --- /dev/null +++ b/src/main/java/de/ph87/data/series/Series.java @@ -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 aliases = new HashSet<>(); + + public Series(@NonNull final String name, @NonNull final SeriesMode mode) { + this.name = name; + this.mode = mode; + } + +} diff --git a/src/main/java/de/ph87/data/series/SeriesMode.java b/src/main/java/de/ph87/data/series/SeriesMode.java new file mode 100644 index 0000000..11f955a --- /dev/null +++ b/src/main/java/de/ph87/data/series/SeriesMode.java @@ -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 delta; + + SeriesMode(@NonNull final BiFunction delta) { + this.delta = delta; + } + + public double getDelta(final double first, final double second) { + return delta.apply(first, second); + } + +} diff --git a/src/main/java/de/ph87/data/series/SeriesRepository.java b/src/main/java/de/ph87/data/series/SeriesRepository.java new file mode 100644 index 0000000..c4e2fec --- /dev/null +++ b/src/main/java/de/ph87/data/series/SeriesRepository.java @@ -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 { + + @NonNull + Optional findByNameOrAliasesContains(@NonNull String name, @NonNull String alias); + +} diff --git a/src/main/java/de/ph87/data/series/SeriesService.java b/src/main/java/de/ph87/data/series/SeriesService.java new file mode 100644 index 0000000..3158bfd --- /dev/null +++ b/src/main/java/de/ph87/data/series/SeriesService.java @@ -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 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()); + } + } + +} diff --git a/src/main/java/de/ph87/data/series/measure/MeasureEvent.java b/src/main/java/de/ph87/data/series/measure/MeasureEvent.java new file mode 100644 index 0000000..a4aa0e4 --- /dev/null +++ b/src/main/java/de/ph87/data/series/measure/MeasureEvent.java @@ -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; + +} diff --git a/src/main/java/de/ph87/data/series/measure/MeasureEventTooOld.java b/src/main/java/de/ph87/data/series/measure/MeasureEventTooOld.java new file mode 100644 index 0000000..42d5b1c --- /dev/null +++ b/src/main/java/de/ph87/data/series/measure/MeasureEventTooOld.java @@ -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)); + } + +} diff --git a/src/main/java/de/ph87/data/series/period/Period.java b/src/main/java/de/ph87/data/series/period/Period.java new file mode 100644 index 0000000..34fa386 --- /dev/null +++ b/src/main/java/de/ph87/data/series/period/Period.java @@ -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(); + } + +} diff --git a/src/main/java/de/ph87/data/series/period/PeriodRepository.java b/src/main/java/de/ph87/data/series/period/PeriodRepository.java new file mode 100644 index 0000000..ccb8d20 --- /dev/null +++ b/src/main/java/de/ph87/data/series/period/PeriodRepository.java @@ -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 { + + List findAllBySeriesIdAndLastDateGreaterThanAndFirstDateLessThan(long seriesId, @NonNull ZonedDateTime wantedEnd, @NonNull ZonedDateTime wantedBegin); + + void deleteAllBySeries(Series oil); + +} diff --git a/src/main/java/de/ph87/data/series/period/PeriodService.java b/src/main/java/de/ph87/data/series/period/PeriodService.java new file mode 100644 index 0000000..4d0c8c1 --- /dev/null +++ b/src/main/java/de/ph87/data/series/period/PeriodService.java @@ -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)); + } + } + +} diff --git a/src/main/java/de/ph87/data/series/period/consumption/Consumption.java b/src/main/java/de/ph87/data/series/period/consumption/Consumption.java new file mode 100644 index 0000000..79baeca --- /dev/null +++ b/src/main/java/de/ph87/data/series/period/consumption/Consumption.java @@ -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; + + } + +} diff --git a/src/main/java/de/ph87/data/series/period/consumption/ConsumptionController.java b/src/main/java/de/ph87/data/series/period/consumption/ConsumptionController.java new file mode 100644 index 0000000..9f5ddd9 --- /dev/null +++ b/src/main/java/de/ph87/data/series/period/consumption/ConsumptionController.java @@ -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> 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> 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> 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> 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 map(@NonNull final Slice slice) { + final ArrayList 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()); + } + +} diff --git a/src/main/java/de/ph87/data/series/period/consumption/ConsumptionRepository.java b/src/main/java/de/ph87/data/series/period/consumption/ConsumptionRepository.java new file mode 100644 index 0000000..45313b2 --- /dev/null +++ b/src/main/java/de/ph87/data/series/period/consumption/ConsumptionRepository.java @@ -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 { + + Optional findByIdPeriodAndIdUnitAndIdAligned(@NonNull Period period, @NonNull Unit unit, @NonNull ZonedDateTime aligned); + + Optional findFirstByIdPeriodAndIdUnitAndIdAlignedBeforeOrderByIdAlignedDesc(@NonNull Period period, @NonNull Unit unit, @NonNull ZonedDateTime aligned); + + Optional findFirstByIdPeriodAndIdUnitAndIdAlignedLessThanOrderByIdAlignedDesc(@NonNull Period period, @NonNull Unit unit, @NonNull ZonedDateTime begin); + + Optional findFirstByIdPeriodAndIdUnitAndIdAlignedGreaterThanOrderByIdAlignedAsc(@NonNull Period period, @NonNull Unit unit, @NonNull ZonedDateTime begin); + + List findAllByIdPeriodAndIdUnitAndIdAlignedGreaterThanEqualAndIdAlignedLessThanEqualOrderByIdAlignedAsc(@NonNull Period period, @NonNull Unit unit, @NonNull ZonedDateTime begin, @NonNull ZonedDateTime end); + + void deleteAllByIdPeriodSeries(Series oil); + +} diff --git a/src/main/java/de/ph87/data/series/period/consumption/ConsumptionService.java b/src/main/java/de/ph87/data/series/period/consumption/ConsumptionService.java new file mode 100644 index 0000000..281429a --- /dev/null +++ b/src/main/java/de/ph87/data/series/period/consumption/ConsumptionService.java @@ -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 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); + } + } + } + +} diff --git a/src/main/java/de/ph87/data/series/period/consumption/slice/Slice.java b/src/main/java/de/ph87/data/series/period/consumption/slice/Slice.java new file mode 100644 index 0000000..b97edbb --- /dev/null +++ b/src/main/java/de/ph87/data/series/period/consumption/slice/Slice.java @@ -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; + } + } + +} diff --git a/src/main/java/de/ph87/data/series/period/consumption/slice/SliceService.java b/src/main/java/de/ph87/data/series/period/consumption/slice/SliceService.java new file mode 100644 index 0000000..b99c516 --- /dev/null +++ b/src/main/java/de/ph87/data/series/period/consumption/slice/SliceService.java @@ -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(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 periods = periodRepository.findAllBySeriesIdAndLastDateGreaterThanAndFirstDateLessThan(seriesId, wantedFirst, unit.plus(wantedLast, 1)); + log.debug(" periods: {}", periods.size()); + + final List 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 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 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 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 sourceList = slicePeriod(period, unit, firstBegin, lastBegin); + final List 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 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 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 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 slicePeriod(@NonNull final Period period, @NonNull final Unit unit, @NonNull final ZonedDateTime wantedFirst, @NonNull final ZonedDateTime wantedLast) { + final Optional 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 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 consumptions = consumptionRepository.findAllByIdPeriodAndIdUnitAndIdAlignedGreaterThanEqualAndIdAlignedLessThanEqualOrderByIdAlignedAsc(period, unit, firstToFetch.getId().getAligned(), lastToFetch.getId().getAligned()); + + print("consumptions", consumptions, 3); + Consumption last = null; + final List 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())); + } + +} diff --git a/src/main/java/de/ph87/data/series/period/consumption/unit/Unit.java b/src/main/java/de/ph87/data/series/period/consumption/unit/Unit.java new file mode 100644 index 0000000..b0340b0 --- /dev/null +++ b/src/main/java/de/ph87/data/series/period/consumption/unit/Unit.java @@ -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 align; + + @NonNull + private final BiFunction offset; + + @NonNull + private final BiFunction estimateCount; + + Unit(@NonNull final String code, @NonNull final Function align, @NonNull final BiFunction offset, @NonNull final BiFunction 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); + } + +} diff --git a/src/main/java/de/ph87/data/series/period/consumption/unit/UnitJpaConverter.java b/src/main/java/de/ph87/data/series/period/consumption/unit/UnitJpaConverter.java new file mode 100644 index 0000000..78dbfc0 --- /dev/null +++ b/src/main/java/de/ph87/data/series/period/consumption/unit/UnitJpaConverter.java @@ -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 { + + @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); + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..f9fffb6 --- /dev/null +++ b/src/main/resources/application.properties @@ -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