diff --git a/security-analysis/mixed-security-analysis-api/pom.xml b/security-analysis/mixed-security-analysis-api/pom.xml new file mode 100644 index 00000000000..cc4196763e2 --- /dev/null +++ b/security-analysis/mixed-security-analysis-api/pom.xml @@ -0,0 +1,104 @@ + + + 4.0.0 + + com.powsybl + powsybl-security-analysis + 7.3.0-SNAPSHOT + + + mixed-security-analysis-api + Mixed-Mode Security Analysis API + An API for running mixed-mode (static and dynamic) security analyses. + + + 21 + 21 + UTF-8 + + + + + com.fasterxml.jackson.core + jackson-databind + + + com.powsybl + powsybl-security-analysis-api + + + com.powsybl + powsybl-open-loadflow + 2.2.0 + + + com.powsybl + powsybl-iidm-serde + ${project.version} + + + + com.google.jimfs + jimfs + test + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.mockito + mockito-core + test + + + org.slf4j + log4j-over-slf4j + test + + + org.slf4j + slf4j-simple + test + + + ${project.groupId} + powsybl-commons-test + ${project.version} + test + + + ${project.groupId} + powsybl-config-test + ${project.version} + test + + + ${project.groupId} + powsybl-iidm-impl + ${project.version} + test + + + ${project.groupId} + powsybl-iidm-test + ${project.version} + test + + + ${project.groupId} + powsybl-tools-test + ${project.version} + test + + + + \ No newline at end of file diff --git a/security-analysis/mixed-security-analysis-api/src/main/java/com/powsybl/mixed/security/analysis/MixedSecurityAnalysis.java b/security-analysis/mixed-security-analysis-api/src/main/java/com/powsybl/mixed/security/analysis/MixedSecurityAnalysis.java new file mode 100644 index 00000000000..1fc8b506054 --- /dev/null +++ b/security-analysis/mixed-security-analysis-api/src/main/java/com/powsybl/mixed/security/analysis/MixedSecurityAnalysis.java @@ -0,0 +1,204 @@ +package com.powsybl.mixed.security.analysis; + +import com.powsybl.contingency.ContingenciesProvider; +import com.powsybl.contingency.Contingency; +import com.powsybl.iidm.network.Network; +import com.powsybl.mixed.security.analysis.criteria.AnalysisSwitchCriteria; +import com.powsybl.mixed.security.analysis.criteria.SwitchDecision; +import com.powsybl.mixed.security.analysis.parameters.MixedModeParametersExtension; +import com.powsybl.security.SecurityAnalysisProvider; +import com.powsybl.security.SecurityAnalysisReport; +import com.powsybl.security.SecurityAnalysisResult; +import com.powsybl.security.SecurityAnalysisRunParameters; +import com.powsybl.security.results.PostContingencyResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +/** + * Business logic for mixed-mode security analysis: static pass on all contingencies, + * then dynamic pass on those that meet the switch criteria, results merged. + * + * @author Riad Benradi {@literal } + */ +public class MixedSecurityAnalysis { + private static final Logger LOGGER = LoggerFactory.getLogger(MixedSecurityAnalysis.class); + + private final Network network; + private final String workingVariantId; + private final ContingenciesProvider contingenciesProvider; + private final SecurityAnalysisRunParameters runParameters; + private final MixedModeParametersExtension extension; + private final List providers; + + public MixedSecurityAnalysis(Network network, String workingVariantId, ContingenciesProvider contingenciesProvider, + SecurityAnalysisRunParameters runParameters, MixedModeParametersExtension extension) { + this(network, workingVariantId, contingenciesProvider, runParameters, extension, null); + } + + public MixedSecurityAnalysis(Network network, String workingVariantId, ContingenciesProvider contingenciesProvider, + SecurityAnalysisRunParameters runParameters, MixedModeParametersExtension extension, + List providers) { + this.network = network; + this.workingVariantId = workingVariantId; + this.contingenciesProvider = contingenciesProvider; + this.runParameters = runParameters; + this.extension = extension; + this.providers = providers; + } + + /** + * Executes the full mixed-mode analysis workflow. + */ + public CompletableFuture run() { + LOGGER.info("Starting mixed-mode security analysis"); + LOGGER.debug("Static simulator: {}, Dynamic simulator: {}", extension.getStaticSimulator(), extension.getDynamicSimulator()); + + // Step 1: Get all contingencies + List allContingencies = contingenciesProvider.getContingencies(network); + LOGGER.info("Total contingencies to analyze: {}", allContingencies.size()); + + // Step 2: Run static analysis + String staticProviderName = extension.getStaticSimulator(); + SecurityAnalysisProvider staticProvider = findProvider(staticProviderName); + + CompletableFuture staticAnalysisFuture = staticProvider.run( + network, workingVariantId, contingenciesProvider, runParameters); + + // Step 3: Chain dynamic analysis based on static results + return staticAnalysisFuture.thenCompose(staticReport -> { + LOGGER.info("Static analysis completed"); + // Evaluate switch criteria + AnalysisSwitchCriteria switchCriteria = new AnalysisSwitchCriteria(extension); + List contingenciesToRunDynamic = identifyDynamicContingencies( + staticReport.getResult(), switchCriteria); + + LOGGER.info("Contingencies requiring dynamic analysis: {}", contingenciesToRunDynamic.size()); + + // If no contingencies need dynamic analysis, return static results + if (contingenciesToRunDynamic.isEmpty()) { + LOGGER.info("No contingencies require dynamic analysis, returning static results"); + return CompletableFuture.completedFuture(staticReport); + } + + // Run dynamic analysis on filtered contingencies + return runDynamicAnalysis(contingenciesToRunDynamic, allContingencies) + .thenApply(dynamicReport -> mergeResults(staticReport, dynamicReport)); + }).exceptionally(ex -> { + LOGGER.error("Error during mixed-mode security analysis", ex); + return new SecurityAnalysisReport(SecurityAnalysisResult.empty()); + }); + } + + /** + * Identifies which contingencies should be analyzed with the dynamic simulator. + */ + private List identifyDynamicContingencies(SecurityAnalysisResult staticResult, + AnalysisSwitchCriteria switchCriteria) { + return staticResult.getPostContingencyResults().stream() + .filter(result -> shouldRunDynamic(result, switchCriteria)) + .map(result -> result.getContingency().getId()) + .collect(Collectors.toList()); + } + + /** + * Evaluates if a contingency result should trigger dynamic analysis. + */ + private boolean shouldRunDynamic(PostContingencyResult result, AnalysisSwitchCriteria switchCriteria) { + SwitchDecision decision = switchCriteria.evaluate(result); + LOGGER.debug("Contingency {} - Switch decision: {}", result.getContingency().getId(), decision.getReason()); + return decision.shouldSwitch(); + } + + /** + * Runs dynamic analysis on a subset of contingencies. + * Uses the already-resolved {@code allContingencies} list to avoid a second provider call. + */ + private CompletableFuture runDynamicAnalysis(List contingencyIds, + List allContingencies) { + LOGGER.info("Starting dynamic analysis pass for {} contingencies", contingencyIds.size()); + + ContingenciesProvider filteredProvider = network -> + allContingencies.stream() + .filter(c -> contingencyIds.contains(c.getId())) + .collect(Collectors.toList()); + + String dynamicProviderName = extension.getDynamicSimulator(); + SecurityAnalysisProvider dynamicProvider = findProvider(dynamicProviderName); + + return dynamicProvider.run(network, workingVariantId, filteredProvider, runParameters); + } + + /** + * Merges static and dynamic analysis results. + * Strategy: For each contingency, keep the result from the last (most relevant) analysis: + * - If analyzed in dynamic pass: use dynamic result + * - Otherwise: use static result + */ + private SecurityAnalysisReport mergeResults(SecurityAnalysisReport staticReport, SecurityAnalysisReport dynamicReport) { + LOGGER.info("Merging static and dynamic analysis results"); + SecurityAnalysisResult staticResult = staticReport.getResult(); + SecurityAnalysisResult dynamicResult = dynamicReport.getResult(); + + // Create a map of dynamic results by contingency ID + Map dynamicResultsMap = dynamicResult.getPostContingencyResults() + .stream() + .collect(Collectors.toMap(r -> r.getContingency().getId(), r -> r)); + + // Merge: use dynamic result if available, otherwise use static + List mergedResults = staticResult.getPostContingencyResults() + .stream() + .map(staticResultItem -> { + String contingencyId = staticResultItem.getContingency().getId(); + if (dynamicResultsMap.containsKey(contingencyId)) { + LOGGER.debug("Using dynamic result for contingency {}", contingencyId); + return dynamicResultsMap.get(contingencyId); + } else { + LOGGER.debug("Using static result for contingency {}", contingencyId); + return staticResultItem; + } + }) + .collect(Collectors.toList()); + + // Create final result + SecurityAnalysisResult finalResult = new SecurityAnalysisResult( + staticResult.getPreContingencyResult(), + mergedResults, + staticResult.getOperatorStrategyResults()); + + if (staticResult.getNetworkMetadata() != null) { + finalResult.setNetworkMetadata(staticResult.getNetworkMetadata()); + } + + LOGGER.info("Merge complete: {} post-contingency results", mergedResults.size()); + return new SecurityAnalysisReport(finalResult) + .setLogBytes(staticReport.getLogBytes().orElse(null)); + } + + /** + * Finds a security analysis provider by name using ServiceLoader. + */ + private SecurityAnalysisProvider findProvider(String providerName) { + Map allProviders = StreamSupport.stream(ServiceLoader.load(SecurityAnalysisProvider.class).spliterator(), false) + .collect(Collectors.toMap(SecurityAnalysisProvider::getName, p -> p)); + + if (providers != null) { + providers.forEach(p -> allProviders.put(p.getName(), p)); + } + + SecurityAnalysisProvider foundProvider = allProviders.get(providerName); + + if (foundProvider == null) { + throw new IllegalArgumentException( + "Security analysis provider '" + providerName + "' not found. " + + "Available providers: " + String.join(", ", allProviders.keySet())); + } + return foundProvider; + } +} diff --git a/security-analysis/mixed-security-analysis-api/src/main/java/com/powsybl/mixed/security/analysis/MixedSecurityAnalysisProvider.java b/security-analysis/mixed-security-analysis-api/src/main/java/com/powsybl/mixed/security/analysis/MixedSecurityAnalysisProvider.java new file mode 100644 index 00000000000..a91cab511d0 --- /dev/null +++ b/security-analysis/mixed-security-analysis-api/src/main/java/com/powsybl/mixed/security/analysis/MixedSecurityAnalysisProvider.java @@ -0,0 +1,108 @@ +package com.powsybl.mixed.security.analysis; + +import com.google.auto.service.AutoService; +import com.powsybl.commons.config.PlatformConfig; +import com.powsybl.commons.extensions.Extension; +import com.powsybl.contingency.ContingenciesProvider; +import com.powsybl.iidm.network.Network; +import com.powsybl.mixed.security.analysis.parameters.MixedModeParametersExtension; +import com.powsybl.security.SecurityAnalysisParameters; +import com.powsybl.security.SecurityAnalysisProvider; +import com.powsybl.security.SecurityAnalysisReport; +import com.powsybl.security.SecurityAnalysisRunParameters; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +/** + * {@link SecurityAnalysisProvider} orchestrating a static pass followed by a selective + * dynamic pass, discovered automatically via {@link java.util.ServiceLoader}. + * + * @author Riad Benradi {@literal } + */ + +@AutoService({SecurityAnalysisProvider.class}) +public class MixedSecurityAnalysisProvider implements SecurityAnalysisProvider { + + private static final Logger LOGGER = LoggerFactory.getLogger(MixedSecurityAnalysisProvider.class); + + private static final String PROVIDER_NAME = "MixedSecurityAnalysis"; + + /** @return {@code "MixedSecurityAnalysis"} */ + @Override + public String getName() { + return PROVIDER_NAME; + } + + /** @return the provider version */ + @Override + public String getVersion() { + return "1.0.0-SNAPSHOT"; + } + + /** + * Reads {@link MixedModeParametersExtension} from {@code runParameters} and delegates + * to {@link MixedSecurityAnalysis}. + * + * @throws IllegalArgumentException if {@link MixedModeParametersExtension} is absent + */ + @Override + public CompletableFuture run(Network network, + String workingVariantId, + ContingenciesProvider contingenciesProvider, + SecurityAnalysisRunParameters runParameters) { + + Objects.requireNonNull(network); + Objects.requireNonNull(workingVariantId); + Objects.requireNonNull(contingenciesProvider); + Objects.requireNonNull(runParameters); + LOGGER.info("Starting mixed-mode security analysis for network: {}", network.getId()); + + MixedModeParametersExtension extension = runParameters.getSecurityAnalysisParameters().getExtension(MixedModeParametersExtension.class); + if (extension == null) { + + LOGGER.warn("MixedModeParametersExtension configuration is missing from SecurityAnalysisRunParameters, using defaults."); + extension = (MixedModeParametersExtension) loadSpecificParameters(PlatformConfig.defaultConfig()).orElseThrow(() -> + new IllegalArgumentException("Failed to load MixedModeParametersExtension configuration")); + } + LOGGER.debug("Configuration loaded - Static simulator: {}, Dynamic simulator: {}, " + + "Switch criteria: {}", + extension.getStaticSimulator(), + extension.getDynamicSimulator(), + extension.getSwitchCriteria()); + + MixedSecurityAnalysis analysis = new MixedSecurityAnalysis( + network, + workingVariantId, + contingenciesProvider, + runParameters, + extension + ); + + return analysis.run(); + } + + @Override + public Optional> loadSpecificParameters(PlatformConfig config) { + MixedModeParametersExtension ext = new MixedModeParametersExtension(); + + // Default values — used if the key is absent from the config file + ext.setStaticSimulator("load-flow"); + ext.setDynamicSimulator("dynaflow"); + ext.setSwitchCriteria(List.of("NOT_CONVERGED")); + + // Override with values from config file if the section exists + // Reads from a YAML block named "mixed-mode-analysis" + config.getOptionalModuleConfig(MixedModeParametersExtension.NAME).ifPresent(module -> { + ext.setStaticSimulator(module.getStringProperty("static-simulator", ext.getStaticSimulator())); + ext.setDynamicSimulator(module.getStringProperty("dynamic-simulator", ext.getDynamicSimulator())); + ext.setSwitchCriteria(module.getStringListProperty("switch-criteria", ext.getSwitchCriteria())); + }); + + return Optional.of(ext); + } +} diff --git a/security-analysis/mixed-security-analysis-api/src/main/java/com/powsybl/mixed/security/analysis/criteria/AnalysisSwitchCriteria.java b/security-analysis/mixed-security-analysis-api/src/main/java/com/powsybl/mixed/security/analysis/criteria/AnalysisSwitchCriteria.java new file mode 100644 index 00000000000..fd5b3a2a6d9 --- /dev/null +++ b/security-analysis/mixed-security-analysis-api/src/main/java/com/powsybl/mixed/security/analysis/criteria/AnalysisSwitchCriteria.java @@ -0,0 +1,70 @@ +package com.powsybl.mixed.security.analysis.criteria; + +import com.powsybl.mixed.security.analysis.parameters.MixedModeParametersExtension; +import com.powsybl.security.PostContingencyComputationStatus; +import com.powsybl.security.results.PostContingencyResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Objects; +/** + * Evaluates whether a contingency result should trigger a switch to dynamic analysis. + * + * @author Riad Benradi {@literal } + */ + +public class AnalysisSwitchCriteria { + + private static final Logger LOGGER = LoggerFactory.getLogger(AnalysisSwitchCriteria.class); + private final MixedModeParametersExtension extension; + + public AnalysisSwitchCriteria(MixedModeParametersExtension extension) { + this.extension = Objects.requireNonNull(extension); + } + + public SwitchDecision evaluate(PostContingencyResult result) { + Objects.requireNonNull(result); + if (extension.getSwitchCriteria() == null || extension.getSwitchCriteria().isEmpty()) { + LOGGER.debug("No switch criteria defined, keeping current result"); + return new SwitchDecision(false, "No criteria defined"); + } + for (String criterion : extension.getSwitchCriteria()) { + if (evaluateCriterion(result, criterion)) { + return new SwitchDecision(true, "Criterion '" + criterion + "' met"); + } + } + return new SwitchDecision(false, "No criteria met"); + } + + private boolean evaluateCriterion(PostContingencyResult result, String criterion) { + return switch (criterion.toUpperCase()) { + case "FAILED" -> evaluateNonConvergence(result); + case "LIMIT_VIOLATIONS" -> evaluateLimitViolations(result); + case "SPS_TRIGGERED" -> evaluateSpsTriggered(); + default -> { + LOGGER.warn("Unknown criterion: {}", criterion); + yield false; + } + }; + } + + private boolean evaluateNonConvergence(PostContingencyResult result) { + boolean converged = result.getStatus() == PostContingencyComputationStatus.CONVERGED; + if (!converged) { + LOGGER.debug("Non-convergence detected for contingency {}", result.getContingency().getId()); + } + return !converged; + } + + private boolean evaluateLimitViolations(PostContingencyResult result) { + boolean hasViolations = !result.getLimitViolationsResult().getLimitViolations().isEmpty(); + if (hasViolations) { + LOGGER.debug("Limit violations detected for contingency {}", result.getContingency().getId()); + } + return hasViolations; + } + + private boolean evaluateSpsTriggered() { + return true; + } +} diff --git a/security-analysis/mixed-security-analysis-api/src/main/java/com/powsybl/mixed/security/analysis/criteria/SwitchDecision.java b/security-analysis/mixed-security-analysis-api/src/main/java/com/powsybl/mixed/security/analysis/criteria/SwitchDecision.java new file mode 100644 index 00000000000..2378289be23 --- /dev/null +++ b/security-analysis/mixed-security-analysis-api/src/main/java/com/powsybl/mixed/security/analysis/criteria/SwitchDecision.java @@ -0,0 +1,52 @@ +package com.powsybl.mixed.security.analysis.criteria; + +import java.util.Objects; + +/** + * Represents a decision on whether to switch to dynamic analysis for a contingency. + * + * @author Riad Benradi {@literal } + */ +public class SwitchDecision { + private final boolean shouldSwitch; + private final String reason; + + public SwitchDecision(boolean shouldSwitch, String reason) { + this.shouldSwitch = shouldSwitch; + this.reason = Objects.requireNonNull(reason); + } + + public boolean shouldSwitch() { + return shouldSwitch; + } + + public String getReason() { + return reason; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SwitchDecision that = (SwitchDecision) o; + return shouldSwitch == that.shouldSwitch && Objects.equals(reason, that.reason); + } + + @Override + public int hashCode() { + return Objects.hash(shouldSwitch, reason); + } + + @Override + public String toString() { + return "SwitchDecision{" + + "shouldSwitch=" + shouldSwitch + + ", reason='" + reason + '\'' + + '}'; + } +} + diff --git a/security-analysis/mixed-security-analysis-api/src/main/java/com/powsybl/mixed/security/analysis/parameters/MixedModeParametersExtension.java b/security-analysis/mixed-security-analysis-api/src/main/java/com/powsybl/mixed/security/analysis/parameters/MixedModeParametersExtension.java new file mode 100644 index 00000000000..95b2343614c --- /dev/null +++ b/security-analysis/mixed-security-analysis-api/src/main/java/com/powsybl/mixed/security/analysis/parameters/MixedModeParametersExtension.java @@ -0,0 +1,61 @@ +package com.powsybl.mixed.security.analysis.parameters; + +import com.powsybl.commons.extensions.AbstractExtension; +import com.powsybl.security.SecurityAnalysisParameters; + +import java.util.List; + +/** + * This class contains configuration parameters specific to + * mixed-mode security analysis. + * It is designed to be used as an extension of PowSybl's standard parameters. + * PowSybl will handle reading a configuration file (e.g., YAML) and populate + * the fields of this class automatically. + */ +public class MixedModeParametersExtension extends AbstractExtension { + + public static final String NAME = "mixed-mode-analysis"; + /** + * The name of the static simulator to use for the first pass. + */ + private String staticSimulator; + + /** + * The name of the dynamic simulator to use for the second pass (for complex cases). + */ + private String dynamicSimulator; + + /** + * The list of criteria that trigger a switch to the dynamic simulator. + */ + private List switchCriteria; + + public String getStaticSimulator() { + return staticSimulator; + } + + public void setStaticSimulator(String staticSimulator) { + this.staticSimulator = staticSimulator; + } + + public String getDynamicSimulator() { + return dynamicSimulator; + } + + public void setDynamicSimulator(String dynamicSimulator) { + this.dynamicSimulator = dynamicSimulator; + } + + public List getSwitchCriteria() { + return switchCriteria; + } + + public void setSwitchCriteria(List switchCriteria) { + this.switchCriteria = switchCriteria; + } + + @Override + public String getName() { + return NAME; + } +} diff --git a/security-analysis/mixed-security-analysis-api/src/test/java/com/powsybl/mixed/security/analysis/MixedSecurityAnalysisProviderTest.java b/security-analysis/mixed-security-analysis-api/src/test/java/com/powsybl/mixed/security/analysis/MixedSecurityAnalysisProviderTest.java new file mode 100644 index 00000000000..5e6e95748e3 --- /dev/null +++ b/security-analysis/mixed-security-analysis-api/src/test/java/com/powsybl/mixed/security/analysis/MixedSecurityAnalysisProviderTest.java @@ -0,0 +1,53 @@ +package com.powsybl.mixed.security.analysis; + +import com.powsybl.contingency.ContingenciesProvider; +import com.powsybl.iidm.network.Network; +import com.powsybl.mixed.security.analysis.parameters.MixedModeParametersExtension; +import com.powsybl.security.SecurityAnalysisParameters; +import com.powsybl.security.SecurityAnalysisRunParameters; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MixedSecurityAnalysisProviderTest { + @Mock + private Network network; + @Mock + private ContingenciesProvider contingenciesProvider; + @Mock + private SecurityAnalysisRunParameters runParameters; + private MixedSecurityAnalysisProvider provider; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + provider = new MixedSecurityAnalysisProvider(); + } + + @Test + void testProviderName() { + assertEquals("MixedSecurityAnalysis", provider.getName()); + } + + @Test + void testProviderVersion() { + String version = provider.getVersion(); + assertNotNull(version); + assertFalse(version.isEmpty()); + } + + @Test + void testRunMissingExtensionThrows() { + SecurityAnalysisParameters mockSaParams = mock(SecurityAnalysisParameters.class); + when(runParameters.getSecurityAnalysisParameters()).thenReturn(mockSaParams); + when(mockSaParams.getExtension(MixedModeParametersExtension.class)).thenReturn(null); + assertThrows(Exception.class, () -> + provider.run(network, "main", contingenciesProvider, runParameters).join() + ); + } +} diff --git a/security-analysis/mixed-security-analysis-api/src/test/java/com/powsybl/mixed/security/analysis/MixedSecurityAnalysisTest.java b/security-analysis/mixed-security-analysis-api/src/test/java/com/powsybl/mixed/security/analysis/MixedSecurityAnalysisTest.java new file mode 100644 index 00000000000..e281ffdf547 --- /dev/null +++ b/security-analysis/mixed-security-analysis-api/src/test/java/com/powsybl/mixed/security/analysis/MixedSecurityAnalysisTest.java @@ -0,0 +1,264 @@ +package com.powsybl.mixed.security.analysis; + +import com.powsybl.commons.report.ReportNode; +import com.powsybl.commons.report.TypedValue; +import com.powsybl.contingency.ContingenciesProvider; +import com.powsybl.contingency.Contingency; +import com.powsybl.iidm.network.Network; +import com.powsybl.iidm.network.test.EurostagTutorialExample1Factory; +import com.powsybl.loadflow.LoadFlowParameters; +import com.powsybl.mixed.security.analysis.parameters.MixedModeParametersExtension; +import com.powsybl.security.*; +import com.powsybl.security.results.ConnectivityResult; +import com.powsybl.security.results.NetworkResult; +import com.powsybl.security.results.PostContingencyResult; +import com.powsybl.security.results.PreContingencyResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static com.powsybl.loadflow.LoadFlowResult.ComponentResult.Status.CONVERGED; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class MixedSecurityAnalysisTest { + @Mock + private Network network; + @Mock + private ContingenciesProvider contingenciesProvider; + @Mock + private SecurityAnalysisRunParameters runParameters; + @Mock + private SecurityAnalysisParameters securityAnalysisParameters; + @Mock + private LoadFlowParameters loadFlowParameters; + @Mock + private SecurityAnalysisProvider staticProvider; + @Mock + private SecurityAnalysisProvider dynamicProvider; + private MixedSecurityAnalysis mixedAnalysis; + private Contingency contingency1; + private Contingency contingency2; + + @BeforeEach + void setUp() { + ReportNode reportNode = ReportNode.newRootReportNode() + .withSeverity(TypedValue.TRACE_SEVERITY) + .withMessageTemplate("test") + .build(); + MockitoAnnotations.openMocks(this); + runParameters.setReportNode(reportNode); + when(runParameters.getSecurityAnalysisParameters()).thenReturn(securityAnalysisParameters); + when(securityAnalysisParameters.getLoadFlowParameters()).thenReturn(loadFlowParameters); + when(staticProvider.getName()).thenReturn("OpenLoadFlow"); + when(dynamicProvider.getName()).thenReturn("dynaFlow"); + MixedModeParametersExtension extension = new MixedModeParametersExtension(); + extension.setStaticSimulator("OpenLoadFlow"); + extension.setDynamicSimulator("dynaFlow"); + extension.setSwitchCriteria(Collections.singletonList("FAILED")); + contingency1 = mock(Contingency.class); + when(contingency1.getId()).thenReturn("contingency-1"); + contingency2 = mock(Contingency.class); + when(contingency2.getId()).thenReturn("contingency-2"); + mixedAnalysis = new MixedSecurityAnalysis(network, "main", contingenciesProvider, + runParameters, extension, + Arrays.asList(staticProvider, dynamicProvider)); + } + + @Test + void testRunSuccessfulNoSwitchNeeded() { + List allContingencies = Arrays.asList(contingency1, contingency2); + when(contingenciesProvider.getContingencies(network)).thenReturn(allContingencies); + PostContingencyResult staticResult1 = createPostContingencyResult(contingency1, true, 0); + PostContingencyResult staticResult2 = createPostContingencyResult(contingency2, true, 0); + PreContingencyResult preResult = new PreContingencyResult(CONVERGED, + new LimitViolationsResult(Collections.emptyList()), + NetworkResult.empty(), Double.NaN); + SecurityAnalysisReport staticReport = new SecurityAnalysisReport( + new SecurityAnalysisResult(preResult, + Arrays.asList(staticResult1, staticResult2), + Collections.emptyList()) + ); + when(staticProvider.run(any(), any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(staticReport)); + + SecurityAnalysisReport report = mixedAnalysis.run().join(); + + verify(staticProvider).run(network, "main", contingenciesProvider, runParameters); + verify(dynamicProvider, never()).run(any(), any(), any(), any()); + assertEquals(2, report.getResult().getPostContingencyResults().size()); + } + + @Test + void testRunWithSwitchOnNotConverged() { + List allContingencies = Arrays.asList(contingency1, contingency2); + when(contingenciesProvider.getContingencies(network)).thenReturn(allContingencies); + // contingency1 fails static → triggers switch to dynamic + PostContingencyResult staticResult1 = createPostContingencyResult(contingency1, false, 0); + PostContingencyResult staticResult2 = createPostContingencyResult(contingency2, true, 0); + PreContingencyResult preResult = new PreContingencyResult(CONVERGED, + new LimitViolationsResult(Collections.emptyList()), + NetworkResult.empty(), Double.NaN); + SecurityAnalysisReport staticReport = new SecurityAnalysisReport( + new SecurityAnalysisResult(preResult, + Arrays.asList(staticResult1, staticResult2), + Collections.emptyList()) + ); + PostContingencyResult dynamicResult1 = createPostContingencyResult(contingency1, true, 0); + SecurityAnalysisReport dynamicReport = new SecurityAnalysisReport( + new SecurityAnalysisResult(preResult, + Collections.singletonList(dynamicResult1), + Collections.emptyList()) + ); + when(staticProvider.run(any(), any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(staticReport)); + when(dynamicProvider.run(any(), any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(dynamicReport)); + + SecurityAnalysisReport report = mixedAnalysis.run().join(); + + verify(staticProvider).run(network, "main", contingenciesProvider, runParameters); + verify(dynamicProvider).run(any(), any(), any(), any()); + assertEquals(2, report.getResult().getPostContingencyResults().size()); + } + + @Test + void testRunMergesResultsProperly() { + List allContingencies = Arrays.asList(contingency1, contingency2); + when(contingenciesProvider.getContingencies(network)).thenReturn(allContingencies); + // contingency1: static FAILED (1 violation), dynamic CONVERGED (2 violations) + PostContingencyResult staticResult1 = createPostContingencyResult(contingency1, false, 1); + PostContingencyResult staticResult2 = createPostContingencyResult(contingency2, true, 0); + PreContingencyResult preResult = new PreContingencyResult(CONVERGED, + new LimitViolationsResult(Collections.emptyList()), + NetworkResult.empty(), Double.NaN); + SecurityAnalysisReport staticReport = new SecurityAnalysisReport( + new SecurityAnalysisResult(preResult, + Arrays.asList(staticResult1, staticResult2), + Collections.emptyList()) + ); + PostContingencyResult dynamicResult1 = createPostContingencyResult(contingency1, true, 2); + SecurityAnalysisReport dynamicReport = new SecurityAnalysisReport( + new SecurityAnalysisResult(preResult, + Collections.singletonList(dynamicResult1), + Collections.emptyList()) + ); + when(staticProvider.run(any(), any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(staticReport)); + when(dynamicProvider.run(any(), any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(dynamicReport)); + + SecurityAnalysisReport report = mixedAnalysis.run().join(); + + List results = report.getResult().getPostContingencyResults(); + assertEquals(2, results.size()); + PostContingencyResult result1 = results.stream() + .filter(r -> r.getContingency().getId().equals("contingency-1")) + .findFirst() + .orElseThrow(); + // dynamic result overrides static: CONVERGED status and 2 violations win + assertSame(PostContingencyComputationStatus.CONVERGED, result1.getStatus()); + assertEquals(2, result1.getLimitViolationsResult().getLimitViolations().size()); + } + + @Test + void testRunHandlesException() { + List allContingencies = Collections.singletonList(contingency1); + when(contingenciesProvider.getContingencies(network)).thenReturn(allContingencies); + when(staticProvider.run(any(), any(), any(), any())) + .thenReturn(CompletableFuture.failedFuture(new RuntimeException("Analysis failed"))); + + SecurityAnalysisReport report = mixedAnalysis.run().join(); + + assertNotNull(report); + assertEquals(0, report.getResult().getPostContingencyResults().size()); + } + + @Test + void testSwitchOnSpsTriggered() { + // 1. Load the network + Network network = EurostagTutorialExample1Factory.create(); + + // 2. Define a simple contingency that will be tested + Contingency simpleContingency = Contingency.line("NHV1_NHV2_2"); + when(contingenciesProvider.getContingencies(network)).thenReturn(Collections.singletonList(simpleContingency)); + + // 3. Configure the mixed analysis to switch on the 'SPS_TRIGGERED' status + MixedModeParametersExtension ext = new MixedModeParametersExtension(); + ext.setStaticSimulator("OpenLoadFlow"); + ext.setDynamicSimulator("dynaFlow"); + ext.setSwitchCriteria(Collections.singletonList("SPS_TRIGGERED")); + + // 4. Create the MixedSecurityAnalysis instance with the network and mock providers + MixedSecurityAnalysis analysis = new MixedSecurityAnalysis( + network, "InitialState", contingenciesProvider, + runParameters, ext, + Arrays.asList(staticProvider, dynamicProvider)); + + // 5. Mock the static provider to return a result with 'SPS_TRIGGERED' status + PostContingencyResult staticSpsResult = new PostContingencyResult( + simpleContingency, + PostContingencyComputationStatus.SPS_TRIGGERED, + new LimitViolationsResult(Collections.emptyList()), + NetworkResult.empty(), + ConnectivityResult.empty(), + Double.NaN + ); + SecurityAnalysisReport staticReport = new SecurityAnalysisReport( + new SecurityAnalysisResult(new PreContingencyResult(CONVERGED, new LimitViolationsResult(Collections.emptyList()), NetworkResult.empty(), Double.NaN), + Collections.singletonList(staticSpsResult), Collections.emptyList()) + ); + when(staticProvider.run(any(Network.class), anyString(), any(ContingenciesProvider.class), any(SecurityAnalysisRunParameters.class))) + .thenReturn(CompletableFuture.completedFuture(staticReport)); + + // 6. Mock the dynamic provider to return a successful 'CONVERGED' result + PostContingencyResult dynamicConvergedResult = createPostContingencyResult(simpleContingency, true, 0); + SecurityAnalysisReport dynamicReport = new SecurityAnalysisReport( + new SecurityAnalysisResult(new PreContingencyResult(CONVERGED, new LimitViolationsResult(Collections.emptyList()), NetworkResult.empty(), Double.NaN), + Collections.singletonList(dynamicConvergedResult), Collections.emptyList()) + ); + when(dynamicProvider.run(any(Network.class), anyString(), any(ContingenciesProvider.class), any(SecurityAnalysisRunParameters.class))) + .thenReturn(CompletableFuture.completedFuture(dynamicReport)); + + // 7. Execute the mixed analysis + SecurityAnalysisReport report = analysis.run().join(); + + // 8. Verify the logic: both providers were called, and the final result is the converged one from the dynamic analysis + verify(staticProvider).run(network, "InitialState", contingenciesProvider, runParameters); + verify(dynamicProvider).run(any(Network.class), anyString(), any(ContingenciesProvider.class), any(SecurityAnalysisRunParameters.class)); + + List results = report.getResult().getPostContingencyResults(); + assertEquals(1, results.size()); + PostContingencyResult finalResult = results.getFirst(); + assertEquals(simpleContingency.getId(), finalResult.getContingency().getId()); + assertSame(PostContingencyComputationStatus.CONVERGED, finalResult.getStatus()); + } + + private PostContingencyResult createPostContingencyResult(Contingency contingency, + boolean converged, + int violationCount) { + PostContingencyComputationStatus status = converged + ? PostContingencyComputationStatus.CONVERGED + : PostContingencyComputationStatus.FAILED; + LimitViolationsResult limitViolations = new LimitViolationsResult( + new ArrayList<>(Collections.nCopies(violationCount, null)) + ); + return new PostContingencyResult( + contingency, + status, + limitViolations, + NetworkResult.empty(), + ConnectivityResult.empty(), + Double.NaN + ); + } +} diff --git a/security-analysis/mixed-security-analysis-api/src/test/java/com/powsybl/mixed/security/analysis/criteria/AnalysisSwitchCriteriaTest.java b/security-analysis/mixed-security-analysis-api/src/test/java/com/powsybl/mixed/security/analysis/criteria/AnalysisSwitchCriteriaTest.java new file mode 100644 index 00000000000..eb31947e5c2 --- /dev/null +++ b/security-analysis/mixed-security-analysis-api/src/test/java/com/powsybl/mixed/security/analysis/criteria/AnalysisSwitchCriteriaTest.java @@ -0,0 +1,136 @@ +package com.powsybl.mixed.security.analysis.criteria; + +import com.powsybl.contingency.Contingency; +import com.powsybl.contingency.violations.LimitViolation; +import com.powsybl.mixed.security.analysis.parameters.MixedModeParametersExtension; +import com.powsybl.security.LimitViolationsResult; +import com.powsybl.security.PostContingencyComputationStatus; +import com.powsybl.security.results.ConnectivityResult; +import com.powsybl.security.results.NetworkResult; +import com.powsybl.security.results.PostContingencyResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AnalysisSwitchCriteriaTest { + private MixedModeParametersExtension extension; + private AnalysisSwitchCriteria criteria; + private PostContingencyResult result; + private Contingency contingency; + + @BeforeEach + void setUp() { + extension = new MixedModeParametersExtension(); + extension.setStaticSimulator("load-flow"); + extension.setDynamicSimulator("dynaflow"); + contingency = Mockito.mock(Contingency.class); + when(contingency.getId()).thenReturn("contingency-1"); + criteria = new AnalysisSwitchCriteria(extension); + } + + @Test + void testNullExtensionThrows() { + assertThrows(NullPointerException.class, () -> new AnalysisSwitchCriteria(null)); + } + + @Test + void testNullResultThrows() { + assertThrows(NullPointerException.class, () -> criteria.evaluate(null)); + } + + @Test + void testNoCriteriaDefinedReturnsNoSwitch() { + extension.setSwitchCriteria(null); + result = createPostContingencyResult(true, 0); + SwitchDecision decision = criteria.evaluate(result); + assertFalse(decision.shouldSwitch()); + assertEquals("No criteria defined", decision.getReason()); + } + + @Test + void testEmptyCriteriaReturnsNoSwitch() { + extension.setSwitchCriteria(Collections.emptyList()); + result = createPostContingencyResult(true, 0); + SwitchDecision decision = criteria.evaluate(result); + assertFalse(decision.shouldSwitch()); + assertEquals("No criteria defined", decision.getReason()); + } + + @Test + void testFailedCriteriaTriggersSwitch() { + extension.setSwitchCriteria(Collections.singletonList("FAILED")); + result = createPostContingencyResult(false, 0); + SwitchDecision decision = criteria.evaluate(result); + assertTrue(decision.shouldSwitch()); + assertTrue(decision.getReason().contains("FAILED")); + } + + @Test + void testFailedCriteriaNoSwitchWhenConverged() { + extension.setSwitchCriteria(Collections.singletonList("FAILED")); + result = createPostContingencyResult(true, 0); + SwitchDecision decision = criteria.evaluate(result); + assertFalse(decision.shouldSwitch()); + assertEquals("No criteria met", decision.getReason()); + } + + @Test + void testLimitViolationsCriteriaTriggersSwitch() { + extension.setSwitchCriteria(Collections.singletonList("LIMIT_VIOLATIONS")); + result = createPostContingencyResult(true, 1); + SwitchDecision decision = criteria.evaluate(result); + assertTrue(decision.shouldSwitch()); + assertTrue(decision.getReason().contains("LIMIT_VIOLATIONS")); + } + + @Test + void testLimitViolationsCriteriaNoSwitchWithoutViolations() { + extension.setSwitchCriteria(Collections.singletonList("LIMIT_VIOLATIONS")); + result = createPostContingencyResult(true, 0); + SwitchDecision decision = criteria.evaluate(result); + assertFalse(decision.shouldSwitch()); + assertEquals("No criteria met", decision.getReason()); + } + + @Test + void testMultipleCriteria() { + extension.setSwitchCriteria(Arrays.asList("FAILED", "LIMIT_VIOLATIONS")); + result = createPostContingencyResult(true, 1); + SwitchDecision decision = criteria.evaluate(result); + assertTrue(decision.shouldSwitch()); + } + + @Test + void testUnknownCriteriaIgnored() { + extension.setSwitchCriteria(Collections.singletonList("UNKNOWN_CRITERION")); + result = createPostContingencyResult(true, 0); + SwitchDecision decision = criteria.evaluate(result); + assertFalse(decision.shouldSwitch()); + assertEquals("No criteria met", decision.getReason()); + } + + private PostContingencyResult createPostContingencyResult(boolean converged, int violationCount) { + PostContingencyComputationStatus status = converged + ? PostContingencyComputationStatus.CONVERGED + : PostContingencyComputationStatus.FAILED; + LimitViolationsResult limitViolations = Mockito.mock(LimitViolationsResult.class); + when(limitViolations.getLimitViolations()).thenReturn( + Collections.nCopies(violationCount, mock(LimitViolation.class)) + ); + return new PostContingencyResult( + contingency, + status, + limitViolations, + NetworkResult.empty(), + ConnectivityResult.empty(), + Double.NaN + ); + } +} diff --git a/security-analysis/mixed-security-analysis-api/src/test/java/com/powsybl/mixed/security/analysis/criteria/SwitchDecisionTest.java b/security-analysis/mixed-security-analysis-api/src/test/java/com/powsybl/mixed/security/analysis/criteria/SwitchDecisionTest.java new file mode 100644 index 00000000000..207672b8930 --- /dev/null +++ b/security-analysis/mixed-security-analysis-api/src/test/java/com/powsybl/mixed/security/analysis/criteria/SwitchDecisionTest.java @@ -0,0 +1,49 @@ +package com.powsybl.mixed.security.analysis.criteria; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +class SwitchDecisionTest { + + @Test + void testSwitchDecisionTrue() { + SwitchDecision decision = new SwitchDecision(true, "Test reason"); + assertTrue(decision.shouldSwitch()); + assertEquals("Test reason", decision.getReason()); + } + + @Test + void testSwitchDecisionFalse() { + SwitchDecision decision = new SwitchDecision(false, "No switch"); + assertFalse(decision.shouldSwitch()); + assertEquals("No switch", decision.getReason()); + } + + @Test + void testSwitchDecisionNullReasonThrows() { + assertThrows(NullPointerException.class, () -> new SwitchDecision(true, null)); + } + + @Test + void testSwitchDecisionToString() { + SwitchDecision decision = new SwitchDecision(true, "Test reason"); + String str = decision.toString(); + assertNotNull(str); + assertTrue(str.contains("shouldSwitch=true")); + assertTrue(str.contains("Test reason")); + } + + @Test + void testSwitchDecisionEquals() { + SwitchDecision decision1 = new SwitchDecision(true, "Test reason"); + SwitchDecision decision2 = new SwitchDecision(true, "Test reason"); + SwitchDecision decision3 = new SwitchDecision(false, "Test reason"); + assertEquals(decision1, decision2); + assertNotEquals(decision1, decision3); + } + + @Test + void testSwitchDecisionHashCode() { + SwitchDecision decision1 = new SwitchDecision(true, "Test reason"); + SwitchDecision decision2 = new SwitchDecision(true, "Test reason"); + assertEquals(decision1.hashCode(), decision2.hashCode()); + } +} diff --git a/security-analysis/mixed-security-analysis-api/src/test/java/com/powsybl/mixed/security/analysis/parameters/MixedModeParametersExtensionTest.java b/security-analysis/mixed-security-analysis-api/src/test/java/com/powsybl/mixed/security/analysis/parameters/MixedModeParametersExtensionTest.java new file mode 100644 index 00000000000..33c29fd7741 --- /dev/null +++ b/security-analysis/mixed-security-analysis-api/src/test/java/com/powsybl/mixed/security/analysis/parameters/MixedModeParametersExtensionTest.java @@ -0,0 +1,64 @@ +package com.powsybl.mixed.security.analysis.parameters; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import java.util.Arrays; +import java.util.Collections; +import static org.junit.jupiter.api.Assertions.*; +class MixedModeParametersExtensionTest { + private MixedModeParametersExtension extension; + + @BeforeEach + void setUp() { + extension = new MixedModeParametersExtension(); + } + + @Test + void testDefaultValues() { + assertNull(extension.getStaticSimulator()); + assertNull(extension.getDynamicSimulator()); + assertNull(extension.getSwitchCriteria()); + } + + @Test + void testSetGetStaticSimulator() { + extension.setStaticSimulator("load-flow"); + assertEquals("load-flow", extension.getStaticSimulator()); + } + + @Test + void testSetGetDynamicSimulator() { + extension.setDynamicSimulator("dynaflow"); + assertEquals("dynaflow", extension.getDynamicSimulator()); + } + + @Test + void testSetGetSwitchCriteria() { + extension.setSwitchCriteria(Arrays.asList("NON_CONVERGENCE", "LIMIT_VIOLATIONS")); + assertEquals(2, extension.getSwitchCriteria().size()); + assertTrue(extension.getSwitchCriteria().contains("NON_CONVERGENCE")); + assertTrue(extension.getSwitchCriteria().contains("LIMIT_VIOLATIONS")); + } + + @Test + void testSetEmptySwitchCriteria() { + extension.setSwitchCriteria(Collections.emptyList()); + assertNotNull(extension.getSwitchCriteria()); + assertEquals(0, extension.getSwitchCriteria().size()); + } + + @Test + void testSetNullSwitchCriteria() { + extension.setSwitchCriteria(null); + assertNull(extension.getSwitchCriteria()); + } + + @Test + void testCompleteConfiguration() { + extension.setStaticSimulator("load-flow"); + extension.setDynamicSimulator("dynaflow"); + extension.setSwitchCriteria(Collections.singletonList("NON_CONVERGENCE")); + assertEquals("load-flow", extension.getStaticSimulator()); + assertEquals("dynaflow", extension.getDynamicSimulator()); + assertEquals(1, extension.getSwitchCriteria().size()); + } +} diff --git a/security-analysis/pom.xml b/security-analysis/pom.xml index aef1982fef3..c568eeb86d5 100644 --- a/security-analysis/pom.xml +++ b/security-analysis/pom.xml @@ -25,6 +25,7 @@ security-analysis-api + mixed-security-analysis-api diff --git a/security-analysis/security-analysis-api/src/main/java/com/powsybl/security/PostContingencyComputationStatus.java b/security-analysis/security-analysis-api/src/main/java/com/powsybl/security/PostContingencyComputationStatus.java index 65a25fa5235..1ec59fea24d 100644 --- a/security-analysis/security-analysis-api/src/main/java/com/powsybl/security/PostContingencyComputationStatus.java +++ b/security-analysis/security-analysis-api/src/main/java/com/powsybl/security/PostContingencyComputationStatus.java @@ -15,5 +15,6 @@ public enum PostContingencyComputationStatus { MAX_ITERATION_REACHED, SOLVER_FAILED, FAILED, - NO_IMPACT + NO_IMPACT, + SPS_TRIGGERED }