From 5ab8a96e44d00c6b730d3fc8d58a9a8d22b0e778 Mon Sep 17 00:00:00 2001 From: benrejebmoh Date: Thu, 9 Jul 2026 15:25:13 +0200 Subject: [PATCH 1/2] POC: customise NAD elements (display TM, displays in bold, change VL color, change line/twt color...) --- .../sld/server/NetworkAreaDiagramService.java | 384 +++++++++++------- .../NadMeasurementValidityLabelProvider.java | 153 +++++++ .../NadMeasurementValidityStyleProvider.java | 195 +++++++++ src/main/resources/config/application.yaml | 1 + ...dMeasurementValidityLabelProviderTest.java | 225 ++++++++++ ...dMeasurementValidityStyleProviderTest.java | 282 +++++++++++++ .../resources/nad-measurement-validity.css | 61 +++ 7 files changed, 1162 insertions(+), 139 deletions(-) create mode 100644 src/main/java/com/powsybl/sld/server/nad/NadMeasurementValidityLabelProvider.java create mode 100644 src/main/java/com/powsybl/sld/server/nad/NadMeasurementValidityStyleProvider.java create mode 100644 src/test/java/com/powsybl/sld/server/nad/NadMeasurementValidityLabelProviderTest.java create mode 100644 src/test/java/com/powsybl/sld/server/nad/NadMeasurementValidityStyleProviderTest.java create mode 100644 src/test/resources/nad-measurement-validity.css diff --git a/src/main/java/com/powsybl/sld/server/NetworkAreaDiagramService.java b/src/main/java/com/powsybl/sld/server/NetworkAreaDiagramService.java index 6fde7524..2b0c30aa 100644 --- a/src/main/java/com/powsybl/sld/server/NetworkAreaDiagramService.java +++ b/src/main/java/com/powsybl/sld/server/NetworkAreaDiagramService.java @@ -34,6 +34,8 @@ import com.powsybl.sld.server.entities.nad.NadVoltageLevelConfiguredPositionEntity; import com.powsybl.sld.server.entities.nad.NadVoltageLevelPositionEntity; import com.powsybl.sld.server.error.DiagramBusinessException; +import com.powsybl.sld.server.nad.NadMeasurementValidityLabelProvider; +import com.powsybl.sld.server.nad.NadMeasurementValidityStyleProvider; import com.powsybl.sld.server.repository.NadConfigRepository; import com.powsybl.sld.server.repository.NadVoltageLevelConfiguredPositionRepository; import com.powsybl.sld.server.utils.*; @@ -65,9 +67,13 @@ @ComponentScan(basePackageClasses = {NetworkStoreService.class}) @Service class NetworkAreaDiagramService { + @Value("${diagram-server.nad.max-voltage-levels}") private int maxVoltageLevels; + @Value("${diagram-server.nad.display-measurements:false}") + private boolean displayMeasurementsWithNad; + private static final int DEFAULT_SCALING_FACTOR = 450000; private static final int MIN_SCALING_FACTOR = 50000; private static final int MAX_SCALING_FACTOR = 600000; @@ -91,14 +97,16 @@ class NetworkAreaDiagramService { private final ObjectMapper objectMapper; - NetworkAreaDiagramService(NetworkStoreService networkStoreService, - GeoDataService geoDataService, - FilterService filterService, - NetworkAreaExecutionService diagramExecutionService, - NadConfigRepository nadConfigRepository, - NadVoltageLevelConfiguredPositionRepository nadVoltageLevelConfiguredPositionRepository, - @Lazy NetworkAreaDiagramService networkAreaDiagramService, - ObjectMapper objectMapper) { + NetworkAreaDiagramService( + NetworkStoreService networkStoreService, + GeoDataService geoDataService, + FilterService filterService, + NetworkAreaExecutionService diagramExecutionService, + NadConfigRepository nadConfigRepository, + NadVoltageLevelConfiguredPositionRepository nadVoltageLevelConfiguredPositionRepository, + @Lazy NetworkAreaDiagramService networkAreaDiagramService, + ObjectMapper objectMapper + ) { this.networkStoreService = networkStoreService; this.geoDataService = geoDataService; this.filterService = filterService; @@ -111,19 +119,20 @@ class NetworkAreaDiagramService { @Transactional public UUID createNetworkAreaDiagramConfig(NadConfigInfos nadConfigInfos) { - return nadConfigRepository.save(nadConfigInfos.toEntity()).getId(); + return nadConfigRepository.save(nadConfigInfos.toEntity()) + .getId(); } @Transactional public List createNetworkAreaDiagramConfigs(List nadConfigs) { List configs = nadConfigs.stream() - .map(NadConfigInfos::toEntity) - .toList(); + .map(NadConfigInfos::toEntity) + .toList(); List savedConfigs = nadConfigRepository.saveAll(configs); return savedConfigs.stream() - .map(NadConfigEntity::getId) - .toList(); + .map(NadConfigEntity::getId) + .toList(); } @Transactional @@ -134,29 +143,33 @@ public void deleteNetworkAreaDiagramConfigs(List configUuids) { @Transactional public UUID duplicateNetworkAreaDiagramConfig(UUID originNadConfigUuid) { NadConfigEntity nadConfigEntity = nadConfigRepository.findById(originNadConfigUuid) - .orElseThrow(() -> - new ResponseStatusException(HttpStatus.NOT_FOUND, "Failed to duplicate NAD config: no configuration found for UUID " + originNadConfigUuid) - ); + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, + "Failed to duplicate NAD config: no configuration found for UUID " + originNadConfigUuid)); NadConfigEntity duplicateEntity = new NadConfigEntity(nadConfigEntity); duplicateEntity.setId(UUID.randomUUID()); // Assign new ID for the duplicate - return nadConfigRepository.save(duplicateEntity).getId(); + return nadConfigRepository.save(duplicateEntity) + .getId(); } @Transactional public void updateNetworkAreaDiagramConfig(UUID nadConfigUuid, NadConfigInfos nadConfigInfos) { NadConfigEntity entity = nadConfigRepository.findWithVoltageLevelIdsById(nadConfigUuid) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Failed to update NAD config: no configuration found for UUID " + nadConfigUuid)); + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, + "Failed to update NAD config: no configuration found for UUID " + nadConfigUuid)); updateNadConfig(entity, nadConfigInfos); } private void updateNadConfig(@NonNull NadConfigEntity entity, @NonNull NadConfigInfos nadConfigInfos) { - Optional.ofNullable(nadConfigInfos.getVoltageLevelIds()).ifPresent(voltageLevels -> - entity.setVoltageLevelIds(new HashSet<>(voltageLevels)) - ); - Optional.ofNullable(nadConfigInfos.getScalingFactor()).ifPresent(entity::setScalingFactor); + Optional.ofNullable(nadConfigInfos.getVoltageLevelIds()) + .ifPresent(voltageLevels -> entity.setVoltageLevelIds(new HashSet<>(voltageLevels))); + Optional.ofNullable(nadConfigInfos.getScalingFactor()) + .ifPresent(entity::setScalingFactor); - if (nadConfigInfos.getPositions() != null && !nadConfigInfos.getPositions().isEmpty()) { + if (nadConfigInfos.getPositions() != null && !nadConfigInfos.getPositions() + .isEmpty()) { updatePositions(entity, nadConfigInfos); } } @@ -171,7 +184,8 @@ private void updatePositions(@NonNull NadConfigEntity entity, @NonNull NadConfig } for (NadVoltageLevelPositionInfos info : nadConfigInfos.getPositions()) { - if ((info.getId() == null || !uuidPositionsMap.containsKey(info.getId())) && info.getVoltageLevelId() == null) { + if ((info.getId() == null || !uuidPositionsMap.containsKey(info.getId())) + && info.getVoltageLevelId() == null) { throw new DiagramBusinessException(EQUIPMENT_NOT_FOUND, "Missing id or voltageLevelId"); } if (voltageLevelIdPositionsMap.containsKey(info.getVoltageLevelId())) { @@ -180,33 +194,44 @@ private void updatePositions(@NonNull NadConfigEntity entity, @NonNull NadConfig updateVoltageLevelPositions(uuidPositionsMap.get(info.getId()), info); } else { NadVoltageLevelPositionEntity newPosition = info.toEntity(); - entity.getPositions().add(newPosition); + entity.getPositions() + .add(newPosition); // We add the newly added position to the map to ensure we don't try to create another position with the same voltageLevelId voltageLevelIdPositionsMap.put(info.getVoltageLevelId(), newPosition); } } } - private void updateVoltageLevelPositions(@NonNull NadVoltageLevelPositionEntity entity, @NonNull NadVoltageLevelPositionInfos nadVoltageLevelPositionInfos) { - Optional.ofNullable(nadVoltageLevelPositionInfos.getVoltageLevelId()).ifPresent(entity::setVoltageLevelId); - Optional.ofNullable(nadVoltageLevelPositionInfos.getXPosition()).ifPresent(entity::setXPosition); - Optional.ofNullable(nadVoltageLevelPositionInfos.getYPosition()).ifPresent(entity::setYPosition); - Optional.ofNullable(nadVoltageLevelPositionInfos.getXLabelPosition()).ifPresent(entity::setXLabelPosition); - Optional.ofNullable(nadVoltageLevelPositionInfos.getYLabelPosition()).ifPresent(entity::setYLabelPosition); + private void updateVoltageLevelPositions( + @NonNull NadVoltageLevelPositionEntity entity, + @NonNull NadVoltageLevelPositionInfos nadVoltageLevelPositionInfos + ) { + Optional.ofNullable(nadVoltageLevelPositionInfos.getVoltageLevelId()) + .ifPresent(entity::setVoltageLevelId); + Optional.ofNullable(nadVoltageLevelPositionInfos.getXPosition()) + .ifPresent(entity::setXPosition); + Optional.ofNullable(nadVoltageLevelPositionInfos.getYPosition()) + .ifPresent(entity::setYPosition); + Optional.ofNullable(nadVoltageLevelPositionInfos.getXLabelPosition()) + .ifPresent(entity::setXLabelPosition); + Optional.ofNullable(nadVoltageLevelPositionInfos.getYLabelPosition()) + .ifPresent(entity::setYLabelPosition); } @Transactional(readOnly = true) public NadConfigInfos getNetworkAreaDiagramConfig(UUID nadConfigUuid) { - return nadConfigRepository.findWithVoltageLevelIdsById(nadConfigUuid).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, - "Failed to retrieve NAD configuration: no configuration found for UUID " + nadConfigUuid - )).toDto(); + return nadConfigRepository.findWithVoltageLevelIdsById(nadConfigUuid) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, + "Failed to retrieve NAD configuration: no configuration found for UUID " + nadConfigUuid)) + .toDto(); } private Set getVoltageLevelIdsFromFilter(UUID networkUuid, String variantId, UUID filterUuid) { List filterContent = filterService.exportFilter(networkUuid, variantId, filterUuid); return filterContent.stream() - .map(IdentifiableAttributes::getId) - .collect(Collectors.toSet()); + .map(IdentifiableAttributes::getId) + .collect(Collectors.toSet()); } @Transactional @@ -214,71 +239,101 @@ public void deleteNetworkAreaDiagramConfig(UUID nadConfigUuid) { nadConfigRepository.deleteById(nadConfigUuid); } - public CompletableFuture generateNetworkAreaDiagramSvgAsync(UUID networkUuid, String variantId, NadRequestInfos nadRequestInfos) { - return diagramExecutionService.supplyAsync(() -> self.generateNetworkAreaDiagramSvg(networkUuid, variantId, nadRequestInfos)); + public CompletableFuture generateNetworkAreaDiagramSvgAsync( + UUID networkUuid, + String variantId, + NadRequestInfos nadRequestInfos + ) { + return diagramExecutionService.supplyAsync(() -> self.generateNetworkAreaDiagramSvg( + networkUuid, + variantId, + nadRequestInfos)); } @Transactional(readOnly = true) public String generateNetworkAreaDiagramSvg(UUID networkUuid, String variantId, NadRequestInfos nadRequestInfos) { NadGenerationContext.NadGenerationContextBuilder nadGenerationContextBuilder = NadGenerationContext.builder() - .networkUuid(networkUuid) - .variantId(variantId) - .network(DiagramUtils.getNetwork(networkUuid, variantId, networkStoreService, PreloadingStrategy.COLLECTION)); + .networkUuid(networkUuid) + .variantId(variantId) + .network(DiagramUtils.getNetwork( + networkUuid, + variantId, + networkStoreService, + PreloadingStrategy.COLLECTION)); // Initial VLs - NadGenerationContext nadGenerationContext = initVoltageLevelsAndPositions(nadGenerationContextBuilder, nadRequestInfos); + NadGenerationContext nadGenerationContext = initVoltageLevelsAndPositions( + nadGenerationContextBuilder, + nadRequestInfos); // Modify the initial VLs // This order is important // Add VLs from filter if (nadRequestInfos.getFilterUuid() != null) { - nadGenerationContext.getVoltageLevelIds().addAll(getVoltageLevelIdsFromFilter(networkUuid, variantId, nadRequestInfos.getFilterUuid())); + nadGenerationContext.getVoltageLevelIds() + .addAll(getVoltageLevelIdsFromFilter(networkUuid, variantId, nadRequestInfos.getFilterUuid())); } // Add VLs from list - nadGenerationContext.getVoltageLevelIds().addAll(nadRequestInfos.getVoltageLevelIds()); + nadGenerationContext.getVoltageLevelIds() + .addAll(nadRequestInfos.getVoltageLevelIds()); // Remove VLs from list - nadGenerationContext.getVoltageLevelIds().removeAll(nadRequestInfos.getVoltageLevelToOmitIds()); + nadGenerationContext.getVoltageLevelIds() + .removeAll(nadRequestInfos.getVoltageLevelToOmitIds()); // Add VLs from expansion // Expansion takes priority over remove - if (!nadRequestInfos.getVoltageLevelToExpandIds().isEmpty()) { - nadGenerationContext.getVoltageLevelIds().addAll(getExpandedVoltageLevelIds(nadRequestInfos.getVoltageLevelToExpandIds(), nadGenerationContext.getNetwork())); + if (!nadRequestInfos.getVoltageLevelToExpandIds() + .isEmpty()) { + nadGenerationContext.getVoltageLevelIds() + .addAll(getExpandedVoltageLevelIds( + nadRequestInfos.getVoltageLevelToExpandIds(), + nadGenerationContext.getNetwork())); } // Remove non existent VLs removeNonExistentVLs(nadGenerationContext); // Maximum number of VLs - int nbVoltageLevels = nadGenerationContext.getVoltageLevelIds().size(); + int nbVoltageLevels = nadGenerationContext.getVoltageLevelIds() + .size(); if (nbVoltageLevels > maxVoltageLevels) { - throw new DiagramBusinessException(MAX_VOLTAGE_LEVELS_DISPLAYED, "You need to reduce the number of voltage levels to be displayed in the network area diagram", Map.of("nbVoltageLevels", - nbVoltageLevels, "maxVoltageLevels", maxVoltageLevels)); + throw new DiagramBusinessException( + MAX_VOLTAGE_LEVELS_DISPLAYED, + "You need to reduce the number of voltage levels to be displayed in the network area diagram", + Map.of("nbVoltageLevels", nbVoltageLevels, "maxVoltageLevels", maxVoltageLevels)); } // Build Powsybl parameters List baseVoltagesConfigInfos = voltagesConfig.getBaseVoltagesConfigInfos(); - buildGraphicalParameters(nadGenerationContext, nadRequestInfos.getCurrentLimitViolationsInfos(), baseVoltagesConfigInfos, nadRequestInfos.getLanguage()); + buildGraphicalParameters( + nadGenerationContext, + nadRequestInfos.getCurrentLimitViolationsInfos(), + baseVoltagesConfigInfos, + nadRequestInfos.getLanguage()); return processSvgAndMetadata(drawSvgAndBuildMetadata(nadGenerationContext)); } private void removeNonExistentVLs(NadGenerationContext nadGenerationContext) { - nadGenerationContext.setVoltageLevelIds(nadGenerationContext.getVoltageLevelIds().stream() - .filter(vl -> nadGenerationContext.getNetwork().getVoltageLevel(vl) != null) - .collect(Collectors.toSet())); - nadGenerationContext.setVoltageLevelFilter( - VoltageLevelFilter.createVoltageLevelsFilter( + nadGenerationContext.setVoltageLevelIds(nadGenerationContext.getVoltageLevelIds() + .stream() + .filter(vl -> nadGenerationContext.getNetwork() + .getVoltageLevel(vl) != null) + .collect(Collectors.toSet())); + nadGenerationContext.setVoltageLevelFilter(VoltageLevelFilter.createVoltageLevelsFilter( nadGenerationContext.getNetwork(), - new ArrayList<>(nadGenerationContext.getVoltageLevelIds()) - ) - ); + new ArrayList<>(nadGenerationContext.getVoltageLevelIds()))); } - private NadGenerationContext initVoltageLevelsAndPositions(NadGenerationContext.NadGenerationContextBuilder nadGenerationContextBuilder, NadRequestInfos nadRequestInfos) { - if (!nadRequestInfos.getPositions().isEmpty()) { // Init from positions + private NadGenerationContext initVoltageLevelsAndPositions( + NadGenerationContext.NadGenerationContextBuilder nadGenerationContextBuilder, + NadRequestInfos nadRequestInfos + ) { + if (!nadRequestInfos.getPositions() + .isEmpty()) { // Init from positions nadGenerationContextBuilder.voltageLevelIds(new HashSet<>(nadRequestInfos.getVoltageLevelIds())); nadGenerationContextBuilder.positions(new ArrayList<>(nadRequestInfos.getPositions())); nadGenerationContextBuilder.nadPositionsGenerationMode(NadPositionsGenerationMode.AUTOMATIC); @@ -299,39 +354,50 @@ private NadGenerationContext initVoltageLevelsAndPositions(NadGenerationContext. } private void buildGraphicalParameters( - NadGenerationContext nadGenerationContext, List currentLimitViolationInfos, List baseVoltagesConfigInfos, String language) { - if (nadGenerationContext.getVoltageLevelIds().isEmpty()) { - throw new DiagramBusinessException(NO_VOLTAGE_LEVEL_FOUND, "No voltage level found for the NAD generation context"); + NadGenerationContext nadGenerationContext, + List currentLimitViolationInfos, + List baseVoltagesConfigInfos, + String language + ) { + if (nadGenerationContext.getVoltageLevelIds() + .isEmpty()) { + throw new DiagramBusinessException( + NO_VOLTAGE_LEVEL_FOUND, + "No voltage level found for the NAD generation context"); } - SvgParameters svgParameters = new SvgParameters() - .setUndefinedValueSymbol("—") + SvgParameters svgParameters = new SvgParameters().setUndefinedValueSymbol("—") .setSvgWidthAndHeightAdded(true) - .setVoltageLevelLegendsIncluded(false) - .setEdgeInfosIncluded(false) - .setCssLocation(SvgParameters.CssLocation.EXTERNAL_NO_IMPORT) + .setVoltageLevelLegendsIncluded(displayMeasurementsWithNad) + .setEdgeInfosIncluded(displayMeasurementsWithNad) + .setCssLocation(displayMeasurementsWithNad ? SvgParameters.CssLocation.INSERTED_IN_SVG + : SvgParameters.CssLocation.EXTERNAL_NO_IMPORT) .setLanguageTag(language); LayoutParameters layoutParameters = new LayoutParameters(); NadParameters nadParameters = new NadParameters(); nadParameters.setSvgParameters(svgParameters); nadParameters.setLayoutParameters(layoutParameters); - Map limitViolationStyles = DiagramUtils.createLimitViolationStyles(currentLimitViolationInfos, StyleProvider.LINE_OVERLOADED_CLASS); - nadParameters.setLabelProviderFactory(NadLabelProvider::new); + Map limitViolationStyles = DiagramUtils.createLimitViolationStyles( + currentLimitViolationInfos, + StyleProvider.LINE_OVERLOADED_CLASS); + nadParameters.setLabelProviderFactory( + displayMeasurementsWithNad ? NadMeasurementValidityLabelProvider::new : NadLabelProvider::new); baseVoltagesConfigInfos.forEach(vl -> vl.setProfile(DiagramConstants.BASE_VOLTAGES_DEFAULT_PROFILE)); BaseVoltagesConfig baseVoltagesConfig = new BaseVoltagesConfig(); baseVoltagesConfig.setBaseVoltages(baseVoltagesConfigInfos); baseVoltagesConfig.setDefaultProfile(DiagramConstants.BASE_VOLTAGES_DEFAULT_PROFILE); - nadParameters.setStyleProviderFactory(n -> new NadLimitStyleProvider( + nadParameters.setStyleProviderFactory(n -> displayMeasurementsWithNad ? new NadMeasurementValidityStyleProvider(nadGenerationContext.getNetwork(), + baseVoltagesConfig) : new NadLimitStyleProvider( nadGenerationContext.getNetwork(), baseVoltagesConfig, - limitViolationStyles - )); + limitViolationStyles)); // Set style provider factory either with geographical data or with the provided positions (if any) - if (nadGenerationContext.getNadPositionsGenerationMode() == NadPositionsGenerationMode.GEOGRAPHICAL_COORDINATES) { + if (nadGenerationContext.getNadPositionsGenerationMode() + == NadPositionsGenerationMode.GEOGRAPHICAL_COORDINATES) { nadParameters.setLayoutFactory(prepareGeographicalLayoutFactory(nadGenerationContext)); } else { nadParameters.setLayoutFactory(prepareFixedLayoutFactory(nadGenerationContext)); @@ -340,7 +406,10 @@ private void buildGraphicalParameters( nadGenerationContext.setNadParameters(nadParameters); } - private void initFromNadConfig(NadGenerationContext.NadGenerationContextBuilder nadGenerationContextBuilder, UUID nadConfigUuid) { + private void initFromNadConfig( + NadGenerationContext.NadGenerationContextBuilder nadGenerationContextBuilder, + UUID nadConfigUuid + ) { NadConfigInfos nadConfigInfos = getNetworkAreaDiagramConfig(nadConfigUuid); nadGenerationContextBuilder.voltageLevelIds(new HashSet<>(nadConfigInfos.getVoltageLevelIds())); nadGenerationContextBuilder.positions(new ArrayList<>(nadConfigInfos.getPositions())); @@ -348,13 +417,12 @@ private void initFromNadConfig(NadGenerationContext.NadGenerationContextBuilder } private void initFromConfiguredPositions(NadGenerationContext.NadGenerationContextBuilder nadGenerationContextBuilder) { - List nadVoltageLevelPositionInfos = nadVoltageLevelConfiguredPositionRepository.findAll(); + List nadVoltageLevelPositionInfos = + nadVoltageLevelConfiguredPositionRepository.findAll(); if (nadVoltageLevelPositionInfos.isEmpty()) { throw new DiagramBusinessException(NO_CONFIGURED_POSITION, "No configured positions found!"); } - nadGenerationContextBuilder.positions( - nadVoltageLevelPositionInfos - .stream() + nadGenerationContextBuilder.positions(nadVoltageLevelPositionInfos.stream() .map(NadVoltageLevelConfiguredPositionEntity::toDto) .toList()); } @@ -363,10 +431,12 @@ private LayoutFactory prepareGeographicalLayoutFactory(NadGenerationContext nadG // In order to draw half the lines that connect to the out of bound voltage levels, we have to know their coordinates. // To do so, we create a filter with a depth=1 that will include these out of bound voltage levels. - List extendedVoltageLevelFilter = VoltageLevelFilter.createVoltageLevelsDepthFilter( - nadGenerationContext.getNetwork(), - new ArrayList<>(nadGenerationContext.getVoltageLevelIds()), - 1).voltageLevels().stream().toList(); + List extendedVoltageLevelFilter = VoltageLevelFilter.createVoltageLevelsDepthFilter(nadGenerationContext.getNetwork(), + new ArrayList<>(nadGenerationContext.getVoltageLevelIds()), + 1) + .voltageLevels() + .stream() + .toList(); List extendedSubstations = extendedVoltageLevelFilter.stream() .map(VoltageLevel::getNullableSubstation) @@ -374,43 +444,51 @@ private LayoutFactory prepareGeographicalLayoutFactory(NadGenerationContext nadG .toList(); // Watch out : assignGeoDataCoordinates also modifies the network - Map substationGeoDataMap = assignGeoDataCoordinates(nadGenerationContext, extendedSubstations); + Map substationGeoDataMap = assignGeoDataCoordinates( + nadGenerationContext, + extendedSubstations); if (nadGenerationContext.getScalingFactor() == null || nadGenerationContext.getScalingFactor() <= 0) { // Let's calculate the scaling factor - List substations = nadGenerationContext.getVoltageLevelFilter().voltageLevels().stream() - .map(VoltageLevel::getNullableSubstation) - .filter(Objects::nonNull) - .map(Substation::getId) - .toList(); + List substations = nadGenerationContext.getVoltageLevelFilter() + .voltageLevels() + .stream() + .map(VoltageLevel::getNullableSubstation) + .filter(Objects::nonNull) + .map(Substation::getId) + .toList(); - List coordinatesForScaling = substationGeoDataMap.entrySet().stream() + List coordinatesForScaling = substationGeoDataMap.entrySet() + .stream() .filter(entry -> substations.contains(entry.getKey())) .map(Map.Entry::getValue) .toList(); nadGenerationContext.setScalingFactor(this.calculateScalingFactor(coordinatesForScaling)); } - return new GeographicalLayoutFactory(nadGenerationContext.getNetwork(), nadGenerationContext.getScalingFactor(), RADIUS_FACTOR, BasicForceLayout::new); + return new GeographicalLayoutFactory( + nadGenerationContext.getNetwork(), + nadGenerationContext.getScalingFactor(), + RADIUS_FACTOR, + BasicForceLayout::new); } private LayoutFactory prepareFixedLayoutFactory(NadGenerationContext nadGenerationContext) { Map positionsForFixedLayout = new HashMap<>(); Map textNodesPositionsForFixedLayout = new HashMap<>(); - nadGenerationContext.getPositions().forEach(info -> { - positionsForFixedLayout.put( - info.getVoltageLevelId(), - new Point(info.getXPosition(), info.getYPosition()) - ); - textNodesPositionsForFixedLayout.put( - info.getVoltageLevelId(), - new TextPosition( - new Point(info.getXLabelPosition(), info.getYLabelPosition()), - new Point(0, 0) // We do not display the edge connections - ) - ); - }); + nadGenerationContext.getPositions() + .forEach(info -> { + positionsForFixedLayout.put( + info.getVoltageLevelId(), + new Point(info.getXPosition(), info.getYPosition())); + textNodesPositionsForFixedLayout.put( + info.getVoltageLevelId(), + new TextPosition( + new Point(info.getXLabelPosition(), info.getYLabelPosition()), + new Point(0, 0) // We do not display the edge connections + )); + }); return new FixedLayoutFactory(positionsForFixedLayout, textNodesPositionsForFixedLayout, BasicForceLayout::new); } @@ -420,11 +498,10 @@ private String processSvgAndMetadata(SvgAndMetadata svgAndMetadata) { String svg = svgAndMetadata.getSvg(); String metadata = svgAndMetadata.getMetadata(); Object additionalMetadata = svgAndMetadata.getAdditionalMetadata(); - return objectMapper.writeValueAsString( - objectMapper.createObjectNode() - .put(SVG_TAG, svg) - .putRawValue(METADATA, new RawValue(metadata)) - .putPOJO(ADDITIONAL_METADATA, additionalMetadata)); + return objectMapper.writeValueAsString(objectMapper.createObjectNode() + .put(SVG_TAG, svg) + .putRawValue(METADATA, new RawValue(metadata)) + .putPOJO(ADDITIONAL_METADATA, additionalMetadata)); } catch (JsonProcessingException e) { throw new UncheckedIOException("Failed to parse JSON response", e); } @@ -478,9 +555,10 @@ private Set getExpandedVoltageLevelIds(@NonNull Set voltageLevel return voltageLevelIds; } return VoltageLevelFilter.createVoltageLevelsDepthFilter(network, new ArrayList<>(voltageLevelIds), 1) - .voltageLevels().stream() - .map(VoltageLevel::getId) - .collect(Collectors.toSet()); + .voltageLevels() + .stream() + .map(VoltageLevel::getId) + .collect(Collectors.toSet()); } private SvgAndMetadata drawSvgAndBuildMetadata(NadGenerationContext nadGenerationContext) { @@ -490,14 +568,14 @@ private SvgAndMetadata drawSvgAndBuildMetadata(NadGenerationContext nadGeneratio svgWriter, metadataWriter, nadGenerationContext.getNadParameters(), - nadGenerationContext.getVoltageLevelFilter() - ); + nadGenerationContext.getVoltageLevelFilter()); Map additionalMetadata = computeAdditionalMetadata(nadGenerationContext); return SvgAndMetadata.builder() .svg(svgWriter.toString()) .metadata(metadataWriter.toString()) - .additionalMetadata(additionalMetadata).build(); + .additionalMetadata(additionalMetadata) + .build(); } catch (IOException e) { throw new UncheckedIOException(e); } @@ -507,24 +585,34 @@ private SvgAndMetadata drawSvgAndBuildMetadata(NadGenerationContext nadGeneratio * Updates the network with the substation's positions in an extension and return the coordinates for further processing. * Note : nadGenerationContext.network is modified by reference */ - public Map assignGeoDataCoordinates(NadGenerationContext nadGenerationContext, List substationsToFetch) { + public Map assignGeoDataCoordinates( + NadGenerationContext nadGenerationContext, + List substationsToFetch + ) { String substationsGeoDataString = geoDataService.getSubstationsGraphics( nadGenerationContext.getNetworkUuid(), nadGenerationContext.getVariantId(), - substationsToFetch.stream().map(Substation::getId).toList() - ); - List substationsGeoData = ResourceUtils.fromStringToSubstationGeoData(substationsGeoDataString, new ObjectMapper()); + substationsToFetch.stream() + .map(Substation::getId) + .toList()); + List substationsGeoData = ResourceUtils.fromStringToSubstationGeoData(substationsGeoDataString, + new ObjectMapper()); Map substationGeoDataMap = substationsGeoData.stream() .collect(Collectors.toMap(SubstationGeoData::getId, SubstationGeoData::getCoordinate)); for (Substation substation : substationsToFetch) { - if (nadGenerationContext.getNetwork().getSubstation(substation.getId()).getExtension(SubstationPosition.class) == null) { + if (nadGenerationContext.getNetwork() + .getSubstation(substation.getId()) + .getExtension(SubstationPosition.class) == null) { com.powsybl.sld.server.dto.Coordinate coordinate = substationGeoDataMap.get(substation.getId()); if (coordinate != null) { - nadGenerationContext.getNetwork().getSubstation(substation.getId()) + nadGenerationContext.getNetwork() + .getSubstation(substation.getId()) .newExtension(SubstationPositionAdder.class) - .withCoordinate(new com.powsybl.iidm.network.extensions.Coordinate(coordinate.getLat(), coordinate.getLon())) + .withCoordinate(new com.powsybl.iidm.network.extensions.Coordinate( + coordinate.getLat(), + coordinate.getLon())) .add(); } } @@ -533,12 +621,17 @@ public Map assignGeoDataCoordinates(NadGenerationContext nad } private Map computeAdditionalMetadata(NadGenerationContext nadGenerationContext) { - List voltageLevelsInfos = nadGenerationContext.getVoltageLevelFilter().voltageLevels().stream() + List voltageLevelsInfos = nadGenerationContext.getVoltageLevelFilter() + .voltageLevels() + .stream() .map(VoltageLevelInfos::new) .toList(); Map metadata = new HashMap<>(); - metadata.put("nbVoltageLevels", nadGenerationContext.getVoltageLevelFilter().getNbVoltageLevels()); + metadata.put( + "nbVoltageLevels", + nadGenerationContext.getVoltageLevelFilter() + .getNbVoltageLevels()); metadata.put("voltageLevels", voltageLevelsInfos); metadata.put("scalingFactor", nadGenerationContext.getScalingFactor()); @@ -548,27 +641,38 @@ private Map computeAdditionalMetadata(NadGenerationContext nadGe @Transactional public void createNadPositionsConfigFromCsv(MultipartFile file) { if (!CsvFileValidator.hasCSVFormat(file)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid CSV format for NAD configured positions"); + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Invalid CSV format for NAD configured positions"); } List positions; try { positions = getPositionsFromCsv(file); if (positions.isEmpty()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No NAD configured positions found from the csv file"); + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "No NAD configured positions found from the csv file"); } } catch (IOException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The csv file is invalid for NAD configured positions", e); + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "The csv file is invalid for NAD configured positions", + e); } nadVoltageLevelConfiguredPositionRepository.deleteAll(); - nadVoltageLevelConfiguredPositionRepository.saveAll(positions.stream().map(NadVoltageLevelPositionInfos::toConfiguredPositionEntity).toList()); + nadVoltageLevelConfiguredPositionRepository.saveAll(positions.stream() + .map(NadVoltageLevelPositionInfos::toConfiguredPositionEntity) + .toList()); } private List parsePositions(CsvMapReader mapReader) throws IOException { String[] headers = CsvFileValidator.getHeaders(mapReader); if (headers.length == 0) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The csv file headers are invalid for NAD configured positions"); + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "The csv file headers are invalid for NAD configured positions"); } List nadVoltageLevelPositionInfos = new ArrayList<>(mapReader.getRowNumber()); Map row; @@ -579,21 +683,23 @@ private List parsePositions(CsvMapReader mapReader double xLabelPosition = Double.parseDouble(row.get(CsvFileValidator.X_LABEL_POSITION)); double yLabelPosition = Double.parseDouble(row.get(CsvFileValidator.Y_LABEL_POSITION)); NadVoltageLevelPositionInfos positionInfos = NadVoltageLevelPositionInfos.builder() - .voltageLevelId(id) - .xPosition(xPosition) - .yPosition(yPosition) - .xLabelPosition(xLabelPosition) - .yLabelPosition(yLabelPosition) - .build(); + .voltageLevelId(id) + .xPosition(xPosition) + .yPosition(yPosition) + .xLabelPosition(xLabelPosition) + .yLabelPosition(yLabelPosition) + .build(); nadVoltageLevelPositionInfos.add(positionInfos); } return nadVoltageLevelPositionInfos; } private List getPositionsFromCsv(MultipartFile file) throws IOException { - try (BOMInputStream bomInputStream = BOMInputStream.builder().setInputStream(file.getInputStream()).setByteOrderMarks(ByteOrderMark.UTF_8).get(); - BufferedReader fileReader = new BufferedReader(new InputStreamReader(bomInputStream, StandardCharsets.UTF_8)); - CsvMapReader mapReader = new CsvMapReader(fileReader, CsvFileValidator.CSV_PREFERENCE)) { + try (BOMInputStream bomInputStream = BOMInputStream.builder() + .setInputStream(file.getInputStream()) + .setByteOrderMarks(ByteOrderMark.UTF_8) + .get(); BufferedReader fileReader = new BufferedReader(new InputStreamReader(bomInputStream, StandardCharsets.UTF_8)); + CsvMapReader mapReader = new CsvMapReader(fileReader, CsvFileValidator.CSV_PREFERENCE)) { return parsePositions(mapReader); } } diff --git a/src/main/java/com/powsybl/sld/server/nad/NadMeasurementValidityLabelProvider.java b/src/main/java/com/powsybl/sld/server/nad/NadMeasurementValidityLabelProvider.java new file mode 100644 index 00000000..4cf471cd --- /dev/null +++ b/src/main/java/com/powsybl/sld/server/nad/NadMeasurementValidityLabelProvider.java @@ -0,0 +1,153 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package com.powsybl.sld.server.nad; + +import com.powsybl.commons.PowsyblException; +import com.powsybl.commons.extensions.Extendable; +import com.powsybl.iidm.network.Branch; +import com.powsybl.iidm.network.Bus; +import com.powsybl.iidm.network.BusbarSection; +import com.powsybl.iidm.network.Network; +import com.powsybl.iidm.network.Terminal; +import com.powsybl.iidm.network.ThreeWindingsTransformer; +import com.powsybl.iidm.network.VoltageLevel; +import com.powsybl.iidm.network.extensions.Measurement; +import com.powsybl.iidm.network.extensions.Measurements; +import com.powsybl.nad.model.BranchEdge; +import com.powsybl.nad.model.ThreeWtEdge; +import com.powsybl.nad.svg.EdgeInfo; +import com.powsybl.nad.svg.SvgParameters; +import com.powsybl.nad.svg.VoltageLevelLegend; +import com.powsybl.sld.server.NadLabelProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Reuses the SLD "measurement validity" idea for the NAD: replaces the transiting active/reactive power + * displayed on branches and three-winding transformers with their TM (telemeasurement) values, and the + * voltage displayed per electrical node with the TM voltage of one of its busbar sections. Every TM value + * is tagged with a dedicated info type so that {@link NadMeasurementValidityStyleProvider} can color it + * according to its validity. + */ +public class NadMeasurementValidityLabelProvider extends NadLabelProvider { + + private static final Logger LOGGER = LoggerFactory.getLogger(NadMeasurementValidityLabelProvider.class); + + public static final String MEASUREMENT_VALID = "MeasurementValid"; + public static final String MEASUREMENT_INVALID = "MeasurementInvalid"; + + public NadMeasurementValidityLabelProvider(Network network, SvgParameters svgParameters) { + super(network, svgParameters); + } + + @Override + public Optional getBranchEdgeInfo(String branchId, String branchType) { + Optional baseInfo = super.getBranchEdgeInfo(branchId, branchType); + if (baseInfo.isEmpty()) { + return baseInfo; + } + Branch branch = getNetwork().getBranch(branchId); + if (branch == null) { + return baseInfo; + } + return Optional.of(withMeasurementInfo(baseInfo.get(), branch)); + } + + @Override + public Optional getBranchEdgeInfo(String branchId, BranchEdge.Side side, String branchType) { + // Suppress the per-side calculated flow (e.g. reactive power) shown at each end of the branch: + // only the TM values from getBranchEdgeInfo(String, String) (the middle info) should be displayed. + return Optional.empty(); + } + + @Override + public Optional getThreeWindingTransformerEdgeInfo(String threeWindingTransformerId, ThreeWtEdge.Side side) { + Optional baseInfo = super.getThreeWindingTransformerEdgeInfo(threeWindingTransformerId, side); + if (baseInfo.isEmpty()) { + return baseInfo; + } + ThreeWindingsTransformer twt = getNetwork().getThreeWindingsTransformer(threeWindingTransformerId); + if (twt == null) { + return baseInfo; + } + return Optional.of(withMeasurementInfo(baseInfo.get(), twt)); + } + + @Override + public VoltageLevelLegend getVoltageLevelLegend(String voltageLevelId) { + VoltageLevelLegend baseLegend = super.getVoltageLevelLegend(voltageLevelId); + VoltageLevel voltageLevel = getNetwork().getVoltageLevel(voltageLevelId); + if (voltageLevel == null) { + return baseLegend; + } + Map busLegend = new HashMap<>(baseLegend.busLegend()); + for (Bus bus : voltageLevel.getBusView().getBuses()) { + try { + findRepresentativeBusbarSection(bus) + .flatMap(bbs -> findMeasurement(bbs, Measurement.Type.VOLTAGE)) + .ifPresent(m -> busLegend.put(bus.getId(), formatVoltage(m))); + } catch (PowsyblException e) { + LOGGER.warn("Could not resolve TM voltage for bus '{}' of voltage level '{}'", bus.getId(), voltageLevelId, e); + } + } + return new VoltageLevelLegend(baseLegend.legendHeader(), baseLegend.legendFooter(), busLegend); + } + + private EdgeInfo withMeasurementInfo(EdgeInfo baseInfo, Extendable equipment) { + Optional pMeasurement = findMeasurement(equipment, Measurement.Type.ACTIVE_POWER); + Optional qMeasurement = findMeasurement(equipment, Measurement.Type.REACTIVE_POWER); + if (pMeasurement.isEmpty() && qMeasurement.isEmpty()) { + return baseInfo; + } + + String infoTypeA = pMeasurement.map(this::infoType).orElse(baseInfo.getInfoTypeA()); + String labelA = pMeasurement.map(this::formatPower).orElse(baseInfo.getLabelA().orElse(null)); + String infoTypeB = qMeasurement.map(this::infoType).orElse(baseInfo.getInfoTypeB()); + String labelB = qMeasurement.map(this::formatPower).orElse(baseInfo.getLabelB().orElse(null)); + + return new EdgeInfo( + infoTypeA, + infoTypeB, + baseInfo.getDirectionA().orElse(null), + baseInfo.getDirectionB().orElse(null), + labelA, + labelB, + baseInfo.getComponentType().orElse(null)); + } + + private String infoType(Measurement measurement) { + return measurement.isValid() ? MEASUREMENT_VALID : MEASUREMENT_INVALID; + } + + private String formatPower(Measurement measurement) { + return getValueFormatter().formatPowerWithAbs(measurement.getValue(), ""); + } + + private String formatVoltage(Measurement measurement) { + return getValueFormatter().formatVoltage(measurement.getValue(), "kV"); + } + + private Optional findRepresentativeBusbarSection(Bus bus) { + return bus.getConnectedTerminalStream() + .map(Terminal::getConnectable) + .filter(BusbarSection.class::isInstance) + .map(BusbarSection.class::cast) + .findFirst(); + } + + private Optional findMeasurement(Extendable equipment, Measurement.Type type) { + Measurements measurements = (Measurements) equipment.getExtension(Measurements.class); + if (measurements == null) { + return Optional.empty(); + } + return measurements.getMeasurements(type).stream().findFirst(); + } +} diff --git a/src/main/java/com/powsybl/sld/server/nad/NadMeasurementValidityStyleProvider.java b/src/main/java/com/powsybl/sld/server/nad/NadMeasurementValidityStyleProvider.java new file mode 100644 index 00000000..106434f8 --- /dev/null +++ b/src/main/java/com/powsybl/sld/server/nad/NadMeasurementValidityStyleProvider.java @@ -0,0 +1,195 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package com.powsybl.sld.server.nad; + +import com.powsybl.commons.PowsyblException; +import com.powsybl.commons.config.BaseVoltagesConfig; +import com.powsybl.iidm.network.Bus; +import com.powsybl.iidm.network.BusbarSection; +import com.powsybl.iidm.network.Connectable; +import com.powsybl.iidm.network.Network; +import com.powsybl.iidm.network.Terminal; +import com.powsybl.iidm.network.extensions.Measurement; +import com.powsybl.iidm.network.extensions.Measurements; +import com.powsybl.nad.model.BranchEdge; +import com.powsybl.nad.model.BusNode; +import com.powsybl.nad.model.ThreeWtEdge; +import com.powsybl.nad.svg.iidm.TopologicalStyleProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * Reuses the SLD "measurement validity" idea for the NAD: colors branches, three-winding transformers and + * the electrical nodes (buses) they connect to according to their Ptm measurement (green when below the + * threshold, red when above), and tags the TM voltage displayed per electrical node with its validity + * (see {@link NadMeasurementValidityLabelProvider}). + */ +public class NadMeasurementValidityStyleProvider extends TopologicalStyleProvider { + + private static final Logger LOGGER = LoggerFactory.getLogger(NadMeasurementValidityStyleProvider.class); + + public static final String LOW_PTM_BRANCH_CLASS = "nad-low-ptm-branch"; + public static final String HIGH_PTM_BRANCH_CLASS = "nad-high-ptm-branch"; + public static final String LOW_PTM_BUS_CLASS = "nad-low-ptm-bus"; + public static final String HIGH_PTM_BUS_CLASS = "nad-high-ptm-bus"; + public static final String MEASUREMENT_VALID_CLASS = "nad-measurement-valid"; + public static final String MEASUREMENT_INVALID_CLASS = "nad-measurement-invalid"; + public static final String VOLTAGE_VALID_CLASS = "nad-voltage-valid"; + public static final String VOLTAGE_INVALID_CLASS = "nad-voltage-invalid"; + public static final String INVALID_VOLTAGE_BRANCH_CLASS = "nad-invalid-voltage-branch"; + + private static final double PTM_THRESHOLD = 18.0; + + private final Set lowPtmIds; + private final Set highPtmIds; + + public NadMeasurementValidityStyleProvider(Network network, BaseVoltagesConfig baseVoltageStyle) { + super(network, baseVoltageStyle); + this.lowPtmIds = new HashSet<>(); + this.highPtmIds = new HashSet<>(); + classifyByPtm(network.getLines()); + classifyByPtm(network.getTwoWindingsTransformers()); + classifyByPtm(network.getThreeWindingsTransformers()); + } + + private void classifyByPtm(Iterable> equipments) { + for (Connectable equipment : equipments) { + Measurements measurements = (Measurements) equipment.getExtension(Measurements.class); + if (measurements == null) { + continue; + } + measurements.getMeasurements(Measurement.Type.ACTIVE_POWER).stream().findFirst().ifPresent(m -> { + if (Math.abs(m.getValue()) > PTM_THRESHOLD) { + highPtmIds.add(equipment.getId()); + } else { + lowPtmIds.add(equipment.getId()); + } + }); + } + } + + @Override + public List getCssFilenames() { + List filenames = new ArrayList<>(super.getCssFilenames()); + filenames.add("nad-measurement-validity.css"); + return filenames; + } + + @Override + public List getBranchEdgeStyleClasses(BranchEdge branchEdge) { + List styles = new ArrayList<>(super.getBranchEdgeStyleClasses(branchEdge)); + addPtmClass(styles, branchEdge.getEquipmentId()); + addInvalidVoltageClass(styles, branchEdge.getEquipmentId()); + return styles; + } + + @Override + public List getThreeWtEdgeStyleClasses(ThreeWtEdge threeWtEdge) { + List styles = new ArrayList<>(super.getThreeWtEdgeStyleClasses(threeWtEdge)); + addPtmClass(styles, threeWtEdge.getEquipmentId()); + addInvalidVoltageClass(styles, threeWtEdge.getEquipmentId()); + return styles; + } + + @Override + public List getBusNodeStyleClasses(BusNode busNode) { + List styles = new ArrayList<>(super.getBusNodeStyleClasses(busNode)); + Bus bus = network.getBusView().getBus(busNode.getEquipmentId()); + if (bus != null) { + try { + addConnectedPtmClass(styles, bus); + addVoltageValidityClass(styles, bus); + } catch (PowsyblException e) { + LOGGER.warn("Could not resolve Ptm/voltage style for bus '{}'", bus.getId(), e); + } + } + return styles; + } + + @Override + public List getEdgeInfoStyleClasses(String infoType) { + if (NadMeasurementValidityLabelProvider.MEASUREMENT_VALID.equals(infoType)) { + return Collections.singletonList(MEASUREMENT_VALID_CLASS); + } + if (NadMeasurementValidityLabelProvider.MEASUREMENT_INVALID.equals(infoType)) { + return Collections.singletonList(MEASUREMENT_INVALID_CLASS); + } + return super.getEdgeInfoStyleClasses(infoType); + } + + private void addPtmClass(List styles, String equipmentId) { + if (highPtmIds.contains(equipmentId)) { + styles.add(HIGH_PTM_BRANCH_CLASS); + } else if (lowPtmIds.contains(equipmentId)) { + styles.add(LOW_PTM_BRANCH_CLASS); + } + } + + private void addConnectedPtmClass(List styles, Bus bus) { + if (isConnectedToAny(bus, highPtmIds)) { + styles.add(HIGH_PTM_BUS_CLASS); + } else if (isConnectedToAny(bus, lowPtmIds)) { + styles.add(LOW_PTM_BUS_CLASS); + } + } + + private boolean isConnectedToAny(Bus bus, Set equipmentIds) { + return bus.getConnectedTerminalStream() + .map(Terminal::getConnectable) + .map(Connectable::getId) + .anyMatch(equipmentIds::contains); + } + + private void addVoltageValidityClass(List styles, Bus bus) { + getBusVoltageValidity(bus).ifPresent(valid -> styles.add(valid ? VOLTAGE_VALID_CLASS : VOLTAGE_INVALID_CLASS)); + } + + private void addInvalidVoltageClass(List styles, String equipmentId) { + try { + if (isConnectedToInvalidVoltageBus(equipmentId)) { + styles.add(INVALID_VOLTAGE_BRANCH_CLASS); + } + } catch (PowsyblException e) { + LOGGER.warn("Could not resolve voltage validity for equipment '{}'", equipmentId, e); + } + } + + private boolean isConnectedToInvalidVoltageBus(String equipmentId) { + Connectable connectable = network.getConnectable(equipmentId); + if (connectable == null) { + return false; + } + return connectable.getTerminals().stream() + .map(t -> t.getBusView().getBus()) + .filter(Objects::nonNull) + .anyMatch(bus -> !getBusVoltageValidity(bus).orElse(true)); + } + + private Optional getBusVoltageValidity(Bus bus) { + return findRepresentativeBusbarSection(bus) + .map(bbs -> (Measurements) bbs.getExtension(Measurements.class)) + .filter(Objects::nonNull) + .flatMap(measurements -> measurements.getMeasurements(Measurement.Type.VOLTAGE).stream().findFirst()) + .map(Measurement::isValid); + } + + private Optional findRepresentativeBusbarSection(Bus bus) { + return bus.getConnectedTerminalStream() + .map(Terminal::getConnectable) + .filter(BusbarSection.class::isInstance) + .map(BusbarSection.class::cast) + .findFirst(); + } +} diff --git a/src/main/resources/config/application.yaml b/src/main/resources/config/application.yaml index d95a6236..765ae004 100644 --- a/src/main/resources/config/application.yaml +++ b/src/main/resources/config/application.yaml @@ -33,3 +33,4 @@ max-concurrent-nad-generations: 3 diagram-server: nad: max-voltage-levels: 7000 + display-measurements: true diff --git a/src/test/java/com/powsybl/sld/server/nad/NadMeasurementValidityLabelProviderTest.java b/src/test/java/com/powsybl/sld/server/nad/NadMeasurementValidityLabelProviderTest.java new file mode 100644 index 00000000..a1d19980 --- /dev/null +++ b/src/test/java/com/powsybl/sld/server/nad/NadMeasurementValidityLabelProviderTest.java @@ -0,0 +1,225 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package com.powsybl.sld.server.nad; + +import com.powsybl.diagram.util.ValueFormatter; +import com.powsybl.iidm.network.*; +import com.powsybl.iidm.network.extensions.Measurement; +import com.powsybl.iidm.network.extensions.Measurements; +import com.powsybl.nad.model.BranchEdge; +import com.powsybl.nad.svg.EdgeInfo; +import com.powsybl.nad.svg.SvgParameters; +import com.powsybl.nad.svg.VoltageLevelLegend; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class NadMeasurementValidityLabelProviderTest { + + private final ValueFormatter valueFormatter = new SvgParameters().createValueFormatter(); + + private Network mockNetwork; + private Measurements measurements; + + private > B setUpBranch(Class branchClass, String id) { + mockNetwork = mock(Network.class); + B branch = mock(branchClass); + Terminal terminal1 = mock(Terminal.class); + Terminal terminal2 = mock(Terminal.class); + + when(mockNetwork.getBranch(id)).thenReturn(branch); + when(branch.getTerminal(TwoSides.ONE)).thenReturn(terminal1); + when(branch.getTerminal(TwoSides.TWO)).thenReturn(terminal2); + when(branch.getTerminal1()).thenReturn(terminal1); + when(branch.getTerminal2()).thenReturn(terminal2); + when(terminal1.getP()).thenReturn(50.0); + when(terminal2.getP()).thenReturn(-30.0); + when(terminal1.isConnected()).thenReturn(true); + when(terminal2.isConnected()).thenReturn(true); + when(branch.getCurrentLimits(TwoSides.ONE)).thenReturn(Optional.empty()); + when(branch.getCurrentLimits(TwoSides.TWO)).thenReturn(Optional.empty()); + + measurements = mock(Measurements.class); + when(branch.getExtension(Measurements.class)).thenReturn(measurements); + when(measurements.getMeasurements(any(Measurement.Type.class))).thenReturn(List.of()); + return branch; + } + + private NadMeasurementValidityLabelProvider newProvider() { + SvgParameters svgParameters = mock(SvgParameters.class); + when(svgParameters.createValueFormatter()).thenReturn(valueFormatter); + return new NadMeasurementValidityLabelProvider(mockNetwork, svgParameters); + } + + private Measurement mockMeasurement(double value, boolean valid) { + Measurement measurement = mock(Measurement.class); + when(measurement.getValue()).thenReturn(value); + when(measurement.isValid()).thenReturn(valid); + return measurement; + } + + private void stubMeasurement(Measurements targetMeasurements, Measurement.Type type, double value, boolean valid) { + Measurement measurement = mockMeasurement(value, valid); + when(targetMeasurements.getMeasurements(type)).thenReturn(List.of(measurement)); + } + + @Test + void testValidPAndInvalidQMeasurementsReplaceBothLabels() { + setUpBranch(Line.class, "LINE1"); + stubMeasurement(measurements, Measurement.Type.ACTIVE_POWER, 25.0, true); + stubMeasurement(measurements, Measurement.Type.REACTIVE_POWER, -12.0, false); + + EdgeInfo edgeInfo = newProvider().getBranchEdgeInfo("LINE1", "LINE").orElseThrow(); + + assertEquals(NadMeasurementValidityLabelProvider.MEASUREMENT_VALID, edgeInfo.getInfoTypeA()); + assertEquals(valueFormatter.formatPowerWithAbs(25.0, ""), edgeInfo.getLabelA().orElse("")); + assertEquals(NadMeasurementValidityLabelProvider.MEASUREMENT_INVALID, edgeInfo.getInfoTypeB()); + assertEquals(valueFormatter.formatPowerWithAbs(-12.0, ""), edgeInfo.getLabelB().orElse("")); + } + + @Test + void testOnlyActivePowerMeasurementKeepsBaseReactiveSide() { + setUpBranch(Line.class, "LINE1"); + stubMeasurement(measurements, Measurement.Type.ACTIVE_POWER, 25.0, true); + + EdgeInfo edgeInfo = newProvider().getBranchEdgeInfo("LINE1", "LINE").orElseThrow(); + + assertEquals(NadMeasurementValidityLabelProvider.MEASUREMENT_VALID, edgeInfo.getInfoTypeA()); + assertEquals(EdgeInfo.VALUE_PERMANENT_LIMIT_PERCENTAGE, edgeInfo.getInfoTypeB()); + } + + @Test + void testPerSideBranchEdgeInfoIsSuppressed() { + setUpBranch(Line.class, "LINE1"); + + Optional edgeInfo = newProvider().getBranchEdgeInfo("LINE1", BranchEdge.Side.ONE, "LINE"); + + assertTrue(edgeInfo.isEmpty()); + } + + @Test + void testNoMeasurementFallsBackToBaseInfo() { + setUpBranch(Line.class, "LINE1"); + + EdgeInfo edgeInfo = newProvider().getBranchEdgeInfo("LINE1", "LINE").orElseThrow(); + + assertEquals(EdgeInfo.ACTIVE_POWER, edgeInfo.getInfoTypeA()); + assertEquals("50", edgeInfo.getLabelA().orElse("")); + } + + @Test + void testTwoWindingsTransformerIsAlsoDecorated() { + setUpBranch(TwoWindingsTransformer.class, "TWT1"); + stubMeasurement(measurements, Measurement.Type.ACTIVE_POWER, 40.0, true); + stubMeasurement(measurements, Measurement.Type.REACTIVE_POWER, 15.0, true); + + EdgeInfo edgeInfo = newProvider().getBranchEdgeInfo("TWT1", "TWO_WT").orElseThrow(); + + assertEquals(NadMeasurementValidityLabelProvider.MEASUREMENT_VALID, edgeInfo.getInfoTypeA()); + assertEquals(valueFormatter.formatPowerWithAbs(40.0, ""), edgeInfo.getLabelA().orElse("")); + assertEquals(NadMeasurementValidityLabelProvider.MEASUREMENT_VALID, edgeInfo.getInfoTypeB()); + assertEquals(valueFormatter.formatPowerWithAbs(15.0, ""), edgeInfo.getLabelB().orElse("")); + } + + @Test + void testThreeWindingsTransformerIsDecorated() { + mockNetwork = mock(Network.class); + ThreeWindingsTransformer twt = mock(ThreeWindingsTransformer.class); + Terminal terminal1 = mock(Terminal.class); + Terminal terminal2 = mock(Terminal.class); + Terminal terminal3 = mock(Terminal.class); + ThreeWindingsTransformer.Leg leg1 = mock(ThreeWindingsTransformer.Leg.class); + ThreeWindingsTransformer.Leg leg2 = mock(ThreeWindingsTransformer.Leg.class); + ThreeWindingsTransformer.Leg leg3 = mock(ThreeWindingsTransformer.Leg.class); + + when(mockNetwork.getThreeWindingsTransformer("TWT3W")).thenReturn(twt); + when(twt.getTerminal(ThreeSides.ONE)).thenReturn(terminal1); + when(twt.getTerminal(ThreeSides.TWO)).thenReturn(terminal2); + when(twt.getTerminal(ThreeSides.THREE)).thenReturn(terminal3); + when(terminal1.isConnected()).thenReturn(true); + when(twt.getLeg1()).thenReturn(leg1); + when(twt.getLeg(ThreeSides.ONE)).thenReturn(leg1); + when(twt.getLeg(ThreeSides.TWO)).thenReturn(leg2); + when(twt.getLeg(ThreeSides.THREE)).thenReturn(leg3); + when(leg1.getTerminal()).thenReturn(terminal1); + when(leg1.getCurrentLimits()).thenReturn(Optional.empty()); + when(leg2.getCurrentLimits()).thenReturn(Optional.empty()); + when(leg3.getCurrentLimits()).thenReturn(Optional.empty()); + + Measurements twtMeasurements = mock(Measurements.class); + when(twt.getExtension(Measurements.class)).thenReturn(twtMeasurements); + when(twtMeasurements.getMeasurements(any(Measurement.Type.class))).thenReturn(List.of()); + stubMeasurement(twtMeasurements, Measurement.Type.ACTIVE_POWER, 18.0, true); + stubMeasurement(twtMeasurements, Measurement.Type.REACTIVE_POWER, -6.0, false); + + EdgeInfo edgeInfo = newProvider().getThreeWindingTransformerEdgeInfo("TWT3W", com.powsybl.nad.model.ThreeWtEdge.Side.ONE).orElseThrow(); + + assertEquals(NadMeasurementValidityLabelProvider.MEASUREMENT_VALID, edgeInfo.getInfoTypeA()); + assertEquals(valueFormatter.formatPowerWithAbs(18.0, ""), edgeInfo.getLabelA().orElse("")); + assertEquals(NadMeasurementValidityLabelProvider.MEASUREMENT_INVALID, edgeInfo.getInfoTypeB()); + assertEquals(valueFormatter.formatPowerWithAbs(-6.0, ""), edgeInfo.getLabelB().orElse("")); + } + + @Test + void testVoltageLevelLegendReplacesKvWithTmVoltageFromBusbarSection() { + mockNetwork = mock(Network.class); + VoltageLevel voltageLevel = mock(VoltageLevel.class); + VoltageLevel.BusView vlBusView = mock(VoltageLevel.BusView.class); + Network.BusView networkBusView = mock(Network.BusView.class); + Bus bus = mock(Bus.class); + Terminal terminal = mock(Terminal.class); + BusbarSection busbarSection = mock(BusbarSection.class); + Measurements bbsMeasurements = mock(Measurements.class); + + when(mockNetwork.getVoltageLevel("VL1")).thenReturn(voltageLevel); + when(voltageLevel.getBusView()).thenReturn(vlBusView); + when(vlBusView.getBuses()).thenReturn(List.of(bus)); + when(mockNetwork.getBusView()).thenReturn(networkBusView); + when(networkBusView.getBus("BUS1")).thenReturn(bus); + when(bus.getId()).thenReturn("BUS1"); + when(bus.getV()).thenReturn(399.0); + when(bus.getAngle()).thenReturn(0.0); + when(bus.getConnectedTerminalStream()).thenAnswer(invocation -> Stream.of(terminal)); + when(terminal.getConnectable()).thenReturn(busbarSection); + when(busbarSection.getExtension(Measurements.class)).thenReturn(bbsMeasurements); + stubMeasurement(bbsMeasurements, Measurement.Type.VOLTAGE, 405.7, true); + + VoltageLevelLegend legend = newProvider().getVoltageLevelLegend("VL1"); + + assertEquals(valueFormatter.formatVoltage(405.7, "kV"), legend.getBusLegend("BUS1")); + } + + @Test + void testVoltageLevelLegendKeepsBaseValueWhenNoVoltageMeasurement() { + mockNetwork = mock(Network.class); + VoltageLevel voltageLevel = mock(VoltageLevel.class); + VoltageLevel.BusView vlBusView = mock(VoltageLevel.BusView.class); + Network.BusView networkBusView = mock(Network.BusView.class); + Bus bus = mock(Bus.class); + + when(mockNetwork.getVoltageLevel("VL1")).thenReturn(voltageLevel); + when(voltageLevel.getBusView()).thenReturn(vlBusView); + when(vlBusView.getBuses()).thenReturn(List.of(bus)); + when(mockNetwork.getBusView()).thenReturn(networkBusView); + when(networkBusView.getBus("BUS1")).thenReturn(bus); + when(bus.getId()).thenReturn("BUS1"); + when(bus.getV()).thenReturn(399.0); + when(bus.getAngle()).thenReturn(0.0); + when(bus.getConnectedTerminalStream()).thenAnswer(invocation -> Stream.empty()); + + VoltageLevelLegend legend = newProvider().getVoltageLevelLegend("VL1"); + + assertEquals(valueFormatter.formatVoltage(399.0, "kV"), legend.getBusLegend("BUS1")); + } +} diff --git a/src/test/java/com/powsybl/sld/server/nad/NadMeasurementValidityStyleProviderTest.java b/src/test/java/com/powsybl/sld/server/nad/NadMeasurementValidityStyleProviderTest.java new file mode 100644 index 00000000..6241d1cf --- /dev/null +++ b/src/test/java/com/powsybl/sld/server/nad/NadMeasurementValidityStyleProviderTest.java @@ -0,0 +1,282 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package com.powsybl.sld.server.nad; + +import com.powsybl.commons.config.BaseVoltagesConfig; +import com.powsybl.iidm.network.*; +import com.powsybl.iidm.network.extensions.Measurement; +import com.powsybl.iidm.network.extensions.Measurements; +import com.powsybl.nad.model.BranchEdge; +import com.powsybl.nad.model.BusNode; +import com.powsybl.nad.model.ThreeWtEdge; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; + +class NadMeasurementValidityStyleProviderTest { + + private Network mockEmptyNetwork() { + Network network = mock(Network.class); + when(network.getLines()).thenReturn(List.of()); + when(network.getTwoWindingsTransformers()).thenReturn(List.of()); + when(network.getThreeWindingsTransformers()).thenReturn(List.of()); + return network; + } + + private Measurements mockActivePowerMeasurement(double value) { + Measurements measurements = mock(Measurements.class); + Measurement measurement = mock(Measurement.class); + when(measurement.getValue()).thenReturn(value); + when(measurements.getMeasurements(Measurement.Type.ACTIVE_POWER)).thenReturn(List.of(measurement)); + return measurements; + } + + private > void mockConnectedBranch(Network network, B branch, String id) { + Terminal terminal1 = mock(Terminal.class); + Terminal terminal2 = mock(Terminal.class); + VoltageLevel voltageLevel = mock(VoltageLevel.class); + when(voltageLevel.getNominalV()).thenReturn(400.0); + when(terminal1.isConnected()).thenReturn(true); + when(terminal2.isConnected()).thenReturn(true); + when(terminal1.getVoltageLevel()).thenReturn(voltageLevel); + when(terminal2.getVoltageLevel()).thenReturn(voltageLevel); + when(branch.getTerminal(TwoSides.ONE)).thenReturn(terminal1); + when(branch.getTerminal(TwoSides.TWO)).thenReturn(terminal2); + when(branch.isOverloaded()).thenReturn(false); + doReturn(branch).when(network).getBranch(id); + } + + @Test + void testHighPtmLineIsRed() { + Network network = mockEmptyNetwork(); + Line highPtmLine = mock(Line.class); + when(highPtmLine.getId()).thenReturn("LINE1"); + Measurements measurements = mockActivePowerMeasurement(25.0); + when(highPtmLine.getExtension(Measurements.class)).thenReturn(measurements); + when(network.getLines()).thenReturn(List.of(highPtmLine)); + mockConnectedBranch(network, highPtmLine, "LINE1"); + + NadMeasurementValidityStyleProvider provider = new NadMeasurementValidityStyleProvider(network, new BaseVoltagesConfig()); + BranchEdge branchEdge = new BranchEdge(id -> id, "LINE1", "LINE1", BranchEdge.LINE_EDGE, null, null, null); + + List styles = provider.getBranchEdgeStyleClasses(branchEdge); + assertTrue(styles.contains(NadMeasurementValidityStyleProvider.HIGH_PTM_BRANCH_CLASS)); + assertFalse(styles.contains(NadMeasurementValidityStyleProvider.LOW_PTM_BRANCH_CLASS)); + } + + @Test + void testLowPtmLineIsGreen() { + Network network = mockEmptyNetwork(); + Line lowPtmLine = mock(Line.class); + when(lowPtmLine.getId()).thenReturn("LINE2"); + Measurements measurements = mockActivePowerMeasurement(5.0); + when(lowPtmLine.getExtension(Measurements.class)).thenReturn(measurements); + when(network.getLines()).thenReturn(List.of(lowPtmLine)); + mockConnectedBranch(network, lowPtmLine, "LINE2"); + + NadMeasurementValidityStyleProvider provider = new NadMeasurementValidityStyleProvider(network, new BaseVoltagesConfig()); + BranchEdge branchEdge = new BranchEdge(id -> id, "LINE2", "LINE2", BranchEdge.LINE_EDGE, null, null, null); + + List styles = provider.getBranchEdgeStyleClasses(branchEdge); + assertTrue(styles.contains(NadMeasurementValidityStyleProvider.LOW_PTM_BRANCH_CLASS)); + assertFalse(styles.contains(NadMeasurementValidityStyleProvider.HIGH_PTM_BRANCH_CLASS)); + } + + @Test + void testTwoWindingsTransformerIsAlsoClassified() { + Network network = mockEmptyNetwork(); + TwoWindingsTransformer twt = mock(TwoWindingsTransformer.class); + when(twt.getId()).thenReturn("TWT1"); + Measurements measurements = mockActivePowerMeasurement(30.0); + when(twt.getExtension(Measurements.class)).thenReturn(measurements); + when(network.getTwoWindingsTransformers()).thenReturn(List.of(twt)); + mockConnectedBranch(network, twt, "TWT1"); + + NadMeasurementValidityStyleProvider provider = new NadMeasurementValidityStyleProvider(network, new BaseVoltagesConfig()); + BranchEdge branchEdge = new BranchEdge(id -> id, "TWT1", "TWT1", BranchEdge.TWO_WT_EDGE, null, null, null); + + assertTrue(provider.getBranchEdgeStyleClasses(branchEdge).contains(NadMeasurementValidityStyleProvider.HIGH_PTM_BRANCH_CLASS)); + } + + @Test + void testThreeWindingsTransformerIsClassifiedViaThreeWtEdgeStyleClasses() { + Network network = mockEmptyNetwork(); + ThreeWindingsTransformer twt = mock(ThreeWindingsTransformer.class); + when(twt.getId()).thenReturn("TWT3W"); + Measurements measurements = mockActivePowerMeasurement(2.0); + when(twt.getExtension(Measurements.class)).thenReturn(measurements); + when(network.getThreeWindingsTransformers()).thenReturn(List.of(twt)); + when(network.getThreeWindingsTransformer("TWT3W")).thenReturn(twt); + + NadMeasurementValidityStyleProvider provider = new NadMeasurementValidityStyleProvider(network, new BaseVoltagesConfig()); + ThreeWtEdge threeWtEdge = new ThreeWtEdge(id -> id, "TWT3W", "TWT3W", ThreeWtEdge.Side.ONE, ThreeWtEdge.THREE_WT_EDGE, true, null); + + assertTrue(provider.getThreeWtEdgeStyleClasses(threeWtEdge).contains(NadMeasurementValidityStyleProvider.LOW_PTM_BRANCH_CLASS)); + } + + @Test + void testBranchConnectedToInvalidVoltageBusGetsInvalidClass() { + Network network = mockEmptyNetwork(); + Line line = mock(Line.class); + doReturn(line).when(network).getConnectable("LINE3"); + mockConnectedBranch(network, line, "LINE3"); + + Terminal terminal1 = mock(Terminal.class); + Terminal terminal2 = mock(Terminal.class); + doReturn(List.of(terminal1, terminal2)).when(line).getTerminals(); + + Bus invalidBus = mock(Bus.class); + BusbarSection busbarSection = mock(BusbarSection.class); + Terminal bbsTerminal = mock(Terminal.class); + Measurements voltageMeasurements = mock(Measurements.class); + Measurement measurement = mock(Measurement.class); + when(measurement.isValid()).thenReturn(false); + when(voltageMeasurements.getMeasurements(Measurement.Type.VOLTAGE)).thenReturn(List.of(measurement)); + when(busbarSection.getExtension(Measurements.class)).thenReturn(voltageMeasurements); + when(bbsTerminal.getConnectable()).thenReturn(busbarSection); + when(invalidBus.getConnectedTerminalStream()).thenAnswer(invocation -> Stream.of(bbsTerminal)); + + Terminal.BusView busView1 = mock(Terminal.BusView.class); + when(busView1.getBus()).thenReturn(invalidBus); + when(terminal1.getBusView()).thenReturn(busView1); + Terminal.BusView busView2 = mock(Terminal.BusView.class); + when(terminal2.getBusView()).thenReturn(busView2); + + NadMeasurementValidityStyleProvider provider = new NadMeasurementValidityStyleProvider(network, new BaseVoltagesConfig()); + BranchEdge branchEdge = new BranchEdge(id -> id, "LINE3", "LINE3", BranchEdge.LINE_EDGE, null, null, null); + + assertTrue(provider.getBranchEdgeStyleClasses(branchEdge).contains(NadMeasurementValidityStyleProvider.INVALID_VOLTAGE_BRANCH_CLASS)); + } + + @Test + void testBusConnectedToHighPtmLineIsRed() { + Network network = mockEmptyNetwork(); + Line highPtmLine = mock(Line.class); + when(highPtmLine.getId()).thenReturn("LINE1"); + Measurements measurements = mockActivePowerMeasurement(25.0); + when(highPtmLine.getExtension(Measurements.class)).thenReturn(measurements); + when(network.getLines()).thenReturn(List.of(highPtmLine)); + + mockBusConnectedTo(network, highPtmLine); + + NadMeasurementValidityStyleProvider provider = new NadMeasurementValidityStyleProvider(network, new BaseVoltagesConfig()); + BusNode busNode = new BusNode("BUS1", "BUS1", Collections.emptyList(), ""); + + List styles = provider.getBusNodeStyleClasses(busNode); + assertTrue(styles.contains(NadMeasurementValidityStyleProvider.HIGH_PTM_BUS_CLASS)); + assertFalse(styles.contains(NadMeasurementValidityStyleProvider.LOW_PTM_BUS_CLASS)); + } + + @Test + void testBusConnectedToLowPtmLineIsGreen() { + Network network = mockEmptyNetwork(); + Line lowPtmLine = mock(Line.class); + when(lowPtmLine.getId()).thenReturn("LINE2"); + Measurements measurements = mockActivePowerMeasurement(5.0); + when(lowPtmLine.getExtension(Measurements.class)).thenReturn(measurements); + when(network.getLines()).thenReturn(List.of(lowPtmLine)); + + mockBusConnectedTo(network, lowPtmLine); + + NadMeasurementValidityStyleProvider provider = new NadMeasurementValidityStyleProvider(network, new BaseVoltagesConfig()); + BusNode busNode = new BusNode("BUS1", "BUS1", Collections.emptyList(), ""); + + List styles = provider.getBusNodeStyleClasses(busNode); + assertTrue(styles.contains(NadMeasurementValidityStyleProvider.LOW_PTM_BUS_CLASS)); + assertFalse(styles.contains(NadMeasurementValidityStyleProvider.HIGH_PTM_BUS_CLASS)); + } + + @Test + void testBusWithValidVoltageMeasurementOnBusbarSectionIsMarkedValid() { + Network network = mockEmptyNetwork(); + Bus bus = mock(Bus.class); + Network.BusView busView = mock(Network.BusView.class); + BusbarSection busbarSection = mock(BusbarSection.class); + Terminal terminal = mock(Terminal.class); + Measurements voltageMeasurements = mock(Measurements.class); + Measurement measurement = mock(Measurement.class); + + when(network.getBusView()).thenReturn(busView); + when(busView.getBus("BUS1")).thenReturn(bus); + when(bus.getConnectedTerminalStream()).thenAnswer(invocation -> Stream.of(terminal)); + when(terminal.getConnectable()).thenReturn(busbarSection); + when(busbarSection.getExtension(Measurements.class)).thenReturn(voltageMeasurements); + when(voltageMeasurements.getMeasurements(Measurement.Type.VOLTAGE)).thenReturn(List.of(measurement)); + when(measurement.isValid()).thenReturn(true); + stubVoltageWithinLimits(bus); + + NadMeasurementValidityStyleProvider provider = new NadMeasurementValidityStyleProvider(network, new BaseVoltagesConfig()); + BusNode busNode = new BusNode("BUS1", "BUS1", Collections.emptyList(), ""); + + List styles = provider.getBusNodeStyleClasses(busNode); + assertTrue(styles.contains(NadMeasurementValidityStyleProvider.VOLTAGE_VALID_CLASS)); + assertFalse(styles.contains(NadMeasurementValidityStyleProvider.VOLTAGE_INVALID_CLASS)); + } + + @Test + void testBusWithInvalidVoltageMeasurementOnBusbarSectionIsMarkedInvalid() { + Network network = mockEmptyNetwork(); + Bus bus = mock(Bus.class); + Network.BusView busView = mock(Network.BusView.class); + BusbarSection busbarSection = mock(BusbarSection.class); + Terminal terminal = mock(Terminal.class); + Measurements voltageMeasurements = mock(Measurements.class); + Measurement measurement = mock(Measurement.class); + + when(network.getBusView()).thenReturn(busView); + when(busView.getBus("BUS1")).thenReturn(bus); + when(bus.getConnectedTerminalStream()).thenAnswer(invocation -> Stream.of(terminal)); + when(terminal.getConnectable()).thenReturn(busbarSection); + when(busbarSection.getExtension(Measurements.class)).thenReturn(voltageMeasurements); + when(voltageMeasurements.getMeasurements(Measurement.Type.VOLTAGE)).thenReturn(List.of(measurement)); + when(measurement.isValid()).thenReturn(false); + stubVoltageWithinLimits(bus); + + NadMeasurementValidityStyleProvider provider = new NadMeasurementValidityStyleProvider(network, new BaseVoltagesConfig()); + BusNode busNode = new BusNode("BUS1", "BUS1", Collections.emptyList(), ""); + + List styles = provider.getBusNodeStyleClasses(busNode); + assertTrue(styles.contains(NadMeasurementValidityStyleProvider.VOLTAGE_INVALID_CLASS)); + assertFalse(styles.contains(NadMeasurementValidityStyleProvider.VOLTAGE_VALID_CLASS)); + } + + @Test + void testEdgeInfoStyleClassesForMeasurementValidity() { + NadMeasurementValidityStyleProvider provider = new NadMeasurementValidityStyleProvider(mockEmptyNetwork(), new BaseVoltagesConfig()); + + assertTrue(provider.getEdgeInfoStyleClasses(NadMeasurementValidityLabelProvider.MEASUREMENT_VALID) + .contains(NadMeasurementValidityStyleProvider.MEASUREMENT_VALID_CLASS)); + assertTrue(provider.getEdgeInfoStyleClasses(NadMeasurementValidityLabelProvider.MEASUREMENT_INVALID) + .contains(NadMeasurementValidityStyleProvider.MEASUREMENT_INVALID_CLASS)); + } + + private void mockBusConnectedTo(Network network, Line line) { + Bus bus = mock(Bus.class); + Terminal terminal = mock(Terminal.class); + Network.BusView busView = mock(Network.BusView.class); + + when(network.getBusView()).thenReturn(busView); + when(busView.getBus("BUS1")).thenReturn(bus); + when(terminal.getConnectable()).thenReturn(line); + when(bus.getConnectedTerminalStream()).thenAnswer(invocation -> Stream.of(terminal)); + stubVoltageWithinLimits(bus); + } + + private void stubVoltageWithinLimits(Bus bus) { + VoltageLevel voltageLevel = mock(VoltageLevel.class); + when(bus.getVoltageLevel()).thenReturn(voltageLevel); + when(voltageLevel.getHighVoltageLimit()).thenReturn(1000.0); + when(voltageLevel.getLowVoltageLimit()).thenReturn(0.0); + when(bus.getV()).thenReturn(400.0); + } +} diff --git a/src/test/resources/nad-measurement-validity.css b/src/test/resources/nad-measurement-validity.css new file mode 100644 index 00000000..4c888b7c --- /dev/null +++ b/src/test/resources/nad-measurement-validity.css @@ -0,0 +1,61 @@ +/* TM transit (active/reactive power labels): yellow = valid, brown = invalid */ +/* selector specificity must beat ".nad-edge-infos text" (whose "font" shorthand resets font-weight to normal) */ +.nad-edge-infos text.nad-measurement-valid { + fill: #FFD700; + font-weight: normal; +} + +.nad-edge-infos text.nad-measurement-invalid { + fill: #8B4513; + font-weight: bold; +} + +/* Branches (lines, 2WT): green = lowPtm, red = highPtm - classes are on the wrapping , path is a descendant */ +.nad-low-ptm-branch .nad-edge-path { + stroke: #008000; +} + +.nad-high-ptm-branch .nad-edge-path { + stroke: #e00000; +} + +/* 3WT windings: same rule, but classes land directly on the winding path element */ +.nad-winding.nad-low-ptm-branch { + stroke: #008000; +} + +.nad-winding.nad-high-ptm-branch { + stroke: #e00000; +} + +/* Electrical nodes (busbars): same green/red rule, connected via their branches */ +.nad-busnode.nad-low-ptm-bus { + fill: #008000; +} + +.nad-busnode.nad-high-ptm-bus { + fill: #e00000; +} + +/* TM voltage (legend square next to the voltage value): yellow = valid, brown = invalid */ +.nad-legend-square.nad-voltage-valid { + background-color: #FFD700; +} + +.nad-legend-square.nad-voltage-invalid { + background-color: #8B4513; +} + +/* A node whose SJB voltage TM is invalid turns brown, overriding the green/red Ptm coloring */ +.nad-busnode.nad-voltage-invalid { + fill: #8B4513; +} + +/* Branches/3WT windings connected to such an invalid node turn brown too, overriding green/red Ptm coloring */ +.nad-invalid-voltage-branch .nad-edge-path { + stroke: #8B4513; +} + +.nad-winding.nad-invalid-voltage-branch { + stroke: #8B4513; +} From 15c0b57d067c3e290616ecfc892d9d88ea616c54 Mon Sep 17 00:00:00 2001 From: benrejebmoh Date: Thu, 9 Jul 2026 15:35:26 +0200 Subject: [PATCH 2/2] POC: customise NAD elements (display TM, displays in bold, change VL color, change line/twt color...) --- src/{test => main}/resources/nad-measurement-validity.css | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/{test => main}/resources/nad-measurement-validity.css (100%) diff --git a/src/test/resources/nad-measurement-validity.css b/src/main/resources/nad-measurement-validity.css similarity index 100% rename from src/test/resources/nad-measurement-validity.css rename to src/main/resources/nad-measurement-validity.css