From 1bd7543f4fa8ca47c72e8b684f9451c73d93e339 Mon Sep 17 00:00:00 2001 From: nader-00 Date: Tue, 21 Jul 2026 12:11:44 +0200 Subject: [PATCH 1/2] Add crosssection_escalation for LV line overloading (#377) reinforce_lines_overloading() currently only stacks parallel standard lines to solve overloading, which #377 flags as sometimes producing an implausibly large number of parallel cables where a larger LV cross-section would carry the same load. This adds an opt-in crosssection_escalation flag (default False): for LV lines that would otherwise need more than two parallel standard lines, select_cable() is tried first (up to two parallel cables, never below the standard cross-section), falling back to today's behaviour otherwise. --- edisgo/flex_opt/reinforce_grid.py | 18 +++- edisgo/flex_opt/reinforce_measures.py | 84 ++++++++++++++++- tests/flex_opt/test_reinforce_measures.py | 107 ++++++++++++++++++++++ 3 files changed, 202 insertions(+), 7 deletions(-) diff --git a/edisgo/flex_opt/reinforce_grid.py b/edisgo/flex_opt/reinforce_grid.py index 5ed4c16c6..c27b4053e 100644 --- a/edisgo/flex_opt/reinforce_grid.py +++ b/edisgo/flex_opt/reinforce_grid.py @@ -42,6 +42,7 @@ def reinforce_grid( mode: str | None = None, without_generator_import: bool = False, n_minus_one: bool = False, + crosssection_escalation: bool = False, **kwargs, ) -> Results: """ @@ -82,6 +83,19 @@ def reinforce_grid( Determines whether n-1 security should be checked. Currently, n-1 security cannot be handled correctly, wherefore the case where this parameter is set to True will lead to an error being raised. Default: False. + crosssection_escalation : bool + If True, only affects LV lines where more than two parallel standard lines + would otherwise be installed due to overloading. For those lines, a larger + LV cross-section (up to two parallel cables) is tried first via + :func:`~.tools.tools.select_cable`, never a cross-section below the + standard type. Falls back to today's parallel-standard-line behaviour if + no cross-section up to two parallel cables can carry the required apparent + power. MV lines are not affected, regardless of this parameter's value. + Note: cable costs in this package do not depend on cross-section (only on + length x quantity, see `costs.py`), so an escalated cross-section is never + more expensive in eDisGo's current cost model than the parallel lines it + replaces. + Default: False. Other Parameters ----------------- @@ -279,7 +293,7 @@ def reinforce_grid( if not crit_lines.empty: # reinforce lines lines_changes = reinforce_measures.reinforce_lines_overloading( - edisgo, crit_lines + edisgo, crit_lines, crosssection_escalation=crosssection_escalation ) # write changed lines to results.equipment_changes _add_lines_changes_to_equipment_changes( @@ -603,7 +617,7 @@ def reinforce_grid( if not crit_lines.empty: # reinforce lines lines_changes = reinforce_measures.reinforce_lines_overloading( - edisgo, crit_lines + edisgo, crit_lines, crosssection_escalation=crosssection_escalation ) # write changed lines to results.equipment_changes _add_lines_changes_to_equipment_changes( diff --git a/edisgo/flex_opt/reinforce_measures.py b/edisgo/flex_opt/reinforce_measures.py index ebec5fff9..f94e36da4 100644 --- a/edisgo/flex_opt/reinforce_measures.py +++ b/edisgo/flex_opt/reinforce_measures.py @@ -24,8 +24,13 @@ _dijkstra as dijkstra_shortest_path_length, ) +from edisgo.flex_opt import exceptions from edisgo.network.grids import LVGrid, MVGrid -from edisgo.tools.tools import get_downstream_buses +from edisgo.tools.tools import ( + calculate_apparent_power, + get_downstream_buses, + select_cable, +) if TYPE_CHECKING: from edisgo import EDisGo @@ -533,7 +538,7 @@ def reinforce_lines_voltage_issues(edisgo_obj, grid, crit_nodes): return lines_changes -def reinforce_lines_overloading(edisgo_obj, crit_lines): +def reinforce_lines_overloading(edisgo_obj, crit_lines, crosssection_escalation=False): """ Reinforce lines in MV and LV topology due to overloading. @@ -550,6 +555,12 @@ def reinforce_lines_overloading(edisgo_obj, crit_lines): time step the over-loading occured in as :pandas:`pandas.Timestamp`, and 'voltage_level' specifying the voltage level the line is in (either 'mv' or 'lv'). + crosssection_escalation : bool + If True, only affects LV lines where more than two parallel standard lines + would otherwise be installed. See + :func:`~.flex_opt.reinforce_grid.reinforce_grid` for the full parameter + description and the cost-model caveat in `Notes` there. + Default: False. Returns ------- @@ -572,11 +583,17 @@ def reinforce_lines_overloading(edisgo_obj, crit_lines): lines_changes = {} # reinforce mv lines lines_changes.update( - _reinforce_lines_overloading_per_grid_level(edisgo_obj, "mv", crit_lines) + _reinforce_lines_overloading_per_grid_level( + edisgo_obj, "mv", crit_lines, + crosssection_escalation=crosssection_escalation, + ) ) # reinforce lv lines lines_changes.update( - _reinforce_lines_overloading_per_grid_level(edisgo_obj, "lv", crit_lines) + _reinforce_lines_overloading_per_grid_level( + edisgo_obj, "lv", crit_lines, + crosssection_escalation=crosssection_escalation, + ) ) if not crit_lines.empty: @@ -588,7 +605,9 @@ def reinforce_lines_overloading(edisgo_obj, crit_lines): return lines_changes -def _reinforce_lines_overloading_per_grid_level(edisgo_obj, voltage_level, crit_lines): +def _reinforce_lines_overloading_per_grid_level( + edisgo_obj, voltage_level, crit_lines, crosssection_escalation=False +): """ Reinforce lines in MV or LV topology due to overloading. @@ -681,6 +700,61 @@ def _replace_by_parallel_standard_lines(lines): # save old nominal power to calculate number of parallel standard lines s_nom_old = edisgo_obj.topology.lines_df.loc[lines, "s_nom"] + # crosssection_escalation: only for lines that would otherwise need MORE + # THAN TWO parallel standard lines -- determine this without touching the + # topology yet, then only attempt select_cable() for those. LV only, see + # reinforce_grid() docstring for the MV follow-up and the cost-model + # caveat (cable costs here do not depend on cross-section, only on + # length x quantity, see costs.py -- an escalated cross-section is + # therefore never more expensive in this cost model). + if crosssection_escalation and voltage_level == "lv": + standard_type_data = edisgo_obj.topology.equipment_data["lv_cables"].loc[ + standard_line_type + ] + standard_single_capacity = calculate_apparent_power( + standard_type_data.U_n, standard_type_data.I_max_th, 1 + ) + number_parallel_lines_standard = np.ceil( + s_nom_old + * crit_lines.loc[lines, "max_rel_overload"] + / standard_single_capacity + ) + escalation_candidates = number_parallel_lines_standard[ + number_parallel_lines_standard > 2 + ].index + + fallback_lines = [] + for line in escalation_candidates: + apparent_power = ( + s_nom_old.at[line] * crit_lines.at[line, "max_rel_overload"] + ) + try: + cable_type, n_new = select_cable( + edisgo_obj, level="lv", apparent_power=apparent_power, + max_cables=2, + ) + except exceptions.MaximumIterationError: + fallback_lines.append(line) + continue + if cable_type.I_max_th < standard_type_data.I_max_th: + # defensive only -- see PR description; mathematically this + # branch should be unreachable given escalation_candidates + # already fail at n<=2 with the standard type. Never + # downgrade below the standard type. + fallback_lines.append(line) + continue + edisgo_obj.topology.change_line_type([line], cable_type.name) + edisgo_obj.topology.update_number_of_parallel_lines( + pd.Series({line: n_new}) + ) + lines_changes[line] = n_new + + successfully_escalated = escalation_candidates.difference(fallback_lines) + lines = pd.Index(lines).difference(successfully_escalated) + s_nom_old = s_nom_old.loc[lines] + if lines.empty: + return + # change line type to standard line edisgo_obj.topology.change_line_type(lines, standard_line_type) diff --git a/tests/flex_opt/test_reinforce_measures.py b/tests/flex_opt/test_reinforce_measures.py index 5ea720064..5e05d7a8c 100644 --- a/tests/flex_opt/test_reinforce_measures.py +++ b/tests/flex_opt/test_reinforce_measures.py @@ -454,6 +454,113 @@ def test_reinforce_lines_overloading(self): assert np.isclose(line.s_nom, 0.275 * 0.4 * np.sqrt(3)) assert line.num_parallel == 1 + def test_reinforce_lines_overloading_crosssection_escalation(self): + # Line_60000001 is LV, kind="cable", original type "NAYY 4x1x35" + # (s_nom=0.085217 MVA). Standard type is "NAYY 4x1x150" + # (single-cable s_nom=0.19051 MVA). Three regimes of max_rel_overload + # are used to exercise the escalation gate (only lines needing MORE + # THAN TWO parallel standard lines are considered for escalation): + # rel=4.0 -> n_standard == 2 (boundary, NOT a candidate) + # rel=6.0 -> n_standard == 3 > 2, select_cable() finds NAYY 4x1x300 + # at n=2 (escalation succeeds) + # rel=9.0 -> n_standard == 5 > 2, even NAYY 4x1x300 at n=2 does not + # carry the required apparent power (0.767 MVA > 0.581 + # MVA) -> select_cable() raises MaximumIterationError, + # falls back to today's parallel-standard-line behaviour + # Values confirmed by direct computation against this fixture before + # writing the test (not guessed). + + # -- regression: crosssection_escalation=False must be bit-identical + # to calling reinforce_lines_overloading() without the parameter -- + self.edisgo = copy.deepcopy(self.edisgo_root) + crit_lines = pd.DataFrame( + {"max_rel_overload": [6.0], "voltage_level": ["lv"]}, + index=["Line_60000001"], + ) + edisgo_default = copy.deepcopy(self.edisgo) + edisgo_explicit_false = copy.deepcopy(self.edisgo) + changes_default = reinforce_measures.reinforce_lines_overloading( + edisgo_default, crit_lines + ) + changes_explicit_false = reinforce_measures.reinforce_lines_overloading( + edisgo_explicit_false, crit_lines, crosssection_escalation=False + ) + assert changes_default == changes_explicit_false + assert edisgo_default.topology.lines_df.equals( + edisgo_explicit_false.topology.lines_df + ) + + # -- boundary: n_standard == 2 is NOT escalated, flag has no effect -- + crit_lines_boundary = pd.DataFrame( + {"max_rel_overload": [4.0], "voltage_level": ["lv"]}, + index=["Line_60000001"], + ) + edisgo_off = copy.deepcopy(self.edisgo) + edisgo_on = copy.deepcopy(self.edisgo) + changes_off = reinforce_measures.reinforce_lines_overloading( + edisgo_off, crit_lines_boundary, crosssection_escalation=False + ) + changes_on = reinforce_measures.reinforce_lines_overloading( + edisgo_on, crit_lines_boundary, crosssection_escalation=True + ) + assert changes_off == changes_on == {"Line_60000001": 2.0} + assert edisgo_off.topology.lines_df.loc["Line_60000001", "type_info"] == ( + edisgo_on.topology.lines_df.loc["Line_60000001", "type_info"] + ) + assert edisgo_on.topology.lines_df.loc["Line_60000001", "type_info"] == ( + "NAYY 4x1x150" + ) + + # -- escalation succeeds: n_standard=3 -> NAYY 4x1x300 at n=2 -- + edisgo_escalated = copy.deepcopy(self.edisgo) + changes_escalated = reinforce_measures.reinforce_lines_overloading( + edisgo_escalated, crit_lines, crosssection_escalation=True + ) + line = edisgo_escalated.topology.lines_df.loc["Line_60000001"] + assert changes_escalated == {"Line_60000001": 2} + assert line.type_info == "NAYY 4x1x300" + assert line.num_parallel == 2 + assert np.isclose(line.s_nom, 0.419 * 0.4 * np.sqrt(3) * 2) + # never a cross-section below the standard type + standard_s_nom_per_cable = 0.275 * 0.4 * np.sqrt(3) + escalated_s_nom_per_cable = 0.419 * 0.4 * np.sqrt(3) + assert escalated_s_nom_per_cable >= standard_s_nom_per_cable + # equipment_changes logging still works from the returned dict alone + # (no separate logging path needed for the escalated type) + assert edisgo_escalated.topology.lines_df.loc[ + "Line_60000001", "type_info" + ] == "NAYY 4x1x300" + + # -- escalation fails: n_standard=5, no cross-section at n<=2 fits -> + # fallback identical to crosssection_escalation=False, AND the gate's + # n_standard must equal the actually installed n (regression guard + # for the gate formula matching change_line_type()'s s_nom formula, + # see PR description) -- + crit_lines_fallback = pd.DataFrame( + {"max_rel_overload": [9.0], "voltage_level": ["lv"]}, + index=["Line_60000001"], + ) + edisgo_fb_off = copy.deepcopy(self.edisgo) + edisgo_fb_on = copy.deepcopy(self.edisgo) + changes_fb_off = reinforce_measures.reinforce_lines_overloading( + edisgo_fb_off, crit_lines_fallback, crosssection_escalation=False + ) + changes_fb_on = reinforce_measures.reinforce_lines_overloading( + edisgo_fb_on, crit_lines_fallback, crosssection_escalation=True + ) + assert changes_fb_off == changes_fb_on + assert edisgo_fb_off.topology.lines_df.equals(edisgo_fb_on.topology.lines_df) + + s_nom_original = 0.085216899732389 # Line_60000001, type "NAYY 4x1x35" + standard_single_capacity = 0.275 * 0.4 * np.sqrt(3) # NAYY 4x1x150, n=1 + n_standard_gate = np.ceil( + s_nom_original * 9.0 / standard_single_capacity + ) + n_installed = edisgo_fb_on.topology.lines_df.loc[ + "Line_60000001", "num_parallel" + ] + assert n_standard_gate == n_installed == 5 + def test_separate_lv_grid(self): self.edisgo = copy.deepcopy(self.edisgo_root) From ea2107766a6dbf04499f3878ea3f8d85a8bc217a Mon Sep 17 00:00:00 2001 From: nader-00 Date: Tue, 21 Jul 2026 13:03:50 +0200 Subject: [PATCH 2/2] Extend crosssection escalation to already-standard parallel stacks (#377) crosssection_escalation only ever reached lines handled by _replace_by_parallel_standard_lines(); lines that were already the standard cross-section (e.g. native multi-cable ding0 lines) were scaled up via _add_parallel_standard_lines() instead, which never attempted escalation. This adds crosssection_escalation_existing (requires crosssection_escalation=True) to also cover that path, using the same >2-parallel-lines gate and select_cable() logic. Unlike the base flag, this can replace cross-sections that were part of the original input topology, not only eDisGo's own prior reinforcement. --- edisgo/flex_opt/reinforce_grid.py | 25 ++++- edisgo/flex_opt/reinforce_measures.py | 72 ++++++++++++++- tests/flex_opt/test_reinforce_measures.py | 106 +++++++++++++++++++++- 3 files changed, 196 insertions(+), 7 deletions(-) diff --git a/edisgo/flex_opt/reinforce_grid.py b/edisgo/flex_opt/reinforce_grid.py index c27b4053e..44c2b8c51 100644 --- a/edisgo/flex_opt/reinforce_grid.py +++ b/edisgo/flex_opt/reinforce_grid.py @@ -43,6 +43,7 @@ def reinforce_grid( without_generator_import: bool = False, n_minus_one: bool = False, crosssection_escalation: bool = False, + crosssection_escalation_existing: bool = False, **kwargs, ) -> Results: """ @@ -96,6 +97,16 @@ def reinforce_grid( more expensive in eDisGo's current cost model than the parallel lines it replaces. Default: False. + crosssection_escalation_existing : bool + If True, ALSO considers LV lines that are already the standard cross- + section for escalation, not only lines that would otherwise be + replaced by parallel standard lines. Requires + `crosssection_escalation=True`. Unlike `crosssection_escalation` alone, + this can replace cross-sections that were part of the original input + topology (e.g. native multi-cable ding0 lines), not only eDisGo's own + prior reinforcement decisions -- see PR description for why this is + called out separately. + Default: False. Other Parameters ----------------- @@ -158,6 +169,12 @@ def reinforce_grid( if n_minus_one is True: raise NotImplementedError("n-1 security can currently not be checked.") + if crosssection_escalation_existing and not crosssection_escalation: + raise ValueError( + "crosssection_escalation_existing=True requires " + "crosssection_escalation=True." + ) + # check if provided mode is valid if mode and mode not in ["mv", "mvlv", "lv"]: raise ValueError(f"Provided mode {mode} is not a valid mode.") @@ -293,7 +310,9 @@ def reinforce_grid( if not crit_lines.empty: # reinforce lines lines_changes = reinforce_measures.reinforce_lines_overloading( - edisgo, crit_lines, crosssection_escalation=crosssection_escalation + edisgo, crit_lines, + crosssection_escalation=crosssection_escalation, + crosssection_escalation_existing=crosssection_escalation_existing, ) # write changed lines to results.equipment_changes _add_lines_changes_to_equipment_changes( @@ -617,7 +636,9 @@ def reinforce_grid( if not crit_lines.empty: # reinforce lines lines_changes = reinforce_measures.reinforce_lines_overloading( - edisgo, crit_lines, crosssection_escalation=crosssection_escalation + edisgo, crit_lines, + crosssection_escalation=crosssection_escalation, + crosssection_escalation_existing=crosssection_escalation_existing, ) # write changed lines to results.equipment_changes _add_lines_changes_to_equipment_changes( diff --git a/edisgo/flex_opt/reinforce_measures.py b/edisgo/flex_opt/reinforce_measures.py index f94e36da4..16b0656ec 100644 --- a/edisgo/flex_opt/reinforce_measures.py +++ b/edisgo/flex_opt/reinforce_measures.py @@ -538,7 +538,10 @@ def reinforce_lines_voltage_issues(edisgo_obj, grid, crit_nodes): return lines_changes -def reinforce_lines_overloading(edisgo_obj, crit_lines, crosssection_escalation=False): +def reinforce_lines_overloading( + edisgo_obj, crit_lines, crosssection_escalation=False, + crosssection_escalation_existing=False, +): """ Reinforce lines in MV and LV topology due to overloading. @@ -561,6 +564,12 @@ def reinforce_lines_overloading(edisgo_obj, crit_lines, crosssection_escalation= :func:`~.flex_opt.reinforce_grid.reinforce_grid` for the full parameter description and the cost-model caveat in `Notes` there. Default: False. + crosssection_escalation_existing : bool + If True, also attempts escalation for LV lines that are already the + standard cross-section. Requires `crosssection_escalation=True`. See + :func:`~.flex_opt.reinforce_grid.reinforce_grid` for the full + parameter description. + Default: False. Returns ------- @@ -586,6 +595,7 @@ def reinforce_lines_overloading(edisgo_obj, crit_lines, crosssection_escalation= _reinforce_lines_overloading_per_grid_level( edisgo_obj, "mv", crit_lines, crosssection_escalation=crosssection_escalation, + crosssection_escalation_existing=crosssection_escalation_existing, ) ) # reinforce lv lines @@ -593,6 +603,7 @@ def reinforce_lines_overloading(edisgo_obj, crit_lines, crosssection_escalation= _reinforce_lines_overloading_per_grid_level( edisgo_obj, "lv", crit_lines, crosssection_escalation=crosssection_escalation, + crosssection_escalation_existing=crosssection_escalation_existing, ) ) @@ -606,7 +617,8 @@ def reinforce_lines_overloading(edisgo_obj, crit_lines, crosssection_escalation= def _reinforce_lines_overloading_per_grid_level( - edisgo_obj, voltage_level, crit_lines, crosssection_escalation=False + edisgo_obj, voltage_level, crit_lines, crosssection_escalation=False, + crosssection_escalation_existing=False, ): """ Reinforce lines in MV or LV topology due to overloading. @@ -791,11 +803,63 @@ def _replace_by_parallel_standard_lines(lines): lines_standard = relevant_lines.loc[ relevant_lines.type_info == standard_line_type ] + lines_standard_escalated: list = [] if not lines_standard.empty: - _add_parallel_standard_lines(lines_standard.index) + # crosssection_escalation_existing: consider already-standard-type + # lines for escalation too, not only lines reaching + # _replace_by_parallel_standard_lines() below. LV only (see + # reinforce_grid() docstring); no separate voltage-path guard + # needed here -- this whole function tree is structurally + # unreachable from reinforce_lines_voltage_issues() (see PR + # description, Command D2d 1c/2d). Deliberately duplicates the + # small escalation-attempt loop from _replace_by_parallel_ + # standard_lines() below rather than refactoring that + # already-tested path -- see PR description. + if ( + crosssection_escalation + and crosssection_escalation_existing + and voltage_level == "lv" + ): + standard_type_data = edisgo_obj.topology.equipment_data[ + "lv_cables" + ].loc[standard_line_type] + number_needed = np.ceil( + crit_lines.max_rel_overload[lines_standard.index] + * lines_standard["num_parallel"] + ) + escalation_candidates = number_needed[number_needed > 2].index + + for line in escalation_candidates: + apparent_power = ( + edisgo_obj.topology.lines_df.at[line, "s_nom"] + * crit_lines.at[line, "max_rel_overload"] + ) + try: + cable_type, n_new = select_cable( + edisgo_obj, level="lv", apparent_power=apparent_power, + max_cables=2, + ) + except exceptions.MaximumIterationError: + continue + if cable_type.I_max_th < standard_type_data.I_max_th: + # defensive only, unreachable by construction -- same + # argument as _replace_by_parallel_standard_lines() + continue + edisgo_obj.topology.change_line_type([line], cable_type.name) + edisgo_obj.topology.update_number_of_parallel_lines( + pd.Series({line: n_new}) + ) + lines_changes[line] = n_new + lines_standard_escalated.append(line) + + lines_standard_remaining = lines_standard.loc[ + ~lines_standard.index.isin(lines_standard_escalated) + ] + if not lines_standard_remaining.empty: + _add_parallel_standard_lines(lines_standard_remaining.index) # get lines that have not been updated yet (i.e. that are not standard - # lines) + # lines, or were already escalated above) relevant_lines = relevant_lines.loc[ ~relevant_lines.index.isin(lines_standard.index) ] diff --git a/tests/flex_opt/test_reinforce_measures.py b/tests/flex_opt/test_reinforce_measures.py index 5e05d7a8c..236872df9 100644 --- a/tests/flex_opt/test_reinforce_measures.py +++ b/tests/flex_opt/test_reinforce_measures.py @@ -5,7 +5,7 @@ import pytest from edisgo import EDisGo -from edisgo.flex_opt import check_tech_constraints, reinforce_measures +from edisgo.flex_opt import check_tech_constraints, reinforce_grid, reinforce_measures class TestReinforceMeasures: @@ -561,6 +561,110 @@ def test_reinforce_lines_overloading_crosssection_escalation(self): ] assert n_standard_gate == n_installed == 5 + def test_reinforce_lines_overloading_crosssection_escalation_existing(self): + # Line_60000001 is synthetically forced to already be standard type + # ("NAYY 4x1x150") with num_parallel=4 -- this puts it in the + # "lines_standard" bucket (_add_parallel_standard_lines()), which + # crosssection_escalation ALONE never reaches (see PR description, + # Command D2d). Values confirmed by direct computation against this + # fixture before writing the test (not guessed): + # rel=0.6 -> number_needed = ceil(0.6*4) = 3 > 2 (candidate); + # apparent_power = 4*0.19051*0.6 = 0.4572 MVA -> + # select_cable(max_cables=2) finds NAYY 4x1x240 at n=2 + # (0.5045 MVA >= 0.4572, NAYY 4x1x185 at n=2 would only + # give 0.4340 MVA, not enough) + # rel=1.0 -> number_needed = ceil(1.0*4) = 4 > 2 (candidate), but + # apparent_power = 0.7621 MVA exceeds even 2x + # NAYY 4x1x300 (0.5806 MVA) -> MaximumIterationError -> + # falls back to _add_parallel_standard_lines() (n=4, + # unchanged, since ceil(1.0*4)=4 equals num_parallel_pre) + + def make_already_standard(edisgo_obj, num_parallel=4): + e = copy.deepcopy(edisgo_obj) + e.topology.lines_df.at["Line_60000001", "type_info"] = "NAYY 4x1x150" + e.topology.lines_df.at["Line_60000001", "num_parallel"] = num_parallel + e.topology.lines_df.at["Line_60000001", "s_nom"] = ( + 0.275 * 0.4 * np.sqrt(3) * num_parallel + ) + e.topology.lines_df.at["Line_60000001", "kind"] = "cable" + return e + + self.edisgo = copy.deepcopy(self.edisgo_root) + + # -- both flags False: bit-identical to today's _add_parallel_standard_lines -- + edisgo_baseline = make_already_standard(self.edisgo) + crit_lines_esc = pd.DataFrame( + {"max_rel_overload": [0.6], "voltage_level": ["lv"]}, + index=["Line_60000001"], + ) + changes_baseline = reinforce_measures.reinforce_lines_overloading( + edisgo_baseline, crit_lines_esc + ) + assert changes_baseline == {"Line_60000001": -1.0} + line = edisgo_baseline.topology.lines_df.loc["Line_60000001"] + assert line.type_info == "NAYY 4x1x150" + assert line.num_parallel == 3 + + # -- crosssection_escalation=True but crosssection_escalation_existing + # =False (default): extension inert, identical to both-False -- + edisgo_ext_off = make_already_standard(self.edisgo) + changes_ext_off = reinforce_measures.reinforce_lines_overloading( + edisgo_ext_off, crit_lines_esc, crosssection_escalation=True, + ) + assert changes_ext_off == changes_baseline + assert edisgo_ext_off.topology.lines_df.loc["Line_60000001", "type_info"] == ( + "NAYY 4x1x150" + ) + assert edisgo_ext_off.topology.lines_df.loc["Line_60000001", "num_parallel"] == 3 + + # -- both flags True: escalation succeeds, n_needed=3>2 -> NAYY 4x1x240 at n=2 -- + edisgo_escalated = make_already_standard(self.edisgo) + changes_escalated = reinforce_measures.reinforce_lines_overloading( + edisgo_escalated, crit_lines_esc, + crosssection_escalation=True, crosssection_escalation_existing=True, + ) + line = edisgo_escalated.topology.lines_df.loc["Line_60000001"] + assert changes_escalated == {"Line_60000001": 2} + assert line.type_info == "NAYY 4x1x240" + assert line.num_parallel == 2 + assert np.isclose(line.s_nom, 0.364 * 0.4 * np.sqrt(3) * 2) + # never a cross-section below the standard type + standard_s_nom_per_cable = 0.275 * 0.4 * np.sqrt(3) + escalated_s_nom_per_cable = 0.364 * 0.4 * np.sqrt(3) + assert escalated_s_nom_per_cable >= standard_s_nom_per_cable + + # -- fallback: candidate (n_needed=4>2), but no cross-section at n<=2 + # fits -> identical to crosssection_escalation_existing=False -- + crit_lines_fb = pd.DataFrame( + {"max_rel_overload": [1.0], "voltage_level": ["lv"]}, + index=["Line_60000001"], + ) + edisgo_fb_off = make_already_standard(self.edisgo) + edisgo_fb_on = make_already_standard(self.edisgo) + changes_fb_off = reinforce_measures.reinforce_lines_overloading( + edisgo_fb_off, crit_lines_fb, crosssection_escalation=True, + ) + changes_fb_on = reinforce_measures.reinforce_lines_overloading( + edisgo_fb_on, crit_lines_fb, + crosssection_escalation=True, crosssection_escalation_existing=True, + ) + assert changes_fb_off == changes_fb_on + assert edisgo_fb_off.topology.lines_df.equals(edisgo_fb_on.topology.lines_df) + assert edisgo_fb_on.topology.lines_df.loc["Line_60000001", "type_info"] == ( + "NAYY 4x1x150" + ) + assert edisgo_fb_on.topology.lines_df.loc["Line_60000001", "num_parallel"] == 4 + + # -- precondition check: crosssection_escalation_existing=True requires + # crosssection_escalation=True, enforced in reinforce_grid() -- + edisgo_invalid = copy.deepcopy(self.edisgo_root) + edisgo_invalid.analyze() + with pytest.raises(ValueError, match="requires crosssection_escalation=True"): + reinforce_grid.reinforce_grid( + edisgo_invalid, crosssection_escalation_existing=True, + crosssection_escalation=False, + ) + def test_separate_lv_grid(self): self.edisgo = copy.deepcopy(self.edisgo_root)