diff --git a/edisgo/io/powermodels_io.py b/edisgo/io/powermodels_io.py index cba6ce944..314737e94 100644 --- a/edisgo/io/powermodels_io.py +++ b/edisgo/io/powermodels_io.py @@ -116,8 +116,8 @@ def to_powermodels( pm = _init_pm() timesteps = len(psa_net.snapshots) # number of considered timesteps pm["name"] = f"ding0_{edisgo_object.topology.id}_t_{timesteps}" - pm["time_elapsed"] = int( - (psa_net.snapshots[1] - psa_net.snapshots[0]).seconds / 3600 + pm["time_elapsed"] = _get_time_elapsed_in_hours( + psa_net.snapshots ) # length of timesteps in hours pm["baseMVA"] = s_base pm["source_version"] = 2 @@ -794,8 +794,8 @@ def _build_branch(edisgo_obj, psa_net, pm, flexible_storage_units, s_base): # only modify r, x and l values if min value is too small branches[par] = val.clip(lower=min_value) logger.warning( - f"Min value of {text} is too small. Lowest {100 * quant}% of {text} values will be set " - f"to {min_value} {unit}" + f"Min value of {text} is too small. Lowest {100 * quant}% of " + f"{text} values will be set to {min_value} {unit}" ) for branch_i in np.arange(len(branches.index)): @@ -940,8 +940,8 @@ def _build_load( pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "charging_point") else: logger.warning( - f"No type specified for load {loads_df.index[load_i]}. Power factor and sign will" - "be set for conventional load." + f"No type specified for load {loads_df.index[load_i]}. " + " Power factor and sign will be set for conventional load." ) pf, sign = _get_pf(edisgo_obj, pm, idx_bus, "conventional_load") p_d = psa_net.loads_t.p_set[loads_df.index[load_i]] @@ -1226,9 +1226,9 @@ def _build_heatpump(psa_net, pm, edisgo_obj, s_base, flexible_hps): comparison = (heat_df2[hp_p_nom.index] > hp_cop * hp_p_nom.squeeze()).any() if comparison.any(): logger.warning( - "Heat demand is higher than rated heatpump power" - f" of heatpumps: {comparison.index[comparison.values].values}. Demand can not be covered if no sufficient" - " heat storage capacities are available." + "Heat demand is higher than rated heatpump power of heatpumps: " + f"{comparison.index[comparison.values].values}. Demand can not be " + "covered if no sufficient heat storage capacities are available." ) for hp_i in np.arange(len(heat_df.index)): idx_bus = _mapping(psa_net, edisgo_obj, heat_df.bus.iloc[hp_i]) @@ -1936,6 +1936,42 @@ def _build_component_timeseries( pm["time_series"][kind] = pm_comp +def _get_time_elapsed_in_hours(snapshots): + """ + Calculate time elapsed in hours between two consecutive snapshots. + + Parameters + ---------- + snapshots : :pandas:`pandas.DatetimeIndex` + DatetimeIndex of snapshots. + + Returns + ------- + float + Time elapsed in hours between two consecutive snapshots. + """ + if len(snapshots) < 2: + raise ValueError( + "At least two snapshots are required to determine time_elapsed " + "for the PowerModels OPF." + ) + + snapshot_deltas = snapshots.to_series().diff().dropna() + + if not (snapshot_deltas == snapshot_deltas.iloc[0]).all(): + raise ValueError( + "PowerModels OPF requires equidistant snapshots because " + "inter-timestep couplings use one global time_elapsed value." + ) + + time_elapsed = snapshot_deltas.iloc[0].total_seconds() / 3600 + + if time_elapsed <= 0: + raise ValueError("Snapshot time step must be positive.") + + return time_elapsed + + def _mapping( psa_net, edisgo_obj, diff --git a/tests/io/test_powermodels_io.py b/tests/io/test_powermodels_io.py index b3bfab036..8b2fbc350 100644 --- a/tests/io/test_powermodels_io.py +++ b/tests/io/test_powermodels_io.py @@ -4,6 +4,7 @@ from edisgo import EDisGo from edisgo.io import powermodels_io +from edisgo.io.powermodels_io import _get_time_elapsed_in_hours from edisgo.tools.tools import aggregate_district_heating_components @@ -329,3 +330,61 @@ def test__get_pf(self): ) assert pf == 1 assert sign == 1 + + +# test _get_time_elapsed_in_hours for inter-timestep couplings in +# Julia to simulate +# 1) timesteps <1h, 1h, 2h, >24h and +# 2) whether too few, non-equidistant or +# negative timestamps raise an error +def _snapshots(*timestamps): + return pd.to_datetime(timestamps) + + +# 1) test various snapshot intervals and expected time elapsed in hours +@pytest.mark.parametrize( + ("snapshots", "expected"), + [ + (pd.date_range("2035-01-01", periods=3, freq="15min"), 0.25), + (pd.date_range("2035-01-01", periods=3, freq="h"), 1.0), + (pd.date_range("2035-01-01", periods=3, freq="2h"), 2.0), + (_snapshots("2035-01-01 00:00", "2035-01-02 01:00"), 25.0), + ], +) +# test expected time elapsed in hours for above defined snapshot intervals +def test_get_time_elapsed_in_hours(snapshots, expected): + assert _get_time_elapsed_in_hours(snapshots) == pytest.approx(expected) + + +# 2) test three other cases: +# 1. not enough snapshots (1 snapshot) +# 2. non-equidistant snapshots (15min, 1h, 1h 15min) +# 3. negative time elapsed (snapshots in reverse order) +@pytest.mark.parametrize( + ("snapshots", "error_message"), + [ + ( + _snapshots("2035-01-01 00:00"), + "At least two snapshots", + ), + ( + _snapshots( + "2035-01-01 00:00", + "2035-01-01 00:15", + "2035-01-01 01:15", + ), + "equidistant", + ), + ( + _snapshots( + "2035-01-01 01:00", + "2035-01-01 00:00", + ), + "positive", + ), + ], +) +# test that ValueError is raised for the above three cases +def test_get_time_elapsed_in_hours_raises(snapshots, error_message): + with pytest.raises(ValueError, match=error_message): + _get_time_elapsed_in_hours(snapshots)