From cdf7df575e2568f6b1908ff7c77dfda3dfd1aac7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 8 Jul 2025 11:12:14 +0200 Subject: [PATCH 01/51] GitHub templates (#280) * Add Templates for issues etc --- .github/CONTRIBUTING.md | 85 +++++++++++++++++++++ .github/ISSUE_TEMPLATE/bug_report.yml | 81 ++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 8 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 88 ++++++++++++++++++++++ .github/pull_request_template.md | 20 +++++ 5 files changed, 282 insertions(+) create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/pull_request_template.md diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 000000000..6ea202fd6 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,85 @@ +# Contributing to FlixOpt + +Thanks for your interest in contributing to FlixOpt! 🚀 + +## Quick Start + +1. **Fork & Clone** + ```bash + git clone https://github.com/yourusername/flixopt.git + cd flixopt + ``` + +2. **Install for Development** + ```bash + pip install -e ".[full]" + ``` + +3. **Make Changes & Submit PR** + ```bash + git checkout -b feature/your-change + # Make your changes + git commit -m "Add: description of changes" + git push origin feature/your-change + # Create Pull Request on GitHub + ``` + +## How to Contribute + +### 🐛 **Found a Bug?** +Use our [bug report template](https://github.com/flixOpt/flixopt/issues/new?template=bug_report.yml) with: +- Minimal code example +- FlixOpt version, Python version, solver used +- Expected vs actual behavior + +### ✨ **Have a Feature Idea?** +Use our [feature request template](https://github.com/flixOpt/flixopt/issues/new?template=feature_request.yml) with: +- Clear energy system use case +- Specific examples of what you want to model + +### ❓ **Need Help?** +- Check the [documentation](https://flixopt.github.io/flixopt/latest/) first +- Search [existing issues](https://github.com/flixOpt/flixopt/issues) +- Start a [discussion](https://github.com/flixOpt/flixopt/discussions) + +## Code Guidelines + +- **Style**: Follow PEP 8, use descriptive names +- **Documentation**: Add docstrings with units (kW, kWh, etc.) if applicable +- **Energy Focus**: Use energy domain terminology consistently +- **Testing**: Test with different solvers when applicable + +### Example +```python +def create_storage( + label: str, + capacity_kwh: float, + charging_power_kw: float +) -> Storage: + """ + Create a battery storage component. + + Args: + label: Unique identifier + capacity_kwh: Storage capacity [kWh] + charging_power_kw: Maximum charging power [kW] + """ +``` + +## What We Welcome + +- 🔧 New energy components (batteries, heat pumps, etc.) +- 📚 Documentation improvements +- 🐛 Bug fixes +- 🧪 Test cases +- 💡 Energy system examples + +## Questions? + +- 📖 [Documentation](https://flixopt.github.io/flixopt/latest/) +- 💬 [Discussions](https://github.com/flixOpt/flixopt/discussions) +- 📧 Contact maintainers (see README) + +--- + +**Every contribution helps advance sustainable energy solutions! 🌱⚡** \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..e7facb6a7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,81 @@ +name: 🐛 Bug Report +description: Report a bug in flixopt +title: "[BUG] " +labels: ["type: bug"] +body: +- type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! +- type: checkboxes + id: checks + attributes: + label: Version Checks (indicate both or one) + options: + - label: > + I have confirmed this bug exists on the latest + [release](https://github.com/flixOpt/flixopt/releases) of FlixOpt. + - label: > + I have confirmed this bug exists on the current + [`main`](https://github.com/flixOpt/flixopt/tree/main) branch of FlixOpt. +- type: textarea + id: problem + attributes: + label: Issue Description + description: > + Please provide a description of the issue. + validations: + required: true +- type: textarea + id: example + validations: + required: true + attributes: + label: Reproducible Example + description: > + Please provide a minimal reproducible example. See how to [craft minimal bug reports](https://matthewrocklin.com/minimal-bug-reports). + placeholder: > + import flixopt as fx + + # Create simple energy system that reproduces the bug + timesteps = pd.date_range('2024-01-01', periods=24, freq='h') + flow_system = fx.FlowSystem(timesteps) + + # Add your components here... + render: python +- type: textarea + id: expected-behavior + validations: + required: true + attributes: + label: Expected Behavior + description: > + Please describe or show a code example of the expected behavior. +- type: dropdown + id: solver + attributes: + label: Solver Used + description: Which solver were you using when the bug occurred? + options: + - HiGHS (default) + - Gurobi + - CPLEX + - GLPK + - Other (please specify in description) + validations: + required: true +- type: textarea + id: version + attributes: + label: Installed Versions + description: > + Please share information on your environment. Paste the output below. + For conda: `conda env export` and for pip: `pip freeze`. + value: > +
+ + ``` + Replace this with your environment info + ``` + +
\ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..d031f8bfe --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: ❓ Questions & Discussion + url: https://github.com/flixOpt/flixopt/discussions + about: Ask questions and discuss with the community + - name: 📖 Documentation + url: https://flixopt.github.io/flixopt/latest/ + about: Check our documentation for guides and examples \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..112a8102c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,88 @@ +name: Feature Request +description: Suggest a new feature or enhancement for FlixOpt. +title: "[FEATURE] " +labels: ["type: feature"] +body: +- type: markdown + attributes: + value: | + Thanks for suggesting a new feature! Please provide as much detail as possible. +- type: checkboxes + id: checks + attributes: + label: Prerequisite Checks + options: + - label: > + I have searched the [existing issues](https://github.com/flixOpt/flixopt/issues) + and confirmed this feature doesn't already exist. + - label: > + I have checked the [documentation](https://flixopt.github.io/flixopt/latest/) + to ensure this feature isn't already available. +- type: textarea + id: problem + attributes: + label: Is your feature request related to a problem? + description: > + A clear and concise description of what the problem is. + placeholder: > + I'm always frustrated when modeling [specific energy system scenario]... +- type: textarea + id: solution + validations: + required: true + attributes: + label: Describe the solution you'd like + description: > + A clear and concise description of what you want to happen. + placeholder: > + I would like to be able to model [specific component/feature]... +- type: textarea + id: use-case + validations: + required: true + attributes: + label: Energy System Use Case + description: > + Describe your specific energy system modeling scenario and how this feature would help. + placeholder: > + I'm modeling a district heating system with seasonal thermal storage and need to... +- type: textarea + id: example + attributes: + label: Desired Code Example (optional) + description: > + If possible, show how you would like to use this feature in code. + placeholder: > + # Example of how the new feature might work + thermal_storage = fx.SeasonalStorage( + label='seasonal_storage', + capacity_mwh=1000, + efficiency=0.95 + ) + render: python +- type: dropdown + id: component-type + attributes: + label: Component Category (if applicable) + description: What type of energy system component does this relate to? + options: + - Storage (batteries, thermal storage, etc.) + - Generation (PV, wind, CHP, etc.) + - Conversion (heat pumps, boilers, etc.) + - Network/Grid (transmission, distribution) + - Optimization/Calculation (algorithms, solvers) + - Data/Input handling + - Results/Output + - Other +- type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: > + Describe any alternative solutions or workarounds you've considered. +- type: textarea + id: additional-context + attributes: + label: Additional context + description: > + Add any other context, research papers, or examples about the feature request here. \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..7efaeac97 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,20 @@ +## Description +Brief description of the changes in this PR. + +## Type of Change +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation update +- [ ] Code refactoring + +## Related Issues +Closes #(issue number) + +## Testing +- [ ] I have tested my changes +- [ ] Existing tests still pass + +## Checklist +- [ ] My code follows the project style +- [ ] I have updated documentation if needed +- [ ] I have added tests for new functionality (if applicable) \ No newline at end of file From 1691cc2c9e48b3c4acb4d66ddfb1a206164bdf32 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 8 Jul 2025 11:42:35 +0200 Subject: [PATCH 02/51] BUGFIX --- flixopt/effects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index 82aa63a43..911ec55eb 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -94,7 +94,7 @@ def transform_data(self, flow_system: 'FlowSystem'): f'{self.label_full}|minimum_operation_per_hour', self.minimum_operation_per_hour ) self.maximum_operation_per_hour = flow_system.create_time_series( - f'{self.label_full}|maximum_operation_per_hour', self.maximum_operation_per_hour, flow_system + f'{self.label_full}|maximum_operation_per_hour', self.maximum_operation_per_hour, ) self.specific_share_to_other_effects_operation = flow_system.create_effect_time_series( From 245f94b23366ed30cca16753f95b91226caf5730 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 8 Jul 2025 11:42:48 +0200 Subject: [PATCH 03/51] Improve Error Messages --- flixopt/core.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/flixopt/core.py b/flixopt/core.py index 08be18f1d..fa1d51803 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -62,13 +62,17 @@ def as_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray return xr.DataArray(data, coords=coords, dims=dims) elif isinstance(data, pd.DataFrame): if not data.index.equals(timesteps): - raise ConversionError("DataFrame index doesn't match timesteps index") + raise ConversionError(f"DataFrame index doesn't match timesteps index. " + f"Its missing the following time steps: {timesteps.difference(data.index)}. " + f"Some parameters might need an extra timestep at the end.") if not len(data.columns) == 1: raise ConversionError('DataFrame must have exactly one column') return xr.DataArray(data.values.flatten(), coords=coords, dims=dims) elif isinstance(data, pd.Series): if not data.index.equals(timesteps): - raise ConversionError("Series index doesn't match timesteps index") + raise ConversionError(f"Series index doesn't match timesteps index. " + f"Its missing the following time steps: {timesteps.difference(data.index)}. " + f"Some parameters might need an extra timestep at the end.") return xr.DataArray(data.values, coords=coords, dims=dims) elif isinstance(data, np.ndarray): if data.ndim != 1: From 4c33aada5bb1ac232ae5148f21fc91e82e8d92db Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 8 Jul 2025 11:48:42 +0200 Subject: [PATCH 04/51] Update CHANGELOG.md --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d692d5e5..ea5b7c958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.1.3] - 2025-07-08 + +### Fixed +- Using `Effect.maximum_operation_per_hour` raised an error, needing an extra timestep. This has been fixed. + ## [2.1.2] - 2025-06-14 ### Fixed From 888935cd0720a392cdb0eee0b5bd07cd40196ce7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 8 Jul 2025 11:51:00 +0200 Subject: [PATCH 05/51] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea5b7c958..f805ea266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.1.3] - 2025-07-08 ### Fixed -- Using `Effect.maximum_operation_per_hour` raised an error, needing an extra timestep. This has been fixed. +- Using `Effect.maximum_operation_per_hour` raised an error, needing an extra timestep. This has been fixed thanks to @PRse4. ## [2.1.2] - 2025-06-14 From 48c1860ba7950e6d3a37db92e6124f0747031f26 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 8 Jul 2025 13:05:32 +0200 Subject: [PATCH 06/51] Update CHANGELOG.md --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f805ea266..ed0e0f2c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.1.4] - 2025-07-08 + +### Fixed +- Fixing release notes of 2.1.3, as well as documentation build. + + ## [2.1.3] - 2025-07-08 ### Fixed From 106574d236a8f8e60482793b07226a301f67aac5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 8 Jul 2025 13:29:47 +0200 Subject: [PATCH 07/51] Fix Workflow --- .github/workflows/python-app.yaml | 5 +++++ CHANGELOG.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/python-app.yaml b/.github/workflows/python-app.yaml index 54a126a51..3e7ae84ba 100644 --- a/.github/workflows/python-app.yaml +++ b/.github/workflows/python-app.yaml @@ -200,6 +200,11 @@ jobs: with: python-version: "3.11" + - name: Sync changelog to docs + run: | + cp CHANGELOG.md docs/changelog.md + echo "✅ Synced changelog to docs" + - name: Install documentation dependencies run: | python -m pip install --upgrade pip diff --git a/CHANGELOG.md b/CHANGELOG.md index ed0e0f2c7..c52c66a2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.1.5] - 2025-07-08 + +### Fixed +- Fixed Docs deployment + ## [2.1.4] - 2025-07-08 ### Fixed From f82556aafaafa5dfb2f3e109bcdc705b5d348013 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 10:23:03 +0200 Subject: [PATCH 08/51] First steps --- flixopt/features.py | 905 +++++++++++++++++++++++++++---------------- flixopt/structure.py | 21 + 2 files changed, 598 insertions(+), 328 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index bc4bfb9b3..e495e2973 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -12,225 +12,623 @@ from .config import CONFIG from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions from .interface import InvestParameters, OnOffParameters, Piecewise -from .structure import Model, FlowSystemModel +from .structure import Model, FlowSystemModel, BaseFeatureModel logger = logging.getLogger('flixopt') -class InvestmentModel(Model): - """Class for modeling an investment""" +class ModelingPrimitives: + """Mathematical modeling primitives returning (variables, constraints) tuples""" - def __init__( - self, + @staticmethod + def binary_state_pair( + model: FlowSystemModel, name: str, coords: List[str] = None + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates complementary binary variables with completeness constraint. + + Mathematical formulation: + on[t] + off[t] = 1 ∀t + on[t], off[t] ∈ {0, 1} + + Returns: + variables: {'on': binary_var, 'off': binary_var} + constraints: {'complementary': constraint} + """ + coords = coords or ['time'] + + on = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(coords)) + off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(coords)) + + # Constraint: on + off = 1 + complementary = model.add_constraints(on + off == 1, name=f'{name}|complementary') + + variables = {'on': on, 'off': off} + constraints = {'complementary': complementary} + + return variables, constraints + + @staticmethod + def proportionally_bounded_variable( model: FlowSystemModel, - label_of_element: str, - parameters: InvestParameters, - defining_variable: [linopy.Variable], - relative_bounds_of_defining_variable: Tuple[TemporalData, TemporalData], - label: Optional[str] = None, - on_variable: Optional[linopy.Variable] = None, - ): - super().__init__(model, label_of_element, label) - self.size: Optional[Union[Scalar, linopy.Variable]] = None - self.is_invested: Optional[linopy.Variable] = None - self.scenario_of_investment: Optional[linopy.Variable] = None + name: str, + controlling_variable, + bounds: Tuple[TemporalData, TemporalData], + coords: List[str] = None, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates variable with bounds proportional to another variable. - self.piecewise_effects: Optional[PiecewiseEffectsModel] = None + Mathematical formulation: + lower_factor[t] * controller[t] ≤ variable[t] ≤ upper_factor[t] * controller[t] ∀t - self._on_variable = on_variable - self._defining_variable = defining_variable - self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable - self.parameters = parameters + Returns: + variables: {'variable': bounded_var} + constraints: {'lower_bound': constraint, 'upper_bound': constraint} + """ + coords = coords or ['time'] + variable = model.add_variables(name=f'{name}|bounded', coords=model.get_coords(coords)) - def do_modeling(self): - self.size = self.add( - self._model.add_variables( - lower=0 if self.parameters.optional else self.parameters.minimum_or_fixed_size, - upper=self.parameters.maximum_or_fixed_size, - name=f'{self.label_full}|size', - coords=self._model.get_coords(['year', 'scenario']), - ), - 'size', + lower_factor, upper_factor = bounds + + # Constraints: lower_factor * controller ≤ var ≤ upper_factor * controller + lower_bound = model.add_constraints( + variable >= controlling_variable * lower_factor, name=f'{name}|proportional_lb' + ) + upper_bound = model.add_constraints( + variable <= controlling_variable * upper_factor, name=f'{name}|proportional_ub' ) - # Optional - if self.parameters.optional: - self.is_invested = self.add( - self._model.add_variables( - binary=True, - name=f'{self.label_full}|is_invested', - coords=self._model.get_coords(['year', 'scenario']), - ), - 'is_invested', - ) + variables = {'variable': variable} + constraints = {'lower_bound': lower_bound, 'upper_bound': upper_bound} - self._create_bounds_for_optional_investment() + return variables, constraints - if self._model.flow_system.scenarios is not None: - self._create_bounds_for_scenarios() + @staticmethod + def expression_tracking_variable( + model: FlowSystemModel, + name: str, + tracked_expression, + bounds: Tuple[TemporalData, TemporalData] = None, + coords: List[str] = None, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates variable that equals a given expression. - # Bounds for defining variable - self._create_bounds_for_defining_variable() + Mathematical formulation: + tracker = expression + lower ≤ tracker ≤ upper (if bounds provided) - self._create_shares() + Returns: + variables: {'tracker': tracker_var} + constraints: {'tracking': constraint} + """ + coords = coords or ['year', 'scenario'] - def _create_shares(self): - # fix_effects: - fix_effects = self.parameters.fix_effects - if fix_effects != {}: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.is_invested * factor if self.is_invested is not None else factor - for effect, factor in fix_effects.items() - }, - target='invest', + if bounds: + tracker = model.add_variables( + lower=bounds[0], upper=bounds[1], name=f'{name}|tracker', coords=model.get_coords(coords) ) + else: + tracker = model.add_variables(name=f'{name}|tracker', coords=model.get_coords(coords)) - if self.parameters.divest_effects != {} and self.parameters.optional: - # share: divest_effects - isInvested * divest_effects - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={effect: -self.is_invested * factor + factor for effect, factor in self.parameters.divest_effects.items()}, - target='invest', - ) + # Constraint: tracker = expression + tracking = model.add_constraints(tracker == tracked_expression, name=f'{name}|tracking_eq') + + variables = {'tracker': tracker} + constraints = {'tracking': tracking} + + return variables, constraints + + @staticmethod + def state_transition_variables( + model: FlowSystemModel, name: str, state_variable, previous_state=0 + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates switch-on/off variables with state transition logic. + + Mathematical formulation: + switch_on[t] - switch_off[t] = state[t] - state[t-1] ∀t > 0 + switch_on[0] - switch_off[0] = state[0] - previous_state + switch_on[t] + switch_off[t] ≤ 1 ∀t + switch_on[t], switch_off[t] ∈ {0, 1} + + Returns: + variables: {'switch_on': binary_var, 'switch_off': binary_var} + constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint} + """ + switch_on = model.add_variables(binary=True, name=f'{name}|switch_on', coords=model.get_coords(['time'])) + switch_off = model.add_variables(binary=True, name=f'{name}|switch_off', coords=model.get_coords(['time'])) + + # State transition constraints for t > 0 + transition = model.add_constraints( + switch_on.isel(time=slice(1, None)) - switch_off.isel(time=slice(1, None)) + == state_variable.isel(time=slice(1, None)) - state_variable.isel(time=slice(None, -1)), + name=f'{name}|state_transition', + ) + + # Initial state transition for t = 0 + initial = model.add_constraints( + switch_on.isel(time=0) - switch_off.isel(time=0) == state_variable.isel(time=0) - previous_state, + name=f'{name}|initial_transition', + ) + + # At most one switch per timestep + mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|switch_mutex') + + variables = {'switch_on': switch_on, 'switch_off': switch_off} + constraints = {'transition': transition, 'initial': initial, 'mutex': mutex} + + return variables, constraints + + @staticmethod + def big_m_binary_bounds( + model: FlowSystemModel, + name: str, + variable, + binary_control, + size_variable, + relative_bounds: Tuple[TemporalData, TemporalData], + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates bounds controlled by both binary and continuous variables. + + Mathematical formulation: + variable[t] ≤ size[t] * upper_factor[t] ∀t + + If binary_control provided: + variable[t] ≥ M * (binary[t] - 1) + size[t] * lower_factor[t] ∀t + where M = max(size) * max(upper_factor) + Else: + variable[t] ≥ size[t] * lower_factor[t] ∀t + + Returns: + variables: {} (no new variables created) + constraints: {'upper_bound': constraint, 'lower_bound': constraint} + """ + rel_lower, rel_upper = relative_bounds + + # Upper bound: variable ≤ size * upper_factor + upper_bound = model.add_constraints(variable <= size_variable * rel_upper, name=f'{name}|size_upper_bound') - if self.parameters.specific_effects != {}: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={effect: self.size * factor for effect, factor in self.parameters.specific_effects.items()}, - target='invest', + if binary_control is not None: + # Big-M lower bound: variable ≥ M*(binary-1) + size*lower_factor + big_m = size_variable.max() * rel_upper.max() # Conservative big-M + lower_bound = model.add_constraints( + variable >= big_m * (binary_control - 1) + size_variable * rel_lower, + name=f'{name}|binary_controlled_lower_bound', ) + else: + # Simple lower bound: variable ≥ size * lower_factor + lower_bound = model.add_constraints(variable >= size_variable * rel_lower, name=f'{name}|size_lower_bound') - if self.parameters.piecewise_effects: - self.piecewise_effects = self.add( - PiecewiseEffectsModel( - model=self._model, - label_of_element=self.label_of_element, - piecewise_origin=(self.size.name, self.parameters.piecewise_effects.piecewise_origin), - piecewise_shares=self.parameters.piecewise_effects.piecewise_shares, - zero_point=self.is_invested, - ), - 'segments', + variables = {} # No new variables created + constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} + + return variables, constraints + + @staticmethod + def consecutive_duration_tracking( + model: FlowSystemModel, + name: str, + state_variable: linopy.Variable, + minimum_duration: Optional[TemporalData] = None, + maximum_duration: Optional[TemporalData] = None, + previous_duration: TemporalData = 0, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates consecutive duration tracking for a binary state variable. + + Mathematical formulation: + duration[t] ≤ state[t] * M ∀t + duration[t+1] ≤ duration[t] + hours_per_step[t] ∀t + duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M ∀t + duration[0] = (hours_per_step[0] + previous_duration) * state[0] + + If minimum_duration provided: + duration[t] ≥ (state[t-1] - state[t]) * minimum_duration[t-1] ∀t > 0 + + Args: + state_variable: Binary state variable to track duration for + minimum_duration: Optional minimum consecutive duration + maximum_duration: Optional maximum consecutive duration + previous_duration: Duration from before first timestep + + Returns: + variables: {'duration': duration_var} + constraints: {'upper_bound': constraint, 'forward': constraint, 'backward': constraint, ...} + """ + hours_per_step = model.hours_per_step + mega = hours_per_step.sum('time') + previous_duration # Big-M value + + # Duration variable + duration = model.add_variables( + lower=0, + upper=maximum_duration if maximum_duration is not None else mega, + coords=model.get_coords(['time']), + name=f'{name}|duration', + ) + + constraints = {} + + # Upper bound: duration[t] ≤ state[t] * M + constraints['upper_bound'] = model.add_constraints( + duration <= state_variable * mega, name=f'{name}|duration_upper_bound' + ) + + # Forward constraint: duration[t+1] ≤ duration[t] + hours_per_step[t] + constraints['forward'] = model.add_constraints( + duration.isel(time=slice(1, None)) + <= duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), + name=f'{name}|duration_forward', + ) + + # Backward constraint: duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M + constraints['backward'] = model.add_constraints( + duration.isel(time=slice(1, None)) + >= duration.isel(time=slice(None, -1)) + + hours_per_step.isel(time=slice(None, -1)) + + (state_variable.isel(time=slice(1, None)) - 1) * mega, + name=f'{name}|duration_backward', + ) + + # Initial condition: duration[0] = (hours_per_step[0] + previous_duration) * state[0] + constraints['initial'] = model.add_constraints( + duration.isel(time=0) + == (hours_per_step.isel(time=0) + previous_duration) * state_variable.isel(time=0), + name=f'{name}|duration_initial', + ) + + # Minimum duration constraint if provided + if minimum_duration is not None: + constraints['minimum'] = model.add_constraints( + duration.isel(time=slice(1, None)) + >= (state_variable.isel(time=slice(None, -1)) - state_variable.isel(time=slice(1, None))) + * minimum_duration.isel(time=slice(None, -1)), + name=f'{name}|duration_minimum', ) - self.piecewise_effects.do_modeling() - def _create_bounds_for_optional_investment(self): - if self.parameters.fixed_size: - # eq: investment_size = isInvested * fixed_size - self.add( - self._model.add_constraints( - self.size == self.is_invested * self.parameters.fixed_size, name=f'{self.label_full}|is_invested' - ), - 'is_invested', + # Handle initial condition for minimum duration + if previous_duration > 0 and previous_duration < minimum_duration.isel(time=0).max(): + constraints['initial_minimum'] = model.add_constraints( + state_variable.isel(time=0) == 1, name=f'{name}|duration_initial_minimum' + ) + + variables = {'duration': duration} + + return variables, constraints + + +class ModelingPatterns: + """High-level patterns that compose primitives and return (variables, constraints) tuples""" + + @staticmethod + def investment_sizing_pattern( + model: FlowSystemModel, + name: str, + size_bounds: Tuple[TemporalData, TemporalData], + controlled_variables: List[linopy.Variable] = None, + control_factors: List[Tuple[TemporalData, TemporalData]] = None, + optional: bool = False, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Complete investment sizing pattern with optional binary decision. + + Returns: + variables: {'size': size_var, 'is_invested': binary_var (if optional)} + constraints: {'investment_upper_bound': constraint, 'investment_lower_bound': constraint, ...} + """ + variables = {} + constraints = {} + + # Investment size variable + size_min, size_max = size_bounds + variables['size'] = model.add_variables( + lower=size_min, + upper=size_max, + name=f'{name}|investment_size', + coords=model.get_coords(['year', 'scenario']), + ) + + # Optional binary investment decision + if optional: + variables['is_invested'] = model.add_variables( + binary=True, name=f'{name}|is_invested', coords=model.get_coords(['year', 'scenario']) ) + # Link size to investment decision + if abs(size_min - size_max) < 1e-10: # Fixed size case + constraints['fixed_investment_size'] = model.add_constraints( + variables['size'] == variables['is_invested'] * size_max, name=f'{name}|fixed_investment_size' + ) + else: # Variable size case + constraints['investment_upper_bound'] = model.add_constraints( + variables['size'] <= variables['is_invested'] * size_max, name=f'{name}|investment_upper_bound' + ) + constraints['investment_lower_bound'] = model.add_constraints( + variables['size'] >= variables['is_invested'] * max(CONFIG.modeling.EPSILON, size_min), + name=f'{name}|investment_lower_bound', + ) + + # Control dependent variables + if controlled_variables and control_factors: + for i, (var, factors) in enumerate(zip(controlled_variables, control_factors)): + _, control_constraints = ModelingPrimitives.big_m_binary_bounds( + model, f'{name}|control_{i}', var, variables.get('is_invested'), variables['size'], factors + ) + # Flatten control constraints with indexed names + constraints[f'control_{i}_upper_bound'] = control_constraints['upper_bound'] + constraints[f'control_{i}_lower_bound'] = control_constraints['lower_bound'] + + return variables, constraints + + @staticmethod + def operational_binary_control_pattern( + model: FlowSystemModel, + name: str, + controlled_variables: List[linopy.Variable], + variable_bounds: List[Tuple[TemporalData, TemporalData]], + use_complement: bool = False, + track_total_duration: bool = False, + track_switches: bool = False, + previous_state=0, + duration_bounds: Tuple[TemporalData, TemporalData] = None, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Operational binary control with optional features. + + Returns: + variables: {'on': binary_var, 'off': binary_var (optional), 'total_duration': var (optional), ...} + constraints: {'complementary': constraint, 'control_0_lower': constraint, ...} + """ + variables = {} + constraints = {} + + # Main binary state + if use_complement: + state_vars, state_constraints = ModelingPrimitives.binary_state_pair(model, name) + variables.update(state_vars) + constraints.update(state_constraints) else: - # eq1: P_invest <= isInvested * investSize_max - self.add( - self._model.add_constraints( - self.size <= self.is_invested * self.parameters.maximum_size, - name=f'{self.label_full}|is_invested_ub', - ), - 'is_invested_ub', + variables['on'] = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) + + # Control variables with binary state + for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): + # Lower bound constraint + constraints[f'control_{i}_lower'] = model.add_constraints( + variables['on'] * max(CONFIG.modeling.EPSILON, lower_bound) <= var, name=f'{name}|control_{i}_lower' + ) + # Upper bound constraint + constraints[f'control_{i}_upper'] = model.add_constraints( + var <= variables['on'] * upper_bound, name=f'{name}|control_{i}_upper' ) - # eq2: P_invest >= isInvested * max(epsilon, investSize_min) - self.add( - self._model.add_constraints( - self.size >= self.is_invested * np.maximum(CONFIG.modeling.EPSILON, self.parameters.minimum_or_fixed_size), - name=f'{self.label_full}|is_invested_lb', - ), - 'is_invested_lb', + # Total duration tracking + if track_total_duration: + duration_expr = (variables['on'] * model.hours_per_step).sum('time') + duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( + model, f'{name}|duration', duration_expr, duration_bounds ) + variables['total_duration'] = duration_vars['tracker'] + constraints['duration_tracking'] = duration_constraints['tracking'] - def _create_bounds_for_defining_variable(self): - variable = self._defining_variable - lb_relative, ub_relative = self._relative_bounds_of_defining_variable - if np.all(lb_relative == ub_relative): - self.add( - self._model.add_constraints( - variable == self.size * ub_relative, name=f'{self.label_full}|fix_{variable.name}' - ), - f'fix_{variable.name}', + # Switch tracking + if track_switches: + switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( + model, f'{name}|switches', variables['on'], previous_state ) - return + variables.update(switch_vars) + # Add switch constraints with prefixed names + for switch_name, switch_constraint in switch_constraints.items(): + constraints[f'switch_{switch_name}'] = switch_constraint - # eq: defining_variable(t) <= size * upper_bound(t) - self.add( - self._model.add_constraints( - variable <= self.size * ub_relative, name=f'{self.label_full}|ub_{variable.name}' - ), - f'ub_{variable.name}', - ) + return variables, constraints - if self._on_variable is None: - # eq: defining_variable(t) >= investment_size * relative_minimum(t) - self.add( - self._model.add_constraints( - variable >= self.size * lb_relative, name=f'{self.label_full}|lb_{variable.name}' - ), - f'lb_{variable.name}', - ) + @staticmethod + def operational_binary_control_pattern( + model: FlowSystemModel, + name: str, + controlled_variables: List[linopy.Variable], + variable_bounds: List[Tuple[TemporalData, TemporalData]], + use_complement: bool = False, + track_total_duration: bool = False, + track_switches: bool = False, + previous_state=0, + duration_bounds: Tuple[TemporalData, TemporalData] = None, + track_consecutive_on: bool = False, + consecutive_on_bounds: Tuple[Optional[TemporalData], Optional[TemporalData]] = (None, None), + previous_on_duration: TemporalData = 0, + track_consecutive_off: bool = False, + consecutive_off_bounds: Tuple[Optional[TemporalData], Optional[TemporalData]] = (None, None), + previous_off_duration: TemporalData = 0, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Enhanced operational binary control with consecutive duration tracking. + + New Args: + track_consecutive_on: Whether to track consecutive on duration + consecutive_on_bounds: (min_duration, max_duration) for consecutive on + previous_on_duration: Previous consecutive on duration + track_consecutive_off: Whether to track consecutive off duration + consecutive_off_bounds: (min_duration, max_duration) for consecutive off + previous_off_duration: Previous consecutive off duration + """ + variables = {} + constraints = {} + + # Main binary state (existing logic) + if use_complement: + state_vars, state_constraints = ModelingPrimitives.binary_state_pair(model, name) + variables.update(state_vars) + constraints.update(state_constraints) else: - ## 2. Gleichung: Minimum durch Investmentgröße und On - # eq: defining_variable(t) >= mega * (On(t)-1) + size * relative_minimum(t) - # ... mit mega = relative_maximum * maximum_size - # äquivalent zu:. - # eq: - defining_variable(t) + mega * On(t) + size * relative_minimum(t) <= + mega - mega = self.parameters.maximum_size * lb_relative - on = self._on_variable - self.add( - self._model.add_constraints( - variable >= mega * (on - 1) + self.size * lb_relative, name=f'{self.label_full}|lb_{variable.name}' - ), - f'lb_{variable.name}', + variables['on'] = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) + + # Control variables (existing logic) + for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): + constraints[f'control_{i}_lower'] = model.add_constraints( + variables['on'] * max(CONFIG.modeling.EPSILON, lower_bound) <= var, name=f'{name}|control_{i}_lower' + ) + constraints[f'control_{i}_upper'] = model.add_constraints( + var <= variables['on'] * upper_bound, name=f'{name}|control_{i}_upper' ) - # anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ?? - def _create_bounds_for_scenarios(self): - if isinstance(self.parameters.investment_scenarios, str): - if self.parameters.investment_scenarios == 'individual': - return - raise ValueError(f'Invalid value for investment_scenarios: {self.parameters.investment_scenarios}') + # Total duration tracking (existing logic) + if track_total_duration: + duration_expr = (variables['on'] * model.hours_per_step).sum('time') + duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( + model, f'{name}|duration', duration_expr, duration_bounds + ) + variables['total_duration'] = duration_vars['tracker'] + constraints['duration_tracking'] = duration_constraints['tracking'] - if self.parameters.investment_scenarios is None: - self.add( - self._model.add_constraints( - self.size.isel(scenario=slice(None, -1)) == self.size.isel(scenario=slice(1, None)), - name=f'{self.label_full}|equalize_size_per_scenario', - ), - 'equalize_size_per_scenario', + # Switch tracking (existing logic) + if track_switches: + switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( + model, f'{name}|switches', variables['on'], previous_state ) - return - if not isinstance(self.parameters.investment_scenarios, list): - raise ValueError(f'Invalid value for investment_scenarios: {self.parameters.investment_scenarios}') - if not all(scenario in self._model.time_series_collection.scenarios for scenario in self.parameters.investment_scenarios): - raise ValueError(f'Some scenarios in investment_scenarios are not present in the time_series_collection: ' - f'{self.parameters.investment_scenarios}. This might be due to selecting a subset of ' - f'all scenarios, which is not yet supported.') - - investment_scenarios = self._model.time_series_collection.scenarios.intersection(self.parameters.investment_scenarios) - no_investment_scenarios = self._model.time_series_collection.scenarios.difference(self.parameters.investment_scenarios) - - # eq: size(s) = size(s') for s, s' in investment_scenarios - if len(investment_scenarios) > 1: - self.add( - self._model.add_constraints( - self.size.sel(scenario=investment_scenarios[:-1]) == self.size.sel(scenario=investment_scenarios[1:]), - name=f'{self.label_full}|investment_scenarios', - ), - 'investment_scenarios', + variables.update(switch_vars) + for switch_name, switch_constraint in switch_constraints.items(): + constraints[f'switch_{switch_name}'] = switch_constraint + + # NEW: Consecutive on duration tracking + if track_consecutive_on: + min_on, max_on = consecutive_on_bounds + consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( + model, + f'{name}|consecutive_on', + variables['on'], + minimum_duration=min_on, + maximum_duration=max_on, + previous_duration=previous_on_duration, ) - - if len(no_investment_scenarios) >= 1: - self.add( - self._model.add_constraints( - self.size.sel(scenario=no_investment_scenarios) == 0, - name=f'{self.label_full}|no_investment_scenarios', - ), - 'no_investment_scenarios', + variables['consecutive_on_duration'] = consecutive_on_vars['duration'] + for cons_name, cons_constraint in consecutive_on_constraints.items(): + constraints[f'consecutive_on_{cons_name}'] = cons_constraint + + # NEW: Consecutive off duration tracking + if track_consecutive_off and 'off' in variables: + min_off, max_off = consecutive_off_bounds + consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( + model, + f'{name}|consecutive_off', + variables['off'], + minimum_duration=min_off, + maximum_duration=max_off, + previous_duration=previous_off_duration, ) + variables['consecutive_off_duration'] = consecutive_off_vars['duration'] + for cons_name, cons_constraint in consecutive_off_constraints.items(): + constraints[f'consecutive_off_{cons_name}'] = cons_constraint + + return variables, constraints + + +class InvestmentModel(BaseFeatureModel): + def create_variables(self): + # Clean tuple unpacking + variables, constraints = ModelingPatterns.investment_sizing_pattern( + model=self._model, + name=self.label_full, + size_bounds=( + 0 if self.parameters.optional else self.parameters.minimum_or_fixed_size, + self.parameters.maximum_or_fixed_size, + ), + controlled_variables=[self._defining_variable], + control_factors=[self._relative_bounds_of_defining_variable], + optional=self.parameters.optional, + ) + + # Register variables + self.size = self.add(variables['size'], 'size') + if 'is_invested' in variables: + self.is_invested = self.add(variables['is_invested'], 'is_invested') + + # Register all constraints + for constraint_name, constraint in constraints.items(): + self.add(constraint, constraint_name) + + +class OnOffModel(BaseFeatureModel): + """OnOff model using factory patterns""" + + def __init__( + self, + model: FlowSystemModel, + on_off_parameters: OnOffParameters, + label_of_element: str, + defining_variables: List[linopy.Variable], + defining_bounds: List[Tuple[TemporalData, TemporalData]], + previous_values: List[Optional[TemporalData]], + label: Optional[str] = None, + ): + super().__init__(model, label_of_element, on_off_parameters, label) + + self._defining_variables = defining_variables + self._defining_bounds = defining_bounds + self._previous_values = previous_values + + # All variables set by factory + self.on: Optional[linopy.Variable] = None + self.off: Optional[linopy.Variable] = None + self.total_on_hours: Optional[linopy.Variable] = None + self.switch_on: Optional[linopy.Variable] = None + self.switch_off: Optional[linopy.Variable] = None + self.consecutive_on_hours: Optional[linopy.Variable] = None + self.consecutive_off_hours: Optional[linopy.Variable] = None + + def create_variables_and_constraints(self): + # Use enhanced factory pattern + variables, constraints = ModelingPatterns.operational_binary_control_pattern( + model=self._model, + name=self.label_full, + controlled_variables=self._defining_variables, + variable_bounds=self._defining_bounds, + use_complement=self.parameters.use_off, + track_total_duration=True, + track_switches=self.parameters.use_switch_on, + previous_state=self._get_previous_state(), + duration_bounds=(self.parameters.on_hours_total_min, self.parameters.on_hours_total_max), + track_consecutive_on=self.parameters.use_consecutive_on_hours, + consecutive_on_bounds=(self.parameters.consecutive_on_hours_min, self.parameters.consecutive_on_hours_max), + previous_on_duration=self._get_previous_on_duration(), + track_consecutive_off=self.parameters.use_consecutive_off_hours, + consecutive_off_bounds=( + self.parameters.consecutive_off_hours_min, + self.parameters.consecutive_off_hours_max, + ), + previous_off_duration=self._get_previous_off_duration(), + ) + + # Register all variables + self.on = self.add(variables['on'], 'on') + if 'off' in variables: + self.off = self.add(variables['off'], 'off') + if 'total_duration' in variables: + self.total_on_hours = self.add(variables['total_duration'], 'total_duration') + if 'switch_on' in variables: + self.switch_on = self.add(variables['switch_on'], 'switch_on') + self.switch_off = self.add(variables['switch_off'], 'switch_off') + if 'consecutive_on_duration' in variables: + self.consecutive_on_hours = self.add(variables['consecutive_on_duration'], 'consecutive_on_hours') + if 'consecutive_off_duration' in variables: + self.consecutive_off_hours = self.add(variables['consecutive_off_duration'], 'consecutive_off_hours') + + # Register all constraints + for constraint_name, constraint in constraints.items(): + self.add(constraint, constraint_name) + + def _get_previous_on_duration(self): + """Calculate previous consecutive on duration""" + # Implementation based on _previous_values + return 0 # Placeholder + + def _get_previous_off_duration(self): + """Calculate previous consecutive off duration""" + # Implementation based on _previous_values + return 0 # Placeholder + + # Remove the old placeholder methods - no longer needed! class StateModel(Model): @@ -657,155 +1055,6 @@ def compute_consecutive_hours_in_state( return np.sum(binary_values[-nr_of_indexes_with_consecutive_ones:] * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:]) -class OnOffModel(Model): - """ - Class for modeling the on and off state of a variable - Uses component models to create a modular implementation - """ - - def __init__( - self, - model: FlowSystemModel, - on_off_parameters: OnOffParameters, - label_of_element: str, - defining_variables: List[linopy.Variable], - defining_bounds: List[Tuple[TemporalData, TemporalData]], - previous_values: List[Optional[TemporalData]], - label: Optional[str] = None, - ): - """ - Constructor for OnOffModel - - Args: - model: Reference to the FlowSystemModel - on_off_parameters: Parameters for the OnOffModel - label_of_element: Label of the Parent - defining_variables: List of Variables that are used to define the OnOffModel - defining_bounds: List of Tuples, defining the absolute bounds of each defining variable - previous_values: List of previous values of the defining variables - label: Label of the OnOffModel - """ - super().__init__(model, label_of_element, label) - self.parameters = on_off_parameters - self._defining_variables = defining_variables - self._defining_bounds = defining_bounds - self._previous_values = previous_values - - self.state_model = None - self.switch_state_model = None - self.consecutive_on_model = None - self.consecutive_off_model = None - - def do_modeling(self): - """Create all variables and constraints for the OnOffModel""" - - # Create binary state component - self.state_model = StateModel( - model=self._model, - label_of_element=self.label_of_element, - defining_variables=self._defining_variables, - defining_bounds=self._defining_bounds, - previous_values=self._previous_values, - use_off=self.parameters.use_off, - on_hours_total_min=self.parameters.on_hours_total_min, - on_hours_total_max=self.parameters.on_hours_total_max, - effects_per_running_hour=self.parameters.effects_per_running_hour, - ) - self.add(self.state_model) - self.state_model.do_modeling() - - # Create switch component if needed - if self.parameters.use_switch_on: - self.switch_state_model = SwitchStateModel( - model=self._model, - label_of_element=self.label_of_element, - state_variable=self.state_model.on, - previous_state=self.state_model.previous_on_states[-1], - switch_on_max=self.parameters.switch_on_total_max, - ) - self.add(self.switch_state_model) - self.switch_state_model.do_modeling() - - # Create consecutive on hours component if needed - if self.parameters.use_consecutive_on_hours: - self.consecutive_on_model = ConsecutiveStateModel( - model=self._model, - label_of_element=self.label_of_element, - state_variable=self.state_model.on, - minimum_duration=self.parameters.consecutive_on_hours_min, - maximum_duration=self.parameters.consecutive_on_hours_max, - previous_states=self.state_model.previous_on_states, - label='ConsecutiveOn', - ) - self.add(self.consecutive_on_model) - self.consecutive_on_model.do_modeling() - - # Create consecutive off hours component if needed - if self.parameters.use_consecutive_off_hours: - self.consecutive_off_model = ConsecutiveStateModel( - model=self._model, - label_of_element=self.label_of_element, - state_variable=self.state_model.off, - minimum_duration=self.parameters.consecutive_off_hours_min, - maximum_duration=self.parameters.consecutive_off_hours_max, - previous_states=self.state_model.previous_off_states, - label='ConsecutiveOff', - ) - self.add(self.consecutive_off_model) - self.consecutive_off_model.do_modeling() - - self._create_shares() - - def _create_shares(self): - if self.parameters.effects_per_running_hour: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.state_model.on * factor * self._model.hours_per_step - for effect, factor in self.parameters.effects_per_running_hour.items() - }, - target='operation', - ) - - if self.parameters.effects_per_switch_on: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.switch_state_model.switch_on * factor - for effect, factor in self.parameters.effects_per_switch_on.items() - }, - target='operation', - ) - - @property - def on(self): - return self.state_model.on - - @property - def off(self): - return self.state_model.off - - @property - def switch_on(self): - return self.switch_state_model.switch_on - - @property - def switch_off(self): - return self.switch_state_model.switch_off - - @property - def switch_on_nr(self): - return self.switch_state_model.switch_on_nr - - @property - def consecutive_on_hours(self): - return self.consecutive_on_model.duration - - @property - def consecutive_off_hours(self): - return self.consecutive_off_model.duration - - class PieceModel(Model): """Class for modeling a linear piece of one or more variables in parallel""" diff --git a/flixopt/structure.py b/flixopt/structure.py index 9566e303f..fed5bed94 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -831,6 +831,27 @@ def all_sub_models(self) -> List['Model']: return [model for sub_model in self.sub_models for model in [sub_model] + sub_model.all_sub_models] +class BaseFeatureModel(Model): + """Minimal base class for feature models that use factory patterns""" + + def __init__(self, model: FlowSystemModel, label_of_element: str, parameters, label: Optional[str] = None): + super().__init__(model, label_of_element, label or self.__class__.__name__) + self.parameters = parameters + + def do_modeling(self): + """Template method - creates variables and constraints, then effects""" + self.create_variables_and_constraints() + self.add_effects() + + def create_variables_and_constraints(self): + """Override in subclasses to create variables and constraints""" + raise NotImplementedError('Subclasses must implement create_variables_and_constraints()') + + def add_effects(self): + """Override in subclasses to add effects""" + pass # Default: no effects + + class ElementModel(Model): """Stores the mathematical Variables and Constraints for Elements""" From 33460a0220d3d415113334433c552cbae2c23ab1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:02:50 +0200 Subject: [PATCH 09/51] Improve Feature Patterns --- flixopt/features.py | 452 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 353 insertions(+), 99 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index e495e2973..3986f7b49 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -17,6 +17,140 @@ logger = logging.getLogger('flixopt') +class ModelingUtilities: + """Utility functions for modeling calculations - used across different classes""" + + @staticmethod + def compute_consecutive_hours_in_state( + binary_values: TemporalData, hours_per_timestep: Union[int, float, np.ndarray] + ) -> Scalar: + """ + Computes the final consecutive duration in state 'on' (=1) in hours, from a binary array. + + Args: + binary_values: An int or 1D binary array containing only `0`s and `1`s. + hours_per_timestep: The duration of each timestep in hours. + If a scalar is provided, it is used for all timesteps. + If an array is provided, it must be as long as the last consecutive duration in binary_values. + + Returns: + The duration of the binary variable in hours. + + Raises + ------ + TypeError + If the length of binary_values and dt_in_hours is not equal, but None is a scalar. + """ + if np.isscalar(binary_values) and np.isscalar(hours_per_timestep): + return binary_values * hours_per_timestep + elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): + return binary_values * hours_per_timestep[-1] + + if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON): + return 0 + + if np.isscalar(hours_per_timestep): + hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep + hours_per_timestep: np.ndarray + + indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] + if len(indexes_with_zero_values) == 0: + nr_of_indexes_with_consecutive_ones = len(binary_values) + else: + nr_of_indexes_with_consecutive_ones = len(binary_values) - indexes_with_zero_values[-1] - 1 + + if len(hours_per_timestep) < nr_of_indexes_with_consecutive_ones: + raise ValueError( + f'When trying to calculate the consecutive duration, the length of the last duration ' + f'({nr_of_indexes_with_consecutive_ones}) is longer than the provided hours_per_timestep ({len(hours_per_timestep)}), ' + f'as {binary_values=}' + ) + + return np.sum( + binary_values[-nr_of_indexes_with_consecutive_ones:] + * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:] + ) + + @staticmethod + def compute_previous_states(previous_values: List[TemporalData], epsilon: float = None) -> np.ndarray: + """ + Computes the previous states {0, 1} of defining variables as a binary array from their previous values. + + Args: + previous_values: List of previous values for variables + epsilon: Tolerance for zero detection (uses CONFIG.modeling.EPSILON if None) + + Returns: + Binary array of previous states + """ + if epsilon is None: + epsilon = CONFIG.modeling.EPSILON + + if not previous_values or all(val is None for val in previous_values): + return np.array([0]) + + # Convert to 2D-array and compute binary on/off states + previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None + if previous_values.ndim > 1: + return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) + + return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) + + @staticmethod + def compute_previous_on_duration(previous_values: List[TemporalData], hours_per_step: Union[int, float]) -> Scalar: + """ + Convenience method to compute previous consecutive 'on' duration. + + Args: + previous_values: List of previous values for variables + hours_per_step: Duration of each timestep in hours + + Returns: + Previous consecutive on duration in hours + """ + if not previous_values: + return 0 + + previous_states = ModelingUtilities.compute_previous_states(previous_values) + return ModelingUtilities.compute_consecutive_hours_in_state(previous_states, hours_per_step) + + @staticmethod + def compute_previous_off_duration(previous_values: List[TemporalData], hours_per_step: Union[int, float]) -> Scalar: + """ + Convenience method to compute previous consecutive 'off' duration. + + Args: + previous_values: List of previous values for variables + hours_per_step: Duration of each timestep in hours + + Returns: + Previous consecutive off duration in hours + """ + if not previous_values: + return 0 + + previous_states = ModelingUtilities.compute_previous_states(previous_values) + previous_off_states = 1 - previous_states + return ModelingUtilities.compute_consecutive_hours_in_state(previous_off_states, hours_per_step) + + @staticmethod + def get_most_recent_state(previous_values: List[TemporalData]) -> int: + """ + Get the most recent binary state from previous values. + + Args: + previous_values: List of previous values for variables + + Returns: + Most recent binary state (0 or 1) + """ + if not previous_values: + return 0 + + previous_states = ModelingUtilities.compute_previous_states(previous_values) + return int(previous_states[-1]) + + class ModelingPrimitives: """Mathematical modeling primitives returning (variables, constraints) tuples""" @@ -105,12 +239,15 @@ def expression_tracking_variable( """ coords = coords or ['year', 'scenario'] - if bounds: + if not bounds: + tracker = model.add_variables(name=f'{name}|tracker', coords=model.get_coords(coords)) + else: tracker = model.add_variables( - lower=bounds[0], upper=bounds[1], name=f'{name}|tracker', coords=model.get_coords(coords) + lower=bounds[0] if bounds[0] is not None else -np.inf, + upper=bounds[1] if bounds[1] is not None else np.inf, + name=f'{name}|tracker', + coords=model.get_coords(coords), ) - else: - tracker = model.add_variables(name=f'{name}|tracker', coords=model.get_coords(coords)) # Constraint: tracker = expression tracking = model.add_constraints(tracker == tracked_expression, name=f'{name}|tracking_eq') @@ -298,6 +435,49 @@ def consecutive_duration_tracking( return variables, constraints + @staticmethod + def mutual_exclusivity_constraint( + model: FlowSystemModel, name: str, binary_variables: List[linopy.Variable], tolerance: float = 1.1 + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates mutual exclusivity constraint for binary variables. + + Mathematical formulation: + Σ(binary_vars[i]) ≤ tolerance ∀t + + Ensures at most one binary variable can be 1 at any time. + Tolerance > 1.0 accounts for binary variable numerical precision. + + Args: + binary_variables: List of binary variables that should be mutually exclusive + tolerance: Upper bound (typically 1.1 for numerical stability) + + Returns: + variables: {} (no new variables created) + constraints: {'mutual_exclusivity': constraint} + + Raises: + AssertionError: If fewer than 2 variables provided or variables aren't binary + """ + assert len(binary_variables) >= 2, ( + f'Mutual exclusivity requires at least 2 variables, got {len(binary_variables)}' + ) + + for var in binary_variables: + assert var.attrs.get('binary', False), ( + f'Variable {var.name} must be binary for mutual exclusivity constraint' + ) + + # Create mutual exclusivity constraint + mutual_exclusivity = model.add_constraints( + sum(binary_variables) <= tolerance, name=f'{name}|mutual_exclusivity' + ) + + variables = {} # No new variables created + constraints = {'mutual_exclusivity': mutual_exclusivity} + + return variables, constraints + class ModelingPatterns: """High-level patterns that compose primitives and return (variables, constraints) tuples""" @@ -362,68 +542,6 @@ def investment_sizing_pattern( return variables, constraints - @staticmethod - def operational_binary_control_pattern( - model: FlowSystemModel, - name: str, - controlled_variables: List[linopy.Variable], - variable_bounds: List[Tuple[TemporalData, TemporalData]], - use_complement: bool = False, - track_total_duration: bool = False, - track_switches: bool = False, - previous_state=0, - duration_bounds: Tuple[TemporalData, TemporalData] = None, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Operational binary control with optional features. - - Returns: - variables: {'on': binary_var, 'off': binary_var (optional), 'total_duration': var (optional), ...} - constraints: {'complementary': constraint, 'control_0_lower': constraint, ...} - """ - variables = {} - constraints = {} - - # Main binary state - if use_complement: - state_vars, state_constraints = ModelingPrimitives.binary_state_pair(model, name) - variables.update(state_vars) - constraints.update(state_constraints) - else: - variables['on'] = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) - - # Control variables with binary state - for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): - # Lower bound constraint - constraints[f'control_{i}_lower'] = model.add_constraints( - variables['on'] * max(CONFIG.modeling.EPSILON, lower_bound) <= var, name=f'{name}|control_{i}_lower' - ) - # Upper bound constraint - constraints[f'control_{i}_upper'] = model.add_constraints( - var <= variables['on'] * upper_bound, name=f'{name}|control_{i}_upper' - ) - - # Total duration tracking - if track_total_duration: - duration_expr = (variables['on'] * model.hours_per_step).sum('time') - duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( - model, f'{name}|duration', duration_expr, duration_bounds - ) - variables['total_duration'] = duration_vars['tracker'] - constraints['duration_tracking'] = duration_constraints['tracking'] - - # Switch tracking - if track_switches: - switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( - model, f'{name}|switches', variables['on'], previous_state - ) - variables.update(switch_vars) - # Add switch constraints with prefixed names - for switch_name, switch_constraint in switch_constraints.items(): - constraints[f'switch_{switch_name}'] = switch_constraint - - return variables, constraints - @staticmethod def operational_binary_control_pattern( model: FlowSystemModel, @@ -467,7 +585,7 @@ def operational_binary_control_pattern( # Control variables (existing logic) for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): constraints[f'control_{i}_lower'] = model.add_constraints( - variables['on'] * max(CONFIG.modeling.EPSILON, lower_bound) <= var, name=f'{name}|control_{i}_lower' + variables['on'] * np.maximum(lower_bound, CONFIG.modeling.EPSILON) <= var, name=f'{name}|control_{i}_lower' ) constraints[f'control_{i}_upper'] = model.add_constraints( var <= variables['on'] * upper_bound, name=f'{name}|control_{i}_upper' @@ -525,8 +643,30 @@ def operational_binary_control_pattern( class InvestmentModel(BaseFeatureModel): - def create_variables(self): - # Clean tuple unpacking + """Investment model using factory patterns but keeping old interface""" + + def __init__( + self, + model: FlowSystemModel, + label_of_element: str, + parameters: InvestParameters, + defining_variable: linopy.Variable, + relative_bounds_of_defining_variable: Tuple[TemporalData, TemporalData], + label: Optional[str] = None, + on_variable: Optional[linopy.Variable] = None, + ): + super().__init__(model, label_of_element, parameters, label) + + self._defining_variable = defining_variable + self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable + self._on_variable = on_variable + + # Only keep non-variable attributes + self.scenario_of_investment: Optional[linopy.Variable] = None + self.piecewise_effects: Optional[PiecewiseEffectsModel] = None + + def create_variables_and_constraints(self): + # Use factory patterns variables, constraints = ModelingPatterns.investment_sizing_pattern( model=self._model, name=self.label_full, @@ -539,15 +679,76 @@ def create_variables(self): optional=self.parameters.optional, ) - # Register variables - self.size = self.add(variables['size'], 'size') + # Register variables (stored in Model's variable tracking) + self.add(variables['size'], 'size') if 'is_invested' in variables: - self.is_invested = self.add(variables['is_invested'], 'is_invested') + self.add(variables['is_invested'], 'is_invested') - # Register all constraints + # Register constraints for constraint_name, constraint in constraints.items(): self.add(constraint, constraint_name) + # Handle scenarios and piecewise effects... + if self._model.flow_system.scenarios is not None: + self._create_bounds_for_scenarios() + + if self.parameters.piecewise_effects: + self.piecewise_effects = self.add( + PiecewiseEffectsModel( + model=self._model, + label_of_element=self.label_of_element, + piecewise_origin=(self.size.name, self.parameters.piecewise_effects.piecewise_origin), + piecewise_shares=self.parameters.piecewise_effects.piecewise_shares, + zero_point=self.is_invested, + ), + 'segments', + ) + self.piecewise_effects.do_modeling() + + # Properties access variables from Model's tracking system + @property + def size(self) -> Optional[linopy.Variable]: + """Investment size variable""" + return self.get_variable_by_short_name('size') + + @property + def is_invested(self) -> Optional[linopy.Variable]: + """Binary investment decision variable""" + return self.get_variable_by_short_name('is_invested') + + def add_effects(self): + """Add investment effects""" + if self.parameters.fix_effects: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.is_invested * factor if self.is_invested is not None else factor + for effect, factor in self.parameters.fix_effects.items() + }, + target='invest', + ) + + if self.parameters.divest_effects and self.parameters.optional: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: -self.is_invested * factor + factor + for effect, factor in self.parameters.divest_effects.items() + }, + target='invest', + ) + + if self.parameters.specific_effects: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={effect: self.size * factor for effect, factor in self.parameters.specific_effects.items()}, + target='invest', + ) + + def _create_bounds_for_scenarios(self): + """Keep existing scenario logic""" + pass + class OnOffModel(BaseFeatureModel): """OnOff model using factory patterns""" @@ -555,8 +756,8 @@ class OnOffModel(BaseFeatureModel): def __init__( self, model: FlowSystemModel, - on_off_parameters: OnOffParameters, label_of_element: str, + on_off_parameters: OnOffParameters, defining_variables: List[linopy.Variable], defining_bounds: List[Tuple[TemporalData, TemporalData]], previous_values: List[Optional[TemporalData]], @@ -568,17 +769,8 @@ def __init__( self._defining_bounds = defining_bounds self._previous_values = previous_values - # All variables set by factory - self.on: Optional[linopy.Variable] = None - self.off: Optional[linopy.Variable] = None - self.total_on_hours: Optional[linopy.Variable] = None - self.switch_on: Optional[linopy.Variable] = None - self.switch_off: Optional[linopy.Variable] = None - self.consecutive_on_hours: Optional[linopy.Variable] = None - self.consecutive_off_hours: Optional[linopy.Variable] = None - def create_variables_and_constraints(self): - # Use enhanced factory pattern + # Use factory patterns variables, constraints = ModelingPatterns.operational_binary_control_pattern( model=self._model, name=self.label_full, @@ -600,35 +792,97 @@ def create_variables_and_constraints(self): previous_off_duration=self._get_previous_off_duration(), ) - # Register all variables - self.on = self.add(variables['on'], 'on') + # Register all variables (stored in Model's variable tracking) + self.add(variables['on'], 'on') if 'off' in variables: - self.off = self.add(variables['off'], 'off') + self.add(variables['off'], 'off') if 'total_duration' in variables: - self.total_on_hours = self.add(variables['total_duration'], 'total_duration') + self.add(variables['total_duration'], 'total_duration') if 'switch_on' in variables: - self.switch_on = self.add(variables['switch_on'], 'switch_on') - self.switch_off = self.add(variables['switch_off'], 'switch_off') + self.add(variables['switch_on'], 'switch_on') + self.add(variables['switch_off'], 'switch_off') if 'consecutive_on_duration' in variables: - self.consecutive_on_hours = self.add(variables['consecutive_on_duration'], 'consecutive_on_hours') + self.add(variables['consecutive_on_duration'], 'consecutive_on_hours') if 'consecutive_off_duration' in variables: - self.consecutive_off_hours = self.add(variables['consecutive_off_duration'], 'consecutive_off_hours') + self.add(variables['consecutive_off_duration'], 'consecutive_off_hours') # Register all constraints for constraint_name, constraint in constraints.items(): self.add(constraint, constraint_name) + # Properties access variables from Model's tracking system + @property + def on(self) -> Optional[linopy.Variable]: + """Binary on state variable""" + return self.get_variable_by_short_name('on') + + @property + def off(self) -> Optional[linopy.Variable]: + """Binary off state variable""" + return self.get_variable_by_short_name('off') + + @property + def total_on_hours(self) -> Optional[linopy.Variable]: + """Total on hours variable""" + return self.get_variable_by_short_name('total_duration') + + @property + def switch_on(self) -> Optional[linopy.Variable]: + """Switch on variable""" + return self.get_variable_by_short_name('switch_on') + + @property + def switch_off(self) -> Optional[linopy.Variable]: + """Switch off variable""" + return self.get_variable_by_short_name('switch_off') + + @property + def switch_on_nr(self) -> Optional[linopy.Variable]: + """Number of switch-ons variable""" + # This could be added to factory if needed + return None + + @property + def consecutive_on_hours(self) -> Optional[linopy.Variable]: + """Consecutive on hours variable""" + return self.get_variable_by_short_name('consecutive_on_hours') + + @property + def consecutive_off_hours(self) -> Optional[linopy.Variable]: + """Consecutive off hours variable""" + return self.get_variable_by_short_name('consecutive_off_hours') + + def add_effects(self): + """Add operational effects""" + if self.parameters.effects_per_running_hour: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.on * factor * self._model.hours_per_step + for effect, factor in self.parameters.effects_per_running_hour.items() + }, + target='operation', + ) + + if self.parameters.effects_per_switch_on and self.switch_on: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.switch_on * factor for effect, factor in self.parameters.effects_per_switch_on.items() + }, + target='operation', + ) + def _get_previous_on_duration(self): - """Calculate previous consecutive on duration""" - # Implementation based on _previous_values - return 0 # Placeholder + hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] + return ModelingUtilities.compute_previous_on_duration(self._previous_values, hours_per_step) def _get_previous_off_duration(self): - """Calculate previous consecutive off duration""" - # Implementation based on _previous_values - return 0 # Placeholder + hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] + return ModelingUtilities.compute_previous_off_duration(self._previous_values, hours_per_step) - # Remove the old placeholder methods - no longer needed! + def _get_previous_state(self): + return ModelingUtilities.get_most_recent_state(self._previous_values) class StateModel(Model): From ff70674ac7425f7b3a1ecb6e0ae2cac8599332cf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:18:00 +0200 Subject: [PATCH 10/51] Improve acess to variables via short names --- flixopt/structure.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index fed5bed94..5a4b016ce 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -739,10 +739,10 @@ def add( # TODO: Check uniquenes of short names if isinstance(item, linopy.Variable): self._variables_direct.append(item.name) - self._variables_short[item.name] = short_name or item.name + self._variables_short[short_name] = item.name elif isinstance(item, linopy.Constraint): self._constraints_direct.append(item.name) - self._constraints_short[item.name] = short_name or item.name + self._constraints_short[short_name] = item.name elif isinstance(item, Model): self.sub_models.append(item) self._sub_models_short[item.label_full] = short_name or item.label_full @@ -830,6 +830,18 @@ def constraints(self) -> linopy.Constraints: def all_sub_models(self) -> List['Model']: return [model for sub_model in self.sub_models for model in [sub_model] + sub_model.all_sub_models] + def get_variable_by_short_name(self, short_name: str, default_return = None) -> Optional[linopy.Variable]: + """Get variable by short name""" + if short_name not in self._variables_short: + return default_return + return self._model.variables[self._variables_short.get(short_name)] + + def get_constraint_by_short_name(self, short_name: str, default_return = None) -> Optional[linopy.Constraint]: + """Get variable by short name""" + if short_name not in self._constraints_short: + return default_return + return self._model.constraints[self._constraints_short.get(short_name)] + class BaseFeatureModel(Model): """Minimal base class for feature models that use factory patterns""" From fa5e30a11e11d6a564228ce6a1408b2613d6aed5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:52:35 +0200 Subject: [PATCH 11/51] Improve --- flixopt/effects.py | 6 +- flixopt/elements.py | 96 ++-- flixopt/features.py | 1193 +++--------------------------------------- flixopt/modeling.py | 636 ++++++++++++++++++++++ flixopt/structure.py | 31 +- 5 files changed, 777 insertions(+), 1185 deletions(-) create mode 100644 flixopt/modeling.py diff --git a/flixopt/effects.py b/flixopt/effects.py index 23943d16b..2fc2aae37 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -147,8 +147,7 @@ def __init__(self, model: FlowSystemModel, element: Effect): model=self._model, dims=('year', 'scenario'), label_of_element=self.label_of_element, - label='invest', - label_full=f'{self.label_full}(invest)', + label_of_model=f'{self.label_of_model}(invest)', total_max=self.element.maximum_invest, total_min=self.element.minimum_invest, ) @@ -159,8 +158,7 @@ def __init__(self, model: FlowSystemModel, element: Effect): model=self._model, dims=('time', 'year', 'scenario'), label_of_element=self.label_of_element, - label='operation', - label_full=f'{self.label_full}(operation)', + label_of_model=f'{self.label_of_model}|(operation)', total_max=self.element.maximum_operation, total_min=self.element.minimum_operation, min_per_hour=self.element.minimum_operation_per_hour diff --git a/flixopt/elements.py b/flixopt/elements.py index a546b5e9c..43907b07a 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -12,7 +12,7 @@ from .config import CONFIG from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser from .effects import TemporalEffectsUser -from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel +from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, ModelingPatterns, ModelingPrimitives from .interface import InvestParameters, OnOffParameters from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io @@ -311,15 +311,14 @@ class FlowModel(ElementModel): def __init__(self, model: FlowSystemModel, element: Flow): super().__init__(model, element) self.element: Flow = element - self.flow_rate: Optional[linopy.Variable] = None - self.total_flow_hours: Optional[linopy.Variable] = None + # Feature models (set by do_modeling) self.on_off: Optional[OnOffModel] = None self._investment: Optional[InvestmentModel] = None def do_modeling(self): - # eq relative_minimum(t) * size <= flow_rate(t) <= relative_maximum(t) * size - self.flow_rate: linopy.Variable = self.add( + # Main flow rate variable + self.add( self._model.add_variables( lower=self.flow_rate_lower_bound, upper=self.flow_rate_upper_bound, @@ -329,7 +328,7 @@ def do_modeling(self): 'flow_rate', ) - # OnOff + # OnOff feature if self.element.on_off_parameters is not None: self.on_off: OnOffModel = self.add( OnOffModel( @@ -344,46 +343,57 @@ def do_modeling(self): ) self.on_off.do_modeling() - # Investment + # Investment feature if isinstance(self.element.size, InvestParameters): self._investment: InvestmentModel = self.add( InvestmentModel( model=self._model, label_of_element=self.label_of_element, + label_of_model=self.label_of_element, parameters=self.element.size, defining_variable=self.flow_rate, - relative_bounds_of_defining_variable=(self.flow_rate_lower_bound_relative, - self.flow_rate_upper_bound_relative), + relative_bounds_of_defining_variable=( + self.flow_rate_lower_bound_relative, + self.flow_rate_upper_bound_relative, + ), on_variable=self.on_off.on if self.on_off is not None else None, ), 'investment', ) self._investment.do_modeling() - self.total_flow_hours = self.add( - self._model.add_variables( - lower=self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else 0, - upper=self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else np.inf, - coords=self._model.get_coords(['year', 'scenario']), - name=f'{self.label_full}|total_flow_hours', + # Total flow hours tracking (could use factory pattern) + variables, constraints = ModelingPrimitives.expression_tracking_variable( + model=self._model, + name=f'{self.label_full}|total_flow_hours', + tracked_expression=(self.flow_rate * self._model.hours_per_step).sum('time'), + bounds=( + self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else 0, + self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else None, ), - 'total_flow_hours', + coords=['year', 'scenario'], ) - self.add( - self._model.add_constraints( - self.total_flow_hours == (self.flow_rate * self._model.hours_per_step).sum('time'), - name=f'{self.label_full}|total_flow_hours', - ), - 'total_flow_hours', - ) + self.add(variables['tracker'], 'total_flow_hours') + self.add(constraints['tracking'], 'total_flow_hours_tracking') - # Load factor + # Load factor constraints self._create_bounds_for_load_factor() - # Shares + # Effects self._create_shares() + # Properties for clean access to variables + @property + def flow_rate(self) -> Optional[linopy.Variable]: + """Main flow rate variable""" + return self.get_variable_by_short_name('flow_rate') + + @property + def total_flow_hours(self) -> Optional[linopy.Variable]: + """Total flow hours variable""" + return self.get_variable_by_short_name('total_flow_hours') + def results_structure(self): return { **super().results_structure(), @@ -393,10 +403,10 @@ def results_structure(self): } def _create_shares(self): - # Arbeitskosten: - if self.element.effects_per_flow_hour != {}: + # Effects per flow hour + if self.element.effects_per_flow_hour: self._model.effects.add_share_to_effects( - name=self.label_full, # Use the full label of the element + name=self.label_full, expressions={ effect: self.flow_rate * self._model.hours_per_step * factor for effect, factor in self.element.effects_per_flow_hour.items() @@ -405,39 +415,35 @@ def _create_shares(self): ) def _create_bounds_for_load_factor(self): - # TODO: Add Variable load_factor for better evaluation? + """Create load factor constraints using current approach""" + # Get the size (either from element or investment) + size = self.element.size if self._investment is None else self._investment.size - # eq: var_sumFlowHours <= size * dt_tot * load_factor_max + # Maximum load factor constraint if self.element.load_factor_max is not None: - name_short = 'load_factor_max' flow_hours_per_size_max = self._model.hours_per_step.sum('time') * self.element.load_factor_max - size = self.element.size if self._investment is None else self._investment.size - self.add( self._model.add_constraints( self.total_flow_hours <= size * flow_hours_per_size_max, - name=f'{self.label_full}|{name_short}', + name=f'{self.label_full}|load_factor_max', ), - name_short, + 'load_factor_max', ) - # eq: size * sum(dt)* load_factor_min <= var_sumFlowHours + # Minimum load factor constraint if self.element.load_factor_min is not None: - name_short = 'load_factor_min' flow_hours_per_size_min = self._model.hours_per_step.sum('time') * self.element.load_factor_min - size = self.element.size if self._investment is None else self._investment.size - self.add( self._model.add_constraints( self.total_flow_hours >= size * flow_hours_per_size_min, - name=f'{self.label_full}|{name_short}', + name=f'{self.label_full}|load_factor_min', ), - name_short, + 'load_factor_min', ) @property def flow_rate_bounds_on(self) -> Tuple[TemporalData, TemporalData]: - """Returns absolute flow rate bounds. Important for OnOffModel""" + """Returns absolute flow rate bounds for OnOffModel""" relative_minimum, relative_maximum = self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative size = self.element.size if not isinstance(size, InvestParameters): @@ -458,7 +464,7 @@ def flow_rate_lower_bound_relative(self) -> TemporalData: @property def flow_rate_upper_bound_relative(self) -> TemporalData: - """ Returns the upper bound of the flow_rate relative to its size""" + """Returns the upper bound of the flow_rate relative to its size""" fixed_profile = self.element.fixed_relative_profile if fixed_profile is None: return self.element.relative_maximum @@ -566,8 +572,8 @@ def do_modeling(self): self.on_off = self.add( OnOffModel( self._model, - self.element.on_off_parameters, - self.label_of_element, + on_off_parameters=self.element.on_off_parameters, + label_of_element=self.label_of_element, defining_variables=[flow.model.flow_rate for flow in all_flows], defining_bounds=[flow.model.flow_rate_bounds_on for flow in all_flows], previous_values=[flow.previous_flow_rate for flow in all_flows], diff --git a/flixopt/features.py b/flixopt/features.py index 3986f7b49..e08d94cb1 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -11,637 +11,13 @@ from .config import CONFIG from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions -from .interface import InvestParameters, OnOffParameters, Piecewise +from .interface import InvestParameters, OnOffParameters, Piecewise, PiecewiseEffects from .structure import Model, FlowSystemModel, BaseFeatureModel +from .modeling import ModelingPatterns, ModelingUtilities, ModelingPrimitives logger = logging.getLogger('flixopt') -class ModelingUtilities: - """Utility functions for modeling calculations - used across different classes""" - - @staticmethod - def compute_consecutive_hours_in_state( - binary_values: TemporalData, hours_per_timestep: Union[int, float, np.ndarray] - ) -> Scalar: - """ - Computes the final consecutive duration in state 'on' (=1) in hours, from a binary array. - - Args: - binary_values: An int or 1D binary array containing only `0`s and `1`s. - hours_per_timestep: The duration of each timestep in hours. - If a scalar is provided, it is used for all timesteps. - If an array is provided, it must be as long as the last consecutive duration in binary_values. - - Returns: - The duration of the binary variable in hours. - - Raises - ------ - TypeError - If the length of binary_values and dt_in_hours is not equal, but None is a scalar. - """ - if np.isscalar(binary_values) and np.isscalar(hours_per_timestep): - return binary_values * hours_per_timestep - elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): - return binary_values * hours_per_timestep[-1] - - if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON): - return 0 - - if np.isscalar(hours_per_timestep): - hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep - hours_per_timestep: np.ndarray - - indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] - if len(indexes_with_zero_values) == 0: - nr_of_indexes_with_consecutive_ones = len(binary_values) - else: - nr_of_indexes_with_consecutive_ones = len(binary_values) - indexes_with_zero_values[-1] - 1 - - if len(hours_per_timestep) < nr_of_indexes_with_consecutive_ones: - raise ValueError( - f'When trying to calculate the consecutive duration, the length of the last duration ' - f'({nr_of_indexes_with_consecutive_ones}) is longer than the provided hours_per_timestep ({len(hours_per_timestep)}), ' - f'as {binary_values=}' - ) - - return np.sum( - binary_values[-nr_of_indexes_with_consecutive_ones:] - * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:] - ) - - @staticmethod - def compute_previous_states(previous_values: List[TemporalData], epsilon: float = None) -> np.ndarray: - """ - Computes the previous states {0, 1} of defining variables as a binary array from their previous values. - - Args: - previous_values: List of previous values for variables - epsilon: Tolerance for zero detection (uses CONFIG.modeling.EPSILON if None) - - Returns: - Binary array of previous states - """ - if epsilon is None: - epsilon = CONFIG.modeling.EPSILON - - if not previous_values or all(val is None for val in previous_values): - return np.array([0]) - - # Convert to 2D-array and compute binary on/off states - previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None - if previous_values.ndim > 1: - return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) - - return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) - - @staticmethod - def compute_previous_on_duration(previous_values: List[TemporalData], hours_per_step: Union[int, float]) -> Scalar: - """ - Convenience method to compute previous consecutive 'on' duration. - - Args: - previous_values: List of previous values for variables - hours_per_step: Duration of each timestep in hours - - Returns: - Previous consecutive on duration in hours - """ - if not previous_values: - return 0 - - previous_states = ModelingUtilities.compute_previous_states(previous_values) - return ModelingUtilities.compute_consecutive_hours_in_state(previous_states, hours_per_step) - - @staticmethod - def compute_previous_off_duration(previous_values: List[TemporalData], hours_per_step: Union[int, float]) -> Scalar: - """ - Convenience method to compute previous consecutive 'off' duration. - - Args: - previous_values: List of previous values for variables - hours_per_step: Duration of each timestep in hours - - Returns: - Previous consecutive off duration in hours - """ - if not previous_values: - return 0 - - previous_states = ModelingUtilities.compute_previous_states(previous_values) - previous_off_states = 1 - previous_states - return ModelingUtilities.compute_consecutive_hours_in_state(previous_off_states, hours_per_step) - - @staticmethod - def get_most_recent_state(previous_values: List[TemporalData]) -> int: - """ - Get the most recent binary state from previous values. - - Args: - previous_values: List of previous values for variables - - Returns: - Most recent binary state (0 or 1) - """ - if not previous_values: - return 0 - - previous_states = ModelingUtilities.compute_previous_states(previous_values) - return int(previous_states[-1]) - - -class ModelingPrimitives: - """Mathematical modeling primitives returning (variables, constraints) tuples""" - - @staticmethod - def binary_state_pair( - model: FlowSystemModel, name: str, coords: List[str] = None - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Creates complementary binary variables with completeness constraint. - - Mathematical formulation: - on[t] + off[t] = 1 ∀t - on[t], off[t] ∈ {0, 1} - - Returns: - variables: {'on': binary_var, 'off': binary_var} - constraints: {'complementary': constraint} - """ - coords = coords or ['time'] - - on = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(coords)) - off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(coords)) - - # Constraint: on + off = 1 - complementary = model.add_constraints(on + off == 1, name=f'{name}|complementary') - - variables = {'on': on, 'off': off} - constraints = {'complementary': complementary} - - return variables, constraints - - @staticmethod - def proportionally_bounded_variable( - model: FlowSystemModel, - name: str, - controlling_variable, - bounds: Tuple[TemporalData, TemporalData], - coords: List[str] = None, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Creates variable with bounds proportional to another variable. - - Mathematical formulation: - lower_factor[t] * controller[t] ≤ variable[t] ≤ upper_factor[t] * controller[t] ∀t - - Returns: - variables: {'variable': bounded_var} - constraints: {'lower_bound': constraint, 'upper_bound': constraint} - """ - coords = coords or ['time'] - variable = model.add_variables(name=f'{name}|bounded', coords=model.get_coords(coords)) - - lower_factor, upper_factor = bounds - - # Constraints: lower_factor * controller ≤ var ≤ upper_factor * controller - lower_bound = model.add_constraints( - variable >= controlling_variable * lower_factor, name=f'{name}|proportional_lb' - ) - upper_bound = model.add_constraints( - variable <= controlling_variable * upper_factor, name=f'{name}|proportional_ub' - ) - - variables = {'variable': variable} - constraints = {'lower_bound': lower_bound, 'upper_bound': upper_bound} - - return variables, constraints - - @staticmethod - def expression_tracking_variable( - model: FlowSystemModel, - name: str, - tracked_expression, - bounds: Tuple[TemporalData, TemporalData] = None, - coords: List[str] = None, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Creates variable that equals a given expression. - - Mathematical formulation: - tracker = expression - lower ≤ tracker ≤ upper (if bounds provided) - - Returns: - variables: {'tracker': tracker_var} - constraints: {'tracking': constraint} - """ - coords = coords or ['year', 'scenario'] - - if not bounds: - tracker = model.add_variables(name=f'{name}|tracker', coords=model.get_coords(coords)) - else: - tracker = model.add_variables( - lower=bounds[0] if bounds[0] is not None else -np.inf, - upper=bounds[1] if bounds[1] is not None else np.inf, - name=f'{name}|tracker', - coords=model.get_coords(coords), - ) - - # Constraint: tracker = expression - tracking = model.add_constraints(tracker == tracked_expression, name=f'{name}|tracking_eq') - - variables = {'tracker': tracker} - constraints = {'tracking': tracking} - - return variables, constraints - - @staticmethod - def state_transition_variables( - model: FlowSystemModel, name: str, state_variable, previous_state=0 - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Creates switch-on/off variables with state transition logic. - - Mathematical formulation: - switch_on[t] - switch_off[t] = state[t] - state[t-1] ∀t > 0 - switch_on[0] - switch_off[0] = state[0] - previous_state - switch_on[t] + switch_off[t] ≤ 1 ∀t - switch_on[t], switch_off[t] ∈ {0, 1} - - Returns: - variables: {'switch_on': binary_var, 'switch_off': binary_var} - constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint} - """ - switch_on = model.add_variables(binary=True, name=f'{name}|switch_on', coords=model.get_coords(['time'])) - switch_off = model.add_variables(binary=True, name=f'{name}|switch_off', coords=model.get_coords(['time'])) - - # State transition constraints for t > 0 - transition = model.add_constraints( - switch_on.isel(time=slice(1, None)) - switch_off.isel(time=slice(1, None)) - == state_variable.isel(time=slice(1, None)) - state_variable.isel(time=slice(None, -1)), - name=f'{name}|state_transition', - ) - - # Initial state transition for t = 0 - initial = model.add_constraints( - switch_on.isel(time=0) - switch_off.isel(time=0) == state_variable.isel(time=0) - previous_state, - name=f'{name}|initial_transition', - ) - - # At most one switch per timestep - mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|switch_mutex') - - variables = {'switch_on': switch_on, 'switch_off': switch_off} - constraints = {'transition': transition, 'initial': initial, 'mutex': mutex} - - return variables, constraints - - @staticmethod - def big_m_binary_bounds( - model: FlowSystemModel, - name: str, - variable, - binary_control, - size_variable, - relative_bounds: Tuple[TemporalData, TemporalData], - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """ - Creates bounds controlled by both binary and continuous variables. - - Mathematical formulation: - variable[t] ≤ size[t] * upper_factor[t] ∀t - - If binary_control provided: - variable[t] ≥ M * (binary[t] - 1) + size[t] * lower_factor[t] ∀t - where M = max(size) * max(upper_factor) - Else: - variable[t] ≥ size[t] * lower_factor[t] ∀t - - Returns: - variables: {} (no new variables created) - constraints: {'upper_bound': constraint, 'lower_bound': constraint} - """ - rel_lower, rel_upper = relative_bounds - - # Upper bound: variable ≤ size * upper_factor - upper_bound = model.add_constraints(variable <= size_variable * rel_upper, name=f'{name}|size_upper_bound') - - if binary_control is not None: - # Big-M lower bound: variable ≥ M*(binary-1) + size*lower_factor - big_m = size_variable.max() * rel_upper.max() # Conservative big-M - lower_bound = model.add_constraints( - variable >= big_m * (binary_control - 1) + size_variable * rel_lower, - name=f'{name}|binary_controlled_lower_bound', - ) - else: - # Simple lower bound: variable ≥ size * lower_factor - lower_bound = model.add_constraints(variable >= size_variable * rel_lower, name=f'{name}|size_lower_bound') - - variables = {} # No new variables created - constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} - - return variables, constraints - - @staticmethod - def consecutive_duration_tracking( - model: FlowSystemModel, - name: str, - state_variable: linopy.Variable, - minimum_duration: Optional[TemporalData] = None, - maximum_duration: Optional[TemporalData] = None, - previous_duration: TemporalData = 0, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Creates consecutive duration tracking for a binary state variable. - - Mathematical formulation: - duration[t] ≤ state[t] * M ∀t - duration[t+1] ≤ duration[t] + hours_per_step[t] ∀t - duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M ∀t - duration[0] = (hours_per_step[0] + previous_duration) * state[0] - - If minimum_duration provided: - duration[t] ≥ (state[t-1] - state[t]) * minimum_duration[t-1] ∀t > 0 - - Args: - state_variable: Binary state variable to track duration for - minimum_duration: Optional minimum consecutive duration - maximum_duration: Optional maximum consecutive duration - previous_duration: Duration from before first timestep - - Returns: - variables: {'duration': duration_var} - constraints: {'upper_bound': constraint, 'forward': constraint, 'backward': constraint, ...} - """ - hours_per_step = model.hours_per_step - mega = hours_per_step.sum('time') + previous_duration # Big-M value - - # Duration variable - duration = model.add_variables( - lower=0, - upper=maximum_duration if maximum_duration is not None else mega, - coords=model.get_coords(['time']), - name=f'{name}|duration', - ) - - constraints = {} - - # Upper bound: duration[t] ≤ state[t] * M - constraints['upper_bound'] = model.add_constraints( - duration <= state_variable * mega, name=f'{name}|duration_upper_bound' - ) - - # Forward constraint: duration[t+1] ≤ duration[t] + hours_per_step[t] - constraints['forward'] = model.add_constraints( - duration.isel(time=slice(1, None)) - <= duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), - name=f'{name}|duration_forward', - ) - - # Backward constraint: duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M - constraints['backward'] = model.add_constraints( - duration.isel(time=slice(1, None)) - >= duration.isel(time=slice(None, -1)) - + hours_per_step.isel(time=slice(None, -1)) - + (state_variable.isel(time=slice(1, None)) - 1) * mega, - name=f'{name}|duration_backward', - ) - - # Initial condition: duration[0] = (hours_per_step[0] + previous_duration) * state[0] - constraints['initial'] = model.add_constraints( - duration.isel(time=0) - == (hours_per_step.isel(time=0) + previous_duration) * state_variable.isel(time=0), - name=f'{name}|duration_initial', - ) - - # Minimum duration constraint if provided - if minimum_duration is not None: - constraints['minimum'] = model.add_constraints( - duration.isel(time=slice(1, None)) - >= (state_variable.isel(time=slice(None, -1)) - state_variable.isel(time=slice(1, None))) - * minimum_duration.isel(time=slice(None, -1)), - name=f'{name}|duration_minimum', - ) - - # Handle initial condition for minimum duration - if previous_duration > 0 and previous_duration < minimum_duration.isel(time=0).max(): - constraints['initial_minimum'] = model.add_constraints( - state_variable.isel(time=0) == 1, name=f'{name}|duration_initial_minimum' - ) - - variables = {'duration': duration} - - return variables, constraints - - @staticmethod - def mutual_exclusivity_constraint( - model: FlowSystemModel, name: str, binary_variables: List[linopy.Variable], tolerance: float = 1.1 - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """ - Creates mutual exclusivity constraint for binary variables. - - Mathematical formulation: - Σ(binary_vars[i]) ≤ tolerance ∀t - - Ensures at most one binary variable can be 1 at any time. - Tolerance > 1.0 accounts for binary variable numerical precision. - - Args: - binary_variables: List of binary variables that should be mutually exclusive - tolerance: Upper bound (typically 1.1 for numerical stability) - - Returns: - variables: {} (no new variables created) - constraints: {'mutual_exclusivity': constraint} - - Raises: - AssertionError: If fewer than 2 variables provided or variables aren't binary - """ - assert len(binary_variables) >= 2, ( - f'Mutual exclusivity requires at least 2 variables, got {len(binary_variables)}' - ) - - for var in binary_variables: - assert var.attrs.get('binary', False), ( - f'Variable {var.name} must be binary for mutual exclusivity constraint' - ) - - # Create mutual exclusivity constraint - mutual_exclusivity = model.add_constraints( - sum(binary_variables) <= tolerance, name=f'{name}|mutual_exclusivity' - ) - - variables = {} # No new variables created - constraints = {'mutual_exclusivity': mutual_exclusivity} - - return variables, constraints - - -class ModelingPatterns: - """High-level patterns that compose primitives and return (variables, constraints) tuples""" - - @staticmethod - def investment_sizing_pattern( - model: FlowSystemModel, - name: str, - size_bounds: Tuple[TemporalData, TemporalData], - controlled_variables: List[linopy.Variable] = None, - control_factors: List[Tuple[TemporalData, TemporalData]] = None, - optional: bool = False, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Complete investment sizing pattern with optional binary decision. - - Returns: - variables: {'size': size_var, 'is_invested': binary_var (if optional)} - constraints: {'investment_upper_bound': constraint, 'investment_lower_bound': constraint, ...} - """ - variables = {} - constraints = {} - - # Investment size variable - size_min, size_max = size_bounds - variables['size'] = model.add_variables( - lower=size_min, - upper=size_max, - name=f'{name}|investment_size', - coords=model.get_coords(['year', 'scenario']), - ) - - # Optional binary investment decision - if optional: - variables['is_invested'] = model.add_variables( - binary=True, name=f'{name}|is_invested', coords=model.get_coords(['year', 'scenario']) - ) - - # Link size to investment decision - if abs(size_min - size_max) < 1e-10: # Fixed size case - constraints['fixed_investment_size'] = model.add_constraints( - variables['size'] == variables['is_invested'] * size_max, name=f'{name}|fixed_investment_size' - ) - else: # Variable size case - constraints['investment_upper_bound'] = model.add_constraints( - variables['size'] <= variables['is_invested'] * size_max, name=f'{name}|investment_upper_bound' - ) - constraints['investment_lower_bound'] = model.add_constraints( - variables['size'] >= variables['is_invested'] * max(CONFIG.modeling.EPSILON, size_min), - name=f'{name}|investment_lower_bound', - ) - - # Control dependent variables - if controlled_variables and control_factors: - for i, (var, factors) in enumerate(zip(controlled_variables, control_factors)): - _, control_constraints = ModelingPrimitives.big_m_binary_bounds( - model, f'{name}|control_{i}', var, variables.get('is_invested'), variables['size'], factors - ) - # Flatten control constraints with indexed names - constraints[f'control_{i}_upper_bound'] = control_constraints['upper_bound'] - constraints[f'control_{i}_lower_bound'] = control_constraints['lower_bound'] - - return variables, constraints - - @staticmethod - def operational_binary_control_pattern( - model: FlowSystemModel, - name: str, - controlled_variables: List[linopy.Variable], - variable_bounds: List[Tuple[TemporalData, TemporalData]], - use_complement: bool = False, - track_total_duration: bool = False, - track_switches: bool = False, - previous_state=0, - duration_bounds: Tuple[TemporalData, TemporalData] = None, - track_consecutive_on: bool = False, - consecutive_on_bounds: Tuple[Optional[TemporalData], Optional[TemporalData]] = (None, None), - previous_on_duration: TemporalData = 0, - track_consecutive_off: bool = False, - consecutive_off_bounds: Tuple[Optional[TemporalData], Optional[TemporalData]] = (None, None), - previous_off_duration: TemporalData = 0, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Enhanced operational binary control with consecutive duration tracking. - - New Args: - track_consecutive_on: Whether to track consecutive on duration - consecutive_on_bounds: (min_duration, max_duration) for consecutive on - previous_on_duration: Previous consecutive on duration - track_consecutive_off: Whether to track consecutive off duration - consecutive_off_bounds: (min_duration, max_duration) for consecutive off - previous_off_duration: Previous consecutive off duration - """ - variables = {} - constraints = {} - - # Main binary state (existing logic) - if use_complement: - state_vars, state_constraints = ModelingPrimitives.binary_state_pair(model, name) - variables.update(state_vars) - constraints.update(state_constraints) - else: - variables['on'] = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) - - # Control variables (existing logic) - for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): - constraints[f'control_{i}_lower'] = model.add_constraints( - variables['on'] * np.maximum(lower_bound, CONFIG.modeling.EPSILON) <= var, name=f'{name}|control_{i}_lower' - ) - constraints[f'control_{i}_upper'] = model.add_constraints( - var <= variables['on'] * upper_bound, name=f'{name}|control_{i}_upper' - ) - - # Total duration tracking (existing logic) - if track_total_duration: - duration_expr = (variables['on'] * model.hours_per_step).sum('time') - duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( - model, f'{name}|duration', duration_expr, duration_bounds - ) - variables['total_duration'] = duration_vars['tracker'] - constraints['duration_tracking'] = duration_constraints['tracking'] - - # Switch tracking (existing logic) - if track_switches: - switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( - model, f'{name}|switches', variables['on'], previous_state - ) - variables.update(switch_vars) - for switch_name, switch_constraint in switch_constraints.items(): - constraints[f'switch_{switch_name}'] = switch_constraint - - # NEW: Consecutive on duration tracking - if track_consecutive_on: - min_on, max_on = consecutive_on_bounds - consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( - model, - f'{name}|consecutive_on', - variables['on'], - minimum_duration=min_on, - maximum_duration=max_on, - previous_duration=previous_on_duration, - ) - variables['consecutive_on_duration'] = consecutive_on_vars['duration'] - for cons_name, cons_constraint in consecutive_on_constraints.items(): - constraints[f'consecutive_on_{cons_name}'] = cons_constraint - - # NEW: Consecutive off duration tracking - if track_consecutive_off and 'off' in variables: - min_off, max_off = consecutive_off_bounds - consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( - model, - f'{name}|consecutive_off', - variables['off'], - minimum_duration=min_off, - maximum_duration=max_off, - previous_duration=previous_off_duration, - ) - variables['consecutive_off_duration'] = consecutive_off_vars['duration'] - for cons_name, cons_constraint in consecutive_off_constraints.items(): - constraints[f'consecutive_off_{cons_name}'] = cons_constraint - - return variables, constraints - - class InvestmentModel(BaseFeatureModel): """Investment model using factory patterns but keeping old interface""" @@ -652,10 +28,10 @@ def __init__( parameters: InvestParameters, defining_variable: linopy.Variable, relative_bounds_of_defining_variable: Tuple[TemporalData, TemporalData], - label: Optional[str] = None, + label_of_model: Optional[str] = None, on_variable: Optional[linopy.Variable] = None, ): - super().__init__(model, label_of_element, parameters, label) + super().__init__(model, label_of_element=label_of_element, parameters=parameters, label_of_model=label_of_model) self._defining_variable = defining_variable self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable @@ -885,430 +261,6 @@ def _get_previous_state(self): return ModelingUtilities.get_most_recent_state(self._previous_values) -class StateModel(Model): - """ - Handles basic on/off binary states for defining variables - """ - - def __init__( - self, - model: FlowSystemModel, - label_of_element: str, - defining_variables: List[linopy.Variable], - defining_bounds: List[Tuple[TemporalData, TemporalData]], - previous_values: List[Optional[TemporalData]] = None, - use_off: bool = True, - on_hours_total_min: Optional[TemporalData] = 0, - on_hours_total_max: Optional[TemporalData] = None, - effects_per_running_hour: Dict[str, TemporalData] = None, - label: Optional[str] = None, - ): - """ - Models binary state variables based on a continous variable. - - Args: - model: The FlowSystemModel that is used to create the model. - label_of_element: The label of the parent (Element). Used to construct the full label of the model. - defining_variables: List of Variables that are used to define the state - defining_bounds: List of Tuples, defining the absolute bounds of each defining variable - previous_values: List of previous values of the defining variables - use_off: Whether to use the off state or not - on_hours_total_min: min. overall sum of operating hours. - on_hours_total_max: max. overall sum of operating hours. - effects_per_running_hour: Costs per operating hours - label: Label of the OnOffModel - """ - super().__init__(model, label_of_element, label) - assert len(defining_variables) == len(defining_bounds), 'Every defining Variable needs bounds to Model OnOff' - self._defining_variables = defining_variables - self._defining_bounds = defining_bounds - self._previous_values = previous_values or [] - self._on_hours_total_min = on_hours_total_min if on_hours_total_min is not None else 0 - self._on_hours_total_max = on_hours_total_max if on_hours_total_max is not None else np.inf - self._use_off = use_off - self._effects_per_running_hour = effects_per_running_hour if effects_per_running_hour is not None else {} - - self.on = None - self.total_on_hours: Optional[linopy.Variable] = None - self.off = None - - def do_modeling(self): - self.on = self.add( - self._model.add_variables( - name=f'{self.label_full}|on', - binary=True, - coords=self._model.get_coords(), - ), - 'on', - ) - - self.total_on_hours = self.add( - self._model.add_variables( - lower=self._on_hours_total_min, - upper=self._on_hours_total_max, - coords=self._model.get_coords(['year', 'scenario']), - name=f'{self.label_full}|on_hours_total', - ), - 'on_hours_total', - ) - - self.add( - self._model.add_constraints( - self.total_on_hours == (self.on * self._model.hours_per_step).sum('time'), - name=f'{self.label_full}|on_hours_total', - ), - 'on_hours_total', - ) - - # Add defining constraints for each variable - self._add_defining_constraints() - - if self._use_off: - self.off = self.add( - self._model.add_variables( - name=f'{self.label_full}|off', - binary=True, - coords=self._model.get_coords(), - ), - 'off', - ) - - # Constraint: on + off = 1 - self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}|off'), 'off') - - return self - - def _add_defining_constraints(self): - """Add constraints that link defining variables to the on state""" - nr_of_def_vars = len(self._defining_variables) - - if nr_of_def_vars == 1: - # Case for a single defining variable - def_var = self._defining_variables[0] - lb, ub = self._defining_bounds[0] - - # Constraint: on * lower_bound <= def_var - self.add( - self._model.add_constraints( - self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, name=f'{self.label_full}|on_con1' - ), - 'on_con1', - ) - - # Constraint: on * upper_bound >= def_var - self.add( - self._model.add_constraints(self.on * ub >= def_var, name=f'{self.label_full}|on_con2'), 'on_con2' - ) - else: - # Case for multiple defining variables - ub = sum(bound[1] for bound in self._defining_bounds) / nr_of_def_vars - lb = CONFIG.modeling.EPSILON #TODO: Can this be a bigger value? (maybe the smallest bound?) - - # Constraint: on * epsilon <= sum(all_defining_variables) - self.add( - self._model.add_constraints( - self.on * lb <= sum(self._defining_variables), name=f'{self.label_full}|on_con1' - ), - 'on_con1', - ) - - # Constraint to ensure all variables are zero when off. - # Divide by nr_of_def_vars to improve numerical stability (smaller factors) - self.add( - self._model.add_constraints( - self.on * ub >= sum([def_var / nr_of_def_vars for def_var in self._defining_variables]), - name=f'{self.label_full}|on_con2', - ), - 'on_con2', - ) - - @property - def previous_states(self) -> np.ndarray: - """Computes the previous states {0, 1} of defining variables as a binary array from their previous values.""" - return StateModel.compute_previous_states(self._previous_values, epsilon=CONFIG.modeling.EPSILON) - - @property - def previous_on_states(self) -> np.ndarray: - return self.previous_states - - @property - def previous_off_states(self): - return 1 - self.previous_states - - @staticmethod - def compute_previous_states(previous_values: List[TemporalData], epsilon: float = 1e-5) -> np.ndarray: - """Computes the previous states {0, 1} of defining variables as a binary array from their previous values.""" - if not previous_values or all([val is None for val in previous_values]): - return np.array([0]) - - # Convert to 2D-array and compute binary on/off states - previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None - if previous_values.ndim > 1: - return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) - - return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) - - -class SwitchStateModel(Model): - """ - Handles switch on/off transitions - """ - - def __init__( - self, - model: FlowSystemModel, - label_of_element: str, - state_variable: linopy.Variable, - previous_state=0, - switch_on_max: Optional[Scalar] = None, - label: Optional[str] = None, - ): - super().__init__(model, label_of_element, label) - self._state_variable = state_variable - self.previous_state = previous_state - self._switch_on_max = switch_on_max if switch_on_max is not None else np.inf - - self.switch_on = None - self.switch_off = None - self.switch_on_nr = None - - def do_modeling(self): - """Create switch variables and constraints""" - - # Create switch variables - self.switch_on = self.add( - self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.get_coords()), - 'switch_on', - ) - - self.switch_off = self.add( - self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.get_coords()), - 'switch_off', - ) - - # Create count variable for number of switches - self.switch_on_nr = self.add( - self._model.add_variables( - upper=self._switch_on_max, - lower=0, - name=f'{self.label_full}|switch_on_nr', - ), - 'switch_on_nr', - ) - - # Add switch constraints for all entries after the first timestep - self.add( - self._model.add_constraints( - self.switch_on.isel(time=slice(1, None)) - self.switch_off.isel(time=slice(1, None)) - == self._state_variable.isel(time=slice(1, None)) - self._state_variable.isel(time=slice(None, -1)), - name=f'{self.label_full}|switch_con', - ), - 'switch_con', - ) - - # Initial switch constraint - self.add( - self._model.add_constraints( - self.switch_on.isel(time=0) - self.switch_off.isel(time=0) - == self._state_variable.isel(time=0) - self.previous_state, - name=f'{self.label_full}|initial_switch_con', - ), - 'initial_switch_con', - ) - - # Mutual exclusivity constraint - self.add( - self._model.add_constraints(self.switch_on + self.switch_off <= 1.1, name=f'{self.label_full}|switch_on_or_off'), - 'switch_on_or_off', - ) - - # Total switch-on count constraint - self.add( - self._model.add_constraints( - self.switch_on_nr == self.switch_on.sum('time'), name=f'{self.label_full}|switch_on_nr' - ), - 'switch_on_nr', - ) - - return self - - -class ConsecutiveStateModel(Model): - """ - Handles tracking consecutive durations in a state - """ - - def __init__( - self, - model: FlowSystemModel, - label_of_element: str, - state_variable: linopy.Variable, - minimum_duration: Optional[TemporalData] = None, - maximum_duration: Optional[TemporalData] = None, - previous_states: Optional[TemporalData] = None, - label: Optional[str] = None, - ): - """ - Model and constraint the consecutive duration of a state variable. - - Args: - model: The FlowSystemModel that is used to create the model. - label_of_element: The label of the parent (Element). Used to construct the full label of the model. - state_variable: The state variable that is used to model the duration. state = {0, 1} - minimum_duration: The minimum duration of the state variable. - maximum_duration: The maximum duration of the state variable. - previous_states: The previous states of the state variable. - label: The label of the model. Used to construct the full label of the model. - """ - super().__init__(model, label_of_element, label) - self._state_variable = state_variable - self._previous_states = previous_states - self._minimum_duration = minimum_duration - self._maximum_duration = maximum_duration - - self.duration = None - - def do_modeling(self): - """Create consecutive duration variables and constraints""" - # Get the hours per step - hours_per_step = self._model.hours_per_step - mega = hours_per_step.sum('time') + self.previous_duration - - # Create the duration variable - self.duration = self.add( - self._model.add_variables( - lower=0, - upper=self._maximum_duration if self._maximum_duration is not None else mega, - coords=self._model.get_coords(), - name=f'{self.label_full}|hours', - ), - 'hours', - ) - - # Add constraints - - # Upper bound constraint - self.add( - self._model.add_constraints( - self.duration <= self._state_variable * mega, name=f'{self.label_full}|con1' - ), - 'con1', - ) - - # Forward constraint - self.add( - self._model.add_constraints( - self.duration.isel(time=slice(1, None)) - <= self.duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), - name=f'{self.label_full}|con2a', - ), - 'con2a', - ) - - # Backward constraint - self.add( - self._model.add_constraints( - self.duration.isel(time=slice(1, None)) - >= self.duration.isel(time=slice(None, -1)) - + hours_per_step.isel(time=slice(None, -1)) - + (self._state_variable.isel(time=slice(1, None)) - 1) * mega, - name=f'{self.label_full}|con2b', - ), - 'con2b', - ) - - # Add minimum duration constraints if specified - if self._minimum_duration is not None: - self.add( - self._model.add_constraints( - self.duration - >= ( - self._state_variable.isel(time=slice(None, -1)) - self._state_variable.isel(time=slice(1, None)) - ) - * self._minimum_duration.isel(time=slice(None, -1)), - name=f'{self.label_full}|minimum', - ), - 'minimum', - ) - - # Handle initial condition - if 0 < self.previous_duration < self._minimum_duration.isel(time=0).max(): - self.add( - self._model.add_constraints( - self._state_variable.isel(time=0) == 1, name=f'{self.label_full}|initial_minimum' - ), - 'initial_minimum', - ) - - # Set initial value - self.add( - self._model.add_constraints( - self.duration.isel(time=0) == - (hours_per_step.isel(time=0) + self.previous_duration) * self._state_variable.isel(time=0), - name=f'{self.label_full}|initial', - ), - 'initial', - ) - - return self - - @property - def previous_duration(self) -> Scalar: - """Computes the previous duration of the state variable""" - #TODO: Allow for other/dynamic timestep resolutions - return ConsecutiveStateModel.compute_consecutive_hours_in_state( - self._previous_states, self._model.hours_per_step.isel(time=0).values.flatten()[0] - ) - - @staticmethod - def compute_consecutive_hours_in_state( - binary_values: TemporalData, hours_per_timestep: Union[int, float, np.ndarray] - ) -> Scalar: - """ - Computes the final consecutive duration in state 'on' (=1) in hours, from a binary array. - - Args: - binary_values: An int or 1D binary array containing only `0`s and `1`s. - hours_per_timestep: The duration of each timestep in hours. - If a scalar is provided, it is used for all timesteps. - If an array is provided, it must be as long as the last consecutive duration in binary_values. - - Returns: - The duration of the binary variable in hours. - - Raises - ------ - TypeError - If the length of binary_values and dt_in_hours is not equal, but None is a scalar. - """ - if np.isscalar(binary_values) and np.isscalar(hours_per_timestep): - return binary_values * hours_per_timestep - elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): - return binary_values * hours_per_timestep[-1] - - if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON): - return 0 - - if np.isscalar(hours_per_timestep): - hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep - hours_per_timestep: np.ndarray - - indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] - if len(indexes_with_zero_values) == 0: - nr_of_indexes_with_consecutive_ones = len(binary_values) - else: - nr_of_indexes_with_consecutive_ones = len(binary_values) - indexes_with_zero_values[-1] - 1 - - if len(hours_per_timestep) < nr_of_indexes_with_consecutive_ones: - raise ValueError( - f'When trying to calculate the consecutive duration, the length of the last duration ' - f'({len(nr_of_indexes_with_consecutive_ones)}) is longer than the provided hours_per_timestep ({len(hours_per_timestep)}), ' - f'as {binary_values=}' - ) - - return np.sum(binary_values[-nr_of_indexes_with_consecutive_ones:] * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:]) - - class PieceModel(Model): """Class for modeling a linear piece of one or more variables in parallel""" @@ -1316,10 +268,10 @@ def __init__( self, model: FlowSystemModel, label_of_element: str, - label: str, + label_of_model: str, as_time_series: bool = True, ): - super().__init__(model, label_of_element, label) + super().__init__(model, label_of_element, label_of_model) self.inside_piece: Optional[linopy.Variable] = None self.lambda0: Optional[linopy.Variable] = None self.lambda1: Optional[linopy.Variable] = None @@ -1373,7 +325,7 @@ def __init__( piecewise_variables: Dict[str, Piecewise], zero_point: Optional[Union[bool, linopy.Variable]], as_time_series: bool, - label: str = '', + label_of_model: str = '', ): """ Modeling a Piecewise relation between miultiple variables. @@ -1388,7 +340,7 @@ def __init__( zero_point: A variable that can be used to define a zero point for the Piecewise relation. If None or False, no zero point is defined. as_time_series: Whether the Piecewise relation is defined for a TimeSeries or a single variable. """ - super().__init__(model, label_of_element, label) + super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) self._piecewise_variables = piecewise_variables self._zero_point = zero_point self._as_time_series = as_time_series @@ -1402,7 +354,7 @@ def do_modeling(self): PieceModel( model=self._model, label_of_element=self.label_of_element, - label=f'Piece_{i}', + label_of_model=f'{self.label_of_element}|Piece_{i}', as_time_series=self._as_time_series, ) ) @@ -1452,20 +404,80 @@ def do_modeling(self): ) +class PiecewiseEffectsModel(Model): + def __init__( + self, + model: FlowSystemModel, + label_of_element: str, + piecewise_origin: Tuple[str, Piecewise], + piecewise_shares: Dict[str, Piecewise], + zero_point: Optional[Union[bool, linopy.Variable]], + label: str = 'PiecewiseEffects', + ): + super().__init__(model, label_of_element, label) + assert len(piecewise_origin[1]) == len(list(piecewise_shares.values())[0]), ( + 'Piece length of variable_segments and share_segments must be equal' + ) + self._zero_point = zero_point + self._piecewise_origin = piecewise_origin + self._piecewise_shares = piecewise_shares + self.shares: Dict[str, linopy.Variable] = {} + + self.piecewise_model: Optional[PiecewiseModel] = None + + def do_modeling(self): + self.shares = { + effect: self.add( + self._model.add_variables( + coords=self._model.get_coords(['year', 'scenario']), name=f'{self.label_full}|{effect}' + ), + f'{effect}', + ) + for effect in self._piecewise_shares + } + + piecewise_variables = { + self._piecewise_origin[0]: self._piecewise_origin[1], + **{ + self.shares[effect_label].name: self._piecewise_shares[effect_label] + for effect_label in self._piecewise_shares + }, + } + + self.piecewise_model = self.add( + PiecewiseModel( + model=self._model, + label_of_element=self.label_of_element, + piecewise_variables=piecewise_variables, + zero_point=self._zero_point, + as_time_series=False, + label_of_model=f'{self.label_of_element}|PiecewiseEffects', + ) + ) + + self.piecewise_model.do_modeling() + + # Shares + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={effect: variable * 1 for effect, variable in self.shares.items()}, + target='invest', + ) + + class ShareAllocationModel(Model): def __init__( self, model: FlowSystemModel, dims: List[FlowSystemDimensions], label_of_element: Optional[str] = None, - label: Optional[str] = None, - label_full: Optional[str] = None, + label_of_model: Optional[str] = None, total_max: Optional[Scalar] = None, total_min: Optional[Scalar] = None, max_per_hour: Optional[TemporalData] = None, min_per_hour: Optional[TemporalData] = None, ): - super().__init__(model, label_of_element=label_of_element, label=label, label_full=label_full) + super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) if 'time' not in dims and (max_per_hour is not None or min_per_hour is not None): raise ValueError('Both max_per_hour and min_per_hour cannot be used when has_time_dim is False') @@ -1565,67 +577,6 @@ def add_share( self._eq_total_per_timestep.lhs -= self.shares[name] -class PiecewiseEffectsModel(Model): - def __init__( - self, - model: FlowSystemModel, - label_of_element: str, - piecewise_origin: Tuple[str, Piecewise], - piecewise_shares: Dict[str, Piecewise], - zero_point: Optional[Union[bool, linopy.Variable]], - label: str = 'PiecewiseEffects', - ): - super().__init__(model, label_of_element, label) - assert len(piecewise_origin[1]) == len(list(piecewise_shares.values())[0]), ( - 'Piece length of variable_segments and share_segments must be equal' - ) - self._zero_point = zero_point - self._piecewise_origin = piecewise_origin - self._piecewise_shares = piecewise_shares - self.shares: Dict[str, linopy.Variable] = {} - - self.piecewise_model: Optional[PiecewiseModel] = None - - def do_modeling(self): - self.shares = { - effect: self.add( - self._model.add_variables( - coords=self._model.get_coords(['year', 'scenario']), name=f'{self.label_full}|{effect}' - ), - f'{effect}', - ) - for effect in self._piecewise_shares - } - - piecewise_variables = { - self._piecewise_origin[0]: self._piecewise_origin[1], - **{ - self.shares[effect_label].name: self._piecewise_shares[effect_label] - for effect_label in self._piecewise_shares - }, - } - - self.piecewise_model = self.add( - PiecewiseModel( - model=self._model, - label_of_element=self.label_of_element, - piecewise_variables=piecewise_variables, - zero_point=self._zero_point, - as_time_series=False, - label='PiecewiseEffects', - ) - ) - - self.piecewise_model.do_modeling() - - # Shares - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={effect: variable * 1 for effect, variable in self.shares.items()}, - target='invest', - ) - - class PreventSimultaneousUsageModel(Model): """ Prevents multiple Multiple Binary variables from being 1 at the same time diff --git a/flixopt/modeling.py b/flixopt/modeling.py new file mode 100644 index 000000000..2b5445a3c --- /dev/null +++ b/flixopt/modeling.py @@ -0,0 +1,636 @@ +import logging +from typing import Dict, List, Optional, Tuple, Union + +import linopy +import numpy as np + +from .config import CONFIG +from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions +from .structure import Model, FlowSystemModel, BaseFeatureModel + +logger = logging.getLogger('flixopt') + + +class ModelingUtilities: + """Utility functions for modeling calculations - used across different classes""" + + @staticmethod + def compute_consecutive_hours_in_state( + binary_values: TemporalData, hours_per_timestep: Union[int, float, np.ndarray] + ) -> Scalar: + """ + Computes the final consecutive duration in state 'on' (=1) in hours, from a binary array. + + Args: + binary_values: An int or 1D binary array containing only `0`s and `1`s. + hours_per_timestep: The duration of each timestep in hours. + If a scalar is provided, it is used for all timesteps. + If an array is provided, it must be as long as the last consecutive duration in binary_values. + + Returns: + The duration of the binary variable in hours. + + Raises + ------ + TypeError + If the length of binary_values and dt_in_hours is not equal, but None is a scalar. + """ + if np.isscalar(binary_values) and np.isscalar(hours_per_timestep): + return binary_values * hours_per_timestep + elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): + return binary_values * hours_per_timestep[-1] + + if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON): + return 0 + + if np.isscalar(hours_per_timestep): + hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep + hours_per_timestep: np.ndarray + + indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] + if len(indexes_with_zero_values) == 0: + nr_of_indexes_with_consecutive_ones = len(binary_values) + else: + nr_of_indexes_with_consecutive_ones = len(binary_values) - indexes_with_zero_values[-1] - 1 + + if len(hours_per_timestep) < nr_of_indexes_with_consecutive_ones: + raise ValueError( + f'When trying to calculate the consecutive duration, the length of the last duration ' + f'({nr_of_indexes_with_consecutive_ones}) is longer than the provided hours_per_timestep ({len(hours_per_timestep)}), ' + f'as {binary_values=}' + ) + + return np.sum( + binary_values[-nr_of_indexes_with_consecutive_ones:] + * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:] + ) + + @staticmethod + def compute_previous_states(previous_values: List[TemporalData], epsilon: float = None) -> np.ndarray: + """ + Computes the previous states {0, 1} of defining variables as a binary array from their previous values. + + Args: + previous_values: List of previous values for variables + epsilon: Tolerance for zero detection (uses CONFIG.modeling.EPSILON if None) + + Returns: + Binary array of previous states + """ + if epsilon is None: + epsilon = CONFIG.modeling.EPSILON + + if not previous_values or all(val is None for val in previous_values): + return np.array([0]) + + # Convert to 2D-array and compute binary on/off states + previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None + if previous_values.ndim > 1: + return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) + + return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) + + @staticmethod + def compute_previous_on_duration(previous_values: List[TemporalData], hours_per_step: Union[int, float]) -> Scalar: + """ + Convenience method to compute previous consecutive 'on' duration. + + Args: + previous_values: List of previous values for variables + hours_per_step: Duration of each timestep in hours + + Returns: + Previous consecutive on duration in hours + """ + if not previous_values: + return 0 + + previous_states = ModelingUtilities.compute_previous_states(previous_values) + return ModelingUtilities.compute_consecutive_hours_in_state(previous_states, hours_per_step) + + @staticmethod + def compute_previous_off_duration(previous_values: List[TemporalData], hours_per_step: Union[int, float]) -> Scalar: + """ + Convenience method to compute previous consecutive 'off' duration. + + Args: + previous_values: List of previous values for variables + hours_per_step: Duration of each timestep in hours + + Returns: + Previous consecutive off duration in hours + """ + if not previous_values: + return 0 + + previous_states = ModelingUtilities.compute_previous_states(previous_values) + previous_off_states = 1 - previous_states + return ModelingUtilities.compute_consecutive_hours_in_state(previous_off_states, hours_per_step) + + @staticmethod + def get_most_recent_state(previous_values: List[TemporalData]) -> int: + """ + Get the most recent binary state from previous values. + + Args: + previous_values: List of previous values for variables + + Returns: + Most recent binary state (0 or 1) + """ + if not previous_values: + return 0 + + previous_states = ModelingUtilities.compute_previous_states(previous_values) + return int(previous_states[-1]) + + +class ModelingPrimitives: + """Mathematical modeling primitives returning (variables, constraints) tuples""" + + @staticmethod + def binary_state_pair( + model: FlowSystemModel, name: str, coords: List[str] = None + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates complementary binary variables with completeness constraint. + + Mathematical formulation: + on[t] + off[t] = 1 ∀t + on[t], off[t] ∈ {0, 1} + + Returns: + variables: {'on': binary_var, 'off': binary_var} + constraints: {'complementary': constraint} + """ + coords = coords or ['time'] + + on = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(coords)) + off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(coords)) + + # Constraint: on + off = 1 + complementary = model.add_constraints(on + off == 1, name=f'{name}|complementary') + + variables = {'on': on, 'off': off} + constraints = {'complementary': complementary} + + return variables, constraints + + @staticmethod + def proportionally_bounded_variable( + model: FlowSystemModel, + name: str, + controlling_variable, + bounds: Tuple[TemporalData, TemporalData], + coords: List[str] = None, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates variable with bounds proportional to another variable. + + Mathematical formulation: + lower_factor[t] * controller[t] ≤ variable[t] ≤ upper_factor[t] * controller[t] ∀t + + Returns: + variables: {'variable': bounded_var} + constraints: {'lower_bound': constraint, 'upper_bound': constraint} + """ + coords = coords or ['time'] + variable = model.add_variables(name=f'{name}|bounded', coords=model.get_coords(coords)) + + lower_factor, upper_factor = bounds + + # Constraints: lower_factor * controller ≤ var ≤ upper_factor * controller + lower_bound = model.add_constraints( + variable >= controlling_variable * lower_factor, name=f'{name}|proportional_lb' + ) + upper_bound = model.add_constraints( + variable <= controlling_variable * upper_factor, name=f'{name}|proportional_ub' + ) + + variables = {'variable': variable} + constraints = {'lower_bound': lower_bound, 'upper_bound': upper_bound} + + return variables, constraints + + @staticmethod + def expression_tracking_variable( + model: FlowSystemModel, + name: str, + tracked_expression, + bounds: Tuple[TemporalData, TemporalData] = None, + coords: List[str] = None, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates variable that equals a given expression. + + Mathematical formulation: + tracker = expression + lower ≤ tracker ≤ upper (if bounds provided) + + Returns: + variables: {'tracker': tracker_var} + constraints: {'tracking': constraint} + """ + coords = coords or ['year', 'scenario'] + + if not bounds: + tracker = model.add_variables(name=f'{name}', coords=model.get_coords(coords)) + else: + tracker = model.add_variables( + lower=bounds[0] if bounds[0] is not None else -np.inf, + upper=bounds[1] if bounds[1] is not None else np.inf, + name=f'{name}', + coords=model.get_coords(coords), + ) + + # Constraint: tracker = expression + tracking = model.add_constraints(tracker == tracked_expression, name=f'{name}') + + variables = {'tracker': tracker} + constraints = {'tracking': tracking} + + return variables, constraints + + @staticmethod + def state_transition_variables( + model: FlowSystemModel, name: str, state_variable, previous_state=0 + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates switch-on/off variables with state transition logic. + + Mathematical formulation: + switch_on[t] - switch_off[t] = state[t] - state[t-1] ∀t > 0 + switch_on[0] - switch_off[0] = state[0] - previous_state + switch_on[t] + switch_off[t] ≤ 1 ∀t + switch_on[t], switch_off[t] ∈ {0, 1} + + Returns: + variables: {'switch_on': binary_var, 'switch_off': binary_var} + constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint} + """ + switch_on = model.add_variables(binary=True, name=f'{name}|switch_on', coords=model.get_coords(['time'])) + switch_off = model.add_variables(binary=True, name=f'{name}|switch_off', coords=model.get_coords(['time'])) + + # State transition constraints for t > 0 + transition = model.add_constraints( + switch_on.isel(time=slice(1, None)) - switch_off.isel(time=slice(1, None)) + == state_variable.isel(time=slice(1, None)) - state_variable.isel(time=slice(None, -1)), + name=f'{name}|state_transition', + ) + + # Initial state transition for t = 0 + initial = model.add_constraints( + switch_on.isel(time=0) - switch_off.isel(time=0) == state_variable.isel(time=0) - previous_state, + name=f'{name}|initial_transition', + ) + + # At most one switch per timestep + mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|switch_mutex') + + variables = {'switch_on': switch_on, 'switch_off': switch_off} + constraints = {'transition': transition, 'initial': initial, 'mutex': mutex} + + return variables, constraints + + @staticmethod + def big_m_binary_bounds( + model: FlowSystemModel, + name: str, + variable, + binary_control, + size_variable, + relative_bounds: Tuple[TemporalData, TemporalData], + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates bounds controlled by both binary and continuous variables. + + Mathematical formulation: + variable[t] ≤ size[t] * upper_factor[t] ∀t + + If binary_control provided: + variable[t] ≥ M * (binary[t] - 1) + size[t] * lower_factor[t] ∀t + where M = max(size) * max(upper_factor) + Else: + variable[t] ≥ size[t] * lower_factor[t] ∀t + + Returns: + variables: {} (no new variables created) + constraints: {'upper_bound': constraint, 'lower_bound': constraint} + """ + rel_lower, rel_upper = relative_bounds + + # Upper bound: variable ≤ size * upper_factor + upper_bound = model.add_constraints(variable <= size_variable * rel_upper, name=f'{name}|size_upper_bound') + + if binary_control is not None: + # Big-M lower bound: variable ≥ M*(binary-1) + size*lower_factor + big_m = size_variable.max() * rel_upper.max() # Conservative big-M + lower_bound = model.add_constraints( + variable >= big_m * (binary_control - 1) + size_variable * rel_lower, + name=f'{name}|binary_controlled_lower_bound', + ) + else: + # Simple lower bound: variable ≥ size * lower_factor + lower_bound = model.add_constraints(variable >= size_variable * rel_lower, name=f'{name}|size_lower_bound') + + variables = {} # No new variables created + constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} + + return variables, constraints + + @staticmethod + def consecutive_duration_tracking( + model: FlowSystemModel, + name: str, + state_variable: linopy.Variable, + minimum_duration: Optional[TemporalData] = None, + maximum_duration: Optional[TemporalData] = None, + previous_duration: TemporalData = 0, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Creates consecutive duration tracking for a binary state variable. + + Mathematical formulation: + duration[t] ≤ state[t] * M ∀t + duration[t+1] ≤ duration[t] + hours_per_step[t] ∀t + duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M ∀t + duration[0] = (hours_per_step[0] + previous_duration) * state[0] + + If minimum_duration provided: + duration[t] ≥ (state[t-1] - state[t]) * minimum_duration[t-1] ∀t > 0 + + Args: + state_variable: Binary state variable to track duration for + minimum_duration: Optional minimum consecutive duration + maximum_duration: Optional maximum consecutive duration + previous_duration: Duration from before first timestep + + Returns: + variables: {'duration': duration_var} + constraints: {'upper_bound': constraint, 'forward': constraint, 'backward': constraint, ...} + """ + hours_per_step = model.hours_per_step + mega = hours_per_step.sum('time') + previous_duration # Big-M value + + # Duration variable + duration = model.add_variables( + lower=0, + upper=maximum_duration if maximum_duration is not None else mega, + coords=model.get_coords(['time']), + name=f'{name}|duration', + ) + + constraints = {} + + # Upper bound: duration[t] ≤ state[t] * M + constraints['upper_bound'] = model.add_constraints( + duration <= state_variable * mega, name=f'{name}|duration_upper_bound' + ) + + # Forward constraint: duration[t+1] ≤ duration[t] + hours_per_step[t] + constraints['forward'] = model.add_constraints( + duration.isel(time=slice(1, None)) + <= duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), + name=f'{name}|duration_forward', + ) + + # Backward constraint: duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M + constraints['backward'] = model.add_constraints( + duration.isel(time=slice(1, None)) + >= duration.isel(time=slice(None, -1)) + + hours_per_step.isel(time=slice(None, -1)) + + (state_variable.isel(time=slice(1, None)) - 1) * mega, + name=f'{name}|duration_backward', + ) + + # Initial condition: duration[0] = (hours_per_step[0] + previous_duration) * state[0] + constraints['initial'] = model.add_constraints( + duration.isel(time=0) + == (hours_per_step.isel(time=0) + previous_duration) * state_variable.isel(time=0), + name=f'{name}|duration_initial', + ) + + # Minimum duration constraint if provided + if minimum_duration is not None: + constraints['minimum'] = model.add_constraints( + duration.isel(time=slice(1, None)) + >= (state_variable.isel(time=slice(None, -1)) - state_variable.isel(time=slice(1, None))) + * minimum_duration.isel(time=slice(None, -1)), + name=f'{name}|duration_minimum', + ) + + # Handle initial condition for minimum duration + if previous_duration > 0 and previous_duration < minimum_duration.isel(time=0).max(): + constraints['initial_minimum'] = model.add_constraints( + state_variable.isel(time=0) == 1, name=f'{name}|duration_initial_minimum' + ) + + variables = {'duration': duration} + + return variables, constraints + + @staticmethod + def mutual_exclusivity_constraint( + model: FlowSystemModel, name: str, binary_variables: List[linopy.Variable], tolerance: float = 1.1 + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates mutual exclusivity constraint for binary variables. + + Mathematical formulation: + Σ(binary_vars[i]) ≤ tolerance ∀t + + Ensures at most one binary variable can be 1 at any time. + Tolerance > 1.0 accounts for binary variable numerical precision. + + Args: + binary_variables: List of binary variables that should be mutually exclusive + tolerance: Upper bound (typically 1.1 for numerical stability) + + Returns: + variables: {} (no new variables created) + constraints: {'mutual_exclusivity': constraint} + + Raises: + AssertionError: If fewer than 2 variables provided or variables aren't binary + """ + assert len(binary_variables) >= 2, ( + f'Mutual exclusivity requires at least 2 variables, got {len(binary_variables)}' + ) + + for var in binary_variables: + assert var.attrs.get('binary', False), ( + f'Variable {var.name} must be binary for mutual exclusivity constraint' + ) + + # Create mutual exclusivity constraint + mutual_exclusivity = model.add_constraints( + sum(binary_variables) <= tolerance, name=f'{name}|mutual_exclusivity' + ) + + variables = {} # No new variables created + constraints = {'mutual_exclusivity': mutual_exclusivity} + + return variables, constraints + + +class ModelingPatterns: + """High-level patterns that compose primitives and return (variables, constraints) tuples""" + + @staticmethod + def investment_sizing_pattern( + model: FlowSystemModel, + name: str, + size_bounds: Tuple[TemporalData, TemporalData], + controlled_variables: List[linopy.Variable] = None, + control_factors: List[Tuple[TemporalData, TemporalData]] = None, + optional: bool = False, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Complete investment sizing pattern with optional binary decision. + + Returns: + variables: {'size': size_var, 'is_invested': binary_var (if optional)} + constraints: {'upper_bound': constraint, 'lower_bound': constraint, ...} + """ + variables = {} + constraints = {} + + # Investment size variable + size_min, size_max = size_bounds + variables['size'] = model.add_variables( + lower=size_min, + upper=size_max, + name=f'{name}|size', + coords=model.get_coords(['year', 'scenario']), + ) + + # Optional binary investment decision + if optional: + variables['is_invested'] = model.add_variables( + binary=True, name=f'{name}|is_invested', coords=model.get_coords(['year', 'scenario']) + ) + + # Link size to investment decision + if abs(size_min - size_max) < 1e-10: # Fixed size case + constraints['fixed_size'] = model.add_constraints( + variables['size'] == variables['is_invested'] * size_max, name=f'{name}|fixed_size' + ) + else: # Variable size case + constraints['upper_bound'] = model.add_constraints( + variables['size'] <= variables['is_invested'] * size_max, name=f'{name}|upper_bound' + ) + constraints['lower_bound'] = model.add_constraints( + variables['size'] >= variables['is_invested'] * max(CONFIG.modeling.EPSILON, size_min), + name=f'{name}|lower_bound', + ) + + # Control dependent variables + if controlled_variables and control_factors: + for i, (var, factors) in enumerate(zip(controlled_variables, control_factors)): + _, control_constraints = ModelingPrimitives.big_m_binary_bounds( + model, f'{name}|control_{i}', var, variables.get('is_invested'), variables['size'], factors + ) + # Flatten control constraints with indexed names + constraints[f'control_{i}_upper_bound'] = control_constraints['upper_bound'] + constraints[f'control_{i}_lower_bound'] = control_constraints['lower_bound'] + + return variables, constraints + + @staticmethod + def operational_binary_control_pattern( + model: FlowSystemModel, + name: str, + controlled_variables: List[linopy.Variable], + variable_bounds: List[Tuple[TemporalData, TemporalData]], + use_complement: bool = False, + track_total_duration: bool = False, + track_switches: bool = False, + previous_state=0, + duration_bounds: Tuple[TemporalData, TemporalData] = None, + track_consecutive_on: bool = False, + consecutive_on_bounds: Tuple[Optional[TemporalData], Optional[TemporalData]] = (None, None), + previous_on_duration: TemporalData = 0, + track_consecutive_off: bool = False, + consecutive_off_bounds: Tuple[Optional[TemporalData], Optional[TemporalData]] = (None, None), + previous_off_duration: TemporalData = 0, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + """ + Enhanced operational binary control with consecutive duration tracking. + + New Args: + track_consecutive_on: Whether to track consecutive on duration + consecutive_on_bounds: (min_duration, max_duration) for consecutive on + previous_on_duration: Previous consecutive on duration + track_consecutive_off: Whether to track consecutive off duration + consecutive_off_bounds: (min_duration, max_duration) for consecutive off + previous_off_duration: Previous consecutive off duration + """ + variables = {} + constraints = {} + + # Main binary state (existing logic) + if use_complement: + state_vars, state_constraints = ModelingPrimitives.binary_state_pair(model, name) + variables.update(state_vars) + constraints.update(state_constraints) + else: + variables['on'] = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) + + # Control variables (existing logic) + for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): + constraints[f'control_{i}_lower'] = model.add_constraints( + variables['on'] * np.maximum(lower_bound, CONFIG.modeling.EPSILON) <= var, name=f'{name}|control_{i}_lower' + ) + constraints[f'control_{i}_upper'] = model.add_constraints( + var <= variables['on'] * upper_bound, name=f'{name}|control_{i}_upper' + ) + + # Total duration tracking (existing logic) + if track_total_duration: + duration_expr = (variables['on'] * model.hours_per_step).sum('time') + duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( + model, f'{name}|duration', duration_expr, duration_bounds + ) + variables['total_duration'] = duration_vars['tracker'] + constraints['duration_tracking'] = duration_constraints['tracking'] + + # Switch tracking (existing logic) + if track_switches: + switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( + model, f'{name}|switches', variables['on'], previous_state + ) + variables.update(switch_vars) + for switch_name, switch_constraint in switch_constraints.items(): + constraints[f'switch_{switch_name}'] = switch_constraint + + # NEW: Consecutive on duration tracking + if track_consecutive_on: + min_on, max_on = consecutive_on_bounds + consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( + model, + f'{name}|consecutive_on', + variables['on'], + minimum_duration=min_on, + maximum_duration=max_on, + previous_duration=previous_on_duration, + ) + variables['consecutive_on_duration'] = consecutive_on_vars['duration'] + for cons_name, cons_constraint in consecutive_on_constraints.items(): + constraints[f'consecutive_on_{cons_name}'] = cons_constraint + + # NEW: Consecutive off duration tracking + if track_consecutive_off and 'off' in variables: + min_off, max_off = consecutive_off_bounds + consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( + model, + f'{name}|consecutive_off', + variables['off'], + minimum_duration=min_off, + maximum_duration=max_off, + previous_duration=previous_off_duration, + ) + variables['consecutive_off_duration'] = consecutive_off_vars['duration'] + for cons_name, cons_constraint in consecutive_off_constraints.items(): + constraints[f'consecutive_off_{cons_name}'] = cons_constraint + + return variables, constraints diff --git a/flixopt/structure.py b/flixopt/structure.py index 5a4b016ce..ec594ca6e 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -700,19 +700,17 @@ class Model: """Stores Variables and Constraints.""" def __init__( - self, model: FlowSystemModel, label_of_element: str, label: str = '', label_full: Optional[str] = None + self, model: FlowSystemModel, label_of_element: str, label_of_model = None ): """ Args: model: The FlowSystemModel that is used to create the model. label_of_element: The label of the parent (Element). Used to construct the full label of the model. - label: The label of the model. Used to construct the full label of the model. - label_full: The full label of the model. Can overwrite the full label constructed from the other labels. + label_of_model: The label of the model. Used as a prefix in all variables and constraints. """ self._model = model self.label_of_element = label_of_element - self._label = label - self._label_full = label_full + self.label_of_model = label_of_model if label_of_model is not None else self.label_of_element self._variables_direct: List[str] = [] self._constraints_direct: List[str] = [] @@ -777,16 +775,11 @@ def filter_variables( @property def label(self) -> str: - return self._label if self._label else self.label_of_element + return self.label_of_model @property def label_full(self) -> str: - """Used to construct the names of variables and constraints""" - if self._label_full: - return self._label_full - elif self._label: - return f'{self.label_of_element}|{self.label}' - return self.label_of_element + return self.label_of_model @property def variables_direct(self) -> linopy.Variables: @@ -846,8 +839,16 @@ def get_constraint_by_short_name(self, short_name: str, default_return = None) - class BaseFeatureModel(Model): """Minimal base class for feature models that use factory patterns""" - def __init__(self, model: FlowSystemModel, label_of_element: str, parameters, label: Optional[str] = None): - super().__init__(model, label_of_element, label or self.__class__.__name__) + def __init__(self, model: FlowSystemModel, label_of_element: str, parameters, label_of_model: Optional[str] = None): + """Initialize the BaseFeatureModel. + Args: + model: The FlowSystemModel that is used to create the model. + label_of_element: The label of the parent (Element). Used to create shares. + label_of_model: The label of the model. Used as a prefix in all variables and constraints. + Defaults to {label_of_element}|{self.__class__.__name__} + parameters: The parameters of the feature model. + """ + super().__init__(model, label_of_element, label_of_model or f'{label_of_element}|{self.__class__.__name__}') self.parameters = parameters def do_modeling(self): @@ -873,7 +874,7 @@ def __init__(self, model: FlowSystemModel, element: Element): model: The FlowSystemModel that is used to create the model. element: The element this model is created for. """ - super().__init__(model, label_of_element=element.label_full, label=element.label, label_full=element.label_full) + super().__init__(model, label_of_element=element.label_full) self.element = element def results_structure(self): From a3511f91aae37d6d50cf04643f6bb275b4078387 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:55:02 +0200 Subject: [PATCH 12/51] Add naming options to big_m_binary_bounds() --- flixopt/modeling.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 2b5445a3c..1651cb12b 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -295,11 +295,12 @@ def state_transition_variables( @staticmethod def big_m_binary_bounds( model: FlowSystemModel, - name: str, variable, binary_control, size_variable, relative_bounds: Tuple[TemporalData, TemporalData], + upper_bound_name: str, + lower_bound_name: str, ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: """ Creates bounds controlled by both binary and continuous variables. @@ -320,18 +321,16 @@ def big_m_binary_bounds( rel_lower, rel_upper = relative_bounds # Upper bound: variable ≤ size * upper_factor - upper_bound = model.add_constraints(variable <= size_variable * rel_upper, name=f'{name}|size_upper_bound') + upper_bound = model.add_constraints(variable <= size_variable * rel_upper, name=upper_bound_name) if binary_control is not None: # Big-M lower bound: variable ≥ M*(binary-1) + size*lower_factor big_m = size_variable.max() * rel_upper.max() # Conservative big-M lower_bound = model.add_constraints( - variable >= big_m * (binary_control - 1) + size_variable * rel_lower, - name=f'{name}|binary_controlled_lower_bound', + variable >= big_m * (binary_control - 1) + size_variable * rel_lower, name=lower_bound_name ) else: - # Simple lower bound: variable ≥ size * lower_factor - lower_bound = model.add_constraints(variable >= size_variable * rel_lower, name=f'{name}|size_lower_bound') + lower_bound = model.add_constraints(variable >= size_variable * rel_lower, name=lower_bound_name) variables = {} # No new variables created constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} From 404dc033fc9de602c955c58cd8a083d08bd515dc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:36:39 +0200 Subject: [PATCH 13/51] Fix and improve FLowModeling with Investment --- flixopt/effects.py | 2 +- flixopt/features.py | 6 ++-- flixopt/modeling.py | 43 ++++++++++++++++++++-------- tests/test_flow.py | 68 ++++++++++++++++++++++----------------------- 4 files changed, 68 insertions(+), 51 deletions(-) diff --git a/flixopt/effects.py b/flixopt/effects.py index 2fc2aae37..0e4236076 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -158,7 +158,7 @@ def __init__(self, model: FlowSystemModel, element: Effect): model=self._model, dims=('time', 'year', 'scenario'), label_of_element=self.label_of_element, - label_of_model=f'{self.label_of_model}|(operation)', + label_of_model=f'{self.label_of_model}(operation)', total_max=self.element.maximum_operation, total_min=self.element.minimum_operation, min_per_hour=self.element.minimum_operation_per_hour diff --git a/flixopt/features.py b/flixopt/features.py index e08d94cb1..49635ad3d 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -46,12 +46,10 @@ def create_variables_and_constraints(self): variables, constraints = ModelingPatterns.investment_sizing_pattern( model=self._model, name=self.label_full, - size_bounds=( - 0 if self.parameters.optional else self.parameters.minimum_or_fixed_size, - self.parameters.maximum_or_fixed_size, - ), + size_bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size,), controlled_variables=[self._defining_variable], control_factors=[self._relative_bounds_of_defining_variable], + state_variables=[self._on_variable], optional=self.parameters.optional, ) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 1651cb12b..f7aca8755 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -323,14 +323,15 @@ def big_m_binary_bounds( # Upper bound: variable ≤ size * upper_factor upper_bound = model.add_constraints(variable <= size_variable * rel_upper, name=upper_bound_name) - if binary_control is not None: + if binary_control is None: + lower_bound = model.add_constraints(variable >= size_variable * rel_lower, name=lower_bound_name) + else: # Big-M lower bound: variable ≥ M*(binary-1) + size*lower_factor - big_m = size_variable.max() * rel_upper.max() # Conservative big-M + big_m = CONFIG.modeling.BIG #size_variable.max() * rel_upper.max() # Conservative big-M lower_bound = model.add_constraints( variable >= big_m * (binary_control - 1) + size_variable * rel_lower, name=lower_bound_name ) - else: - lower_bound = model.add_constraints(variable >= size_variable * rel_lower, name=lower_bound_name) + variables = {} # No new variables created constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} @@ -482,11 +483,21 @@ def investment_sizing_pattern( size_bounds: Tuple[TemporalData, TemporalData], controlled_variables: List[linopy.Variable] = None, control_factors: List[Tuple[TemporalData, TemporalData]] = None, + state_variables: List[linopy.Variable] = None, optional: bool = False, ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: """ Complete investment sizing pattern with optional binary decision. + Args: + model: The model to add the variables to. + name: The name of the investment variable. + size_bounds: The minimum and maximum investment size. + controlled_variables: The variables that are controlled by the investment decision. + control_factors: The control factors for the controlled variables. + state_variables: State variable defining the state of the controlled variables. + optional: Whether the investment decision is optional. + Returns: variables: {'size': size_var, 'is_invested': binary_var (if optional)} constraints: {'upper_bound': constraint, 'lower_bound': constraint, ...} @@ -497,7 +508,7 @@ def investment_sizing_pattern( # Investment size variable size_min, size_max = size_bounds variables['size'] = model.add_variables( - lower=size_min, + lower=0 if optional else size_min, upper=size_max, name=f'{name}|size', coords=model.get_coords(['year', 'scenario']), @@ -516,22 +527,30 @@ def investment_sizing_pattern( ) else: # Variable size case constraints['upper_bound'] = model.add_constraints( - variables['size'] <= variables['is_invested'] * size_max, name=f'{name}|upper_bound' + variables['size'] <= variables['is_invested'] * size_max, name=f'{name}|size|upper_bound' ) constraints['lower_bound'] = model.add_constraints( - variables['size'] >= variables['is_invested'] * max(CONFIG.modeling.EPSILON, size_min), - name=f'{name}|lower_bound', + variables['size'] >= variables['is_invested'] * np.maximum(CONFIG.modeling.EPSILON, size_min), + name=f'{name}|size|lower_bound', ) # Control dependent variables if controlled_variables and control_factors: - for i, (var, factors) in enumerate(zip(controlled_variables, control_factors)): + for i, (var, factors, state_variable) in enumerate(zip(controlled_variables, control_factors, state_variables)): + upper_bound_name = f'{var.name}|upper_bound' + lower_bound_name = f'{var.name}|lower_bound' _, control_constraints = ModelingPrimitives.big_m_binary_bounds( - model, f'{name}|control_{i}', var, variables.get('is_invested'), variables['size'], factors + model=model, + variable=var, + binary_control=state_variable, + size_variable=variables['size'], + relative_bounds=factors, + upper_bound_name=upper_bound_name, + lower_bound_name=lower_bound_name, ) # Flatten control constraints with indexed names - constraints[f'control_{i}_upper_bound'] = control_constraints['upper_bound'] - constraints[f'control_{i}_lower_bound'] = control_constraints['lower_bound'] + constraints[upper_bound_name] = control_constraints['upper_bound'] + constraints[lower_bound_name] = control_constraints['lower_bound'] return variables, constraints diff --git a/tests/test_flow.py b/tests/test_flow.py index cce10b21a..9038af1c7 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -143,8 +143,8 @@ def test_flow_invest(self, basic_flow_system_linopy): assert set(flow.model.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', - 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|flow_rate|upper_bound', + 'Sink(Wärme)|flow_rate|lower_bound', ] ) @@ -161,13 +161,13 @@ def test_flow_invest(self, basic_flow_system_linopy): ), ) assert_conequal( - model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|lower_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( - model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|upper_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), @@ -194,10 +194,10 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): assert set(flow.model.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|is_invested_ub', - 'Sink(Wärme)|is_invested_lb', - 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', - 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|size|lower_bound', + 'Sink(Wärme)|size|upper_bound', + 'Sink(Wärme)|flow_rate|lower_bound', + 'Sink(Wärme)|flow_rate|upper_bound', ] ) @@ -215,13 +215,13 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): ), ) assert_conequal( - model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|lower_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( - model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|upper_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), @@ -229,11 +229,11 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): # Is invested assert_conequal( - model.constraints['Sink(Wärme)|is_invested_ub'], + model.constraints['Sink(Wärme)|size|upper_bound'], flow.model.variables['Sink(Wärme)|size'] <= flow.model.variables['Sink(Wärme)|is_invested'] * 100, ) assert_conequal( - model.constraints['Sink(Wärme)|is_invested_lb'], + model.constraints['Sink(Wärme)|size|lower_bound'], flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 20, ) @@ -258,10 +258,10 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): assert set(flow.model.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|is_invested_ub', - 'Sink(Wärme)|is_invested_lb', - 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', - 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|size|upper_bound', + 'Sink(Wärme)|size|lower_bound', + 'Sink(Wärme)|flow_rate|lower_bound', + 'Sink(Wärme)|flow_rate|upper_bound', ] ) @@ -279,13 +279,13 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): ), ) assert_conequal( - model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|lower_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( - model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|upper_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), @@ -293,11 +293,11 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): # Is invested assert_conequal( - model.constraints['Sink(Wärme)|is_invested_ub'], + model.constraints['Sink(Wärme)|size|upper_bound'], flow.model.variables['Sink(Wärme)|size'] <= flow.model.variables['Sink(Wärme)|is_invested'] * 100, ) assert_conequal( - model.constraints['Sink(Wärme)|is_invested_lb'], + model.constraints['Sink(Wärme)|size|lower_bound'], flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 1e-5, ) @@ -322,8 +322,8 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): assert set(flow.model.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', - 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|flow_rate|lower_bound', + 'Sink(Wärme)|flow_rate|upper_bound', ] ) @@ -339,13 +339,13 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): ), ) assert_conequal( - model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|lower_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( - model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|upper_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), @@ -935,10 +935,10 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): 'Sink(Wärme)|on_hours_total', 'Sink(Wärme)|on_con1', 'Sink(Wärme)|on_con2', - 'Sink(Wärme)|is_invested_lb', - 'Sink(Wärme)|is_invested_ub', - 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', - 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|size|lower_bound', + 'Sink(Wärme)|size|upper_bound', + 'Sink(Wärme)|flow_rate|lower_bound', + 'Sink(Wärme)|flow_rate|upper_bound', ] ) @@ -980,12 +980,12 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): mega = 0.2 * 200 # Relative minimum * maximum size assert_conequal( - model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|lower_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|on'] * mega + flow.model.variables['Sink(Wärme)|size'] * 0.2 - mega, ) assert_conequal( - model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|upper_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8, ) @@ -1019,8 +1019,8 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): 'Sink(Wärme)|on_hours_total', 'Sink(Wärme)|on_con1', 'Sink(Wärme)|on_con2', - 'Sink(Wärme)|lb_Sink(Wärme)|flow_rate', - 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate', + 'Sink(Wärme)|flow_rate|lower_bound', + 'Sink(Wärme)|flow_rate|upper_bound', ] ) @@ -1062,12 +1062,12 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): mega = 0.2 * 200 # Relative minimum * maximum size assert_conequal( - model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|lower_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|on'] * mega + flow.model.variables['Sink(Wärme)|size'] * 0.2 - mega, ) assert_conequal( - model.constraints['Sink(Wärme)|ub_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|upper_bound'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8, ) From 1ad74ce43b507872bfe16533a3fe3d56f942e2ab Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 13:17:45 +0200 Subject: [PATCH 14/51] Improve --- flixopt/elements.py | 9 +-- flixopt/features.py | 133 ++++++++++++++++++++++++++++---------------- flixopt/modeling.py | 57 ++++++++++--------- 3 files changed, 118 insertions(+), 81 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 43907b07a..440ac6de4 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -334,10 +334,11 @@ def do_modeling(self): OnOffModel( model=self._model, label_of_element=self.label_of_element, - on_off_parameters=self.element.on_off_parameters, - defining_variables=[self.flow_rate], - defining_bounds=[self.flow_rate_bounds_on], - previous_values=[self.element.previous_flow_rate], + parameters=self.element.on_off_parameters, + flow_rates=[self.flow_rate], + flow_rate_bounds=[self.flow_rate_bounds_on], + previous_flow_rates=[self.element.previous_flow_rate], + label_of_model=self.label_of_element, ), 'on_off', ) diff --git a/flixopt/features.py b/flixopt/features.py index 49635ad3d..52c1302c2 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -131,58 +131,95 @@ def __init__( self, model: FlowSystemModel, label_of_element: str, - on_off_parameters: OnOffParameters, - defining_variables: List[linopy.Variable], - defining_bounds: List[Tuple[TemporalData, TemporalData]], - previous_values: List[Optional[TemporalData]], - label: Optional[str] = None, + parameters: OnOffParameters, + flow_rates: List[linopy.Variable], + flow_rate_bounds: List[Tuple[TemporalData, TemporalData]], + previous_flow_rates: List[Optional[TemporalData]], + label_of_model: Optional[str] = None, ): - super().__init__(model, label_of_element, on_off_parameters, label) - - self._defining_variables = defining_variables - self._defining_bounds = defining_bounds - self._previous_values = previous_values + super().__init__(model, label_of_element, parameters=parameters, label_of_model=label_of_model) + self._flow_rates = flow_rates + self._flow_rate_bounds = flow_rate_bounds + self._previous_flow_rates = previous_flow_rates def create_variables_and_constraints(self): - # Use factory patterns - variables, constraints = ModelingPatterns.operational_binary_control_pattern( - model=self._model, - name=self.label_full, - controlled_variables=self._defining_variables, - variable_bounds=self._defining_bounds, - use_complement=self.parameters.use_off, - track_total_duration=True, - track_switches=self.parameters.use_switch_on, - previous_state=self._get_previous_state(), - duration_bounds=(self.parameters.on_hours_total_min, self.parameters.on_hours_total_max), - track_consecutive_on=self.parameters.use_consecutive_on_hours, - consecutive_on_bounds=(self.parameters.consecutive_on_hours_min, self.parameters.consecutive_on_hours_max), - previous_on_duration=self._get_previous_on_duration(), - track_consecutive_off=self.parameters.use_consecutive_off_hours, - consecutive_off_bounds=( - self.parameters.consecutive_off_hours_min, - self.parameters.consecutive_off_hours_max, - ), - previous_off_duration=self._get_previous_off_duration(), + variables = {} + constraints = {} + + # 1. Main binary state using existing pattern + state_vars, state_constraints = ModelingPrimitives.binary_state_pair(self._model, self.label_of_model, use_complement=self.parameters.use_off) + variables.update(state_vars) + constraints.update(state_constraints) + + # 2. Control variables - use big_m_binary_bounds pattern for consistency + for i, (flow_rate, (lower_bound, upper_bound)) in enumerate(zip(self._flow_rates, self._flow_rate_bounds)): + suffix = f'_{i}' if len(self._flow_rates) > 1 else '' + # Use the big_m pattern but without binary control (None) + _, control_constraints = ModelingPrimitives.big_m_binary_bounds( + model=self._model, + variable=flow_rate, + binary_control=None, + size_variable=variables['on'], + relative_bounds=(lower_bound, upper_bound), + upper_bound_name=f'{variables['on'].name}|ub{suffix}', + lower_bound_name=f'{variables['on'].name}|lb{suffix}', + ) + constraints[f'ub_{i}'] = control_constraints['upper_bound'] + constraints[f'lb_{i}'] = control_constraints['lower_bound'] + + # 3. Total duration tracking using existing pattern + duration_expr = (variables['on'] * self._model.hours_per_step).sum('time') + duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( + self._model, f'{self.label_of_model}|on_hours_total', duration_expr, + (self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, + self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf),#TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) ) + variables['on_hours_total'] = duration_vars['tracker'] + constraints['on_hours_total'] = duration_constraints['tracking'] + + # 4. Switch tracking using existing pattern + if self.parameters.use_switch_on: + switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( + self._model, f'{self.label_of_model}|switches', variables['on'], + previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rates) + ) + variables.update(switch_vars) + for switch_name, switch_constraint in switch_constraints.items(): + constraints[f'switch_{switch_name}'] = switch_constraint + + # 5. Consecutive on duration using existing pattern + if self.parameters.use_consecutive_on_hours: + consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( + self._model, + f'{self.label_of_model}|consecutive_on', + variables['on'], + minimum_duration=self.parameters.consecutive_on_hours_min, + maximum_duration=self.parameters.consecutive_on_hours_max, + previous_duration=ModelingUtilities.compute_previous_on_duration(self._previous_flow_rates, self._model.hours_per_step), + ) + variables['consecutive_on_duration'] = consecutive_on_vars['duration'] + for cons_name, cons_constraint in consecutive_on_constraints.items(): + constraints[f'consecutive_on_{cons_name}'] = cons_constraint + + # 6. Consecutive off duration using existing pattern + if self.parameters.use_consecutive_off_hours: + consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( + self._model, + f'{self.label_of_model}|consecutive_off', + variables['off'], + minimum_duration=self.parameters.consecutive_off_hours_min, + maximum_duration=self.parameters.consecutive_off_hours_max, + previous_duration=ModelingUtilities.compute_previous_off_duration(self._previous_flow_rates, self._model.hours_per_step), + ) + variables['consecutive_off_duration'] = consecutive_off_vars['duration'] + for cons_name, cons_constraint in consecutive_off_constraints.items(): + constraints[f'consecutive_off_{cons_name}'] = cons_constraint - # Register all variables (stored in Model's variable tracking) - self.add(variables['on'], 'on') - if 'off' in variables: - self.add(variables['off'], 'off') - if 'total_duration' in variables: - self.add(variables['total_duration'], 'total_duration') - if 'switch_on' in variables: - self.add(variables['switch_on'], 'switch_on') - self.add(variables['switch_off'], 'switch_off') - if 'consecutive_on_duration' in variables: - self.add(variables['consecutive_on_duration'], 'consecutive_on_hours') - if 'consecutive_off_duration' in variables: - self.add(variables['consecutive_off_duration'], 'consecutive_off_hours') - - # Register all constraints + # Register all constraints and variables for constraint_name, constraint in constraints.items(): self.add(constraint, constraint_name) + for variable_name, variable in variables.items(): + self.add(variable, variable_name) # Properties access variables from Model's tracking system @property @@ -249,14 +286,14 @@ def add_effects(self): def _get_previous_on_duration(self): hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] - return ModelingUtilities.compute_previous_on_duration(self._previous_values, hours_per_step) + return ModelingUtilities.compute_previous_on_duration(self._previous_flow_rates, hours_per_step) def _get_previous_off_duration(self): hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] - return ModelingUtilities.compute_previous_off_duration(self._previous_values, hours_per_step) + return ModelingUtilities.compute_previous_off_duration(self._previous_flow_rates, hours_per_step) def _get_previous_state(self): - return ModelingUtilities.get_most_recent_state(self._previous_values) + return ModelingUtilities.get_most_recent_state(self._previous_flow_rates) class PieceModel(Model): diff --git a/flixopt/modeling.py b/flixopt/modeling.py index f7aca8755..64f0164d6 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -150,7 +150,7 @@ class ModelingPrimitives: @staticmethod def binary_state_pair( - model: FlowSystemModel, name: str, coords: List[str] = None + model: FlowSystemModel, name: str, coords: List[str] = None, use_complement: bool = True ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: """ Creates complementary binary variables with completeness constraint. @@ -166,15 +166,16 @@ def binary_state_pair( coords = coords or ['time'] on = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(coords)) - off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(coords)) - - # Constraint: on + off = 1 - complementary = model.add_constraints(on + off == 1, name=f'{name}|complementary') + if use_complement: + off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(coords)) - variables = {'on': on, 'off': off} - constraints = {'complementary': complementary} + # Constraint: on + off = 1 + complementary = model.add_constraints(on + off == 1, name=f'{name}|complementary') - return variables, constraints + variables = {'on': on, 'off': off} + constraints = {'complementary': complementary} + return variables, constraints + return {'on': on}, {} @staticmethod def proportionally_bounded_variable( @@ -573,20 +574,12 @@ def operational_binary_control_pattern( previous_off_duration: TemporalData = 0, ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: """ - Enhanced operational binary control with consecutive duration tracking. - - New Args: - track_consecutive_on: Whether to track consecutive on duration - consecutive_on_bounds: (min_duration, max_duration) for consecutive on - previous_on_duration: Previous consecutive on duration - track_consecutive_off: Whether to track consecutive off duration - consecutive_off_bounds: (min_duration, max_duration) for consecutive off - previous_off_duration: Previous consecutive off duration + Enhanced operational binary control using composable patterns. """ variables = {} constraints = {} - # Main binary state (existing logic) + # 1. Main binary state using existing pattern if use_complement: state_vars, state_constraints = ModelingPrimitives.binary_state_pair(model, name) variables.update(state_vars) @@ -594,25 +587,31 @@ def operational_binary_control_pattern( else: variables['on'] = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) - # Control variables (existing logic) + # 2. Control variables - use big_m_binary_bounds pattern for consistency for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): - constraints[f'control_{i}_lower'] = model.add_constraints( - variables['on'] * np.maximum(lower_bound, CONFIG.modeling.EPSILON) <= var, name=f'{name}|control_{i}_lower' - ) - constraints[f'control_{i}_upper'] = model.add_constraints( - var <= variables['on'] * upper_bound, name=f'{name}|control_{i}_upper' + # Use the big_m pattern but without binary control (None) + _, control_constraints = ModelingPrimitives.big_m_binary_bounds( + model=model, + variable=var, + binary_control=variables['on'], # The on state controls the variables + size_variable=1, # No size scaling, just on/off + relative_bounds=(lower_bound, upper_bound), + upper_bound_name=f'{name}|control_{i}_upper', + lower_bound_name=f'{name}|control_{i}_lower', ) + constraints[f'control_{i}_upper'] = control_constraints['upper_bound'] + constraints[f'control_{i}_lower'] = control_constraints['lower_bound'] - # Total duration tracking (existing logic) + # 3. Total duration tracking using existing pattern if track_total_duration: duration_expr = (variables['on'] * model.hours_per_step).sum('time') duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( - model, f'{name}|duration', duration_expr, duration_bounds + model, f'{name}|on_hours_total', duration_expr, duration_bounds ) variables['total_duration'] = duration_vars['tracker'] constraints['duration_tracking'] = duration_constraints['tracking'] - # Switch tracking (existing logic) + # 4. Switch tracking using existing pattern if track_switches: switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( model, f'{name}|switches', variables['on'], previous_state @@ -621,7 +620,7 @@ def operational_binary_control_pattern( for switch_name, switch_constraint in switch_constraints.items(): constraints[f'switch_{switch_name}'] = switch_constraint - # NEW: Consecutive on duration tracking + # 5. Consecutive on duration using existing pattern if track_consecutive_on: min_on, max_on = consecutive_on_bounds consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( @@ -636,7 +635,7 @@ def operational_binary_control_pattern( for cons_name, cons_constraint in consecutive_on_constraints.items(): constraints[f'consecutive_on_{cons_name}'] = cons_constraint - # NEW: Consecutive off duration tracking + # 6. Consecutive off duration using existing pattern if track_consecutive_off and 'off' in variables: min_off, max_off = consecutive_off_bounds consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( From d1408a48e323bf06f6412508748bfc55143f873f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 13:56:58 +0200 Subject: [PATCH 15/51] Tyring to improve the Methods for bounding variables in different scenarios --- flixopt/modeling.py | 395 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 386 insertions(+), 9 deletions(-) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 64f0164d6..019652a0b 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -293,15 +293,70 @@ def state_transition_variables( return variables, constraints + @staticmethod + def proportional_bounds_with_binary_control( + model: FlowSystemModel, + bounded_variable, + binary_gate: linopy.Variable, + gate_bounds: Tuple[TemporalData, TemporalData], + upper_bound_name: str, + lower_bound_name: str, + scaling_variable=None, + relative_gate_bounds: Tuple[float, float] = None, + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates proportional bounds with optional scaling and binary control. + + Args: + bounded_variable: Variable to apply bounds to + relative_bounds: (min_factor, max_factor) - either absolute bounds or factors for scaling + upper_bound_name: Name for the upper bound constraint + lower_bound_name: Name for the lower bound constraint + scaling_variable: Optional variable to scale bounds by (e.g., investment_size) + binary_gate: Optional binary variable that can disable lower bound when 0 + scaling_bounds: Optional (min_value, max_value) of scaling_variable for tighter big-M + + Returns: + variables: {} (no new variables created) + constraints: {'upper_bound': constraint, 'lower_bound': constraint} + """ + + # Determine base expressions for bounds + if scaling_variable is not None: + upper_expr = scaling_variable * relative_gate_bounds[1] + lower_expr = scaling_variable * relative_gate_bounds[0] + else: + upper_expr = gate_bounds[1] + lower_expr = gate_bounds[0] + + # Upper bound constraint + upper_bound = model.add_constraints(bounded_variable <= upper_expr, name=upper_bound_name) + + # Lower bound constraint + if binary_gate is None: + lower_bound = model.add_constraints(bounded_variable >= lower_expr, name=lower_bound_name) + else: + # Calculate tight big-M using scaling bounds if provided + big_m = np.minimum(absolute_bounds[1], CONFIG.modeling.BIG) if absolute_bounds is not None else CONFIG.modeling.BIG + + lower_bound = model.add_constraints( + bounded_variable >= big_m * (binary_gate - 1) + lower_expr, name=lower_bound_name + ) + + variables = {} + constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} + return variables, constraints + @staticmethod def big_m_binary_bounds( model: FlowSystemModel, - variable, - binary_control, - size_variable, + bounded_variable: linopy.Variable, + scaling_variable: linopy.Variable, + binary_gate: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], upper_bound_name: str, lower_bound_name: str, + big_m: float = CONFIG.modeling.BIG, ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: """ Creates bounds controlled by both binary and continuous variables. @@ -322,23 +377,345 @@ def big_m_binary_bounds( rel_lower, rel_upper = relative_bounds # Upper bound: variable ≤ size * upper_factor - upper_bound = model.add_constraints(variable <= size_variable * rel_upper, name=upper_bound_name) + upper_bound = model.add_constraints(bounded_variable <= scaling_variable * rel_upper, name=upper_bound_name) - if binary_control is None: - lower_bound = model.add_constraints(variable >= size_variable * rel_lower, name=lower_bound_name) + if binary_gate is None: + lower_bound = model.add_constraints(bounded_variable >= scaling_variable * rel_lower, name=lower_bound_name) else: # Big-M lower bound: variable ≥ M*(binary-1) + size*lower_factor - big_m = CONFIG.modeling.BIG #size_variable.max() * rel_upper.max() # Conservative big-M lower_bound = model.add_constraints( - variable >= big_m * (binary_control - 1) + size_variable * rel_lower, name=lower_bound_name + bounded_variable >= big_m * (binary_gate - 1) + scaling_variable * rel_lower, name=lower_bound_name ) - variables = {} # No new variables created constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} return variables, constraints + @staticmethod + def binary_controlled_bounds( + model: FlowSystemModel, + variable: linopy.Variable, + bounds: Tuple[TemporalData, TemporalData], + binary_control: linopy.Variable, + upper_bound_name: str, + lower_bound_name: str, + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates bounds controlled by a binary variable with epsilon handling. + + Mathematical formulation: + binary * max(ε, lower_bound) ≤ variable ≤ binary * upper_bound + + When binary = 1: normal bounds apply + When binary = 0: variable is forced to 0 + + Example use case - Investment bounds: + β_inv * max(ε, V^L) ≤ V ≤ β_inv * V^U + where β_inv is investment decision, V is investment size + + Args: + variable: Variable to be bounded + bounds: (lower_bound, upper_bound) absolute bounds + binary_control: Binary variable controlling the bounds + upper_bound_name: Name for upper bound constraint + lower_bound_name: Name for lower bound constraint + + Returns: + variables: {} (no new variables created) + constraints: {'upper_bound': constraint, 'lower_bound': constraint} + """ + lower_bound, upper_bound = bounds + + # Apply epsilon to lower bound to distinguish 0 from "very small positive" + epsilon_lower = np.maximum(CONFIG.modeling.EPSILON, lower_bound) + + upper_constraint = model.add_constraints(variable <= binary_control * upper_bound, name=upper_bound_name) + lower_constraint = model.add_constraints(variable >= binary_control * epsilon_lower, name=lower_bound_name) + + variables = {} + constraints = {'upper_bound': upper_constraint, 'lower_bound': lower_constraint} + return variables, constraints + + @staticmethod + def binary_scaled_bounds( + model: FlowSystemModel, + variable: linopy.Variable, + scaling_variable: linopy.Variable, + relative_bounds: Tuple[TemporalData, TemporalData], + binary_control: linopy.Variable, + upper_bound_name: str, + lower_bound_name: str, + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates scaled bounds controlled by a binary variable. + + Mathematical formulation: + binary * max(ε, scaling * lower_factor) ≤ variable ≤ binary * scaling * upper_factor + + When binary = 1: variable bounded by scaled factors + When binary = 0: variable forced to 0 + + Example use case - Fixed size with on/off control: + β_on(t) * max(ε, P * p_rel^L(t)) ≤ p(t) ≤ β_on(t) * P * p_rel^U(t) + where β_on is on/off state, P is fixed size, p is flow rate + + Args: + variable: Variable to be bounded + scaling_variable: Variable to scale the bounds by + relative_bounds: (lower_factor, upper_factor) relative to scaling variable + binary_control: Binary variable controlling the bounds + upper_bound_name: Name for upper bound constraint + lower_bound_name: Name for lower bound constraint + + Returns: + variables: {} (no new variables created) + constraints: {'upper_bound': constraint, 'lower_bound': constraint} + """ + rel_lower, rel_upper = relative_bounds + + # Calculate scaled expressions + upper_expr = scaling_variable * rel_upper + lower_expr = scaling_variable * rel_lower + + # Apply epsilon to lower expression + epsilon_lower = np.maximum(CONFIG.modeling.EPSILON, lower_expr) + + upper_constraint = model.add_constraints(variable <= binary_control * upper_expr, name=upper_bound_name) + lower_constraint = model.add_constraints(variable >= binary_control * epsilon_lower, name=lower_bound_name) + + variables = {} + constraints = {'upper_bound': upper_constraint, 'lower_bound': lower_constraint} + return variables, constraints + + @staticmethod + def big_m_dual_control_bounds( + model: FlowSystemModel, + variable: linopy.Variable, + scaling_variable: linopy.Variable, + relative_bounds: Tuple[TemporalData, TemporalData], + binary_control: linopy.Variable, + scaling_bounds: Tuple[TemporalData, TemporalData], + constraint_name_prefix: str, + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates bounds with both binary and continuous variable control using big-M formulation. + + Mathematical formulation: + # Binary control with big-M bounds: + binary * max(ε, scaling_min * lower_factor) ≤ variable ≤ binary * M + # Continuous scaling bounds: + M * (binary - 1) + scaling * lower_factor ≤ variable ≤ scaling * upper_factor + + Where M = scaling_max * upper_factor + + This maintains linearity when both binary and continuous controls are present. + + Example use case - Variable investment size with on/off control: + β_on(t) * max(ε, P^L * p_rel^L(t)) ≤ p(t) ≤ β_on(t) * M(t) + M(t) * (β_on(t) - 1) + P * p_rel^L(t) ≤ p(t) ≤ P * p_rel^U(t) + where β_on is on/off state, P is variable investment size, p is flow rate + + Args: + variable: Variable to be bounded + scaling_variable: Continuous variable that scales the bounds + relative_bounds: (lower_factor, upper_factor) relative to scaling variable + binary_control: Binary variable for on/off control + scaling_bounds: (scaling_min, scaling_max) bounds of the scaling variable + constraint_name_prefix: Prefix for constraint names + + Returns: + variables: {} (no new variables created) + constraints: { + 'binary_lower': binary-controlled lower bound, + 'binary_upper': binary-controlled upper bound, + 'scaling_lower': scaling-controlled lower bound, + 'scaling_upper': scaling-controlled upper bound + } + """ + rel_lower, rel_upper = relative_bounds + scaling_min, scaling_max = scaling_bounds + + # Calculate big-M as maximum possible value + big_m = rel_upper * scaling_max + + # Binary-controlled lower bound with epsilon + epsilon_lower = np.maximum(CONFIG.modeling.EPSILON, rel_lower * scaling_min) + binary_lower = model.add_constraints( + binary_control * epsilon_lower <= variable, name=f'{constraint_name_prefix}|binary_lower' + ) + + # Binary-controlled upper bound with big-M + binary_upper = model.add_constraints( + variable <= binary_control * big_m, name=f'{constraint_name_prefix}|binary_upper' + ) + + # Scaling-controlled lower bound with big-M relaxation + scaling_lower = model.add_constraints( + big_m * (binary_control - 1) + scaling_variable * rel_lower <= variable, + name=f'{constraint_name_prefix}|scaling_lower', + ) + + # Scaling-controlled upper bound + scaling_upper = model.add_constraints( + variable <= scaling_variable * rel_upper, name=f'{constraint_name_prefix}|scaling_upper' + ) + + variables = {} + constraints = { + 'binary_lower': binary_lower, + 'binary_upper': binary_upper, + 'scaling_lower': scaling_lower, + 'scaling_upper': scaling_upper, + } + return variables, constraints + + @staticmethod + def scaled_bounds( + model: FlowSystemModel, + variable: linopy.Variable, + scaling_variable: linopy.Variable, + relative_bounds: Tuple[TemporalData, TemporalData], + upper_bound_name: str, + lower_bound_name: str, + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Creates simple bounds scaled by another variable. + + Mathematical formulation: + scaling * lower_factor ≤ variable ≤ scaling * upper_factor + + Example use case - Flow rate bounded by size: + P * p_rel^L(t) ≤ p(t) ≤ P * p_rel^U(t) + where P is size, p is flow rate + + Args: + variable: Variable to be bounded + scaling_variable: Variable to scale the bounds by + relative_bounds: (lower_factor, upper_factor) relative to scaling variable + upper_bound_name: Name for upper bound constraint + lower_bound_name: Name for lower bound constraint + + Returns: + variables: {} (no new variables created) + constraints: {'upper_bound': constraint, 'lower_bound': constraint} + """ + rel_lower, rel_upper = relative_bounds + + upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=upper_bound_name) + lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=lower_bound_name) + + variables = {} + constraints = {'upper_bound': upper_constraint, 'lower_bound': lower_constraint} + return variables, constraints + + @staticmethod + def auto_bounds( + model: FlowSystemModel, + variable: linopy.Variable, + bounds: Tuple[TemporalData, TemporalData], + upper_bound_name: str, + lower_bound_name: str, + scaling_variable: linopy.Variable = None, + binary_control: linopy.Variable = None, + scaling_bounds: Tuple[TemporalData, TemporalData] = None, + constraint_name_prefix: str = None, + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """ + Automatically selects the appropriate bounds method based on provided parameters. + + Parameter combinations and resulting method calls: + + 1. Only bounds → Simple absolute bounds: + lower_bound ≤ variable ≤ upper_bound + + 2. bounds + scaling_variable → scaled_bounds(): + scaling * lower_factor ≤ variable ≤ scaling * upper_factor + + 3. bounds + binary_control → binary_controlled_bounds(): + binary * max(ε, lower_bound) ≤ variable ≤ binary * upper_bound + + 4. bounds + scaling_variable + binary_control → binary_scaled_bounds(): + binary * max(ε, scaling * lower_factor) ≤ variable ≤ binary * scaling * upper_factor + + 5. All parameters → big_m_dual_control_bounds(): + Complex big-M formulation for binary + variable scaling control + + Args: + variable: Variable to be bounded + bounds: (lower, upper) - absolute bounds or relative factors if scaling + upper_bound_name: Name for upper bound constraint + lower_bound_name: Name for lower bound constraint + scaling_variable: Optional variable to scale bounds by + binary_control: Optional binary variable for on/off control + scaling_bounds: Required if using big-M (case 5), bounds of scaling variable + constraint_name_prefix: Required if using big-M (case 5) + + Returns: + Same as the underlying primitive method + + Raises: + ValueError: If big-M case is detected but required parameters are missing + """ + + # Case 5: Big-M dual control (most complex) + if scaling_variable is not None and binary_control is not None and scaling_bounds is not None: + if constraint_name_prefix is None: + raise ValueError('constraint_name_prefix is required when using big-M dual control') + + return ModelingPrimitives.big_m_dual_control_bounds( + model=model, + variable=variable, + scaling_variable=scaling_variable, + relative_bounds=bounds, + binary_control=binary_control, + scaling_bounds=scaling_bounds, + constraint_name_prefix=constraint_name_prefix, + ) + + # Case 4: Binary + scaling (fixed size with on/off) + elif scaling_variable is not None and binary_control is not None: + return ModelingPrimitives.binary_scaled_bounds( + model=model, + variable=variable, + scaling_variable=scaling_variable, + relative_bounds=bounds, + binary_control=binary_control, + upper_bound_name=upper_bound_name, + lower_bound_name=lower_bound_name, + ) + + # Case 3: Binary only (investment decision) + elif binary_control is not None: + return ModelingPrimitives.binary_controlled_bounds( + model=model, + variable=variable, + bounds=bounds, + binary_control=binary_control, + upper_bound_name=upper_bound_name, + lower_bound_name=lower_bound_name, + ) + + # Case 2: Scaling only (size-dependent bounds) + elif scaling_variable is not None: + return ModelingPrimitives.scaled_bounds( + model=model, + variable=variable, + scaling_variable=scaling_variable, + relative_bounds=bounds, + upper_bound_name=upper_bound_name, + lower_bound_name=lower_bound_name, + ) + + # Case 1: Simple absolute bounds + else: + upper_constraint = model.add_constraints(variable <= bounds[1], name=upper_bound_name) + lower_constraint = model.add_constraints(variable >= bounds[0], name=lower_bound_name) + + variables = {} + constraints = {'upper_bound': upper_constraint, 'lower_bound': lower_constraint} + return variables, constraints + @staticmethod def consecutive_duration_tracking( model: FlowSystemModel, From ab000ca804d31f1aa9ef938db56a4fdd6536aeb5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 17:33:21 +0200 Subject: [PATCH 16/51] Improve BoundingPatterns --- flixopt/modeling.py | 908 +++++++++++++++++++++++--------------------- 1 file changed, 484 insertions(+), 424 deletions(-) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 019652a0b..a443c3a74 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -193,7 +193,7 @@ def proportionally_bounded_variable( Returns: variables: {'variable': bounded_var} - constraints: {'lower_bound': constraint, 'upper_bound': constraint} + constraints: {'lb': constraint, 'ub': constraint} """ coords = coords or ['time'] variable = model.add_variables(name=f'{name}|bounded', coords=model.get_coords(coords)) @@ -209,7 +209,7 @@ def proportionally_bounded_variable( ) variables = {'variable': variable} - constraints = {'lower_bound': lower_bound, 'upper_bound': upper_bound} + constraints = {'lb': lower_bound, 'ub': upper_bound} return variables, constraints @@ -294,561 +294,639 @@ def state_transition_variables( return variables, constraints @staticmethod - def proportional_bounds_with_binary_control( + def consecutive_duration_tracking( model: FlowSystemModel, - bounded_variable, - binary_gate: linopy.Variable, - gate_bounds: Tuple[TemporalData, TemporalData], - upper_bound_name: str, - lower_bound_name: str, - scaling_variable=None, - relative_gate_bounds: Tuple[float, float] = None, - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + name: str, + state_variable: linopy.Variable, + minimum_duration: Optional[TemporalData] = None, + maximum_duration: Optional[TemporalData] = None, + previous_duration: TemporalData = 0, + ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: """ - Creates proportional bounds with optional scaling and binary control. + Creates consecutive duration tracking for a binary state variable. + + Mathematical formulation: + duration[t] ≤ state[t] * M ∀t + duration[t+1] ≤ duration[t] + hours_per_step[t] ∀t + duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M ∀t + duration[0] = (hours_per_step[0] + previous_duration) * state[0] + + If minimum_duration provided: + duration[t] ≥ (state[t-1] - state[t]) * minimum_duration[t-1] ∀t > 0 Args: - bounded_variable: Variable to apply bounds to - relative_bounds: (min_factor, max_factor) - either absolute bounds or factors for scaling - upper_bound_name: Name for the upper bound constraint - lower_bound_name: Name for the lower bound constraint - scaling_variable: Optional variable to scale bounds by (e.g., investment_size) - binary_gate: Optional binary variable that can disable lower bound when 0 - scaling_bounds: Optional (min_value, max_value) of scaling_variable for tighter big-M + state_variable: Binary state variable to track duration for + minimum_duration: Optional minimum consecutive duration + maximum_duration: Optional maximum consecutive duration + previous_duration: Duration from before first timestep Returns: - variables: {} (no new variables created) - constraints: {'upper_bound': constraint, 'lower_bound': constraint} + variables: {'duration': duration_var} + constraints: {'ub': constraint, 'forward': constraint, 'backward': constraint, ...} """ + hours_per_step = model.hours_per_step + mega = hours_per_step.sum('time') + previous_duration # Big-M value - # Determine base expressions for bounds - if scaling_variable is not None: - upper_expr = scaling_variable * relative_gate_bounds[1] - lower_expr = scaling_variable * relative_gate_bounds[0] - else: - upper_expr = gate_bounds[1] - lower_expr = gate_bounds[0] + # Duration variable + duration = model.add_variables( + lower=0, + upper=maximum_duration if maximum_duration is not None else mega, + coords=model.get_coords(['time']), + name=f'{name}|duration', + ) - # Upper bound constraint - upper_bound = model.add_constraints(bounded_variable <= upper_expr, name=upper_bound_name) + constraints = {} - # Lower bound constraint - if binary_gate is None: - lower_bound = model.add_constraints(bounded_variable >= lower_expr, name=lower_bound_name) - else: - # Calculate tight big-M using scaling bounds if provided - big_m = np.minimum(absolute_bounds[1], CONFIG.modeling.BIG) if absolute_bounds is not None else CONFIG.modeling.BIG + # Upper bound: duration[t] ≤ state[t] * M + constraints['ub'] = model.add_constraints( + duration <= state_variable * mega, name=f'{name}|duration_upper_bound' + ) + + # Forward constraint: duration[t+1] ≤ duration[t] + hours_per_step[t] + constraints['forward'] = model.add_constraints( + duration.isel(time=slice(1, None)) + <= duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), + name=f'{name}|duration_forward', + ) - lower_bound = model.add_constraints( - bounded_variable >= big_m * (binary_gate - 1) + lower_expr, name=lower_bound_name + # Backward constraint: duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M + constraints['backward'] = model.add_constraints( + duration.isel(time=slice(1, None)) + >= duration.isel(time=slice(None, -1)) + + hours_per_step.isel(time=slice(None, -1)) + + (state_variable.isel(time=slice(1, None)) - 1) * mega, + name=f'{name}|duration_backward', + ) + + # Initial condition: duration[0] = (hours_per_step[0] + previous_duration) * state[0] + constraints['initial'] = model.add_constraints( + duration.isel(time=0) + == (hours_per_step.isel(time=0) + previous_duration) * state_variable.isel(time=0), + name=f'{name}|duration_initial', + ) + + # Minimum duration constraint if provided + if minimum_duration is not None: + constraints['minimum'] = model.add_constraints( + duration.isel(time=slice(1, None)) + >= (state_variable.isel(time=slice(None, -1)) - state_variable.isel(time=slice(1, None))) + * minimum_duration.isel(time=slice(None, -1)), + name=f'{name}|duration_minimum', ) - variables = {} - constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} + # Handle initial condition for minimum duration + if previous_duration > 0 and previous_duration < minimum_duration.isel(time=0).max(): + constraints['initial_minimum'] = model.add_constraints( + state_variable.isel(time=0) == 1, name=f'{name}|duration_initial_minimum' + ) + + variables = {'duration': duration} + return variables, constraints @staticmethod - def big_m_binary_bounds( - model: FlowSystemModel, - bounded_variable: linopy.Variable, - scaling_variable: linopy.Variable, - binary_gate: linopy.Variable, - relative_bounds: Tuple[TemporalData, TemporalData], - upper_bound_name: str, - lower_bound_name: str, - big_m: float = CONFIG.modeling.BIG, + def mutual_exclusivity_constraint( + model: FlowSystemModel, name: str, binary_variables: List[linopy.Variable], tolerance: float = 1.1 ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: """ - Creates bounds controlled by both binary and continuous variables. + Creates mutual exclusivity constraint for binary variables. Mathematical formulation: - variable[t] ≤ size[t] * upper_factor[t] ∀t + Σ(binary_vars[i]) ≤ tolerance ∀t - If binary_control provided: - variable[t] ≥ M * (binary[t] - 1) + size[t] * lower_factor[t] ∀t - where M = max(size) * max(upper_factor) - Else: - variable[t] ≥ size[t] * lower_factor[t] ∀t + Ensures at most one binary variable can be 1 at any time. + Tolerance > 1.0 accounts for binary variable numerical precision. + + Args: + binary_variables: List of binary variables that should be mutually exclusive + tolerance: Upper bound (typically 1.1 for numerical stability) Returns: variables: {} (no new variables created) - constraints: {'upper_bound': constraint, 'lower_bound': constraint} - """ - rel_lower, rel_upper = relative_bounds + constraints: {'mutual_exclusivity': constraint} - # Upper bound: variable ≤ size * upper_factor - upper_bound = model.add_constraints(bounded_variable <= scaling_variable * rel_upper, name=upper_bound_name) + Raises: + AssertionError: If fewer than 2 variables provided or variables aren't binary + """ + assert len(binary_variables) >= 2, ( + f'Mutual exclusivity requires at least 2 variables, got {len(binary_variables)}' + ) - if binary_gate is None: - lower_bound = model.add_constraints(bounded_variable >= scaling_variable * rel_lower, name=lower_bound_name) - else: - # Big-M lower bound: variable ≥ M*(binary-1) + size*lower_factor - lower_bound = model.add_constraints( - bounded_variable >= big_m * (binary_gate - 1) + scaling_variable * rel_lower, name=lower_bound_name + for var in binary_variables: + assert var.attrs.get('binary', False), ( + f'Variable {var.name} must be binary for mutual exclusivity constraint' ) + # Create mutual exclusivity constraint + mutual_exclusivity = model.add_constraints( + sum(binary_variables) <= tolerance, name=f'{name}|mutual_exclusivity' + ) + variables = {} # No new variables created - constraints = {'upper_bound': upper_bound, 'lower_bound': lower_bound} + constraints = {'mutual_exclusivity': mutual_exclusivity} return variables, constraints + +class BoundingPatterns: + """High-level patterns that compose primitives and return (variables, constraints) tuples""" + + @staticmethod + def basic_bounds( + model: FlowSystemModel, + variable: linopy.Variable, + bounds: Tuple[TemporalData, TemporalData], + ): + """Create simple bounds. + + Args: + model: The optimization model instance + variable: Variable to be bounded + bounds: Tuple of (lower_bound, upper_bound) absolute bounds + + Returns: + Tuple containing: + - variables (Dict): Empty dict (no new variables created) + - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: + - 'ub': Upper bound constraint + - 'lb': Lower bound constraint + """ + lower_bound, upper_bound = bounds + + upper_constraint = model.add_constraints(variable <= upper_bound, name=f'{variable.name}|ub') + lower_constraint = model.add_constraints(variable >= lower_bound, name=f'{variable.name}|lb') + + return {}, {'ub': upper_constraint, 'lb': lower_constraint} + @staticmethod def binary_controlled_bounds( model: FlowSystemModel, variable: linopy.Variable, bounds: Tuple[TemporalData, TemporalData], binary_control: linopy.Variable, - upper_bound_name: str, - lower_bound_name: str, ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """ - Creates bounds controlled by a binary variable with epsilon handling. + """Create bounds controlled by a binary variable with epsilon handling. - Mathematical formulation: + This method implements binary-controlled bounds where a binary variable acts as an on/off + switch for the bounded variable. When the binary is 1, normal bounds apply; when 0, the + variable is forced to zero. + + Mathematical Formulation: binary * max(ε, lower_bound) ≤ variable ≤ binary * upper_bound - When binary = 1: normal bounds apply - When binary = 0: variable is forced to 0 + Where: + - binary ∈ {0, 1}: Control variable + - ε: Small positive constant (CONFIG.modeling.EPSILON) + - When binary = 1: Normal bounds apply + - When binary = 0: Variable is forced to 0 + + Use Cases: + - Investment decisions (invest or don't invest) + - Unit commitment (on/off operational states) + - Feature selection in optimization models - Example use case - Investment bounds: + Example: + Investment bounds where β_inv controls whether investment occurs: β_inv * max(ε, V^L) ≤ V ≤ β_inv * V^U - where β_inv is investment decision, V is investment size Args: + model: The optimization model instance variable: Variable to be bounded - bounds: (lower_bound, upper_bound) absolute bounds + bounds: Tuple of (lower_bound, upper_bound) absolute bounds binary_control: Binary variable controlling the bounds - upper_bound_name: Name for upper bound constraint - lower_bound_name: Name for lower bound constraint Returns: - variables: {} (no new variables created) - constraints: {'upper_bound': constraint, 'lower_bound': constraint} + Tuple containing: + - variables (Dict): Empty dict (no new variables created) + - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: + - 'ub': Upper bound constraint + - 'lb': Lower bound constraint + - 'fix': Fix constraint, if upper bound is equal to lower bound + + Note: + The epsilon value is applied to the lower bound to distinguish between + zero and "very small positive" values, which is important for numerical + stability in optimization solvers. """ lower_bound, upper_bound = bounds + if np.all(lower_bound - upper_bound) < 1e-10: + fix_constraint = model.add_constraints( + variable == binary_control * upper_bound, name=f'{variable.name}|fixed_size' + ) + return {}, {'ub': fix_constraint, 'lb': fix_constraint} + # Apply epsilon to lower bound to distinguish 0 from "very small positive" - epsilon_lower = np.maximum(CONFIG.modeling.EPSILON, lower_bound) + epsilon = np.maximum(CONFIG.modeling.EPSILON, lower_bound) - upper_constraint = model.add_constraints(variable <= binary_control * upper_bound, name=upper_bound_name) - lower_constraint = model.add_constraints(variable >= binary_control * epsilon_lower, name=lower_bound_name) + upper_constraint = model.add_constraints(variable <= binary_control * upper_bound, name=f'{variable.name}|ub') + lower_constraint = model.add_constraints(variable >= binary_control * epsilon, name=f'{variable.name}|lb') - variables = {} - constraints = {'upper_bound': upper_constraint, 'lower_bound': lower_constraint} - return variables, constraints + return {}, {'ub': upper_constraint, 'lb': lower_constraint} @staticmethod - def binary_scaled_bounds( + def scaled_bounds( model: FlowSystemModel, variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], - binary_control: linopy.Variable, - upper_bound_name: str, - lower_bound_name: str, ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """ - Creates scaled bounds controlled by a binary variable. + """Create simple bounds scaled by another variable. - Mathematical formulation: - binary * max(ε, scaling * lower_factor) ≤ variable ≤ binary * scaling * upper_factor + This method creates proportional bounds where the actual bounds are determined + by multiplying relative factors with a scaling variable. This is useful for + capacity-dependent constraints. - When binary = 1: variable bounded by scaled factors - When binary = 0: variable forced to 0 + Mathematical Formulation: + scaling * lower_factor ≤ variable ≤ scaling * upper_factor + + Where: + - scaling: Continuous scaling variable (e.g., capacity, size) + - lower_factor, upper_factor: Relative bound multipliers + + Use Cases: + - Flow rates bounded by equipment capacity + - Production levels scaled by plant size + - Resource consumption proportional to activity level - Example use case - Fixed size with on/off control: - β_on(t) * max(ε, P * p_rel^L(t)) ≤ p(t) ≤ β_on(t) * P * p_rel^U(t) - where β_on is on/off state, P is fixed size, p is flow rate + Example: + Flow rate bounded by equipment size: + P * p_rel^L(t) ≤ p(t) ≤ P * p_rel^U(t) + where P is equipment size, p is flow rate Args: + model: The optimization model instance variable: Variable to be bounded - scaling_variable: Variable to scale the bounds by - relative_bounds: (lower_factor, upper_factor) relative to scaling variable - binary_control: Binary variable controlling the bounds - upper_bound_name: Name for upper bound constraint - lower_bound_name: Name for lower bound constraint + scaling_variable: Variable that scales the bound factors + relative_bounds: Tuple of (lower_factor, upper_factor) relative to scaling variable Returns: - variables: {} (no new variables created) - constraints: {'upper_bound': constraint, 'lower_bound': constraint} + Tuple containing: + - variables (Dict): Empty dict (no new variables created) + - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: + - 'ub': Upper bound constraint + - 'lb': Lower bound constraint + + Note: + This method assumes the scaling variable is always non-negative. + For negative scaling variables, the inequality directions would need adjustment. """ rel_lower, rel_upper = relative_bounds - # Calculate scaled expressions - upper_expr = scaling_variable * rel_upper - lower_expr = scaling_variable * rel_lower - - # Apply epsilon to lower expression - epsilon_lower = np.maximum(CONFIG.modeling.EPSILON, lower_expr) - - upper_constraint = model.add_constraints(variable <= binary_control * upper_expr, name=upper_bound_name) - lower_constraint = model.add_constraints(variable >= binary_control * epsilon_lower, name=lower_bound_name) + upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{variable}|ub') + lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=f'{variable}|lb') variables = {} - constraints = {'upper_bound': upper_constraint, 'lower_bound': lower_constraint} + constraints = {'ub': upper_constraint, 'lb': lower_constraint} return variables, constraints @staticmethod - def big_m_dual_control_bounds( + def binary_scaled_bounds( model: FlowSystemModel, variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], binary_control: linopy.Variable, scaling_bounds: Tuple[TemporalData, TemporalData], - constraint_name_prefix: str, ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """ - Creates bounds with both binary and continuous variable control using big-M formulation. + """Create scaled bounds controlled by a binary variable using linear big-M formulation. - Mathematical formulation: - # Binary control with big-M bounds: - binary * max(ε, scaling_min * lower_factor) ≤ variable ≤ binary * M - # Continuous scaling bounds: - M * (binary - 1) + scaling * lower_factor ≤ variable ≤ scaling * upper_factor + Desired (Non-Linear) Formulation: + binary * max(ε, scaling * lower_factor) ≤ variable ≤ binary * scaling * upper_factor - Where M = scaling_max * upper_factor + Actual Linear Big-M Formulation: + # When binary = 1: scaling bounds apply + scaling * lower_factor ≤ variable ≤ scaling * upper_factor + + # When binary = 0: variable forced to 0 + variable ≤ binary * M_upper + variable ≥ binary * M_lower + + Where: + M_upper = scaling_max * upper_factor + M_lower = max(ε, scaling_min * lower_factor) - This maintains linearity when both binary and continuous controls are present. + Behavior: + - When binary = 1: Variable bounded by scaling * factors (normal operation) + - When binary = 0: Variable forced to 0 (off state) - Example use case - Variable investment size with on/off control: - β_on(t) * max(ε, P^L * p_rel^L(t)) ≤ p(t) ≤ β_on(t) * M(t) - M(t) * (β_on(t) - 1) + P * p_rel^L(t) ≤ p(t) ≤ P * p_rel^U(t) - where β_on is on/off state, P is variable investment size, p is flow rate + Use Cases: + - Fixed-size units with on/off control + - Capacity-scaled operations with binary states + - Process units with binary operational modes + + Example: + Power plant with capacity P ∈ [P_min, P_max] and on/off control β_on: + Linear formulation replaces: β_on(t) * P * p_rel^L(t) ≤ p(t) ≤ β_on(t) * P * p_rel^U(t) Args: + model: The optimization model instance variable: Variable to be bounded - scaling_variable: Continuous variable that scales the bounds - relative_bounds: (lower_factor, upper_factor) relative to scaling variable + scaling_variable: Variable that scales the bound factors + relative_bounds: Tuple of (lower_factor, upper_factor) relative to scaling variable binary_control: Binary variable for on/off control - scaling_bounds: (scaling_min, scaling_max) bounds of the scaling variable - constraint_name_prefix: Prefix for constraint names + scaling_bounds: Tuple of (scaling_min, scaling_max) bounds of the scaling variable Returns: - variables: {} (no new variables created) - constraints: { - 'binary_lower': binary-controlled lower bound, - 'binary_upper': binary-controlled upper bound, - 'scaling_lower': scaling-controlled lower bound, - 'scaling_upper': scaling-controlled upper bound - } + Tuple containing: + - variables (Dict): Empty dict (no new variables created) + - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: + - 'ub': Upper bound constraint + - 'lb': Lower bound constraint + + Note: + This method now requires scaling_bounds to compute appropriate big-M values. + The big-M formulation maintains linearity while preserving the intended behavior. """ rel_lower, rel_upper = relative_bounds scaling_min, scaling_max = scaling_bounds - # Calculate big-M as maximum possible value - big_m = rel_upper * scaling_max + # Calculate big-M values for upper and lower bounds + big_m_upper = scaling_max * rel_upper + big_m_lower = np.maximum(CONFIG.modeling.EPSILON, scaling_min * rel_lower) - # Binary-controlled lower bound with epsilon - epsilon_lower = np.maximum(CONFIG.modeling.EPSILON, rel_lower * scaling_min) - binary_lower = model.add_constraints( - binary_control * epsilon_lower <= variable, name=f'{constraint_name_prefix}|binary_lower' - ) + # Linear constraints using big-M technique: + # When binary = 1: normal scaling bounds apply + # When binary = 0: variable forced to 0 - # Binary-controlled upper bound with big-M + # Upper bound: variable ≤ min(scaling * rel_upper, binary * big_m_upper) + # Implemented as two constraints: + scaling_upper = model.add_constraints( + variable <= scaling_variable * rel_upper, name=f'{scaling_variable.name}|ub' + ) binary_upper = model.add_constraints( - variable <= binary_control * big_m, name=f'{constraint_name_prefix}|binary_upper' + variable <= binary_control * big_m_upper, name=f'{variable.name}|ub' ) - # Scaling-controlled lower bound with big-M relaxation + # Lower bound: variable ≥ max(scaling * rel_lower, binary * big_m_lower) + # When binary = 0: second constraint gives variable ≥ 0 + # When binary = 1: first constraint is active scaling_lower = model.add_constraints( - big_m * (binary_control - 1) + scaling_variable * rel_lower <= variable, - name=f'{constraint_name_prefix}|scaling_lower', + variable >= scaling_variable * rel_lower, name=f'{scaling_variable.name}|ub' ) - - # Scaling-controlled upper bound - scaling_upper = model.add_constraints( - variable <= scaling_variable * rel_upper, name=f'{constraint_name_prefix}|scaling_upper' + binary_lower = model.add_constraints( + variable >= binary_control * big_m_lower, name=f'{variable.name}|lb' ) variables = {} constraints = { - 'binary_lower': binary_lower, - 'binary_upper': binary_upper, - 'scaling_lower': scaling_lower, - 'scaling_upper': scaling_upper, + 'ub': scaling_upper, # Primary upper bound constraint + 'lb': scaling_lower, # Primary lower bound constraint + 'binary_upper': binary_upper, # Binary control upper bound + 'binary_lower': binary_lower, # Binary control lower bound } return variables, constraints @staticmethod - def scaled_bounds( + def dual_binary_scaled_bounds( model: FlowSystemModel, variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], - upper_bound_name: str, - lower_bound_name: str, + scaling_binary: linopy.Variable, + secondary_binary: linopy.Variable, + scaling_bounds: Tuple[TemporalData, TemporalData], ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """ - Creates simple bounds scaled by another variable. + """Create bounds with dual binary control over a scaled variable. - Mathematical formulation: - scaling * lower_factor ≤ variable ≤ scaling * upper_factor + This method implements the most complex bounding case where you have two binary variables + controlling a scaled relationship between variables. This is commonly used for investment + and operational control scenarios. - Example use case - Flow rate bounded by size: - P * p_rel^L(t) ≤ p(t) ≤ P * p_rel^U(t) - where P is size, p is flow rate + Hierarchical Control: + 1. scaling_binary: Controls whether the scaling variable can be non-zero + 2. Secondary binary: Controls whether the main variable can be non-zero (given scaling exists) - Args: - variable: Variable to be bounded - scaling_variable: Variable to scale the bounds by - relative_bounds: (lower_factor, upper_factor) relative to scaling variable - upper_bound_name: Name for upper bound constraint - lower_bound_name: Name for lower bound constraint + Mathematical Formulation: - Returns: - variables: {} (no new variables created) - constraints: {'upper_bound': constraint, 'lower_bound': constraint} - """ - rel_lower, rel_upper = relative_bounds + Scaling variable bounds: + scaling_binary * max(ε, scaling_min) ≤ scaling_variable ≤ scaling_binary * scaling_max - upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=upper_bound_name) - lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=lower_bound_name) + Main variable bounds with dual control: + secondary_binary * max(ε, rel_lower * scaling_min) ≤ variable ≤ secondary_binary * M + M * (secondary_binary - 1) + scaling_variable * rel_lower ≤ variable ≤ scaling_variable * rel_upper - variables = {} - constraints = {'upper_bound': upper_constraint, 'lower_bound': lower_constraint} - return variables, constraints + Where: M = rel_upper * scaling_max - @staticmethod - def auto_bounds( - model: FlowSystemModel, - variable: linopy.Variable, - bounds: Tuple[TemporalData, TemporalData], - upper_bound_name: str, - lower_bound_name: str, - scaling_variable: linopy.Variable = None, - binary_control: linopy.Variable = None, - scaling_bounds: Tuple[TemporalData, TemporalData] = None, - constraint_name_prefix: str = None, - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """ - Automatically selects the appropriate bounds method based on provided parameters. - - Parameter combinations and resulting method calls: - - 1. Only bounds → Simple absolute bounds: - lower_bound ≤ variable ≤ upper_bound - - 2. bounds + scaling_variable → scaled_bounds(): - scaling * lower_factor ≤ variable ≤ scaling * upper_factor + Logical Behavior: + - scaling_binary = 0: No scaling capacity (scaling_variable = 0), no main variable (variable = 0) + - scaling_binary = 1, secondary_binary = 0: Scaling exists but main variable is off (variable = 0) + - scaling_binary = 1, secondary_binary = 1: Normal scaled operation - 3. bounds + binary_control → binary_controlled_bounds(): - binary * max(ε, lower_bound) ≤ variable ≤ binary * upper_bound + Use Cases: + - Investment + operational control (capacity sizing + on/off dispatch) + - Resource allocation + activation (budget + spending) + - Equipment sizing + utilization (capacity + operation) + - Feature selection + intensity (enable + level) - 4. bounds + scaling_variable + binary_control → binary_scaled_bounds(): - binary * max(ε, scaling * lower_factor) ≤ variable ≤ binary * scaling * upper_factor - - 5. All parameters → big_m_dual_control_bounds(): - Complex big-M formulation for binary + variable scaling control + Examples: + - Power plant: Build capacity? (primary) How big? (scaling) When to run? (secondary) + - Marketing: Enter market? (primary) Budget size? (scaling) Campaign active? (secondary) + - Production: Install line? (primary) Line capacity? (scaling) Line running? (secondary) Args: - variable: Variable to be bounded - bounds: (lower, upper) - absolute bounds or relative factors if scaling - upper_bound_name: Name for upper bound constraint - lower_bound_name: Name for lower bound constraint - scaling_variable: Optional variable to scale bounds by - binary_control: Optional binary variable for on/off control - scaling_bounds: Required if using big-M (case 5), bounds of scaling variable - constraint_name_prefix: Required if using big-M (case 5) + model: The optimization model instance + variable: Main variable to be bounded + scaling_variable: Variable that scales the bounds (e.g., capacity, size, budget) + relative_bounds: Tuple of (rel_lower, rel_upper) relative bound multipliers + scaling_binary: Binary controlling scaling_variable existence (e.g., investment decision) + secondary_binary: Binary controlling variable operation (e.g., operational on/off) + scaling_bounds: Tuple of (scaling_min, scaling_max) bounds for scaling_variable Returns: - Same as the underlying primitive method - - Raises: - ValueError: If big-M case is detected but required parameters are missing + Tuple containing: + - variables (Dict): Empty dict (no new variables created) + - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: + - 'primary_scaling_ub': Primary control upper bound for scaling variable + - 'primary_scaling_lb': Primary control lower bound for scaling variable + - 'secondary_variable_ub': Secondary control upper bound for main variable + - 'secondary_variable_lb': Secondary control lower bound for main variable + - 'scaling_variable_ub': Scaling-dependent upper bound for main variable + - 'scaling_variable_lb': Scaling-dependent lower bound for main variable + + Note: + This implements hierarchical binary control where the primary binary enables the scaling + variable, and the secondary binary controls the main variable's operation within the + scaled bounds. Both binaries must be active for normal operation. """ + rel_lower, rel_upper = relative_bounds + scaling_min, scaling_max = scaling_bounds - # Case 5: Big-M dual control (most complex) - if scaling_variable is not None and binary_control is not None and scaling_bounds is not None: - if constraint_name_prefix is None: - raise ValueError('constraint_name_prefix is required when using big-M dual control') - - return ModelingPrimitives.big_m_dual_control_bounds( - model=model, - variable=variable, - scaling_variable=scaling_variable, - relative_bounds=bounds, - binary_control=binary_control, - scaling_bounds=scaling_bounds, - constraint_name_prefix=constraint_name_prefix, - ) + # Calculate big-M value for secondary control constraints + # M = rel_upper * scaling_max (maximum possible variable value) + big_m = rel_upper * scaling_max - # Case 4: Binary + scaling (fixed size with on/off) - elif scaling_variable is not None and binary_control is not None: - return ModelingPrimitives.binary_scaled_bounds( - model=model, - variable=variable, - scaling_variable=scaling_variable, - relative_bounds=bounds, - binary_control=binary_control, - upper_bound_name=upper_bound_name, - lower_bound_name=lower_bound_name, - ) + # 1. PRIMARY BINARY CONSTRAINTS FOR SCALING VARIABLE + # scaling_binary * max(ε, scaling_min) ≤ scaling_variable ≤ scaling_binary * scaling_max + epsilon_scaling = np.maximum(CONFIG.modeling.EPSILON, scaling_min) - # Case 3: Binary only (investment decision) - elif binary_control is not None: - return ModelingPrimitives.binary_controlled_bounds( - model=model, - variable=variable, - bounds=bounds, - binary_control=binary_control, - upper_bound_name=upper_bound_name, - lower_bound_name=lower_bound_name, - ) + primary_scaling_ub = model.add_constraints( + scaling_variable <= scaling_binary * scaling_max, name=f'{scaling_variable.name}|primary_ub' + ) - # Case 2: Scaling only (size-dependent bounds) - elif scaling_variable is not None: - return ModelingPrimitives.scaled_bounds( - model=model, - variable=variable, - scaling_variable=scaling_variable, - relative_bounds=bounds, - upper_bound_name=upper_bound_name, - lower_bound_name=lower_bound_name, - ) + primary_scaling_lb = model.add_constraints( + scaling_variable >= scaling_binary * epsilon_scaling, name=f'{scaling_variable.name}|primary_lb' + ) - # Case 1: Simple absolute bounds - else: - upper_constraint = model.add_constraints(variable <= bounds[1], name=upper_bound_name) - lower_constraint = model.add_constraints(variable >= bounds[0], name=lower_bound_name) + # 2. SECONDARY BINARY CONSTRAINTS FOR MAIN VARIABLE + # secondary_binary * max(ε, rel_lower * scaling_min) ≤ variable ≤ secondary_binary * M + epsilon_variable = np.maximum(CONFIG.modeling.EPSILON, rel_lower * scaling_min) - variables = {} - constraints = {'upper_bound': upper_constraint, 'lower_bound': lower_constraint} - return variables, constraints + secondary_variable_ub = model.add_constraints( + variable <= secondary_binary * big_m, name=f'{variable.name}|secondary_ub' + ) - @staticmethod - def consecutive_duration_tracking( - model: FlowSystemModel, - name: str, - state_variable: linopy.Variable, - minimum_duration: Optional[TemporalData] = None, - maximum_duration: Optional[TemporalData] = None, - previous_duration: TemporalData = 0, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Creates consecutive duration tracking for a binary state variable. + secondary_variable_lb = model.add_constraints( + variable >= secondary_binary * epsilon_variable, name=f'{variable.name}|secondary_lb' + ) - Mathematical formulation: - duration[t] ≤ state[t] * M ∀t - duration[t+1] ≤ duration[t] + hours_per_step[t] ∀t - duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M ∀t - duration[0] = (hours_per_step[0] + previous_duration) * state[0] + # 3. SCALING-DEPENDENT CONSTRAINTS FOR MAIN VARIABLE + # M * (secondary_binary - 1) + scaling_variable * rel_lower ≤ variable ≤ scaling_variable * rel_upper - If minimum_duration provided: - duration[t] ≥ (state[t-1] - state[t]) * minimum_duration[t-1] ∀t > 0 + scaling_variable_ub = model.add_constraints( + variable <= scaling_variable * rel_upper, name=f'{variable.name}|scaling_ub' + ) - Args: - state_variable: Binary state variable to track duration for - minimum_duration: Optional minimum consecutive duration - maximum_duration: Optional maximum consecutive duration - previous_duration: Duration from before first timestep + scaling_variable_lb = model.add_constraints( + big_m * (secondary_binary - 1) + scaling_variable * rel_lower <= variable, + name=f'{variable.name}|scaling_lb', + ) - Returns: - variables: {'duration': duration_var} - constraints: {'upper_bound': constraint, 'forward': constraint, 'backward': constraint, ...} - """ - hours_per_step = model.hours_per_step - mega = hours_per_step.sum('time') + previous_duration # Big-M value + variables = {} + constraints = { + 'primary_scaling_ub': primary_scaling_ub, + 'primary_scaling_lb': primary_scaling_lb, + 'secondary_variable_ub': secondary_variable_ub, + 'secondary_variable_lb': secondary_variable_lb, + 'scaling_variable_ub': scaling_variable_ub, + 'scaling_variable_lb': scaling_variable_lb, + } - # Duration variable - duration = model.add_variables( - lower=0, - upper=maximum_duration if maximum_duration is not None else mega, - coords=model.get_coords(['time']), - name=f'{name}|duration', - ) + return variables, constraints - constraints = {} + @staticmethod + def auto_bounds( + model: FlowSystemModel, + variable: linopy.Variable, + variable_bounds: Tuple[TemporalData, TemporalData], + scaling_variable: linopy.Variable = None, + scaling_state: linopy.Variable = None, + scaling_bounds: Tuple[TemporalData, TemporalData] = None, + variable_state: linopy.Variable = None, + ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + """Automatically select the appropriate bounds method based on provided parameters. - # Upper bound: duration[t] ≤ state[t] * M - constraints['upper_bound'] = model.add_constraints( - duration <= state_variable * mega, name=f'{name}|duration_upper_bound' - ) + This intelligent dispatcher analyzes the provided parameters and automatically + selects the most appropriate bounding method. It simplifies the API by providing + a single entry point for all bounding scenarios. - # Forward constraint: duration[t+1] ≤ duration[t] + hours_per_step[t] - constraints['forward'] = model.add_constraints( - duration.isel(time=slice(1, None)) - <= duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), - name=f'{name}|duration_forward', - ) + Parameter Combinations and Method Selection: - # Backward constraint: duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M - constraints['backward'] = model.add_constraints( - duration.isel(time=slice(1, None)) - >= duration.isel(time=slice(None, -1)) - + hours_per_step.isel(time=slice(None, -1)) - + (state_variable.isel(time=slice(1, None)) - 1) * mega, - name=f'{name}|duration_backward', - ) + 1. **Simple Bounds**: Only `bounds` provided + → Creates: lower_bound ≤ variable ≤ upper_bound - # Initial condition: duration[0] = (hours_per_step[0] + previous_duration) * state[0] - constraints['initial'] = model.add_constraints( - duration.isel(time=0) - == (hours_per_step.isel(time=0) + previous_duration) * state_variable.isel(time=0), - name=f'{name}|duration_initial', - ) + 2. **Scaled Bounds**: `bounds` + `scaling_variable` + → Calls: scaled_bounds() + → Creates: scaling * lower_factor ≤ variable ≤ scaling * upper_factor - # Minimum duration constraint if provided - if minimum_duration is not None: - constraints['minimum'] = model.add_constraints( - duration.isel(time=slice(1, None)) - >= (state_variable.isel(time=slice(None, -1)) - state_variable.isel(time=slice(1, None))) - * minimum_duration.isel(time=slice(None, -1)), - name=f'{name}|duration_minimum', - ) + 3. **Binary Controlled**: `bounds` + `binary_control` + → Calls: binary_controlled_bounds() + → Creates: binary * max(ε, lower_bound) ≤ variable ≤ binary * upper_bound - # Handle initial condition for minimum duration - if previous_duration > 0 and previous_duration < minimum_duration.isel(time=0).max(): - constraints['initial_minimum'] = model.add_constraints( - state_variable.isel(time=0) == 1, name=f'{name}|duration_initial_minimum' - ) + 4. **Binary + Scaling**: `bounds` + `scaling_variable` + `binary_control` + → Calls: binary_scaled_bounds() + → Creates: binary * max(ε, scaling * lower_factor) ≤ variable ≤ binary * scaling * upper_factor - variables = {'duration': duration} + 5. **Big-M Dual Control**: All parameters provided + → Calls: big_m_dual_control_bounds() + → Creates: Complex big-M formulation for binary + variable scaling control - return variables, constraints + Usage Examples: + ```python + # Simple bounds + auto_bounds(model, var, (0, 100), 'upper', 'lower') - @staticmethod - def mutual_exclusivity_constraint( - model: FlowSystemModel, name: str, binary_variables: List[linopy.Variable], tolerance: float = 1.1 - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """ - Creates mutual exclusivity constraint for binary variables. + # Capacity-scaled bounds + auto_bounds(model, flow_var, (0.2, 0.8), 'upper', 'lower', scaling_variable=capacity_var) - Mathematical formulation: - Σ(binary_vars[i]) ≤ tolerance ∀t + # Binary on/off control + auto_bounds(model, var, (10, 100), 'upper', 'lower', binary_control=on_off_var) - Ensures at most one binary variable can be 1 at any time. - Tolerance > 1.0 accounts for binary variable numerical precision. + # Full dual control + auto_bounds( + model, + var, + (0.1, 0.9), + 'upper', + 'lower', + scaling_variable=size_var, + binary_control=on_var, + scaling_bounds=(0, 1000), + constraint_name_prefix='dual', + ) + ``` Args: - binary_variables: List of binary variables that should be mutually exclusive - tolerance: Upper bound (typically 1.1 for numerical stability) + model: The optimization model instance + variable: Variable to be bounded + variable_bounds: Tuple of (lower, upper) - absolute bounds or relative factors if scaling + scaling_variable: Optional variable to scale bounds by + scaling_state: Optional binary variable for the state of the scaling variable + scaling_bounds: Required for big-M case - bounds of scaling variable + variable_state: Optional variable that controls the variable state (e.g., on/off) Returns: - variables: {} (no new variables created) - constraints: {'mutual_exclusivity': constraint} + Tuple containing: + - variables (Dict): Variable dictionary from the selected method + - constraints (Dict[str, linopy.Constraint]): Constraint dictionary from the selected method Raises: - AssertionError: If fewer than 2 variables provided or variables aren't binary + ValueError: If big-M dual control is detected but required parameters are missing + + Note: + The method prioritizes more complex formulations when multiple options are available. + Parameter validation ensures all required arguments are provided for each case. """ - assert len(binary_variables) >= 2, ( - f'Mutual exclusivity requires at least 2 variables, got {len(binary_variables)}' - ) + # Case 1: Scaled bounds with state and a state for the variable + if variable_state is not None and scaling_variable is None and scaling_state is None: + return BoundingPatterns.dual_binary_scaled_bounds( + model=model, + variable=variable, + scaling_variable=variable_state, + relative_bounds=variable_bounds, + scaling_binary=variable_state, + secondary_binary=variable_state, + scaling_bounds=scaling_bounds, + ) - for var in binary_variables: - assert var.attrs.get('binary', False), ( - f'Variable {var.name} must be binary for mutual exclusivity constraint' + # Case 2: Scaled Bounds with state for the scaled variable + if variable_state is not None and scaling_variable is not None: + if scaling_bounds is None: + raise ValueError('scaling_bounds is required when using binary_scaled_bounds to compute big-M values') + + return BoundingPatterns.binary_scaled_bounds( + model=model, + variable=variable, + scaling_variable=scaling_variable, + relative_bounds=variable_bounds, + binary_control=variable_state, + scaling_bounds=scaling_bounds, ) - # Create mutual exclusivity constraint - mutual_exclusivity = model.add_constraints( - sum(binary_variables) <= tolerance, name=f'{name}|mutual_exclusivity' - ) + # Case 3: Binary controlled variable with fixed bounds + if variable_state is not None and scaling_variable is None: + return BoundingPatterns.binary_controlled_bounds( + model=model, + variable=variable, + bounds=variable_bounds, + binary_control=variable_state, + ) - variables = {} # No new variables created - constraints = {'mutual_exclusivity': mutual_exclusivity} + # Case 4: Simple absolute bounds + if scaling_variable is None and variable_state is None: + return BoundingPatterns.basic_bounds(model, variable, variable_bounds) - return variables, constraints + raise ValueError('Invalid combination of arguments') class ModelingPatterns: @@ -859,9 +937,9 @@ def investment_sizing_pattern( model: FlowSystemModel, name: str, size_bounds: Tuple[TemporalData, TemporalData], - controlled_variables: List[linopy.Variable] = None, - control_factors: List[Tuple[TemporalData, TemporalData]] = None, - state_variables: List[linopy.Variable] = None, + controlled_variable: linopy.Variable, + control_factors: Tuple[TemporalData, TemporalData], + state_variable: List[linopy.Variable] = None, optional: bool = False, ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: """ @@ -878,7 +956,7 @@ def investment_sizing_pattern( Returns: variables: {'size': size_var, 'is_invested': binary_var (if optional)} - constraints: {'upper_bound': constraint, 'lower_bound': constraint, ...} + constraints: {'ub': constraint, 'lb': constraint, ...} """ variables = {} constraints = {} @@ -898,37 +976,19 @@ def investment_sizing_pattern( binary=True, name=f'{name}|is_invested', coords=model.get_coords(['year', 'scenario']) ) - # Link size to investment decision - if abs(size_min - size_max) < 1e-10: # Fixed size case - constraints['fixed_size'] = model.add_constraints( - variables['size'] == variables['is_invested'] * size_max, name=f'{name}|fixed_size' - ) - else: # Variable size case - constraints['upper_bound'] = model.add_constraints( - variables['size'] <= variables['is_invested'] * size_max, name=f'{name}|size|upper_bound' - ) - constraints['lower_bound'] = model.add_constraints( - variables['size'] >= variables['is_invested'] * np.maximum(CONFIG.modeling.EPSILON, size_min), - name=f'{name}|size|lower_bound', - ) + _, new_cons = BoundingPatterns.auto_bounds( + model=model, + variable=controlled_variable, + bounds=control_factors, + upper_bound_name=f'{controlled_variable.name}|ub', + lower_bound_name=f'{controlled_variable.name}|lb', + scaling_variable=variables['size'], + binary_control=variables['is_invested'] if optional else None, + scaling_bounds=(size_min, size_max), + constraint_name_prefix=name, + ) - # Control dependent variables - if controlled_variables and control_factors: - for i, (var, factors, state_variable) in enumerate(zip(controlled_variables, control_factors, state_variables)): - upper_bound_name = f'{var.name}|upper_bound' - lower_bound_name = f'{var.name}|lower_bound' - _, control_constraints = ModelingPrimitives.big_m_binary_bounds( - model=model, - variable=var, - binary_control=state_variable, - size_variable=variables['size'], - relative_bounds=factors, - upper_bound_name=upper_bound_name, - lower_bound_name=lower_bound_name, - ) - # Flatten control constraints with indexed names - constraints[upper_bound_name] = control_constraints['upper_bound'] - constraints[lower_bound_name] = control_constraints['lower_bound'] + constraints.update(new_cons) return variables, constraints @@ -967,7 +1027,7 @@ def operational_binary_control_pattern( # 2. Control variables - use big_m_binary_bounds pattern for consistency for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): # Use the big_m pattern but without binary control (None) - _, control_constraints = ModelingPrimitives.big_m_binary_bounds( + _, control_constraints = BoundingPatterns.big_m_binary_bounds( model=model, variable=var, binary_control=variables['on'], # The on state controls the variables @@ -976,8 +1036,8 @@ def operational_binary_control_pattern( upper_bound_name=f'{name}|control_{i}_upper', lower_bound_name=f'{name}|control_{i}_lower', ) - constraints[f'control_{i}_upper'] = control_constraints['upper_bound'] - constraints[f'control_{i}_lower'] = control_constraints['lower_bound'] + constraints[f'control_{i}_upper'] = control_constraints['ub'] + constraints[f'control_{i}_lower'] = control_constraints['lb'] # 3. Total duration tracking using existing pattern if track_total_duration: From 2afc24e15b0b16d30d5e06ec0ffde221b645177a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 17:35:29 +0200 Subject: [PATCH 17/51] Improve BoundingPatterns --- flixopt/modeling.py | 384 +++++++++++++------------------------------- 1 file changed, 110 insertions(+), 274 deletions(-) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index a443c3a74..359ae24e1 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -439,6 +439,9 @@ def basic_bounds( ): """Create simple bounds. + Mathematical Formulation: + lower_bound ≤ variable ≤ upper_bound + Args: model: The optimization model instance variable: Variable to be bounded @@ -446,10 +449,8 @@ def basic_bounds( Returns: Tuple containing: - - variables (Dict): Empty dict (no new variables created) - - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: - - 'ub': Upper bound constraint - - 'lb': Lower bound constraint + - variables (Dict): Empty dict + - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb' """ lower_bound, upper_bound = bounds @@ -463,64 +464,40 @@ def binary_controlled_bounds( model: FlowSystemModel, variable: linopy.Variable, bounds: Tuple[TemporalData, TemporalData], - binary_control: linopy.Variable, + variable_state: linopy.Variable, ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """Create bounds controlled by a binary variable with epsilon handling. - - This method implements binary-controlled bounds where a binary variable acts as an on/off - switch for the bounded variable. When the binary is 1, normal bounds apply; when 0, the - variable is forced to zero. + """Create bounds controlled by a binary variable. Mathematical Formulation: - binary * max(ε, lower_bound) ≤ variable ≤ binary * upper_bound - - Where: - - binary ∈ {0, 1}: Control variable - - ε: Small positive constant (CONFIG.modeling.EPSILON) - - When binary = 1: Normal bounds apply - - When binary = 0: Variable is forced to 0 + variable_state * max(ε, lower_bound) ≤ variable ≤ variable_state * upper_bound Use Cases: - - Investment decisions (invest or don't invest) - - Unit commitment (on/off operational states) - - Feature selection in optimization models - - Example: - Investment bounds where β_inv controls whether investment occurs: - β_inv * max(ε, V^L) ≤ V ≤ β_inv * V^U + - Investment decisions + - Unit commitment (on/off states) Args: model: The optimization model instance variable: Variable to be bounded bounds: Tuple of (lower_bound, upper_bound) absolute bounds - binary_control: Binary variable controlling the bounds + variable_state: Binary variable controlling the bounds Returns: Tuple containing: - - variables (Dict): Empty dict (no new variables created) - - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: - - 'ub': Upper bound constraint - - 'lb': Lower bound constraint - - 'fix': Fix constraint, if upper bound is equal to lower bound - - Note: - The epsilon value is applied to the lower bound to distinguish between - zero and "very small positive" values, which is important for numerical - stability in optimization solvers. + - variables (Dict): Empty dict + - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb' """ lower_bound, upper_bound = bounds if np.all(lower_bound - upper_bound) < 1e-10: fix_constraint = model.add_constraints( - variable == binary_control * upper_bound, name=f'{variable.name}|fixed_size' + variable == variable_state * upper_bound, name=f'{variable.name}|fixed_size' ) return {}, {'ub': fix_constraint, 'lb': fix_constraint} - # Apply epsilon to lower bound to distinguish 0 from "very small positive" epsilon = np.maximum(CONFIG.modeling.EPSILON, lower_bound) - upper_constraint = model.add_constraints(variable <= binary_control * upper_bound, name=f'{variable.name}|ub') - lower_constraint = model.add_constraints(variable >= binary_control * epsilon, name=f'{variable.name}|lb') + upper_constraint = model.add_constraints(variable <= variable_state * upper_bound, name=f'{variable.name}|ub') + lower_constraint = model.add_constraints(variable >= variable_state * epsilon, name=f'{variable.name}|lb') return {}, {'ub': upper_constraint, 'lb': lower_constraint} @@ -531,28 +508,14 @@ def scaled_bounds( scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """Create simple bounds scaled by another variable. - - This method creates proportional bounds where the actual bounds are determined - by multiplying relative factors with a scaling variable. This is useful for - capacity-dependent constraints. + """Create bounds scaled by another variable. Mathematical Formulation: - scaling * lower_factor ≤ variable ≤ scaling * upper_factor - - Where: - - scaling: Continuous scaling variable (e.g., capacity, size) - - lower_factor, upper_factor: Relative bound multipliers + scaling_variable * lower_factor ≤ variable ≤ scaling_variable * upper_factor Use Cases: - Flow rates bounded by equipment capacity - Production levels scaled by plant size - - Resource consumption proportional to activity level - - Example: - Flow rate bounded by equipment size: - P * p_rel^L(t) ≤ p(t) ≤ P * p_rel^U(t) - where P is equipment size, p is flow rate Args: model: The optimization model instance @@ -562,19 +525,13 @@ def scaled_bounds( Returns: Tuple containing: - - variables (Dict): Empty dict (no new variables created) - - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: - - 'ub': Upper bound constraint - - 'lb': Lower bound constraint - - Note: - This method assumes the scaling variable is always non-negative. - For negative scaling variables, the inequality directions would need adjustment. + - variables (Dict): Empty dict + - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb' """ rel_lower, rel_upper = relative_bounds - upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{variable}|ub') - lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=f'{variable}|lb') + upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{variable.name}|ub') + lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=f'{variable.name}|lb') variables = {} constraints = {'ub': upper_constraint, 'lb': lower_constraint} @@ -586,94 +543,57 @@ def binary_scaled_bounds( variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], - binary_control: linopy.Variable, + variable_state: linopy.Variable, scaling_bounds: Tuple[TemporalData, TemporalData], ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """Create scaled bounds controlled by a binary variable using linear big-M formulation. - - Desired (Non-Linear) Formulation: - binary * max(ε, scaling * lower_factor) ≤ variable ≤ binary * scaling * upper_factor - - Actual Linear Big-M Formulation: - # When binary = 1: scaling bounds apply - scaling * lower_factor ≤ variable ≤ scaling * upper_factor + """Create scaled bounds controlled by a binary variable. - # When binary = 0: variable forced to 0 - variable ≤ binary * M_upper - variable ≥ binary * M_lower + Mathematical Formulation (Big-M): + scaling_variable * lower_factor ≤ variable ≤ scaling_variable * upper_factor + variable ≤ variable_state * M_upper + variable ≥ variable_state * M_lower - Where: - M_upper = scaling_max * upper_factor - M_lower = max(ε, scaling_min * lower_factor) - - Behavior: - - When binary = 1: Variable bounded by scaling * factors (normal operation) - - When binary = 0: Variable forced to 0 (off state) + Where: M_upper = scaling_max * upper_factor, M_lower = max(ε, scaling_min * lower_factor) Use Cases: - - Fixed-size units with on/off control - - Capacity-scaled operations with binary states - - Process units with binary operational modes - - Example: - Power plant with capacity P ∈ [P_min, P_max] and on/off control β_on: - Linear formulation replaces: β_on(t) * P * p_rel^L(t) ≤ p(t) ≤ β_on(t) * P * p_rel^U(t) + - Equipment with capacity and on/off control + - Variable-size units with operational states Args: model: The optimization model instance variable: Variable to be bounded scaling_variable: Variable that scales the bound factors relative_bounds: Tuple of (lower_factor, upper_factor) relative to scaling variable - binary_control: Binary variable for on/off control + variable_state: Binary variable for on/off control scaling_bounds: Tuple of (scaling_min, scaling_max) bounds of the scaling variable Returns: Tuple containing: - - variables (Dict): Empty dict (no new variables created) - - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: - - 'ub': Upper bound constraint - - 'lb': Lower bound constraint - - Note: - This method now requires scaling_bounds to compute appropriate big-M values. - The big-M formulation maintains linearity while preserving the intended behavior. + - variables (Dict): Empty dict + - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb', 'binary_upper', 'binary_lower' """ rel_lower, rel_upper = relative_bounds scaling_min, scaling_max = scaling_bounds - # Calculate big-M values for upper and lower bounds big_m_upper = scaling_max * rel_upper big_m_lower = np.maximum(CONFIG.modeling.EPSILON, scaling_min * rel_lower) - # Linear constraints using big-M technique: - # When binary = 1: normal scaling bounds apply - # When binary = 0: variable forced to 0 - - # Upper bound: variable ≤ min(scaling * rel_upper, binary * big_m_upper) - # Implemented as two constraints: scaling_upper = model.add_constraints( - variable <= scaling_variable * rel_upper, name=f'{scaling_variable.name}|ub' - ) - binary_upper = model.add_constraints( - variable <= binary_control * big_m_upper, name=f'{variable.name}|ub' + variable <= scaling_variable * rel_upper, name=f'{variable.name}|scaling_ub' ) + binary_upper = model.add_constraints(variable <= variable_state * big_m_upper, name=f'{variable.name}|ub') - # Lower bound: variable ≥ max(scaling * rel_lower, binary * big_m_lower) - # When binary = 0: second constraint gives variable ≥ 0 - # When binary = 1: first constraint is active scaling_lower = model.add_constraints( - variable >= scaling_variable * rel_lower, name=f'{scaling_variable.name}|ub' - ) - binary_lower = model.add_constraints( - variable >= binary_control * big_m_lower, name=f'{variable.name}|lb' + variable >= scaling_variable * rel_lower, name=f'{variable.name}|scaling_lb' ) + binary_lower = model.add_constraints(variable >= variable_state * big_m_lower, name=f'{variable.name}|lb') variables = {} constraints = { - 'ub': scaling_upper, # Primary upper bound constraint - 'lb': scaling_lower, # Primary lower bound constraint - 'binary_upper': binary_upper, # Binary control upper bound - 'binary_lower': binary_lower, # Binary control lower bound + 'ub': scaling_upper, + 'lb': scaling_lower, + 'binary_upper': binary_upper, + 'binary_lower': binary_lower, } return variables, constraints @@ -683,121 +603,76 @@ def dual_binary_scaled_bounds( variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], - scaling_binary: linopy.Variable, - secondary_binary: linopy.Variable, + scaling_state: linopy.Variable, + variable_state: linopy.Variable, scaling_bounds: Tuple[TemporalData, TemporalData], ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: """Create bounds with dual binary control over a scaled variable. - This method implements the most complex bounding case where you have two binary variables - controlling a scaled relationship between variables. This is commonly used for investment - and operational control scenarios. - - Hierarchical Control: - 1. scaling_binary: Controls whether the scaling variable can be non-zero - 2. Secondary binary: Controls whether the main variable can be non-zero (given scaling exists) - Mathematical Formulation: - - Scaling variable bounds: - scaling_binary * max(ε, scaling_min) ≤ scaling_variable ≤ scaling_binary * scaling_max - - Main variable bounds with dual control: - secondary_binary * max(ε, rel_lower * scaling_min) ≤ variable ≤ secondary_binary * M - M * (secondary_binary - 1) + scaling_variable * rel_lower ≤ variable ≤ scaling_variable * rel_upper + scaling_state * max(ε, scaling_min) ≤ scaling_variable ≤ scaling_state * scaling_max + variable_state * max(ε, rel_lower * scaling_min) ≤ variable ≤ variable_state * M + M * (variable_state - 1) + scaling_variable * rel_lower ≤ variable ≤ scaling_variable * rel_upper Where: M = rel_upper * scaling_max - Logical Behavior: - - scaling_binary = 0: No scaling capacity (scaling_variable = 0), no main variable (variable = 0) - - scaling_binary = 1, secondary_binary = 0: Scaling exists but main variable is off (variable = 0) - - scaling_binary = 1, secondary_binary = 1: Normal scaled operation - Use Cases: - - Investment + operational control (capacity sizing + on/off dispatch) - - Resource allocation + activation (budget + spending) - - Equipment sizing + utilization (capacity + operation) - - Feature selection + intensity (enable + level) - - Examples: - - Power plant: Build capacity? (primary) How big? (scaling) When to run? (secondary) - - Marketing: Enter market? (primary) Budget size? (scaling) Campaign active? (secondary) - - Production: Install line? (primary) Line capacity? (scaling) Line running? (secondary) + - Investment + operational control (capacity sizing + on/off dispatch) + - Equipment sizing + utilization Args: model: The optimization model instance - variable: Main variable to be bounded - scaling_variable: Variable that scales the bounds (e.g., capacity, size, budget) + variable: Variable to be bounded + scaling_variable: Variable that scales the bounds relative_bounds: Tuple of (rel_lower, rel_upper) relative bound multipliers - scaling_binary: Binary controlling scaling_variable existence (e.g., investment decision) - secondary_binary: Binary controlling variable operation (e.g., operational on/off) + scaling_state: Binary controlling scaling_variable existence + variable_state: Binary controlling variable operation scaling_bounds: Tuple of (scaling_min, scaling_max) bounds for scaling_variable Returns: Tuple containing: - - variables (Dict): Empty dict (no new variables created) - - constraints (Dict[str, linopy.Constraint]): Dictionary with keys: - - 'primary_scaling_ub': Primary control upper bound for scaling variable - - 'primary_scaling_lb': Primary control lower bound for scaling variable - - 'secondary_variable_ub': Secondary control upper bound for main variable - - 'secondary_variable_lb': Secondary control lower bound for main variable - - 'scaling_variable_ub': Scaling-dependent upper bound for main variable - - 'scaling_variable_lb': Scaling-dependent lower bound for main variable - - Note: - This implements hierarchical binary control where the primary binary enables the scaling - variable, and the secondary binary controls the main variable's operation within the - scaled bounds. Both binaries must be active for normal operation. + - variables (Dict): Empty dict + - constraints (Dict[str, linopy.Constraint]): Multiple constraint keys """ rel_lower, rel_upper = relative_bounds scaling_min, scaling_max = scaling_bounds - # Calculate big-M value for secondary control constraints - # M = rel_upper * scaling_max (maximum possible variable value) big_m = rel_upper * scaling_max - # 1. PRIMARY BINARY CONSTRAINTS FOR SCALING VARIABLE - # scaling_binary * max(ε, scaling_min) ≤ scaling_variable ≤ scaling_binary * scaling_max + # 1. SCALING VARIABLE CONSTRAINTS epsilon_scaling = np.maximum(CONFIG.modeling.EPSILON, scaling_min) - primary_scaling_ub = model.add_constraints( - scaling_variable <= scaling_binary * scaling_max, name=f'{scaling_variable.name}|primary_ub' + scaling_ub = model.add_constraints( + scaling_variable <= scaling_state * scaling_max, name=f'{scaling_variable.name}|ub' ) - primary_scaling_lb = model.add_constraints( - scaling_variable >= scaling_binary * epsilon_scaling, name=f'{scaling_variable.name}|primary_lb' + scaling_lb = model.add_constraints( + scaling_variable >= scaling_state * epsilon_scaling, name=f'{scaling_variable.name}|lb' ) - # 2. SECONDARY BINARY CONSTRAINTS FOR MAIN VARIABLE - # secondary_binary * max(ε, rel_lower * scaling_min) ≤ variable ≤ secondary_binary * M + # 2. VARIABLE STATE CONSTRAINTS epsilon_variable = np.maximum(CONFIG.modeling.EPSILON, rel_lower * scaling_min) - secondary_variable_ub = model.add_constraints( - variable <= secondary_binary * big_m, name=f'{variable.name}|secondary_ub' - ) + variable_ub = model.add_constraints(variable <= variable_state * big_m, name=f'{variable.name}|ub') - secondary_variable_lb = model.add_constraints( - variable >= secondary_binary * epsilon_variable, name=f'{variable.name}|secondary_lb' - ) - - # 3. SCALING-DEPENDENT CONSTRAINTS FOR MAIN VARIABLE - # M * (secondary_binary - 1) + scaling_variable * rel_lower ≤ variable ≤ scaling_variable * rel_upper + variable_lb = model.add_constraints(variable >= variable_state * epsilon_variable, name=f'{variable.name}|lb') + # 3. SCALING-DEPENDENT CONSTRAINTS scaling_variable_ub = model.add_constraints( variable <= scaling_variable * rel_upper, name=f'{variable.name}|scaling_ub' ) scaling_variable_lb = model.add_constraints( - big_m * (secondary_binary - 1) + scaling_variable * rel_lower <= variable, + big_m * (variable_state - 1) + scaling_variable * rel_lower <= variable, name=f'{variable.name}|scaling_lb', ) variables = {} constraints = { - 'primary_scaling_ub': primary_scaling_ub, - 'primary_scaling_lb': primary_scaling_lb, - 'secondary_variable_ub': secondary_variable_ub, - 'secondary_variable_lb': secondary_variable_lb, + 'scaling_ub': scaling_ub, + 'scaling_lb': scaling_lb, + 'variable_ub': variable_ub, + 'variable_lb': variable_lb, 'scaling_variable_ub': scaling_variable_ub, 'scaling_variable_lb': scaling_variable_lb, } @@ -808,123 +683,84 @@ def dual_binary_scaled_bounds( def auto_bounds( model: FlowSystemModel, variable: linopy.Variable, - variable_bounds: Tuple[TemporalData, TemporalData], + bounds: Tuple[TemporalData, TemporalData], scaling_variable: linopy.Variable = None, scaling_state: linopy.Variable = None, scaling_bounds: Tuple[TemporalData, TemporalData] = None, variable_state: linopy.Variable = None, ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """Automatically select the appropriate bounds method based on provided parameters. - - This intelligent dispatcher analyzes the provided parameters and automatically - selects the most appropriate bounding method. It simplifies the API by providing - a single entry point for all bounding scenarios. - - Parameter Combinations and Method Selection: - - 1. **Simple Bounds**: Only `bounds` provided - → Creates: lower_bound ≤ variable ≤ upper_bound - - 2. **Scaled Bounds**: `bounds` + `scaling_variable` - → Calls: scaled_bounds() - → Creates: scaling * lower_factor ≤ variable ≤ scaling * upper_factor - - 3. **Binary Controlled**: `bounds` + `binary_control` - → Calls: binary_controlled_bounds() - → Creates: binary * max(ε, lower_bound) ≤ variable ≤ binary * upper_bound - - 4. **Binary + Scaling**: `bounds` + `scaling_variable` + `binary_control` - → Calls: binary_scaled_bounds() - → Creates: binary * max(ε, scaling * lower_factor) ≤ variable ≤ binary * scaling * upper_factor - - 5. **Big-M Dual Control**: All parameters provided - → Calls: big_m_dual_control_bounds() - → Creates: Complex big-M formulation for binary + variable scaling control - - Usage Examples: - ```python - # Simple bounds - auto_bounds(model, var, (0, 100), 'upper', 'lower') - - # Capacity-scaled bounds - auto_bounds(model, flow_var, (0.2, 0.8), 'upper', 'lower', scaling_variable=capacity_var) - - # Binary on/off control - auto_bounds(model, var, (10, 100), 'upper', 'lower', binary_control=on_off_var) + """Automatically select the appropriate bounds method. - # Full dual control - auto_bounds( - model, - var, - (0.1, 0.9), - 'upper', - 'lower', - scaling_variable=size_var, - binary_control=on_var, - scaling_bounds=(0, 1000), - constraint_name_prefix='dual', - ) - ``` + Parameter Combinations: + 1. Only bounds → basic_bounds() + 2. bounds + scaling_variable → scaled_bounds() + 3. bounds + variable_state → binary_controlled_bounds() + 4. bounds + scaling_variable + variable_state → binary_scaled_bounds() + 5. bounds + scaling_variable + scaling_state + variable_state → dual_binary_scaled_bounds() Args: model: The optimization model instance variable: Variable to be bounded - variable_bounds: Tuple of (lower, upper) - absolute bounds or relative factors if scaling + bounds: Tuple of (lower, upper) bounds or relative factors scaling_variable: Optional variable to scale bounds by - scaling_state: Optional binary variable for the state of the scaling variable - scaling_bounds: Required for big-M case - bounds of scaling variable - variable_state: Optional variable that controls the variable state (e.g., on/off) + scaling_state: Optional binary variable for scaling_variable state + scaling_bounds: Required for cases 4,5 - bounds of scaling variable + variable_state: Optional binary variable for variable state Returns: - Tuple containing: - - variables (Dict): Variable dictionary from the selected method - - constraints (Dict[str, linopy.Constraint]): Constraint dictionary from the selected method + Tuple from the selected method Raises: - ValueError: If big-M dual control is detected but required parameters are missing - - Note: - The method prioritizes more complex formulations when multiple options are available. - Parameter validation ensures all required arguments are provided for each case. + ValueError: If required parameters are missing """ - # Case 1: Scaled bounds with state and a state for the variable - if variable_state is not None and scaling_variable is None and scaling_state is None: + # Case 5: Dual binary control + if scaling_variable is not None and scaling_state is not None and variable_state is not None: + if scaling_bounds is None: + raise ValueError('scaling_bounds is required for dual binary control') return BoundingPatterns.dual_binary_scaled_bounds( model=model, variable=variable, - scaling_variable=variable_state, - relative_bounds=variable_bounds, - scaling_binary=variable_state, - secondary_binary=variable_state, + scaling_variable=scaling_variable, + relative_bounds=bounds, + scaling_state=scaling_state, + variable_state=variable_state, scaling_bounds=scaling_bounds, ) - # Case 2: Scaled Bounds with state for the scaled variable - if variable_state is not None and scaling_variable is not None: + # Case 4: Binary scaled bounds + if scaling_variable is not None and variable_state is not None: if scaling_bounds is None: - raise ValueError('scaling_bounds is required when using binary_scaled_bounds to compute big-M values') - + raise ValueError('scaling_bounds is required for binary scaled bounds') return BoundingPatterns.binary_scaled_bounds( model=model, variable=variable, scaling_variable=scaling_variable, - relative_bounds=variable_bounds, - binary_control=variable_state, + relative_bounds=bounds, + variable_state=variable_state, scaling_bounds=scaling_bounds, ) - # Case 3: Binary controlled variable with fixed bounds + # Case 3: Binary controlled bounds if variable_state is not None and scaling_variable is None: return BoundingPatterns.binary_controlled_bounds( model=model, variable=variable, - bounds=variable_bounds, - binary_control=variable_state, + bounds=bounds, + variable_state=variable_state, + ) + + # Case 2: Scaled bounds + if scaling_variable is not None and variable_state is None: + return BoundingPatterns.scaled_bounds( + model=model, + variable=variable, + scaling_variable=scaling_variable, + relative_bounds=bounds, ) - # Case 4: Simple absolute bounds + # Case 1: Basic bounds if scaling_variable is None and variable_state is None: - return BoundingPatterns.basic_bounds(model, variable, variable_bounds) + return BoundingPatterns.basic_bounds(model, variable, bounds) raise ValueError('Invalid combination of arguments') From b248f5872b285f32ba9a96a1d38d08eafe36c8d6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 19:32:07 +0200 Subject: [PATCH 18/51] Improve BoundingPatterns --- flixopt/elements.py | 2 +- flixopt/features.py | 114 +++++++++++++++++++++++--------- flixopt/modeling.py | 158 ++++++++++++-------------------------------- 3 files changed, 125 insertions(+), 149 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 440ac6de4..09fd07fe9 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -357,7 +357,7 @@ def do_modeling(self): self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative, ), - on_variable=self.on_off.on if self.on_off is not None else None, + state_variable=self.on_off.on if self.on_off is not None else None, ), 'investment', ) diff --git a/flixopt/features.py b/flixopt/features.py index 52c1302c2..884665e9b 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -13,7 +13,7 @@ from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions from .interface import InvestParameters, OnOffParameters, Piecewise, PiecewiseEffects from .structure import Model, FlowSystemModel, BaseFeatureModel -from .modeling import ModelingPatterns, ModelingUtilities, ModelingPrimitives +from .modeling import ModelingPatterns, ModelingUtilities, ModelingPrimitives, BoundingPatterns logger = logging.getLogger('flixopt') @@ -29,42 +29,82 @@ def __init__( defining_variable: linopy.Variable, relative_bounds_of_defining_variable: Tuple[TemporalData, TemporalData], label_of_model: Optional[str] = None, - on_variable: Optional[linopy.Variable] = None, + state_variable: Optional[linopy.Variable] = None, ): + """ + This feature model is used to model the investment of a variable. + It applies the corresponding bounds to the variable and the on/off state of the variable. + + Args: + model: The optimization model instance + label_of_element: The label of the parent (Element). Used to construct the full label of the model. + parameters: The parameters of the feature model. + defining_variable: The variable to be invested + relative_bounds_of_defining_variable: The bounds of the variable, with respect to the minimum/maximum investment sizes + label_of_model: The label of the model. This is needed to construct the full label of the model. + state_variable: The variable tracking the state of the variable + """ super().__init__(model, label_of_element=label_of_element, parameters=parameters, label_of_model=label_of_model) self._defining_variable = defining_variable self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable - self._on_variable = on_variable + self._state_variable = state_variable # Only keep non-variable attributes self.scenario_of_investment: Optional[linopy.Variable] = None self.piecewise_effects: Optional[PiecewiseEffectsModel] = None def create_variables_and_constraints(self): - # Use factory patterns - variables, constraints = ModelingPatterns.investment_sizing_pattern( - model=self._model, - name=self.label_full, - size_bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size,), - controlled_variables=[self._defining_variable], - control_factors=[self._relative_bounds_of_defining_variable], - state_variables=[self._on_variable], - optional=self.parameters.optional, + constraints = [] + size_min, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size) + size = self.add( + self._model.add_variables( + lower=0 if self.parameters.optional else size_min, + upper=size_max, + name=f'{self.label_of_model}|size', + coords=self._model.get_coords(['year', 'scenario']), + ), + 'size', ) - # Register variables (stored in Model's variable tracking) - self.add(variables['size'], 'size') - if 'is_invested' in variables: - self.add(variables['is_invested'], 'is_invested') + constraints += BoundingPatterns.scaled_bounds( + self._model, + variable=self._defining_variable, + scaling_variable=size, + relative_bounds=self._relative_bounds_of_defining_variable, + ) - # Register constraints - for constraint_name, constraint in constraints.items(): - self.add(constraint, constraint_name) + # Optional binary investment decision + if self.parameters.optional: + is_invested = self.add( + self._model.add_variables( + binary=True, name=f'{self.label_of_model}|is_invested', coords=self._model.get_coords(['year', 'scenario']) + ), + 'is_invested', + ) - # Handle scenarios and piecewise effects... - if self._model.flow_system.scenarios is not None: - self._create_bounds_for_scenarios() + if self._state_variable is None: + constraints += BoundingPatterns.bounds_with_state( + self._model, + variable=size, + variable_state=is_invested, + bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), + ) + + else: + constraints += BoundingPatterns.scaled_bounds_with_state( + self._model, + variable=self._defining_variable, + variable_state=self._state_variable, + scaling_variable=size, + relative_bounds=self._relative_bounds_of_defining_variable, + scaling_bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), + scaling_state=is_invested, + ) + + # Register constraints + for constraint in constraints: + self.add(constraint) if self.parameters.piecewise_effects: self.piecewise_effects = self.add( @@ -137,6 +177,19 @@ def __init__( previous_flow_rates: List[Optional[TemporalData]], label_of_model: Optional[str] = None, ): + """ + This feature model is used to model the on/off state of flow_rate(s). It does not matter of the flow_rates are + bounded by a size variable or by a hard bound. THe used bound here is the absolute highest/lowest bound! + + Args: + model: The optimization model instance + label_of_element: The label of the parent (Element). Used to construct the full label of the model. + parameters: The parameters of the feature model. + flow_rates: The flow_rates to be modeled + flow_rate_bounds: The bounds of the flow_rates, with respect to the minimum/maximum investment sizes + previous_flow_rates: The previous flow_rates + label_of_model: The label of the model. This is needed to construct the full label of the model. + """ super().__init__(model, label_of_element, parameters=parameters, label_of_model=label_of_model) self._flow_rates = flow_rates self._flow_rate_bounds = flow_rate_bounds @@ -155,17 +208,14 @@ def create_variables_and_constraints(self): for i, (flow_rate, (lower_bound, upper_bound)) in enumerate(zip(self._flow_rates, self._flow_rate_bounds)): suffix = f'_{i}' if len(self._flow_rates) > 1 else '' # Use the big_m pattern but without binary control (None) - _, control_constraints = ModelingPrimitives.big_m_binary_bounds( + _, control_constraints = BoundingPatterns.binary_controlled_bounds( model=self._model, variable=flow_rate, - binary_control=None, - size_variable=variables['on'], - relative_bounds=(lower_bound, upper_bound), - upper_bound_name=f'{variables['on'].name}|ub{suffix}', - lower_bound_name=f'{variables['on'].name}|lb{suffix}', + bounds=(lower_bound, upper_bound), + variable_state=variables['on'], ) - constraints[f'ub_{i}'] = control_constraints['upper_bound'] - constraints[f'lb_{i}'] = control_constraints['lower_bound'] + constraints[f'ub{suffix}'] = control_constraints['ub'] + constraints[f'lb{suffix}'] = control_constraints['lb'] # 3. Total duration tracking using existing pattern duration_expr = (variables['on'] * self._model.hours_per_step).sum('time') @@ -216,8 +266,8 @@ def create_variables_and_constraints(self): constraints[f'consecutive_off_{cons_name}'] = cons_constraint # Register all constraints and variables - for constraint_name, constraint in constraints.items(): - self.add(constraint, constraint_name) + for constraint in constraints: + self.add(constraint) for variable_name, variable in variables.items(): self.add(variable, variable_name) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 359ae24e1..7380dcdec 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -438,6 +438,7 @@ def basic_bounds( bounds: Tuple[TemporalData, TemporalData], ): """Create simple bounds. + variable ∈ [lower_bound, upper_bound] Mathematical Formulation: lower_bound ≤ variable ≤ upper_bound @@ -457,19 +458,20 @@ def basic_bounds( upper_constraint = model.add_constraints(variable <= upper_bound, name=f'{variable.name}|ub') lower_constraint = model.add_constraints(variable >= lower_bound, name=f'{variable.name}|lb') - return {}, {'ub': upper_constraint, 'lb': lower_constraint} + return [lower_constraint, upper_constraint] @staticmethod - def binary_controlled_bounds( + def bounds_with_state( model: FlowSystemModel, variable: linopy.Variable, bounds: Tuple[TemporalData, TemporalData], variable_state: linopy.Variable, - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """Create bounds controlled by a binary variable. + ) -> List[linopy.Constraint]: + """Constraint a variable to bounds, that can be escaped from to 0 by a binary variable. + variable ∈ {0, [max(ε, lower_bound), upper_bound]} Mathematical Formulation: - variable_state * max(ε, lower_bound) ≤ variable ≤ variable_state * upper_bound + - variable_state * max(ε, lower_bound) ≤ variable ≤ variable_state * upper_bound Use Cases: - Investment decisions @@ -490,16 +492,16 @@ def binary_controlled_bounds( if np.all(lower_bound - upper_bound) < 1e-10: fix_constraint = model.add_constraints( - variable == variable_state * upper_bound, name=f'{variable.name}|fixed_size' + variable == variable_state * upper_bound, name=f'{variable.name}|fix' ) - return {}, {'ub': fix_constraint, 'lb': fix_constraint} + return [fix_constraint] epsilon = np.maximum(CONFIG.modeling.EPSILON, lower_bound) upper_constraint = model.add_constraints(variable <= variable_state * upper_bound, name=f'{variable.name}|ub') lower_constraint = model.add_constraints(variable >= variable_state * epsilon, name=f'{variable.name}|lb') - return {}, {'ub': upper_constraint, 'lb': lower_constraint} + return [lower_constraint, upper_constraint] @staticmethod def scaled_bounds( @@ -507,8 +509,9 @@ def scaled_bounds( variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """Create bounds scaled by another variable. + ) -> List[linopy.Constraint]: + """Constraint a variable by scaling bounds, dependent on another variable. + variable ∈ [lower_bound * scaling_variable, upper_bound * scaling_variable] Mathematical Formulation: scaling_variable * lower_factor ≤ variable ≤ scaling_variable * upper_factor @@ -533,20 +536,24 @@ def scaled_bounds( upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{variable.name}|ub') lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=f'{variable.name}|lb') - variables = {} - constraints = {'ub': upper_constraint, 'lb': lower_constraint} - return variables, constraints + return [lower_constraint, upper_constraint] @staticmethod - def binary_scaled_bounds( + def scaled_bounds_with_state( model: FlowSystemModel, variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], - variable_state: linopy.Variable, scaling_bounds: Tuple[TemporalData, TemporalData], - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """Create scaled bounds controlled by a binary variable. + variable_state: linopy.Variable, + scaling_state: linopy.Variable, + ) -> List[linopy.Constraint]: + """Constraint a variable by scaling bounds, dependent on another variable. + The bounds only apply if variable_state is 1. + + variable ∈ {0, + [max(ε, lower_relative_bound) * scaling_variable, upper_relative_bound * scaling_variable] + } Mathematical Formulation (Big-M): scaling_variable * lower_factor ≤ variable ≤ scaling_variable * upper_factor @@ -572,112 +579,31 @@ def binary_scaled_bounds( - variables (Dict): Empty dict - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb', 'binary_upper', 'binary_lower' """ + rel_lower, rel_upper = relative_bounds scaling_min, scaling_max = scaling_bounds big_m_upper = scaling_max * rel_upper big_m_lower = np.maximum(CONFIG.modeling.EPSILON, scaling_min * rel_lower) - scaling_upper = model.add_constraints( - variable <= scaling_variable * rel_upper, name=f'{variable.name}|scaling_ub' - ) - binary_upper = model.add_constraints(variable <= variable_state * big_m_upper, name=f'{variable.name}|ub') - - scaling_lower = model.add_constraints( - variable >= scaling_variable * rel_lower, name=f'{variable.name}|scaling_lb' - ) - binary_lower = model.add_constraints(variable >= variable_state * big_m_lower, name=f'{variable.name}|lb') - - variables = {} - constraints = { - 'ub': scaling_upper, - 'lb': scaling_lower, - 'binary_upper': binary_upper, - 'binary_lower': binary_lower, - } - return variables, constraints - - @staticmethod - def dual_binary_scaled_bounds( - model: FlowSystemModel, - variable: linopy.Variable, - scaling_variable: linopy.Variable, - relative_bounds: Tuple[TemporalData, TemporalData], - scaling_state: linopy.Variable, - variable_state: linopy.Variable, - scaling_bounds: Tuple[TemporalData, TemporalData], - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: - """Create bounds with dual binary control over a scaled variable. - - Mathematical Formulation: - scaling_state * max(ε, scaling_min) ≤ scaling_variable ≤ scaling_state * scaling_max - variable_state * max(ε, rel_lower * scaling_min) ≤ variable ≤ variable_state * M - M * (variable_state - 1) + scaling_variable * rel_lower ≤ variable ≤ scaling_variable * rel_upper - - Where: M = rel_upper * scaling_max - - Use Cases: - - Investment + operational control (capacity sizing + on/off dispatch) - - Equipment sizing + utilization - - Args: - model: The optimization model instance - variable: Variable to be bounded - scaling_variable: Variable that scales the bounds - relative_bounds: Tuple of (rel_lower, rel_upper) relative bound multipliers - scaling_state: Binary controlling scaling_variable existence - variable_state: Binary controlling variable operation - scaling_bounds: Tuple of (scaling_min, scaling_max) bounds for scaling_variable - - Returns: - Tuple containing: - - variables (Dict): Empty dict - - constraints (Dict[str, linopy.Constraint]): Multiple constraint keys - """ - rel_lower, rel_upper = relative_bounds - scaling_min, scaling_max = scaling_bounds - - big_m = rel_upper * scaling_max - - # 1. SCALING VARIABLE CONSTRAINTS - epsilon_scaling = np.maximum(CONFIG.modeling.EPSILON, scaling_min) - - scaling_ub = model.add_constraints( - scaling_variable <= scaling_state * scaling_max, name=f'{scaling_variable.name}|ub' + _, constraints = BoundingPatterns.bounds_with_state( + model, + variable=scaling_variable, + bounds=scaling_bounds, + variable_state=scaling_state, ) - scaling_lb = model.add_constraints( - scaling_variable >= scaling_state * epsilon_scaling, name=f'{scaling_variable.name}|lb' - ) - - # 2. VARIABLE STATE CONSTRAINTS - epsilon_variable = np.maximum(CONFIG.modeling.EPSILON, rel_lower * scaling_min) - - variable_ub = model.add_constraints(variable <= variable_state * big_m, name=f'{variable.name}|ub') - - variable_lb = model.add_constraints(variable >= variable_state * epsilon_variable, name=f'{variable.name}|lb') - - # 3. SCALING-DEPENDENT CONSTRAINTS - scaling_variable_ub = model.add_constraints( - variable <= scaling_variable * rel_upper, name=f'{variable.name}|scaling_ub' + scaling_upper = model.add_constraints( + variable <= scaling_variable * rel_upper, name=f'{variable.name}|ub' ) + binary_upper = model.add_constraints(variable <= variable_state * big_m_upper, name=f'{variable_state.name}|ub') - scaling_variable_lb = model.add_constraints( - big_m * (variable_state - 1) + scaling_variable * rel_lower <= variable, - name=f'{variable.name}|scaling_lb', + scaling_lower = model.add_constraints( + variable >= scaling_variable * rel_lower, name=f'{variable.name}|lb' ) + binary_lower = model.add_constraints(variable >= variable_state * big_m_lower, name=f'{variable_state.name}|lb') - variables = {} - constraints = { - 'scaling_ub': scaling_ub, - 'scaling_lb': scaling_lb, - 'variable_ub': variable_ub, - 'variable_lb': variable_lb, - 'scaling_variable_ub': scaling_variable_ub, - 'scaling_variable_lb': scaling_variable_lb, - } - - return variables, constraints + return [scaling_lower, scaling_upper, binary_lower, binary_upper] @staticmethod def auto_bounds( @@ -688,15 +614,15 @@ def auto_bounds( scaling_state: linopy.Variable = None, scaling_bounds: Tuple[TemporalData, TemporalData] = None, variable_state: linopy.Variable = None, - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + ) -> List[linopy.Constraint]: """Automatically select the appropriate bounds method. Parameter Combinations: 1. Only bounds → basic_bounds() 2. bounds + scaling_variable → scaled_bounds() - 3. bounds + variable_state → binary_controlled_bounds() + 3. bounds + variable_state → bounds_with_state() 4. bounds + scaling_variable + variable_state → binary_scaled_bounds() - 5. bounds + scaling_variable + scaling_state + variable_state → dual_binary_scaled_bounds() + 5. bounds + scaling_variable + scaling_state + variable_state → scaled_bounds_with_state_on_both_scaling_and_variable() Args: model: The optimization model instance @@ -717,7 +643,7 @@ def auto_bounds( if scaling_variable is not None and scaling_state is not None and variable_state is not None: if scaling_bounds is None: raise ValueError('scaling_bounds is required for dual binary control') - return BoundingPatterns.dual_binary_scaled_bounds( + return BoundingPatterns.scaled_bounds_with_state_on_both_scaling_and_variable( model=model, variable=variable, scaling_variable=scaling_variable, @@ -742,7 +668,7 @@ def auto_bounds( # Case 3: Binary controlled bounds if variable_state is not None and scaling_variable is None: - return BoundingPatterns.binary_controlled_bounds( + return BoundingPatterns.bounds_with_state( model=model, variable=variable, bounds=bounds, From d34445cd59b18838d810b677a852bcd5f3bf4b7b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 20:06:30 +0200 Subject: [PATCH 19/51] Fix duration Modeling --- flixopt/modeling.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 7380dcdec..083ba8c54 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -315,6 +315,7 @@ def consecutive_duration_tracking( duration[t] ≥ (state[t-1] - state[t]) * minimum_duration[t-1] ∀t > 0 Args: + name: Name of the duration variable state_variable: Binary state variable to track duration for minimum_duration: Optional minimum consecutive duration maximum_duration: Optional maximum consecutive duration @@ -332,21 +333,21 @@ def consecutive_duration_tracking( lower=0, upper=maximum_duration if maximum_duration is not None else mega, coords=model.get_coords(['time']), - name=f'{name}|duration', + name=name, ) constraints = {} # Upper bound: duration[t] ≤ state[t] * M constraints['ub'] = model.add_constraints( - duration <= state_variable * mega, name=f'{name}|duration_upper_bound' + duration <= state_variable * mega, name=f'{duration.name}|ub' ) # Forward constraint: duration[t+1] ≤ duration[t] + hours_per_step[t] constraints['forward'] = model.add_constraints( duration.isel(time=slice(1, None)) <= duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)), - name=f'{name}|duration_forward', + name=f'{duration.name}|forward', ) # Backward constraint: duration[t+1] ≥ duration[t] + hours_per_step[t] + (state[t+1] - 1) * M @@ -355,29 +356,29 @@ def consecutive_duration_tracking( >= duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)) + (state_variable.isel(time=slice(1, None)) - 1) * mega, - name=f'{name}|duration_backward', + name=f'{duration.name}|backward', ) # Initial condition: duration[0] = (hours_per_step[0] + previous_duration) * state[0] constraints['initial'] = model.add_constraints( duration.isel(time=0) == (hours_per_step.isel(time=0) + previous_duration) * state_variable.isel(time=0), - name=f'{name}|duration_initial', + name=f'{duration.name}|initial', ) # Minimum duration constraint if provided if minimum_duration is not None: - constraints['minimum'] = model.add_constraints( - duration.isel(time=slice(1, None)) + constraints['lb'] = model.add_constraints( + duration >= (state_variable.isel(time=slice(None, -1)) - state_variable.isel(time=slice(1, None))) * minimum_duration.isel(time=slice(None, -1)), - name=f'{name}|duration_minimum', + name=f'{duration.name}|lb', ) # Handle initial condition for minimum duration if previous_duration > 0 and previous_duration < minimum_duration.isel(time=0).max(): - constraints['initial_minimum'] = model.add_constraints( - state_variable.isel(time=0) == 1, name=f'{name}|duration_initial_minimum' + constraints['initial_lb'] = model.add_constraints( + state_variable.isel(time=0) == 1, name=f'{duration.name}|initial_lb' ) variables = {'duration': duration} From bde07b471b44fc401bc3af13da26a5ca2b783b51 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 18 Jul 2025 23:20:19 +0200 Subject: [PATCH 20/51] Fix On + Size --- flixopt/features.py | 83 +++++++++++++++++++++--------------- flixopt/modeling.py | 101 +++++++++++++++++++++++--------------------- 2 files changed, 101 insertions(+), 83 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 884665e9b..9ec91fec6 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -67,15 +67,25 @@ def create_variables_and_constraints(self): 'size', ) - constraints += BoundingPatterns.scaled_bounds( - self._model, - variable=self._defining_variable, - scaling_variable=size, - relative_bounds=self._relative_bounds_of_defining_variable, - ) + if self._state_variable is None and not self.parameters.optional: + constraints += BoundingPatterns.scaled_bounds( + self._model, + variable=self._defining_variable, + scaling_variable=size, + relative_bounds=self._relative_bounds_of_defining_variable, + name=self._defining_variable.name if self._state_variable is None else f'{self._defining_variable.name}_state', + ) - # Optional binary investment decision - if self.parameters.optional: + elif self._state_variable is not None and not self.parameters.optional: + constraints += BoundingPatterns.bounds_with_state( + self._model, + variable=self._defining_variable, + variable_state=self._state_variable, + bounds=self._relative_bounds_of_defining_variable, + name=self._defining_variable.name if self._state_variable is None else f'{self._defining_variable.name}_state', + ) + + elif self.parameters.optional: is_invested = self.add( self._model.add_variables( binary=True, name=f'{self.label_of_model}|is_invested', coords=self._model.get_coords(['year', 'scenario']) @@ -91,6 +101,13 @@ def create_variables_and_constraints(self): bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) + constraints += BoundingPatterns.scaled_bounds( + self._model, + variable=self._defining_variable, + scaling_variable=size, + relative_bounds=self._relative_bounds_of_defining_variable, + ) + else: constraints += BoundingPatterns.scaled_bounds_with_state( self._model, @@ -99,7 +116,7 @@ def create_variables_and_constraints(self): scaling_variable=size, relative_bounds=self._relative_bounds_of_defining_variable, scaling_bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), - scaling_state=is_invested, + name=f'{self.label_of_model}|size+on', ) # Register constraints @@ -197,25 +214,23 @@ def __init__( def create_variables_and_constraints(self): variables = {} - constraints = {} + constraints = [] # 1. Main binary state using existing pattern state_vars, state_constraints = ModelingPrimitives.binary_state_pair(self._model, self.label_of_model, use_complement=self.parameters.use_off) variables.update(state_vars) - constraints.update(state_constraints) + constraints += list(state_constraints.values()) # 2. Control variables - use big_m_binary_bounds pattern for consistency for i, (flow_rate, (lower_bound, upper_bound)) in enumerate(zip(self._flow_rates, self._flow_rate_bounds)): - suffix = f'_{i}' if len(self._flow_rates) > 1 else '' - # Use the big_m pattern but without binary control (None) - _, control_constraints = BoundingPatterns.binary_controlled_bounds( + # TODO: Add suffix options + constraints += BoundingPatterns.bounds_with_state( model=self._model, variable=flow_rate, bounds=(lower_bound, upper_bound), variable_state=variables['on'], + name=flow_rate.name, ) - constraints[f'ub{suffix}'] = control_constraints['ub'] - constraints[f'lb{suffix}'] = control_constraints['lb'] # 3. Total duration tracking using existing pattern duration_expr = (variables['on'] * self._model.hours_per_step).sum('time') @@ -225,45 +240,43 @@ def create_variables_and_constraints(self): self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf),#TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) ) variables['on_hours_total'] = duration_vars['tracker'] - constraints['on_hours_total'] = duration_constraints['tracking'] + constraints += [duration_constraints['tracking']] # 4. Switch tracking using existing pattern if self.parameters.use_switch_on: switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( - self._model, f'{self.label_of_model}|switches', variables['on'], - previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rates) + self._model, f'{self.label_of_model}|switch', variables['on'], + previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rates), + max_count=self.parameters.switch_on_total_max, ) - variables.update(switch_vars) - for switch_name, switch_constraint in switch_constraints.items(): - constraints[f'switch_{switch_name}'] = switch_constraint + variables.update({'switch|on': switch_vars['on'], 'switch|off': switch_vars['off'], 'switch|count': switch_vars['count']}) + constraints += list(switch_constraints.values()) # 5. Consecutive on duration using existing pattern if self.parameters.use_consecutive_on_hours: consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( self._model, - f'{self.label_of_model}|consecutive_on', + f'{self.label_of_model}|consecutive_on_hours', #TODO: Change name variables['on'], minimum_duration=self.parameters.consecutive_on_hours_min, maximum_duration=self.parameters.consecutive_on_hours_max, previous_duration=ModelingUtilities.compute_previous_on_duration(self._previous_flow_rates, self._model.hours_per_step), ) - variables['consecutive_on_duration'] = consecutive_on_vars['duration'] - for cons_name, cons_constraint in consecutive_on_constraints.items(): - constraints[f'consecutive_on_{cons_name}'] = cons_constraint + variables['consecutive_on_hours'] = consecutive_on_vars['duration'] + constraints += list(consecutive_on_constraints.values()) # 6. Consecutive off duration using existing pattern if self.parameters.use_consecutive_off_hours: consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( self._model, - f'{self.label_of_model}|consecutive_off', + f'{self.label_of_model}|consecutive_off_hours', variables['off'], minimum_duration=self.parameters.consecutive_off_hours_min, maximum_duration=self.parameters.consecutive_off_hours_max, previous_duration=ModelingUtilities.compute_previous_off_duration(self._previous_flow_rates, self._model.hours_per_step), ) - variables['consecutive_off_duration'] = consecutive_off_vars['duration'] - for cons_name, cons_constraint in consecutive_off_constraints.items(): - constraints[f'consecutive_off_{cons_name}'] = cons_constraint + variables['consecutive_off_hours'] = consecutive_off_vars['duration'] + constraints += list(consecutive_off_constraints.values()) # Register all constraints and variables for constraint in constraints: @@ -285,23 +298,23 @@ def off(self) -> Optional[linopy.Variable]: @property def total_on_hours(self) -> Optional[linopy.Variable]: """Total on hours variable""" - return self.get_variable_by_short_name('total_duration') + return self.get_variable_by_short_name('total_on_hours') @property def switch_on(self) -> Optional[linopy.Variable]: """Switch on variable""" - return self.get_variable_by_short_name('switch_on') + return self.get_variable_by_short_name('switch|on') @property def switch_off(self) -> Optional[linopy.Variable]: """Switch off variable""" - return self.get_variable_by_short_name('switch_off') + return self.get_variable_by_short_name('switch|off') @property def switch_on_nr(self) -> Optional[linopy.Variable]: """Number of switch-ons variable""" # This could be added to factory if needed - return None + return self.get_variable_by_short_name('switch|count') @property def consecutive_on_hours(self) -> Optional[linopy.Variable]: @@ -325,7 +338,7 @@ def add_effects(self): target='operation', ) - if self.parameters.effects_per_switch_on and self.switch_on: + if self.parameters.effects_per_switch_on: self._model.effects.add_share_to_effects( name=self.label_of_element, expressions={ diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 083ba8c54..b8e00a723 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -254,7 +254,8 @@ def expression_tracking_variable( @staticmethod def state_transition_variables( - model: FlowSystemModel, name: str, state_variable, previous_state=0 + model: FlowSystemModel, name: str, state_variable, previous_state=0, + max_count: Optional[int] = None, ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: """ Creates switch-on/off variables with state transition logic. @@ -269,27 +270,36 @@ def state_transition_variables( variables: {'switch_on': binary_var, 'switch_off': binary_var} constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint} """ - switch_on = model.add_variables(binary=True, name=f'{name}|switch_on', coords=model.get_coords(['time'])) - switch_off = model.add_variables(binary=True, name=f'{name}|switch_off', coords=model.get_coords(['time'])) + switch_on = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) + switch_off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(['time'])) # State transition constraints for t > 0 transition = model.add_constraints( switch_on.isel(time=slice(1, None)) - switch_off.isel(time=slice(1, None)) == state_variable.isel(time=slice(1, None)) - state_variable.isel(time=slice(None, -1)), - name=f'{name}|state_transition', + name=name, ) # Initial state transition for t = 0 initial = model.add_constraints( switch_on.isel(time=0) - switch_off.isel(time=0) == state_variable.isel(time=0) - previous_state, - name=f'{name}|initial_transition', + name=f'{name}|initial', ) # At most one switch per timestep - mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|switch_mutex') + mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|mutex') + + count = model.add_variables( + lower=0, + upper=max_count if max_count is not None else np.inf, + coords=model.get_coords(['year', 'scenario']), + name=f'{name}|count', + ) + + count_constraint = model.add_constraints(count == switch_on.sum('time'), name=f'{name}|count') - variables = {'switch_on': switch_on, 'switch_off': switch_off} - constraints = {'transition': transition, 'initial': initial, 'mutex': mutex} + variables = {'on': switch_on, 'off': switch_off, 'count': count} + constraints = {'transition': transition, 'initial': initial, 'mutex': mutex, 'count': count_constraint} return variables, constraints @@ -437,6 +447,7 @@ def basic_bounds( model: FlowSystemModel, variable: linopy.Variable, bounds: Tuple[TemporalData, TemporalData], + name: str = None, ): """Create simple bounds. variable ∈ [lower_bound, upper_bound] @@ -455,9 +466,10 @@ def basic_bounds( - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb' """ lower_bound, upper_bound = bounds + name = name or f'{variable.name}' - upper_constraint = model.add_constraints(variable <= upper_bound, name=f'{variable.name}|ub') - lower_constraint = model.add_constraints(variable >= lower_bound, name=f'{variable.name}|lb') + upper_constraint = model.add_constraints(variable <= upper_bound, name=f'{name}|ub') + lower_constraint = model.add_constraints(variable >= lower_bound, name=f'{name}|lb') return [lower_constraint, upper_constraint] @@ -467,6 +479,7 @@ def bounds_with_state( variable: linopy.Variable, bounds: Tuple[TemporalData, TemporalData], variable_state: linopy.Variable, + name: str = None, ) -> List[linopy.Constraint]: """Constraint a variable to bounds, that can be escaped from to 0 by a binary variable. variable ∈ {0, [max(ε, lower_bound), upper_bound]} @@ -490,17 +503,18 @@ def bounds_with_state( - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb' """ lower_bound, upper_bound = bounds + name = name or f'{variable.name}' if np.all(lower_bound - upper_bound) < 1e-10: fix_constraint = model.add_constraints( - variable == variable_state * upper_bound, name=f'{variable.name}|fix' + variable == variable_state * upper_bound, name=f'{name}|fix' ) return [fix_constraint] epsilon = np.maximum(CONFIG.modeling.EPSILON, lower_bound) - upper_constraint = model.add_constraints(variable <= variable_state * upper_bound, name=f'{variable.name}|ub') - lower_constraint = model.add_constraints(variable >= variable_state * epsilon, name=f'{variable.name}|lb') + upper_constraint = model.add_constraints(variable <= variable_state * upper_bound, name=f'{name}|ub') + lower_constraint = model.add_constraints(variable >= variable_state * epsilon, name=f'{name}|lb') return [lower_constraint, upper_constraint] @@ -510,6 +524,7 @@ def scaled_bounds( variable: linopy.Variable, scaling_variable: linopy.Variable, relative_bounds: Tuple[TemporalData, TemporalData], + name: str = None, ) -> List[linopy.Constraint]: """Constraint a variable by scaling bounds, dependent on another variable. variable ∈ [lower_bound * scaling_variable, upper_bound * scaling_variable] @@ -533,9 +548,10 @@ def scaled_bounds( - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb' """ rel_lower, rel_upper = relative_bounds + name = name or f'{variable.name}' - upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{variable.name}|ub') - lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=f'{variable.name}|lb') + upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{name}|ub') + lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=f'{name}|lb') return [lower_constraint, upper_constraint] @@ -547,62 +563,51 @@ def scaled_bounds_with_state( relative_bounds: Tuple[TemporalData, TemporalData], scaling_bounds: Tuple[TemporalData, TemporalData], variable_state: linopy.Variable, - scaling_state: linopy.Variable, + name: str = None, ) -> List[linopy.Constraint]: - """Constraint a variable by scaling bounds, dependent on another variable. - The bounds only apply if variable_state is 1. + """Constraint a variable by scaling bounds with binary state control. - variable ∈ {0, - [max(ε, lower_relative_bound) * scaling_variable, upper_relative_bound * scaling_variable] - } + variable ∈ {0, [max(ε, lower_relative_bound) * scaling_variable, upper_relative_bound * scaling_variable]} Mathematical Formulation (Big-M): - scaling_variable * lower_factor ≤ variable ≤ scaling_variable * upper_factor - variable ≤ variable_state * M_upper - variable ≥ variable_state * M_lower - - Where: M_upper = scaling_max * upper_factor, M_lower = max(ε, scaling_min * lower_factor) + (variable_state - 1) * M_misc + scaling_variable * rel_lower ≤ variable ≤ scaling_variable * rel_upper + variable_state * big_m_lower ≤ variable ≤ variable_state * big_m_upper - Use Cases: - - Equipment with capacity and on/off control - - Variable-size units with operational states + Where: + M_misc = scaling_max * rel_lower + big_m_upper = scaling_max * rel_upper + big_m_lower = max(ε, scaling_min * rel_lower) Args: model: The optimization model instance variable: Variable to be bounded scaling_variable: Variable that scales the bound factors relative_bounds: Tuple of (lower_factor, upper_factor) relative to scaling variable - variable_state: Binary variable for on/off control scaling_bounds: Tuple of (scaling_min, scaling_max) bounds of the scaling variable + variable_state: Binary variable for on/off control + name: Optional name prefix for constraints Returns: - Tuple containing: - - variables (Dict): Empty dict - - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb', 'binary_upper', 'binary_lower' + List[linopy.Constraint]: List of constraint objects """ - rel_lower, rel_upper = relative_bounds scaling_min, scaling_max = scaling_bounds + name = name or f'{variable.name}' - big_m_upper = scaling_max * rel_upper - big_m_lower = np.maximum(CONFIG.modeling.EPSILON, scaling_min * rel_lower) + big_m_misc = scaling_max * rel_lower - _, constraints = BoundingPatterns.bounds_with_state( - model, - variable=scaling_variable, - bounds=scaling_bounds, - variable_state=scaling_state, + scaling_lower = model.add_constraints( + variable >= (variable_state - 1) * big_m_misc + scaling_variable * rel_lower, name=f'{name}|lb2' ) - scaling_upper = model.add_constraints( - variable <= scaling_variable * rel_upper, name=f'{variable.name}|ub' + variable <= scaling_variable * rel_upper, name=f'{name}|ub2' ) - binary_upper = model.add_constraints(variable <= variable_state * big_m_upper, name=f'{variable_state.name}|ub') - scaling_lower = model.add_constraints( - variable >= scaling_variable * rel_lower, name=f'{variable.name}|lb' - ) - binary_lower = model.add_constraints(variable >= variable_state * big_m_lower, name=f'{variable_state.name}|lb') + big_m_upper = scaling_max * rel_upper + big_m_lower = np.maximum(CONFIG.modeling.EPSILON, scaling_min * rel_lower) + + binary_upper = model.add_constraints(variable_state * big_m_upper >= variable, name=f'{name}|ub1') + binary_lower = model.add_constraints(variable_state * big_m_lower <= variable, name=f'{name}|lb1') return [scaling_lower, scaling_upper, binary_lower, binary_upper] From 5861b281e11789333ea1a0f77fc28f7cb4ad1475 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 19 Jul 2025 19:14:13 +0200 Subject: [PATCH 21/51] Fix InvestmentModel --- flixopt/features.py | 62 +++++++++++++++------------------------------ 1 file changed, 21 insertions(+), 41 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index 9ec91fec6..5a0ce6cf1 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -67,58 +67,39 @@ def create_variables_and_constraints(self): 'size', ) - if self._state_variable is None and not self.parameters.optional: + if self.parameters.optional: + is_invested = self.add( + self._model.add_variables( + binary=True, name=f'{self.label_of_model}|is_invested', coords=self._model.get_coords(['year', 'scenario']) + ), + 'is_invested', + ) + constraints += BoundingPatterns.bounds_with_state( + self._model, + variable=size, + variable_state=is_invested, + bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), + ) + + if self._state_variable is None: constraints += BoundingPatterns.scaled_bounds( self._model, variable=self._defining_variable, scaling_variable=size, relative_bounds=self._relative_bounds_of_defining_variable, - name=self._defining_variable.name if self._state_variable is None else f'{self._defining_variable.name}_state', ) - elif self._state_variable is not None and not self.parameters.optional: - constraints += BoundingPatterns.bounds_with_state( + else: + constraints += BoundingPatterns.scaled_bounds_with_state( self._model, variable=self._defining_variable, variable_state=self._state_variable, - bounds=self._relative_bounds_of_defining_variable, - name=self._defining_variable.name if self._state_variable is None else f'{self._defining_variable.name}_state', - ) - - elif self.parameters.optional: - is_invested = self.add( - self._model.add_variables( - binary=True, name=f'{self.label_of_model}|is_invested', coords=self._model.get_coords(['year', 'scenario']) - ), - 'is_invested', + scaling_variable=size, + relative_bounds=self._relative_bounds_of_defining_variable, + scaling_bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), + name=f'{self.label_of_model}|size+on', ) - if self._state_variable is None: - constraints += BoundingPatterns.bounds_with_state( - self._model, - variable=size, - variable_state=is_invested, - bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), - ) - - constraints += BoundingPatterns.scaled_bounds( - self._model, - variable=self._defining_variable, - scaling_variable=size, - relative_bounds=self._relative_bounds_of_defining_variable, - ) - - else: - constraints += BoundingPatterns.scaled_bounds_with_state( - self._model, - variable=self._defining_variable, - variable_state=self._state_variable, - scaling_variable=size, - relative_bounds=self._relative_bounds_of_defining_variable, - scaling_bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), - name=f'{self.label_of_model}|size+on', - ) - # Register constraints for constraint in constraints: self.add(constraint) @@ -229,7 +210,6 @@ def create_variables_and_constraints(self): variable=flow_rate, bounds=(lower_bound, upper_bound), variable_state=variables['on'], - name=flow_rate.name, ) # 3. Total duration tracking using existing pattern From 7809ee4119f8e607797a45d9e7b1f917c8f58ebd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 19 Jul 2025 22:00:29 +0200 Subject: [PATCH 22/51] Fix Models --- flixopt/elements.py | 19 ++++++++++++++++++- flixopt/features.py | 41 +++++++++++++++++------------------------ flixopt/modeling.py | 3 +++ 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 09fd07fe9..4fcce79a2 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -15,6 +15,7 @@ from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, ModelingPatterns, ModelingPrimitives from .interface import InvestParameters, OnOffParameters from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io +from .modeling import BoundingPatterns if TYPE_CHECKING: from .flow_system import FlowSystem @@ -328,6 +329,8 @@ def do_modeling(self): 'flow_rate', ) + default_cons = not (self.element.on_off_parameters is not None and isinstance(self.element.size, InvestParameters)) + # OnOff feature if self.element.on_off_parameters is not None: self.on_off: OnOffModel = self.add( @@ -339,6 +342,7 @@ def do_modeling(self): flow_rate_bounds=[self.flow_rate_bounds_on], previous_flow_rates=[self.element.previous_flow_rate], label_of_model=self.label_of_element, + apply_bounds_to_flow_rates=default_cons, ), 'on_off', ) @@ -357,12 +361,25 @@ def do_modeling(self): self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative, ), - state_variable=self.on_off.on if self.on_off is not None else None, + apply_bounds_to_flow_rates=default_cons, ), 'investment', ) self._investment.do_modeling() + if not default_cons: + constraints = BoundingPatterns.scaled_bounds_with_state( + model=self._model, + variable=self.flow_rate, + scaling_variable=self._investment.size, + relative_bounds=(self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative), + scaling_bounds=(self.element.size.minimum_or_fixed_size, self.element.size.maximum_or_fixed_size), + variable_state=self.on_off.on, + ) + + for constraint in constraints: + self.add(constraint) + # Total flow hours tracking (could use factory pattern) variables, constraints = ModelingPrimitives.expression_tracking_variable( model=self._model, diff --git a/flixopt/features.py b/flixopt/features.py index 5a0ce6cf1..916defa4f 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -29,7 +29,7 @@ def __init__( defining_variable: linopy.Variable, relative_bounds_of_defining_variable: Tuple[TemporalData, TemporalData], label_of_model: Optional[str] = None, - state_variable: Optional[linopy.Variable] = None, + apply_bounds_to_defining_variable: bool = True, ): """ This feature model is used to model the investment of a variable. @@ -42,13 +42,13 @@ def __init__( defining_variable: The variable to be invested relative_bounds_of_defining_variable: The bounds of the variable, with respect to the minimum/maximum investment sizes label_of_model: The label of the model. This is needed to construct the full label of the model. - state_variable: The variable tracking the state of the variable + """ super().__init__(model, label_of_element=label_of_element, parameters=parameters, label_of_model=label_of_model) self._defining_variable = defining_variable self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable - self._state_variable = state_variable + self._apply_bounds_to_defining_variable = apply_bounds_to_defining_variable # Only keep non-variable attributes self.scenario_of_investment: Optional[linopy.Variable] = None @@ -81,23 +81,12 @@ def create_variables_and_constraints(self): bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) - if self._state_variable is None: + if self._apply_bounds_to_defining_variable: constraints += BoundingPatterns.scaled_bounds( self._model, variable=self._defining_variable, - scaling_variable=size, - relative_bounds=self._relative_bounds_of_defining_variable, - ) - - else: - constraints += BoundingPatterns.scaled_bounds_with_state( - self._model, - variable=self._defining_variable, - variable_state=self._state_variable, - scaling_variable=size, + scaling_variable=self.size, relative_bounds=self._relative_bounds_of_defining_variable, - scaling_bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), - name=f'{self.label_of_model}|size+on', ) # Register constraints @@ -174,6 +163,7 @@ def __init__( flow_rate_bounds: List[Tuple[TemporalData, TemporalData]], previous_flow_rates: List[Optional[TemporalData]], label_of_model: Optional[str] = None, + apply_bounds_to_flow_rates: bool = True, ): """ This feature model is used to model the on/off state of flow_rate(s). It does not matter of the flow_rates are @@ -192,6 +182,7 @@ def __init__( self._flow_rates = flow_rates self._flow_rate_bounds = flow_rate_bounds self._previous_flow_rates = previous_flow_rates + self._apply_bounds_to_flow_rates = apply_bounds_to_flow_rates def create_variables_and_constraints(self): variables = {} @@ -203,14 +194,16 @@ def create_variables_and_constraints(self): constraints += list(state_constraints.values()) # 2. Control variables - use big_m_binary_bounds pattern for consistency - for i, (flow_rate, (lower_bound, upper_bound)) in enumerate(zip(self._flow_rates, self._flow_rate_bounds)): - # TODO: Add suffix options - constraints += BoundingPatterns.bounds_with_state( - model=self._model, - variable=flow_rate, - bounds=(lower_bound, upper_bound), - variable_state=variables['on'], - ) + if self._apply_bounds_to_flow_rates: + for i, (flow_rate, flow_rate_bounds) in enumerate( + zip(self._flow_rates, self._flow_rate_bounds, strict=True) + ): + constraints += BoundingPatterns.bounds_with_state( + model=self._model, + variable=flow_rate, + bounds=flow_rate_bounds, + variable_state=variables['on'], + ) # 3. Total duration tracking using existing pattern duration_expr = (variables['on'] * self._model.hours_per_step).sum('time') diff --git a/flixopt/modeling.py b/flixopt/modeling.py index b8e00a723..98fc65756 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -550,6 +550,9 @@ def scaled_bounds( rel_lower, rel_upper = relative_bounds name = name or f'{variable.name}' + if np.abs(rel_lower - rel_upper).all() < 10e-10: + return [model.add_constraints(variable == scaling_variable * rel_lower, name=f'{name}|fixed')] + upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{name}|ub') lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=f'{name}|lb') From 2bbdb4483f2db250d582a158ad5fb25185b70336 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 19 Jul 2025 22:03:29 +0200 Subject: [PATCH 23/51] Update constraint names in test --- tests/test_flow.py | 282 +++++++++++++++++++++++---------------------- 1 file changed, 145 insertions(+), 137 deletions(-) diff --git a/tests/test_flow.py b/tests/test_flow.py index 9038af1c7..5b99a79f2 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -143,8 +143,8 @@ def test_flow_invest(self, basic_flow_system_linopy): assert set(flow.model.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|flow_rate|upper_bound', - 'Sink(Wärme)|flow_rate|lower_bound', + 'Sink(Wärme)|flow_rate|ub', + 'Sink(Wärme)|flow_rate|lb', ] ) @@ -161,13 +161,13 @@ def test_flow_invest(self, basic_flow_system_linopy): ), ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lower_bound'], + model.constraints['Sink(Wärme)|flow_rate|lb'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|upper_bound'], + model.constraints['Sink(Wärme)|flow_rate|ub'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), @@ -194,10 +194,10 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): assert set(flow.model.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|size|lower_bound', - 'Sink(Wärme)|size|upper_bound', - 'Sink(Wärme)|flow_rate|lower_bound', - 'Sink(Wärme)|flow_rate|upper_bound', + 'Sink(Wärme)|size|lb', + 'Sink(Wärme)|size|ub', + 'Sink(Wärme)|flow_rate|lb', + 'Sink(Wärme)|flow_rate|ub', ] ) @@ -215,13 +215,13 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): ), ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lower_bound'], + model.constraints['Sink(Wärme)|flow_rate|lb'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|upper_bound'], + model.constraints['Sink(Wärme)|flow_rate|ub'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), @@ -229,11 +229,11 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): # Is invested assert_conequal( - model.constraints['Sink(Wärme)|size|upper_bound'], + model.constraints['Sink(Wärme)|size|ub'], flow.model.variables['Sink(Wärme)|size'] <= flow.model.variables['Sink(Wärme)|is_invested'] * 100, ) assert_conequal( - model.constraints['Sink(Wärme)|size|lower_bound'], + model.constraints['Sink(Wärme)|size|lb'], flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 20, ) @@ -258,10 +258,10 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): assert set(flow.model.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|size|upper_bound', - 'Sink(Wärme)|size|lower_bound', - 'Sink(Wärme)|flow_rate|lower_bound', - 'Sink(Wärme)|flow_rate|upper_bound', + 'Sink(Wärme)|size|ub', + 'Sink(Wärme)|size|lb', + 'Sink(Wärme)|flow_rate|lb', + 'Sink(Wärme)|flow_rate|ub', ] ) @@ -279,13 +279,13 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): ), ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lower_bound'], + model.constraints['Sink(Wärme)|flow_rate|lb'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|upper_bound'], + model.constraints['Sink(Wärme)|flow_rate|ub'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), @@ -293,11 +293,11 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): # Is invested assert_conequal( - model.constraints['Sink(Wärme)|size|upper_bound'], + model.constraints['Sink(Wärme)|size|ub'], flow.model.variables['Sink(Wärme)|size'] <= flow.model.variables['Sink(Wärme)|is_invested'] * 100, ) assert_conequal( - model.constraints['Sink(Wärme)|size|lower_bound'], + model.constraints['Sink(Wärme)|size|lb'], flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 1e-5, ) @@ -322,8 +322,8 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): assert set(flow.model.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|flow_rate|lower_bound', - 'Sink(Wärme)|flow_rate|upper_bound', + 'Sink(Wärme)|flow_rate|lb', + 'Sink(Wärme)|flow_rate|ub', ] ) @@ -339,13 +339,13 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): ), ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lower_bound'], + model.constraints['Sink(Wärme)|flow_rate|lb'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|upper_bound'], + model.constraints['Sink(Wärme)|flow_rate|ub'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), @@ -466,8 +466,8 @@ def test_flow_on(self, basic_flow_system_linopy): [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|on_hours_total', - 'Sink(Wärme)|on_con1', - 'Sink(Wärme)|on_con2', + 'Sink(Wärme)|flow_rate|lb', + 'Sink(Wärme)|flow_rate|ub', ] ) # flow_rate @@ -490,12 +490,12 @@ def test_flow_on(self, basic_flow_system_linopy): model.add_variables(lower=0), ) assert_conequal( - model.constraints['Sink(Wärme)|on_con1'], - flow.model.variables['Sink(Wärme)|on'] * 0.2 * 100 <= flow.model.variables['Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|lb'], + flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|on'] * 0.2 * 100, ) assert_conequal( - model.constraints['Sink(Wärme)|on_con2'], - flow.model.variables['Sink(Wärme)|on'] * 0.8 * 100 >= flow.model.variables['Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|ub'], + flow.model.variables['Sink(Wärme)|flow_rate']<= flow.model.variables['Sink(Wärme)|on'] * 0.8 * 100, ) assert_conequal( @@ -530,8 +530,8 @@ def test_effects_per_running_hour(self, basic_flow_system_linopy): } assert set(flow.model.constraints) == { 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|on_con1', - 'Sink(Wärme)|on_con2', + 'Sink(Wärme)|flow_rate|lb', + 'Sink(Wärme)|flow_rate|ub', 'Sink(Wärme)|on_hours_total', } @@ -568,51 +568,51 @@ def test_consecutive_on_hours(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|ConsecutiveOn|hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) - assert {'Sink(Wärme)|ConsecutiveOn|con1', - 'Sink(Wärme)|ConsecutiveOn|con2a', - 'Sink(Wärme)|ConsecutiveOn|con2b', - 'Sink(Wärme)|ConsecutiveOn|initial', - 'Sink(Wärme)|ConsecutiveOn|minimum', + assert {'Sink(Wärme)|consecutive_on_hours|ub', + 'Sink(Wärme)|consecutive_on_hours|forward', + 'Sink(Wärme)|consecutive_on_hours|backward', + 'Sink(Wärme)|consecutive_on_hours|initial', + 'Sink(Wärme)|consecutive_on_hours|lb', }.issubset(set(flow.model.constraints)) assert_var_equal( - model.variables['Sink(Wärme)|ConsecutiveOn|hours'], + model.variables['Sink(Wärme)|consecutive_on_hours'], model.add_variables(lower=0, upper=8, coords=(timesteps,)) ) mega = model.hours_per_step.sum('time') assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|con1'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'] <= model.variables['Sink(Wärme)|on'] * mega + model.constraints['Sink(Wärme)|consecutive_on_hours|ub'], + model.variables['Sink(Wärme)|consecutive_on_hours'] <= model.variables['Sink(Wärme)|on'] * mega ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|con2a'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|consecutive_on_hours|forward'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) ) # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|con2b'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(1, None)) - >= model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|consecutive_on_hours|backward'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + (model.variables['Sink(Wärme)|on'].isel(time=slice(1, None)) - 1) * mega ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|initial'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=0) + model.constraints['Sink(Wärme)|consecutive_on_hours|initial'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=0) == model.variables['Sink(Wärme)|on'].isel(time=0) * model.hours_per_step.isel(time=0), ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|minimum'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'] + model.constraints['Sink(Wärme)|consecutive_on_hours|lb'], + model.variables['Sink(Wärme)|consecutive_on_hours'] >= (model.variables['Sink(Wärme)|on'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|on'].isel(time=slice(1, None))) * 2 ) @@ -635,51 +635,51 @@ def test_consecutive_on_hours_previous(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|ConsecutiveOn|hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) - assert {'Sink(Wärme)|ConsecutiveOn|con1', - 'Sink(Wärme)|ConsecutiveOn|con2a', - 'Sink(Wärme)|ConsecutiveOn|con2b', - 'Sink(Wärme)|ConsecutiveOn|initial', - 'Sink(Wärme)|ConsecutiveOn|minimum', + assert {'Sink(Wärme)|consecutive_on_hours|lb', + 'Sink(Wärme)|consecutive_on_hours|forward', + 'Sink(Wärme)|consecutive_on_hours|backward', + 'Sink(Wärme)|consecutive_on_hours|initial', + 'Sink(Wärme)|consecutive_on_hours|lb', }.issubset(set(flow.model.constraints)) assert_var_equal( - model.variables['Sink(Wärme)|ConsecutiveOn|hours'], + model.variables['Sink(Wärme)|consecutive_on_hours'], model.add_variables(lower=0, upper=8, coords=(timesteps,)) ) mega = model.hours_per_step.sum('time') + model.hours_per_step.isel(time=0) * 3 assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|con1'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'] <= model.variables['Sink(Wärme)|on'] * mega + model.constraints['Sink(Wärme)|consecutive_on_hours|ub'], + model.variables['Sink(Wärme)|consecutive_on_hours'] <= model.variables['Sink(Wärme)|on'] * mega ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|con2a'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|consecutive_on_hours|forward'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) ) # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|con2b'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(1, None)) - >= model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|consecutive_on_hours|backward'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + (model.variables['Sink(Wärme)|on'].isel(time=slice(1, None)) - 1) * mega ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|initial'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'].isel(time=0) + model.constraints['Sink(Wärme)|consecutive_on_hours|initial'], + model.variables['Sink(Wärme)|consecutive_on_hours'].isel(time=0) == model.variables['Sink(Wärme)|on'].isel(time=0) * (model.hours_per_step.isel(time=0) * (1 + 3)), ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOn|minimum'], - model.variables['Sink(Wärme)|ConsecutiveOn|hours'] + model.constraints['Sink(Wärme)|consecutive_on_hours|lb'], + model.variables['Sink(Wärme)|consecutive_on_hours'] >= (model.variables['Sink(Wärme)|on'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|on'].isel(time=slice(1, None))) * 2 ) @@ -701,52 +701,52 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|ConsecutiveOff|hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) assert { - 'Sink(Wärme)|ConsecutiveOff|con1', - 'Sink(Wärme)|ConsecutiveOff|con2a', - 'Sink(Wärme)|ConsecutiveOff|con2b', - 'Sink(Wärme)|ConsecutiveOff|initial', - 'Sink(Wärme)|ConsecutiveOff|minimum' + 'Sink(Wärme)|consecutive_off_hours|ub', + 'Sink(Wärme)|consecutive_off_hours|forward', + 'Sink(Wärme)|consecutive_off_hours|backward', + 'Sink(Wärme)|consecutive_off_hours|initial', + 'Sink(Wärme)|consecutive_off_hours|lb' }.issubset(set(flow.model.constraints)) assert_var_equal( - model.variables['Sink(Wärme)|ConsecutiveOff|hours'], + model.variables['Sink(Wärme)|consecutive_off_hours'], model.add_variables(lower=0, upper=12, coords=(timesteps,)) ) mega = model.hours_per_step.sum('time') + model.hours_per_step.isel(time=0) * 1 # previously off for 1h assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|con1'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'] <= model.variables['Sink(Wärme)|off'] * mega + model.constraints['Sink(Wärme)|consecutive_off_hours|ub'], + model.variables['Sink(Wärme)|consecutive_off_hours'] <= model.variables['Sink(Wärme)|off'] * mega ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|con2a'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|consecutive_off_hours|forward'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) ) # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|con2b'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(1, None)) - >= model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|consecutive_off_hours|backward'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + (model.variables['Sink(Wärme)|off'].isel(time=slice(1, None)) - 1) * mega ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|initial'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=0) + model.constraints['Sink(Wärme)|consecutive_off_hours|initial'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=0) == model.variables['Sink(Wärme)|off'].isel(time=0) * (model.hours_per_step.isel(time=0) * (1 + 1)), ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|minimum'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'] + model.constraints['Sink(Wärme)|consecutive_off_hours|lb'], + model.variables['Sink(Wärme)|consecutive_off_hours'] >= (model.variables['Sink(Wärme)|off'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|off'].isel(time=slice(1, None))) * 4 ) @@ -769,52 +769,52 @@ def test_consecutive_off_hours_previous(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|ConsecutiveOff|hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) assert { - 'Sink(Wärme)|ConsecutiveOff|con1', - 'Sink(Wärme)|ConsecutiveOff|con2a', - 'Sink(Wärme)|ConsecutiveOff|con2b', - 'Sink(Wärme)|ConsecutiveOff|initial', - 'Sink(Wärme)|ConsecutiveOff|minimum' + 'Sink(Wärme)|consecutive_off_hours|ub', + 'Sink(Wärme)|consecutive_off_hours|forward', + 'Sink(Wärme)|consecutive_off_hours|backward', + 'Sink(Wärme)|consecutive_off_hours|initial', + 'Sink(Wärme)|consecutive_off_hours|lb' }.issubset(set(flow.model.constraints)) assert_var_equal( - model.variables['Sink(Wärme)|ConsecutiveOff|hours'], + model.variables['Sink(Wärme)|consecutive_off_hours'], model.add_variables(lower=0, upper=12, coords=(timesteps,)) ) mega = model.hours_per_step.sum('time') + model.hours_per_step.isel(time=0) * 2 assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|con1'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'] <= model.variables['Sink(Wärme)|off'] * mega + model.constraints['Sink(Wärme)|consecutive_off_hours|ub'], + model.variables['Sink(Wärme)|consecutive_off_hours'] <= model.variables['Sink(Wärme)|off'] * mega ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|con2a'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|consecutive_off_hours|forward'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) + <= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) ) # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|con2b'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(1, None)) - >= model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=slice(None, -1)) + model.constraints['Sink(Wärme)|consecutive_off_hours|backward'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(1, None)) + >= model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=slice(None, -1)) + model.hours_per_step.isel(time=slice(None, -1)) + (model.variables['Sink(Wärme)|off'].isel(time=slice(1, None)) - 1) * mega ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|initial'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'].isel(time=0) + model.constraints['Sink(Wärme)|consecutive_off_hours|initial'], + model.variables['Sink(Wärme)|consecutive_off_hours'].isel(time=0) == model.variables['Sink(Wärme)|off'].isel(time=0) * (model.hours_per_step.isel(time=0) * (1+2)), ) assert_conequal( - model.constraints['Sink(Wärme)|ConsecutiveOff|minimum'], - model.variables['Sink(Wärme)|ConsecutiveOff|hours'] + model.constraints['Sink(Wärme)|consecutive_off_hours|lb'], + model.variables['Sink(Wärme)|consecutive_off_hours'] >= (model.variables['Sink(Wärme)|off'].isel(time=slice(None, -1)) - model.variables['Sink(Wärme)|off'].isel(time=slice(1, None))) * 4 ) @@ -836,26 +836,26 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) # Check that variables exist - assert {'Sink(Wärme)|switch_on', 'Sink(Wärme)|switch_off', 'Sink(Wärme)|switch_on_nr'}.issubset( + assert {'Sink(Wärme)|switch|on', 'Sink(Wärme)|switch|off', 'Sink(Wärme)|switch|count'}.issubset( set(flow.model.variables) ) # Check that constraints exist assert { - 'Sink(Wärme)|switch_con', - 'Sink(Wärme)|initial_switch_con', - 'Sink(Wärme)|switch_on_or_off', - 'Sink(Wärme)|switch_on_nr', + 'Sink(Wärme)|switch', + 'Sink(Wärme)|switch|initial', + 'Sink(Wärme)|switch|mutex', + 'Sink(Wärme)|switch|count', }.issubset(set(flow.model.constraints)) # Check switch_on_nr variable bounds - assert_var_equal(flow.model.variables['Sink(Wärme)|switch_on_nr'], model.add_variables(lower=0, upper=5)) + assert_var_equal(flow.model.variables['Sink(Wärme)|switch|count'], model.add_variables(lower=0, upper=5)) # Verify switch_on_nr constraint (limits number of startups) assert_conequal( - model.constraints['Sink(Wärme)|switch_on_nr'], - flow.model.variables['Sink(Wärme)|switch_on_nr'] - == flow.model.variables['Sink(Wärme)|switch_on'].sum('time'), + model.constraints['Sink(Wärme)|switch|count'], + flow.model.variables['Sink(Wärme)|switch|count'] + == flow.model.variables['Sink(Wärme)|switch|on'].sum('time'), ) # Check that startup cost effect constraint exists @@ -864,7 +864,7 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): # Verify the startup cost effect constraint assert_conequal( model.constraints['Sink(Wärme)->Costs(operation)'], - model.variables['Sink(Wärme)->Costs(operation)'] == flow.model.variables['Sink(Wärme)|switch_on'] * 100, + model.variables['Sink(Wärme)->Costs(operation)'] == flow.model.variables['Sink(Wärme)|switch|on'] * 100, ) def test_on_hours_limits(self, basic_flow_system_linopy): @@ -933,12 +933,12 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|on_hours_total', - 'Sink(Wärme)|on_con1', - 'Sink(Wärme)|on_con2', - 'Sink(Wärme)|size|lower_bound', - 'Sink(Wärme)|size|upper_bound', - 'Sink(Wärme)|flow_rate|lower_bound', - 'Sink(Wärme)|flow_rate|upper_bound', + 'Sink(Wärme)|flow_rate|lb1', + 'Sink(Wärme)|flow_rate|ub1', + 'Sink(Wärme)|size|lb', + 'Sink(Wärme)|size|ub', + 'Sink(Wärme)|flow_rate|lb2', + 'Sink(Wärme)|flow_rate|ub2', ] ) @@ -962,11 +962,19 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): model.add_variables(lower=0), ) assert_conequal( - model.constraints['Sink(Wärme)|on_con1'], + model.constraints['Sink(Wärme)|size|lb'], + flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 20, + ) + assert_conequal( + model.constraints['Sink(Wärme)|size|ub'], + flow.model.variables['Sink(Wärme)|size']<= flow.model.variables['Sink(Wärme)|is_invested'] * 200, + ) + assert_conequal( + model.constraints['Sink(Wärme)|flow_rate|lb1'], flow.model.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.model.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( - model.constraints['Sink(Wärme)|on_con2'], + model.constraints['Sink(Wärme)|flow_rate|ub1'], flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( @@ -980,12 +988,12 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): mega = 0.2 * 200 # Relative minimum * maximum size assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lower_bound'], + model.constraints['Sink(Wärme)|flow_rate|lb2'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|on'] * mega + flow.model.variables['Sink(Wärme)|size'] * 0.2 - mega, ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|upper_bound'], + model.constraints['Sink(Wärme)|flow_rate|ub2'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8, ) @@ -1017,10 +1025,10 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|on_hours_total', - 'Sink(Wärme)|on_con1', - 'Sink(Wärme)|on_con2', - 'Sink(Wärme)|flow_rate|lower_bound', - 'Sink(Wärme)|flow_rate|upper_bound', + 'Sink(Wärme)|flow_rate|lb1', + 'Sink(Wärme)|flow_rate|ub1', + 'Sink(Wärme)|flow_rate|lb2', + 'Sink(Wärme)|flow_rate|ub2', ] ) @@ -1044,11 +1052,11 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): model.add_variables(lower=0), ) assert_conequal( - model.constraints['Sink(Wärme)|on_con1'], + model.constraints['Sink(Wärme)|flow_rate|lb1'], flow.model.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.model.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( - model.constraints['Sink(Wärme)|on_con2'], + model.constraints['Sink(Wärme)|flow_rate|ub1'], flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( @@ -1062,12 +1070,12 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): mega = 0.2 * 200 # Relative minimum * maximum size assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lower_bound'], + model.constraints['Sink(Wärme)|flow_rate|lb2'], flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|on'] * mega + flow.model.variables['Sink(Wärme)|size'] * 0.2 - mega, ) assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|upper_bound'], + model.constraints['Sink(Wärme)|flow_rate|ub2'], flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8, ) @@ -1122,7 +1130,7 @@ def test_fixed_profile_with_investment(self, basic_flow_system_linopy): # The constraint should link flow_rate to size * profile assert_conequal( - model.constraints['Sink(Wärme)|fix_Sink(Wärme)|flow_rate'], + model.constraints['Sink(Wärme)|flow_rate|fixed'], flow.model.variables['Sink(Wärme)|flow_rate'] == flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(profile, coords=(timesteps,)), ) From 2a01abe31cf6f0e2c1eaff9a40620dcb94099c30 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 19 Jul 2025 22:43:13 +0200 Subject: [PATCH 24/51] Fix OnOffModel for multiple Flows --- flixopt/elements.py | 16 ++++---- flixopt/features.py | 97 ++++++++++++++++++++++++++++++--------------- 2 files changed, 75 insertions(+), 38 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 4fcce79a2..627a4afd6 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -361,7 +361,7 @@ def do_modeling(self): self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative, ), - apply_bounds_to_flow_rates=default_cons, + apply_bounds_to_defining_variable=default_cons, ), 'investment', ) @@ -589,13 +589,15 @@ def do_modeling(self): if self.element.on_off_parameters: self.on_off = self.add( OnOffModel( - self._model, - on_off_parameters=self.element.on_off_parameters, + model=self._model, label_of_element=self.label_of_element, - defining_variables=[flow.model.flow_rate for flow in all_flows], - defining_bounds=[flow.model.flow_rate_bounds_on for flow in all_flows], - previous_values=[flow.previous_flow_rate for flow in all_flows], - ) + parameters=self.element.on_off_parameters, + flow_rates=[flow.model.flow_rate for flow in all_flows], + flow_rate_bounds=[flow.model.flow_rate_bounds_on for flow in all_flows], + previous_flow_rates=[flow.previous_flow_rate for flow in all_flows], + label_of_model=self.label_of_element, + apply_bounds_to_flow_rates=True, + ), ) self.on_off.do_modeling() diff --git a/flixopt/features.py b/flixopt/features.py index 916defa4f..5a1a91810 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -185,77 +185,112 @@ def __init__( self._apply_bounds_to_flow_rates = apply_bounds_to_flow_rates def create_variables_and_constraints(self): - variables = {} - constraints = [] - # 1. Main binary state using existing pattern state_vars, state_constraints = ModelingPrimitives.binary_state_pair(self._model, self.label_of_model, use_complement=self.parameters.use_off) - variables.update(state_vars) - constraints += list(state_constraints.values()) + for k, v in state_vars.items(): + self.add(v, k) + for k, v in state_constraints.items(): + self.add(v, k) # 2. Control variables - use big_m_binary_bounds pattern for consistency if self._apply_bounds_to_flow_rates: - for i, (flow_rate, flow_rate_bounds) in enumerate( - zip(self._flow_rates, self._flow_rate_bounds, strict=True) - ): - constraints += BoundingPatterns.bounds_with_state( - model=self._model, - variable=flow_rate, - bounds=flow_rate_bounds, - variable_state=variables['on'], - ) + self._add_defining_constraints() # 3. Total duration tracking using existing pattern - duration_expr = (variables['on'] * self._model.hours_per_step).sum('time') + duration_expr = (self.on * self._model.hours_per_step).sum('time') duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( self._model, f'{self.label_of_model}|on_hours_total', duration_expr, (self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf),#TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) ) - variables['on_hours_total'] = duration_vars['tracker'] - constraints += [duration_constraints['tracking']] + self.add(duration_vars['tracker'], 'on_hours_total') + self.add(duration_constraints['tracking']) # 4. Switch tracking using existing pattern if self.parameters.use_switch_on: switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( - self._model, f'{self.label_of_model}|switch', variables['on'], + self._model, f'{self.label_of_model}|switch', self.on, previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rates), max_count=self.parameters.switch_on_total_max, ) - variables.update({'switch|on': switch_vars['on'], 'switch|off': switch_vars['off'], 'switch|count': switch_vars['count']}) - constraints += list(switch_constraints.values()) + self.add(switch_vars['on'], 'switch|on') + self.add(switch_vars['off'], 'switch|off') + self.add(switch_vars['count'], 'switch|count') + self.add(switch_constraints['transition']) + self.add(switch_constraints['initial']) + self.add(switch_constraints['mutex']) # 5. Consecutive on duration using existing pattern if self.parameters.use_consecutive_on_hours: consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( self._model, f'{self.label_of_model}|consecutive_on_hours', #TODO: Change name - variables['on'], + self.on, minimum_duration=self.parameters.consecutive_on_hours_min, maximum_duration=self.parameters.consecutive_on_hours_max, previous_duration=ModelingUtilities.compute_previous_on_duration(self._previous_flow_rates, self._model.hours_per_step), ) - variables['consecutive_on_hours'] = consecutive_on_vars['duration'] - constraints += list(consecutive_on_constraints.values()) + self.add(consecutive_on_vars['duration'], 'consecutive_on_hours') + for constraint in consecutive_on_constraints.values(): + self.add(constraint) # 6. Consecutive off duration using existing pattern if self.parameters.use_consecutive_off_hours: consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( self._model, f'{self.label_of_model}|consecutive_off_hours', - variables['off'], + self.off, minimum_duration=self.parameters.consecutive_off_hours_min, maximum_duration=self.parameters.consecutive_off_hours_max, previous_duration=ModelingUtilities.compute_previous_off_duration(self._previous_flow_rates, self._model.hours_per_step), ) - variables['consecutive_off_hours'] = consecutive_off_vars['duration'] - constraints += list(consecutive_off_constraints.values()) + self.add(consecutive_off_vars['duration'], 'consecutive_off_hours') + for constraint in consecutive_off_constraints.values(): + self.add(constraint) - # Register all constraints and variables - for constraint in constraints: - self.add(constraint) - for variable_name, variable in variables.items(): - self.add(variable, variable_name) + def _add_defining_constraints(self): + """Add constraints that link defining variables to the on state""" + count = len(self._flow_rates) + + if count == 1: + # Case for a single defining variable + flow_rate = self._flow_rates[0] + lb, ub = self._flow_rate_bounds[0] + + # Constraint: on * lower_bound <= def_var + self.add( + self._model.add_constraints( + self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= flow_rate, name=f'{self.label_full}|on|lb' + ), + 'on|lb', + ) + + # Constraint: on * upper_bound >= def_var + self.add( + self._model.add_constraints(self.on * ub >= flow_rate, name=f'{self.label_full}|on|ub'), 'on|ub' + ) + else: + # Case for multiple defining variables + ub = sum(bound[1] for bound in self._flow_rate_bounds) / count + lb = CONFIG.modeling.EPSILON #TODO: Can this be a bigger value? (maybe the smallest bound?) + + # Constraint: on * epsilon <= sum(all_defining_variables) + self.add( + self._model.add_constraints( + self.on * lb <= sum(self._flow_rates), name=f'{self.label_full}|on|lb' + ), + 'on|lb', + ) + + # Constraint to ensure all variables are zero when off. + # Divide by count to improve numerical stability (smaller factors) + self.add( + self._model.add_constraints( + self.on * ub >= sum([def_var / count for def_var in self._flow_rates]), + name=f'{self.label_full}|on|ub', + ), + 'on|ub', + ) # Properties access variables from Model's tracking system @property From 1f1ebb702a70a1c10a11b1b975af80f16f1fc618 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 19 Jul 2025 22:43:25 +0200 Subject: [PATCH 25/51] Update constraint names in tests --- tests/test_component.py | 56 +++++++++++++++--------------- tests/test_on_hours_computation.py | 8 ++--- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/tests/test_component.py b/tests/test_component.py index 11b5385c2..fbedbd415 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -78,7 +78,7 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): flow_system.add_elements(comp) model = create_linopy_model(flow_system) - assert { + assert set(comp.model.variables) == { 'TestComponent(In1)|flow_rate', 'TestComponent(In1)|total_flow_hours', 'TestComponent(In1)|on', @@ -93,38 +93,38 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): 'TestComponent(Out2)|on_hours_total', 'TestComponent|on', 'TestComponent|on_hours_total', - } == set(comp.model.variables) + } - assert { + assert set(comp.model.constraints) == { 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|on_con1', - 'TestComponent(In1)|on_con2', + 'TestComponent(In1)|on|lb', + 'TestComponent(In1)|on|ub', 'TestComponent(In1)|on_hours_total', 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out1)|on_con1', - 'TestComponent(Out1)|on_con2', + 'TestComponent(Out1)|on|lb', + 'TestComponent(Out1)|on|ub', 'TestComponent(Out1)|on_hours_total', 'TestComponent(Out2)|total_flow_hours', - 'TestComponent(Out2)|on_con1', - 'TestComponent(Out2)|on_con2', + 'TestComponent(Out2)|on|lb', + 'TestComponent(Out2)|on|ub', 'TestComponent(Out2)|on_hours_total', - 'TestComponent|on_con1', - 'TestComponent|on_con2', + 'TestComponent|on|lb', + 'TestComponent|on|ub', 'TestComponent|on_hours_total', - } == set(comp.model.constraints) + } assert_var_equal(model['TestComponent(Out2)|flow_rate'], model.add_variables(lower=0, upper=300 * ub_out2, coords=(timesteps,))) assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords = (timesteps,))) assert_var_equal(model['TestComponent(Out2)|on'], model.add_variables(binary=True, coords=(timesteps,))) - assert_conequal(model.constraints['TestComponent(Out2)|on_con1'], model.variables['TestComponent(Out2)|on'] * 0.3 * 300 <= model.variables['TestComponent(Out2)|flow_rate']) - assert_conequal(model.constraints['TestComponent(Out2)|on_con2'], model.variables['TestComponent(Out2)|on'] * 300 * ub_out2 >= model.variables['TestComponent(Out2)|flow_rate']) + assert_conequal(model.constraints['TestComponent(Out2)|on|lb'], model.variables['TestComponent(Out2)|on'] * 0.3 * 300 <= model.variables['TestComponent(Out2)|flow_rate']) + assert_conequal(model.constraints['TestComponent(Out2)|on|ub'], model.variables['TestComponent(Out2)|on'] * 300 * ub_out2 >= model.variables['TestComponent(Out2)|flow_rate']) - assert_conequal(model.constraints['TestComponent|on_con1'], + assert_conequal(model.constraints['TestComponent|on|lb'], model.variables['TestComponent|on'] * 1e-5 <= model.variables['TestComponent(In1)|flow_rate'] + model.variables['TestComponent(Out1)|flow_rate'] + model.variables['TestComponent(Out2)|flow_rate']) # TODO: Might there be a better way to no use 1e-5? - assert_conequal(model.constraints['TestComponent|on_con2'], + assert_conequal(model.constraints['TestComponent|on|ub'], model.variables['TestComponent|on'] * (100 + 200 + 300 * ub_out2)/3 >= (model.variables['TestComponent(In1)|flow_rate'] + model.variables['TestComponent(Out1)|flow_rate'] @@ -145,24 +145,24 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): flow_system.add_elements(comp) model = create_linopy_model(flow_system) - assert { + assert set(comp.model.variables) == { 'TestComponent(In1)|flow_rate', 'TestComponent(In1)|total_flow_hours', 'TestComponent(In1)|on', 'TestComponent(In1)|on_hours_total', 'TestComponent|on', 'TestComponent|on_hours_total', - } == set(comp.model.variables) + } - assert { + assert set(comp.model.constraints) == { 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|on_con1', - 'TestComponent(In1)|on_con2', + 'TestComponent(In1)|on|lb', + 'TestComponent(In1)|on|ub', 'TestComponent(In1)|on_hours_total', - 'TestComponent|on_con1', - 'TestComponent|on_con2', + 'TestComponent|on|lb', + 'TestComponent|on|ub', 'TestComponent|on_hours_total', - } == set(comp.model.constraints) + } assert_var_equal( model['TestComponent(In1)|flow_rate'], model.add_variables(lower=0, upper=100, coords=(timesteps,)) @@ -171,20 +171,20 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): assert_var_equal(model['TestComponent(In1)|on'], model.add_variables(binary=True, coords=(timesteps,))) assert_conequal( - model.constraints['TestComponent(In1)|on_con1'], + model.constraints['TestComponent(In1)|on|lb'], model.variables['TestComponent(In1)|on'] * 0.1 * 100 <= model.variables['TestComponent(In1)|flow_rate'], ) assert_conequal( - model.constraints['TestComponent(In1)|on_con2'], + model.constraints['TestComponent(In1)|on|ub'], model.variables['TestComponent(In1)|on'] * 100 >= model.variables['TestComponent(In1)|flow_rate'], ) assert_conequal( - model.constraints['TestComponent|on_con1'], + model.constraints['TestComponent|on|lb'], model.variables['TestComponent|on'] * 0.1 * 100 <= model.variables['TestComponent(In1)|flow_rate'], ) assert_conequal( - model.constraints['TestComponent|on_con2'], + model.constraints['TestComponent|on|ub'], model.variables['TestComponent|on'] * 100 >= model.variables['TestComponent(In1)|flow_rate'], ) diff --git a/tests/test_on_hours_computation.py b/tests/test_on_hours_computation.py index a873bbd12..c8fa113aa 100644 --- a/tests/test_on_hours_computation.py +++ b/tests/test_on_hours_computation.py @@ -1,7 +1,7 @@ import numpy as np import pytest -from flixopt.features import ConsecutiveStateModel, StateModel +from flixopt.modeling import ModelingUtilities class TestComputeConsecutiveDuration: @@ -31,7 +31,7 @@ class TestComputeConsecutiveDuration: ]) def test_compute_duration(self, binary_values, hours_per_timestep, expected): """Test compute_consecutive_duration with various inputs.""" - result = ConsecutiveStateModel.compute_consecutive_hours_in_state(binary_values, hours_per_timestep) + result = ModelingUtilities.compute_consecutive_hours_in_state(binary_values, hours_per_timestep) assert np.isclose(result, expected) @pytest.mark.parametrize("binary_values, hours_per_timestep", [ @@ -41,7 +41,7 @@ def test_compute_duration(self, binary_values, hours_per_timestep, expected): def test_compute_duration_raises_error(self, binary_values, hours_per_timestep): """Test error conditions.""" with pytest.raises(TypeError): - ConsecutiveStateModel.compute_consecutive_hours_in_state(binary_values, hours_per_timestep) + ModelingUtilities.compute_consecutive_hours_in_state(binary_values, hours_per_timestep) class TestComputePreviousOnStates: @@ -76,7 +76,7 @@ class TestComputePreviousOnStates: ) def test_compute_previous_on_states(self, previous_values, expected): """Test compute_previous_on_states with various inputs.""" - result = StateModel.compute_previous_states(previous_values) + result = ModelingUtilities.compute_previous_states(previous_values) np.testing.assert_array_equal(result, expected) @pytest.mark.parametrize("previous_values, epsilon, expected", [ From c7b351fbd688ef742c1ca430c7b05a47941b872d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 20 Jul 2025 00:01:59 +0200 Subject: [PATCH 26/51] Simplify --- flixopt/elements.py | 12 ++-- flixopt/features.py | 130 +++++++++++++++---------------------------- flixopt/modeling.py | 80 ++++++++++++++------------ flixopt/structure.py | 6 ++ 4 files changed, 103 insertions(+), 125 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 627a4afd6..15d17ef92 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -338,9 +338,9 @@ def do_modeling(self): model=self._model, label_of_element=self.label_of_element, parameters=self.element.on_off_parameters, - flow_rates=[self.flow_rate], - flow_rate_bounds=[self.flow_rate_bounds_on], - previous_flow_rates=[self.element.previous_flow_rate], + flow_rate=self.flow_rate, + flow_rate_bounds=self.flow_rate_bounds_on, + previous_flow_rate=self.element.previous_flow_rate, label_of_model=self.label_of_element, apply_bounds_to_flow_rates=default_cons, ), @@ -381,7 +381,7 @@ def do_modeling(self): self.add(constraint) # Total flow hours tracking (could use factory pattern) - variables, constraints = ModelingPrimitives.expression_tracking_variable( + variable, constraint = ModelingPrimitives.expression_tracking_variable( model=self._model, name=f'{self.label_full}|total_flow_hours', tracked_expression=(self.flow_rate * self._model.hours_per_step).sum('time'), @@ -392,8 +392,8 @@ def do_modeling(self): coords=['year', 'scenario'], ) - self.add(variables['tracker'], 'total_flow_hours') - self.add(constraints['tracking'], 'total_flow_hours_tracking') + self.add(variable, 'total_flow_hours') + self.add(constraint, 'total_flow_hours_tracking') # Load factor constraints self._create_bounds_for_load_factor() diff --git a/flixopt/features.py b/flixopt/features.py index 5a1a91810..dab25b49b 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -159,9 +159,9 @@ def __init__( model: FlowSystemModel, label_of_element: str, parameters: OnOffParameters, - flow_rates: List[linopy.Variable], - flow_rate_bounds: List[Tuple[TemporalData, TemporalData]], - previous_flow_rates: List[Optional[TemporalData]], + flow_rate: linopy.Variable, + flow_rate_bounds: Tuple[TemporalData, TemporalData], + previous_flow_rate: Optional[TemporalData], label_of_model: Optional[str] = None, apply_bounds_to_flow_rates: bool = True, ): @@ -173,53 +173,59 @@ def __init__( model: The optimization model instance label_of_element: The label of the parent (Element). Used to construct the full label of the model. parameters: The parameters of the feature model. - flow_rates: The flow_rates to be modeled + flow_rate: The flow_rates to be modeled flow_rate_bounds: The bounds of the flow_rates, with respect to the minimum/maximum investment sizes - previous_flow_rates: The previous flow_rates + previous_flow_rate: The previous flow_rates label_of_model: The label of the model. This is needed to construct the full label of the model. """ super().__init__(model, label_of_element, parameters=parameters, label_of_model=label_of_model) - self._flow_rates = flow_rates + self._flow_rate = flow_rate self._flow_rate_bounds = flow_rate_bounds - self._previous_flow_rates = previous_flow_rates + self._previous_flow_rate = previous_flow_rate self._apply_bounds_to_flow_rates = apply_bounds_to_flow_rates def create_variables_and_constraints(self): # 1. Main binary state using existing pattern - state_vars, state_constraints = ModelingPrimitives.binary_state_pair(self._model, self.label_of_model, use_complement=self.parameters.use_off) - for k, v in state_vars.items(): - self.add(v, k) - for k, v in state_constraints.items(): - self.add(v, k) + on = self.add(self._model.add_variables(binary=True, name=f'{self.label_of_model}|on', coords=self._model.get_coords()), 'on') + if self.parameters.use_off: + off = self.add(self._model.add_variables(binary=True, name=f'{self.label_of_model}|off', coords=self._model.get_coords()), 'off') + self.add(self._model.add_constraints(on + off == 1, name=f'{self.label_of_model}|complementary'), 'complementary') - # 2. Control variables - use big_m_binary_bounds pattern for consistency + # 2. Control variables if self._apply_bounds_to_flow_rates: - self._add_defining_constraints() + self.add_batch(*BoundingPatterns.bounds_with_state( + self._model, + variable=self._flow_rate, + bounds=self._flow_rate_bounds, + variable_state=self.on, + )) # 3. Total duration tracking using existing pattern duration_expr = (self.on * self._model.hours_per_step).sum('time') - duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( + var, con = ModelingPrimitives.expression_tracking_variable( self._model, f'{self.label_of_model}|on_hours_total', duration_expr, (self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf),#TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) ) - self.add(duration_vars['tracker'], 'on_hours_total') - self.add(duration_constraints['tracking']) + self.add(var, 'on_hours_total') + self.add(con) # 4. Switch tracking using existing pattern if self.parameters.use_switch_on: switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( self._model, f'{self.label_of_model}|switch', self.on, - previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rates), - max_count=self.parameters.switch_on_total_max, + previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rate), ) self.add(switch_vars['on'], 'switch|on') self.add(switch_vars['off'], 'switch|off') - self.add(switch_vars['count'], 'switch|count') self.add(switch_constraints['transition']) self.add(switch_constraints['initial']) self.add(switch_constraints['mutex']) + if self.parameters.switch_on_total_max is not None: + count = self.add(self._model.add_variables(lower=0, upper=self.parameters.switch_on_total_max, coords=self._model.get_coords(('year', 'scenario')), name=f'{self.label_of_model}|switch|count'), 'switch|count') + self.add(self._model.add_constraints(count == self.switch_on.sum('time'), name=f'{self.label_of_model}|switch|count'), 'switch|count') + # 5. Consecutive on duration using existing pattern if self.parameters.use_consecutive_on_hours: consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( @@ -228,7 +234,7 @@ def create_variables_and_constraints(self): self.on, minimum_duration=self.parameters.consecutive_on_hours_min, maximum_duration=self.parameters.consecutive_on_hours_max, - previous_duration=ModelingUtilities.compute_previous_on_duration(self._previous_flow_rates, self._model.hours_per_step), + previous_duration=ModelingUtilities.compute_previous_on_duration([self._previous_flow_rate], self._model.hours_per_step), ) self.add(consecutive_on_vars['duration'], 'consecutive_on_hours') for constraint in consecutive_on_constraints.values(): @@ -242,54 +248,31 @@ def create_variables_and_constraints(self): self.off, minimum_duration=self.parameters.consecutive_off_hours_min, maximum_duration=self.parameters.consecutive_off_hours_max, - previous_duration=ModelingUtilities.compute_previous_off_duration(self._previous_flow_rates, self._model.hours_per_step), + previous_duration=ModelingUtilities.compute_previous_off_duration([self._previous_flow_rate], self._model.hours_per_step), ) self.add(consecutive_off_vars['duration'], 'consecutive_off_hours') for constraint in consecutive_off_constraints.values(): self.add(constraint) - def _add_defining_constraints(self): - """Add constraints that link defining variables to the on state""" - count = len(self._flow_rates) - - if count == 1: - # Case for a single defining variable - flow_rate = self._flow_rates[0] - lb, ub = self._flow_rate_bounds[0] - - # Constraint: on * lower_bound <= def_var - self.add( - self._model.add_constraints( - self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= flow_rate, name=f'{self.label_full}|on|lb' - ), - 'on|lb', - ) - - # Constraint: on * upper_bound >= def_var - self.add( - self._model.add_constraints(self.on * ub >= flow_rate, name=f'{self.label_full}|on|ub'), 'on|ub' - ) - else: - # Case for multiple defining variables - ub = sum(bound[1] for bound in self._flow_rate_bounds) / count - lb = CONFIG.modeling.EPSILON #TODO: Can this be a bigger value? (maybe the smallest bound?) - - # Constraint: on * epsilon <= sum(all_defining_variables) - self.add( - self._model.add_constraints( - self.on * lb <= sum(self._flow_rates), name=f'{self.label_full}|on|lb' - ), - 'on|lb', + def add_effects(self): + """Add operational effects""" + if self.parameters.effects_per_running_hour: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.on * factor * self._model.hours_per_step + for effect, factor in self.parameters.effects_per_running_hour.items() + }, + target='operation', ) - # Constraint to ensure all variables are zero when off. - # Divide by count to improve numerical stability (smaller factors) - self.add( - self._model.add_constraints( - self.on * ub >= sum([def_var / count for def_var in self._flow_rates]), - name=f'{self.label_full}|on|ub', - ), - 'on|ub', + if self.parameters.effects_per_switch_on: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.switch_on * factor for effect, factor in self.parameters.effects_per_switch_on.items() + }, + target='operation', ) # Properties access variables from Model's tracking system @@ -334,30 +317,9 @@ def consecutive_off_hours(self) -> Optional[linopy.Variable]: """Consecutive off hours variable""" return self.get_variable_by_short_name('consecutive_off_hours') - def add_effects(self): - """Add operational effects""" - if self.parameters.effects_per_running_hour: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.on * factor * self._model.hours_per_step - for effect, factor in self.parameters.effects_per_running_hour.items() - }, - target='operation', - ) - - if self.parameters.effects_per_switch_on: - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={ - effect: self.switch_on * factor for effect, factor in self.parameters.effects_per_switch_on.items() - }, - target='operation', - ) - def _get_previous_on_duration(self): hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] - return ModelingUtilities.compute_previous_on_duration(self._previous_flow_rates, hours_per_step) + return ModelingUtilities.compute_previous_on_duration([self._previous_flow_rate], hours_per_step) def _get_previous_off_duration(self): hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 98fc65756..8c539caa2 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -151,31 +151,23 @@ class ModelingPrimitives: @staticmethod def binary_state_pair( model: FlowSystemModel, name: str, coords: List[str] = None, use_complement: bool = True - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + ) -> Tuple[Tuple[linopy.Variable, linopy.Variable], linopy.Constraint]: """ Creates complementary binary variables with completeness constraint. Mathematical formulation: on[t] + off[t] = 1 ∀t on[t], off[t] ∈ {0, 1} - - Returns: - variables: {'on': binary_var, 'off': binary_var} - constraints: {'complementary': constraint} """ coords = coords or ['time'] on = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(coords)) - if use_complement: - off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(coords)) + off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(coords)) - # Constraint: on + off = 1 - complementary = model.add_constraints(on + off == 1, name=f'{name}|complementary') + # Constraint: on + off = 1 + complementary = model.add_constraints(on + off == 1, name=f'{name}|complementary') - variables = {'on': on, 'off': off} - constraints = {'complementary': complementary} - return variables, constraints - return {'on': on}, {} + return (on, off), complementary @staticmethod def proportionally_bounded_variable( @@ -220,7 +212,7 @@ def expression_tracking_variable( tracked_expression, bounds: Tuple[TemporalData, TemporalData] = None, coords: List[str] = None, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + ) -> Tuple[linopy.Variable, linopy.Constraint]: """ Creates variable that equals a given expression. @@ -247,15 +239,14 @@ def expression_tracking_variable( # Constraint: tracker = expression tracking = model.add_constraints(tracker == tracked_expression, name=f'{name}') - variables = {'tracker': tracker} - constraints = {'tracking': tracking} - - return variables, constraints + return tracker, tracking @staticmethod def state_transition_variables( - model: FlowSystemModel, name: str, state_variable, previous_state=0, - max_count: Optional[int] = None, + model: FlowSystemModel, + name: str, + state_variable: linopy.Variable, + previous_state=0, ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: """ Creates switch-on/off variables with state transition logic. @@ -289,19 +280,41 @@ def state_transition_variables( # At most one switch per timestep mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|mutex') + return {'on': switch_on, 'off': switch_off}, {'transition': transition, 'initial': initial, 'mutex': mutex} + + @staticmethod + def sum_up_variable( + model: FlowSystemModel, + variable_to_count: linopy.Variable, + name: str, + bounds: Tuple[NonTemporalData, NonTemporalData] = None, + factor: TemporalData = 1, + ) -> Tuple[linopy.Variable, linopy.Constraint]: + """ + SUms up a variable over time, applying a factor to the variable. + + Args: + model: The optimization model instance + variable_to_count: The variable to be summed up + name: The name of the constraint + bounds: The bounds of the constraint + factor: The factor to be applied to the variable + """ + if bounds is None: + bounds = (0, np.inf) + else: + bounds = (bounds[0] if bounds[0] is not None else 0, bounds[1] if bounds[1] is not None else np.inf) + count = model.add_variables( - lower=0, - upper=max_count if max_count is not None else np.inf, + lower=bounds[0], + upper=bounds[1], coords=model.get_coords(['year', 'scenario']), - name=f'{name}|count', + name=name, ) - count_constraint = model.add_constraints(count == switch_on.sum('time'), name=f'{name}|count') + count_constraint = model.add_constraints(count == (variable_to_count * factor).sum('time'), name=name) - variables = {'on': switch_on, 'off': switch_off, 'count': count} - constraints = {'transition': transition, 'initial': initial, 'mutex': mutex, 'count': count_constraint} - - return variables, constraints + return count, count_constraint @staticmethod def consecutive_duration_tracking( @@ -397,8 +410,8 @@ def consecutive_duration_tracking( @staticmethod def mutual_exclusivity_constraint( - model: FlowSystemModel, name: str, binary_variables: List[linopy.Variable], tolerance: float = 1.1 - ) -> Tuple[Dict, Dict[str, linopy.Constraint]]: + model: FlowSystemModel, name: str, binary_variables: List[linopy.Variable], tolerance: float = 1 + ) -> linopy.Constraint: """ Creates mutual exclusivity constraint for binary variables. @@ -410,7 +423,7 @@ def mutual_exclusivity_constraint( Args: binary_variables: List of binary variables that should be mutually exclusive - tolerance: Upper bound (typically 1.1 for numerical stability) + tolerance: Upper bound Returns: variables: {} (no new variables created) @@ -433,10 +446,7 @@ def mutual_exclusivity_constraint( sum(binary_variables) <= tolerance, name=f'{name}|mutual_exclusivity' ) - variables = {} # No new variables created - constraints = {'mutual_exclusivity': mutual_exclusivity} - - return variables, constraints + return mutual_exclusivity class BoundingPatterns: diff --git a/flixopt/structure.py b/flixopt/structure.py index ec594ca6e..0997a8093 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -750,6 +750,12 @@ def add( ) return item + def add_batch(self, *con_or_var: Union[linopy.Constraint, linopy.Variable]) -> None: + """Add constraints to the model""" + con_or_var = list(con_or_var) + for c_o_v in con_or_var: + self.add(c_o_v) + def filter_variables( self, filter_by: Optional[Literal['binary', 'continuous', 'integer']] = None, From 5d9b591498be73e011e8ef24e61397f97821261d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 20 Jul 2025 00:18:42 +0200 Subject: [PATCH 27/51] Improve handling of vars/cons and models --- flixopt/structure.py | 84 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 73 insertions(+), 11 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 0997a8093..49ef38db8 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -721,41 +721,103 @@ def __init__( self._sub_models_short: Dict[str, str] = {} logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"') + def __getitem__(self, key: str) -> linopy.Variable: + if key in self._variables: + return self.variables_direct[key] + if key in self._variables_short: + return self.variables_direct[self._variables_short[key]] + raise KeyError(f'Variable "{key}" not found in model "{self.label_full}"') + def do_modeling(self): raise NotImplementedError('Every Model needs a do_modeling() method') def add( + self, + item: Union[ + linopy.Variable, linopy.Constraint, 'Model', Dict[str, Union[linopy.Variable, linopy.Constraint, 'Model']] + ], + short_name: Optional[str] = None, + ) -> Union[ + linopy.Variable, linopy.Constraint, 'Model', Dict[str, Union[linopy.Variable, linopy.Constraint, 'Model']] + ]: + """ + Add a variable, constraint, sub-model, or batch of items to the model + + Args: + item: The variable, constraint, sub-model, or dictionary of items to add + short_name: The short name for single items. Ignored for dictionary inputs. + + Returns: + The added item(s) - same type as input + + Examples: + # Single item + self.add(my_variable, 'var_name') + + # Batch of items + self.add({ + 'on': on_variable, + 'off': off_variable, + 'duration': duration_constraint + }) + """ + # Handle dictionary input (batch mode) + if isinstance(item, dict): + return self._add_batch(item) + + # Handle single item + return self._add_single(item, short_name) + + def _add_batch( + self, items: Dict[str, Union[linopy.Variable, linopy.Constraint, 'Model']] + ) -> Dict[str, Union[linopy.Variable, linopy.Constraint, 'Model']]: + """ + Add a batch of items using their dictionary keys as short names + + Args: + items: Dictionary with short_name -> item mapping + + Returns: + The same dictionary for chaining + """ + for short_name, item in items.items(): + self._add_single(item, short_name) + return items + + def _add_single( self, item: Union[linopy.Variable, linopy.Constraint, 'Model'], short_name: Optional[str] = None ) -> Union[linopy.Variable, linopy.Constraint, 'Model']: """ - Add a variable, constraint or sub-model to the model + Add a single variable, constraint or sub-model to the model Args: item: The variable, constraint or sub-model to add to the model short_name: The short name of the variable, constraint or sub-model. If not provided, the full name is used. + + Returns: + The added item for chaining """ - # TODO: Check uniquenes of short names + if short_name is not None and (short_name in self._variables_short or short_name in self._constraints_short or short_name in self._sub_models_short): + raise ValueError(f'Short name "{short_name}" already assigned to model') + # TODO: Check uniqueness of short names if isinstance(item, linopy.Variable): self._variables_direct.append(item.name) - self._variables_short[short_name] = item.name + if short_name is not None: + self._variables_short[short_name] = item.name elif isinstance(item, linopy.Constraint): self._constraints_direct.append(item.name) - self._constraints_short[short_name] = item.name + if short_name is not None: + self._constraints_short[short_name] = item.name elif isinstance(item, Model): self.sub_models.append(item) - self._sub_models_short[item.label_full] = short_name or item.label_full + if short_name is not None: + self._sub_models_short[short_name] = item.label_full else: raise ValueError( f'Item must be a linopy.Variable, linopy.Constraint or flixopt.structure.Model, got {type(item)}' ) return item - def add_batch(self, *con_or_var: Union[linopy.Constraint, linopy.Variable]) -> None: - """Add constraints to the model""" - con_or_var = list(con_or_var) - for c_o_v in con_or_var: - self.add(c_o_v) - def filter_variables( self, filter_by: Optional[Literal['binary', 'continuous', 'integer']] = None, From 5c56b63da274295c0433c14d21a9c8f9d568b698 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 20 Jul 2025 10:33:46 +0200 Subject: [PATCH 28/51] Revising the basic structure of a class Model --- flixopt/structure.py | 222 +++++++++++++++---------------------------- 1 file changed, 77 insertions(+), 145 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 49ef38db8..59cb62251 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -712,111 +712,59 @@ def __init__( self.label_of_element = label_of_element self.label_of_model = label_of_model if label_of_model is not None else self.label_of_element - self._variables_direct: List[str] = [] - self._constraints_direct: List[str] = [] - self.sub_models: List[Model] = [] + self._variables: Dict[str, linopy.Variable] = {} # Mapping from short name to variable + self._constraints: Dict[str, linopy.Constraint] = {} # Mapping from short name to constraint + self.sub_models: Dict[str, 'Model'] = {} - self._variables_short: Dict[str, str] = {} - self._constraints_short: Dict[str, str] = {} - self._sub_models_short: Dict[str, str] = {} logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"') - def __getitem__(self, key: str) -> linopy.Variable: - if key in self._variables: - return self.variables_direct[key] - if key in self._variables_short: - return self.variables_direct[self._variables_short[key]] - raise KeyError(f'Variable "{key}" not found in model "{self.label_full}"') - - def do_modeling(self): - raise NotImplementedError('Every Model needs a do_modeling() method') - - def add( - self, - item: Union[ - linopy.Variable, linopy.Constraint, 'Model', Dict[str, Union[linopy.Variable, linopy.Constraint, 'Model']] - ], - short_name: Optional[str] = None, - ) -> Union[ - linopy.Variable, linopy.Constraint, 'Model', Dict[str, Union[linopy.Variable, linopy.Constraint, 'Model']] - ]: - """ - Add a variable, constraint, sub-model, or batch of items to the model - - Args: - item: The variable, constraint, sub-model, or dictionary of items to add - short_name: The short name for single items. Ignored for dictionary inputs. - - Returns: - The added item(s) - same type as input - - Examples: - # Single item - self.add(my_variable, 'var_name') - - # Batch of items - self.add({ - 'on': on_variable, - 'off': off_variable, - 'duration': duration_constraint - }) - """ - # Handle dictionary input (batch mode) - if isinstance(item, dict): - return self._add_batch(item) - - # Handle single item - return self._add_single(item, short_name) - - def _add_batch( - self, items: Dict[str, Union[linopy.Variable, linopy.Constraint, 'Model']] - ) -> Dict[str, Union[linopy.Variable, linopy.Constraint, 'Model']]: - """ - Add a batch of items using their dictionary keys as short names - - Args: - items: Dictionary with short_name -> item mapping - - Returns: - The same dictionary for chaining - """ - for short_name, item in items.items(): - self._add_single(item, short_name) - return items - - def _add_single( - self, item: Union[linopy.Variable, linopy.Constraint, 'Model'], short_name: Optional[str] = None - ) -> Union[linopy.Variable, linopy.Constraint, 'Model']: - """ - Add a single variable, constraint or sub-model to the model + def add_variable(self, short_name: str, **kwargs) -> linopy.Variable: + """Create and add a variable in one step""" + if 'name' not in kwargs: + kwargs['name'] = f'{self.label_of_model}|{short_name}' + + variable = self._model.add_variables(**kwargs) + self.register_variable(variable, short_name) + return variable + + def add_constraint(self, short_name: str, expression, **kwargs) -> linopy.Constraint: + """Create and add a constraint in one step""" + if 'name' not in kwargs: + kwargs['name'] = f'{self.label_of_model}|{short_name}' + + constraint = self._model.add_constraints(expression, **kwargs) + self.register_constraint(constraint, short_name) + return constraint + + def register_variable(self, variable: linopy.Variable, short_name: str = None) -> None: + """Register a variable with the model""" + if short_name is None: + short_name = self._extract_short_name(variable) + if short_name in self._variables: + raise ValueError(f'Short name "{short_name}" already assigned to model') + self._variables[short_name] = variable - Args: - item: The variable, constraint or sub-model to add to the model - short_name: The short name of the variable, constraint or sub-model. If not provided, the full name is used. + def register_constraint(self, constraint: linopy.Constraint, short_name: str = None) -> None: + """Register a constraint with the model""" + if short_name is None: + short_name = self._extract_short_name(constraint) + if short_name in self._constraints: + raise ValueError(f'Short name "{short_name}" already assigned to model') + self._constraints[short_name] = constraint - Returns: - The added item for chaining - """ - if short_name is not None and (short_name in self._variables_short or short_name in self._constraints_short or short_name in self._sub_models_short): + def register_sub_model(self, sub_model: 'Model', short_name: str) -> None: + """Register a sub-model with the model""" + if short_name is None: + short_name = sub_model.__class__.__name__ + if short_name in self.sub_models: raise ValueError(f'Short name "{short_name}" already assigned to model') - # TODO: Check uniqueness of short names - if isinstance(item, linopy.Variable): - self._variables_direct.append(item.name) - if short_name is not None: - self._variables_short[short_name] = item.name - elif isinstance(item, linopy.Constraint): - self._constraints_direct.append(item.name) - if short_name is not None: - self._constraints_short[short_name] = item.name - elif isinstance(item, Model): - self.sub_models.append(item) - if short_name is not None: - self._sub_models_short[short_name] = item.label_full - else: - raise ValueError( - f'Item must be a linopy.Variable, linopy.Constraint or flixopt.structure.Model, got {type(item)}' - ) - return item + self.sub_models[short_name] = sub_model + + def __getitem__(self, key: str) -> linopy.Variable: + """Get a variable by its short name""" + if key in self._variables: + return self._variables[key] + raise KeyError(f'Variable "{key}" not found in model "{self.label_full}"') def filter_variables( self, @@ -841,67 +789,51 @@ def filter_variables( return all_variables[[name for name in all_variables if 'time' in all_variables[name].dims]] raise ValueError(f'Invalid length "{length}", must be one of "scalar", "time" or None') - @property - def label(self) -> str: - return self.label_of_model - @property def label_full(self) -> str: return self.label_of_model @property - def variables_direct(self) -> linopy.Variables: - return self._model.variables[self._variables_direct] + def variables(self) -> linopy.Variables: + return self._model.variables[[var.name for var in self._variables.values()]] @property - def constraints_direct(self) -> linopy.Constraints: - return self._model.constraints[self._constraints_direct] + def constraints(self) -> linopy.Constraints: + return self._model.constraints[[con.name for con in self._constraints.values()]] @property - def _variables(self) -> List[str]: - all_variables = self._variables_direct.copy() - for sub_model in self.sub_models: - for variable in sub_model._variables: - if variable in all_variables: - raise KeyError( - f"Duplicate key found: '{variable}' in both {self.label_full} and {sub_model.label_full}!" - ) - all_variables.append(variable) - return all_variables + def all_sub_models(self) -> List['Model']: + return [model for sub_model in self.sub_models.values() for model in [sub_model] + sub_model.all_sub_models] @property - def _constraints(self) -> List[str]: - all_constraints = self._constraints_direct.copy() - for sub_model in self.sub_models: - for constraint in sub_model._constraints: - if constraint in all_constraints: - raise KeyError(f"Duplicate key found: '{constraint}' in both main model and submodel!") - all_constraints.append(constraint) - return all_constraints + def all_constraints(self) -> linopy.Constraints: + names = [constraint_name for constraint_name in self.constraints] + [ + constraint.name + for sub_model in self.all_sub_models + for constraint in sub_model.constraints.values() + ] - @property - def variables(self) -> linopy.Variables: - return self._model.variables[self._variables] + return self._model.constraints[names] @property - def constraints(self) -> linopy.Constraints: - return self._model.constraints[self._constraints] + def all_variables(self) -> linopy.Variables: + names = [variable_name for variable_name in self.variables] + [ + variable.name + for sub_model in self.all_sub_models + for variable in sub_model.constraints.values() + ] - @property - def all_sub_models(self) -> List['Model']: - return [model for sub_model in self.sub_models for model in [sub_model] + sub_model.all_sub_models] - - def get_variable_by_short_name(self, short_name: str, default_return = None) -> Optional[linopy.Variable]: - """Get variable by short name""" - if short_name not in self._variables_short: - return default_return - return self._model.variables[self._variables_short.get(short_name)] - - def get_constraint_by_short_name(self, short_name: str, default_return = None) -> Optional[linopy.Constraint]: - """Get variable by short name""" - if short_name not in self._constraints_short: - return default_return - return self._model.constraints[self._constraints_short.get(short_name)] + return self._model.variables[names] + + @staticmethod + def _extract_short_name(item: Union[linopy.Variable, linopy.Constraint]) -> str: + """Extract short name from variable's full name""" + # Assumes format like "model_prefix|short_name" + name = str(item.name) + if '|' in name: + return name.split('|')[-1] # Take last part after | + else: + return name # Use full name if no | separator class BaseFeatureModel(Model): From 9d242b6aee8ad2f2270f9738234722914e703211 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 20 Jul 2025 12:08:12 +0200 Subject: [PATCH 29/51] Revising the basic structure of a class Model --- flixopt/calculation.py | 4 +- flixopt/components.py | 164 ++++++++++------------ flixopt/effects.py | 35 ++--- flixopt/elements.py | 122 ++++++++--------- flixopt/features.py | 301 ++++++++++++++++------------------------- flixopt/modeling.py | 2 +- flixopt/structure.py | 84 ++++++++---- 7 files changed, 325 insertions(+), 387 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 6bf86bb20..438fbeea5 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -116,13 +116,13 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: 'Invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() - for model in component.model.all_sub_models + for model in component.model.sub_models if isinstance(model, InvestmentModel) and model.size.solution.max() >= CONFIG.modeling.EPSILON }, 'Not invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() - for model in component.model.all_sub_models + for model in component.model.sub_models if isinstance(model, InvestmentModel) and model.size.solution.max() < CONFIG.modeling.EPSILON }, }, diff --git a/flixopt/components.py b/flixopt/components.py index 685928714..6631cb214 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -418,23 +418,17 @@ def do_modeling(self): # equate size of both directions if self.element.balanced: # eq: in1.size = in2.size - self.add( - self._model.add_constraints( - self.element.in1.model._investment.size == self.element.in2.model._investment.size, - name=f'{self.label_full}|same_size', - ), - 'same_size', + self.add_constraints( + self.element.in1.model._investment.size == self.element.in2.model._investment.size, + short_name='same_size', ) def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) -> linopy.Constraint: """Creates an Equation for the Transmission efficiency and adds it to the model""" # eq: out(t) + on(t)*loss_abs(t) = in(t)*(1 - loss_rel(t)) - con_transmission = self.add( - self._model.add_constraints( - out_flow.model.flow_rate == -in_flow.model.flow_rate * (self.element.relative_losses - 1), - name=f'{self.label_full}|{name}', - ), - name, + con_transmission = self.add_constraints( + out_flow.model.flow_rate == -in_flow.model.flow_rate * (self.element.relative_losses - 1), + short_name=name, ) if self.element.absolute_losses is not None: @@ -464,12 +458,10 @@ def do_modeling(self): used_inputs: Set = all_input_flows & used_flows used_outputs: Set = all_output_flows & used_flows - self.add( - self._model.add_constraints( - sum([flow.model.flow_rate * conv_factors[flow.label] for flow in used_inputs]) - == sum([flow.model.flow_rate * conv_factors[flow.label] for flow in used_outputs]), - name=f'{self.label_full}|conversion_{i}', - ) + self.add_constraints( + sum([flow.model.flow_rate * conv_factors[flow.label] for flow in used_inputs]) + == sum([flow.model.flow_rate * conv_factors[flow.label] for flow in used_outputs]), + short_name=f'conversion_{i}', ) else: @@ -479,14 +471,15 @@ def do_modeling(self): for flow, piecewise in self.element.piecewise_conversion.items() } - self.piecewise_conversion = self.add( + self.piecewise_conversion = self.register_sub_model( PiecewiseModel( model=self._model, label_of_element=self.label_of_element, piecewise_variables=piecewise_conversion, zero_point=self.on_off.on if self.on_off is not None else False, as_time_series=True, - ) + ), + short_name='PiecewiseConversion', ) self.piecewise_conversion.do_modeling() @@ -497,36 +490,26 @@ class StorageModel(ComponentModel): def __init__(self, model: FlowSystemModel, element: Storage): super().__init__(model, element) self.element: Storage = element - self.charge_state: Optional[linopy.Variable] = None - self.netto_discharge: Optional[linopy.Variable] = None - self._investment: Optional[InvestmentModel] = None def do_modeling(self): super().do_modeling() lb, ub = self.absolute_charge_state_bounds - self.charge_state = self.add( - self._model.add_variables( - lower=lb, - upper=ub, - coords=self._model.get_coords(extra_timestep=True), - name=f'{self.label_full}|charge_state', - ), - 'charge_state', - ) - self.netto_discharge = self.add( - self._model.add_variables(coords=self._model.get_coords(), name=f'{self.label_full}|netto_discharge'), - 'netto_discharge', + self.add_variables( + lower=lb, + upper=ub, + coords=self._model.get_coords(extra_timestep=True), + short_name='charge_state', ) + + self.add_variables(coords=self._model.get_coords(), short_name='netto_discharge') + # netto_discharge: # eq: nettoFlow(t) - discharging(t) + charging(t) = 0 - self.add( - self._model.add_constraints( - self.netto_discharge - == self.element.discharging.model.flow_rate - self.element.charging.model.flow_rate, - name=f'{self.label_full}|netto_discharge', - ), - 'netto_discharge', + self.add_constraints( + self.netto_discharge + == self.element.discharging.model.flow_rate - self.element.charging.model.flow_rate, + short_name='netto_discharge', ) charge_state = self.charge_state @@ -537,76 +520,57 @@ def do_modeling(self): eff_charge = self.element.eta_charge eff_discharge = self.element.eta_discharge - self.add( - self._model.add_constraints( - charge_state.isel(time=slice(1, None)) - == charge_state.isel(time=slice(None, -1)) * ((1 - rel_loss) ** hours_per_step) - + charge_rate * eff_charge * hours_per_step - - discharge_rate * eff_discharge * hours_per_step, - name=f'{self.label_full}|charge_state', - ), - 'charge_state', + self.add_constraints( + charge_state.isel(time=slice(1, None)) + == charge_state.isel(time=slice(None, -1)) * ((1 - rel_loss) ** hours_per_step) + + charge_rate * eff_charge * hours_per_step + - discharge_rate * eff_discharge * hours_per_step, + short_name='charge_state', ) if isinstance(self.element.capacity_in_flow_hours, InvestParameters): - self._investment = InvestmentModel( - model=self._model, - label_of_element=self.label_of_element, - parameters=self.element.capacity_in_flow_hours, - defining_variable=self.charge_state, - relative_bounds_of_defining_variable=self.relative_charge_state_bounds, + self.register_sub_model( + InvestmentModel( + model=self._model, + label_of_element=self.label_of_element, + parameters=self.element.capacity_in_flow_hours, + defining_variable=self.charge_state, + relative_bounds_of_defining_variable=self.relative_charge_state_bounds, + ), + short_name='investment', ) - self.sub_models.append(self._investment) self._investment.do_modeling() # Initial charge state self._initial_and_final_charge_state() if self.element.balanced: - self.add( - self._model.add_constraints( - self.element.charging.model._investment.size * 1 == self.element.discharging.model._investment.size * 1, - name=f'{self.label_full}|balanced_sizes', - ), - 'balanced_sizes' + self.add_constraints( + self.element.charging.model._investment.size * 1 == self.element.discharging.model._investment.size * 1, + short_name='balanced_sizes', ) def _initial_and_final_charge_state(self): if self.element.initial_charge_state is not None: - name_short = 'initial_charge_state' - name = f'{self.label_full}|{name_short}' - if isinstance(self.element.initial_charge_state, str): - self.add( - self._model.add_constraints( - self.charge_state.isel(time=0) == self.charge_state.isel(time=-1), name=name - ), - name_short, + self.add_constraints( + self.charge_state.isel(time=0) == self.charge_state.isel(time=-1), short_name='initial_charge_state' ) else: - self.add( - self._model.add_constraints( - self.charge_state.isel(time=0) == self.element.initial_charge_state, name=name - ), - name_short, + self.add_constraints( + self.charge_state.isel(time=0) == self.element.initial_charge_state, short_name='initial_charge_state' ) if self.element.maximal_final_charge_state is not None: - 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', - ), - 'final_charge_max', + self.add_constraints( + self.charge_state.isel(time=-1) <= self.element.maximal_final_charge_state, + short_name='final_charge_max', ) if self.element.minimal_final_charge_state is not None: - self.add( - self._model.add_constraints( - self.charge_state.isel(time=-1) >= self.element.minimal_final_charge_state, - name=f'{self.label_full}|final_charge_min', - ), - 'final_charge_min', + self.add_constraints( + self.charge_state.isel(time=-1) >= self.element.minimal_final_charge_state, + short_name='final_charge_min', ) @property @@ -652,6 +616,28 @@ def relative_charge_state_bounds(self) -> Tuple[xr.DataArray, xr.DataArray]: return min_bounds, max_bounds + @property + def _investment(self) -> Optional[InvestmentModel]: + """Deprecated alias for investment""" + return self.investment + + @property + def investment(self) -> Optional[InvestmentModel]: + """OnOff feature""" + if 'investment' not in self.sub_models_direct: + return None + return self.sub_models_direct['investment'] + + @property + def charge_state(self) -> linopy.Variable: + """Charge state variable""" + return self['charge_state'] + + @property + def netto_discharge(self) -> linopy.Variable: + """Netto discharge variable""" + return self['netto_discharge'] + @register_class_for_io class SourceAndSink(Component): diff --git a/flixopt/effects.py b/flixopt/effects.py index 0e4236076..13ee524e5 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -142,7 +142,7 @@ def __init__(self, model: FlowSystemModel, element: Effect): super().__init__(model, element) self.element: Effect = element self.total: Optional[linopy.Variable] = None - self.invest: ShareAllocationModel = self.add( + self.invest: ShareAllocationModel = self.register_sub_model( ShareAllocationModel( model=self._model, dims=('year', 'scenario'), @@ -150,10 +150,11 @@ def __init__(self, model: FlowSystemModel, element: Effect): label_of_model=f'{self.label_of_model}(invest)', total_max=self.element.maximum_invest, total_min=self.element.minimum_invest, - ) + ), + short_name='invest', ) - self.operation: ShareAllocationModel = self.add( + self.operation: ShareAllocationModel = self.register_sub_model( ShareAllocationModel( model=self._model, dims=('time', 'year', 'scenario'), @@ -167,29 +168,22 @@ def __init__(self, model: FlowSystemModel, element: Effect): max_per_hour=self.element.maximum_operation_per_hour if self.element.maximum_operation_per_hour is not None else None, - ) + ), + short_name='operation', ) def do_modeling(self): for model in self.sub_models: model.do_modeling() - self.total = self.add( - self._model.add_variables( - lower=self.element.minimum_total if self.element.minimum_total is not None else -np.inf, - upper=self.element.maximum_total if self.element.maximum_total is not None else np.inf, - coords=self._model.get_coords(['year', 'scenario']), - name=f'{self.label_full}|total', - ), - 'total', + self.total = self.add_variables( + lower=self.element.minimum_total if self.element.minimum_total is not None else -np.inf, + upper=self.element.maximum_total if self.element.maximum_total is not None else np.inf, + coords=self._model.get_coords(['year', 'scenario']), + short_name='total', ) - self.add( - self._model.add_constraints( - self.total == self.operation.total + self.invest.total, name=f'{self.label_full}|total' - ), - 'total', - ) + self.add_constraints(self.total == self.operation.total + self.invest.total, short_name='total') TemporalEffectsUser = Union[TemporalDataUser, Dict[str, TemporalDataUser]] # User-specified Shares to Effects @@ -421,8 +415,9 @@ def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) - def do_modeling(self): for effect in self.effects: effect.create_model(self._model) - self.penalty = self.add( - ShareAllocationModel(self._model, dims=(), label_of_element='Penalty') + self.penalty = self.register_sub_model( + ShareAllocationModel(self._model, dims=(), label_of_element='Penalty'), + short_name='penalty', ) for model in [effect.model for effect in self.effects] + [self.penalty]: model.do_modeling() diff --git a/flixopt/elements.py b/flixopt/elements.py index 15d17ef92..dec7425a7 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -313,27 +313,20 @@ def __init__(self, model: FlowSystemModel, element: Flow): super().__init__(model, element) self.element: Flow = element - # Feature models (set by do_modeling) - self.on_off: Optional[OnOffModel] = None - self._investment: Optional[InvestmentModel] = None - def do_modeling(self): # Main flow rate variable - self.add( - self._model.add_variables( - lower=self.flow_rate_lower_bound, - upper=self.flow_rate_upper_bound, - coords=self._model.get_coords(), - name=f'{self.label_full}|flow_rate', - ), - 'flow_rate', + self.add_variables( + lower=self.flow_rate_lower_bound, + upper=self.flow_rate_upper_bound, + coords=self._model.get_coords(), + short_name='flow_rate', ) default_cons = not (self.element.on_off_parameters is not None and isinstance(self.element.size, InvestParameters)) # OnOff feature if self.element.on_off_parameters is not None: - self.on_off: OnOffModel = self.add( + self.register_sub_model( OnOffModel( model=self._model, label_of_element=self.label_of_element, @@ -344,13 +337,12 @@ def do_modeling(self): label_of_model=self.label_of_element, apply_bounds_to_flow_rates=default_cons, ), - 'on_off', - ) - self.on_off.do_modeling() + short_name='on_off', + ).do_modeling() # Investment feature if isinstance(self.element.size, InvestParameters): - self._investment: InvestmentModel = self.add( + self.register_sub_model( InvestmentModel( model=self._model, label_of_element=self.label_of_element, @@ -364,12 +356,11 @@ def do_modeling(self): apply_bounds_to_defining_variable=default_cons, ), 'investment', - ) - self._investment.do_modeling() + ).do_modeling() if not default_cons: - constraints = BoundingPatterns.scaled_bounds_with_state( - model=self._model, + BoundingPatterns.scaled_bounds_with_state( + model=self, variable=self.flow_rate, scaling_variable=self._investment.size, relative_bounds=(self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative), @@ -377,12 +368,9 @@ def do_modeling(self): variable_state=self.on_off.on, ) - for constraint in constraints: - self.add(constraint) - - # Total flow hours tracking (could use factory pattern) - variable, constraint = ModelingPrimitives.expression_tracking_variable( - model=self._model, + # Total flow hours tracking + ModelingPrimitives.expression_tracking_variable( + model=self, name=f'{self.label_full}|total_flow_hours', tracked_expression=(self.flow_rate * self._model.hours_per_step).sum('time'), bounds=( @@ -392,9 +380,6 @@ def do_modeling(self): coords=['year', 'scenario'], ) - self.add(variable, 'total_flow_hours') - self.add(constraint, 'total_flow_hours_tracking') - # Load factor constraints self._create_bounds_for_load_factor() @@ -403,14 +388,14 @@ def do_modeling(self): # Properties for clean access to variables @property - def flow_rate(self) -> Optional[linopy.Variable]: + def flow_rate(self) -> linopy.Variable: """Main flow rate variable""" - return self.get_variable_by_short_name('flow_rate') + return self['flow_rate'] @property - def total_flow_hours(self) -> Optional[linopy.Variable]: + def total_flow_hours(self) -> linopy.Variable: """Total flow hours variable""" - return self.get_variable_by_short_name('total_flow_hours') + return self['total_flow_hours'] def results_structure(self): return { @@ -440,23 +425,17 @@ def _create_bounds_for_load_factor(self): # Maximum load factor constraint if self.element.load_factor_max is not None: flow_hours_per_size_max = self._model.hours_per_step.sum('time') * self.element.load_factor_max - self.add( - self._model.add_constraints( - self.total_flow_hours <= size * flow_hours_per_size_max, - name=f'{self.label_full}|load_factor_max', - ), - 'load_factor_max', + self.add_constraints( + self.total_flow_hours <= size * flow_hours_per_size_max, + short_name='load_factor_max', ) # Minimum load factor constraint if self.element.load_factor_min is not None: flow_hours_per_size_min = self._model.hours_per_step.sum('time') * self.element.load_factor_min - self.add( - self._model.add_constraints( - self.total_flow_hours >= size * flow_hours_per_size_min, - name=f'{self.label_full}|load_factor_min', - ), - 'load_factor_min', + self.add_constraints( + self.total_flow_hours >= size * flow_hours_per_size_min, + short_name='load_factor_min', ) @property @@ -512,6 +491,25 @@ def flow_rate_upper_bound(self) -> TemporalData: return self.flow_rate_upper_bound_relative * self.element.size.maximum_or_fixed_size return self.flow_rate_upper_bound_relative * self.element.size + @property + def on_off(self) -> Optional[OnOffModel]: + """OnOff feature""" + if 'on_off' not in self.sub_models_direct: + return None + return self.sub_models_direct['on_off'] + + @property + def _investment(self) -> Optional[InvestmentModel]: + """Deprecated alias for investment""" + return self.investment + + @property + def investment(self) -> Optional[InvestmentModel]: + """OnOff feature""" + if 'investment' not in self.sub_models_direct: + return None + return self.sub_models_direct['investment'] + class BusModel(ElementModel): def __init__(self, model: FlowSystemModel, element: Bus): @@ -523,28 +521,19 @@ def __init__(self, model: FlowSystemModel, element: Bus): def do_modeling(self) -> None: # inputs == outputs for flow in self.element.inputs + self.element.outputs: - self.add(flow.model.flow_rate, flow.label_full) + self.register_variable(flow.model.flow_rate, flow.label_full) inputs = sum([flow.model.flow_rate for flow in self.element.inputs]) outputs = sum([flow.model.flow_rate for flow in self.element.outputs]) - eq_bus_balance = self.add(self._model.add_constraints(inputs == outputs, name=f'{self.label_full}|balance')) + eq_bus_balance = self.add_constraints(inputs == outputs, short_name='balance') # Fehlerplus/-minus: if self.element.with_excess: - excess_penalty = np.multiply( - self._model.hours_per_step, self.element.excess_penalty_per_flow_hour - ) - self.excess_input = self.add( - self._model.add_variables( - lower=0, coords=self._model.get_coords(), name=f'{self.label_full}|excess_input' - ), - 'excess_input', - ) - self.excess_output = self.add( - self._model.add_variables( - lower=0, coords=self._model.get_coords(), name=f'{self.label_full}|excess_output' - ), - 'excess_output', - ) + excess_penalty = np.multiply(self._model.hours_per_step, self.element.excess_penalty_per_flow_hour) + + self.excess_input = self.add_variables(lower=0, coords=self._model.get_coords(), short_name='excess_input') + + self.excess_output = self.add_variables(lower=0, coords=self._model.get_coords(), short_name='excess_output') + eq_bus_balance.lhs -= -self.excess_input + self.excess_output self._model.effects.add_share_to_penalty(self.label_of_element, (self.excess_input * excess_penalty).sum()) @@ -581,13 +570,13 @@ def do_modeling(self): flow.on_off_parameters = OnOffParameters() for flow in all_flows: - self.add(flow.create_model(self._model), flow.label) + self.register_sub_model(flow.create_model(self._model), short_name=flow.label) for sub_model in self.sub_models: sub_model.do_modeling() if self.element.on_off_parameters: - self.on_off = self.add( + self.on_off = self.register_sub_model( OnOffModel( model=self._model, label_of_element=self.label_of_element, @@ -598,6 +587,7 @@ def do_modeling(self): label_of_model=self.label_of_element, apply_bounds_to_flow_rates=True, ), + short_name='on_off', ) self.on_off.do_modeling() @@ -605,7 +595,7 @@ def do_modeling(self): if self.element.prevent_simultaneous_flows: # Simultanious Useage --> Only One FLow is On at a time, but needs a Binary for every flow on_variables = [flow.model.on_off.on for flow in self.element.prevent_simultaneous_flows] - simultaneous_use = self.add(PreventSimultaneousUsageModel(self._model, on_variables, self.label_full)) + simultaneous_use = self.register_sub_model(PreventSimultaneousUsageModel(self._model, on_variables, self.label_full), short_name='prevent_simultaneous_use') simultaneous_use.do_modeling() def results_structure(self): diff --git a/flixopt/features.py b/flixopt/features.py index dab25b49b..49c80d1c8 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -51,50 +51,40 @@ def __init__( self._apply_bounds_to_defining_variable = apply_bounds_to_defining_variable # Only keep non-variable attributes - self.scenario_of_investment: Optional[linopy.Variable] = None self.piecewise_effects: Optional[PiecewiseEffectsModel] = None + def create_variables_and_constraints(self): - constraints = [] size_min, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size) - size = self.add( - self._model.add_variables( - lower=0 if self.parameters.optional else size_min, - upper=size_max, - name=f'{self.label_of_model}|size', - coords=self._model.get_coords(['year', 'scenario']), - ), - 'size', + self.add_variables( + lower=0 if self.parameters.optional else size_min, + upper=size_max, + name=f'{self.label_of_model}|size', + coords=self._model.get_coords(['year', 'scenario']), ) if self.parameters.optional: - is_invested = self.add( - self._model.add_variables( - binary=True, name=f'{self.label_of_model}|is_invested', coords=self._model.get_coords(['year', 'scenario']) - ), - 'is_invested', + self.add_variables( + binary=True, name=f'{self.label_of_model}|is_invested', coords=self._model.get_coords(['year', 'scenario']) ) - constraints += BoundingPatterns.bounds_with_state( - self._model, - variable=size, - variable_state=is_invested, + + BoundingPatterns.bounds_with_state( + self, + variable=self.size, + variable_state=self.is_invested, bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) if self._apply_bounds_to_defining_variable: - constraints += BoundingPatterns.scaled_bounds( - self._model, + BoundingPatterns.scaled_bounds( + self, variable=self._defining_variable, scaling_variable=self.size, relative_bounds=self._relative_bounds_of_defining_variable, ) - # Register constraints - for constraint in constraints: - self.add(constraint) - if self.parameters.piecewise_effects: - self.piecewise_effects = self.add( + self.piecewise_effects = self.register_sub_model( PiecewiseEffectsModel( model=self._model, label_of_element=self.label_of_element, @@ -102,21 +92,10 @@ def create_variables_and_constraints(self): piecewise_shares=self.parameters.piecewise_effects.piecewise_shares, zero_point=self.is_invested, ), - 'segments', + short_name='segments', ) self.piecewise_effects.do_modeling() - # Properties access variables from Model's tracking system - @property - def size(self) -> Optional[linopy.Variable]: - """Investment size variable""" - return self.get_variable_by_short_name('size') - - @property - def is_invested(self) -> Optional[linopy.Variable]: - """Binary investment decision variable""" - return self.get_variable_by_short_name('is_invested') - def add_effects(self): """Add investment effects""" if self.parameters.fix_effects: @@ -146,9 +125,17 @@ def add_effects(self): target='invest', ) - def _create_bounds_for_scenarios(self): - """Keep existing scenario logic""" - pass + @property + def size(self) -> linopy.Variable: + """Investment size variable""" + return self._variables['size'] + + @property + def is_invested(self) -> Optional[linopy.Variable]: + """Binary investment decision variable""" + if 'is_invested' not in self._variables: + return None + return self._variables['is_invested'] class OnOffModel(BaseFeatureModel): @@ -186,73 +173,60 @@ def __init__( def create_variables_and_constraints(self): # 1. Main binary state using existing pattern - on = self.add(self._model.add_variables(binary=True, name=f'{self.label_of_model}|on', coords=self._model.get_coords()), 'on') + on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords()) if self.parameters.use_off: - off = self.add(self._model.add_variables(binary=True, name=f'{self.label_of_model}|off', coords=self._model.get_coords()), 'off') - self.add(self._model.add_constraints(on + off == 1, name=f'{self.label_of_model}|complementary'), 'complementary') + off = self.add_variables(binary=True, short_name='off', coords=self._model.get_coords()) + self.add_constraints(on + off == 1, short_name='complementary') # 2. Control variables if self._apply_bounds_to_flow_rates: - self.add_batch(*BoundingPatterns.bounds_with_state( - self._model, + BoundingPatterns.bounds_with_state( + self, variable=self._flow_rate, bounds=self._flow_rate_bounds, variable_state=self.on, - )) + ) # 3. Total duration tracking using existing pattern duration_expr = (self.on * self._model.hours_per_step).sum('time') - var, con = ModelingPrimitives.expression_tracking_variable( - self._model, f'{self.label_of_model}|on_hours_total', duration_expr, + ModelingPrimitives.expression_tracking_variable( + self, f'{self.label_of_model}|on_hours_total', duration_expr, (self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf),#TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) ) - self.add(var, 'on_hours_total') - self.add(con) # 4. Switch tracking using existing pattern if self.parameters.use_switch_on: - switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( - self._model, f'{self.label_of_model}|switch', self.on, + ModelingPrimitives.state_transition_variables( + self, f'{self.label_of_model}|switch', self.on, previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rate), ) - self.add(switch_vars['on'], 'switch|on') - self.add(switch_vars['off'], 'switch|off') - self.add(switch_constraints['transition']) - self.add(switch_constraints['initial']) - self.add(switch_constraints['mutex']) if self.parameters.switch_on_total_max is not None: - count = self.add(self._model.add_variables(lower=0, upper=self.parameters.switch_on_total_max, coords=self._model.get_coords(('year', 'scenario')), name=f'{self.label_of_model}|switch|count'), 'switch|count') - self.add(self._model.add_constraints(count == self.switch_on.sum('time'), name=f'{self.label_of_model}|switch|count'), 'switch|count') + count = self.add_variables(lower=0, upper=self.parameters.switch_on_total_max, coords=self._model.get_coords(('year', 'scenario')), short_name='switch|count') + self.add_constraints(count == self.switch_on.sum('time'), short_name='switch|count') # 5. Consecutive on duration using existing pattern if self.parameters.use_consecutive_on_hours: - consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( - self._model, + ModelingPrimitives.consecutive_duration_tracking( + self, f'{self.label_of_model}|consecutive_on_hours', #TODO: Change name self.on, minimum_duration=self.parameters.consecutive_on_hours_min, maximum_duration=self.parameters.consecutive_on_hours_max, previous_duration=ModelingUtilities.compute_previous_on_duration([self._previous_flow_rate], self._model.hours_per_step), ) - self.add(consecutive_on_vars['duration'], 'consecutive_on_hours') - for constraint in consecutive_on_constraints.values(): - self.add(constraint) # 6. Consecutive off duration using existing pattern if self.parameters.use_consecutive_off_hours: - consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( - self._model, + ModelingPrimitives.consecutive_duration_tracking( + self, f'{self.label_of_model}|consecutive_off_hours', self.off, minimum_duration=self.parameters.consecutive_off_hours_min, maximum_duration=self.parameters.consecutive_off_hours_max, previous_duration=ModelingUtilities.compute_previous_off_duration([self._previous_flow_rate], self._model.hours_per_step), ) - self.add(consecutive_off_vars['duration'], 'consecutive_off_hours') - for constraint in consecutive_off_constraints.values(): - self.add(constraint) def add_effects(self): """Add operational effects""" @@ -279,43 +253,42 @@ def add_effects(self): @property def on(self) -> Optional[linopy.Variable]: """Binary on state variable""" - return self.get_variable_by_short_name('on') - - @property - def off(self) -> Optional[linopy.Variable]: - """Binary off state variable""" - return self.get_variable_by_short_name('off') + return self['on'] @property def total_on_hours(self) -> Optional[linopy.Variable]: """Total on hours variable""" - return self.get_variable_by_short_name('total_on_hours') + return self['total_on_hours'] + + @property + def off(self) -> Optional[linopy.Variable]: + """Binary off state variable""" + return self.get('off') @property def switch_on(self) -> Optional[linopy.Variable]: """Switch on variable""" - return self.get_variable_by_short_name('switch|on') + return self.get('switch|on') @property def switch_off(self) -> Optional[linopy.Variable]: """Switch off variable""" - return self.get_variable_by_short_name('switch|off') + return self.get('switch|off') @property def switch_on_nr(self) -> Optional[linopy.Variable]: """Number of switch-ons variable""" - # This could be added to factory if needed - return self.get_variable_by_short_name('switch|count') + return self.get('switch|count') @property def consecutive_on_hours(self) -> Optional[linopy.Variable]: """Consecutive on hours variable""" - return self.get_variable_by_short_name('consecutive_on_hours') + return self.get('consecutive_on_hours') @property def consecutive_off_hours(self) -> Optional[linopy.Variable]: """Consecutive off hours variable""" - return self.get_variable_by_short_name('consecutive_off_hours') + return self.get('consecutive_off_hours') def _get_previous_on_duration(self): hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] @@ -347,42 +320,27 @@ def __init__( def do_modeling(self): dims =('time', 'year','scenario') if self._as_time_series else ('year','scenario') - self.inside_piece = self.add( - self._model.add_variables( - binary=True, - name=f'{self.label_full}|inside_piece', - coords=self._model.get_coords(dims=dims), - ), - 'inside_piece', + self.inside_piece = self.add_variables( + binary=True, + short_name='inside_piece', + coords=self._model.get_coords(dims=dims), ) - - self.lambda0 = self.add( - self._model.add_variables( - lower=0, - upper=1, - name=f'{self.label_full}|lambda0', - coords=self._model.get_coords(dims=dims), - ), - 'lambda0', + self.lambda0 = self.add_variables( + lower=0, + upper=1, + name='lambda0', + coords=self._model.get_coords(dims=dims), ) - self.lambda1 = self.add( - self._model.add_variables( - lower=0, - upper=1, - name=f'{self.label_full}|lambda1', - coords=self._model.get_coords(dims=dims), - ), - 'lambda1', + self.lambda1 = self.add_variables( + lower=0, + upper=1, + short_name='lambda1', + coords=self._model.get_coords(dims=dims), ) # eq: lambda0(t) + lambda1(t) = inside_piece(t) - self.add( - self._model.add_constraints( - self.inside_piece == self.lambda0 + self.lambda1, name=f'{self.label_full}|inside_piece' - ), - 'inside_piece', - ) + self.add_constraints(self.inside_piece == self.lambda0 + self.lambda1, short_name='inside_piece') class PiecewiseModel(Model): @@ -418,34 +376,33 @@ def __init__( def do_modeling(self): for i in range(len(list(self._piecewise_variables.values())[0])): - new_piece = self.add( + new_piece = self.register_sub_model( PieceModel( model=self._model, label_of_element=self.label_of_element, label_of_model=f'{self.label_of_element}|Piece_{i}', as_time_series=self._as_time_series, - ) + ), + short_name=f'Piece_{i}', ) self.pieces.append(new_piece) new_piece.do_modeling() for var_name in self._piecewise_variables: variable = self._model.variables[var_name] - self.add( - self._model.add_constraints( - variable - == sum( - [ - piece_model.lambda0 * piece_bounds.start + piece_model.lambda1 * piece_bounds.end - for piece_model, piece_bounds in zip( - self.pieces, self._piecewise_variables[var_name], strict=False - ) - ] - ), - name=f'{self.label_full}|{var_name}|lambda', + self.add_constraints( + variable + == sum( + [ + piece_model.lambda0 * piece_bounds.start + piece_model.lambda1 * piece_bounds.end + for piece_model, piece_bounds in zip( + self.pieces, self._piecewise_variables[var_name], strict=False + ) + ] ), - f'{var_name}|lambda', - ) + name=f'{self.label_full}|{var_name}|lambda', + short_name=f'{var_name}|lambda', + ) # a) eq: Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 1 Aufenthalt nur in Segmenten erlaubt # b) eq: -On(t) + Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 0 zusätzlich kann alles auch Null sein @@ -453,22 +410,17 @@ def do_modeling(self): self.zero_point = self._zero_point rhs = self.zero_point elif self._zero_point is True: - self.zero_point = self.add( - self._model.add_variables( - coords=self._model.get_coords(), binary=True, name=f'{self.label_full}|zero_point' - ), - 'zero_point', + self.zero_point = self.add_variables( + coords=self._model.get_coords(), binary=True, short_name='zero_point' ) rhs = self.zero_point else: rhs = 1 - self.add( - self._model.add_constraints( - sum([piece.inside_piece for piece in self.pieces]) <= rhs, - name=f'{self.label_full}|{variable.name}|single_segment', - ), - f'{var_name}|single_segment', + self.add_constraints( + sum([piece.inside_piece for piece in self.pieces]) <= rhs, + name=f'{self.label_full}|{variable.name}|single_segment', + short_name=f'{var_name}|single_segment', ) @@ -495,12 +447,7 @@ def __init__( def do_modeling(self): self.shares = { - effect: self.add( - self._model.add_variables( - coords=self._model.get_coords(['year', 'scenario']), name=f'{self.label_full}|{effect}' - ), - f'{effect}', - ) + effect: self.add_variables(coords=self._model.get_coords(['year', 'scenario']), short_name=effect) for effect in self._piecewise_shares } @@ -512,7 +459,7 @@ def do_modeling(self): }, } - self.piecewise_model = self.add( + self.piecewise_model = self.register_sub_model( PiecewiseModel( model=self._model, label_of_element=self.label_of_element, @@ -520,7 +467,8 @@ def do_modeling(self): zero_point=self._zero_point, as_time_series=False, label_of_model=f'{self.label_of_element}|PiecewiseEffects', - ) + ), + short_name='PiecewiseEffects', ) self.piecewise_model.do_modeling() @@ -566,35 +514,24 @@ def __init__( self._min_per_hour = min_per_hour if min_per_hour is not None else -np.inf def do_modeling(self): - self.total = self.add( - self._model.add_variables( - lower=self._total_min, - upper=self._total_max, - coords=self._model.get_coords([dim for dim in self._dims if dim != 'time']), - name=f'{self.label_full}|total', - ), - 'total', + self.total = self.add_variables( + lower=self._total_min, + upper=self._total_max, + coords=self._model.get_coords([dim for dim in self._dims if dim != 'time']), + short_name='total' ) # eq: sum = sum(share_i) # skalar - self._eq_total = self.add( - self._model.add_constraints(self.total == 0, name=f'{self.label_full}|total'), 'total' - ) + self._eq_total = self.add_constraints(self.total == 0, short_name='total') if 'time' in self._dims: - self.total_per_timestep = self.add( - self._model.add_variables( - lower=-np.inf if (self._min_per_hour is None) else self._min_per_hour * self._model.hours_per_step, - upper=np.inf if (self._max_per_hour is None) else self._max_per_hour * self._model.hours_per_step, - coords=self._model.get_coords(self._dims), - name=f'{self.label_full}|total_per_timestep', - ), - 'total_per_timestep', + self.total_per_timestep = self.add_variables( + lower=-np.inf if (self._min_per_hour is None) else self._min_per_hour * self._model.hours_per_step, + upper=np.inf if (self._max_per_hour is None) else self._max_per_hour * self._model.hours_per_step, + coords=self._model.get_coords(self._dims), + short_name='total_per_timestep', ) - self._eq_total_per_timestep = self.add( - self._model.add_constraints(self.total_per_timestep == 0, name=f'{self.label_full}|total_per_timestep'), - 'total_per_timestep', - ) + self._eq_total_per_timestep = self.add_constraints(self.total_per_timestep == 0, short_name='total_per_timestep') # Add it to the total self._eq_total.lhs -= self.total_per_timestep.sum(dim='time') @@ -629,16 +566,15 @@ def add_share( if name in self.shares: self.share_constraints[name].lhs -= expression else: - self.shares[name] = self.add( - self._model.add_variables( - coords=self._model.get_coords(dims), - name=f'{name}->{self.label_full}', - ), - name, + self.shares[name] = self.add_variables( + coords=self._model.get_coords(dims), + short_name=f'{name}->{self.label_full}', ) - self.share_constraints[name] = self.add( - self._model.add_constraints(self.shares[name] == expression, name=f'{name}->{self.label_full}'), name + + self.share_constraints[name] = self.add_constraints( + self.shares[name] == expression, short_name=f'{name}->{self.label_full}' ) + if 'time' not in dims: self._eq_total.lhs -= self.shares[name] else: @@ -680,9 +616,4 @@ def __init__( def do_modeling(self): # eq: sum(flow_i.on(t)) <= 1.1 (1 wird etwas größer gewählt wg. Binärvariablengenauigkeit) - self.add( - self._model.add_constraints( - sum(self._simultanious_use_variables) <= 1.1, name=f'{self.label_full}|prevent_simultaneous_use' - ), - 'prevent_simultaneous_use', - ) + self.add_constraints(sum(self._simultanious_use_variables) <= 1.1, short_name='prevent_simultaneous_use') diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 8c539caa2..41f5b4224 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -324,7 +324,7 @@ def consecutive_duration_tracking( minimum_duration: Optional[TemporalData] = None, maximum_duration: Optional[TemporalData] = None, previous_duration: TemporalData = 0, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + ) -> Tuple[linopy.Variable, Tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint]]: """ Creates consecutive duration tracking for a binary state variable. diff --git a/flixopt/structure.py b/flixopt/structure.py index 59cb62251..b6c4572d1 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -714,51 +714,58 @@ def __init__( self._variables: Dict[str, linopy.Variable] = {} # Mapping from short name to variable self._constraints: Dict[str, linopy.Constraint] = {} # Mapping from short name to constraint - self.sub_models: Dict[str, 'Model'] = {} + self._sub_models: Dict[str, 'Model'] = {} logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"') - def add_variable(self, short_name: str, **kwargs) -> linopy.Variable: - """Create and add a variable in one step""" + def add_variables(self, short_name: str = None, **kwargs) -> linopy.Variable: + """Create and register a variable in one step""" if 'name' not in kwargs: + if short_name is None: + raise ValueError('Short name must be provided when no name is given') kwargs['name'] = f'{self.label_of_model}|{short_name}' variable = self._model.add_variables(**kwargs) self.register_variable(variable, short_name) return variable - def add_constraint(self, short_name: str, expression, **kwargs) -> linopy.Constraint: - """Create and add a constraint in one step""" + def add_constraints(self, expression, short_name: str = None, **kwargs) -> linopy.Constraint: + """Create and register a constraint in one step""" if 'name' not in kwargs: + if short_name is None: + raise ValueError('Short name must be provided when no name is given') kwargs['name'] = f'{self.label_of_model}|{short_name}' constraint = self._model.add_constraints(expression, **kwargs) self.register_constraint(constraint, short_name) return constraint - def register_variable(self, variable: linopy.Variable, short_name: str = None) -> None: + def register_variable(self, variable: linopy.Variable, short_name: str = None) -> linopy.Variable: """Register a variable with the model""" if short_name is None: short_name = self._extract_short_name(variable) if short_name in self._variables: raise ValueError(f'Short name "{short_name}" already assigned to model') self._variables[short_name] = variable + return variable - def register_constraint(self, constraint: linopy.Constraint, short_name: str = None) -> None: + def register_constraint(self, constraint: linopy.Constraint, short_name: str = None) -> linopy.Constraint: """Register a constraint with the model""" if short_name is None: short_name = self._extract_short_name(constraint) if short_name in self._constraints: raise ValueError(f'Short name "{short_name}" already assigned to model') self._constraints[short_name] = constraint + return constraint - def register_sub_model(self, sub_model: 'Model', short_name: str) -> None: + def register_sub_model(self, sub_model: 'Model', short_name: str) -> 'Model': """Register a sub-model with the model""" if short_name is None: short_name = sub_model.__class__.__name__ - if short_name in self.sub_models: + if short_name in self._sub_models: raise ValueError(f'Short name "{short_name}" already assigned to model') - self.sub_models[short_name] = sub_model + self._sub_models[short_name] = sub_model + return sub_model def __getitem__(self, key: str) -> linopy.Variable: """Get a variable by its short name""" @@ -766,6 +773,24 @@ def __getitem__(self, key: str) -> linopy.Variable: return self._variables[key] raise KeyError(f'Variable "{key}" not found in model "{self.label_full}"') + def __contains__(self, name: str) -> bool: + """Check if a variable exists in the model""" + return name in self._variables or name in self.variables + + def get(self, name: str, default=None): + """Get variable by short name, returning default if not found""" + try: + return self[name] + except KeyError: + return default + + def get_coords( + self, + dims: Optional[Collection[str]] = None, + extra_timestep: bool = False, + ) -> Optional[xr.Coordinates]: + return self._model.get_coords(dims=dims, extra_timestep=extra_timestep) + def filter_variables( self, filter_by: Optional[Literal['binary', 'continuous', 'integer']] = None, @@ -794,33 +819,44 @@ def label_full(self) -> str: return self.label_of_model @property - def variables(self) -> linopy.Variables: + def variables_direct(self) -> linopy.Variables: + """Variables of the model, excluding those of sub-models""" return self._model.variables[[var.name for var in self._variables.values()]] @property - def constraints(self) -> linopy.Constraints: + def constraints_direct(self) -> linopy.Constraints: + """Costraints of the model, excluding those of sub-models""" return self._model.constraints[[con.name for con in self._constraints.values()]] @property - def all_sub_models(self) -> List['Model']: - return [model for sub_model in self.sub_models.values() for model in [sub_model] + sub_model.all_sub_models] + def sub_models_direct(self) -> Dict[str, 'Model']: + """All sub-models of the model, excluding those of sub-models""" + return self._sub_models @property - def all_constraints(self) -> linopy.Constraints: - names = [constraint_name for constraint_name in self.constraints] + [ - constraint.name - for sub_model in self.all_sub_models - for constraint in sub_model.constraints.values() + def sub_models(self) -> List['Model']: + """All sub-models of the model""" + direct = list(self.sub_models_direct.values()) + return direct + [model for sub_model in direct for model in sub_model.sub_models] + + @property + def constraints(self) -> linopy.Constraints: + """All constraints of the model, including those of sub-models""" + names = list(self.constraints_direct) + [ + constraint_name + for sub_model in self.sub_models + for constraint_name in sub_model.constraints_direct ] return self._model.constraints[names] @property - def all_variables(self) -> linopy.Variables: - names = [variable_name for variable_name in self.variables] + [ - variable.name - for sub_model in self.all_sub_models - for variable in sub_model.constraints.values() + def variables(self) -> linopy.Variables: + """All variables of the model, including those of sub-models""" + names = list(self.variables_direct) + [ + variable_name + for sub_model in self.sub_models + for variable_name in sub_model.variables_direct ] return self._model.variables[names] From 099784384730e140f9b4f8da56125660417ae71f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 11:05:02 +0200 Subject: [PATCH 30/51] Simplify and focus more on own Model class --- flixopt/elements.py | 3 +- flixopt/features.py | 42 +++++--- flixopt/modeling.py | 252 ++++--------------------------------------- flixopt/structure.py | 44 ++++++-- 4 files changed, 84 insertions(+), 257 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index dec7425a7..9329a88dd 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -12,7 +12,7 @@ from .config import CONFIG from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser from .effects import TemporalEffectsUser -from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, ModelingPatterns, ModelingPrimitives +from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, ModelingPrimitives from .interface import InvestParameters, OnOffParameters from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io from .modeling import BoundingPatterns @@ -378,6 +378,7 @@ def do_modeling(self): self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else None, ), coords=['year', 'scenario'], + short_name='total_flow_hours', ) # Load factor constraints diff --git a/flixopt/features.py b/flixopt/features.py index 49c80d1c8..6708a3221 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -13,7 +13,7 @@ from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions from .interface import InvestParameters, OnOffParameters, Piecewise, PiecewiseEffects from .structure import Model, FlowSystemModel, BaseFeatureModel -from .modeling import ModelingPatterns, ModelingUtilities, ModelingPrimitives, BoundingPatterns +from .modeling import ModelingUtilities, ModelingPrimitives, BoundingPatterns logger = logging.getLogger('flixopt') @@ -57,15 +57,15 @@ def __init__( def create_variables_and_constraints(self): size_min, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size) self.add_variables( + short_name='size', lower=0 if self.parameters.optional else size_min, upper=size_max, - name=f'{self.label_of_model}|size', coords=self._model.get_coords(['year', 'scenario']), ) if self.parameters.optional: self.add_variables( - binary=True, name=f'{self.label_of_model}|is_invested', coords=self._model.get_coords(['year', 'scenario']) + binary=True, coords=self._model.get_coords(['year', 'scenario']), short_name='is_invested', ) BoundingPatterns.bounds_with_state( @@ -190,15 +190,24 @@ def create_variables_and_constraints(self): # 3. Total duration tracking using existing pattern duration_expr = (self.on * self._model.hours_per_step).sum('time') ModelingPrimitives.expression_tracking_variable( - self, f'{self.label_of_model}|on_hours_total', duration_expr, - (self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, - self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf),#TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) + self, duration_expr, short_name='on_hours_total', + bounds=( + self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, + self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf, + ), #TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) ) # 4. Switch tracking using existing pattern if self.parameters.use_switch_on: + self.add_variables(binary=True, short_name='switch|on', coords=self.get_coords()) + self.add_variables(binary=True, short_name='switch|off', coords=self.get_coords()) + ModelingPrimitives.state_transition_variables( - self, f'{self.label_of_model}|switch', self.on, + self, + state_variable=self.on, + switch_on=self.switch_on, + switch_off=self.switch_off, + name=f'{self.label_of_model}|switch', previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rate), ) @@ -210,8 +219,8 @@ def create_variables_and_constraints(self): if self.parameters.use_consecutive_on_hours: ModelingPrimitives.consecutive_duration_tracking( self, - f'{self.label_of_model}|consecutive_on_hours', #TODO: Change name - self.on, + state_variable=self.on, + short_name='consecutive_on_hours', minimum_duration=self.parameters.consecutive_on_hours_min, maximum_duration=self.parameters.consecutive_on_hours_max, previous_duration=ModelingUtilities.compute_previous_on_duration([self._previous_flow_rate], self._model.hours_per_step), @@ -221,11 +230,13 @@ def create_variables_and_constraints(self): if self.parameters.use_consecutive_off_hours: ModelingPrimitives.consecutive_duration_tracking( self, - f'{self.label_of_model}|consecutive_off_hours', - self.off, + state_variable=self.off, + short_name='consecutive_off_hours', minimum_duration=self.parameters.consecutive_off_hours_min, maximum_duration=self.parameters.consecutive_off_hours_max, - previous_duration=ModelingUtilities.compute_previous_off_duration([self._previous_flow_rate], self._model.hours_per_step), + previous_duration=ModelingUtilities.compute_previous_off_duration( + [self._previous_flow_rate], self._model.hours_per_step + ), ) def add_effects(self): @@ -328,7 +339,7 @@ def do_modeling(self): self.lambda0 = self.add_variables( lower=0, upper=1, - name='lambda0', + short_name='lambda0', coords=self._model.get_coords(dims=dims), ) @@ -568,11 +579,12 @@ def add_share( else: self.shares[name] = self.add_variables( coords=self._model.get_coords(dims), - short_name=f'{name}->{self.label_full}', + name=f'{name}->{self.label_full}', + short_name=name, ) self.share_constraints[name] = self.add_constraints( - self.shares[name] == expression, short_name=f'{name}->{self.label_full}' + self.shares[name] == expression, name=f'{name}->{self.label_full}' ) if 'time' not in dims: diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 41f5b4224..17f47c939 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -148,68 +148,12 @@ def get_most_recent_state(previous_values: List[TemporalData]) -> int: class ModelingPrimitives: """Mathematical modeling primitives returning (variables, constraints) tuples""" - @staticmethod - def binary_state_pair( - model: FlowSystemModel, name: str, coords: List[str] = None, use_complement: bool = True - ) -> Tuple[Tuple[linopy.Variable, linopy.Variable], linopy.Constraint]: - """ - Creates complementary binary variables with completeness constraint. - - Mathematical formulation: - on[t] + off[t] = 1 ∀t - on[t], off[t] ∈ {0, 1} - """ - coords = coords or ['time'] - - on = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(coords)) - off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(coords)) - - # Constraint: on + off = 1 - complementary = model.add_constraints(on + off == 1, name=f'{name}|complementary') - - return (on, off), complementary - - @staticmethod - def proportionally_bounded_variable( - model: FlowSystemModel, - name: str, - controlling_variable, - bounds: Tuple[TemporalData, TemporalData], - coords: List[str] = None, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Creates variable with bounds proportional to another variable. - - Mathematical formulation: - lower_factor[t] * controller[t] ≤ variable[t] ≤ upper_factor[t] * controller[t] ∀t - - Returns: - variables: {'variable': bounded_var} - constraints: {'lb': constraint, 'ub': constraint} - """ - coords = coords or ['time'] - variable = model.add_variables(name=f'{name}|bounded', coords=model.get_coords(coords)) - - lower_factor, upper_factor = bounds - - # Constraints: lower_factor * controller ≤ var ≤ upper_factor * controller - lower_bound = model.add_constraints( - variable >= controlling_variable * lower_factor, name=f'{name}|proportional_lb' - ) - upper_bound = model.add_constraints( - variable <= controlling_variable * upper_factor, name=f'{name}|proportional_ub' - ) - - variables = {'variable': variable} - constraints = {'lb': lower_bound, 'ub': upper_bound} - - return variables, constraints - @staticmethod def expression_tracking_variable( - model: FlowSystemModel, - name: str, + model: Model, tracked_expression, + name: str = None, + short_name: str = None, bounds: Tuple[TemporalData, TemporalData] = None, coords: List[str] = None, ) -> Tuple[linopy.Variable, linopy.Constraint]: @@ -227,27 +171,30 @@ def expression_tracking_variable( coords = coords or ['year', 'scenario'] if not bounds: - tracker = model.add_variables(name=f'{name}', coords=model.get_coords(coords)) + tracker = model.add_variables(name=name, coords=model.get_coords(coords), short_name=short_name) else: tracker = model.add_variables( lower=bounds[0] if bounds[0] is not None else -np.inf, upper=bounds[1] if bounds[1] is not None else np.inf, - name=f'{name}', + name=name, coords=model.get_coords(coords), + short_name=short_name, ) # Constraint: tracker = expression - tracking = model.add_constraints(tracker == tracked_expression, name=f'{name}') + tracking = model.add_constraints(tracker == tracked_expression, name=name, short_name=short_name) return tracker, tracking @staticmethod def state_transition_variables( - model: FlowSystemModel, - name: str, + model: Union[FlowSystemModel, Model], state_variable: linopy.Variable, + switch_on: linopy.Variable, + switch_off: linopy.Variable, + name: str, previous_state=0, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: + ) -> Tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint]: """ Creates switch-on/off variables with state transition logic. @@ -261,14 +208,11 @@ def state_transition_variables( variables: {'switch_on': binary_var, 'switch_off': binary_var} constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint} """ - switch_on = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) - switch_off = model.add_variables(binary=True, name=f'{name}|off', coords=model.get_coords(['time'])) - # State transition constraints for t > 0 transition = model.add_constraints( switch_on.isel(time=slice(1, None)) - switch_off.isel(time=slice(1, None)) == state_variable.isel(time=slice(1, None)) - state_variable.isel(time=slice(None, -1)), - name=name, + name=f'{name}|transition', ) # Initial state transition for t = 0 @@ -280,13 +224,14 @@ def state_transition_variables( # At most one switch per timestep mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|mutex') - return {'on': switch_on, 'off': switch_off}, {'transition': transition, 'initial': initial, 'mutex': mutex} + return transition, initial, mutex @staticmethod def sum_up_variable( model: FlowSystemModel, variable_to_count: linopy.Variable, - name: str, + name: str = None, + short_name: str = None, bounds: Tuple[NonTemporalData, NonTemporalData] = None, factor: TemporalData = 1, ) -> Tuple[linopy.Variable, linopy.Constraint]: @@ -319,8 +264,9 @@ def sum_up_variable( @staticmethod def consecutive_duration_tracking( model: FlowSystemModel, - name: str, state_variable: linopy.Variable, + name: str = None, + short_name: str = None, minimum_duration: Optional[TemporalData] = None, maximum_duration: Optional[TemporalData] = None, previous_duration: TemporalData = 0, @@ -357,6 +303,7 @@ def consecutive_duration_tracking( upper=maximum_duration if maximum_duration is not None else mega, coords=model.get_coords(['time']), name=name, + short_name=short_name, ) constraints = {} @@ -708,164 +655,3 @@ def auto_bounds( return BoundingPatterns.basic_bounds(model, variable, bounds) raise ValueError('Invalid combination of arguments') - - -class ModelingPatterns: - """High-level patterns that compose primitives and return (variables, constraints) tuples""" - - @staticmethod - def investment_sizing_pattern( - model: FlowSystemModel, - name: str, - size_bounds: Tuple[TemporalData, TemporalData], - controlled_variable: linopy.Variable, - control_factors: Tuple[TemporalData, TemporalData], - state_variable: List[linopy.Variable] = None, - optional: bool = False, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Complete investment sizing pattern with optional binary decision. - - Args: - model: The model to add the variables to. - name: The name of the investment variable. - size_bounds: The minimum and maximum investment size. - controlled_variables: The variables that are controlled by the investment decision. - control_factors: The control factors for the controlled variables. - state_variables: State variable defining the state of the controlled variables. - optional: Whether the investment decision is optional. - - Returns: - variables: {'size': size_var, 'is_invested': binary_var (if optional)} - constraints: {'ub': constraint, 'lb': constraint, ...} - """ - variables = {} - constraints = {} - - # Investment size variable - size_min, size_max = size_bounds - variables['size'] = model.add_variables( - lower=0 if optional else size_min, - upper=size_max, - name=f'{name}|size', - coords=model.get_coords(['year', 'scenario']), - ) - - # Optional binary investment decision - if optional: - variables['is_invested'] = model.add_variables( - binary=True, name=f'{name}|is_invested', coords=model.get_coords(['year', 'scenario']) - ) - - _, new_cons = BoundingPatterns.auto_bounds( - model=model, - variable=controlled_variable, - bounds=control_factors, - upper_bound_name=f'{controlled_variable.name}|ub', - lower_bound_name=f'{controlled_variable.name}|lb', - scaling_variable=variables['size'], - binary_control=variables['is_invested'] if optional else None, - scaling_bounds=(size_min, size_max), - constraint_name_prefix=name, - ) - - constraints.update(new_cons) - - return variables, constraints - - @staticmethod - def operational_binary_control_pattern( - model: FlowSystemModel, - name: str, - controlled_variables: List[linopy.Variable], - variable_bounds: List[Tuple[TemporalData, TemporalData]], - use_complement: bool = False, - track_total_duration: bool = False, - track_switches: bool = False, - previous_state=0, - duration_bounds: Tuple[TemporalData, TemporalData] = None, - track_consecutive_on: bool = False, - consecutive_on_bounds: Tuple[Optional[TemporalData], Optional[TemporalData]] = (None, None), - previous_on_duration: TemporalData = 0, - track_consecutive_off: bool = False, - consecutive_off_bounds: Tuple[Optional[TemporalData], Optional[TemporalData]] = (None, None), - previous_off_duration: TemporalData = 0, - ) -> Tuple[Dict[str, linopy.Variable], Dict[str, linopy.Constraint]]: - """ - Enhanced operational binary control using composable patterns. - """ - variables = {} - constraints = {} - - # 1. Main binary state using existing pattern - if use_complement: - state_vars, state_constraints = ModelingPrimitives.binary_state_pair(model, name) - variables.update(state_vars) - constraints.update(state_constraints) - else: - variables['on'] = model.add_variables(binary=True, name=f'{name}|on', coords=model.get_coords(['time'])) - - # 2. Control variables - use big_m_binary_bounds pattern for consistency - for i, (var, (lower_bound, upper_bound)) in enumerate(zip(controlled_variables, variable_bounds)): - # Use the big_m pattern but without binary control (None) - _, control_constraints = BoundingPatterns.big_m_binary_bounds( - model=model, - variable=var, - binary_control=variables['on'], # The on state controls the variables - size_variable=1, # No size scaling, just on/off - relative_bounds=(lower_bound, upper_bound), - upper_bound_name=f'{name}|control_{i}_upper', - lower_bound_name=f'{name}|control_{i}_lower', - ) - constraints[f'control_{i}_upper'] = control_constraints['ub'] - constraints[f'control_{i}_lower'] = control_constraints['lb'] - - # 3. Total duration tracking using existing pattern - if track_total_duration: - duration_expr = (variables['on'] * model.hours_per_step).sum('time') - duration_vars, duration_constraints = ModelingPrimitives.expression_tracking_variable( - model, f'{name}|on_hours_total', duration_expr, duration_bounds - ) - variables['total_duration'] = duration_vars['tracker'] - constraints['duration_tracking'] = duration_constraints['tracking'] - - # 4. Switch tracking using existing pattern - if track_switches: - switch_vars, switch_constraints = ModelingPrimitives.state_transition_variables( - model, f'{name}|switches', variables['on'], previous_state - ) - variables.update(switch_vars) - for switch_name, switch_constraint in switch_constraints.items(): - constraints[f'switch_{switch_name}'] = switch_constraint - - # 5. Consecutive on duration using existing pattern - if track_consecutive_on: - min_on, max_on = consecutive_on_bounds - consecutive_on_vars, consecutive_on_constraints = ModelingPrimitives.consecutive_duration_tracking( - model, - f'{name}|consecutive_on', - variables['on'], - minimum_duration=min_on, - maximum_duration=max_on, - previous_duration=previous_on_duration, - ) - variables['consecutive_on_duration'] = consecutive_on_vars['duration'] - for cons_name, cons_constraint in consecutive_on_constraints.items(): - constraints[f'consecutive_on_{cons_name}'] = cons_constraint - - # 6. Consecutive off duration using existing pattern - if track_consecutive_off and 'off' in variables: - min_off, max_off = consecutive_off_bounds - consecutive_off_vars, consecutive_off_constraints = ModelingPrimitives.consecutive_duration_tracking( - model, - f'{name}|consecutive_off', - variables['off'], - minimum_duration=min_off, - maximum_duration=max_off, - previous_duration=previous_off_duration, - ) - variables['consecutive_off_duration'] = consecutive_off_vars['duration'] - for cons_name, cons_constraint in consecutive_off_constraints.items(): - constraints[f'consecutive_off_{cons_name}'] = cons_constraint - - return variables, constraints diff --git a/flixopt/structure.py b/flixopt/structure.py index b6c4572d1..953636f9b 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -720,7 +720,7 @@ def __init__( def add_variables(self, short_name: str = None, **kwargs) -> linopy.Variable: """Create and register a variable in one step""" - if 'name' not in kwargs: + if kwargs.get('name') is None: if short_name is None: raise ValueError('Short name must be provided when no name is given') kwargs['name'] = f'{self.label_of_model}|{short_name}' @@ -731,7 +731,7 @@ def add_variables(self, short_name: str = None, **kwargs) -> linopy.Variable: def add_constraints(self, expression, short_name: str = None, **kwargs) -> linopy.Constraint: """Create and register a constraint in one step""" - if 'name' not in kwargs: + if kwargs.get('name') is None: if short_name is None: raise ValueError('Short name must be provided when no name is given') kwargs['name'] = f'{self.label_of_model}|{short_name}' @@ -743,18 +743,20 @@ def add_constraints(self, expression, short_name: str = None, **kwargs) -> linop def register_variable(self, variable: linopy.Variable, short_name: str = None) -> linopy.Variable: """Register a variable with the model""" if short_name is None: - short_name = self._extract_short_name(variable) - if short_name in self._variables: - raise ValueError(f'Short name "{short_name}" already assigned to model') + short_name = variable.name + elif short_name in self._variables: + raise ValueError(f'Short name "{short_name}" already assigned to model variables') + self._variables[short_name] = variable return variable def register_constraint(self, constraint: linopy.Constraint, short_name: str = None) -> linopy.Constraint: """Register a constraint with the model""" if short_name is None: - short_name = self._extract_short_name(constraint) - if short_name in self._constraints: - raise ValueError(f'Short name "{short_name}" already assigned to model') + short_name = constraint.name + elif short_name in self._constraints: + raise ValueError(f'Short name "{short_name}" already assigned to model constraint') + self._constraints[short_name] = constraint return constraint @@ -871,6 +873,28 @@ def _extract_short_name(item: Union[linopy.Variable, linopy.Constraint]) -> str: else: return name # Use full name if no | separator + def __repr__(self) -> str: + """ + Return a string representation of the linopy model. + """ + var_string = self.variables.__repr__().split("\n", 2)[2] + con_string = self.constraints.__repr__().split("\n", 2)[2] + model_string = f"Linopy {self._model.type} submodel: {self.label_of_model}" + + if len(self.sub_models) == 0: + sub_models_string = ' \n' + else: + sub_models_string = '' + for sub_model in self.sub_models: + sub_models_string += f'\n * {sub_model.label_of_model}' + + return ( + f"{model_string}\n{'=' * len(model_string)}\n\n" + f"Variables:\n----------\n{var_string}\n" + f"Constraints:\n------------\n{con_string}\n" + f"Submodels:\n----------\n{sub_models_string}" + ) + class BaseFeatureModel(Model): """Minimal base class for feature models that use factory patterns""" @@ -900,6 +924,10 @@ def add_effects(self): """Override in subclasses to add effects""" pass # Default: no effects + @property + def hours_per_step(self): + return self._model.hours_per_step + class ElementModel(Model): """Stores the mathematical Variables and Constraints for Elements""" From 1d6ef9745b11c29fa5d7c2c35ac27cb8aab7e5a1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 11:05:39 +0200 Subject: [PATCH 31/51] Update tests --- tests/test_component.py | 24 ++++++++++++------------ tests/test_flow.py | 6 +++--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/test_component.py b/tests/test_component.py index fbedbd415..f65c93414 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -97,16 +97,16 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): assert set(comp.model.constraints) == { 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|on|lb', - 'TestComponent(In1)|on|ub', + 'TestComponent(In1)|flow_rate|lb', + 'TestComponent(In1)|flow_rate|ub', 'TestComponent(In1)|on_hours_total', 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out1)|on|lb', - 'TestComponent(Out1)|on|ub', + 'TestComponent(Out1)|flow_rate|lb', + 'TestComponent(Out1)|flow_rate|ub', 'TestComponent(Out1)|on_hours_total', 'TestComponent(Out2)|total_flow_hours', - 'TestComponent(Out2)|on|lb', - 'TestComponent(Out2)|on|ub', + 'TestComponent(Out2)|flow_rate|lb', + 'TestComponent(Out2)|flow_rate|ub', 'TestComponent(Out2)|on_hours_total', 'TestComponent|on|lb', 'TestComponent|on|ub', @@ -118,8 +118,8 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords = (timesteps,))) assert_var_equal(model['TestComponent(Out2)|on'], model.add_variables(binary=True, coords=(timesteps,))) - assert_conequal(model.constraints['TestComponent(Out2)|on|lb'], model.variables['TestComponent(Out2)|on'] * 0.3 * 300 <= model.variables['TestComponent(Out2)|flow_rate']) - assert_conequal(model.constraints['TestComponent(Out2)|on|ub'], model.variables['TestComponent(Out2)|on'] * 300 * ub_out2 >= model.variables['TestComponent(Out2)|flow_rate']) + assert_conequal(model.constraints['TestComponent(Out2)|flow_rate|lb'], model.variables['TestComponent(Out2)|flow_rate'] >= model.variables['TestComponent(Out2)|on'] * 0.3 * 300) + assert_conequal(model.constraints['TestComponent(Out2)|flow_rate|ub'], model.variables['TestComponent(Out2)|flow_rate'] <= model.variables['TestComponent(Out2)|on'] * 300 * ub_out2) assert_conequal(model.constraints['TestComponent|on|lb'], model.variables['TestComponent|on'] * 1e-5 <= model.variables['TestComponent(In1)|flow_rate'] + model.variables['TestComponent(Out1)|flow_rate'] + model.variables['TestComponent(Out2)|flow_rate']) @@ -156,8 +156,8 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): assert set(comp.model.constraints) == { 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|on|lb', - 'TestComponent(In1)|on|ub', + 'TestComponent(In1)|flow_rate|lb', + 'TestComponent(In1)|flow_rate|ub', 'TestComponent(In1)|on_hours_total', 'TestComponent|on|lb', 'TestComponent|on|ub', @@ -171,11 +171,11 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): assert_var_equal(model['TestComponent(In1)|on'], model.add_variables(binary=True, coords=(timesteps,))) assert_conequal( - model.constraints['TestComponent(In1)|on|lb'], + model.constraints['TestComponent(In1)|flow_rate|lb'], model.variables['TestComponent(In1)|on'] * 0.1 * 100 <= model.variables['TestComponent(In1)|flow_rate'], ) assert_conequal( - model.constraints['TestComponent(In1)|on|ub'], + model.constraints['TestComponent(In1)|flow_rate|ub'], model.variables['TestComponent(In1)|on'] * 100 >= model.variables['TestComponent(In1)|flow_rate'], ) diff --git a/tests/test_flow.py b/tests/test_flow.py index 5b99a79f2..50154859d 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -491,11 +491,11 @@ def test_flow_on(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], - flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|on'] * 0.2 * 100, + flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|on'] * 0.2 * 100, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.model.variables['Sink(Wärme)|flow_rate']<= flow.model.variables['Sink(Wärme)|on'] * 0.8 * 100, + flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|on'] * 0.8 * 100, ) assert_conequal( @@ -842,7 +842,7 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): # Check that constraints exist assert { - 'Sink(Wärme)|switch', + 'Sink(Wärme)|switch|transition', 'Sink(Wärme)|switch|initial', 'Sink(Wärme)|switch|mutex', 'Sink(Wärme)|switch|count', From 972cb901920b4c8dfc39bc3a497439f7c75bfe1a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 14:22:22 +0200 Subject: [PATCH 32/51] Improve state computation in ModelingUtilities --- flixopt/elements.py | 207 +++++++++++++++++++++++-------------------- flixopt/features.py | 80 ++++++----------- flixopt/modeling.py | 167 +++++++++++++++++++--------------- flixopt/structure.py | 8 +- 4 files changed, 239 insertions(+), 223 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 9329a88dd..796d82864 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -8,6 +8,7 @@ import linopy import numpy as np +import xarray as xr from .config import CONFIG from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser @@ -15,7 +16,7 @@ from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, ModelingPrimitives from .interface import InvestParameters, OnOffParameters from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io -from .modeling import BoundingPatterns +from .modeling import BoundingPatterns, ModelingUtilities if TYPE_CHECKING: from .flow_system import FlowSystem @@ -316,57 +317,13 @@ def __init__(self, model: FlowSystemModel, element: Flow): def do_modeling(self): # Main flow rate variable self.add_variables( - lower=self.flow_rate_lower_bound, - upper=self.flow_rate_upper_bound, + lower=self.absolute_flow_rate_bounds[0], + upper=self.absolute_flow_rate_bounds[1], coords=self._model.get_coords(), short_name='flow_rate', ) - default_cons = not (self.element.on_off_parameters is not None and isinstance(self.element.size, InvestParameters)) - - # OnOff feature - if self.element.on_off_parameters is not None: - self.register_sub_model( - OnOffModel( - model=self._model, - label_of_element=self.label_of_element, - parameters=self.element.on_off_parameters, - flow_rate=self.flow_rate, - flow_rate_bounds=self.flow_rate_bounds_on, - previous_flow_rate=self.element.previous_flow_rate, - label_of_model=self.label_of_element, - apply_bounds_to_flow_rates=default_cons, - ), - short_name='on_off', - ).do_modeling() - - # Investment feature - if isinstance(self.element.size, InvestParameters): - self.register_sub_model( - InvestmentModel( - model=self._model, - label_of_element=self.label_of_element, - label_of_model=self.label_of_element, - parameters=self.element.size, - defining_variable=self.flow_rate, - relative_bounds_of_defining_variable=( - self.flow_rate_lower_bound_relative, - self.flow_rate_upper_bound_relative, - ), - apply_bounds_to_defining_variable=default_cons, - ), - 'investment', - ).do_modeling() - - if not default_cons: - BoundingPatterns.scaled_bounds_with_state( - model=self, - variable=self.flow_rate, - scaling_variable=self._investment.size, - relative_bounds=(self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative), - scaling_bounds=(self.element.size.minimum_or_fixed_size, self.element.size.maximum_or_fixed_size), - variable_state=self.on_off.on, - ) + self._constraint_flow_rate() # Total flow hours tracking ModelingPrimitives.expression_tracking_variable( @@ -387,6 +344,81 @@ def do_modeling(self): # Effects self._create_shares() + def _create_on_off_model(self): + on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords()) + self.register_sub_model( + OnOffModel( + model=self._model, + label_of_element=self.label_of_element, + parameters=self.element.on_off_parameters, + on_variable=on, + previous_states=self.previous_states, + label_of_model=self.label_of_element, + ), + short_name='on_off', + ).do_modeling() + + def _create_investment_model(self): + self.register_sub_model( + InvestmentModel( + model=self._model, + label_of_element=self.label_of_element, + parameters=self.element.size, + label_of_model=self.label_of_element, + ), + 'investment', + ).do_modeling() + + def _constraint_flow_rate(self): + if not self.with_investment and not self.with_on_off: + # Most basic case. Already covered by direct variable bounds + pass + + elif self.with_on_off and not self.with_investment: + # OnOff, but no Investment + self._create_on_off_model() + bounds = self.relative_flow_rate_bounds + BoundingPatterns.bounds_with_state( + self, + variable=self.flow_rate, + bounds=(bounds[0] * self.element.size, bounds[1] * self.element.size), + variable_state=self.on_off.on, + ) + + elif self.with_investment and not self.with_on_off: + # Investment, but no OnOff + self._create_investment_model() + BoundingPatterns.scaled_bounds( + self, + variable=self.flow_rate, + scaling_variable=self.investment.size, + relative_bounds=self.relative_flow_rate_bounds, + ) + + elif self.with_investment and self.with_on_off: + # Investment and OnOff + self._create_investment_model() + self._create_on_off_model() + + BoundingPatterns.scaled_bounds_with_state( + model=self, + variable=self.flow_rate, + scaling_variable=self._investment.size, + relative_bounds=self.relative_flow_rate_bounds, + scaling_bounds=(self.element.size.minimum_or_fixed_size, self.element.size.maximum_or_fixed_size), + variable_state=self.on_off.on, + ) + else: + raise Exception('Not valid') + + @property + def with_on_off(self) -> bool: + return self.element.on_off_parameters is not None + + @property + def with_investment(self) -> bool: + return isinstance(self.element.size, InvestParameters) + # Properties for clean access to variables @property def flow_rate(self) -> linopy.Variable: @@ -421,7 +453,7 @@ def _create_shares(self): def _create_bounds_for_load_factor(self): """Create load factor constraints using current approach""" # Get the size (either from element or investment) - size = self.element.size if self._investment is None else self._investment.size + size = self.investment.size if self.with_investment else self.element.size # Maximum load factor constraint if self.element.load_factor_max is not None: @@ -440,57 +472,34 @@ def _create_bounds_for_load_factor(self): ) @property - def flow_rate_bounds_on(self) -> Tuple[TemporalData, TemporalData]: - """Returns absolute flow rate bounds for OnOffModel""" - relative_minimum, relative_maximum = self.flow_rate_lower_bound_relative, self.flow_rate_upper_bound_relative - size = self.element.size - if not isinstance(size, InvestParameters): - return relative_minimum * size, relative_maximum * size - - if size.fixed_size is not None: - return relative_minimum * size.fixed_size, relative_maximum * size.fixed_size - - return relative_minimum * size.minimum_or_fixed_size, relative_maximum * size.maximum_or_fixed_size - - @property - def flow_rate_lower_bound_relative(self) -> TemporalData: - """Returns the lower bound of the flow_rate relative to its size""" - fixed_profile = self.element.fixed_relative_profile - if fixed_profile is None: - return self.element.relative_minimum - return fixed_profile + def relative_flow_rate_bounds(self) -> Tuple[TemporalData, TemporalData]: + if self.element.fixed_relative_profile is not None: + return self.element.fixed_relative_profile, self.element.fixed_relative_profile + return self.element.relative_minimum, self.element.relative_maximum @property - def flow_rate_upper_bound_relative(self) -> TemporalData: - """Returns the upper bound of the flow_rate relative to its size""" - fixed_profile = self.element.fixed_relative_profile - if fixed_profile is None: - return self.element.relative_maximum - return fixed_profile - - @property - def flow_rate_lower_bound(self) -> TemporalData: + def absolute_flow_rate_bounds(self) -> Tuple[TemporalData, TemporalData]: """ - Returns the minimum bound the flow_rate can reach. - Further constraining might be done in OnOffModel and InvestmentModel + Returns the absolute bounds the flow_rate can reach. + Further constraining might be needed """ - if self.element.on_off_parameters is not None: - return 0 - if isinstance(self.element.size, InvestParameters): - if self.element.size.optional: - return 0 - return self.flow_rate_lower_bound_relative * self.element.size.minimum_or_fixed_size - return self.flow_rate_lower_bound_relative * self.element.size + lb_relative, ub_relative = self.relative_flow_rate_bounds + + lb = 0 + if not self.with_on_off: + if not self.with_investment: + # Basic case without investment and without OnOff + lb = lb_relative * self.element.size + elif not self.element.size.optional: + # With non-optional Investment + lb = lb_relative * self.element.size.minimum_or_fixed_size + + if self.with_investment: + ub = ub_relative * self.element.size.maximum_or_fixed_size + else: + ub = ub_relative * self.element.size - @property - def flow_rate_upper_bound(self) -> TemporalData: - """ - Returns the maximum bound the flow_rate can reach. - Further constraining might be done in OnOffModel and InvestmentModel - """ - if isinstance(self.element.size, InvestParameters): - return self.flow_rate_upper_bound_relative * self.element.size.maximum_or_fixed_size - return self.flow_rate_upper_bound_relative * self.element.size + return lb, ub @property def on_off(self) -> Optional[OnOffModel]: @@ -511,6 +520,14 @@ def investment(self) -> Optional[InvestmentModel]: return None return self.sub_models_direct['investment'] + @property + def previous_states(self) -> Optional[xr.DataArray]: + """Previous states of the flow rate""" + if self.element.previous_flow_rate is None: + return None + + return ModelingUtilities.compute_previous_states(self.element.previous_flow_rate) + class BusModel(ElementModel): def __init__(self, model: FlowSystemModel, element: Bus): diff --git a/flixopt/features.py b/flixopt/features.py index 6708a3221..a9e2dc589 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -26,10 +26,7 @@ def __init__( model: FlowSystemModel, label_of_element: str, parameters: InvestParameters, - defining_variable: linopy.Variable, - relative_bounds_of_defining_variable: Tuple[TemporalData, TemporalData], label_of_model: Optional[str] = None, - apply_bounds_to_defining_variable: bool = True, ): """ This feature model is used to model the investment of a variable. @@ -46,11 +43,6 @@ def __init__( """ super().__init__(model, label_of_element=label_of_element, parameters=parameters, label_of_model=label_of_model) - self._defining_variable = defining_variable - self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable - self._apply_bounds_to_defining_variable = apply_bounds_to_defining_variable - - # Only keep non-variable attributes self.piecewise_effects: Optional[PiecewiseEffectsModel] = None @@ -75,14 +67,6 @@ def create_variables_and_constraints(self): bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) - if self._apply_bounds_to_defining_variable: - BoundingPatterns.scaled_bounds( - self, - variable=self._defining_variable, - scaling_variable=self.size, - relative_bounds=self._relative_bounds_of_defining_variable, - ) - if self.parameters.piecewise_effects: self.piecewise_effects = self.register_sub_model( PiecewiseEffectsModel( @@ -146,11 +130,9 @@ def __init__( model: FlowSystemModel, label_of_element: str, parameters: OnOffParameters, - flow_rate: linopy.Variable, - flow_rate_bounds: Tuple[TemporalData, TemporalData], - previous_flow_rate: Optional[TemporalData], + on_variable: linopy.Variable, + previous_states: Optional[TemporalData], label_of_model: Optional[str] = None, - apply_bounds_to_flow_rates: bool = True, ): """ This feature model is used to model the on/off state of flow_rate(s). It does not matter of the flow_rates are @@ -160,32 +142,18 @@ def __init__( model: The optimization model instance label_of_element: The label of the parent (Element). Used to construct the full label of the model. parameters: The parameters of the feature model. - flow_rate: The flow_rates to be modeled - flow_rate_bounds: The bounds of the flow_rates, with respect to the minimum/maximum investment sizes - previous_flow_rate: The previous flow_rates + on_variable: The variable that determines the on state + previous_states: The previous flow_rates label_of_model: The label of the model. This is needed to construct the full label of the model. """ super().__init__(model, label_of_element, parameters=parameters, label_of_model=label_of_model) - self._flow_rate = flow_rate - self._flow_rate_bounds = flow_rate_bounds - self._previous_flow_rate = previous_flow_rate - self._apply_bounds_to_flow_rates = apply_bounds_to_flow_rates + self.on = on_variable + self._previous_states = previous_states def create_variables_and_constraints(self): - # 1. Main binary state using existing pattern - on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords()) if self.parameters.use_off: off = self.add_variables(binary=True, short_name='off', coords=self._model.get_coords()) - self.add_constraints(on + off == 1, short_name='complementary') - - # 2. Control variables - if self._apply_bounds_to_flow_rates: - BoundingPatterns.bounds_with_state( - self, - variable=self._flow_rate, - bounds=self._flow_rate_bounds, - variable_state=self.on, - ) + self.add_constraints(self.on + off == 1, short_name='complementary') # 3. Total duration tracking using existing pattern duration_expr = (self.on * self._model.hours_per_step).sum('time') @@ -208,7 +176,9 @@ def create_variables_and_constraints(self): switch_on=self.switch_on, switch_off=self.switch_off, name=f'{self.label_of_model}|switch', - previous_state=ModelingUtilities.get_most_recent_state(self._previous_flow_rate), + previous_state=ModelingUtilities.get_most_recent_state( + self._previous_states.isel(time=-1) + ) if self._previous_states is not None else 0, ) if self.parameters.switch_on_total_max is not None: @@ -223,7 +193,7 @@ def create_variables_and_constraints(self): short_name='consecutive_on_hours', minimum_duration=self.parameters.consecutive_on_hours_min, maximum_duration=self.parameters.consecutive_on_hours_max, - previous_duration=ModelingUtilities.compute_previous_on_duration([self._previous_flow_rate], self._model.hours_per_step), + previous_duration=self._get_previous_on_duration(), ) # 6. Consecutive off duration using existing pattern @@ -234,10 +204,9 @@ def create_variables_and_constraints(self): short_name='consecutive_off_hours', minimum_duration=self.parameters.consecutive_off_hours_min, maximum_duration=self.parameters.consecutive_off_hours_max, - previous_duration=ModelingUtilities.compute_previous_off_duration( - [self._previous_flow_rate], self._model.hours_per_step - ), + previous_duration=self._get_previous_off_duration(), ) + #TODO: def add_effects(self): """Add operational effects""" @@ -261,10 +230,6 @@ def add_effects(self): ) # Properties access variables from Model's tracking system - @property - def on(self) -> Optional[linopy.Variable]: - """Binary on state variable""" - return self['on'] @property def total_on_hours(self) -> Optional[linopy.Variable]: @@ -302,15 +267,20 @@ def consecutive_off_hours(self) -> Optional[linopy.Variable]: return self.get('consecutive_off_hours') def _get_previous_on_duration(self): - hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] - return ModelingUtilities.compute_previous_on_duration([self._previous_flow_rate], hours_per_step) + """Get previous on duration. Previously OFF by default, for one timestep""" + hours_per_step = self._model.hours_per_step.isel(time=0).min().item() + if self._previous_states is None: + return 0 + else: + return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_states, hours_per_step) def _get_previous_off_duration(self): - hours_per_step = self._model.hours_per_step.isel(time=0).values.flatten()[0] - return ModelingUtilities.compute_previous_off_duration(self._previous_flow_rates, hours_per_step) - - def _get_previous_state(self): - return ModelingUtilities.get_most_recent_state(self._previous_flow_rates) + """Get previous off duration. Previously OFF by default, for one timestep""" + hours_per_step = self._model.hours_per_step.isel(time=0).min().item() + if self._previous_states is None: + return hours_per_step + else: + return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_states * -1 + 1, hours_per_step) class PieceModel(Model): diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 17f47c939..0c989d01e 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -3,6 +3,7 @@ import linopy import numpy as np +import xarray as xr from .config import CONFIG from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions @@ -11,138 +12,166 @@ logger = logging.getLogger('flixopt') -class ModelingUtilities: - """Utility functions for modeling calculations - used across different classes""" +class ModelingUtilitiesAbstract: + """Utility functions for modeling calculations - leveraging xarray for temporal data""" @staticmethod - def compute_consecutive_hours_in_state( - binary_values: TemporalData, hours_per_timestep: Union[int, float, np.ndarray] - ) -> Scalar: + def to_binary( + values: xr.DataArray, + epsilon: Optional[float] = None, + dims: Optional[Union[str, List[str]]] = None, + ) -> xr.DataArray: """ - Computes the final consecutive duration in state 'on' (=1) in hours, from a binary array. + Converts a DataArray to binary {0, 1} values. Args: - binary_values: An int or 1D binary array containing only `0`s and `1`s. - hours_per_timestep: The duration of each timestep in hours. - If a scalar is provided, it is used for all timesteps. - If an array is provided, it must be as long as the last consecutive duration in binary_values. + values: Input DataArray to convert to binary + epsilon: Tolerance for zero detection (uses CONFIG.modeling.EPSILON if None) + dims: Dims to keep. Other dimensions are collapsed using .any() -> If any value is 1, all are 1. Returns: - The duration of the binary variable in hours. - - Raises - ------ - TypeError - If the length of binary_values and dt_in_hours is not equal, but None is a scalar. + Binary DataArray with same shape (or collapsed if collapse_non_time=True) """ - if np.isscalar(binary_values) and np.isscalar(hours_per_timestep): - return binary_values * hours_per_timestep - elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): - return binary_values * hours_per_timestep[-1] + if not isinstance(values, xr.DataArray): + values = xr.DataArray(values, dims=['time'], coords={'time': range(len(values))}) - if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON): - return 0 + if epsilon is None: + epsilon = CONFIG.modeling.EPSILON - if np.isscalar(hours_per_timestep): - hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep - hours_per_timestep: np.ndarray + if values.size == 0: + return xr.DataArray(0) if values.item() < epsilon else xr.DataArray(1) - indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] - if len(indexes_with_zero_values) == 0: - nr_of_indexes_with_consecutive_ones = len(binary_values) - else: - nr_of_indexes_with_consecutive_ones = len(binary_values) - indexes_with_zero_values[-1] - 1 + # Convert to binary states + binary_states = (np.abs(values) >= epsilon) - if len(hours_per_timestep) < nr_of_indexes_with_consecutive_ones: - raise ValueError( - f'When trying to calculate the consecutive duration, the length of the last duration ' - f'({nr_of_indexes_with_consecutive_ones}) is longer than the provided hours_per_timestep ({len(hours_per_timestep)}), ' - f'as {binary_values=}' - ) + # Optionally collapse dimensions using .any() + if dims is not None: + dims = [dims] if isinstance(dims, str) else dims - return np.sum( - binary_values[-nr_of_indexes_with_consecutive_ones:] - * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:] - ) + binary_states = binary_states.any(dim=[d for d in binary_states.dims if d not in dims]) + + return binary_states.astype(int) @staticmethod - def compute_previous_states(previous_values: List[TemporalData], epsilon: float = None) -> np.ndarray: + def count_consecutive_states( + binary_values: xr.DataArray, + epsilon: float = None, + ) -> float: """ - Computes the previous states {0, 1} of defining variables as a binary array from their previous values. + Counts the number of consecutive states in a binary time series. Args: - previous_values: List of previous values for variables + binary_values: Binary DataArray with 'time' dim epsilon: Tolerance for zero detection (uses CONFIG.modeling.EPSILON if None) Returns: - Binary array of previous states + The consecutive number of steps spent in the final state of the timeseries """ if epsilon is None: epsilon = CONFIG.modeling.EPSILON - if not previous_values or all(val is None for val in previous_values): - return np.array([0]) + binary_values = binary_values.any(dim=[d for d in binary_values.dims if d != 'time']) + + # Handle scalar case + if binary_values.ndim == 0: + return float(binary_values.item()) - # Convert to 2D-array and compute binary on/off states - previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None - if previous_values.ndim > 1: - return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) + # Check if final state is off + if np.isclose(binary_values.isel(time=-1).item(), 0, atol=epsilon): + return 0.0 + + # Find consecutive 'on' period from the end + is_zero = np.isclose(binary_values, 0, atol=epsilon) + + # Find the last zero, then sum everything after it + zero_indices = np.where(is_zero)[0] + if len(zero_indices) == 0: + # All 'on' - sum everything + start_idx = 0 + else: + # Start after last zero + start_idx = zero_indices[-1] + 1 - return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) + consecutive_values = binary_values.isel(time=slice(start_idx, None)) + + return float(consecutive_values.sum().item()) + + +class ModelingUtilities: @staticmethod - def compute_previous_on_duration(previous_values: List[TemporalData], hours_per_step: Union[int, float]) -> Scalar: + def compute_consecutive_hours_in_state( + binary_values: Union[xr.DataArray, np.ndarray, int], + hours_per_timestep: Union[int, float], + epsilon: float = None, + ) -> float: """ - Convenience method to compute previous consecutive 'on' duration. + Computes the final consecutive duration in state 'on' (=1) in hours. Args: - previous_values: List of previous values for variables - hours_per_step: Duration of each timestep in hours + binary_values: Binary DataArray with 'time' dim, or scalar/array + hours_per_timestep: Duration of each timestep in hours + epsilon: Tolerance for zero detection (uses CONFIG.modeling.EPSILON if None) Returns: - Previous consecutive on duration in hours + The duration of the final consecutive 'on' period in hours """ - if not previous_values: - return 0 + if not isinstance(hours_per_timestep, (int, float)): + raise TypeError(f'hours_per_timestep must be a scalar, got {type(hours_per_timestep)}') - previous_states = ModelingUtilities.compute_previous_states(previous_values) - return ModelingUtilities.compute_consecutive_hours_in_state(previous_states, hours_per_step) + return ModelingUtilitiesAbstract.count_consecutive_states( + binary_values=binary_values, epsilon=epsilon + ) * hours_per_timestep + + @staticmethod + def compute_previous_states(previous_values: Optional[xr.DataArray], epsilon: Optional[float] = None) -> xr.DataArray: + return ModelingUtilitiesAbstract.to_binary(values=previous_values, epsilon=epsilon, dims='time') @staticmethod - def compute_previous_off_duration(previous_values: List[TemporalData], hours_per_step: Union[int, float]) -> Scalar: + def compute_previous_on_duration( + previous_values: xr.DataArray, hours_per_step: Union[xr.DataArray, float, int] + ) -> float: + return ModelingUtilitiesAbstract.count_consecutive_states( + ModelingUtilitiesAbstract.to_binary(previous_values) + ) * hours_per_step + + @staticmethod + def compute_previous_off_duration( + previous_values: xr.DataArray, hours_per_step: Union[xr.DataArray, float, int] + ) -> float: """ - Convenience method to compute previous consecutive 'off' duration. + Compute previous consecutive 'off' duration. Args: - previous_values: List of previous values for variables + previous_values: DataArray with 'time' dimension hours_per_step: Duration of each timestep in hours Returns: Previous consecutive off duration in hours """ - if not previous_values: - return 0 + if previous_values is None or previous_values.size == 0: + return 0.0 previous_states = ModelingUtilities.compute_previous_states(previous_values) previous_off_states = 1 - previous_states return ModelingUtilities.compute_consecutive_hours_in_state(previous_off_states, hours_per_step) @staticmethod - def get_most_recent_state(previous_values: List[TemporalData]) -> int: + def get_most_recent_state(previous_values: Optional[xr.DataArray]) -> int: """ Get the most recent binary state from previous values. Args: - previous_values: List of previous values for variables + previous_values: DataArray with 'time' dimension Returns: Most recent binary state (0 or 1) """ - if not previous_values: + if previous_values is None or previous_values.size == 0: return 0 previous_states = ModelingUtilities.compute_previous_states(previous_values) - return int(previous_states[-1]) + return int(previous_states.isel(time=-1).item()) class ModelingPrimitives: diff --git a/flixopt/structure.py b/flixopt/structure.py index 953636f9b..8fdce6ad0 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -895,6 +895,10 @@ def __repr__(self) -> str: f"Submodels:\n----------\n{sub_models_string}" ) + @property + def hours_per_step(self): + return self._model.hours_per_step + class BaseFeatureModel(Model): """Minimal base class for feature models that use factory patterns""" @@ -924,10 +928,6 @@ def add_effects(self): """Override in subclasses to add effects""" pass # Default: no effects - @property - def hours_per_step(self): - return self._model.hours_per_step - class ElementModel(Model): """Stores the mathematical Variables and Constraints for Elements""" From 29bec8c97aa2b58a5f353590fa273b4aa6db2c6d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:59:38 +0200 Subject: [PATCH 33/51] Improve handling of previous flowrates --- flixopt/elements.py | 69 ++++++++++++++++++------ flixopt/modeling.py | 13 ++--- tests/test_component.py | 113 ++++++++++++++++++++++++++++++++++------ 3 files changed, 159 insertions(+), 36 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 796d82864..02d0bf115 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -16,7 +16,7 @@ from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, ModelingPrimitives from .interface import InvestParameters, OnOffParameters from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io -from .modeling import BoundingPatterns, ModelingUtilities +from .modeling import BoundingPatterns, ModelingUtilitiesAbstract if TYPE_CHECKING: from .flow_system import FlowSystem @@ -163,7 +163,7 @@ def __init__( flow_hours_total_min: Optional[Scalar] = None, load_factor_min: Optional[Scalar] = None, load_factor_max: Optional[Scalar] = None, - previous_flow_rate: Optional[TemporalDataUser] = None, + previous_flow_rate: Optional[Union[Scalar, List[Scalar]]] = None, meta_data: Optional[Dict] = None, ): r""" @@ -210,9 +210,7 @@ def __init__( self.flow_hours_total_min = flow_hours_total_min self.on_off_parameters = on_off_parameters - self.previous_flow_rate = ( - previous_flow_rate if not isinstance(previous_flow_rate, list) else np.array(previous_flow_rate) - ) + self.previous_flow_rate = previous_flow_rate self.component: str = 'UnknownComponent' self.is_input_in_component: Optional[bool] = None @@ -294,6 +292,11 @@ def _plausibility_checks(self) -> None: f'Consider using on_off_parameters to allow the flow to be switched on and off.' ) + if self.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))]): + 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)}') + @property def label_full(self) -> str: return f'{self.component}({self.label})' @@ -523,10 +526,19 @@ def investment(self) -> Optional[InvestmentModel]: @property def previous_states(self) -> Optional[xr.DataArray]: """Previous states of the flow rate""" - if self.element.previous_flow_rate is None: + #TODO: This would be nicer to handle in the Flow itself, and allow DataArrays as well. + previous_flow_rate = self.element.previous_flow_rate + if previous_flow_rate is None: return None - return ModelingUtilities.compute_previous_states(self.element.previous_flow_rate) + return ModelingUtilitiesAbstract.to_binary( + values=xr.DataArray( + [previous_flow_rate] if np.isscalar(previous_flow_rate) else previous_flow_rate, + dims='time' + ), + epsilon=CONFIG.modeling.EPSILON, + dims='time', + ) class BusModel(ElementModel): @@ -588,22 +600,27 @@ def do_modeling(self): flow.on_off_parameters = OnOffParameters() for flow in all_flows: - self.register_sub_model(flow.create_model(self._model), short_name=flow.label) - - for sub_model in self.sub_models: - sub_model.do_modeling() + flow_model = self.register_sub_model(flow.create_model(self._model), short_name=flow.label) + flow_model.do_modeling() if self.element.on_off_parameters: + on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords()) + if len(all_flows) == 1: + self.add_constraints(on == all_flows[0].model.on_off.on, short_name='on') + else: + flow_ons = [flow.model.on_off.on for flow in all_flows] + #TODO: Is the EPSILON even necessary? + self.add_constraints(on <= sum(flow_ons) + CONFIG.modeling.EPSILON, short_name='on|ub') + self.add_constraints(on >= sum(flow_ons) / (len(flow_ons) + CONFIG.modeling.EPSILON), short_name='on|lb') + self.on_off = self.register_sub_model( OnOffModel( model=self._model, label_of_element=self.label_of_element, parameters=self.element.on_off_parameters, - flow_rates=[flow.model.flow_rate for flow in all_flows], - flow_rate_bounds=[flow.model.flow_rate_bounds_on for flow in all_flows], - previous_flow_rates=[flow.previous_flow_rate for flow in all_flows], + on_variable=on, label_of_model=self.label_of_element, - apply_bounds_to_flow_rates=True, + previous_states=self.previous_states, ), short_name='on_off', ) @@ -623,3 +640,25 @@ def results_structure(self): 'outputs': [flow.model.flow_rate.name for flow in self.element.outputs], 'flows': [flow.label_full for flow in self.element.inputs + self.element.outputs], } + + @property + def previous_states(self) -> Optional[xr.DataArray]: + """Previous state of the component, derived from its flows""" + if self.element.on_off_parameters is None: + raise ValueError(f'OnOffModel not present in \n{self}\nCant access previous_states') + + previous_states = [flow.model.on_off._previous_states for flow in self.element.inputs + self.element.outputs] + previous_states = [da for da in previous_states if da is not None] + + if not previous_states: # Empty list + return None + + max_len = max(da.sizes['time'] for da in previous_states) + + padded_previous_states = [ + da.assign_coords( + time=range(-da.sizes['time'], 0) + ).reindex(time=range(-max_len, 0), fill_value=0) + for da in previous_states + ] + return xr.concat(padded_previous_states, dim='flow').any(dim='flow').astype(int) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 0c989d01e..c3839749c 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -55,13 +55,15 @@ def to_binary( @staticmethod def count_consecutive_states( binary_values: xr.DataArray, + dim: str = 'time', epsilon: float = None, ) -> float: """ Counts the number of consecutive states in a binary time series. Args: - binary_values: Binary DataArray with 'time' dim + binary_values: Binary DataArray + dim: Dimension to count consecutive states over epsilon: Tolerance for zero detection (uses CONFIG.modeling.EPSILON if None) Returns: @@ -70,14 +72,14 @@ def count_consecutive_states( if epsilon is None: epsilon = CONFIG.modeling.EPSILON - binary_values = binary_values.any(dim=[d for d in binary_values.dims if d != 'time']) + binary_values = binary_values.any(dim=[d for d in binary_values.dims if d != dim]) # Handle scalar case if binary_values.ndim == 0: return float(binary_values.item()) # Check if final state is off - if np.isclose(binary_values.isel(time=-1).item(), 0, atol=epsilon): + if np.isclose(binary_values.isel({dim: -1}).item(), 0, atol=epsilon).all(): return 0.0 # Find consecutive 'on' period from the end @@ -92,9 +94,9 @@ def count_consecutive_states( # Start after last zero start_idx = zero_indices[-1] + 1 - consecutive_values = binary_values.isel(time=slice(start_idx, None)) + consecutive_values = binary_values.isel({dim:slice(start_idx, None)}) - return float(consecutive_values.sum().item()) + return float(consecutive_values.sum().item()) #TODO: Som only over one dim? class ModelingUtilities: @@ -260,7 +262,6 @@ def sum_up_variable( model: FlowSystemModel, variable_to_count: linopy.Variable, name: str = None, - short_name: str = None, bounds: Tuple[NonTemporalData, NonTemporalData] = None, factor: TemporalData = 1, ) -> Tuple[linopy.Variable, linopy.Constraint]: diff --git a/tests/test_component.py b/tests/test_component.py index f65c93414..25e496694 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -121,15 +121,28 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): assert_conequal(model.constraints['TestComponent(Out2)|flow_rate|lb'], model.variables['TestComponent(Out2)|flow_rate'] >= model.variables['TestComponent(Out2)|on'] * 0.3 * 300) assert_conequal(model.constraints['TestComponent(Out2)|flow_rate|ub'], model.variables['TestComponent(Out2)|flow_rate'] <= model.variables['TestComponent(Out2)|on'] * 300 * ub_out2) - assert_conequal(model.constraints['TestComponent|on|lb'], - model.variables['TestComponent|on'] * 1e-5 <= model.variables['TestComponent(In1)|flow_rate'] + model.variables['TestComponent(Out1)|flow_rate'] + model.variables['TestComponent(Out2)|flow_rate']) - # TODO: Might there be a better way to no use 1e-5? - assert_conequal(model.constraints['TestComponent|on|ub'], - model.variables['TestComponent|on'] * (100 + 200 + 300 * ub_out2)/3 - >= (model.variables['TestComponent(In1)|flow_rate'] - + model.variables['TestComponent(Out1)|flow_rate'] - + model.variables['TestComponent(Out2)|flow_rate']) / 3 - ) + assert_conequal( + model.constraints['TestComponent|on|lb'], + model.variables['TestComponent|on'] + >= ( + model.variables['TestComponent(In1)|on'] + + model.variables['TestComponent(Out1)|on'] + + model.variables['TestComponent(Out2)|on'] + ) + / (3 + 1e-5), + ) + assert_conequal( + model.constraints['TestComponent|on|ub'], + model.variables['TestComponent|on'] + <= ( + model.variables['TestComponent(In1)|on'] + + model.variables['TestComponent(Out1)|on'] + + model.variables['TestComponent(Out2)|on'] + ) + + 1e-5, + ) + + def test_on_with_single_flow(self, basic_flow_system_linopy): """Test that flow model constraints are correctly generated.""" @@ -159,8 +172,7 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): 'TestComponent(In1)|flow_rate|lb', 'TestComponent(In1)|flow_rate|ub', 'TestComponent(In1)|on_hours_total', - 'TestComponent|on|lb', - 'TestComponent|on|ub', + 'TestComponent|on', 'TestComponent|on_hours_total', } @@ -172,20 +184,91 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): assert_conequal( model.constraints['TestComponent(In1)|flow_rate|lb'], - model.variables['TestComponent(In1)|on'] * 0.1 * 100 <= model.variables['TestComponent(In1)|flow_rate'], + model.variables['TestComponent(In1)|flow_rate'] >= model.variables['TestComponent(In1)|on'] * 0.1 * 100, ) assert_conequal( model.constraints['TestComponent(In1)|flow_rate|ub'], - model.variables['TestComponent(In1)|on'] * 100 >= model.variables['TestComponent(In1)|flow_rate'], + model.variables['TestComponent(In1)|flow_rate'] <= model.variables['TestComponent(In1)|on'] * 100, ) + assert_conequal( + model.constraints['TestComponent|on'], + model.variables['TestComponent|on'] == model.variables['TestComponent(In1)|on'], + ) + + def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy): + """Test that flow model constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.timesteps + ub_out2 = np.linspace(1, 1.5, 10).round(2) + inputs = [ + fx.Flow('In1', 'Fernwärme', relative_minimum=np.ones(10) * 0.1, size=100, previous_flow_rate=np.array([0, 0, 1e-6, 1e-5, 1e-4, 3,4])), + ] + outputs = [ + fx.Flow('Out1', 'Gas', relative_minimum=np.ones(10) * 0.2, size=200, previous_flow_rate=xr.DataArray([3,4,5], dims='time')), + fx.Flow('Out2', 'Gas', relative_minimum=np.ones(10) * 0.3, + relative_maximum = ub_out2, size=300, previous_flow_rate=20), + ] + comp = flixopt.elements.Component('TestComponent', inputs=inputs, outputs=outputs, + on_off_parameters=fx.OnOffParameters()) + flow_system.add_elements(comp) + model = create_linopy_model(flow_system) + + assert set(comp.model.variables) == { + 'TestComponent(In1)|flow_rate', + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In1)|on', + 'TestComponent(In1)|on_hours_total', + 'TestComponent(Out1)|flow_rate', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out1)|on', + 'TestComponent(Out1)|on_hours_total', + 'TestComponent(Out2)|flow_rate', + 'TestComponent(Out2)|total_flow_hours', + 'TestComponent(Out2)|on', + 'TestComponent(Out2)|on_hours_total', + 'TestComponent|on', + 'TestComponent|on_hours_total', + } + + assert set(comp.model.constraints) == { + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In1)|flow_rate|lb', + 'TestComponent(In1)|flow_rate|ub', + 'TestComponent(In1)|on_hours_total', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out1)|flow_rate|lb', + 'TestComponent(Out1)|flow_rate|ub', + 'TestComponent(Out1)|on_hours_total', + 'TestComponent(Out2)|total_flow_hours', + 'TestComponent(Out2)|flow_rate|lb', + 'TestComponent(Out2)|flow_rate|ub', + 'TestComponent(Out2)|on_hours_total', + 'TestComponent|on|lb', + 'TestComponent|on|ub', + 'TestComponent|on_hours_total', + } + + assert_var_equal(model['TestComponent(Out2)|flow_rate'], + model.add_variables(lower=0, upper=300 * ub_out2, coords=(timesteps,))) + assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords = (timesteps,))) + assert_var_equal(model['TestComponent(Out2)|on'], model.add_variables(binary=True, coords=(timesteps,))) + + assert_conequal(model.constraints['TestComponent(Out2)|flow_rate|lb'], model.variables['TestComponent(Out2)|flow_rate'] >= model.variables['TestComponent(Out2)|on'] * 0.3 * 300) + assert_conequal(model.constraints['TestComponent(Out2)|flow_rate|ub'], model.variables['TestComponent(Out2)|flow_rate'] <= model.variables['TestComponent(Out2)|on'] * 300 * ub_out2) + assert_conequal( model.constraints['TestComponent|on|lb'], - model.variables['TestComponent|on'] * 0.1 * 100 <= model.variables['TestComponent(In1)|flow_rate'], + model.variables['TestComponent|on'] >= (model.variables['TestComponent(In1)|on'] + model.variables['TestComponent(Out1)|on'] + model.variables['TestComponent(Out2)|on']) / (3 + 1e-5), ) assert_conequal( model.constraints['TestComponent|on|ub'], - model.variables['TestComponent|on'] * 100 >= model.variables['TestComponent(In1)|flow_rate'], + model.variables['TestComponent|on'] + <= ( + model.variables['TestComponent(In1)|on'] + + model.variables['TestComponent(Out1)|on'] + + model.variables['TestComponent(Out2)|on'] + ) + 1e-5, ) From 370ac9414788cacf1fd40b55ffac95c9d8b48cef Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:00:02 +0200 Subject: [PATCH 34/51] Imropove repr and submodel acess --- flixopt/structure.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 8fdce6ad0..34de27f35 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -838,8 +838,14 @@ def sub_models_direct(self) -> Dict[str, 'Model']: @property def sub_models(self) -> List['Model']: """All sub-models of the model""" - direct = list(self.sub_models_direct.values()) - return direct + [model for sub_model in direct for model in sub_model.sub_models] + direct_submodels = list(self._sub_models.values()) + + # Recursively collect nested sub-models + nested_submodels = [] + for submodel in direct_submodels: + nested_submodels.extend(submodel.sub_models) # This calls the property recursively + + return direct_submodels + nested_submodels @property def constraints(self) -> linopy.Constraints: @@ -863,16 +869,6 @@ def variables(self) -> linopy.Variables: return self._model.variables[names] - @staticmethod - def _extract_short_name(item: Union[linopy.Variable, linopy.Constraint]) -> str: - """Extract short name from variable's full name""" - # Assumes format like "model_prefix|short_name" - name = str(item.name) - if '|' in name: - return name.split('|')[-1] # Take last part after | - else: - return name # Use full name if no | separator - def __repr__(self) -> str: """ Return a string representation of the linopy model. @@ -885,14 +881,14 @@ def __repr__(self) -> str: sub_models_string = ' \n' else: sub_models_string = '' - for sub_model in self.sub_models: - sub_models_string += f'\n * {sub_model.label_of_model}' + for sub_model_name, sub_model in self.sub_models_direct.items(): + sub_models_string += f'\n * {sub_model_name} [{sub_model.__class__.__name__}]' return ( f"{model_string}\n{'=' * len(model_string)}\n\n" f"Variables:\n----------\n{var_string}\n" f"Constraints:\n------------\n{con_string}\n" - f"Submodels:\n----------\n{sub_models_string}" + f"Submodels:\n----------{sub_models_string}" ) @property From 0f89ff07953d9ca114cd0080aa6e2165f351b213 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:12:44 +0200 Subject: [PATCH 35/51] Update access pattern in tests --- flixopt/components.py | 1 + flixopt/features.py | 2 +- tests/test_linear_converter.py | 12 ++++++------ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 6631cb214..b733d2d39 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -475,6 +475,7 @@ def do_modeling(self): PiecewiseModel( model=self._model, label_of_element=self.label_of_element, + label_of_model=f'{self.label_of_element}', piecewise_variables=piecewise_conversion, zero_point=self.on_off.on if self.on_off is not None else False, as_time_series=True, diff --git a/flixopt/features.py b/flixopt/features.py index a9e2dc589..dd095a9e2 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -329,10 +329,10 @@ def __init__( self, model: FlowSystemModel, label_of_element: str, + label_of_model: str, piecewise_variables: Dict[str, Piecewise], zero_point: Optional[Union[bool, linopy.Variable]], as_time_series: bool, - label_of_model: str = '', ): """ Modeling a Piecewise relation between miultiple variables. diff --git a/tests/test_linear_converter.py b/tests/test_linear_converter.py index a01c17ef2..11b5b5673 100644 --- a/tests/test_linear_converter.py +++ b/tests/test_linear_converter.py @@ -189,8 +189,8 @@ def test_linear_converter_with_on_off(self, basic_flow_system_linopy): # Check on_hours_total constraint assert_conequal( model.constraints['Converter|on_hours_total'], - converter.model.on_off.variables['Converter|on_hours_total'] == - (converter.model.on_off.variables['Converter|on'] * model.hours_per_step).sum() + model.variables['Converter|on_hours_total'] == + (model.variables['Converter|on'] * model.hours_per_step).sum() ) # Check conversion constraint @@ -204,7 +204,7 @@ def test_linear_converter_with_on_off(self, basic_flow_system_linopy): assert_conequal( model.constraints['Converter->Costs(operation)'], model.variables['Converter->Costs(operation)'] == - converter.model.on_off.variables['Converter|on'] * model.hours_per_step * 5 + model.variables['Converter|on'] * model.hours_per_step * 5 ) def test_linear_converter_multidimensional(self, basic_flow_system_linopy): @@ -539,8 +539,8 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): assert 'Converter|on_hours_total' in model.constraints assert_conequal( model.constraints['Converter|on_hours_total'], - converter.model.on_off.variables['Converter|on_hours_total'] == - (converter.model.on_off.variables['Converter|on'] * model.hours_per_step).sum() + model['Converter|on_hours_total'] == + (model['Converter|on'] * model.hours_per_step).sum() ) # Verify that the costs effect is applied @@ -548,7 +548,7 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): assert_conequal( model.constraints['Converter->Costs(operation)'], model.variables['Converter->Costs(operation)'] == - converter.model.on_off.variables['Converter|on'] * model.hours_per_step * 5 + model.variables['Converter|on'] * model.hours_per_step * 5 ) From 4781cff3d5499bc88a28a9297e8397e40fb756e4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 20:44:25 +0200 Subject: [PATCH 36/51] Fix PiecewiseEffects and StorageModel --- flixopt/components.py | 9 +++++++-- flixopt/features.py | 11 +++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index b733d2d39..1377d1f83 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -15,6 +15,7 @@ from .features import InvestmentModel, OnOffModel, PiecewiseModel from .interface import InvestParameters, OnOffParameters, PiecewiseConversion from .structure import FlowSystemModel, register_class_for_io +from.modeling import BoundingPatterns if TYPE_CHECKING: from .flow_system import FlowSystem @@ -535,12 +536,16 @@ def do_modeling(self): model=self._model, label_of_element=self.label_of_element, parameters=self.element.capacity_in_flow_hours, - defining_variable=self.charge_state, - relative_bounds_of_defining_variable=self.relative_charge_state_bounds, ), short_name='investment', ) self._investment.do_modeling() + BoundingPatterns.scaled_bounds( + self, + variable=self.charge_state, + scaling_variable=self.investment.size, + relative_bounds=self.relative_charge_state_bounds, + ) # Initial charge state self._initial_and_final_charge_state() diff --git a/flixopt/features.py b/flixopt/features.py index dd095a9e2..475c0e553 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -72,6 +72,7 @@ def create_variables_and_constraints(self): PiecewiseEffectsModel( model=self._model, label_of_element=self.label_of_element, + label_of_model=f'{self.label_of_element}|PiecewiseEffects', piecewise_origin=(self.size.name, self.parameters.piecewise_effects.piecewise_origin), piecewise_shares=self.parameters.piecewise_effects.piecewise_shares, zero_point=self.is_invested, @@ -176,10 +177,8 @@ def create_variables_and_constraints(self): switch_on=self.switch_on, switch_off=self.switch_off, name=f'{self.label_of_model}|switch', - previous_state=ModelingUtilities.get_most_recent_state( - self._previous_states.isel(time=-1) - ) if self._previous_states is not None else 0, - ) + previous_state=self._previous_states.isel(time=-1) if self._previous_states is not None else 0, + ) if self.parameters.switch_on_total_max is not None: count = self.add_variables(lower=0, upper=self.parameters.switch_on_total_max, coords=self._model.get_coords(('year', 'scenario')), short_name='switch|count') @@ -410,12 +409,12 @@ def __init__( self, model: FlowSystemModel, label_of_element: str, + label_of_model: str, piecewise_origin: Tuple[str, Piecewise], piecewise_shares: Dict[str, Piecewise], zero_point: Optional[Union[bool, linopy.Variable]], - label: str = 'PiecewiseEffects', ): - super().__init__(model, label_of_element, label) + super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) assert len(piecewise_origin[1]) == len(list(piecewise_shares.values())[0]), ( 'Piece length of variable_segments and share_segments must be equal' ) From 333ab83bd25c19427584304bc270492e4bba6a48 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:00:51 +0200 Subject: [PATCH 37/51] Fix StorageModel and Remove PreventSimultaniousUseModel --- flixopt/components.py | 1 + flixopt/elements.py | 9 ++++++--- flixopt/features.py | 38 -------------------------------------- flixopt/modeling.py | 8 ++++---- 4 files changed, 11 insertions(+), 45 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 1377d1f83..81570f9f3 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -535,6 +535,7 @@ def do_modeling(self): InvestmentModel( model=self._model, label_of_element=self.label_of_element, + label_of_model=self.label_of_element, parameters=self.element.capacity_in_flow_hours, ), short_name='investment', diff --git a/flixopt/elements.py b/flixopt/elements.py index 02d0bf115..663434ad8 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -13,7 +13,7 @@ from .config import CONFIG from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser from .effects import TemporalEffectsUser -from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel, ModelingPrimitives +from .features import InvestmentModel, OnOffModel, ModelingPrimitives from .interface import InvestParameters, OnOffParameters from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io from .modeling import BoundingPatterns, ModelingUtilitiesAbstract @@ -630,8 +630,11 @@ def do_modeling(self): if self.element.prevent_simultaneous_flows: # Simultanious Useage --> Only One FLow is On at a time, but needs a Binary for every flow on_variables = [flow.model.on_off.on for flow in self.element.prevent_simultaneous_flows] - simultaneous_use = self.register_sub_model(PreventSimultaneousUsageModel(self._model, on_variables, self.label_full), short_name='prevent_simultaneous_use') - simultaneous_use.do_modeling() + ModelingPrimitives.mutual_exclusivity_constraint( + self, + binary_variables=[flow.model.on_off.on for flow in self.element.prevent_simultaneous_flows], + short_name='prevent_simultaneous_use', + ) def results_structure(self): return { diff --git a/flixopt/features.py b/flixopt/features.py index 475c0e553..a31550e63 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -560,41 +560,3 @@ def add_share( self._eq_total.lhs -= self.shares[name] else: self._eq_total_per_timestep.lhs -= self.shares[name] - - -class PreventSimultaneousUsageModel(Model): - """ - Prevents multiple Multiple Binary variables from being 1 at the same time - - Only 'classic type is modeled for now (# "classic" -> alle Flows brauchen Binärvariable:) - In 'new', the binary Variables need to be forced beforehand, which is not that straight forward... --> TODO maybe - - - # "new": - # eq: flow_1.on(t) + flow_2.on(t) + .. + flow_i.val(t)/flow_i.max <= 1 (1 Flow ohne Binärvariable!) - - # Anmerkung: Patrick Schönfeld (oemof, custom/link.py) macht bei 2 Flows ohne Binärvariable dies: - # 1) bin + flow1/flow1_max <= 1 - # 2) bin - flow2/flow2_max >= 0 - # 3) geht nur, wenn alle flow.min >= 0 - # --> könnte man auch umsetzen (statt force_on_variable() für die Flows, aber sollte aufs selbe wie "new" kommen) - """ - - def __init__( - self, - model: FlowSystemModel, - variables: List[linopy.Variable], - label_of_element: str, - label: str = 'PreventSimultaneousUsage', - ): - super().__init__(model, label_of_element, label) - self._simultanious_use_variables = variables - assert len(self._simultanious_use_variables) >= 2, ( - f'Model {self.__class__.__name__} must get at least two variables' - ) - for variable in self._simultanious_use_variables: # classic - assert variable.attrs['binary'], f'Variable {variable} must be binary for use in {self.__class__.__name__}' - - def do_modeling(self): - # eq: sum(flow_i.on(t)) <= 1.1 (1 wird etwas größer gewählt wg. Binärvariablengenauigkeit) - self.add_constraints(sum(self._simultanious_use_variables) <= 1.1, short_name='prevent_simultaneous_use') diff --git a/flixopt/modeling.py b/flixopt/modeling.py index c3839749c..d1b739487 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -387,7 +387,8 @@ def consecutive_duration_tracking( @staticmethod def mutual_exclusivity_constraint( - model: FlowSystemModel, name: str, binary_variables: List[linopy.Variable], tolerance: float = 1 + model: Model, binary_variables: List[linopy.Variable], tolerance: float = 1, + short_name: str = 'mutual_exclusivity', ) -> linopy.Constraint: """ Creates mutual exclusivity constraint for binary variables. @@ -401,6 +402,7 @@ def mutual_exclusivity_constraint( Args: binary_variables: List of binary variables that should be mutually exclusive tolerance: Upper bound + short_name: Short name of the constraint Returns: variables: {} (no new variables created) @@ -419,9 +421,7 @@ def mutual_exclusivity_constraint( ) # Create mutual exclusivity constraint - mutual_exclusivity = model.add_constraints( - sum(binary_variables) <= tolerance, name=f'{name}|mutual_exclusivity' - ) + mutual_exclusivity = model.add_constraints(sum(binary_variables) <= tolerance, short_name=short_name) return mutual_exclusivity From 9702303c534f31c27244a48126a9a8b9bd72f2e1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:01:25 +0200 Subject: [PATCH 38/51] Fix Aggregation and SegmentedCalculation --- flixopt/aggregation.py | 51 ++++++++++++++---------------------------- flixopt/calculation.py | 2 +- 2 files changed, 18 insertions(+), 35 deletions(-) diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index 47ac1336d..26fb921c9 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -301,7 +301,7 @@ def __init__( """ Modeling-Element for "index-equating"-equations """ - super().__init__(model, label_of_element='Aggregation', label_full='Aggregation') + super().__init__(model, label_of_element='Aggregation', label_of_model='Aggregation') self.flow_system = flow_system self.aggregation_parameters = aggregation_parameters self.aggregation_data = aggregation_data @@ -343,12 +343,9 @@ def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, # Gleichung: # eq1: x(p1,t) - x(p3,t) = 0 # wobei p1 und p3 im gleichen Cluster sind und t = 0..N_p - con = self.add( - self._model.add_constraints( - variable.isel(time=indices[0]) - variable.isel(time=indices[1]) == 0, - name=f'{self.label_full}|equate_indices|{variable.name}', - ), - f'equate_indices|{variable.name}', + con = self.add_constraints( + variable.isel(time=indices[0]) - variable.isel(time=indices[1]) == 0, + short_name=f'equate_indices|{variable.name}', ) # Korrektur: (bisher nur für Binärvariablen:) @@ -356,22 +353,16 @@ def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, variable.name in self._model.variables.binaries and self.aggregation_parameters.percentage_of_period_freedom > 0 ): - var_k1 = self.add( - self._model.add_variables( - binary=True, - coords={'time': variable.isel(time=indices[0]).indexes['time']}, - name=f'{self.label_full}|correction1|{variable.name}', - ), - f'correction1|{variable.name}', + var_k1 = self.add_variables( + binary=True, + coords={'time': variable.isel(time=indices[0]).indexes['time']}, + short_name=f'correction1|{variable.name}', ) - var_k0 = self.add( - self._model.add_variables( - binary=True, - coords={'time': variable.isel(time=indices[0]).indexes['time']}, - name=f'{self.label_full}|correction0|{variable.name}', - ), - f'correction0|{variable.name}', + var_k0 = self.add_variables( + binary=True, + coords={'time': variable.isel(time=indices[0]).indexes['time']}, + short_name=f'correction0|{variable.name}', ) # equation extends ... @@ -384,20 +375,12 @@ def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, # interlock var_k1 and var_K2: # eq: var_k0(t)+var_k1(t) <= 1.1 - self.add( - self._model.add_constraints( - var_k0 + var_k1 <= 1.1, name=f'{self.label_full}|lock_k0_and_k1|{variable.name}' - ), - f'lock_k0_and_k1|{variable.name}', - ) + self.add_constraints(var_k0 + var_k1 <= 1.1, short_name=f'lock_k0_and_k1|{variable.name}') # Begrenzung der Korrektur-Anzahl: # eq: sum(K) <= n_Corr_max - self.add( - self._model.add_constraints( - sum(var_k0) + sum(var_k1) - <= round(self.aggregation_parameters.percentage_of_period_freedom / 100 * length), - name=f'{self.label_full}|limit_corrections|{variable.name}', - ), - f'limit_corrections|{variable.name}', + self.add_constraints( + sum(var_k0) + sum(var_k1) + <= round(self.aggregation_parameters.percentage_of_period_freedom / 100 * length), + short_name=f'limit_corrections|{variable.name}', ) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 438fbeea5..61747ffe7 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -488,7 +488,7 @@ def do_modeling_and_solve( invest_elements = [ model.label_full for component in calculation.flow_system.components.values() - for model in component.model.all_sub_models + for model in component.model.sub_models if isinstance(model, InvestmentModel) ] if invest_elements: From 91bd4610df55878615a8993a55680c54bf2909a5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:01:50 +0200 Subject: [PATCH 39/51] Update tests --- tests/test_component.py | 2 +- tests/test_storage.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_component.py b/tests/test_component.py index 25e496694..3bf1699ec 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -205,7 +205,7 @@ def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy): fx.Flow('In1', 'Fernwärme', relative_minimum=np.ones(10) * 0.1, size=100, previous_flow_rate=np.array([0, 0, 1e-6, 1e-5, 1e-4, 3,4])), ] outputs = [ - fx.Flow('Out1', 'Gas', relative_minimum=np.ones(10) * 0.2, size=200, previous_flow_rate=xr.DataArray([3,4,5], dims='time')), + fx.Flow('Out1', 'Gas', relative_minimum=np.ones(10) * 0.2, size=200, previous_flow_rate=[3,4,5]), fx.Flow('Out2', 'Gas', relative_minimum=np.ones(10) * 0.3, relative_maximum = ub_out2, size=300, previous_flow_rate=20), ] diff --git a/tests/test_storage.py b/tests/test_storage.py index 1b9b3b875..3a6b2a06c 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -291,7 +291,7 @@ def test_storage_with_investment(self, basic_flow_system_linopy): assert var_name in model.variables, f"Missing investment variable: {var_name}" # Check investment constraints exist - for con_name in {'InvestStorage|is_invested_ub', 'InvestStorage|is_invested_lb'}: + for con_name in {'InvestStorage|size|ub', 'InvestStorage|size|lb'}: assert con_name in model.constraints, f"Missing investment constraint: {con_name}" # Check variable properties @@ -303,9 +303,9 @@ def test_storage_with_investment(self, basic_flow_system_linopy): model['InvestStorage|is_invested'], model.add_variables(binary=True) ) - assert_conequal(model.constraints['InvestStorage|is_invested_ub'], + assert_conequal(model.constraints['InvestStorage|size|ub'], model.variables['InvestStorage|size'] <= model.variables['InvestStorage|is_invested'] * 100) - assert_conequal(model.constraints['InvestStorage|is_invested_lb'], + assert_conequal(model.constraints['InvestStorage|size|lb'], model.variables['InvestStorage|size'] >= model.variables['InvestStorage|is_invested'] * 20) def test_storage_with_final_state_constraints(self, basic_flow_system_linopy): @@ -417,17 +417,17 @@ def test_simultaneous_charge_discharge(self, basic_flow_system_linopy, prevent_s assert var_name in model.variables, f'Missing binary variable: {var_name}' # Check for constraints that enforce either charging or discharging - constraint_name = 'SimultaneousStorage|PreventSimultaneousUsage|prevent_simultaneous_use' + constraint_name = 'SimultaneousStorage|prevent_simultaneous_use' assert constraint_name in model.constraints, 'Missing constraint to prevent simultaneous operation' - assert_conequal(model.constraints['SimultaneousStorage|PreventSimultaneousUsage|prevent_simultaneous_use'], - model.variables['SimultaneousStorage(Q_th_in)|on'] + model.variables['SimultaneousStorage(Q_th_out)|on'] <= 1.1) + assert_conequal(model.constraints['SimultaneousStorage|prevent_simultaneous_use'], + model.variables['SimultaneousStorage(Q_th_in)|on'] + model.variables['SimultaneousStorage(Q_th_out)|on'] <= 1) @pytest.mark.parametrize( 'optional,minimum_size,expected_vars,expected_constraints', [ - (True, None, {'InvestStorage|is_invested'}, {'InvestStorage|is_invested_lb'}), - (True, 20, {'InvestStorage|is_invested'}, {'InvestStorage|is_invested_lb'}), + (True, None, {'InvestStorage|is_invested'}, {'InvestStorage|size|lb'}), + (True, 20, {'InvestStorage|is_invested'}, {'InvestStorage|size|lb'}), (False, None, set(), set()), (False, 20, set(), set()), ], From 94314c3866a980d824cf3caa87a616b443c156c2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:03:31 +0200 Subject: [PATCH 40/51] Loosen precision in tests --- tests/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5d98cdcb5..902e01c12 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,7 +33,7 @@ def solver_fixture(request): # Custom assertion function def assert_almost_equal_numeric( - actual, desired, err_msg, relative_error_range_in_percent=0.011, absolute_tolerance=1e-9 + actual, desired, err_msg, relative_error_range_in_percent=0.011, absolute_tolerance=1e-7 ): """ Custom assertion function for comparing numeric values with relative and absolute tolerances @@ -122,6 +122,7 @@ def simple_flow_system() -> fx.FlowSystem: return flow_system + @pytest.fixture def simple_flow_system_scenarios() -> fx.FlowSystem: """ From 50cc2cbbb4e324ac145ddba5a0d7f81282268de8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:16:20 +0200 Subject: [PATCH 41/51] Update test_on_hours_computation.py and some types --- flixopt/elements.py | 2 +- flixopt/modeling.py | 2 +- tests/test_on_hours_computation.py | 138 ++++++++++++++--------------- 3 files changed, 68 insertions(+), 74 deletions(-) diff --git a/flixopt/elements.py b/flixopt/elements.py index 663434ad8..62e723d98 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -524,7 +524,7 @@ def investment(self) -> Optional[InvestmentModel]: return self.sub_models_direct['investment'] @property - def previous_states(self) -> Optional[xr.DataArray]: + def previous_states(self) -> Optional[TemporalData]: """Previous states of the flow rate""" #TODO: This would be nicer to handle in the Flow itself, and allow DataArrays as well. previous_flow_rate = self.element.previous_flow_rate diff --git a/flixopt/modeling.py b/flixopt/modeling.py index d1b739487..b4ce4d5db 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -103,7 +103,7 @@ class ModelingUtilities: @staticmethod def compute_consecutive_hours_in_state( - binary_values: Union[xr.DataArray, np.ndarray, int], + binary_values: TemporalData, hours_per_timestep: Union[int, float], epsilon: float = None, ) -> float: diff --git a/tests/test_on_hours_computation.py b/tests/test_on_hours_computation.py index c8fa113aa..578fd7792 100644 --- a/tests/test_on_hours_computation.py +++ b/tests/test_on_hours_computation.py @@ -1,43 +1,43 @@ import numpy as np import pytest +import xarray as xr from flixopt.modeling import ModelingUtilities class TestComputeConsecutiveDuration: - """Tests for the compute_consecutive_duration static method.""" + """Tests for the compute_consecutive_hours_in_state static method.""" - @pytest.mark.parametrize("binary_values, hours_per_timestep, expected", [ - # Case 1: Both scalar inputs - (1, 5, 5), - (0, 3, 0), - - # Case 2: Scalar binary, array hours - (1, np.array([1, 2, 3]), 3), - (0, np.array([2, 4, 6]), 0), - - # Case 3: Array binary, scalar hours - (np.array([0, 0, 1, 1, 1, 0]), 2, 0), - (np.array([0, 1, 1, 0, 1, 1]), 1, 2), - (np.array([1, 1, 1]), 2, 6), - - # Case 4: Both array inputs - (np.array([0, 1, 1, 0, 1, 1]), np.array([1, 2, 3, 4, 5, 6]), 11), # 5+6 - (np.array([1, 0, 0, 1, 1, 1]), np.array([2, 2, 2, 3, 4, 5]), 12), # 3+4+5 - - # Case 5: Edge cases - (np.array([1]), np.array([4]), 4), - (np.array([0]), np.array([3]), 0), - ]) + @pytest.mark.parametrize( + 'binary_values, hours_per_timestep, expected', + [ + # Case 1: Single timestep DataArrays + (xr.DataArray([1], dims=['time']), 5, 5), + (xr.DataArray([0], dims=['time']), 3, 0), + # Case 2: Array binary, scalar hours + (xr.DataArray([0, 0, 1, 1, 1, 0], dims=['time']), 2, 0), + (xr.DataArray([0, 1, 1, 0, 1, 1], dims=['time']), 1, 2), + (xr.DataArray([1, 1, 1], dims=['time']), 2, 6), + # Case 3: Edge cases + (xr.DataArray([1], dims=['time']), 4, 4), + (xr.DataArray([0], dims=['time']), 3, 0), + # Case 4: More complex patterns + (xr.DataArray([1, 0, 0, 1, 1, 1], dims=['time']), 2, 6), # 3 consecutive at end * 2 hours + (xr.DataArray([0, 1, 1, 1, 0, 0], dims=['time']), 1, 0), # ends with 0 + ], + ) def test_compute_duration(self, binary_values, hours_per_timestep, expected): - """Test compute_consecutive_duration with various inputs.""" + """Test compute_consecutive_hours_in_state with various inputs.""" result = ModelingUtilities.compute_consecutive_hours_in_state(binary_values, hours_per_timestep) assert np.isclose(result, expected) - @pytest.mark.parametrize("binary_values, hours_per_timestep", [ - # Case: Incompatible array lengths - (np.array([1, 1, 1, 1, 1]), np.array([1, 2])), - ]) + @pytest.mark.parametrize( + 'binary_values, hours_per_timestep', + [ + # Case: hours_per_timestep must be scalar + (xr.DataArray([1, 1, 1, 1, 1], dims=['time']), np.array([1, 2])), + ], + ) def test_compute_duration_raises_error(self, binary_values, hours_per_timestep): """Test error conditions.""" with pytest.raises(TypeError): @@ -45,61 +45,55 @@ def test_compute_duration_raises_error(self, binary_values, hours_per_timestep): class TestComputePreviousOnStates: - """Tests for the compute_previous_on_states static method.""" + """Tests for the compute_previous_states static method.""" @pytest.mark.parametrize( 'previous_values, expected', [ - # Case 1: Empty list - ([], np.array([0])), - - # Case 2: All None values - ([None, None], np.array([0])), - - # Case 3: Single value arrays - ([np.array([0])], np.array([0])), - ([np.array([1])], np.array([1])), - ([np.array([0.001])], np.array([1])), # Using default epsilon - ([np.array([1e-4])], np.array([1])), - ([np.array([1e-8])], np.array([0])), - - # Case 4: Multiple 1D arrays - ([np.array([0, 5, 0]), np.array([0, 0, 1])], np.array([0, 1, 1])), - ([np.array([0.1, 0, 0.3]), None, np.array([0, 0, 0])], np.array([1, 0, 1])), - ([np.array([0, 0, 0]), np.array([0, 1, 0])], np.array([0, 1, 0])), - ([np.array([0.1, 0, 0]), np.array([0, 0, 0.2])], np.array([1, 0, 1])), - - # Case 6: Mix of None, 1D and 2D arrays - ([None, np.array([0, 0, 0]), np.array([0, 1, 0]), np.array([0, 0, 0])], np.array([0, 1, 0])), - ([np.array([0, 0, 0]), None, np.array([0, 0, 0]), np.array([0, 0, 0])], np.array([0, 0, 0])), + # Case 1: Single value DataArrays + (xr.DataArray([0], dims=['time']), xr.DataArray([0], dims=['time'])), + (xr.DataArray([1], dims=['time']), xr.DataArray([1], dims=['time'])), + (xr.DataArray([0.001], dims=['time']), xr.DataArray([1], dims=['time'])), # Using default epsilon + (xr.DataArray([1e-4], dims=['time']), xr.DataArray([1], dims=['time'])), + (xr.DataArray([1e-8], dims=['time']), xr.DataArray([0], dims=['time'])), + # Case 1: Multiple timestep DataArrays + (xr.DataArray([0, 5, 0], dims=['time']), xr.DataArray([0, 1, 0], dims=['time'])), + (xr.DataArray([0.1, 0, 0.3], dims=['time']), xr.DataArray([1, 0, 1], dims=['time'])), + (xr.DataArray([0, 0, 0], dims=['time']), xr.DataArray([0, 0, 0], dims=['time'])), + (xr.DataArray([0.1, 0, 0.2], dims=['time']), xr.DataArray([1, 0, 1], dims=['time'])), ], ) def test_compute_previous_on_states(self, previous_values, expected): - """Test compute_previous_on_states with various inputs.""" + """Test compute_previous_states with various inputs.""" result = ModelingUtilities.compute_previous_states(previous_values) - np.testing.assert_array_equal(result, expected) - - @pytest.mark.parametrize("previous_values, epsilon, expected", [ - # Testing with different epsilon values - ([np.array([1e-6, 1e-4, 1e-2])], 1e-3, np.array([0, 0, 1])), - ([np.array([1e-6, 1e-4, 1e-2])], 1e-5, np.array([0, 1, 1])), - ([np.array([1e-6, 1e-4, 1e-2])], 1e-1, np.array([0, 0, 0])), + xr.testing.assert_equal(result, expected) - # Mixed case with custom epsilon - ([np.array([0.05, 0.005, 0.0005])], 0.01, np.array([1, 0, 0])), - ]) + @pytest.mark.parametrize( + 'previous_values, epsilon, expected', + [ + # Testing with different epsilon values + (xr.DataArray([1e-6, 1e-4, 1e-2], dims=['time']), 1e-3, xr.DataArray([0, 0, 1], dims=['time'])), + (xr.DataArray([1e-6, 1e-4, 1e-2], dims=['time']), 1e-5, xr.DataArray([0, 1, 1], dims=['time'])), + (xr.DataArray([1e-6, 1e-4, 1e-2], dims=['time']), 1e-1, xr.DataArray([0, 0, 0], dims=['time'])), + # Mixed case with custom epsilon + (xr.DataArray([0.05, 0.005, 0.0005], dims=['time']), 0.01, xr.DataArray([1, 0, 0], dims=['time'])), + ], + ) def test_compute_previous_on_states_with_epsilon(self, previous_values, epsilon, expected): - """Test compute_previous_on_states with custom epsilon values.""" - result = StateModel.compute_previous_states(previous_values, epsilon) - np.testing.assert_array_equal(result, expected) + """Test compute_previous_states with custom epsilon values.""" + result = ModelingUtilities.compute_previous_states(previous_values, epsilon) + xr.testing.assert_equal(result, expected) - @pytest.mark.parametrize("previous_values, expected_shape", [ - # Check that output shapes match expected dimensions - ([np.array([0, 1, 0, 1])], (4,)), - ([np.array([0, 1]), np.array([1, 0]), np.array([0, 0])], (2,)), - ([np.array([0, 1]), np.array([1, 0])], (2,)), - ]) + @pytest.mark.parametrize( + 'previous_values, expected_shape', + [ + # Check that output shapes match expected dimensions + (xr.DataArray([0, 1, 0, 1], dims=['time']), (4,)), + (xr.DataArray([0, 1], dims=['time']), (2,)), + (xr.DataArray([1, 0], dims=['time']), (2,)), + ], + ) def test_output_shapes(self, previous_values, expected_shape): """Test that output array has the correct shape.""" - result = StateModel.compute_previous_states(previous_values) + result = ModelingUtilities.compute_previous_states(previous_values) assert result.shape == expected_shape From e52f8002e96071483e4dbeb52bca99f7538c0205 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:34:20 +0200 Subject: [PATCH 42/51] Rename class Model to Submodel --- flixopt/aggregation.py | 4 ++-- flixopt/calculation.py | 2 +- flixopt/components.py | 2 +- flixopt/effects.py | 4 ++-- flixopt/elements.py | 2 +- flixopt/features.py | 12 ++++++------ flixopt/modeling.py | 8 ++++---- flixopt/structure.py | 14 +++++++------- 8 files changed, 24 insertions(+), 24 deletions(-) diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index 26fb921c9..eb44ad707 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -27,7 +27,7 @@ from .flow_system import FlowSystem from .structure import ( Element, - Model, + Submodel, FlowSystemModel, ) @@ -285,7 +285,7 @@ def use_low_peaks(self): return self.time_series_for_low_peaks is not None -class AggregationModel(Model): +class AggregationModel(Submodel): """The AggregationModel holds equations and variables related to the Aggregation of a FLowSystem. It creates Equations that equates indices of variables, and introduces penalties related to binary variables, that escape the equation to their related binaries in other periods""" diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 61747ffe7..141a8ead5 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -300,7 +300,7 @@ def do_modeling(self) -> 'AggregatedCalculation': # Model the System self.model = self.flow_system.create_model() self.model.do_modeling() - # Add Aggregation Model after modeling the rest + # Add Aggregation Submodel after modeling the rest self.aggregation = AggregationModel( self.model, self.aggregation_parameters, self.flow_system, self.aggregation, self.components_to_clusterize ) diff --git a/flixopt/components.py b/flixopt/components.py index 81570f9f3..42f1cfdd5 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -487,7 +487,7 @@ def do_modeling(self): class StorageModel(ComponentModel): - """Model of Storage""" + """Submodel of Storage""" def __init__(self, model: FlowSystemModel, element: Storage): super().__init__(model, element) diff --git a/flixopt/effects.py b/flixopt/effects.py index 13ee524e5..2b1b2ed6e 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -15,7 +15,7 @@ from .core import Scalar, TemporalData, TemporalDataUser from .features import ShareAllocationModel -from .structure import Element, ElementModel, Interface, Model, FlowSystemModel, register_class_for_io +from .structure import Element, ElementModel, Interface, Submodel, FlowSystemModel, register_class_for_io if TYPE_CHECKING: from .flow_system import FlowSystem @@ -375,7 +375,7 @@ def calculate_effect_share_factors(self) -> Tuple[ return shares_operation, shares_invest -class EffectCollectionModel(Model): +class EffectCollectionModel(Submodel): """ Handling all Effects """ diff --git a/flixopt/elements.py b/flixopt/elements.py index 62e723d98..c53f7c84f 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -30,7 +30,7 @@ class Component(Element): A Component contains incoming and outgoing [`Flows`][flixopt.elements.Flow]. It defines how these Flows interact with each other. The On or Off state of the Component is defined by all its Flows. Its on, if any of its FLows is On. It's mathematically advisable to define the On/Off state in a FLow rather than a Component if possible, - as this introduces less binary variables to the Model + as this introduces less binary variables to the Submodel Constraints to the On/Off state are defined by the [`on_off_parameters`][flixopt.interface.OnOffParameters]. """ diff --git a/flixopt/features.py b/flixopt/features.py index a31550e63..99928e410 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -12,7 +12,7 @@ from .config import CONFIG from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions from .interface import InvestParameters, OnOffParameters, Piecewise, PiecewiseEffects -from .structure import Model, FlowSystemModel, BaseFeatureModel +from .structure import Submodel, FlowSystemModel, BaseFeatureModel from .modeling import ModelingUtilities, ModelingPrimitives, BoundingPatterns logger = logging.getLogger('flixopt') @@ -228,7 +228,7 @@ def add_effects(self): target='operation', ) - # Properties access variables from Model's tracking system + # Properties access variables from Submodel's tracking system @property def total_on_hours(self) -> Optional[linopy.Variable]: @@ -282,7 +282,7 @@ def _get_previous_off_duration(self): return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_states * -1 + 1, hours_per_step) -class PieceModel(Model): +class PieceModel(Submodel): """Class for modeling a linear piece of one or more variables in parallel""" def __init__( @@ -323,7 +323,7 @@ def do_modeling(self): self.add_constraints(self.inside_piece == self.lambda0 + self.lambda1, short_name='inside_piece') -class PiecewiseModel(Model): +class PiecewiseModel(Submodel): def __init__( self, model: FlowSystemModel, @@ -404,7 +404,7 @@ def do_modeling(self): ) -class PiecewiseEffectsModel(Model): +class PiecewiseEffectsModel(Submodel): def __init__( self, model: FlowSystemModel, @@ -461,7 +461,7 @@ def do_modeling(self): ) -class ShareAllocationModel(Model): +class ShareAllocationModel(Submodel): def __init__( self, model: FlowSystemModel, diff --git a/flixopt/modeling.py b/flixopt/modeling.py index b4ce4d5db..262b0d17d 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -7,7 +7,7 @@ from .config import CONFIG from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions -from .structure import Model, FlowSystemModel, BaseFeatureModel +from .structure import Submodel, FlowSystemModel, BaseFeatureModel logger = logging.getLogger('flixopt') @@ -181,7 +181,7 @@ class ModelingPrimitives: @staticmethod def expression_tracking_variable( - model: Model, + model: Submodel, tracked_expression, name: str = None, short_name: str = None, @@ -219,7 +219,7 @@ def expression_tracking_variable( @staticmethod def state_transition_variables( - model: Union[FlowSystemModel, Model], + model: Submodel, state_variable: linopy.Variable, switch_on: linopy.Variable, switch_off: linopy.Variable, @@ -387,7 +387,7 @@ def consecutive_duration_tracking( @staticmethod def mutual_exclusivity_constraint( - model: Model, binary_variables: List[linopy.Variable], tolerance: float = 1, + model: Submodel, binary_variables: List[linopy.Variable], tolerance: float = 1, short_name: str = 'mutual_exclusivity', ) -> linopy.Constraint: """ diff --git a/flixopt/structure.py b/flixopt/structure.py index 34de27f35..e6ed849b3 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -696,7 +696,7 @@ def _valid_label(label: str) -> str: return label -class Model: +class Submodel: """Stores Variables and Constraints.""" def __init__( @@ -714,7 +714,7 @@ def __init__( self._variables: Dict[str, linopy.Variable] = {} # Mapping from short name to variable self._constraints: Dict[str, linopy.Constraint] = {} # Mapping from short name to constraint - self._sub_models: Dict[str, 'Model'] = {} + self._sub_models: Dict[str, 'Submodel'] = {} logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"') @@ -760,7 +760,7 @@ def register_constraint(self, constraint: linopy.Constraint, short_name: str = N self._constraints[short_name] = constraint return constraint - def register_sub_model(self, sub_model: 'Model', short_name: str) -> 'Model': + def register_sub_model(self, sub_model: 'Submodel', short_name: str) -> 'Submodel': """Register a sub-model with the model""" if short_name is None: short_name = sub_model.__class__.__name__ @@ -831,12 +831,12 @@ def constraints_direct(self) -> linopy.Constraints: return self._model.constraints[[con.name for con in self._constraints.values()]] @property - def sub_models_direct(self) -> Dict[str, 'Model']: + def sub_models_direct(self) -> Dict[str, 'Submodel']: """All sub-models of the model, excluding those of sub-models""" return self._sub_models @property - def sub_models(self) -> List['Model']: + def sub_models(self) -> List['Submodel']: """All sub-models of the model""" direct_submodels = list(self._sub_models.values()) @@ -896,7 +896,7 @@ def hours_per_step(self): return self._model.hours_per_step -class BaseFeatureModel(Model): +class BaseFeatureModel(Submodel): """Minimal base class for feature models that use factory patterns""" def __init__(self, model: FlowSystemModel, label_of_element: str, parameters, label_of_model: Optional[str] = None): @@ -925,7 +925,7 @@ def add_effects(self): pass # Default: no effects -class ElementModel(Model): +class ElementModel(Submodel): """Stores the mathematical Variables and Constraints for Elements""" def __init__(self, model: FlowSystemModel, element: Element): From 928125640bc8295ecbdfb8f520a974692a7421ab Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:36:23 +0200 Subject: [PATCH 43/51] rename sub_model to submodel everywhere --- flixopt/structure.py | 20 ++++++++++---------- tests/test_linear_converter.py | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index e6ed849b3..7da8ade78 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -760,14 +760,14 @@ def register_constraint(self, constraint: linopy.Constraint, short_name: str = N self._constraints[short_name] = constraint return constraint - def register_sub_model(self, sub_model: 'Submodel', short_name: str) -> 'Submodel': + def register_sub_model(self, submodel: 'Submodel', short_name: str) -> 'Submodel': """Register a sub-model with the model""" if short_name is None: - short_name = sub_model.__class__.__name__ + short_name = submodel.__class__.__name__ if short_name in self._sub_models: raise ValueError(f'Short name "{short_name}" already assigned to model') - self._sub_models[short_name] = sub_model - return sub_model + self._sub_models[short_name] = submodel + return submodel def __getitem__(self, key: str) -> linopy.Variable: """Get a variable by its short name""" @@ -852,8 +852,8 @@ def constraints(self) -> linopy.Constraints: """All constraints of the model, including those of sub-models""" names = list(self.constraints_direct) + [ constraint_name - for sub_model in self.sub_models - for constraint_name in sub_model.constraints_direct + for submodel in self.sub_models + for constraint_name in submodel.constraints_direct ] return self._model.constraints[names] @@ -863,8 +863,8 @@ def variables(self) -> linopy.Variables: """All variables of the model, including those of sub-models""" names = list(self.variables_direct) + [ variable_name - for sub_model in self.sub_models - for variable_name in sub_model.variables_direct + for submodel in self.sub_models + for variable_name in submodel.variables_direct ] return self._model.variables[names] @@ -881,8 +881,8 @@ def __repr__(self) -> str: sub_models_string = ' \n' else: sub_models_string = '' - for sub_model_name, sub_model in self.sub_models_direct.items(): - sub_models_string += f'\n * {sub_model_name} [{sub_model.__class__.__name__}]' + for submodel_name, submodel in self.sub_models_direct.items(): + sub_models_string += f'\n * {submodel_name} [{submodel.__class__.__name__}]' return ( f"{model_string}\n{'=' * len(model_string)}\n\n" diff --git a/tests/test_linear_converter.py b/tests/test_linear_converter.py index 11b5b5673..e15b11c1b 100644 --- a/tests/test_linear_converter.py +++ b/tests/test_linear_converter.py @@ -360,7 +360,7 @@ def test_piecewise_conversion(self, basic_flow_system_linopy): # Create model with the piecewise conversion model = create_linopy_model(flow_system) - # Verify that PiecewiseModel was created and added as a sub_model + # Verify that PiecewiseModel was created and added as a submodel assert converter.model.piecewise_conversion is not None # Get the PiecewiseModel instance @@ -472,7 +472,7 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): # Create model with the piecewise conversion model = create_linopy_model(flow_system) - # Verify that PiecewiseModel was created and added as a sub_model + # Verify that PiecewiseModel was created and added as a submodel assert converter.model.piecewise_conversion is not None # Get the PiecewiseModel instance From 9001c6ab129b9f8c98e7f738c603205679cfcd45 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:37:34 +0200 Subject: [PATCH 44/51] rename self.model to self.submodel everywhere --- flixopt/calculation.py | 4 ++-- flixopt/components.py | 6 +++--- flixopt/effects.py | 4 ++-- flixopt/elements.py | 6 +++--- flixopt/flow_system.py | 2 +- flixopt/results.py | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 141a8ead5..53c02beca 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -180,7 +180,7 @@ def do_modeling(self) -> 'FullCalculation': t_start = timeit.default_timer() self.flow_system.connect_and_transform() - self.model = self.flow_system.create_model() + self.submodel = self.flow_system.create_model() self.model.do_modeling() self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) @@ -298,7 +298,7 @@ def do_modeling(self) -> 'AggregatedCalculation': self._perform_aggregation() # Model the System - self.model = self.flow_system.create_model() + self.submodel = self.flow_system.create_model() self.model.do_modeling() # Add Aggregation Submodel after modeling the rest self.aggregation = AggregationModel( diff --git a/flixopt/components.py b/flixopt/components.py index 42f1cfdd5..fe776e02d 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -61,7 +61,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'LinearConverterModel': self._plausibility_checks() - self.model = LinearConverterModel(model, self) + self.submodel = LinearConverterModel(model, self) return self.model def _plausibility_checks(self) -> None: @@ -203,7 +203,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'StorageModel': self._plausibility_checks() - self.model = StorageModel(model, self) + self.submodel = StorageModel(model, self) return self.model def transform_data(self, flow_system: 'FlowSystem') -> None: @@ -380,7 +380,7 @@ def _plausibility_checks(self): def create_model(self, model) -> 'TransmissionModel': self._plausibility_checks() - self.model = TransmissionModel(model, self) + self.submodel = TransmissionModel(model, self) return self.model def transform_data(self, flow_system: 'FlowSystem') -> None: diff --git a/flixopt/effects.py b/flixopt/effects.py index 2b1b2ed6e..9f8e2506f 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -129,7 +129,7 @@ def transform_data(self, flow_system: 'FlowSystem'): def create_model(self, model: FlowSystemModel) -> 'EffectModel': self._plausibility_checks() - self.model = EffectModel(model, self) + self.submodel = EffectModel(model, self) return self.model def _plausibility_checks(self) -> None: @@ -216,7 +216,7 @@ def __init__(self, *effects: List[Effect]): def create_model(self, model: FlowSystemModel) -> 'EffectCollectionModel': self._plausibility_checks() - self.model = EffectCollectionModel(model, self) + self.submodel = EffectCollectionModel(model, self) return self.model def add_effects(self, *effects: Effect) -> None: diff --git a/flixopt/elements.py b/flixopt/elements.py index c53f7c84f..4dbf67aea 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -67,7 +67,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'ComponentModel': self._plausibility_checks() - self.model = ComponentModel(model, self) + self.submodel = ComponentModel(model, self) return self.model def transform_data(self, flow_system: 'FlowSystem') -> None: @@ -112,7 +112,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'BusModel': self._plausibility_checks() - self.model = BusModel(model, self) + self.submodel = BusModel(model, self) return self.model def transform_data(self, flow_system: 'FlowSystem'): @@ -229,7 +229,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'FlowModel': self._plausibility_checks() - self.model = FlowModel(model, self) + self.submodel = FlowModel(model, self) return self.model def transform_data(self, flow_system: 'FlowSystem'): diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 877db6fdc..454a552b3 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -451,7 +451,7 @@ def add_elements(self, *elements: Element) -> None: def create_model(self) -> FlowSystemModel: if not self.connected_and_transformed: raise RuntimeError('FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.') - self.model = FlowSystemModel(self) + self.submodel = FlowSystemModel(self) return self.model def plot_network( diff --git a/flixopt/results.py b/flixopt/results.py index 941d0d6dc..9ede9ab49 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -167,7 +167,7 @@ def __init__( self.flow_system_data = flow_system_data self.summary = summary self.name = name - self.model = model + self.submodel = model self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' self.components = { label: ComponentResults(self, **infos) for label, infos in self.solution.attrs['Components'].items() From 286a8b7f3455972750c8ce1744d5f17632518ed7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:56:26 +0200 Subject: [PATCH 45/51] Rename .model with .submodel if its only a submodel --- .../example_calculation_types.py | 2 +- flixopt/aggregation.py | 8 +- flixopt/calculation.py | 28 +-- flixopt/components.py | 24 +- flixopt/effects.py | 26 +- flixopt/elements.py | 30 +-- flixopt/flow_system.py | 2 +- flixopt/results.py | 2 +- flixopt/structure.py | 10 +- tests/test_bus.py | 8 +- tests/test_component.py | 50 ++-- tests/test_effect.py | 12 +- tests/test_flow.py | 232 +++++++++--------- tests/test_functional.py | 88 +++---- tests/test_integration.py | 22 +- tests/test_linear_converter.py | 28 +-- 16 files changed, 286 insertions(+), 286 deletions(-) diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 8bbdf1773..9f5828cec 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -188,7 +188,7 @@ def get_solutions(calcs: List, variable: str) -> xr.Dataset: if calc.name == 'Segmented': dataarrays.append(calc.results.solution_without_overlap(variable).rename(calc.name)) else: - dataarrays.append(calc.results.model.variables[variable].solution.rename(calc.name)) + dataarrays.append(calc.results.submodel.variables[variable].solution.rename(calc.name)) return xr.merge(dataarrays) # --- Plotting for comparison --- diff --git a/flixopt/aggregation.py b/flixopt/aggregation.py index eb44ad707..e4f7a598a 100644 --- a/flixopt/aggregation.py +++ b/flixopt/aggregation.py @@ -323,14 +323,14 @@ def do_modeling(self): if isinstance(component, Storage) and not self.aggregation_parameters.fix_storage_flows: continue # Fix Nothing in The Storage - all_variables_of_component = set(component.model.variables) + all_variables_of_component = set(component.submodel.variables) if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: - relevant_variables = component.model.variables[all_variables_of_component & time_variables] + relevant_variables = component.submodel.variables[all_variables_of_component & time_variables] else: - relevant_variables = component.model.variables[all_variables_of_component & binary_time_variables] + relevant_variables = component.submodel.variables[all_variables_of_component & binary_time_variables] for variable in relevant_variables: - self._equate_indices(component.model.variables[variable], indices) + self._equate_indices(component.submodel.variables[variable], indices) penalty = self.aggregation_parameters.penalty_of_period_freedom if (self.aggregation_parameters.percentage_of_period_freedom > 0) and penalty != 0: diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 53c02beca..5e505ff0f 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -81,7 +81,7 @@ def __init__( flow_system._used_in_calculation = True self.flow_system = flow_system - self.model: Optional[FlowSystemModel] = None + self.submodel: Optional[FlowSystemModel] = None self.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0} self.folder = pathlib.Path.cwd() / 'results' if folder is None else pathlib.Path(folder) @@ -106,9 +106,9 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: 'Penalty': self.model.effects.penalty.total.solution.values, 'Effects': { f'{effect.label} [{effect.unit}]': { - 'operation': effect.model.operation.total.solution.values, - 'invest': effect.model.invest.total.solution.values, - 'total': effect.model.total.solution.values, + 'operation': effect.submodel.operation.total.solution.values, + 'invest': effect.submodel.invest.total.solution.values, + 'total': effect.submodel.total.solution.values, } for effect in self.flow_system.effects }, @@ -116,28 +116,28 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: 'Invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() - for model in component.model.sub_models + for model in component.submodel.sub_models if isinstance(model, InvestmentModel) and model.size.solution.max() >= CONFIG.modeling.EPSILON }, 'Not invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() - for model in component.model.sub_models + for model in component.submodel.sub_models if isinstance(model, InvestmentModel) and model.size.solution.max() < CONFIG.modeling.EPSILON }, }, 'Buses with excess': [ { bus.label_full: { - 'input': bus.model.excess_input.solution.sum('time'), - 'output': bus.model.excess_output.solution.sum('time'), + 'input': bus.submodel.excess_input.solution.sum('time'), + 'output': bus.submodel.excess_output.solution.sum('time'), } } for bus in self.flow_system.buses.values() if bus.with_excess and ( - bus.model.excess_input.solution.sum() > 1e-3 - or bus.model.excess_output.solution.sum() > 1e-3 + bus.submodel.excess_input.solution.sum() > 1e-3 + or bus.submodel.excess_output.solution.sum() > 1e-3 ) ], } @@ -180,7 +180,7 @@ def do_modeling(self) -> 'FullCalculation': t_start = timeit.default_timer() self.flow_system.connect_and_transform() - self.submodel = self.flow_system.create_model() + self.model = self.flow_system.create_model() self.model.do_modeling() self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) @@ -298,7 +298,7 @@ def do_modeling(self) -> 'AggregatedCalculation': self._perform_aggregation() # Model the System - self.submodel = self.flow_system.create_model() + self.model = self.flow_system.create_model() self.model.do_modeling() # Add Aggregation Submodel after modeling the rest self.aggregation = AggregationModel( @@ -488,7 +488,7 @@ def do_modeling_and_solve( invest_elements = [ model.label_full for component in calculation.flow_system.components.values() - for model in component.model.sub_models + for model in component.submodel.sub_models if isinstance(model, InvestmentModel) ] if invest_elements: @@ -532,7 +532,7 @@ def _transfer_start_values(self, i: int): for current_flow in current_flow_system.flows.values(): next_flow = next_flow_system.flows[current_flow.label_full] - next_flow.previous_flow_rate = current_flow.model.flow_rate.solution.sel( + next_flow.previous_flow_rate = current_flow.submodel.flow_rate.solution.sel( time=slice(start_previous_values, end_previous_values) ).values start_values_of_this_segment[current_flow.label_full] = next_flow.previous_flow_rate diff --git a/flixopt/components.py b/flixopt/components.py index fe776e02d..52e676323 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -62,7 +62,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'LinearConverterModel': self._plausibility_checks() self.submodel = LinearConverterModel(model, self) - return self.model + return self.submodel def _plausibility_checks(self) -> None: super()._plausibility_checks() @@ -204,7 +204,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'StorageModel': self._plausibility_checks() self.submodel = StorageModel(model, self) - return self.model + return self.submodel def transform_data(self, flow_system: 'FlowSystem') -> None: super().transform_data(flow_system) @@ -381,7 +381,7 @@ def _plausibility_checks(self): def create_model(self, model) -> 'TransmissionModel': self._plausibility_checks() self.submodel = TransmissionModel(model, self) - return self.model + return self.submodel def transform_data(self, flow_system: 'FlowSystem') -> None: super().transform_data(flow_system) @@ -420,7 +420,7 @@ def do_modeling(self): if self.element.balanced: # eq: in1.size = in2.size self.add_constraints( - self.element.in1.model._investment.size == self.element.in2.model._investment.size, + self.element.in1.submodel._investment.size == self.element.in2.submodel._investment.size, short_name='same_size', ) @@ -428,12 +428,12 @@ def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) """Creates an Equation for the Transmission efficiency and adds it to the model""" # eq: out(t) + on(t)*loss_abs(t) = in(t)*(1 - loss_rel(t)) con_transmission = self.add_constraints( - out_flow.model.flow_rate == -in_flow.model.flow_rate * (self.element.relative_losses - 1), + out_flow.submodel.flow_rate == -in_flow.submodel.flow_rate * (self.element.relative_losses - 1), short_name=name, ) if self.element.absolute_losses is not None: - con_transmission.lhs += in_flow.model.on_off.on * self.element.absolute_losses + con_transmission.lhs += in_flow.submodel.on_off.on * self.element.absolute_losses return con_transmission @@ -460,15 +460,15 @@ def do_modeling(self): used_outputs: Set = all_output_flows & used_flows self.add_constraints( - sum([flow.model.flow_rate * conv_factors[flow.label] for flow in used_inputs]) - == sum([flow.model.flow_rate * conv_factors[flow.label] for flow in used_outputs]), + sum([flow.submodel.flow_rate * conv_factors[flow.label] for flow in used_inputs]) + == sum([flow.submodel.flow_rate * conv_factors[flow.label] for flow in used_outputs]), short_name=f'conversion_{i}', ) else: # TODO: Improve Inclusion of OnOffParameters. Instead of creating a Binary in every flow, the binary could only be part of the Piece itself piecewise_conversion = { - self.element.flows[flow].model.flow_rate.name: piecewise + self.element.flows[flow].submodel.flow_rate.name: piecewise for flow, piecewise in self.element.piecewise_conversion.items() } @@ -510,15 +510,15 @@ def do_modeling(self): # eq: nettoFlow(t) - discharging(t) + charging(t) = 0 self.add_constraints( self.netto_discharge - == self.element.discharging.model.flow_rate - self.element.charging.model.flow_rate, + == self.element.discharging.submodel.flow_rate - self.element.charging.submodel.flow_rate, short_name='netto_discharge', ) charge_state = self.charge_state rel_loss = self.element.relative_loss_per_hour hours_per_step = self._model.hours_per_step - charge_rate = self.element.charging.model.flow_rate - discharge_rate = self.element.discharging.model.flow_rate + charge_rate = self.element.charging.submodel.flow_rate + discharge_rate = self.element.discharging.submodel.flow_rate eff_charge = self.element.eta_charge eff_discharge = self.element.eta_discharge diff --git a/flixopt/effects.py b/flixopt/effects.py index 9f8e2506f..f9b122b1b 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -130,7 +130,7 @@ def transform_data(self, flow_system: 'FlowSystem'): def create_model(self, model: FlowSystemModel) -> 'EffectModel': self._plausibility_checks() self.submodel = EffectModel(model, self) - return self.model + return self.submodel def _plausibility_checks(self) -> None: # TODO: Check for plausibility @@ -211,13 +211,13 @@ def __init__(self, *effects: List[Effect]): self._standard_effect: Optional[Effect] = None self._objective_effect: Optional[Effect] = None - self.model: Optional[EffectCollectionModel] = None + self.submodel: Optional[EffectCollectionModel] = None self.add_effects(*effects) def create_model(self, model: FlowSystemModel) -> 'EffectCollectionModel': self._plausibility_checks() self.submodel = EffectCollectionModel(model, self) - return self.model + return self.submodel def add_effects(self, *effects: Effect) -> None: for effect in list(effects): @@ -393,13 +393,13 @@ def add_share_to_effects( ) -> None: for effect, expression in expressions.items(): if target == 'operation': - self.effects[effect].model.operation.add_share( + self.effects[effect].submodel.operation.add_share( name, expression, dims=('time', 'year', 'scenario'), ) elif target == 'invest': - self.effects[effect].model.invest.add_share( + self.effects[effect].submodel.invest.add_share( name, expression, dims=('year', 'scenario'), @@ -419,13 +419,13 @@ def do_modeling(self): ShareAllocationModel(self._model, dims=(), label_of_element='Penalty'), short_name='penalty', ) - for model in [effect.model for effect in self.effects] + [self.penalty]: + for model in [effect.submodel for effect in self.effects] + [self.penalty]: model.do_modeling() self._add_share_between_effects() self._model.add_objective( - (self.effects.objective_effect.model.total * self._model.weights).sum() + (self.effects.objective_effect.submodel.total * self._model.weights).sum() + self.penalty.total.sum() ) @@ -433,16 +433,16 @@ def _add_share_between_effects(self): for origin_effect in self.effects: # 1. operation: -> hier sind es Zeitreihen (share_TS) for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items(): - self.effects[target_effect].model.operation.add_share( - origin_effect.model.operation.label_full, - origin_effect.model.operation.total_per_timestep * time_series, + self.effects[target_effect].submodel.operation.add_share( + origin_effect.submodel.operation.label_full, + origin_effect.submodel.operation.total_per_timestep * time_series, dims=('time', 'year', 'scenario'), ) # 2. invest: -> hier ist es Scalar (share) for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items(): - self.effects[target_effect].model.invest.add_share( - origin_effect.model.invest.label_full, - origin_effect.model.invest.total * factor, + self.effects[target_effect].submodel.invest.add_share( + origin_effect.submodel.invest.label_full, + origin_effect.submodel.invest.total * factor, dims=('year', 'scenario'), ) diff --git a/flixopt/elements.py b/flixopt/elements.py index 4dbf67aea..b952093ba 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -68,7 +68,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'ComponentModel': self._plausibility_checks() self.submodel = ComponentModel(model, self) - return self.model + return self.submodel def transform_data(self, flow_system: 'FlowSystem') -> None: if self.on_off_parameters is not None: @@ -113,7 +113,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'BusModel': self._plausibility_checks() self.submodel = BusModel(model, self) - return self.model + return self.submodel def transform_data(self, flow_system: 'FlowSystem'): self.excess_penalty_per_flow_hour = flow_system.fit_to_model_coords( @@ -230,7 +230,7 @@ def __init__( def create_model(self, model: FlowSystemModel) -> 'FlowModel': self._plausibility_checks() self.submodel = FlowModel(model, self) - return self.model + return self.submodel def transform_data(self, flow_system: 'FlowSystem'): self.relative_minimum = flow_system.fit_to_model_coords( @@ -551,9 +551,9 @@ def __init__(self, model: FlowSystemModel, element: Bus): def do_modeling(self) -> None: # inputs == outputs for flow in self.element.inputs + self.element.outputs: - self.register_variable(flow.model.flow_rate, flow.label_full) - inputs = sum([flow.model.flow_rate for flow in self.element.inputs]) - outputs = sum([flow.model.flow_rate for flow in self.element.outputs]) + self.register_variable(flow.submodel.flow_rate, flow.label_full) + inputs = sum([flow.submodel.flow_rate for flow in self.element.inputs]) + outputs = sum([flow.submodel.flow_rate for flow in self.element.outputs]) eq_bus_balance = self.add_constraints(inputs == outputs, short_name='balance') # Fehlerplus/-minus: @@ -570,8 +570,8 @@ def do_modeling(self) -> None: self._model.effects.add_share_to_penalty(self.label_of_element, (self.excess_output * excess_penalty).sum()) def results_structure(self): - inputs = [flow.model.flow_rate.name for flow in self.element.inputs] - outputs = [flow.model.flow_rate.name for flow in self.element.outputs] + inputs = [flow.submodel.flow_rate.name for flow in self.element.inputs] + outputs = [flow.submodel.flow_rate.name for flow in self.element.outputs] if self.excess_input is not None: inputs.append(self.excess_input.name) if self.excess_output is not None: @@ -606,9 +606,9 @@ def do_modeling(self): if self.element.on_off_parameters: on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords()) if len(all_flows) == 1: - self.add_constraints(on == all_flows[0].model.on_off.on, short_name='on') + self.add_constraints(on == all_flows[0].submodel.on_off.on, short_name='on') else: - flow_ons = [flow.model.on_off.on for flow in all_flows] + flow_ons = [flow.submodel.on_off.on for flow in all_flows] #TODO: Is the EPSILON even necessary? self.add_constraints(on <= sum(flow_ons) + CONFIG.modeling.EPSILON, short_name='on|ub') self.add_constraints(on >= sum(flow_ons) / (len(flow_ons) + CONFIG.modeling.EPSILON), short_name='on|lb') @@ -629,18 +629,18 @@ def do_modeling(self): if self.element.prevent_simultaneous_flows: # Simultanious Useage --> Only One FLow is On at a time, but needs a Binary for every flow - on_variables = [flow.model.on_off.on for flow in self.element.prevent_simultaneous_flows] + on_variables = [flow.submodel.on_off.on for flow in self.element.prevent_simultaneous_flows] ModelingPrimitives.mutual_exclusivity_constraint( self, - binary_variables=[flow.model.on_off.on for flow in self.element.prevent_simultaneous_flows], + binary_variables=[flow.submodel.on_off.on for flow in self.element.prevent_simultaneous_flows], short_name='prevent_simultaneous_use', ) def results_structure(self): return { **super().results_structure(), - 'inputs': [flow.model.flow_rate.name for flow in self.element.inputs], - 'outputs': [flow.model.flow_rate.name for flow in self.element.outputs], + 'inputs': [flow.submodel.flow_rate.name for flow in self.element.inputs], + 'outputs': [flow.submodel.flow_rate.name for flow in self.element.outputs], 'flows': [flow.label_full for flow in self.element.inputs + self.element.outputs], } @@ -650,7 +650,7 @@ def previous_states(self) -> Optional[xr.DataArray]: if self.element.on_off_parameters is None: raise ValueError(f'OnOffModel not present in \n{self}\nCant access previous_states') - previous_states = [flow.model.on_off._previous_states for flow in self.element.inputs + self.element.outputs] + previous_states = [flow.submodel.on_off._previous_states for flow in self.element.inputs + self.element.outputs] previous_states = [da for da in previous_states if da is not None] if not previous_states: # Empty list diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 454a552b3..0a10b3ceb 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -452,7 +452,7 @@ def create_model(self) -> FlowSystemModel: if not self.connected_and_transformed: raise RuntimeError('FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.') self.submodel = FlowSystemModel(self) - return self.model + return self.submodel def plot_network( self, diff --git a/flixopt/results.py b/flixopt/results.py index 9ede9ab49..941d0d6dc 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -167,7 +167,7 @@ def __init__( self.flow_system_data = flow_system_data self.summary = summary self.name = name - self.submodel = model + self.model = model self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' self.components = { label: ComponentResults(self, **infos) for label, infos in self.solution.attrs['Components'].items() diff --git a/flixopt/structure.py b/flixopt/structure.py index 7da8ade78..61becd11f 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -74,21 +74,21 @@ def solution(self): solution['objective'] = self.objective.value solution.attrs = { 'Components': { - comp.label_full: comp.model.results_structure() + comp.label_full: comp.submodel.results_structure() for comp in sorted( self.flow_system.components.values(), key=lambda component: component.label_full.upper() ) }, 'Buses': { - bus.label_full: bus.model.results_structure() + bus.label_full: bus.submodel.results_structure() for bus in sorted(self.flow_system.buses.values(), key=lambda bus: bus.label_full.upper()) }, 'Effects': { - effect.label_full: effect.model.results_structure() + effect.label_full: effect.submodel.results_structure() for effect in sorted(self.flow_system.effects, key=lambda effect: effect.label_full.upper()) }, 'Flows': { - flow.label_full: flow.model.results_structure() + flow.label_full: flow.submodel.results_structure() for flow in sorted(self.flow_system.flows.values(), key=lambda flow: flow.label_full.upper()) }, } @@ -661,7 +661,7 @@ def __init__(self, label: str, meta_data: Dict = None): """ self.label = Element._valid_label(label) self.meta_data = meta_data if meta_data is not None else {} - self.model: Optional[ElementModel] = None + self.submodel: Optional[ElementModel] = None def _plausibility_checks(self) -> None: """This function is used to do some basic plausibility checks for each Element during initialization""" diff --git a/tests/test_bus.py b/tests/test_bus.py index 136f9d2cc..fb1cfcda3 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -20,8 +20,8 @@ def test_bus(self, basic_flow_system_linopy): fx.Source('GastarifTest', source=fx.Flow('Q_Gas', 'TestBus'))) model = create_linopy_model(flow_system) - assert set(bus.model.variables) == {'WärmelastTest(Q_th_Last)|flow_rate', 'GastarifTest(Q_Gas)|flow_rate'} - assert set(bus.model.constraints) == {'TestBus|balance'} + assert set(bus.submodel.variables) == {'WärmelastTest(Q_th_Last)|flow_rate', 'GastarifTest(Q_Gas)|flow_rate'} + assert set(bus.submodel.constraints) == {'TestBus|balance'} assert_conequal( model.constraints['TestBus|balance'], @@ -38,11 +38,11 @@ def test_bus_penalty(self, basic_flow_system_linopy): fx.Source('GastarifTest', source=fx.Flow('Q_Gas', 'TestBus'))) model = create_linopy_model(flow_system) - assert set(bus.model.variables) == {'TestBus|excess_input', + assert set(bus.submodel.variables) == {'TestBus|excess_input', 'TestBus|excess_output', 'WärmelastTest(Q_th_Last)|flow_rate', 'GastarifTest(Q_Gas)|flow_rate'} - assert set(bus.model.constraints) == {'TestBus|balance'} + assert set(bus.submodel.constraints) == {'TestBus|balance'} assert_var_equal(model.variables['TestBus|excess_input'], model.add_variables(lower=0, coords = (timesteps,))) assert_var_equal(model.variables['TestBus|excess_output'], model.add_variables(lower=0, coords=(timesteps,))) diff --git a/tests/test_component.py b/tests/test_component.py index 3bf1699ec..14b1544dd 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -53,12 +53,12 @@ def test_component(self, basic_flow_system_linopy): 'TestComponent(Out1)|flow_rate', 'TestComponent(Out1)|total_flow_hours', 'TestComponent(Out2)|flow_rate', - 'TestComponent(Out2)|total_flow_hours'} == set(comp.model.variables) + 'TestComponent(Out2)|total_flow_hours'} == set(comp.submodel.variables) assert {'TestComponent(In1)|total_flow_hours', 'TestComponent(In2)|total_flow_hours', 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out2)|total_flow_hours'} == set(comp.model.constraints) + 'TestComponent(Out2)|total_flow_hours'} == set(comp.submodel.constraints) def test_on_with_multiple_flows(self, basic_flow_system_linopy): """Test that flow model constraints are correctly generated.""" @@ -78,7 +78,7 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): flow_system.add_elements(comp) model = create_linopy_model(flow_system) - assert set(comp.model.variables) == { + assert set(comp.submodel.variables) == { 'TestComponent(In1)|flow_rate', 'TestComponent(In1)|total_flow_hours', 'TestComponent(In1)|on', @@ -95,7 +95,7 @@ def test_on_with_multiple_flows(self, basic_flow_system_linopy): 'TestComponent|on_hours_total', } - assert set(comp.model.constraints) == { + assert set(comp.submodel.constraints) == { 'TestComponent(In1)|total_flow_hours', 'TestComponent(In1)|flow_rate|lb', 'TestComponent(In1)|flow_rate|ub', @@ -158,7 +158,7 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): flow_system.add_elements(comp) model = create_linopy_model(flow_system) - assert set(comp.model.variables) == { + assert set(comp.submodel.variables) == { 'TestComponent(In1)|flow_rate', 'TestComponent(In1)|total_flow_hours', 'TestComponent(In1)|on', @@ -167,7 +167,7 @@ def test_on_with_single_flow(self, basic_flow_system_linopy): 'TestComponent|on_hours_total', } - assert set(comp.model.constraints) == { + assert set(comp.submodel.constraints) == { 'TestComponent(In1)|total_flow_hours', 'TestComponent(In1)|flow_rate|lb', 'TestComponent(In1)|flow_rate|ub', @@ -214,7 +214,7 @@ def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy): flow_system.add_elements(comp) model = create_linopy_model(flow_system) - assert set(comp.model.variables) == { + assert set(comp.submodel.variables) == { 'TestComponent(In1)|flow_rate', 'TestComponent(In1)|total_flow_hours', 'TestComponent(In1)|on', @@ -231,7 +231,7 @@ def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy): 'TestComponent|on_hours_total', } - assert set(comp.model.constraints) == { + assert set(comp.submodel.constraints) == { 'TestComponent(In1)|total_flow_hours', 'TestComponent(In1)|flow_rate|lb', 'TestComponent(In1)|flow_rate|ub', @@ -296,14 +296,14 @@ def test_transmission_basic(self, basic_flow_system, highs_solver): # Assertions assert_almost_equal_numeric( - transmission.in1.model.on_off.on.solution.values, + transmission.in1.submodel.on_off.on.solution.values, np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), 'On does not work properly', ) assert_almost_equal_numeric( - transmission.in1.model.flow_rate.solution.values * 0.8 - 20, - transmission.out1.model.flow_rate.solution.values, + transmission.in1.submodel.flow_rate.solution.values * 0.8 - 20, + transmission.out1.submodel.flow_rate.solution.values, 'Losses are not computed correctly', ) @@ -351,27 +351,27 @@ def test_transmission_balanced(self, basic_flow_system, highs_solver): # Assertions assert_almost_equal_numeric( - transmission.in1.model.on_off.on.solution.values, + transmission.in1.submodel.on_off.on.solution.values, np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), 'On does not work properly', ) assert_almost_equal_numeric( calculation.results.model.variables['Rohr(Rohr1b)|flow_rate'].solution.values, - transmission.out1.model.flow_rate.solution.values, + transmission.out1.submodel.flow_rate.solution.values, 'Flow rate of Rohr__Rohr1b is not correct', ) assert_almost_equal_numeric( - transmission.in1.model.flow_rate.solution.values * 0.8 - - np.array([20 if val > 0.1 else 0 for val in transmission.in1.model.flow_rate.solution.values]), - transmission.out1.model.flow_rate.solution.values, + transmission.in1.submodel.flow_rate.solution.values * 0.8 + - np.array([20 if val > 0.1 else 0 for val in transmission.in1.submodel.flow_rate.solution.values]), + transmission.out1.submodel.flow_rate.solution.values, 'Losses are not computed correctly', ) assert_almost_equal_numeric( - transmission.in1.model._investment.size.solution.item(), - transmission.in2.model._investment.size.solution.item(), + transmission.in1.submodel._investment.size.solution.item(), + transmission.in2.submodel._investment.size.solution.item(), 'The Investments are not equated correctly', ) @@ -419,28 +419,28 @@ def test_transmission_unbalanced(self, basic_flow_system, highs_solver): # Assertions assert_almost_equal_numeric( - transmission.in1.model.on_off.on.solution.values, + transmission.in1.submodel.on_off.on.solution.values, np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), 'On does not work properly', ) assert_almost_equal_numeric( calculation.results.model.variables['Rohr(Rohr1b)|flow_rate'].solution.values, - transmission.out1.model.flow_rate.solution.values, + transmission.out1.submodel.flow_rate.solution.values, 'Flow rate of Rohr__Rohr1b is not correct', ) assert_almost_equal_numeric( - transmission.in1.model.flow_rate.solution.values * 0.8 - - np.array([20 if val > 0.1 else 0 for val in transmission.in1.model.flow_rate.solution.values]), - transmission.out1.model.flow_rate.solution.values, + transmission.in1.submodel.flow_rate.solution.values * 0.8 + - np.array([20 if val > 0.1 else 0 for val in transmission.in1.submodel.flow_rate.solution.values]), + transmission.out1.submodel.flow_rate.solution.values, 'Losses are not computed correctly', ) - assert transmission.in1.model._investment.size.solution.item() > 11 + assert transmission.in1.submodel._investment.size.solution.item() > 11 assert_almost_equal_numeric( - transmission.in2.model._investment.size.solution.item(), + transmission.in2.submodel._investment.size.solution.item(), 10, 'Sizing does not work properly', ) diff --git a/tests/test_effect.py b/tests/test_effect.py index 8c75813e7..cce8ac939 100644 --- a/tests/test_effect.py +++ b/tests/test_effect.py @@ -19,11 +19,11 @@ def test_minimal(self, basic_flow_system_linopy): flow_system.add_elements(effect) model = create_linopy_model(flow_system) - assert set(effect.model.variables) == {'Effect1(invest)|total', + assert set(effect.submodel.variables) == {'Effect1(invest)|total', 'Effect1(operation)|total', 'Effect1(operation)|total_per_timestep', 'Effect1|total',} - assert set(effect.model.constraints) == {'Effect1(invest)|total', + assert set(effect.submodel.constraints) == {'Effect1(invest)|total', 'Effect1(operation)|total', 'Effect1(operation)|total_per_timestep', 'Effect1|total',} @@ -58,11 +58,11 @@ def test_bounds(self, basic_flow_system_linopy): flow_system.add_elements(effect) model = create_linopy_model(flow_system) - assert set(effect.model.variables) == {'Effect1(invest)|total', + assert set(effect.submodel.variables) == {'Effect1(invest)|total', 'Effect1(operation)|total', 'Effect1(operation)|total_per_timestep', 'Effect1|total',} - assert set(effect.model.constraints) == {'Effect1(invest)|total', + assert set(effect.submodel.constraints) == {'Effect1(invest)|total', 'Effect1(operation)|total', 'Effect1(operation)|total_per_timestep', 'Effect1|total',} @@ -100,7 +100,7 @@ def test_shares(self, basic_flow_system_linopy): flow_system.add_elements(effect1, effect2, effect3) model = create_linopy_model(flow_system) - assert set(effect2.model.variables) == { + assert set(effect2.submodel.variables) == { 'Effect2(invest)|total', 'Effect2(operation)|total', 'Effect2(operation)|total_per_timestep', @@ -108,7 +108,7 @@ def test_shares(self, basic_flow_system_linopy): 'Effect1(invest)->Effect2(invest)', 'Effect1(operation)->Effect2(operation)', } - assert set(effect2.model.constraints) == { + assert set(effect2.submodel.constraints) == { 'Effect2(invest)|total', 'Effect2(operation)|total', 'Effect2(operation)|total_per_timestep', diff --git a/tests/test_flow.py b/tests/test_flow.py index 50154859d..43ecbe34f 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -23,14 +23,14 @@ def test_flow_minimal(self, basic_flow_system_linopy): assert_conequal( model.constraints['Sink(Wärme)|total_flow_hours'], - flow.model.variables['Sink(Wärme)|total_flow_hours'] == (flow.model.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum() + flow.submodel.variables['Sink(Wärme)|total_flow_hours'] == (flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum() ) - assert_var_equal(flow.model.flow_rate, + assert_var_equal(flow.submodel.flow_rate, model.add_variables(lower=0, upper=100, coords=(timesteps,))) - assert_var_equal(flow.model.total_flow_hours, model.add_variables(lower=0)) + assert_var_equal(flow.submodel.total_flow_hours, model.add_variables(lower=0)) - assert set(flow.model.variables) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate']) - assert set(flow.model.constraints) == set(['Sink(Wärme)|total_flow_hours']) + assert set(flow.submodel.variables) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate']) + assert set(flow.submodel.constraints) == set(['Sink(Wärme)|total_flow_hours']) def test_flow(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy @@ -53,17 +53,17 @@ def test_flow(self, basic_flow_system_linopy): # total_flow_hours assert_conequal( model.constraints['Sink(Wärme)|total_flow_hours'], - flow.model.variables['Sink(Wärme)|total_flow_hours'] - == (flow.model.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum(), + flow.submodel.variables['Sink(Wärme)|total_flow_hours'] + == (flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step).sum(), ) assert_var_equal( - flow.model.total_flow_hours, + flow.submodel.total_flow_hours, model.add_variables(lower=10, upper=1000) ) assert_var_equal( - flow.model.flow_rate, + flow.submodel.flow_rate, model.add_variables(lower=np.linspace(0, 0.5, timesteps.size) * 100, upper=np.linspace(0.5, 1, timesteps.size) * 100, coords=(timesteps,)) @@ -71,18 +71,18 @@ def test_flow(self, basic_flow_system_linopy): assert_conequal( model.constraints['Sink(Wärme)|load_factor_min'], - flow.model.variables['Sink(Wärme)|total_flow_hours'] + flow.submodel.variables['Sink(Wärme)|total_flow_hours'] >= model.hours_per_step.sum('time') * 0.1 * 100, ) assert_conequal( model.constraints['Sink(Wärme)|load_factor_max'], - flow.model.variables['Sink(Wärme)|total_flow_hours'] + flow.submodel.variables['Sink(Wärme)|total_flow_hours'] <= model.hours_per_step.sum('time') * 0.9 * 100, ) - assert set(flow.model.variables) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate']) - assert set(flow.model.constraints) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|load_factor_max', 'Sink(Wärme)|load_factor_min']) + assert set(flow.submodel.variables) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate']) + assert set(flow.submodel.constraints) == set(['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|load_factor_max', 'Sink(Wärme)|load_factor_min']) def test_effects_per_flow_hour(self, basic_flow_system_linopy): flow_system = basic_flow_system_linopy @@ -100,19 +100,19 @@ def test_effects_per_flow_hour(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) costs, co2 = flow_system.effects['Costs'], flow_system.effects['CO2'] - assert set(flow.model.variables) == {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate'} - assert set(flow.model.constraints) == {'Sink(Wärme)|total_flow_hours'} + assert set(flow.submodel.variables) == {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate'} + assert set(flow.submodel.constraints) == {'Sink(Wärme)|total_flow_hours'} - assert 'Sink(Wärme)->Costs(operation)' in set(costs.model.constraints) - assert 'Sink(Wärme)->CO2(operation)' in set(co2.model.constraints) + assert 'Sink(Wärme)->Costs(operation)' in set(costs.submodel.constraints) + assert 'Sink(Wärme)->CO2(operation)' in set(co2.submodel.constraints) assert_conequal( model.constraints['Sink(Wärme)->Costs(operation)'], - model.variables['Sink(Wärme)->Costs(operation)'] == flow.model.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step * costs_per_flow_hour) + model.variables['Sink(Wärme)->Costs(operation)'] == flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step * costs_per_flow_hour) assert_conequal( model.constraints['Sink(Wärme)->CO2(operation)'], - model.variables['Sink(Wärme)->CO2(operation)'] == flow.model.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step * co2_per_flow_hour) + model.variables['Sink(Wärme)->CO2(operation)'] == flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.hours_per_step * co2_per_flow_hour) class TestFlowInvestModel: @@ -133,14 +133,14 @@ def test_flow_invest(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.model.variables) == set( + assert set(flow.submodel.variables) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', ] ) - assert set(flow.model.constraints) == set( + assert set(flow.submodel.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate|ub', @@ -153,7 +153,7 @@ def test_flow_invest(self, basic_flow_system_linopy): # flow_rate assert_var_equal( - flow.model.flow_rate, + flow.submodel.flow_rate, model.add_variables( lower=np.linspace(0.1, 0.5, timesteps.size) * 20, upper=np.linspace(0.5, 1, timesteps.size) * 100, @@ -162,14 +162,14 @@ def test_flow_invest(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], - flow.model.variables['Sink(Wärme)|flow_rate'] - >= flow.model.variables['Sink(Wärme)|size'] + flow.submodel.variables['Sink(Wärme)|flow_rate'] + >= flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.model.variables['Sink(Wärme)|flow_rate'] - <= flow.model.variables['Sink(Wärme)|size'] + flow.submodel.variables['Sink(Wärme)|flow_rate'] + <= flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), ) @@ -188,10 +188,10 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.model.variables) == set( + assert set(flow.submodel.variables) == set( ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|is_invested'] ) - assert set(flow.model.constraints) == set( + assert set(flow.submodel.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|size|lb', @@ -207,7 +207,7 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): # flow_rate assert_var_equal( - flow.model.flow_rate, + flow.submodel.flow_rate, model.add_variables( lower=0, # Optional investment upper=np.linspace(0.5, 1, timesteps.size) * 100, @@ -216,25 +216,25 @@ def test_flow_invest_optional(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], - flow.model.variables['Sink(Wärme)|flow_rate'] - >= flow.model.variables['Sink(Wärme)|size'] + flow.submodel.variables['Sink(Wärme)|flow_rate'] + >= flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.model.variables['Sink(Wärme)|flow_rate'] - <= flow.model.variables['Sink(Wärme)|size'] + flow.submodel.variables['Sink(Wärme)|flow_rate'] + <= flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), ) # Is invested assert_conequal( model.constraints['Sink(Wärme)|size|ub'], - flow.model.variables['Sink(Wärme)|size'] <= flow.model.variables['Sink(Wärme)|is_invested'] * 100, + flow.submodel.variables['Sink(Wärme)|size'] <= flow.submodel.variables['Sink(Wärme)|is_invested'] * 100, ) assert_conequal( model.constraints['Sink(Wärme)|size|lb'], - flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 20, + flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|is_invested'] * 20, ) def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): @@ -252,10 +252,10 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.model.variables) == set( + assert set(flow.submodel.variables) == set( ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|is_invested'] ) - assert set(flow.model.constraints) == set( + assert set(flow.submodel.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|size|ub', @@ -271,7 +271,7 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): # flow_rate assert_var_equal( - flow.model.flow_rate, + flow.submodel.flow_rate, model.add_variables( lower=0, # Optional investment upper=np.linspace(0.5, 1, timesteps.size) * 100, @@ -280,25 +280,25 @@ def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], - flow.model.variables['Sink(Wärme)|flow_rate'] - >= flow.model.variables['Sink(Wärme)|size'] + flow.submodel.variables['Sink(Wärme)|flow_rate'] + >= flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.model.variables['Sink(Wärme)|flow_rate'] - <= flow.model.variables['Sink(Wärme)|size'] + flow.submodel.variables['Sink(Wärme)|flow_rate'] + <= flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), ) # Is invested assert_conequal( model.constraints['Sink(Wärme)|size|ub'], - flow.model.variables['Sink(Wärme)|size'] <= flow.model.variables['Sink(Wärme)|is_invested'] * 100, + flow.submodel.variables['Sink(Wärme)|size'] <= flow.submodel.variables['Sink(Wärme)|is_invested'] * 100, ) assert_conequal( model.constraints['Sink(Wärme)|size|lb'], - flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 1e-5, + flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|is_invested'] * 1e-5, ) def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): @@ -316,10 +316,10 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.model.variables) == set( + assert set(flow.submodel.variables) == set( ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size'] ) - assert set(flow.model.constraints) == set( + assert set(flow.submodel.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate|lb', @@ -331,7 +331,7 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): # flow_rate assert_var_equal( - flow.model.flow_rate, + flow.submodel.flow_rate, model.add_variables( lower=np.linspace(0.1, 0.5, timesteps.size) * 1e-5, upper=np.linspace(0.5, 1, timesteps.size) * 100, @@ -340,14 +340,14 @@ def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], - flow.model.variables['Sink(Wärme)|flow_rate'] - >= flow.model.variables['Sink(Wärme)|size'] + flow.submodel.variables['Sink(Wärme)|flow_rate'] + >= flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.1, 0.5, timesteps.size), coords=(timesteps,)), ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.model.variables['Sink(Wärme)|flow_rate'] - <= flow.model.variables['Sink(Wärme)|size'] + flow.submodel.variables['Sink(Wärme)|flow_rate'] + <= flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), ) @@ -367,13 +367,13 @@ def test_flow_invest_fixed_size(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.model.variables) == {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size'} + assert set(flow.submodel.variables) == {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size'} # Check that size is fixed to 75 - assert_var_equal(flow.model.variables['Sink(Wärme)|size'], model.add_variables(lower=75, upper=75)) + assert_var_equal(flow.submodel.variables['Sink(Wärme)|size'], model.add_variables(lower=75, upper=75)) # Check flow rate bounds - assert_var_equal(flow.model.flow_rate, model.add_variables(lower=0.2 * 75, upper=0.9 * 75, coords=(timesteps,))) + assert_var_equal(flow.submodel.flow_rate, model.add_variables(lower=0.2 * 75, upper=0.9 * 75, coords=(timesteps,))) def test_flow_invest_with_effects(self, basic_flow_system_linopy): """Test flow with investment effects.""" @@ -405,13 +405,13 @@ def test_flow_invest_with_effects(self, basic_flow_system_linopy): assert_conequal( model.constraints['Sink(Wärme)->Costs(invest)'], model.variables['Sink(Wärme)->Costs(invest)'] - == flow.model.variables['Sink(Wärme)|is_invested'] * 1000 + flow.model.variables['Sink(Wärme)|size'] * 500, + == flow.submodel.variables['Sink(Wärme)|is_invested'] * 1000 + flow.submodel.variables['Sink(Wärme)|size'] * 500, ) assert_conequal( model.constraints['Sink(Wärme)->CO2(invest)'], model.variables['Sink(Wärme)->CO2(invest)'] - == flow.model.variables['Sink(Wärme)|is_invested'] * 5 + flow.model.variables['Sink(Wärme)|size'] * 0.1, + == flow.submodel.variables['Sink(Wärme)|is_invested'] * 5 + flow.submodel.variables['Sink(Wärme)|size'] * 0.1, ) def test_flow_invest_divest_effects(self, basic_flow_system_linopy): @@ -458,11 +458,11 @@ def test_flow_on(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.model.variables) == set( + assert set(flow.submodel.variables) == set( ['Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total'] ) - assert set(flow.model.constraints) == set( + assert set(flow.submodel.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|on_hours_total', @@ -472,7 +472,7 @@ def test_flow_on(self, basic_flow_system_linopy): ) # flow_rate assert_var_equal( - flow.model.flow_rate, + flow.submodel.flow_rate, model.add_variables( lower=0, upper=0.8 * 100, @@ -482,7 +482,7 @@ def test_flow_on(self, basic_flow_system_linopy): # OnOff assert_var_equal( - flow.model.on_off.on, + flow.submodel.on_off.on, model.add_variables(binary=True, coords=(timesteps,)), ) assert_var_equal( @@ -491,17 +491,17 @@ def test_flow_on(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb'], - flow.model.variables['Sink(Wärme)|flow_rate'] >= flow.model.variables['Sink(Wärme)|on'] * 0.2 * 100, + flow.submodel.variables['Sink(Wärme)|flow_rate'] >= flow.submodel.variables['Sink(Wärme)|on'] * 0.2 * 100, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|on'] * 0.8 * 100, + flow.submodel.variables['Sink(Wärme)|flow_rate'] <= flow.submodel.variables['Sink(Wärme)|on'] * 0.8 * 100, ) assert_conequal( model.constraints['Sink(Wärme)|on_hours_total'], - flow.model.variables['Sink(Wärme)|on_hours_total'] - == (flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + flow.submodel.variables['Sink(Wärme)|on_hours_total'] + == (flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), ) def test_effects_per_running_hour(self, basic_flow_system_linopy): @@ -522,32 +522,32 @@ def test_effects_per_running_hour(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) costs, co2 = flow_system.effects['Costs'], flow_system.effects['CO2'] - assert set(flow.model.variables) == { + assert set(flow.submodel.variables) == { 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total', } - assert set(flow.model.constraints) == { + assert set(flow.submodel.constraints) == { 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate|lb', 'Sink(Wärme)|flow_rate|ub', 'Sink(Wärme)|on_hours_total', } - assert 'Sink(Wärme)->Costs(operation)' in set(costs.model.constraints) - assert 'Sink(Wärme)->CO2(operation)' in set(co2.model.constraints) + assert 'Sink(Wärme)->Costs(operation)' in set(costs.submodel.constraints) + assert 'Sink(Wärme)->CO2(operation)' in set(co2.submodel.constraints) assert_conequal( model.constraints['Sink(Wärme)->Costs(operation)'], model.variables['Sink(Wärme)->Costs(operation)'] - == flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step * costs_per_running_hour, + == flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step * costs_per_running_hour, ) assert_conequal( model.constraints['Sink(Wärme)->CO2(operation)'], model.variables['Sink(Wärme)->CO2(operation)'] - == flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step * co2_per_running_hour, + == flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step * co2_per_running_hour, ) def test_consecutive_on_hours(self, basic_flow_system_linopy): @@ -568,14 +568,14 @@ def test_consecutive_on_hours(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.submodel.variables)) assert {'Sink(Wärme)|consecutive_on_hours|ub', 'Sink(Wärme)|consecutive_on_hours|forward', 'Sink(Wärme)|consecutive_on_hours|backward', 'Sink(Wärme)|consecutive_on_hours|initial', 'Sink(Wärme)|consecutive_on_hours|lb', - }.issubset(set(flow.model.constraints)) + }.issubset(set(flow.submodel.constraints)) assert_var_equal( model.variables['Sink(Wärme)|consecutive_on_hours'], @@ -635,14 +635,14 @@ def test_consecutive_on_hours_previous(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|consecutive_on_hours', 'Sink(Wärme)|on'}.issubset(set(flow.submodel.variables)) assert {'Sink(Wärme)|consecutive_on_hours|lb', 'Sink(Wärme)|consecutive_on_hours|forward', 'Sink(Wärme)|consecutive_on_hours|backward', 'Sink(Wärme)|consecutive_on_hours|initial', 'Sink(Wärme)|consecutive_on_hours|lb', - }.issubset(set(flow.model.constraints)) + }.issubset(set(flow.submodel.constraints)) assert_var_equal( model.variables['Sink(Wärme)|consecutive_on_hours'], @@ -701,7 +701,7 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.submodel.variables)) assert { 'Sink(Wärme)|consecutive_off_hours|ub', @@ -709,7 +709,7 @@ def test_consecutive_off_hours(self, basic_flow_system_linopy): 'Sink(Wärme)|consecutive_off_hours|backward', 'Sink(Wärme)|consecutive_off_hours|initial', 'Sink(Wärme)|consecutive_off_hours|lb' - }.issubset(set(flow.model.constraints)) + }.issubset(set(flow.submodel.constraints)) assert_var_equal( model.variables['Sink(Wärme)|consecutive_off_hours'], @@ -769,7 +769,7 @@ def test_consecutive_off_hours_previous(self, basic_flow_system_linopy): flow_system.add_elements( fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|consecutive_off_hours', 'Sink(Wärme)|off'}.issubset(set(flow.submodel.variables)) assert { 'Sink(Wärme)|consecutive_off_hours|ub', @@ -777,7 +777,7 @@ def test_consecutive_off_hours_previous(self, basic_flow_system_linopy): 'Sink(Wärme)|consecutive_off_hours|backward', 'Sink(Wärme)|consecutive_off_hours|initial', 'Sink(Wärme)|consecutive_off_hours|lb' - }.issubset(set(flow.model.constraints)) + }.issubset(set(flow.submodel.constraints)) assert_var_equal( model.variables['Sink(Wärme)|consecutive_off_hours'], @@ -837,7 +837,7 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): # Check that variables exist assert {'Sink(Wärme)|switch|on', 'Sink(Wärme)|switch|off', 'Sink(Wärme)|switch|count'}.issubset( - set(flow.model.variables) + set(flow.submodel.variables) ) # Check that constraints exist @@ -846,16 +846,16 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): 'Sink(Wärme)|switch|initial', 'Sink(Wärme)|switch|mutex', 'Sink(Wärme)|switch|count', - }.issubset(set(flow.model.constraints)) + }.issubset(set(flow.submodel.constraints)) # Check switch_on_nr variable bounds - assert_var_equal(flow.model.variables['Sink(Wärme)|switch|count'], model.add_variables(lower=0, upper=5)) + assert_var_equal(flow.submodel.variables['Sink(Wärme)|switch|count'], model.add_variables(lower=0, upper=5)) # Verify switch_on_nr constraint (limits number of startups) assert_conequal( model.constraints['Sink(Wärme)|switch|count'], - flow.model.variables['Sink(Wärme)|switch|count'] - == flow.model.variables['Sink(Wärme)|switch|on'].sum('time'), + flow.submodel.variables['Sink(Wärme)|switch|count'] + == flow.submodel.variables['Sink(Wärme)|switch|on'].sum('time'), ) # Check that startup cost effect constraint exists @@ -864,7 +864,7 @@ def test_switch_on_constraints(self, basic_flow_system_linopy): # Verify the startup cost effect constraint assert_conequal( model.constraints['Sink(Wärme)->Costs(operation)'], - model.variables['Sink(Wärme)->Costs(operation)'] == flow.model.variables['Sink(Wärme)|switch|on'] * 100, + model.variables['Sink(Wärme)->Costs(operation)'] == flow.submodel.variables['Sink(Wärme)|switch|on'] * 100, ) def test_on_hours_limits(self, basic_flow_system_linopy): @@ -885,19 +885,19 @@ def test_on_hours_limits(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) # Check that variables exist - assert {'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total'}.issubset(set(flow.model.variables)) + assert {'Sink(Wärme)|on', 'Sink(Wärme)|on_hours_total'}.issubset(set(flow.submodel.variables)) # Check that constraints exist assert 'Sink(Wärme)|on_hours_total' in model.constraints # Check on_hours_total variable bounds - assert_var_equal(flow.model.variables['Sink(Wärme)|on_hours_total'], model.add_variables(lower=20, upper=100)) + assert_var_equal(flow.submodel.variables['Sink(Wärme)|on_hours_total'], model.add_variables(lower=20, upper=100)) # Check on_hours_total constraint assert_conequal( model.constraints['Sink(Wärme)|on_hours_total'], - flow.model.variables['Sink(Wärme)|on_hours_total'] - == (flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + flow.submodel.variables['Sink(Wärme)|on_hours_total'] + == (flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), ) @@ -918,7 +918,7 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.model.variables) == set( + assert set(flow.submodel.variables) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', @@ -929,7 +929,7 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): ] ) - assert set(flow.model.constraints) == set( + assert set(flow.submodel.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|on_hours_total', @@ -944,7 +944,7 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): # flow_rate assert_var_equal( - flow.model.flow_rate, + flow.submodel.flow_rate, model.add_variables( lower=0, upper=0.8 * 200, @@ -954,7 +954,7 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): # OnOff assert_var_equal( - flow.model.on_off.on, + flow.submodel.on_off.on, model.add_variables(binary=True, coords=(timesteps,)), ) assert_var_equal( @@ -963,24 +963,24 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|size|lb'], - flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 20, + flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|is_invested'] * 20, ) assert_conequal( model.constraints['Sink(Wärme)|size|ub'], - flow.model.variables['Sink(Wärme)|size']<= flow.model.variables['Sink(Wärme)|is_invested'] * 200, + flow.submodel.variables['Sink(Wärme)|size']<= flow.submodel.variables['Sink(Wärme)|is_invested'] * 200, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb1'], - flow.model.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.model.variables['Sink(Wärme)|flow_rate'], + flow.submodel.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.submodel.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub1'], - flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'], + flow.submodel.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.submodel.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( model.constraints['Sink(Wärme)|on_hours_total'], - flow.model.variables['Sink(Wärme)|on_hours_total'] - == (flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + flow.submodel.variables['Sink(Wärme)|on_hours_total'] + == (flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), ) # Investment @@ -989,12 +989,12 @@ def test_flow_on_invest_optional(self, basic_flow_system_linopy): mega = 0.2 * 200 # Relative minimum * maximum size assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb2'], - flow.model.variables['Sink(Wärme)|flow_rate'] - >= flow.model.variables['Sink(Wärme)|on'] * mega + flow.model.variables['Sink(Wärme)|size'] * 0.2 - mega, + flow.submodel.variables['Sink(Wärme)|flow_rate'] + >= flow.submodel.variables['Sink(Wärme)|on'] * mega + flow.submodel.variables['Sink(Wärme)|size'] * 0.2 - mega, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub2'], - flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8, + flow.submodel.variables['Sink(Wärme)|flow_rate'] <= flow.submodel.variables['Sink(Wärme)|size'] * 0.8, ) def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): @@ -1011,7 +1011,7 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert set(flow.model.variables) == set( + assert set(flow.submodel.variables) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', @@ -1021,7 +1021,7 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): ] ) - assert set(flow.model.constraints) == set( + assert set(flow.submodel.constraints) == set( [ 'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|on_hours_total', @@ -1034,7 +1034,7 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): # flow_rate assert_var_equal( - flow.model.flow_rate, + flow.submodel.flow_rate, model.add_variables( lower=0, upper=0.8 * 200, @@ -1044,7 +1044,7 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): # OnOff assert_var_equal( - flow.model.on_off.on, + flow.submodel.on_off.on, model.add_variables(binary=True, coords=(timesteps,)), ) assert_var_equal( @@ -1053,16 +1053,16 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb1'], - flow.model.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.model.variables['Sink(Wärme)|flow_rate'], + flow.submodel.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.submodel.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub1'], - flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'], + flow.submodel.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.submodel.variables['Sink(Wärme)|flow_rate'], ) assert_conequal( model.constraints['Sink(Wärme)|on_hours_total'], - flow.model.variables['Sink(Wärme)|on_hours_total'] - == (flow.model.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), + flow.submodel.variables['Sink(Wärme)|on_hours_total'] + == (flow.submodel.variables['Sink(Wärme)|on'] * model.hours_per_step).sum(), ) # Investment @@ -1071,12 +1071,12 @@ def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): mega = 0.2 * 200 # Relative minimum * maximum size assert_conequal( model.constraints['Sink(Wärme)|flow_rate|lb2'], - flow.model.variables['Sink(Wärme)|flow_rate'] - >= flow.model.variables['Sink(Wärme)|on'] * mega + flow.model.variables['Sink(Wärme)|size'] * 0.2 - mega, + flow.submodel.variables['Sink(Wärme)|flow_rate'] + >= flow.submodel.variables['Sink(Wärme)|on'] * mega + flow.submodel.variables['Sink(Wärme)|size'] * 0.2 - mega, ) assert_conequal( model.constraints['Sink(Wärme)|flow_rate|ub2'], - flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8, + flow.submodel.variables['Sink(Wärme)|flow_rate'] <= flow.submodel.variables['Sink(Wärme)|size'] * 0.8, ) @@ -1098,7 +1098,7 @@ def test_fixed_relative_profile(self, basic_flow_system_linopy): flow_system.add_elements(fx.Sink('Sink', sink=flow)) model = create_linopy_model(flow_system) - assert_var_equal(flow.model.variables['Sink(Wärme)|flow_rate'], + assert_var_equal(flow.submodel.variables['Sink(Wärme)|flow_rate'], model.add_variables(lower=profile * 100, upper=profile * 100, coords=(timesteps,)) @@ -1124,15 +1124,15 @@ def test_fixed_profile_with_investment(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) assert_var_equal( - flow.model.variables['Sink(Wärme)|flow_rate'], + flow.submodel.variables['Sink(Wärme)|flow_rate'], model.add_variables(lower=0, upper=profile * 200, coords=(timesteps,)), ) # The constraint should link flow_rate to size * profile assert_conequal( model.constraints['Sink(Wärme)|flow_rate|fixed'], - flow.model.variables['Sink(Wärme)|flow_rate'] - == flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(profile, coords=(timesteps,)), + flow.submodel.variables['Sink(Wärme)|flow_rate'] + == flow.submodel.variables['Sink(Wärme)|size'] * xr.DataArray(profile, coords=(timesteps,)), ) diff --git a/tests/test_functional.py b/tests/test_functional.py index 9542d656b..2315867f1 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -155,21 +155,21 @@ def test_fixed_size(solver_fixture, time_steps_fixture): boiler = flow_system.all_elements['Boiler'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 80 + 1000 * 1 + 10, rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.size.solution.item(), + boiler.Q_th.submodel._investment.size.solution.item(), 1000, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.is_invested.solution.item(), + boiler.Q_th.submodel._investment.is_invested.solution.item(), 1, rtol=1e-5, atol=1e-10, @@ -196,21 +196,21 @@ def test_optimize_size(solver_fixture, time_steps_fixture): boiler = flow_system.all_elements['Boiler'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 80 + 20 * 1 + 10, rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.size.solution.item(), + boiler.Q_th.submodel._investment.size.solution.item(), 20, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.is_invested.solution.item(), + boiler.Q_th.submodel._investment.is_invested.solution.item(), 1, rtol=1e-5, atol=1e-10, @@ -237,21 +237,21 @@ def test_size_bounds(solver_fixture, time_steps_fixture): boiler = flow_system.all_elements['Boiler'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 80 + 40 * 1 + 10, rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.size.solution.item(), + boiler.Q_th.submodel._investment.size.solution.item(), 40, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.is_invested.solution.item(), + boiler.Q_th.submodel._investment.is_invested.solution.item(), 1, rtol=1e-5, atol=1e-10, @@ -289,21 +289,21 @@ def test_optional_invest(solver_fixture, time_steps_fixture): boiler_optional = flow_system.all_elements['Boiler_optional'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 80 + 40 * 1 + 10, rtol=1e-5, atol=1e-10, err_msg='The total costs does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.size.solution.item(), + boiler.Q_th.submodel._investment.size.solution.item(), 40, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler.Q_th.model._investment.is_invested.solution.item(), + boiler.Q_th.submodel._investment.is_invested.solution.item(), 1, rtol=1e-5, atol=1e-10, @@ -311,14 +311,14 @@ def test_optional_invest(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler_optional.Q_th.model._investment.size.solution.item(), + boiler_optional.Q_th.submodel._investment.size.solution.item(), 0, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__Investment_size" does not have the right value', ) assert_allclose( - boiler_optional.Q_th.model._investment.is_invested.solution.item(), + boiler_optional.Q_th.submodel._investment.is_invested.solution.item(), 0, rtol=1e-5, atol=1e-10, @@ -342,7 +342,7 @@ def test_on(solver_fixture, time_steps_fixture): boiler = flow_system.all_elements['Boiler'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 80, rtol=1e-5, atol=1e-10, @@ -350,14 +350,14 @@ def test_on(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.model.on_off.on.solution.values, + boiler.Q_th.submodel.on_off.on.solution.values, [0, 1, 1, 0, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.solution.values, + boiler.Q_th.submodel.flow_rate.solution.values, [0, 10, 20, 0, 10], rtol=1e-5, atol=1e-10, @@ -386,7 +386,7 @@ def test_off(solver_fixture, time_steps_fixture): boiler = flow_system.all_elements['Boiler'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 80, rtol=1e-5, atol=1e-10, @@ -394,21 +394,21 @@ def test_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.model.on_off.on.solution.values, + boiler.Q_th.submodel.on_off.on.solution.values, [0, 1, 1, 0, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.on_off.off.solution.values, - 1 - boiler.Q_th.model.on_off.on.solution.values, + boiler.Q_th.submodel.on_off.off.solution.values, + 1 - boiler.Q_th.submodel.on_off.on.solution.values, rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__off" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.solution.values, + boiler.Q_th.submodel.flow_rate.solution.values, [0, 10, 20, 0, 10], rtol=1e-5, atol=1e-10, @@ -437,7 +437,7 @@ def test_switch_on_off(solver_fixture, time_steps_fixture): boiler = flow_system.all_elements['Boiler'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 80, rtol=1e-5, atol=1e-10, @@ -445,28 +445,28 @@ def test_switch_on_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.model.on_off.on.solution.values, + boiler.Q_th.submodel.on_off.on.solution.values, [0, 1, 1, 0, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.on_off.switch_on.solution.values, + boiler.Q_th.submodel.on_off.switch_on.solution.values, [0, 1, 0, 0, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__switch_on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.on_off.switch_off.solution.values, + boiler.Q_th.submodel.on_off.switch_off.solution.values, [0, 0, 0, 1, 0], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__switch_on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.solution.values, + boiler.Q_th.submodel.flow_rate.solution.values, [0, 10, 20, 0, 10], rtol=1e-5, atol=1e-10, @@ -501,7 +501,7 @@ def test_on_total_max(solver_fixture, time_steps_fixture): boiler = flow_system.all_elements['Boiler'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 140, rtol=1e-5, atol=1e-10, @@ -509,14 +509,14 @@ def test_on_total_max(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.model.on_off.on.solution.values, + boiler.Q_th.submodel.on_off.on.solution.values, [0, 0, 1, 0, 0], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.solution.values, + boiler.Q_th.submodel.flow_rate.solution.values, [0, 0, 20, 0, 0], rtol=1e-5, atol=1e-10, @@ -560,7 +560,7 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): boiler_backup = flow_system.all_elements['Boiler_backup'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 114, rtol=1e-5, atol=1e-10, @@ -568,14 +568,14 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.model.on_off.on.solution.values, + boiler.Q_th.submodel.on_off.on.solution.values, [0, 0, 1, 0, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.solution.values, + boiler.Q_th.submodel.flow_rate.solution.values, [0, 0, 20, 0, 12 - 1e-5], rtol=1e-5, atol=1e-10, @@ -583,14 +583,14 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): ) assert_allclose( - sum(boiler_backup.Q_th.model.on_off.on.solution.values), + sum(boiler_backup.Q_th.submodel.on_off.on.solution.values), 3, rtol=1e-5, atol=1e-10, err_msg='"Boiler_backup__Q_th__on" does not have the right value', ) assert_allclose( - boiler_backup.Q_th.model.flow_rate.solution.values, + boiler_backup.Q_th.submodel.flow_rate.solution.values, [0, 10, 1.0e-05, 0, 1.0e-05], rtol=1e-5, atol=1e-10, @@ -628,7 +628,7 @@ def test_consecutive_on_off(solver_fixture, time_steps_fixture): boiler_backup = flow_system.all_elements['Boiler_backup'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 190, rtol=1e-5, atol=1e-10, @@ -636,14 +636,14 @@ def test_consecutive_on_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.model.on_off.on.solution.values, + boiler.Q_th.submodel.on_off.on.solution.values, [1, 1, 0, 1, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler__Q_th__on" does not have the right value', ) assert_allclose( - boiler.Q_th.model.flow_rate.solution.values, + boiler.Q_th.submodel.flow_rate.solution.values, [5, 10, 0, 18, 12], rtol=1e-5, atol=1e-10, @@ -651,7 +651,7 @@ def test_consecutive_on_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler_backup.Q_th.model.flow_rate.solution.values, + boiler_backup.Q_th.submodel.flow_rate.solution.values, [0, 0, 20, 0, 0], rtol=1e-5, atol=1e-10, @@ -691,7 +691,7 @@ def test_consecutive_off(solver_fixture, time_steps_fixture): boiler_backup = flow_system.all_elements['Boiler_backup'] costs = flow_system.effects['costs'] assert_allclose( - costs.model.total.solution.item(), + costs.submodel.total.solution.item(), 110, rtol=1e-5, atol=1e-10, @@ -699,21 +699,21 @@ def test_consecutive_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler_backup.Q_th.model.on_off.on.solution.values, + boiler_backup.Q_th.submodel.on_off.on.solution.values, [0, 0, 1, 0, 0], rtol=1e-5, atol=1e-10, err_msg='"Boiler_backup__Q_th__on" does not have the right value', ) assert_allclose( - boiler_backup.Q_th.model.on_off.off.solution.values, + boiler_backup.Q_th.submodel.on_off.off.solution.values, [1, 1, 0, 1, 1], rtol=1e-5, atol=1e-10, err_msg='"Boiler_backup__Q_th__off" does not have the right value', ) assert_allclose( - boiler_backup.Q_th.model.flow_rate.solution.values, + boiler_backup.Q_th.submodel.flow_rate.solution.values, [0, 0, 1e-5, 0, 0], rtol=1e-5, atol=1e-10, @@ -721,7 +721,7 @@ def test_consecutive_off(solver_fixture, time_steps_fixture): ) assert_allclose( - boiler.Q_th.model.flow_rate.solution.values, + boiler.Q_th.submodel.flow_rate.solution.values, [5, 0, 20 - 1e-5, 18, 12], rtol=1e-5, atol=1e-10, diff --git a/tests/test_integration.py b/tests/test_integration.py index e3d44d764..babc7b131 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -23,12 +23,12 @@ def test_simple_flow_system(self, simple_flow_system, highs_solver): # Cost assertions assert_almost_equal_numeric( - effects['costs'].model.total.solution.item(), 81.88394666666667, 'costs doesnt match expected value' + effects['costs'].submodel.total.solution.item(), 81.88394666666667, 'costs doesnt match expected value' ) # CO2 assertions assert_almost_equal_numeric( - effects['CO2'].model.total.solution.item(), 255.09184, 'CO2 doesnt match expected value' + effects['CO2'].submodel.total.solution.item(), 255.09184, 'CO2 doesnt match expected value' ) def test_model_components(self, simple_flow_system, highs_solver): @@ -40,14 +40,14 @@ def test_model_components(self, simple_flow_system, highs_solver): # Boiler assertions assert_almost_equal_numeric( - comps['Boiler'].Q_th.model.flow_rate.solution.values, + comps['Boiler'].Q_th.submodel.flow_rate.solution.values, [0, 0, 0, 28.4864, 35, 0, 0, 0, 0], 'Q_th doesnt match expected value', ) # CHP unit assertions assert_almost_equal_numeric( - comps['CHP_unit'].Q_th.model.flow_rate.solution.values, + comps['CHP_unit'].Q_th.submodel.flow_rate.solution.values, [30.0, 26.66666667, 75.0, 75.0, 75.0, 20.0, 20.0, 20.0, 20.0], 'Q_th doesnt match expected value', ) @@ -217,36 +217,36 @@ def test_piecewise_conversion(self, flow_system_piecewise_conversion, highs_solv # Compare expected values with actual values assert_almost_equal_numeric( - effects['costs'].model.total.solution.item(), -10710.997365760755, 'costs doesnt match expected value' + effects['costs'].submodel.total.solution.item(), -10710.997365760755, 'costs doesnt match expected value' ) assert_almost_equal_numeric( - effects['CO2'].model.total.solution.item(), 1278.7939026086956, 'CO2 doesnt match expected value' + effects['CO2'].submodel.total.solution.item(), 1278.7939026086956, 'CO2 doesnt match expected value' ) assert_almost_equal_numeric( - comps['Kessel'].Q_th.model.flow_rate.solution.values, + comps['Kessel'].Q_th.submodel.flow_rate.solution.values, [0, 0, 0, 45, 0, 0, 0, 0, 0], 'Kessel doesnt match expected value', ) kwk_flows = {flow.label: flow for flow in comps['KWK'].inputs + comps['KWK'].outputs} assert_almost_equal_numeric( - kwk_flows['Q_th'].model.flow_rate.solution.values, + kwk_flows['Q_th'].submodel.flow_rate.solution.values, [45.0, 45.0, 64.5962087, 100.0, 61.3136, 45.0, 45.0, 12.86469565, 0.0], 'KWK Q_th doesnt match expected value', ) assert_almost_equal_numeric( - kwk_flows['P_el'].model.flow_rate.solution.values, + kwk_flows['P_el'].submodel.flow_rate.solution.values, [40.0, 40.0, 47.12589407, 60.0, 45.93221818, 40.0, 40.0, 10.91784108, -0.0], 'KWK P_el doesnt match expected value', ) assert_almost_equal_numeric( - comps['Speicher'].model.netto_discharge.solution.values, + comps['Speicher'].submodel.netto_discharge.solution.values, [-15.0, -45.0, 25.4037913, -35.0, 48.6864, -25.0, -25.0, 7.13530435, 20.0], 'Speicher nettoFlow doesnt match expected value', ) assert_almost_equal_numeric( - comps['Speicher'].model.variables['Speicher|PiecewiseEffects|costs'].solution.values, + comps['Speicher'].submodel.variables['Speicher|PiecewiseEffects|costs'].solution.values, 454.74666666666667, 'Speicher investCosts_segmented_costs doesnt match expected value', ) diff --git a/tests/test_linear_converter.py b/tests/test_linear_converter.py index e15b11c1b..7f65d8fc2 100644 --- a/tests/test_linear_converter.py +++ b/tests/test_linear_converter.py @@ -46,7 +46,7 @@ def test_basic_linear_converter(self, basic_flow_system_linopy): # Check conversion constraint (input * 0.8 == output * 1.0) assert_conequal( model.constraints['Converter|conversion_0'], - input_flow.model.flow_rate * 0.8 == output_flow.model.flow_rate * 1.0 + input_flow.submodel.flow_rate * 0.8 == output_flow.submodel.flow_rate * 1.0 ) def test_linear_converter_time_varying(self, basic_flow_system_linopy): @@ -88,7 +88,7 @@ def test_linear_converter_time_varying(self, basic_flow_system_linopy): # Check conversion constraint (input * efficiency_series == output * 1.0) assert_conequal( model.constraints['Converter|conversion_0'], - input_flow.model.flow_rate * efficiency_series == output_flow.model.flow_rate * 1.0 + input_flow.submodel.flow_rate * efficiency_series == output_flow.submodel.flow_rate * 1.0 ) def test_linear_converter_multiple_factors(self, basic_flow_system_linopy): @@ -133,19 +133,19 @@ def test_linear_converter_multiple_factors(self, basic_flow_system_linopy): # Check conversion constraint 1 (input1 * 0.8 == output1 * 1.0) assert_conequal( model.constraints['Converter|conversion_0'], - input_flow1.model.flow_rate * 0.8 == output_flow1.model.flow_rate * 1.0 + input_flow1.submodel.flow_rate * 0.8 == output_flow1.submodel.flow_rate * 1.0 ) # Check conversion constraint 2 (input2 * 0.5 == output2 * 1.0) assert_conequal( model.constraints['Converter|conversion_1'], - input_flow2.model.flow_rate * 0.5 == output_flow2.model.flow_rate * 1.0 + input_flow2.submodel.flow_rate * 0.5 == output_flow2.submodel.flow_rate * 1.0 ) # Check conversion constraint 3 (input1 * 0.2 == output2 * 0.3) assert_conequal( model.constraints['Converter|conversion_2'], - input_flow1.model.flow_rate * 0.2 == output_flow2.model.flow_rate * 0.3 + input_flow1.submodel.flow_rate * 0.2 == output_flow2.submodel.flow_rate * 0.3 ) def test_linear_converter_with_on_off(self, basic_flow_system_linopy): @@ -196,7 +196,7 @@ def test_linear_converter_with_on_off(self, basic_flow_system_linopy): # Check conversion constraint assert_conequal( model.constraints['Converter|conversion_0'], - input_flow.model.flow_rate * 0.8 == output_flow.model.flow_rate * 1.0 + input_flow.submodel.flow_rate * 0.8 == output_flow.submodel.flow_rate * 1.0 ) # Check on_off effects @@ -252,17 +252,17 @@ def test_linear_converter_multidimensional(self, basic_flow_system_linopy): # Check the conversion equations assert_conequal( model.constraints['MultiConverter|conversion_0'], - input_flow1.model.flow_rate * 0.7 == output_flow1.model.flow_rate * 1.0 + input_flow1.submodel.flow_rate * 0.7 == output_flow1.submodel.flow_rate * 1.0 ) assert_conequal( model.constraints['MultiConverter|conversion_1'], - input_flow2.model.flow_rate * 0.3 == output_flow2.model.flow_rate * 1.0 + input_flow2.submodel.flow_rate * 0.3 == output_flow2.submodel.flow_rate * 1.0 ) assert_conequal( model.constraints['MultiConverter|conversion_2'], - input_flow1.model.flow_rate * 0.1 == output_flow2.model.flow_rate * 0.5 + input_flow1.submodel.flow_rate * 0.1 == output_flow2.submodel.flow_rate * 0.5 ) def test_edge_case_time_varying_conversion(self, basic_flow_system_linopy): @@ -311,7 +311,7 @@ def test_edge_case_time_varying_conversion(self, basic_flow_system_linopy): # Verify the constraint has the time-varying coefficient assert_conequal( model.constraints['VariableConverter|conversion_0'], - input_flow.model.flow_rate * fluctuating_cop == output_flow.model.flow_rate * 1.0 + input_flow.submodel.flow_rate * fluctuating_cop == output_flow.submodel.flow_rate * 1.0 ) def test_piecewise_conversion(self, basic_flow_system_linopy): @@ -361,10 +361,10 @@ def test_piecewise_conversion(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) # Verify that PiecewiseModel was created and added as a submodel - assert converter.model.piecewise_conversion is not None + assert converter.submodel.piecewise_conversion is not None # Get the PiecewiseModel instance - piecewise_model = converter.model.piecewise_conversion + piecewise_model = converter.submodel.piecewise_conversion # Check that we have the expected pieces (2 in this case) assert len(piecewise_model.pieces) == 2 @@ -473,10 +473,10 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): model = create_linopy_model(flow_system) # Verify that PiecewiseModel was created and added as a submodel - assert converter.model.piecewise_conversion is not None + assert converter.submodel.piecewise_conversion is not None # Get the PiecewiseModel instance - piecewise_model = converter.model.piecewise_conversion + piecewise_model = converter.submodel.piecewise_conversion # Check that we have the expected pieces (2 in this case) assert len(piecewise_model.pieces) == 2 From ae1752b41b26d060ee5511824b3844cdb62ed664 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 21 Jul 2025 22:00:25 +0200 Subject: [PATCH 46/51] Rename .sub_models with .submodels --- flixopt/calculation.py | 6 +++--- flixopt/effects.py | 2 +- flixopt/structure.py | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 5e505ff0f..d50bde388 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -116,13 +116,13 @@ def main_results(self) -> Dict[str, Union[Scalar, Dict]]: 'Invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() - for model in component.submodel.sub_models + for model in component.submodel.submodels if isinstance(model, InvestmentModel) and model.size.solution.max() >= CONFIG.modeling.EPSILON }, 'Not invested': { model.label_of_element: model.size.solution for component in self.flow_system.components.values() - for model in component.submodel.sub_models + for model in component.submodel.submodels if isinstance(model, InvestmentModel) and model.size.solution.max() < CONFIG.modeling.EPSILON }, }, @@ -488,7 +488,7 @@ def do_modeling_and_solve( invest_elements = [ model.label_full for component in calculation.flow_system.components.values() - for model in component.submodel.sub_models + for model in component.submodel.submodels if isinstance(model, InvestmentModel) ] if invest_elements: diff --git a/flixopt/effects.py b/flixopt/effects.py index f9b122b1b..77a48e791 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -173,7 +173,7 @@ def __init__(self, model: FlowSystemModel, element: Effect): ) def do_modeling(self): - for model in self.sub_models: + for model in self.submodels: model.do_modeling() self.total = self.add_variables( diff --git a/flixopt/structure.py b/flixopt/structure.py index 61becd11f..f57a13031 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -836,14 +836,14 @@ def sub_models_direct(self) -> Dict[str, 'Submodel']: return self._sub_models @property - def sub_models(self) -> List['Submodel']: + def submodels(self) -> List['Submodel']: """All sub-models of the model""" direct_submodels = list(self._sub_models.values()) # Recursively collect nested sub-models nested_submodels = [] for submodel in direct_submodels: - nested_submodels.extend(submodel.sub_models) # This calls the property recursively + nested_submodels.extend(submodel.submodels) # This calls the property recursively return direct_submodels + nested_submodels @@ -852,7 +852,7 @@ def constraints(self) -> linopy.Constraints: """All constraints of the model, including those of sub-models""" names = list(self.constraints_direct) + [ constraint_name - for submodel in self.sub_models + for submodel in self.submodels for constraint_name in submodel.constraints_direct ] @@ -863,7 +863,7 @@ def variables(self) -> linopy.Variables: """All variables of the model, including those of sub-models""" names = list(self.variables_direct) + [ variable_name - for submodel in self.sub_models + for submodel in self.submodels for variable_name in submodel.variables_direct ] @@ -877,7 +877,7 @@ def __repr__(self) -> str: con_string = self.constraints.__repr__().split("\n", 2)[2] model_string = f"Linopy {self._model.type} submodel: {self.label_of_model}" - if len(self.sub_models) == 0: + if len(self.submodels) == 0: sub_models_string = ' \n' else: sub_models_string = '' From 1822384f1ffa77e3845fe9a7951eb263acd2b693 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:49:03 +0200 Subject: [PATCH 47/51] Improve repr --- flixopt/structure.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index f57a13031..37e20ae4d 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -875,21 +875,21 @@ def __repr__(self) -> str: """ var_string = self.variables.__repr__().split("\n", 2)[2] con_string = self.constraints.__repr__().split("\n", 2)[2] - model_string = f"Linopy {self._model.type} submodel: {self.label_of_model}" + model_string = f"Submodel of Linopy {self._model.type}:" if len(self.submodels) == 0: sub_models_string = ' \n' else: sub_models_string = '' for submodel_name, submodel in self.sub_models_direct.items(): - sub_models_string += f'\n * {submodel_name} [{submodel.__class__.__name__}]' + sub_models_string += f'\n * {submodel.__class__.__name__}: "{submodel_name}" [{len(submodel.variables)} Vars + {len(submodel.constraints)} Cons]' - return ( - f"{model_string}\n{'=' * len(model_string)}\n\n" - f"Variables:\n----------\n{var_string}\n" - f"Constraints:\n------------\n{con_string}\n" - f"Submodels:\n----------{sub_models_string}" - ) + text = {f"Variables: [{len(self.variables)}/{len(self._model.variables)}]": var_string, + f"Constraints: [{len(self.constraints)}/{len(self._model.constraints)}]": con_string, + f"Submodels: [{len(self.submodels)}]": sub_models_string} + comb = '\n'.join(f"{key}\n{'-' * len(key)}\n{value}" for key, value in text.items()) + + return f"{model_string}\n{'=' * len(model_string)}\n\n{comb}" @property def hours_per_step(self): From 2aa9d4b559a2afb6e0d4a64e55e03447cf93669b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:55:11 +0200 Subject: [PATCH 48/51] Improve repr --- flixopt/structure.py | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/flixopt/structure.py b/flixopt/structure.py index 37e20ae4d..76cfc2392 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -873,23 +873,40 @@ def __repr__(self) -> str: """ Return a string representation of the linopy model. """ - var_string = self.variables.__repr__().split("\n", 2)[2] - con_string = self.constraints.__repr__().split("\n", 2)[2] - model_string = f"Submodel of Linopy {self._model.type}:" + # Extract content from variables and constraints representations + var_string = self.variables.__repr__().split('\n', 2)[2] + con_string = self.constraints.__repr__().split('\n', 2)[2] + model_string = f'Submodel of Linopy {self._model.type}:' + # Build submodels section if len(self.submodels) == 0: sub_models_string = ' \n' else: - sub_models_string = '' + submodel_lines = [] for submodel_name, submodel in self.sub_models_direct.items(): - sub_models_string += f'\n * {submodel.__class__.__name__}: "{submodel_name}" [{len(submodel.variables)} Vars + {len(submodel.constraints)} Cons]' + class_name = submodel.__class__.__name__ + submodel_lines.append(f' * {class_name}: "{submodel_name}" [{len(submodel.variables)} Vars + {len(submodel.constraints)} Cons]') + sub_models_string = '\n' + '\n'.join(submodel_lines) + + # Create sections with counts and content + sections = { + f'Variables: [{len(self.variables)}/{len(self._model.variables)}]': var_string, + f'Constraints: [{len(self.constraints)}/{len(self._model.constraints)}]': con_string, + f'Submodels: [{len(self.sub_models_direct)}]': sub_models_string, + } + + # Format sections with headers and underlines + formatted_sections = [] + for section_header, section_content in sections.items(): + underline = '-' * len(section_header) + formatted_section = f'{section_header}\n{underline}\n{section_content}' + formatted_sections.append(formatted_section) - text = {f"Variables: [{len(self.variables)}/{len(self._model.variables)}]": var_string, - f"Constraints: [{len(self.constraints)}/{len(self._model.constraints)}]": con_string, - f"Submodels: [{len(self.submodels)}]": sub_models_string} - comb = '\n'.join(f"{key}\n{'-' * len(key)}\n{value}" for key, value in text.items()) + # Combine everything with proper formatting + all_sections = '\n'.join(formatted_sections) + header_separator = '=' * len(model_string) - return f"{model_string}\n{'=' * len(model_string)}\n\n{comb}" + return f'{model_string}\n{header_separator}\n\n{all_sections}' @property def hours_per_step(self): From 5ca9707d75dee0d1f61f4db3652b26df10a25cf5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Jul 2025 11:24:05 +0200 Subject: [PATCH 49/51] Include def do_modeling() into __init__() of models --- flixopt/components.py | 32 ++++++++++++--------------- flixopt/effects.py | 14 +++++------- flixopt/elements.py | 25 ++++++++++------------ flixopt/features.py | 50 ++++++++++++++++++++++++------------------- flixopt/structure.py | 36 +++++++++++-------------------- 5 files changed, 71 insertions(+), 86 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 52e676323..16e74ade0 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -395,19 +395,18 @@ def transform_data(self, flow_system: 'FlowSystem') -> None: class TransmissionModel(ComponentModel): def __init__(self, model: FlowSystemModel, element: Transmission): - super().__init__(model, element) + if (element.absolute_losses is not None) and np.any(element.absolute_losses != 0): + for flow in element.inputs + element.outputs: + if flow.on_off_parameters is None: + flow.on_off_parameters = OnOffParameters() self.element: Transmission = element self.on_off: Optional[OnOffModel] = None - def do_modeling(self): - """Initiates all FlowModels""" - # Force On Variable if absolute losses are present - if (self.element.absolute_losses is not None) and np.any(self.element.absolute_losses != 0): - for flow in self.element.inputs + self.element.outputs: - if flow.on_off_parameters is None: - flow.on_off_parameters = OnOffParameters() + super().__init__(model, element) - super().do_modeling() + def _do_modeling(self): + """Initiates all FlowModels""" + super()._do_modeling() # first direction self.create_transmission_equation('dir1', self.element.in1, self.element.out1) @@ -440,14 +439,13 @@ def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) class LinearConverterModel(ComponentModel): def __init__(self, model: FlowSystemModel, element: LinearConverter): - super().__init__(model, element) self.element: LinearConverter = element self.on_off: Optional[OnOffModel] = None self.piecewise_conversion: Optional[PiecewiseConversion] = None + super().__init__(model, element) - def do_modeling(self): - super().do_modeling() - + def _do_modeling(self): + super()._do_modeling() # conversion_factors: if self.element.conversion_factors: all_input_flows = set(self.element.inputs) @@ -483,7 +481,6 @@ def do_modeling(self): ), short_name='PiecewiseConversion', ) - self.piecewise_conversion.do_modeling() class StorageModel(ComponentModel): @@ -491,10 +488,9 @@ class StorageModel(ComponentModel): def __init__(self, model: FlowSystemModel, element: Storage): super().__init__(model, element) - self.element: Storage = element - def do_modeling(self): - super().do_modeling() + def _do_modeling(self): + super()._do_modeling() lb, ub = self.absolute_charge_state_bounds self.add_variables( @@ -540,7 +536,7 @@ def do_modeling(self): ), short_name='investment', ) - self._investment.do_modeling() + BoundingPatterns.scaled_bounds( self, variable=self.charge_state, diff --git a/flixopt/effects.py b/flixopt/effects.py index 77a48e791..b5ea81e3e 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -140,7 +140,8 @@ def _plausibility_checks(self) -> None: class EffectModel(ElementModel): def __init__(self, model: FlowSystemModel, element: Effect): super().__init__(model, element) - self.element: Effect = element + + def _do_modeling(self): self.total: Optional[linopy.Variable] = None self.invest: ShareAllocationModel = self.register_sub_model( ShareAllocationModel( @@ -172,10 +173,6 @@ def __init__(self, model: FlowSystemModel, element: Effect): short_name='operation', ) - def do_modeling(self): - for model in self.submodels: - model.do_modeling() - self.total = self.add_variables( lower=self.element.minimum_total if self.element.minimum_total is not None else -np.inf, upper=self.element.maximum_total if self.element.maximum_total is not None else np.inf, @@ -381,9 +378,9 @@ class EffectCollectionModel(Submodel): """ def __init__(self, model: FlowSystemModel, effects: EffectCollection): - super().__init__(model, label_of_element='Effects') self.effects = effects self.penalty: Optional[ShareAllocationModel] = None + super().__init__(model, label_of_element='Effects') def add_share_to_effects( self, @@ -412,15 +409,14 @@ 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 do_modeling(self): + def _do_modeling(self): + super()._do_modeling() for effect in self.effects: effect.create_model(self._model) self.penalty = self.register_sub_model( ShareAllocationModel(self._model, dims=(), label_of_element='Penalty'), short_name='penalty', ) - for model in [effect.submodel for effect in self.effects] + [self.penalty]: - model.do_modeling() self._add_share_between_effects() diff --git a/flixopt/elements.py b/flixopt/elements.py index b952093ba..bd4c27eca 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -315,9 +315,9 @@ def invest_is_optional(self) -> bool: class FlowModel(ElementModel): def __init__(self, model: FlowSystemModel, element: Flow): super().__init__(model, element) - self.element: Flow = element - def do_modeling(self): + def _do_modeling(self): + super()._do_modeling() # Main flow rate variable self.add_variables( lower=self.absolute_flow_rate_bounds[0], @@ -359,7 +359,7 @@ def _create_on_off_model(self): label_of_model=self.label_of_element, ), short_name='on_off', - ).do_modeling() + ) def _create_investment_model(self): self.register_sub_model( @@ -370,7 +370,7 @@ def _create_investment_model(self): label_of_model=self.label_of_element, ), 'investment', - ).do_modeling() + ) def _constraint_flow_rate(self): if not self.with_investment and not self.with_on_off: @@ -543,12 +543,12 @@ def previous_states(self) -> Optional[TemporalData]: class BusModel(ElementModel): def __init__(self, model: FlowSystemModel, element: Bus): - super().__init__(model, element) - self.element: Bus = element self.excess_input: Optional[linopy.Variable] = None self.excess_output: Optional[linopy.Variable] = None + super().__init__(model, element) - def do_modeling(self) -> None: + def _do_modeling(self) -> None: + super()._do_modeling() # inputs == outputs for flow in self.element.inputs + self.element.outputs: self.register_variable(flow.submodel.flow_rate, flow.label_full) @@ -582,12 +582,12 @@ def results_structure(self): class ComponentModel(ElementModel): def __init__(self, model: FlowSystemModel, element: Component): - super().__init__(model, element) - self.element: Component = element self.on_off: Optional[OnOffModel] = None + super().__init__(model, element) - def do_modeling(self): + def _do_modeling(self): """Initiates all FlowModels""" + super()._do_modeling() all_flows = self.element.inputs + self.element.outputs if self.element.on_off_parameters: for flow in all_flows: @@ -600,8 +600,7 @@ def do_modeling(self): flow.on_off_parameters = OnOffParameters() for flow in all_flows: - flow_model = self.register_sub_model(flow.create_model(self._model), short_name=flow.label) - flow_model.do_modeling() + self.register_sub_model(flow.create_model(self._model), short_name=flow.label) if self.element.on_off_parameters: on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords()) @@ -625,8 +624,6 @@ def do_modeling(self): short_name='on_off', ) - self.on_off.do_modeling() - if self.element.prevent_simultaneous_flows: # Simultanious Useage --> Only One FLow is On at a time, but needs a Binary for every flow on_variables = [flow.submodel.on_off.on for flow in self.element.prevent_simultaneous_flows] diff --git a/flixopt/features.py b/flixopt/features.py index 99928e410..fdf62bf75 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -36,17 +36,18 @@ def __init__( model: The optimization model instance label_of_element: The label of the parent (Element). Used to construct the full label of the model. parameters: The parameters of the feature model. - defining_variable: The variable to be invested - relative_bounds_of_defining_variable: The bounds of the variable, with respect to the minimum/maximum investment sizes label_of_model: The label of the model. This is needed to construct the full label of the model. """ - super().__init__(model, label_of_element=label_of_element, parameters=parameters, label_of_model=label_of_model) - self.piecewise_effects: Optional[PiecewiseEffectsModel] = None + super().__init__(model, label_of_element=label_of_element, parameters=parameters, label_of_model=label_of_model) + def _do_modeling(self): + super()._do_modeling() + self._create_variables_and_constraints() + self._add_effects() - def create_variables_and_constraints(self): + def _create_variables_and_constraints(self): size_min, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size) self.add_variables( short_name='size', @@ -79,9 +80,8 @@ def create_variables_and_constraints(self): ), short_name='segments', ) - self.piecewise_effects.do_modeling() - def add_effects(self): + def _add_effects(self): """Add investment effects""" if self.parameters.fix_effects: self._model.effects.add_share_to_effects( @@ -147,11 +147,13 @@ def __init__( previous_states: The previous flow_rates label_of_model: The label of the model. This is needed to construct the full label of the model. """ - super().__init__(model, label_of_element, parameters=parameters, label_of_model=label_of_model) self.on = on_variable self._previous_states = previous_states + super().__init__(model, label_of_element, parameters=parameters, label_of_model=label_of_model) + + def _do_modeling(self): + super()._do_modeling() - def create_variables_and_constraints(self): if self.parameters.use_off: off = self.add_variables(binary=True, short_name='off', coords=self._model.get_coords()) self.add_constraints(self.on + off == 1, short_name='complementary') @@ -207,7 +209,9 @@ def create_variables_and_constraints(self): ) #TODO: - def add_effects(self): + self._add_effects() + + def _add_effects(self): """Add operational effects""" if self.parameters.effects_per_running_hour: self._model.effects.add_share_to_effects( @@ -292,13 +296,15 @@ def __init__( label_of_model: str, as_time_series: bool = True, ): - super().__init__(model, label_of_element, label_of_model) self.inside_piece: Optional[linopy.Variable] = None self.lambda0: Optional[linopy.Variable] = None self.lambda1: Optional[linopy.Variable] = None self._as_time_series = as_time_series - def do_modeling(self): + super().__init__(model, label_of_element, label_of_model) + + def _do_modeling(self): + super()._do_modeling() dims =('time', 'year','scenario') if self._as_time_series else ('year','scenario') self.inside_piece = self.add_variables( binary=True, @@ -346,15 +352,16 @@ def __init__( zero_point: A variable that can be used to define a zero point for the Piecewise relation. If None or False, no zero point is defined. as_time_series: Whether the Piecewise relation is defined for a TimeSeries or a single variable. """ - super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) self._piecewise_variables = piecewise_variables self._zero_point = zero_point self._as_time_series = as_time_series self.pieces: List[PieceModel] = [] self.zero_point: Optional[linopy.Variable] = None + super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) - def do_modeling(self): + def _do_modeling(self): + super()._do_modeling() for i in range(len(list(self._piecewise_variables.values())[0])): new_piece = self.register_sub_model( PieceModel( @@ -366,7 +373,6 @@ def do_modeling(self): short_name=f'Piece_{i}', ) self.pieces.append(new_piece) - new_piece.do_modeling() for var_name in self._piecewise_variables: variable = self._model.variables[var_name] @@ -414,7 +420,6 @@ def __init__( piecewise_shares: Dict[str, Piecewise], zero_point: Optional[Union[bool, linopy.Variable]], ): - super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) assert len(piecewise_origin[1]) == len(list(piecewise_shares.values())[0]), ( 'Piece length of variable_segments and share_segments must be equal' ) @@ -425,7 +430,9 @@ def __init__( self.piecewise_model: Optional[PiecewiseModel] = None - def do_modeling(self): + super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) + + def _do_modeling(self): self.shares = { effect: self.add_variables(coords=self._model.get_coords(['year', 'scenario']), short_name=effect) for effect in self._piecewise_shares @@ -451,8 +458,6 @@ def do_modeling(self): short_name='PiecewiseEffects', ) - self.piecewise_model.do_modeling() - # Shares self._model.effects.add_share_to_effects( name=self.label_of_element, @@ -473,8 +478,6 @@ def __init__( max_per_hour: Optional[TemporalData] = None, min_per_hour: Optional[TemporalData] = None, ): - super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) - if 'time' not in dims and (max_per_hour is not None or min_per_hour is not None): raise ValueError('Both max_per_hour and min_per_hour cannot be used when has_time_dim is False') @@ -493,7 +496,10 @@ def __init__( self._max_per_hour = max_per_hour if max_per_hour is not None else np.inf self._min_per_hour = min_per_hour if min_per_hour is not None else -np.inf - def do_modeling(self): + super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) + + def _do_modeling(self): + super()._do_modeling() self.total = self.add_variables( lower=self._total_min, upper=self._total_max, diff --git a/flixopt/structure.py b/flixopt/structure.py index 76cfc2392..0a0b7d4df 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -60,13 +60,10 @@ def __init__(self, flow_system: 'FlowSystem'): def do_modeling(self): self.effects = self.flow_system.effects.create_model(self) - self.effects.do_modeling() - component_models = [component.create_model(self) for component in self.flow_system.components.values()] - bus_models = [bus.create_model(self) for bus in self.flow_system.buses.values()] - for component_model in component_models: - component_model.do_modeling() - for bus_model in bus_models: # Buses after Components, because FlowModels are created in ComponentModels - bus_model.do_modeling() + for component in self.flow_system.components.values(): + component.create_model(self) + for bus in self.flow_system.buses.values(): + bus.create_model(self) @property def solution(self): @@ -716,7 +713,9 @@ def __init__( self._constraints: Dict[str, linopy.Constraint] = {} # Mapping from short name to constraint self._sub_models: Dict[str, 'Submodel'] = {} - logger.debug(f'Created {self.__class__.__name__} "{self.label_full}"') + + logger.debug(f'Creating {self.__class__.__name__} "{self.label_full}"') + self._do_modeling() def add_variables(self, short_name: str = None, **kwargs) -> linopy.Variable: """Create and register a variable in one step""" @@ -912,6 +911,10 @@ def __repr__(self) -> str: def hours_per_step(self): return self._model.hours_per_step + def _do_modeling(self): + """Template method""" + pass + class BaseFeatureModel(Submodel): """Minimal base class for feature models that use factory patterns""" @@ -925,21 +928,8 @@ def __init__(self, model: FlowSystemModel, label_of_element: str, parameters, la Defaults to {label_of_element}|{self.__class__.__name__} parameters: The parameters of the feature model. """ - super().__init__(model, label_of_element, label_of_model or f'{label_of_element}|{self.__class__.__name__}') self.parameters = parameters - - def do_modeling(self): - """Template method - creates variables and constraints, then effects""" - self.create_variables_and_constraints() - self.add_effects() - - def create_variables_and_constraints(self): - """Override in subclasses to create variables and constraints""" - raise NotImplementedError('Subclasses must implement create_variables_and_constraints()') - - def add_effects(self): - """Override in subclasses to add effects""" - pass # Default: no effects + super().__init__(model, label_of_element, label_of_model or f'{label_of_element}|{self.__class__.__name__}') class ElementModel(Submodel): @@ -951,8 +941,8 @@ def __init__(self, model: FlowSystemModel, element: Element): model: The FlowSystemModel that is used to create the model. element: The element this model is created for. """ - super().__init__(model, label_of_element=element.label_full) self.element = element + super().__init__(model, label_of_element=element.label_full) def results_structure(self): return { From 7e043995610852eeaaa40b11dfec98c8548f25a8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Jul 2025 11:35:35 +0200 Subject: [PATCH 50/51] Make properties private --- flixopt/components.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 16e74ade0..4e69f1bcd 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -492,7 +492,7 @@ def __init__(self, model: FlowSystemModel, element: Storage): def _do_modeling(self): super()._do_modeling() - lb, ub = self.absolute_charge_state_bounds + lb, ub = self._absolute_charge_state_bounds self.add_variables( lower=lb, upper=ub, @@ -541,7 +541,7 @@ def _do_modeling(self): self, variable=self.charge_state, scaling_variable=self.investment.size, - relative_bounds=self.relative_charge_state_bounds, + relative_bounds=self._relative_charge_state_bounds, ) # Initial charge state @@ -577,8 +577,8 @@ def _initial_and_final_charge_state(self): ) @property - def absolute_charge_state_bounds(self) -> Tuple[TemporalData, TemporalData]: - relative_lower_bound, relative_upper_bound = self.relative_charge_state_bounds + def _absolute_charge_state_bounds(self) -> Tuple[TemporalData, TemporalData]: + relative_lower_bound, relative_upper_bound = self._relative_charge_state_bounds if not isinstance(self.element.capacity_in_flow_hours, InvestParameters): return ( relative_lower_bound * self.element.capacity_in_flow_hours, @@ -591,7 +591,7 @@ def absolute_charge_state_bounds(self) -> Tuple[TemporalData, TemporalData]: ) @property - def relative_charge_state_bounds(self) -> Tuple[xr.DataArray, xr.DataArray]: + def _relative_charge_state_bounds(self) -> Tuple[xr.DataArray, xr.DataArray]: """ Get relative charge state bounds with final timestep values. From 4f95ebc9ef69e8bb00d4c79a0e07d375f50f18d1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Jul 2025 11:42:19 +0200 Subject: [PATCH 51/51] Improve Inheritance of Models --- flixopt/features.py | 12 +++++++----- flixopt/modeling.py | 2 +- flixopt/structure.py | 19 +------------------ 3 files changed, 9 insertions(+), 24 deletions(-) diff --git a/flixopt/features.py b/flixopt/features.py index fdf62bf75..7115c54a8 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -12,13 +12,13 @@ from .config import CONFIG from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions from .interface import InvestParameters, OnOffParameters, Piecewise, PiecewiseEffects -from .structure import Submodel, FlowSystemModel, BaseFeatureModel +from .structure import Submodel, FlowSystemModel from .modeling import ModelingUtilities, ModelingPrimitives, BoundingPatterns logger = logging.getLogger('flixopt') -class InvestmentModel(BaseFeatureModel): +class InvestmentModel(Submodel): """Investment model using factory patterns but keeping old interface""" def __init__( @@ -40,7 +40,8 @@ def __init__( """ self.piecewise_effects: Optional[PiecewiseEffectsModel] = None - super().__init__(model, label_of_element=label_of_element, parameters=parameters, label_of_model=label_of_model) + self.parameters = parameters + super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) def _do_modeling(self): super()._do_modeling() @@ -123,7 +124,7 @@ def is_invested(self) -> Optional[linopy.Variable]: return self._variables['is_invested'] -class OnOffModel(BaseFeatureModel): +class OnOffModel(Submodel): """OnOff model using factory patterns""" def __init__( @@ -149,7 +150,8 @@ def __init__( """ self.on = on_variable self._previous_states = previous_states - super().__init__(model, label_of_element, parameters=parameters, label_of_model=label_of_model) + self.parameters = parameters + super().__init__(model, label_of_element, label_of_model=label_of_model) def _do_modeling(self): super()._do_modeling() diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 262b0d17d..a8a0b6f44 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -7,7 +7,7 @@ from .config import CONFIG from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions -from .structure import Submodel, FlowSystemModel, BaseFeatureModel +from .structure import Submodel, FlowSystemModel logger = logging.getLogger('flixopt') diff --git a/flixopt/structure.py b/flixopt/structure.py index 0a0b7d4df..f38f04815 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -912,26 +912,9 @@ def hours_per_step(self): return self._model.hours_per_step def _do_modeling(self): - """Template method""" + """Called at the end of initialization. Override in subclasses to create variables and constraints.""" pass - -class BaseFeatureModel(Submodel): - """Minimal base class for feature models that use factory patterns""" - - def __init__(self, model: FlowSystemModel, label_of_element: str, parameters, label_of_model: Optional[str] = None): - """Initialize the BaseFeatureModel. - Args: - model: The FlowSystemModel that is used to create the model. - label_of_element: The label of the parent (Element). Used to create shares. - label_of_model: The label of the model. Used as a prefix in all variables and constraints. - Defaults to {label_of_element}|{self.__class__.__name__} - parameters: The parameters of the feature model. - """ - self.parameters = parameters - super().__init__(model, label_of_element, label_of_model or f'{label_of_element}|{self.__class__.__name__}') - - class ElementModel(Submodel): """Stores the mathematical Variables and Constraints for Elements"""