diff --git a/edisgo/config/config_grid_expansion_default.cfg b/edisgo/config/config_grid_expansion_default.cfg index 74a612eb7..e6b611fb0 100644 --- a/edisgo/config/config_grid_expansion_default.cfg +++ b/edisgo/config/config_grid_expansion_default.cfg @@ -66,6 +66,20 @@ lv_max_v_drop = 0.065 # max. allowed voltage drop over MV/LV stations mv_lv_station_max_v_drop = 0.02 +# ront_voltage_range: +# RONT (regelbarer Ortsnetztransformator) control range in p.u., +# relative to the transformer's own unregulated (neutral tap) secondary +# voltage. +# Source: FNN-Hinweis "Regelbarer Ortsnetztransformator (rONT) -- Einsatz +# in Netzplanung und Netzbetrieb", 2016, sections 5.9.1/5.9.2: network +# planning criterion uses a +/-10% control range, 9 taps, 2.5% tap step; +# common market products (ibid. table 1) and all five practical examples +# (annex B) confirm +/-4 x 2.5%. Note: real rONTs are stepped (2.5% per +# tap) with a control bandwidth/deadband of typically 4% (1.6x the tap +# step, section 5.5.2) -- our ref(t) model is continuous and deadband- +# free, an idealisation in the optimistic direction (see CONCEPT_ront.md). +ront_voltage_range = 0.10 + [grid_expansion_load_factors] # These are the load factors to use when grid issues in normal grid operation are checked. @@ -122,3 +136,14 @@ mv_cable_incl_earthwork_urban = 140 # costs in kEUR, source: DENA Verteilnetzstudie lv = 10 mv = 1000 + +# surcharge in kEUR for a RONT (regelbarer Ortsnetztransformator) over the +# standard LV transformer cost above ('lv'): investment surcharge only. +# Source: FNN-Hinweis "Regelbarer Ortsnetztransformator (rONT) -- Einsatz in +# Netzplanung und Netzbetrieb", 2016, section 5.8.1, names the cost +# components (procurement, installation incl. sensors/control electronics, +# documentation) but no absolute figures; operating costs and losses do not +# differ significantly from a conventional ONT per the same source, hence a +# pure investment surcharge here. Value is operator- and product-specific +# and must be adapted -- see PR description for the break-even sensitivity. +ront_surcharge = 20 diff --git a/edisgo/flex_opt/check_tech_constraints.py b/edisgo/flex_opt/check_tech_constraints.py index aeadafc9b..c0ac51e4e 100644 --- a/edisgo/flex_opt/check_tech_constraints.py +++ b/edisgo/flex_opt/check_tech_constraints.py @@ -19,6 +19,7 @@ import pandas as pd from edisgo.network.grids import LVGrid, MVGrid +from edisgo.tools.tools import is_ront if TYPE_CHECKING: from edisgo import EDisGo @@ -1019,6 +1020,44 @@ def allowed_voltage_limits(edisgo_obj, buses=None, split_voltage_band=True): else: upper = pd.DataFrame(1.1, columns=buses, index=edisgo_obj.results.v_res.index) lower = pd.DataFrame(0.9, columns=buses, index=edisgo_obj.results.v_res.index) + + # RONT-tagged stations for which lv_grid_ront_feasible() (the same + # single source of truth used by the enable_ront guard, re-evaluated + # here against the CURRENT v_res) confirms that a RONT with the + # configured control range resolves all voltage issues: exclude + # their buses from this blind absolute check entirely, rather than + # constructing a substitute per-bus band. lv_grid_ront_feasible() + # proves the EXISTENCE of a valid reference ref(t) under which the + # raw bus voltages satisfy the LV rise/drop band -- it does NOT + # claim the raw bus voltages themselves lie in some window around + # v_unreg(t), so comparing them directly against such a window + # (an earlier, incorrect version of this fix) is not a valid + # substitute criterion (see CONCEPT_ront.md, "Finaler Check -- + # erlaubtes Band als Schnitt"). Dropping these columns is safe: + # voltage_deviation_from_allowed_voltage_limits() derives its own + # `buses` from these DataFrames' columns (see the comment there), + # so dropped buses are cleanly excluded from the violation + # computation, not compared against NaN/inf. RONT-tagged stations + # that are NOT (or no longer) feasible are left on the hard + # [0.9, 1.1] band -- an honest backstop, not a silent pass. Buses + # not belonging to any RONT station are left untouched (bit- + # identical to the unconditional [0.9, 1.1] band above). + ront_voltage_range = float( + edisgo_obj.config["grid_expansion_allowed_voltage_deviations"][ + "ront_voltage_range" + ] + ) + for lv_grid in edisgo_obj.topology.mv_grid.lv_grids: + if not is_ront(lv_grid.transformers_df.iloc[0].type_info): + continue + if not lv_grid_ront_feasible(edisgo_obj, lv_grid, ront_voltage_range): + continue + grid_buses = [b for b in lv_grid.buses_df.index if b in upper.columns] + if not grid_buses: + continue + upper = upper.drop(columns=grid_buses) + lower = lower.drop(columns=grid_buses) + return upper, lower @@ -1135,33 +1174,69 @@ def _lv_allowed_voltage_limits( ) else: config_string = "lv" + v_max_rise = edisgo_obj.config["grid_expansion_allowed_voltage_deviations"][ + f"{config_string}_max_v_rise" + ] + v_max_drop = edisgo_obj.config["grid_expansion_allowed_voltage_deviations"][ + f"{config_string}_max_v_drop" + ] + ront_voltage_range = float( + edisgo_obj.config["grid_expansion_allowed_voltage_deviations"][ + "ront_voltage_range" + ] + ) # get all secondary sides and buses in grids buses_dict = {} secondary_sides_dict = {} + voltage_base_dict = {} for grid in lv_grids: secondary_side = grid.station.index[0] if secondary_side in buses_in_pfa: secondary_sides_dict[grid] = secondary_side - buses_dict[grid.station.index[0]] = grid.buses_df.index.drop( - grid.station.index[0] - ) + internal_buses = grid.buses_df.index.drop(secondary_side) + buses_dict[secondary_side] = internal_buses + if is_ront(grid.transformers_df.iloc[0].type_info): + # RONT (regelbarer Ortsnetztransformator): the reference + # voltage used to build the allowed band is not the + # actual, unregulated secondary side voltage, but an + # idealised per-time-step value chosen such that the + # achievable band [ref - v_max_drop, ref + v_max_rise] + # contains the actual spread of this grid's internal bus + # voltages at this time step, whenever that spread fits + # within the band width (v_max_rise + v_max_drop) -- see + # CONCEPT_ront.md, "Constraint-Check-Mechanismus". The + # idealised reference is clipped to + # [v_unreg - ront_voltage_range, v_unreg + ront_voltage_range] + # -- a RONT can only shift its secondary voltage relative + # to its own unregulated (neutral tap) operating point, + # v_unreg, by a bounded amount (its real, finite control + # range), not by an arbitrary amount. v_unreg is exactly + # the value this branch would otherwise use directly + # (the "else" case below), since r_pu/x_pu/s_nom are left + # unchanged for a RONT (see CONCEPT_ront.md, Befund 1). + v_internal = voltages_pfa.loc[ + :, internal_buses.intersection(buses_in_pfa) + ] + v_unreg = voltages_pfa.loc[:, secondary_side] + ref_unclipped = ( + (v_internal.max(axis=1) - v_max_rise) + + (v_internal.min(axis=1) + v_max_drop) + ) / 2 + voltage_base_dict[secondary_side] = ref_unclipped.clip( + lower=v_unreg - ront_voltage_range, + upper=v_unreg + ront_voltage_range, + ) + else: + voltage_base_dict[secondary_side] = voltages_pfa.loc[ + :, secondary_side + ] secondary_sides = pd.Series(secondary_sides_dict) - voltage_base = voltages_pfa.loc[:, secondary_sides.values] + voltage_base = pd.DataFrame(voltage_base_dict) - upper_limits_df_tmp = ( - voltage_base - + edisgo_obj.config["grid_expansion_allowed_voltage_deviations"][ - f"{config_string}_max_v_rise" - ] - ) - lower_limits_df_tmp = ( - voltage_base - - edisgo_obj.config["grid_expansion_allowed_voltage_deviations"][ - f"{config_string}_max_v_drop" - ] - ) + upper_limits_df_tmp = voltage_base + v_max_rise + lower_limits_df_tmp = voltage_base - v_max_drop # rename columns to secondary side # collect per-station frames and concatenate once (axis=1) to avoid @@ -1193,6 +1268,70 @@ def _lv_allowed_voltage_limits( return upper_limits_df, lower_limits_df +def lv_grid_ront_feasible(edisgo_obj, lv_grid, ront_voltage_range): + """ + Checks whether a RONT with the given control range could resolve all + voltage issues in the given LV grid. + + This is the single source of truth for RONT feasibility, used both by + the `enable_ront` trigger in :func:`~.flex_opt.reinforce_grid. + reinforce_grid` and by the RONT-aware final +/-10% check in + :func:`allowed_voltage_limits` (`split_voltage_band=False`) -- using one + shared function guarantees that a grid for which RONT is set will also + pass the final check, rather than relying on two independently derived + criteria that happen to agree (see CONCEPT_ront.md, "Finaler Check -- + erlaubtes Band als Schnitt", point 3). + + For every time step, a reference voltage ref(t) must exist that lies + both within the RONT's control range around its unregulated secondary + voltage v_unreg(t) intersected with the hard +/-10% system limit, + `[max(0.9, v_unreg(t) - ront_voltage_range), + min(1.1, v_unreg(t) + ront_voltage_range)]`, and within the window + required to keep the grid's internal buses inside the allowed LV band, + `[v_max(t) - lv_max_v_rise, v_min(t) + lv_max_v_drop]`. Feasible for the + grid overall only if this holds for every time step. + + Parameters + ---------- + edisgo_obj : :class:`~.EDisGo` + lv_grid : :class:`~.network.grids.LVGrid` + ront_voltage_range : float + RONT control range in p.u. (see config option + `ront_voltage_range` in section `grid_expansion_allowed_voltage_deviations`). + + Returns + ------- + bool + True if a RONT could resolve all voltage issues in the given LV + grid for every time step in the last power flow analysis. + + """ + secondary_side = lv_grid.station.index[0] + internal_buses = lv_grid.buses_df.index.drop(secondary_side).intersection( + edisgo_obj.results.v_res.columns + ) + if secondary_side not in edisgo_obj.results.v_res.columns or internal_buses.empty: + return False + + v_max_rise = edisgo_obj.config["grid_expansion_allowed_voltage_deviations"][ + "lv_max_v_rise" + ] + v_max_drop = edisgo_obj.config["grid_expansion_allowed_voltage_deviations"][ + "lv_max_v_drop" + ] + + v_unreg = edisgo_obj.results.v_res[secondary_side] + v_internal = edisgo_obj.results.v_res[internal_buses] + v_max_t = v_internal.max(axis=1) + v_min_t = v_internal.min(axis=1) + + lower_allowed = np.maximum(0.9, v_unreg - ront_voltage_range) + upper_allowed = np.minimum(1.1, v_unreg + ront_voltage_range) + lower_needed = np.maximum(v_max_t - v_max_rise, lower_allowed) + upper_needed = np.minimum(v_min_t + v_max_drop, upper_allowed) + return bool((lower_needed <= upper_needed).all()) + + def voltage_deviation_from_allowed_voltage_limits( edisgo_obj, buses=None, split_voltage_band=True ): diff --git a/edisgo/flex_opt/costs.py b/edisgo/flex_opt/costs.py index 3ee27cfa4..942569898 100644 --- a/edisgo/flex_opt/costs.py +++ b/edisgo/flex_opt/costs.py @@ -18,6 +18,7 @@ from shapely.ops import transform from edisgo.tools.geo import proj2equidistant +from edisgo.tools.tools import is_ront logger = logging.getLogger(__name__) @@ -82,13 +83,34 @@ def _get_transformer_costs(trafos): }, index=hvmv_trafos, ) + # RONT transformers get a surcharge on top of the standard MV/LV + # transformer cost (`ront_surcharge`), instead of a separate, fully + # independent replacement price -- consistent with the flat, + # size-independent cost model already used for standard lines and + # transformers here. As with the line cross-section cost model (see + # the crosssection_escalation PR), this is a simplified cost + # assumption; `ront_surcharge` is a placeholder value, source to be + # verified before merging (see CONCEPT_ront.md). + mvlv_cost_base = float(edisgo_obj.config["costs_transformers"]["lv"]) + mvlv_ront_surcharge = float( + edisgo_obj.config["costs_transformers"].get("ront_surcharge", 0.0) + ) + mvlv_type_info = edisgo_obj.topology.transformers_df.loc[ + mvlv_trafos, "type_info" + ] + mvlv_costs = mvlv_type_info.apply( + lambda type_info: ( + mvlv_cost_base + mvlv_ront_surcharge + if is_ront(type_info) + else mvlv_cost_base + ) + ) costs_trafos = pd.concat( [ costs_trafos, pd.DataFrame( { - "costs_transformers": len(mvlv_trafos) - * [float(edisgo_obj.config["costs_transformers"]["lv"])], + "costs_transformers": mvlv_costs.values, "voltage_level": len(mvlv_trafos) * ["mv/lv"], }, index=mvlv_trafos, @@ -133,6 +155,19 @@ def _get_line_costs(lines_added): added_transformers = added_transformers[ ~added_transformers["equipment"].isin(added_removed_transformers.equipment) ] + # "changed" transformers (currently only RONT conversions, see + # CONCEPT_ront.md -- the transformer itself is not replaced, only its + # type_info changes) must be costed alongside added ones, just like + # "changed" lines already are (see costs for lines below). A + # "changed" transformer that was later removed (e.g. because a + # subsequent, more severe overload replaced the whole station) no + # longer exists in the final topology and must not be costed -- + # mirrors the added/removed cancellation above. + changed_transformers = transformers[transformers["change"] == "changed"] + changed_transformers = changed_transformers[ + ~changed_transformers["equipment"].isin(removed_transformers["equipment"]) + ] + costable_transformers = pd.concat([added_transformers, changed_transformers]) # calculate costs for transformers all_trafos = pd.concat( [ @@ -140,7 +175,7 @@ def _get_line_costs(lines_added): edisgo_obj.topology.transformers_df, ] ) - trafos = all_trafos.loc[added_transformers["equipment"]] + trafos = all_trafos.loc[costable_transformers["equipment"]] # calculate costs for each transformer transformer_costs = _get_transformer_costs(trafos) costs = pd.concat( diff --git a/edisgo/flex_opt/reinforce_grid.py b/edisgo/flex_opt/reinforce_grid.py index 4319a82e9..8d59fd9f9 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, + enable_ront: bool = False, **kwargs, ) -> Results: """ @@ -82,6 +83,25 @@ 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. + enable_ront : bool + If True, resolves LV-internal voltage issues by installing a RONT + (regelbarer Ortsnetztransformator, voltage-regulating transformer) instead of + disconnecting or reinforcing lines, for LV grids for which + :func:`~.flex_opt.check_tech_constraints.lv_grid_ront_feasible` reports that a + RONT with the configured control range (`ront_voltage_range`, section + `grid_expansion_allowed_voltage_deviations`) can resolve all voltage + issues at every time step, respecting both the RONT's bounded control + range and the hard +/-10% system voltage limit. The RONT's + regulating effect is modelled as an idealised + reference-voltage shift in the voltage limit checks -- not as an actual + tap-changing transformer in the power flow, which the current eDisGo/PyPSA + power flow call pattern (one vectorized call for all time steps) does not + support without substantially larger changes. See + :func:`~.flex_opt.reinforce_measures.reinforce_lv_grid_ront_voltage_issues`, + :func:`~.flex_opt.check_tech_constraints.lv_grid_ront_feasible`, and + CONCEPT_ront.md for the underlying assumptions and a discussion of a more + realistic, load-flow-based RONT model (tap ratio control) as a possible + follow-up. Default: False. Other Parameters ----------------- @@ -483,14 +503,53 @@ def reinforce_grid( lv_grid_id=lv_grid_id, ) + if enable_ront: + ront_voltage_range = float( + edisgo.config["grid_expansion_allowed_voltage_deviations"][ + "ront_voltage_range" + ] + ) + while_counter = 0 while not crit_nodes.empty and while_counter < max_while_iterations: # for every topology in crit_nodes do reinforcement for grid_id in crit_nodes.lv_grid_id.unique(): + lv_grid = edisgo.topology.get_lv_grid(int(grid_id)) + + # if enabled, prefer installing a RONT over disconnecting or + # reinforcing lines, for grids for which a RONT with the + # configured control range can resolve all voltage issues + # (see CONCEPT_ront.md). lv_grid_ront_feasible() is the same + # function used by the RONT-aware final +/-10% check further + # below (RECHECK FOR OVERLOADED TRANSFORMERS AND LINES, + # unaffected by enable_ront -- a RONT is electrically + # identical to the standard transformer it replaces, see + # reinforce_lv_grid_ront_voltage_issues()), which guarantees + # that a grid RONT is set for here will also pass that check. + # The 'not tools.is_ront(...)' guard avoids re-triggering the + # measure for a grid that already has a RONT (idempotency + # across while-iterations). + if ( + enable_ront + and not tools.is_ront(lv_grid.transformers_df.iloc[0].type_info) + and checks.lv_grid_ront_feasible( + edisgo, lv_grid, ront_voltage_range + ) + ): + transformer_changes = ( + reinforce_measures.reinforce_lv_grid_ront_voltage_issues( + edisgo, lv_grid + ) + ) + _add_transformer_changes_to_equipment_changes( + edisgo, transformer_changes, iteration_step, "changed" + ) + continue + # reinforce lines lines_changes = reinforce_measures.reinforce_lines_voltage_issues( edisgo, - edisgo.topology.get_lv_grid(int(grid_id)), + lv_grid, crit_nodes[crit_nodes.lv_grid_id == grid_id], ) # write changed lines to results.equipment_changes diff --git a/edisgo/flex_opt/reinforce_measures.py b/edisgo/flex_opt/reinforce_measures.py index 28ced73f7..1ee14505f 100644 --- a/edisgo/flex_opt/reinforce_measures.py +++ b/edisgo/flex_opt/reinforce_measures.py @@ -25,7 +25,12 @@ ) from edisgo.network.grids import LVGrid, MVGrid -from edisgo.tools.tools import get_downstream_buses +from edisgo.tools.tools import ( + get_downstream_buses, + is_ront, + ront_type_name, + standard_type_name, +) if TYPE_CHECKING: from edisgo import EDisGo @@ -219,6 +224,16 @@ def _reinforce_station_overloading(edisgo_obj, critical_stations, voltage_level) ].idxmin() ] ] + if is_ront(new_transformers.iloc[0].type_info): + # the cloned transformer is a plain standard unit that + # resolves overloading, not a second RONT -- a RONT resolves + # voltage issues and stays untouched (see CONCEPT_ront.md, + # "Integrationsbefunde & Aufloesung", Befund 2). s_nom/r_pu/ + # x_pu are left unchanged since RONT is electrically + # identical to its base standard type. + new_transformers["type_info"] = standard_type_name( + new_transformers.iloc[0].type_info + ) name = new_transformers.index[0].split("_") name.insert(-1, "reinforced") name[-1] = len(grid.transformers_df) + 1 @@ -374,6 +389,61 @@ def reinforce_mv_lv_station_voltage_issues(edisgo_obj, critical_stations): return transformers_changes +def reinforce_lv_grid_ront_voltage_issues(edisgo_obj, lv_grid): + """ + Installs a RONT (regelbarer Ortsnetztransformator) in the given LV grid + to resolve LV-internal voltage issues, instead of disconnecting or + reinforcing lines. + + The existing (first) transformer's electrical parameters (`s_nom`, + `r_pu`, `x_pu`) are left unchanged -- only `type_info` is changed to the + corresponding RONT type (see :func:`~.tools.tools.ront_type_name`). The + RONT's voltage-regulating effect is therefore not modelled in the power + flow; it is accounted for in the voltage limit checks instead (see + :func:`~.flex_opt.check_tech_constraints._lv_allowed_voltage_limits` and + :func:`~.flex_opt.check_tech_constraints.allowed_voltage_limits`). This + models a bounded (see `ront_voltage_range`), otherwise ideal tap changer + -- a real RONT has discrete steps; this is a deliberate simplification, + see CONCEPT_ront.md. Whether installing a RONT is an appropriate measure + for the given grid must be checked by the caller beforehand, using + :func:`~.flex_opt.check_tech_constraints.lv_grid_ront_feasible`. + + If the LV grid has more than one transformer, only the first one (as in + :attr:`~.network.grids.LVGrid.transformers_df`) is converted to a RONT + -- consistent with the "one representative transformer" pattern already + used in :func:`reinforce_mv_lv_station_voltage_issues`. + + Parameters + ---------- + edisgo_obj : :class:`~.EDisGo` + lv_grid : :class:`~.network.grids.LVGrid` + + Returns + ------- + dict + Dictionary with the changed transformer in the form:: + + {'changed': {'LVGrid_1': ['transformer_reinforced_1']}} + + Empty (`{'changed': {}}`) if the grid's transformer already is a + RONT. + + """ + transformer_name = lv_grid.transformers_df.index[0] + base_type_info = lv_grid.transformers_df.at[transformer_name, "type_info"] + + if is_ront(base_type_info): + return {"changed": {}} + + edisgo_obj.topology.transformers_df.at[transformer_name, "type_info"] = ( + ront_type_name(base_type_info) + ) + + logger.debug(f"==> RONT installed in LV grid {lv_grid} to resolve voltage issues.") + + return {"changed": {str(lv_grid): [transformer_name]}} + + def reinforce_lines_voltage_issues(edisgo_obj, grid, crit_nodes): """ Reinforce lines in MV and LV topology due to voltage issues. diff --git a/edisgo/tools/tools.py b/edisgo/tools/tools.py index ead0219e5..88b2c2c2d 100644 --- a/edisgo/tools/tools.py +++ b/edisgo/tools/tools.py @@ -211,6 +211,97 @@ def calculate_apparent_power(nominal_voltage, current, num_parallel): return sqrt(3) * nominal_voltage * current * num_parallel +# suffix used to mark a transformer type as a RONT (regelbarer +# Ortsnetztransformator); see :func:`is_ront`. +RONT_TYPE_SUFFIX = " RONT" + + +def is_ront(type_info): + """ + Checks whether a given transformer type is a RONT (regelbarer + Ortsnetztransformator, voltage-regulating distribution transformer). + + RONT transformer types are identified by the name of the standard + equipment type they are based on (see + :attr:`~.network.topology.Topology.equipment_data`), suffixed with + " RONT", e.g. "630 kVA RONT". RONT types are electrically identical to + their base standard type (same `s_nom`/`r_pu`/`x_pu`) -- only the + voltage-regulating capability, which is modelled in the LV voltage limit + check rather than in the power flow (see + :func:`~.flex_opt.check_tech_constraints._lv_allowed_voltage_limits`), + differs. See also CONCEPT_ront.md for the underlying assumptions. + + Parameters + ---------- + type_info : str + Transformer type name as in column 'type_info' of + :attr:`~.network.topology.Topology.transformers_df`. + + Returns + ------- + bool + True if `type_info` designates a RONT type. + + """ + return str(type_info).endswith(RONT_TYPE_SUFFIX) + + +def ront_type_name(type_info): + """ + Returns the RONT (regelbarer Ortsnetztransformator) type name + corresponding to a given standard transformer type name. + + See :func:`is_ront`. + + The RONT-suffixed name returned here is a marker, not a catalogue type: + it is not, and does not need to be, a row in + :attr:`~.network.topology.Topology.equipment_data` `["lv_transformers"]`. + A transformer's electrical parameters (`s_nom`/`r_pu`/`x_pu`) always come + from its existing row in + :attr:`~.network.topology.Topology.transformers_df`, which + :func:`~.flex_opt.reinforce_measures.reinforce_lv_grid_ront_voltage_issues` + leaves unchanged when converting to RONT -- no code path re-derives them + by looking up `type_info` (RONT-suffixed or not) in the equipment + catalogue, so this works for any base name, including non-standard ones + from grid import that never had a catalogue entry to begin with. + + Parameters + ---------- + type_info : str + Standard transformer type name, e.g. as in + :attr:`~.network.topology.Topology.equipment_data` + `["lv_transformers"]`. + + Returns + ------- + str + Corresponding RONT type name, e.g. "630 kVA RONT". + + """ + return f"{type_info}{RONT_TYPE_SUFFIX}" + + +def standard_type_name(type_info): + """ + Returns the standard transformer type name corresponding to a given + RONT type name (the inverse of :func:`ront_type_name`). + + Parameters + ---------- + type_info : str + RONT transformer type name, e.g. "630 kVA RONT". + + Returns + ------- + str + Corresponding standard type name, e.g. "630 kVA". + + """ + if not is_ront(type_info): + raise ValueError(f"'{type_info}' is not a RONT type (see is_ront).") + return type_info[: -len(RONT_TYPE_SUFFIX)] + + def drop_duplicated_indices(dataframe, keep="last"): """ Drop rows of duplicate indices in dataframe. diff --git a/tests/flex_opt/test_check_tech_constraints.py b/tests/flex_opt/test_check_tech_constraints.py index bf9c90fa5..8f3507da8 100644 --- a/tests/flex_opt/test_check_tech_constraints.py +++ b/tests/flex_opt/test_check_tech_constraints.py @@ -6,6 +6,7 @@ from edisgo import EDisGo from edisgo.flex_opt import check_tech_constraints +from edisgo.tools import tools class TestCheckTechConstraints: @@ -695,6 +696,131 @@ def test_allowed_voltage_limits(self): ) assert upper.shape == (4, 41) + def test_allowed_voltage_limits_ront_split_voltage_band_false(self): + # RONT-aware final +/-10% check (CONCEPT_ront.md, "Finaler Check -- + # erlaubtes Band als Schnitt"): lv_grid_ront_feasible() proves the + # EXISTENCE of a valid reference ref(t), not that raw bus voltages + # lie in some window around v_unreg(t) -- so a feasible RONT + # station's buses are EXCLUDED from this blind check (column drop), + # not given a substitute band. An infeasible RONT station's buses + # stay on the hard [0.9, 1.1] backstop. Non-RONT buses must remain + # bit-identical to the unconditional [0.9, 1.1] band. + lv_grid_1 = self.edisgo.topology.get_lv_grid(1) + lv_grid_3 = self.edisgo.topology.get_lv_grid(3) + station_bus_1 = lv_grid_1.station.index[0] + internal_buses_1 = lv_grid_1.buses_df.index.drop(station_bus_1) + transformer_name = lv_grid_1.transformers_df.index[0] + original_type_info = self.edisgo.topology.transformers_df.at[ + transformer_name, "type_info" + ] + + # feasible at the default +/-6% control range (same numbers as + # test__lv_allowed_voltage_limits_ront) + self.edisgo.results._v_res.loc[:, station_bus_1] = 1.00 + self.edisgo.results._v_res.loc[:, internal_buses_1[::2]] = 1.05 + self.edisgo.results._v_res.loc[:, internal_buses_1[1::2]] = 1.03 + + try: + self.edisgo.topology.transformers_df.at[transformer_name, "type_info"] = ( + tools.ront_type_name(original_type_info) + ) + assert ( + check_tech_constraints.lv_grid_ront_feasible( + self.edisgo, lv_grid_1, 0.06 + ) + is True + ) + upper, lower = check_tech_constraints.allowed_voltage_limits( + self.edisgo, + buses=self.edisgo.topology.buses_df.index, + split_voltage_band=False, + ) + + # feasible RONT grid: buses excluded entirely, not present + ront_buses = lv_grid_1.buses_df.index + assert not any(b in upper.columns for b in ront_buses) + assert not any(b in lower.columns for b in ront_buses) + + # non-RONT grid: bit-identical to the unconditional [0.9, 1.1] + non_ront_buses = lv_grid_3.buses_df.index + assert (upper.loc[:, non_ront_buses] == 1.1).all().all() + assert (lower.loc[:, non_ront_buses] == 0.9).all().all() + + # now make the same RONT grid infeasible (spread too large) -- + # its buses must stay on the hard [0.9, 1.1] backstop + self.edisgo.results._v_res.loc[:, internal_buses_1[::2]] = 1.21 + self.edisgo.results._v_res.loc[:, internal_buses_1[1::2]] = 1.09 + assert ( + check_tech_constraints.lv_grid_ront_feasible( + self.edisgo, lv_grid_1, 0.06 + ) + is False + ) + upper2, lower2 = check_tech_constraints.allowed_voltage_limits( + self.edisgo, + buses=self.edisgo.topology.buses_df.index, + split_voltage_band=False, + ) + assert (upper2.loc[:, ront_buses] == 1.1).all().all() + assert (lower2.loc[:, ront_buses] == 0.9).all().all() + finally: + self.edisgo.topology.transformers_df.at[transformer_name, "type_info"] = ( + original_type_info + ) + + def test_allowed_voltage_limits_ront_buses_param_excludes_station(self): + # `buses` need not cover every bus with power flow results -- e.g. + # voltage_deviation_from_allowed_voltage_limits() is commonly called + # with `buses` restricted to a single LV grid after + # edisgo.analyze(mode="lv", lv_grid_id=...) for just that grid (see + # test_lv_line_max_relative_overload for that call pattern). For a + # feasible RONT station whose buses are not part of the + # caller-supplied `buses` in the first place (independent of + # whether it has power flow results), `grid_buses` is empty and the + # loop must skip it (continue) rather than erroring on an empty + # column drop. + lv_grid_1 = self.edisgo.topology.get_lv_grid(1) + lv_grid_3 = self.edisgo.topology.get_lv_grid(3) + station_bus_1 = lv_grid_1.station.index[0] + internal_buses_1 = lv_grid_1.buses_df.index.drop(station_bus_1) + transformer_name = lv_grid_1.transformers_df.index[0] + original_type_info = self.edisgo.topology.transformers_df.at[ + transformer_name, "type_info" + ] + + self.edisgo.results._v_res.loc[:, station_bus_1] = 1.00 + self.edisgo.results._v_res.loc[:, internal_buses_1[::2]] = 1.05 + self.edisgo.results._v_res.loc[:, internal_buses_1[1::2]] = 1.03 + + buses_excl_lv_grid_1 = lv_grid_3.buses_df.index + + try: + self.edisgo.topology.transformers_df.at[transformer_name, "type_info"] = ( + tools.ront_type_name(original_type_info) + ) + assert ( + check_tech_constraints.lv_grid_ront_feasible( + self.edisgo, lv_grid_1, 0.06 + ) + is True + ) + upper, lower = check_tech_constraints.allowed_voltage_limits( + self.edisgo, + buses=buses_excl_lv_grid_1, + split_voltage_band=False, + ) + + # lv_grid_1 was never part of `buses` -- stays absent, no error + assert not any(b in upper.columns for b in lv_grid_1.buses_df.index) + assert not any(b in lower.columns for b in lv_grid_1.buses_df.index) + # buses that were requested are untouched + assert (upper.loc[:, buses_excl_lv_grid_1] == 1.1).all().all() + assert (lower.loc[:, buses_excl_lv_grid_1] == 0.9).all().all() + finally: + self.edisgo.topology.transformers_df.at[transformer_name, "type_info"] = ( + original_type_info + ) + def test__mv_allowed_voltage_limits(self): ( v_limits_upper, @@ -807,6 +933,155 @@ def test__lv_allowed_voltage_limits(self): == v_limits_lower.at[self.timesteps[1], "BusBar_MVGrid_1_LVGrid_1_LV"] ) + def test_lv_grid_ront_feasible(self): + # three scenarios, same pattern reused at the constraint-check level + # below (test__lv_allowed_voltage_limits_ront*): (a) small spread, + # v_unreg close enough that a +/-6% control range reaches it -- + # feasible; (b) small spread (well within the 0.10 LV band) but + # v_unreg far enough away that +/-6% does NOT reach -- infeasible at + # 6%, feasible at a hypothetically larger range (20%), demonstrating + # the bounded control range is the limiting factor, not the spread; + # (c) spread itself exceeds the LV band -- infeasible regardless of + # range (see CONCEPT_ront.md, "Befund 1"). + lv_grid_1 = self.edisgo.topology.get_lv_grid(1) + station_bus = lv_grid_1.station.index[0] + internal_buses = lv_grid_1.buses_df.index.drop(station_bus) + + # (a) feasible at +/-6% + self.edisgo.results._v_res.loc[:, station_bus] = 1.00 + self.edisgo.results._v_res.loc[:, internal_buses[::2]] = 1.05 + self.edisgo.results._v_res.loc[:, internal_buses[1::2]] = 1.03 + assert ( + check_tech_constraints.lv_grid_ront_feasible(self.edisgo, lv_grid_1, 0.06) + is True + ) + + # (b) small spread (0.03), but v_unreg too far away for +/-6%; + # +/-10% would reach. Internal levels chosen so that the required + # window (v_max-rise=1.085, v_min+drop=1.155) stays within the hard + # +/-10% system limit at range=0.10 (v_unreg+0.10=1.10), so this + # tests the control-range limit specifically, not the hard clip + # from Punkt 1 (see test__lv_allowed_voltage_limits_ront_bounded_ + # range_insufficient for a case where the hard clip itself binds). + self.edisgo.results._v_res.loc[:, station_bus] = 1.00 + self.edisgo.results._v_res.loc[:, internal_buses[::2]] = 1.12 + self.edisgo.results._v_res.loc[:, internal_buses[1::2]] = 1.09 + assert ( + check_tech_constraints.lv_grid_ront_feasible(self.edisgo, lv_grid_1, 0.06) + is False + ) + assert ( + check_tech_constraints.lv_grid_ront_feasible(self.edisgo, lv_grid_1, 0.10) + is True + ) + + # (c) spread itself (0.12) exceeds the LV band (0.10) -- infeasible + # even with a very large range + self.edisgo.results._v_res.loc[:, station_bus] = 1.00 + self.edisgo.results._v_res.loc[:, internal_buses[::2]] = 1.21 + self.edisgo.results._v_res.loc[:, internal_buses[1::2]] = 1.09 + assert ( + check_tech_constraints.lv_grid_ront_feasible(self.edisgo, lv_grid_1, 0.50) + is False + ) + + def test_lv_grid_ront_feasible_grid_not_in_power_flow(self): + # if this grid's secondary side was not part of the last power flow + # (e.g. edisgo.analyze(mode="lv", lv_grid_id=...) for a different + # grid), lv_grid_ront_feasible() cannot prove feasibility and must + # return False via its guard clause rather than raising a KeyError + # when indexing v_res. + lv_grid_1 = self.edisgo.topology.get_lv_grid(1) + self.edisgo.analyze(mode="lv", lv_grid_id=5) + + assert ( + check_tech_constraints.lv_grid_ront_feasible(self.edisgo, lv_grid_1, 0.06) + is False + ) + + def test__lv_allowed_voltage_limits_ront(self): + # Isolated test of the RONT constraint-check mechanism (Option A, + # see CONCEPT_ront.md, "Constraint-Check-Mechanismus"): for a RONT + # (regelbarer Ortsnetztransformator) grid, the reference voltage + # used to build the allowed band is not the actual, unregulated + # secondary side voltage, but an idealised per-time-step value + # derived from the grid's own internal bus voltages, clipped to the + # configured control range (default ront_voltage_range=0.06) around + # the unregulated secondary voltage. Same v_res in both cases, only + # `type_info` differs. + lv_grid_1 = self.edisgo.topology.get_lv_grid(1) + station_bus = lv_grid_1.station.index[0] + internal_buses = lv_grid_1.buses_df.index.drop(station_bus) + transformer_name = lv_grid_1.transformers_df.index[0] + original_type_info = self.edisgo.topology.transformers_df.at[ + transformer_name, "type_info" + ] + + # station (unregulated reference) at 1.00, internal buses close + # enough (1.03/1.05) that the default +/-6% control range reaches -- + # but the absolute level still exceeds the unregulated +/-[3.5%,6.5%] + # LV band. + self.edisgo.results._v_res.loc[:, station_bus] = 1.00 + self.edisgo.results._v_res.loc[:, internal_buses[::2]] = 1.05 + self.edisgo.results._v_res.loc[:, internal_buses[1::2]] = 1.03 + + try: + # without RONT: reference is the actual secondary side voltage + # (1.00) -- band [0.935, 1.035] does not reach 1.05, violation + # remains + upper, lower = check_tech_constraints._lv_allowed_voltage_limits( + self.edisgo, lv_grids=[lv_grid_1], mode=None + ) + v = self.edisgo.results.v_res.loc[:, internal_buses] + assert (v > upper[internal_buses]).any().any() + + # with RONT: reference floats (clipped to +/-6% around 1.00) -- + # no violation + self.edisgo.topology.transformers_df.at[transformer_name, "type_info"] = ( + tools.ront_type_name(original_type_info) + ) + upper_r, lower_r = check_tech_constraints._lv_allowed_voltage_limits( + self.edisgo, lv_grids=[lv_grid_1], mode=None + ) + assert not (v > upper_r[internal_buses]).any().any() + assert not (v < lower_r[internal_buses]).any().any() + finally: + self.edisgo.topology.transformers_df.at[transformer_name, "type_info"] = ( + original_type_info + ) + + def test__lv_allowed_voltage_limits_ront_bounded_range_insufficient(self): + # small spread (0.02, well within the 0.10 LV band) but v_unreg far + # enough from the internal buses that the default +/-6% control + # range cannot reach -- must remain a violation (bounding, not just + # spread, is the limiting factor here; see CONCEPT_ront.md, + # "Befund 1"). + lv_grid_1 = self.edisgo.topology.get_lv_grid(1) + station_bus = lv_grid_1.station.index[0] + internal_buses = lv_grid_1.buses_df.index.drop(station_bus) + transformer_name = lv_grid_1.transformers_df.index[0] + original_type_info = self.edisgo.topology.transformers_df.at[ + transformer_name, "type_info" + ] + + self.edisgo.results._v_res.loc[:, station_bus] = 1.00 + self.edisgo.results._v_res.loc[:, internal_buses[::2]] = 1.16 + self.edisgo.results._v_res.loc[:, internal_buses[1::2]] = 1.14 + + try: + self.edisgo.topology.transformers_df.at[transformer_name, "type_info"] = ( + tools.ront_type_name(original_type_info) + ) + upper_r, lower_r = check_tech_constraints._lv_allowed_voltage_limits( + self.edisgo, lv_grids=[lv_grid_1], mode=None + ) + v = self.edisgo.results.v_res.loc[:, internal_buses] + assert (v > upper_r[internal_buses]).any().any() + finally: + self.edisgo.topology.transformers_df.at[transformer_name, "type_info"] = ( + original_type_info + ) + def test_voltage_deviation_from_allowed_voltage_limits(self): # create MV voltage issues self.mv_voltage_issues() diff --git a/tests/flex_opt/test_costs.py b/tests/flex_opt/test_costs.py index 308f9e7e6..1978be1d4 100644 --- a/tests/flex_opt/test_costs.py +++ b/tests/flex_opt/test_costs.py @@ -102,6 +102,63 @@ def test_costs(self): assert costs.loc["Line_50000002", "type"] == "NAYY 4x1x35" assert costs.loc["Line_50000002", "voltage_level"] == "lv" + def test_costs_ront_transformer(self): + # "changed" transformer entries (currently only RONT conversions, + # see CONCEPT_ront.md) must be costed alongside "added" ones, with + # the ront_surcharge on top of the base lv rate -- and must NOT be + # costed if later removed (e.g. a subsequent, more severe overload + # replaced the whole station). + edisgo = EDisGo(ding0_grid=pytest.ding0_test_network_path) + edisgo.set_time_series_worst_case_analysis() + edisgo.analyze() + + transformer_name = "LVStation_1_transformer_1" + edisgo.topology.transformers_df.at[transformer_name, "type_info"] = ( + "160 kVA RONT" + ) + + edisgo.results.equipment_changes = pd.DataFrame( + { + "iteration_step": [3, 5], + "change": ["changed", "changed"], + "equipment": [transformer_name, "LVStation_9_transformer_1"], + "quantity": [1, 1], + }, + index=["LVGrid_1_station", "LVGrid_9_station"], + ) + + costs = costs_mod.grid_expansion_costs(edisgo) + + assert len(costs) == 2 + assert costs.at[transformer_name, "total_costs"] == 10 + 20 + assert costs.at[transformer_name, "voltage_level"] == "mv/lv" + # non-RONT "changed" transformer entry: plain base rate, no surcharge + assert costs.at["LVStation_9_transformer_1", "total_costs"] == 10 + + def test_costs_ront_transformer_later_removed_not_costed(self): + edisgo = EDisGo(ding0_grid=pytest.ding0_test_network_path) + edisgo.set_time_series_worst_case_analysis() + edisgo.analyze() + + transformer_name = "LVStation_1_transformer_1" + edisgo.topology.transformers_df.at[transformer_name, "type_info"] = ( + "160 kVA RONT" + ) + edisgo.topology.transformers_df.drop(transformer_name, inplace=True) + + edisgo.results.equipment_changes = pd.DataFrame( + { + "iteration_step": [3, 6], + "change": ["changed", "removed"], + "equipment": [transformer_name, transformer_name], + "quantity": [1, 1], + }, + index=["LVGrid_1_station", "LVGrid_1_station"], + ) + + costs = costs_mod.grid_expansion_costs(edisgo) + assert transformer_name not in costs.index + def test_line_expansion_costs(self): costs = costs_mod.line_expansion_costs(self.edisgo) assert len(costs) == len(self.edisgo.topology.lines_df) diff --git a/tests/flex_opt/test_reinforce_grid.py b/tests/flex_opt/test_reinforce_grid.py index e073b2326..7a463430a 100644 --- a/tests/flex_opt/test_reinforce_grid.py +++ b/tests/flex_opt/test_reinforce_grid.py @@ -1,14 +1,17 @@ import copy import numpy as np +import pandas as pd import pytest from numpy.testing import assert_array_equal from pandas.testing import assert_frame_equal from edisgo import EDisGo +from edisgo.flex_opt import check_tech_constraints from edisgo.flex_opt.costs import grid_expansion_costs from edisgo.flex_opt.reinforce_grid import reinforce_grid, run_separate_lv_grids +from edisgo.tools import tools class TestReinforceGrid: @@ -63,6 +66,141 @@ def test_reinforce_grid(self): ) assert len(res_reduced.i_res) == 2 + def test_reinforce_grid_enable_ront_idempotent_across_iterations(self): + # A grid that keeps reappearing in crit_nodes across several + # while-iterations (simulated via monkeypatching + # check_tech_constraints.voltage_issues with a call counter) must + # get RONT installed exactly once -- not once per iteration. This + # is the idempotency guard 'not tools.is_ront(...)' added to the + # enable_ront branch in reinforce_grid() (see CONCEPT_ront.md). + edisgo = copy.deepcopy(self.edisgo) + edisgo.analyze() + + lv_grid_1 = edisgo.topology.get_lv_grid(1) + transformer_name = lv_grid_1.transformers_df.index[0] + original_type_info = edisgo.topology.transformers_df.at[ + transformer_name, "type_info" + ] + timestep = edisgo.timeseries.timeindex[0] + + crit_nodes_df = pd.DataFrame( + { + "abs_max_voltage_dev": [0.05], + "time_index": [timestep], + "lv_grid_id": [1], + }, + index=["Bus_BranchTee_LVGrid_1_1"], + ) + + call_state = {"count": 0} + real_voltage_issues = check_tech_constraints.voltage_issues + real_lv_grid_ront_feasible = check_tech_constraints.lv_grid_ront_feasible + + def fake_voltage_issues(edisgo_obj, voltage_level, **kwargs): + if voltage_level == "lv": + call_state["count"] += 1 + # keep reporting the same grid as critical for the first + # two calls, forcing a second while-iteration to revisit it + if call_state["count"] <= 2: + return crit_nodes_df.copy() + return pd.DataFrame(dtype=float) + return real_voltage_issues(edisgo_obj, voltage_level=voltage_level, **kwargs) + + def fake_feasible(edisgo_obj, lv_grid, ront_voltage_range): + return True # always feasible -> RONT triggers + + check_tech_constraints.voltage_issues = fake_voltage_issues + check_tech_constraints.lv_grid_ront_feasible = fake_feasible + try: + result = reinforce_grid(edisgo, mode="lv", enable_ront=True) + finally: + check_tech_constraints.voltage_issues = real_voltage_issues + check_tech_constraints.lv_grid_ront_feasible = real_lv_grid_ront_feasible + + # sanity check that the test actually forced multiple iterations + assert call_state["count"] >= 3 + + assert edisgo.topology.transformers_df.at[ + transformer_name, "type_info" + ] == tools.ront_type_name(original_type_info) + + changed_rows = result.equipment_changes[ + (result.equipment_changes.index == str(lv_grid_1)) + & (result.equipment_changes.change == "changed") + & (result.equipment_changes.equipment == transformer_name) + ] + assert len(changed_rows) == 1 + + def test_reinforce_grid_enable_ront_infeasible_falls_back(self): + # A grid for which lv_grid_ront_feasible() reports infeasible (e.g. + # spread too large, or v_unreg too far for the control range -- see + # CONCEPT_ront.md, "Befund 1") must NOT get RONT -- the existing + # line-based voltage kaskade must run unchanged, bit-identical to + # enable_ront=False. + edisgo_ront = copy.deepcopy(self.edisgo) + edisgo_baseline = copy.deepcopy(self.edisgo) + edisgo_ront.analyze() + edisgo_baseline.analyze() + + timestep = edisgo_ront.timeseries.timeindex[0] + crit_nodes_df = pd.DataFrame( + { + "abs_max_voltage_dev": [0.08], + "time_index": [timestep], + "lv_grid_id": [1], + }, + index=["Bus_BranchTee_LVGrid_1_1"], + ) + + real_voltage_issues = check_tech_constraints.voltage_issues + real_lv_grid_ront_feasible = check_tech_constraints.lv_grid_ront_feasible + + def make_fake_voltage_issues(): + state = {"count": 0} + + def fake(edisgo_obj, voltage_level, **kwargs): + if voltage_level == "lv": + state["count"] += 1 + if state["count"] == 1: + return crit_nodes_df.copy() + return real_voltage_issues( + edisgo_obj, voltage_level=voltage_level, **kwargs + ) + + return fake + + def fake_infeasible(edisgo_obj, lv_grid, ront_voltage_range): + return False + + check_tech_constraints.voltage_issues = make_fake_voltage_issues() + check_tech_constraints.lv_grid_ront_feasible = fake_infeasible + try: + result_ront = reinforce_grid(edisgo_ront, mode="lv", enable_ront=True) + finally: + check_tech_constraints.voltage_issues = real_voltage_issues + check_tech_constraints.lv_grid_ront_feasible = real_lv_grid_ront_feasible + + check_tech_constraints.voltage_issues = make_fake_voltage_issues() + try: + result_baseline = reinforce_grid( + edisgo_baseline, mode="lv", enable_ront=False + ) + finally: + check_tech_constraints.voltage_issues = real_voltage_issues + + transformer_name = edisgo_ront.topology.get_lv_grid(1).transformers_df.index[0] + assert not tools.is_ront( + edisgo_ront.topology.transformers_df.at[transformer_name, "type_info"] + ) + assert_frame_equal( + edisgo_ront.topology.lines_df.sort_index(), + edisgo_baseline.topology.lines_df.sort_index(), + ) + assert_frame_equal( + result_ront.equipment_changes.sort_index(), + result_baseline.equipment_changes.sort_index(), + ) + def test_run_separate_lv_grids(self): edisgo = copy.deepcopy(self.edisgo) diff --git a/tests/flex_opt/test_reinforce_measures.py b/tests/flex_opt/test_reinforce_measures.py index 5ea720064..9535b0608 100644 --- a/tests/flex_opt/test_reinforce_measures.py +++ b/tests/flex_opt/test_reinforce_measures.py @@ -6,6 +6,7 @@ from edisgo import EDisGo from edisgo.flex_opt import check_tech_constraints, reinforce_measures +from edisgo.tools import tools class TestReinforceMeasures: @@ -197,6 +198,107 @@ def test_reinforce_mv_lv_station_voltage_issues(self): assert trafo_new.s_nom == trafo_copy.S_nom assert trafo_new.type_info == "630 kVA" + def test_reinforce_mv_lv_station_overloading_ront_clone_branch(self): + # Befund 2 (CONCEPT_ront.md, "Integrationsbefunde & Aufloesung"): + # the "second transformer of the same kind" clone branch in + # _reinforce_station_overloading() must not clone a RONT type_info + # onto the new transformer -- the new one resolves overloading + # (standard type), the existing RONT stays untouched (resolves + # voltage issues). + self.edisgo = copy.deepcopy(self.edisgo_root) + lv_grid_4 = self.edisgo.topology.get_lv_grid(4) + original_transformer_name = lv_grid_4.transformers_df.index[0] + original_type_info = self.edisgo.topology.transformers_df.at[ + original_transformer_name, "type_info" + ] + self.edisgo.topology.transformers_df.at[ + original_transformer_name, "type_info" + ] = tools.ront_type_name(original_type_info) + + crit_lv_stations = pd.DataFrame( + { + "s_missing": [0.04], + "time_index": [self.timesteps[1]], + "grid": [lv_grid_4], + }, + index=[lv_grid_4.station_name], + ) + transformer_changes = reinforce_measures.reinforce_mv_lv_station_overloading( + self.edisgo, crit_lv_stations + ) + + new_transformer_name = transformer_changes["added"]["LVGrid_4_station"][0] + + # the original (RONT) transformer is untouched + assert tools.is_ront( + self.edisgo.topology.transformers_df.at[ + original_transformer_name, "type_info" + ] + ) + # the newly added, cloned transformer is the plain standard type, + # not a second RONT -- but electrically identical (s_nom/r_pu/x_pu + # unchanged, since RONT and standard type are electrically the same) + new_trafo = self.edisgo.topology.transformers_df.loc[new_transformer_name] + assert not tools.is_ront(new_trafo.type_info) + assert new_trafo.type_info == original_type_info + assert ( + new_trafo.s_nom + == self.edisgo.topology.transformers_df.at[ + original_transformer_name, "s_nom" + ] + ) + + def test_reinforce_lv_grid_ront_voltage_issues(self): + self.edisgo = copy.deepcopy(self.edisgo_root) + lv_grid_1 = self.edisgo.topology.get_lv_grid(1) + transformer_name = lv_grid_1.transformers_df.index[0] + original_type_info = self.edisgo.topology.transformers_df.at[ + transformer_name, "type_info" + ] + + s_nom_before = self.edisgo.topology.transformers_df.at[ + transformer_name, "s_nom" + ] + r_pu_before = self.edisgo.topology.transformers_df.at[transformer_name, "r_pu"] + x_pu_before = self.edisgo.topology.transformers_df.at[transformer_name, "x_pu"] + + transformer_changes = ( + reinforce_measures.reinforce_lv_grid_ront_voltage_issues( + self.edisgo, lv_grid_1 + ) + ) + + assert transformer_changes == {"changed": {"LVGrid_1": [transformer_name]}} + assert ( + self.edisgo.topology.transformers_df.at[transformer_name, "type_info"] + == f"{original_type_info} RONT" + ) + assert ( + self.edisgo.topology.transformers_df.at[transformer_name, "s_nom"] + == s_nom_before + ) + assert ( + self.edisgo.topology.transformers_df.at[transformer_name, "r_pu"] + == r_pu_before + ) + assert ( + self.edisgo.topology.transformers_df.at[transformer_name, "x_pu"] + == x_pu_before + ) + + # idempotency: a second call on an already-RONT transformer is a + # no-op (no double suffix, no further change reported) + transformer_changes_2 = ( + reinforce_measures.reinforce_lv_grid_ront_voltage_issues( + self.edisgo, lv_grid_1 + ) + ) + assert transformer_changes_2 == {"changed": {}} + assert ( + self.edisgo.topology.transformers_df.at[transformer_name, "type_info"] + == f"{original_type_info} RONT" + ) + def test_reinforce_lines_voltage_issues(self): # MV: # * check where node_2_3 is an LV station => problem at diff --git a/tests/tools/test_tools.py b/tests/tools/test_tools.py index f6d08f729..4900dc9bc 100644 --- a/tests/tools/test_tools.py +++ b/tests/tools/test_tools.py @@ -248,6 +248,23 @@ def test_calculate_apparent_power(self): ) assert_allclose(data, np.array([1039.23 * 2, 1558.84 * 3]), rtol=1e-5) + def test_is_ront(self): + assert tools.is_ront("630 kVA RONT") is True + assert tools.is_ront("630 kVA") is False + assert tools.is_ront("NAYY 4x1x150") is False + + def test_ront_type_name(self): + assert tools.ront_type_name("630 kVA") == "630 kVA RONT" + assert tools.is_ront(tools.ront_type_name("630 kVA")) is True + + def test_standard_type_name(self): + assert tools.standard_type_name("630 kVA RONT") == "630 kVA" + assert ( + tools.standard_type_name(tools.ront_type_name("630 kVA")) == "630 kVA" + ) + with pytest.raises(ValueError, match="is not a RONT type"): + tools.standard_type_name("630 kVA") + def test_drop_duplicated_indices(self): test_df = pd.DataFrame( data={