From af709852ac53516079aa520659c212712739b035 Mon Sep 17 00:00:00 2001 From: litoral05 Date: Fri, 22 May 2026 17:23:36 +0100 Subject: [PATCH] Add accumulated analytics with radiation integration --- .../historian/HistorianAccumulatedBucket.java | 11 ++ .../historian/HistorianController.java | 26 ++++ .../backend/historian/HistorianService.java | 139 ++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 src/main/java/com/litoralregas/backend/historian/HistorianAccumulatedBucket.java diff --git a/src/main/java/com/litoralregas/backend/historian/HistorianAccumulatedBucket.java b/src/main/java/com/litoralregas/backend/historian/HistorianAccumulatedBucket.java new file mode 100644 index 0000000..a22d7b3 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/historian/HistorianAccumulatedBucket.java @@ -0,0 +1,11 @@ +package com.litoralregas.backend.historian; + +import java.time.Instant; + +public record HistorianAccumulatedBucket( + String label, + Instant from, + Instant to, + Double total, + String unit +) {} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/historian/HistorianController.java b/src/main/java/com/litoralregas/backend/historian/HistorianController.java index 63e4343..caef22d 100644 --- a/src/main/java/com/litoralregas/backend/historian/HistorianController.java +++ b/src/main/java/com/litoralregas/backend/historian/HistorianController.java @@ -46,4 +46,30 @@ public class HistorianController { ) { return historianService.getDashboardHistory(keys, from, to); } + + @GetMapping("/api/historian/accumulated") + public List getAccumulated( + @RequestParam String key, + @RequestParam String range + ) { + Instant to = Instant.now(); + Instant from; + String bucket = "day"; + + switch (range) { + case "30d" -> from = to.minus(java.time.Duration.ofDays(30)); + case "month" -> { + java.time.ZonedDateTime now = java.time.ZonedDateTime.now(java.time.ZoneId.of("Europe/Lisbon")); + from = now.withDayOfMonth(1).truncatedTo(java.time.temporal.ChronoUnit.DAYS).toInstant(); + } + case "year" -> { + java.time.ZonedDateTime now = java.time.ZonedDateTime.now(java.time.ZoneId.of("Europe/Lisbon")); + from = now.withDayOfYear(1).truncatedTo(java.time.temporal.ChronoUnit.DAYS).toInstant(); + bucket = "month"; + } + default -> from = to.minus(java.time.Duration.ofDays(7)); + } + + return historianService.getAccumulated(key, from, to, bucket); + } } \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/historian/HistorianService.java b/src/main/java/com/litoralregas/backend/historian/HistorianService.java index f1f3cca..90f7b1a 100644 --- a/src/main/java/com/litoralregas/backend/historian/HistorianService.java +++ b/src/main/java/com/litoralregas/backend/historian/HistorianService.java @@ -183,4 +183,143 @@ public class HistorianService { } } } + + @Transactional(readOnly = true) + public List getAccumulated( + String keyName, + Instant from, + Instant to, + String bucket + ) { + List samples = historianSampleRepository + .findByKeyNameAndSampledAtBetweenOrderBySampledAtAsc(keyName, from, to); + + if (samples.isEmpty()) return List.of(); + + Map> grouped = samples.stream() + .collect(java.util.stream.Collectors.groupingBy( + sample -> bucketStart(sample.getSampledAt(), bucket), + LinkedHashMap::new, + java.util.stream.Collectors.toList() + )); + + return grouped.entrySet().stream() + .map(entry -> { + Instant bucketFrom = entry.getKey(); + Instant bucketTo = bucketEnd(bucketFrom, bucket); + + List bucketSamples = entry.getValue(); + + String unit; + double total; + + if (isRadiationKey(keyName)) { + total = integrateWhPerSquareMeter(bucketSamples); + unit = "Wh/m²"; + } else { + total = bucketSamples.stream() + .map(HistorianSample::getNumericValue) + .filter(java.util.Objects::nonNull) + .mapToDouble(Double::doubleValue) + .sum(); + + unit = bucketSamples.stream() + .map(HistorianSample::getUnit) + .filter(java.util.Objects::nonNull) + .findFirst() + .orElse(null); + } + + return new HistorianAccumulatedBucket( + bucketLabel(bucketFrom, bucket), + bucketFrom, + bucketTo, + total, + unit + ); + }) + .toList(); + } + + private boolean isRadiationKey(String keyName) { + String normalized = keyName.toLowerCase(); + + return normalized.contains("radiacao") + || normalized.contains("radiação") + || normalized.contains("radiation"); + } + + private double integrateWhPerSquareMeter(List samples) { + if (samples.size() < 2) return 0.0; + + double total = 0.0; + + for (int i = 1; i < samples.size(); i++) { + HistorianSample previous = samples.get(i - 1); + HistorianSample current = samples.get(i); + + if (previous.getNumericValue() == null || current.getNumericValue() == null) { + continue; + } + + double previousValue = previous.getNumericValue(); + double currentValue = current.getNumericValue(); + + double averageWm2 = (previousValue + currentValue) / 2.0; + + double elapsedHours = + java.time.Duration.between( + previous.getSampledAt(), + current.getSampledAt() + ).toMillis() / 1000.0 / 60.0 / 60.0; + + if (elapsedHours <= 0) { + continue; + } + + total += averageWm2 * elapsedHours; + } + + return total; + } + + private Instant bucketStart(Instant instant, String bucket) { + java.time.ZonedDateTime date = instant.atZone(java.time.ZoneId.of("Europe/Lisbon")); + + return switch (bucket) { + case "month" -> date + .withDayOfMonth(1) + .truncatedTo(java.time.temporal.ChronoUnit.DAYS) + .toInstant(); + + case "year" -> date + .withDayOfYear(1) + .truncatedTo(java.time.temporal.ChronoUnit.DAYS) + .toInstant(); + + default -> date + .truncatedTo(java.time.temporal.ChronoUnit.DAYS) + .toInstant(); + }; + } + + private Instant bucketEnd(Instant bucketFrom, String bucket) { + java.time.ZonedDateTime date = bucketFrom.atZone(java.time.ZoneId.of("Europe/Lisbon")); + + return switch (bucket) { + case "month" -> date.plusMonths(1).toInstant(); + case "year" -> date.plusYears(1).toInstant(); + default -> date.plusDays(1).toInstant(); + }; + } + + private String bucketLabel(Instant bucketFrom, String bucket) { + java.time.ZonedDateTime date = bucketFrom.atZone(java.time.ZoneId.of("Europe/Lisbon")); + + return switch (bucket) { + case "month" -> date.format(java.time.format.DateTimeFormatter.ofPattern("MM/yyyy")); + case "year" -> String.valueOf(date.getYear()); + default -> date.format(java.time.format.DateTimeFormatter.ofPattern("dd/MM")); + }; + } } \ No newline at end of file