From 394e7fa79ab795e0b4da4132ae7cc096a209ebf2 Mon Sep 17 00:00:00 2001 From: fkeller Date: Mon, 19 May 2025 12:37:00 +0200 Subject: [PATCH 01/33] first implementation of virtual storage DSM Sink class --- examples/00_Minmal/minimal_example_DSM.py | 84 ++++++++ flixopt/__init__.py | 1 + flixopt/commons.py | 2 + flixopt/components.py | 252 ++++++++++++++++++++++ 4 files changed, 339 insertions(+) create mode 100644 examples/00_Minmal/minimal_example_DSM.py diff --git a/examples/00_Minmal/minimal_example_DSM.py b/examples/00_Minmal/minimal_example_DSM.py new file mode 100644 index 000000000..c9a85dcf7 --- /dev/null +++ b/examples/00_Minmal/minimal_example_DSM.py @@ -0,0 +1,84 @@ +""" +This script shows how to use the flixopt framework to model a super minimalistic energy system. +""" + +import numpy as np +import pandas as pd +from rich.pretty import pprint + +import sys +sys.path.append("C:/Florian/Studium/RES/2025SoSe/Studienarbeit/code/flixopt") +import flixopt as fx + +if __name__ == '__main__': + # --- Define the Flow System, that will hold all elements, and the time steps you want to model --- + timesteps = pd.date_range('2020-01-01', periods=24, freq='h') + flow_system = fx.FlowSystem(timesteps) + + # --- Define Thermal Load Profile --- + # Load profile (e.g., kW) for heating demand over time + thermal_load_profile = np.array([80, 80, 80, 80, 80, 80, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 80, 80, 80, 80, 80, 80]) + + # --- Define Energy Buses --- + # These are balancing nodes (inputs=outputs) and balance the different energy carriers your system + flow_system.add_elements(fx.Bus('District Heating'), fx.Bus('Natural Gas')) + + # --- Define Objective Effect (Cost) --- + # Cost effect representing the optimization objective (minimizing costs) + cost_effect = fx.Effect('costs', '€', 'Cost', is_standard=True, is_objective=True) + + # --- Define Flow System Components --- + # Boiler component with thermal output (heat) and fuel input (gas) + boiler1 = fx.linear_converters.Boiler( + 'Boiler1', + eta=0.5, + Q_th=fx.Flow(label='Thermal Output', bus='District Heating', size=100), + Q_fu=fx.Flow(label='Fuel Input', bus='Natural Gas'), + ) + boiler2 = fx.linear_converters.Boiler( + 'Boiler2', + eta=1/3, + Q_th=fx.Flow(label='Thermal Output', bus='District Heating', size=50), + Q_fu=fx.Flow(label='Fuel Input', bus='Natural Gas'), + ) + + # Heat load component with a fixed thermal demand profile + heat_load = fx.DSMSink( + 'DSM Sink Heat Demand', + sink=fx.Flow(label='Heat Load', bus='District Heating', size=150), + initial_demand=thermal_load_profile, + virtual_capacity_in_flow_hours=60, + relative_loss_per_hour_positive_charge_state = 0.05, + relative_minimum_charge_state = 0 + ) + + # Gas source component with cost-effect per flow hour + gas_source = fx.Source( + 'Natural Gas Tariff', + source=fx.Flow(label='Gas Flow', bus='Natural Gas', size=1000, effects_per_flow_hour=0.04), # 0.04 €/kWh + ) + + # --- Build the Flow System --- + # Add all components and effects to the system + flow_system.add_elements(cost_effect, boiler1, boiler2, heat_load, gas_source) + + # --- Define, model and solve a Calculation --- + calculation = fx.FullCalculation('Simulation1', flow_system) + calculation.do_modeling() + #calculation.solve(fx.solvers.HighsSolver(0.01, 60)) + calculation.solve(fx.solvers.GurobiSolver(0.01, 60)) + + # --- Analyze Results --- + # Access the results of an element + df1 = calculation.results['costs'].filter_solution('time').to_dataframe() + + # Plot the results of a specific element + calculation.results['District Heating'].plot_node_balance_pie() + calculation.results['District Heating'].plot_node_balance() + + # Save results to a file + df2 = calculation.results['District Heating'].node_balance().to_dataframe() + # df2.to_csv('results/District Heating.csv') # Save results to csv + + # Print infos to the console. + pprint(calculation.summary) diff --git a/flixopt/__init__.py b/flixopt/__init__.py index b92766449..a647a3982 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -20,6 +20,7 @@ PiecewiseEffects, SegmentedCalculation, Sink, + DSMSink, Source, SourceAndSink, Storage, diff --git a/flixopt/commons.py b/flixopt/commons.py index 68412d6fe..d1e0801a0 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -8,6 +8,7 @@ from .components import ( LinearConverter, Sink, + DSMSink, Source, SourceAndSink, Storage, @@ -29,6 +30,7 @@ 'Effect', 'Source', 'Sink', + 'DSMSink', 'SourceAndSink', 'Storage', 'LinearConverter', diff --git a/flixopt/components.py b/flixopt/components.py index bdac6d2fb..9f624ddf8 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -635,3 +635,255 @@ def __init__(self, label: str, sink: Flow, meta_data: Optional[Dict] = None): """ super().__init__(label, inputs=[sink], meta_data=meta_data) self.sink = sink + +@register_class_for_io +class DSMSink(Sink): + """ + Used to model sinks with the ability to perform demand side management. + In this class DSM ist modeled via a virtual storage. + """ + + def __init__( + self, + label: str, + sink: Flow, + initial_demand: NumericData, + virtual_capacity_in_flow_hours: Scalar, + maximum_relative_virtual_charging_rate: NumericData = 1, + maximum_relative_virtual_discharging_rate: NumericData = -1, + relative_minimum_charge_state: NumericData = -1, + relative_maximum_charge_state: NumericData = 1, + initial_charge_state: Union[Scalar, Literal['lastValueOfSim']] = 0, + relative_loss_per_hour_positive_charge_state: NumericData = 0, + relative_loss_per_hour_negative_charge_state: NumericData = 0, + prevent_simultaneous_charge_and_discharge: bool = True, + penalty_costs_positive_charge_states: NumericData = 0, + penalty_costs_negative_charge_states: NumericData = 0, + meta_data: Optional[Dict] = None + ): + """ + Args: + label: The label of the Element. Used to identify it in the FlowSystem + sink: input-flow of DSM sink after DSM + initial_demand: initial demand of DSM sink before DSM + virtual_capacity_in_flow_hours: nominal capacity of the virtual storage + maximum_relative_virtual_charging_rate: maximum flow rate relative to the capacity of the virtual storage at which charging is possible. The default is 1. + maximum_relative_virtual_discharging_rate: maximum flow rate relative to the capacity of the virtual storage at which discharging is possible. The default is -1. + relative_minimum_charge_state: minimum relative charge state. The default is -1. + relative_maximum_charge_state: maximum relative charge state. The default is 1. + initial_charge_state: virtual storage charge_state at the beginning. The default is 0. + relative_loss_per_hour_positive_charge_state: loss per chargeState-Unit per hour for positive charge states of the virtual storage. The default is 0. + relative_loss_per_hour_negative_charge_state: loss per chargeState-Unit per hour for negative charge states of the virtual storage. The default is 0. + prevent_simultaneous_charge_and_discharge: If True, charging and discharging of the virtual storage at the same time is not possible. + Increases the number of binary variables, but is recommended for easier evaluation. The default is True. + penalty_costs_positive_charge_states: penalty costs per flow hour for loss of comfort due to positive charge states of the virtual storage (e.g. increased room temperature). The default is 0. + penalty_costs_negative_charge_states: penalty costs per flow hour for loss of comfort due to negative charge states of the virtual storage (e.g. decreased room temperature). The default is 0. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + super().__init__( + label, + sink, + meta_data + ) + + self.initial_demand: NumericDataTS = initial_demand + self.virtual_capacity_in_flow_hours = virtual_capacity_in_flow_hours + self.maximum_relative_virtual_charging_rate: NumericDataTS = maximum_relative_virtual_charging_rate + self.maximum_relative_virtual_discharging_rate: NumericDataTS = maximum_relative_virtual_discharging_rate + self.relative_minimum_charge_state: NumericDataTS = relative_minimum_charge_state + self.relative_maximum_charge_state: NumericDataTS = relative_maximum_charge_state + + self.initial_charge_state = initial_charge_state + + self.relative_loss_per_hour_positive_charge_state: NumericDataTS = relative_loss_per_hour_positive_charge_state + self.relative_loss_per_hour_negative_charge_state: NumericDataTS = relative_loss_per_hour_negative_charge_state + self.prevent_simultaneous_charge_and_discharge = prevent_simultaneous_charge_and_discharge + + self.penalty_costs_positive_charge_states: NumericDataTS = penalty_costs_positive_charge_states + self.penalty_costs_negative_charge_states: NumericDataTS = penalty_costs_negative_charge_states + + def create_model(self, model: SystemModel) -> 'DSMSinkModel': + self._plausibility_checks() + self.model = DSMSinkModel(model, self) + return self.model + + def transform_data(self, flow_system: 'FlowSystem') -> None: + super().transform_data(flow_system) + self.initial_demand = flow_system.create_time_series( + f'{self.label_full}|initial_demand', + self.initial_demand, + ) + self.maximum_relative_virtual_charging_rate = flow_system.create_time_series( + f'{self.label_full}|maximum_relative_virtual_charging_rate', + self.maximum_relative_virtual_charging_rate, + ) + self.maximum_relative_virtual_discharging_rate = flow_system.create_time_series( + f'{self.label_full}|maximum_relative_virtual_discharging_rate', + self.maximum_relative_virtual_discharging_rate, + ) + self.relative_minimum_charge_state = flow_system.create_time_series( + f'{self.label_full}|relative_minimum_charge_state', + self.relative_minimum_charge_state, + needs_extra_timestep=True, + ) + self.relative_maximum_charge_state = flow_system.create_time_series( + f'{self.label_full}|relative_maximum_charge_state', + self.relative_maximum_charge_state, + needs_extra_timestep=True, + ) + self.relative_loss_per_hour_negative_charge_state = flow_system.create_time_series( + f'{self.label_full}|relative_loss_per_hour_negative_charge_state', + self.relative_loss_per_hour_negative_charge_state, + ) + self.relative_loss_per_hour_positive_charge_state = flow_system.create_time_series( + f'{self.label_full}|relative_loss_per_hour_positive_charge_state', + self.relative_loss_per_hour_positive_charge_state, + ) + self.penalty_costs_negative_charge_states = flow_system.create_time_series( + f'{self.label_full}|penalty_costs_negative_charge_states', + self.penalty_costs_negative_charge_states + ) + self.penalty_costs_positive_charge_states = flow_system.create_time_series( + f'{self.label_full}|penalty_costs_positive_charge_states', + self.penalty_costs_positive_charge_states + ) + + def _plausibility_checks(self): + """ + Check for infeasible or uncommon combinations of parameters + """ + super()._plausibility_checks() + if utils.is_number(self.initial_charge_state): + maximum_capacity = self.virtual_capacity_in_flow_hours + minimum_capacity = self.virtual_capacity_in_flow_hours + + # initial capacity >= allowed min for maximum_size: + minimum_inital_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=1) + # initial capacity <= allowed max for minimum_size: + maximum_inital_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=1) + + if self.initial_charge_state > maximum_inital_capacity: + raise ValueError( + f'{self.label_full}: {self.initial_charge_state=} ' + f'is above allowed maximum charge_state {maximum_inital_capacity}' + ) + if self.initial_charge_state < minimum_inital_capacity: + raise ValueError( + f'{self.label_full}: {self.initial_charge_state=} ' + f'is below allowed minimum charge_state {minimum_inital_capacity}' + ) + elif self.initial_charge_state != 'lastValueOfSim': + raise ValueError(f'{self.label_full}: {self.initial_charge_state=} has an invalid value') + + #TODO: think about other implausibilities + #INFO: investments not implemented + +class DSMSinkModel(ComponentModel): + """Model of DSM Sink""" + + def __init__(self, model: SystemModel, element: DSMSink): + super().__init__(model, element) + self.element: DSMSink = element + self.positive_charge_state: Optional[linopy.Variable] = None + self.negative_charge_state: Optional[linopy.Variable] = None + self.netto_charge_rate: Optional[linopy.Variable] = None + + def do_modeling(self): + super().do_modeling() + + #add variable for charge rate + lb,ub = self.absolute_charge_rate_bounds + self.netto_charge_rate = self.add( + self._model.add_variables( + lower=lb, upper=ub, coords=self._model.coords, name=f'{self.label_full}|netto_charge_rate' + ), + 'netto_charge_rate', + ) + + #add variable for negative charge states + lb,ub = self.absolute_charge_state_bounds[0],0 + self.negative_charge_state = self.add( + self._model.add_variables( + lower=lb, upper=ub, coords=self._model.coords_extra, name=f'{self.label_full}|negative_charge_state' + ), + 'negative_charge_state', + ) + + #add variable for positive charge states + lb,ub = 0,self.absolute_charge_state_bounds[1] + self.positive_charge_state = self.add( + self._model.add_variables( + lower=lb, upper=ub, coords=self._model.coords_extra, name=f'{self.label_full}|positive_charge_state' + ), + 'positive_charge_state' + ) + + positive_charge_state = self.positive_charge_state + negative_charge_state = self.negative_charge_state + etapos = 1 - self.element.relative_loss_per_hour_positive_charge_state.active_data + etaneg = 1 - self.element.relative_loss_per_hour_negative_charge_state.active_data + hours_per_step = self._model.hours_per_step + charge_rate = self.netto_charge_rate + initial_demand = self.element.initial_demand.active_data + sink = self.element.sink.model.flow_rate + + # eq: positive_charge_state(t) + negative_charge_state(t) = positive_charge_state(t-1) * etapos + negative_charge_state(t-1) * ehtaneg + charge_rate(t) + self.add( + self._model.add_constraints( + positive_charge_state.isel(time=slice(1, None)) + + negative_charge_state.isel(time=slice(1,None)) + == positive_charge_state.isel(time=slice(None, -1)) * (etapos * hours_per_step) + + negative_charge_state.isel(time=slice(None,-1)) * (etaneg * hours_per_step) + + charge_rate, + name=f'{self.label_full}|charge_state', + ), + 'charge_state', + ) + + # eq: sink(t) = initial_demand(t) + charge_rate(t) + self.add( + self._model.add_constraints( + sink + == charge_rate + + initial_demand, + name=f'{self.label_full}|resulting_load_profile', + ), + 'resulting_load_profile', + ) + + + #TODO: dissable positive and negative charge states at the same time + #TODO: add penalty costs + #TODO: add final charge state + + + + @property + def absolute_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: + relative_lower_bound, relative_upper_bound = self.relative_charge_state_bounds + return ( + relative_lower_bound * self.element.virtual_capacity_in_flow_hours, + relative_upper_bound * self.element.virtual_capacity_in_flow_hours, + ) + + @property + def relative_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: + return ( + self.element.relative_minimum_charge_state.active_data, + self.element.relative_maximum_charge_state.active_data, + ) + + @property + def absolute_charge_rate_bounds(self) -> Tuple[NumericData, NumericData]: + relative_discharging_bound, relative_charging_bound = self.relative_charge_rate_bounds + return( + relative_discharging_bound * self.element.virtual_capacity_in_flow_hours, + relative_charging_bound * self.element.virtual_capacity_in_flow_hours, + ) + + @property + def relative_charge_rate_bounds(self) -> Tuple[NumericData, NumericData]: + return( + self.element.maximum_relative_virtual_discharging_rate.active_data, + self.element.maximum_relative_virtual_charging_rate.active_data, + ) \ No newline at end of file From 91c0fe5a80f53a67e5467720799d14f072decdf5 Mon Sep 17 00:00:00 2001 From: fkeller Date: Mon, 19 May 2025 13:10:32 +0200 Subject: [PATCH 02/33] implemented initial and final charge state constraints --- examples/00_Minmal/minimal_example_DSM.py | 2 +- flixopt/components.py | 57 +++++++++++++++++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/examples/00_Minmal/minimal_example_DSM.py b/examples/00_Minmal/minimal_example_DSM.py index c9a85dcf7..7cb0a3e0f 100644 --- a/examples/00_Minmal/minimal_example_DSM.py +++ b/examples/00_Minmal/minimal_example_DSM.py @@ -49,7 +49,7 @@ initial_demand=thermal_load_profile, virtual_capacity_in_flow_hours=60, relative_loss_per_hour_positive_charge_state = 0.05, - relative_minimum_charge_state = 0 + initial_charge_state = "lastValueOfSim" ) # Gas source component with cost-effect per flow hour diff --git a/flixopt/components.py b/flixopt/components.py index 9f624ddf8..63224d07f 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -654,6 +654,8 @@ def __init__( relative_minimum_charge_state: NumericData = -1, relative_maximum_charge_state: NumericData = 1, initial_charge_state: Union[Scalar, Literal['lastValueOfSim']] = 0, + minimal_final_charge_state: Optional[Scalar] = None, + maximal_final_charge_state: Optional[Scalar] = None, relative_loss_per_hour_positive_charge_state: NumericData = 0, relative_loss_per_hour_negative_charge_state: NumericData = 0, prevent_simultaneous_charge_and_discharge: bool = True, @@ -672,6 +674,8 @@ def __init__( relative_minimum_charge_state: minimum relative charge state. The default is -1. relative_maximum_charge_state: maximum relative charge state. The default is 1. initial_charge_state: virtual storage charge_state at the beginning. The default is 0. + minimal_final_charge_state: minimal value of charge state at the end of timeseries. + maximal_final_charge_state: maximal value of charge state at the end of timeseries. relative_loss_per_hour_positive_charge_state: loss per chargeState-Unit per hour for positive charge states of the virtual storage. The default is 0. relative_loss_per_hour_negative_charge_state: loss per chargeState-Unit per hour for negative charge states of the virtual storage. The default is 0. prevent_simultaneous_charge_and_discharge: If True, charging and discharging of the virtual storage at the same time is not possible. @@ -694,6 +698,8 @@ def __init__( self.relative_maximum_charge_state: NumericDataTS = relative_maximum_charge_state self.initial_charge_state = initial_charge_state + self.minimal_final_charge_state = minimal_final_charge_state + self.maximal_final_charge_state = maximal_final_charge_state self.relative_loss_per_hour_positive_charge_state: NumericDataTS = relative_loss_per_hour_positive_charge_state self.relative_loss_per_hour_negative_charge_state: NumericDataTS = relative_loss_per_hour_negative_charge_state @@ -851,12 +857,57 @@ def do_modeling(self): 'resulting_load_profile', ) + # Initial and final charge state constraints + self._initial_and_final_charge_state() #TODO: dissable positive and negative charge states at the same time #TODO: add penalty costs - #TODO: add final charge state - - + + def _initial_and_final_charge_state(self): + """Add constraints for initial and final charge states""" + if self.element.initial_charge_state is not None: + name_short = 'initial_charge_state' + name = f'{self.label_full}|{name_short}' + + if utils.is_number(self.element.initial_charge_state): + self.add( + self._model.add_constraints( + self.positive_charge_state.isel(time=0) + self.negative_charge_state.isel(time=0) == self.element.initial_charge_state, + name=name + ), + name_short, + ) + elif self.element.initial_charge_state == 'lastValueOfSim': + self.add( + self._model.add_constraints( + self.positive_charge_state.isel(time=0) + self.negative_charge_state.isel(time=0) + == self.positive_charge_state.isel(time=-1) + self.negative_charge_state.isel(time=-1), + name=name + ), + name_short, + ) + else: + raise PlausibilityError( + f'initial_charge_state has undefined value: {self.element.initial_charge_state}' + ) + + if self.element.maximal_final_charge_state is not None: + self.add( + self._model.add_constraints( + self.positive_charge_state.isel(time=-1) + self.negative_charge_state.isel(time=-1) <= self.element.maximal_final_charge_state, + name=f'{self.label_full}|final_charge_max', + ), + 'final_charge_max', + ) + + if self.element.minimal_final_charge_state is not None: + self.add( + self._model.add_constraints( + self.positive_charge_state.isel(time=-1) + self.negative_charge_state.isel(time=-1) >= self.element.minimal_final_charge_state, + name=f'{self.label_full}|final_charge_min', + ), + 'final_charge_min', + ) @property def absolute_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: From 7933a9c53a8bf3ed72b6b23ccad033bd7f031655 Mon Sep 17 00:00:00 2001 From: fkeller Date: Tue, 20 May 2025 16:03:27 +0200 Subject: [PATCH 03/33] added penalty costs for positive and negative charge states --- examples/00_Minmal/minimal_example_DSM.py | 5 ++++- flixopt/components.py | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/examples/00_Minmal/minimal_example_DSM.py b/examples/00_Minmal/minimal_example_DSM.py index 7cb0a3e0f..b1f948eca 100644 --- a/examples/00_Minmal/minimal_example_DSM.py +++ b/examples/00_Minmal/minimal_example_DSM.py @@ -49,7 +49,10 @@ initial_demand=thermal_load_profile, virtual_capacity_in_flow_hours=60, relative_loss_per_hour_positive_charge_state = 0.05, - initial_charge_state = "lastValueOfSim" + relative_loss_per_hour_negative_charge_state = 0.05, + initial_charge_state = 'lastValueOfSim', + penalty_costs_positive_charge_states=0, + penalty_costs_negative_charge_states=0.01 ) # Gas source component with cost-effect per flow hour diff --git a/flixopt/components.py b/flixopt/components.py index 63224d07f..2246d78e9 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -860,8 +860,25 @@ def do_modeling(self): # Initial and final charge state constraints self._initial_and_final_charge_state() + # Add penalty costs as effects for positive and negative charge states + penalty_costs_pos = self.element.penalty_costs_positive_charge_states.active_data + penalty_costs_neg = self.element.penalty_costs_negative_charge_states.active_data + + # Add effects for positive charge states + if np.any(penalty_costs_pos != 0): + self._model.effects.add_share_to_penalty( + name = self.label_full, + expression = (positive_charge_state * penalty_costs_pos).sum() + ) + + # Add effects for negative charge states + if np.any(penalty_costs_neg != 0): + self._model.effects.add_share_to_penalty( + name = self.label_full, + expression = -(negative_charge_state * penalty_costs_neg).sum() + ) + #TODO: dissable positive and negative charge states at the same time - #TODO: add penalty costs def _initial_and_final_charge_state(self): """Add constraints for initial and final charge states""" From 693e354ff0379bc2cf551f57f251355f6e540194 Mon Sep 17 00:00:00 2001 From: fkeller Date: Sat, 24 May 2025 19:59:27 +0200 Subject: [PATCH 04/33] added charge state exclusivity constraints --- examples/00_Minmal/minimal_example_DSM.py | 9 +++- flixopt/components.py | 66 +++++++++++++++++++++-- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/examples/00_Minmal/minimal_example_DSM.py b/examples/00_Minmal/minimal_example_DSM.py index b1f948eca..af174180a 100644 --- a/examples/00_Minmal/minimal_example_DSM.py +++ b/examples/00_Minmal/minimal_example_DSM.py @@ -52,7 +52,8 @@ relative_loss_per_hour_negative_charge_state = 0.05, initial_charge_state = 'lastValueOfSim', penalty_costs_positive_charge_states=0, - penalty_costs_negative_charge_states=0.01 + penalty_costs_negative_charge_states=0.01, + allow_mixed_charge_states=False ) # Gas source component with cost-effect per flow hour @@ -79,9 +80,13 @@ calculation.results['District Heating'].plot_node_balance_pie() calculation.results['District Heating'].plot_node_balance() + + # Save the DSM Sink Heat Demand solution dataset to a CSV file + calculation.results['DSM Sink Heat Demand'].solution.to_dataframe().to_csv('results/DSM_Sink_Heat_Demand_results.csv') + # Save results to a file df2 = calculation.results['District Heating'].node_balance().to_dataframe() - # df2.to_csv('results/District Heating.csv') # Save results to csv + #df2.to_csv('results/District Heating.csv') # Save results to csv # Print infos to the console. pprint(calculation.summary) diff --git a/flixopt/components.py b/flixopt/components.py index 2246d78e9..dc073b3a3 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -658,7 +658,7 @@ def __init__( maximal_final_charge_state: Optional[Scalar] = None, relative_loss_per_hour_positive_charge_state: NumericData = 0, relative_loss_per_hour_negative_charge_state: NumericData = 0, - prevent_simultaneous_charge_and_discharge: bool = True, + allow_mixed_charge_states: bool = False, penalty_costs_positive_charge_states: NumericData = 0, penalty_costs_negative_charge_states: NumericData = 0, meta_data: Optional[Dict] = None @@ -678,8 +678,8 @@ def __init__( maximal_final_charge_state: maximal value of charge state at the end of timeseries. relative_loss_per_hour_positive_charge_state: loss per chargeState-Unit per hour for positive charge states of the virtual storage. The default is 0. relative_loss_per_hour_negative_charge_state: loss per chargeState-Unit per hour for negative charge states of the virtual storage. The default is 0. - prevent_simultaneous_charge_and_discharge: If True, charging and discharging of the virtual storage at the same time is not possible. - Increases the number of binary variables, but is recommended for easier evaluation. The default is True. + allow_mixed_charge_states: If True, positive and negative charge states can occur simultaneously. + If False, only one type of charge state is allowed at a time. The default is False. penalty_costs_positive_charge_states: penalty costs per flow hour for loss of comfort due to positive charge states of the virtual storage (e.g. increased room temperature). The default is 0. penalty_costs_negative_charge_states: penalty costs per flow hour for loss of comfort due to negative charge states of the virtual storage (e.g. decreased room temperature). The default is 0. meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. @@ -703,7 +703,7 @@ def __init__( self.relative_loss_per_hour_positive_charge_state: NumericDataTS = relative_loss_per_hour_positive_charge_state self.relative_loss_per_hour_negative_charge_state: NumericDataTS = relative_loss_per_hour_negative_charge_state - self.prevent_simultaneous_charge_and_discharge = prevent_simultaneous_charge_and_discharge + self.allow_mixed_charge_states = allow_mixed_charge_states self.penalty_costs_positive_charge_states: NumericDataTS = penalty_costs_positive_charge_states self.penalty_costs_negative_charge_states: NumericDataTS = penalty_costs_negative_charge_states @@ -793,6 +793,8 @@ def __init__(self, model: SystemModel, element: DSMSink): self.positive_charge_state: Optional[linopy.Variable] = None self.negative_charge_state: Optional[linopy.Variable] = None self.netto_charge_rate: Optional[linopy.Variable] = None + self.is_positive_charge_state: Optional[linopy.Variable] = None + self.is_negative_charge_state: Optional[linopy.Variable] = None def do_modeling(self): super().do_modeling() @@ -857,6 +859,10 @@ def do_modeling(self): 'resulting_load_profile', ) + # Add constraints for preventing mixed charge states if not allowed + if not self.element.allow_mixed_charge_states: + self._add_charge_state_exclusivity_constraints() + # Initial and final charge state constraints self._initial_and_final_charge_state() @@ -878,7 +884,57 @@ def do_modeling(self): expression = -(negative_charge_state * penalty_costs_neg).sum() ) - #TODO: dissable positive and negative charge states at the same time + def _add_charge_state_exclusivity_constraints(self): + """Add constraints to prevent simultaneous positive and negative charge states""" + # Add binary variables to track charge state type + self.is_positive_charge_state = self.add( + self._model.add_variables( + binary=True, + coords=self._model.coords_extra, + name=f'{self.label_full}|is_positive_charge_state' + ), + 'is_positive_charge_state' + ) + + self.is_negative_charge_state = self.add( + self._model.add_variables( + binary=True, + coords=self._model.coords_extra, + name=f'{self.label_full}|is_negative_charge_state' + ), + 'is_negative_charge_state' + ) + + positive_charge_state = self.positive_charge_state + negative_charge_state = self.negative_charge_state + + # If positive_charge_state > 0, then is_positive_charge_state must be 1 + self.add( + self._model.add_constraints( + positive_charge_state <= self.absolute_charge_state_bounds[1] * self.is_positive_charge_state, + name=f'{self.label_full}|positive_charge_state_binary' + ), + 'positive_charge_state_binary' + ) + + # If negative_charge_state < 0, then is_negative_charge_state must be 1 + self.add( + self._model.add_constraints( + -negative_charge_state <= -self.absolute_charge_state_bounds[0] * self.is_negative_charge_state, + name=f'{self.label_full}|negative_charge_state_binary' + ), + 'negative_charge_state_binary' + ) + + # Ensure only one type of charge state can be active at a time + self.add( + self._model.add_constraints( + self.is_positive_charge_state + self.is_negative_charge_state <= 1.1, + name=f'{self.label_full}|mutually_exclusive_charge_states' + ), + 'mutually_exclusive_charge_states' + ) + #TODO: debugging of exclusivity constraints def _initial_and_final_charge_state(self): """Add constraints for initial and final charge states""" From 3536c43cb816d6d960153090e4a0ebbb2fca6586 Mon Sep 17 00:00:00 2001 From: fkeller Date: Sat, 24 May 2025 20:27:09 +0200 Subject: [PATCH 05/33] added epsilon values to the charge state exclusivity to avoid numerical issues --- flixopt/components.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index dc073b3a3..7be3f61f4 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -9,6 +9,7 @@ import numpy as np from . import utils +from .config import CONFIG from .core import NumericData, NumericDataTS, PlausibilityError, Scalar, TimeSeries from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, OnOffModel, PiecewiseModel @@ -912,29 +913,48 @@ def _add_charge_state_exclusivity_constraints(self): self.add( self._model.add_constraints( positive_charge_state <= self.absolute_charge_state_bounds[1] * self.is_positive_charge_state, - name=f'{self.label_full}|positive_charge_state_binary' + name=f'{self.label_full}|positive_charge_state_binary_upper' ), - 'positive_charge_state_binary' + 'positive_charge_state_binary_upper' + ) + + # If is_positive_charge_state is 1, then positive_charge_state must be > 0 + self.add( + self._model.add_constraints( + positive_charge_state >= CONFIG.modeling.EPSILON * self.absolute_charge_state_bounds[1] * self.is_positive_charge_state, # Small epsilon to avoid numerical issues + name=f'{self.label_full}|positive_charge_state_binary_lower' + ), + 'positive_charge_state_binary_lower' ) # If negative_charge_state < 0, then is_negative_charge_state must be 1 self.add( self._model.add_constraints( - -negative_charge_state <= -self.absolute_charge_state_bounds[0] * self.is_negative_charge_state, - name=f'{self.label_full}|negative_charge_state_binary' + negative_charge_state >= self.absolute_charge_state_bounds[0] * self.is_negative_charge_state, + name=f'{self.label_full}|negative_charge_state_binary_upper' ), - 'negative_charge_state_binary' + 'negative_charge_state_binary_upper' + ) + + # If is_negative_charge_state is 1, then negative_charge_state must be < 0 + self.add( + self._model.add_constraints( + negative_charge_state <= CONFIG.modeling.EPSILON * self.absolute_charge_state_bounds[0] * self.is_negative_charge_state, # Small epsilon to avoid numerical issues + name=f'{self.label_full}|negative_charge_state_binary_lower' + ), + 'negative_charge_state_binary_lower' ) # Ensure only one type of charge state can be active at a time self.add( self._model.add_constraints( - self.is_positive_charge_state + self.is_negative_charge_state <= 1.1, + self.is_positive_charge_state + self.is_negative_charge_state <= 1, name=f'{self.label_full}|mutually_exclusive_charge_states' ), 'mutually_exclusive_charge_states' ) - #TODO: debugging of exclusivity constraints + + #TODO: change implementation to use implemented state models def _initial_and_final_charge_state(self): """Add constraints for initial and final charge states""" From 797c7510ee199a3647be61dac357bae6538b54dd Mon Sep 17 00:00:00 2001 From: fkeller Date: Sat, 24 May 2025 20:34:02 +0200 Subject: [PATCH 06/33] fixed minor flaw where penalty costs would not scale with hours per timestep --- flixopt/components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 7be3f61f4..262860862 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -875,14 +875,14 @@ def do_modeling(self): if np.any(penalty_costs_pos != 0): self._model.effects.add_share_to_penalty( name = self.label_full, - expression = (positive_charge_state * penalty_costs_pos).sum() + expression = (positive_charge_state * penalty_costs_pos * hours_per_step).sum() ) # Add effects for negative charge states if np.any(penalty_costs_neg != 0): self._model.effects.add_share_to_penalty( name = self.label_full, - expression = -(negative_charge_state * penalty_costs_neg).sum() + expression = -(negative_charge_state * penalty_costs_neg * hours_per_step).sum() ) def _add_charge_state_exclusivity_constraints(self): From c1ab82032cb965256b6206f0db44e41f8aa5ecc0 Mon Sep 17 00:00:00 2001 From: fkeller Date: Mon, 26 May 2025 17:56:45 +0200 Subject: [PATCH 07/33] implemented DSMSinkTS class for demand side management using a timeshift model --- .../minimal_example_timeshift_DSM.py | 92 +++++ flixopt/__init__.py | 1 + flixopt/commons.py | 2 + flixopt/components.py | 315 +++++++++++++++++- 4 files changed, 407 insertions(+), 3 deletions(-) create mode 100644 examples/00_Minmal/minimal_example_timeshift_DSM.py diff --git a/examples/00_Minmal/minimal_example_timeshift_DSM.py b/examples/00_Minmal/minimal_example_timeshift_DSM.py new file mode 100644 index 000000000..4e8500159 --- /dev/null +++ b/examples/00_Minmal/minimal_example_timeshift_DSM.py @@ -0,0 +1,92 @@ +""" +This script shows how to use the flixopt framework to model a super minimalistic energy system. +""" + +import numpy as np +import pandas as pd +from rich.pretty import pprint + +import sys +sys.path.append("C:/Florian/Studium/RES/2025SoSe/Studienarbeit/code/flixopt") +import flixopt as fx + +if __name__ == '__main__': + # --- Define the Flow System, that will hold all elements, and the time steps you want to model --- + timesteps = pd.date_range('2020-01-01', periods=24, freq='h') + flow_system = fx.FlowSystem(timesteps) + + # --- Define Thermal Load Profile --- + # Load profile (e.g., kW) for heating demand over time + #thermal_load_profile = np.array([80, 80, 80, 80, 80, 80, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 80, 80, 80, 80, 80, 80]) + thermal_load_profile = np.array([100, 100, 100, 100, 100, 100, 120, 120, 120, 100, 100, 100, 100, 100, 100, 80, 80, 80, 100, 100, 100, 100, 100, 100]) + + # --- Define Energy Buses --- + # These are balancing nodes (inputs=outputs) and balance the different energy carriers your system + flow_system.add_elements(fx.Bus('District Heating'), fx.Bus('Natural Gas')) + + # --- Define Objective Effect (Cost) --- + # Cost effect representing the optimization objective (minimizing costs) + cost_effect = fx.Effect('costs', '€', 'Cost', is_standard=True, is_objective=True) + + # --- Define Flow System Components --- + # Boiler component with thermal output (heat) and fuel input (gas) + boiler1 = fx.linear_converters.Boiler( + 'Boiler1', + eta=0.5, + Q_th=fx.Flow(label='Thermal Output', bus='District Heating', size=100), + Q_fu=fx.Flow(label='Fuel Input', bus='Natural Gas'), + ) + boiler2 = fx.linear_converters.Boiler( + 'Boiler2', + eta=1/3, + Q_th=fx.Flow(label='Thermal Output', bus='District Heating', size=50), + Q_fu=fx.Flow(label='Fuel Input', bus='Natural Gas'), + ) + + # Heat load component with a fixed thermal demand profile + heat_load = fx.DSMSinkTS( + 'DSM Sink Heat Demand', + sink=fx.Flow(label='Heat Load', bus='District Heating', size=150), + initial_demand=thermal_load_profile, + timesteps_forward=3, + timesteps_backward=3, + maximum_flow_surplus_per_hour=20, + maximum_flow_deficit_per_hour=-20, + allow_parallel_surplus_and_deficit = True + ) + + # Gas source component with cost-effect per flow hour + gas_source = fx.Source( + 'Natural Gas Tariff', + source=fx.Flow(label='Gas Flow', bus='Natural Gas', size=1000, effects_per_flow_hour=0.04), # 0.04 €/kWh + ) + + # --- Build the Flow System --- + # Add all components and effects to the system + flow_system.add_elements(cost_effect, boiler1, boiler2, heat_load, gas_source) + + # --- Define, model and solve a Calculation --- + calculation = fx.FullCalculation('Simulation1', flow_system) + calculation.do_modeling() + #calculation.solve(fx.solvers.HighsSolver(0.01, 60)) + calculation.solve(fx.solvers.GurobiSolver(0.01, 60)) + + # --- Analyze Results --- + # Access the results of an element + df1 = calculation.results['costs'].filter_solution('time').to_dataframe() + + # Plot the results of a specific element + calculation.results['District Heating'].plot_node_balance_pie() + calculation.results['District Heating'].plot_node_balance() + + + # Save the DSM Sink Heat Demand solution dataset to a CSV file + calculation.results['DSM Sink Heat Demand'].solution.to_dataframe().to_csv('results/DSM_Sink_Heat_Demand_results.csv') + calculation.results.solution.to_dask_dataframe().to_csv('results/results.csv') + + # Save results to a file + df2 = calculation.results['District Heating'].node_balance().to_dataframe() + #df2.to_csv('results/District Heating.csv') # Save results to csv + + # Print infos to the console. + pprint(calculation.summary) diff --git a/flixopt/__init__.py b/flixopt/__init__.py index a647a3982..9db23e791 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -21,6 +21,7 @@ SegmentedCalculation, Sink, DSMSink, + DSMSinkTS, Source, SourceAndSink, Storage, diff --git a/flixopt/commons.py b/flixopt/commons.py index d1e0801a0..8be5ba692 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -9,6 +9,7 @@ LinearConverter, Sink, DSMSink, + DSMSinkTS, Source, SourceAndSink, Storage, @@ -31,6 +32,7 @@ 'Source', 'Sink', 'DSMSink', + 'DSMSinkTS', 'SourceAndSink', 'Storage', 'LinearConverter', diff --git a/flixopt/components.py b/flixopt/components.py index 262860862..85e2cb325 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -12,7 +12,7 @@ from .config import CONFIG from .core import NumericData, NumericDataTS, PlausibilityError, Scalar, TimeSeries from .elements import Component, ComponentModel, Flow -from .features import InvestmentModel, OnOffModel, PiecewiseModel +from .features import InvestmentModel, OnOffModel, PiecewiseModel, StateModel, PreventSimultaneousUsageModel from .interface import InvestParameters, OnOffParameters, PiecewiseConversion from .structure import SystemModel, register_class_for_io @@ -641,7 +641,7 @@ def __init__(self, label: str, sink: Flow, meta_data: Optional[Dict] = None): class DSMSink(Sink): """ Used to model sinks with the ability to perform demand side management. - In this class DSM ist modeled via a virtual storage. + In this class DSM is modeled via a virtual storage. """ def __init__( @@ -1030,4 +1030,313 @@ def relative_charge_rate_bounds(self) -> Tuple[NumericData, NumericData]: return( self.element.maximum_relative_virtual_discharging_rate.active_data, self.element.maximum_relative_virtual_charging_rate.active_data, - ) \ No newline at end of file + ) + + +@register_class_for_io +class DSMSinkTS(Sink): + """ + Used to model a sink with the ability to perform demand side management. + In this class DSM is modeled via a timeshift of the demand. + """ + + def __init__( + self, + label: str, + sink: Flow, + initial_demand: NumericData, + timesteps_forward: Scalar, + timesteps_backward: Scalar, + maximum_flow_surplus_per_hour: NumericData, + maximum_flow_deficit_per_hour: NumericData, + allow_parallel_surplus_and_deficit: bool = False, + penalty_costs: NumericData = 0.001, + meta_data: Optional[Dict] = None + ): + """ + Args: + label: The label of the Element. Used to identify it in the FlowSystem + sink: input-flow of DSM sink after DSM + initial_demand: initial demand of DSM sink before DSM + timesteps_forward: Maximum number of timesteps by which the demand can be shifted forward in time (e.g., if timesteps_backward=2, demand at t can be satisfied at t-1 or t-2) + timesteps_backward: Maximum number of timesteps by which the demand can be shifted backward in time (e.g., if timesteps_forward=2, demand at t can be satisfied at t+1 or t+2) + maximum_flow_surplus_per_hour: Maximum amount that the supply can exceed the demand per hour + maximum_flow_deficit_per_hour: Maximum amount that the supply can fall below the demand per hour + allow_parallel_surplus_and_deficit: If True, allows simultaneous surplus and deficit in one timestep that compensate each other but allow for timeshifts longer than the set timestep bounds + penalty_costs: Penalty costs per flow unit for deviating from the initial demand profile (default is a small epsilon to avoid multiple optimization results of equal value) + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + + super().__init__( + label, + sink, + meta_data + ) + + self.initial_demand: NumericDataTS = initial_demand + self.timesteps_backward = timesteps_backward + self.timesteps_forward = timesteps_forward + self.maximum_flow_surplus_per_hour: NumericDataTS = maximum_flow_surplus_per_hour + self.maximum_flow_deficit_per_hour: NumericDataTS = maximum_flow_deficit_per_hour + self.allow_parallel_surplus_and_deficit = allow_parallel_surplus_and_deficit + self.penalty_costs: NumericDataTS = penalty_costs + + def create_model(self, model: SystemModel) -> 'DSMSinkTSModel': + self._plausibility_checks() + self.model = DSMSinkTSModel(model, self) + return self.model + + def transform_data(self, flow_system: 'FlowSystem') -> None: + super().transform_data(flow_system) + self.initial_demand = flow_system.create_time_series( + f'{self.label_full}|initial_demand', + self.initial_demand, + ) + self.maximum_flow_surplus_per_hour = flow_system.create_time_series( + f'{self.label_full}|maximum_flow_surplus_per_hour', + self.maximum_flow_surplus_per_hour + ) + self.maximum_flow_deficit_per_hour = flow_system.create_time_series( + f'{self.label_full}|maximum_flow_deficit_per_hour', + self.maximum_flow_deficit_per_hour + ) + self.penalty_costs = flow_system.create_time_series( + f'{self.label_full}|penalty_costs', + self.penalty_costs + ) + + def _plausibility_checks(self): + """ + Check for infeasible or uncommon combinations of parameters + """ + super()._plausibility_checks() + + if any(self.maximum_flow_deficit_per_hour >= 0): + raise ValueError( + f'{self.label_full}: {self.maximum_flow_deficit_per_hour=} ' + f'must have a negative value assigned.' + ) + + #TODO: think of infeasabilities + + +@register_class_for_io +class DSMSinkTSModel(ComponentModel): + """ Model of timeshift DSM sink """ + + def __init__(self, model: SystemModel, element: DSMSinkTS): + super().__init__(model, element) + self.element: DSMSinkTS = element + self.surplus: Optional[linopy.Variable] = None + self.deficit_pre: Optional[linopy.Variable] = None + self.deficit_post: Optional[linopy.Variable] = None + self.is_surplus: Optional[StateModel] = None + self.is_deficit: Optional[StateModel] = None + self.prevent_simultaneous: Optional[PreventSimultaneousUsageModel] = None + + def do_modeling(self): + super().do_modeling() + + # add variables for supply surplus (one variable per timestep) + lb,ub = 0,self.absolute_DSM_bounds[1] + self.surplus = self.add( + self._model.add_variables( + lower=lb, upper=ub, coords=self._model.coords, name=f'{self.label_full}|surplus' + ), + 'surplus', + ) + + # add variables for supply deficit (one variable per timestep and + # per number of timesteps by which demand can be shifted backward) + # nomenclature: a backwards timeshift results in a deficit preceding its assigned surplus --> deficit_pre + lb,ub = self.absolute_DSM_bounds[0],0 + self.deficit_pre = { + i: self.add( + self._model.add_variables( + lower=lb, upper=ub, coords=self._model.coords, name=f'{self.label_full}|deficit_pre_{i}' + ), + f'deficit_pre_{i}', + ) + for i in range(1, self.element.timesteps_backward + 1) + # range starting from 1 to be consistent with the number of timesteps that the deficit occurs prior to its assigned surplus + } + + # add variables for supply deficit (one variable per timestep and + # per number of timesteps by which demand can be shifted forward) + # nomenclature: a forwards timeshift results in a deficit lagging behind its assigned surplus --> deficit_post + lb,ub = self.absolute_DSM_bounds[0],0 + self.deficit_post = { + i: self.add( + self._model.add_variables( + lower=lb, upper=ub, coords=self._model.coords, name=f'{self.label_full}|deficit_post_{i}' + ), + f'deficit_post_{i}', + ) + for i in range(1, self.element.timesteps_forward + 1) + # range starting from 1 to be consistent with the number of timesteps that the deficit occurs after its assigned surplus + } + + timesteps_backward = self.element.timesteps_backward + timesteps_forward = self.element.timesteps_forward + surplus = self.surplus + deficit_pre = self.deficit_pre + deficit_post = self.deficit_post + initial_demand = self.element.initial_demand.active_data + sink = self.element.sink.model.flow_rate + hours_per_step = self._model.hours_per_step + + # For each timestep t: + # surplus(t) = sum over n (deficit_pre(t-n,n)) + sum over m (deficit_post(t+m,m)) + # where n ranges from 1 to timesteps_backward and m ranges from 1 to timesteps_forward + # INTERPRETATION: flow conservation equation where surplus at t compensates for: + # - deficits that occurred n timesteps before t (deficit_pre) + # - deficits that will occur m timesteps after t (deficit_post) + for t in range(len(self._model.coords[0])): + # Sum up all deficits that are assigned to this timestep's surplus + deficit_sum = 0 + # Add deficits from past timesteps (t-n) that are assigned to surplus at t + for n in range(1, timesteps_backward + 1): + if t - n >= 0: + deficit_sum += deficit_pre[n].isel(time=t - n) + # Add deficits from future timesteps (t+m) that are assigned to surplus at t + for m in range(1, timesteps_forward + 1): + if t + m < len(self._model.coords[0]): + deficit_sum += deficit_post[m].isel(time=t + m) + + self.add( + self._model.add_constraints( + surplus.isel(time=t) + deficit_sum == 0, + name=f'{self.label_full}|assign_surplus_deficit_{t}', + ), + f'assign_surplus_deficit_{t}' + ) + + # For each timestep t: + # sink(t) = initial_demand(t) + surplus(t) + sum over n (deficit_pre(t,n)) + sum over m (deficit_post(t,m)) + # where n ranges from 1 to timesteps_backward and m ranges from 1 to timesteps_forward + # INTERPRETATION: balance equation where sink flow equals initial demand plus surplus in t and all deficits in t + # that are assigned to valid future surpluses (t+n) or past surpluses (t-m) + # Note: Only deficits that appear in the flow conservation equation are included + for t in range(len(self._model.coords[0])): + # Sum up all deficits that occur at this timestep + deficit_sum = 0 + # Add deficits at t that are assigned to future surpluses + for n in range(1, timesteps_backward + 1): + if t + n < len(self._model.coords[0]): # only add deficits that appear in flow conservation equation + deficit_sum += deficit_pre[n].isel(time=t) + # Add deficits at t that are assigned to past surpluses + for m in range(1, timesteps_forward + 1): + if t - m >= 0: # only add deficits that appear in flow conservation equation + deficit_sum += deficit_post[m].isel(time=t) + + self.add( + self._model.add_constraints( + sink.isel(time=t) + == surplus.isel(time=t) + + deficit_sum + + initial_demand.isel(time=t), + name=f'{self.label_full}|balance_{t}', + ), + f'balance_{t}' + ) + + # Add exclusivity constraints using StateModel and PreventSimultaneousUsageModel + self._add_surplus_deficit_exclusivity_constraints() + + # Add penalty costs as effects for supply surplus + # default is a small epsilon to avoid multiple optimization results of equal value + penalty_costs = self.element.penalty_costs.active_data + self._model.effects.add_share_to_penalty( + name = self.label_full, + expression = (surplus * penalty_costs * hours_per_step).sum() + ) + + def _add_surplus_deficit_exclusivity_constraints(self): + """ + If allow_parallel_surplus_and_deficit is True, no exclusivity constraints are added. + Instead a constraint is added that limits longterm timeshifting to half of the max DSM capacity. + If allow_parallel_surplus_and_deficit is False, StateModel and PreventSimultaneousUsageModel is used to ensure surplus and deficit can not be active simultaneously. + """ + + timesteps_backward = self.element.timesteps_backward + timesteps_forward = self.element.timesteps_forward + surplus = self.surplus + deficit_pre = self.deficit_pre + deficit_post = self.deficit_post + + if self.element.allow_parallel_surplus_and_deficit: + #add a constraint that limits long term timeshifting to half of the max DSM capacity + for t in range(len(self._model.coords[0])): + # Sum up all deficits that occur at this timestep + deficit_sum = 0 + # Add deficits at t that are assigned to future surpluses + for n in range(1, timesteps_backward + 1): + if t + n < len(self._model.coords[0]): # only add deficits that appear in flow conservation equation + deficit_sum += deficit_pre[n].isel(time=t) + # Add deficits at t that are assigned to past surpluses + for m in range(1, timesteps_forward + 1): + if t - m >= 0: # only add deficits that appear in flow conservation equation + deficit_sum += deficit_post[m].isel(time=t) + # eq: surplus(t) + abs(deficit(t)) < min(upper bound of surplus, absolute value of lower bound of deficit) + self.add( + self._model.add_constraints( + surplus.isel(time=t) + - deficit_sum + <= min(-self.absolute_DSM_bounds[0].isel(time=t), self.absolute_DSM_bounds[1].isel(time=t)), + name=f'{self.label_full}|long_term_timeshift_constraint_{t}', + ), + f'long_term_timeshift_constraint_{t}' + ) + else: + # Create a single time series of zeros to be used for both bounds + timeseries_zeros = np.zeros_like(self._model.coords[0], dtype=float) + + # create StateModel for surplus with time series of zeros as lower bound + self.is_surplus = self.add( + StateModel( + model=self._model, + label_of_element=f'{self.label_full}|surplus', + defining_variables=[self.surplus], + defining_bounds=[(timeseries_zeros, self.absolute_DSM_bounds[1])], # Use time series of zeros and time-varying upper bound + use_off=False + ) + ) + self.is_surplus.do_modeling() + + # create StateModel for deficit (using the sum of all deficits) + deficit_sum = sum(deficit_pre.values()) + sum(deficit_post.values()) + self.is_deficit = self.add( + StateModel( + model=self._model, + label_of_element=f'{self.label_full}|deficit', + defining_variables=[-deficit_sum], # StateModel can only handel positive variables --> inverted value of deficit_sum is used + defining_bounds=[(timeseries_zeros, -self.absolute_DSM_bounds[0])], # Use time series of zeros and negative time-varying lower bound + use_off=False + ) + ) + self.is_deficit.do_modeling() + + # create PreventSimultaneousUsageModel + self.prevent_simultaneous = self.add( + PreventSimultaneousUsageModel( + model=self._model, + variables=[self.is_surplus.on, self.is_deficit.on], + label_of_element=self.label_full, + label='PreventSimultaneousSurplusDeficit' + ) + ) + self.prevent_simultaneous.do_modeling() + + + @property + def absolute_DSM_bounds(self) -> Tuple[NumericData, NumericData]: + return( + self.element.maximum_flow_deficit_per_hour.active_data, + self.element.maximum_flow_surplus_per_hour.active_data, + ) + + """zu klären: + Können coords auch andere Koordinaten enthalten als time? Wie funktioniert der korrekte Zugriff? + Lieber built-in Komponenten für Speicher und StateModel verwenden? + Passt die Einbindung der Strafkosten? + """ \ No newline at end of file From 6ceb3ec7f759da21bfcbcc6a56c1c5d5a91b3b04 Mon Sep 17 00:00:00 2001 From: fkeller Date: Mon, 26 May 2025 18:28:08 +0200 Subject: [PATCH 08/33] renamed DSMSink class to SDMSinkVS; removed hours_per_timestep in penalty costs for DSMSinkVS to fix a bug; added extra timestep to said penalty costs --- .../minimal_example_timeshift_DSM.py | 5 ++--- ...=> minimal_example_virtual_storage_DSM.py} | 2 +- flixopt/__init__.py | 2 +- flixopt/commons.py | 4 ++-- flixopt/components.py | 22 ++++++++++--------- 5 files changed, 18 insertions(+), 17 deletions(-) rename examples/00_Minmal/{minimal_example_DSM.py => minimal_example_virtual_storage_DSM.py} (99%) diff --git a/examples/00_Minmal/minimal_example_timeshift_DSM.py b/examples/00_Minmal/minimal_example_timeshift_DSM.py index 4e8500159..41580a72f 100644 --- a/examples/00_Minmal/minimal_example_timeshift_DSM.py +++ b/examples/00_Minmal/minimal_example_timeshift_DSM.py @@ -17,8 +17,8 @@ # --- Define Thermal Load Profile --- # Load profile (e.g., kW) for heating demand over time - #thermal_load_profile = np.array([80, 80, 80, 80, 80, 80, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 80, 80, 80, 80, 80, 80]) - thermal_load_profile = np.array([100, 100, 100, 100, 100, 100, 120, 120, 120, 100, 100, 100, 100, 100, 100, 80, 80, 80, 100, 100, 100, 100, 100, 100]) + thermal_load_profile = np.array([80, 80, 80, 80, 80, 80, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 80, 80, 80, 80, 80, 80]) + #thermal_load_profile = np.array([100, 100, 100, 100, 100, 100, 120, 120, 120, 100, 100, 100, 100, 100, 100, 80, 80, 80, 100, 100, 100, 100, 100, 100]) # --- Define Energy Buses --- # These are balancing nodes (inputs=outputs) and balance the different energy carriers your system @@ -52,7 +52,6 @@ timesteps_backward=3, maximum_flow_surplus_per_hour=20, maximum_flow_deficit_per_hour=-20, - allow_parallel_surplus_and_deficit = True ) # Gas source component with cost-effect per flow hour diff --git a/examples/00_Minmal/minimal_example_DSM.py b/examples/00_Minmal/minimal_example_virtual_storage_DSM.py similarity index 99% rename from examples/00_Minmal/minimal_example_DSM.py rename to examples/00_Minmal/minimal_example_virtual_storage_DSM.py index af174180a..17d4466f1 100644 --- a/examples/00_Minmal/minimal_example_DSM.py +++ b/examples/00_Minmal/minimal_example_virtual_storage_DSM.py @@ -43,7 +43,7 @@ ) # Heat load component with a fixed thermal demand profile - heat_load = fx.DSMSink( + heat_load = fx.DSMSinkVS( 'DSM Sink Heat Demand', sink=fx.Flow(label='Heat Load', bus='District Heating', size=150), initial_demand=thermal_load_profile, diff --git a/flixopt/__init__.py b/flixopt/__init__.py index 9db23e791..42407e947 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -20,7 +20,7 @@ PiecewiseEffects, SegmentedCalculation, Sink, - DSMSink, + DSMSinkVS, DSMSinkTS, Source, SourceAndSink, diff --git a/flixopt/commons.py b/flixopt/commons.py index 8be5ba692..1ea194d64 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -8,7 +8,7 @@ from .components import ( LinearConverter, Sink, - DSMSink, + DSMSinkVS, DSMSinkTS, Source, SourceAndSink, @@ -31,7 +31,7 @@ 'Effect', 'Source', 'Sink', - 'DSMSink', + 'DSMSinkVS', 'DSMSinkTS', 'SourceAndSink', 'Storage', diff --git a/flixopt/components.py b/flixopt/components.py index 85e2cb325..00e57a2c9 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -638,7 +638,7 @@ def __init__(self, label: str, sink: Flow, meta_data: Optional[Dict] = None): self.sink = sink @register_class_for_io -class DSMSink(Sink): +class DSMSinkVS(Sink): """ Used to model sinks with the ability to perform demand side management. In this class DSM is modeled via a virtual storage. @@ -709,9 +709,9 @@ def __init__( self.penalty_costs_positive_charge_states: NumericDataTS = penalty_costs_positive_charge_states self.penalty_costs_negative_charge_states: NumericDataTS = penalty_costs_negative_charge_states - def create_model(self, model: SystemModel) -> 'DSMSinkModel': + def create_model(self, model: SystemModel) -> 'DSMSinkVSModel': self._plausibility_checks() - self.model = DSMSinkModel(model, self) + self.model = DSMSinkVSModel(model, self) return self.model def transform_data(self, flow_system: 'FlowSystem') -> None: @@ -748,11 +748,13 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: ) self.penalty_costs_negative_charge_states = flow_system.create_time_series( f'{self.label_full}|penalty_costs_negative_charge_states', - self.penalty_costs_negative_charge_states + self.penalty_costs_negative_charge_states, + needs_extra_timestep=True ) self.penalty_costs_positive_charge_states = flow_system.create_time_series( f'{self.label_full}|penalty_costs_positive_charge_states', - self.penalty_costs_positive_charge_states + self.penalty_costs_positive_charge_states, + needs_extra_timestep=True ) def _plausibility_checks(self): @@ -785,12 +787,12 @@ def _plausibility_checks(self): #TODO: think about other implausibilities #INFO: investments not implemented -class DSMSinkModel(ComponentModel): +class DSMSinkVSModel(ComponentModel): """Model of DSM Sink""" - def __init__(self, model: SystemModel, element: DSMSink): + def __init__(self, model: SystemModel, element: DSMSinkVS): super().__init__(model, element) - self.element: DSMSink = element + self.element: DSMSinkVS = element self.positive_charge_state: Optional[linopy.Variable] = None self.negative_charge_state: Optional[linopy.Variable] = None self.netto_charge_rate: Optional[linopy.Variable] = None @@ -875,14 +877,14 @@ def do_modeling(self): if np.any(penalty_costs_pos != 0): self._model.effects.add_share_to_penalty( name = self.label_full, - expression = (positive_charge_state * penalty_costs_pos * hours_per_step).sum() + expression = (positive_charge_state * penalty_costs_pos).sum() ) # Add effects for negative charge states if np.any(penalty_costs_neg != 0): self._model.effects.add_share_to_penalty( name = self.label_full, - expression = -(negative_charge_state * penalty_costs_neg * hours_per_step).sum() + expression = - (negative_charge_state * penalty_costs_neg).sum() ) def _add_charge_state_exclusivity_constraints(self): From 03fb38729b8329b5f3523cd190e2970f996d1a7b Mon Sep 17 00:00:00 2001 From: fkeller Date: Wed, 4 Jun 2025 10:01:34 +0200 Subject: [PATCH 09/33] changes to how hours_per_timestep is handeled in the DSMSinkVS class --- flixopt/components.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 00e57a2c9..21bc4b467 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -843,9 +843,9 @@ def do_modeling(self): self._model.add_constraints( positive_charge_state.isel(time=slice(1, None)) + negative_charge_state.isel(time=slice(1,None)) - == positive_charge_state.isel(time=slice(None, -1)) * (etapos * hours_per_step) - + negative_charge_state.isel(time=slice(None,-1)) * (etaneg * hours_per_step) - + charge_rate, + == positive_charge_state.isel(time=slice(None, -1)) * (etapos ** hours_per_step) + + negative_charge_state.isel(time=slice(None,-1)) * (etaneg ** hours_per_step) + + charge_rate * hours_per_step, name=f'{self.label_full}|charge_state', ), 'charge_state', @@ -1040,6 +1040,7 @@ class DSMSinkTS(Sink): """ Used to model a sink with the ability to perform demand side management. In this class DSM is modeled via a timeshift of the demand. + DON'T USE WITH TIMESTEPS OF VARIABLE LENGTH! """ def __init__( @@ -1120,6 +1121,7 @@ def _plausibility_checks(self): ) #TODO: think of infeasabilities + #TODO: check for invariable timestep lengths @register_class_for_io @@ -1242,6 +1244,8 @@ def do_modeling(self): f'balance_{t}' ) + # TODO: handle hours_per_timesteps appropriately + # Add exclusivity constraints using StateModel and PreventSimultaneousUsageModel self._add_surplus_deficit_exclusivity_constraints() @@ -1341,4 +1345,5 @@ def absolute_DSM_bounds(self) -> Tuple[NumericData, NumericData]: Können coords auch andere Koordinaten enthalten als time? Wie funktioniert der korrekte Zugriff? Lieber built-in Komponenten für Speicher und StateModel verwenden? Passt die Einbindung der Strafkosten? + Kein hours_per_step bei Strafkosten für virtual storage --> lieber ersten Schritt auslassen? """ \ No newline at end of file From b1de3a1b4b2d2cbe1bf548d2ee843fc2cc8f71b7 Mon Sep 17 00:00:00 2001 From: fkeller Date: Wed, 4 Jun 2025 10:03:28 +0200 Subject: [PATCH 10/33] added diagrams for the DSM classes in examples files --- .../minimal_example_timeshift_DSM.py | 108 +++++++++++++++++- .../minimal_example_virtual_storage_DSM.py | 86 +++++++++++++- 2 files changed, 188 insertions(+), 6 deletions(-) diff --git a/examples/00_Minmal/minimal_example_timeshift_DSM.py b/examples/00_Minmal/minimal_example_timeshift_DSM.py index 41580a72f..73ee83e6a 100644 --- a/examples/00_Minmal/minimal_example_timeshift_DSM.py +++ b/examples/00_Minmal/minimal_example_timeshift_DSM.py @@ -74,10 +74,112 @@ # Access the results of an element df1 = calculation.results['costs'].filter_solution('time').to_dataframe() - # Plot the results of a specific element - calculation.results['District Heating'].plot_node_balance_pie() - calculation.results['District Heating'].plot_node_balance() + # Create a custom plot showing node balance, initial demand and surplus/deficit + import plotly.graph_objects as go + from plotly.subplots import make_subplots + from flixopt import plotting + + # Get the data + node_balance = calculation.results['District Heating'].node_balance(with_last_timestep=True).to_dataframe() + dsm_results = calculation.results['DSM Sink Heat Demand'] + + # Get initial demand from the flow system's time series collection + initial_demand = calculation.flow_system.time_series_collection.time_series_data[f'{dsm_results.label}|initial_demand'].active_data.to_dataframe(name='initial_demand') + + # Get surplus and deficit from the solution + surplus = dsm_results.solution[f'{dsm_results.label}|surplus'].to_dataframe() + + # Get the number of timesteps from the component's model + timesteps_backward = calculation.flow_system.components['DSM Sink Heat Demand'].timesteps_backward + timesteps_forward = calculation.flow_system.components['DSM Sink Heat Demand'].timesteps_forward + + # For timeshift DSM, deficit is split into pre and post timesteps + # Initialize deficit DataFrames with zeros + deficit_pre = pd.DataFrame(0, index=surplus.index, columns=['deficit_pre']) + deficit_post = pd.DataFrame(0, index=surplus.index, columns=['deficit_post']) + + # Sum up all pre and post deficits + for i in range(1, timesteps_backward + 1): + pre_df = dsm_results.solution[f'{dsm_results.label}|deficit_pre_{i}'].to_dataframe() + deficit_pre['deficit_pre'] += pre_df.values.flatten() + + for i in range(1, timesteps_forward + 1): + post_df = dsm_results.solution[f'{dsm_results.label}|deficit_post_{i}'].to_dataframe() + deficit_post['deficit_post'] += post_df.values.flatten() + + # Combine deficits + deficit = pd.DataFrame(0, index=surplus.index, columns=['deficit']) + deficit['deficit'] = deficit_pre['deficit_pre'] + deficit_post['deficit_post'] + + print(deficit) + + # Create figure with secondary y-axis using the same style as node balance + fig = plotting.with_plotly( + node_balance, + mode='area', + colors='viridis', + title='District Heating Node Balance with DSM Surplus/Deficit', + ylabel='Power [kW]', + xlabel='Time' + ) + + # Get colors from viridis for the surplus/deficit + import plotly.express as px + viridis_colors = px.colors.sample_colorscale('viridis', 4) + surplus_color = viridis_colors[1] # Use a blue-ish color from viridis + deficit_color = viridis_colors[0] # Use a violette-ish color from viridis + + # Add initial demand with step lines (no interpolation) + fig.add_trace( + go.Scatter( + x=initial_demand.index, + y=initial_demand['initial_demand'], + name='Initial Demand', + line=dict(dash='dash', color='black', shape='hv'), # 'hv' for horizontal-vertical steps + mode='lines' + ) + ) + + # Add surplus and deficit as bars on secondary y-axis with reduced opacity + fig.add_trace( + go.Bar( + x=surplus.index, + y=surplus.values.flatten(), + name='Surplus', + marker=dict(color=surplus_color, opacity=0.7), + yaxis='y2' + ) + ) + + fig.add_trace( + go.Bar( + x=deficit.index, + y=deficit['deficit'], + name='Deficit', + marker=dict(color=deficit_color, opacity=0.7), + yaxis='y2' + ) + ) + + # Update layout for secondary y-axis and bar styling + fig.update_layout( + yaxis2=dict( + title='Power [kW]', + overlaying='y', + side='right', + showgrid=False + ), + hovermode='x unified', + bargap=0, # No gap between bars + bargroupgap=0 # No gap between bar groups + ) + + # Show the plot + fig.show() + # Original plots + #calculation.results['District Heating'].plot_node_balance_pie() + #calculation.results['District Heating'].plot_node_balance() # Save the DSM Sink Heat Demand solution dataset to a CSV file calculation.results['DSM Sink Heat Demand'].solution.to_dataframe().to_csv('results/DSM_Sink_Heat_Demand_results.csv') diff --git a/examples/00_Minmal/minimal_example_virtual_storage_DSM.py b/examples/00_Minmal/minimal_example_virtual_storage_DSM.py index 17d4466f1..69a41b3f2 100644 --- a/examples/00_Minmal/minimal_example_virtual_storage_DSM.py +++ b/examples/00_Minmal/minimal_example_virtual_storage_DSM.py @@ -76,10 +76,90 @@ # Access the results of an element df1 = calculation.results['costs'].filter_solution('time').to_dataframe() - # Plot the results of a specific element - calculation.results['District Heating'].plot_node_balance_pie() - calculation.results['District Heating'].plot_node_balance() + # Create a custom plot showing node balance, initial demand and charge states + import plotly.graph_objects as go + from plotly.subplots import make_subplots + from flixopt import plotting + + # Get the data + node_balance = calculation.results['District Heating'].node_balance(with_last_timestep=True).to_dataframe() + dsm_results = calculation.results['DSM Sink Heat Demand'] + + # Get initial demand from the flow system's time series collection + initial_demand = calculation.flow_system.time_series_collection.time_series_data[f'{dsm_results.label}|initial_demand'].active_data.to_dataframe(name='initial_demand') + + # Get charge states from the solution + positive_charge = dsm_results.solution[f'{dsm_results.label}|positive_charge_state'].to_dataframe() + negative_charge = dsm_results.solution[f'{dsm_results.label}|negative_charge_state'].to_dataframe() + + # Create figure with secondary y-axis using the same style as node balance + fig = plotting.with_plotly( + node_balance, + mode='area', + colors='viridis', + title='District Heating Node Balance with DSM Charge States', + ylabel='Power [kW]', + xlabel='Time' + ) + + # Get colors from viridis for the charge states + import plotly.express as px + # Use a more muted color scale + viridis_colors = px.colors.sample_colorscale('viridis', 4) + positive_color = viridis_colors[1] # Use a blue-ish color from viridis + negative_color = viridis_colors[0] # Use a violette-ish color from viridis + + # Add initial demand with step lines (no interpolation) + fig.add_trace( + go.Scatter( + x=initial_demand.index, + y=initial_demand['initial_demand'], + name='Initial Demand', + line=dict(dash='dash', color='black', shape='hv'), # 'hv' for horizontal-vertical steps + mode='lines' + ) + ) + + # Add charge states as bars on secondary y-axis with reduced opacity + fig.add_trace( + go.Bar( + x=positive_charge.index, + y=positive_charge.values.flatten(), + name='Positive Charge State', + marker=dict(color=positive_color, opacity=0.7), # Add opacity for less saturation + yaxis='y2' + ) + ) + + fig.add_trace( + go.Bar( + x=negative_charge.index, + y=negative_charge.values.flatten(), + name='Negative Charge State', + marker=dict(color=negative_color, opacity=0.7), # Add opacity for less saturation + yaxis='y2' + ) + ) + + # Update layout for secondary y-axis and bar styling + fig.update_layout( + yaxis2=dict( + title='Charge State [kWh]', + overlaying='y', + side='right', + showgrid=False + ), + hovermode='x unified', + bargap=0, # No gap between bars + bargroupgap=0 # No gap between bar groups + ) + + # Show the plot + fig.show() + # Original plots + #calculation.results['District Heating'].plot_node_balance_pie() + #calculation.results['District Heating'].plot_node_balance() # Save the DSM Sink Heat Demand solution dataset to a CSV file calculation.results['DSM Sink Heat Demand'].solution.to_dataframe().to_csv('results/DSM_Sink_Heat_Demand_results.csv') From bba2060e4485a8bda65fc248f091e86be1b372ac Mon Sep 17 00:00:00 2001 From: fkeller Date: Wed, 4 Jun 2025 17:28:51 +0200 Subject: [PATCH 11/33] added constraints that set unused deficit variables to zero --- flixopt/components.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 21bc4b467..db85e1852 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -1121,6 +1121,7 @@ def _plausibility_checks(self): ) #TODO: think of infeasabilities + # - L > amount of timesteps #TODO: check for invariable timestep lengths @@ -1160,7 +1161,7 @@ def do_modeling(self): lower=lb, upper=ub, coords=self._model.coords, name=f'{self.label_full}|deficit_pre_{i}' ), f'deficit_pre_{i}', - ) + ) for i in range(1, self.element.timesteps_backward + 1) # range starting from 1 to be consistent with the number of timesteps that the deficit occurs prior to its assigned surplus } @@ -1171,7 +1172,7 @@ def do_modeling(self): lb,ub = self.absolute_DSM_bounds[0],0 self.deficit_post = { i: self.add( - self._model.add_variables( + self._model.add_variables( lower=lb, upper=ub, coords=self._model.coords, name=f'{self.label_full}|deficit_post_{i}' ), f'deficit_post_{i}', @@ -1228,10 +1229,14 @@ def do_modeling(self): for n in range(1, timesteps_backward + 1): if t + n < len(self._model.coords[0]): # only add deficits that appear in flow conservation equation deficit_sum += deficit_pre[n].isel(time=t) + else: # unused deficits are set to zero + self.add(self._model.add_constraints(deficit_pre[n].isel(time=t)==0)) # Add deficits at t that are assigned to past surpluses for m in range(1, timesteps_forward + 1): if t - m >= 0: # only add deficits that appear in flow conservation equation deficit_sum += deficit_post[m].isel(time=t) + else: # unused deficits are set to zero + self.add(self._model.add_constraints(deficit_post[m].isel(time=t)==0)) self.add( self._model.add_constraints( From e8c07f81522db85c4ffc90ca7f03a95032bfa9f4 Mon Sep 17 00:00:00 2001 From: fkeller Date: Fri, 6 Jun 2025 11:38:54 +0200 Subject: [PATCH 12/33] penalty costs now correctly scale with hours_per_step --- flixopt/components.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index db85e1852..6d8febcdf 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -749,12 +749,10 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: self.penalty_costs_negative_charge_states = flow_system.create_time_series( f'{self.label_full}|penalty_costs_negative_charge_states', self.penalty_costs_negative_charge_states, - needs_extra_timestep=True ) self.penalty_costs_positive_charge_states = flow_system.create_time_series( f'{self.label_full}|penalty_costs_positive_charge_states', self.penalty_costs_positive_charge_states, - needs_extra_timestep=True ) def _plausibility_checks(self): @@ -875,16 +873,22 @@ def do_modeling(self): # Add effects for positive charge states if np.any(penalty_costs_pos != 0): + # Multiply penalty costs with hours_per_step first to get a single coefficient per timestep + penalty_coeff_pos = penalty_costs_pos * hours_per_step self._model.effects.add_share_to_penalty( name = self.label_full, - expression = (positive_charge_state * penalty_costs_pos).sum() + # charge state is shifted backwards in time to apply penalty costs to the charge state at the end of a timestep + expression = (positive_charge_state.shift(time=-1).isel(time=slice(None,-1)) * penalty_coeff_pos).sum() ) # Add effects for negative charge states if np.any(penalty_costs_neg != 0): + # Multiply penalty costs with hours_per_step first to get a single coefficient per timestep + penalty_coeff_neg = - penalty_costs_neg * hours_per_step self._model.effects.add_share_to_penalty( name = self.label_full, - expression = - (negative_charge_state * penalty_costs_neg).sum() + # charge state is shifted backwards in time to apply penalty costs to the charge state at the end of a timestep + expression = (negative_charge_state.shift(time=-1).isel(time=slice(None,-1)) * penalty_coeff_neg).sum() ) def _add_charge_state_exclusivity_constraints(self): From c65e2bbe9ee04fa1ca8952f2b8e843fc4c54a0e7 Mon Sep 17 00:00:00 2001 From: fkeller Date: Sat, 7 Jun 2025 18:09:12 +0200 Subject: [PATCH 13/33] amount of timesteps now adapt automatically to timestep length --- flixopt/components.py | 59 +++++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 6d8febcdf..d93a8c391 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -1052,8 +1052,8 @@ def __init__( label: str, sink: Flow, initial_demand: NumericData, - timesteps_forward: Scalar, - timesteps_backward: Scalar, + forward_timeshift: Scalar, + backward_timeshift: Scalar, maximum_flow_surplus_per_hour: NumericData, maximum_flow_deficit_per_hour: NumericData, allow_parallel_surplus_and_deficit: bool = False, @@ -1065,8 +1065,8 @@ def __init__( label: The label of the Element. Used to identify it in the FlowSystem sink: input-flow of DSM sink after DSM initial_demand: initial demand of DSM sink before DSM - timesteps_forward: Maximum number of timesteps by which the demand can be shifted forward in time (e.g., if timesteps_backward=2, demand at t can be satisfied at t-1 or t-2) - timesteps_backward: Maximum number of timesteps by which the demand can be shifted backward in time (e.g., if timesteps_forward=2, demand at t can be satisfied at t+1 or t+2) + forward_timeshift: Maximum number of hours by which the demand can be shifted forward in time (e.g., if forward_timeshift = 2 and timesteps are 1 hour long, demand at t can be satisfied at t-1 or t-2) + backward_timeshift: Maximum number of hours by which the demand can be shifted backward in time (e.g., if backward_timeshift = 2 and timesteps are 1 hour long, demand at t can be satisfied at t+1 or t+2) maximum_flow_surplus_per_hour: Maximum amount that the supply can exceed the demand per hour maximum_flow_deficit_per_hour: Maximum amount that the supply can fall below the demand per hour allow_parallel_surplus_and_deficit: If True, allows simultaneous surplus and deficit in one timestep that compensate each other but allow for timeshifts longer than the set timestep bounds @@ -1081,15 +1081,21 @@ def __init__( ) self.initial_demand: NumericDataTS = initial_demand - self.timesteps_backward = timesteps_backward - self.timesteps_forward = timesteps_forward + self.forward_timeshift = forward_timeshift + self.backward_timeshift = backward_timeshift self.maximum_flow_surplus_per_hour: NumericDataTS = maximum_flow_surplus_per_hour self.maximum_flow_deficit_per_hour: NumericDataTS = maximum_flow_deficit_per_hour self.allow_parallel_surplus_and_deficit = allow_parallel_surplus_and_deficit self.penalty_costs: NumericDataTS = penalty_costs def create_model(self, model: SystemModel) -> 'DSMSinkTSModel': - self._plausibility_checks() + self._plausibility_checks(model) + + # calculate amount of timesteps, by which demand can be shifted forward/backward in time + hours_per_step = model.hours_per_step + self.timesteps_forward = int(self.forward_timeshift/hours_per_step.values[0]) + self.timesteps_backward = int(self.backward_timeshift/hours_per_step.values[0]) + self.model = DSMSinkTSModel(model, self) return self.model @@ -1112,7 +1118,7 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: self.penalty_costs ) - def _plausibility_checks(self): + def _plausibility_checks(self, model: SystemModel): """ Check for infeasible or uncommon combinations of parameters """ @@ -1124,9 +1130,28 @@ def _plausibility_checks(self): f'must have a negative value assigned.' ) + hours_per_step = model.hours_per_step + + if self.forward_timeshift%hours_per_step.values[0]!=0: + raise ValueError( + f'{self.label_full}: {self.forward_timeshift=} ' + f'must be a multiple of the timestep length.' + ) + + if self.backward_timeshift%hours_per_step.values[0]!=0: + raise ValueError( + f'{self.label_full}: {self.backward_timeshift=} ' + f'must be a multiple of the timestep length.' + ) + + if any(hours_per_step.values[0]!=hours_per_step.values): + raise ValueError( + f'{self.label_full}:' + f'DSMSinkTS class can only be used for timesteps of equal length' + ) + #TODO: think of infeasabilities # - L > amount of timesteps - #TODO: check for invariable timestep lengths @register_class_for_io @@ -1146,6 +1171,10 @@ def __init__(self, model: SystemModel, element: DSMSinkTS): def do_modeling(self): super().do_modeling() + hours_per_step = self._model.hours_per_step + timesteps_forward = self.element.timesteps_forward + timesteps_backward = self.element.timesteps_backward + # add variables for supply surplus (one variable per timestep) lb,ub = 0,self.absolute_DSM_bounds[1] self.surplus = self.add( @@ -1166,7 +1195,7 @@ def do_modeling(self): ), f'deficit_pre_{i}', ) - for i in range(1, self.element.timesteps_backward + 1) + for i in range(1, timesteps_backward + 1) # range starting from 1 to be consistent with the number of timesteps that the deficit occurs prior to its assigned surplus } @@ -1181,18 +1210,15 @@ def do_modeling(self): ), f'deficit_post_{i}', ) - for i in range(1, self.element.timesteps_forward + 1) + for i in range(1, timesteps_forward + 1) # range starting from 1 to be consistent with the number of timesteps that the deficit occurs after its assigned surplus } - timesteps_backward = self.element.timesteps_backward - timesteps_forward = self.element.timesteps_forward surplus = self.surplus deficit_pre = self.deficit_pre deficit_post = self.deficit_post initial_demand = self.element.initial_demand.active_data sink = self.element.sink.model.flow_rate - hours_per_step = self._model.hours_per_step # For each timestep t: # surplus(t) = sum over n (deficit_pre(t-n,n)) + sum over m (deficit_post(t+m,m)) @@ -1273,8 +1299,9 @@ def _add_surplus_deficit_exclusivity_constraints(self): If allow_parallel_surplus_and_deficit is False, StateModel and PreventSimultaneousUsageModel is used to ensure surplus and deficit can not be active simultaneously. """ - timesteps_backward = self.element.timesteps_backward - timesteps_forward = self.element.timesteps_forward + hours_per_step = self._model.hours_per_step + timesteps_forward = int(self.element.forward_timeshift/hours_per_step.values[0]) + timesteps_backward = int(self.element.backward_timeshift/hours_per_step.values[0]) surplus = self.surplus deficit_pre = self.deficit_pre deficit_post = self.deficit_post From c9fd235286714dcee8d801fbc632157a81cd59a9 Mon Sep 17 00:00:00 2001 From: fkeller Date: Mon, 9 Jun 2025 13:21:04 +0200 Subject: [PATCH 14/33] implemented possibility to set boundaries for cumulated flow deviation of timeshift DSM Sink class and changed application of penalty costs from surplus to deficits --- .../minimal_example_timeshift_DSM.py | 7 +- .../minimal_example_virtual_storage_DSM.py | 1 + flixopt/components.py | 86 +++++++++++++++++-- 3 files changed, 83 insertions(+), 11 deletions(-) diff --git a/examples/00_Minmal/minimal_example_timeshift_DSM.py b/examples/00_Minmal/minimal_example_timeshift_DSM.py index 73ee83e6a..df919932d 100644 --- a/examples/00_Minmal/minimal_example_timeshift_DSM.py +++ b/examples/00_Minmal/minimal_example_timeshift_DSM.py @@ -19,6 +19,7 @@ # Load profile (e.g., kW) for heating demand over time thermal_load_profile = np.array([80, 80, 80, 80, 80, 80, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 80, 80, 80, 80, 80, 80]) #thermal_load_profile = np.array([100, 100, 100, 100, 100, 100, 120, 120, 120, 100, 100, 100, 100, 100, 100, 80, 80, 80, 100, 100, 100, 100, 100, 100]) + #thermal_load_profile = np.array([80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80]) # --- Define Energy Buses --- # These are balancing nodes (inputs=outputs) and balance the different energy carriers your system @@ -48,8 +49,8 @@ 'DSM Sink Heat Demand', sink=fx.Flow(label='Heat Load', bus='District Heating', size=150), initial_demand=thermal_load_profile, - timesteps_forward=3, - timesteps_backward=3, + forward_timeshift=3, + backward_timeshift=3, maximum_flow_surplus_per_hour=20, maximum_flow_deficit_per_hour=-20, ) @@ -111,7 +112,7 @@ deficit = pd.DataFrame(0, index=surplus.index, columns=['deficit']) deficit['deficit'] = deficit_pre['deficit_pre'] + deficit_post['deficit_post'] - print(deficit) + # Create figure with secondary y-axis using the same style as node balance fig = plotting.with_plotly( diff --git a/examples/00_Minmal/minimal_example_virtual_storage_DSM.py b/examples/00_Minmal/minimal_example_virtual_storage_DSM.py index 69a41b3f2..7692605cd 100644 --- a/examples/00_Minmal/minimal_example_virtual_storage_DSM.py +++ b/examples/00_Minmal/minimal_example_virtual_storage_DSM.py @@ -18,6 +18,7 @@ # --- Define Thermal Load Profile --- # Load profile (e.g., kW) for heating demand over time thermal_load_profile = np.array([80, 80, 80, 80, 80, 80, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 80, 80, 80, 80, 80, 80]) + #thermal_load_profile = np.array([80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80]) # --- Define Energy Buses --- # These are balancing nodes (inputs=outputs) and balance the different energy carriers your system diff --git a/flixopt/components.py b/flixopt/components.py index d93a8c391..45ecf46f6 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -893,6 +893,9 @@ def do_modeling(self): def _add_charge_state_exclusivity_constraints(self): """Add constraints to prevent simultaneous positive and negative charge states""" + #TODO: this method currently does not use the implemented statemodel and preventsimultaneous model + #because they do not work with variables using coords_extra + # Add binary variables to track charge state type self.is_positive_charge_state = self.add( self._model.add_variables( @@ -960,8 +963,6 @@ def _add_charge_state_exclusivity_constraints(self): 'mutually_exclusive_charge_states' ) - #TODO: change implementation to use implemented state models - def _initial_and_final_charge_state(self): """Add constraints for initial and final charge states""" if self.element.initial_charge_state is not None: @@ -1056,6 +1057,8 @@ def __init__( backward_timeshift: Scalar, maximum_flow_surplus_per_hour: NumericData, maximum_flow_deficit_per_hour: NumericData, + maximum_cumulated_surplus: NumericData = np.inf, + maximum_cumulated_deficit: NumericData = -np.inf, allow_parallel_surplus_and_deficit: bool = False, penalty_costs: NumericData = 0.001, meta_data: Optional[Dict] = None @@ -1069,6 +1072,8 @@ def __init__( backward_timeshift: Maximum number of hours by which the demand can be shifted backward in time (e.g., if backward_timeshift = 2 and timesteps are 1 hour long, demand at t can be satisfied at t+1 or t+2) maximum_flow_surplus_per_hour: Maximum amount that the supply can exceed the demand per hour maximum_flow_deficit_per_hour: Maximum amount that the supply can fall below the demand per hour + maximum_cumulated_surplus: Maximum cumulated flow hours that the supply can exceed the demand + maximum_cumulated_deficit: Maximum cumulated flow hours that the supply can fall below the demand allow_parallel_surplus_and_deficit: If True, allows simultaneous surplus and deficit in one timestep that compensate each other but allow for timeshifts longer than the set timestep bounds penalty_costs: Penalty costs per flow unit for deviating from the initial demand profile (default is a small epsilon to avoid multiple optimization results of equal value) meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. @@ -1085,6 +1090,8 @@ def __init__( self.backward_timeshift = backward_timeshift self.maximum_flow_surplus_per_hour: NumericDataTS = maximum_flow_surplus_per_hour self.maximum_flow_deficit_per_hour: NumericDataTS = maximum_flow_deficit_per_hour + self.maximum_cumulated_surplus: NumericDataTS = maximum_cumulated_surplus + self.maximum_cumulated_deficit: NumericDataTS = maximum_cumulated_deficit self.allow_parallel_surplus_and_deficit = allow_parallel_surplus_and_deficit self.penalty_costs: NumericDataTS = penalty_costs @@ -1117,6 +1124,16 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: f'{self.label_full}|penalty_costs', self.penalty_costs ) + self.maximum_cumulated_surplus = flow_system.create_time_series( + f'{self.label_full}|maximum_cumulated_surplus', + self.maximum_cumulated_surplus, + needs_extra_timestep=True, + ) + self.maximum_cumulated_deficit = flow_system.create_time_series( + f'{self.label_full}|maximum_cumulated_deficit', + self.maximum_cumulated_deficit, + needs_extra_timestep=True, + ) def _plausibility_checks(self, model: SystemModel): """ @@ -1129,6 +1146,12 @@ def _plausibility_checks(self, model: SystemModel): f'{self.label_full}: {self.maximum_flow_deficit_per_hour=} ' f'must have a negative value assigned.' ) + if self.maximum_cumulated_deficit is not None: + if any(self.maximum_cumulated_deficit > 0): + raise ValueError( + f'{self.label_full}: {self.maximum_cumulated_deficit=} ' + f'must have a non positive value assinged.' + ) hours_per_step = model.hours_per_step @@ -1164,6 +1187,7 @@ def __init__(self, model: SystemModel, element: DSMSinkTS): self.surplus: Optional[linopy.Variable] = None self.deficit_pre: Optional[linopy.Variable] = None self.deficit_post: Optional[linopy.Variable] = None + self.cumulated_flow_deviation: Optional[linopy.Variable] = None self.is_surplus: Optional[StateModel] = None self.is_deficit: Optional[StateModel] = None self.prevent_simultaneous: Optional[PreventSimultaneousUsageModel] = None @@ -1214,11 +1238,21 @@ def do_modeling(self): # range starting from 1 to be consistent with the number of timesteps that the deficit occurs after its assigned surplus } + #add variables for cumulated flow on hold + lb,ub = self.absolute_cumulated_bounds + self.cumulated_flow_deviation = self.add( + self._model.add_variables( + lower=lb, upper=ub, coords=self._model.coords_extra, name=f'{self.label_full}|cumulated_flow_deviation' + ), + f'cumulated_flow_deviation' + ) + surplus = self.surplus deficit_pre = self.deficit_pre deficit_post = self.deficit_post initial_demand = self.element.initial_demand.active_data sink = self.element.sink.model.flow_rate + cumulated_flow_deviation = self.cumulated_flow_deviation # For each timestep t: # surplus(t) = sum over n (deficit_pre(t-n,n)) + sum over m (deficit_post(t+m,m)) @@ -1279,18 +1313,47 @@ def do_modeling(self): f'balance_{t}' ) - # TODO: handle hours_per_timesteps appropriately + # equation for the cumulated deviation + self.add( + self._model.add_constraints( + cumulated_flow_deviation.isel(time=slice(1,None)) + == cumulated_flow_deviation.isel(time=slice(None,-1)) + + sink * hours_per_step + - initial_demand * hours_per_step, + name=f'{self.label_full}|deviating_flow_cumulation' + ), + f'deviating_flow_cumulation' + ) + + # set initial value of cumulated deviation to + self.add( + self._model.add_constraints( + cumulated_flow_deviation.isel(time=0) == 0, + name=f'{self.label_full}|initial_deviation' + ), + f'initial_deviation' + ) # Add exclusivity constraints using StateModel and PreventSimultaneousUsageModel self._add_surplus_deficit_exclusivity_constraints() - # Add penalty costs as effects for supply surplus + # Add penalty costs as effects for supply deficits # default is a small epsilon to avoid multiple optimization results of equal value + # timeshifts of greater length are penalized with a higher cost penalty_costs = self.element.penalty_costs.active_data - self._model.effects.add_share_to_penalty( - name = self.label_full, - expression = (surplus * penalty_costs * hours_per_step).sum() - ) + for i in range(1, timesteps_forward + 1): + deficit = deficit_post[i] + self._model.effects.add_share_to_penalty( + name = self.label_full, + expression = - (deficit * i * penalty_costs * hours_per_step).sum() + ) + + for i in range(1, timesteps_backward + 1): + deficit = deficit_pre[i] + self._model.effects.add_share_to_penalty( + name = self.label_full, + expression = - (deficit * i * penalty_costs * hours_per_step).sum() + ) def _add_surplus_deficit_exclusivity_constraints(self): """ @@ -1376,6 +1439,13 @@ def absolute_DSM_bounds(self) -> Tuple[NumericData, NumericData]: self.element.maximum_flow_deficit_per_hour.active_data, self.element.maximum_flow_surplus_per_hour.active_data, ) + + @property + def absolute_cumulated_bounds(self) -> Tuple[NumericData, NumericData]: + return( + self.element.maximum_cumulated_deficit.active_data, + self.element.maximum_cumulated_surplus.active_data, + ) """zu klären: Können coords auch andere Koordinaten enthalten als time? Wie funktioniert der korrekte Zugriff? From 5962487b24aaaf17035aba3290afc46eaed54bde Mon Sep 17 00:00:00 2001 From: fkeller Date: Tue, 10 Jun 2025 08:19:37 +0200 Subject: [PATCH 15/33] some changes made to the diagrams in minimal example timeshift DSM --- .../minimal_example_timeshift_DSM.py | 59 +++++++++++++------ 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/examples/00_Minmal/minimal_example_timeshift_DSM.py b/examples/00_Minmal/minimal_example_timeshift_DSM.py index df919932d..87c3fa888 100644 --- a/examples/00_Minmal/minimal_example_timeshift_DSM.py +++ b/examples/00_Minmal/minimal_example_timeshift_DSM.py @@ -112,9 +112,7 @@ deficit = pd.DataFrame(0, index=surplus.index, columns=['deficit']) deficit['deficit'] = deficit_pre['deficit_pre'] + deficit_post['deficit_post'] - - - # Create figure with secondary y-axis using the same style as node balance + # Create figure with area plot for node balance fig = plotting.with_plotly( node_balance, mode='area', @@ -126,9 +124,10 @@ # Get colors from viridis for the surplus/deficit import plotly.express as px - viridis_colors = px.colors.sample_colorscale('viridis', 4) - surplus_color = viridis_colors[1] # Use a blue-ish color from viridis - deficit_color = viridis_colors[0] # Use a violette-ish color from viridis + viridis_colors = px.colors.sample_colorscale('viridis', 8) + surplus_color = viridis_colors[4] # Use a blue-ish color from viridis + deficit_color = viridis_colors[2] # Use a violette-ish color from viridis + cumulated_color = viridis_colors[3] # Use another color from viridis for cumulated flow # Add initial demand with step lines (no interpolation) fig.add_trace( @@ -137,42 +136,64 @@ y=initial_demand['initial_demand'], name='Initial Demand', line=dict(dash='dash', color='black', shape='hv'), # 'hv' for horizontal-vertical steps - mode='lines' + mode='lines' ) ) - # Add surplus and deficit as bars on secondary y-axis with reduced opacity + # Add surplus and deficit as area plots with similar style to node balance fig.add_trace( - go.Bar( + go.Scatter( x=surplus.index, y=surplus.values.flatten(), name='Surplus', - marker=dict(color=surplus_color, opacity=0.7), - yaxis='y2' + fill='tonexty', # Fill to the next trace + line=dict(color=surplus_color, width=1, shape='hv'), # Thin line for the area border + mode='lines', + stackgroup='one' # Stack with other traces in the same group ) ) fig.add_trace( - go.Bar( + go.Scatter( x=deficit.index, y=deficit['deficit'], name='Deficit', - marker=dict(color=deficit_color, opacity=0.7), + fill='tonexty', # Fill to the next trace + line=dict(color=deficit_color, width=1, shape='hv'), # Thin line for the area border + mode='lines', + stackgroup='two' # Stack with other traces in the same group + ) + ) + + # Get cumulated flow deviation from the model + cumulated_flow = dsm_results.solution[f'{dsm_results.label}|cumulated_flow_deviation'].to_dataframe() + + # Add cumulated flow deviation as diamonds on secondary y-axis + fig.add_trace( + go.Scatter( + x=cumulated_flow.index, + y=cumulated_flow.values.flatten(), + name='Cumulated Flow Deviation', + mode='markers', + marker=dict( + color=cumulated_color, + size=10, + symbol='diamond', + line=dict(width=1, color='black') + ), yaxis='y2' ) ) - # Update layout for secondary y-axis and bar styling + # Update layout to include secondary y-axis fig.update_layout( + hovermode='x unified', yaxis2=dict( - title='Power [kW]', + title='Cumulated Flow [kWh]', overlaying='y', side='right', showgrid=False - ), - hovermode='x unified', - bargap=0, # No gap between bars - bargroupgap=0 # No gap between bar groups + ) ) # Show the plot From c65921d160c7807c0b02cd0df8c1391d38f205ef Mon Sep 17 00:00:00 2001 From: fkeller Date: Tue, 10 Jun 2025 09:16:28 +0200 Subject: [PATCH 16/33] small changes to diagram scaling in timeshift DSM example --- examples/00_Minmal/minimal_example_timeshift_DSM.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/00_Minmal/minimal_example_timeshift_DSM.py b/examples/00_Minmal/minimal_example_timeshift_DSM.py index 87c3fa888..d4b1fb046 100644 --- a/examples/00_Minmal/minimal_example_timeshift_DSM.py +++ b/examples/00_Minmal/minimal_example_timeshift_DSM.py @@ -188,11 +188,16 @@ # Update layout to include secondary y-axis fig.update_layout( hovermode='x unified', + yaxis=dict( + range=[-1.2*max(0, node_balance.max().max(), -cumulated_flow.min().min(), -deficit.min().min()), 1.2*max(node_balance.max().max(), surplus.max().max(), cumulated_flow.max().max(), initial_demand.max().max())], + showgrid=True + ), yaxis2=dict( title='Cumulated Flow [kWh]', overlaying='y', side='right', - showgrid=False + showgrid=False, + range=[-1.2*max(0, node_balance.max().max(), -cumulated_flow.min().min(), -deficit.min().min()), 1.2*max(node_balance.max().max(), surplus.max().max(), cumulated_flow.max().max(), initial_demand.max().max())] ) ) From d83c65f9741389e2f0164f0530751ba6d73c07e3 Mon Sep 17 00:00:00 2001 From: fkeller Date: Tue, 10 Jun 2025 09:41:11 +0200 Subject: [PATCH 17/33] initial commit --- flixopt/components.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flixopt/components.py b/flixopt/components.py index 45ecf46f6..2948a7b12 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -793,7 +793,8 @@ def __init__(self, model: SystemModel, element: DSMSinkVS): self.element: DSMSinkVS = element self.positive_charge_state: Optional[linopy.Variable] = None self.negative_charge_state: Optional[linopy.Variable] = None - self.netto_charge_rate: Optional[linopy.Variable] = None + self.positive_charge_rate: Optional[linopy.Variable] = None + self.negative_charge_rate: Optional[linopy.Variable] = None self.is_positive_charge_state: Optional[linopy.Variable] = None self.is_negative_charge_state: Optional[linopy.Variable] = None From 6c92abec7c1d0399f8a9eccec72c811cc0bb7eec Mon Sep 17 00:00:00 2001 From: fkeller Date: Tue, 10 Jun 2025 09:45:57 +0200 Subject: [PATCH 18/33] added seperate positive and negative charge rates instead of one netto charge rate --- flixopt/components.py | 88 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 74 insertions(+), 14 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 2948a7b12..d1c476f7e 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -795,23 +795,37 @@ def __init__(self, model: SystemModel, element: DSMSinkVS): self.negative_charge_state: Optional[linopy.Variable] = None self.positive_charge_rate: Optional[linopy.Variable] = None self.negative_charge_rate: Optional[linopy.Variable] = None + self.is_positive_charge_state: Optional[linopy.Variable] = None self.is_negative_charge_state: Optional[linopy.Variable] = None + + # Add state models for charge rates + self.is_positive_charge_rate: Optional[StateModel] = None + self.is_negative_charge_rate: Optional[StateModel] = None + self.prevent_simultaneous_charge_rates: Optional[PreventSimultaneousUsageModel] = None def do_modeling(self): super().do_modeling() - #add variable for charge rate - lb,ub = self.absolute_charge_rate_bounds - self.netto_charge_rate = self.add( + # Add variables for positive and negative charge rates + lb, ub = 0, self.absolute_charge_rate_bounds[1] + self.positive_charge_rate = self.add( + self._model.add_variables( + lower=lb, upper=ub, coords=self._model.coords, name=f'{self.label_full}|positive_charge_rate' + ), + 'positive_charge_rate', + ) + + lb, ub = self.absolute_charge_rate_bounds[0], 0 + self.negative_charge_rate = self.add( self._model.add_variables( - lower=lb, upper=ub, coords=self._model.coords, name=f'{self.label_full}|netto_charge_rate' + lower=lb, upper=ub, coords=self._model.coords, name=f'{self.label_full}|negative_charge_rate' ), - 'netto_charge_rate', + 'negative_charge_rate', ) - #add variable for negative charge states - lb,ub = self.absolute_charge_state_bounds[0],0 + # Add variables for negative charge states + lb, ub = self.absolute_charge_state_bounds[0], 0 self.negative_charge_state = self.add( self._model.add_variables( lower=lb, upper=ub, coords=self._model.coords_extra, name=f'{self.label_full}|negative_charge_state' @@ -819,8 +833,8 @@ def do_modeling(self): 'negative_charge_state', ) - #add variable for positive charge states - lb,ub = 0,self.absolute_charge_state_bounds[1] + # Add variables for positive charge states + lb, ub = 0, self.absolute_charge_state_bounds[1] self.positive_charge_state = self.add( self._model.add_variables( lower=lb, upper=ub, coords=self._model.coords_extra, name=f'{self.label_full}|positive_charge_state' @@ -833,28 +847,31 @@ def do_modeling(self): etapos = 1 - self.element.relative_loss_per_hour_positive_charge_state.active_data etaneg = 1 - self.element.relative_loss_per_hour_negative_charge_state.active_data hours_per_step = self._model.hours_per_step - charge_rate = self.netto_charge_rate + positive_charge_rate = self.positive_charge_rate + negative_charge_rate = self.negative_charge_rate initial_demand = self.element.initial_demand.active_data sink = self.element.sink.model.flow_rate - # eq: positive_charge_state(t) + negative_charge_state(t) = positive_charge_state(t-1) * etapos + negative_charge_state(t-1) * ehtaneg + charge_rate(t) + # eq: positive_charge_state(t) + negative_charge_state(t) = positive_charge_state(t-1) * etapos + negative_charge_state(t-1) * etaneg + positive_charge_rate(t) + negative_charge_rate(t) self.add( self._model.add_constraints( positive_charge_state.isel(time=slice(1, None)) + negative_charge_state.isel(time=slice(1,None)) == positive_charge_state.isel(time=slice(None, -1)) * (etapos ** hours_per_step) + negative_charge_state.isel(time=slice(None,-1)) * (etaneg ** hours_per_step) - + charge_rate * hours_per_step, + + positive_charge_rate * hours_per_step + + negative_charge_rate * hours_per_step, name=f'{self.label_full}|charge_state', ), 'charge_state', ) - # eq: sink(t) = initial_demand(t) + charge_rate(t) + # eq: sink(t) = initial_demand(t) + positive_charge_rate(t) + negative_charge_rate(t) self.add( self._model.add_constraints( sink - == charge_rate + == positive_charge_rate + + negative_charge_rate + initial_demand, name=f'{self.label_full}|resulting_load_profile', ), @@ -865,6 +882,9 @@ def do_modeling(self): if not self.element.allow_mixed_charge_states: self._add_charge_state_exclusivity_constraints() + # Add charge rate exclusivity constraints + self._add_charge_rate_exclusivity_constraints() + # Initial and final charge state constraints self._initial_and_final_charge_state() @@ -964,6 +984,46 @@ def _add_charge_state_exclusivity_constraints(self): 'mutually_exclusive_charge_states' ) + def _add_charge_rate_exclusivity_constraints(self): + """Add constraints to prevent simultaneous positive and negative charge rates using StateModel and PreventSimultaneousUsageModel""" + # Create a single time series of zeros to be used for both bounds + timeseries_zeros = np.zeros_like(self._model.coords[0], dtype=float) + + # Create StateModel for positive charge rate + self.is_positive_charge_rate = self.add( + StateModel( + model=self._model, + label_of_element=f'{self.label_full}|positive_charge_rate', + defining_variables=[self.positive_charge_rate], + defining_bounds=[(timeseries_zeros, self.absolute_charge_rate_bounds[1])], + use_off=False + ) + ) + self.is_positive_charge_rate.do_modeling() + + # Create StateModel for negative charge rate + self.is_negative_charge_rate = self.add( + StateModel( + model=self._model, + label_of_element=f'{self.label_full}|negative_charge_rate', + defining_variables=[-self.negative_charge_rate], # StateModel can only handle positive variables + defining_bounds=[(timeseries_zeros, -self.absolute_charge_rate_bounds[0])], + use_off=False + ) + ) + self.is_negative_charge_rate.do_modeling() + + # Create PreventSimultaneousUsageModel for charge rates + self.prevent_simultaneous_charge_rates = self.add( + PreventSimultaneousUsageModel( + model=self._model, + variables=[self.is_positive_charge_rate.on, self.is_negative_charge_rate.on], + label_of_element=self.label_full, + label='PreventSimultaneousChargeRates' + ) + ) + self.prevent_simultaneous_charge_rates.do_modeling() + def _initial_and_final_charge_state(self): """Add constraints for initial and final charge states""" if self.element.initial_charge_state is not None: From cad7b5b42db3a61e84368c5835b3c9c6191fec1e Mon Sep 17 00:00:00 2001 From: fkeller Date: Tue, 10 Jun 2025 10:24:44 +0200 Subject: [PATCH 19/33] added parameter for allowing simultaneous charge and discharge plus some work on forward and backward timeshift limits --- .../minimal_example_virtual_storage_DSM.py | 3 +- flixopt/components.py | 69 ++++++++++++++++++- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/examples/00_Minmal/minimal_example_virtual_storage_DSM.py b/examples/00_Minmal/minimal_example_virtual_storage_DSM.py index 7692605cd..e83df0b49 100644 --- a/examples/00_Minmal/minimal_example_virtual_storage_DSM.py +++ b/examples/00_Minmal/minimal_example_virtual_storage_DSM.py @@ -54,7 +54,8 @@ initial_charge_state = 'lastValueOfSim', penalty_costs_positive_charge_states=0, penalty_costs_negative_charge_states=0.01, - allow_mixed_charge_states=False + allow_mixed_charge_states=False, + forward_timeshift = 1 ) # Gas source component with cost-effect per flow hour diff --git a/flixopt/components.py b/flixopt/components.py index d1c476f7e..31b031b1a 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -650,6 +650,8 @@ def __init__( sink: Flow, initial_demand: NumericData, virtual_capacity_in_flow_hours: Scalar, + forward_timeshift: Scalar = None, + backward_timeshift: Scalar = None, maximum_relative_virtual_charging_rate: NumericData = 1, maximum_relative_virtual_discharging_rate: NumericData = -1, relative_minimum_charge_state: NumericData = -1, @@ -660,6 +662,7 @@ def __init__( relative_loss_per_hour_positive_charge_state: NumericData = 0, relative_loss_per_hour_negative_charge_state: NumericData = 0, allow_mixed_charge_states: bool = False, + allow_parallel_charge_and_discharge: bool = False, penalty_costs_positive_charge_states: NumericData = 0, penalty_costs_negative_charge_states: NumericData = 0, meta_data: Optional[Dict] = None @@ -670,6 +673,8 @@ def __init__( sink: input-flow of DSM sink after DSM initial_demand: initial demand of DSM sink before DSM virtual_capacity_in_flow_hours: nominal capacity of the virtual storage + forward_timeshift: Maximum number of hours by which the demand can be shifted forward in time. Default is infinite. + backward_timeshift: Maximum number of hours by which the demand can be shifted backward in time. Default is infinite. maximum_relative_virtual_charging_rate: maximum flow rate relative to the capacity of the virtual storage at which charging is possible. The default is 1. maximum_relative_virtual_discharging_rate: maximum flow rate relative to the capacity of the virtual storage at which discharging is possible. The default is -1. relative_minimum_charge_state: minimum relative charge state. The default is -1. @@ -681,6 +686,8 @@ def __init__( relative_loss_per_hour_negative_charge_state: loss per chargeState-Unit per hour for negative charge states of the virtual storage. The default is 0. allow_mixed_charge_states: If True, positive and negative charge states can occur simultaneously. If False, only one type of charge state is allowed at a time. The default is False. + allow_parallel_charge_and_discharge: If True, allows simultaneous charging and discharging in one timestep. + If False, charging and discharging cannot occur simultaneously. The default is False. penalty_costs_positive_charge_states: penalty costs per flow hour for loss of comfort due to positive charge states of the virtual storage (e.g. increased room temperature). The default is 0. penalty_costs_negative_charge_states: penalty costs per flow hour for loss of comfort due to negative charge states of the virtual storage (e.g. decreased room temperature). The default is 0. meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. @@ -693,6 +700,8 @@ def __init__( self.initial_demand: NumericDataTS = initial_demand self.virtual_capacity_in_flow_hours = virtual_capacity_in_flow_hours + self.forward_timeshift = forward_timeshift + self.backward_timeshift = backward_timeshift self.maximum_relative_virtual_charging_rate: NumericDataTS = maximum_relative_virtual_charging_rate self.maximum_relative_virtual_discharging_rate: NumericDataTS = maximum_relative_virtual_discharging_rate self.relative_minimum_charge_state: NumericDataTS = relative_minimum_charge_state @@ -705,12 +714,19 @@ def __init__( self.relative_loss_per_hour_positive_charge_state: NumericDataTS = relative_loss_per_hour_positive_charge_state self.relative_loss_per_hour_negative_charge_state: NumericDataTS = relative_loss_per_hour_negative_charge_state self.allow_mixed_charge_states = allow_mixed_charge_states + self.allow_parallel_charge_and_discharge = allow_parallel_charge_and_discharge self.penalty_costs_positive_charge_states: NumericDataTS = penalty_costs_positive_charge_states self.penalty_costs_negative_charge_states: NumericDataTS = penalty_costs_negative_charge_states def create_model(self, model: SystemModel) -> 'DSMSinkVSModel': - self._plausibility_checks() + self._plausibility_checks(model) + + # calculate amount of timesteps, by which demand can be shifted forward/backward in time + hours_per_step = model.hours_per_step + self.timesteps_forward = int(self.forward_timeshift/hours_per_step.values[0]) if self.forward_timeshift is not None else None + self.timesteps_backward = int(self.backward_timeshift/hours_per_step.values[0]) if self.backward_timeshift is not None else None + self.model = DSMSinkVSModel(model, self) return self.model @@ -755,7 +771,7 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: self.penalty_costs_positive_charge_states, ) - def _plausibility_checks(self): + def _plausibility_checks(self, model): """ Check for infeasible or uncommon combinations of parameters """ @@ -781,6 +797,31 @@ def _plausibility_checks(self): ) elif self.initial_charge_state != 'lastValueOfSim': raise ValueError(f'{self.label_full}: {self.initial_charge_state=} has an invalid value') + + hours_per_step = model.hours_per_step + + if self.forward_timeshift != None or self.backward_timeshift != None: + if any(hours_per_step.values[0]!=hours_per_step.values): + raise ValueError( + f'{self.label_full}:' + f'DSMSinkTS class can only be used for timesteps of equal length' + ) + + if self.forward_timeshift is not None: + if self.forward_timeshift%hours_per_step.values[0]!=0: + raise ValueError( + f'{self.label_full}: {self.forward_timeshift=} ' + f'must be a multiple of the timestep length.' + ) + + if self.backward_timeshift is not None: + if self.backward_timeshift%hours_per_step.values[0]!=0: + raise ValueError( + f'{self.label_full}: {self.backward_timeshift=} ' + f'must be a multiple of the timestep length.' + ) + + #TODO: think about other implausibilities #INFO: investments not implemented @@ -883,7 +924,11 @@ def do_modeling(self): self._add_charge_state_exclusivity_constraints() # Add charge rate exclusivity constraints - self._add_charge_rate_exclusivity_constraints() + if not self.element.allow_parallel_charge_and_discharge: + self._add_charge_rate_exclusivity_constraints() + + # Forward and backward timeshift constraints + self._add_timeshift_limits() # Initial and final charge state constraints self._initial_and_final_charge_state() @@ -1024,6 +1069,24 @@ def _add_charge_rate_exclusivity_constraints(self): ) self.prevent_simultaneous_charge_rates.do_modeling() + def _add_timeshift_limits(self): + hours_per_step = self._model.hours_per_step + timesteps_forward = self.element.timesteps_forward + timesteps_backward = self.element.timesteps_backward + + positive_charge_state = self.positive_charge_state + + if timesteps_forward is not None: + surplus_sum = 0 + for i in range(1, timesteps_forward): + surplus_sum += self.positive_charge_rate.isel(time=XXX) + self.add( + self._model.add_constraints( + positive_charge_state + <= surplus_sum + ) + ) + def _initial_and_final_charge_state(self): """Add constraints for initial and final charge states""" if self.element.initial_charge_state is not None: From 467a716dd29fff5bd3b720f66cc5b611b7a4a57a Mon Sep 17 00:00:00 2001 From: fkeller Date: Tue, 10 Jun 2025 14:10:58 +0200 Subject: [PATCH 20/33] implemented full functionality of both DSM classes in a common DSMSink class --- .../minimal_example_virtual_storage_DSM.py | 19 ++++--- flixopt/__init__.py | 2 +- flixopt/commons.py | 4 +- flixopt/components.py | 49 +++++++++++++------ 4 files changed, 49 insertions(+), 25 deletions(-) diff --git a/examples/00_Minmal/minimal_example_virtual_storage_DSM.py b/examples/00_Minmal/minimal_example_virtual_storage_DSM.py index e83df0b49..04855045d 100644 --- a/examples/00_Minmal/minimal_example_virtual_storage_DSM.py +++ b/examples/00_Minmal/minimal_example_virtual_storage_DSM.py @@ -44,18 +44,21 @@ ) # Heat load component with a fixed thermal demand profile - heat_load = fx.DSMSinkVS( + heat_load = fx.DSMSink( 'DSM Sink Heat Demand', sink=fx.Flow(label='Heat Load', bus='District Heating', size=150), initial_demand=thermal_load_profile, - virtual_capacity_in_flow_hours=60, - relative_loss_per_hour_positive_charge_state = 0.05, - relative_loss_per_hour_negative_charge_state = 0.05, + virtual_capacity_in_flow_hours=100, + maximum_relative_virtual_charging_rate = 0.2, + maximum_relative_virtual_discharging_rate = -0.2, + #relative_loss_per_hour_positive_charge_state = 0.05, + #relative_loss_per_hour_negative_charge_state = 0.05, initial_charge_state = 'lastValueOfSim', - penalty_costs_positive_charge_states=0, - penalty_costs_negative_charge_states=0.01, + #penalty_costs_positive_charge_states=0, + #penalty_costs_negative_charge_states=0.01, allow_mixed_charge_states=False, - forward_timeshift = 1 + forward_timeshift = 3, + backward_timeshift = 3 ) # Gas source component with cost-effect per flow hour @@ -72,7 +75,7 @@ calculation = fx.FullCalculation('Simulation1', flow_system) calculation.do_modeling() #calculation.solve(fx.solvers.HighsSolver(0.01, 60)) - calculation.solve(fx.solvers.GurobiSolver(0.01, 60)) + calculation.solve(fx.solvers.GurobiSolver(0.001, 60)) # --- Analyze Results --- # Access the results of an element diff --git a/flixopt/__init__.py b/flixopt/__init__.py index 42407e947..9db23e791 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -20,7 +20,7 @@ PiecewiseEffects, SegmentedCalculation, Sink, - DSMSinkVS, + DSMSink, DSMSinkTS, Source, SourceAndSink, diff --git a/flixopt/commons.py b/flixopt/commons.py index 1ea194d64..8be5ba692 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -8,7 +8,7 @@ from .components import ( LinearConverter, Sink, - DSMSinkVS, + DSMSink, DSMSinkTS, Source, SourceAndSink, @@ -31,7 +31,7 @@ 'Effect', 'Source', 'Sink', - 'DSMSinkVS', + 'DSMSink', 'DSMSinkTS', 'SourceAndSink', 'Storage', diff --git a/flixopt/components.py b/flixopt/components.py index 31b031b1a..a4f203df3 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -638,7 +638,7 @@ def __init__(self, label: str, sink: Flow, meta_data: Optional[Dict] = None): self.sink = sink @register_class_for_io -class DSMSinkVS(Sink): +class DSMSink(Sink): """ Used to model sinks with the ability to perform demand side management. In this class DSM is modeled via a virtual storage. @@ -663,8 +663,8 @@ def __init__( relative_loss_per_hour_negative_charge_state: NumericData = 0, allow_mixed_charge_states: bool = False, allow_parallel_charge_and_discharge: bool = False, - penalty_costs_positive_charge_states: NumericData = 0, - penalty_costs_negative_charge_states: NumericData = 0, + penalty_costs_positive_charge_states: NumericData = 0.001, + penalty_costs_negative_charge_states: NumericData = 0.001, meta_data: Optional[Dict] = None ): """ @@ -688,8 +688,8 @@ def __init__( If False, only one type of charge state is allowed at a time. The default is False. allow_parallel_charge_and_discharge: If True, allows simultaneous charging and discharging in one timestep. If False, charging and discharging cannot occur simultaneously. The default is False. - penalty_costs_positive_charge_states: penalty costs per flow hour for loss of comfort due to positive charge states of the virtual storage (e.g. increased room temperature). The default is 0. - penalty_costs_negative_charge_states: penalty costs per flow hour for loss of comfort due to negative charge states of the virtual storage (e.g. decreased room temperature). The default is 0. + penalty_costs_positive_charge_states: penalty costs per flow hour for loss of comfort due to positive charge states of the virtual storage (e.g. increased room temperature). The default is a small epsilon. + penalty_costs_negative_charge_states: penalty costs per flow hour for loss of comfort due to negative charge states of the virtual storage (e.g. decreased room temperature). The default is a small epsilon. meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__( @@ -719,7 +719,7 @@ def __init__( self.penalty_costs_positive_charge_states: NumericDataTS = penalty_costs_positive_charge_states self.penalty_costs_negative_charge_states: NumericDataTS = penalty_costs_negative_charge_states - def create_model(self, model: SystemModel) -> 'DSMSinkVSModel': + def create_model(self, model: SystemModel) -> 'DSMSinkModel': self._plausibility_checks(model) # calculate amount of timesteps, by which demand can be shifted forward/backward in time @@ -727,7 +727,7 @@ def create_model(self, model: SystemModel) -> 'DSMSinkVSModel': self.timesteps_forward = int(self.forward_timeshift/hours_per_step.values[0]) if self.forward_timeshift is not None else None self.timesteps_backward = int(self.backward_timeshift/hours_per_step.values[0]) if self.backward_timeshift is not None else None - self.model = DSMSinkVSModel(model, self) + self.model = DSMSinkModel(model, self) return self.model def transform_data(self, flow_system: 'FlowSystem') -> None: @@ -826,12 +826,12 @@ def _plausibility_checks(self, model): #TODO: think about other implausibilities #INFO: investments not implemented -class DSMSinkVSModel(ComponentModel): +class DSMSinkModel(ComponentModel): """Model of DSM Sink""" - def __init__(self, model: SystemModel, element: DSMSinkVS): + def __init__(self, model: SystemModel, element: DSMSink): super().__init__(model, element) - self.element: DSMSinkVS = element + self.element: DSMSink = element self.positive_charge_state: Optional[linopy.Variable] = None self.negative_charge_state: Optional[linopy.Variable] = None self.positive_charge_rate: Optional[linopy.Variable] = None @@ -1074,19 +1074,40 @@ def _add_timeshift_limits(self): timesteps_forward = self.element.timesteps_forward timesteps_backward = self.element.timesteps_backward + etapos = 1 - self.element.relative_loss_per_hour_positive_charge_state.active_data + etaneg = 1 - self.element.relative_loss_per_hour_negative_charge_state.active_data + positive_charge_state = self.positive_charge_state + negative_charge_state = self.negative_charge_state if timesteps_forward is not None: surplus_sum = 0 - for i in range(1, timesteps_forward): - surplus_sum += self.positive_charge_rate.isel(time=XXX) + for i in range(1, timesteps_forward + 1): + surplus_sum += self.positive_charge_rate.shift(time=i).isel(time=slice(i,None)) * hours_per_step * (etapos ** (hours_per_step * (i-1))) self.add( self._model.add_constraints( positive_charge_state - <= surplus_sum - ) + <= surplus_sum, + name=f'{self.label_full}|limit_forward_timeshift' + ), + f'limit_forward_timeshift' ) + if timesteps_backward is not None: + deficit_sum = 0 + for i in range(1, timesteps_backward + 1): + deficit_sum += self.negative_charge_rate.shift(time=i).isel(time=slice(i,None)) * hours_per_step * (etaneg ** (hours_per_step * (i-1))) + self.add( + self._model.add_constraints( + -negative_charge_state + <= -deficit_sum, + name=f'{self.label_full}|limit_backward_timeshift' + ), + f'limit_backward_timeshift' + ) + + #TODO: handle clash with initial charge state + def _initial_and_final_charge_state(self): """Add constraints for initial and final charge states""" if self.element.initial_charge_state is not None: From 2657081fc70f56a5e78e023d254f1c33f56ccdb9 Mon Sep 17 00:00:00 2001 From: fkeller Date: Tue, 10 Jun 2025 15:40:27 +0200 Subject: [PATCH 21/33] fixed minor error with timeshift limit constraints --- .../minimal_example_virtual_storage_DSM.py | 1 + flixopt/components.py | 26 +++++++++---------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/examples/00_Minmal/minimal_example_virtual_storage_DSM.py b/examples/00_Minmal/minimal_example_virtual_storage_DSM.py index 04855045d..fda799439 100644 --- a/examples/00_Minmal/minimal_example_virtual_storage_DSM.py +++ b/examples/00_Minmal/minimal_example_virtual_storage_DSM.py @@ -18,6 +18,7 @@ # --- Define Thermal Load Profile --- # Load profile (e.g., kW) for heating demand over time thermal_load_profile = np.array([80, 80, 80, 80, 80, 80, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 80, 80, 80, 80, 80, 80]) + #thermal_load_profile = np.array([100, 100, 100, 100, 100, 100, 120, 120, 120, 100, 100, 100, 100, 100, 100, 80, 80, 80, 100, 100, 100, 100, 100, 100]) #thermal_load_profile = np.array([80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80]) # --- Define Energy Buses --- diff --git a/flixopt/components.py b/flixopt/components.py index a4f203df3..816438071 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -520,8 +520,8 @@ def _initial_and_final_charge_state(self): name = f'{self.label_full}|{name_short}' if utils.is_number(self.element.initial_charge_state): - self.add( - self._model.add_constraints( + self.add( + self._model.add_constraints( self.charge_state.isel(time=0) == self.element.initial_charge_state, name=name ), name_short, @@ -539,8 +539,8 @@ def _initial_and_final_charge_state(self): ) if self.element.maximal_final_charge_state is not None: - self.add( - self._model.add_constraints( + self.add( + self._model.add_constraints( self.charge_state.isel(time=-1) <= self.element.maximal_final_charge_state, name=f'{self.label_full}|final_charge_max', ), @@ -554,7 +554,7 @@ def _initial_and_final_charge_state(self): name=f'{self.label_full}|final_charge_min', ), 'final_charge_min', - ) + ) @property def absolute_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: @@ -825,7 +825,7 @@ def _plausibility_checks(self, model): #TODO: think about other implausibilities #INFO: investments not implemented - + class DSMSinkModel(ComponentModel): """Model of DSM Sink""" @@ -1082,11 +1082,11 @@ def _add_timeshift_limits(self): if timesteps_forward is not None: surplus_sum = 0 - for i in range(1, timesteps_forward + 1): - surplus_sum += self.positive_charge_rate.shift(time=i).isel(time=slice(i,None)) * hours_per_step * (etapos ** (hours_per_step * (i-1))) + for i in range(0, timesteps_forward): + surplus_sum += self.positive_charge_rate.shift(time=i).isel(time=slice(i,None)) * hours_per_step * (etapos ** (hours_per_step * i)) self.add( self._model.add_constraints( - positive_charge_state + positive_charge_state.isel(time = slice(1,None)) <= surplus_sum, name=f'{self.label_full}|limit_forward_timeshift' ), @@ -1095,11 +1095,11 @@ def _add_timeshift_limits(self): if timesteps_backward is not None: deficit_sum = 0 - for i in range(1, timesteps_backward + 1): - deficit_sum += self.negative_charge_rate.shift(time=i).isel(time=slice(i,None)) * hours_per_step * (etaneg ** (hours_per_step * (i-1))) + for i in range(0, timesteps_backward): + deficit_sum += self.negative_charge_rate.shift(time=i).isel(time=slice(i,None)) * hours_per_step * (etaneg ** (hours_per_step * i)) self.add( self._model.add_constraints( - -negative_charge_state + -negative_charge_state.isel(time=slice(1,None)) <= -deficit_sum, name=f'{self.label_full}|limit_backward_timeshift' ), @@ -1285,7 +1285,7 @@ def _plausibility_checks(self, model: SystemModel): Check for infeasible or uncommon combinations of parameters """ super()._plausibility_checks() - + if any(self.maximum_flow_deficit_per_hour >= 0): raise ValueError( f'{self.label_full}: {self.maximum_flow_deficit_per_hour=} ' From da7b82c31601e037ff214ec60dbfd61977b671df Mon Sep 17 00:00:00 2001 From: fkeller Date: Tue, 10 Jun 2025 15:54:34 +0200 Subject: [PATCH 22/33] removed possibility to manualley set initial and final charge states. they are now always set to 0. this could be reverted later however a change of the implementation of the timeshift limit would be necessary --- .../minimal_example_virtual_storage_DSM.py | 1 - flixopt/components.py | 113 +++++------------- 2 files changed, 28 insertions(+), 86 deletions(-) diff --git a/examples/00_Minmal/minimal_example_virtual_storage_DSM.py b/examples/00_Minmal/minimal_example_virtual_storage_DSM.py index fda799439..1d0ceb0d6 100644 --- a/examples/00_Minmal/minimal_example_virtual_storage_DSM.py +++ b/examples/00_Minmal/minimal_example_virtual_storage_DSM.py @@ -54,7 +54,6 @@ maximum_relative_virtual_discharging_rate = -0.2, #relative_loss_per_hour_positive_charge_state = 0.05, #relative_loss_per_hour_negative_charge_state = 0.05, - initial_charge_state = 'lastValueOfSim', #penalty_costs_positive_charge_states=0, #penalty_costs_negative_charge_states=0.01, allow_mixed_charge_states=False, diff --git a/flixopt/components.py b/flixopt/components.py index 816438071..dab3cc88b 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -520,12 +520,12 @@ def _initial_and_final_charge_state(self): name = f'{self.label_full}|{name_short}' if utils.is_number(self.element.initial_charge_state): - self.add( - self._model.add_constraints( - self.charge_state.isel(time=0) == self.element.initial_charge_state, name=name - ), - name_short, - ) + self.add( + self._model.add_constraints( + self.charge_state.isel(time=0) == self.element.initial_charge_state, name=name + ), + name_short, + ) elif self.element.initial_charge_state == 'lastValueOfSim': self.add( self._model.add_constraints( @@ -539,8 +539,8 @@ def _initial_and_final_charge_state(self): ) if self.element.maximal_final_charge_state is not None: - self.add( - self._model.add_constraints( + self.add( + self._model.add_constraints( self.charge_state.isel(time=-1) <= self.element.maximal_final_charge_state, name=f'{self.label_full}|final_charge_max', ), @@ -554,7 +554,7 @@ def _initial_and_final_charge_state(self): name=f'{self.label_full}|final_charge_min', ), 'final_charge_min', - ) + ) @property def absolute_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: @@ -656,9 +656,6 @@ def __init__( maximum_relative_virtual_discharging_rate: NumericData = -1, relative_minimum_charge_state: NumericData = -1, relative_maximum_charge_state: NumericData = 1, - initial_charge_state: Union[Scalar, Literal['lastValueOfSim']] = 0, - minimal_final_charge_state: Optional[Scalar] = None, - maximal_final_charge_state: Optional[Scalar] = None, relative_loss_per_hour_positive_charge_state: NumericData = 0, relative_loss_per_hour_negative_charge_state: NumericData = 0, allow_mixed_charge_states: bool = False, @@ -679,9 +676,6 @@ def __init__( maximum_relative_virtual_discharging_rate: maximum flow rate relative to the capacity of the virtual storage at which discharging is possible. The default is -1. relative_minimum_charge_state: minimum relative charge state. The default is -1. relative_maximum_charge_state: maximum relative charge state. The default is 1. - initial_charge_state: virtual storage charge_state at the beginning. The default is 0. - minimal_final_charge_state: minimal value of charge state at the end of timeseries. - maximal_final_charge_state: maximal value of charge state at the end of timeseries. relative_loss_per_hour_positive_charge_state: loss per chargeState-Unit per hour for positive charge states of the virtual storage. The default is 0. relative_loss_per_hour_negative_charge_state: loss per chargeState-Unit per hour for negative charge states of the virtual storage. The default is 0. allow_mixed_charge_states: If True, positive and negative charge states can occur simultaneously. @@ -707,10 +701,6 @@ def __init__( self.relative_minimum_charge_state: NumericDataTS = relative_minimum_charge_state self.relative_maximum_charge_state: NumericDataTS = relative_maximum_charge_state - self.initial_charge_state = initial_charge_state - self.minimal_final_charge_state = minimal_final_charge_state - self.maximal_final_charge_state = maximal_final_charge_state - self.relative_loss_per_hour_positive_charge_state: NumericDataTS = relative_loss_per_hour_positive_charge_state self.relative_loss_per_hour_negative_charge_state: NumericDataTS = relative_loss_per_hour_negative_charge_state self.allow_mixed_charge_states = allow_mixed_charge_states @@ -776,27 +766,6 @@ def _plausibility_checks(self, model): Check for infeasible or uncommon combinations of parameters """ super()._plausibility_checks() - if utils.is_number(self.initial_charge_state): - maximum_capacity = self.virtual_capacity_in_flow_hours - minimum_capacity = self.virtual_capacity_in_flow_hours - - # initial capacity >= allowed min for maximum_size: - minimum_inital_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=1) - # initial capacity <= allowed max for minimum_size: - maximum_inital_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=1) - - if self.initial_charge_state > maximum_inital_capacity: - raise ValueError( - f'{self.label_full}: {self.initial_charge_state=} ' - f'is above allowed maximum charge_state {maximum_inital_capacity}' - ) - if self.initial_charge_state < minimum_inital_capacity: - raise ValueError( - f'{self.label_full}: {self.initial_charge_state=} ' - f'is below allowed minimum charge_state {minimum_inital_capacity}' - ) - elif self.initial_charge_state != 'lastValueOfSim': - raise ValueError(f'{self.label_full}: {self.initial_charge_state=} has an invalid value') hours_per_step = model.hours_per_step @@ -825,7 +794,7 @@ def _plausibility_checks(self, model): #TODO: think about other implausibilities #INFO: investments not implemented - + class DSMSinkModel(ComponentModel): """Model of DSM Sink""" @@ -1109,50 +1078,24 @@ def _add_timeshift_limits(self): #TODO: handle clash with initial charge state def _initial_and_final_charge_state(self): - """Add constraints for initial and final charge states""" - if self.element.initial_charge_state is not None: - name_short = 'initial_charge_state' - name = f'{self.label_full}|{name_short}' - - if utils.is_number(self.element.initial_charge_state): - self.add( - self._model.add_constraints( - self.positive_charge_state.isel(time=0) + self.negative_charge_state.isel(time=0) == self.element.initial_charge_state, - name=name - ), - name_short, - ) - elif self.element.initial_charge_state == 'lastValueOfSim': - self.add( - self._model.add_constraints( - self.positive_charge_state.isel(time=0) + self.negative_charge_state.isel(time=0) - == self.positive_charge_state.isel(time=-1) + self.negative_charge_state.isel(time=-1), - name=name - ), - name_short, - ) - else: - raise PlausibilityError( - f'initial_charge_state has undefined value: {self.element.initial_charge_state}' - ) - - if self.element.maximal_final_charge_state is not None: - self.add( - self._model.add_constraints( - self.positive_charge_state.isel(time=-1) + self.negative_charge_state.isel(time=-1) <= self.element.maximal_final_charge_state, - name=f'{self.label_full}|final_charge_max', - ), - 'final_charge_max', - ) + """Add constraints for initial and final charge states to be zero""" + # Set initial charge state to zero + self.add( + self._model.add_constraints( + self.positive_charge_state.isel(time=0) + self.negative_charge_state.isel(time=0) == 0, + name=f'{self.label_full}|initial_charge_state' + ), + 'initial_charge_state' + ) - if self.element.minimal_final_charge_state is not None: - self.add( - self._model.add_constraints( - self.positive_charge_state.isel(time=-1) + self.negative_charge_state.isel(time=-1) >= self.element.minimal_final_charge_state, - name=f'{self.label_full}|final_charge_min', - ), - 'final_charge_min', - ) + # Set final charge state to zero + self.add( + self._model.add_constraints( + self.positive_charge_state.isel(time=-1) + self.negative_charge_state.isel(time=-1) == 0, + name=f'{self.label_full}|final_charge_state' + ), + 'final_charge_state' + ) @property def absolute_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: @@ -1285,7 +1228,7 @@ def _plausibility_checks(self, model: SystemModel): Check for infeasible or uncommon combinations of parameters """ super()._plausibility_checks() - + if any(self.maximum_flow_deficit_per_hour >= 0): raise ValueError( f'{self.label_full}: {self.maximum_flow_deficit_per_hour=} ' From 3998e5942e97ef1750d88bf5b8e5e8e3580dd3f3 Mon Sep 17 00:00:00 2001 From: fkeller Date: Wed, 11 Jun 2025 14:50:15 +0200 Subject: [PATCH 23/33] added native diagrams for dsm sink class --- flixopt/results.py | 133 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/flixopt/results.py b/flixopt/results.py index d9eb5a654..eb7df45c3 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -9,6 +9,7 @@ import numpy as np import pandas as pd import plotly +import plotly.express import xarray as xr import yaml @@ -531,6 +532,17 @@ class ComponentResults(_NodeResults): def is_storage(self) -> bool: return self._charge_state in self._variable_names + @property + def is_DSM_sink(self) -> bool: + """Check if this component is a DSM sink by looking for characteristic variables""" + return ( + f'{self.label}|positive_charge_state' in self._variable_names + and f'{self.label}|negative_charge_state' in self._variable_names + and f'{self.label}|positive_charge_rate' in self._variable_names + and f'{self.label}|negative_charge_rate' in self._variable_names + and f'{self.label}|resulting_load_profile' in self._constraint_names + ) + @property def _charge_state(self) -> str: return f'{self.label}|charge_state' @@ -542,6 +554,127 @@ def charge_state(self) -> xr.DataArray: raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage') return self.solution[self._charge_state] + def plot_DSM_sink( + self, + save: Union[bool, pathlib.Path] = False, + show: bool = True, + colors: plotting.ColorType = 'viridis', + engine: plotting.PlottingEngine = 'plotly', + ) -> plotly.graph_objs.Figure: + """ + Plots the operation of a DSM sink, showing the resulting heat load, initial demand, surplus/deficit, and cumulated flow deviation. + + Args: + save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location. + show: Whether to show the plot or not. + colors: The colors to use for the plot. + engine: Plotting engine to use. Only 'plotly' is implemented atm. + + Raises: + ValueError: If the Component is not a DSM sink. + """ + if engine != 'plotly': + raise NotImplementedError( + f'Plotting engine "{engine}" not implemented for ComponentResults.plot_DSM_sink.' + ) + + if not self.is_DSM_sink: + raise ValueError(f'Cannot plot DSM sink. "{self.label}" is not a DSM sink') + + # Get the node balance and the initial demand + node_balance = - self.node_balance(with_last_timestep=True).to_dataframe() + initial_demand = self._calculation_results.flow_system[f'{self.label}|initial_demand'].to_dataframe(name='initial_demand') + + # Get surplus and deficit from the solution + surplus = self.solution[f'{self.label}|positive_charge_rate'].to_dataframe() + deficit = self.solution[f'{self.label}|negative_charge_rate'].to_dataframe() + + # Substract surplus from node blance + node_balance[f'{self.inputs[0]}'] = node_balance[f'{self.inputs[0]}'].values.flatten() - surplus[f'{self.label}|positive_charge_rate'].values.flatten() + + # Merge dataframes into one + data = pd.concat([node_balance, surplus, deficit], axis='columns') + + # Create figure with area plot for node balance + fig = plotting.with_plotly( + data, + mode='area', + colors=colors, + title=f'District Heating Node Balance with DSM Surplus/Deficit for {self.label}', + ylabel='Power [kW]', + xlabel='Time' + ) + + # Add initial demand + fig.add_trace( + plotly.graph_objs.Scatter( + x=initial_demand.index, + y=initial_demand['initial_demand'], + name='Initial Demand', + line=dict(dash='dash', color='black', shape='hv'), # 'hv' for horizontal-vertical steps + mode='lines' + ) + ) + + # Get colors for the cumulated flow + color_samples = plotly.express.colors.sample_colorscale(colors, 8) + cumulated_color = color_samples[3] # Use another color from viridis for cumulated flow + + # Get cumulated flow deviation from the model + cumulated_flow = pd.DataFrame(0, index=surplus.index, columns=['cumulated_flow']) + cumulated_flow['cumulated_flow'] = ( + self.solution[f'{self.label}|positive_charge_state'].values.flatten() + + self.solution[f'{self.label}|negative_charge_state'].values.flatten() + ) + + # Add cumulated flow deviation as diamonds on secondary y-axis + fig.add_trace( + plotly.graph_objs.Scatter( + x=cumulated_flow.index, + y=cumulated_flow.values.flatten(), + name='Cumulated Flow Deviation', + mode='markers', + marker=dict( + color=cumulated_color, + size=10, + symbol='diamond', + line=dict(width=1, color='black') + ), + yaxis='y2' + ) + ) + + # Update layout to include secondary y-axis and scale both y-axis appropriately + fig.update_layout( + hovermode='x unified', + yaxis=dict( + range=[ + -1.2*max(0, node_balance.max().max(), -cumulated_flow.min().min(), -deficit.min().min()), + 1.2*max(node_balance.max().max(), surplus.max().max(), cumulated_flow.max().max(), initial_demand.max().max()) + ], + showgrid=True + ), + yaxis2=dict( + title='Cumulated Flow [kWh]', + overlaying='y', + side='right', + showgrid=False, + range=[ + -1.2*max(0, node_balance.max().max(), -cumulated_flow.min().min(), -deficit.min().min()), + 1.2*max(node_balance.max().max(), surplus.max().max(), cumulated_flow.max().max(), initial_demand.max().max()) + ] + ) + ) + + return plotting.export_figure( + fig, + default_path=self._calculation_results.folder / f'{self.label} (DSM sink)', + default_filetype='.html', + user_path=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False, + ) + def plot_charge_state( self, save: Union[bool, pathlib.Path] = False, From a714a78c01b05232f6600ce6c6ec511bbd3ab44a Mon Sep 17 00:00:00 2001 From: fkeller Date: Wed, 11 Jun 2025 14:50:59 +0200 Subject: [PATCH 24/33] changed DSM Sink class to use parameters for absolute bounds instead of relative bounds --- flixopt/components.py | 127 +++++++++++++++++++++--------------------- 1 file changed, 65 insertions(+), 62 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index dab3cc88b..25e6183a8 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -641,7 +641,6 @@ def __init__(self, label: str, sink: Flow, meta_data: Optional[Dict] = None): class DSMSink(Sink): """ Used to model sinks with the ability to perform demand side management. - In this class DSM is modeled via a virtual storage. """ def __init__( @@ -649,13 +648,12 @@ def __init__( label: str, sink: Flow, initial_demand: NumericData, - virtual_capacity_in_flow_hours: Scalar, + maximum_virtual_charging_rate: NumericData, + maximum_virtual_discharging_rate: NumericData, + minimum_virtual_charge_state: NumericData, + maximum_virtual_charge_state: NumericData, forward_timeshift: Scalar = None, backward_timeshift: Scalar = None, - maximum_relative_virtual_charging_rate: NumericData = 1, - maximum_relative_virtual_discharging_rate: NumericData = -1, - relative_minimum_charge_state: NumericData = -1, - relative_maximum_charge_state: NumericData = 1, relative_loss_per_hour_positive_charge_state: NumericData = 0, relative_loss_per_hour_negative_charge_state: NumericData = 0, allow_mixed_charge_states: bool = False, @@ -669,13 +667,12 @@ def __init__( label: The label of the Element. Used to identify it in the FlowSystem sink: input-flow of DSM sink after DSM initial_demand: initial demand of DSM sink before DSM - virtual_capacity_in_flow_hours: nominal capacity of the virtual storage + maximum_virtual_charging_rate: maximum flow rate at which charging is possible + maximum_virtual_discharging_rate: maximum flow rate at which discharging is possible. + minimum_virtual_charge_state: minimum charge state. + maximum_virtual_charge_state: maximum charge state. forward_timeshift: Maximum number of hours by which the demand can be shifted forward in time. Default is infinite. backward_timeshift: Maximum number of hours by which the demand can be shifted backward in time. Default is infinite. - maximum_relative_virtual_charging_rate: maximum flow rate relative to the capacity of the virtual storage at which charging is possible. The default is 1. - maximum_relative_virtual_discharging_rate: maximum flow rate relative to the capacity of the virtual storage at which discharging is possible. The default is -1. - relative_minimum_charge_state: minimum relative charge state. The default is -1. - relative_maximum_charge_state: maximum relative charge state. The default is 1. relative_loss_per_hour_positive_charge_state: loss per chargeState-Unit per hour for positive charge states of the virtual storage. The default is 0. relative_loss_per_hour_negative_charge_state: loss per chargeState-Unit per hour for negative charge states of the virtual storage. The default is 0. allow_mixed_charge_states: If True, positive and negative charge states can occur simultaneously. @@ -693,13 +690,12 @@ def __init__( ) self.initial_demand: NumericDataTS = initial_demand - self.virtual_capacity_in_flow_hours = virtual_capacity_in_flow_hours self.forward_timeshift = forward_timeshift self.backward_timeshift = backward_timeshift - self.maximum_relative_virtual_charging_rate: NumericDataTS = maximum_relative_virtual_charging_rate - self.maximum_relative_virtual_discharging_rate: NumericDataTS = maximum_relative_virtual_discharging_rate - self.relative_minimum_charge_state: NumericDataTS = relative_minimum_charge_state - self.relative_maximum_charge_state: NumericDataTS = relative_maximum_charge_state + self.maximum_virtual_charging_rate: NumericDataTS = maximum_virtual_charging_rate + self.maximum_virtual_discharging_rate: NumericDataTS = maximum_virtual_discharging_rate + self.minimum_virtual_charge_state: NumericDataTS = minimum_virtual_charge_state + self.maximum_virtual_charge_state: NumericDataTS = maximum_virtual_charge_state self.relative_loss_per_hour_positive_charge_state: NumericDataTS = relative_loss_per_hour_positive_charge_state self.relative_loss_per_hour_negative_charge_state: NumericDataTS = relative_loss_per_hour_negative_charge_state @@ -726,22 +722,22 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: f'{self.label_full}|initial_demand', self.initial_demand, ) - self.maximum_relative_virtual_charging_rate = flow_system.create_time_series( - f'{self.label_full}|maximum_relative_virtual_charging_rate', - self.maximum_relative_virtual_charging_rate, + self.maximum_virtual_charging_rate = flow_system.create_time_series( + f'{self.label_full}|maximum_virtual_charging_rate', + self.maximum_virtual_charging_rate, ) - self.maximum_relative_virtual_discharging_rate = flow_system.create_time_series( - f'{self.label_full}|maximum_relative_virtual_discharging_rate', - self.maximum_relative_virtual_discharging_rate, + self.maximum_virtual_discharging_rate = flow_system.create_time_series( + f'{self.label_full}|maximum_virtual_discharging_rate', + self.maximum_virtual_discharging_rate, ) - self.relative_minimum_charge_state = flow_system.create_time_series( - f'{self.label_full}|relative_minimum_charge_state', - self.relative_minimum_charge_state, + self.minimum_virtual_charge_state = flow_system.create_time_series( + f'{self.label_full}|minimum_virtual_charge_state', + self.minimum_virtual_charge_state, needs_extra_timestep=True, ) - self.relative_maximum_charge_state = flow_system.create_time_series( - f'{self.label_full}|relative_maximum_charge_state', - self.relative_maximum_charge_state, + self.maximum_virtual_charge_state = flow_system.create_time_series( + f'{self.label_full}|maximum_virtual_charge_state', + self.maximum_virtual_charge_state, needs_extra_timestep=True, ) self.relative_loss_per_hour_negative_charge_state = flow_system.create_time_series( @@ -761,7 +757,7 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: self.penalty_costs_positive_charge_states, ) - def _plausibility_checks(self, model): + def _plausibility_checks(self, model: SystemModel): """ Check for infeasible or uncommon combinations of parameters """ @@ -773,7 +769,7 @@ def _plausibility_checks(self, model): if any(hours_per_step.values[0]!=hours_per_step.values): raise ValueError( f'{self.label_full}:' - f'DSMSinkTS class can only be used for timesteps of equal length' + f'limits to forward and backward timeshifts can only be used for timesteps of equal length' ) if self.forward_timeshift is not None: @@ -789,9 +785,7 @@ def _plausibility_checks(self, model): f'{self.label_full}: {self.backward_timeshift=} ' f'must be a multiple of the timestep length.' ) - - #TODO: think about other implausibilities #INFO: investments not implemented @@ -818,7 +812,7 @@ def do_modeling(self): super().do_modeling() # Add variables for positive and negative charge rates - lb, ub = 0, self.absolute_charge_rate_bounds[1] + lb, ub = self.charge_rate_bounds self.positive_charge_rate = self.add( self._model.add_variables( lower=lb, upper=ub, coords=self._model.coords, name=f'{self.label_full}|positive_charge_rate' @@ -826,7 +820,7 @@ def do_modeling(self): 'positive_charge_rate', ) - lb, ub = self.absolute_charge_rate_bounds[0], 0 + lb, ub = self.discharge_rate_bounds self.negative_charge_rate = self.add( self._model.add_variables( lower=lb, upper=ub, coords=self._model.coords, name=f'{self.label_full}|negative_charge_rate' @@ -835,7 +829,7 @@ def do_modeling(self): ) # Add variables for negative charge states - lb, ub = self.absolute_charge_state_bounds[0], 0 + lb, ub = self.negative_charge_state_bounds self.negative_charge_state = self.add( self._model.add_variables( lower=lb, upper=ub, coords=self._model.coords_extra, name=f'{self.label_full}|negative_charge_state' @@ -844,7 +838,7 @@ def do_modeling(self): ) # Add variables for positive charge states - lb, ub = 0, self.absolute_charge_state_bounds[1] + lb, ub = self.positive_charge_state_bounds self.positive_charge_state = self.add( self._model.add_variables( lower=lb, upper=ub, coords=self._model.coords_extra, name=f'{self.label_full}|positive_charge_state' @@ -956,7 +950,7 @@ def _add_charge_state_exclusivity_constraints(self): # If positive_charge_state > 0, then is_positive_charge_state must be 1 self.add( self._model.add_constraints( - positive_charge_state <= self.absolute_charge_state_bounds[1] * self.is_positive_charge_state, + positive_charge_state <= self.positive_charge_state_bounds[1] * self.is_positive_charge_state, name=f'{self.label_full}|positive_charge_state_binary_upper' ), 'positive_charge_state_binary_upper' @@ -965,7 +959,7 @@ def _add_charge_state_exclusivity_constraints(self): # If is_positive_charge_state is 1, then positive_charge_state must be > 0 self.add( self._model.add_constraints( - positive_charge_state >= CONFIG.modeling.EPSILON * self.absolute_charge_state_bounds[1] * self.is_positive_charge_state, # Small epsilon to avoid numerical issues + positive_charge_state >= CONFIG.modeling.EPSILON * self.positive_charge_state_bounds[1] * self.is_positive_charge_state, # Small epsilon to avoid numerical issues name=f'{self.label_full}|positive_charge_state_binary_lower' ), 'positive_charge_state_binary_lower' @@ -974,7 +968,7 @@ def _add_charge_state_exclusivity_constraints(self): # If negative_charge_state < 0, then is_negative_charge_state must be 1 self.add( self._model.add_constraints( - negative_charge_state >= self.absolute_charge_state_bounds[0] * self.is_negative_charge_state, + negative_charge_state >= self.negative_charge_state_bounds[0] * self.is_negative_charge_state, name=f'{self.label_full}|negative_charge_state_binary_upper' ), 'negative_charge_state_binary_upper' @@ -983,7 +977,7 @@ def _add_charge_state_exclusivity_constraints(self): # If is_negative_charge_state is 1, then negative_charge_state must be < 0 self.add( self._model.add_constraints( - negative_charge_state <= CONFIG.modeling.EPSILON * self.absolute_charge_state_bounds[0] * self.is_negative_charge_state, # Small epsilon to avoid numerical issues + negative_charge_state <= CONFIG.modeling.EPSILON * self.negative_charge_state_bounds[0] * self.is_negative_charge_state, # Small epsilon to avoid numerical issues name=f'{self.label_full}|negative_charge_state_binary_lower' ), 'negative_charge_state_binary_lower' @@ -1009,7 +1003,7 @@ def _add_charge_rate_exclusivity_constraints(self): model=self._model, label_of_element=f'{self.label_full}|positive_charge_rate', defining_variables=[self.positive_charge_rate], - defining_bounds=[(timeseries_zeros, self.absolute_charge_rate_bounds[1])], + defining_bounds=[(timeseries_zeros, self.charge_rate_bounds[1])], use_off=False ) ) @@ -1021,7 +1015,7 @@ def _add_charge_rate_exclusivity_constraints(self): model=self._model, label_of_element=f'{self.label_full}|negative_charge_rate', defining_variables=[-self.negative_charge_rate], # StateModel can only handle positive variables - defining_bounds=[(timeseries_zeros, -self.absolute_charge_rate_bounds[0])], + defining_bounds=[(timeseries_zeros, -self.discharge_rate_bounds[0])], use_off=False ) ) @@ -1049,10 +1043,16 @@ def _add_timeshift_limits(self): positive_charge_state = self.positive_charge_state negative_charge_state = self.negative_charge_state + # Add constraints limiting the forward timeshift if timesteps_forward is not None: surplus_sum = 0 for i in range(0, timesteps_forward): surplus_sum += self.positive_charge_rate.shift(time=i).isel(time=slice(i,None)) * hours_per_step * (etapos ** (hours_per_step * i)) + # eq: positive_charge_state(t) <= sum over n (positive_charge_rate(t-n) * hours_per_step * (etapos ^ (hours_per_step * i)) + # where n ranges from 0 to timesteps_forward + # The positive charge state can't be any higher than the charge that was added during the last x timesteps minus the losses + # x is defined by the timesteps_forward + # This forces the virtual storage to discharge after the maximum timesteps that the demand is allowed to be shifted forward self.add( self._model.add_constraints( positive_charge_state.isel(time = slice(1,None)) @@ -1062,14 +1062,20 @@ def _add_timeshift_limits(self): f'limit_forward_timeshift' ) + # Add constraints limiting the backwards timeshift if timesteps_backward is not None: deficit_sum = 0 for i in range(0, timesteps_backward): deficit_sum += self.negative_charge_rate.shift(time=i).isel(time=slice(i,None)) * hours_per_step * (etaneg ** (hours_per_step * i)) + # eq: -negative_charge_state(t) <= -sum over n (negative_charge_rate(t-n) * hours_per_step * (etaneg ^ (hours_per_step * i)) + # where n ranges from 0 to timesteps_backward + # The negative charge state can't be any higher than the deficit that was accumulated during the last x timesteps minus the losses. + # x is defined by the timesteps_backward. + # This forces the virtual storage to "recharge" (i. e. "discharge" the negative charge state) after the maximum timesteps that the demand is allowed to be shifted backward. self.add( self._model.add_constraints( - -negative_charge_state.isel(time=slice(1,None)) - <= -deficit_sum, + - negative_charge_state.isel(time=slice(1,None)) + <= - deficit_sum, name=f'{self.label_full}|limit_backward_timeshift' ), f'limit_backward_timeshift' @@ -1098,36 +1104,33 @@ def _initial_and_final_charge_state(self): ) @property - def absolute_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: - relative_lower_bound, relative_upper_bound = self.relative_charge_state_bounds + def positive_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: return ( - relative_lower_bound * self.element.virtual_capacity_in_flow_hours, - relative_upper_bound * self.element.virtual_capacity_in_flow_hours, + 0, + self.element.maximum_virtual_charge_state.active_data, ) - + @property - def relative_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: + def negative_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: return ( - self.element.relative_minimum_charge_state.active_data, - self.element.relative_maximum_charge_state.active_data, + self.element.minimum_virtual_charge_state.active_data, + 0, ) - + @property - def absolute_charge_rate_bounds(self) -> Tuple[NumericData, NumericData]: - relative_discharging_bound, relative_charging_bound = self.relative_charge_rate_bounds + def charge_rate_bounds(self) -> Tuple[NumericData, NumericData]: return( - relative_discharging_bound * self.element.virtual_capacity_in_flow_hours, - relative_charging_bound * self.element.virtual_capacity_in_flow_hours, + 0, + self.element.maximum_virtual_charging_rate.active_data, ) - + @property - def relative_charge_rate_bounds(self) -> Tuple[NumericData, NumericData]: + def discharge_rate_bounds(self) -> Tuple[NumericData, NumericData]: return( - self.element.maximum_relative_virtual_discharging_rate.active_data, - self.element.maximum_relative_virtual_charging_rate.active_data, + self.element.maximum_virtual_discharging_rate.active_data, + 0, ) - @register_class_for_io class DSMSinkTS(Sink): """ From 75757d0e855d2130cbf3a7da5d8f9c704f117563 Mon Sep 17 00:00:00 2001 From: fkeller Date: Wed, 11 Jun 2025 15:14:13 +0200 Subject: [PATCH 25/33] removed DSMSinkTS class because all the functionalities are now modeled with the unified DSMSink class --- ..._storage_DSM.py => minimal_example_DSM.py} | 100 +--- .../minimal_example_timeshift_DSM.py | 220 --------- flixopt/__init__.py | 1 - flixopt/commons.py | 2 - flixopt/components.py | 444 +----------------- 5 files changed, 25 insertions(+), 742 deletions(-) rename examples/00_Minmal/{minimal_example_virtual_storage_DSM.py => minimal_example_DSM.py} (54%) delete mode 100644 examples/00_Minmal/minimal_example_timeshift_DSM.py diff --git a/examples/00_Minmal/minimal_example_virtual_storage_DSM.py b/examples/00_Minmal/minimal_example_DSM.py similarity index 54% rename from examples/00_Minmal/minimal_example_virtual_storage_DSM.py rename to examples/00_Minmal/minimal_example_DSM.py index 1d0ceb0d6..9fd9deb0d 100644 --- a/examples/00_Minmal/minimal_example_virtual_storage_DSM.py +++ b/examples/00_Minmal/minimal_example_DSM.py @@ -49,14 +49,14 @@ 'DSM Sink Heat Demand', sink=fx.Flow(label='Heat Load', bus='District Heating', size=150), initial_demand=thermal_load_profile, - virtual_capacity_in_flow_hours=100, - maximum_relative_virtual_charging_rate = 0.2, - maximum_relative_virtual_discharging_rate = -0.2, - #relative_loss_per_hour_positive_charge_state = 0.05, - #relative_loss_per_hour_negative_charge_state = 0.05, - #penalty_costs_positive_charge_states=0, - #penalty_costs_negative_charge_states=0.01, - allow_mixed_charge_states=False, + minimum_virtual_charge_state = -50, + maximum_virtual_charge_state = 50, + maximum_virtual_discharge_rate = -20, + maximum_virtual_charge_rate = 20, + relative_loss_per_hour_positive_charge_state = 0.05, + relative_loss_per_hour_negative_charge_state = 0.05, + penalty_costs_positive_charge_states=0, + penalty_costs_negative_charge_states=0.01, forward_timeshift = 3, backward_timeshift = 3 ) @@ -81,90 +81,10 @@ # Access the results of an element df1 = calculation.results['costs'].filter_solution('time').to_dataframe() - # Create a custom plot showing node balance, initial demand and charge states - import plotly.graph_objects as go - from plotly.subplots import make_subplots - from flixopt import plotting - - # Get the data - node_balance = calculation.results['District Heating'].node_balance(with_last_timestep=True).to_dataframe() - dsm_results = calculation.results['DSM Sink Heat Demand'] - - # Get initial demand from the flow system's time series collection - initial_demand = calculation.flow_system.time_series_collection.time_series_data[f'{dsm_results.label}|initial_demand'].active_data.to_dataframe(name='initial_demand') - - # Get charge states from the solution - positive_charge = dsm_results.solution[f'{dsm_results.label}|positive_charge_state'].to_dataframe() - negative_charge = dsm_results.solution[f'{dsm_results.label}|negative_charge_state'].to_dataframe() - - # Create figure with secondary y-axis using the same style as node balance - fig = plotting.with_plotly( - node_balance, - mode='area', - colors='viridis', - title='District Heating Node Balance with DSM Charge States', - ylabel='Power [kW]', - xlabel='Time' - ) - - # Get colors from viridis for the charge states - import plotly.express as px - # Use a more muted color scale - viridis_colors = px.colors.sample_colorscale('viridis', 4) - positive_color = viridis_colors[1] # Use a blue-ish color from viridis - negative_color = viridis_colors[0] # Use a violette-ish color from viridis - - # Add initial demand with step lines (no interpolation) - fig.add_trace( - go.Scatter( - x=initial_demand.index, - y=initial_demand['initial_demand'], - name='Initial Demand', - line=dict(dash='dash', color='black', shape='hv'), # 'hv' for horizontal-vertical steps - mode='lines' - ) - ) - - # Add charge states as bars on secondary y-axis with reduced opacity - fig.add_trace( - go.Bar( - x=positive_charge.index, - y=positive_charge.values.flatten(), - name='Positive Charge State', - marker=dict(color=positive_color, opacity=0.7), # Add opacity for less saturation - yaxis='y2' - ) - ) - - fig.add_trace( - go.Bar( - x=negative_charge.index, - y=negative_charge.values.flatten(), - name='Negative Charge State', - marker=dict(color=negative_color, opacity=0.7), # Add opacity for less saturation - yaxis='y2' - ) - ) - - # Update layout for secondary y-axis and bar styling - fig.update_layout( - yaxis2=dict( - title='Charge State [kWh]', - overlaying='y', - side='right', - showgrid=False - ), - hovermode='x unified', - bargap=0, # No gap between bars - bargroupgap=0 # No gap between bar groups - ) - - # Show the plot - fig.show() - # Original plots #calculation.results['District Heating'].plot_node_balance_pie() - #calculation.results['District Heating'].plot_node_balance() + calculation.results['District Heating'].plot_node_balance() + calculation.results['DSM Sink Heat Demand'].plot_DSM_sink() # Save the DSM Sink Heat Demand solution dataset to a CSV file calculation.results['DSM Sink Heat Demand'].solution.to_dataframe().to_csv('results/DSM_Sink_Heat_Demand_results.csv') diff --git a/examples/00_Minmal/minimal_example_timeshift_DSM.py b/examples/00_Minmal/minimal_example_timeshift_DSM.py deleted file mode 100644 index d4b1fb046..000000000 --- a/examples/00_Minmal/minimal_example_timeshift_DSM.py +++ /dev/null @@ -1,220 +0,0 @@ -""" -This script shows how to use the flixopt framework to model a super minimalistic energy system. -""" - -import numpy as np -import pandas as pd -from rich.pretty import pprint - -import sys -sys.path.append("C:/Florian/Studium/RES/2025SoSe/Studienarbeit/code/flixopt") -import flixopt as fx - -if __name__ == '__main__': - # --- Define the Flow System, that will hold all elements, and the time steps you want to model --- - timesteps = pd.date_range('2020-01-01', periods=24, freq='h') - flow_system = fx.FlowSystem(timesteps) - - # --- Define Thermal Load Profile --- - # Load profile (e.g., kW) for heating demand over time - thermal_load_profile = np.array([80, 80, 80, 80, 80, 80, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 80, 80, 80, 80, 80, 80]) - #thermal_load_profile = np.array([100, 100, 100, 100, 100, 100, 120, 120, 120, 100, 100, 100, 100, 100, 100, 80, 80, 80, 100, 100, 100, 100, 100, 100]) - #thermal_load_profile = np.array([80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80]) - - # --- Define Energy Buses --- - # These are balancing nodes (inputs=outputs) and balance the different energy carriers your system - flow_system.add_elements(fx.Bus('District Heating'), fx.Bus('Natural Gas')) - - # --- Define Objective Effect (Cost) --- - # Cost effect representing the optimization objective (minimizing costs) - cost_effect = fx.Effect('costs', '€', 'Cost', is_standard=True, is_objective=True) - - # --- Define Flow System Components --- - # Boiler component with thermal output (heat) and fuel input (gas) - boiler1 = fx.linear_converters.Boiler( - 'Boiler1', - eta=0.5, - Q_th=fx.Flow(label='Thermal Output', bus='District Heating', size=100), - Q_fu=fx.Flow(label='Fuel Input', bus='Natural Gas'), - ) - boiler2 = fx.linear_converters.Boiler( - 'Boiler2', - eta=1/3, - Q_th=fx.Flow(label='Thermal Output', bus='District Heating', size=50), - Q_fu=fx.Flow(label='Fuel Input', bus='Natural Gas'), - ) - - # Heat load component with a fixed thermal demand profile - heat_load = fx.DSMSinkTS( - 'DSM Sink Heat Demand', - sink=fx.Flow(label='Heat Load', bus='District Heating', size=150), - initial_demand=thermal_load_profile, - forward_timeshift=3, - backward_timeshift=3, - maximum_flow_surplus_per_hour=20, - maximum_flow_deficit_per_hour=-20, - ) - - # Gas source component with cost-effect per flow hour - gas_source = fx.Source( - 'Natural Gas Tariff', - source=fx.Flow(label='Gas Flow', bus='Natural Gas', size=1000, effects_per_flow_hour=0.04), # 0.04 €/kWh - ) - - # --- Build the Flow System --- - # Add all components and effects to the system - flow_system.add_elements(cost_effect, boiler1, boiler2, heat_load, gas_source) - - # --- Define, model and solve a Calculation --- - calculation = fx.FullCalculation('Simulation1', flow_system) - calculation.do_modeling() - #calculation.solve(fx.solvers.HighsSolver(0.01, 60)) - calculation.solve(fx.solvers.GurobiSolver(0.01, 60)) - - # --- Analyze Results --- - # Access the results of an element - df1 = calculation.results['costs'].filter_solution('time').to_dataframe() - - # Create a custom plot showing node balance, initial demand and surplus/deficit - import plotly.graph_objects as go - from plotly.subplots import make_subplots - from flixopt import plotting - - # Get the data - node_balance = calculation.results['District Heating'].node_balance(with_last_timestep=True).to_dataframe() - dsm_results = calculation.results['DSM Sink Heat Demand'] - - # Get initial demand from the flow system's time series collection - initial_demand = calculation.flow_system.time_series_collection.time_series_data[f'{dsm_results.label}|initial_demand'].active_data.to_dataframe(name='initial_demand') - - # Get surplus and deficit from the solution - surplus = dsm_results.solution[f'{dsm_results.label}|surplus'].to_dataframe() - - # Get the number of timesteps from the component's model - timesteps_backward = calculation.flow_system.components['DSM Sink Heat Demand'].timesteps_backward - timesteps_forward = calculation.flow_system.components['DSM Sink Heat Demand'].timesteps_forward - - # For timeshift DSM, deficit is split into pre and post timesteps - # Initialize deficit DataFrames with zeros - deficit_pre = pd.DataFrame(0, index=surplus.index, columns=['deficit_pre']) - deficit_post = pd.DataFrame(0, index=surplus.index, columns=['deficit_post']) - - # Sum up all pre and post deficits - for i in range(1, timesteps_backward + 1): - pre_df = dsm_results.solution[f'{dsm_results.label}|deficit_pre_{i}'].to_dataframe() - deficit_pre['deficit_pre'] += pre_df.values.flatten() - - for i in range(1, timesteps_forward + 1): - post_df = dsm_results.solution[f'{dsm_results.label}|deficit_post_{i}'].to_dataframe() - deficit_post['deficit_post'] += post_df.values.flatten() - - # Combine deficits - deficit = pd.DataFrame(0, index=surplus.index, columns=['deficit']) - deficit['deficit'] = deficit_pre['deficit_pre'] + deficit_post['deficit_post'] - - # Create figure with area plot for node balance - fig = plotting.with_plotly( - node_balance, - mode='area', - colors='viridis', - title='District Heating Node Balance with DSM Surplus/Deficit', - ylabel='Power [kW]', - xlabel='Time' - ) - - # Get colors from viridis for the surplus/deficit - import plotly.express as px - viridis_colors = px.colors.sample_colorscale('viridis', 8) - surplus_color = viridis_colors[4] # Use a blue-ish color from viridis - deficit_color = viridis_colors[2] # Use a violette-ish color from viridis - cumulated_color = viridis_colors[3] # Use another color from viridis for cumulated flow - - # Add initial demand with step lines (no interpolation) - fig.add_trace( - go.Scatter( - x=initial_demand.index, - y=initial_demand['initial_demand'], - name='Initial Demand', - line=dict(dash='dash', color='black', shape='hv'), # 'hv' for horizontal-vertical steps - mode='lines' - ) - ) - - # Add surplus and deficit as area plots with similar style to node balance - fig.add_trace( - go.Scatter( - x=surplus.index, - y=surplus.values.flatten(), - name='Surplus', - fill='tonexty', # Fill to the next trace - line=dict(color=surplus_color, width=1, shape='hv'), # Thin line for the area border - mode='lines', - stackgroup='one' # Stack with other traces in the same group - ) - ) - - fig.add_trace( - go.Scatter( - x=deficit.index, - y=deficit['deficit'], - name='Deficit', - fill='tonexty', # Fill to the next trace - line=dict(color=deficit_color, width=1, shape='hv'), # Thin line for the area border - mode='lines', - stackgroup='two' # Stack with other traces in the same group - ) - ) - - # Get cumulated flow deviation from the model - cumulated_flow = dsm_results.solution[f'{dsm_results.label}|cumulated_flow_deviation'].to_dataframe() - - # Add cumulated flow deviation as diamonds on secondary y-axis - fig.add_trace( - go.Scatter( - x=cumulated_flow.index, - y=cumulated_flow.values.flatten(), - name='Cumulated Flow Deviation', - mode='markers', - marker=dict( - color=cumulated_color, - size=10, - symbol='diamond', - line=dict(width=1, color='black') - ), - yaxis='y2' - ) - ) - - # Update layout to include secondary y-axis - fig.update_layout( - hovermode='x unified', - yaxis=dict( - range=[-1.2*max(0, node_balance.max().max(), -cumulated_flow.min().min(), -deficit.min().min()), 1.2*max(node_balance.max().max(), surplus.max().max(), cumulated_flow.max().max(), initial_demand.max().max())], - showgrid=True - ), - yaxis2=dict( - title='Cumulated Flow [kWh]', - overlaying='y', - side='right', - showgrid=False, - range=[-1.2*max(0, node_balance.max().max(), -cumulated_flow.min().min(), -deficit.min().min()), 1.2*max(node_balance.max().max(), surplus.max().max(), cumulated_flow.max().max(), initial_demand.max().max())] - ) - ) - - # Show the plot - fig.show() - - # Original plots - #calculation.results['District Heating'].plot_node_balance_pie() - #calculation.results['District Heating'].plot_node_balance() - - # Save the DSM Sink Heat Demand solution dataset to a CSV file - calculation.results['DSM Sink Heat Demand'].solution.to_dataframe().to_csv('results/DSM_Sink_Heat_Demand_results.csv') - calculation.results.solution.to_dask_dataframe().to_csv('results/results.csv') - - # Save results to a file - df2 = calculation.results['District Heating'].node_balance().to_dataframe() - #df2.to_csv('results/District Heating.csv') # Save results to csv - - # Print infos to the console. - pprint(calculation.summary) diff --git a/flixopt/__init__.py b/flixopt/__init__.py index 9db23e791..a647a3982 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -21,7 +21,6 @@ SegmentedCalculation, Sink, DSMSink, - DSMSinkTS, Source, SourceAndSink, Storage, diff --git a/flixopt/commons.py b/flixopt/commons.py index 8be5ba692..d1e0801a0 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -9,7 +9,6 @@ LinearConverter, Sink, DSMSink, - DSMSinkTS, Source, SourceAndSink, Storage, @@ -32,7 +31,6 @@ 'Source', 'Sink', 'DSMSink', - 'DSMSinkTS', 'SourceAndSink', 'Storage', 'LinearConverter', diff --git a/flixopt/components.py b/flixopt/components.py index 25e6183a8..00f2aae95 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -648,8 +648,8 @@ def __init__( label: str, sink: Flow, initial_demand: NumericData, - maximum_virtual_charging_rate: NumericData, - maximum_virtual_discharging_rate: NumericData, + maximum_virtual_charge_rate: NumericData, + maximum_virtual_discharge_rate: NumericData, minimum_virtual_charge_state: NumericData, maximum_virtual_charge_state: NumericData, forward_timeshift: Scalar = None, @@ -667,8 +667,8 @@ def __init__( label: The label of the Element. Used to identify it in the FlowSystem sink: input-flow of DSM sink after DSM initial_demand: initial demand of DSM sink before DSM - maximum_virtual_charging_rate: maximum flow rate at which charging is possible - maximum_virtual_discharging_rate: maximum flow rate at which discharging is possible. + maximum_virtual_charge_rate: maximum flow rate at which charging is possible + maximum_virtual_discharge_rate: maximum flow rate at which discharging is possible. minimum_virtual_charge_state: minimum charge state. maximum_virtual_charge_state: maximum charge state. forward_timeshift: Maximum number of hours by which the demand can be shifted forward in time. Default is infinite. @@ -692,8 +692,8 @@ def __init__( self.initial_demand: NumericDataTS = initial_demand self.forward_timeshift = forward_timeshift self.backward_timeshift = backward_timeshift - self.maximum_virtual_charging_rate: NumericDataTS = maximum_virtual_charging_rate - self.maximum_virtual_discharging_rate: NumericDataTS = maximum_virtual_discharging_rate + self.maximum_virtual_charge_rate: NumericDataTS = maximum_virtual_charge_rate + self.maximum_virtual_discharge_rate: NumericDataTS = maximum_virtual_discharge_rate self.minimum_virtual_charge_state: NumericDataTS = minimum_virtual_charge_state self.maximum_virtual_charge_state: NumericDataTS = maximum_virtual_charge_state @@ -722,13 +722,13 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: f'{self.label_full}|initial_demand', self.initial_demand, ) - self.maximum_virtual_charging_rate = flow_system.create_time_series( - f'{self.label_full}|maximum_virtual_charging_rate', - self.maximum_virtual_charging_rate, + self.maximum_virtual_charge_rate = flow_system.create_time_series( + f'{self.label_full}|maximum_virtual_charge_rate', + self.maximum_virtual_charge_rate, ) - self.maximum_virtual_discharging_rate = flow_system.create_time_series( - f'{self.label_full}|maximum_virtual_discharging_rate', - self.maximum_virtual_discharging_rate, + self.maximum_virtual_discharge_rate = flow_system.create_time_series( + f'{self.label_full}|maximum_virtual_discharge_rate', + self.maximum_virtual_discharge_rate, ) self.minimum_virtual_charge_state = flow_system.create_time_series( f'{self.label_full}|minimum_virtual_charge_state', @@ -1121,426 +1121,12 @@ def negative_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: def charge_rate_bounds(self) -> Tuple[NumericData, NumericData]: return( 0, - self.element.maximum_virtual_charging_rate.active_data, + self.element.maximum_virtual_charge_rate.active_data, ) @property def discharge_rate_bounds(self) -> Tuple[NumericData, NumericData]: return( - self.element.maximum_virtual_discharging_rate.active_data, + self.element.maximum_virtual_discharge_rate.active_data, 0, - ) - -@register_class_for_io -class DSMSinkTS(Sink): - """ - Used to model a sink with the ability to perform demand side management. - In this class DSM is modeled via a timeshift of the demand. - DON'T USE WITH TIMESTEPS OF VARIABLE LENGTH! - """ - - def __init__( - self, - label: str, - sink: Flow, - initial_demand: NumericData, - forward_timeshift: Scalar, - backward_timeshift: Scalar, - maximum_flow_surplus_per_hour: NumericData, - maximum_flow_deficit_per_hour: NumericData, - maximum_cumulated_surplus: NumericData = np.inf, - maximum_cumulated_deficit: NumericData = -np.inf, - allow_parallel_surplus_and_deficit: bool = False, - penalty_costs: NumericData = 0.001, - meta_data: Optional[Dict] = None - ): - """ - Args: - label: The label of the Element. Used to identify it in the FlowSystem - sink: input-flow of DSM sink after DSM - initial_demand: initial demand of DSM sink before DSM - forward_timeshift: Maximum number of hours by which the demand can be shifted forward in time (e.g., if forward_timeshift = 2 and timesteps are 1 hour long, demand at t can be satisfied at t-1 or t-2) - backward_timeshift: Maximum number of hours by which the demand can be shifted backward in time (e.g., if backward_timeshift = 2 and timesteps are 1 hour long, demand at t can be satisfied at t+1 or t+2) - maximum_flow_surplus_per_hour: Maximum amount that the supply can exceed the demand per hour - maximum_flow_deficit_per_hour: Maximum amount that the supply can fall below the demand per hour - maximum_cumulated_surplus: Maximum cumulated flow hours that the supply can exceed the demand - maximum_cumulated_deficit: Maximum cumulated flow hours that the supply can fall below the demand - allow_parallel_surplus_and_deficit: If True, allows simultaneous surplus and deficit in one timestep that compensate each other but allow for timeshifts longer than the set timestep bounds - penalty_costs: Penalty costs per flow unit for deviating from the initial demand profile (default is a small epsilon to avoid multiple optimization results of equal value) - meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. - """ - - super().__init__( - label, - sink, - meta_data - ) - - self.initial_demand: NumericDataTS = initial_demand - self.forward_timeshift = forward_timeshift - self.backward_timeshift = backward_timeshift - self.maximum_flow_surplus_per_hour: NumericDataTS = maximum_flow_surplus_per_hour - self.maximum_flow_deficit_per_hour: NumericDataTS = maximum_flow_deficit_per_hour - self.maximum_cumulated_surplus: NumericDataTS = maximum_cumulated_surplus - self.maximum_cumulated_deficit: NumericDataTS = maximum_cumulated_deficit - self.allow_parallel_surplus_and_deficit = allow_parallel_surplus_and_deficit - self.penalty_costs: NumericDataTS = penalty_costs - - def create_model(self, model: SystemModel) -> 'DSMSinkTSModel': - self._plausibility_checks(model) - - # calculate amount of timesteps, by which demand can be shifted forward/backward in time - hours_per_step = model.hours_per_step - self.timesteps_forward = int(self.forward_timeshift/hours_per_step.values[0]) - self.timesteps_backward = int(self.backward_timeshift/hours_per_step.values[0]) - - self.model = DSMSinkTSModel(model, self) - return self.model - - def transform_data(self, flow_system: 'FlowSystem') -> None: - super().transform_data(flow_system) - self.initial_demand = flow_system.create_time_series( - f'{self.label_full}|initial_demand', - self.initial_demand, - ) - self.maximum_flow_surplus_per_hour = flow_system.create_time_series( - f'{self.label_full}|maximum_flow_surplus_per_hour', - self.maximum_flow_surplus_per_hour - ) - self.maximum_flow_deficit_per_hour = flow_system.create_time_series( - f'{self.label_full}|maximum_flow_deficit_per_hour', - self.maximum_flow_deficit_per_hour - ) - self.penalty_costs = flow_system.create_time_series( - f'{self.label_full}|penalty_costs', - self.penalty_costs - ) - self.maximum_cumulated_surplus = flow_system.create_time_series( - f'{self.label_full}|maximum_cumulated_surplus', - self.maximum_cumulated_surplus, - needs_extra_timestep=True, - ) - self.maximum_cumulated_deficit = flow_system.create_time_series( - f'{self.label_full}|maximum_cumulated_deficit', - self.maximum_cumulated_deficit, - needs_extra_timestep=True, - ) - - def _plausibility_checks(self, model: SystemModel): - """ - Check for infeasible or uncommon combinations of parameters - """ - super()._plausibility_checks() - - if any(self.maximum_flow_deficit_per_hour >= 0): - raise ValueError( - f'{self.label_full}: {self.maximum_flow_deficit_per_hour=} ' - f'must have a negative value assigned.' - ) - if self.maximum_cumulated_deficit is not None: - if any(self.maximum_cumulated_deficit > 0): - raise ValueError( - f'{self.label_full}: {self.maximum_cumulated_deficit=} ' - f'must have a non positive value assinged.' - ) - - hours_per_step = model.hours_per_step - - if self.forward_timeshift%hours_per_step.values[0]!=0: - raise ValueError( - f'{self.label_full}: {self.forward_timeshift=} ' - f'must be a multiple of the timestep length.' - ) - - if self.backward_timeshift%hours_per_step.values[0]!=0: - raise ValueError( - f'{self.label_full}: {self.backward_timeshift=} ' - f'must be a multiple of the timestep length.' - ) - - if any(hours_per_step.values[0]!=hours_per_step.values): - raise ValueError( - f'{self.label_full}:' - f'DSMSinkTS class can only be used for timesteps of equal length' - ) - - #TODO: think of infeasabilities - # - L > amount of timesteps - - -@register_class_for_io -class DSMSinkTSModel(ComponentModel): - """ Model of timeshift DSM sink """ - - def __init__(self, model: SystemModel, element: DSMSinkTS): - super().__init__(model, element) - self.element: DSMSinkTS = element - self.surplus: Optional[linopy.Variable] = None - self.deficit_pre: Optional[linopy.Variable] = None - self.deficit_post: Optional[linopy.Variable] = None - self.cumulated_flow_deviation: Optional[linopy.Variable] = None - self.is_surplus: Optional[StateModel] = None - self.is_deficit: Optional[StateModel] = None - self.prevent_simultaneous: Optional[PreventSimultaneousUsageModel] = None - - def do_modeling(self): - super().do_modeling() - - hours_per_step = self._model.hours_per_step - timesteps_forward = self.element.timesteps_forward - timesteps_backward = self.element.timesteps_backward - - # add variables for supply surplus (one variable per timestep) - lb,ub = 0,self.absolute_DSM_bounds[1] - self.surplus = self.add( - self._model.add_variables( - lower=lb, upper=ub, coords=self._model.coords, name=f'{self.label_full}|surplus' - ), - 'surplus', - ) - - # add variables for supply deficit (one variable per timestep and - # per number of timesteps by which demand can be shifted backward) - # nomenclature: a backwards timeshift results in a deficit preceding its assigned surplus --> deficit_pre - lb,ub = self.absolute_DSM_bounds[0],0 - self.deficit_pre = { - i: self.add( - self._model.add_variables( - lower=lb, upper=ub, coords=self._model.coords, name=f'{self.label_full}|deficit_pre_{i}' - ), - f'deficit_pre_{i}', - ) - for i in range(1, timesteps_backward + 1) - # range starting from 1 to be consistent with the number of timesteps that the deficit occurs prior to its assigned surplus - } - - # add variables for supply deficit (one variable per timestep and - # per number of timesteps by which demand can be shifted forward) - # nomenclature: a forwards timeshift results in a deficit lagging behind its assigned surplus --> deficit_post - lb,ub = self.absolute_DSM_bounds[0],0 - self.deficit_post = { - i: self.add( - self._model.add_variables( - lower=lb, upper=ub, coords=self._model.coords, name=f'{self.label_full}|deficit_post_{i}' - ), - f'deficit_post_{i}', - ) - for i in range(1, timesteps_forward + 1) - # range starting from 1 to be consistent with the number of timesteps that the deficit occurs after its assigned surplus - } - - #add variables for cumulated flow on hold - lb,ub = self.absolute_cumulated_bounds - self.cumulated_flow_deviation = self.add( - self._model.add_variables( - lower=lb, upper=ub, coords=self._model.coords_extra, name=f'{self.label_full}|cumulated_flow_deviation' - ), - f'cumulated_flow_deviation' - ) - - surplus = self.surplus - deficit_pre = self.deficit_pre - deficit_post = self.deficit_post - initial_demand = self.element.initial_demand.active_data - sink = self.element.sink.model.flow_rate - cumulated_flow_deviation = self.cumulated_flow_deviation - - # For each timestep t: - # surplus(t) = sum over n (deficit_pre(t-n,n)) + sum over m (deficit_post(t+m,m)) - # where n ranges from 1 to timesteps_backward and m ranges from 1 to timesteps_forward - # INTERPRETATION: flow conservation equation where surplus at t compensates for: - # - deficits that occurred n timesteps before t (deficit_pre) - # - deficits that will occur m timesteps after t (deficit_post) - for t in range(len(self._model.coords[0])): - # Sum up all deficits that are assigned to this timestep's surplus - deficit_sum = 0 - # Add deficits from past timesteps (t-n) that are assigned to surplus at t - for n in range(1, timesteps_backward + 1): - if t - n >= 0: - deficit_sum += deficit_pre[n].isel(time=t - n) - # Add deficits from future timesteps (t+m) that are assigned to surplus at t - for m in range(1, timesteps_forward + 1): - if t + m < len(self._model.coords[0]): - deficit_sum += deficit_post[m].isel(time=t + m) - - self.add( - self._model.add_constraints( - surplus.isel(time=t) + deficit_sum == 0, - name=f'{self.label_full}|assign_surplus_deficit_{t}', - ), - f'assign_surplus_deficit_{t}' - ) - - # For each timestep t: - # sink(t) = initial_demand(t) + surplus(t) + sum over n (deficit_pre(t,n)) + sum over m (deficit_post(t,m)) - # where n ranges from 1 to timesteps_backward and m ranges from 1 to timesteps_forward - # INTERPRETATION: balance equation where sink flow equals initial demand plus surplus in t and all deficits in t - # that are assigned to valid future surpluses (t+n) or past surpluses (t-m) - # Note: Only deficits that appear in the flow conservation equation are included - for t in range(len(self._model.coords[0])): - # Sum up all deficits that occur at this timestep - deficit_sum = 0 - # Add deficits at t that are assigned to future surpluses - for n in range(1, timesteps_backward + 1): - if t + n < len(self._model.coords[0]): # only add deficits that appear in flow conservation equation - deficit_sum += deficit_pre[n].isel(time=t) - else: # unused deficits are set to zero - self.add(self._model.add_constraints(deficit_pre[n].isel(time=t)==0)) - # Add deficits at t that are assigned to past surpluses - for m in range(1, timesteps_forward + 1): - if t - m >= 0: # only add deficits that appear in flow conservation equation - deficit_sum += deficit_post[m].isel(time=t) - else: # unused deficits are set to zero - self.add(self._model.add_constraints(deficit_post[m].isel(time=t)==0)) - - self.add( - self._model.add_constraints( - sink.isel(time=t) - == surplus.isel(time=t) - + deficit_sum - + initial_demand.isel(time=t), - name=f'{self.label_full}|balance_{t}', - ), - f'balance_{t}' - ) - - # equation for the cumulated deviation - self.add( - self._model.add_constraints( - cumulated_flow_deviation.isel(time=slice(1,None)) - == cumulated_flow_deviation.isel(time=slice(None,-1)) - + sink * hours_per_step - - initial_demand * hours_per_step, - name=f'{self.label_full}|deviating_flow_cumulation' - ), - f'deviating_flow_cumulation' - ) - - # set initial value of cumulated deviation to - self.add( - self._model.add_constraints( - cumulated_flow_deviation.isel(time=0) == 0, - name=f'{self.label_full}|initial_deviation' - ), - f'initial_deviation' - ) - - # Add exclusivity constraints using StateModel and PreventSimultaneousUsageModel - self._add_surplus_deficit_exclusivity_constraints() - - # Add penalty costs as effects for supply deficits - # default is a small epsilon to avoid multiple optimization results of equal value - # timeshifts of greater length are penalized with a higher cost - penalty_costs = self.element.penalty_costs.active_data - for i in range(1, timesteps_forward + 1): - deficit = deficit_post[i] - self._model.effects.add_share_to_penalty( - name = self.label_full, - expression = - (deficit * i * penalty_costs * hours_per_step).sum() - ) - - for i in range(1, timesteps_backward + 1): - deficit = deficit_pre[i] - self._model.effects.add_share_to_penalty( - name = self.label_full, - expression = - (deficit * i * penalty_costs * hours_per_step).sum() - ) - - def _add_surplus_deficit_exclusivity_constraints(self): - """ - If allow_parallel_surplus_and_deficit is True, no exclusivity constraints are added. - Instead a constraint is added that limits longterm timeshifting to half of the max DSM capacity. - If allow_parallel_surplus_and_deficit is False, StateModel and PreventSimultaneousUsageModel is used to ensure surplus and deficit can not be active simultaneously. - """ - - hours_per_step = self._model.hours_per_step - timesteps_forward = int(self.element.forward_timeshift/hours_per_step.values[0]) - timesteps_backward = int(self.element.backward_timeshift/hours_per_step.values[0]) - surplus = self.surplus - deficit_pre = self.deficit_pre - deficit_post = self.deficit_post - - if self.element.allow_parallel_surplus_and_deficit: - #add a constraint that limits long term timeshifting to half of the max DSM capacity - for t in range(len(self._model.coords[0])): - # Sum up all deficits that occur at this timestep - deficit_sum = 0 - # Add deficits at t that are assigned to future surpluses - for n in range(1, timesteps_backward + 1): - if t + n < len(self._model.coords[0]): # only add deficits that appear in flow conservation equation - deficit_sum += deficit_pre[n].isel(time=t) - # Add deficits at t that are assigned to past surpluses - for m in range(1, timesteps_forward + 1): - if t - m >= 0: # only add deficits that appear in flow conservation equation - deficit_sum += deficit_post[m].isel(time=t) - # eq: surplus(t) + abs(deficit(t)) < min(upper bound of surplus, absolute value of lower bound of deficit) - self.add( - self._model.add_constraints( - surplus.isel(time=t) - - deficit_sum - <= min(-self.absolute_DSM_bounds[0].isel(time=t), self.absolute_DSM_bounds[1].isel(time=t)), - name=f'{self.label_full}|long_term_timeshift_constraint_{t}', - ), - f'long_term_timeshift_constraint_{t}' - ) - else: - # Create a single time series of zeros to be used for both bounds - timeseries_zeros = np.zeros_like(self._model.coords[0], dtype=float) - - # create StateModel for surplus with time series of zeros as lower bound - self.is_surplus = self.add( - StateModel( - model=self._model, - label_of_element=f'{self.label_full}|surplus', - defining_variables=[self.surplus], - defining_bounds=[(timeseries_zeros, self.absolute_DSM_bounds[1])], # Use time series of zeros and time-varying upper bound - use_off=False - ) - ) - self.is_surplus.do_modeling() - - # create StateModel for deficit (using the sum of all deficits) - deficit_sum = sum(deficit_pre.values()) + sum(deficit_post.values()) - self.is_deficit = self.add( - StateModel( - model=self._model, - label_of_element=f'{self.label_full}|deficit', - defining_variables=[-deficit_sum], # StateModel can only handel positive variables --> inverted value of deficit_sum is used - defining_bounds=[(timeseries_zeros, -self.absolute_DSM_bounds[0])], # Use time series of zeros and negative time-varying lower bound - use_off=False - ) - ) - self.is_deficit.do_modeling() - - # create PreventSimultaneousUsageModel - self.prevent_simultaneous = self.add( - PreventSimultaneousUsageModel( - model=self._model, - variables=[self.is_surplus.on, self.is_deficit.on], - label_of_element=self.label_full, - label='PreventSimultaneousSurplusDeficit' - ) - ) - self.prevent_simultaneous.do_modeling() - - - @property - def absolute_DSM_bounds(self) -> Tuple[NumericData, NumericData]: - return( - self.element.maximum_flow_deficit_per_hour.active_data, - self.element.maximum_flow_surplus_per_hour.active_data, - ) - - @property - def absolute_cumulated_bounds(self) -> Tuple[NumericData, NumericData]: - return( - self.element.maximum_cumulated_deficit.active_data, - self.element.maximum_cumulated_surplus.active_data, - ) - - """zu klären: - Können coords auch andere Koordinaten enthalten als time? Wie funktioniert der korrekte Zugriff? - Lieber built-in Komponenten für Speicher und StateModel verwenden? - Passt die Einbindung der Strafkosten? - Kein hours_per_step bei Strafkosten für virtual storage --> lieber ersten Schritt auslassen? - """ \ No newline at end of file + ) \ No newline at end of file From c72022a2315b332ddb9ec41b53a9b82c737b869d Mon Sep 17 00:00:00 2001 From: fkeller Date: Wed, 11 Jun 2025 15:31:38 +0200 Subject: [PATCH 26/33] changed some parameter names to make them more intuitive and some changes to native DSM diagrams --- examples/00_Minmal/minimal_example_DSM.py | 8 ++-- flixopt/components.py | 56 +++++++++++------------ flixopt/results.py | 30 ++---------- 3 files changed, 35 insertions(+), 59 deletions(-) diff --git a/examples/00_Minmal/minimal_example_DSM.py b/examples/00_Minmal/minimal_example_DSM.py index 9fd9deb0d..053fe6e8d 100644 --- a/examples/00_Minmal/minimal_example_DSM.py +++ b/examples/00_Minmal/minimal_example_DSM.py @@ -49,10 +49,10 @@ 'DSM Sink Heat Demand', sink=fx.Flow(label='Heat Load', bus='District Heating', size=150), initial_demand=thermal_load_profile, - minimum_virtual_charge_state = -50, - maximum_virtual_charge_state = 50, - maximum_virtual_discharge_rate = -20, - maximum_virtual_charge_rate = 20, + maximum_cumulated_deficit = -50, + maximum_cumulated_surplus = 50, + maximum_flow_deficit = -20, + maximum_flow_surplus = 20, relative_loss_per_hour_positive_charge_state = 0.05, relative_loss_per_hour_negative_charge_state = 0.05, penalty_costs_positive_charge_states=0, diff --git a/flixopt/components.py b/flixopt/components.py index 00f2aae95..4f66c25ef 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -648,10 +648,10 @@ def __init__( label: str, sink: Flow, initial_demand: NumericData, - maximum_virtual_charge_rate: NumericData, - maximum_virtual_discharge_rate: NumericData, - minimum_virtual_charge_state: NumericData, - maximum_virtual_charge_state: NumericData, + maximum_flow_deficit: NumericData, + maximum_flow_surplus: NumericData, + maximum_cumulated_deficit: NumericData, + maximum_cumulated_surplus: NumericData, forward_timeshift: Scalar = None, backward_timeshift: Scalar = None, relative_loss_per_hour_positive_charge_state: NumericData = 0, @@ -667,10 +667,10 @@ def __init__( label: The label of the Element. Used to identify it in the FlowSystem sink: input-flow of DSM sink after DSM initial_demand: initial demand of DSM sink before DSM - maximum_virtual_charge_rate: maximum flow rate at which charging is possible - maximum_virtual_discharge_rate: maximum flow rate at which discharging is possible. - minimum_virtual_charge_state: minimum charge state. - maximum_virtual_charge_state: maximum charge state. + maximum_flow_deficit: maximum that the supply flow can fall short of the demand + maximum_flow_surplus: maximum that the supply flow can exceed the demand + maximum_cumulated_deficit: maximum cumulated supply deficit in flow hours + maximum_cumulated_surplus: maximum cumulated supply surplus in flow hours forward_timeshift: Maximum number of hours by which the demand can be shifted forward in time. Default is infinite. backward_timeshift: Maximum number of hours by which the demand can be shifted backward in time. Default is infinite. relative_loss_per_hour_positive_charge_state: loss per chargeState-Unit per hour for positive charge states of the virtual storage. The default is 0. @@ -692,10 +692,10 @@ def __init__( self.initial_demand: NumericDataTS = initial_demand self.forward_timeshift = forward_timeshift self.backward_timeshift = backward_timeshift - self.maximum_virtual_charge_rate: NumericDataTS = maximum_virtual_charge_rate - self.maximum_virtual_discharge_rate: NumericDataTS = maximum_virtual_discharge_rate - self.minimum_virtual_charge_state: NumericDataTS = minimum_virtual_charge_state - self.maximum_virtual_charge_state: NumericDataTS = maximum_virtual_charge_state + self.maximum_flow_deficit: NumericDataTS = maximum_flow_deficit + self.maximum_flow_surplus: NumericDataTS = maximum_flow_surplus + self.maximum_cumulated_deficit: NumericDataTS = maximum_cumulated_deficit + self.maximum_cumulated_surplus: NumericDataTS = maximum_cumulated_surplus self.relative_loss_per_hour_positive_charge_state: NumericDataTS = relative_loss_per_hour_positive_charge_state self.relative_loss_per_hour_negative_charge_state: NumericDataTS = relative_loss_per_hour_negative_charge_state @@ -722,22 +722,22 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: f'{self.label_full}|initial_demand', self.initial_demand, ) - self.maximum_virtual_charge_rate = flow_system.create_time_series( - f'{self.label_full}|maximum_virtual_charge_rate', - self.maximum_virtual_charge_rate, + self.maximum_flow_surplus = flow_system.create_time_series( + f'{self.label_full}|maximum_flow_surplus', + self.maximum_flow_surplus, ) - self.maximum_virtual_discharge_rate = flow_system.create_time_series( - f'{self.label_full}|maximum_virtual_discharge_rate', - self.maximum_virtual_discharge_rate, + self.maximum_flow_deficit = flow_system.create_time_series( + f'{self.label_full}|maximum_flow_deficit', + self.maximum_flow_deficit, ) - self.minimum_virtual_charge_state = flow_system.create_time_series( - f'{self.label_full}|minimum_virtual_charge_state', - self.minimum_virtual_charge_state, + self.maximum_cumulated_deficit = flow_system.create_time_series( + f'{self.label_full}|maximum_cumulated_deficit', + self.maximum_cumulated_deficit, needs_extra_timestep=True, ) - self.maximum_virtual_charge_state = flow_system.create_time_series( - f'{self.label_full}|maximum_virtual_charge_state', - self.maximum_virtual_charge_state, + self.maximum_cumulated_surplus = flow_system.create_time_series( + f'{self.label_full}|maximum_cumulated_surplus', + self.maximum_cumulated_surplus, needs_extra_timestep=True, ) self.relative_loss_per_hour_negative_charge_state = flow_system.create_time_series( @@ -1107,13 +1107,13 @@ def _initial_and_final_charge_state(self): def positive_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: return ( 0, - self.element.maximum_virtual_charge_state.active_data, + self.element.maximum_cumulated_surplus.active_data, ) @property def negative_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: return ( - self.element.minimum_virtual_charge_state.active_data, + self.element.maximum_cumulated_deficit.active_data, 0, ) @@ -1121,12 +1121,12 @@ def negative_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: def charge_rate_bounds(self) -> Tuple[NumericData, NumericData]: return( 0, - self.element.maximum_virtual_charge_rate.active_data, + self.element.maximum_flow_surplus.active_data, ) @property def discharge_rate_bounds(self) -> Tuple[NumericData, NumericData]: return( - self.element.maximum_virtual_discharge_rate.active_data, + self.element.maximum_flow_deficit.active_data, 0, ) \ No newline at end of file diff --git a/flixopt/results.py b/flixopt/results.py index eb7df45c3..47294ac2a 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -600,9 +600,8 @@ def plot_DSM_sink( data, mode='area', colors=colors, - title=f'District Heating Node Balance with DSM Surplus/Deficit for {self.label}', - ylabel='Power [kW]', - xlabel='Time' + title=f'DSM sink behaviour for {self.label}', + xlabel='Time in h' ) # Add initial demand @@ -639,30 +638,7 @@ def plot_DSM_sink( size=10, symbol='diamond', line=dict(width=1, color='black') - ), - yaxis='y2' - ) - ) - - # Update layout to include secondary y-axis and scale both y-axis appropriately - fig.update_layout( - hovermode='x unified', - yaxis=dict( - range=[ - -1.2*max(0, node_balance.max().max(), -cumulated_flow.min().min(), -deficit.min().min()), - 1.2*max(node_balance.max().max(), surplus.max().max(), cumulated_flow.max().max(), initial_demand.max().max()) - ], - showgrid=True - ), - yaxis2=dict( - title='Cumulated Flow [kWh]', - overlaying='y', - side='right', - showgrid=False, - range=[ - -1.2*max(0, node_balance.max().max(), -cumulated_flow.min().min(), -deficit.min().min()), - 1.2*max(node_balance.max().max(), surplus.max().max(), cumulated_flow.max().max(), initial_demand.max().max()) - ] + ) ) ) From 1a335dd076d7ed28c21097d991b8888146e8c607 Mon Sep 17 00:00:00 2001 From: fkeller Date: Wed, 11 Jun 2025 15:50:04 +0200 Subject: [PATCH 27/33] used built-in epsilon value for default penalty costs --- flixopt/components.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 4f66c25ef..4e1058c15 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -658,8 +658,8 @@ def __init__( relative_loss_per_hour_negative_charge_state: NumericData = 0, allow_mixed_charge_states: bool = False, allow_parallel_charge_and_discharge: bool = False, - penalty_costs_positive_charge_states: NumericData = 0.001, - penalty_costs_negative_charge_states: NumericData = 0.001, + penalty_costs_positive_charge_states: NumericData = None, + penalty_costs_negative_charge_states: NumericData = None, meta_data: Optional[Dict] = None ): """ @@ -702,8 +702,8 @@ def __init__( self.allow_mixed_charge_states = allow_mixed_charge_states self.allow_parallel_charge_and_discharge = allow_parallel_charge_and_discharge - self.penalty_costs_positive_charge_states: NumericDataTS = penalty_costs_positive_charge_states - self.penalty_costs_negative_charge_states: NumericDataTS = penalty_costs_negative_charge_states + self.penalty_costs_positive_charge_states: NumericDataTS = penalty_costs_positive_charge_states if penalty_costs_positive_charge_states is not None else CONFIG.modeling.EPSILON + self.penalty_costs_negative_charge_states: NumericDataTS = penalty_costs_negative_charge_states if penalty_costs_negative_charge_states is not None else CONFIG.modeling.EPSILON def create_model(self, model: SystemModel) -> 'DSMSinkModel': self._plausibility_checks(model) From 091282acec336e55eccaf950b723b1b9bdd36eb9 Mon Sep 17 00:00:00 2001 From: fkeller Date: Wed, 11 Jun 2025 16:05:22 +0200 Subject: [PATCH 28/33] minor changes to comments --- flixopt/components.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 4e1058c15..942589490 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -994,7 +994,7 @@ def _add_charge_state_exclusivity_constraints(self): def _add_charge_rate_exclusivity_constraints(self): """Add constraints to prevent simultaneous positive and negative charge rates using StateModel and PreventSimultaneousUsageModel""" - # Create a single time series of zeros to be used for both bounds + # Create a time series of zeros to be used for both bounds timeseries_zeros = np.zeros_like(self._model.coords[0], dtype=float) # Create StateModel for positive charge rate @@ -1081,8 +1081,6 @@ def _add_timeshift_limits(self): f'limit_backward_timeshift' ) - #TODO: handle clash with initial charge state - def _initial_and_final_charge_state(self): """Add constraints for initial and final charge states to be zero""" # Set initial charge state to zero From 4c63668565b565fcd358502c2c6f8eb36ac30d01 Mon Sep 17 00:00:00 2001 From: fkeller Date: Wed, 11 Jun 2025 17:16:36 +0200 Subject: [PATCH 29/33] Added many plausibility checks --- flixopt/components.py | 128 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 124 insertions(+), 4 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 942589490..33043bfd5 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -765,6 +765,7 @@ def _plausibility_checks(self, model: SystemModel): hours_per_step = model.hours_per_step + # Check timeshift parameters if self.forward_timeshift != None or self.backward_timeshift != None: if any(hours_per_step.values[0]!=hours_per_step.values): raise ValueError( @@ -773,22 +774,141 @@ def _plausibility_checks(self, model: SystemModel): ) if self.forward_timeshift is not None: + if self.forward_timeshift <= 0: + raise ValueError( + f'{self.label_full}: {self.forward_timeshift=} ' + f'must be positive.' + ) if self.forward_timeshift%hours_per_step.values[0]!=0: raise ValueError( f'{self.label_full}: {self.forward_timeshift=} ' f'must be a multiple of the timestep length.' ) + # Check if forward timeshift is specified but maximum flow surplus is zero + if np.all(self.maximum_flow_surplus.active_data == 0): + logger.warning( + f'{self.label_full}: forward_timeshift is specified but maximum_flow_surplus is zero. ' + f'This is contradictory as you cannot shift demand forward if supply cannot exceed initial demand.' + ) if self.backward_timeshift is not None: + if self.backward_timeshift <= 0: + raise ValueError( + f'{self.label_full}: {self.backward_timeshift=} ' + f'must be positive.' + ) if self.backward_timeshift%hours_per_step.values[0]!=0: raise ValueError( f'{self.label_full}: {self.backward_timeshift=} ' f'must be a multiple of the timestep length.' ) - - #TODO: think about other implausibilities - #INFO: investments not implemented - + # Check if backward timeshift is specified but maximum flow deficit is zero + if np.all(self.maximum_flow_deficit.active_data == 0): + logger.warning( + f'{self.label_full}: backward_timeshift is specified but maximum_flow_deficit is zero. ' + f'This is contradictory as you cannot shift demand backward if supply cannot fall short of initial demand.' + ) + + # Check maximum flow bounds + if np.any(self.maximum_flow_deficit.active_data > 0): + raise ValueError( + f'{self.label_full}: maximum_flow_deficit must be non-positive ' + f'(as it represents a deficit).' + ) + if np.any(self.maximum_flow_surplus.active_data < 0): + raise ValueError( + f'{self.label_full}: maximum_flow_surplus must be non-negative ' + f'(as it represents a surplus).' + ) + + # Check maximum cumulated bounds + if np.any(self.maximum_cumulated_deficit.active_data > 0): + raise ValueError( + f'{self.label_full}: maximum_cumulated_deficit must be non-positive ' + f'(as it represents a deficit).' + ) + if np.any(self.maximum_cumulated_surplus.active_data < 0): + raise ValueError( + f'{self.label_full}: maximum_cumulated_surplus must be non-negative ' + f'(as it represents a surplus).' + ) + + # Check loss rates + if np.any(self.relative_loss_per_hour_positive_charge_state.active_data < 0) or \ + np.any(self.relative_loss_per_hour_positive_charge_state.active_data > 1): + raise ValueError( + f'{self.label_full}: relative_loss_per_hour_positive_charge_state must be between 0 and 1 ' + f'(representing 0% to 100% loss).' + ) + if np.any(self.relative_loss_per_hour_negative_charge_state.active_data < 0) or \ + np.any(self.relative_loss_per_hour_negative_charge_state.active_data > 1): + raise ValueError( + f'{self.label_full}: relative_loss_per_hour_negative_charge_state must be between 0 and 1 ' + f'(representing 0% to 100% loss).' + ) + + # Check penalty costs + if np.any(self.penalty_costs_positive_charge_states.active_data < 0): + raise ValueError( + f'{self.label_full}: penalty_costs_positive_charge_states must be non-negative.' + ) + if np.any(self.penalty_costs_negative_charge_states.active_data < 0): + raise ValueError( + f'{self.label_full}: penalty_costs_negative_charge_states must be non-negative.' + ) + + # Check initial demand + if np.any(self.initial_demand.active_data < 0): + raise ValueError( + f'{self.label_full}: initial_demand must be non-negative.' + ) + + # Check for zero bounds that would disable DSM + if np.all(self.maximum_flow_surplus.active_data == 0): + logger.warning( + f'{self.label_full}: maximum_flow_surplus is zero. ' + f'This could effectively disable DSM functionality as supply can never exceed the initial demand.' + ) + if np.all(self.maximum_flow_deficit.active_data == 0): + logger.warning( + f'{self.label_full}: maximum_flow_deficit is zero. ' + f'This could effectively disable DSM functionality as supply can never fall short of the initial demand.' + ) + if np.all(self.maximum_cumulated_surplus.active_data == 0) and np.all(self.maximum_cumulated_deficit.active_data == 0): + logger.warning( + f'{self.label_full}: Both maximum_cumulated_surplus and maximum_cumulated_deficit are zero. ' + f'This effectively disables DSM functionality as no energy can be stored.' + ) + + # Check for parallel charge and discharge + if self.allow_parallel_charge_and_discharge: + logger.warning( + f'{self.label_full}: allow_parallel_charge_and_discharge is True. ' + f'This might lead to unexpected behaviour.' + ) + + # Check for mixed charge states + if self.allow_mixed_charge_states: + logger.warning( + f'{self.label_full}: allow_mixed_charge_states is True. ' + f'This might lead to unexpected behaviour.' + ) + + # Check for zero penalty costs with non-zero bounds + if (np.all(self.penalty_costs_positive_charge_states.active_data == 0) and + not np.all(self.maximum_flow_surplus.active_data == 0) and + not np.any(self.relative_loss_per_hour_positive_charge_state.active_data > 0)): + logger.warning( + f'{self.label_full}: penalty_costs_positive_charge_states is zero and relative_loss_per_hour_positive_charge_state is zero but maximum_flow_surplus is non-zero. ' + f'This might lead to unexpected behavior as there is no cost and no loss associated with exceeding the initial demand.' + ) + if (np.all(self.penalty_costs_negative_charge_states.active_data == 0) and + not np.all(self.maximum_flow_deficit.active_data == 0)): + logger.warning( + f'{self.label_full}: penalty_costs_negative_charge_states is zero but maximum_flow_deficit is non-zero. ' + f'This might lead to unexpected behavior as there is no cost associated with falling short of the initial demand.' + ) + class DSMSinkModel(ComponentModel): """Model of DSM Sink""" From 62d52bf700d75ed59057aa5b9fb277ecfe6f1300 Mon Sep 17 00:00:00 2001 From: fkeller Date: Wed, 11 Jun 2025 18:49:16 +0200 Subject: [PATCH 30/33] small style change to DSM diagramm --- flixopt/results.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/flixopt/results.py b/flixopt/results.py index 47294ac2a..dc6ddf5c1 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -632,13 +632,8 @@ def plot_DSM_sink( x=cumulated_flow.index, y=cumulated_flow.values.flatten(), name='Cumulated Flow Deviation', - mode='markers', - marker=dict( - color=cumulated_color, - size=10, - symbol='diamond', - line=dict(width=1, color='black') - ) + line=dict(width=3, color='black'), + mode='lines' ) ) From 096d0010f52726692d3e3bf6d6f6649c10eded2f Mon Sep 17 00:00:00 2001 From: fkeller Date: Sat, 28 Jun 2025 12:55:08 +0200 Subject: [PATCH 31/33] implemented possibility to add penalty costs to charge rates + some changes to plausibility checks --- flixopt/components.py | 56 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 33043bfd5..6bcf1ef98 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -660,6 +660,8 @@ def __init__( allow_parallel_charge_and_discharge: bool = False, penalty_costs_positive_charge_states: NumericData = None, penalty_costs_negative_charge_states: NumericData = None, + penalty_costs_positive_charge_rates: NumericData = 0, + penalty_costs_negative_charge_rates: NumericData = 0, meta_data: Optional[Dict] = None ): """ @@ -681,6 +683,8 @@ def __init__( If False, charging and discharging cannot occur simultaneously. The default is False. penalty_costs_positive_charge_states: penalty costs per flow hour for loss of comfort due to positive charge states of the virtual storage (e.g. increased room temperature). The default is a small epsilon. penalty_costs_negative_charge_states: penalty costs per flow hour for loss of comfort due to negative charge states of the virtual storage (e.g. decreased room temperature). The default is a small epsilon. + penalty_costs_positive_charge_rates: penalty costs per flow hour for positive charge rates (e.g. costs for increasing supply above demand). The default is 0. + penalty_costs_negative_charge_rates: penalty costs per flow hour for negative charge rates (e.g. costs for decreasing supply below demand). The default is 0. meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. """ super().__init__( @@ -704,6 +708,8 @@ def __init__( self.penalty_costs_positive_charge_states: NumericDataTS = penalty_costs_positive_charge_states if penalty_costs_positive_charge_states is not None else CONFIG.modeling.EPSILON self.penalty_costs_negative_charge_states: NumericDataTS = penalty_costs_negative_charge_states if penalty_costs_negative_charge_states is not None else CONFIG.modeling.EPSILON + self.penalty_costs_positive_charge_rates: NumericDataTS = penalty_costs_positive_charge_rates + self.penalty_costs_negative_charge_rates: NumericDataTS = penalty_costs_negative_charge_rates def create_model(self, model: SystemModel) -> 'DSMSinkModel': self._plausibility_checks(model) @@ -756,6 +762,14 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: f'{self.label_full}|penalty_costs_positive_charge_states', self.penalty_costs_positive_charge_states, ) + self.penalty_costs_negative_charge_rates = flow_system.create_time_series( + f'{self.label_full}|penalty_costs_negative_charge_rates', + self.penalty_costs_negative_charge_rates, + ) + self.penalty_costs_positive_charge_rates = flow_system.create_time_series( + f'{self.label_full}|penalty_costs_positive_charge_rates', + self.penalty_costs_positive_charge_rates, + ) def _plausibility_checks(self, model: SystemModel): """ @@ -849,18 +863,26 @@ def _plausibility_checks(self, model: SystemModel): # Check penalty costs if np.any(self.penalty_costs_positive_charge_states.active_data < 0): - raise ValueError( - f'{self.label_full}: penalty_costs_positive_charge_states must be non-negative.' + logger.warning( + f'{self.label_full}: penalty_costs_positive_charge_states should be non-negative.' ) if np.any(self.penalty_costs_negative_charge_states.active_data < 0): - raise ValueError( - f'{self.label_full}: penalty_costs_negative_charge_states must be non-negative.' + logger.warning( + f'{self.label_full}: penalty_costs_negative_charge_states should be non-negative.' + ) + if np.any(self.penalty_costs_positive_charge_rates.active_data < 0): + logger.warning( + f'{self.label_full}: penalty_costs_positive_charge_rates should be non-negative.' + ) + if np.any(self.penalty_costs_negative_charge_rates.active_data < 0): + logger.warning( + f'{self.label_full}: penalty_costs_negative_charge_rates should be non-negative.' ) # Check initial demand if np.any(self.initial_demand.active_data < 0): - raise ValueError( - f'{self.label_full}: initial_demand must be non-negative.' + logger.warning( + f'{self.label_full}: initial_demand should be non-negative.' ) # Check for zero bounds that would disable DSM @@ -1040,6 +1062,28 @@ def do_modeling(self): expression = (negative_charge_state.shift(time=-1).isel(time=slice(None,-1)) * penalty_coeff_neg).sum() ) + # Add penalty costs as effects for positive and negative charge rates + penalty_costs_pos_rate = self.element.penalty_costs_positive_charge_rates.active_data + penalty_costs_neg_rate = self.element.penalty_costs_negative_charge_rates.active_data + + # Add effects for positive charge rates + if np.any(penalty_costs_pos_rate != 0): + # Multiply penalty costs with hours_per_step first to get a single coefficient per timestep + penalty_coeff_pos_rate = penalty_costs_pos_rate * hours_per_step + self._model.effects.add_share_to_penalty( + name = self.label_full, + expression = (positive_charge_rate * penalty_coeff_pos_rate).sum() + ) + + # Add effects for negative charge rates + if np.any(penalty_costs_neg_rate != 0): + # Multiply penalty costs with hours_per_step first to get a single coefficient per timestep + penalty_coeff_neg_rate = - penalty_costs_neg_rate * hours_per_step + self._model.effects.add_share_to_penalty( + name = self.label_full, + expression = (negative_charge_rate * penalty_coeff_neg_rate).sum() + ) + def _add_charge_state_exclusivity_constraints(self): """Add constraints to prevent simultaneous positive and negative charge states""" #TODO: this method currently does not use the implemented statemodel and preventsimultaneous model From 96bad7c930e14b044eb07cff51e85d189a1e9f24 Mon Sep 17 00:00:00 2001 From: fkeller Date: Fri, 4 Jul 2025 15:29:09 +0200 Subject: [PATCH 32/33] renamed a label in DSM sink plot --- flixopt/results.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/results.py b/flixopt/results.py index dc6ddf5c1..48e566c24 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -631,7 +631,7 @@ def plot_DSM_sink( plotly.graph_objs.Scatter( x=cumulated_flow.index, y=cumulated_flow.values.flatten(), - name='Cumulated Flow Deviation', + name='Virtual Charge State', line=dict(width=3, color='black'), mode='lines' ) From 8c4c118165447e19e3cc0f47a25b6b2b1564d17d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 5 Oct 2025 13:57:48 +0200 Subject: [PATCH 33/33] Update examples/00_Minmal/minimal_example_DSM.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- examples/00_Minmal/minimal_example_DSM.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/00_Minmal/minimal_example_DSM.py b/examples/00_Minmal/minimal_example_DSM.py index 053fe6e8d..c43e0fda1 100644 --- a/examples/00_Minmal/minimal_example_DSM.py +++ b/examples/00_Minmal/minimal_example_DSM.py @@ -6,10 +6,7 @@ import pandas as pd from rich.pretty import pprint -import sys -sys.path.append("C:/Florian/Studium/RES/2025SoSe/Studienarbeit/code/flixopt") import flixopt as fx - if __name__ == '__main__': # --- Define the Flow System, that will hold all elements, and the time steps you want to model --- timesteps = pd.date_range('2020-01-01', periods=24, freq='h')