From d88a95050e37593730ce9cd1bfe8bcae12f4f4d3 Mon Sep 17 00:00:00 2001 From: marota Date: Wed, 24 Jun 2026 12:15:59 +0200 Subject: [PATCH] fix(backend): raise diagram-path LF outer-loop cap to 100 + RONCI/BEON reproducibility docs The N-1 diagram / overload path runs create_olf_rte_parameter() (lib make_env_utils), which keeps OpenLoadFlow's stock maxOuterLoopIterations=20. On the full French grid the IncrementalTransformerVoltageControl outer loop needs ~40-50 iterations to settle after tripping P.SAOL31RONCI, so slow-mode AC returned MAX_ITERATION_REACHED / null flows. _run_ac_with_fallback now forces maxOuterLoopIterations=100 -- the single choke point behind every create_olf_rte_parameter() call site (_get_n_variant, _get_contingency_variant, diagram_mixin) -- mirroring the lib analysis path's _create_default_lf_parameters (already 100). Convergence-only: grids that already converged are unchanged and the grid2op analysis path is untouched. Slow mode now converges (BEON L31CPVAN 95.84%, was null flows); fast mode unchanged (95.38%). Docs/script: - docs/architecture/ronci-beon-reproducibility.md: reference values per grid (reduced 98.75% / full 96.10%), the parameters_hades2 SecurityAnalysis recipe, fast/slow modes, the 95.84->96.10% decomposition (LF params / contingencyPropagation / Hades2 solver), the CHALOY631 remedial action, and the pypowsybl-rte / native-Hades2 situation. - scripts/reproduce_ronci_overload.py: reproduces all three references via the Hades2 SecurityAnalysis recipe to <0.04 A. - simulation-pipeline.md + docs/README.md: cross-links and a diagram-path caveat. Co-Authored-By: Claude Opus 4.8 --- docs/README.md | 1 + .../ronci-beon-reproducibility.md | 186 ++++++++++++++ docs/architecture/simulation-pipeline.md | 15 ++ .../services/recommender_service.py | 15 +- scripts/reproduce_ronci_overload.py | 238 ++++++++++++++++++ 5 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/ronci-beon-reproducibility.md create mode 100644 scripts/reproduce_ronci_overload.py diff --git a/docs/README.md b/docs/README.md index 42b878e..72be6ee 100644 --- a/docs/README.md +++ b/docs/README.md @@ -78,6 +78,7 @@ folder's index for the full list. | [`code-quality-analysis.md`](architecture/code-quality-analysis.md) | Continuous audit; latest deltas (§14–15) cover the 0.7.0 release + the function-LoC ceiling, postMessage envelope, FastAPI return-type follow-ups. | | [`development-cycle.md`](architecture/development-cycle.md) | Chronological retrospective covering the four development phases through 0.7.0 (minimal end-to-end → features → consolidation → PyPSA-EUR + interactive overflow). | | [`simulation-pipeline.md`](architecture/simulation-pipeline.md) | End-to-end backend simulation pipeline: state lifecycle, two-step analysis, manual-action flow, superposition, available modes, hypotheses, UI → config mapping. Companion to the lib's `docs/architecture/simulation-pipeline.md` (physical / numerical layer). | +| [`ronci-beon-reproducibility.md`](architecture/ronci-beon-reproducibility.md) | Calibration case `P.SAOL31RONCI` → `BEON L31CPVAN` overload: reference values per grid (reduced 98.75 % / full 96.1 %), the Hades2 SecurityAnalysis recipe, fast/slow modes, the full-grid slow-mode divergence (20→100 outer-iter fix) and the `CHALOY631` remedial action. Reproduced by `scripts/reproduce_ronci_overload.py`. | ## Proposals (`proposals/`) diff --git a/docs/architecture/ronci-beon-reproducibility.md b/docs/architecture/ronci-beon-reproducibility.md new file mode 100644 index 0000000..d19aae9 --- /dev/null +++ b/docs/architecture/ronci-beon-reproducibility.md @@ -0,0 +1,186 @@ +# Reproducibility — P.SAOL31RONCI / BEON L31CPVAN overload + +Canonical calibration case for the load-flow pipeline: tripping line +**`P.SAOL31RONCI`** overloads line **`BEON L31CPVAN`** (IST permanent +current limit **236 A**, side TWO). This note records the *reference* +values, the recipe that reproduces them, and how the live app's +**fast / slow** simulation modes relate to them. + +Companion script: [`scripts/reproduce_ronci_overload.py`](../../scripts/reproduce_ronci_overload.py) +(`python scripts/reproduce_ronci_overload.py` → `ALL REFERENCES REPRODUCED`). +For the LF mechanics see [`architecture/simulation-pipeline.md`](simulation-pipeline.md) +and the lib's sister doc `Expert_op4grid_recommender/docs/architecture/simulation-pipeline.md`. + +## TL;DR + +| What you observe | Why | +|---|---| +| Two different "reference" numbers (96.1 % vs 98.75 %) | They are on **two different grids**. The reduced `config_small_grid` grid (93 VL) gives **98.75 %**; the full French grid (6835 VL) gives **96.1 %**. Same contingency, different network. | +| App shows 95.4 % on the full grid (fast mode) | *Fast* mode disables transformer + shunt voltage control. | +| Full grid N-1 **diverges / null flows** without fast mode | The diagram path's LF params (`create_olf_rte_parameter`) keep the **stock 20 `maxOuterLoopIterations`** cap. The French grid needs ~40–50 outer iterations after the trip → `MAX_ITERATION_REACHED`. Raise the cap to **100** and it converges (→ 95.84 %). | +| Exact reference (96.1 %, 226.78 A) | A pypowsybl **SecurityAnalysis** + the **`parameters_hades2`** bundle reproduces **96.08 %** (OpenLoadFlow). The last 0.02 % to 96.10 % is the real **Hades2** solver (`pypowsybl-rte`). The SA's extra +0.09 % over a plain `run_ac` is **`contingencyPropagation`** (breaker-level trip), *not* a load-flow parameter — see the decomposition below. | + +## The reference values + +The operator's reference JSON (keys `violations` / `limitReduction` / +`extensions.ActivePower` / `operator_strategy`) is a serialised pypowsybl +`SecurityAnalysis` result. BEON L31CPVAN, side TWO, against the 236 A limit +(pre-contingency active power is 13.493 MW in every case): + +| Grid | State | current | loading | post active | +|---|---|---|---|---| +| reduced (93 VL, `config_small_grid`) | N-1 | 233.050 A | 98.75 % | 25.704 MW | +| full (6835 VL, `bare_env_20240828T0100Z`) | N-1 | 226.785 A | 96.10 % | 24.972 MW | +| full (6835 VL) | N-1 + open transformer `CHALOY631` | 226.634 A | 96.03 % | 24.947 MW | + +`scripts/reproduce_ronci_overload.py` reproduces all three via the Hades2 +SecurityAnalysis recipe to within **0.04 A** (the residual is OpenLoadFlow's +Hades2 emulation vs the actual Hades2 solver). + +### The reference recipe (`parameters_hades2`) + +```python +pp.loadflow.Parameters( + use_reactive_limits=True, + transformer_voltage_control_on=True, + distributed_slack=False, # <- single slack (Hades2-style) + twt_split_shunt_admittance=True, + voltage_init_mode=DC_VALUES, + provider_parameters={ + "slackDistributionFailureBehavior": "FAIL", + "maxOuterLoopIterations": "30", + "transformerVoltageControlMode": "AFTER_GENERATOR_VOLTAGE_CONTROL", + "stateVectorScalingMode": "MAX_VOLTAGE_CHANGE", + "transformerVoltageControlUseInitialTapPosition": "true", + "generatorVoltageControlMinNominalVoltage": "120", + "fictitiousGeneratorVoltageControlCheckMode": "FORCED", + "mostMeshedSlackBusSelectorMaxNominalVoltagePercentile": "100", + }, +) +``` + +The CHALOY631 remedial action is reproduced by adding a SecurityAnalysis +operator strategy on top of the contingency: + +```python +sa.add_terminals_connection_action("open_CHALOY631", "CHALOY631", opening=True) +sa.add_operator_strategy("strat_open_CHALOY631", "P.SAOL31RONCI", + ["open_CHALOY631"], + condition_type=pp.security.ConditionType.TRUE_CONDITION) +``` + +## Simulation modes on the full grid (and what each yields) + +All rows below are BEON L31CPVAN, N-1 (`P.SAOL31RONCI` tripped), full grid, +pypowsybl 1.15. "Slow" = full transformer + shunt voltage control; "Fast" = +those two outer loops disabled. + +| Recipe / mode | Converges? | loading | post MW | note | +|---|---|---|---|---| +| **Fast** (xfo/shunt VC off) | yes | **95.38 %** | 25.22 | what the live app shows today | +| **Slow, 20 outer iter** (stock OLF default) | **no — MAX_ITERATION → null flows** | — | — | the divergence the operator hit (pre-fix) | +| **Slow, 100 outer iter** (now the diagram-path default) | yes | **95.84 %** | 25.03 | the shipped fix | +| `parameters_hades2` LF params, terminal-only disconnect | yes | 95.99 % | 24.95 | LF params only, `run_ac` | +| `parameters_hades2` + `contingencyPropagation` (SecurityAnalysis) | yes | **96.08 %** | 24.97 | the reproducible reference (OpenLoadFlow) | +| **Hades2 provider** (`pypowsybl-rte`) | — | **96.10 %?** | 24.97 | the actual RTE reference solver — see below | + +### Decomposition of the 95.84 % → 96.10 % gap (full grid, all factors verified) + +The gap between the shipped slow-mode result and the reference is **three +small, independent effects** — and, importantly, **none of the last two is a +`run_ac` load-flow parameter**: + +| Step | What changes | loading | Δ | +|---|---|---|---| +| `create_olf_rte` (+100 iter), terminal-only trip, OpenLoadFlow | baseline (shipped) | 95.84 % | — | +| → `parameters_hades2` **LF params** | the Hades2 voltage-control bundle: shunt + PST control **off**, most-meshed-over-all-VL slack, `AFTER_GENERATOR_VOLTAGE_CONTROL` / initial-tap / `MAX_VOLTAGE_CHANGE` tuning. **NOT** `distributed_slack` — toggling it True/False changes nothing on this grid (verified). | 95.99 % | +0.15 % | +| → `contingencyPropagation=true` (**contingency model**) | the SecurityAnalysis trips the line at **breaker level**: it propagates the disconnection through closed switches and opens the full electrical node (`P.SAOP3` node 6 / `RONCIP3` node 4), not just the line's two terminals. | 96.08 % | +0.09 % | +| → **real Hades2 solver** (`pypowsybl-rte`) | OpenLoadFlow's Hades2-*emulation* → the actual Hades2 Newton-Raphson engine. | 96.10 %? | +0.02 % | + +1. **Outer-loop iteration cap (convergence, factor 0).** `create_olf_rte_parameter` + (`make_env_utils`, the backend **diagram / overload** path) does **not** set + `maxOuterLoopIterations`, so it inherited OpenLoadFlow's stock **20**. On the + French grid the `IncrementalTransformerVoltageControl` outer loop needs + ~40–50 iterations to settle after the trip → `MAX_ITERATION_REACHED` / null + flows. The lib's `_create_default_lf_parameters` (grid2op **analysis** path) + already uses **100** (lib `simulation-pipeline.md` §3.6). **Shipped fix:** + `_run_ac_with_fallback` now forces 100. + +2. **`contingencyPropagation` — the SA-vs-`run_ac` gap is *entirely* this.** + With the *same* `parameters_hades2`, a pypowsybl `SecurityAnalysis` and a + manual `run_ac` on a disconnected variant differ by exactly the SA provider + parameter `contingencyPropagation` (default **true**): + + | mechanism | loading | + |---|---| + | SA, `contingencyPropagation=true` (default) | **96.08 %** (226.750 A) | + | SA, `contingencyPropagation=false` | 95.98 % (226.522 A) | + | `run_ac` on opened terminals / `remove_elements` | 95.98–95.99 % (226.52 A) | + + So this is a **contingency-modelling** choice internal to the SecurityAnalysis, + **not** a load-flow parameter — there is no `lf.Parameters` knob that closes it. + + **Can the +0.1 % be replicated with a manual breaker trip? No (tested).** The + line sits behind a breaker `P.SAO3RONCI.1 DJ_OC` at `P.SAOP3` (node 6↔7) and a + load-break switch `RONCI3P.SAO.1 SRB.1_OC` at `RONCIP3` (node 4↔6). Opening + **both** bay switches via `run_ac` (a clean breaker trip) gives **226.524 A / + 95.98 %** — i.e. *identical* to the app's current terminal-only + `update_lines(connected1/2=False)` (226.527 A). So the app is **not** + under-disconnecting. The SA's `contingencyPropagation=true` value (226.752 A) is + only approached by opening the `RONCIP3` switch **alone** (226.730 A), which + leaves the line **dangling and energised** at `P.SAOP3` (its shunt still + injecting) — a physically invalid trip state, not a faithful model. No + neighbouring load / generator / branch sits on the bay nodes, so propagation + trips nothing extra. **Conclusion:** the +0.1 % is a SecurityAnalysis-internal + numerical artefact of how the post-contingency `LfNetwork` is built, *not* a + reproducible clean topology operation. The grid2op / `run_ac` value + (95.98–95.99 %) is the physically-correct clean-trip result. + + To get the SA figure in the app you must therefore **run a SecurityAnalysis** + for the contingency (which bakes in `contingencyPropagation`), not patch the + disconnection. On this grid `n.disconnect("P.SAOL31RONCI")` returns `False` + (no-op) anyway, so the backend's `_apply_contingency_element` already falls back + to terminal-only `update_lines`. + +3. **Hades2 provider (`pypowsybl-rte`).** The reference JSON was produced by + RTE's **Hades2** solver. `pypowsybl-rte` registers a `Hades2` load-flow / + security provider, but the **native Hades2 module must be installed and + declared** in `~/.itools/config.yml` (a `hades2:` section pointing at its + `homeDir`). Without it, `provider="Hades2"` raises + `PyPowsyblError: hades2 module is absent of the platform config`. The figures + in the "Hades2 provider" rows above are **to be filled in from a properly + configured Hades2 run** — `parameters_hades2` is OpenLoadFlow's *emulation* of + it and lands at 96.08 %, ~0.02 % shy of the 96.10 % reference. + + Installing `pypowsybl-rte` does **not** change anything for OpenLoadFlow: + verified that the LF/SA **default provider stays `OpenLoadFlow`** (it only + *adds* `Hades2` to the provider list), and the OpenLoadFlow figures are + byte-identical with or without `pypowsybl-rte` (reduced N-1 233.04 A, full + N-1 226.75 A, full + CHALOY631 226.60 A). So the reproduction script runs + unchanged; only an actual configured Hades2 module would move the last + ~0.02 %. + +## How to reach the reference in the live app + +| Goal | Change | Result | +|---|---|---| +| Stop the full-grid slow-mode divergence | **Shipped.** `recommender_service._run_ac_with_fallback` now forces `maxOuterLoopIterations=100` on the diagram-path params (the single choke point behind all `create_olf_rte_parameter()` call sites in `_get_n_variant` / `_get_contingency_variant` / `diagram_mixin`). Convergence-only; grids that already converged are unchanged, and the grid2op analysis path is untouched (the lib already uses 100). | Slow mode converges → **95.84 %** | +| Close most of the gap (LF params) | Swap the diagram-path params to `parameters_hades2` (the Hades2 voltage-control bundle). | **95.99 %** | +| Match the SecurityAnalysis reference (OpenLoadFlow) | `parameters_hades2` **and run the contingency through a `SecurityAnalysis`** (so `contingencyPropagation` applies). It is *not* reproducible by opening breakers in `run_ac` — a clean breaker trip equals the terminal-only value (tested, see factor 2). It needs the SA mechanism. | **96.08 %** | +| Match the RTE reference exactly (96.10 %) | Run the **Hades2 provider** (`pypowsybl-rte` + the native Hades2 module configured in `~/.itools/config.yml`). `parameters_hades2` is OpenLoadFlow's emulation of it. | **96.10 %** (TBC — see § factor 3) | + +The small grid (`config_small_grid`) is deliberately **left as-is** — it is a +93-VL Dijon reduction whose post-contingency redistribution does not preserve +the full-grid flow (it reads 97.9 % live / 98.75 % under the Hades2 SA +reference). See the script header for the full rationale. + +## Provenance + +- Reference grid & recipe trace back to the recommender notebook + `analyse_bare_grid_env.ipynb` (full grid `bare_env_20240828T0100Z`, + `parameters_hades2`, `pp.security` SecurityAnalysis). +- pypowsybl **1.14** and **1.15** produce identical flows here — the LF + version is **not** a factor. The factors are: the grid (reduced vs full), + the outer-iteration cap (convergence), the Hades2 LF-parameter bundle, the + `contingencyPropagation` contingency model, and finally the Hades2 vs + OpenLoadFlow solver. diff --git a/docs/architecture/simulation-pipeline.md b/docs/architecture/simulation-pipeline.md index 051d3af..d60dc57 100644 --- a/docs/architecture/simulation-pipeline.md +++ b/docs/architecture/simulation-pipeline.md @@ -316,6 +316,21 @@ When to flip the UI checkbox off: `DC_VALUES` fallback is the closest you can get to the lib's reference `run_load_flow` semantics. +> **Caveat — the diagram / overload path on large grids.** The N-1 +> *diagram* path (`recommender_service._get_contingency_variant` → +> `_run_ac_with_fallback`) runs `create_olf_rte_parameter()` from the +> lib's `make_env_utils`, which keeps OpenLoadFlow's **stock 20 +> `maxOuterLoopIterations`** — unlike the analysis path's +> `_create_default_lf_parameters` (raised to **100**, lib doc § 3.6). +> On the full French grid this made **slow mode diverge** +> (`MAX_ITERATION_REACHED` → null flows) for `P.SAOL31RONCI`, while +> fast mode converges at 95.4 %. **`_run_ac_with_fallback` now forces +> `maxOuterLoopIterations=100`** so the diagram path converges in slow +> mode too (95.84 %). The exact Hades2 reference (96.1 %) still needs +> `pypowsybl-rte` / the `parameters_hades2` recipe and +> `SecurityAnalysis`. Full write-up + reproduction: +> [`ronci-beon-reproducibility.md`](ronci-beon-reproducibility.md). + ## 5. Manual action simulation `POST /api/simulate-manual-action {action_id, disconnected_elements, action_content?, lines_overloaded?, target_mw?, target_tap?}` diff --git a/expert_backend/services/recommender_service.py b/expert_backend/services/recommender_service.py index cdb0347..5a4724f 100644 --- a/expert_backend/services/recommender_service.py +++ b/expert_backend/services/recommender_service.py @@ -526,7 +526,20 @@ def update_config(self, settings) -> None: def _run_ac_with_fallback(self, network, params): import pypowsybl.loadflow as lf - + + # Raise OpenLoadFlow's outer-loop iteration cap on the diagram / overload + # path. create_olf_rte_parameter() (make_env_utils) inherits the stock + # maxOuterLoopIterations=20; on the full French grid the + # IncrementalTransformerVoltageControl outer loop needs ~40-50 iterations + # to settle after a contingency, so slow-mode AC otherwise returns + # MAX_ITERATION_REACHED / null flows (reproduced on P.SAOL31RONCI). Mirror + # the lib analysis-path default of 100 (lib + # docs/architecture/simulation-pipeline.md §3.6; see + # docs/architecture/ronci-beon-reproducibility.md). + provider = dict(params.provider_parameters) + provider["maxOuterLoopIterations"] = "100" + params.provider_parameters = provider + is_fast_mode = getattr(config, 'PYPOWSYBL_FAST_MODE', False) if is_fast_mode: fast_params = lf.Parameters.from_json(params.to_json()) diff --git a/scripts/reproduce_ronci_overload.py b/scripts/reproduce_ronci_overload.py new file mode 100644 index 0000000..6fafb1d --- /dev/null +++ b/scripts/reproduce_ronci_overload.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025-2026, RTE (https://www.rte-france.com) +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 + +"""Reproducibility check: BEON L31CPVAN overload on contingency P.SAOL31RONCI. + +Reproduces the *reference* post-contingency loading of line ``BEON L31CPVAN`` +when ``P.SAOL31RONCI`` is tripped — on the reduced (``config_small_grid``) grid, +on the full French snapshot, and with the ``CHALOY631`` transformer-opening +remedial action applied after the contingency. + +See ``docs/architecture/ronci-beon-reproducibility.md`` for the full write-up. + +Background +---------- +The reference values come from a pypowsybl ``SecurityAnalysis`` run with the +"Hades2-mimicking" OpenLoadFlow parameters (``parameters_hades2`` below) — this +is the recipe the previous reproducibility scripts / ``analyse_bare_grid_env`` +notebook used. The JSON the operator reports ("violations" / "limitReduction" +/ "extensions.ActivePower" / "operator_strategy") is precisely a serialised +``SecurityAnalysis`` result, so reproducing it means re-running a +``SecurityAnalysis`` with the same parameters — NOT the product's grid2op +``obs.simulate`` pipeline. + +References (BEON L31CPVAN, side TWO, against the 236 A IST permanent limit): + + grid state i (A) load P (MW) + --------------- ---------------------------- -------- ------- -------- + reduced (93 VL) N-1 233.050 98.75 % 25.704 + full (6835 VL) N-1 226.785 96.10 % 24.972 + full (6835 VL) N-1 + open CHALOY631 226.634 96.03 % 24.947 + (pre-contingency active power on all grids: 13.493 MW) + +Why the live app differs +------------------------ +The Co-Study4Grid backend detects the overload through the recommender's +grid2op ``obs.simulate`` path (a full AC re-run) using the library's RTE +load-flow parameters. Two things differ from the reference recipe: + + 1. *Slack distribution.* The RTE parameters pin the slack to bus + ``N.SE1P1_0#0`` with ``distributed_slack=True``. That bus node exists in + NEITHER grid, so OpenLoadFlow silently falls back to its default slack + selection. The Hades2 recipe uses a single slack + (``distributed_slack=False``) / most-meshed-over-all-VLs selection. This + is the dominant term. + 2. *Mechanism / convergence.* On the full grid, the product's *slow* AC + re-run (full transformer + shunt voltage control, default 20 outer + iterations) does NOT converge — it returns MAX_ITERATION_REACHED / null + flows. The Hades2 recipe converges thanks to ``maxOuterLoopIterations=30`` + + ``transformerVoltageControlUseInitialTapPosition=true`` + + ``AFTER_GENERATOR_VOLTAGE_CONTROL`` + ``MAX_VOLTAGE_CHANGE`` scaling. + +Live-app numbers on the full grid: + * RTE *fast* (xfo/shunt control off) -> 95.4 % (converges) + * RTE *slow* (full control) -> diverges (null flows) + * Hades2 recipe -> 95.99 % (converges; closest to ref) + +Usage +----- + python scripts/reproduce_ronci_overload.py + +By default the reduced grid path is read from ``config_small_grid.json``. Point +``--full-grid`` at the full snapshot to also reproduce the 96.1 % reference and +the CHALOY631 remedial-action value. +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +import pypowsybl as pp +import pypowsybl.loadflow as lf +import pypowsybl.network as pn + +CONTINGENCY = "P.SAOL31RONCI" +MONITORED_LINE = "BEON L31CPVAN" +IST_LIMIT_A = 236.0 # permanent (IST) current limit on BEON L31CPVAN +REMEDIAL_TRANSFORMER = "CHALOY631" # 2-winding transformer opened as a post-N-1 action + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_CONFIG = PROJECT_ROOT / "config_small_grid.json" +# Full French snapshot that yields the 96.1 % reference (6835 VLs). Optional. +DEFAULT_FULL_GRID = Path( + "/home/marotant/dev/Expert_op4grid_recommender/data/bare_env_20240828T0100Z/grid.xiidm" +) + +REF_PRE_CONTINGENCY_MW = 13.4935 +TOL_A = 0.5 # tolerance on the reproduced current (Amps) + +# Reference post-contingency values (BEON L31CPVAN, side TWO) per case. +# key -> (current_a, active_mw, label, remedial_transformer_or_None) +CASES = { + "reduced": (233.05036492893106, 25.70415528794635, "N-1", None), + "full": (226.78494728387966, 24.971643228943947, "N-1", None), + "full_action": (226.63426559954289, 24.947121542168418, + f"N-1 + open {REMEDIAL_TRANSFORMER}", REMEDIAL_TRANSFORMER), +} + + +def parameters_hades2() -> pp.loadflow.Parameters: + """OpenLoadFlow parameters calibrated to mimic RTE's Hades2 reference LF. + + This is the recipe that reproduces the reference ``SecurityAnalysis`` + violations AND converges on the full French grid (where the product's + "slow" RTE recipe hits MAX_ITERATION_REACHED). The decisive deviations from + the product's RTE parameters are ``distributed_slack=False`` plus the + Hades2 slack / voltage-control provider bundle. + """ + return pp.loadflow.Parameters( + use_reactive_limits=True, + transformer_voltage_control_on=True, + distributed_slack=False, + twt_split_shunt_admittance=True, + voltage_init_mode=pp.loadflow.VoltageInitMode.DC_VALUES, + provider_parameters={ + "slackDistributionFailureBehavior": "FAIL", + "maxOuterLoopIterations": "30", + "transformerVoltageControlMode": "AFTER_GENERATOR_VOLTAGE_CONTROL", + "stateVectorScalingMode": "MAX_VOLTAGE_CHANGE", + "transformerVoltageControlUseInitialTapPosition": "true", + "generatorVoltageControlMinNominalVoltage": "120", + "fictitiousGeneratorVoltageControlCheckMode": "FORCED", + # required because of the 0.95 limit reduction + "mostMeshedSlackBusSelectorMaxNominalVoltagePercentile": "100", + }, + ) + + +def reproduce_via_security_analysis(grid_path: Path, remedial_transformer: str | None): + """Run the reference SecurityAnalysis and return BEON L31CPVAN results. + + When ``remedial_transformer`` is given, an operator strategy that opens that + transformer is applied after the contingency (TRUE_CONDITION) and the + post-strategy row is returned. + + Returns ``(pre_active_mw, post_current_a, post_active_mw)``. + """ + n = pn.load(str(grid_path)) + params = parameters_hades2() + lf.run_ac(n, parameters=params) + b = n.get_branches() + pre_active = max(abs(b["p1"].get(MONITORED_LINE, 0.0)), + abs(b["p2"].get(MONITORED_LINE, 0.0))) + + sa = pp.security.create_analysis() + sa.add_single_element_contingency(CONTINGENCY) + sa.add_monitored_elements(branch_ids=[MONITORED_LINE]) + if remedial_transformer: + action_id = f"open_{remedial_transformer}" + sa.add_terminals_connection_action(action_id, remedial_transformer, opening=True) + sa.add_operator_strategy( + f"strat_{action_id}", CONTINGENCY, [action_id], + condition_type=pp.security.ConditionType.TRUE_CONDITION, + ) + + res = sa.run_ac(n, parameters=params) + rows = res.branch_results + rows = rows[rows.index.get_level_values("branch_id") == MONITORED_LINE] + # Last row is the post-strategy state when an action is applied, else the + # post-contingency state. + row = rows.iloc[-1] + post_current = max(abs(row["i1"]), abs(row["i2"])) + post_active = max(abs(row["p1"]), abs(row["p2"])) + return pre_active, post_current, post_active + + +def report(label: str, grid_path: Path, case_key: str) -> bool: + ref_i, ref_p, state, remedial = CASES[case_key] + print("=" * 78) + print(f"[{label}] {state}") + print(f" grid: {grid_path}") + n = pn.load(str(grid_path)) + print(f" voltage levels = {n.get_voltage_levels().shape[0]}, " + f"lines = {n.get_lines().shape[0]}") + pre_mw, cur_a, post_mw = reproduce_via_security_analysis(grid_path, remedial) + loading = cur_a / IST_LIMIT_A * 100 + ref_loading = ref_i / IST_LIMIT_A * 100 + ok = abs(cur_a - ref_i) <= TOL_A + + print(f" contingency : {CONTINGENCY}") + if remedial: + print(f" remedial action : open transformer {remedial}") + print(f" monitored line : {MONITORED_LINE} (IST limit {IST_LIMIT_A:.1f} A)") + print(f" pre-contingency active : {pre_mw:8.3f} MW (ref {REF_PRE_CONTINGENCY_MW:.3f})") + print(f" post active : {post_mw:8.3f} MW (ref {ref_p:.3f})") + print(f" post current : {cur_a:8.3f} A -> {loading:6.2f} %") + print(f" reference current : {ref_i:8.3f} A -> {ref_loading:6.2f} %") + print(f" delta : {cur_a - ref_i:+.3f} A " + f"[{'PASS' if ok else 'FAIL'} @ tol {TOL_A} A]") + print() + return ok + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--config", type=Path, default=DEFAULT_CONFIG, + help="config json holding the reduced-grid network_path") + ap.add_argument("--full-grid", type=Path, default=DEFAULT_FULL_GRID, + help="full French snapshot grid.xiidm (for the 96.1%% refs)") + args = ap.parse_args() + + print(f"pypowsybl {pp.__version__}\n") + results = [] + + # Reduced grid (the config_small_grid case the operator runs). + reduced_path = None + if args.config.exists(): + cfg = json.loads(args.config.read_text()) + reduced_path = Path(cfg.get("network_path", "")) + if reduced_path and reduced_path.exists(): + results.append(report("REDUCED GRID (config_small_grid)", reduced_path, "reduced")) + else: + print(f"[skip] reduced grid not found via {args.config}\n") + + # Full grid (reproduces the original 96.1 % reference + the CHALOY631 action). + if args.full_grid and args.full_grid.exists(): + results.append(report("FULL GRID (reference snapshot)", args.full_grid, "full")) + results.append(report("FULL GRID (reference snapshot)", args.full_grid, "full_action")) + else: + print(f"[skip] full grid not found at {args.full_grid}\n") + + if not results: + print("Nothing reproduced — no grid files found.") + return 1 + ok = all(results) + print("=" * 78) + print(f"RESULT: {'ALL REFERENCES REPRODUCED' if ok else 'MISMATCH — see FAIL rows above'}") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main())