Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions edisgo/config/config_grid_expansion_default.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
171 changes: 155 additions & 16 deletions edisgo/flex_opt/check_tech_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
):
Expand Down
41 changes: 38 additions & 3 deletions edisgo/flex_opt/costs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -133,14 +155,27 @@ 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(
[
edisgo_obj.topology.transformers_hvmv_df,
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(
Expand Down
Loading