diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d18c1a3e1..624ae6f25 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,7 +2,10 @@ name: "CodeQL" on: push: - branches: [ "dev", "master" ] + branches: + - dev + - master + - 'release/**' pull_request: branches: [ "dev" ] schedule: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8a4a7fff9..3e5f73159 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,6 +5,7 @@ on: branches: - master - dev + - 'release/**' pull_request: jobs: diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml index 874a5bfb7..d53aa82b8 100644 --- a/.github/workflows/packaging.yml +++ b/.github/workflows/packaging.yml @@ -3,7 +3,10 @@ name: packaging on: # Make sure packaging process is not broken push: - branches: [master, dev] + branches: + - master + - dev + - 'release/**' pull_request: # Make a package for release release: diff --git a/.github/workflows/tox_checks.yml b/.github/workflows/tox_checks.yml index f575ed022..5553b0acf 100644 --- a/.github/workflows/tox_checks.yml +++ b/.github/workflows/tox_checks.yml @@ -6,6 +6,7 @@ on: branches: - master - dev + - 'release/**' pull_request: workflow_dispatch: diff --git a/.github/workflows/tox_pytests.yml b/.github/workflows/tox_pytests.yml index 44c8d09c9..a28cb482d 100644 --- a/.github/workflows/tox_pytests.yml +++ b/.github/workflows/tox_pytests.yml @@ -5,6 +5,7 @@ on: branches: - master - dev + - 'release/**' pull_request: workflow_dispatch: diff --git a/docs/changelog.rst b/docs/changelog.rst index 05eaa230c..1e47eccbd 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,7 @@ These are new features and improvements of note in each release .. include:: whatsnew/v0-6-0.rst +.. include:: whatsnew/v0-5-5.rst .. include:: whatsnew/v0-5-4.rst .. include:: whatsnew/v0-5-3.rst .. include:: whatsnew/v0-5-2.rst diff --git a/docs/whatsnew/v0-5-5.rst b/docs/whatsnew/v0-5-5.rst new file mode 100644 index 000000000..944d2e1b8 --- /dev/null +++ b/docs/whatsnew/v0-5-5.rst @@ -0,0 +1,12 @@ +v0.5.5 (August 29th, 2024) +-------------------------- + +Bug fixes +######### + +* Fix iterating over _FakeSequence objects + +Contributors +############ + +* Patrik Schönfeldt diff --git a/src/oemof/solph/_plumbing.py b/src/oemof/solph/_plumbing.py index bb6ed5b80..77c8ffb09 100644 --- a/src/oemof/solph/_plumbing.py +++ b/src/oemof/solph/_plumbing.py @@ -10,7 +10,7 @@ SPDX-License-Identifier: MIT """ - +import warnings from collections import abc from itertools import repeat @@ -19,8 +19,8 @@ def sequence(iterable_or_scalar): """Checks if an object is iterable (except string) or scalar and returns - the original sequence if object is an iterable and an 'emulated' - sequence object of class _Sequence if object is a scalar or string. + the an numpy array of the sequence if object is an iterable or an + 'emulated' sequence object of class _FakeSequence if object is a scalar. Parameters ---------- @@ -56,6 +56,43 @@ def sequence(iterable_or_scalar): return _FakeSequence(value=iterable_or_scalar) +def valid_sequence(sequence, length: int) -> bool: + """Checks if an object is a numpy array of at least the given length + or an 'emulated' sequence object of class _FakeSequence. + If unset, the latter is set to the required lenght. + + """ + if sequence[0] is None: + return False + + if isinstance(sequence, _FakeSequence): + if sequence.size is None: + sequence.size = length + + if sequence.size == length: + return True + else: + return False + + if isinstance(sequence, np.ndarray): + if sequence.size == length: + return True + # --- BEGIN: To be removed for versions >= v0.6 --- + elif sequence.size > length: + warnings.warn( + "Sequence longer than needed" + f" ({sequence.size} items instead of {length})." + " This will be trated as an error in the future.", + FutureWarning, + ) + return True + # --- END --- + else: + raise ValueError(f"Lentgh of {sequence} should be {length}.") + + return False + + class _FakeSequence: """Emulates a list whose length is not known in advance. diff --git a/src/oemof/solph/components/_extraction_turbine_chp.py b/src/oemof/solph/components/_extraction_turbine_chp.py index cef1a6152..dc8fb393b 100644 --- a/src/oemof/solph/components/_extraction_turbine_chp.py +++ b/src/oemof/solph/components/_extraction_turbine_chp.py @@ -23,8 +23,8 @@ from pyomo.environ import BuildAction from pyomo.environ import Constraint -from oemof.solph._plumbing import sequence as solph_sequence -from oemof.solph.components._converter import Converter +from oemof.solph._plumbing import sequence +from oemof.solph.components import Converter class ExtractionTurbineCHP(Converter): @@ -87,7 +87,7 @@ def __init__( custom_attributes=custom_attributes, ) self.conversion_factor_full_condensation = { - k: solph_sequence(v) + k: sequence(v) for k, v in conversion_factor_full_condensation.items() } diff --git a/src/oemof/solph/components/_generic_storage.py b/src/oemof/solph/components/_generic_storage.py index 653e6f0c4..33358a469 100644 --- a/src/oemof/solph/components/_generic_storage.py +++ b/src/oemof/solph/components/_generic_storage.py @@ -38,7 +38,8 @@ from oemof.solph._helpers import check_node_object_for_missing_attribute from oemof.solph._options import Investment -from oemof.solph._plumbing import sequence as solph_sequence +from oemof.solph._plumbing import sequence +from oemof.solph._plumbing import valid_sequence class GenericStorage(Node): @@ -225,26 +226,22 @@ def __init__( self.initial_storage_level = initial_storage_level self.balanced = balanced - self.loss_rate = solph_sequence(loss_rate) - self.fixed_losses_relative = solph_sequence(fixed_losses_relative) - self.fixed_losses_absolute = solph_sequence(fixed_losses_absolute) - self.inflow_conversion_factor = solph_sequence( - inflow_conversion_factor - ) - self.outflow_conversion_factor = solph_sequence( - outflow_conversion_factor - ) - self.max_storage_level = solph_sequence(max_storage_level) - self.min_storage_level = solph_sequence(min_storage_level) - self.fixed_costs = solph_sequence(fixed_costs) - self.storage_costs = solph_sequence(storage_costs) - self.invest_relation_input_output = solph_sequence( + self.loss_rate = sequence(loss_rate) + self.fixed_losses_relative = sequence(fixed_losses_relative) + self.fixed_losses_absolute = sequence(fixed_losses_absolute) + self.inflow_conversion_factor = sequence(inflow_conversion_factor) + self.outflow_conversion_factor = sequence(outflow_conversion_factor) + self.max_storage_level = sequence(max_storage_level) + self.min_storage_level = sequence(min_storage_level) + self.fixed_costs = sequence(fixed_costs) + self.storage_costs = sequence(storage_costs) + self.invest_relation_input_output = sequence( invest_relation_input_output ) - self.invest_relation_input_capacity = solph_sequence( + self.invest_relation_input_capacity = sequence( invest_relation_input_capacity ) - self.invest_relation_output_capacity = solph_sequence( + self.invest_relation_output_capacity = sequence( invest_relation_output_capacity ) self.lifetime_inflow = lifetime_inflow @@ -607,7 +604,7 @@ def _objective_expression(self): if m.es.periods is not None: for n in self.STORAGES: - if n.fixed_costs[0] is not None: + if valid_sequence(n.fixed_costs, len(m.PERIODS)): fixed_costs += sum( n.nominal_storage_capacity * n.fixed_costs[pp] @@ -619,7 +616,7 @@ def _objective_expression(self): storage_costs = 0 for n in self.STORAGES: - if n.storage_costs[0] is not None: + if valid_sequence(n.storage_costs, len(m.TIMESTEPS)): # We actually want to iterate over all TIMEPOINTS except the # 0th. As integers are used for the index, this is equicalent # to iterating over the TIMESTEPS with one offset. @@ -1875,7 +1872,7 @@ def _objective_expression(self): period_investment_costs[p] += investment_costs_increment for n in self.INVESTSTORAGES: - if n.investment.fixed_costs[0] is not None: + if valid_sequence(n.investment.fixed_costs, len(m.PERIODS)): lifetime = n.investment.lifetime for p in m.PERIODS: range_limit = min( @@ -1893,7 +1890,7 @@ def _objective_expression(self): ) for n in self.EXISTING_INVESTSTORAGES: - if n.investment.fixed_costs[0] is not None: + if valid_sequence(n.investment.fixed_costs, len(m.PERIODS)): lifetime = n.investment.lifetime age = n.investment.age range_limit = min( diff --git a/src/oemof/solph/components/experimental/_sink_dsm.py b/src/oemof/solph/components/experimental/_sink_dsm.py index 1d910f54e..4645a5899 100644 --- a/src/oemof/solph/components/experimental/_sink_dsm.py +++ b/src/oemof/solph/components/experimental/_sink_dsm.py @@ -38,6 +38,7 @@ from oemof.solph._options import Investment from oemof.solph._plumbing import sequence +from oemof.solph._plumbing import valid_sequence from oemof.solph.components._sink import Sink @@ -703,7 +704,7 @@ def _objective_expression(self): * (1 + m.discount_rate) ** (-m.es.periods_years[p]) ) - if g.fixed_costs[0] is not None: + if valid_sequence(g.fixed_costs, len(m.PERIODS)): fixed_costs += sum( max(g.max_capacity_up, g.max_capacity_down) * g.fixed_costs[pp] @@ -1434,7 +1435,7 @@ def _objective_expression(self): * (1 + m.discount_rate) ** (-m.es.periods_years[p]) ) - if g.investment.fixed_costs[0] is not None: + if valid_sequence(g.investment.fixed_costs, len(m.PERIODS)): lifetime = g.investment.lifetime for p in m.PERIODS: range_limit = min( @@ -1452,7 +1453,7 @@ def _objective_expression(self): ) for g in self.EXISTING_INVESTDSM: - if g.investment.fixed_costs[0] is not None: + if valid_sequence(g.investment.fixed_costs, len(m.PERIODS)): lifetime = g.investment.lifetime age = g.investment.age range_limit = min( @@ -2198,7 +2199,7 @@ def _objective_expression(self): * (1 + m.discount_rate) ** (-m.es.periods_years[p]) ) - if g.fixed_costs[0] is not None: + if valid_sequence(g.fixed_costs, len(m.PERIODS)): fixed_costs += sum( max(g.max_capacity_up, g.max_capacity_down) * g.fixed_costs[pp] @@ -3290,7 +3291,7 @@ def _objective_expression(self): * (1 + m.discount_rate) ** (-m.es.periods_years[p]) ) - if g.investment.fixed_costs[0] is not None: + if valid_sequence(g.investment.fixed_costs, len(m.PERIODS)): lifetime = g.investment.lifetime for p in m.PERIODS: range_limit = min( @@ -3308,7 +3309,7 @@ def _objective_expression(self): ) for g in self.EXISTING_INVESTDSM: - if g.investment.fixed_costs[0] is not None: + if valid_sequence(g.investment.fixed_costs, len(m.PERIODS)): lifetime = g.investment.lifetime age = g.investment.age range_limit = min( @@ -4391,7 +4392,7 @@ def _objective_expression(self): * (1 + m.discount_rate) ** (-m.es.periods_years[p]) ) - if g.fixed_costs[0] is not None: + if valid_sequence(g.fixed_costs, len(m.PERIODS)): fixed_costs += sum( max(g.max_capacity_up, g.max_capacity_down) * g.fixed_costs[pp] @@ -5791,7 +5792,7 @@ def _objective_expression(self): * (1 + m.discount_rate) ** (-m.es.periods_years[p]) ) - if g.investment.fixed_costs[0] is not None: + if valid_sequence(g.investment.fixed_costs, len(m.PERIODS)): lifetime = g.investment.lifetime for p in m.PERIODS: range_limit = min( @@ -5809,7 +5810,7 @@ def _objective_expression(self): ) for g in self.EXISTING_INVESTDSM: - if g.investment.fixed_costs[0] is not None: + if valid_sequence(g.investment.fixed_costs, len(m.PERIODS)): lifetime = g.investment.lifetime age = g.investment.age range_limit = min( diff --git a/src/oemof/solph/flows/_investment_flow_block.py b/src/oemof/solph/flows/_investment_flow_block.py index e77ec40b5..2a09f11a2 100644 --- a/src/oemof/solph/flows/_investment_flow_block.py +++ b/src/oemof/solph/flows/_investment_flow_block.py @@ -29,6 +29,8 @@ from pyomo.core import Var from pyomo.core.base.block import ScalarBlock +from oemof.solph._plumbing import valid_sequence + class InvestmentFlowBlock(ScalarBlock): r"""Block for all flows with :attr:`Investment` being not None. @@ -1007,7 +1009,9 @@ def _objective_expression(self): period_investment_costs[p] += investment_costs_increment for i, o in self.INVESTFLOWS: - if m.flows[i, o].investment.fixed_costs[0] is not None: + if valid_sequence( + m.flows[i, o].investment.fixed_costs, len(m.PERIODS) + ): lifetime = m.flows[i, o].investment.lifetime for p in m.PERIODS: range_limit = min( @@ -1022,7 +1026,9 @@ def _objective_expression(self): ) for i, o in self.EXISTING_INVESTFLOWS: - if m.flows[i, o].investment.fixed_costs[0] is not None: + if valid_sequence( + m.flows[i, o].investment.fixed_costs, len(m.PERIODS) + ): lifetime = m.flows[i, o].investment.lifetime age = m.flows[i, o].investment.age range_limit = min( diff --git a/src/oemof/solph/flows/_non_convex_flow_block.py b/src/oemof/solph/flows/_non_convex_flow_block.py index 88eb76850..c772081aa 100644 --- a/src/oemof/solph/flows/_non_convex_flow_block.py +++ b/src/oemof/solph/flows/_non_convex_flow_block.py @@ -25,6 +25,8 @@ from pyomo.core import Var from pyomo.core.base.block import ScalarBlock +from oemof.solph._plumbing import valid_sequence + class NonConvexFlowBlock(ScalarBlock): r""" @@ -326,7 +328,9 @@ def _startup_costs(self): m = self.parent_block() for i, o in self.STARTUPFLOWS: - if m.flows[i, o].nonconvex.startup_costs[0] is not None: + if valid_sequence( + m.flows[i, o].nonconvex.startup_costs, len(m.TIMESTEPS) + ): startup_costs += sum( self.startup[i, o, t] * m.flows[i, o].nonconvex.startup_costs[t] @@ -349,7 +353,10 @@ def _shutdown_costs(self): m = self.parent_block() for i, o in self.SHUTDOWNFLOWS: - if m.flows[i, o].nonconvex.shutdown_costs[0] is not None: + if valid_sequence( + m.flows[i, o].nonconvex.shutdown_costs, + len(m.TIMESTEPS), + ): shutdown_costs += sum( self.shutdown[i, o, t] * m.flows[i, o].nonconvex.shutdown_costs[t] @@ -372,7 +379,10 @@ def _activity_costs(self): m = self.parent_block() for i, o in self.ACTIVITYCOSTFLOWS: - if m.flows[i, o].nonconvex.activity_costs[0] is not None: + if valid_sequence( + m.flows[i, o].nonconvex.activity_costs, + len(m.TIMESTEPS), + ): activity_costs += sum( self.status[i, o, t] * m.flows[i, o].nonconvex.activity_costs[t] @@ -395,7 +405,10 @@ def _inactivity_costs(self): m = self.parent_block() for i, o in self.INACTIVITYCOSTFLOWS: - if m.flows[i, o].nonconvex.inactivity_costs[0] is not None: + if valid_sequence( + m.flows[i, o].nonconvex.inactivity_costs, + len(m.TIMESTEPS), + ): inactivity_costs += sum( (1 - self.status[i, o, t]) * m.flows[i, o].nonconvex.inactivity_costs[t] diff --git a/src/oemof/solph/flows/_simple_flow_block.py b/src/oemof/solph/flows/_simple_flow_block.py index dc893592b..0a8c910d4 100644 --- a/src/oemof/solph/flows/_simple_flow_block.py +++ b/src/oemof/solph/flows/_simple_flow_block.py @@ -26,6 +26,8 @@ from pyomo.core import Var from pyomo.core.base.block import ScalarBlock +from oemof.solph._plumbing import valid_sequence + class SimpleFlowBlock(ScalarBlock): r"""Flow block with definitions for standard flows. @@ -172,12 +174,16 @@ def _create_variables(self, group): ) # set upper bound of gradient variable for i, o, f in group: - if m.flows[i, o].positive_gradient_limit[0] is not None: + if valid_sequence( + m.flows[i, o].positive_gradient_limit, len(m.TIMESTEPS) + ): for t in m.TIMESTEPS: self.positive_gradient[i, o, t].setub( f.positive_gradient_limit[t] * f.nominal_value ) - if m.flows[i, o].negative_gradient_limit[0] is not None: + if valid_sequence( + m.flows[i, o].negative_gradient_limit, len(m.TIMESTEPS) + ): for t in m.TIMESTEPS: self.negative_gradient[i, o, t].setub( f.negative_gradient_limit[t] * f.nominal_value @@ -429,7 +435,9 @@ def _objective_expression(self): if m.es.periods is None: for i, o in m.FLOWS: - if m.flows[i, o].variable_costs[0] is not None: + if valid_sequence( + m.flows[i, o].variable_costs, len(m.TIMESTEPS) + ): for t in m.TIMESTEPS: variable_costs += ( m.flow[i, o, t] @@ -439,7 +447,9 @@ def _objective_expression(self): else: for i, o in m.FLOWS: - if m.flows[i, o].variable_costs[0] is not None: + if valid_sequence( + m.flows[i, o].variable_costs, len(m.TIMESTEPS) + ): for p, t in m.TIMEINDEX: variable_costs += ( m.flow[i, o, t] @@ -464,7 +474,7 @@ def _objective_expression(self): # Fixed costs for units with limited lifetime for i, o in self.LIFETIME_FLOWS: - if m.flows[i, o].fixed_costs[0] is not None: + if valid_sequence(m.flows[i, o].fixed_costs, len(m.TIMESTEPS)): range_limit = min( m.es.end_year_of_optimization, m.flows[i, o].lifetime, @@ -477,7 +487,7 @@ def _objective_expression(self): ) for i, o in self.LIFETIME_AGE_FLOWS: - if m.flows[i, o].fixed_costs[0] is not None: + if valid_sequence(m.flows[i, o].fixed_costs, len(m.TIMESTEPS)): range_limit = min( m.es.end_year_of_optimization, m.flows[i, o].lifetime - m.flows[i, o].age, diff --git a/src/oemof/solph/flows/experimental/_electrical_line.py b/src/oemof/solph/flows/experimental/_electrical_line.py index 2b5e2eb85..ad08f99ff 100644 --- a/src/oemof/solph/flows/experimental/_electrical_line.py +++ b/src/oemof/solph/flows/experimental/_electrical_line.py @@ -25,7 +25,7 @@ from pyomo.environ import Set from pyomo.environ import Var -from oemof.solph._plumbing import sequence as solph_sequence +from oemof.solph._plumbing import sequence from oemof.solph.buses.experimental._electrical_bus import ElectricalBus from oemof.solph.flows._flow import Flow @@ -75,7 +75,7 @@ def __init__(self, **kwargs): nonconvex=kwargs.get("nonconvex"), custom_attributes=kwargs.get("costom_attributes"), ) - self.reactance = solph_sequence(kwargs.get("reactance", 0.00001)) + self.reactance = sequence(kwargs.get("reactance", 0.00001)) self.input = kwargs.get("input") self.output = kwargs.get("output") diff --git a/tests/test_plumbing.py b/tests/test_plumbing.py index b51187be8..f97ff96c1 100644 --- a/tests/test_plumbing.py +++ b/tests/test_plumbing.py @@ -11,6 +11,7 @@ from oemof.solph._plumbing import _FakeSequence from oemof.solph._plumbing import sequence +from oemof.solph._plumbing import valid_sequence def test_fake_sequence(): @@ -63,3 +64,31 @@ def test_sequence(): seq_ab = sequence("ab") assert isinstance(seq_ab, str) assert seq_ab == "ab" + + +def test_valid_sequence(): + np_array = np.array([0, 1, 2, 3, 4]) + assert valid_sequence(np_array, 5) + + with pytest.warns(FutureWarning, match="Sequence longer than needed"): + valid_sequence(np_array, 4) + + # it's not that long + with pytest.raises(ValueError): + valid_sequence(np_array, 1337) + + fake_sequence = _FakeSequence(42) + assert valid_sequence(fake_sequence, 5) + assert len(fake_sequence) == 5 + + # wil not automatically overwrite size + assert not valid_sequence(fake_sequence, 1337) + assert len(fake_sequence) == 5 + + # manually overwriting length is still possible + fake_sequence.size = 1337 + assert valid_sequence(fake_sequence, 1337) + assert len(fake_sequence) == 1337 + + # strings are no valid sequences + assert not valid_sequence("abc", 3) diff --git a/tests/test_scripts/test_solph/test_generic_caes/generic_caes.csv b/tests/test_scripts/test_solph/test_generic_caes/generic_caes.csv index 0c00956b4..1658a9f4c 100644 --- a/tests/test_scripts/test_solph/test_generic_caes/generic_caes.csv +++ b/tests/test_scripts/test_solph/test_generic_caes/generic_caes.csv @@ -238,4 +238,3 @@ timestep,price_el_sink,price_el_source 237,-33.57,33.57 238,-30.51,30.51 239,-30.57,30.57 -240,-29.28,29.28 diff --git a/tests/test_scripts/test_solph/test_generic_caes/test_generic_caes.py b/tests/test_scripts/test_solph/test_generic_caes/test_generic_caes.py index dc40286cb..8c9e041e4 100644 --- a/tests/test_scripts/test_solph/test_generic_caes/test_generic_caes.py +++ b/tests/test_scripts/test_solph/test_generic_caes/test_generic_caes.py @@ -34,7 +34,7 @@ def test_gen_caes(): data = pd.read_csv(full_filename) # select periods - periods = len(data) - 1 + periods = len(data) # create an energy system idx = pd.date_range("1/1/2017", periods=periods, freq="h") diff --git a/tests/test_scripts/test_solph/test_generic_chp/ccet.csv b/tests/test_scripts/test_solph/test_generic_chp/ccet.csv index ec231419f..185a1f1a2 100644 --- a/tests/test_scripts/test_solph/test_generic_chp/ccet.csv +++ b/tests/test_scripts/test_solph/test_generic_chp/ccet.csv @@ -198,4 +198,3 @@ timestep,demand_th,price_el,Eta_el_max_woDH,P_max_woDH,Eta_el_min_woDH,P_min_woD 197,0.97,-50,0.53,199.73,0.43,80.65,0.19,31.71,0.19 198,0.98,-50,0.53,199.73,0.43,80.65,0.19,31.71,0.19 199,0.99,-50,0.53,199.73,0.43,80.65,0.19,31.71,0.19 -200,1,-50,0.53,199.73,0.43,80.65,0.19,31.71,0.19 diff --git a/tests/test_scripts/test_solph/test_generic_chp/test_generic_chp.py b/tests/test_scripts/test_solph/test_generic_chp/test_generic_chp.py index 6c9f8c423..c5770d279 100644 --- a/tests/test_scripts/test_solph/test_generic_chp/test_generic_chp.py +++ b/tests/test_scripts/test_solph/test_generic_chp/test_generic_chp.py @@ -28,7 +28,7 @@ def test_gen_chp(): data = pd.read_csv(full_filename) # select periods - periods = len(data) - 1 + periods = len(data) # create an energy system idx = pd.date_range("1/1/2017", periods=periods, freq="h")