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
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 f1ee8a916..31d6a526a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -83,6 +83,22 @@ This might occur when scenarios represent years or months, while an investment d
* The assignment of Bus Objects to Flow.bus is deprecated and will be removed in a future version. Use the label of the Bus instead.
* The usage of Effects objects in Dicts to assign shares to Effects is deprecated and will be removed in a future version. Use the label of the Effect instead.
+## [2.1.5] - 2025-07-08
+
+### Fixed
+- Fixed Docs deployment
+
+## [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
+- 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
### Fixed
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 47ac1336d..e4f7a598a 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"""
@@ -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
@@ -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:
@@ -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 6bf86bb20..d50bde388 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.all_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.model.all_sub_models
+ for model in component.submodel.submodels
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
)
],
}
@@ -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
)
@@ -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.submodel.submodels
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 685928714..4e69f1bcd 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
@@ -60,8 +61,8 @@ def __init__(
def create_model(self, model: FlowSystemModel) -> 'LinearConverterModel':
self._plausibility_checks()
- self.model = LinearConverterModel(model, self)
- return self.model
+ self.submodel = LinearConverterModel(model, self)
+ return self.submodel
def _plausibility_checks(self) -> None:
super()._plausibility_checks()
@@ -202,8 +203,8 @@ def __init__(
def create_model(self, model: FlowSystemModel) -> 'StorageModel':
self._plausibility_checks()
- self.model = StorageModel(model, self)
- return self.model
+ self.submodel = StorageModel(model, self)
+ return self.submodel
def transform_data(self, flow_system: 'FlowSystem') -> None:
super().transform_data(flow_system)
@@ -379,8 +380,8 @@ def _plausibility_checks(self):
def create_model(self, model) -> 'TransmissionModel':
self._plausibility_checks()
- self.model = TransmissionModel(model, self)
- return self.model
+ self.submodel = TransmissionModel(model, self)
+ return self.submodel
def transform_data(self, flow_system: 'FlowSystem') -> None:
super().transform_data(flow_system)
@@ -394,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)
@@ -418,41 +418,34 @@ 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.submodel._investment.size == self.element.in2.submodel._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.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
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)
@@ -464,154 +457,128 @@ 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.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()
}
- self.piecewise_conversion = self.add(
+ self.piecewise_conversion = self.register_sub_model(
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,
- )
+ ),
+ short_name='PiecewiseConversion',
)
- self.piecewise_conversion.do_modeling()
class StorageModel(ComponentModel):
- """Model of Storage"""
+ """Submodel of Storage"""
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',
+
+ def _do_modeling(self):
+ super()._do_modeling()
+
+ lb, ub = self._absolute_charge_state_bounds
+ 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.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
- 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,
+ label_of_model=self.label_of_element,
+ parameters=self.element.capacity_in_flow_hours,
+ ),
+ short_name='investment',
+ )
+
+ BoundingPatterns.scaled_bounds(
+ self,
+ variable=self.charge_state,
+ scaling_variable=self.investment.size,
+ relative_bounds=self._relative_charge_state_bounds,
)
- 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
- 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,
@@ -624,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.
@@ -652,6 +619,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 23943d16b..b5ea81e3e 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
@@ -129,8 +129,8 @@ def transform_data(self, flow_system: 'FlowSystem'):
def create_model(self, model: FlowSystemModel) -> 'EffectModel':
self._plausibility_checks()
- self.model = EffectModel(model, self)
- return self.model
+ self.submodel = EffectModel(model, self)
+ return self.submodel
def _plausibility_checks(self) -> None:
# TODO: Check for plausibility
@@ -140,27 +140,27 @@ 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.add(
+ self.invest: ShareAllocationModel = self.register_sub_model(
ShareAllocationModel(
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,
- )
+ ),
+ short_name='invest',
)
- self.operation: ShareAllocationModel = self.add(
+ self.operation: ShareAllocationModel = self.register_sub_model(
ShareAllocationModel(
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
@@ -169,30 +169,19 @@ 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,
- )
- )
-
- 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',
+ short_name='operation',
)
- self.add(
- self._model.add_constraints(
- self.total == self.operation.total + self.invest.total, 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_constraints(self.total == self.operation.total + self.invest.total, short_name='total')
+
TemporalEffectsUser = Union[TemporalDataUser, Dict[str, TemporalDataUser]] # User-specified Shares to Effects
""" This datatype is used to define a temporal share to an effect by a certain attribute. """
@@ -219,13 +208,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.model = EffectCollectionModel(model, self)
- return self.model
+ self.submodel = EffectCollectionModel(model, self)
+ return self.submodel
def add_effects(self, *effects: Effect) -> None:
for effect in list(effects):
@@ -383,15 +372,15 @@ def calculate_effect_share_factors(self) -> Tuple[
return shares_operation, shares_invest
-class EffectCollectionModel(Model):
+class EffectCollectionModel(Submodel):
"""
Handling all Effects
"""
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,
@@ -401,13 +390,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'),
@@ -420,19 +409,19 @@ 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.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()
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()
)
@@ -440,16 +429,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 a546b5e9c..bd4c27eca 100644
--- a/flixopt/elements.py
+++ b/flixopt/elements.py
@@ -8,13 +8,15 @@
import linopy
import numpy as np
+import xarray as xr
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, ModelingPrimitives
from .interface import InvestParameters, OnOffParameters
from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io
+from .modeling import BoundingPatterns, ModelingUtilitiesAbstract
if TYPE_CHECKING:
from .flow_system import FlowSystem
@@ -28,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].
"""
@@ -65,8 +67,8 @@ def __init__(
def create_model(self, model: FlowSystemModel) -> 'ComponentModel':
self._plausibility_checks()
- self.model = ComponentModel(model, self)
- return self.model
+ self.submodel = ComponentModel(model, self)
+ return self.submodel
def transform_data(self, flow_system: 'FlowSystem') -> None:
if self.on_off_parameters is not None:
@@ -110,8 +112,8 @@ def __init__(
def create_model(self, model: FlowSystemModel) -> 'BusModel':
self._plausibility_checks()
- self.model = BusModel(model, self)
- return self.model
+ self.submodel = BusModel(model, self)
+ return self.submodel
def transform_data(self, flow_system: 'FlowSystem'):
self.excess_penalty_per_flow_hour = flow_system.fit_to_model_coords(
@@ -161,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"""
@@ -208,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
@@ -229,8 +229,8 @@ def __init__(
def create_model(self, model: FlowSystemModel) -> 'FlowModel':
self._plausibility_checks()
- self.model = FlowModel(model, self)
- return self.model
+ self.submodel = FlowModel(model, self)
+ return self.submodel
def transform_data(self, flow_system: 'FlowSystem'):
self.relative_minimum = flow_system.fit_to_model_coords(
@@ -292,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})'
@@ -310,79 +315,123 @@ def invest_is_optional(self) -> bool:
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
- 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(
- 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',
+ def _do_modeling(self):
+ super()._do_modeling()
+ # Main flow rate variable
+ self.add_variables(
+ lower=self.absolute_flow_rate_bounds[0],
+ upper=self.absolute_flow_rate_bounds[1],
+ coords=self._model.get_coords(),
+ short_name='flow_rate',
+ )
+
+ self._constraint_flow_rate()
+
+ # 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=(
+ 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,
),
- 'flow_rate',
+ coords=['year', 'scenario'],
+ short_name='total_flow_hours',
)
- # OnOff
- if self.element.on_off_parameters is not None:
- self.on_off: OnOffModel = self.add(
- 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],
- ),
- 'on_off',
- )
- self.on_off.do_modeling()
+ # Load factor constraints
+ self._create_bounds_for_load_factor()
- # Investment
- if isinstance(self.element.size, InvestParameters):
- self._investment: InvestmentModel = self.add(
- InvestmentModel(
- model=self._model,
- label_of_element=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),
- 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',
+ # 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,
),
- 'total_flow_hours',
+ short_name='on_off',
)
- 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',
+ 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,
),
- 'total_flow_hours',
+ 'investment',
)
- # Load factor
- self._create_bounds_for_load_factor()
+ 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,
+ )
- # Shares
- self._create_shares()
+ 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:
+ """Main flow rate variable"""
+ return self['flow_rate']
+
+ @property
+ def total_flow_hours(self) -> linopy.Variable:
+ """Total flow hours variable"""
+ return self['total_flow_hours']
def results_structure(self):
return {
@@ -393,10 +442,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,130 +454,124 @@ 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.investment.size if self.with_investment else self.element.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_short,
+ self.add_constraints(
+ self.total_flow_hours <= size * flow_hours_per_size_max,
+ short_name='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_short,
+ self.add_constraints(
+ self.total_flow_hours >= size * flow_hours_per_size_min,
+ short_name='load_factor_min',
)
@property
- def flow_rate_bounds_on(self) -> Tuple[TemporalData, TemporalData]:
- """Returns absolute flow rate bounds. Important 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
+ 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
- if size.fixed_size is not None:
- return relative_minimum * size.fixed_size, relative_maximum * size.fixed_size
+ @property
+ def absolute_flow_rate_bounds(self) -> Tuple[TemporalData, TemporalData]:
+ """
+ Returns the absolute bounds the flow_rate can reach.
+ Further constraining might be needed
+ """
+ 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
- return relative_minimum * size.minimum_or_fixed_size, relative_maximum * size.maximum_or_fixed_size
+ return lb, ub
@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 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 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
+ def _investment(self) -> Optional[InvestmentModel]:
+ """Deprecated alias for investment"""
+ return self.investment
@property
- def flow_rate_lower_bound(self) -> TemporalData:
- """
- Returns the minimum bound the flow_rate can reach.
- Further constraining might be done in OnOffModel and InvestmentModel
- """
- 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
+ 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 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
+ 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
+ if previous_flow_rate is None:
+ return None
+
+ 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):
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.add(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'))
+ 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:
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())
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:
@@ -539,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:
@@ -557,35 +600,65 @@ def do_modeling(self):
flow.on_off_parameters = OnOffParameters()
for flow in all_flows:
- self.add(flow.create_model(self._model), flow.label)
-
- for sub_model in self.sub_models:
- sub_model.do_modeling()
+ self.register_sub_model(flow.create_model(self._model), short_name=flow.label)
if self.element.on_off_parameters:
- self.on_off = self.add(
+ 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].submodel.on_off.on, short_name='on')
+ else:
+ 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')
+
+ self.on_off = self.register_sub_model(
OnOffModel(
- self._model,
- self.element.on_off_parameters,
- 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],
- )
+ model=self._model,
+ label_of_element=self.label_of_element,
+ parameters=self.element.on_off_parameters,
+ on_variable=on,
+ label_of_model=self.label_of_element,
+ previous_states=self.previous_states,
+ ),
+ 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.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.do_modeling()
+ on_variables = [flow.submodel.on_off.on for flow in self.element.prevent_simultaneous_flows]
+ ModelingPrimitives.mutual_exclusivity_constraint(
+ self,
+ 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],
}
+
+ @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.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
+ 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/features.py b/flixopt/features.py
index bc4bfb9b3..7115c54a8 100644
--- a/flixopt/features.py
+++ b/flixopt/features.py
@@ -11,757 +11,215 @@
from .config import CONFIG
from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions
-from .interface import InvestParameters, OnOffParameters, Piecewise
-from .structure import Model, FlowSystemModel
+from .interface import InvestParameters, OnOffParameters, Piecewise, PiecewiseEffects
+from .structure import Submodel, FlowSystemModel
+from .modeling import ModelingUtilities, ModelingPrimitives, BoundingPatterns
logger = logging.getLogger('flixopt')
-class InvestmentModel(Model):
- """Class for modeling an investment"""
+class InvestmentModel(Submodel):
+ """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,
+ label_of_model: Optional[str] = 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
+ """
+ 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.
- self.piecewise_effects: Optional[PiecewiseEffectsModel] = None
+ 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.
+ label_of_model: The label of the model. This is needed to construct the full label of the model.
- self._on_variable = on_variable
- self._defining_variable = defining_variable
- self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable
+ """
+ self.piecewise_effects: Optional[PiecewiseEffectsModel] = None
self.parameters = parameters
-
- 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',
+ super().__init__(model, label_of_element=label_of_element, 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):
+ 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,
+ coords=self._model.get_coords(['year', 'scenario']),
)
- # 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',
+ self.add_variables(
+ binary=True, coords=self._model.get_coords(['year', 'scenario']), short_name='is_invested',
)
- self._create_bounds_for_optional_investment()
-
- if self._model.flow_system.scenarios is not None:
- self._create_bounds_for_scenarios()
-
- # Bounds for defining variable
- self._create_bounds_for_defining_variable()
+ 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),
+ )
- self._create_shares()
+ if self.parameters.piecewise_effects:
+ self.piecewise_effects = self.register_sub_model(
+ 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,
+ ),
+ short_name='segments',
+ )
- def _create_shares(self):
- # fix_effects:
- fix_effects = self.parameters.fix_effects
- if fix_effects != {}:
+ 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 fix_effects.items()
+ for effect, factor in self.parameters.fix_effects.items()
},
target='invest',
)
- if self.parameters.divest_effects != {} and self.parameters.optional:
- # share: divest_effects - isInvested * divest_effects
+ 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()},
+ expressions={
+ effect: -self.is_invested * factor + factor
+ for effect, factor in self.parameters.divest_effects.items()
+ },
target='invest',
)
- if self.parameters.specific_effects != {}:
+ 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 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()
-
- 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',
- )
-
- 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',
- )
-
- # 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',
- )
-
- 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}',
- )
- return
-
- # 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}',
- )
-
- 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}',
- )
- 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}',
- )
- # 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}')
-
- 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',
- )
- 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',
- )
-
- 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',
- )
-
-
-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
+ def size(self) -> linopy.Variable:
+ """Investment size variable"""
+ return self._variables['size']
@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',
- )
+ 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']
- return self
-
-class ConsecutiveStateModel(Model):
- """
- Handles tracking consecutive durations in a state
- """
+class OnOffModel(Submodel):
+ """OnOff model using factory patterns"""
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,
+ parameters: OnOffParameters,
+ on_variable: linopy.Variable,
+ previous_states: Optional[TemporalData],
+ label_of_model: Optional[str] = None,
):
"""
- Model and constraint the consecutive duration of a state variable.
+ 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 FlowSystemModel that is used to create the model.
+ model: The optimization model instance
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.
+ parameters: The parameters of the feature model.
+ 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, label)
- self._state_variable = state_variable
+ self.on = on_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]
+ self.parameters = parameters
+ super().__init__(model, label_of_element, label_of_model=label_of_model)
+
+ def _do_modeling(self):
+ super()._do_modeling()
+
+ 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')
+
+ # 3. Total duration tracking using existing pattern
+ duration_expr = (self.on * self._model.hours_per_step).sum('time')
+ ModelingPrimitives.expression_tracking_variable(
+ 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())
)
- @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 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,
+ # 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,
+ state_variable=self.on,
+ switch_on=self.switch_on,
+ switch_off=self.switch_off,
+ name=f'{self.label_of_model}|switch',
+ previous_state=self._previous_states.isel(time=-1) if self._previous_states is not None else 0,
)
- 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()
+ 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')
+ self.add_constraints(count == self.switch_on.sum('time'), short_name='switch|count')
- # Create consecutive on hours component if needed
+ # 5. Consecutive on duration using existing pattern
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,
+ ModelingPrimitives.consecutive_duration_tracking(
+ self,
+ 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_states=self.state_model.previous_on_states,
- label='ConsecutiveOn',
+ previous_duration=self._get_previous_on_duration(),
)
- self.add(self.consecutive_on_model)
- self.consecutive_on_model.do_modeling()
- # Create consecutive off hours component if needed
+ # 6. Consecutive off duration using existing pattern
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,
+ ModelingPrimitives.consecutive_duration_tracking(
+ self,
+ 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_states=self.state_model.previous_off_states,
- label='ConsecutiveOff',
+ previous_duration=self._get_previous_off_duration(),
)
- self.add(self.consecutive_off_model)
- self.consecutive_off_model.do_modeling()
+ #TODO:
- self._create_shares()
+ self._add_effects()
- def _create_shares(self):
+ 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.state_model.on * factor * self._model.hours_per_step
+ effect: self.on * factor * self._model.hours_per_step
for effect, factor in self.parameters.effects_per_running_hour.items()
},
target='operation',
@@ -771,106 +229,117 @@ def _create_shares(self):
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()
+ effect: self.switch_on * factor for effect, factor in self.parameters.effects_per_switch_on.items()
},
target='operation',
)
+ # Properties access variables from Submodel's tracking system
+
@property
- def on(self):
- return self.state_model.on
+ def total_on_hours(self) -> Optional[linopy.Variable]:
+ """Total on hours variable"""
+ return self['total_on_hours']
@property
- def off(self):
- return self.state_model.off
+ def off(self) -> Optional[linopy.Variable]:
+ """Binary off state variable"""
+ return self.get('off')
@property
- def switch_on(self):
- return self.switch_state_model.switch_on
+ def switch_on(self) -> Optional[linopy.Variable]:
+ """Switch on variable"""
+ return self.get('switch|on')
@property
- def switch_off(self):
- return self.switch_state_model.switch_off
+ def switch_off(self) -> Optional[linopy.Variable]:
+ """Switch off variable"""
+ return self.get('switch|off')
@property
- def switch_on_nr(self):
- return self.switch_state_model.switch_on_nr
+ def switch_on_nr(self) -> Optional[linopy.Variable]:
+ """Number of switch-ons variable"""
+ return self.get('switch|count')
@property
- def consecutive_on_hours(self):
- return self.consecutive_on_model.duration
+ def consecutive_on_hours(self) -> Optional[linopy.Variable]:
+ """Consecutive on hours variable"""
+ return self.get('consecutive_on_hours')
@property
- def consecutive_off_hours(self):
- return self.consecutive_off_model.duration
+ def consecutive_off_hours(self) -> Optional[linopy.Variable]:
+ """Consecutive off hours variable"""
+ return self.get('consecutive_off_hours')
+
+ def _get_previous_on_duration(self):
+ """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):
+ """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):
+
+class PieceModel(Submodel):
"""Class for modeling a linear piece of one or more variables in parallel"""
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)
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(
- 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,
+ short_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):
+class PiecewiseModel(Submodel):
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: str = '',
):
"""
Modeling a Piecewise relation between miultiple variables.
@@ -885,44 +354,43 @@ 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)
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.add(
+ new_piece = self.register_sub_model(
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,
- )
+ ),
+ 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
@@ -930,40 +398,88 @@ 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',
)
-class ShareAllocationModel(Model):
+class PiecewiseEffectsModel(Submodel):
+ 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]],
+ ):
+ 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
+
+ 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
+ }
+
+ 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.register_sub_model(
+ 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',
+ ),
+ short_name='PiecewiseEffects',
+ )
+
+ # 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(Submodel):
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)
-
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')
@@ -982,36 +498,28 @@ 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):
- 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',
+ 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,
+ 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')
@@ -1046,121 +554,17 @@ 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),
+ name=f'{name}->{self.label_full}',
+ short_name=name,
)
- 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, name=f'{name}->{self.label_full}'
)
+
if 'time' not in dims:
self._eq_total.lhs -= self.shares[name]
else:
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
-
- 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(
- self._model.add_constraints(
- sum(self._simultanious_use_variables) <= 1.1, name=f'{self.label_full}|prevent_simultaneous_use'
- ),
- 'prevent_simultaneous_use',
- )
diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py
index 877db6fdc..0a10b3ceb 100644
--- a/flixopt/flow_system.py
+++ b/flixopt/flow_system.py
@@ -451,8 +451,8 @@ 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)
- return self.model
+ self.submodel = FlowSystemModel(self)
+ return self.submodel
def plot_network(
self,
diff --git a/flixopt/modeling.py b/flixopt/modeling.py
new file mode 100644
index 000000000..a8a0b6f44
--- /dev/null
+++ b/flixopt/modeling.py
@@ -0,0 +1,687 @@
+import logging
+from typing import Dict, List, Optional, Tuple, Union
+
+import linopy
+import numpy as np
+import xarray as xr
+
+from .config import CONFIG
+from .core import NonTemporalData, Scalar, TemporalData, FlowSystemDimensions
+from .structure import Submodel, FlowSystemModel
+
+logger = logging.getLogger('flixopt')
+
+
+class ModelingUtilitiesAbstract:
+ """Utility functions for modeling calculations - leveraging xarray for temporal data"""
+
+ @staticmethod
+ def to_binary(
+ values: xr.DataArray,
+ epsilon: Optional[float] = None,
+ dims: Optional[Union[str, List[str]]] = None,
+ ) -> xr.DataArray:
+ """
+ Converts a DataArray to binary {0, 1} values.
+
+ Args:
+ 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:
+ Binary DataArray with same shape (or collapsed if collapse_non_time=True)
+ """
+ if not isinstance(values, xr.DataArray):
+ values = xr.DataArray(values, dims=['time'], coords={'time': range(len(values))})
+
+ if epsilon is None:
+ epsilon = CONFIG.modeling.EPSILON
+
+ if values.size == 0:
+ return xr.DataArray(0) if values.item() < epsilon else xr.DataArray(1)
+
+ # Convert to binary states
+ binary_states = (np.abs(values) >= epsilon)
+
+ # Optionally collapse dimensions using .any()
+ if dims is not None:
+ dims = [dims] if isinstance(dims, str) else dims
+
+ 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 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
+ dim: Dimension to count consecutive states over
+ epsilon: Tolerance for zero detection (uses CONFIG.modeling.EPSILON if None)
+
+ Returns:
+ The consecutive number of steps spent in the final state of the timeseries
+ """
+ if epsilon is None:
+ epsilon = CONFIG.modeling.EPSILON
+
+ 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({dim: -1}).item(), 0, atol=epsilon).all():
+ 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
+
+ consecutive_values = binary_values.isel({dim:slice(start_idx, None)})
+
+ return float(consecutive_values.sum().item()) #TODO: Som only over one dim?
+
+
+class ModelingUtilities:
+
+ @staticmethod
+ def compute_consecutive_hours_in_state(
+ binary_values: TemporalData,
+ hours_per_timestep: Union[int, float],
+ epsilon: float = None,
+ ) -> float:
+ """
+ Computes the final consecutive duration in state 'on' (=1) in hours.
+
+ Args:
+ 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:
+ The duration of the final consecutive 'on' period in hours
+ """
+ if not isinstance(hours_per_timestep, (int, float)):
+ raise TypeError(f'hours_per_timestep must be a scalar, got {type(hours_per_timestep)}')
+
+ 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_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:
+ """
+ Compute previous consecutive 'off' duration.
+
+ Args:
+ previous_values: DataArray with 'time' dimension
+ hours_per_step: Duration of each timestep in hours
+
+ Returns:
+ Previous consecutive off duration in hours
+ """
+ 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: Optional[xr.DataArray]) -> int:
+ """
+ Get the most recent binary state from previous values.
+
+ Args:
+ previous_values: DataArray with 'time' dimension
+
+ Returns:
+ Most recent binary state (0 or 1)
+ """
+ if previous_values is None or previous_values.size == 0:
+ return 0
+
+ previous_states = ModelingUtilities.compute_previous_states(previous_values)
+ return int(previous_states.isel(time=-1).item())
+
+
+class ModelingPrimitives:
+ """Mathematical modeling primitives returning (variables, constraints) tuples"""
+
+ @staticmethod
+ def expression_tracking_variable(
+ model: Submodel,
+ tracked_expression,
+ name: str = None,
+ short_name: str = None,
+ bounds: Tuple[TemporalData, TemporalData] = None,
+ coords: List[str] = None,
+ ) -> Tuple[linopy.Variable, 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=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=name,
+ coords=model.get_coords(coords),
+ short_name=short_name,
+ )
+
+ # Constraint: tracker = expression
+ tracking = model.add_constraints(tracker == tracked_expression, name=name, short_name=short_name)
+
+ return tracker, tracking
+
+ @staticmethod
+ def state_transition_variables(
+ model: Submodel,
+ state_variable: linopy.Variable,
+ switch_on: linopy.Variable,
+ switch_off: linopy.Variable,
+ name: str,
+ previous_state=0,
+ ) -> Tuple[linopy.Constraint, linopy.Constraint, 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}
+ """
+ # 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}|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',
+ )
+
+ # At most one switch per timestep
+ mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|mutex')
+
+ return transition, initial, mutex
+
+ @staticmethod
+ def sum_up_variable(
+ model: FlowSystemModel,
+ variable_to_count: linopy.Variable,
+ name: str = None,
+ 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=bounds[0],
+ upper=bounds[1],
+ coords=model.get_coords(['year', 'scenario']),
+ name=name,
+ )
+
+ count_constraint = model.add_constraints(count == (variable_to_count * factor).sum('time'), name=name)
+
+ return count, count_constraint
+
+ @staticmethod
+ def consecutive_duration_tracking(
+ model: FlowSystemModel,
+ 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,
+ ) -> Tuple[linopy.Variable, Tuple[linopy.Constraint, linopy.Constraint, 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:
+ 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
+ previous_duration: Duration from before first timestep
+
+ Returns:
+ 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
+
+ # 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=name,
+ short_name=short_name,
+ )
+
+ constraints = {}
+
+ # Upper bound: duration[t] ≤ state[t] * M
+ constraints['ub'] = model.add_constraints(
+ 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'{duration.name}|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'{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'{duration.name}|initial',
+ )
+
+ # Minimum duration constraint if provided
+ if minimum_duration is not 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'{duration.name}|lb',
+ )
+
+ # Handle initial condition for minimum duration
+ if previous_duration > 0 and previous_duration < minimum_duration.isel(time=0).max():
+ constraints['initial_lb'] = model.add_constraints(
+ state_variable.isel(time=0) == 1, name=f'{duration.name}|initial_lb'
+ )
+
+ variables = {'duration': duration}
+
+ return variables, constraints
+
+ @staticmethod
+ def mutual_exclusivity_constraint(
+ model: Submodel, binary_variables: List[linopy.Variable], tolerance: float = 1,
+ short_name: str = 'mutual_exclusivity',
+ ) -> 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
+ short_name: Short name of the constraint
+
+ 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, short_name=short_name)
+
+ return mutual_exclusivity
+
+
+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],
+ name: str = None,
+ ):
+ """Create simple bounds.
+ variable ∈ [lower_bound, upper_bound]
+
+ Mathematical Formulation:
+ lower_bound ≤ variable ≤ upper_bound
+
+ 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
+ - 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'{name}|ub')
+ lower_constraint = model.add_constraints(variable >= lower_bound, name=f'{name}|lb')
+
+ return [lower_constraint, upper_constraint]
+
+ @staticmethod
+ def bounds_with_state(
+ model: FlowSystemModel,
+ 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]}
+
+ Mathematical Formulation:
+ - variable_state * max(ε, lower_bound) ≤ variable ≤ variable_state * upper_bound
+
+ Use Cases:
+ - 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
+ variable_state: Binary variable controlling the bounds
+
+ Returns:
+ Tuple containing:
+ - variables (Dict): Empty dict
+ - 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'{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'{name}|ub')
+ lower_constraint = model.add_constraints(variable >= variable_state * epsilon, name=f'{name}|lb')
+
+ return [lower_constraint, upper_constraint]
+
+ @staticmethod
+ def scaled_bounds(
+ model: FlowSystemModel,
+ 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]
+
+ Mathematical Formulation:
+ scaling_variable * lower_factor ≤ variable ≤ scaling_variable * upper_factor
+
+ Use Cases:
+ - Flow rates bounded by equipment capacity
+ - Production levels scaled by plant size
+
+ 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
+
+ Returns:
+ Tuple containing:
+ - variables (Dict): Empty dict
+ - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb'
+ """
+ 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')
+
+ return [lower_constraint, upper_constraint]
+
+ @staticmethod
+ def scaled_bounds_with_state(
+ model: FlowSystemModel,
+ variable: linopy.Variable,
+ scaling_variable: linopy.Variable,
+ relative_bounds: Tuple[TemporalData, TemporalData],
+ scaling_bounds: Tuple[TemporalData, TemporalData],
+ variable_state: linopy.Variable,
+ name: str = None,
+ ) -> List[linopy.Constraint]:
+ """Constraint a variable by scaling bounds with binary state control.
+
+ variable ∈ {0, [max(ε, lower_relative_bound) * scaling_variable, upper_relative_bound * scaling_variable]}
+
+ Mathematical Formulation (Big-M):
+ (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
+
+ 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
+ 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:
+ 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_misc = scaling_max * rel_lower
+
+ 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'{name}|ub2'
+ )
+
+ 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]
+
+ @staticmethod
+ def auto_bounds(
+ model: FlowSystemModel,
+ variable: linopy.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,
+ ) -> 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 → bounds_with_state()
+ 4. bounds + scaling_variable + variable_state → 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
+ variable: Variable to be bounded
+ bounds: Tuple of (lower, upper) bounds or relative factors
+ scaling_variable: Optional variable to scale bounds by
+ 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 from the selected method
+
+ Raises:
+ ValueError: If required parameters are missing
+ """
+ # 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.scaled_bounds_with_state_on_both_scaling_and_variable(
+ model=model,
+ variable=variable,
+ scaling_variable=scaling_variable,
+ relative_bounds=bounds,
+ scaling_state=scaling_state,
+ variable_state=variable_state,
+ scaling_bounds=scaling_bounds,
+ )
+
+ # 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 for binary scaled bounds')
+ return BoundingPatterns.binary_scaled_bounds(
+ model=model,
+ variable=variable,
+ scaling_variable=scaling_variable,
+ relative_bounds=bounds,
+ variable_state=variable_state,
+ scaling_bounds=scaling_bounds,
+ )
+
+ # Case 3: Binary controlled bounds
+ if variable_state is not None and scaling_variable is None:
+ return BoundingPatterns.bounds_with_state(
+ model=model,
+ variable=variable,
+ 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 1: Basic bounds
+ if scaling_variable is None and variable_state is None:
+ return BoundingPatterns.basic_bounds(model, variable, bounds)
+
+ raise ValueError('Invalid combination of arguments')
diff --git a/flixopt/structure.py b/flixopt/structure.py
index 9566e303f..f38f04815 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):
@@ -74,21 +71,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 +658,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"""
@@ -696,61 +693,104 @@ def _valid_label(label: str) -> str:
return label
-class Model:
+class Submodel:
"""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._variables_direct: List[str] = []
- self._constraints_direct: List[str] = []
- self.sub_models: List[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 do_modeling(self):
- raise NotImplementedError('Every Model needs a do_modeling() method')
-
- def add(
- 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
+ self.label_of_model = label_of_model if label_of_model is not None else self.label_of_element
+
+ 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, 'Submodel'] = {}
+
+
+ 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"""
+ 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}'
+
+ variable = self._model.add_variables(**kwargs)
+ self.register_variable(variable, short_name)
+ return variable
+
+ def add_constraints(self, expression, short_name: str = None, **kwargs) -> linopy.Constraint:
+ """Create and register a constraint in one step"""
+ 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}'
+
+ 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) -> linopy.Variable:
+ """Register a variable with the model"""
+ if short_name is None:
+ 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 = 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
+
+ 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 = 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] = submodel
+ return submodel
+
+ 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 __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
- 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.
- """
- # 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
- elif isinstance(item, linopy.Constraint):
- self._constraints_direct.append(item.name)
- self._constraints_short[item.name] = short_name or item.name
- elif isinstance(item, Model):
- self.sub_models.append(item)
- self._sub_models_short[item.label_full] = short_name or 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 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,
@@ -775,63 +815,107 @@ 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 if self._label else self.label_of_element
-
@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:
- return self._model.variables[self._variables_direct]
+ """Variables of the model, excluding those of sub-models"""
+ 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]
+ """Costraints of the model, excluding those of sub-models"""
+ 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 sub_models_direct(self) -> Dict[str, 'Submodel']:
+ """All sub-models of the model, excluding those of sub-models"""
+ return self._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 submodels(self) -> List['Submodel']:
+ """All sub-models of the model"""
+ direct_submodels = list(self._sub_models.values())
- @property
- def variables(self) -> linopy.Variables:
- return self._model.variables[self._variables]
+ # Recursively collect nested sub-models
+ nested_submodels = []
+ for submodel in direct_submodels:
+ nested_submodels.extend(submodel.submodels) # This calls the property recursively
+
+ return direct_submodels + nested_submodels
@property
def constraints(self) -> linopy.Constraints:
- return self._model.constraints[self._constraints]
+ """All constraints of the model, including those of sub-models"""
+ names = list(self.constraints_direct) + [
+ constraint_name
+ for submodel in self.submodels
+ for constraint_name in submodel.constraints_direct
+ ]
+
+ return self._model.constraints[names]
+
+ @property
+ 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.submodels
+ for variable_name in submodel.variables_direct
+ ]
+
+ return self._model.variables[names]
+
+ def __repr__(self) -> str:
+ """
+ Return a string representation of the linopy model.
+ """
+ # 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:
+ submodel_lines = []
+ for submodel_name, submodel in self.sub_models_direct.items():
+ 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)
+
+ # Combine everything with proper formatting
+ all_sections = '\n'.join(formatted_sections)
+ header_separator = '=' * len(model_string)
+
+ return f'{model_string}\n{header_separator}\n\n{all_sections}'
@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 hours_per_step(self):
+ return self._model.hours_per_step
+ def _do_modeling(self):
+ """Called at the end of initialization. Override in subclasses to create variables and constraints."""
+ pass
-class ElementModel(Model):
+class ElementModel(Submodel):
"""Stores the mathematical Variables and Constraints for Elements"""
def __init__(self, model: FlowSystemModel, element: Element):
@@ -840,8 +924,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, label=element.label, label_full=element.label_full)
self.element = element
+ super().__init__(model, label_of_element=element.label_full)
def results_structure(self):
return {
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:
"""
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 11b5385c2..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 {
+ assert set(comp.submodel.variables) == {
'TestComponent(In1)|flow_rate',
'TestComponent(In1)|total_flow_hours',
'TestComponent(In1)|on',
@@ -93,43 +93,56 @@ 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.submodel.constraints) == {
'TestComponent(In1)|total_flow_hours',
- 'TestComponent(In1)|on_con1',
- 'TestComponent(In1)|on_con2',
+ 'TestComponent(In1)|flow_rate|lb',
+ 'TestComponent(In1)|flow_rate|ub',
'TestComponent(In1)|on_hours_total',
'TestComponent(Out1)|total_flow_hours',
- 'TestComponent(Out1)|on_con1',
- 'TestComponent(Out1)|on_con2',
+ 'TestComponent(Out1)|flow_rate|lb',
+ 'TestComponent(Out1)|flow_rate|ub',
'TestComponent(Out1)|on_hours_total',
'TestComponent(Out2)|total_flow_hours',
- 'TestComponent(Out2)|on_con1',
- 'TestComponent(Out2)|on_con2',
+ 'TestComponent(Out2)|flow_rate|lb',
+ 'TestComponent(Out2)|flow_rate|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)|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']
+ >= (
+ 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,
+ )
+
- assert_conequal(model.constraints['TestComponent|on_con1'],
- 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'],
- 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
- )
def test_on_with_single_flow(self, basic_flow_system_linopy):
"""Test that flow model constraints are correctly generated."""
@@ -145,24 +158,23 @@ 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.submodel.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.submodel.constraints) == {
'TestComponent(In1)|total_flow_hours',
- 'TestComponent(In1)|on_con1',
- 'TestComponent(In1)|on_con2',
+ 'TestComponent(In1)|flow_rate|lb',
+ 'TestComponent(In1)|flow_rate|ub',
'TestComponent(In1)|on_hours_total',
- 'TestComponent|on_con1',
- 'TestComponent|on_con2',
+ 'TestComponent|on',
'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,21 +183,92 @@ 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.variables['TestComponent(In1)|on'] * 0.1 * 100 <= model.variables['TestComponent(In1)|flow_rate'],
+ model.constraints['TestComponent(In1)|flow_rate|lb'],
+ model.variables['TestComponent(In1)|flow_rate'] >= model.variables['TestComponent(In1)|on'] * 0.1 * 100,
)
assert_conequal(
- model.constraints['TestComponent(In1)|on_con2'],
- model.variables['TestComponent(In1)|on'] * 100 >= model.variables['TestComponent(In1)|flow_rate'],
+ model.constraints['TestComponent(In1)|flow_rate|ub'],
+ model.variables['TestComponent(In1)|flow_rate'] <= model.variables['TestComponent(In1)|on'] * 100,
)
assert_conequal(
- model.constraints['TestComponent|on_con1'],
- model.variables['TestComponent|on'] * 0.1 * 100 <= model.variables['TestComponent(In1)|flow_rate'],
+ 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=[3,4,5]),
+ 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.submodel.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.submodel.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'] >= (model.variables['TestComponent(In1)|on'] + model.variables['TestComponent(Out1)|on'] + model.variables['TestComponent(Out2)|on']) / (3 + 1e-5),
)
assert_conequal(
- model.constraints['TestComponent|on_con2'],
- model.variables['TestComponent|on'] * 100 >= model.variables['TestComponent(In1)|flow_rate'],
+ 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,
)
@@ -213,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',
)
@@ -268,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',
)
@@ -336,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 cce10b21a..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,18 +133,18 @@ 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)|lb_Sink(Wärme)|flow_rate',
- 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate',
+ 'Sink(Wärme)|flow_rate|ub',
+ 'Sink(Wärme)|flow_rate|lb',
]
)
@@ -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,
@@ -161,15 +161,15 @@ def test_flow_invest(self, basic_flow_system_linopy):
),
)
assert_conequal(
- model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'],
- flow.model.variables['Sink(Wärme)|flow_rate']
- >= flow.model.variables['Sink(Wärme)|size']
+ model.constraints['Sink(Wärme)|flow_rate|lb'],
+ 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)|ub_Sink(Wärme)|flow_rate'],
- flow.model.variables['Sink(Wärme)|flow_rate']
- <= flow.model.variables['Sink(Wärme)|size']
+ model.constraints['Sink(Wärme)|flow_rate|ub'],
+ 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,16 +188,16 @@ 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)|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|lb',
+ 'Sink(Wärme)|size|ub',
+ 'Sink(Wärme)|flow_rate|lb',
+ 'Sink(Wärme)|flow_rate|ub',
]
)
@@ -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,
@@ -215,26 +215,26 @@ def test_flow_invest_optional(self, basic_flow_system_linopy):
),
)
assert_conequal(
- model.constraints['Sink(Wärme)|lb_Sink(Wärme)|flow_rate'],
- flow.model.variables['Sink(Wärme)|flow_rate']
- >= flow.model.variables['Sink(Wärme)|size']
+ model.constraints['Sink(Wärme)|flow_rate|lb'],
+ 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)|ub_Sink(Wärme)|flow_rate'],
- flow.model.variables['Sink(Wärme)|flow_rate']
- <= flow.model.variables['Sink(Wärme)|size']
+ model.constraints['Sink(Wärme)|flow_rate|ub'],
+ 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)|is_invested_ub'],
- flow.model.variables['Sink(Wärme)|size'] <= flow.model.variables['Sink(Wärme)|is_invested'] * 100,
+ model.constraints['Sink(Wärme)|size|ub'],
+ flow.submodel.variables['Sink(Wärme)|size'] <= flow.submodel.variables['Sink(Wärme)|is_invested'] * 100,
)
assert_conequal(
- model.constraints['Sink(Wärme)|is_invested_lb'],
- flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 20,
+ model.constraints['Sink(Wärme)|size|lb'],
+ 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,16 +252,16 @@ 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)|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|ub',
+ 'Sink(Wärme)|size|lb',
+ 'Sink(Wärme)|flow_rate|lb',
+ 'Sink(Wärme)|flow_rate|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,
@@ -279,26 +279,26 @@ 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'],
- flow.model.variables['Sink(Wärme)|flow_rate']
- >= flow.model.variables['Sink(Wärme)|size']
+ model.constraints['Sink(Wärme)|flow_rate|lb'],
+ 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)|ub_Sink(Wärme)|flow_rate'],
- flow.model.variables['Sink(Wärme)|flow_rate']
- <= flow.model.variables['Sink(Wärme)|size']
+ model.constraints['Sink(Wärme)|flow_rate|ub'],
+ 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)|is_invested_ub'],
- flow.model.variables['Sink(Wärme)|size'] <= flow.model.variables['Sink(Wärme)|is_invested'] * 100,
+ model.constraints['Sink(Wärme)|size|ub'],
+ flow.submodel.variables['Sink(Wärme)|size'] <= flow.submodel.variables['Sink(Wärme)|is_invested'] * 100,
)
assert_conequal(
- model.constraints['Sink(Wärme)|is_invested_lb'],
- flow.model.variables['Sink(Wärme)|size'] >= flow.model.variables['Sink(Wärme)|is_invested'] * 1e-5,
+ model.constraints['Sink(Wärme)|size|lb'],
+ 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,14 +316,14 @@ 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)|lb_Sink(Wärme)|flow_rate',
- 'Sink(Wärme)|ub_Sink(Wärme)|flow_rate',
+ 'Sink(Wärme)|flow_rate|lb',
+ 'Sink(Wärme)|flow_rate|ub',
]
)
@@ -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,
@@ -339,15 +339,15 @@ 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'],
- flow.model.variables['Sink(Wärme)|flow_rate']
- >= flow.model.variables['Sink(Wärme)|size']
+ model.constraints['Sink(Wärme)|flow_rate|lb'],
+ 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)|ub_Sink(Wärme)|flow_rate'],
- flow.model.variables['Sink(Wärme)|flow_rate']
- <= flow.model.variables['Sink(Wärme)|size']
+ model.constraints['Sink(Wärme)|flow_rate|ub'],
+ 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,21 +458,21 @@ 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',
- 'Sink(Wärme)|on_con1',
- 'Sink(Wärme)|on_con2',
+ 'Sink(Wärme)|flow_rate|lb',
+ 'Sink(Wärme)|flow_rate|ub',
]
)
# 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(
@@ -490,18 +490,18 @@ 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.submodel.variables['Sink(Wärme)|flow_rate'] >= flow.submodel.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.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)|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',
}
- 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,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.submodel.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',
- }.issubset(set(flow.model.constraints))
+ 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.submodel.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.submodel.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',
- }.issubset(set(flow.model.constraints))
+ 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.submodel.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.submodel.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'
- }.issubset(set(flow.model.constraints))
+ '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.submodel.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.submodel.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'
- }.issubset(set(flow.model.constraints))
+ '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.submodel.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(
- set(flow.model.variables)
+ assert {'Sink(Wärme)|switch|on', 'Sink(Wärme)|switch|off', 'Sink(Wärme)|switch|count'}.issubset(
+ set(flow.submodel.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',
- }.issubset(set(flow.model.constraints))
+ 'Sink(Wärme)|switch|transition',
+ 'Sink(Wärme)|switch|initial',
+ 'Sink(Wärme)|switch|mutex',
+ 'Sink(Wärme)|switch|count',
+ }.issubset(set(flow.submodel.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.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_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.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,22 +929,22 @@ 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',
- '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)|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',
]
)
# 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(
@@ -962,17 +962,25 @@ 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'],
- flow.model.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.model.variables['Sink(Wärme)|flow_rate'],
+ model.constraints['Sink(Wärme)|size|lb'],
+ flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|is_invested'] * 20,
)
assert_conequal(
- model.constraints['Sink(Wärme)|on_con2'],
- flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'],
+ model.constraints['Sink(Wärme)|size|ub'],
+ 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.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.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
@@ -980,13 +988,13 @@ 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'],
- 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,
+ model.constraints['Sink(Wärme)|flow_rate|lb2'],
+ 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)|ub_Sink(Wärme)|flow_rate'],
- flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8,
+ model.constraints['Sink(Wärme)|flow_rate|ub2'],
+ 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):
@@ -1003,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',
@@ -1013,20 +1021,20 @@ 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',
- '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|lb1',
+ 'Sink(Wärme)|flow_rate|ub1',
+ 'Sink(Wärme)|flow_rate|lb2',
+ 'Sink(Wärme)|flow_rate|ub2',
]
)
# flow_rate
assert_var_equal(
- flow.model.flow_rate,
+ flow.submodel.flow_rate,
model.add_variables(
lower=0,
upper=0.8 * 200,
@@ -1036,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(
@@ -1044,17 +1052,17 @@ 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'],
- flow.model.variables['Sink(Wärme)|on'] * 0.2 * 20 <= flow.model.variables['Sink(Wärme)|flow_rate'],
+ model.constraints['Sink(Wärme)|flow_rate|lb1'],
+ 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)|on_con2'],
- flow.model.variables['Sink(Wärme)|on'] * 0.8 * 200 >= flow.model.variables['Sink(Wärme)|flow_rate'],
+ model.constraints['Sink(Wärme)|flow_rate|ub1'],
+ 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
@@ -1062,13 +1070,13 @@ 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'],
- 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,
+ model.constraints['Sink(Wärme)|flow_rate|lb2'],
+ 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)|ub_Sink(Wärme)|flow_rate'],
- flow.model.variables['Sink(Wärme)|flow_rate'] <= flow.model.variables['Sink(Wärme)|size'] * 0.8,
+ model.constraints['Sink(Wärme)|flow_rate|ub2'],
+ flow.submodel.variables['Sink(Wärme)|flow_rate'] <= flow.submodel.variables['Sink(Wärme)|size'] * 0.8,
)
@@ -1090,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,))
@@ -1116,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)|fix_Sink(Wärme)|flow_rate'],
- flow.model.variables['Sink(Wärme)|flow_rate']
- == flow.model.variables['Sink(Wärme)|size'] * xr.DataArray(profile, coords=(timesteps,)),
+ model.constraints['Sink(Wärme)|flow_rate|fixed'],
+ 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 a01c17ef2..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):
@@ -189,14 +189,14 @@ 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
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
@@ -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):
@@ -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):
@@ -360,11 +360,11 @@ 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
- assert converter.model.piecewise_conversion is not None
+ # Verify that PiecewiseModel was created and added as a submodel
+ 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
@@ -472,11 +472,11 @@ 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
- assert converter.model.piecewise_conversion is not None
+ # Verify that PiecewiseModel was created and added as a submodel
+ 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
@@ -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
)
diff --git a/tests/test_on_hours_computation.py b/tests/test_on_hours_computation.py
index a873bbd12..578fd7792 100644
--- a/tests/test_on_hours_computation.py
+++ b/tests/test_on_hours_computation.py
@@ -1,105 +1,99 @@
import numpy as np
import pytest
+import xarray as xr
-from flixopt.features import ConsecutiveStateModel, StateModel
+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."""
- result = ConsecutiveStateModel.compute_consecutive_hours_in_state(binary_values, hours_per_timestep)
+ """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):
- ConsecutiveStateModel.compute_consecutive_hours_in_state(binary_values, hours_per_timestep)
+ ModelingUtilities.compute_consecutive_hours_in_state(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."""
- result = StateModel.compute_previous_states(previous_values)
- np.testing.assert_array_equal(result, expected)
+ """Test compute_previous_states with various inputs."""
+ result = ModelingUtilities.compute_previous_states(previous_values)
+ xr.testing.assert_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])),
-
- # 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
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()),
],