From f8db3556fbe16977db6a94684d64c19afddd8b5b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Nov 2025 08:14:21 +0100 Subject: [PATCH 1/4] Move validation to Submodel classes --- flixopt/components.py | 249 +++++++++++++++++++++------------------- flixopt/effects.py | 56 +++++---- flixopt/elements.py | 142 ++++++++++++----------- flixopt/structure.py | 26 ++++- tests/test_component.py | 18 ++- 5 files changed, 274 insertions(+), 217 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 1f7f4ab60..8ad9199ac 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -178,7 +178,6 @@ def __init__( self.piecewise_conversion = piecewise_conversion def create_model(self, model: FlowSystemModel) -> LinearConverterModel: - self._plausibility_checks() self.submodel = LinearConverterModel(model, self) return self.submodel @@ -188,36 +187,6 @@ def _set_flow_system(self, flow_system) -> None: if self.piecewise_conversion is not None: self.piecewise_conversion._set_flow_system(flow_system) - def _plausibility_checks(self) -> None: - super()._plausibility_checks() - if not self.conversion_factors and not self.piecewise_conversion: - raise PlausibilityError('Either conversion_factors or piecewise_conversion must be defined!') - if self.conversion_factors and self.piecewise_conversion: - raise PlausibilityError('Only one of conversion_factors or piecewise_conversion can be defined, not both!') - - if self.conversion_factors: - if self.degrees_of_freedom <= 0: - raise PlausibilityError( - f'Too Many conversion_factors_specified. Care that you use less conversion_factors ' - f'then inputs + outputs!! With {len(self.inputs + self.outputs)} inputs and outputs, ' - f'use not more than {len(self.inputs + self.outputs) - 1} conversion_factors!' - ) - - for conversion_factor in self.conversion_factors: - for flow in conversion_factor: - if flow not in self.flows: - raise PlausibilityError( - f'{self.label}: Flow {flow} in conversion_factors is not in inputs/outputs' - ) - if self.piecewise_conversion: - for flow in self.flows.values(): - if isinstance(flow.size, InvestParameters) and flow.size.fixed_size is None: - logger.warning( - f'Using a Flow with variable size (InvestParameters without fixed_size) ' - f'and a piecewise_conversion in {self.label_full} is uncommon. Please verify intent ' - f'({flow.label_full}).' - ) - def transform_data(self, name_prefix: str = '') -> None: prefix = '|'.join(filter(None, [name_prefix, self.label_full])) super().transform_data(prefix) @@ -436,7 +405,6 @@ def __init__( self.balanced = balanced def create_model(self, model: FlowSystemModel) -> StorageModel: - self._plausibility_checks() self.submodel = StorageModel(model, self) return self.submodel @@ -485,62 +453,6 @@ def transform_data(self, name_prefix: str = '') -> None: f'{prefix}|capacity_in_flow_hours', self.capacity_in_flow_hours, dims=['period', 'scenario'] ) - def _plausibility_checks(self) -> None: - """ - Check for infeasible or uncommon combinations of parameters - """ - super()._plausibility_checks() - - # Validate string values and set flag - initial_is_last = False - if isinstance(self.initial_charge_state, str): - if self.initial_charge_state == 'lastValueOfSim': - initial_is_last = True - else: - raise PlausibilityError(f'initial_charge_state has undefined value: {self.initial_charge_state}') - - # Use new InvestParameters methods to get capacity bounds - if isinstance(self.capacity_in_flow_hours, InvestParameters): - minimum_capacity = self.capacity_in_flow_hours.minimum_or_fixed_size - maximum_capacity = self.capacity_in_flow_hours.maximum_or_fixed_size - else: - maximum_capacity = self.capacity_in_flow_hours - minimum_capacity = self.capacity_in_flow_hours - - # Initial capacity should not constraint investment decision - minimum_initial_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=0) - maximum_initial_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=0) - - # Only perform numeric comparisons if not using 'lastValueOfSim' - if not initial_is_last: - if (self.initial_charge_state > maximum_initial_capacity).any(): - raise PlausibilityError( - f'{self.label_full}: {self.initial_charge_state=} ' - f'is constraining the investment decision. Chosse a value above {maximum_initial_capacity}' - ) - if (self.initial_charge_state < minimum_initial_capacity).any(): - raise PlausibilityError( - f'{self.label_full}: {self.initial_charge_state=} ' - f'is constraining the investment decision. Chosse a value below {minimum_initial_capacity}' - ) - - if self.balanced: - if not isinstance(self.charging.size, InvestParameters) or not isinstance( - self.discharging.size, InvestParameters - ): - raise PlausibilityError( - f'Balancing charging and discharging Flows in {self.label_full} is only possible with Investments.' - ) - - if (self.charging.size.minimum_size > self.discharging.size.maximum_size).any() or ( - self.charging.size.maximum_size < self.discharging.size.minimum_size - ).any(): - raise PlausibilityError( - f'Balancing charging and discharging Flows in {self.label_full} need compatible minimum and maximum sizes.' - f'Got: {self.charging.size.minimum_size=}, {self.charging.size.maximum_size=} and ' - f'{self.discharging.size.minimum_size=}, {self.discharging.size.maximum_size=}.' - ) - def __repr__(self) -> str: """Return string representation.""" # Use build_repr_from_init directly to exclude charging and discharging @@ -697,34 +609,7 @@ def __init__( self.absolute_losses = absolute_losses self.balanced = balanced - def _plausibility_checks(self): - super()._plausibility_checks() - # check buses: - if self.in2 is not None: - assert self.in2.bus == self.out1.bus, ( - f'Output 1 and Input 2 do not start/end at the same Bus: {self.out1.bus=}, {self.in2.bus=}' - ) - if self.out2 is not None: - assert self.out2.bus == self.in1.bus, ( - f'Input 1 and Output 2 do not start/end at the same Bus: {self.in1.bus=}, {self.out2.bus=}' - ) - - if self.balanced: - if self.in2 is None: - raise ValueError('Balanced Transmission needs InvestParameters in both in-Flows') - if not isinstance(self.in1.size, InvestParameters) or not isinstance(self.in2.size, InvestParameters): - raise ValueError('Balanced Transmission needs InvestParameters in both in-Flows') - if (self.in1.size.minimum_or_fixed_size > self.in2.size.maximum_or_fixed_size).any() or ( - self.in1.size.maximum_or_fixed_size < self.in2.size.minimum_or_fixed_size - ).any(): - raise ValueError( - f'Balanced Transmission needs compatible minimum and maximum sizes.' - f'Got: {self.in1.size.minimum_size=}, {self.in1.size.maximum_size=}, {self.in1.size.fixed_size=} and ' - f'{self.in2.size.minimum_size=}, {self.in2.size.maximum_size=}, {self.in2.size.fixed_size=}.' - ) - def create_model(self, model) -> TransmissionModel: - self._plausibility_checks() self.submodel = TransmissionModel(model, self) return self.submodel @@ -746,6 +631,41 @@ def __init__(self, model: FlowSystemModel, element: Transmission): super().__init__(model, element) + def _validate(self): + """Validate Transmission configuration for modeling.""" + super()._validate() + + # Check bus connections for bidirectional transmission + if self.element.in2 is not None: + assert self.element.in2.bus == self.element.out1.bus, ( + f'Output 1 and Input 2 do not start/end at the same Bus: ' + f'{self.element.out1.bus=}, {self.element.in2.bus=}' + ) + if self.element.out2 is not None: + assert self.element.out2.bus == self.element.in1.bus, ( + f'Input 1 and Output 2 do not start/end at the same Bus: ' + f'{self.element.in1.bus=}, {self.element.out2.bus=}' + ) + + # Validate balanced transmission requirements + if self.element.balanced: + if self.element.in2 is None: + raise ValueError('Balanced Transmission needs InvestParameters in both in-Flows') + if not isinstance(self.element.in1.size, InvestParameters) or not isinstance( + self.element.in2.size, InvestParameters + ): + raise ValueError('Balanced Transmission needs InvestParameters in both in-Flows') + if (self.element.in1.size.minimum_or_fixed_size > self.element.in2.size.maximum_or_fixed_size).any() or ( + self.element.in1.size.maximum_or_fixed_size < self.element.in2.size.minimum_or_fixed_size + ).any(): + raise ValueError( + f'Balanced Transmission needs compatible minimum and maximum sizes. ' + f'Got: {self.element.in1.size.minimum_size=}, {self.element.in1.size.maximum_size=}, ' + f'{self.element.in1.size.fixed_size=} and ' + f'{self.element.in2.size.minimum_size=}, {self.element.in2.size.maximum_size=}, ' + f'{self.element.in2.size.fixed_size=}.' + ) + def _do_modeling(self): """Create variables, constraints, and nested submodels""" super()._do_modeling() @@ -787,6 +707,48 @@ def __init__(self, model: FlowSystemModel, element: LinearConverter): self.piecewise_conversion: PiecewiseConversion | None = None super().__init__(model, element) + def _validate(self): + """Validate LinearConverter configuration for modeling.""" + super()._validate() + + # Check that either conversion_factors or piecewise_conversion is defined + if not self.element.conversion_factors and not self.element.piecewise_conversion: + raise PlausibilityError('Either conversion_factors or piecewise_conversion must be defined!') + + # Check mutual exclusivity + if self.element.conversion_factors and self.element.piecewise_conversion: + raise PlausibilityError( + 'Only one of conversion_factors or piecewise_conversion can be defined, not both!' + ) + + # Validate conversion_factors + if self.element.conversion_factors: + # Check degrees of freedom + if self.element.degrees_of_freedom <= 0: + raise PlausibilityError( + f'Too Many conversion_factors_specified. Care that you use less conversion_factors ' + f'then inputs + outputs!! With {len(self.element.inputs + self.element.outputs)} inputs and outputs, ' + f'use not more than {len(self.element.inputs + self.element.outputs) - 1} conversion_factors!' + ) + + # Validate flow names in conversion_factors + for conversion_factor in self.element.conversion_factors: + for flow in conversion_factor: + if flow not in self.element.flows: + raise PlausibilityError( + f'{self.element.label}: Flow {flow} in conversion_factors is not in inputs/outputs' + ) + + # Warn about piecewise_conversion with variable InvestParameters + if self.element.piecewise_conversion: + for flow in self.element.flows.values(): + if isinstance(flow.size, InvestParameters) and flow.size.fixed_size is None: + logger.warning( + f'Using a Flow with variable size (InvestParameters without fixed_size) ' + f'and a piecewise_conversion in {self.element.label_full} is uncommon. Please verify intent ' + f'({flow.label_full}).' + ) + def _do_modeling(self): """Create variables, constraints, and nested submodels""" super()._do_modeling() @@ -836,6 +798,63 @@ class StorageModel(ComponentModel): def __init__(self, model: FlowSystemModel, element: Storage): super().__init__(model, element) + def _validate(self): + """Validate Storage configuration for modeling.""" + super()._validate() + + # Validate string values and set flag + initial_is_last = False + if isinstance(self.element.initial_charge_state, str): + if self.element.initial_charge_state == 'lastValueOfSim': + initial_is_last = True + else: + raise PlausibilityError( + f'initial_charge_state has undefined value: {self.element.initial_charge_state}' + ) + + # Use new InvestParameters methods to get capacity bounds + if isinstance(self.element.capacity_in_flow_hours, InvestParameters): + minimum_capacity = self.element.capacity_in_flow_hours.minimum_or_fixed_size + maximum_capacity = self.element.capacity_in_flow_hours.maximum_or_fixed_size + else: + maximum_capacity = self.element.capacity_in_flow_hours + minimum_capacity = self.element.capacity_in_flow_hours + + # Initial capacity should not constraint investment decision + minimum_initial_capacity = maximum_capacity * self.element.relative_minimum_charge_state.isel(time=0) + maximum_initial_capacity = minimum_capacity * self.element.relative_maximum_charge_state.isel(time=0) + + # Only perform numeric comparisons if not using 'lastValueOfSim' + if not initial_is_last: + if (self.element.initial_charge_state > maximum_initial_capacity).any(): + raise PlausibilityError( + f'{self.element.label_full}: {self.element.initial_charge_state=} ' + f'is constraining the investment decision. Chosse a value above {maximum_initial_capacity}' + ) + if (self.element.initial_charge_state < minimum_initial_capacity).any(): + raise PlausibilityError( + f'{self.element.label_full}: {self.element.initial_charge_state=} ' + f'is constraining the investment decision. Chosse a value below {minimum_initial_capacity}' + ) + + # Validate balanced flow requirements + if self.element.balanced: + if not isinstance(self.element.charging.size, InvestParameters) or not isinstance( + self.element.discharging.size, InvestParameters + ): + raise PlausibilityError( + f'Balancing charging and discharging Flows in {self.element.label_full} is only possible with Investments.' + ) + + if (self.element.charging.size.minimum_size > self.element.discharging.size.maximum_size).any() or ( + self.element.charging.size.maximum_size < self.element.discharging.size.minimum_size + ).any(): + raise PlausibilityError( + f'Balancing charging and discharging Flows in {self.element.label_full} need compatible minimum and maximum sizes. ' + f'Got: {self.element.charging.size.minimum_size=}, {self.element.charging.size.maximum_size=} and ' + f'{self.element.discharging.size.minimum_size=}, {self.element.discharging.size.maximum_size=}.' + ) + def _do_modeling(self): """Create variables, constraints, and nested submodels""" super()._do_modeling() diff --git a/flixopt/effects.py b/flixopt/effects.py index 074809a5a..2901c11fe 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -378,15 +378,9 @@ def transform_data(self, name_prefix: str = '') -> None: ) def create_model(self, model: FlowSystemModel) -> EffectModel: - self._plausibility_checks() self.submodel = EffectModel(model, self) return self.submodel - def _plausibility_checks(self) -> None: - # TODO: Check for plausibility - pass - - class EffectModel(ElementModel): element: Effect # Type hint @@ -474,7 +468,6 @@ def __init__(self, *effects: Effect, truncate_repr: int | None = None): self.add_effects(*effects) def create_model(self, model: FlowSystemModel) -> EffectCollectionModel: - self._plausibility_checks() self.submodel = EffectCollectionModel(model, self) return self.submodel @@ -528,29 +521,6 @@ def get_effect_label(eff: Effect | str) -> str: return {get_effect_label(effect): value for effect, value in effect_values_user.items()} return {self.standard_effect.label: effect_values_user} - def _plausibility_checks(self) -> None: - # Check circular loops in effects: - temporal, periodic = self.calculate_effect_share_factors() - - # Validate all referenced effects (both sources and targets) exist - edges = list(temporal.keys()) + list(periodic.keys()) - unknown_sources = {src for src, _ in edges if src not in self} - unknown_targets = {tgt for _, tgt in edges if tgt not in self} - unknown = unknown_sources | unknown_targets - if unknown: - raise KeyError(f'Unknown effects used in effect share mappings: {sorted(unknown)}') - - temporal_cycles = detect_cycles(tuples_to_adjacency_list([key for key in temporal])) - periodic_cycles = detect_cycles(tuples_to_adjacency_list([key for key in periodic])) - - if temporal_cycles: - cycle_str = '\n'.join([' -> '.join(cycle) for cycle in temporal_cycles]) - raise ValueError(f'Error: circular temporal-shares detected:\n{cycle_str}') - - if periodic_cycles: - cycle_str = '\n'.join([' -> '.join(cycle) for cycle in periodic_cycles]) - raise ValueError(f'Error: circular periodic-shares detected:\n{cycle_str}') - def __getitem__(self, effect: str | Effect | None) -> Effect: """ Get an effect by label, or return the standard effect if None is passed @@ -675,8 +645,34 @@ def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) - raise TypeError(f'Penalty shares must be scalar expressions! ({expression.ndim=})') self.penalty.add_share(name, expression, dims=()) + def _validate(self): + """Validate EffectCollection configuration for modeling.""" + # Check circular loops in effects + temporal, periodic = self.effects.calculate_effect_share_factors() + + # Validate all referenced effects (both sources and targets) exist + edges = list(temporal.keys()) + list(periodic.keys()) + unknown_sources = {src for src, _ in edges if src not in self.effects} + unknown_targets = {tgt for _, tgt in edges if tgt not in self.effects} + unknown = unknown_sources | unknown_targets + if unknown: + raise KeyError(f'Unknown effects used in effect share mappings: {sorted(unknown)}') + + # Detect circular dependencies + temporal_cycles = detect_cycles(tuples_to_adjacency_list([key for key in temporal])) + periodic_cycles = detect_cycles(tuples_to_adjacency_list([key for key in periodic])) + + if temporal_cycles: + cycle_str = '\n'.join([' -> '.join(cycle) for cycle in temporal_cycles]) + raise ValueError(f'Error: circular temporal-shares detected:\n{cycle_str}') + + if periodic_cycles: + cycle_str = '\n'.join([' -> '.join(cycle) for cycle in periodic_cycles]) + raise ValueError(f'Error: circular periodic-shares detected:\n{cycle_str}') + def _do_modeling(self): """Create variables, constraints, and nested submodels""" + self._validate() super()._do_modeling() # Create EffectModel for each effect diff --git a/flixopt/elements.py b/flixopt/elements.py index b57cda292..1e86e59e5 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -90,13 +90,11 @@ def __init__( self.on_off_parameters = on_off_parameters self.prevent_simultaneous_flows: list[Flow] = prevent_simultaneous_flows or [] - self._check_unique_flow_labels() self._connect_flows() self.flows: dict[str, Flow] = {flow.label: flow for flow in self.inputs + self.outputs} def create_model(self, model: FlowSystemModel) -> ComponentModel: - self._plausibility_checks() self.submodel = ComponentModel(model, self) return self.submodel @@ -116,16 +114,6 @@ def transform_data(self, name_prefix: str = '') -> None: for flow in self.inputs + self.outputs: flow.transform_data() # Flow doesnt need the name_prefix - def _check_unique_flow_labels(self): - all_flow_labels = [flow.label for flow in self.inputs + self.outputs] - - if len(set(all_flow_labels)) != len(all_flow_labels): - duplicates = {label for label in all_flow_labels if all_flow_labels.count(label) > 1} - raise ValueError(f'Flow names must be unique! "{self.label_full}" got 2 or more of: {duplicates}') - - def _plausibility_checks(self) -> None: - self._check_unique_flow_labels() - def _connect_flows(self): # Inputs for flow in self.inputs: @@ -245,7 +233,6 @@ def __init__( self.outputs: list[Flow] = [] def create_model(self, model: FlowSystemModel) -> BusModel: - self._plausibility_checks() self.submodel = BusModel(model, self) return self.submodel @@ -261,18 +248,6 @@ def transform_data(self, name_prefix: str = '') -> None: f'{prefix}|excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour ) - def _plausibility_checks(self) -> None: - if self.excess_penalty_per_flow_hour is not None: - zero_penalty = np.all(np.equal(self.excess_penalty_per_flow_hour, 0)) - if zero_penalty: - logger.warning( - f'In Bus {self.label_full}, the excess_penalty_per_flow_hour is 0. Use "None" or a value > 0.' - ) - if len(self.inputs) == 0 and len(self.outputs) == 0: - raise ValueError( - f'Bus "{self.label_full}" has no Flows connected to it. Please remove it from the FlowSystem' - ) - @property def with_excess(self) -> bool: return False if self.excess_penalty_per_flow_hour is None else True @@ -478,7 +453,6 @@ def __init__( self._bus_object = None def create_model(self, model: FlowSystemModel) -> FlowModel: - self._plausibility_checks() self.submodel = FlowModel(model, self) return self.submodel @@ -516,65 +490,76 @@ def transform_data(self, name_prefix: str = '') -> None: else: self.size = self._fit_coords(f'{prefix}|size', self.size, dims=['period', 'scenario']) - def _plausibility_checks(self) -> None: - # TODO: Incorporate into Variable? (Lower_bound can not be greater than upper bound - if (self.relative_minimum > self.relative_maximum).any(): - raise PlausibilityError(self.label_full + ': Take care, that relative_minimum <= relative_maximum!') + @property + def label_full(self) -> str: + return f'{self.component}({self.label})' + + @property + def size_is_fixed(self) -> bool: + # Wenn kein InvestParameters existiert --> True; Wenn Investparameter, den Wert davon nehmen + return False if (isinstance(self.size, InvestParameters) and self.size.fixed_size is None) else True + + def _format_invest_params(self, params: InvestParameters) -> str: + """Format InvestParameters for display.""" + return f'size: {params.format_for_repr()}' + + +class FlowModel(ElementModel): + element: Flow # Type hint + + def __init__(self, model: FlowSystemModel, element: Flow): + super().__init__(model, element) + + def _validate(self): + """Validate Flow configuration for modeling.""" + super()._validate() + + # Check relative_minimum <= relative_maximum + if (self.element.relative_minimum > self.element.relative_maximum).any(): + raise PlausibilityError( + self.element.label_full + ': Take care, that relative_minimum <= relative_maximum!' + ) - if not isinstance(self.size, InvestParameters) and ( - np.any(self.size == CONFIG.Modeling.big) and self.fixed_relative_profile is not None - ): # Default Size --> Most likely by accident + # Warn about default size with fixed_relative_profile + if not isinstance(self.element.size, InvestParameters) and ( + np.any(self.element.size == CONFIG.Modeling.big) and self.element.fixed_relative_profile is not None + ): logger.warning( - f'Flow "{self.label_full}" has no size assigned, but a "fixed_relative_profile". ' + f'Flow "{self.element.label_full}" has no size assigned, but a "fixed_relative_profile". ' f'The default size is {CONFIG.Modeling.big}. As "flow_rate = size * fixed_relative_profile", ' - f'the resulting flow_rate will be very high. To fix this, assign a size to the Flow {self}.' + f'the resulting flow_rate will be very high. To fix this, assign a size to the Flow {self.element}.' ) - if self.fixed_relative_profile is not None and self.on_off_parameters is not None: + # Warn about fixed_relative_profile with on_off_parameters + if self.element.fixed_relative_profile is not None and self.element.on_off_parameters is not None: logger.warning( - f'Flow {self.label_full} has both a fixed_relative_profile and an on_off_parameters.' + f'Flow {self.element.label_full} has both a fixed_relative_profile and an on_off_parameters. ' f'This will allow the flow to be switched on and off, effectively differing from the fixed_flow_rate.' ) - if np.any(self.relative_minimum > 0) and self.on_off_parameters is None: + # Warn about relative_minimum > 0 without on_off_parameters + if np.any(self.element.relative_minimum > 0) and self.element.on_off_parameters is None: logger.warning( - f'Flow {self.label_full} has a relative_minimum of {self.relative_minimum} and no on_off_parameters. ' - f'This prevents the flow_rate from switching off (flow_rate = 0). ' + f'Flow {self.element.label_full} has a relative_minimum of {self.element.relative_minimum} ' + f'and no on_off_parameters. This prevents the flow_rate from switching off (flow_rate = 0). ' f'Consider using on_off_parameters to allow the flow to be switched on and off.' ) - if self.previous_flow_rate is not None: + # Validate previous_flow_rate type + if self.element.previous_flow_rate is not None: if not any( [ - isinstance(self.previous_flow_rate, np.ndarray) and self.previous_flow_rate.ndim == 1, - isinstance(self.previous_flow_rate, (int, float, list)), + isinstance(self.element.previous_flow_rate, np.ndarray) + and self.element.previous_flow_rate.ndim == 1, + isinstance(self.element.previous_flow_rate, (int, float, list)), ] ): raise TypeError( - f'previous_flow_rate must be None, a scalar, a list of scalars or a 1D-numpy-array. Got {type(self.previous_flow_rate)}. ' + f'previous_flow_rate must be None, a scalar, a list of scalars or a 1D-numpy-array. ' + f'Got {type(self.element.previous_flow_rate)}. ' f'Different values in different periods or scenarios are not yet supported.' ) - @property - def label_full(self) -> str: - return f'{self.component}({self.label})' - - @property - def size_is_fixed(self) -> bool: - # Wenn kein InvestParameters existiert --> True; Wenn Investparameter, den Wert davon nehmen - return False if (isinstance(self.size, InvestParameters) and self.size.fixed_size is None) else True - - def _format_invest_params(self, params: InvestParameters) -> str: - """Format InvestParameters for display.""" - return f'size: {params.format_for_repr()}' - - -class FlowModel(ElementModel): - element: Flow # Type hint - - def __init__(self, model: FlowSystemModel, element: Flow): - super().__init__(model, element) - def _do_modeling(self): """Create variables, constraints, and nested submodels""" super()._do_modeling() @@ -810,6 +795,25 @@ def __init__(self, model: FlowSystemModel, element: Bus): self.excess_output: linopy.Variable | None = None super().__init__(model, element) + def _validate(self): + """Validate Bus configuration for modeling.""" + super()._validate() + + # Check excess penalty value + if self.element.excess_penalty_per_flow_hour is not None: + zero_penalty = np.all(np.equal(self.element.excess_penalty_per_flow_hour, 0)) + if zero_penalty: + logger.warning( + f'In Bus {self.element.label_full}, the excess_penalty_per_flow_hour is 0. ' + f'Use "None" or a value > 0.' + ) + + # Check bus has connected flows + if len(self.element.inputs) == 0 and len(self.element.outputs) == 0: + raise ValueError( + f'Bus "{self.element.label_full}" has no Flows connected to it. Please remove it from the FlowSystem' + ) + def _do_modeling(self): """Create variables, constraints, and nested submodels""" super()._do_modeling() @@ -857,6 +861,16 @@ def __init__(self, model: FlowSystemModel, element: Component): self.on_off: OnOffModel | None = None super().__init__(model, element) + def _validate(self): + """Validate Component configuration for modeling.""" + super()._validate() + + # Check unique flow labels + all_flow_labels = [flow.label for flow in self.element.inputs + self.element.outputs] + if len(set(all_flow_labels)) != len(all_flow_labels): + duplicates = {label for label in all_flow_labels if all_flow_labels.count(label) > 1} + raise ValueError(f'Flow names must be unique! "{self.element.label_full}" got 2 or more of: {duplicates}') + def _do_modeling(self): """Create variables, constraints, and nested submodels""" super()._do_modeling() diff --git a/flixopt/structure.py b/flixopt/structure.py index 452094d9a..0ae1bc592 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -931,11 +931,6 @@ def __init__(self, label: str, meta_data: dict | None = None): self.submodel = None self._flow_system: FlowSystem | None = None - def _plausibility_checks(self) -> None: - """This function is used to do some basic plausibility checks for each Element during initialization. - This is run after all data is transformed to the correct format/type""" - raise NotImplementedError('Every Element needs a _plausibility_checks() method') - def create_model(self, model: FlowSystemModel) -> ElementModel: raise NotImplementedError('Every Element needs a create_model() method') @@ -1580,6 +1575,27 @@ def __init__(self, model: FlowSystemModel, element: Element): super().__init__(model, label_of_element=element.label_full, label_of_model=element.label_full) self._model.add_submodels(self, short_name=self.label_of_model) + def _validate(self): + """ + Validate that the element configuration can be properly modeled. + Override in subclasses to add element-specific validation. + + This method checks modeling constraints - whether the element's configuration + can be translated into a valid optimization model (e.g., feasible constraints, + solvable systems, valid objective function terms). + """ + pass + + def _do_modeling(self): + """ + Create variables, constraints, and submodels for this element. + + Calls _validate() first to validate the element configuration + before creating any model components. + """ + self._validate() + super()._do_modeling() + def results_structure(self): return { 'label': self.label_full, diff --git a/tests/test_component.py b/tests/test_component.py index be1eecf3b..b1ca04744 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -1,4 +1,5 @@ import numpy as np +import pandas as pd import pytest import flixopt as fx @@ -16,7 +17,7 @@ class TestComponentModel: def test_flow_label_check(self): - """Test that flow model constraints are correctly generated.""" + """Test that duplicate flow labels are detected during model creation.""" inputs = [ fx.Flow('Q_th_Last', 'Fernwärme', relative_minimum=np.ones(10) * 0.1), fx.Flow('Q_Gas', 'Fernwärme', relative_minimum=np.ones(10) * 0.1), @@ -25,8 +26,19 @@ def test_flow_label_check(self): fx.Flow('Q_th_Last', 'Gas', relative_minimum=np.ones(10) * 0.01), fx.Flow('Q_Gas', 'Gas', relative_minimum=np.ones(10) * 0.01), ] - with pytest.raises(ValueError, match='Flow names must be unique!'): - _ = flixopt.elements.Component('TestComponent', inputs=inputs, outputs=outputs) + # Component creation should succeed + comp = flixopt.elements.Component('TestComponent', inputs=inputs, outputs=outputs) + + # But model creation should fail due to duplicate labels + timesteps = pd.date_range('2020-01-01', periods=10, freq='h') + flow_system = fx.FlowSystem(timesteps=timesteps) + bus1 = fx.Bus('Fernwärme') + bus2 = fx.Bus('Gas') + flow_system.add_elements(bus1, bus2, comp) + + with pytest.raises(ValueError, match='already exists in flows'): + calculation = fx.FullCalculation('test', flow_system) + calculation.do_modeling() def test_component(self, basic_flow_system_linopy_coords, coords_config): """Test that flow model constraints are correctly generated.""" From f765e25cef0d45f1568a23985ab634e6c38009ba Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Nov 2025 08:47:45 +0100 Subject: [PATCH 2/4] ruff format --- flixopt/components.py | 4 +--- flixopt/effects.py | 1 + flixopt/elements.py | 4 +--- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 8ad9199ac..3b44d1043 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -717,9 +717,7 @@ def _validate(self): # Check mutual exclusivity if self.element.conversion_factors and self.element.piecewise_conversion: - raise PlausibilityError( - 'Only one of conversion_factors or piecewise_conversion can be defined, not both!' - ) + raise PlausibilityError('Only one of conversion_factors or piecewise_conversion can be defined, not both!') # Validate conversion_factors if self.element.conversion_factors: diff --git a/flixopt/effects.py b/flixopt/effects.py index 2901c11fe..d6ccb8142 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -381,6 +381,7 @@ def create_model(self, model: FlowSystemModel) -> EffectModel: self.submodel = EffectModel(model, self) return self.submodel + class EffectModel(ElementModel): element: Effect # Type hint diff --git a/flixopt/elements.py b/flixopt/elements.py index 1e86e59e5..c3ddcb92b 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -516,9 +516,7 @@ def _validate(self): # Check relative_minimum <= relative_maximum if (self.element.relative_minimum > self.element.relative_maximum).any(): - raise PlausibilityError( - self.element.label_full + ': Take care, that relative_minimum <= relative_maximum!' - ) + raise PlausibilityError(self.element.label_full + ': Take care, that relative_minimum <= relative_maximum!') # Warn about default size with fixed_relative_profile if not isinstance(self.element.size, InvestParameters) and ( From ec0d7c6bd33c7b757c29aeb067969bb1e785b08b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:32:23 +0100 Subject: [PATCH 3/4] Temp --- flixopt/elements.py | 68 +++++++++++++++++++++++++++----------------- flixopt/structure.py | 14 +++++++++ 2 files changed, 56 insertions(+), 26 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index c3ddcb92b..f37424bde 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -13,7 +13,7 @@ from . import io as fx_io from .config import CONFIG -from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser +from .core import PlausibilityError from .features import InvestmentModel, OnOffModel from .interface import InvestParameters, OnOffParameters from .modeling import BoundingPatterns, ModelingPrimitives, ModelingUtilitiesAbstract @@ -22,6 +22,7 @@ if TYPE_CHECKING: import linopy + from .core import Scalar, TemporalData, TemporalDataUser from .effects import TemporalEffectsUser from .flow_system import FlowSystem @@ -452,6 +453,36 @@ def __init__( self.bus = bus self._bus_object = None + # Validate simple data integrity + self.validate() + + def validate(self) -> None: + """Validate simple data integrity of Flow configuration.""" + super().validate() + + # Check relative_minimum <= relative_maximum (simple comparison) + if hasattr(self.relative_minimum, 'any'): # numpy array or xarray + if (self.relative_minimum > self.relative_maximum).any(): + raise ValueError(f'{self.label}: relative_minimum must be <= relative_maximum') + else: # scalar + if self.relative_minimum > self.relative_maximum: + raise ValueError(f'{self.label}: relative_minimum must be <= relative_maximum') + + # Validate previous_flow_rate type (basic type check) + if self.previous_flow_rate is not None: + valid_type = any( + [ + isinstance(self.previous_flow_rate, np.ndarray) and self.previous_flow_rate.ndim == 1, + isinstance(self.previous_flow_rate, (int, float, list)), + ] + ) + if not valid_type: + raise TypeError( + f'previous_flow_rate must be None, a scalar, a list of scalars or a 1D-numpy-array. ' + f'Got {type(self.previous_flow_rate)}. ' + f'Different values in different periods or scenarios are not yet supported.' + ) + def create_model(self, model: FlowSystemModel) -> FlowModel: self.submodel = FlowModel(model, self) return self.submodel @@ -511,16 +542,16 @@ def __init__(self, model: FlowSystemModel, element: Flow): super().__init__(model, element) def _validate(self): - """Validate Flow configuration for modeling.""" + """Validate Flow configuration for modeling (complex modeling concerns only).""" super()._validate() - # Check relative_minimum <= relative_maximum - if (self.element.relative_minimum > self.element.relative_maximum).any(): - raise PlausibilityError(self.element.label_full + ': Take care, that relative_minimum <= relative_maximum!') + # Note: Simple data validation (relative_minimum <= relative_maximum, previous_flow_rate type) + # is now in Flow.validate() - # Warn about default size with fixed_relative_profile + # Warn about default size with fixed_relative_profile (modeling concern) if not isinstance(self.element.size, InvestParameters) and ( - np.any(self.element.size == CONFIG.Modeling.big) and self.element.fixed_relative_profile is not None + np.any(np.asarray(self.element.size == CONFIG.Modeling.big)) + and self.element.fixed_relative_profile is not None ): logger.warning( f'Flow "{self.element.label_full}" has no size assigned, but a "fixed_relative_profile". ' @@ -528,36 +559,21 @@ def _validate(self): f'the resulting flow_rate will be very high. To fix this, assign a size to the Flow {self.element}.' ) - # Warn about fixed_relative_profile with on_off_parameters + # Warn about fixed_relative_profile with on_off_parameters (modeling interaction) if self.element.fixed_relative_profile is not None and self.element.on_off_parameters is not None: logger.warning( f'Flow {self.element.label_full} has both a fixed_relative_profile and an on_off_parameters. ' f'This will allow the flow to be switched on and off, effectively differing from the fixed_flow_rate.' ) - # Warn about relative_minimum > 0 without on_off_parameters - if np.any(self.element.relative_minimum > 0) and self.element.on_off_parameters is None: + # Warn about relative_minimum > 0 without on_off_parameters (modeling behavior) + if np.any(np.asarray(self.element.relative_minimum > 0)) and self.element.on_off_parameters is None: logger.warning( f'Flow {self.element.label_full} has a relative_minimum of {self.element.relative_minimum} ' f'and no on_off_parameters. This prevents the flow_rate from switching off (flow_rate = 0). ' f'Consider using on_off_parameters to allow the flow to be switched on and off.' ) - # Validate previous_flow_rate type - if self.element.previous_flow_rate is not None: - if not any( - [ - isinstance(self.element.previous_flow_rate, np.ndarray) - and self.element.previous_flow_rate.ndim == 1, - isinstance(self.element.previous_flow_rate, (int, float, list)), - ] - ): - raise TypeError( - f'previous_flow_rate must be None, a scalar, a list of scalars or a 1D-numpy-array. ' - f'Got {type(self.element.previous_flow_rate)}. ' - f'Different values in different periods or scenarios are not yet supported.' - ) - def _do_modeling(self): """Create variables, constraints, and nested submodels""" super()._do_modeling() @@ -799,7 +815,7 @@ def _validate(self): # Check excess penalty value if self.element.excess_penalty_per_flow_hour is not None: - zero_penalty = np.all(np.equal(self.element.excess_penalty_per_flow_hour, 0)) + zero_penalty = np.all(np.asarray(self.element.excess_penalty_per_flow_hour == 0)) if zero_penalty: logger.warning( f'In Bus {self.element.label_full}, the excess_penalty_per_flow_hour is 0. ' diff --git a/flixopt/structure.py b/flixopt/structure.py index 0ae1bc592..83d831199 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -289,6 +289,20 @@ def transform_data(self, name_prefix: str = '') -> None: """ raise NotImplementedError('Every Interface subclass needs a transform_data() method') + def validate(self) -> None: + """Validate simple data integrity of the interface. + + This method checks basic data consistency that doesn't require modeling context: + - Type checks + - Simple value ranges + - Data structure integrity + - Basic consistency between parameters + + Override in subclasses to add element-specific validation. + Complex modeling validation should be done in Model._validate() instead. + """ + pass + def _set_flow_system(self, flow_system: FlowSystem) -> None: """Store flow_system reference and propagate to nested Interface objects. From 10706aed24a0fe7e2f919da41cd0d8d13612dcb2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 17 Nov 2025 18:33:17 +0100 Subject: [PATCH 4/4] Move more validation to Model --- flixopt/components.py | 22 ++++++++-------- flixopt/elements.py | 58 ++++++++++++++++--------------------------- 2 files changed, 34 insertions(+), 46 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 3b44d1043..1aa6e29ca 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -637,15 +637,17 @@ def _validate(self): # Check bus connections for bidirectional transmission if self.element.in2 is not None: - assert self.element.in2.bus == self.element.out1.bus, ( - f'Output 1 and Input 2 do not start/end at the same Bus: ' - f'{self.element.out1.bus=}, {self.element.in2.bus=}' - ) + if self.element.in2.bus != self.element.out1.bus: + raise ValueError( + f'Output 1 and Input 2 do not start/end at the same Bus: ' + f'{self.element.out1.bus=}, {self.element.in2.bus=}' + ) if self.element.out2 is not None: - assert self.element.out2.bus == self.element.in1.bus, ( - f'Input 1 and Output 2 do not start/end at the same Bus: ' - f'{self.element.in1.bus=}, {self.element.out2.bus=}' - ) + if self.element.out2.bus != self.element.in1.bus: + raise ValueError( + f'Input 1 and Output 2 do not start/end at the same Bus: ' + f'{self.element.in1.bus=}, {self.element.out2.bus=}' + ) # Validate balanced transmission requirements if self.element.balanced: @@ -737,7 +739,7 @@ def _validate(self): f'{self.element.label}: Flow {flow} in conversion_factors is not in inputs/outputs' ) - # Warn about piecewise_conversion with variable InvestParameters + # Warn about piecewise_conversion with variable InvestParameters (modeling interaction) if self.element.piecewise_conversion: for flow in self.element.flows.values(): if isinstance(flow.size, InvestParameters) and flow.size.fixed_size is None: @@ -800,7 +802,7 @@ def _validate(self): """Validate Storage configuration for modeling.""" super()._validate() - # Validate string values and set flag + # Validate initial_charge_state string value initial_is_last = False if isinstance(self.element.initial_charge_state, str): if self.element.initial_charge_state == 'lastValueOfSim': diff --git a/flixopt/elements.py b/flixopt/elements.py index f37424bde..cac6ed5bd 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -453,36 +453,6 @@ def __init__( self.bus = bus self._bus_object = None - # Validate simple data integrity - self.validate() - - def validate(self) -> None: - """Validate simple data integrity of Flow configuration.""" - super().validate() - - # Check relative_minimum <= relative_maximum (simple comparison) - if hasattr(self.relative_minimum, 'any'): # numpy array or xarray - if (self.relative_minimum > self.relative_maximum).any(): - raise ValueError(f'{self.label}: relative_minimum must be <= relative_maximum') - else: # scalar - if self.relative_minimum > self.relative_maximum: - raise ValueError(f'{self.label}: relative_minimum must be <= relative_maximum') - - # Validate previous_flow_rate type (basic type check) - if self.previous_flow_rate is not None: - valid_type = any( - [ - isinstance(self.previous_flow_rate, np.ndarray) and self.previous_flow_rate.ndim == 1, - isinstance(self.previous_flow_rate, (int, float, list)), - ] - ) - if not valid_type: - raise TypeError( - f'previous_flow_rate must be None, a scalar, a list of scalars or a 1D-numpy-array. ' - f'Got {type(self.previous_flow_rate)}. ' - f'Different values in different periods or scenarios are not yet supported.' - ) - def create_model(self, model: FlowSystemModel) -> FlowModel: self.submodel = FlowModel(model, self) return self.submodel @@ -542,16 +512,32 @@ def __init__(self, model: FlowSystemModel, element: Flow): super().__init__(model, element) def _validate(self): - """Validate Flow configuration for modeling (complex modeling concerns only).""" + """Validate Flow configuration for modeling.""" super()._validate() - # Note: Simple data validation (relative_minimum <= relative_maximum, previous_flow_rate type) - # is now in Flow.validate() + # Validate relative_minimum <= relative_maximum (after xarray transformation) + if (self.element.relative_minimum > self.element.relative_maximum).any(): + raise ValueError(f'{self.element.label}: relative_minimum must be <= relative_maximum') + + # Validate previous_flow_rate type + if self.element.previous_flow_rate is not None: + valid_type = any( + [ + isinstance(self.element.previous_flow_rate, np.ndarray) + and self.element.previous_flow_rate.ndim == 1, + isinstance(self.element.previous_flow_rate, (int, float, list)), + ] + ) + if not valid_type: + raise TypeError( + f'previous_flow_rate must be None, a scalar, a list of scalars or a 1D-numpy-array. ' + f'Got {type(self.element.previous_flow_rate)}. ' + f'Different values in different periods or scenarios are not yet supported.' + ) # Warn about default size with fixed_relative_profile (modeling concern) if not isinstance(self.element.size, InvestParameters) and ( - np.any(np.asarray(self.element.size == CONFIG.Modeling.big)) - and self.element.fixed_relative_profile is not None + (self.element.size == CONFIG.Modeling.big).any() and self.element.fixed_relative_profile is not None ): logger.warning( f'Flow "{self.element.label_full}" has no size assigned, but a "fixed_relative_profile". ' @@ -567,7 +553,7 @@ def _validate(self): ) # Warn about relative_minimum > 0 without on_off_parameters (modeling behavior) - if np.any(np.asarray(self.element.relative_minimum > 0)) and self.element.on_off_parameters is None: + if (self.element.relative_minimum > 0).any() and self.element.on_off_parameters is None: logger.warning( f'Flow {self.element.label_full} has a relative_minimum of {self.element.relative_minimum} ' f'and no on_off_parameters. This prevents the flow_rate from switching off (flow_rate = 0). '