From 571ed54c296d638905970c38cfa54414cbd81031 Mon Sep 17 00:00:00 2001 From: litoral05 Date: Tue, 19 May 2026 17:14:24 +0100 Subject: [PATCH] Optimize acquisition polling with register blocks --- .../backend/acquisition/AcquisitionBlock.java | 10 ++ .../acquisition/AcquisitionBlockPlanner.java | 89 +++++++++++ .../acquisition/AcquisitionController.java | 37 +++++ .../backend/acquisition/AcquisitionPlan.java | 16 ++ .../acquisition/AcquisitionPlanBuilder.java | 112 ++++++++++++++ .../acquisition/AcquisitionPollResult.java | 14 ++ .../AcquisitionPollingService.java | 52 +++++++ .../acquisition/BlockPollingService.java | 138 ++++++++++++++++++ .../acquisition/TelemetryController.java | 13 +- 9 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/litoralregas/backend/acquisition/AcquisitionBlock.java create mode 100644 src/main/java/com/litoralregas/backend/acquisition/AcquisitionBlockPlanner.java create mode 100644 src/main/java/com/litoralregas/backend/acquisition/AcquisitionController.java create mode 100644 src/main/java/com/litoralregas/backend/acquisition/AcquisitionPlan.java create mode 100644 src/main/java/com/litoralregas/backend/acquisition/AcquisitionPlanBuilder.java create mode 100644 src/main/java/com/litoralregas/backend/acquisition/AcquisitionPollResult.java create mode 100644 src/main/java/com/litoralregas/backend/acquisition/AcquisitionPollingService.java create mode 100644 src/main/java/com/litoralregas/backend/acquisition/BlockPollingService.java diff --git a/src/main/java/com/litoralregas/backend/acquisition/AcquisitionBlock.java b/src/main/java/com/litoralregas/backend/acquisition/AcquisitionBlock.java new file mode 100644 index 0000000..2984ee4 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/acquisition/AcquisitionBlock.java @@ -0,0 +1,10 @@ +package com.litoralregas.backend.acquisition; + +import java.util.List; + +public record AcquisitionBlock( + Integer startingAddress, + Integer quantity, + List sensorIds +) { +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/acquisition/AcquisitionBlockPlanner.java b/src/main/java/com/litoralregas/backend/acquisition/AcquisitionBlockPlanner.java new file mode 100644 index 0000000..f910a97 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/acquisition/AcquisitionBlockPlanner.java @@ -0,0 +1,89 @@ +package com.litoralregas.backend.acquisition; + +import com.litoralregas.backend.sensor.SensorDefinition; +import com.litoralregas.backend.sensor.SensorDefinitionRepository; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +@Service +public class AcquisitionBlockPlanner { + + private static final int BLOCK_SIZE = 40; + + private final AcquisitionPlanBuilder acquisitionPlanBuilder; + private final SensorDefinitionRepository sensorDefinitionRepository; + + public AcquisitionBlockPlanner( + AcquisitionPlanBuilder acquisitionPlanBuilder, + SensorDefinitionRepository sensorDefinitionRepository + ) { + this.acquisitionPlanBuilder = acquisitionPlanBuilder; + this.sensorDefinitionRepository = sensorDefinitionRepository; + } + + public List buildBlocks() { + AcquisitionPlan plan = acquisitionPlanBuilder.buildPlan(); + + List sensors = sensorDefinitionRepository.findAllById(plan.sensorIds()) + .stream() + .sorted(Comparator.comparing(SensorDefinition::getModbusAddress)) + .toList(); + + List blocks = new ArrayList<>(); + + List currentSensorIds = new ArrayList<>(); + + Integer currentBlockStart = null; + Integer currentBlockEnd = null; + + for (SensorDefinition sensor : sensors) { + Integer address = sensor.getModbusAddress(); + + if (address == null || address < 0) { + continue; + } + + if (currentBlockStart == null) { + currentBlockStart = address; + currentBlockEnd = address; + currentSensorIds.add(sensor.getId()); + continue; + } + + boolean fitsCurrentBlock = + address <= currentBlockStart + BLOCK_SIZE + && (address - currentBlockStart) <= BLOCK_SIZE; + + if (fitsCurrentBlock) { + currentBlockEnd = address; + currentSensorIds.add(sensor.getId()); + } else { + blocks.add(new AcquisitionBlock( + currentBlockStart, + (currentBlockEnd - currentBlockStart) + 1, + List.copyOf(currentSensorIds) + )); + + currentSensorIds.clear(); + + currentBlockStart = address; + currentBlockEnd = address; + + currentSensorIds.add(sensor.getId()); + } + } + + if (currentBlockStart != null && !currentSensorIds.isEmpty()) { + blocks.add(new AcquisitionBlock( + currentBlockStart, + (currentBlockEnd - currentBlockStart) + 1, + List.copyOf(currentSensorIds) + )); + } + + return blocks; + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/acquisition/AcquisitionController.java b/src/main/java/com/litoralregas/backend/acquisition/AcquisitionController.java new file mode 100644 index 0000000..1128384 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/acquisition/AcquisitionController.java @@ -0,0 +1,37 @@ +package com.litoralregas.backend.acquisition; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class AcquisitionController { + + private final AcquisitionPlanBuilder acquisitionPlanBuilder; + private final AcquisitionPollingService acquisitionPollingService; + private final BlockPollingService blockPollingService; + public AcquisitionController( + AcquisitionPlanBuilder acquisitionPlanBuilder, + AcquisitionPollingService acquisitionPollingService, + BlockPollingService blockPollingService + ) { + this.acquisitionPlanBuilder = acquisitionPlanBuilder; + this.acquisitionPollingService = acquisitionPollingService; + this.blockPollingService = blockPollingService; + } + + @GetMapping("/api/acquisition/plan") + public AcquisitionPlan getPlan() { + return acquisitionPlanBuilder.buildPlan(); + } + + @PostMapping("/api/acquisition/poll-once") + public AcquisitionPollResult pollOnce() { + return acquisitionPollingService.pollOnce(); + } + + @PostMapping("/api/acquisition/poll-once-blocks") + public AcquisitionPollResult pollOnceByBlocks() { + return blockPollingService.pollOnceByBlocks(); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/acquisition/AcquisitionPlan.java b/src/main/java/com/litoralregas/backend/acquisition/AcquisitionPlan.java new file mode 100644 index 0000000..eb92bbd --- /dev/null +++ b/src/main/java/com/litoralregas/backend/acquisition/AcquisitionPlan.java @@ -0,0 +1,16 @@ +package com.litoralregas.backend.acquisition; + +import java.time.Instant; +import java.util.List; + +public record AcquisitionPlan( + Integer climateGreenhouseCount, + Integer irrigationControllerCount, + Boolean climateEnabled, + Boolean irrigationEnabled, + Boolean lightingEnabled, + Integer sensorCount, + List sensorIds, + Instant timestamp +) { +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/acquisition/AcquisitionPlanBuilder.java b/src/main/java/com/litoralregas/backend/acquisition/AcquisitionPlanBuilder.java new file mode 100644 index 0000000..f4ea34e --- /dev/null +++ b/src/main/java/com/litoralregas/backend/acquisition/AcquisitionPlanBuilder.java @@ -0,0 +1,112 @@ +package com.litoralregas.backend.acquisition; + +import com.litoralregas.backend.sensor.SensorDefinition; +import com.litoralregas.backend.sensor.SensorDefinitionRepository; +import com.litoralregas.backend.sensor.SensorSourceType; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; + +@Service +public class AcquisitionPlanBuilder { + + private final ControllerCapabilitiesReader capabilitiesReader; + private final SensorDefinitionRepository sensorDefinitionRepository; + + public AcquisitionPlanBuilder( + ControllerCapabilitiesReader capabilitiesReader, + SensorDefinitionRepository sensorDefinitionRepository + ) { + this.capabilitiesReader = capabilitiesReader; + this.sensorDefinitionRepository = sensorDefinitionRepository; + } + + public AcquisitionPlan buildPlan() { + ControllerCapabilities capabilities = capabilitiesReader.readCapabilities(); + + List sensorIds = sensorDefinitionRepository.findByEnabledTrueOrderByNameAsc() + .stream() + .filter(sensor -> sensor.getSourceType() == SensorSourceType.MODBUS) + .filter(sensor -> shouldPollSensor(sensor, capabilities)) + .map(SensorDefinition::getId) + .sorted() + .toList(); + + return new AcquisitionPlan( + capabilities.climateGreenhouseCount(), + capabilities.irrigationControllerCount(), + capabilities.climateEnabled(), + capabilities.irrigationEnabled(), + capabilities.lightingEnabled(), + sensorIds.size(), + sensorIds, + Instant.now() + ); + } + + private boolean shouldPollSensor( + SensorDefinition sensor, + ControllerCapabilities capabilities + ) { + String category = sensor.getCategory(); + + if ("CLIMATE".equals(category)) { + return capabilities.climateEnabled() + && belongsToEnabledClimateRange(sensor, capabilities.climateGreenhouseCount()); + } + + if ("IRRIGATION".equals(category)) { + return capabilities.irrigationEnabled() + && belongsToEnabledIrrigationRange(sensor, capabilities.irrigationControllerCount()); + } + + if ("LIGHTING".equals(category)) { + return capabilities.lightingEnabled(); + } + + return false; + } + + private boolean belongsToEnabledClimateRange( + SensorDefinition sensor, + Integer greenhouseCount + ) { + Integer address = sensor.getModbusAddress(); + + if (address == null || address < 0) { + return false; + } + + if (address >= 10 && address <= 22) { + return true; + } + + if (address < 100 || address > 899) { + return false; + } + + int greenhouseNumber = ((address - 100) / 40) + 1; + + return greenhouseNumber >= 1 && greenhouseNumber <= greenhouseCount; + } + + private boolean belongsToEnabledIrrigationRange( + SensorDefinition sensor, + Integer controllerCount + ) { + Integer address = sensor.getModbusAddress(); + + if (address == null || address < 0) { + return false; + } + + if (address < 1000 || address > 1599) { + return false; + } + + int controllerNumber = ((address - 1000) / 100) + 1; + + return controllerNumber >= 1 && controllerNumber <= controllerCount; + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/acquisition/AcquisitionPollResult.java b/src/main/java/com/litoralregas/backend/acquisition/AcquisitionPollResult.java new file mode 100644 index 0000000..984e18d --- /dev/null +++ b/src/main/java/com/litoralregas/backend/acquisition/AcquisitionPollResult.java @@ -0,0 +1,14 @@ +package com.litoralregas.backend.acquisition; + +import java.time.Instant; +import java.util.List; + +public record AcquisitionPollResult( + Integer plannedSensorCount, + Integer successfulReads, + Integer failedReads, + List failedSensorIds, + Instant startedAt, + Instant finishedAt +) { +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/acquisition/AcquisitionPollingService.java b/src/main/java/com/litoralregas/backend/acquisition/AcquisitionPollingService.java new file mode 100644 index 0000000..91a8175 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/acquisition/AcquisitionPollingService.java @@ -0,0 +1,52 @@ +package com.litoralregas.backend.acquisition; + +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Service +public class AcquisitionPollingService { + + private final AcquisitionPlanBuilder acquisitionPlanBuilder; + private final SensorTelemetryReader sensorTelemetryReader; + + public AcquisitionPollingService( + AcquisitionPlanBuilder acquisitionPlanBuilder, + SensorTelemetryReader sensorTelemetryReader + ) { + this.acquisitionPlanBuilder = acquisitionPlanBuilder; + this.sensorTelemetryReader = sensorTelemetryReader; + } + + public AcquisitionPollResult pollOnce() { + Instant startedAt = Instant.now(); + + AcquisitionPlan plan = acquisitionPlanBuilder.buildPlan(); + + int successfulReads = 0; + + List failedSensorIds = new ArrayList<>(); + + for (Integer sensorId : plan.sensorIds()) { + try { + sensorTelemetryReader.readSensor(sensorId); + successfulReads++; + } catch (Exception exception) { + failedSensorIds.add(sensorId); + } + } + + Instant finishedAt = Instant.now(); + + return new AcquisitionPollResult( + plan.sensorCount(), + successfulReads, + failedSensorIds.size(), + failedSensorIds, + startedAt, + finishedAt + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/acquisition/BlockPollingService.java b/src/main/java/com/litoralregas/backend/acquisition/BlockPollingService.java new file mode 100644 index 0000000..bcc02f3 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/acquisition/BlockPollingService.java @@ -0,0 +1,138 @@ +package com.litoralregas.backend.acquisition; + +import com.litoralregas.backend.modbus.LrModbusClient; +import com.litoralregas.backend.modbus.ModbusReadResult; +import com.litoralregas.backend.modbus.ModbusUnit; +import com.litoralregas.backend.sensor.SensorDefinition; +import com.litoralregas.backend.sensor.SensorDefinitionRepository; +import com.litoralregas.backend.sensor.SensorValueType; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class BlockPollingService { + + private final AcquisitionBlockPlanner blockPlanner; + private final SensorDefinitionRepository sensorDefinitionRepository; + private final LrModbusClient modbusClient; + private final TelemetryCache telemetryCache; + + public BlockPollingService( + AcquisitionBlockPlanner blockPlanner, + SensorDefinitionRepository sensorDefinitionRepository, + LrModbusClient modbusClient, + TelemetryCache telemetryCache + ) { + this.blockPlanner = blockPlanner; + this.sensorDefinitionRepository = sensorDefinitionRepository; + this.modbusClient = modbusClient; + this.telemetryCache = telemetryCache; + } + + public AcquisitionPollResult pollOnceByBlocks() { + Instant startedAt = Instant.now(); + + List blocks = blockPlanner.buildBlocks(); + + List allSensorIds = blocks.stream() + .flatMap(block -> block.sensorIds().stream()) + .toList(); + + Map sensorsById = sensorDefinitionRepository.findAllById(allSensorIds) + .stream() + .collect(Collectors.toMap(SensorDefinition::getId, Function.identity())); + + int successfulReads = 0; + List failedSensorIds = new ArrayList<>(); + + for (AcquisitionBlock block : blocks) { + try { + ModbusReadResult result = modbusClient.readInputRegisters( + ModbusUnit.PC, + block.startingAddress(), + block.quantity() + ); + + for (Integer sensorId : block.sensorIds()) { + SensorDefinition sensor = sensorsById.get(sensorId); + + if (sensor == null) { + failedSensorIds.add(sensorId); + continue; + } + + try { + Integer rawValue = rawValueForSensor(sensor, block, result); + Object value = convertValue(sensor, rawValue); + + telemetryCache.put(new TelemetrySnapshot( + sensor.getId(), + sensor.getName(), + sensor.getModbusAddress(), + sensor.getBitOffset(), + rawValue, + value, + sensor.getUnit(), + Instant.now() + )); + + successfulReads++; + } catch (Exception exception) { + failedSensorIds.add(sensorId); + } + } + } catch (Exception exception) { + failedSensorIds.addAll(block.sensorIds()); + } + } + + Instant finishedAt = Instant.now(); + + return new AcquisitionPollResult( + allSensorIds.size(), + successfulReads, + failedSensorIds.size(), + failedSensorIds, + startedAt, + finishedAt + ); + } + + private Integer rawValueForSensor( + SensorDefinition sensor, + AcquisitionBlock block, + ModbusReadResult result + ) { + int offset = sensor.getModbusAddress() - block.startingAddress(); + + if (offset < 0 || offset >= result.values().size()) { + throw new IllegalStateException("Sensor address outside acquisition block."); + } + + return result.values().get(offset); + } + + private Object convertValue(SensorDefinition sensor, Integer rawValue) { + if (sensor.getValueType() == SensorValueType.BOOLEAN) { + Integer bitOffset = sensor.getBitOffset(); + + if (bitOffset == null) { + throw new IllegalStateException("BOOLEAN sensor requires bitOffset."); + } + + return ((rawValue >> bitOffset) & 1) == 1; + } + + if (sensor.getValueType() == SensorValueType.DECIMAL) { + return rawValue / Math.pow(10, sensor.getDecimalPlaces()); + } + + return rawValue; + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/acquisition/TelemetryController.java b/src/main/java/com/litoralregas/backend/acquisition/TelemetryController.java index 25dd10c..a978701 100644 --- a/src/main/java/com/litoralregas/backend/acquisition/TelemetryController.java +++ b/src/main/java/com/litoralregas/backend/acquisition/TelemetryController.java @@ -3,18 +3,29 @@ package com.litoralregas.backend.acquisition; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; +import java.util.Collection; @RestController public class TelemetryController { private final SensorTelemetryReader sensorTelemetryReader; + private final TelemetryCache telemetryCache; - public TelemetryController(SensorTelemetryReader sensorTelemetryReader) { + public TelemetryController( + SensorTelemetryReader sensorTelemetryReader, + TelemetryCache telemetryCache + ) { this.sensorTelemetryReader = sensorTelemetryReader; + this.telemetryCache = telemetryCache; } @GetMapping("/api/telemetry/sensors/{sensorId}") public TelemetrySnapshot readSensor(@PathVariable Integer sensorId) { return sensorTelemetryReader.readSensor(sensorId); } + + @GetMapping("/api/telemetry/latest") + public Collection getLatest() { + return telemetryCache.getAll(); + } } \ No newline at end of file