diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml new file mode 100644 index 000000000..73a3f0b1f --- /dev/null +++ b/.github/workflows/deploy-docs.yaml @@ -0,0 +1,33 @@ +name: Documentation + +on: + release: + types: [created] # Automatically deploy docs on release + workflow_dispatch: # Allow manual triggering + +jobs: + deploy-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for proper versioning + + - name: Configure Git Credentials + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + + - uses: actions/setup-python@v5 + with: + python-version: 3.11 + + - name: Install documentation dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[docs]" + + - name: Deploy docs + run: | + VERSION=${GITHUB_REF#refs/tags/v} + mike deploy --push --update-aliases $VERSION latest \ No newline at end of file diff --git a/.github/workflows/python-app.yaml b/.github/workflows/python-app.yaml index 5d65ad61e..abaef42e1 100644 --- a/.github/workflows/python-app.yaml +++ b/.github/workflows/python-app.yaml @@ -37,7 +37,7 @@ jobs: strategy: fail-fast: false # Continue testing other Python versions if one fails matrix: - python-version: ['3.10', '3.11', '3.12'] + python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - name: Check out code @@ -82,7 +82,7 @@ jobs: - name: Upload to TestPyPI run: | - twine upload --repository testpypi dist/* + twine upload --repository testpypi dist/* --verbose env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} @@ -99,12 +99,12 @@ jobs: (sleep 30 && pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ $PACKAGE_NAME) || \ (sleep 60 && pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ $PACKAGE_NAME) # Basic import test - python -c "import $PACKAGE_NAME; print('Installation successful!')" + python -c "import flixopt; print('Installation successful!')" publish-pypi: name: Publish to PyPI runs-on: ubuntu-22.04 - needs: [publish-testpypi] # Only run after TestPyPI publish succeeds + needs: [test] # Only run after TestPyPI publish succeeds if: github.event_name == 'release' && github.event.action == 'created' # Only on release creation steps: @@ -124,3 +124,24 @@ jobs: - name: Build the distribution run: | python -m build + + - name: Upload to PyPI + run: | + twine upload dist/* --verbose + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + + - name: Verify PyPI installation + run: | + # Create a temporary environment to test installation + python -m venv prod_test_env + source prod_test_env/bin/activate + # Get the package name from the built distribution + PACKAGE_NAME=$(ls dist/*.tar.gz | head -n 1 | sed 's/dist\///' | sed 's/-[0-9].*$//') + # Wait for PyPI to index the package + sleep 60 + # Install from PyPI + pip install $PACKAGE_NAME + # Basic import test + python -c "import flixopt; print('PyPI installation successful!')" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 69585dfd3..cc2179b07 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.pyc +*.log results/ .idea/ .venv/ diff --git a/README.md b/README.md index 2a48b398f..1312dace9 100644 --- a/README.md +++ b/README.md @@ -1,134 +1,86 @@ -# flixOpt: Energy and Material Flow Optimization Framework +# FlixOpt: Energy and Material Flow Optimization Framework -**flixOpt** is a Python-based optimization framework designed to tackle energy and material flow problems using mixed-integer linear programming (MILP). Combining flexibility and efficiency, it provides a powerful platform for both dispatch and investment optimization challenges. +[![Documentation](https://img.shields.io/badge/docs-latest-brightgreen.svg)](https://flixopt.github.io/flixopt/latest/) +[![Build Status](https://github.com/flixOpt/flixopt/actions/workflows/python-app.yaml/badge.svg)](https://github.com/flixOpt/flixopt/actions/workflows/python-app.yaml) +[![PyPI version](https://img.shields.io/pypi/v/flixopt)](https://pypi.org/project/flixopt/) +[![Python Versions](https://img.shields.io/pypi/pyversions/flixopt.svg)](https://pypi.org/project/flixopt/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) --- -## šŸš€ Introduction +## šŸš€ Purpose -flixOpt was developed by [TU Dresden](https://github.com/gewv-tu-dresden) as part of the SMARTBIOGRID project, funded by the German Federal Ministry for Economic Affairs and Energy (FKZ: 03KB159B). Building on the Matlab-based flixOptMat framework (developed in the FAKS project), flixOpt also incorporates concepts from [oemof/solph](https://github.com/oemof/oemof-solph). +**flixopt** is a Python-based optimization framework designed to tackle energy and material flow problems using mixed-integer linear programming (MILP). -Although flixOpt is in its early stages, it is fully functional and ready for experimentation. It is used for investment and operation decisions by energy providing companys as well as research institutions. Feedback and collaboration are highly encouraged to help shape its future. +**flixopt** bridges the gap between high-level energy systems models like [FINE](https://github.com/FZJ-IEK3-VSA/FINE) used for design and (multi-period) investment decisions and low-level dispatch optimization tools used for operation decisions. ---- - -## šŸ“¦ Installation +**flixopt** leverages the fast and efficient [linopy](https://github.com/PyPSA/linopy/) for the mathematical modeling and [xarray](https://github.com/pydata/xarray) for data handling. -Install flixOpt directly into your environment using pip. Thanks to [HiGHS](https://github.com/ERGO-Code/HiGHS?tab=readme-ov-file), flixOpt can be used without further setup. -`pip install git+https://github.com/flixOpt/flixopt.git` +**flixopt** provides a user-friendly interface with options for advanced users. -We recommend installing flixOpt with all dependencies, which enables interactive network visualizations by [pyvis](https://github.com/WestHealth/pyvis) and time series aggregation by [tsam](https://github.com/FZJ-IEK3-VSA/tsam). -`pip install "flixOpt[full] @ git+https://github.com/flixOpt/flixopt.git"` +It was originally developed by [TU Dresden](https://github.com/gewv-tu-dresden) as part of the SMARTBIOGRID project, funded by the German Federal Ministry for Economic Affairs and Energy (FKZ: 03KB159B). Building on the Matlab-based flixOptMat framework (developed in the FAKS project), FlixOpt also incorporates concepts from [oemof/solph](https://github.com/oemof/oemof-solph). --- -## 🌟 Key Features and Concepts - -### šŸ’” High-level Interface... - - flixOpt aims to provide a user-friendly interface for defining and solving energy systems, without sacrificing fine-grained control where necessary. - - This is achieved through a high-level interface with many optional or default parameters. - - The most important concepts are: - - **FlowSystem**: Represents the System that is modeled. - - **Flow**: A Flow represents a stream of matter or energy. In an Energy-System, it could be electricity [kW] - - **Bus**: A Bus represents a balancing node in the Energy-System, typically connecting a demand to a supply. - - **Component**: A Component is a physical entity that consumes or produces matter or energy. It can also transform matter or energy into other kinds of matter or energy. - - **Effect**: Flows and Components can have Effects, related to their usage (or size). Common effects are *costs*, *CO2-emissions*, *primary-energy-demand* or *area-demand*. One Effect is used as the optimization target. The others can be constrained. - - To simplify the modeling process, high-level **Components** (CHP, Boiler, Heat Pump, Cooling Tower, Storage, etc.) are availlable. - -### šŸŽ›ļø ...with low-level control -- **Segmented Linear Correlations** - - Accurate modeling for efficiencies, investment effects, and sizes. -- **On/Off Variables** - - Modeling On/Off-Variables and their constraints. - - On-Hours/Off-Hours - - Consecutive On-Hours/ Off-Hours - - Switch On/Off - -### šŸ’° Investment Optimization -- flixOpt combines dispatch optimization with investment optimization in one model. -- Size and/or discrete investment decisions can be modeled -- Investment decisions can be combined with Modeling On/Off-Variables and their constraints - -### Further Features -- **Multiple Effects** - - Couple effects (e.g., specific CO2 costs) and set constraints (e.g., max CO2 emissions). - - Easily switch between optimization targets (e.g., minimize CO2 or costs). - - This allows to solve questions like "How much does it cost to reduce CO2 emissions by 20%?" - -- **Advanced Time Handling** - - Non-equidistant timesteps supported. - - Energy prices or effects in general can always be defined per hour (or per MWh...) - - - A variety of predefined constraints for operational and investment optimization can be applied. - - Many of these are optional and only applied when necessary, keeping the amount o variables and equations low. - ---- +## 🌟 Key Features -## šŸ–„ļø Usage Example -![Usage Example](https://github.com/user-attachments/assets/fa0e12fa-2853-4f51-a9e2-804abbefe20c) +- **High-level Interface** with low-level control + - User-friendly interface for defining flow systems + - Pre-defined components like CHP, Heat Pump, Cooling Tower, etc. + - Fine-grained control for advanced configurations -**Plotting examples**: -![flixOpt plotting](/pics/flixOpt_plotting.jpg) +- **Investment Optimization** + - Combined dispatch and investment optimization + - Size optimization and discrete investment decisions + - Combined with On/Off variables and constraints -## āš™ļø Calculation Modes +- **Effects, not only Costs --> Multi-criteria Optimization** + - flixopt abstracts costs as so called 'Effects'. This allows to model costs, CO2-emissions, primary-energy-demand or area-demand at the same time. + - Effects can interact with each other(e.g., specific CO2 costs) + - Any of these `Effects` can be used as the optimization objective. + - A **Weigted Sum** of Effects can be used as the optimization objective. + - Every Effect can be constrained ($\epsilon$-constraint method). -flixOpt offers three calculation modes, tailored to different performance and accuracy needs: +- **Calculation Modes** + - **Full** - Solve the model with highest accuracy and computational requirements. + - **Segmented** - Speed up solving by using a rolling horizon. + - **Aggregated** - Speed up solving by identifying typical periods using [TSAM](https://github.com/FZJ-IEK3-VSA/tsam). Suitable for large models. -- **Full Mode** - - Provides exact solutions with high computational requirements. - - Recommended for detailed analyses and investment decision problems. - -- **Segmented Mode** - - Solving a Model segmentwise, this mode can speed up the solving process for complex systems, while being fairly accurate. - - Utilizes variable time overlap to improve accuracy. - - Not suitable for large storage systems or investment decisions. +--- -- **Aggregated Mode** - - Automatically generates typical periods using [TSAM](https://github.com/FZJ-IEK3-VSA/tsam). - - Balances speed and accuracy, making it ideal for large-scale simulations. +## šŸ“¦ Installation +Install FlixOpt via pip. +`pip install flixopt` +With [HiGHS](https://github.com/ERGO-Code/HiGHS?tab=readme-ov-file) included out of the box, flixopt is ready to use.. -## šŸ—ļø Architecture +We recommend installing FlixOpt with all dependencies, which enables additional features like interactive network visualizations ([pyvis](https://github.com/WestHealth/pyvis)) and time series aggregation ([tsam](https://github.com/FZJ-IEK3-VSA/tsam)). +`pip install "flixopt[full]"` -- **Minimal coupling to Pyomo** - - Included independent module is used to organize variables and equations, independently of a specific modeling language. - - While currently only working with [Pyomo](http://www.pyomo.org/), flixOpt is designed to work with different modeling languages with minor modifications ([cvxpy](https://www.cvxpy.org)). +--- -- **File-based Post-Processing Unit** - - Results are saved to .json and .yaml files for easy access and analysis anytime. - - Internal plotting functions utilizing matplotlib, plotly and pandas simplify results visualization and reporting. +## šŸ“š Documentation -![Architecture Diagram](/pics/architecture_flixOpt.png) +The documentation is available at [https://flixopt.github.io/flixopt/latest/](https://flixopt.github.io/flixopt/latest/) --- ## šŸ› ļø Solver Integration -By default, flixOpt uses the open-source solver [HiGHS](https://highs.dev/) which is installed by default. However, it is compatible with additional solvers such as: +By default, FlixOpt uses the open-source solver [HiGHS](https://highs.dev/) which is installed by default. However, it is compatible with additional solvers such as: -- [CBC](https://github.com/coin-or/Cbc) -- [GLPK](https://www.gnu.org/software/glpk/) - [Gurobi](https://www.gurobi.com/) +- [CBC](https://github.com/coin-or/Cbc) +- [GLPK](https://www.gnu.org/software/glpk/) - [CPLEX](https://www.ibm.com/analytics/cplex-optimizer) -Executables can be found for example [here for CBC](https://portal.ampl.com/dl/open/cbc/) and [here for GLPK](https://sourceforge.net/projects/winglpk/) (Windows: You have to put solver-executables to the PATH-variable) - For detailed licensing and installation instructions, refer to the respective solver documentation. --- ## šŸ“– Citation -If you use flixOpt in your research or project, please cite the following: +If you use FlixOpt in your research or project, please cite the following: - **Main Citation:** [DOI:10.18086/eurosun.2022.04.07](https://doi.org/10.18086/eurosun.2022.04.07) - **Short Overview:** [DOI:10.13140/RG.2.2.14948.24969](https://doi.org/10.13140/RG.2.2.14948.24969) - ---- - -## šŸ”§ Development and Testing - -Run the tests using: - -```bash -python -m unittest discover -s tests diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 000000000..7afca119d --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,7 @@ +- [Home](index.md) +- [Getting Started](getting-started.md) +- [User Guide](user-guide/) +- [Examples](examples/) +- [FAQ](faq/) +- [API-Reference](api-reference/) +- [Release Notes](release-notes/) \ No newline at end of file diff --git a/docs/contribute.md b/docs/contribute.md new file mode 100644 index 000000000..439fefe1d --- /dev/null +++ b/docs/contribute.md @@ -0,0 +1,49 @@ +# Contributing to the Project + +We warmly welcome contributions from the community! This guide will help you get started with contributing to our project. + +## Development Setup +1. Clone the repository `git clone https://github.com/flixOpt/flixopt.git` +2. Install the development dependencies `pip install -editable .[dev, docs]` +3. Run `pytest` and `ruff check .` to ensure your code passes all tests + +## Documentation +FlixOpt uses [mkdocs](https://www.mkdocs.org/) to generate documentation. To preview the documentation locally, run `mkdocs serve` in the root directory. + +## Helpful Commands +- `mkdocs serve` to preview the documentation locally. Navigate to `http://127.0.0.1:8000/` to view the documentation. +- `pytest` to run the test suite (You can also run the provided python script `run_all_test.py`) +- `ruff check .` to run the linter +- `ruff check . --fix` to automatically fix linting issues + +--- +# Best practices + +## Coding Guidelines + +- Follow PEP 8 style guidelines +- Write clear, commented code +- Include type hints +- Create or update tests for new functionality +- Ensure 100% test coverage for new code + +## Branches +As we start to think FlixOpt in **Releases**, we decided to introduce multiple **dev**-branches instead of only one: +Following the **Semantic Versioning** guidelines, we introduced: +- `next/patch`: This is where all pull requests for the next patch release (1.0.x) go. +- `next/minor`: This is where all pull requests for the next minor release (1.x.0) go. +- `next/major`: This is where all pull requests for the next major release (x.0.0) go. + +Everything else remains in `feature/...`-branches. + +## Pull requests +Every feature or bugfix should be merged into one of the 3 [release branches](#branches), using **Squash and merge** or a regular **single commit**. +At some point, `next/minor` or `next/major` will get merged into `main` using a regular **Merge** (not squash). +*This ensures that Features are kept separate, and the `next/...`branches stay in synch with ``main`.* + +## Releases +As stated, we follow **Semantic Versioning**. +Right after one of the 3 [release branches](#branches) is merged into main, a **Tag** should be added to the merge commit and pushed to the main branch. The tag has the form `v1.2.3`. +With this tag, a release with **Release Notes** must be created. + +*This is our current best practice* diff --git a/docs/examples/00-Minimal Example.md b/docs/examples/00-Minimal Example.md new file mode 100644 index 000000000..c61283951 --- /dev/null +++ b/docs/examples/00-Minimal Example.md @@ -0,0 +1,5 @@ +# Minimal Example + +```python +{! ../examples/00_Minmal/minimal_example.py !} +``` \ No newline at end of file diff --git a/docs/examples/01-Basic Example.md b/docs/examples/01-Basic Example.md new file mode 100644 index 000000000..600f2516a --- /dev/null +++ b/docs/examples/01-Basic Example.md @@ -0,0 +1,5 @@ +# Simple example + +```python +{! ../examples/01_Simple/simple_example.py !} +``` \ No newline at end of file diff --git a/docs/examples/02-Complex Example.md b/docs/examples/02-Complex Example.md new file mode 100644 index 000000000..d5373c083 --- /dev/null +++ b/docs/examples/02-Complex Example.md @@ -0,0 +1,10 @@ +# Complex example +This saves the results of a calculation to file and reloads them to analyze the results +## Build the Model +```python +{! ../examples/02_Complex/complex_example.py !} +``` +## Load the Results from file +```python +{! ../examples/02_Complex/complex_example_results.py !} +``` \ No newline at end of file diff --git a/docs/examples/03-Calculation Modes.md b/docs/examples/03-Calculation Modes.md new file mode 100644 index 000000000..dd0321d43 --- /dev/null +++ b/docs/examples/03-Calculation Modes.md @@ -0,0 +1,5 @@ +# Calculation Mode comparison +**Note:** This example relies on time series data. You can find it in the `examples` folder of the FlixOpt repository. +```python +{! ../examples/03_Calculation_types/example_calculation_types.py !} +``` diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 000000000..8d535771f --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,5 @@ +# Examples + +Here you can find a collection of examples that demonstrate how to use FlixOpt. + +We work on improving this gallery. If you have something to share, please contact us! \ No newline at end of file diff --git a/docs/faq/contribute.md b/docs/faq/contribute.md new file mode 100644 index 000000000..439fefe1d --- /dev/null +++ b/docs/faq/contribute.md @@ -0,0 +1,49 @@ +# Contributing to the Project + +We warmly welcome contributions from the community! This guide will help you get started with contributing to our project. + +## Development Setup +1. Clone the repository `git clone https://github.com/flixOpt/flixopt.git` +2. Install the development dependencies `pip install -editable .[dev, docs]` +3. Run `pytest` and `ruff check .` to ensure your code passes all tests + +## Documentation +FlixOpt uses [mkdocs](https://www.mkdocs.org/) to generate documentation. To preview the documentation locally, run `mkdocs serve` in the root directory. + +## Helpful Commands +- `mkdocs serve` to preview the documentation locally. Navigate to `http://127.0.0.1:8000/` to view the documentation. +- `pytest` to run the test suite (You can also run the provided python script `run_all_test.py`) +- `ruff check .` to run the linter +- `ruff check . --fix` to automatically fix linting issues + +--- +# Best practices + +## Coding Guidelines + +- Follow PEP 8 style guidelines +- Write clear, commented code +- Include type hints +- Create or update tests for new functionality +- Ensure 100% test coverage for new code + +## Branches +As we start to think FlixOpt in **Releases**, we decided to introduce multiple **dev**-branches instead of only one: +Following the **Semantic Versioning** guidelines, we introduced: +- `next/patch`: This is where all pull requests for the next patch release (1.0.x) go. +- `next/minor`: This is where all pull requests for the next minor release (1.x.0) go. +- `next/major`: This is where all pull requests for the next major release (x.0.0) go. + +Everything else remains in `feature/...`-branches. + +## Pull requests +Every feature or bugfix should be merged into one of the 3 [release branches](#branches), using **Squash and merge** or a regular **single commit**. +At some point, `next/minor` or `next/major` will get merged into `main` using a regular **Merge** (not squash). +*This ensures that Features are kept separate, and the `next/...`branches stay in synch with ``main`.* + +## Releases +As stated, we follow **Semantic Versioning**. +Right after one of the 3 [release branches](#branches) is merged into main, a **Tag** should be added to the merge commit and pushed to the main branch. The tag has the form `v1.2.3`. +With this tag, a release with **Release Notes** must be created. + +*This is our current best practice* diff --git a/docs/faq/index.md b/docs/faq/index.md new file mode 100644 index 000000000..85d44e6af --- /dev/null +++ b/docs/faq/index.md @@ -0,0 +1,3 @@ +# Frequently Asked Questions + +## Work in progress \ No newline at end of file diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 000000000..d163c156f --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,42 @@ +# Getting Started with FlixOpt + +This guide will help you install FlixOpt, understand its basic concepts, and run your first optimization model. + +## Installation + +### Basic Installation + +Install FlixOpt directly into your environment using pip: + +```bash +pip install flixopt +``` + +This provides the core functionality with the HiGHS solver included. + +### Full Installation + +For all features including interactive network visualizations and time series aggregation: + +```bash +pip install "flixopt[full]"" +``` + +## Basic Workflow + +Working with FlixOpt follows a general pattern: + +1. **Create a [`FlowSystem`][flixopt.flow_system.FlowSystem]** with a time series +2. **Define [`Effects`][flixopt.effects.Effect]** (costs, emissions, etc.) +3. **Define [`Buses`][flixopt.elements.Bus]** as connection points in your system +4. **Add [`Components`][flixopt.components]** like converters, storage, sources/sinks with their Flows +5. **Run [`Calculations`][flixopt.calculation]** to optimize your system +6. **Analyze [`Results`][flixopt.results]** using built-in or external visualization tools + +## Next Steps + +Now that you've installed FlixOpt and understand the basic workflow, you can: + +- Learn about the [core concepts of FlixOpt](user-guide/index.md) +- Explore some [examples](examples/) +- Check the [API reference](api-reference/index.md) for detailed documentation diff --git a/docs/images/architecture_flixOpt-pre2.0.0.png b/docs/images/architecture_flixOpt-pre2.0.0.png new file mode 100644 index 000000000..1469a9de8 Binary files /dev/null and b/docs/images/architecture_flixOpt-pre2.0.0.png differ diff --git a/docs/images/architecture_flixOpt.png b/docs/images/architecture_flixOpt.png new file mode 100644 index 000000000..d4d775f69 Binary files /dev/null and b/docs/images/architecture_flixOpt.png differ diff --git a/docs/images/flixopt-icon.svg b/docs/images/flixopt-icon.svg new file mode 100644 index 000000000..04a6a6851 --- /dev/null +++ b/docs/images/flixopt-icon.svg @@ -0,0 +1 @@ +flixOpt \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..04020639e --- /dev/null +++ b/docs/index.md @@ -0,0 +1,47 @@ +# FlixOpt + +**FlixOpt** is a Python-based optimization framework designed to tackle energy and material flow problems using mixed-integer linear programming (MILP). + +It borrows concepts from both [FINE](https://github.com/FZJ-IEK3-VSA/FINE) and [oemof.solph](https://github.com/oemof/oemof-solph). + +## Why FlixOpt? + +FlixOpt is designed as a general-purpose optimization framework to get your model running quickly, without sacrificing flexibility down the road: + +- **Easy to Use API**: FlixOpt provides a Pythonic, object-oriented interface that makes mathematical optimization more accessible to Python developers. + +- **Approachable Learning Curve**: Designed to be accessible from the start, with options for more detailed models down the road. + +- **Domain Independence**: While frameworks like oemof and FINE excel at energy system modeling with domain-specific components, FlixOpt offers a more general mathematical approach that can be applied across different fields. + +- **Extensibility**: Easily add custom constraints or variables to any FlixOpt Model using [linopy](https://github.com/PyPSA/linopy). Tailor any FlixOpt model to your specific needs without loosing the convenience of the framework. + +- **Solver Agnostic**: Work with different solvers through a consistent interface. + +- **Results File I/O**: Built to analyze results independent of running the optimization. + +
+ ![FlixOpt Conceptual Usage](./images/architecture_flixOpt.png) +
Conceptual Usage and IO operations of FlixOpt
+
+ +## Installation + +```bash +pip install flixopt +``` + +For more detailed installation options, see the [Getting Started](getting-started.md) guide. + +## License + +FlixOpt is released under the MIT License. See [LICENSE](https://github.com/flixopt/flixopt/blob/main/LICENSE) for details. + +## Citation + +If you use FlixOpt in your research or project, please cite: + +- **Main Citation:** [DOI:10.18086/eurosun.2022.04.07](https://doi.org/10.18086/eurosun.2022.04.07) +- **Short Overview:** [DOI:10.13140/RG.2.2.14948.24969](https://doi.org/10.13140/RG.2.2.14948.24969) + +*A more sophisticated paper is in progress* diff --git a/docs/javascripts/mathjax.js b/docs/javascripts/mathjax.js new file mode 100644 index 000000000..bb7094d50 --- /dev/null +++ b/docs/javascripts/mathjax.js @@ -0,0 +1,18 @@ +window.MathJax = { + tex: { + inlineMath: [['$', '$'], ['\\(', '\\)']], + displayMath: [['$$', '$$'], ['\\[', '\\]']], + processEscapes: true, + tags: 'all' + }, + options: { + skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre'] + } +}; + +document$.subscribe(() => { + MathJax.startup.output.clearCache() + MathJax.typesetClear() + MathJax.texReset() + MathJax.typesetPromise() +}) \ No newline at end of file diff --git a/docs/release-notes/_template.txt b/docs/release-notes/_template.txt new file mode 100644 index 000000000..fe85a0554 --- /dev/null +++ b/docs/release-notes/_template.txt @@ -0,0 +1,32 @@ +# Release v{version} + +**Release Date:** YYYY-MM-DD + +## What's New + +* Feature 1 - Description +* Feature 2 - Description + +## Improvements + +* Improvement 1 - Description +* Improvement 2 - Description + +## Bug Fixes + +* Fixed issue with X +* Resolved problem with Y + +## Breaking Changes + +* Change 1 - Migration instructions +* Change 2 - Migration instructions + +## Deprecations + +* Feature X will be removed in v{next_version} + +## Dependencies + +* Added dependency X v1.2.3 +* Updated dependency Y to v2.0.0 \ No newline at end of file diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md new file mode 100644 index 000000000..fecb3d61b --- /dev/null +++ b/docs/release-notes/index.md @@ -0,0 +1,7 @@ +# Release Notes + +This page provides links to release notes for all versions of flixopt. + +## Latest Release + +* [v2.0.0](v2.0.0.md) - 29.03.2025 - Migration from pyomo to linopy. Alround improvements in performance and usability diff --git a/docs/release-notes/v2.0.0.md b/docs/release-notes/v2.0.0.md new file mode 100644 index 000000000..008d1360d --- /dev/null +++ b/docs/release-notes/v2.0.0.md @@ -0,0 +1,93 @@ +# Release v2.0.0 + +**Release Date:** March 29, 2025 + +## šŸš€ Major Framework Changes + +- **Migration from Pyomo to Linopy**: Completely rebuilt the optimization foundation to use Linopy instead of Pyomo + - Significant performance improvements, especially for large models + - Internal useage of linopys mathematical modeling language +- **xarray-Based Data Architecture**: Redesigned data handling to rely on xarray.Dataset throughout the package for: + - Improved solution representation and analysis + - Enhanced time series management + - Consistent serialization across all elements + - Reduced file size and improved performance +- **Saving and restoring unsolved Models**: The FlowSystem is now fully serializable and can be saved to a file. + - Share your work with others by saving the FlowSystem to a file + - Load a FlowSystem from a file to extend or modify your work + +## šŸ”§ Key Improvements + +### Model Handling + +- **Extend every flixopt model with the linopy language**: Add any additional constraint or variable to your flixopt model by using the linopy language. +- **Full Model Export/Import**: As a result of the migration to Linopy, the linopy.Model can be exported before or after the solve. +- **Improved Infeasible Model Handling**: Added better detection and reporting for infeasible optimization models +- **Improved Model Documentation**: Every model can be documented in a .yaml file, containing human readable mathematical formulations of all variables and constraints. THis is used to document every Calculation. + +### Calculation Results and documentation: +The `CalculationResults` class has been completely redesigned to provide a more streamlined and intuitive interface. +Accessing the results of a Calculation is now as simple as: +```python +fx.FullCalculation('Sim1', flow_system) +calculation.solve(fx.solvers.HighsSolver()) +calculation.results # This object can be entirely saved and reloaded to file without any information loss +``` +This access doesn't change if you save and load the results to a file or use them in your script directly! + +- **Improved Documentation**: The FlowSystem as well as a model Documentation is created for every model run. +- **Results without saving to file**: The results of a Calculation can now be properly accessed without saving the results to a file first. +- **Unified Solution exploration**: Every `Calculation` has a `Calculation.results` attribute, which accesses the solution. This can be saved and reloaded without any information loss. +- **Improved Calculation Results**: The results of a Calculation are now more intuitive and easier to access. The `CalculationResults` class has been completely redesigned to provide a more streamlined and intuitive interface. + +### Data Management & I/O + +- **Unified Serialization**: Standardized serialization and deserialization across all elements +- **Compression Support**: Added data compression when saving results to reduce file size +- **to_netcdf/from_netcdf Methods**: Added for FlowSystem and other core components + +### Details +#### TimeSeries Enhancements + +- **xarray Integration**: Redesigned TimeSeries to depend on xr.DataArray +- **datatypes**: Added support for more datatypes, with methods for conversion to TimeSeries +- **Improved TimeSeriesCollection**: Enhanced indexing, representation, and dataset conversion +- **Simplified Time Management**: Removed period concepts and focused on timesteps for more intuitive time handling + +## šŸ“š Documentation + +- Improved documentation of the FlixOpt API and mathematical formulations +- **Google Style Docstrings**: Updated all docstrings to Google style format + +## šŸ”„ Dependencies + +- **Linopy**: Added as the core dependency replacing Pyomo +- **xarray**: Now a critical dependency for data handling and file I/O +- **netcdf4**: Dependency for fast and efficient file I/O + +### Dropped Dependencies +- **pyomo**: Replaced by linopy as the modeling language + +## šŸ“‹ Migration Notes + +This version represents a significant architecture change. If you're upgrading: + +- Code that directly accessed Pyomo models will need to be updated to work with Linopy +- Data handling now uses xarray.Dataset throughout, which may require changes in how you interact with results +- The way labels are constructed has changed throughout the system +- The results of calculations are now handled differently, and may require changes in how you access results +- The framework was renamed from flixOpt to flixopt. Use `import flixopt as fx`. + +For complete details, please refer to the full commit history. + +## Installation + +```bash +pip install flixopt==2.0.0 +``` + +## Upgrading + +```bash +pip install --upgrade flixopt +``` \ No newline at end of file diff --git a/docs/release-notes/v2.0.1.md b/docs/release-notes/v2.0.1.md new file mode 100644 index 000000000..9b6884e48 --- /dev/null +++ b/docs/release-notes/v2.0.1.md @@ -0,0 +1,12 @@ +# Release v2.0.1 + +**Release Date:** 2025-04-10 + +## Improvements + +* Add logger warning if relative_minimum is used without on_off_parameters in Flow, as this prevents the flow_rate from switching "OFF" + +## Bug Fixes + +* Replace "|" with "__" in filenames when saving figures, as "|" can lead to issues on windows +* Fixed a Bug that prevented the load factor from working without InvestmentParameters diff --git a/docs/release-notes/v2.1.0.md b/docs/release-notes/v2.1.0.md new file mode 100644 index 000000000..09972c5f7 --- /dev/null +++ b/docs/release-notes/v2.1.0.md @@ -0,0 +1,31 @@ +# Release v2.1.0 + +**Release Date:** 2025-04-11 + +## Improvements + +* Add logger warning if relative_minimum is used without on_off_parameters in Flow, as this prevents the flow_rate from switching "OFF" +* Python 3.13 support added +* Greatly improved internal testing infrastructure by leveraging linopy's testing framework + +## Bug Fixes + +* Bugfixing the lower bound of `flow_rate` when using optional investments without OnOffParameters. +* Fixes a Bug that prevented divest effects from working. +* added lower bounds of 0 to two unbounded vars (only numerical better) + +## Breaking Changes + +* We restructured the modeling of the On/Off state of FLows or Components. This leads to slightly renaming of variables and constraints. + +### Variable renaming +* "...|consecutive_on_hours" is now "...|ConsecutiveOn|hours" +* "...|consecutive_off_hours" is now "...|ConsecutiveOff|hours" + +### Constraint renaming +* "...|consecutive_on_hours_con1" is now "...|ConsecutiveOn|con1" +* "...|consecutive_on_hours_con2a" is now "...|ConsecutiveOn|con2a" +* "...|consecutive_on_hours_con2b" is now "...|ConsecutiveOn|con2b" +* "...|consecutive_on_hours_initial" is now "...|ConsecutiveOn|initial" +* "...|consecutive_on_hours_minimum_duration" is now "...|ConsecutiveOn|minimum" +The same goes for "...|consecutive_off..." --> "...|ConsecutiveOff|..." \ No newline at end of file diff --git a/docs/release-notes/v2.1.1.md b/docs/release-notes/v2.1.1.md new file mode 100644 index 000000000..44e635f87 --- /dev/null +++ b/docs/release-notes/v2.1.1.md @@ -0,0 +1,11 @@ +# Release v2.1.1 + +**Release Date:** 2025-05-08 + +## Improvements + +* Improving docstring and tests + +## Bug Fixes + +* Fixing bug in the `_ElementResults.constraints` not returning the constraints but rather the variables diff --git a/docs/user-guide/Mathematical Notation/Bus.md b/docs/user-guide/Mathematical Notation/Bus.md new file mode 100644 index 000000000..840c90a08 --- /dev/null +++ b/docs/user-guide/Mathematical Notation/Bus.md @@ -0,0 +1,33 @@ +A Bus is a simple nodal balance between its incoming and outgoing flow rates. + +$$ \label{eq:bus_balance} + \sum_{f_\text{in} \in \mathcal{F}_\text{in}} p_{f_\text{in}}(\text{t}_i) = + \sum_{f_\text{out} \in \mathcal{F}_\text{out}} p_{f_\text{out}}(\text{t}_i) +$$ + +Optionally, a Bus can have a `excess_penalty_per_flow_hour` parameter, which allows to penaltize the balance for missing or excess flow-rates. +This is usefull as it handles a possible ifeasiblity gently. + +This changes the balance to + +$$ \label{eq:bus_balance-excess} + \sum_{f_\text{in} \in \mathcal{F}_\text{in}} p_{f_ \text{in}}(\text{t}_i) + \phi_\text{in}(\text{t}_i) = + \sum_{f_\text{out} \in \mathcal{F}_\text{out}} p_{f_\text{out}}(\text{t}_i) + \phi_\text{out}(\text{t}_i) +$$ + +The penalty term is defined as + +$$ \label{eq:bus_penalty} + s_{b \rightarrow \Phi}(\text{t}_i) = + \text a_{b \rightarrow \Phi}(\text{t}_i) \cdot \Delta \text{t}_i + \cdot [ \phi_\text{in}(\text{t}_i) + \phi_\text{out}(\text{t}_i) ] +$$ + +With: + +- $\mathcal{F}_\text{in}$ and $\mathcal{F}_\text{out}$ being the set of all incoming and outgoing flows +- $p_{f_\text{in}}(\text{t}_i)$ and $p_{f_\text{out}}(\text{t}_i)$ being the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively +- $\phi_\text{in}(\text{t}_i)$ and $\phi_\text{out}(\text{t}_i)$ being the missing or excess flow-rate at time $\text{t}_i$, respectively +- $\text{t}_i$ being the time step +- $s_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty term +- $\text a_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty coefficient (`excess_penalty_per_flow_hour`) \ No newline at end of file diff --git a/docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md b/docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md new file mode 100644 index 000000000..1f2f0abdb --- /dev/null +++ b/docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md @@ -0,0 +1,132 @@ +## Effects +[`Effects`][flixopt.effects.Effect] are used to allocate things like costs, emissions, or other "effects" occuring in the system. +These arise from so called **Shares**, which originate from **Elements** like [Flows](Flow.md). + +**Example:** + +[`Flows`][flixopt.elements.Flow] have an attribute called `effects_per_flow_hour`, defining the effect amount of per flow hour. +Assiziated effects could be: +- costs - given in [€/kWh]... +- ...or emissions - given in [kg/kWh]. +- +Effects are allocated seperatly for investments and operation. + +### Shares to Effects + +$$ \label{eq:Share_invest} +s_{l \rightarrow e, \text{inv}} = \sum_{v \in \mathcal{V}_{l, \text{inv}}} v \cdot \text a_{v \rightarrow e} +$$ + +$$ \label{eq:Share_operation} +s_{l \rightarrow e, \text{op}}(\text{t}_i) = \sum_{v \in \mathcal{V}_{l,\text{op}}} v(\text{t}_i) \cdot \text a_{v \rightarrow e}(\text{t}_i) +$$ + +With: + +- $\text{t}_i$ being the time step +- $\mathcal{V_l}$ being the set of all optimization variables of element $e$ +- $\mathcal{V}_{l, \text{inv}}$ being the set of all optimization variables of element $e$ related to investment +- $\mathcal{V}_{l, \text{op}}$ being the set of all optimization variables of element $e$ related to operation +- $v$ being an optimization variable of the element $l$ +- $v(\text{t}_i)$ being an optimization variable of the element $l$ at timestep $\text{t}_i$ +- $\text a_{v \rightarrow e}$ being the factor between the optimization variable $v$ to effect $e$ +- $\text a_{v \rightarrow e}(\text{t}_i)$ being the factor between the optimization variable $v$ to effect $e$ for timestep $\text{t}_i$ +- $s_{l \rightarrow e, \text{inv}}$ being the share of element $l$ to the investment part of effect $e$ +- $s_{l \rightarrow e, \text{op}}(\text{t}_i)$ being the share of element $l$ to the operation part of effect $e$ + +### Shares between different Effects + +Furthermore, the Effect $x$ can contribute a share to another Effect ${e} \in \mathcal{E}\backslash x$. +This share is defined by the factor $\text r_{x \rightarrow e}$. + +For example, the Effect "CO$_2$ emissions" (unit: kg) +can cause an additional share to Effect "monetary costs" (unit: €). +In this case, the factor $\text a_{x \rightarrow e}$ is the specific CO$_2$ price in €/kg. However, circular references have to be avoided. + +The overall sum of investment shares of an Effect $e$ is given by $\eqref{Effect_invest}$ + +$$ \label{eq:Effect_invest} +E_{e, \text{inv}} = +\sum_{l \in \mathcal{L}} s_{l \rightarrow e,\text{inv}} + +\sum_{x \in \mathcal{E}\backslash e} E_{x, \text{inv}} \cdot \text{r}_{x \rightarrow e,\text{inv}} +$$ + +The overall sum of operation shares is given by $\eqref{eq:Effect_Operation}$ + +$$ \label{eq:Effect_Operation} +E_{e, \text{op}}(\text{t}_{i}) = +\sum_{l \in \mathcal{L}} s_{l \rightarrow e, \text{op}}(\text{t}_i) + +\sum_{x \in \mathcal{E}\backslash e} E_{x, \text{op}}(\text{t}_i) \cdot \text{r}_{x \rightarrow {e},\text{op}}(\text{t}_i) +$$ + +and totals to $\eqref{eq:Effect_Operation_total}$ +$$\label{eq:Effect_Operation_total} +E_{e,\text{op},\text{tot}} = \sum_{i=1}^n E_{e,\text{op}}(\text{t}_{i}) +$$ + +With: + +- $\mathcal{L}$ being the set of all elements in the FlowSystem +- $\mathcal{E}$ being the set of all effects in the FlowSystem +- $\text r_{x \rightarrow e, \text{inv}}$ being the factor between the operation part of Effect $x$ and Effect $e$ +- $\text r_{x \rightarrow e, \text{op}}(\text{t}_i)$ being the factor between the invest part of Effect $x$ and Effect $e$ + +- $\text{t}_i$ being the time step +- $s_{l \rightarrow e, \text{inv}}$ being the share of element $l$ to the investment part of effect $e$ +- $s_{l \rightarrow e, \text{op}}(\text{t}_i)$ being the share of element $l$ to the operation part of effect $e$ + + +The total of an effect $E_{e}$ is given as $\eqref{eq:Effect_Total}$ + +$$ \label{eq:Effect_Total} +E_{e} = E_{\text{inv},e} +E_{\text{op},\text{tot},e} +$$ + +### Constraining Effects + +For each variable $v \in \{ E_{e,\text{inv}}, E_{e,\text{op},\text{tot}}, E_e\}$, a lower bound $v^\text{L}$ and upper bound $v^\text{U}$ can be defined as + +$$ \label{eq:Bounds_Single} +\text v^\text{L} \leq v \leq \text v^\text{U} +$$ + +Furthermore, bounds for the operational shares can be set for each time step + +$$ \label{eq:Bounds_Time_Steps} +\text E_{e,\text{op}}^\text{L}(\text{t}_i) \leq E_{e,\text{op}}(\text{t}_i) \leq \text E_{e,\text{op}}^\text{U}(\text{t}_i) +$$ + +## Penalty + +Additionally to the user defined [Effects](#effects), a Penalty $\Phi$ is part of every FlixOpt Model. +Its used to prevent unsolvable problems and simplify troubleshooting. +Shares to the penalty can originate from every Element and are constructed similarly to +$\eqref{Share_invest}$ and $\eqref{Share_operation}$. + +$$ \label{eq:Penalty} +\Phi = \sum_{l \in \mathcal{L}} \left( s_{l \rightarrow \Phi} +\sum_{\text{t}_i \in \mathcal{T}} s_{l \rightarrow \Phi}(\text{t}_{i}) \right) +$$ + +With: + +- $\mathcal{L}$ being the set of all elements in the FlowSystem +- $\mathcal{T}$ being the set of all timesteps +- $s_{l \rightarrow \Phi}$ being the share of element $l$ to the penalty + +At the moment, penalties only occur in [Buses](#buses) + +## Objective + +The optimization objective of a FlixOpt Model is defined as $\eqref{eq:Objective}$ +$$ \label{eq:Objective} +\min(E_{\Omega} + \Phi) +$$ + +With: + +- $\Omega$ being the chosen **Objective [Effect](#effects)** (see $\eqref{eq:Effect_Total}$) +- $\Phi$ being the [Penalty](#penalty) + +This approach allows for a multi-criteria optimization using both... + - ... the **Weigted Sum**Method, as the chosen **Objective Effect** can incorporate other Effects. + - ... the ($\epsilon$-constraint method) by constraining effects. \ No newline at end of file diff --git a/docs/user-guide/Mathematical Notation/Flow.md b/docs/user-guide/Mathematical Notation/Flow.md new file mode 100644 index 000000000..4b755d005 --- /dev/null +++ b/docs/user-guide/Mathematical Notation/Flow.md @@ -0,0 +1,26 @@ +The flow_rate is the main optimization variable of the Flow. It's limited by the size of the Flow and relative bounds \eqref{eq:flow_rate}. + +$$ \label{eq:flow_rate} + \text P \cdot \text p^{\text{L}}_{\text{rel}}(\text{t}_{i}) + \leq p(\text{t}_{i}) \leq + \text P \cdot \text p^{\text{U}}_{\text{rel}}(\text{t}_{i}) +$$ + +With: + +- $\text P$ being the size of the Flow +- $p(\text{t}_{i})$ being the flow-rate at time $\text{t}_{i}$ +- $\text p^{\text{L}}_{\text{rel}}(\text{t}_{i})$ being the relative lower bound (typically 0) +- $\text p^{\text{U}}_{\text{rel}}(\text{t}_{i})$ being the relative upper bound (typically 1) + +With $\text p^{\text{L}}_{\text{rel}}(\text{t}_{i}) = 0$ and $\text p^{\text{U}}_{\text{rel}}(\text{t}_{i}) = 1$, +equation \eqref{eq:flow_rate} simplifies to + +$$ + 0 \leq p(\text{t}_{i}) \leq \text P +$$ + + +This mathematical Formulation can be extended or changed when using [OnOffParameters](#onoffparameters) +to define the On/Off state of the Flow, or [InvestParameters](#investments), +which changes the size of the Flow from a constant to an optimization variable. \ No newline at end of file diff --git a/docs/user-guide/Mathematical Notation/LinearConverter.md b/docs/user-guide/Mathematical Notation/LinearConverter.md new file mode 100644 index 000000000..a8cea843e --- /dev/null +++ b/docs/user-guide/Mathematical Notation/LinearConverter.md @@ -0,0 +1,21 @@ +[`LinearConverters`][flixopt.components.LinearConverter] define a ratio between incoming and outgoing [Flows](#flows). + +$$ \label{eq:Linear-Transformer-Ratio} + \sum_{f_{\text{in}} \in \mathcal F_{in}} \text a_{f_{\text{in}}}(\text{t}_i) \cdot p_{f_\text{in}}(\text{t}_i) = \sum_{f_{\text{out}} \in \mathcal F_{out}} \text b_{f_\text{out}}(\text{t}_i) \cdot p_{f_\text{out}}(\text{t}_i) +$$ + +With: + +- $\mathcal F_{in}$ and $\mathcal F_{out}$ being the set of all incoming and outgoing flows +- $p_{f_\text{in}}(\text{t}_i)$ and $p_{f_\text{out}}(\text{t}_i)$ being the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively +- $\text a_{f_\text{in}}(\text{t}_i)$ and $\text b_{f_\text{out}}(\text{t}_i)$ being the ratio of the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively + +With one incoming **Flow** and one outgoing **Flow**, this can be simplified to: + +$$ \label{eq:Linear-Transformer-Ratio-simple} + \text a(\text{t}_i) \cdot p_{f_\text{in}}(\text{t}_i) = p_{f_\text{out}}(\text{t}_i) +$$ + +where $\text a$ can be interpreted as the conversion efficiency of the **LinearTransformer**. +#### Piecewise Concersion factors +The conversion efficiency can be defined as a piecewise linear approximation. See [Piecewise](Piecewise.md) for more details. \ No newline at end of file diff --git a/docs/user-guide/Mathematical Notation/Piecewise.md b/docs/user-guide/Mathematical Notation/Piecewise.md new file mode 100644 index 000000000..4e73cfece --- /dev/null +++ b/docs/user-guide/Mathematical Notation/Piecewise.md @@ -0,0 +1,49 @@ +# Piecewise + +A Piecewise is a collection of [`Pieces`][flixopt.interface.Piece], which each define a valid range for a variable $v$ + +$$ \label{eq:active_piece} + \beta_\text{k} = \lambda_\text{0, k} + \lambda_\text{1, k} +$$ + +$$ \label{eq:piece} + v_\text{k} = \lambda_\text{0, k} * \text{v}_{\text{start,k}} + \lambda_\text{1,k} * \text{v}_{\text{end,k}} +$$ + +$$ \label{eq:piecewise_in_pieces} +\sum_{k=1}^k \beta_{k} = 1 +$$ + +With: + +- $v$: The variable to be defined by the Piecewise +- $\text{v}_{\text{start,k}}$: the start point of the piece for variable $v$ +- $\text{v}_{\text{end,k}}$: the end point of the piece for variable $v$ +- $\beta_\text{k} \in \{0, 1\}$: defining wether the Piece $k$ is active +- $\lambda_\text{0,k} \in [0, 1]$: A variable defining the fraction of $\text{v}_{\text{start,k}}$ that is active +- $\lambda_\text{1,k} \in [0, 1]$: A variable defining the fraction of $\text{v}_{\text{end,k}}$ that is active + +Which can also be described as $v \in 0 \cup [\text{v}_\text{start}, \text{v}_\text{end}]$. + +Instead of \eqref{eq:piecewise_in_pieces}, the following constraint is used to also allow all variables to be zero: + +$$ \label{eq:piecewise_in_pieces_zero} +\sum_{k=1}^k \beta_{k} = \beta_\text{zero} +$$ + +With: + +- $\beta_\text{zero} \in \{0, 1\}$. + +Which can also be described as $v \in \{0\} \cup [\text{v}_{\text{start_k}}, \text{v}_{\text{end_k}}]$ + + +## Combining multiple Piecewises + +Piecewise allows representing non-linear relationships. +This is a powerful technique in linear optimization to model non-linear behaviors while maintaining the problem's linearity. + +Therefore, each Piecewise must have the same number of Pieces $k$. + +The variables described in [Piecewise](#piecewise) are created for each Piece, but nor for each Piecewise. +Rather, \eqref{eq:piece} is the only constraint that is created for each Piecewise, using the start and endpoints $\text{v}_{\text{start,k}}$ and $\text{v}_{\text{end,k}}$ of each Piece for the corresponding variable $v$ diff --git a/docs/user-guide/Mathematical Notation/Storage.md b/docs/user-guide/Mathematical Notation/Storage.md new file mode 100644 index 000000000..db78b6ab3 --- /dev/null +++ b/docs/user-guide/Mathematical Notation/Storage.md @@ -0,0 +1,44 @@ +# Storages +**Storages** have one incoming and one outgoing **[Flow](#flows)** with a charging and discharging efficiency. +A storage has a state of charge $c(\text{t}_i)$ which is limited by its `size` $\text C$ and relative bounds $\eqref{eq:Storage_Bounds}$. + +$$ \label{eq:Storage_Bounds} + \text C \cdot \text c^{\text{L}}_{\text{rel}}(\text t_{i}) + \leq c(\text{t}_i) \leq + \text C \cdot \text c^{\text{U}}_{\text{rel}}(\text t_{i}) +$$ + +Where: + +- $\text C$ is the size of the storage +- $c(\text{t}_i)$ is the state of charge at time $\text{t}_i$ +- $\text c^{\text{L}}_{\text{rel}}(\text t_{i})$ is the relative lower bound (typically 0) +- $\text c^{\text{U}}_{\text{rel}}(\text t_{i})$ is the relative upper bound (typically 1) + +With $\text c^{\text{L}}_{\text{rel}}(\text t_{i}) = 0$ and $\text c^{\text{U}}_{\text{rel}}(\text t_{i}) = 1$, +Equation $\eqref{eq:Storage_Bounds}$ simplifies to + +$$ 0 \leq c(\text t_{i}) \leq \text C $$ + +The state of charge $c(\text{t}_i)$ decreases by a fraction of the prior state of charge. The belonging parameter +$ \dot{ \text c}_\text{rel, loss}(\text{t}_i)$ expresses the "loss fraction per hour". The storage balance from $\text{t}_i$ to $\text t_{i+1}$ is + +$$ +\begin{align*} + c(\text{t}_{i+1}) &= c(\text{t}_{i}) \cdot (1-\dot{\text{c}}_\text{rel,loss}(\text{t}_i) \cdot \Delta \text{t}_{i}) \\ + &\quad + p_{f_\text{in}}(\text{t}_i) \cdot \Delta \text{t}_i \cdot \eta_\text{in}(\text{t}_i) \\ + &\quad - \frac{p_{f_\text{out}}(\text{t}_i) \cdot \Delta \text{t}_i}{\eta_\text{out}(\text{t}_i)} + \tag{3} +\end{align*} +$$ + +Where: + +- $c(\text{t}_{i+1})$ is the state of charge at time $\text{t}_{i+1}$ +- $c(\text{t}_{i})$ is the state of charge at time $\text{t}_{i}$ +- $\dot{\text{c}}_\text{rel,loss}(\text{t}_i)$ is the relative loss rate (self-discharge) per hour +- $\Delta \text{t}_{i}$ is the time step duration in hours +- $p_{f_\text{in}}(\text{t}_i)$ is the input flow rate at time $\text{t}_i$ +- $\eta_\text{in}(\text{t}_i)$ is the charging efficiency at time $\text{t}_i$ +- $p_{f_\text{out}}(\text{t}_i)$ is the output flow rate at time $\text{t}_i$ +- $\eta_\text{out}(\text{t}_i)$ is the discharging efficiency at time $\text{t}_i$ \ No newline at end of file diff --git a/docs/user-guide/Mathematical Notation/index.md b/docs/user-guide/Mathematical Notation/index.md new file mode 100644 index 000000000..4dabe2af2 --- /dev/null +++ b/docs/user-guide/Mathematical Notation/index.md @@ -0,0 +1,22 @@ + +# Mathematical Notation + +## Naming Conventions + +FlixOpt uses the following naming conventions: + +- All optimization variables are denoted by italic letters (e.g., $x$, $y$, $z$) +- All parameters and constants are denoted by non italic small letters (e.g., $\text{a}$, $\text{b}$, $\text{c}$) +- All Sets are denoted by greek capital letters (e.g., $\mathcal{F}$, $\mathcal{E}$) +- All units of a set are denoted by greek small letters (e.g., $\mathcal{f}$, $\mathcal{e}$) +- The letter $i$ is used to denote an index (e.g., $i=1,\dots,\text n$) +- All time steps are denoted by the letter $\text{t}$ (e.g., $\text{t}_0$, $\text{t}_1$, $\text{t}_i$) + +## Timesteps +Time steps are defined as a sequence of discrete time steps $\text{t}_i \in \mathcal{T} \quad \text{for} \quad i \in \{1, 2, \dots, \text{n}\}$ (left-aligned in its timespan). +From this sequence, the corresponding time intervals $\Delta \text{t}_i \in \Delta \mathcal{T}$ are derived as + +$$\Delta \text{t}_i = \text{t}_{i+1} - \text{t}_i \quad \text{for} \quad i \in \{1, 2, \dots, \text{n}-1\}$$ + +The final time interval $\Delta \text{t}_\text n$ defaults to $\Delta \text{t}_\text n = \Delta \text{t}_{\text n-1}$, but is of course customizable. +Non-equidistant time steps are also supported. diff --git a/docs/user-guide/Mathematical Notation/others.md b/docs/user-guide/Mathematical Notation/others.md new file mode 100644 index 000000000..0cd82de94 --- /dev/null +++ b/docs/user-guide/Mathematical Notation/others.md @@ -0,0 +1,3 @@ +# Work in Progress + +This is a work in progress. \ No newline at end of file diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md new file mode 100644 index 000000000..8789779b2 --- /dev/null +++ b/docs/user-guide/index.md @@ -0,0 +1,124 @@ +# FlixOpt Concepts + +FlixOpt is built around a set of core concepts that work together to represent and optimize energy and material flow systems. This page provides a high-level overview of these concepts and how they interact. + +## Core Concepts + +### FlowSystem + +The [`FlowSystem`][flixopt.flow_system.FlowSystem] is the central organizing unit in FlixOpt. +Every FlixOpt model starts with creating a FlowSystem. It: + +- Defines the timesteps for the optimization +- Contains and connects [components](#components), [buses](#buses), and [flows](#flows) +- Manages the [effects](#effects) (objectives and constraints) + +### Flows + +[`Flow`][flixopt.elements.Flow] objects represent the movement of energy or material between a [Bus](#buses) and a [Component](#components) in a predefined direction. + +- Have a `size` which, generally speaking, defines how fast energy or material can be moved. Usually measured in MW, kW, m³/h, etc. +- Have a `flow_rate`, which is defines how fast energy or material is transported. Usually measured in MW, kW, m³/h, etc. +- Have constraints to limit the flow-rate (min/max, total flow hours, on/off etc.) +- Can have fixed profiles (for demands or renewable generation) +- Can have [Effects](#effects) associated by their use (operation, investment, on/off, ...) + +#### Flow Hours +While the **Flow Rate** defines the rate in which energy or material is transported, the **Flow Hours** define the amount of energy or material that is transported. +Its defined by the flow_rate times the duration of the timestep in hours. + +Examples: + +| Flow Rate | Timestep | Flow Hours | +|-----------|----------|------------| +| 10 (MW) | 1 hour | 10 (MWh) | +| 10 (MW) | 6 minutes | 0.1 (MWh) | +| 10 (kg/h) | 1 hour | 10 (kg) | + +### Buses + +[`Bus`][flixopt.elements.Bus] objects represent nodes or connection points in a FlowSystem. They: + +- Balance incoming and outgoing flows +- Can represent physical networks like heat, electricity, or gas +- Handle infeasible balances gently by allowing the balance to be closed in return for a big Penalty (optional) + +### Components + +[`Component`][flixopt.elements.Component] objects usually represent physical entities in your system that interact with [`Flows`][flixopt.elements.Flow]. They include: + +- [`LinearConverters`][flixopt.components.LinearConverter] - Converts input flows to output flows with (piecewise) linear relationships +- [`Storages`][flixopt.components.Storage] - Stores energy or material over time +- [`Sources`][flixopt.components.Source] / [`Sinks`][flixopt.components.Sink] / [`SourceAndSinks`][flixopt.components.SourceAndSink] - Produce or consume flows. They are usually used to model external demands or supplies. +- [`Transmissions`][flixopt.components.Transmission] - Moves flows between locations with possible losses +- Specialized [`LinearConverters`][flixopt.components.LinearConverter] like [`Boilers`][flixopt.linear_converters.Boiler], [`HeatPumps`][flixopt.linear_converters.HeatPump], [`CHPs`][flixopt.linear_converters.CHP], etc. These simplify the usage of the `LinearConverter` class and can also be used as blueprint on how to define custom classes or parameterize existing ones. + +### Effects + +[`Effect`][flixopt.effects.Effect] objects represent impacts or metrics related to your system, such as: + +- Costs (investment, operation) +- Emissions (COā‚‚, NOx, etc.) +- Resource consumption +- Area demand + +These can be freely defined and crosslink to each other (`COā‚‚` ──[specific COā‚‚-costs]─→ `Costs`). +One effect is designated as the **optimization objective** (typically Costs), while others can be constrained. +This approach allows for a multi-criteria optimization using both... + - ... the **Weigted Sum**Method, by Optimizing a theoretical Effect which other Effects crosslink to. + - ... the ($\epsilon$-constraint method) by constraining effects. + +### Calculation + +A [`FlowSystem`][flixopt.flow_system.FlowSystem] can be converted to a Model and optimized by creating a [`Calculation`][flixopt.calculation.Calculation] from it. + +FlixOpt offers different calculation modes: + +- [`FullCalculation`][flixopt.calculation.FullCalculation] - Solves the entire problem at once +- [`SegmentedCalculation`][flixopt.calculation.SegmentedCalculation] - Solves the problem in segments (with optioinal overlap), improving performance for large problems +- [`AggregatedCalculation`][flixopt.calculation.AggregatedCalculation] - Uses typical periods to reduce computational requirements + +### Results + +The results of a calculation are stored in a [`CalculationResults`][flixopt.results.CalculationResults] object. +This object contains the solutions of the optimization as well as all information about the [`Calculation`][flixopt.calculation.Calculation] and the [`FlowSystem`][flixopt.flow_system.FlowSystem] it was created from. +The solutions is stored as an `xarray.Dataset`, but can be accessed through their assotiated Component, Bus or Effect. + +This [`CalculationResults`][flixopt.results.CalculationResults] object can be saved to file and reloaded from file, allowing you to analyze the results anytime after the solve. + +## How These Concepts Work Together + +The process of working with FlixOpt can be divided into 3 steps: + +1. Create a [`FlowSystem`][flixopt.flow_system.FlowSystem], containing all the elements and data of your system + - Define the time series of your system + - Add [`Components`][flixopt.components] like [`Boilers`][flixopt.linear_converters.Boiler], [`HeatPumps`][flixopt.linear_converters.HeatPump], [`CHPs`][flixopt.linear_converters.CHP], etc. + - Add [`Buses`][flixopt.elements.Bus] as connection points in your system + - Add [`Effects`][flixopt.effects.Effect] to represent costs, emissions, etc. + - *This [`FlowSystem`][flixopt.flow_system.FlowSystem] can also be loaded from a netCDF file* +2. Translate the model to a mathematical optimization problem + - Create a [`Calculation`][flixopt.calculation.Calculation] from your FlowSystem and choose a Solver + - ...The Calculation is translated internaly to a mathematical optimization problem... + - ...and solved by the chosen solver. +3. Analyze the results + - The results are stored in a [`CalculationResults`][flixopt.results.CalculationResults] object + - This object can be saved to file and reloaded from file, retaining all information about the calculation + - As it contains the used [`FlowSystem`][flixopt.flow_system.FlowSystem], it can be used to start a new calculation + +
+ ![FlixOpt Conceptual Usage](../images/architecture_flixOpt.png) +
Conceptual Usage and IO operations of FlixOpt
+
+ +## Advanced Usage +As flixopt is build on [linopy](https://github.com/PyPSA/linopy), any model created with FlixOpt can be extended or modified using the great [linopy API](https://linopy.readthedocs.io/en/latest/api.html). +This allows to adjust your model to very specific requirements without loosing the convenience of FlixOpt. + + + + + + + + + diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index efaed0dbf..e9ef241ff 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -1,23 +1,25 @@ """ -This script shows how to use the flixOpt framework to model a super minimalistic energy system. +This script shows how to use the flixopt framework to model a super minimalistic energy system. """ import numpy as np +import pandas as pd from rich.pretty import pprint -import flixOpt as fx +import flixopt as fx if __name__ == '__main__': + # --- Define the Flow System, that will hold all elements, and the time steps you want to model --- + timesteps = pd.date_range('2020-01-01', periods=3, freq='h') + flow_system = fx.FlowSystem(timesteps) + # --- Define Thermal Load Profile --- # Load profile (e.g., kW) for heating demand over time thermal_load_profile = np.array([30, 0, 20]) - datetime_series = fx.create_datetime_array('2020-01-01', 3, 'h') # --- Define Energy Buses --- - # These represent the different energy carriers in the system - electricity_bus = fx.Bus('Electricity') - heat_bus = fx.Bus('District Heating') - fuel_bus = fx.Bus('Natural Gas') + # These are balancing nodes (inputs=outputs) and balance the different energy carriers your system + flow_system.add_elements(fx.Bus('District Heating'), fx.Bus('Natural Gas')) # --- Define Objective Effect (Cost) --- # Cost effect representing the optimization objective (minimizing costs) @@ -28,40 +30,42 @@ boiler = fx.linear_converters.Boiler( 'Boiler', eta=0.5, - Q_th=fx.Flow(label='Thermal Output', bus=heat_bus, size=50), - Q_fu=fx.Flow(label='Fuel Input', bus=fuel_bus), + Q_th=fx.Flow(label='Thermal Output', bus='District Heating', size=50), + Q_fu=fx.Flow(label='Fuel Input', bus='Natural Gas'), ) # Heat load component with a fixed thermal demand profile heat_load = fx.Sink( 'Heat Demand', - sink=fx.Flow(label='Thermal Load', bus=heat_bus, size=1, fixed_relative_profile=thermal_load_profile), + sink=fx.Flow(label='Thermal Load', bus='District Heating', size=1, fixed_relative_profile=thermal_load_profile), ) # Gas source component with cost-effect per flow hour gas_source = fx.Source( 'Natural Gas Tariff', - source=fx.Flow(label='Gas Flow', bus=fuel_bus, size=1000, effects_per_flow_hour=0.04), # 0.04 €/kWh + source=fx.Flow(label='Gas Flow', bus='Natural Gas', size=1000, effects_per_flow_hour=0.04), # 0.04 €/kWh ) # --- Build the Flow System --- # Add all components and effects to the system - flow_system = fx.FlowSystem(datetime_series) flow_system.add_elements(cost_effect, boiler, heat_load, gas_source) - # --- Define and Run Calculation --- + # --- Define, model and solve a Calculation --- calculation = fx.FullCalculation('Simulation1', flow_system) calculation.do_modeling() + calculation.solve(fx.solvers.HighsSolver(0.01, 60)) + + # --- Analyze Results --- + # Access the results of an element + df1 = calculation.results['costs'].filter_solution('time').to_dataframe() - # --- Solve the Calculation and Save Results --- - calculation.solve(fx.solvers.HighsSolver(), save_results=True) + # Plot the results of a specific element + calculation.results['District Heating'].plot_node_balance_pie() + calculation.results['District Heating'].plot_node_balance() - # --- Load and Analyze Results --- - # Load results and plot the operation of the District Heating Bus - results = fx.results.CalculationResults(calculation.name, folder='results') - results.plot_operation('District Heating', 'area') + # Save results to a file + df2 = calculation.results['District Heating'].node_balance().to_dataframe() + # df2.to_csv('results/District Heating.csv') # Save results to csv - # Print results to the console. Check Results in file or perform more plotting - pprint(calculation.results()) - pprint('Look into .yaml and .json file for results') - pprint(calculation.system_model.main_results) + # Print infos to the console. + pprint(calculation.summary) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 317df2665..45550c9cc 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -1,11 +1,12 @@ """ -THis script shows how to use the flixOpt framework to model a simple energy system. +This script shows how to use the flixopt framework to model a simple energy system. """ import numpy as np +import pandas as pd from rich.pretty import pprint # Used for pretty printing -import flixOpt as fx +import flixopt as fx if __name__ == '__main__': # --- Create Time Series Data --- @@ -14,11 +15,12 @@ power_prices = 1 / 1000 * np.array([80, 80, 80, 80, 80, 80, 80, 80, 80]) # Create datetime array starting from '2020-01-01' for the given time period - time_series = fx.create_datetime_array('2020-01-01', len(heat_demand_per_h)) + timesteps = pd.date_range('2020-01-01', periods=len(heat_demand_per_h), freq='h') + flow_system = fx.FlowSystem(timesteps=timesteps) # --- Define Energy Buses --- # These represent nodes, where the used medias are balanced (electricity, heat, and gas) - Strom, Fernwaerme, Gas = fx.Bus(label='Strom'), fx.Bus(label='FernwƤrme'), fx.Bus(label='Gas') + flow_system.add_elements(fx.Bus(label='Strom'), fx.Bus(label='FernwƤrme'), fx.Bus(label='Gas')) # --- Define Effects (Objective and CO2 Emissions) --- # Cost effect: used as the optimization objective --> minimizing costs @@ -35,7 +37,7 @@ label='CO2', unit='kg', description='CO2_e-Emissionen', - specific_share_to_other_effects_operation={costs: 0.2}, + specific_share_to_other_effects_operation={costs.label: 0.2}, maximum_operation_per_hour=1000, # Max CO2 emissions per hour ) @@ -44,8 +46,8 @@ boiler = fx.linear_converters.Boiler( label='Boiler', eta=0.5, - Q_th=fx.Flow(label='Q_th', bus=Fernwaerme, size=50, relative_minimum=0.1, relative_maximum=1), - Q_fu=fx.Flow(label='Q_fu', bus=Gas), + Q_th=fx.Flow(label='Q_th', bus='FernwƤrme', size=50, relative_minimum=0.1, relative_maximum=1), + Q_fu=fx.Flow(label='Q_fu', bus='Gas'), ) # Combined Heat and Power (CHP): Generates both electricity and heat from fuel @@ -53,16 +55,16 @@ label='CHP', eta_th=0.5, eta_el=0.4, - P_el=fx.Flow('P_el', bus=Strom, size=60, relative_minimum=5 / 60), - Q_th=fx.Flow('Q_th', bus=Fernwaerme), - Q_fu=fx.Flow('Q_fu', bus=Gas), + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60), + Q_th=fx.Flow('Q_th', bus='FernwƤrme'), + Q_fu=fx.Flow('Q_fu', bus='Gas'), ) # Storage: Energy storage system with charging and discharging capabilities storage = fx.Storage( label='Storage', - charging=fx.Flow('Q_th_load', bus=Fernwaerme, size=1000), - discharging=fx.Flow('Q_th_unload', bus=Fernwaerme, size=1000), + charging=fx.Flow('Q_th_load', bus='FernwƤrme', size=1000), + discharging=fx.Flow('Q_th_unload', bus='FernwƤrme', size=1000), capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), initial_charge_state=0, # Initial storage state: empty relative_maximum_charge_state=1 / 100 * np.array([80, 70, 80, 80, 80, 80, 80, 80, 80, 80]), @@ -75,27 +77,26 @@ # Heat Demand Sink: Represents a fixed heat demand profile heat_sink = fx.Sink( label='Heat Demand', - sink=fx.Flow(label='Q_th_Last', bus=Fernwaerme, size=1, fixed_relative_profile=heat_demand_per_h), + sink=fx.Flow(label='Q_th_Last', bus='FernwƤrme', size=1, fixed_relative_profile=heat_demand_per_h), ) # Gas Source: Gas tariff source with associated costs and CO2 emissions gas_source = fx.Source( label='Gastarif', - source=fx.Flow(label='Q_Gas', bus=Gas, size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}), + source=fx.Flow(label='Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs.label: 0.04, CO2.label: 0.3}), ) # Power Sink: Represents the export of electricity to the grid power_sink = fx.Sink( - label='Einspeisung', sink=fx.Flow(label='P_el', bus=Strom, effects_per_flow_hour=-1 * power_prices) + label='Einspeisung', sink=fx.Flow(label='P_el', bus='Strom', effects_per_flow_hour=-1 * power_prices) ) # --- Build the Flow System --- - # Create the flow system and add all defined components and effects - flow_system = fx.FlowSystem(time_series=time_series) + # Add all defined components and effects to the flow system flow_system.add_elements(costs, CO2, boiler, storage, chp, heat_sink, gas_source, power_sink) # Visualize the flow system for validation purposes - flow_system.visualize_network() + flow_system.plot_network(show=True) # --- Define and Run Calculation --- # Create a calculation object to model the Flow System @@ -103,18 +104,17 @@ calculation.do_modeling() # Translate the model to a solvable form, creating equations and Variables # --- Solve the Calculation and Save Results --- - calculation.solve(fx.solvers.HighsSolver(), save_results=True) - - # --- Load and Analyze Results --- - # Load the results and plot the operation of the District Heating Bus - results = fx.results.CalculationResults(calculation.name, folder='results') - results.plot_operation('FernwƤrme', 'area') - results.plot_storage('Storage') - results.plot_operation('FernwƤrme', 'bar') - results.plot_operation('FernwƤrme', 'line') - results.plot_operation('CHP__Q_th', 'line') - results.plot_operation('CHP__Q_th', 'heatmap') + calculation.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) + + # --- Analyze Results --- + calculation.results['FernwƤrme'].plot_node_balance_pie() + calculation.results['FernwƤrme'].plot_node_balance() + calculation.results['Storage'].plot_node_balance() + calculation.results.plot_heatmap('CHP(Q_th)|flow_rate') # Convert the results for the storage component to a dataframe and display - results.to_dataframe('Storage') - pprint(results.all_results) + df = calculation.results['Storage'].node_balance_with_charge_state() + print(df) + + # Save results to file for later usage + calculation.results.to_file() diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index e5828583d..44ef496d7 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -1,19 +1,19 @@ """ -This script shows how to use the flixOpt framework to model a more complex energy system. +This script shows how to use the flixopt framework to model a more complex energy system. """ import numpy as np import pandas as pd from rich.pretty import pprint # Used for pretty printing -import flixOpt as fx +import flixopt as fx if __name__ == '__main__': # --- Experiment Options --- # Configure options for testing various parameters and behaviors check_penalty = False excess_penalty = 1e5 - use_chp_with_segments = False + use_chp_with_piecewise_conversion = True time_indices = None # Define specific time steps for custom calculations, or use the entire series # --- Define Demand and Price Profiles --- @@ -26,18 +26,22 @@ ) electricity_price = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) - time_series = fx.create_datetime_array('2020-01-01', len(heat_demand), freq='h') + # --- Define the Flow System, that will hold all elements, and the time steps you want to model --- + timesteps = pd.date_range('2020-01-01', periods=len(heat_demand), freq='h') + flow_system = fx.FlowSystem(timesteps) # Create FlowSystem # --- Define Energy Buses --- - # Represent different energy carriers (electricity, heat, gas) in the system - Strom = fx.Bus('Strom', excess_penalty_per_flow_hour=excess_penalty) - Fernwaerme = fx.Bus('FernwƤrme', excess_penalty_per_flow_hour=excess_penalty) - Gas = fx.Bus('Gas', excess_penalty_per_flow_hour=excess_penalty) + # Represent node balances (inputs=outputs) for the different energy carriers (electricity, heat, gas) in the system + flow_system.add_elements( + fx.Bus('Strom', excess_penalty_per_flow_hour=excess_penalty), + fx.Bus('FernwƤrme', excess_penalty_per_flow_hour=excess_penalty), + fx.Bus('Gas', excess_penalty_per_flow_hour=excess_penalty), + ) # --- Define Effects --- # Specify effects related to costs, CO2 emissions, and primary energy consumption Costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) - CO2 = fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={Costs: 0.2}) + CO2 = fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={Costs.label: 0.2}) PE = fx.Effect('PE', 'kWh_PE', 'PrimƤrenergie', maximum_total=3.5e3) # --- Define Components --- @@ -46,15 +50,17 @@ Gaskessel = fx.linear_converters.Boiler( 'Kessel', eta=0.5, # Efficiency ratio - on_off_parameters=fx.OnOffParameters(effects_per_running_hour={Costs: 0, CO2: 1000}), # CO2 emissions per hour + on_off_parameters=fx.OnOffParameters( + effects_per_running_hour={Costs.label: 0, CO2.label: 1000} + ), # CO2 emissions per hour Q_th=fx.Flow( label='Q_th', # Thermal output - bus=Fernwaerme, # Linked bus + bus='FernwƤrme', # Linked bus size=fx.InvestParameters( fix_effects=1000, # Fixed investment costs fixed_size=50, # Fixed size optional=False, # Forced investment - specific_effects={Costs: 10, PE: 2}, # Specific costs + specific_effects={Costs.label: 10, PE.label: 2}, # Specific costs ), load_factor_max=1.0, # Maximum load factor (50 kW) load_factor_min=0.1, # Minimum load factor (5 kW) @@ -73,7 +79,7 @@ switch_on_total_max=1000, # Max number of starts ), ), - Q_fu=fx.Flow(label='Q_fu', bus=Gas, size=200), + Q_fu=fx.Flow(label='Q_fu', bus='Gas', size=200), ) # 2. Define CHP Unit @@ -83,46 +89,48 @@ eta_th=0.5, eta_el=0.4, on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - P_el=fx.Flow('P_el', bus=Strom, size=60, relative_minimum=5 / 60), - Q_th=fx.Flow('Q_th', bus=Fernwaerme, size=1e3), - Q_fu=fx.Flow('Q_fu', bus=Gas, size=1e3, previous_flow_rate=20), # The CHP was ON previously + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60), + Q_th=fx.Flow('Q_th', bus='FernwƤrme', size=1e3), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=1e3, previous_flow_rate=20), # The CHP was ON previously ) - # 3. Define CHP with Linear Segments - # This CHP unit uses linear segments for more dynamic behavior over time - P_el = fx.Flow('P_el', bus=Strom, size=60, previous_flow_rate=20) - Q_th = fx.Flow('Q_th', bus=Fernwaerme) - Q_fu = fx.Flow('Q_fu', bus=Gas) - segmented_conversion_factors = { - P_el: [(5, 30), (40, 60)], # Similar to eta_th, each factor here can be an array - Q_th: [(6, 35), (45, 100)], - Q_fu: [(12, 70), (90, 200)], - } + # 3. Define CHP with Piecewise Conversion + # This CHP unit uses piecewise conversion for more dynamic behavior over time + P_el = fx.Flow('P_el', bus='Strom', size=60, previous_flow_rate=20) + Q_th = fx.Flow('Q_th', bus='FernwƤrme') + Q_fu = fx.Flow('Q_fu', bus='Gas') + piecewise_conversion = fx.PiecewiseConversion( + { + P_el.label: fx.Piecewise([fx.Piece(5, 30), fx.Piece(40, 60)]), + Q_th.label: fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), + Q_fu.label: fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), + } + ) bhkw_2 = fx.LinearConverter( 'BHKW2', inputs=[Q_fu], outputs=[P_el, Q_th], - segmented_conversion_factors=segmented_conversion_factors, + piecewise_conversion=piecewise_conversion, on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), ) # 4. Define Storage Component - # Storage with variable size and segmented investment effects - segmented_investment_effects = ( - [(5, 25), (25, 100)], # Investment size - { - Costs: [(50, 250), (250, 800)], # Investment costs - PE: [(5, 25), (25, 100)], # Primary energy costs + # Storage with variable size and piecewise investment effects + segmented_investment_effects = fx.PiecewiseEffects( + piecewise_origin=fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), + piecewise_shares={ + Costs.label: fx.Piecewise([fx.Piece(50, 250), fx.Piece(250, 800)]), + PE.label: fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), }, ) speicher = fx.Storage( 'Speicher', - charging=fx.Flow('Q_th_load', bus=Fernwaerme, size=1e4), - discharging=fx.Flow('Q_th_unload', bus=Fernwaerme, size=1e4), + charging=fx.Flow('Q_th_load', bus='FernwƤrme', size=1e4), + discharging=fx.Flow('Q_th_unload', bus='FernwƤrme', size=1e4), capacity_in_flow_hours=fx.InvestParameters( - effects_in_segments=segmented_investment_effects, # Investment effects + piecewise_effects=segmented_investment_effects, # Investment effects optional=False, # Forced investment minimum_size=0, maximum_size=1000, # Optimizing between 0 and 1000 kWh @@ -141,7 +149,7 @@ 'WƤrmelast', sink=fx.Flow( 'Q_th_Last', # Heat sink - bus=Fernwaerme, # Linked bus + bus='FernwƤrme', # Linked bus size=1, fixed_relative_profile=heat_demand, # Fixed demand profile ), @@ -152,9 +160,9 @@ 'Gastarif', source=fx.Flow( 'Q_Gas', - bus=Gas, # Gas source + bus='Gas', # Gas source size=1000, # Nominal size - effects_per_flow_hour={Costs: 0.04, CO2: 0.3}, + effects_per_flow_hour={Costs.label: 0.04, CO2.label: 0.3}, ), ) @@ -163,41 +171,30 @@ 'Einspeisung', sink=fx.Flow( 'P_el', - bus=Strom, # Feed-in tariff for electricity + bus='Strom', # Feed-in tariff for electricity effects_per_flow_hour=-1 * electricity_price, # Negative price for feed-in ), ) # --- Build FlowSystem --- - # Select components to be included in the final system model - flow_system = fx.FlowSystem(time_series, last_time_step_hours=None) # Create FlowSystem - + # Select components to be included in the flow system flow_system.add_elements(Costs, CO2, PE, Gaskessel, Waermelast, Gasbezug, Stromverkauf, speicher) - flow_system.add_elements(bhkw_2) if use_chp_with_segments else flow_system.add_components(bhkw) + flow_system.add_elements(bhkw_2) if use_chp_with_piecewise_conversion else flow_system.add_elements(bhkw) pprint(flow_system) # Get a string representation of the FlowSystem # --- Solve FlowSystem --- - calculation = fx.FullCalculation('Sim1', flow_system, 'pyomo', time_indices) + calculation = fx.FullCalculation('complex example', flow_system, time_indices) calculation.do_modeling() - # Show variables as str (else, you can find them in the results.yaml file - pprint(calculation.system_model.description_of_constraints()) - pprint(calculation.system_model.description_of_variables()) - - calculation.solve( - fx.solvers.HighsSolver( - mip_gap=0.005, time_limit_seconds=30 - ), # Specify which solver you want to use and specify parameters - save_results='results', # If and where to save results - ) + calculation.solve(fx.solvers.HighsSolver(0.01, 60)) # --- Results --- - # You can analyze results directly. But it's better to save them to a file and start from there, - # letting you continue at any time - # See complex_example_evaluation.py - used_time_series = time_series[time_indices] if time_indices else time_series - # Analyze results directly - fig = fx.plotting.with_plotly( - data=pd.DataFrame(Gaskessel.Q_th.model.flow_rate.result, index=used_time_series), mode='bar', show=True - ) + # You can analyze results directly or save them to file and reload them later. + calculation.results.to_file() + + # But let's plot some results anyway + calculation.results.plot_heatmap('BHKW2(Q_th)|flow_rate') + calculation.results['BHKW2'].plot_node_balance() + calculation.results['Speicher'].plot_charge_state() + calculation.results['FernwƤrme'].plot_node_balance_pie() diff --git a/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index 06548cdbd..8ff9c7a95 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -5,12 +5,12 @@ import pandas as pd import plotly.offline -import flixOpt as fx +import flixopt as fx if __name__ == '__main__': # --- Load Results --- try: - results = fx.results.CalculationResults('Sim1', folder='results') + results = fx.results.CalculationResults.from_file('results', 'complex example') except FileNotFoundError as e: raise FileNotFoundError( f"Results file not found in the specified directory ('results'). " @@ -19,30 +19,15 @@ ) from e # --- Basic overview --- - results.visualize_network() - results.plot_operation('FernwƤrme') - results.plot_operation('FernwƤrme', 'bar') - results.plot_operation('FernwƤrme', 'bar', engine='matplotlib') + results.plot_network(show=True) + results['FernwƤrme'].plot_node_balance() # --- Detailed Plots --- # In depth plot for individual flow rates ('__' is used as the delimiter between Component and Flow - results.plot_operation('WƤrmelast__Q_th_Last', 'heatmap') - figs = [] - for flow_label in results.flow_results(): - if flow_label.startswith('BHKW2'): - fig = results.plot_operation(flow_label, 'heatmap', heatmap_steps_per_period='h', heatmap_periods='D') + results.plot_heatmap('WƤrmelast(Q_th_Last)|flow_rate') + for flow_rate in results['BHKW2'].inputs + results['BHKW2'].outputs: + results.plot_heatmap(flow_rate) # --- Plotting internal variables manually --- - on_data = pd.DataFrame( - { - 'BHKW2 On': results.component_results['BHKW2'].variables['Q_th']['OnOff']['on'], - 'Kessel On': results.component_results['Kessel'].variables['Q_th']['OnOff']['on'], - }, - index=results.time, - ) - fig = fx.plotting.with_plotly(on_data, 'line') - fig.write_html('results/on.html') # Writing to file - - fig = fx.plotting.with_plotly(on_data, 'bar') - fig.update_layout(barmode='group', bargap=0.1) # Applying custom layout - plotly.offline.plot(fig) + results.plot_heatmap('BHKW2(Q_th)|on') + results.plot_heatmap('Kessel(Q_th)|on') diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 0cffbfcaa..97b18e3c0 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -8,9 +8,10 @@ import numpy as np import pandas as pd +import xarray as xr from rich.pretty import pprint # Used for pretty printing -import flixOpt as fx +import flixopt as fx if __name__ == '__main__': # Calculation Types @@ -23,12 +24,13 @@ aggregation_parameters = fx.AggregationParameters( hours_per_period=6, nr_of_periods=4, - fix_storage_flows=True, + fix_storage_flows=False, aggregate_data_and_fix_non_binary_vars=True, percentage_of_period_freedom=0, penalty_of_period_freedom=0, ) keep_extreme_periods = True + excess_penalty = 1e5 # or set to None if not needed # Data Import data_import = pd.read_csv(pathlib.Path('Zeitreihen2020.csv'), index_col=0).sort_index() @@ -36,7 +38,7 @@ # filtered_data = data_import[0:500] # Alternatively filter by index filtered_data.index = pd.to_datetime(filtered_data.index) - datetime_series = np.array(filtered_data.index).astype('datetime64') + timesteps = filtered_data.index # Access specific columns and convert to 1D-numpy array electricity_demand = filtered_data['P_Netz/MW'].to_numpy() @@ -50,12 +52,13 @@ TS_electricity_price_sell = fx.TimeSeriesData(-(electricity_demand - 0.5), agg_group='p_el') TS_electricity_price_buy = fx.TimeSeriesData(electricity_price + 0.5, agg_group='p_el') - # Bus Definitions - excess_penalty = 1e5 # or set to None if not needed - Strom = fx.Bus('Strom', excess_penalty_per_flow_hour=excess_penalty) - Fernwaerme = fx.Bus('FernwƤrme', excess_penalty_per_flow_hour=excess_penalty) - Gas = fx.Bus('Gas', excess_penalty_per_flow_hour=excess_penalty) - Kohle = fx.Bus('Kohle', excess_penalty_per_flow_hour=excess_penalty) + flow_system = fx.FlowSystem(timesteps) + flow_system.add_elements( + fx.Bus('Strom', excess_penalty_per_flow_hour=excess_penalty), + fx.Bus('FernwƤrme', excess_penalty_per_flow_hour=excess_penalty), + fx.Bus('Gas', excess_penalty_per_flow_hour=excess_penalty), + fx.Bus('Kohle', excess_penalty_per_flow_hour=excess_penalty), + ) # Effects costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) @@ -68,10 +71,10 @@ a_gaskessel = fx.linear_converters.Boiler( 'Kessel', eta=0.85, - Q_th=fx.Flow(label='Q_th', bus=Fernwaerme), + Q_th=fx.Flow(label='Q_th', bus='FernwƤrme'), Q_fu=fx.Flow( label='Q_fu', - bus=Gas, + bus='Gas', size=95, relative_minimum=12 / 95, previous_flow_rate=20, @@ -85,9 +88,9 @@ eta_th=0.58, eta_el=0.22, on_off_parameters=fx.OnOffParameters(effects_per_switch_on=24000), - P_el=fx.Flow('P_el', bus=Strom, size=200), - Q_th=fx.Flow('Q_th', bus=Fernwaerme, size=200), - Q_fu=fx.Flow('Q_fu', bus=Kohle, size=288, relative_minimum=87 / 288, previous_flow_rate=100), + P_el=fx.Flow('P_el', bus='Strom', size=200), + Q_th=fx.Flow('Q_th', bus='FernwƤrme', size=200), + Q_fu=fx.Flow('Q_fu', bus='Kohle', size=288, relative_minimum=87 / 288, previous_flow_rate=100), ) # 3. Storage @@ -101,45 +104,48 @@ eta_discharge=1, relative_loss_per_hour=0.001, prevent_simultaneous_charge_and_discharge=True, - charging=fx.Flow('Q_th_load', size=137, bus=Fernwaerme), - discharging=fx.Flow('Q_th_unload', size=158, bus=Fernwaerme), + charging=fx.Flow('Q_th_load', size=137, bus='FernwƤrme'), + discharging=fx.Flow('Q_th_unload', size=158, bus='FernwƤrme'), ) # 4. Sinks and Sources # Heat Load Profile a_waermelast = fx.Sink( - 'WƤrmelast', sink=fx.Flow('Q_th_Last', bus=Fernwaerme, size=1, fixed_relative_profile=TS_heat_demand) + 'WƤrmelast', sink=fx.Flow('Q_th_Last', bus='FernwƤrme', size=1, fixed_relative_profile=TS_heat_demand) ) # Electricity Feed-in a_strom_last = fx.Sink( - 'Stromlast', sink=fx.Flow('P_el_Last', bus=Strom, size=1, fixed_relative_profile=TS_electricity_demand) + 'Stromlast', sink=fx.Flow('P_el_Last', bus='Strom', size=1, fixed_relative_profile=TS_electricity_demand) ) # Gas Tariff a_gas_tarif = fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus=Gas, size=1000, effects_per_flow_hour={costs: gas_price, CO2: 0.3}) + 'Gastarif', + source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs.label: gas_price, CO2.label: 0.3}), ) # Coal Tariff a_kohle_tarif = fx.Source( - 'Kohletarif', source=fx.Flow('Q_Kohle', bus=Kohle, size=1000, effects_per_flow_hour={costs: 4.6, CO2: 0.3}) + 'Kohletarif', + source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={costs.label: 4.6, CO2.label: 0.3}), ) # Electricity Tariff and Feed-in a_strom_einspeisung = fx.Sink( - 'Einspeisung', sink=fx.Flow('P_el', bus=Strom, size=1000, effects_per_flow_hour=TS_electricity_price_sell) + 'Einspeisung', sink=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour=TS_electricity_price_sell) ) a_strom_tarif = fx.Source( 'Stromtarif', - source=fx.Flow('P_el', bus=Strom, size=1000, effects_per_flow_hour={costs: TS_electricity_price_buy, CO2: 0.3}), + source=fx.Flow( + 'P_el', bus='Strom', size=1000, effects_per_flow_hour={costs.label: TS_electricity_price_buy, CO2: 0.3} + ), ) # Flow System Setup - flow_system = fx.FlowSystem(datetime_series) - flow_system.add_effects(costs, CO2, PE) - flow_system.add_components( + flow_system.add_elements(costs, CO2, PE) + flow_system.add_elements( a_gaskessel, a_waermelast, a_strom_last, @@ -150,100 +156,72 @@ a_kwk, a_speicher, ) - flow_system.visualize_network(controls=False) + flow_system.plot_network(controls=False, show=True) + # Calculations - kinds = ['Full', 'Segmented', 'Aggregated'] - calculations: dict = {key: None for key in kinds} - results: dict = {key: None for key in kinds} + calculations: List[Union[fx.FullCalculation, fx.AggregatedCalculation, fx.SegmentedCalculation]] = [] if full: - calculation = fx.FullCalculation('fullModel', flow_system, 'pyomo') + calculation = fx.FullCalculation('Full', flow_system) calculation.do_modeling() - calculation.solve(fx.solvers.HighsSolver()) - calculations['Full'] = calculation - results['Full'] = calculations['Full'].results() + calculation.solve(fx.solvers.HighsSolver(0, 60)) + calculations.append(calculation) if segmented: - calculation = fx.SegmentedCalculation('segModel', flow_system, segment_length, overlap_length) - calculation.do_modeling_and_solve(fx.solvers.HighsSolver()) - calculations['Segmented'] = calculation - results['Segmented'] = calculations['Segmented'].results(combined_arrays=True) + calculation = fx.SegmentedCalculation('Segmented', flow_system, segment_length, overlap_length) + calculation.do_modeling_and_solve(fx.solvers.HighsSolver(0, 60)) + calculations.append(calculation) if aggregated: if keep_extreme_periods: aggregation_parameters.time_series_for_high_peaks = [TS_heat_demand] aggregation_parameters.time_series_for_low_peaks = [TS_electricity_demand, TS_heat_demand] - calculation = fx.AggregatedCalculation('aggModel', flow_system, aggregation_parameters) + calculation = fx.AggregatedCalculation('Aggregated', flow_system, aggregation_parameters) calculation.do_modeling() - calculation.solve(fx.solvers.HighsSolver()) - calculations['Aggregated'] = calculation - results['Aggregated'] = calculations['Aggregated'].results() - pprint(results) - - def extract_result(results_data: dict[str, dict], keys: List[str]) -> Dict[str, Union[int, float, np.ndarray]]: - """ - Function to retrieve values from a nested dictionary. - Tries to get the wanted value for eachnkey in the first layer of the dict. - Returns a dict with one key value pair for each dict it found a value in. - """ - - def get_nested_value(d, ks): - for k in ks: - if isinstance(d, dict): - d = d.get(k, None) - else: - return None - return d - - return {kind: get_nested_value(results_data.get(kind, {}), keys) for kind in results_data.keys()} - - if calculations['Full'] is not None: - time_series_used = calculations['Full'].system_model.time_series - time_series_used_w_end = calculations['Full'].system_model.time_series_with_end - else: - time_series_used = calculations['Aggregated'].system_model.time_series - time_series_used_w_end = calculations['Aggregated'].system_model.time_series_with_end - - data = pd.DataFrame( - extract_result(results, ['Components', 'Speicher', 'charge_state']), index=time_series_used_w_end - ) - fig = fx.plotting.with_plotly(data, 'line') - fig.update_layout(title='Charge State Comparison', xaxis_title='Time', yaxis_title='Charge state') - fig.write_html('results/Charge State.html') - - data = pd.DataFrame(extract_result(results, ['Components', 'BHKW2', 'Q_th', 'flow_rate']), index=time_series_used) - fig = fx.plotting.with_plotly(data, 'line') - fig.update_layout(title='BHKW2 Q_th Flow Rate Comparison', xaxis_title='Time', yaxis_title='Flow rate') - fig.write_html('results/BHKW2 Thermal Power.html') - - data = pd.DataFrame( - extract_result(results, ['Effects', 'costs', 'operation', 'operation_sum_TS']), - index=calculations['Full'].system_model.time_series, - ) - fig = fx.plotting.with_plotly(data, 'line') - fig.update_layout(title='Cost Comparison', xaxis_title='Time', yaxis_title='Costs (€)') - fig.write_html('results/Operation Costs.html') - - data = pd.DataFrame( - extract_result(results, ['Effects', 'costs', 'operation', 'operation_sum_TS']), index=time_series_used + calculation.solve(fx.solvers.HighsSolver(0, 60)) + calculations.append(calculation) + + # Get solutions for plotting for different calculations + def get_solutions(calcs: List, variable: str) -> xr.Dataset: + dataarrays = [] + for calc in calcs: + 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)) + return xr.merge(dataarrays) + + # --- Plotting for comparison --- + fx.plotting.with_plotly( + get_solutions(calculations, 'Speicher|charge_state').to_dataframe(), + mode='line', + title='Charge State Comparison', + ylabel='Charge state', + ).write_html('results/Charge State.html') + + fx.plotting.with_plotly( + get_solutions(calculations, 'BHKW2(Q_th)|flow_rate').to_dataframe(), + mode='line', + title='BHKW2(Q_th) Flow Rate Comparison', + ylabel='Flow rate', + ).write_html('results/BHKW2 Thermal Power.html') + + fx.plotting.with_plotly( + get_solutions(calculations, 'costs(operation)|total_per_timestep').to_dataframe(), + mode='line', + title='Operation Cost Comparison', + ylabel='Costs [€]', + ).write_html('results/Operation Costs.html') + + fx.plotting.with_plotly( + pd.DataFrame(get_solutions(calculations, 'costs(operation)|total_per_timestep').to_dataframe().sum()).T, + mode='bar', + title='Total Cost Comparison', + ylabel='Costs [€]', + ).update_layout(barmode='group').write_html('results/Total Costs.html') + + fx.plotting.with_plotly( + pd.DataFrame([calc.durations for calc in calculations], index=[calc.name for calc in calculations]), 'bar' + ).update_layout(title='Duration Comparison', xaxis_title='Calculation type', yaxis_title='Time (s)').write_html( + 'results/Speed Comparison.html' ) - data = pd.DataFrame(data.sum()).T - fig = fx.plotting.with_plotly(data, 'bar') - fig.update_layout(title='Total Cost Comparison', yaxis_title='Costs (€)', barmode='group') - fig.write_html('results/Total Costs.html') - - duration_data = pd.DataFrame( - { - 'Full': [calculations['Full'].durations.get(key, 0) for key in calculations['Aggregated'].durations], - 'Aggregated': [ - calculations['Aggregated'].durations.get(key, 0) for key in calculations['Aggregated'].durations - ], - 'Segmented': [ - calculations['Segmented'].durations.get(key, 0) for key in calculations['Aggregated'].durations - ], - }, - index=list(calculations['Aggregated'].durations.keys()), - ).T - fig = fx.plotting.with_plotly(duration_data, 'bar') - fig.update_layout(title='Duration Comparison', xaxis_title='Calculation type', yaxis_title='Time (s)') - fig.write_html('results/Speed Comparison.html') diff --git a/flixOpt/calculation.py b/flixOpt/calculation.py deleted file mode 100644 index 5a7a16fe8..000000000 --- a/flixOpt/calculation.py +++ /dev/null @@ -1,629 +0,0 @@ -""" -This module contains the Calculation functionality for the flixOpt framework. -It is used to calculate a SystemModel for a given FlowSystem through a solver. -There are three different Calculation types: - 1. FullCalculation: Calculates the SystemModel for the full FlowSystem - 2. AggregatedCalculation: Calculates the SystemModel for the full FlowSystem, but aggregates the TimeSeriesData. - This simplifies the mathematical model and usually speeds up the solving process. - 3. SegmentedCalculation: Solves a SystemModel for each individual Segment of the FlowSystem. -""" - -import datetime -import json -import logging -import math -import pathlib -import timeit -from typing import Any, Dict, List, Literal, Optional, Union - -import numpy as np -import yaml - -from . import utils as utils -from .aggregation import AggregationModel, AggregationParameters, TimeSeriesCollection -from .components import Storage -from .core import Numeric, Skalar -from .elements import Component -from .features import InvestmentModel -from .flow_system import FlowSystem -from .solvers import Solver -from .structure import SystemModel, copy_and_convert_datatypes, get_compact_representation - -logger = logging.getLogger('flixOpt') - - -class Calculation: - """ - class for defined way of solving a flow_system optimization - """ - - def __init__( - self, - name, - flow_system: FlowSystem, - modeling_language: Literal['pyomo', 'cvxpy'] = 'pyomo', - time_indices: Optional[Union[range, List[int]]] = None, - ): - """ - Parameters - ---------- - name : str - name of calculation - flow_system : FlowSystem - flow_system which should be calculated - modeling_language : 'pyomo','cvxpy' (not implemeted yet) - choose optimization modeling language - time_indices : List[int] or None - list with indices, which should be used for calculation. If None, then all timesteps are used. - """ - self.name = name - self.flow_system = flow_system - self.modeling_language = modeling_language - self.time_indices = time_indices - - self.system_model: Optional[SystemModel] = None - self.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0} # Dauer der einzelnen Dinge - - self._paths: Dict[str, Optional[Union[pathlib.Path, List[pathlib.Path]]]] = { - 'log': None, - 'data': None, - 'info': None, - } - self._results = None - - def _define_path_names(self, save_results: Union[bool, str, pathlib.Path], include_timestamp: bool = False): - """ - Creates the path for saving results and alters the name of the calculation to have a timestamp - """ - if include_timestamp: - timestamp = datetime.datetime.now() - self.name = f'{timestamp.strftime("%Y-%m-%d")}_{self.name.replace(" ", "")}' - - if save_results: - if not isinstance(save_results, (str, pathlib.Path)): - save_results = 'results/' # Standard path for results - path = pathlib.Path.cwd() / save_results # absoluter Pfad: - - path.mkdir(parents=True, exist_ok=True) # Pfad anlegen, fall noch nicht vorhanden: - - self._paths['log'] = path / f'{self.name}_solver.log' - self._paths['data'] = path / f'{self.name}_data.json' - self._paths['results'] = path / f'{self.name}_results.json' - self._paths['infos'] = path / f'{self.name}_infos.yaml' - - def _save_solve_infos(self): - t_start = timeit.default_timer() - indent = 4 if len(self.flow_system.time_series) < 50 else None - with open(self._paths['results'], 'w', encoding='utf-8') as f: - results = copy_and_convert_datatypes(self.results(), use_numpy=False, use_element_label=False) - json.dump(results, f, indent=indent) - - with open(self._paths['data'], 'w', encoding='utf-8') as f: - data = copy_and_convert_datatypes(self.flow_system.infos(), use_numpy=False, use_element_label=False) - json.dump(data, f, indent=indent) - - self.durations['saving'] = round(timeit.default_timer() - t_start, 2) - - t_start = timeit.default_timer() - nodes_info, edges_info = self.flow_system.network_infos() - infos = { - 'Calculation': self.infos, - 'Model': self.system_model.infos, - 'FlowSystem': get_compact_representation(self.flow_system.infos(use_numpy=True, use_element_label=True)), - 'Network': {'Nodes': nodes_info, 'Edges': edges_info}, - } - - with open(self._paths['infos'], 'w', encoding='utf-8') as f: - yaml.dump( - infos, - f, - width=1000, # Verhinderung Zeilenumbruch für lange equations - allow_unicode=True, - sort_keys=False, - ) - - message = f' Saved Calculation: {self.name} ' - logger.info(f'{"":#^80}\n{message:#^80}\n{"":#^80}') - logger.info(f'Saving calculation to .json took {self.durations["saving"]:>8.2f} seconds') - logger.info(f'Saving calculation to .yaml took {(timeit.default_timer() - t_start):>8.2f} seconds') - - def results(self): - if self._results is None: - self._results = self.system_model.results() - return self._results - - @property - def infos(self): - return { - 'Name': self.name, - 'Number of indices': len(self.time_indices) if self.time_indices else 'all', - 'Calculation Type': self.__class__.__name__, - 'Durations': self.durations, - } - - -class FullCalculation(Calculation): - """ - class for defined way of solving a flow_system optimization - """ - - def do_modeling(self) -> SystemModel: - t_start = timeit.default_timer() - - self.flow_system.transform_data() - for time_series in self.flow_system.all_time_series: - time_series.activate_indices(self.time_indices) - - self.system_model = SystemModel(self.name, self.modeling_language, self.flow_system, self.time_indices) - self.system_model.do_modeling() - self.system_model.translate_to_modeling_language() - - self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) - return self.system_model - - def solve(self, solver: Solver, save_results: Union[bool, str, pathlib.Path] = False): - self._define_path_names(save_results) - t_start = timeit.default_timer() - solver.logfile_name = self._paths['log'] - self.system_model.solve(solver) - self.durations['solving'] = round(timeit.default_timer() - t_start, 2) - - if save_results: - self._save_solve_infos() - - -class AggregatedCalculation(Calculation): - """ - class for defined way of solving a flow_system optimization - """ - - def __init__( - self, - name, - flow_system: FlowSystem, - aggregation_parameters: AggregationParameters, - components_to_clusterize: Optional[List[Component]] = None, - modeling_language: Literal['pyomo', 'cvxpy'] = 'pyomo', - time_indices: Optional[Union[range, List[int]]] = None, - ): - """ - Class for Optimizing the FLowSystem including: - 1. Aggregating TimeSeriesData via typical periods using tsam. - 2. Equalizing variables of typical periods. - Parameters - ---------- - name : str - name of calculation - aggregation_parameters : AggregationParameters - Parameters for aggregation. See documentation of AggregationParameters class. - components_to_clusterize: List[Component] or None - List of Components to perform aggregation on. If None, then all components are aggregated. - This means, teh variables in the components are equalized to each other, according to the typical periods - computed in the DataAggregation - flow_system : FlowSystem - flow_system which should be calculated - modeling_language : 'pyomo','cvxpy' (not implemeted yet) - choose optimization modeling language - time_indices : List[int] or None - list with indices, which should be used for calculation. If None, then all timesteps are used. - """ - super().__init__(name, flow_system, modeling_language, time_indices) - self.aggregation_parameters = aggregation_parameters - self.components_to_clusterize = components_to_clusterize - self.time_series_for_aggregation = None - self.aggregation = None - self.time_series_collection: Optional[TimeSeriesCollection] = None - - def do_modeling(self) -> SystemModel: - self.flow_system.transform_data() - for time_series in self.flow_system.all_time_series: - time_series.activate_indices(self.time_indices) - - from .aggregation import Aggregation - - (chosen_time_series, chosen_time_series_with_end, dt_in_hours, dt_in_hours_total) = ( - self.flow_system.get_time_data_from_indices(self.time_indices) - ) - - t_start_agg = timeit.default_timer() - - # Validation - dt_min, dt_max = np.min(dt_in_hours), np.max(dt_in_hours) - if not dt_min == dt_max: - raise ValueError( - f'Aggregation failed due to inconsistent time step sizes:' - f'delta_t varies from {dt_min} to {dt_max} hours.' - ) - steps_per_period = self.aggregation_parameters.hours_per_period / dt_in_hours[0] - if not steps_per_period.is_integer(): - raise Exception( - f'The selected {self.aggregation_parameters.hours_per_period=} does not match the time ' - f'step size of {dt_in_hours[0]} hours). It must be a multiple of {dt_in_hours[0]} hours.' - ) - - logger.info(f'{"":#^80}') - logger.info(f'{" Aggregating TimeSeries Data ":#^80}') - - self.time_series_collection = TimeSeriesCollection( - [ts for ts in self.flow_system.all_time_series if ts.is_array] - ) - - import pandas as pd - - original_data = pd.DataFrame(self.time_series_collection.data, index=chosen_time_series) - - # Aggregation - creation of aggregated timeseries: - self.aggregation = Aggregation( - original_data=original_data, - hours_per_time_step=dt_min, - hours_per_period=self.aggregation_parameters.hours_per_period, - nr_of_periods=self.aggregation_parameters.nr_of_periods, - weights=self.time_series_collection.weights, - time_series_for_high_peaks=self.aggregation_parameters.labels_for_high_peaks, - time_series_for_low_peaks=self.aggregation_parameters.labels_for_low_peaks, - ) - - self.aggregation.cluster() - self.aggregation.plot() - if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: - self.time_series_collection.insert_data( # Converting it into a dict with labels as keys - { - col: np.array(values) - for col, values in self.aggregation.aggregated_data.to_dict(orient='list').items() - } - ) - self.durations['aggregation'] = round(timeit.default_timer() - t_start_agg, 2) - - # Model the System - t_start = timeit.default_timer() - - self.system_model = SystemModel(self.name, self.modeling_language, self.flow_system, self.time_indices) - self.system_model.do_modeling() - # Add Aggregation Model after modeling the rest - aggregation_model = AggregationModel( - self.aggregation_parameters, self.flow_system, self.aggregation, self.components_to_clusterize - ) - self.system_model.other_models.append(aggregation_model) - aggregation_model.do_modeling(self.system_model) - - self.system_model.translate_to_modeling_language() - - self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) - return self.system_model - - def solve(self, solver: Solver, save_results: Union[bool, str, pathlib.Path] = False): - self._define_path_names(save_results) - t_start = timeit.default_timer() - solver.logfile_name = self._paths['log'] - self.system_model.solve(solver) - self.durations['solving'] = round(timeit.default_timer() - t_start, 2) - - if save_results: - self._save_solve_infos() - - -class SegmentedCalculation(Calculation): - def __init__( - self, - name, - flow_system: FlowSystem, - segment_length: int, - overlap_length: int, - modeling_language: Literal['pyomo', 'cvxpy'] = 'pyomo', - time_indices: Optional[Union[range, list[int]]] = None, - ): - """ - Dividing and Modeling the problem in (overlapping) segments. - The final values of each Segment are recognized by the following segment, effectively coupling - charge_states and flow_rates between segments. - Because of this intersection, both modeling and solving is done in one step - - Take care: - Parameters like InvestParameters, sum_of_flow_hours and other restrictions over the total time_series - don't really work in this Calculation. Lower bounds to such SUMS can lead to weird results. - This is NOT yet explicitly checked for... - - Parameters - ---------- - name : str - name of calculation - flow_system : FlowSystem - flow_system which should be calculated - segment_length : int - The number of time_steps per individual segment (without the overlap) - overlap_length : int - The number of time_steps that are added to each individual model. Used for better - results of storages) - modeling_language : 'pyomo', 'cvxpy' (not implemeted yet) - choose optimization modeling language - time_indices : List[int] or None - list with indices, which should be used for calculation. If None, then all timesteps are used. - - """ - super().__init__(name, flow_system, modeling_language, time_indices) - self.segment_length = segment_length - self.overlap_length = overlap_length - self._total_length = len(self.time_indices) if self.time_indices is not None else len(flow_system.time_series) - self.number_of_segments = math.ceil(self._total_length / self.segment_length) - self.sub_calculations: List[FullCalculation] = [] - - assert segment_length > 2, 'The Segment length must be greater 2, due to unwanted internal side effects' - assert self.segment_length_with_overlap <= self._total_length, ( - f'{self.segment_length_with_overlap=} cant be greater than the total length {self._total_length}' - ) - - # Storing all original start values - self._original_start_values = { - **{flow: flow.previous_flow_rate for flow in self.flow_system.flows.values()}, - **{ - comp: comp.initial_charge_state - for comp in self.flow_system.components.values() - if isinstance(comp, Storage) - }, - } - self._transfered_start_values: Dict[str, Dict[str, Any]] = {} - - def do_modeling_and_solve(self, solver: Solver, save_results: Union[bool, str, pathlib.Path] = True): - logger.info(f'{"":#^80}') - logger.info(f'{" Segmented Solving ":#^80}') - self._define_path_names(save_results) - - for i in range(self.number_of_segments): - name_of_segment = f'Segment_{i + 1}' - if self.sub_calculations: - self._transfer_start_values(name_of_segment) - time_indices = self._get_indices(i) - logger.info(f'{name_of_segment}. (flow_system indices {time_indices.start}...{time_indices.stop - 1}):') - calculation = FullCalculation(name_of_segment, self.flow_system, self.modeling_language, time_indices) - # TODO: Add Before Values if available - self.sub_calculations.append(calculation) - calculation.do_modeling() - invest_elements = [ - model.element.label_full - for model in calculation.system_model.sub_models - if isinstance(model, InvestmentModel) - ] - if invest_elements: - logger.critical( - f'Investments are not supported in Segmented Calculation! ' - f'Following elements Contain Investments: {invest_elements}' - ) - calculation.solve(solver, save_results=False) - - self._reset_start_values() - - for calc in self.sub_calculations: - for key, value in calc.durations.items(): - self.durations[key] += value - - if save_results: - self._save_solve_infos() - - def results( - self, combined_arrays: bool = False, combined_scalars: bool = False, individual_results: bool = False - ) -> Dict[str, Union[Numeric, Dict[str, Numeric]]]: - """ - Retrieving the results of a Segmented Calculation is not as straight forward as with other Calculation types. - You have 3 options: - 1. combined_arrays: - Retrieve the combined array Results of all Segments as 'combined_arrays'. All result arrays ar concatenated, - taking care of removing the overlap. These results can be directly compared to other Calculation results. - Unfortunately, Scalar values like the total of effects can not be combined in a deterministic way. - Rather convert the time series effect results to a sum yourself. - 2. combined_scalars: - Retrieve the combined scalar Results of all Segments. All Scalar Values like the total of effects are - combined and stored in a List. Take care that the total of multiple Segment is not equivalent to the - total of the total timeSeries, as it includes the Overlap! - 3. individual_results: - Retrieve the individual results of each Segment - - """ - options_chosen = combined_arrays + combined_scalars + individual_results - assert options_chosen == 1, ( - 'Exactly one of the three options to retrieve the results needs to be chosen! You chose {options_chosen}!' - ) - all_results = {f'Segment_{i + 1}': calculation.results() for i, calculation in enumerate(self.sub_calculations)} - if combined_arrays: - return _combine_nested_arrays(*list(all_results.values()), length_per_array=self.segment_length) - elif combined_scalars: - return _combine_nested_scalars(*list(all_results.values())) - else: - return all_results - - def _save_solve_infos(self): - t_start = timeit.default_timer() - indent = 4 if len(self.flow_system.time_series) < 50 else None - with open(self._paths['results'], 'w', encoding='utf-8') as f: - results = copy_and_convert_datatypes( - self.results(combined_arrays=True), use_numpy=False, use_element_label=False - ) - json.dump(results, f, indent=indent) - - with open(self._paths['data'], 'w', encoding='utf-8') as f: - data = copy_and_convert_datatypes(self.flow_system.infos(), use_numpy=False, use_element_label=False) - json.dump(data, f, indent=indent) - - with open(self._paths['results'].parent / f'{self.name}_results_extra.json', 'w', encoding='utf-8') as f: - results = { - 'Individual Results': copy_and_convert_datatypes( - self.results(individual_results=True), use_numpy=False, use_element_label=False - ), - 'Skalar Results': copy_and_convert_datatypes( - self.results(combined_scalars=True), use_numpy=False, use_element_label=False - ), - } - json.dump(results, f, indent=indent) - self.durations['saving'] = round(timeit.default_timer() - t_start, 2) - - t_start = timeit.default_timer() - nodes_info, edges_info = self.flow_system.network_infos() - infos = { - 'Calculation': self.infos, - 'Model': self.sub_calculations[0].system_model.infos, - 'FlowSystem': get_compact_representation(self.flow_system.infos(use_numpy=True, use_element_label=True)), - 'Network': {'Nodes': nodes_info, 'Edges': edges_info}, - } - - with open(self._paths['infos'], 'w', encoding='utf-8') as f: - yaml.dump( - infos, - f, - width=1000, # Verhinderung Zeilenumbruch für lange equations - allow_unicode=True, - sort_keys=False, - ) - - message = f' Saved Calculation: {self.name} ' - logger.info(f'{"":#^80}\n{message:#^80}\n{"":#^80}') - logger.info(f'Saving calculation to .json took {self.durations["saving"]:>8.2f} seconds') - logger.info(f'Saving calculation to .yaml took {(timeit.default_timer() - t_start):>8.2f} seconds') - - def _transfer_start_values(self, segment_name: str): - """ - This function gets the last values of the previous solved segment and - inserts them as start values for the nest segment - """ - final_index_of_prior_segment = -(1 + self.overlap_length) - start_values_of_this_segment = {} - for flow in self.flow_system.flows.values(): - flow.previous_flow_rate = flow.model.flow_rate.result[ - final_index_of_prior_segment - ] # TODO: maybe more values? - start_values_of_this_segment[flow.label_full] = flow.previous_flow_rate - for comp in self.flow_system.components.values(): - if isinstance(comp, Storage): - comp.initial_charge_state = comp.model.charge_state.result[final_index_of_prior_segment] - start_values_of_this_segment[comp.label_full] = comp.initial_charge_state - - self._transfered_start_values[segment_name] = start_values_of_this_segment - - def _reset_start_values(self): - """This resets the start values of all Elements to its original state""" - for flow in self.flow_system.flows.values(): - flow.previous_flow_rate = self._original_start_values[flow] - for comp in self.flow_system.components.values(): - if isinstance(comp, Storage): - comp.initial_charge_state = self._original_start_values[comp] - - def _get_indices(self, segment_index: int) -> range: - start = segment_index * self.segment_length - return range(start, min(start + self.segment_length + self.overlap_length, self._total_length)) - - @property - def segment_length_with_overlap(self): - return self.segment_length + self.overlap_length - - @property - def start_values_of_segments(self) -> Dict[str, Dict[str, Any]]: - """Gives an overview of the start values of all Segments""" - return { - self.sub_calculations[0].name: { - element.label_full: value for element, value in self._original_start_values.items() - }, - **self._transfered_start_values, - } - - -def _remove_none_values(d: Dict[Any, Optional[Any]]) -> Dict[Any, Any]: - # Remove None values from a dictionary - return {k: _remove_none_values(v) if isinstance(v, dict) else v for k, v in d.items() if v is not None} - - -def _remove_empty_dicts(d: Dict[Any, Any]) -> Dict[Any, Any]: - """Recursively removes empty dictionaries from a nested dictionary.""" - return { - k: _remove_empty_dicts(v) if isinstance(v, dict) else v - for k, v in d.items() - if not isinstance(v, dict) or _remove_empty_dicts(v) - } - - -def _combine_nested_arrays( - *dicts: Dict[str, Union[Numeric, dict]], - trim: Optional[int] = None, - length_per_array: Optional[int] = None, -) -> Dict[str, Union[np.ndarray, dict]]: - """ - Combines multiple dictionaries with identical structures by concatenating their arrays, - with optional trimming. Filters out all other values. - - Parameters - ---------- - *dicts : Dict[str, Union[np.ndarray, dict]] - Dictionaries with matching structures and Numeric values. - trim : int, optional - Number of elements to trim from the end of each array except the last. Defaults to None. - length_per_array : int, optional - Trims the arrays to the desired length. Defaults to None. - If None, then trim is used. - - Returns - ------- - Dict[str, Union[np.ndarray, dict]] - A single dictionary with concatenated arrays at each key, ignoring non-array values. - - Example - ------- - >>> dict1 = {'a': np.array([1, 2, 3]), 'b': {'c': np.array([4, 5, 6])}} - >>> dict2 = {'a': np.array([7, 8, 9]), 'b': {'c': np.array([10, 11, 12])}} - >>> _combine_nested_arrays(dict1, dict2, trim=1) - {'a': array([1, 2, 7, 8, 9]), 'b': {'c': array([4, 5, 10, 11, 12])}} - """ - assert (trim is None) != (length_per_array is None), ( - 'Either trim or length_per_array must be provided,But not both!' - ) - - def combine_arrays_recursively( - *values: Union[Numeric, Dict[str, Numeric], Any], - ) -> Optional[Union[np.ndarray, Dict[str, Union[np.ndarray, dict]]]]: - if all(isinstance(val, dict) for val in values): # If all values are dictionaries, recursively combine each key - return {key: combine_arrays_recursively(*(val[key] for val in values)) for key in values[0]} - - if all(isinstance(val, np.ndarray) for val in values): - - def limit(idx: int, arr: np.ndarray) -> np.ndarray: - # Performs the trimming of the arrays. Doesn't trim the last array! - if trim and idx < len(values) - 1: - return arr[:-trim] - elif length_per_array and idx < len(values) - 1: - return arr[:length_per_array] - return arr - - values: List[np.ndarray] - return np.concatenate([limit(idx, arr) for idx, arr in enumerate(values)]) - - else: # Ignore non-array values - return None - - combined_arrays = combine_arrays_recursively(*dicts) - combined_arrays = _remove_none_values(combined_arrays) - return _remove_empty_dicts(combined_arrays) - - -def _combine_nested_scalars(*dicts: Dict[str, Union[Numeric, dict]]) -> Dict[str, Union[List[Skalar], dict]]: - """ - Combines multiple dictionaries with identical structures by combining its skalar values to a list. - Filters out all other values. - - Parameters - ---------- - *dicts : Dict[str, Union[np.ndarray, dict]] - Dictionaries with matching structures and Numeric values. - """ - - def combine_scalars_recursively( - *values: Union[Numeric, Dict[str, Numeric], Any], - ) -> Optional[Union[List[Skalar], Dict[str, Union[List[Skalar], dict]]]]: - # If all values are dictionaries, recursively combine each key - if all(isinstance(val, dict) for val in values): - return {key: combine_scalars_recursively(*(val[key] for val in values)) for key in values[0]} - - # Concatenate arrays with optional trimming - if all(np.isscalar(val) for val in values): - return [val for val in values] - else: # Ignore non-skalar values - return None - - combined_scalars = combine_scalars_recursively(*dicts) - combined_scalars = _remove_none_values(combined_scalars) - return _remove_empty_dicts(combined_scalars) diff --git a/flixOpt/components.py b/flixOpt/components.py deleted file mode 100644 index c71655173..000000000 --- a/flixOpt/components.py +++ /dev/null @@ -1,614 +0,0 @@ -""" -This module contains the basic components of the flixOpt framework. -""" - -import logging -from typing import Dict, List, Literal, Optional, Set, Tuple, Union - -import numpy as np - -from . import utils -from .core import Numeric, Numeric_TS, Skalar, TimeSeries -from .elements import Component, ComponentModel, Flow, _create_time_series -from .features import InvestmentModel, MultipleSegmentsModel, OnOffModel -from .interface import InvestParameters, OnOffParameters -from .math_modeling import Equation, VariableTS -from .structure import SystemModel, create_equation, create_variable - -logger = logging.getLogger('flixOpt') - - -class LinearConverter(Component): - """ - Converts one FLow into another via linear conversion factors - """ - - def __init__( - self, - label: str, - inputs: List[Flow], - outputs: List[Flow], - on_off_parameters: OnOffParameters = None, - conversion_factors: List[Dict[Flow, Numeric_TS]] = None, - segmented_conversion_factors: Dict[Flow, List[Tuple[Numeric_TS, Numeric_TS]]] = None, - meta_data: Optional[Dict] = None, - ): - """ - Parameters - ---------- - label : str - name. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - inputs : input flows. - outputs : output flows. - on_off_parameters: Information about on and off states. See class OnOffParameters. - conversion_factors : linear relation between flows. - Either 'conversion_factors' or 'segmented_conversion_factors' can be used! - example heat pump: - segmented_conversion_factors : Segmented linear relation between flows. - Each Flow gets a List of Segments assigned. - If FLows need to be 0 (or Off), include a "Zero-Segment" "(0, 0)", or use on_off_parameters - Either 'segmented_conversion_factors' or 'conversion_factors' can be used! - --> "gaps" can be expressed by a segment not starting at the end of the prior segment : [(1,3), (4,5)] - --> "points" can expressed as segment with same begin and end : [(3,3), (4,4)] - - """ - super().__init__(label, inputs, outputs, on_off_parameters, meta_data=meta_data) - self.conversion_factors = conversion_factors or [] - self.segmented_conversion_factors = segmented_conversion_factors or {} - self._plausibility_checks() - - def create_model(self) -> 'LinearConverterModel': - self.model = LinearConverterModel(self) - return self.model - - def _plausibility_checks(self) -> None: - if not self.conversion_factors and not self.segmented_conversion_factors: - raise Exception('Either conversion_factors or segmented_conversion_factors must be defined!') - if self.conversion_factors and self.segmented_conversion_factors: - raise Exception('Only one of conversion_factors or segmented_conversion_factors can be defined, not both!') - - if self.conversion_factors: - if self.degrees_of_freedom <= 0: - raise Exception( - f'Too Many conversion_factors_specified. Care that you use less conversion_factors ' - f'then inputs + outputs!! With {len(self.inputs + self.outputs)} inputs and outputs, ' - f'use not more than {len(self.inputs + self.outputs) - 1} conversion_factors!' - ) - - for conversion_factor in self.conversion_factors: - for flow in conversion_factor: - if flow not in (self.inputs + self.outputs): - raise Exception( - f'{self.label}: Flow {flow.label} in conversion_factors is not in inputs/outputs' - ) - if self.segmented_conversion_factors: - for flow in self.inputs + self.outputs: - if isinstance(flow.size, InvestParameters) and flow.size.fixed_size is None: - raise Exception( - f'segmented_conversion_factors (in {self.label_full}) and variable size ' - f'(in flow {flow.label_full}) do not make sense together!' - ) - - def transform_data(self): - super().transform_data() - if self.conversion_factors: - self.conversion_factors = self._transform_conversion_factors() - else: - segmented_conversion_factors = {} - for flow, segments in self.segmented_conversion_factors.items(): - segmented_conversion_factors[flow] = [ - ( - _create_time_series('Stuetzstelle', segment[0], self), - _create_time_series('Stuetzstelle', segment[1], self), - ) - for segment in segments - ] - self.segmented_conversion_factors = segmented_conversion_factors - - def _transform_conversion_factors(self) -> List[Dict[Flow, TimeSeries]]: - """macht alle Faktoren, die nicht TimeSeries sind, zu TimeSeries""" - list_of_conversion_factors = [] - for conversion_factor in self.conversion_factors: - transformed_dict = {} - for flow, values in conversion_factor.items(): - transformed_dict[flow] = _create_time_series(f'{flow.label}_factor', values, self) - list_of_conversion_factors.append(transformed_dict) - return list_of_conversion_factors - - @property - def degrees_of_freedom(self): - return len(self.inputs + self.outputs) - len(self.conversion_factors) - - -class Storage(Component): - """ - Klasse Storage - """ - - # TODO: Dabei fƤllt mir auf. Vielleicht sollte man mal überlegen, ob man für Ladeleistungen bereits in dem - # jeweiligen Zeitschritt mit einem Verlust berücksichtigt. Zumindest für große Zeitschritte bzw. große Verluste - # eventuell relevant. - # -> Sprich: speicherverlust = charge_state(t) * relative_loss_per_hour * dt + 0.5 * Q_lade(t) * dt * relative_loss_per_hour * dt - # -> müsste man aber auch für den sich Ƥndernden Ladezustand berücksichtigten - - def __init__( - self, - label: str, - charging: Flow, - discharging: Flow, - capacity_in_flow_hours: Union[Skalar, InvestParameters], - relative_minimum_charge_state: Numeric = 0, - relative_maximum_charge_state: Numeric = 1, - initial_charge_state: Optional[Union[Skalar, Literal['lastValueOfSim']]] = 0, - minimal_final_charge_state: Optional[Skalar] = None, - maximal_final_charge_state: Optional[Skalar] = None, - eta_charge: Numeric = 1, - eta_discharge: Numeric = 1, - relative_loss_per_hour: Numeric = 0, - prevent_simultaneous_charge_and_discharge: bool = True, - meta_data: Optional[Dict] = None, - ): - """ - constructor of storage - - Parameters - ---------- - label : str - description. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - charging : Flow - ingoing flow. - discharging : Flow - outgoing flow. - capacity_in_flow_hours : Skalar or InvestParameter - nominal capacity of the storage - relative_minimum_charge_state : float or TS, optional - minimum relative charge state. The default is 0. - relative_maximum_charge_state : float or TS, optional - maximum relative charge state. The default is 1. - initial_charge_state : None, float (0...1), 'lastValueOfSim', optional - storage charge_state at the beginning. The default is 0. - float: defined charge_state at start of first timestep - None: free to choose by optimizer - 'lastValueOfSim': chargeState0 is equal to chargestate of last timestep ("closed simulation") - minimal_final_charge_state : float or None, optional - minimal value of chargeState at the end of timeseries. - maximal_final_charge_state : float or None, optional - maximal value of chargeState at the end of timeseries. - eta_charge : float, optional - efficiency factor of charging/loading. The default is 1. - eta_discharge : TYPE, optional - efficiency factor of uncharging/unloading. The default is 1. - relative_loss_per_hour : float or TS. optional - loss per chargeState-Unit per hour. The default is 0. - prevent_simultaneous_charge_and_discharge : boolean, optional - should simultaneously Loading and Unloading be avoided? (Attention, Performance maybe becomes worse with avoidInAndOutAtOnce=True). The default is True. - """ - # TODO: fixed_relative_chargeState implementieren - super().__init__( - label, - inputs=[charging], - outputs=[discharging], - prevent_simultaneous_flows=[charging, discharging] if prevent_simultaneous_charge_and_discharge else None, - meta_data=meta_data, - ) - - self.charging = charging - self.discharging = discharging - self.capacity_in_flow_hours = capacity_in_flow_hours - self.relative_minimum_charge_state: Numeric_TS = relative_minimum_charge_state - self.relative_maximum_charge_state: Numeric_TS = relative_maximum_charge_state - - self.initial_charge_state = initial_charge_state - self.minimal_final_charge_state = minimal_final_charge_state - self.maximal_final_charge_state = maximal_final_charge_state - - self.eta_charge: Numeric_TS = eta_charge - self.eta_discharge: Numeric_TS = eta_discharge - self.relative_loss_per_hour: Numeric_TS = relative_loss_per_hour - - def create_model(self) -> 'StorageModel': - self.model = StorageModel(self) - return self.model - - def transform_data(self) -> None: - super().transform_data() - self.relative_minimum_charge_state = _create_time_series( - 'relative_minimum_charge_state', self.relative_minimum_charge_state, self - ) - self.relative_maximum_charge_state = _create_time_series( - 'relative_maximum_charge_state', self.relative_maximum_charge_state, self - ) - self.eta_charge = _create_time_series('eta_charge', self.eta_charge, self) - self.eta_discharge = _create_time_series('eta_discharge', self.eta_discharge, self) - self.relative_loss_per_hour = _create_time_series('relative_loss_per_hour', self.relative_loss_per_hour, self) - if isinstance(self.capacity_in_flow_hours, InvestParameters): - self.capacity_in_flow_hours.transform_data() - - -class Transmission(Component): - # TODO: automatic on-Value in Flows if loss_abs - # TODO: loss_abs must be: investment_size * loss_abs_rel!!! - # TODO: investmentsize only on 1 flow - # TODO: automatic investArgs for both in-flows (or alternatively both out-flows!) - # TODO: optional: capacities should be recognised for losses - - def __init__( - self, - label: str, - in1: Flow, - out1: Flow, - in2: Optional[Flow] = None, - out2: Optional[Flow] = None, - relative_losses: Optional[Numeric_TS] = None, - absolute_losses: Optional[Numeric_TS] = None, - on_off_parameters: OnOffParameters = None, - prevent_simultaneous_flows_in_both_directions: bool = True, - ): - """ - Initializes a Transmission component (Pipe, cable, ...) that models the flows between two sides - with potential losses. - - Parameters - ---------- - label : str - The name of the transmission component. - in1 : Flow - The inflow at side A. Pass InvestmentParameters here. - out1 : Flow - The outflow at side B. - in2 : Optional[Flow], optional - The optional inflow at side B. - If in1 got Investmentparameters, the size of this Flow will be equal to in1 (with no extra effects!) - out2 : Optional[Flow], optional - The optional outflow at side A. - relative_losses : Optional[Numeric_TS], optional - The relative loss between inflow and outflow, e.g., 0.02 for 2% loss. - absolute_losses : Optional[Numeric_TS], optional - The absolute loss, occur only when the Flow is on. Induces the creation of the ON-Variable - on_off_parameters : OnOffParameters, optional - Parameters defining the on/off behavior of the component. - prevent_simultaneous_flows_in_both_directions : bool, default=True - If True, prevents simultaneous flows in both directions. - """ - super().__init__( - label, - inputs=[flow for flow in (in1, in2) if flow is not None], - outputs=[flow for flow in (out1, out2) if flow is not None], - on_off_parameters=on_off_parameters, - prevent_simultaneous_flows=None - if in2 is None or prevent_simultaneous_flows_in_both_directions is False - else [in1, in2], - ) - self.in1 = in1 - self.out1 = out1 - self.in2 = in2 - self.out2 = out2 - - self.relative_losses = relative_losses - self.absolute_losses = absolute_losses - - def _plausibility_checks(self): - # check buses: - if self.in2 is not None: - assert self.in2.bus == self.out1.bus, ( - f'Output 1 and Input 2 do not start/end at the same Bus: {self.out1.bus=}, {self.in2.bus=}' - ) - if self.out2 is not None: - assert self.out2.bus == self.in1.bus, ( - f'Input 1 and Output 2 do not start/end at the same Bus: {self.in1.bus=}, {self.out2.bus=}' - ) - # Check Investments - for flow in [self.out1, self.in2, self.out2]: - if flow is not None and isinstance(flow.size, InvestParameters): - raise ValueError( - 'Transmission currently does not support separate InvestParameters for Flows. ' - 'Please use Flow in1. The size of in2 is equal to in1. THis is handled internally' - ) - - def create_model(self) -> 'TransmissionModel': - self.model = TransmissionModel(self) - return self.model - - def transform_data(self) -> None: - super().transform_data() - self.relative_losses = _create_time_series('relative_losses', self.relative_losses, self) - self.absolute_losses = _create_time_series('absolute_losses', self.absolute_losses, self) - - -class TransmissionModel(ComponentModel): - def __init__(self, element: Transmission): - super().__init__(element) - self.element: Transmission = element - self._on: Optional[OnOffModel] = None - - def do_modeling(self, system_model: SystemModel): - """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.active_data != 0): - for flow in self.element.inputs + self.element.outputs: - if flow.on_off_parameters is None: - flow.on_off_parameters = OnOffParameters() - - # Make sure either None or both in Flows have InvestParameters - if self.element.in2 is not None: - if isinstance(self.element.in1.size, InvestParameters) and not isinstance( - self.element.in2.size, InvestParameters - ): - self.element.in2.size = InvestParameters(maximum_size=self.element.in1.size.maximum_size) - - super().do_modeling(system_model) - - # first direction - self.create_transmission_equation('direction_1', self.element.in1, self.element.out1) - - # second direction: - if self.element.in2 is not None: - self.create_transmission_equation('direction_2', self.element.in2, self.element.out2) - - # equate size of both directions - if isinstance(self.element.in1.size, InvestParameters) and self.element.in2 is not None: - # eq: in1.size = in2.size - eq_equal_size = create_equation('equal_size_in_both_directions', self, 'eq') - eq_equal_size.add_summand(self.element.in1.model._investment.size, 1) - eq_equal_size.add_summand(self.element.in2.model._investment.size, -1) - - def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) -> Equation: - """Creates an Equation for the Transmission efficiency and adds it to the model""" - # first direction - # eq: in(t)*(1-loss_rel(t)) = out(t) + on(t)*loss_abs(t) - eq_transmission = create_equation(name, self, 'eq') - efficiency = 1 if self.element.relative_losses is None else (1 - self.element.relative_losses.active_data) - eq_transmission.add_summand(in_flow.model.flow_rate, efficiency) - eq_transmission.add_summand(out_flow.model.flow_rate, -1) - if self.element.absolute_losses is not None: - eq_transmission.add_summand(in_flow.model._on.on, -1 * self.element.absolute_losses.active_data) - return eq_transmission - - -class LinearConverterModel(ComponentModel): - def __init__(self, element: LinearConverter): - super().__init__(element) - self.element: LinearConverter = element - self._on: Optional[OnOffModel] = None - - def do_modeling(self, system_model: SystemModel): - super().do_modeling(system_model) - - # conversion_factors: - if self.element.conversion_factors: - all_input_flows = set(self.element.inputs) - all_output_flows = set(self.element.outputs) - - # für alle linearen Gleichungen: - for i, conversion_factor in enumerate(self.element.conversion_factors): - # erstelle Gleichung für jedes t: - # sum(inputs * factor) = sum(outputs * factor) - # left = in1.flow_rate[t] * factor_in1[t] + in2.flow_rate[t] * factor_in2[t] + ... - # right = out1.flow_rate[t] * factor_out1[t] + out2.flow_rate[t] * factor_out2[t] + ... - # eq: left = right - used_flows = set(conversion_factor.keys()) - used_inputs: Set = all_input_flows & used_flows - used_outputs: Set = all_output_flows & used_flows - - eq_conversion = create_equation(f'conversion_{i}', self) - for flow in used_inputs: - factor = conversion_factor[flow].active_data - eq_conversion.add_summand(flow.model.flow_rate, factor) # flow1.flow_rate[t] * factor[t] - for flow in used_outputs: - factor = conversion_factor[flow].active_data - eq_conversion.add_summand(flow.model.flow_rate, -1 * factor) # output.val[t] * -1 * factor[t] - - eq_conversion.add_constant(0) # TODO: Is this necessary? - - # (linear) segments: - else: - # TODO: Improve Inclusion of OnOffParameters. Instead of creating a Binary in every flow, the binary could only be part of the Segment itself - segments = { - flow.model.flow_rate: [ - (ts1.active_data, ts2.active_data) for ts1, ts2 in self.element.segmented_conversion_factors[flow] - ] - for flow in self.element.inputs + self.element.outputs - } - linear_segments = MultipleSegmentsModel( - self.element, segments, self._on.on if self._on is not None else None - ) # TODO: Add Outside_segments Variable (On) - linear_segments.do_modeling(system_model) - self.sub_models.append(linear_segments) - - -class StorageModel(ComponentModel): - """Model of Storage""" - - # TODO: Add additional Timestep!!! - def __init__(self, element: Storage): - super().__init__(element) - self.element: Storage = element - self.charge_state: Optional[VariableTS] = None - self.netto_discharge: Optional[VariableTS] = None - self._investment: Optional[InvestmentModel] = None - - def do_modeling(self, system_model): - super().do_modeling(system_model) - - lb, ub = self.absolute_charge_state_bounds - self.charge_state = create_variable( - 'charge_state', self, system_model.nr_of_time_steps + 1, lower_bound=lb, upper_bound=ub - ) - - self.netto_discharge = create_variable( - 'netto_discharge', self, system_model.nr_of_time_steps, lower_bound=-np.inf - ) # negative Werte zulƤssig! - - # netto_discharge: - # eq: nettoFlow(t) - discharging(t) + charging(t) = 0 - eq_netto = create_equation('netto_discharge', self, eq_type='eq') - eq_netto.add_summand(self.netto_discharge, 1) - eq_netto.add_summand(self.element.charging.model.flow_rate, 1) - eq_netto.add_summand(self.element.discharging.model.flow_rate, -1) - - indices_charge_state = range(system_model.indices.start, system_model.indices.stop + 1) # additional - - ############# Charge State Equation - # charge_state(n+1) - # + charge_state(n) * [relative_loss_per_hour * dt(n) - 1] - # - charging(n) * eta_charge * dt(n) - # + discharging(n) * 1 / eta_discharge * dt(n) - # = 0 - eq_charge_state = create_equation('charge_state', self, eq_type='eq') - eq_charge_state.add_summand(self.charge_state, 1, indices_charge_state[1:]) # 1:end - eq_charge_state.add_summand( - self.charge_state, - (self.element.relative_loss_per_hour.active_data * system_model.dt_in_hours) - 1, - indices_charge_state[:-1], - ) # sprich 0 .. end-1 % nach letztem Zeitschritt gibt es noch einen weiteren Ladezustand! - eq_charge_state.add_summand( - self.element.charging.model.flow_rate, -1 * self.element.eta_charge.active_data * system_model.dt_in_hours - ) - eq_charge_state.add_summand( - self.element.discharging.model.flow_rate, - 1 / self.element.eta_discharge.active_data * system_model.dt_in_hours, - ) - - if isinstance(self.element.capacity_in_flow_hours, InvestParameters): - self._investment = InvestmentModel( - self.element, self.element.capacity_in_flow_hours, self.charge_state, self.relative_charge_state_bounds - ) - self.sub_models.append(self._investment) - self._investment.do_modeling(system_model) - - # Initial charge state - if self.element.initial_charge_state is not None: - self._model_initial_and_final_charge_state(system_model) - - def _model_initial_and_final_charge_state(self, system_model): - indices_charge_state = range(system_model.indices.start, system_model.indices.stop + 1) # additional - - if self.element.initial_charge_state is not None: - eq_initial = create_equation('initial_charge_state', self, eq_type='eq') - if utils.is_number(self.element.initial_charge_state): - # eq: Q_Ladezustand(1) = Q_Ladezustand_Start; - eq_initial.add_constant(self.element.initial_charge_state) # chargeState_0 ! - eq_initial.add_summand(self.charge_state, 1, system_model.indices[0]) - elif self.element.initial_charge_state == 'lastValueOfSim': - # eq: Q_Ladezustand(1) - Q_Ladezustand(end) = 0; - eq_initial.add_summand(self.charge_state, 1, system_model.indices[0]) - eq_initial.add_summand(self.charge_state, -1, system_model.indices[-1]) - else: - raise Exception(f'initial_charge_state has undefined value: {self.element.initial_charge_state}') - # TODO: Validation in Storage Class, not in Model - - #################################### - # Final Charge State - # 1: eq: Q_charge_state(end) <= Q_max - if self.element.maximal_final_charge_state is not None: - eq_max = create_equation('eq_final_charge_state_max', self, eq_type='ineq') - eq_max.add_summand(self.charge_state, 1, indices_charge_state[-1]) - eq_max.add_constant(self.element.maximal_final_charge_state) - - # 2: eq: - Q_charge_state(end) <= - Q_min - if self.element.minimal_final_charge_state is not None: - eq_min = create_equation('eq_charge_state_end_min', self, eq_type='ineq') - eq_min.add_summand(self.charge_state, -1, indices_charge_state[-1]) - eq_min.add_constant(-self.element.minimal_final_charge_state) - - @property - def absolute_charge_state_bounds(self) -> Tuple[Numeric, Numeric]: - 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, - relative_upper_bound * self.element.capacity_in_flow_hours, - ) - else: - return ( - relative_lower_bound * self.element.capacity_in_flow_hours.minimum_size, - relative_upper_bound * self.element.capacity_in_flow_hours.maximum_size, - ) - - @property - def relative_charge_state_bounds(self) -> Tuple[Numeric, Numeric]: - return ( - self.element.relative_minimum_charge_state.active_data, - self.element.relative_maximum_charge_state.active_data, - ) - - -class SourceAndSink(Component): - """ - class for source (output-flow) and sink (input-flow) in one commponent - """ - - # source : Flow - # sink : Flow - - def __init__( - self, - label: str, - source: Flow, - sink: Flow, - prevent_simultaneous_flows: bool = True, - meta_data: Optional[Dict] = None, - ): - """ - Parameters - ---------- - label : str - name of sourceAndSink - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - source : Flow - output-flow of this component - sink : Flow - input-flow of this component - prevent_simultaneous_flows: boolean. Default ist True. - True: inflow and outflow are not allowed to be both non-zero at same timestep. - False: inflow and outflow are working independently. - - """ - super().__init__( - label, - inputs=[sink], - outputs=[source], - prevent_simultaneous_flows=[sink, source] if prevent_simultaneous_flows is True else None, - meta_data=meta_data, - ) - self.source = source - self.sink = sink - - -class Source(Component): - def __init__(self, label: str, source: Flow, meta_data: Optional[Dict] = None): - """ - Parameters - ---------- - label : str - name of source - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - source : Flow - output-flow of source - """ - super().__init__(label, outputs=[source], meta_data=meta_data) - self.source = source - - -class Sink(Component): - def __init__(self, label: str, sink: Flow, meta_data: Optional[Dict] = None): - """ - constructor of sink - - Parameters - ---------- - label : str - name of sink. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - sink : Flow - input-flow of sink - """ - super().__init__(label, inputs=[sink], meta_data=meta_data) - self.sink = sink diff --git a/flixOpt/core.py b/flixOpt/core.py deleted file mode 100644 index 9ee489cda..000000000 --- a/flixOpt/core.py +++ /dev/null @@ -1,182 +0,0 @@ -""" -This module contains the core functionality of the flixOpt framework. -It provides Datatypes, logging functionality, and some functions to transform data structures. -""" - -import inspect -import logging -from typing import Any, Dict, List, Optional, Union - -import numpy as np - -from . import utils - -logger = logging.getLogger('flixOpt') - -Skalar = Union[int, float] # Datatype -Numeric = Union[int, float, np.ndarray] # Datatype - - -class TimeSeriesData: - # TODO: Move to Interface.py - def __init__(self, data: Numeric, agg_group: Optional[str] = None, agg_weight: Optional[float] = None): - """ - timeseries class for transmit timeseries AND special characteristics of timeseries, - i.g. to define weights needed in calculation_type 'aggregated' - EXAMPLE solar: - you have several solar timeseries. These should not be overweighted - compared to the remaining timeseries (i.g. heat load, price)! - fixed_relative_profile_solar1 = TimeSeriesData(sol_array_1, type = 'solar') - fixed_relative_profile_solar2 = TimeSeriesData(sol_array_2, type = 'solar') - fixed_relative_profile_solar3 = TimeSeriesData(sol_array_3, type = 'solar') - --> this 3 series of same type share one weight, i.e. internally assigned each weight = 1/3 - (instead of standard weight = 1) - - Parameters - ---------- - data : Union[int, float, np.ndarray] - The timeseries data, which can be a scalar, array, or numpy array. - agg_group : str, optional - The group this TimeSeriesData is a part of. agg_weight is split between members of a group. Default is None. - agg_weight : float, optional - The weight for calculation_type 'aggregated', should be between 0 and 1. Default is None. - - Raises - ------ - Exception - If both agg_group and agg_weight are set, an exception is raised. - """ - self.data = data - self.agg_group = agg_group - self.agg_weight = agg_weight - if (agg_group is not None) and (agg_weight is not None): - raise Exception('Either or explicit can be used. Not both!') - self.label: Optional[str] = None - - def __repr__(self): - # Get the constructor arguments and their current values - init_signature = inspect.signature(self.__init__) - init_args = init_signature.parameters - - # Create a dictionary with argument names and their values - args_str = ', '.join(f'{name}={repr(getattr(self, name, None))}' for name in init_args if name != 'self') - return f'{self.__class__.__name__}({args_str})' - - def __str__(self): - return str(self.data) - - -Numeric_TS = Union[ - Skalar, np.ndarray, TimeSeriesData -] # TODO: This is not really correct throughozt the codebase. Sometimes its used for TimeSeries aswell? - - -class TimeSeries: - """ - Class for data that applies to time series, stored as vector (np.ndarray) or scalar. - - This class represents a vector or scalar value that makes the handling of time series easier. - It supports various operations such as activation of specific time indices, setting explicit active data, and - aggregation weight management. - - Attributes - ---------- - label : str - The label for the time series. - data : Optional[Numeric] - The actual data for the time series. Can be None. - aggregated_data : Optional[Numeric] - aggregated_data to use instead of data if provided. - active_indices : Optional[np.ndarray] - Indices of the time steps to activate. - aggregation_weight : float - Weight for aggregation method, between 0 and 1, normally 1. - aggregation_group : str - Group for calculating the aggregation weigth for aggregation method. - """ - - def __init__(self, label: str, data: Optional[Numeric_TS]): - self.label: str = label - if isinstance(data, TimeSeriesData): - self.data = self.make_scalar_if_possible(data.data) - self.aggregation_weight, self.aggregation_group = data.agg_weight, data.agg_group - data.label = self.label # Connecting User_time_series to real Time_series - else: - self.data = self.make_scalar_if_possible(data) - self.aggregation_weight, self.aggregation_group = None, None - - self.active_indices: Optional[Union[range, List[int]]] = None - self.aggregated_data: Optional[Numeric] = None - - def activate_indices(self, indices: Optional[Union[range, List[int]]], aggregated_data: Optional[Numeric] = None): - self.active_indices = indices - - if aggregated_data is not None: - assert len(aggregated_data) == len(self.active_indices) or len(aggregated_data) == 1, ( - f'The aggregated_data has the wrong length for TimeSeries {self.label}. ' - f'Length should be: {len(self.active_indices)} or 1, but is {len(aggregated_data)}' - ) - self.aggregated_data = self.make_scalar_if_possible(aggregated_data) - - def clear_indices_and_aggregated_data(self): - self.active_indices = None - self.aggregated_data = None - - @property - def active_data(self) -> Numeric: - if self.aggregated_data is not None: # Aggregated data is always active, if present - return self.aggregated_data - - indices_not_applicable = np.isscalar(self.data) or (self.data is None) or (self.active_indices is None) - if indices_not_applicable: - return self.data - else: - return self.data[self.active_indices] - - @property - def active_data_vector(self) -> np.ndarray: - # Always returns the active data as a vector. - return utils.as_vector(self.active_data, len(self.active_indices)) - - @property - def is_scalar(self) -> bool: - return np.isscalar(self.data) - - @property - def is_array(self) -> bool: - return not self.is_scalar and self.data is not None - - def __repr__(self): - # Retrieve all attributes and their values - attrs = vars(self) - # Format each attribute as 'key=value' - attrs_str = ', '.join(f'{key}={value!r}' for key, value in attrs.items()) - # Format the output as 'ClassName(attr1=value1, attr2=value2, ...)' - return f'{self.__class__.__name__}({attrs_str})' - - def __str__(self): - return str(self.active_data) - - @staticmethod - def make_scalar_if_possible(data: Optional[Numeric]) -> Optional[Numeric]: - """ - Convert an array to a scalar if all values are equal, or return the array as-is. - Can Return None if the passed data is None - - Parameters - ---------- - data : Numeric, None - The data to process. - - Returns - ------- - Numeric - A scalar if all values in the array are equal, otherwise the array itself. None, if the passed value is None - """ - # TODO: Should this really return None Values? - if np.isscalar(data) or data is None: - return data - data = np.array(data) - if np.all(data == data[0]): - return data[0] - return data diff --git a/flixOpt/effects.py b/flixOpt/effects.py deleted file mode 100644 index 1b359b874..000000000 --- a/flixOpt/effects.py +++ /dev/null @@ -1,410 +0,0 @@ -""" -This module contains the effects of the flixOpt framework. -Furthermore, it contains the EffectCollection, which is used to collect all effects of a system. -Different Datatypes are used to represent the effects with assigned values by the user, -which are then transformed into the internal data structure. -""" - -import logging -from typing import Dict, Literal, Optional, Union - -import numpy as np - -from .core import Numeric, Numeric_TS, Skalar, TimeSeries -from .features import ShareAllocationModel -from .math_modeling import Equation, Variable -from .structure import Element, ElementModel, SystemModel, _create_time_series - -logger = logging.getLogger('flixOpt') - - -class Effect(Element): - """ - Effect, i.g. costs, CO2 emissions, area, ... - Components, FLows, and so on can contribute to an Effect. One Effect is chosen as the Objective of the Optimization - """ - - def __init__( - self, - label: str, - unit: str, - description: str, - meta_data: Optional[Dict] = None, - is_standard: bool = False, - is_objective: bool = False, - specific_share_to_other_effects_operation: 'EffectValues' = None, - specific_share_to_other_effects_invest: 'EffectValuesInvest' = None, - minimum_operation: Optional[Skalar] = None, - maximum_operation: Optional[Skalar] = None, - minimum_invest: Optional[Skalar] = None, - maximum_invest: Optional[Skalar] = None, - minimum_operation_per_hour: Optional[Numeric_TS] = None, - maximum_operation_per_hour: Optional[Numeric_TS] = None, - minimum_total: Optional[Skalar] = None, - maximum_total: Optional[Skalar] = None, - ): - """ - Parameters - ---------- - label : str - name - unit : str - unit of effect, i.g. €, kg_CO2, kWh_primaryEnergy - description : str - long name - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - is_standard : boolean, optional - true, if Standard-Effect (for direct input of value without effect (alternatively to dict)) , else false - is_objective : boolean, optional - true, if optimization target - specific_share_to_other_effects_operation : {effectType: TS, ...}, i.g. 180 €/t_CO2, input as {costs: 180}, optional - share to other effects (only operation) - specific_share_to_other_effects_invest : {effectType: TS, ...}, i.g. 180 €/t_CO2, input as {costs: 180}, optional - share to other effects (only invest). - minimum_operation : scalar, optional - minimal sum (only operation) of the effect - maximum_operation : scalar, optional - maximal sum (nur operation) of the effect. - minimum_operation_per_hour : scalar or TS - maximum value per hour (only operation) of effect (=sum of all effect-shares) for each timestep! - maximum_operation_per_hour : scalar or TS - minimum value per hour (only operation) of effect (=sum of all effect-shares) for each timestep! - minimum_invest : scalar, optional - minimal sum (only invest) of the effect - maximum_invest : scalar, optional - maximal sum (only invest) of the effect - minimum_total : sclalar, optional - min sum of effect (invest+operation). - maximum_total : scalar, optional - max sum of effect (invest+operation). - **kwargs : TYPE - DESCRIPTION. - - Returns - ------- - None. - - """ - super().__init__(label, meta_data=meta_data) - self.label = label - self.unit = unit - self.description = description - self.is_standard = is_standard - self.is_objective = is_objective - self.specific_share_to_other_effects_operation: Union[EffectValues, EffectTimeSeries] = ( - specific_share_to_other_effects_operation or {} - ) - self.specific_share_to_other_effects_invest: Union[EffectValuesInvest, EffectDictInvest] = ( - specific_share_to_other_effects_invest or {} - ) - self.minimum_operation = minimum_operation - self.maximum_operation = maximum_operation - self.minimum_operation_per_hour: Numeric_TS = minimum_operation_per_hour - self.maximum_operation_per_hour: Numeric_TS = maximum_operation_per_hour - self.minimum_invest = minimum_invest - self.maximum_invest = maximum_invest - self.minimum_total = minimum_total - self.maximum_total = maximum_total - - self._plausibility_checks() - - def _plausibility_checks(self) -> None: - # Check circular loops in effects: (Effekte fügen sich gegenseitig Shares hinzu): - # TODO: Improve checks!! Only most basic case covered... - - def error_str(effect_label: str, share_ffect_label: str): - return ( - f' {effect_label} -> has share in: {share_ffect_label}\n' - f' {share_ffect_label} -> has share in: {effect_label}' - ) - - # Effekt darf nicht selber als Share in seinen ShareEffekten auftauchen: - # operation: - for target_effect in self.specific_share_to_other_effects_operation.keys(): - assert self not in target_effect.specific_share_to_other_effects_operation.keys(), ( - f'Error: circular operation-shares \n{error_str(target_effect.label, target_effect.label)}' - ) - # invest: - for target_effect in self.specific_share_to_other_effects_invest.keys(): - assert self not in target_effect.specific_share_to_other_effects_invest.keys(), ( - f'Error: circular invest-shares \n{error_str(target_effect.label, target_effect.label)}' - ) - - def transform_data(self): - self.minimum_operation_per_hour = _create_time_series( - 'minimum_operation_per_hour', self.minimum_operation_per_hour, self - ) - self.maximum_operation_per_hour = _create_time_series( - 'maximum_operation_per_hour', self.maximum_operation_per_hour, self - ) - - self.specific_share_to_other_effects_operation = effect_values_to_time_series( - 'specific_share_to_other_effects_operation', self.specific_share_to_other_effects_operation, self - ) - - def create_model(self) -> 'EffectModel': - self.model = EffectModel(self) - return self.model - - -class EffectModel(ElementModel): - def __init__(self, element: Effect): - super().__init__(element) - self.element: Effect - self.invest = ShareAllocationModel( - self.element, 'invest', False, total_max=self.element.maximum_invest, total_min=self.element.minimum_invest - ) - self.operation = ShareAllocationModel( - self.element, - 'operation', - True, - total_max=self.element.maximum_operation, - total_min=self.element.minimum_operation, - min_per_hour=self.element.minimum_operation_per_hour.active_data - if self.element.minimum_operation_per_hour is not None - else None, - max_per_hour=self.element.maximum_operation_per_hour.active_data - if self.element.maximum_operation_per_hour is not None - else None, - ) - self.all = ShareAllocationModel( - self.element, 'all', False, total_max=self.element.maximum_total, total_min=self.element.minimum_total - ) - self.sub_models.extend([self.invest, self.operation, self.all]) - - def do_modeling(self, system_model: SystemModel): - for model in self.sub_models: - model.do_modeling(system_model) - - self.all.add_share(system_model, 'operation', self.operation.sum, 1) - self.all.add_share(system_model, 'invest', self.invest.sum, 1) - - -EffectDict = Dict[Optional['Effect'], Numeric] -EffectDictInvest = Dict[Optional['Effect'], Skalar] - -EffectValues = Optional[Union[Numeric_TS, EffectDict]] # Datatype for User Input -EffectValuesInvest = Optional[Union[Skalar, EffectDictInvest]] # Datatype for User Input - -EffectTimeSeries = Dict[Optional['Effect'], TimeSeries] # Final Internal Data Structure -ElementTimeSeries = Dict[Optional[Element], TimeSeries] # Final Internal Data Structure - - -def nested_values_to_time_series( - nested_values: Dict[Element, Numeric_TS], label_suffix: str, parent_element: Element -) -> ElementTimeSeries: - """ - Creates TimeSeries from nested values, which are a Dict of Elements to values. - The resulting label of the TimeSeries is the label of the parent_element, followed by the label of the element in - the nested_values and the label_suffix. - """ - return { - element: _create_time_series(f'{element.label}_{label_suffix}', value, parent_element) - for element, value in nested_values.items() - if element is not None - } - - -def effect_values_to_time_series( - label_suffix: str, nested_values: EffectValues, parent_element: Element -) -> Optional[EffectTimeSeries]: - """ - Creates TimeSeries from EffectValues. The resulting label of the TimeSeries is the label of the parent_element, - followed by the label of the Effect in the nested_values and the label_suffix. - If the key in the EffectValues is None, the alias 'Standart_Effect' is used - """ - nested_values = as_effect_dict(nested_values) - if nested_values is None: - return None - else: - standard_value = nested_values.pop(None, None) - transformed_values = nested_values_to_time_series(nested_values, label_suffix, parent_element) - if standard_value is not None: - transformed_values[None] = _create_time_series( - f'Standard_Effect_{label_suffix}', standard_value, parent_element - ) - return transformed_values - - -def as_effect_dict(effect_values: EffectValues) -> Optional[EffectDict]: - """ - Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type. - - Examples - -------- - costs = 20 -> {None: 20} - costs = None -> None - costs = {effect1: 20, effect2: 0.3} -> {effect1: 20, effect2: 0.3} - - Parameters - ---------- - effect_values : None, int, float, TimeSeries, or dict - The effect values to convert, either a scalar, TimeSeries, or a dictionary. - - Returns - ------- - dict or None - A dictionary with None or Effect as the key, or None if input is None. - """ - return ( - effect_values - if isinstance(effect_values, dict) - else {None: effect_values} - if effect_values is not None - else None - ) - - -def effect_values_from_effect_time_series(effect_time_series: EffectTimeSeries) -> Dict[Optional[Effect], Numeric]: - return {effect: time_series.active_data for effect, time_series in effect_time_series.items()} - - -class EffectCollection: - """ - Handling all Effects - """ - - def __init__(self, label: str): - self.label = label - self.model: Optional[EffectCollectionModel] = None - self.effects: Dict[str, Effect] = {} - - def create_model(self, system_model: SystemModel) -> 'EffectCollectionModel': - self.model = EffectCollectionModel(self, system_model) - return self.model - - def add_effect(self, effect: 'Effect') -> None: - if effect.is_standard and self.standard_effect is not None: - raise Exception(f'A standard-effect already exists! ({self.standard_effect.label=})') - if effect.is_objective and self.objective_effect is not None: - raise Exception(f'A objective-effect already exists! ({self.objective_effect.label=})') - if effect in self.effects.values(): - raise Exception(f'Effect already added! ({effect.label=})') - if effect.label in self.effects: - raise Exception(f'Effect with label "{effect.label=}" already added!') - self.effects[effect.label] = effect - - @property - def standard_effect(self) -> Optional[Effect]: - for effect in self.effects.values(): - if effect.is_standard: - return effect - - @property - def objective_effect(self) -> Optional[Effect]: - for effect in self.effects.values(): - if effect.is_objective: - return effect - - @property - def label_full(self): - return self.label - - -class EffectCollectionModel(ElementModel): - # TODO: Maybe all EffectModels should be sub_models of this Model? Including Objective and Penalty? - def __init__(self, element: EffectCollection, system_model: SystemModel): - super().__init__(element) - self.element = element - self._system_model = system_model - self._effect_models: Dict[Effect, EffectModel] = {} - self.penalty: Optional[ShareAllocationModel] = None - self.objective: Optional[Equation] = None - - def do_modeling(self, system_model: SystemModel): - self._effect_models = {effect: effect.create_model() for effect in self.element.effects.values()} - self.penalty = ShareAllocationModel(self.element, 'penalty', False) - self.sub_models.extend(list(self._effect_models.values()) + [self.penalty]) - for model in self.sub_models: - model.do_modeling(system_model) - - self.add_share_between_effects() - - self.objective = Equation('OBJECTIVE', 'OBJECTIVE', is_objective=True) - self.objective.add_summand(self._objective_effect_model.operation.sum, 1) - self.objective.add_summand(self._objective_effect_model.invest.sum, 1) - self.objective.add_summand(self.penalty.sum, 1) - - @property - def _objective_effect_model(self) -> EffectModel: - return self._effect_models[self.element.objective_effect] - - def _add_share_to_effects( - self, - name: str, - element: Element, - target: Literal['operation', 'invest'], - effect_values: EffectDict, - factor: Numeric, - variable: Optional[Variable] = None, - ) -> None: - # an alle Effects, die einen Wert haben, anhƤngen: - for effect, value in effect_values.items(): - if effect is None: # Falls None, dann Standard-effekt nutzen: - effect = self.element.standard_effect - assert effect in self.element.effects.values(), f'Effect {effect.label} was used but not added to model!' - - if target == 'operation': - model = self._effect_models[effect].operation - elif target == 'invest': - model = self._effect_models[effect].invest - else: - raise ValueError(f'Target {target} not supported!') - - name_of_share = f'{element.label_full}__{name}' - total_factor = np.multiply(value, factor) - model.add_share(self._system_model, name_of_share, variable, total_factor) - - def add_share_to_invest( - self, - name: str, - element: Element, - effect_values: EffectDictInvest, - factor: Numeric, - variable: Optional[Variable] = None, - ) -> None: - # TODO: Add checks - self._add_share_to_effects(name, element, 'invest', effect_values, factor, variable) - - def add_share_to_operation( - self, - name: str, - element: Element, - effect_values: EffectTimeSeries, - factor: Numeric, - variable: Optional[Variable] = None, - ) -> None: - # TODO: Add checks - self._add_share_to_effects( - name, element, 'operation', effect_values_from_effect_time_series(effect_values), factor, variable - ) - - def add_share_to_penalty( - self, - name: Optional[str], - variable: Variable, - factor: Numeric, - ) -> None: - assert variable is not None, 'A Variable must be passed to add a share to penalty! Else its a constant Penalty!' - self.penalty.add_share(self._system_model, name, variable, factor, True) - - def add_share_between_effects(self): - for origin_effect in self.element.effects.values(): - # 1. operation: -> hier sind es Zeitreihen (share_TS) - for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items(): - target_model = self._effect_models[target_effect].operation - origin_model = self._effect_models[origin_effect].operation - target_model.add_share( - self._system_model, - f'{origin_effect.label_full}_operation', - origin_model.sum_TS, - time_series.active_data, - ) - # 2. invest: -> hier ist es Skalar (share) - for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items(): - target_model = self._effect_models[target_effect].invest - origin_model = self._effect_models[origin_effect].invest - target_model.add_share( - self._system_model, f'{origin_effect.label_full}_invest', origin_model.sum, factor - ) diff --git a/flixOpt/elements.py b/flixOpt/elements.py deleted file mode 100644 index e2402d608..000000000 --- a/flixOpt/elements.py +++ /dev/null @@ -1,489 +0,0 @@ -""" -This module contains the basic elements of the flixOpt framework. -""" - -import logging -from typing import Dict, List, Optional, Tuple, Union - -import numpy as np - -from .config import CONFIG -from .core import Numeric, Numeric_TS, Skalar -from .effects import EffectValues, effect_values_to_time_series -from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel -from .interface import InvestParameters, OnOffParameters -from .math_modeling import Variable, VariableTS -from .structure import ( - Element, - ElementModel, - SystemModel, - _create_time_series, - copy_and_convert_datatypes, - create_equation, - create_variable, -) - -logger = logging.getLogger('flixOpt') - - -class Component(Element): - """ - basic component class for all components - """ - - def __init__( - self, - label: str, - inputs: Optional[List['Flow']] = None, - outputs: Optional[List['Flow']] = None, - on_off_parameters: Optional[OnOffParameters] = None, - prevent_simultaneous_flows: Optional[List['Flow']] = None, - meta_data: Optional[Dict] = None, - ): - """ - Parameters - ---------- - label : str - name. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - inputs : input flows. - outputs : output flows. - on_off_parameters: Information about on and off state of Component. - Component is On/Off, if all connected Flows are On/Off. - Induces On-Variable in all FLows! - See class OnOffParameters. - prevent_simultaneous_flows: Define a Group of Flows. Only one them can be on at a time. - Induces On-Variable in all FLows! - """ - super().__init__(label, meta_data=meta_data) - self.inputs: List['Flow'] = inputs or [] - self.outputs: List['Flow'] = outputs or [] - self.on_off_parameters = on_off_parameters - self.prevent_simultaneous_flows: List['Flow'] = prevent_simultaneous_flows or [] - - self.flows: Dict[str, Flow] = {flow.label: flow for flow in self.inputs + self.outputs} - - def create_model(self) -> 'ComponentModel': - self.model = ComponentModel(self) - return self.model - - def transform_data(self) -> None: - if self.on_off_parameters is not None: - self.on_off_parameters.transform_data(self) - - def register_component_in_flows(self) -> None: - for flow in self.inputs + self.outputs: - flow.comp = self - - def register_flows_in_bus(self) -> None: - for flow in self.inputs: - flow.bus.add_output(flow) - for flow in self.outputs: - flow.bus.add_input(flow) - - def infos(self, use_numpy=True, use_element_label=False) -> Dict: - infos = super().infos(use_numpy, use_element_label) - infos['inputs'] = [flow.infos(use_numpy, use_element_label) for flow in self.inputs] - infos['outputs'] = [flow.infos(use_numpy, use_element_label) for flow in self.outputs] - return infos - - -class Bus(Element): - """ - realizing balance of all linked flows - (penalty flow is excess can be activated) - """ - - def __init__( - self, label: str, excess_penalty_per_flow_hour: Optional[Numeric_TS] = 1e5, meta_data: Optional[Dict] = None - ): - """ - Parameters - ---------- - label : str - name. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - excess_penalty_per_flow_hour : none or scalar, array or TimeSeriesData - excess costs / penalty costs (bus balance compensation) - (none/ 0 -> no penalty). The default is 1e5. - (Take care: if you use a timeseries (no scalar), timeseries is aggregated if calculation_type = aggregated!) - """ - super().__init__(label, meta_data=meta_data) - self.excess_penalty_per_flow_hour = excess_penalty_per_flow_hour - self.inputs: List[Flow] = [] - self.outputs: List[Flow] = [] - - def create_model(self) -> 'BusModel': - self.model = BusModel(self) - return self.model - - def transform_data(self): - self.excess_penalty_per_flow_hour = _create_time_series( - 'excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour, self - ) - - def add_input(self, flow) -> None: - flow: Flow - self.inputs.append(flow) - - def add_output(self, flow) -> None: - flow: Flow - self.outputs.append(flow) - - def _plausibility_checks(self) -> None: - if self.excess_penalty_per_flow_hour == 0: - logger.warning(f'In Bus {self.label}, the excess_penalty_per_flow_hour is 0. Use "None" or a value > 0.') - - @property - def with_excess(self) -> bool: - return False if self.excess_penalty_per_flow_hour is None else True - - -class Connection: - # input/output-dock (TODO: - # -> wƤre cool, damit Komponenten auch auch ohne Knoten verbindbar - # input wƤren wie Flow,aber statt bus : connectsTo -> hier andere Connection oder aber Bus (dort keine Connection, weil nicht notwendig) - - def __init__(self): - raise NotImplementedError() - - -class Flow(Element): - """ - flows are inputs and outputs of components - """ - - def __init__( - self, - label: str, - bus: Bus, - size: Union[Skalar, InvestParameters] = None, - fixed_relative_profile: Optional[Numeric_TS] = None, - relative_minimum: Numeric_TS = 0, - relative_maximum: Numeric_TS = 1, - effects_per_flow_hour: EffectValues = None, - on_off_parameters: Optional[OnOffParameters] = None, - flow_hours_total_max: Optional[Skalar] = None, - flow_hours_total_min: Optional[Skalar] = None, - load_factor_min: Optional[Skalar] = None, - load_factor_max: Optional[Skalar] = None, - previous_flow_rate: Optional[Numeric] = None, - meta_data: Optional[Dict] = None, - ): - r""" - Parameters - ---------- - label : str - name of flow - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - bus : Bus, optional - bus to which flow is linked - size : scalar, InvestmentParameters, optional - size of the flow. If InvestmentParameters is used, size is optimized. - If size is None, a default value is used. - relative_minimum : scalar, array, TimeSeriesData, optional - min value is relative_minimum multiplied by size - relative_maximum : scalar, array, TimeSeriesData, optional - max value is relative_maximum multiplied by size. If size = max then relative_maximum=1 - load_factor_min : scalar, optional - minimal load factor general: avg Flow per nominalVal/investSize - (e.g. boiler, kW/kWh=h; solarthermal: kW/m²; - def: :math:`load\_factor:= sumFlowHours/ (nominal\_val \cdot \Delta t_{tot})` - load_factor_max : scalar, optional - maximal load factor (see minimal load factor) - effects_per_flow_hour : scalar, array, TimeSeriesData, optional - operational costs, costs per flow-"work" - on_off_parameters : OnOffParameters, optional - If present, flow can be "off", i.e. be zero (only relevant if relative_minimum > 0) - Therefore a binary var "on" is used. Further, several other restrictions and effects can be modeled - through this On/Off State (See OnOffParameters) - flow_hours_total_max : TYPE, optional - maximum flow-hours ("flow-work") - (if size is not const, maybe load_factor_max fits better for you!) - flow_hours_total_min : TYPE, optional - minimum flow-hours ("flow-work") - (if size is not const, maybe load_factor_min fits better for you!) - fixed_relative_profile : scalar, array, TimeSeriesData, optional - fixed relative values for flow (if given). - val(t) := fixed_relative_profile(t) * size(t) - With this value, the flow_rate is no opt-variable anymore; - (relative_minimum u. relative_maximum are making sense anymore) - used for fixed load profiles, i.g. heat demand, wind-power, solarthermal - If the load-profile is just an upper limit, use relative_maximum instead. - previous_flow_rate : scalar, array, optional - previous flow rate of the component. - """ - super().__init__(label, meta_data=meta_data) - self.size = size or CONFIG.modeling.BIG # Default size - self.relative_minimum = relative_minimum - self.relative_maximum = relative_maximum - self.fixed_relative_profile = fixed_relative_profile - - self.load_factor_min = load_factor_min - self.load_factor_max = load_factor_max - # self.positive_gradient = TimeSeries('positive_gradient', positive_gradient, self) - self.effects_per_flow_hour = effects_per_flow_hour if effects_per_flow_hour is not None else {} - self.flow_hours_total_max = flow_hours_total_max - self.flow_hours_total_min = flow_hours_total_min - self.on_off_parameters = on_off_parameters - - self.previous_flow_rate = previous_flow_rate - - self.bus = bus - self.comp: Optional[Component] = None - - self._plausibility_checks() - - def create_model(self) -> 'FlowModel': - self.model = FlowModel(self) - return self.model - - def transform_data(self): - self.relative_minimum = _create_time_series('relative_minimum', self.relative_minimum, self) - self.relative_maximum = _create_time_series('relative_maximum', self.relative_maximum, self) - self.fixed_relative_profile = _create_time_series('fixed_relative_profile', self.fixed_relative_profile, self) - self.effects_per_flow_hour = effect_values_to_time_series('per_flow_hour', self.effects_per_flow_hour, self) - if self.on_off_parameters is not None: - self.on_off_parameters.transform_data(self) - if isinstance(self.size, InvestParameters): - self.size.transform_data() - - def infos(self, use_numpy=True, use_element_label=False) -> Dict: - infos = super().infos(use_numpy, use_element_label) - infos['is_input_in_component'] = self.is_input_in_comp - return infos - - def _plausibility_checks(self) -> None: - # TODO: Incorporate into Variable? (Lower_bound can not be greater than upper bound - if np.any(self.relative_minimum > self.relative_maximum): - raise Exception(self.label_full + ': Take care, that relative_minimum <= relative_maximum!') - - if ( - self.size == CONFIG.modeling.BIG and self.fixed_relative_profile is not None - ): # Default Size --> Most likely by accident - logger.warning( - f'Flow "{self.label}" has no size assigned, but a "fixed_relative_profile". ' - f'The default size is {CONFIG.modeling.BIG}. As "flow_rate = size * fixed_relative_profile", ' - f'the resulting flow_rate will be very high. To fix this, assign a size to the Flow {self}.' - ) - - @property - def label_full(self) -> str: - # Wenn im Erstellungsprozess comp noch nicht bekannt: - comp_label = 'unknownComp' if self.comp is None else self.comp.label - return f'{comp_label}__{self.label}' # z.B. für results_struct (deswegen auch _ statt . dazwischen) - - @property # Richtung - def is_input_in_comp(self) -> bool: - return True if self in self.comp.inputs else False - - @property - def size_is_fixed(self) -> bool: - # Wenn kein InvestParameters existiert --> True; Wenn Investparameter, den Wert davon nehmen - return False if (isinstance(self.size, InvestParameters) and self.size.fixed_size is None) else True - - @property - def invest_is_optional(self) -> bool: - # Wenn kein InvestParameters existiert: # Investment ist nicht optional -> Keine Variable --> False - return False if (isinstance(self.size, InvestParameters) and not self.size.optional) else True - - -class FlowModel(ElementModel): - def __init__(self, element: Flow): - super().__init__(element) - self.element: Flow = element - self.flow_rate: Optional[VariableTS] = None - self.sum_flow_hours: Optional[Variable] = None - - self._on: Optional[OnOffModel] = None - self._investment: Optional[InvestmentModel] = None - - def do_modeling(self, system_model: SystemModel): - # eq relative_minimum(t) * size <= flow_rate(t) <= relative_maximum(t) * size - self.flow_rate = create_variable( - 'flow_rate', - self, - system_model.nr_of_time_steps, - lower_bound=self.absolute_flow_rate_bounds[0] if self.element.on_off_parameters is None else 0, - upper_bound=self.absolute_flow_rate_bounds[1] if self.element.on_off_parameters is None else None, - previous_values=self.element.previous_flow_rate, - ) - - # OnOff - if self.element.on_off_parameters is not None: - self._on = OnOffModel( - self.element, self.element.on_off_parameters, [self.flow_rate], [self.absolute_flow_rate_bounds] - ) - self._on.do_modeling(system_model) - self.sub_models.append(self._on) - - # Investment - if isinstance(self.element.size, InvestParameters): - self._investment = InvestmentModel( - self.element, - self.element.size, - self.flow_rate, - self.relative_flow_rate_bounds, - fixed_relative_profile=(None - if self.element.fixed_relative_profile is None - else self.element.fixed_relative_profile.active_data), - on_variable=self._on.on if self._on is not None else None, - ) - self._investment.do_modeling(system_model) - self.sub_models.append(self._investment) - - # sumFLowHours - self.sum_flow_hours = create_variable( - 'sumFlowHours', - self, - 1, - lower_bound=self.element.flow_hours_total_min, - upper_bound=self.element.flow_hours_total_max, - ) - eq_sum_flow_hours = create_equation('sumFlowHours', self, 'eq') - eq_sum_flow_hours.add_summand(self.flow_rate, system_model.dt_in_hours, as_sum=True) - eq_sum_flow_hours.add_summand(self.sum_flow_hours, -1) - - # Load factor - self._create_bounds_for_load_factor(system_model) - - # Shares - self._create_shares(system_model) - - def _create_shares(self, system_model: SystemModel): - # Arbeitskosten: - if self.element.effects_per_flow_hour != {}: - system_model.effect_collection_model.add_share_to_operation( - name='effects_per_flow_hour', - element=self.element, - variable=self.flow_rate, - effect_values=self.element.effects_per_flow_hour, - factor=system_model.dt_in_hours, - ) - - def _create_bounds_for_load_factor(self, system_model: SystemModel): - # TODO: Add Variable load_factor for better evaluation? - - # eq: var_sumFlowHours <= size * dt_tot * load_factor_max - if self.element.load_factor_max is not None: - flow_hours_per_size_max = system_model.dt_in_hours_total * self.element.load_factor_max - eq_load_factor_max = create_equation('load_factor_max', self, 'ineq') - eq_load_factor_max.add_summand(self.sum_flow_hours, 1) - # if investment: - if self._investment is not None: - eq_load_factor_max.add_summand(self._investment.size, -1 * flow_hours_per_size_max) - else: - eq_load_factor_max.add_constant(self.element.size * flow_hours_per_size_max) - - # eq: size * sum(dt)* load_factor_min <= var_sumFlowHours - if self.element.load_factor_min is not None: - flow_hours_per_size_min = system_model.dt_in_hours_total * self.element.load_factor_min - eq_load_factor_min = create_equation('load_factor_min', self, 'ineq') - eq_load_factor_min.add_summand(self.sum_flow_hours, -1) - if self._investment is not None: - eq_load_factor_min.add_summand(self._investment.size, flow_hours_per_size_min) - else: - eq_load_factor_min.add_constant(-1 * self.element.size * flow_hours_per_size_min) - - @property - def with_investment(self) -> bool: - """Checks if the element's size is investment-driven.""" - return isinstance(self.element.size, InvestParameters) - - @property - def absolute_flow_rate_bounds(self) -> Tuple[Numeric, Numeric]: - """Returns absolute flow rate bounds. Important for OnOffModel""" - rel_min, rel_max = self.relative_flow_rate_bounds - size = self.element.size - if not self.with_investment: - return rel_min * size, rel_max * size - if size.fixed_size is not None: - return rel_min * size.fixed_size, rel_max * size.fixed_size - return rel_min * size.minimum_size, rel_max * size.maximum_size - - - @property - def relative_flow_rate_bounds(self) -> Tuple[Numeric, Numeric]: - """Returns relative flow rate bounds.""" - fixed_profile = self.element.fixed_relative_profile - if fixed_profile is None: - return self.element.relative_minimum.active_data, self.element.relative_maximum.active_data - return fixed_profile.active_data, fixed_profile.active_data - - -class BusModel(ElementModel): - def __init__(self, element: Bus): - super().__init__(element) - self.element: Bus - self.excess_input: Optional[VariableTS] = None - self.excess_output: Optional[VariableTS] = None - - def do_modeling(self, system_model: SystemModel) -> None: - self.element: Bus - # inputs = outputs - eq_bus_balance = create_equation('busBalance', self) - for flow in self.element.inputs: - eq_bus_balance.add_summand(flow.model.flow_rate, 1) - for flow in self.element.outputs: - eq_bus_balance.add_summand(flow.model.flow_rate, -1) - - # Fehlerplus/-minus: - if self.element.with_excess: - excess_penalty = np.multiply( - system_model.dt_in_hours, self.element.excess_penalty_per_flow_hour.active_data - ) - self.excess_input = create_variable('excess_input', self, system_model.nr_of_time_steps, lower_bound=0) - self.excess_output = create_variable('excess_output', self, system_model.nr_of_time_steps, lower_bound=0) - - eq_bus_balance.add_summand(self.excess_output, -1) - eq_bus_balance.add_summand(self.excess_input, 1) - - fx_collection = system_model.effect_collection_model - - fx_collection.add_share_to_penalty( - f'{self.element.label_full}__excess_input', self.excess_input, excess_penalty - ) - fx_collection.add_share_to_penalty( - f'{self.element.label_full}__excess_output', self.excess_output, excess_penalty - ) - - -class ComponentModel(ElementModel): - def __init__(self, element: Component): - super().__init__(element) - self.element: Component = element - self._on: Optional[OnOffModel] = None - - def do_modeling(self, system_model: SystemModel): - """Initiates all FlowModels""" - all_flows = self.element.inputs + self.element.outputs - if self.element.on_off_parameters: - for flow in all_flows: - if flow.on_off_parameters is None: - flow.on_off_parameters = OnOffParameters() - - if self.element.prevent_simultaneous_flows: - for flow in self.element.prevent_simultaneous_flows: - if flow.on_off_parameters is None: - flow.on_off_parameters = OnOffParameters() - - self.sub_models.extend([flow.create_model() for flow in all_flows]) - for sub_model in self.sub_models: - sub_model.do_modeling(system_model) - - if self.element.on_off_parameters: - flow_rates: List[VariableTS] = [flow.model.flow_rate for flow in all_flows] - bounds: List[Tuple[Numeric, Numeric]] = [flow.model.absolute_flow_rate_bounds for flow in all_flows] - self._on = OnOffModel(self.element, self.element.on_off_parameters, flow_rates, bounds) - self.sub_models.append(self._on) - self._on.do_modeling(system_model) - - 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.on for flow in self.element.prevent_simultaneous_flows] - simultaneous_use = PreventSimultaneousUsageModel(self.element, on_variables) - self.sub_models.append(simultaneous_use) - simultaneous_use.do_modeling(system_model) diff --git a/flixOpt/features.py b/flixOpt/features.py deleted file mode 100644 index f05d7d29e..000000000 --- a/flixOpt/features.py +++ /dev/null @@ -1,942 +0,0 @@ -""" -This module contains the features of the flixOpt framework. -Features extend the functionality of Elements. -""" - -import logging -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union - -import numpy as np - -from .config import CONFIG -from .core import Numeric, Skalar, TimeSeries -from .interface import InvestParameters, OnOffParameters -from .math_modeling import Equation, Variable, VariableTS -from .structure import ( - Element, - ElementModel, - SystemModel, - create_equation, - create_variable, -) - -if TYPE_CHECKING: # for type checking and preventing circular imports - from .components import Storage - from .effects import Effect - from .elements import Flow - - -logger = logging.getLogger('flixOpt') - - -class InvestmentModel(ElementModel): - """Class for modeling an investment""" - - def __init__( - self, - element: Union['Flow', 'Storage'], - invest_parameters: InvestParameters, - defining_variable: [VariableTS], - relative_bounds_of_defining_variable: Tuple[Numeric, Numeric], - fixed_relative_profile: Optional[Numeric] = None, - label: str = 'Investment', - on_variable: Optional[VariableTS] = None, - ): - """ - If fixed relative profile is used, the relative bounds are ignored - """ - super().__init__(element, label) - self.element: Union['Flow', 'Storage'] = element - self.size: Optional[Union[Skalar, Variable]] = None - self.is_invested: Optional[Variable] = None - - self._segments: Optional[SegmentedSharesModel] = None - - self._on_variable = on_variable - self._defining_variable = defining_variable - self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable - self._fixed_relative_profile = fixed_relative_profile - self._invest_parameters = invest_parameters - - def do_modeling(self, system_model: SystemModel): - invest_parameters = self._invest_parameters - if invest_parameters.fixed_size and not invest_parameters.optional: - self.size = create_variable('size', self, 1, fixed_value=invest_parameters.fixed_size) - else: - lower_bound = 0 if invest_parameters.optional else invest_parameters.minimum_size - self.size = create_variable( - 'size', self, 1, lower_bound=lower_bound, upper_bound=invest_parameters.maximum_size - ) - # Optional - if invest_parameters.optional: - self.is_invested = create_variable('isInvested', self, 1, is_binary=True) - self._create_bounds_for_optional_investment(system_model) - - # Bounds for defining variable - self._create_bounds_for_defining_variable(system_model) - - self._create_shares(system_model) - - def _create_shares(self, system_model: SystemModel): - effect_collection = system_model.effect_collection_model - invest_parameters = self._invest_parameters - - # fix_effects: - fix_effects = invest_parameters.fix_effects - if fix_effects != {}: - if invest_parameters.optional: # share: + isInvested * fix_effects - variable_is_invested = self.is_invested - else: - variable_is_invested = None - effect_collection.add_share_to_invest('fix_effects', self.element, fix_effects, 1, variable_is_invested) - - # divest_effects: - divest_effects = invest_parameters.divest_effects - if divest_effects != {}: - if invest_parameters.optional: # share: [divest_effects - isInvested * divest_effects] - # 1. part of share [+ divest_effects]: - effect_collection.add_share_to_invest('divest_effects', self.element, divest_effects, 1, None) - # 2. part of share [- isInvested * divest_effects]: - effect_collection.add_share_to_invest( - 'divest_cancellation_effects', self.element, divest_effects, -1, self.is_invested - ) - # TODO : these 2 parts should be one share! -> SingleShareModel...? - - # # specific_effects: - specific_effects = invest_parameters.specific_effects - if specific_effects != {}: - # share: + investment_size (=var) * specific_effects - effect_collection.add_share_to_invest('specific_effects', self.element, specific_effects, 1, self.size) - # segmented Effects - invest_segments = invest_parameters.effects_in_segments - if invest_segments: - self._segments = SegmentedSharesModel( - self.element, (self.size, invest_segments[0]), invest_segments[1], self.is_invested - ) - self.sub_models.append(self._segments) - self._segments.do_modeling(system_model) - - def _create_bounds_for_optional_investment(self, system_model: SystemModel): - if self._invest_parameters.fixed_size: - # eq: investment_size = isInvested * fixed_size - eq_is_invested = create_equation('is_invested', self, 'eq') - eq_is_invested.add_summand(self.size, -1) - eq_is_invested.add_summand(self.is_invested, self._invest_parameters.fixed_size) - else: - # eq1: P_invest <= isInvested * investSize_max - eq_is_invested_ub = create_equation('is_invested_ub', self, 'ineq') - eq_is_invested_ub.add_summand(self.size, 1) - eq_is_invested_ub.add_summand(self.is_invested, np.multiply(-1, self._invest_parameters.maximum_size)) - - # eq2: P_invest >= isInvested * max(epsilon, investSize_min) - eq_is_invested_lb = create_equation('is_invested_lb', self, 'ineq') - eq_is_invested_lb.add_summand(self.size, -1) - eq_is_invested_lb.add_summand( - self.is_invested, np.maximum(CONFIG.modeling.EPSILON, self._invest_parameters.minimum_size) - ) - - def _create_bounds_for_defining_variable(self, system_model: SystemModel): - label = self._defining_variable.label - # fixed relative value - if self._fixed_relative_profile is not None: - # TODO: Allow Off? Currently not... - eq_fixed = create_equation(f'fixed_{label}', self) - eq_fixed.add_summand(self._defining_variable, 1) - eq_fixed.add_summand(self.size, np.multiply(-1, self._fixed_relative_profile)) - else: - relative_minimum, relative_maximum = self._relative_bounds_of_defining_variable - eq_upper = create_equation(f'ub_{label}', self, 'ineq') - # eq: defining_variable(t) <= size * upper_bound(t) - eq_upper.add_summand(self._defining_variable, 1) - eq_upper.add_summand(self.size, np.multiply(-1, relative_maximum)) - - ## 2. Gleichung: Minimum durch Investmentgröße ## - eq_lower = create_equation(f'lb_{label}', self, 'ineq') - if self._on_variable is None: - # eq: defining_variable(t) >= investment_size * relative_minimum(t) - eq_lower.add_summand(self._defining_variable, -1) - eq_lower.add_summand(self.size, relative_minimum) - 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 = relative_maximum * self._invest_parameters.maximum_size - eq_lower.add_summand(self._defining_variable, -1) - eq_lower.add_summand(self._on_variable, mega) - eq_lower.add_summand(self.size, relative_minimum) - eq_lower.add_constant(mega) - # Anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ?? - - -class OnOffModel(ElementModel): - """ - Class for modeling the on and off state of a variable - If defining_bounds are given, creates sufficient lower bounds - """ - - def __init__( - self, - element: Element, - on_off_parameters: OnOffParameters, - defining_variables: List[VariableTS], - defining_bounds: List[Tuple[Numeric, Numeric]], - label: str = 'OnOff', - ): - """ - defining_bounds: a list of Numeric, that can be used to create the bound for On/Off more efficiently - """ - super().__init__(element, label) - self.element = element - self.on: Optional[VariableTS] = None - self.total_on_hours: Optional[Variable] = None - - self.consecutive_on_hours: Optional[VariableTS] = None - self.consecutive_off_hours: Optional[VariableTS] = None - - self.off: Optional[VariableTS] = None - - self.switch_on: Optional[VariableTS] = None - self.switch_off: Optional[VariableTS] = None - self.nr_switch_on: Optional[VariableTS] = None - - self._on_off_parameters = on_off_parameters - self._defining_variables = defining_variables - # Ensure that no lower bound is below a certain threshold - self._defining_bounds = [(np.maximum(lb, CONFIG.modeling.EPSILON), ub) for lb, ub in defining_bounds] - assert len(defining_variables) == len(defining_bounds), 'Every defining Variable needs bounds to Model OnOff' - - def do_modeling(self, system_model: SystemModel): - self.on = create_variable( - 'on', - self, - system_model.nr_of_time_steps, - is_binary=True, - previous_values=self._previous_on_values(CONFIG.modeling.EPSILON), - ) - - self.total_on_hours = create_variable( - 'totalOnHours', - self, - 1, - lower_bound=self._on_off_parameters.on_hours_total_min, - upper_bound=self._on_off_parameters.on_hours_total_max, - ) - eq_total_on = create_equation('totalOnHours', self) - eq_total_on.add_summand(self.on, system_model.dt_in_hours, as_sum=True) - eq_total_on.add_summand(self.total_on_hours, -1) - - self._add_on_constraints(system_model, system_model.indices) - - if self._on_off_parameters.use_off: - self.off = create_variable( - 'off', - self, - system_model.nr_of_time_steps, - is_binary=True, - previous_values=1 - self._previous_on_values(CONFIG.modeling.EPSILON), - ) - - self._add_off_constraints(system_model, system_model.indices) - - if self._on_off_parameters.use_consecutive_on_hours: - self.consecutive_on_hours = self._get_duration_in_hours( - 'consecutiveOnHours', - self.on, - self._on_off_parameters.consecutive_on_hours_min, - self._on_off_parameters.consecutive_on_hours_max, - system_model, - system_model.indices, - ) - - if self._on_off_parameters.use_consecutive_off_hours: - self.consecutive_off_hours = self._get_duration_in_hours( - 'consecutiveOffHours', - self.off, - self._on_off_parameters.consecutive_off_hours_min, - self._on_off_parameters.consecutive_off_hours_max, - system_model, - system_model.indices, - ) - - if self._on_off_parameters.use_switch_on: - self.switch_on = create_variable('switchOn', self, system_model.nr_of_time_steps, is_binary=True) - self.switch_off = create_variable('switchOff', self, system_model.nr_of_time_steps, is_binary=True) - self.nr_switch_on = create_variable( - 'nrSwitchOn', self, 1, upper_bound=self._on_off_parameters.switch_on_total_max - ) - self._add_switch_constraints(system_model) - - self._create_shares(system_model) - - def _add_on_constraints(self, system_model: SystemModel, time_indices: Union[list[int], range]): - assert self.on is not None, f'On variable of {self.element} must be defined to add constraints' - # % Bedingungen 1) und 2) müssen erfüllt sein: - - # % Anmerkung: Falls "abschnittsweise linear" gewƤhlt, dann ist eigentlich nur Bedingung 1) noch notwendig - # % (und dann auch nur wenn erstes Segment bei Q_th=0 beginnt. Dann soll bei Q_th=0 (d.h. die Maschine ist Aus) On = 0 und segment1.onSeg = 0):) - # % Fazit: Wenn kein Performance-Verlust durch mehr Gleichungen, dann egal! - - nr_of_defining_variables = len(self._defining_variables) - assert nr_of_defining_variables > 0, 'Achtung: mindestens 1 Flow notwendig' - - eq_on_1 = create_equation('On_Constraint_1', self, eq_type='ineq') - eq_on_2 = create_equation('On_Constraint_2', self, eq_type='ineq') - if nr_of_defining_variables == 1: - variable = self._defining_variables[0] - lower_bound, upper_bound = self._defining_bounds[0] - #### Bedingung 1) #### - # eq: On(t) * max(epsilon, lower_bound) <= Q_th(t) - eq_on_1.add_summand(variable, -1, time_indices) - eq_on_1.add_summand(self.on, np.maximum(CONFIG.modeling.EPSILON, lower_bound), time_indices) - - #### Bedingung 2) #### - # eq: Q_th(t) <= Q_th_max * On(t) - eq_on_2.add_summand(variable, 1, time_indices) - eq_on_2.add_summand(self.on, -1 * upper_bound, time_indices) - - else: # Bei mehreren Leistungsvariablen: - #### Bedingung 1) #### - # When all defining variables are 0, On is 0 - # eq: - sum(alle Leistungen(t)) + Epsilon * On(t) <= 0 - for variable in self._defining_variables: - eq_on_1.add_summand(variable, -1, time_indices) - eq_on_1.add_summand(self.on, CONFIG.modeling.EPSILON, time_indices) - - #### Bedingung 2) #### - ## sum(alle Leistung) >0 -> On = 1 | On=0 -> sum(Leistung)=0 - # eq: sum( Leistung(t,i)) - sum(Leistung_max(i)) * On(t) <= 0 - # --> damit Gleichungswerte nicht zu groß werden, noch durch nr_of_flows geteilt: - # eq: sum( Leistung(t,i) / nr_of_flows ) - sum(Leistung_max(i)) / nr_of_flows * On(t) <= 0 - absolute_maximum: Numeric = 0.0 - for variable, bounds in zip(self._defining_variables, self._defining_bounds, strict=False): - eq_on_2.add_summand(variable, 1 / nr_of_defining_variables, time_indices) - absolute_maximum += bounds[ - 1 - ] # der maximale Nennwert reicht als Obergrenze hier aus. (immer noch math. günster als BigM) - - upper_bound = absolute_maximum / nr_of_defining_variables - eq_on_2.add_summand(self.on, -1 * upper_bound, time_indices) - - if np.max(upper_bound) > CONFIG.modeling.BIG_BINARY_BOUND: - logger.warning( - f'In "{self.element.label_full}", a binary definition was created with a big upper bound ' - f'({np.max(upper_bound)}). This can lead to wrong results regarding the on and off variables. ' - f'Avoid this warning by reducing the size of {self.element.label_full} ' - f'(or the maximum_size of the corresponding InvestParameters). ' - f'If its a Component, you might need to adjust the sizes of all of its flows.' - ) - - def _add_off_constraints(self, system_model: SystemModel, time_indices: Union[list[int], range]): - assert self.off is not None, f'Off variable of {self.element} must be defined to add constraints' - # Definition var_off: - # eq: var_on(t) + var_off(t) = 1 - eq_off = create_equation('var_off', self, eq_type='eq') - eq_off.add_summand(self.off, 1, time_indices) - eq_off.add_summand(self.on, 1, time_indices) - eq_off.add_constant(1) - - def _get_duration_in_hours( - self, - variable_label: str, - binary_variable: VariableTS, - minimum_duration: Optional[TimeSeries], - maximum_duration: Optional[TimeSeries], - system_model: SystemModel, - time_indices: Union[list[int], range], - ) -> VariableTS: - """ - creates duration variable and adds constraints to a time-series variable to enforce duration limits based on - binary activity. - The minimum duration in the last time step is not restricted. - Previous values before t=0 are not recognised! - - Parameters: - variable_label (str): - Label for the duration variable to be created. - binary_variable (VariableTS): - Time-series binary variable (e.g., [0, 0, 1, 1, 1, 0, ...]) representing activity states. - minimum_duration (Optional[TimeSeries]): - Minimum duration the activity must remain active once started. - If None, no minimum duration constraint is applied. - maximum_duration (Optional[TimeSeries]): - Maximum duration the activity can remain active. - If None, the maximum duration is set to the total available time. - system_model (SystemModel): - The system model containing time step information. - time_indices (Union[list[int], range]): - List or range of indices to which to apply the constraints. - - Returns: - VariableTS: The created duration variable representing consecutive active durations. - - Example: - binary_variable: [0, 0, 1, 1, 1, 1, 0, 1, 1, 1, 0, ...] - duration_in_hours: [0, 0, 1, 2, 3, 4, 0, 1, 2, 3, 0, ...] (only if dt_in_hours=1) - - Here, duration_in_hours increments while binary_variable is 1. Minimum and maximum durations - can be enforced to constrain how long the activity remains active. - - Notes: - - To count consecutive zeros instead of ones, use a transformed binary variable - (e.g., `1 - binary_variable`). - - Constraints ensure the duration variable properly resets or increments based on activity. - - Raises: - AssertionError: If the binary_variable is None, indicating the duration constraints cannot be applied. - - """ - try: - previous_duration: Skalar = self.get_consecutive_duration( - binary_variable.previous_values, system_model.previous_dt_in_hours - ) - except TypeError as e: - raise TypeError(f'The consecutive_duration of "{variable_label}" could not be calculated. {e}') from e - mega = system_model.dt_in_hours_total + previous_duration - - if maximum_duration is not None: - first_step_max: Skalar = ( - maximum_duration.active_data[0] if maximum_duration.is_array else maximum_duration.active_data - ) - if previous_duration + system_model.dt_in_hours[0] > first_step_max: - logger.warning( - f'The maximum duration of "{variable_label}" is set to {maximum_duration.active_data}h, ' - f'but the consecutive_duration previous to this model is {previous_duration}h. ' - f'This forces "{binary_variable.label} = 0" in the first time step ' - f'(dt={system_model.dt_in_hours[0]}h)!' - ) - - duration_in_hours = create_variable( - variable_label, - self, - system_model.nr_of_time_steps, - lower_bound=0, - upper_bound=maximum_duration.active_data if maximum_duration is not None else mega, - previous_values=previous_duration, - ) - label_prefix = duration_in_hours.label - - assert binary_variable is not None, f'Duration Variable of {self.element} must be defined to add constraints' - # TODO: Einfachere Variante von Peter umsetzen! - - # 1) eq: duration(t) - On(t) * BIG <= 0 - constraint_1 = create_equation(f'{label_prefix}_constraint_1', self, eq_type='ineq') - constraint_1.add_summand(duration_in_hours, 1) - constraint_1.add_summand(binary_variable, -1 * mega) - - # 2a) eq: duration(t) - duration(t-1) <= dt(t) - # on(t)=1 -> duration(t) - duration(t-1) <= dt(t) - # on(t)=0 -> duration(t-1) >= negat. value - constraint_2a = create_equation(f'{label_prefix}_constraint_2a', self, eq_type='ineq') - constraint_2a.add_summand(duration_in_hours, 1, time_indices[1:]) # duration(t) - constraint_2a.add_summand(duration_in_hours, -1, time_indices[0:-1]) # duration(t-1) - constraint_2a.add_constant(system_model.dt_in_hours[1:]) # dt(t) - - # 2b) eq: dt(t) - BIG * ( 1-On(t) ) <= duration(t) - duration(t-1) - # eq: -duration(t) + duration(t-1) + On(t) * BIG <= -dt(t) + BIG - # with BIG = dt_in_hours_total. - # on(t)=1 -> duration(t)- duration(t-1) >= dt(t) - # on(t)=0 -> duration(t)- duration(t-1) >= negat. value - - constraint_2b = create_equation(f'{label_prefix}_constraint_2b', self, eq_type='ineq') - constraint_2b.add_summand(duration_in_hours, -1, time_indices[1:]) # duration(t) - constraint_2b.add_summand(duration_in_hours, 1, time_indices[0:-1]) # duration(t-1) - constraint_2b.add_summand(binary_variable, mega, time_indices[1:]) # on(t) - constraint_2b.add_constant(-1 * system_model.dt_in_hours[1:] + mega) # dt(t) - - # 3) check minimum_duration before switchOff-step - - if minimum_duration is not None: - # Note: switchOff-step is when: On(t) - On(t+1) == 1 - # Note: (last on-time period (with last timestep of period t=n) is not checked and can be shorter) - # Note: (previous values before t=1 are not recognised!) - # eq: duration(t) >= minimum_duration(t) * [On(t) - On(t+1)] for t=1..(n-1) - # eq: -duration(t) + minimum_duration(t) * On(t) - minimum_duration(t) * On(t+1) <= 0 - if minimum_duration.is_scalar: - minimum_duration_used = minimum_duration.active_data - else: - minimum_duration_used = minimum_duration.active_data[0:-1] # only checked for t=1...(n-1) - eq_min_duration = create_equation(f'{label_prefix}_minimum_duration', self, eq_type='ineq') - eq_min_duration.add_summand(duration_in_hours, -1, time_indices[0:-1]) # -duration(t) - eq_min_duration.add_summand( - binary_variable, -1 * minimum_duration_used, time_indices[1:] - ) # - minimum_duration (t) * On(t+1) - eq_min_duration.add_summand( - binary_variable, minimum_duration_used, time_indices[0:-1] - ) # minimum_duration * On(t) - - first_step_min: Skalar = ( - minimum_duration.active_data[0] if minimum_duration.is_array else minimum_duration.active_data - ) - if 0 < duration_in_hours.previous_values < first_step_min: - # Force the first step to be = 1, if the minimum_duration is not reached in previous_values - # Note: Only if the previous consecutive_duration is smaller than the minimum duration, - # and the previous_values is greater 0! - # eq: duration(t=0) = duration(t=-1) + dt(0) - eq_min_duration_inital = create_equation(f'{label_prefix}_minimum_duration_inital', self, eq_type='eq') - eq_min_duration_inital.add_summand(binary_variable, 1, time_indices[0]) - eq_min_duration_inital.add_constant(1) - - # 4) first index: - # eq: duration(t=0)= dt(0) * On(0) - first_index = time_indices[0] # only first element - eq_first = create_equation(f'{label_prefix}_initial', self) - eq_first.add_summand(duration_in_hours, 1, first_index) - eq_first.add_summand( - binary_variable, - -1 * (system_model.dt_in_hours[first_index] + duration_in_hours.previous_values), - first_index, - ) - - return duration_in_hours - - def _add_switch_constraints(self, system_model: SystemModel): - assert self.switch_on is not None, f'Switch On Variable of {self.element} must be defined to add constraints' - assert self.switch_off is not None, f'Switch Off Variable of {self.element} must be defined to add constraints' - assert self.nr_switch_on is not None, ( - f'Nr of Switch On Variable of {self.element} must be defined to add constraints' - ) - assert self.on is not None, f'On Variable of {self.element} must be defined to add constraints' - # % SchaltƤnderung aus On-Variable - # % SwitchOn(t)-SwitchOff(t) = On(t)-On(t-1) - eq_switch = create_equation('Switch', self) - eq_switch.add_summand(self.switch_on, 1, system_model.indices[1:]) # SwitchOn(t) - eq_switch.add_summand(self.switch_off, -1, system_model.indices[1:]) # SwitchOff(t) - eq_switch.add_summand(self.on, -1, system_model.indices[1:]) # On(t) - eq_switch.add_summand(self.on, +1, system_model.indices[0:-1]) # On(t-1) - - # Initital switch on - # eq: SwitchOn(t=0)-SwitchOff(t=0) = On(t=0) - On(t=-1) - eq_initial_switch = create_equation('Initial_Switch', self) - eq_initial_switch.add_summand(self.switch_on, 1, indices_of_variable=0) # SwitchOn(t=0) - eq_initial_switch.add_summand(self.switch_off, -1, indices_of_variable=0) # SwitchOff(t=0) - eq_initial_switch.add_summand(self.on, -1, indices_of_variable=0) # On(t=0) - eq_initial_switch.add_constant(-1 * self.on.previous_values[-1]) # On(t-1) - - ## Entweder SwitchOff oder SwitchOn - # eq: SwitchOn(t) + SwitchOff(t) <= 1.1 - eq_switch_on_or_off = create_equation('Switch_On_or_Off', self, eq_type='ineq') - eq_switch_on_or_off.add_summand(self.switch_on, 1) - eq_switch_on_or_off.add_summand(self.switch_off, 1) - eq_switch_on_or_off.add_constant(1.1) - - ## Anzahl Starts: - # eq: nrSwitchOn = sum(SwitchOn(t)) - eq_nr_switch_on = create_equation('NrSwitchOn', self) - eq_nr_switch_on.add_summand(self.nr_switch_on, 1) - eq_nr_switch_on.add_summand(self.switch_on, -1, as_sum=True) - - def _create_shares(self, system_model: SystemModel): - # Anfahrkosten: - effect_collection = system_model.effect_collection_model - effects_per_switch_on = self._on_off_parameters.effects_per_switch_on - if effects_per_switch_on != {}: - effect_collection.add_share_to_operation( - 'switch_on_effects', self.element, effects_per_switch_on, 1, self.switch_on - ) - - # Betriebskosten: - effects_per_running_hour = self._on_off_parameters.effects_per_running_hour - if effects_per_running_hour != {}: - effect_collection.add_share_to_operation( - 'running_hour_effects', self.element, effects_per_running_hour, system_model.dt_in_hours, self.on - ) - - def _previous_on_values(self, epsilon: float = 1e-5) -> np.ndarray: - """ - Returns the previous 'on' states of defining variables as a binary array. - - Parameters: - ---------- - epsilon : float, optional - Tolerance for equality to determine "off" state, default is 1e-5. - - Returns: - ------- - np.ndarray - A binary array (0 and 1) indicating the previous on/off states of the variables. - Returns `array([0])` if no previous values are available. - """ - previous_values = [var.previous_values for var in self._defining_variables if var.previous_values is not None] - - if not previous_values: - return np.array([0]) - else: # Convert to 2D-array and compute binary on/off states - previous_values = np.array(previous_values) - if previous_values.ndim > 1: - return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int) - else: - return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int) - - @classmethod - def get_consecutive_duration( - cls, binary_values: Union[int, np.ndarray], dt_in_hours: Union[int, float, np.ndarray] - ) -> Skalar: - """ - Returns the current consecutive duration in hours, computed from binary values. - If only one binary value is availlable, the last dt_in_hours is used. - Of both binary_values and dt_in_hours are arrays, checks that the length of dt_in_hours has at least as - many elements as the last consecutive duration in binary_values. - - Parameters - ---------- - binary_values : int, np.ndarray - An int or 1D binary array containing only `0`s and `1`s. - dt_in_hours : int, float, np.ndarray - The duration of each time step in hours. - - Returns - ------- - np.ndarray - 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(dt_in_hours): - return binary_values * dt_in_hours - elif np.isscalar(binary_values) and not np.isscalar(dt_in_hours): - return binary_values * dt_in_hours[-1] - - # Find the indexes where value=`0` in a 1D-array - zero_indices = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] - length_of_last_duration = zero_indices[-1] + 1 if zero_indices.size > 0 else len(binary_values) - - if not np.isscalar(binary_values) and np.isscalar(dt_in_hours): - return np.sum(binary_values[-length_of_last_duration:] * dt_in_hours) - - elif not np.isscalar(binary_values) and not np.isscalar(dt_in_hours): - if length_of_last_duration > len(dt_in_hours): # check that lengths are compatible - raise TypeError( - f'When trying to calculate the consecutive duration, the length of the last duration ' - f'({len(length_of_last_duration)}) is longer than the dt_in_hours ({len(dt_in_hours)}), ' - f'as {binary_values=}' - ) - return np.sum(binary_values[-length_of_last_duration:] * dt_in_hours[-length_of_last_duration:]) - - else: - raise Exception( - f'Unexpected state reached in function get_consecutive_duration(). binary_values={binary_values}; ' - f'dt_in_hours={dt_in_hours}' - ) - - -class SegmentModel(ElementModel): - """Class for modeling a linear segment of one or more variables in parallel""" - - def __init__( - self, - element: Element, - segment_index: Union[int, str], - sample_points: Dict[Variable, Tuple[Union[Numeric, TimeSeries], Union[Numeric, TimeSeries]]], - as_time_series: bool = True, - ): - super().__init__(element, f'Segment_{segment_index}') - self.element = element - self.in_segment: Optional[VariableTS] = None - self.lambda0: Optional[VariableTS] = None - self.lambda1: Optional[VariableTS] = None - - self._segment_index = segment_index - self._as_time_series = as_time_series - self.sample_points = sample_points - - def do_modeling(self, system_model: SystemModel): - length = system_model.nr_of_time_steps if self._as_time_series else 1 - self.in_segment = create_variable('inSegment', self, length, is_binary=True) - self.lambda0 = create_variable('lambda0', self, length, lower_bound=0, upper_bound=1) # Wertebereich 0..1 - self.lambda1 = create_variable('lambda1', self, length, lower_bound=0, upper_bound=1) # Wertebereich 0..1 - - # eq: -aSegment.onSeg(t) + aSegment.lambda1(t) + aSegment.lambda2(t) = 0 - equation = create_equation('inSegment', self) - - equation.add_summand(self.in_segment, -1) - equation.add_summand(self.lambda0, 1) - equation.add_summand(self.lambda1, 1) - - -class MultipleSegmentsModel(ElementModel): - # TODO: Length... - def __init__( - self, - element: Element, - sample_points: Dict[Variable, List[Tuple[Numeric, Numeric]]], - can_be_outside_segments: Optional[Union[bool, Variable]], - as_time_series: bool = True, - label: str = 'MultipleSegments', - ): - """ - can_be_outside_segments: True -> Variable gets created; - False or None -> No Variable gets_created; - Variable -> the Variable gets used - """ - super().__init__(element, label) - self.element = element - - self.outside_segments: Optional[VariableTS] = None - - self._as_time_series = as_time_series - self._can_be_outside_segments = can_be_outside_segments - self._sample_points = sample_points - self._segment_models: List[SegmentModel] = [] - - def do_modeling(self, system_model: SystemModel): - restructured_variables_with_segments: List[Dict[Variable, Tuple[Numeric, Numeric]]] = [ - {key: values[i] for key, values in self._sample_points.items()} for i in range(self._nr_of_segments) - ] - - self._segment_models = [ - SegmentModel(self.element, i, sample_points, self._as_time_series) - for i, sample_points in enumerate(restructured_variables_with_segments) - ] - - self.sub_models.extend(self._segment_models) - - for segment_model in self._segment_models: - segment_model.do_modeling(system_model) - - # eq: - v(t) + (v_0_0 * lambda_0_0 + v_0_1 * lambda_0_1) + (v_1_0 * lambda_1_0 + v_1_1 * lambda_1_1) ... = 0 - # -> v_0_0, v_0_1 = Stützstellen des Segments 0 - for variable in self._sample_points.keys(): - lambda_eq = create_equation(f'lambda_{variable.label}', self) - lambda_eq.add_summand(variable, -1) - for segment_model in self._segment_models: - lambda_eq.add_summand(segment_model.lambda0, segment_model.sample_points[variable][0]) - lambda_eq.add_summand(segment_model.lambda1, segment_model.sample_points[variable][1]) - - # 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 - in_single_segment = create_equation('in_single_Segment', self) - for segment_model in self._segment_models: - in_single_segment.add_summand(segment_model.in_segment, 1) - - # a) or b) ? - if isinstance(self._can_be_outside_segments, Variable): # Use existing Variable - self.outside_segments = self._can_be_outside_segments - in_single_segment.add_summand(self.outside_segments, -1) - elif self._can_be_outside_segments is True: # Create Variable - length = system_model.nr_of_time_steps if self._as_time_series else 1 - self.outside_segments = create_variable('outside_segments', self, length, is_binary=True) - in_single_segment.add_summand(self.outside_segments, -1) - else: # Dont allow outside Segments - in_single_segment.add_constant(1) - - @property - def _nr_of_segments(self): - return len(next(iter(self._sample_points.values()))) - - -class ShareAllocationModel(ElementModel): - def __init__( - self, - element: Element, - label: str, - shares_are_time_series: bool, - total_max: Optional[Skalar] = None, - total_min: Optional[Skalar] = None, - max_per_hour: Optional[Numeric] = None, - min_per_hour: Optional[Numeric] = None, - ): - super().__init__(element, label) - if not shares_are_time_series: # If the condition is True - assert max_per_hour is None and min_per_hour is None, ( - 'Both max_per_hour and min_per_hour cannot be used when shares_are_time_series is False' - ) - self.element = element - self.sum_TS: Optional[VariableTS] = None - self.sum: Optional[Variable] = None - self.shares: Dict[str, Variable] = {} - - self._eq_time_series: Optional[Equation] = None - self._eq_sum: Optional[Equation] = None - - # Parameters - self._shares_are_time_series = shares_are_time_series - self._total_max = total_max - self._total_min = total_min - self._max_per_hour = max_per_hour - self._min_per_hour = min_per_hour - - def do_modeling(self, system_model: SystemModel): - self.sum = create_variable( - f'{self.label}_sum', self, 1, lower_bound=self._total_min, upper_bound=self._total_max - ) - # eq: sum = sum(share_i) # skalar - self._eq_sum = create_equation(f'{self.label}_sum', self) - self._eq_sum.add_summand(self.sum, -1) - - if self._shares_are_time_series: - lb_ts = None if (self._min_per_hour is None) else np.multiply(self._min_per_hour, system_model.dt_in_hours) - ub_ts = None if (self._max_per_hour is None) else np.multiply(self._max_per_hour, system_model.dt_in_hours) - self.sum_TS = create_variable( - f'{self.label}_sum_TS', self, system_model.nr_of_time_steps, lower_bound=lb_ts, upper_bound=ub_ts - ) - - # eq: sum_TS = sum(share_TS_i) # TS - self._eq_time_series = create_equation(f'{self.label}_time_series', self) - self._eq_time_series.add_summand(self.sum_TS, -1) - - # eq: sum = sum(sum_TS(t)) # additionaly to self.sum - self._eq_sum.add_summand(self.sum_TS, 1, as_sum=True) - - def add_share( - self, - system_model: SystemModel, - name_of_share: str, - variable: Optional[Variable], - factor: Numeric, - share_as_sum: bool = False, - ): - """ - Adding a Share to a Share Allocation Model. - """ - # TODO: accept only one factor or accept unlimited factors -> *factors - - # Check to which equation the share should be added - if share_as_sum or not self._shares_are_time_series: - target_eq = self._eq_sum - else: - target_eq = self._eq_time_series - - new_share = SingleShareModel(self.element, name_of_share, variable, factor, share_as_sum) - target_eq.add_summand(new_share.single_share, 1) - - self.sub_models.append(new_share) - assert new_share.label not in self.shares, ( - f'A Share with the label {new_share.label} was already present in {self.label}' - ) - self.shares[new_share.label] = new_share.single_share - - def results(self): - return { - **{variable.label_short: variable.result for variable in self.variables.values()}, - **{'Shares': {variable.label_short: variable.result for variable in self.shares.values()}}, - } - - -class SingleShareModel(ElementModel): - """Holds a Variable and an Equation. Summands can be added to the Equation. Used to publish Shares""" - - def __init__(self, element: Element, name: str, variable: Optional[Variable], factor: Numeric, share_as_sum: bool): - super().__init__(element, name) - if variable is not None: - assert not (variable.length == 1 and share_as_sum), 'A Variable with the length 1 cannot be summed up!' - - if ( - share_as_sum - or (variable is not None and variable.length == 1) - or (variable is None and np.isscalar(factor)) - ): - self.single_share = Variable(self.label_full, 1, self.label) - elif variable is not None: - self.single_share = VariableTS(self.label_full, variable.length, self.label) - else: - raise Exception('This case is not yet covered for a SingleShareModel') - - self.add_variables(self.single_share) - self.single_equation = create_equation(self.label_full, self) - self.single_equation.add_summand(self.single_share, -1) - - if variable is None: - self.single_equation.add_constant(-1 * np.sum(factor) if share_as_sum else -1 * factor) - else: - self.single_equation.add_summand(variable, factor, as_sum=share_as_sum) - - -class SegmentedSharesModel(ElementModel): - # TODO: Length... - def __init__( - self, - element: Element, - variable_segments: Tuple[Variable, List[Tuple[Skalar, Skalar]]], - share_segments: Dict['Effect', List[Tuple[Skalar, Skalar]]], - can_be_outside_segments: Optional[Union[bool, Variable]], - label: str = 'SegmentedShares', - ): - super().__init__(element, label) - assert len(variable_segments[1]) == len(list(share_segments.values())[0]), ( - 'Segment length of variable_segments and share_segments must be equal' - ) - self.element: Element - self._can_be_outside_segments = can_be_outside_segments - self._variable_segments = variable_segments - self._share_segments = share_segments - self._shares: Optional[Dict['Effect', SingleShareModel]] = None - self._segments_model: Optional[MultipleSegmentsModel] = None - self._as_tme_series: bool = isinstance(self._variable_segments[0], VariableTS) - - def do_modeling(self, system_model: SystemModel): - length = system_model.nr_of_time_steps if self._as_tme_series else 1 - self._shares = { - effect: create_variable(f'{effect.label}_segmented', self, length) for effect in self._share_segments - } - - segments: Dict[Variable, List[Tuple[Skalar, Skalar]]] = { - **{self._shares[effect]: segment for effect, segment in self._share_segments.items()}, - **{self._variable_segments[0]: self._variable_segments[1]}, - } - - self._segments_model = MultipleSegmentsModel( - self.element, - segments, - can_be_outside_segments=self._can_be_outside_segments, - as_time_series=self._as_tme_series, - ) - self._segments_model.do_modeling(system_model) - self.sub_models.append(self._segments_model) - - # Shares - effect_collection = system_model.effect_collection_model - for effect, variable in self._shares.items(): - if self._as_tme_series: - effect_collection.add_share_to_operation( - name='segmented_effects', - element=self.element, - effect_values={effect: 1}, - factor=1, - variable=variable, - ) - else: - effect_collection.add_share_to_invest( - name='segmented_effects', - element=self.element, - effect_values={effect: 1}, - factor=1, - variable=variable, - ) - - -class PreventSimultaneousUsageModel(ElementModel): - """ - 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, element: Element, variables: List[VariableTS], label: str = 'PreventSimultaneousUsage'): - super().__init__(element, label) - self._variables = variables - assert len(self._variables) >= 2, f'Model {self.__class__.__name__} must get at least two variables' - for variable in self._variables: # classic - assert variable.is_binary, f'Variable {variable} must be binary for use in {self.__class__.__name__}' - - def do_modeling(self, system_model: SystemModel): - # eq: sum(flow_i.on(t)) <= 1.1 (1 wird etwas größer gewƤhlt wg. BinƤrvariablengenauigkeit) - eq = create_equation('prevent_simultaneous_use', self, eq_type='ineq') - for variable in self._variables: - eq.add_summand(variable, 1) - eq.add_constant(1.1) diff --git a/flixOpt/flow_system.py b/flixOpt/flow_system.py deleted file mode 100644 index 42c2fbe7f..000000000 --- a/flixOpt/flow_system.py +++ /dev/null @@ -1,351 +0,0 @@ -""" -This module contains the FlowSystem class, which is used to collect instances of many other classes by the end User. -""" - -import json -import logging -import pathlib -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union - -import numpy as np - -from . import utils -from .core import TimeSeries -from .effects import Effect, EffectCollection -from .elements import Bus, Component, Flow -from .structure import Element, SystemModel, get_compact_representation, get_str_representation - -if TYPE_CHECKING: - import pyvis - -logger = logging.getLogger('flixOpt') - - -class FlowSystem: - """ - A FlowSystem organizes the high level Elements (Components & Effects). - """ - - def __init__( - self, - time_series: np.ndarray[np.datetime64], - last_time_step_hours: Optional[Union[int, float]] = None, - previous_dt_in_hours: Optional[Union[int, float, np.ndarray]] = None, - ): - """ - Parameters - ---------- - time_series : np.ndarray of datetime64 - timeseries of the data. Must be in datetime64 format. Don't use precisions below 'us'. !np.datetime64[ns]! - last_time_step_hours : - The duration of last time step. - Storages needs this time-duration for calculation of charge state - after last time step. - If None, then last time increment of time_series is used. - previous_dt_in_hours : Union[int, float, np.ndarray] - The duration of previous time steps. - If None, the first time increment of time_series is used. - This is needed to calculate previous durations (for example consecutive_on_hours). - If you use an array, take care that its long enough to cover all previous values! - """ - self.time_series = time_series if isinstance(time_series, np.ndarray) else np.array(time_series) - if self.time_series.dtype == np.dtype('datetime64[ns]'): - self.time_series = self.time_series.astype('datetime64[us]') - - self.last_time_step_hours = ( - self.time_series[-1] - self.time_series[-2] if last_time_step_hours is None else last_time_step_hours - ) - self.time_series_with_end = np.append(self.time_series, self.time_series[-1] + self.last_time_step_hours) - self.previous_dt_in_hours: Union[int, float, np.ndarray] = ( - ((self.time_series[1] - self.time_series[0]) / np.timedelta64(1, 'h')) - if previous_dt_in_hours is None - else previous_dt_in_hours - ) - - utils.check_time_series('time series of FlowSystem', self.time_series_with_end) - - # defaults: - self.components: Dict[str, Component] = {} - self.effect_collection: EffectCollection = EffectCollection('Effects') # Organizes Effects, Penalty & Objective - self.model: Optional[SystemModel] = None - - def add_effects(self, *args: Effect) -> None: - for new_effect in list(args): - logger.info(f'Registered new Effect: {new_effect.label}') - self.effect_collection.add_effect(new_effect) - - def add_components(self, *args: Component) -> None: - # Komponenten registrieren: - new_components = list(args) - for new_component in new_components: - logger.info(f'Registered new Component: {new_component.label}') - self._check_if_element_is_unique(new_component) # check if already exists: - new_component.register_component_in_flows() # Komponente in Flow registrieren - new_component.register_flows_in_bus() # Flows in Bus registrieren: - self.components[new_component.label] = new_component # Add to existing components - - def add_elements(self, *args: Element) -> None: - """ - add all modeling elements, like storages, boilers, heatpumps, buses, ... - - Parameters - ---------- - *args : childs of Element like Boiler, HeatPump, Bus,... - modeling Elements - - """ - for new_element in list(args): - if isinstance(new_element, Component): - self.add_components(new_element) - elif isinstance(new_element, Effect): - self.add_effects(new_element) - else: - raise Exception('argument is not instance of a modeling Element (Element)') - - def transform_data(self): - for element in self.all_elements.values(): - element.transform_data() - - def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, str]]]: - nodes = { - node.label_full: { - 'label': node.label, - 'class': 'Bus' if isinstance(node, Bus) else 'Component', - 'infos': node.__str__(), - } - for node in list(self.components.values()) + list(self.buses.values()) - } - - edges = { - flow.label_full: { - 'label': flow.label, - 'start': flow.bus.label_full if flow.is_input_in_comp else flow.comp.label_full, - 'end': flow.comp.label_full if flow.is_input_in_comp else flow.bus.label_full, - 'infos': flow.__str__(), - } - for flow in self.flows.values() - } - - return nodes, edges - - def infos(self, use_numpy=True, use_element_label=False) -> Dict: - infos = { - 'Components': { - comp.label: comp.infos(use_numpy, use_element_label) - for comp in sorted(self.components.values(), key=lambda component: component.label.upper()) - }, - 'Buses': { - bus.label: bus.infos(use_numpy, use_element_label) - for bus in sorted(self.buses.values(), key=lambda bus: bus.label.upper()) - }, - 'Effects': { - effect.label: effect.infos(use_numpy, use_element_label) - for effect in sorted(self.effect_collection.effects.values(), key=lambda effect: effect.label.upper()) - }, - } - return infos - - def to_json(self, path: Union[str, pathlib.Path]): - """ - Saves the flow system to a json file. - This not meant to be reloaded and recreate the object, but rather used to document or compare the object. - - Parameters: - ----------- - path : Union[str, pathlib.Path] - The path to the json file. - """ - data = get_compact_representation(self.infos(use_numpy=True, use_element_label=True)) - with open(path, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=4, ensure_ascii=False) - - def visualize_network( - self, - path: Union[bool, str, pathlib.Path] = 'flow_system.html', - controls: Union[ - bool, - List[ - Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'] - ], - ] = True, - show: bool = True, - ) -> Optional['pyvis.network.Network']: - """ - Visualizes the network structure of a FlowSystem using PyVis, saving it as an interactive HTML file. - - Parameters: - - path (Union[bool, str, pathlib.Path], default='flow_system.html'): - Path to save the HTML visualization. - - `False`: Visualization is created but not saved. - - `str` or `Path`: Specifies file path (default: 'flow_system.html'). - - - controls (Union[bool, List[str]], default=True): - UI controls to add to the visualization. - - `True`: Enables all available controls. - - `List`: Specify controls, e.g., ['nodes', 'layout']. - - Options: 'nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'. - - - show (bool, default=True): - Whether to open the visualization in the web browser. - - Returns: - - Optional[pyvis.network.Network]: The `Network` instance representing the visualization, or `None` if `pyvis` is not installed. - - Usage: - - Visualize and open the network with default options: - >>> self.visualize_network() - - - Save the visualization without opening: - >>> self.visualize_network(show=False) - - - Visualize with custom controls and path: - >>> self.visualize_network(path='output/custom_network.html', controls=['nodes', 'layout']) - - Notes: - - This function requires `pyvis`. If not installed, the function prints a warning and returns `None`. - - Nodes are styled based on type (e.g., circles for buses, boxes for components) and annotated with node information. - """ - from . import plotting - - node_infos, edge_infos = self.network_infos() - return plotting.visualize_network(node_infos, edge_infos, path, controls, show) - - def _check_if_element_is_unique(self, element: Element) -> None: - """ - checks if element or label of element already exists in list - - Parameters - ---------- - element : Element - new element to check - """ - if element in self.all_elements: - raise Exception(f'Element {element.label} already added to FlowSystem!') - # check if name is already used: - if element.label_full in self.all_elements: - raise Exception(f'Label of Element {element.label} already used in another element!') - - def get_time_data_from_indices( - self, time_indices: Optional[Union[List[int], range]] = None - ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.float64]: - """ - Computes time series data based on the provided time indices. - - Args: - time_indices: A list of indices or a range object indicating which time steps to extract. - If None, the entire time series is used. - - Returns: - A tuple containing: - - Extracted time series - - Time series with the "end time" appended - - Differences between consecutive timestamps in hours - - Total time in hours - """ - # If time_indices is None, use the full time series range - if time_indices is None: - time_indices = range(len(self.time_series)) - - # Extract the time series for the provided indices - time_series = self.time_series[time_indices] - - # Ensure the next timestamp for end time is within bounds - last_index = time_indices[-1] - if last_index + 1 < len(self.time_series_with_end): - end_time = self.time_series_with_end[last_index + 1] - else: - raise IndexError(f"Index {last_index + 1} out of bounds for 'self.time_series_with_end'.") - - # Append end time to the time series - time_series_with_end = np.append(time_series, end_time) - - # Calculate time differences (time deltas) in hours - time_deltas = time_series_with_end[1:] - time_series_with_end[:-1] - dt_in_hours = time_deltas / np.timedelta64(1, 'h') - - # Calculate the total time in hours - dt_in_hours_total = np.sum(dt_in_hours) - - return time_series, time_series_with_end, dt_in_hours, dt_in_hours_total - - def __repr__(self): - return f'<{self.__class__.__name__} with {len(self.components)} components and {len(self.effect_collection.effects)} effects>' - - def __str__(self): - return get_str_representation(self.infos(use_numpy=True, use_element_label=True)) - - @property - def flows(self) -> Dict[str, Flow]: - set_of_flows = {flow for comp in self.components.values() for flow in comp.inputs + comp.outputs} - return {flow.label_full: flow for flow in set_of_flows} - - @property - def buses(self) -> Dict[str, Bus]: - return {flow.bus.label: flow.bus for flow in self.flows.values()} - - @property - def all_elements(self) -> Dict[str, Element]: - return {**self.components, **self.effect_collection.effects, **self.flows, **self.buses} - - @property - def all_time_series(self) -> List[TimeSeries]: - return [ts for element in self.all_elements.values() for ts in element.used_time_series] - - -def create_datetime_array( - start: str, steps: Optional[int] = None, freq: str = '1h', end: Optional[str] = None -) -> np.ndarray[np.datetime64]: - """ - Create a NumPy array with datetime64 values. - - Parameters - ---------- - start : str - Start date in 'YYYY-MM-DD' format or a full timestamp (e.g., 'YYYY-MM-DD HH:MM'). - steps : int, optional - Number of steps in the datetime array. If `end` is provided, `steps` is ignored. - freq : str, optional - Frequency for the datetime64 array. Supports flexible intervals: - - 'Y', 'M', 'W', 'D', 'h', 'm', 's' (e.g., '1h', '15m', '2h'). - Defaults to 'h' (hourly). - end : str, optional - End date in 'YYYY-MM-DD' format or a full timestamp (e.g., 'YYYY-MM-DD HH:MM'). - If provided, the function generates an array from `start` to `end` using `freq`. - - Returns - ------- - np.ndarray - NumPy array of datetime64 values. - - Examples - -------- - Create an array with 15-minute intervals: - >>> create_datetime_array('2023-01-01', steps=5, freq='15m') - array(['2023-01-01T00:00', '2023-01-01T00:15', '2023-01-01T00:30', ...], dtype='datetime64[m]') - - Create 2-hour intervals: - >>> create_datetime_array('2023-01-01T00', steps=4, freq='2h') - array(['2023-01-01T00', '2023-01-01T02', '2023-01-01T04', ...], dtype='datetime64[h]') - - Generate minute intervals until a specified end time: - >>> create_datetime_array('2023-01-01T00:00', end='2023-01-01T01:00', freq='m') - array(['2023-01-01T00:00', '2023-01-01T00:01', ..., '2023-01-01T00:59'], dtype='datetime64[m]') - """ - # Parse the frequency and interval - unit = freq[-1] # Get the time unit (e.g., 'h', 'm', 's') - interval = int(freq[:-1]) if freq[:-1].isdigit() else 1 # Default to interval=1 if not specified - step_size = np.timedelta64(interval, unit) # Create the timedelta step size - - # Convert the start time to a datetime64 object - start_dt = np.datetime64(start) - - # Generate the array based on the parameters - if end: # If `end` is specified, create a range from start to end - end_dt = np.datetime64(end) - return np.arange(start_dt, end_dt, step_size) - - elif steps: # If `steps` is specified, create a range with the given number of steps - return np.array([start_dt + i * step_size for i in range(steps)], dtype='datetime64') - - else: # If neither `steps` nor `end` is provided, raise an error - raise ValueError('Either `steps` or `end` must be provided.') diff --git a/flixOpt/interface.py b/flixOpt/interface.py deleted file mode 100644 index 900d7d9b8..000000000 --- a/flixOpt/interface.py +++ /dev/null @@ -1,203 +0,0 @@ -""" -This module contains classes to collect Parameters for the Investment and OnOff decisions. -These are tightly connected to features.py -""" - -import logging -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union - -from .config import CONFIG -from .core import Numeric, Numeric_TS, Skalar -from .structure import Element, Interface - -if TYPE_CHECKING: - from .effects import Effect, EffectTimeSeries, EffectValues, EffectValuesInvest - -logger = logging.getLogger('flixOpt') - - -class InvestParameters(Interface): - """ - collects arguments for invest-stuff - """ - - def __init__( - self, - fixed_size: Optional[Union[int, float]] = None, - minimum_size: Union[int, float] = 0, # TODO: Use EPSILON? - maximum_size: Optional[Union[int, float]] = None, - optional: bool = True, # Investition ist weglassbar - fix_effects: Union[Dict, int, float] = None, - specific_effects: Union[Dict, int, float] = None, # costs per Flow-Unit/Storage-Size/... - effects_in_segments: Optional[ - Tuple[List[Tuple[Skalar, Skalar]], Dict['Effect', List[Tuple[Skalar, Skalar]]]] - ] = None, - divest_effects: Union[Dict, int, float] = None, - ): - """ - Parameters - ---------- - fix_effects : None or scalar, optional - Fixed investment costs if invested. - (Attention: Annualize costs to chosen period!) - divest_effects : None or scalar, optional - Fixed divestment costs (if not invested, e.g., demolition costs or contractual penalty). - fixed_size : int, float, optional - Determines if the investment size is fixed. - optional : bool, optional - If True, investment is not forced. - specific_effects : scalar or Dict[Effect: Union[int, float, np.ndarray], optional - Specific costs, e.g., in €/kW_nominal or €/m²_nominal. - Example: {costs: 3, CO2: 0.3} with costs and CO2 representing an Object of class Effect - (Attention: Annualize costs to chosen period!) - effects_in_segments : list or List[ List[Union[int,float]], Dict[cEffecType: Union[List[Union[int,float]], optional - Linear relation in segments [invest_segments, cost_segments]. - Example 1: - [ [5, 25, 25, 100], # size in kW - {costs: [50,250,250,800], # € - PE: [5, 25, 25, 100] # kWh_PrimaryEnergy - } - ] - Example 2 (if only standard-effect): - [ [5, 25, 25, 100], # kW # size in kW - [50,250,250,800] # value for standart effect, typically € - ] # € - (Attention: Annualize costs to chosen period!) - (Args 'specific_effects' and 'fix_effects' can be used in parallel to InvestsizeSegments) - minimum_size : scalar - Min nominal value (only if: size_is_fixed = False). - maximum_size : scalar, Optional - Max nominal value (only if: size_is_fixed = False). - """ - self.fix_effects: EffectValuesInvest = fix_effects or {} - self.divest_effects: EffectValuesInvest = divest_effects or {} - self.fixed_size = fixed_size - self.optional = optional - self.specific_effects: EffectValuesInvest = specific_effects or {} - self.effects_in_segments = effects_in_segments - self._minimum_size = minimum_size - self._maximum_size = maximum_size or CONFIG.modeling.BIG # default maximum - - def transform_data(self): - from .effects import as_effect_dict - - self.fix_effects = as_effect_dict(self.fix_effects) - self.divest_effects = as_effect_dict(self.divest_effects) - self.specific_effects = as_effect_dict(self.specific_effects) - - @property - def minimum_size(self): - return self.fixed_size or self._minimum_size - - @property - def maximum_size(self): - return self.fixed_size or self._maximum_size - - -class OnOffParameters(Interface): - def __init__( - self, - effects_per_switch_on: Union[Dict, Numeric] = None, - effects_per_running_hour: Union[Dict, Numeric] = None, - on_hours_total_min: Optional[int] = None, - on_hours_total_max: Optional[int] = None, - consecutive_on_hours_min: Optional[Numeric] = None, - consecutive_on_hours_max: Optional[Numeric] = None, - consecutive_off_hours_min: Optional[Numeric] = None, - consecutive_off_hours_max: Optional[Numeric] = None, - switch_on_total_max: Optional[int] = None, - force_switch_on: bool = False, - ): - """ - on_off_parameters class for modeling on and off state of an Element. - If no parameters are given, the default is to create a binary variable for the on state - without further constraints or effects and a variable for the total on hours. - - Parameters - ---------- - effects_per_switch_on : scalar, array, TimeSeriesData, optional - cost of one switch from off (var_on=0) to on (var_on=1), - unit i.g. in Euro - effects_per_running_hour : scalar or TS, optional - costs for operating, i.g. in € per hour - on_hours_total_min : scalar, optional - min. overall sum of operating hours. - on_hours_total_max : scalar, optional - max. overall sum of operating hours. - consecutive_on_hours_min : scalar, optional - min sum of operating hours in one piece - (last on-time period of timeseries is not checked and can be shorter) - consecutive_on_hours_max : scalar, optional - max sum of operating hours in one piece - consecutive_off_hours_min : scalar, optional - min sum of non-operating hours in one piece - (last off-time period of timeseries is not checked and can be shorter) - consecutive_off_hours_max : scalar, optional - max sum of non-operating hours in one piece - switch_on_total_max : integer, optional - max nr of switchOn operations - force_switch_on : bool - force creation of switch on variable, even if there is no switch_on_total_max - """ - self.effects_per_switch_on: Union[EffectValues, EffectTimeSeries] = effects_per_switch_on or {} - self.effects_per_running_hour: Union[EffectValues, EffectTimeSeries] = effects_per_running_hour or {} - self.on_hours_total_min: Skalar = on_hours_total_min - self.on_hours_total_max: Skalar = on_hours_total_max - self.consecutive_on_hours_min: Numeric_TS = consecutive_on_hours_min - self.consecutive_on_hours_max: Numeric_TS = consecutive_on_hours_max - self.consecutive_off_hours_min: Numeric_TS = consecutive_off_hours_min - self.consecutive_off_hours_max: Numeric_TS = consecutive_off_hours_max - self.switch_on_total_max: Skalar = switch_on_total_max - self.force_switch_on: bool = force_switch_on - - def transform_data(self, owner: 'Element'): - from .effects import effect_values_to_time_series - from .structure import _create_time_series - - self.effects_per_switch_on = effect_values_to_time_series('per_switch_on', self.effects_per_switch_on, owner) - self.effects_per_running_hour = effect_values_to_time_series( - 'per_running_hour', self.effects_per_running_hour, owner - ) - self.consecutive_on_hours_min = _create_time_series( - 'consecutive_on_hours_min', self.consecutive_on_hours_min, owner - ) - self.consecutive_on_hours_max = _create_time_series( - 'consecutive_on_hours_max', self.consecutive_on_hours_max, owner - ) - self.consecutive_off_hours_min = _create_time_series( - 'consecutive_off_hours_min', self.consecutive_off_hours_min, owner - ) - self.consecutive_off_hours_max = _create_time_series( - 'consecutive_off_hours_max', self.consecutive_off_hours_max, owner - ) - - @property - def use_off(self) -> bool: - """Determines wether the OFF Variable is needed or not""" - return self.use_consecutive_off_hours - - @property - def use_consecutive_on_hours(self) -> bool: - """Determines wether a Variable for consecutive off hours is needed or not""" - return any(param is not None for param in [self.consecutive_on_hours_min, self.consecutive_on_hours_max]) - - @property - def use_consecutive_off_hours(self) -> bool: - """Determines wether a Variable for consecutive off hours is needed or not""" - return any(param is not None for param in [self.consecutive_off_hours_min, self.consecutive_off_hours_max]) - - @property - def use_switch_on(self) -> bool: - """Determines wether a Variable for SWITCH-ON is needed or not""" - return ( - any( - param not in (None, {}) - for param in [ - self.effects_per_switch_on, - self.switch_on_total_max, - self.on_hours_total_min, - self.on_hours_total_max, - ] - ) - or self.force_switch_on - ) diff --git a/flixOpt/linear_converters.py b/flixOpt/linear_converters.py deleted file mode 100644 index 65e6e88b7..000000000 --- a/flixOpt/linear_converters.py +++ /dev/null @@ -1,325 +0,0 @@ -""" -This Module contains high-level classes to easily model a FlowSystem. -""" - -import logging -from typing import Dict, Optional - -import numpy as np - -from .components import LinearConverter -from .core import Numeric_TS, TimeSeriesData -from .elements import Flow -from .interface import OnOffParameters - -logger = logging.getLogger('flixOpt') - - -class Boiler(LinearConverter): - def __init__( - self, - label: str, - eta: Numeric_TS, - Q_fu: Flow, - Q_th: Flow, - on_off_parameters: OnOffParameters = None, - meta_data: Optional[Dict] = None, - ): - """ - constructor for boiler - - Parameters - ---------- - label : str - name of bolier. - eta : float or TS - thermal efficiency. - Q_fu : Flow - fuel input-flow - Q_th : Flow - thermal output-flow. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - """ - super().__init__( - label, - inputs=[Q_fu], - outputs=[Q_th], - conversion_factors=[{Q_fu: eta, Q_th: 1}], - on_off_parameters=on_off_parameters, - meta_data=meta_data, - ) - - self.eta = eta - self.Q_fu = Q_fu - self.Q_th = Q_th - - check_bounds(eta, 'eta', self.label_full, 0, 1) - - -class Power2Heat(LinearConverter): - def __init__( - self, - label: str, - eta: Numeric_TS, - P_el: Flow, - Q_th: Flow, - on_off_parameters: OnOffParameters = None, - meta_data: Optional[Dict] = None, - ): - """ - Parameters - ---------- - label : str - name of bolier. - eta : float or TS - thermal efficiency. - P_el : Flow - electric input-flow - Q_th : Flow - thermal output-flow. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - - """ - super().__init__( - label, - inputs=[P_el], - outputs=[Q_th], - conversion_factors=[{P_el: eta, Q_th: 1}], - on_off_parameters=on_off_parameters, - meta_data=meta_data, - ) - - self.eta = eta - self.P_el = P_el - self.Q_th = Q_th - - check_bounds(eta, 'eta', self.label_full, 0, 1) - - -class HeatPump(LinearConverter): - def __init__( - self, - label: str, - COP: Numeric_TS, - P_el: Flow, - Q_th: Flow, - on_off_parameters: OnOffParameters = None, - meta_data: Optional[Dict] = None, - ): - """ - Parameters - ---------- - label : str - name of heatpump. - COP : float or TS - Coefficient of performance. - P_el : Flow - electricity input-flow. - Q_th : Flow - thermal output-flow. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - """ - super().__init__( - label, - inputs=[P_el], - outputs=[Q_th], - conversion_factors=[{P_el: COP, Q_th: 1}], - on_off_parameters=on_off_parameters, - meta_data=meta_data, - ) - - self.COP = COP - self.P_el = P_el - self.Q_th = Q_th - - check_bounds(COP, 'COP', self.label_full, 1, 20) - - -class CoolingTower(LinearConverter): - def __init__( - self, - label: str, - specific_electricity_demand: Numeric_TS, - P_el: Flow, - Q_th: Flow, - on_off_parameters: OnOffParameters = None, - meta_data: Optional[Dict] = None, - ): - """ - Parameters - ---------- - label : str - name of cooling tower. - specific_electricity_demand : float or TS - auxiliary electricty demand per cooling power, i.g. 0.02 (2 %). - P_el : Flow - electricity input-flow. - Q_th : Flow - thermal input-flow. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - - """ - super().__init__( - label, - inputs=[P_el, Q_th], - outputs=[], - conversion_factors=[{P_el: 1, Q_th: -specific_electricity_demand}], - on_off_parameters=on_off_parameters, - meta_data=meta_data, - ) - - self.specific_electricity_demand = specific_electricity_demand - self.P_el = P_el - self.Q_th = Q_th - - check_bounds(specific_electricity_demand, 'specific_electricity_demand', self.label_full, 0, 1) - - -class CHP(LinearConverter): - def __init__( - self, - label: str, - eta_th: Numeric_TS, - eta_el: Numeric_TS, - Q_fu: Flow, - P_el: Flow, - Q_th: Flow, - on_off_parameters: OnOffParameters = None, - meta_data: Optional[Dict] = None, - ): - """ - constructor of cCHP - - Parameters - ---------- - label : str - name of CHP-unit. - eta_th : float or TS - thermal efficiency. - eta_el : float or TS - electrical efficiency. - Q_fu : cFlow - fuel input-flow. - P_el : cFlow - electricity output-flow. - Q_th : cFlow - heat output-flow. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - """ - heat = {Q_fu: eta_th, Q_th: 1} - electricity = {Q_fu: eta_el, P_el: 1} - - super().__init__( - label, - inputs=[Q_fu], - outputs=[Q_th, P_el], - conversion_factors=[heat, electricity], - on_off_parameters=on_off_parameters, - meta_data=meta_data, - ) - - # args to attributes: - self.eta_th = eta_th - self.eta_el = eta_el - self.Q_fu = Q_fu - self.P_el = P_el - self.Q_th = Q_th - - check_bounds(eta_th, 'eta_th', self.label_full, 0, 1) - check_bounds(eta_el, 'eta_el', self.label_full, 0, 1) - check_bounds(eta_el + eta_th, 'eta_th+eta_el', self.label_full, 0, 1) - - -class HeatPumpWithSource(LinearConverter): - def __init__( - self, - label: str, - COP: Numeric_TS, - P_el: Flow, - Q_ab: Flow, - Q_th: Flow, - on_off_parameters: OnOffParameters = None, - meta_data: Optional[Dict] = None, - ): - """ - Parameters - ---------- - label : str - name of heatpump. - COP : float, TS - Coefficient of performance. - Q_ab : Flow - Heatsource input-flow. - P_el : Flow - electricity input-flow. - Q_th : Flow - thermal output-flow. - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - """ - - # super: - electricity = {P_el: COP, Q_th: 1} - heat_source = {Q_ab: COP / (COP - 1), Q_th: 1} - - super().__init__( - label, - inputs=[P_el, Q_ab], - outputs=[Q_th], - conversion_factors=[electricity, heat_source], - on_off_parameters=on_off_parameters, - meta_data=meta_data, - ) - - self.COP = COP - self.P_el = P_el - self.Q_ab = Q_ab - self.Q_th = Q_th - - check_bounds(COP, 'eta_th', self.label_full, 1, 20) - - -def check_bounds( - value: Numeric_TS, parameter_label: str, element_label: str, lower_bound: Numeric_TS, upper_bound: Numeric_TS -): - """ - Check if the value is within the bounds. The bounds are exclusive. - If not, log a warning. - Parameters - ---------- - value: Numeric_TS - The value to check. - parameter_label: str - The label of the value. - element_label: str - The label of the element. - lower_bound: Numeric_TS - The lower bound. - upper_bound: Numeric_TS - The upper bound. - - Returns - ------- - - """ - if isinstance(value, TimeSeriesData): - value = value.data - if isinstance(lower_bound, TimeSeriesData): - lower_bound = lower_bound.data - if isinstance(upper_bound, TimeSeriesData): - upper_bound = upper_bound.data - if not np.all(value > lower_bound): - logger.warning( - f"'{element_label}.{parameter_label}' is equal or below the common lower bound {lower_bound}." - f' {parameter_label}.min={np.min(value)}; {parameter_label}={value}' - ) - if not np.all(value < upper_bound): - logger.warning( - f"'{element_label}.{parameter_label}' exceeds or matches the common upper bound {upper_bound}." - f' {parameter_label}.max={np.max(value)}; {parameter_label}={value}' - ) diff --git a/flixOpt/math_modeling.py b/flixOpt/math_modeling.py deleted file mode 100644 index 4fe7d70c0..000000000 --- a/flixOpt/math_modeling.py +++ /dev/null @@ -1,1145 +0,0 @@ -""" -This module contains the mathematical core of the flixOpt framework. -THe module is designed to be used by other modules than flixOpt itself. -It holds all necessary classes and functions to create a mathematical model, consisting of Varaibles and constraints, -and translate it into a ModelingLanguage like Pyomo, and the solve it through a solver. -Multiple solvers are supported. -""" - -import logging -import re -import timeit -from abc import ABC, abstractmethod -from typing import Any, Dict, List, Literal, Optional, Union - -import numpy as np -import pyomo.environ as pyo - -from . import utils -from .core import Numeric - -logger = logging.getLogger('flixOpt') - - -class Variable: - """ - Variable class - """ - - def __init__( - self, - label: str, - length: int, - label_short: Optional[str] = None, - is_binary: bool = False, - fixed_value: Optional[Numeric] = None, - lower_bound: Optional[Numeric] = None, - upper_bound: Optional[Numeric] = None, - ): - """ - label: full label of the variable - label_short: short label of the variable - - # TODO: Allow for None values in fixed_value. If None, the index gets not fixed! - """ - self.label = label - self.label_short = label_short or label - self.length = length - self.is_binary = is_binary - self.fixed_value = fixed_value - self.lower_bound = lower_bound - self.upper_bound = upper_bound - - self.indices = range(self.length) - self.fixed = False - - self.result = None # Ergebnis-Speicher - - if self.fixed_value is not None: # Check if value is within bounds, element-wise - above = self.lower_bound is None or np.all(np.asarray(self.fixed_value) >= np.asarray(self.lower_bound)) - below = self.upper_bound is None or np.all(np.asarray(self.fixed_value) <= np.asarray(self.upper_bound)) - if not (above and below): - raise Exception( - f'Fixed value of Variable {self.label} not inside set bounds:' - f'\n{self.fixed_value=};\n{self.lower_bound=};\n{self.upper_bound=}' - ) - - # Mark as fixed - self.fixed = True - - logger.debug('Variable created: ' + self.label) - - def description(self, max_length_ts=60) -> str: - bin_type = 'bin' if self.is_binary else ' ' - - header = f'Var {bin_type} x {self.length:<6} "{self.label}"' - if self.fixed: - description = f'{header:<40}: fixed={str(self.fixed_value)[:max_length_ts]:<10}' - else: - description = ( - f'{header:<40}: min={str(self.lower_bound)[:max_length_ts]:<10}, ' - f'max={str(self.upper_bound)[:max_length_ts]:<10}' - ) - return description - - def reset_result(self): - self.result = None - - -class VariableTS(Variable): - """ - Timeseries-Variable, optionally with previous_values. class for Variables that are related by time - """ - - def __init__( - self, - label: str, - length: int, - label_short: Optional[str] = None, - is_binary: bool = False, - fixed_value: Optional[Numeric] = None, - lower_bound: Optional[Numeric] = None, - upper_bound: Optional[Numeric] = None, - previous_values: Optional[Numeric] = None, - ): - assert length > 1, 'length is one, that seems not right for VariableTS' - super().__init__( - label, - length, - label_short, - is_binary=is_binary, - fixed_value=fixed_value, - lower_bound=lower_bound, - upper_bound=upper_bound, - ) - self.previous_values = previous_values - - -class _Constraint: - """ - Abstract Class for Constraints. Use Child classes! - - """ - - def __init__(self, label: str, label_short: Optional[str] = None): - """ - Equation of the form: āˆ‘() = type: 'eq' - Equation of the form: āˆ‘() <= type: 'ineq' - Equation of the form: āˆ‘() = type: 'objective' - - Parameters - ---------- - label: full label of the variable - label_short: short label of the variable. If None, the the full label is used - """ - self.label = label - self.label_short = label_short or label - self.summands: List[SumOfSummand] = [] - self.parts_of_constant: List[Numeric] = [] - self.constant: Numeric = 0 # Total of right side - - self.length = 1 # Anzahl der Gleichungen - - logger.debug(f'Equation created: {self.label}') - - def add_summand( - self, - variable: Variable, - factor: Numeric, - indices_of_variable: Optional[Union[int, np.ndarray, range, List[int]]] = None, - as_sum: bool = False, - ) -> None: - """ - Adds a summand to the left side of the equation. - - This method creates a summand from the given variable and factor, optionally summing over all given indices. - The summand is then added to the summands of the equation, which represent the left side. - - Parameters: - ----------- - variable : Variable - The variable to be used in the summand. - factor : Numeric - The factor by which the variable is multiplied. - indices_of_variable : Optional[Numeric], optional - Specific indices of the variable to be used. If not provided, all indices are used. - as_sum : bool, optional - If True, the summand is treated as a sum over all indices of the variable. - - Raises: - ------- - TypeError - If the provided variable is not an instance of the Variable class. - ValueError - If the variable is None and as_sum is True. - ValueError - If the length doesnt match the Equation's length. - """ - # TODO: Functionality to create A Sum of Summand over a specified range of indices? For Limiting stuff per one year...? - if not isinstance(variable, Variable): - raise TypeError(f'Error in Equation "{self.label}": no variable given (variable = "{variable}")') - if variable is None and as_sum: - raise ValueError(f'Error in Equation "{self.label}": Variable can not be None and be summed up!') - - if np.isscalar(indices_of_variable): # Wenn nur ein Wert, dann Liste mit einem Eintrag drausmachen: - indices_of_variable = [indices_of_variable] - - if as_sum: - summand = SumOfSummand(variable, factor, indices=indices_of_variable) - else: - summand = Summand(variable, factor, indices=indices_of_variable) - - try: - self._update_length(summand.length) # Check Variablen-LƤnge: - except ValueError as e: - raise ValueError( - f'Length of Summand with variable "{variable.label}" does not fit equation "{self.label}": {e}' - ) from e - self.summands.append(summand) - - def add_constant(self, value: Numeric) -> None: - """ - Adds a constant value to the rigth side of the equation - - Parameters - ---------- - value : float or array - constant-value of equation [A*x = constant] or [A*x <= constant] - - Returns - ------- - None. - - Raises: - ------- - ValueError - If the length doesnt match the Equation's length. - - """ - self.constant = np.add(self.constant, value) # Adding to current constant - self.parts_of_constant.append(value) # Adding to parts of constants - - length = 1 if np.isscalar(self.constant) else len(self.constant) - try: - self._update_length(length) - except ValueError as e: - raise ValueError(f'Length of Constant {value=} does not fit: {e}') from e - - def description(self, at_index: int = 0) -> str: - raise NotImplementedError('Not implemented for Abstract class <_Constraint>') - - def _update_length(self, new_length: int) -> None: - """ - Passes if the new_length is 1, the current length is 1 or new_length matches the existing length of the Equation - """ - if self.length == 1: # First Summand sets length - self.length = new_length - elif new_length == 1 or new_length == self.length: # Length 1 is always possible - pass - else: - raise ValueError( - f'The length of the new element {new_length=} doesnt match the existing ' - f'length of the Equation {self.length=}!' - ) - - @property - def constant_vector(self) -> Numeric: - return utils.as_vector(self.constant, self.length) - - -class Equation(_Constraint): - """ - Equation of the form: āˆ‘() = - Can be the Objective of a MathModel. - - Parameters - ---------- - label : str - Full label of the variable. - label_short : str, optional - Short label of the variable. If None, the full label is used. - is_objective : bool, optional - Indicates if this equation is the objective of the model (default is False). - """ - - def __init__(self, label, label_short=None, is_objective=False): - super().__init__(label, label_short) - self.is_objective = is_objective - - def description(self, at_index: int = 0) -> str: - equation_nr = min(at_index, self.length - 1) - - # Name and index as str - if self.is_objective == 'objective': - name, index_str = 'OBJ', '' - else: - name, index_str = f'EQ {self.label}', f'[{equation_nr + 1}/{self.length}]' - - # Summands: - summand_strings = [summand.description(at_index) for summand in self.summands] - all_summands_string = ' + '.join(summand_strings) - - constant = self.constant_vector[equation_nr] - - # String formating - header_width = 30 - header = f'{name:<{header_width - len(index_str) - 1}} {index_str}' - return f'{header:<{header_width}}: {constant:>8} = {all_summands_string}' - - -class Inequation(_Constraint): - """ - Equation of the form: >= āˆ‘() - - Parameters - ---------- - label: full label of the variable - label_short: short label of the variable. If None, the full label is used - """ - - def __init__(self, label, label_short=None): - super().__init__(label, label_short) - - def description(self, at_index: int = 0) -> str: - equation_nr = min(at_index, self.length - 1) - - # Name and index as str - name, index_str = f'INEQ {self.label}', f'[{equation_nr + 1}/{self.length}]' - - # Summands: - summand_strings = [summand.description(at_index) for summand in self.summands] - all_summands_string = ' + '.join(summand_strings) - - constant = self.constant_vector[equation_nr] - - # String formating - header_width = 30 - header = f'{name:<{header_width - len(index_str) - 1}} {index_str}' - return f'{header:<{header_width}}: {constant:>8} >= {all_summands_string}' - - -class Summand: - """ - Represents a part of a Constraint , consisting of a variable (or a time-series variable) and a factor. - - Parameters - ---------- - variable : Variable - The variable associated with this summand. - factor : Numeric - The factor by which the variable is multiplied in the equation. - indices : int, np.ndarray, range, List[int], optional - Specifies which indices of the variable to use. If None, all indices of the variable are used. - """ - - def __init__( - self, variable: Variable, factor: Numeric, indices: Optional[Union[int, np.ndarray, range, List[int]]] = None - ): # indices_of_variable default : alle - self.variable = variable - self.factor = factor - self.indices = indices if indices is not None else variable.indices # wenn nicht definiert, dann alle Indexe - - self.length = self._check_length() # LƤnge ermitteln: - - self.factor_vec = utils.as_vector(factor, self.length) # Faktor als Vektor: - - def description(self, at_index=0): - i = 0 if self.length == 1 else at_index - index = self.indices[i] - factor = self.factor_vec[i] - factor_str = f'{factor:.6}' if isinstance(factor, (float, np.floating)) else str(factor) - return f'{factor_str} * {self.variable.label}[{index}]' - - def _check_length(self): - """ - Determines and returns the length of the summand by comparing the lengths of the factor and the variable indices. - Sets the attribute .length to this value. - - Returns: - -------- - int - The length of the summand, which is the length of the indices if they match the length of the factor, - or the length of the longer one if one of them is a scalar. - - Raises: - ------- - Exception - If the lengths of the factor and the variable indices do not match and neither is a scalar. - """ - length_of_factor = 1 if np.isscalar(self.factor) else len(self.factor) - length_of_indices = len(self.indices) - if length_of_indices == length_of_factor: - return length_of_indices - elif length_of_factor == 1: - return length_of_indices - elif length_of_indices == 1: - return length_of_factor - else: - raise Exception( - f'Variable {self.variable.label} (length={length_of_indices}) und ' - f'Faktor (length={length_of_factor}) müssen gleiche LƤnge haben oder Skalar sein' - ) - - -class SumOfSummand(Summand): - """ - Represents a part of an Equation that sums all components of a regular Summand over specified indices. - - Parameters - ---------- - variable : Variable - The variable associated with this summand. - factor : Numeric - The factor by which the variable is multiplied. - indices : int, np.ndarray, range, List[int], optional - Specifies which indices of the variable to use for the sum. If None, all indices are summed. - """ - - def __init__( - self, variable: Variable, factor: Numeric, indices: Optional[Union[int, np.ndarray, range, List[int]]] = None - ): # indices_of_variable default : alle - super().__init__(variable, factor, indices) - self.length = 1 - - def description(self, at_index=0): - index = self.indices[at_index] - factor = self.factor_vec[0] - factor_str = str(factor) if isinstance(factor, int) else f'{factor:.6}' - single_summand_str = f'{factor_str} * {self.variable.label}[{index}]' - return f'āˆ‘({("..+" if index > 0 else "")}{single_summand_str}{("+.." if index < self.variable.length else "")})' - - -class MathModel: - """ - A mathematical model for defining equations and constraints of the form: - - a1 * x1 + a2 + x2 = y - and - a1 * x1 + a2 + x2 <= y - - where 'a1', 'a2' and y can be vectors or scalars, while 'x1' and 'x2' are variables with an appropriate length. - - - This class provides methods to add variables, equations, and inequality constraints to the model and supports - translation to a specified modeling language like pyomo. - - The expression 'a1 * x1' is referred to as a 'Summand'. Supported summand formats are: - - 'Variable[j] * Factor[i]' : Multiplication of vector variables and vector factors. - - 'Variable[j] * Factor' : Vector variable with scalar factor. - - 'Variable * Factor' : Scalar variable with scalar factor. - - 'Factor' : Scalar constant. - - - Parameters - ---------- - label : str - A descriptive label for the model. - modeling_language : {'pyomo', 'cvxpy'}, optional - Specifies the modeling language used for translation (default is 'pyomo'). - - Attributes - ---------- - label : str - The label assigned to the model. - modeling_language : str - The modeling language to which the model will be translated. - epsilon : float - Small tolerance value used in model calculations, defaulting to `1e-5`. - solver : Optional[Solver] - The solver instance assigned to solve the model. - model : Optional[ModelingLanguage] - The model instance in the specified modeling language. - _variables : List[Variable] - List of variables added to the model. - _constraints : List[Union[Equation, Inequation]] - List of equations and inequality constraints in the model. - _objective : Optional[Equation] - The objective function, if defined as an equation. - duration : dict - Dictionary tracking the time taken for translation and solving steps. - - Methods - ------- - add(*args) - Adds variables, equations, or inequations to the model. - describe_size() - Provides a summary of the number of equations, inequations, and variables. - translate_to_modeling_language() - Translates the model to the specified modeling language. - solve(solver) - Solves the model using the specified solver instance. - results() - Returns a dictionary of variable results after solving. - """ - - def __init__(self, label: str, modeling_language: Literal['pyomo', 'cvxpy'] = 'pyomo'): - self._infos = {} - self.label = label - self.modeling_language: str = modeling_language - - self.solver: Optional[Solver] = None - self.model: Optional[ModelingLanguage] = None - - self._variables: List[Variable] = [] - self._constraints: List[Union[Equation, Inequation]] = [] - self._objective: Optional[Equation] = None - self.result_of_objective: Optional[float] = None - - self.duration = {} - - def add(self, *args: Union[Variable, Equation, Inequation]) -> None: - if not isinstance(args, list): - args = list(args) - for arg in args: - if isinstance(arg, Variable): - self._variables.append(arg) - elif isinstance(arg, (Equation, Inequation)): - if isinstance(arg, Equation) and arg.is_objective: - self._objective = arg - else: - self._constraints.append(arg) - else: - raise Exception(f'{arg} cant be added this way!') - - def describe_size(self) -> str: - return ( - f'No. of Equations (single): {self.nr_of_equations} ({self.nr_of_single_equations})\n' - f'No. of Inequations (single): {self.nr_of_inequations} ({self.nr_of_single_inequations})\n' - f'No. of Variables (single): {self.nr_of_variables} ({self.nr_of_single_variables})' - ) - - def translate_to_modeling_language(self) -> None: - t_start = timeit.default_timer() - if self.modeling_language == 'pyomo': - self.model = PyomoModel() - self.model.translate_model(self) - else: - raise NotImplementedError('Modeling Language cvxpy is not yet implemented') - self.duration['Translation'] = round(timeit.default_timer() - t_start, 2) - - def solve(self, solver: 'Solver') -> None: - self.solver = solver - t_start = timeit.default_timer() - for variable in self.variables: - variable.reset_result() # altes Ergebnis lƶschen (falls vorhanden) - self.model.solve(self, solver) - self.duration['Solving'] = round(timeit.default_timer() - t_start, 2) - - def results(self) -> Dict[str, Numeric]: - return {variable.label: variable.result for variable in self.variables} - - @property - def infos(self) -> Dict: - return { - 'Solver': repr(self.solver), - 'Model Size': { - 'No. of Eqs.': self.nr_of_equations, - 'No. of Eqs. (single)': self.nr_of_single_equations, - 'No. of Ineqs.': self.nr_of_inequations, - 'No. of Ineqs. (single)': self.nr_of_single_inequations, - 'No. of Vars.': self.nr_of_variables, - 'No. of Vars. (single)': self.nr_of_single_variables, - 'No. of Vars. (TS)': len(self.ts_variables), - }, - 'Solver Log': self.solver.log.infos if isinstance(self.solver.log, SolverLog) else self.solver.log, - } - - @property - def variables(self) -> List[Variable]: - return self._variables - - @property - def equations(self) -> List[Equation]: - return [eq for eq in self._constraints if isinstance(eq, Equation)] - - @property - def inequations(self): - return [eq for eq in self._constraints if isinstance(eq, Inequation)] - - @property - def objective(self) -> Equation: - return self._objective - - @property - def ts_variables(self) -> List[VariableTS]: - return [variable for variable in self.variables if isinstance(variable, VariableTS)] - - @property - def nr_of_variables(self) -> int: - return len(self.variables) - - @property - def nr_of_constraints(self) -> int: - return len(self._constraints) - - @property - def nr_of_equations(self) -> int: - return len(self.equations) - - @property - def nr_of_inequations(self) -> int: - return len(self.inequations) - - @property - def nr_of_single_variables(self) -> int: - return sum([var.length for var in self.variables]) - - @property - def nr_of_single_equations(self) -> int: - return sum([eq.length for eq in self.equations]) - - @property - def nr_of_single_inequations(self) -> int: - return sum([eq.length for eq in self.inequations]) - - -class SolverLog: - """ - Parses and holds solver log information for specific solvers. - - Attributes: - solver_name (str): Name of the solver (e.g., 'gurobi', 'cbc'). - log (str): Content of the log file. - presolved_rows (Optional[int]): Number of rows after presolving. - presolved_cols (Optional[int]): Number of columns after presolving. - presolved_nonzeros (Optional[int]): Number of nonzeros after presolving. - presolved_continuous (Optional[int]): Number of continuous variables after presolving. - presolved_integer (Optional[int]): Number of integer variables after presolving. - presolved_binary (Optional[int]): Number of binary variables after presolving. - """ - - def __init__(self, solver_name: str, filename: str): - with open(filename, 'r') as file: - self.log = file.read() - - self.solver_name = solver_name - - self.presolved_rows = None - self.presolved_cols = None - self.presolved_nonzeros = None - - self.presolved_continuous = None - self.presolved_integer = None - self.presolved_binary = None - self.parse_infos() - - @property - def infos(self) -> Dict[str, Dict[str, int]]: - return { - 'presolved': { - 'cols': self.presolved_cols, - 'continuous': self.presolved_continuous, - 'integer': self.presolved_integer, - 'binary': self.presolved_binary, - 'rows': self.presolved_rows, - 'nonzeros': self.presolved_nonzeros, - } - } - - # Suche infos aus log: - def parse_infos(self): - if self.solver_name == 'gurobi': - # string-Schnipsel 1: - """ - Optimize a model with 285 rows, 292 columns and 878 nonzeros - Model fingerprint: 0x1756ffd1 - Variable types: 202 continuous, 90 integer (90 binary) - """ - # string-Schnipsel 2: - """ - Presolve removed 154 rows and 172 columns - Presolve time: 0.00s - Presolved: 131 rows, 120 columns, 339 nonzeros - Variable types: 53 continuous, 67 integer (67 binary) - """ - # string: Presolved: 131 rows, 120 columns, 339 nonzeros\n - match = re.search( - r'Presolved: (\d+) rows, (\d+) columns, (\d+) nonzeros\n' - r'Variable types: (\d+) continuous, (\d+) integer \((\d+) binary\)', - self.log, - ) - if match: - # string: Presolved: 131 rows, 120 columns, 339 nonzeros\n - self.presolved_rows = int(match.group(1)) - self.presolved_cols = int(match.group(2)) - self.presolved_nonzeros = int(match.group(3)) - # string: Variable types: 53 continuous, 67 integer (67 binary) - self.presolved_continuous = int(match.group(4)) - self.presolved_integer = int(match.group(5)) - self.presolved_binary = int(match.group(6)) - - elif self.solver_name == 'cbc': - # string: Presolve 1623 (-1079) rows, 1430 (-1078) columns and 4296 (-3306) elements - match = re.search(r'Presolve (\d+) \((-?\d+)\) rows, (\d+) \((-?\d+)\) columns and (\d+)', self.log) - if match is not None: - self.presolved_rows = int(match.group(1)) - self.presolved_cols = int(match.group(3)) - self.presolved_nonzeros = int(match.group(5)) - - # string: Presolved problem has 862 integers (862 of which binary) - match = re.search(r'Presolved problem has (\d+) integers \((\d+) of which binary\)', self.log) - if match is not None: - self.presolved_integer = int(match.group(1)) - self.presolved_binary = int(match.group(2)) - self.presolved_continuous = self.presolved_cols - self.presolved_integer - - elif self.solver_name == 'glpk': - logger.warning(f'{"":#^80}\n') - logger.warning(f'{" No solver-log parsing implemented for glpk yet! ":#^80}\n') - else: - raise Exception('SolverLog.parse_infos() is not defined for solver ' + self.solver_name) - - -class Solver(ABC): - """ - Abstract base class for solvers. - - Attributes: - mip_gap (float): Solver's mip gap setting. The MIP gap describes the accepted (MILP) objective, - and the lower bound, which is the theoretically optimal solution (LP) - solver_output_to_console (bool): Whether to display solver output. - logfile_name (str): Filename for saving the solver log. - objective (Optional[float]): Objective value from the solution. - best_bound (Optional[float]): Best bound from the solver. - termination_message (Optional[str]): Solver's termination message. - """ - - def __init__( - self, - mip_gap: float, - solver_output_to_console: bool, - logfile_name: str, - ): - self.mip_gap = mip_gap - self.solver_output_to_console = solver_output_to_console - self.logfile_name = logfile_name - - self.objective: Optional[float] = None - self.best_bound: Optional[float] = None - self.termination_message: Optional[str] = None - self.log: Optional[str, SolverLog] = None - - self._solver = None - self._results: Optional[float, str] = None - - @abstractmethod - def solve(self, modeling_language: 'ModelingLanguage'): - raise NotImplementedError(' Solving is not possible with this Abstract class') - - def __repr__(self): - return ( - f'{self.__class__.__name__}(' - f'mip_gap={self.mip_gap}, ' - f'solver_output_to_console={self.solver_output_to_console}, ' - f"logfile_name='{self.logfile_name}', " - f'objective={self.objective!r}, ' - f'best_bound={self.best_bound!r}, ' - f'termination_message={self.termination_message!r})' - ) - - -class GurobiSolver(Solver): - """ - Solver implementation for Gurobi. - Also Look in class Solver for more details - - Attributes: - time_limit_seconds (int): Time limit for the solver. After this time, the solver takes the currently - best solution, ignoring the mip_gap. - """ - - def __init__( - self, - mip_gap: float = 0.01, - time_limit_seconds: int = 300, - logfile_name: str = 'gurobi.log', - solver_output_to_console: bool = True, - ): - super().__init__(mip_gap, solver_output_to_console, logfile_name) - self.time_limit_seconds = time_limit_seconds - - def solve(self, modeling_language: 'ModelingLanguage'): - if isinstance(modeling_language, PyomoModel): - self._solver = pyo.SolverFactory('gurobi') - self._results = self._solver.solve( - modeling_language.model, - tee=self.solver_output_to_console, - keepfiles=True, - logfile=self.logfile_name, - options={'mipgap': self.mip_gap, 'TimeLimit': self.time_limit_seconds}, - ) - - self.objective = modeling_language.model.objective.expr() - self.termination_message = self._results.solver.termination_message - self.best_bound = self._results.problem.lower_bound - - from pyomo.opt import SolverStatus, TerminationCondition - - if not ( - self._results.solver.status == SolverStatus.ok - and self._results.solver.termination_condition == TerminationCondition.optimal - ): - logger.warning( - f'Solver ended with status {self._results.solver.status} and ' - f'termination condition {self._results.solver.termination_condition}' - ) - try: - self.log = SolverLog('gurobi', self.logfile_name) - except Exception as e: - self.log = None - logger.warning(f'SolverLog could not be loaded. {e}') - - try: - import gurobi_logtools - - self.log = gurobi_logtools.get_dataframe([str(self.logfile_name)]).T.to_dict()[0] - except ImportError: - logger.info( - 'Evaluationg the gurobi log after the solve was not possible, due to a missing dependency ' - '"gurobi_logtools". For further details of the solving process, ' - 'install the dependency via "pip install gurobi_logtools".' - ) - else: - raise NotImplementedError('Only Pyomo is implemented for GUROBI solver.') - - -class CplexSolver(Solver): - """ - Solver implementation for CPLEX. - Also Look in class Solver for more details - - Attributes: - time_limit_seconds (int): Time limit for the solver. After this time, the solver takes the currently - best solution, ignoring the mip_gap. - """ - - def __init__( - self, - mip_gap: float = 0.01, - time_limit_seconds: int = 300, - logfile_name: str = 'cplex.log', - solver_output_to_console: bool = True, - ): - super().__init__(mip_gap, solver_output_to_console, logfile_name) - self.time_limit_seconds = time_limit_seconds - - def solve(self, modeling_language: 'ModelingLanguage'): - if isinstance(modeling_language, PyomoModel): - self._solver = pyo.SolverFactory('cplex') - self._results = self._solver.solve( - modeling_language.model, - tee=self.solver_output_to_console, - keepfiles=True, - logfile=self.logfile_name, - options={'mipgap': self.mip_gap, 'timelimit': self.time_limit_seconds}, - ) - - self.objective = modeling_language.model.objective.expr() - self.termination_message: Optional[str] = f'Not Implemented for {self.__class__.__name__} yet' - self.best_bound = self._results['Problem'][0]['Lower bound'] - self.log = f'Not Implemented for {self.__class__.__name__} yet' - else: - raise NotImplementedError('Only Pyomo is implemented for CPLEX solver.') - - -class HighsSolver(Solver): - """ - Solver implementation for HIGHS. - Also Look in class Solver for more details - - Attributes: - time_limit_seconds (int): Time limit for the solver. After this time, the solver takes the currently - best solution, ignoring the mip_gap. - threads (int): Number of threads to use for the solver. - """ - - def __init__( - self, - mip_gap: float = 0.01, - time_limit_seconds: int = 300, - logfile_name: str = 'highs.log', - solver_output_to_console: bool = True, - threads: int = 4, - ): - super().__init__(mip_gap, solver_output_to_console, logfile_name) - self.time_limit_seconds = time_limit_seconds - self.threads = threads - - def solve(self, modeling_language: 'ModelingLanguage'): - if isinstance(modeling_language, PyomoModel): - from pyomo.contrib import appsi - - self._solver = appsi.solvers.Highs() - self._solver.highs_options = { - 'mip_rel_gap': self.mip_gap, - 'time_limit': self.time_limit_seconds, - 'log_file': str(self.logfile_name), - # "log_to_console": self.solver_output_to_console, - 'threads': self.threads, - 'parallel': 'on', - 'presolve': 'on', - 'output_flag': True, - } - self._solver.config.stream_solver = True - - self._results = self._solver.solve( - modeling_language.model - ) # HiGHS writes logs to stdout/stderr, so we capture them here - - self.objective = modeling_language.model.objective.expr() - self.termination_message: Optional[str] = self._results.termination_condition.name - if not self.termination_message == 'optimal': - logger.warning(f'Solution is not optimal. Termination Message: "{self.termination_message}"') - self.best_bound = self._results.best_objective_bound - self.log = f'Not Implemented for {self.__class__.__name__} yet' - else: - raise NotImplementedError('Only Pyomo is implemented for HIGHS solver.') - - -class CbcSolver(Solver): - """ - Solver implementation for CBC. - Also Look in class Solver for more details - - Attributes: - time_limit_seconds (int): Time limit for the solver. After this time, the solver takes the currently - best solution, ignoring the mip_gap. - """ - - def __init__( - self, - mip_gap: float = 0.01, - time_limit_seconds: int = 300, - logfile_name: str = 'cbc.log', - solver_output_to_console: bool = True, - ): - super().__init__(mip_gap, solver_output_to_console, logfile_name) - self.time_limit_seconds = time_limit_seconds - - def solve(self, modeling_language: 'ModelingLanguage'): - if isinstance(modeling_language, PyomoModel): - self._solver = pyo.SolverFactory('cbc') - self._results = self._solver.solve( - modeling_language.model, - tee=self.solver_output_to_console, - keepfiles=True, - logfile=self.logfile_name, - options={'ratio': self.mip_gap, 'sec': self.time_limit_seconds}, - ) - self.objective = modeling_language.model.objective.expr() - self.termination_message: Optional[str] = f'Not Implemented for {self.__class__.__name__} yet' - self.best_bound = self._results['Problem'][0]['Lower bound'] - self.log = f'Not Implemented for {self.__class__.__name__} yet' - else: - raise NotImplementedError('Only Pyomo is implemented for Cbc solver.') - - -class GlpkSolver(Solver): - """Solver implementation for Glpk. Also Look in class Solver for more details""" - - def __init__( - self, - mip_gap: float = 0.01, - logfile_name: str = 'glpk.log', - solver_output_to_console: bool = True, - ): - super().__init__(mip_gap, solver_output_to_console, logfile_name) - - def solve(self, modeling_language: 'ModelingLanguage'): - if isinstance(modeling_language, PyomoModel): - self._solver = pyo.SolverFactory('glpk') - self._results = self._solver.solve( - modeling_language.model, - tee=self.solver_output_to_console, - keepfiles=True, - logfile=self.logfile_name, - options={'mipgap': self.mip_gap}, - ) - - self.objective = modeling_language.model.objective.expr() - self.termination_message = self._results['Solver'][0]['Status'] - self.best_bound = self._results['Problem'][0]['Lower bound'] - try: - self.log = SolverLog('glpk', self.logfile_name) - except Exception as e: - self.log = None - logger.warning(f'SolverLog could not be loaded. {e}') - else: - raise NotImplementedError('Only Pyomo is implemented for Cbc solver.') - - -class ModelingLanguage(ABC): - """ - Abstract base class for modeling languages. - - Methods: - translate_model(model): Translates a math model into a solveable form. - """ - - @abstractmethod - def translate_model(self, model: MathModel): - raise NotImplementedError - - def solve(self, math_model: MathModel, solver: Solver): - raise NotImplementedError - - -class PyomoModel(ModelingLanguage): - """ - Pyomo-based modeling language for constructing and solving optimization models. - Translates a MathModel into a PyomoModel. - - Attributes: - model: Pyomo model instance. - mapping (dict): Maps variables and equations to Pyomo components. - _counter (int): Counter for naming Pyomo components. - """ - - def __init__(self): - logger.debug('Loaded pyomo modules') - - self.model = pyo.ConcreteModel(name='(Minimalbeispiel)') - - self.mapping: Dict[Union[Variable, Equation], Any] = {} # Mapping to Pyomo Units - self._counter = 0 - - def solve(self, math_model: MathModel, solver: Solver): - if self._counter == 0: - raise Exception(' First, call .translate_model(). Else PyomoModel cant solve()') - solver.solve(self) - - # write results - math_model.result_of_objective = self.model.objective.expr() - for variable in math_model.variables: - raw_results = self.mapping[variable].get_values().values() # .values() of dict, because {0:0.1, 1:0.3,...} - if variable.is_binary: - dtype = np.int8 # geht das vielleicht noch kleiner ??? - else: - dtype = float - # transform to np-array (fromiter() is 5-7x faster than np.array(list(...)) ) - result = np.fromiter(raw_results, dtype=dtype) - # Falls skalar: - if len(result) == 1: - variable.result = result[0] - else: - variable.result = result - - def translate_model(self, math_model: MathModel): - for variable in math_model.variables: # Variablen erstellen - logger.debug(f'VAR {variable.label} gets translated to Pyomo') - self.translate_variable(variable) - for eq in math_model.equations: # Gleichungen erstellen - logger.debug(f'EQ {eq.label} gets translated to Pyomo') - self.translate_equation(eq) - for ineq in math_model.inequations: # Ungleichungen erstellen: - logger.debug(f'INEQ {ineq.label} gets translated to Pyomo') - self.translate_inequation(ineq) - - obj = math_model.objective - logger.debug(f'{obj.label} gets translated to Pyomo') - self.translate_objective(obj) - - def translate_variable(self, variable: Variable): - assert isinstance(variable, Variable), 'Wrong type of variable' - - if variable.is_binary: - pyomo_comp = pyo.Var(variable.indices, domain=pyo.Binary) - else: - pyomo_comp = pyo.Var(variable.indices, within=pyo.Reals) - self.mapping[variable] = pyomo_comp - - # Register in pyomo-model: - self._register_pyomo_comp(pyomo_comp, variable) - - lower_bound_vector = utils.as_vector(variable.lower_bound, variable.length) - upper_bound_vector = utils.as_vector(variable.upper_bound, variable.length) - fixed_value_vector = utils.as_vector(variable.fixed_value, variable.length) - for i in variable.indices: - # Wenn Vorgabe-Wert vorhanden: - if variable.fixed and (fixed_value_vector[i] is not None): - # Fixieren: - pyomo_comp[i].value = fixed_value_vector[i] - pyomo_comp[i].fix() - else: - # Boundaries: - pyomo_comp[i].setlb(lower_bound_vector[i]) # min - pyomo_comp[i].setub(upper_bound_vector[i]) # max - - def translate_equation(self, equation: Equation): - if not isinstance(equation, Equation): - raise TypeError(f'Wrong Class: {equation.__class__.__name__}') - - # constant_vector hier erneut erstellen, da Anz. Glg. vorher noch nicht bekannt: - constant_vector = equation.constant_vector - - def linear_sum_pyomo_rule(model, i): - """This function is needed for pyomoy internal construction of Constraints.""" - lhs = 0 - summand: Summand - for summand in equation.summands: - lhs += self._summand_math_expression(summand, i) # i-te Gleichung (wenn Skalar, dann wird i ignoriert) - rhs = constant_vector[i] - return lhs == rhs - - pyomo_comp = pyo.Constraint(range(equation.length), rule=linear_sum_pyomo_rule) # Nebenbedingung erstellen - - self._register_pyomo_comp(pyomo_comp, equation) - - def translate_inequation(self, inequation: Inequation): - if not isinstance(inequation, Inequation): - raise TypeError(f'Wrong Class: {inequation.__class__.__name__}') - - # constant_vector hier erneut erstellen, da Anz. Glg. vorher noch nicht bekannt: - constant_vector = inequation.constant_vector - - def linear_sum_pyomo_rule(model, i): - """This function is needed for pyomoy internal construction of Constraints.""" - lhs = 0 - summand: Summand - for summand in inequation.summands: - lhs += self._summand_math_expression(summand, i) # i-te Gleichung (wenn Skalar, dann wird i ignoriert) - rhs = constant_vector[i] - - return lhs <= rhs - - pyomo_comp = pyo.Constraint(range(inequation.length), rule=linear_sum_pyomo_rule) # Nebenbedingung erstellen - - self._register_pyomo_comp(pyomo_comp, inequation) - - def translate_objective(self, objective: Equation): - if not isinstance(objective, Equation): - raise TypeError(f'Class {objective.__class__.__name__} Can not be the objective!') - if not objective.is_objective: - raise TypeError( - f'Objective Equation is not marked as objective, {objective.is_objective=}, ' - f'but was sent to translate to objective!' - ) - if objective.length != 1: - raise Exception('Length of Objective must be 0') - - def _rule_linear_sum_skalar(model): - skalar = 0 - for summand in objective.summands: - skalar += self._summand_math_expression(summand) - return skalar - - self.model.objective = pyo.Objective(rule=_rule_linear_sum_skalar, sense=pyo.minimize) - self.mapping[objective] = self.model.objective - - def _summand_math_expression(self, summand: Summand, at_index: int = 0) -> 'pyo.Expression': - pyomo_variable = self.mapping[summand.variable] - if isinstance(summand, SumOfSummand): - return sum(pyomo_variable[summand.indices[j]] * summand.factor_vec[j] for j in summand.indices) - - # Ausdruck für i-te Gleichung (falls Skalar, dann immer gleicher Ausdruck ausgegeben) - if summand.length == 1: - # ignore argument at_index, because Skalar is used for every single equation - return pyomo_variable[summand.indices[0]] * summand.factor_vec[0] - if len(summand.indices) == 1: - return pyomo_variable[summand.indices[0]] * summand.factor_vec[at_index] - return pyomo_variable[summand.indices[at_index]] * summand.factor_vec[at_index] - - def _register_pyomo_comp(self, pyomo_comp, part: Union[Variable, Equation, Inequation]) -> None: - self._counter += 1 # Counter to guarantee unique names - self.model.add_component(f'{part.label}__{self._counter}', pyomo_comp) - self.mapping[part] = pyomo_comp diff --git a/flixOpt/plotting.py b/flixOpt/plotting.py deleted file mode 100644 index b3771148a..000000000 --- a/flixOpt/plotting.py +++ /dev/null @@ -1,712 +0,0 @@ -""" -This module contains the plotting functionality of the flixOpt framework. -It provides high level functions to plot data with plotly and matplotlib. -It's meant to be used in results.py, but is designed to be used by the end user as well. -""" - -import logging -import pathlib -from typing import TYPE_CHECKING, List, Literal, Optional, Tuple, Union - -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd -import plotly.express as px -import plotly.graph_objects as go -import plotly.offline - -if TYPE_CHECKING: - import pyvis - -logger = logging.getLogger('flixOpt') - - -def with_plotly( - data: pd.DataFrame, - mode: Literal['bar', 'line', 'area'] = 'area', - colors: Union[List[str], str] = 'viridis', - title: str = '', - ylabel: str = '', - fig: Optional[go.Figure] = None, - show: bool = False, - save: bool = False, - path: Union[str, pathlib.Path] = 'temp-plot.html', -) -> go.Figure: - """ - Plot a DataFrame with Plotly, using either stacked bars or stepped lines. - - Parameters - ---------- - data : pd.DataFrame - A DataFrame containing the data to plot, where the index represents - time (e.g., hours), and each column represents a separate data series. - mode : {'bar', 'line'}, default='bar' - The plotting mode. Use 'bar' for stacked bar charts or 'line' for - stepped lines. - colors : List[str], str, default='viridis' - A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') to use for - coloring the data series. - title: str - The title of the plot. - ylabel: str - The label for the y-axis. - fig : go.Figure, optional - A Plotly figure object to plot on. If not provided, a new figure - will be created. - show: bool - Wether to show the figure after creation. (This includes saving the figure) - save: bool - Wether to save the figure after creation (without showing) - path: Union[str, pathlib.Path] - Path to save the figure. - - Returns - ------- - go.Figure - A Plotly figure object containing the generated plot. - - Notes - ----- - - If `mode` is 'bar', bars are stacked for each data series. - - If `mode` is 'line', a stepped line is drawn for each data series. - - The legend is positioned below the plot for a cleaner layout when many - data series are present. - - Examples - -------- - >>> fig = with_plotly(data, mode='bar', colorscale='plasma') - >>> fig.show() - """ - assert mode in ['bar', 'line', 'area'], f"'mode' must be one of {['bar', 'line', 'area']}" - if data.empty: - return go.Figure() - if isinstance(colors, str): - colorscale = px.colors.get_colorscale(colors) - colors = px.colors.sample_colorscale( - colorscale, - [i / (len(data.columns) - 1) for i in range(len(data.columns))] if len(data.columns) > 1 else [0], - ) - - assert len(colors) == len(data.columns), ( - f'The number of colors does not match the provided data columns. {len(colors)=}; {len(colors)=}' - ) - fig = fig if fig is not None else go.Figure() - - if mode == 'bar': - for i, column in enumerate(data.columns): - fig.add_trace( - go.Bar( - x=data.index, - y=data[column], - name=column, - marker=dict(color=colors[i]), - ) - ) - - fig.update_layout( - barmode='relative' if mode == 'bar' else None, - bargap=0, # No space between bars - bargroupgap=0, # No space between groups of bars - ) - elif mode == 'line': - for i, column in enumerate(data.columns): - fig.add_trace( - go.Scatter( - x=data.index, - y=data[column], - mode='lines', - name=column, - line=dict(shape='hv', color=colors[i]), - ) - ) - elif mode == 'area': - data[(data > -1e-5) & (data < 1e-5)] = 0 # Preventing issues with plotting - # Split columns into positive, negative, and mixed categories - positive_columns = list(data.columns[(data >= 0).all()]) - negative_columns = list(data.columns[(data <= 0).all()]) - mixed_columns = list(set(data.columns) - set(positive_columns + negative_columns)) - if mixed_columns: - logger.warning( - f'Data for plotting stacked lines contains columns with both positive and negative values:' - f' {mixed_columns}. These can not be stacked, and are printed as simple lines' - ) - - colors_stacked = {column: colors[i] for i, column in enumerate(data.columns)} - - for column in positive_columns + negative_columns: - fig.add_trace( - go.Scatter( - x=data.index, - y=data[column], - mode='lines', - name=column, - line=dict(shape='hv', color=colors_stacked[column]), - fill='tonexty', - stackgroup='pos' if column in positive_columns else 'neg', - ) - ) - - for column in mixed_columns: - fig.add_trace( - go.Scatter( - x=data.index, - y=data[column], - mode='lines', - name=column, - line=dict(shape='hv', color=colors_stacked[column], dash='dash'), - ) - ) - - # Update layout for better aesthetics - fig.update_layout( - title=title, - yaxis=dict( - title=ylabel, - showgrid=True, # Enable grid lines on the y-axis - gridcolor='lightgrey', # Customize grid line color - gridwidth=0.5, # Customize grid line width - ), - xaxis=dict( - title='Time in h', - showgrid=True, # Enable grid lines on the x-axis - gridcolor='lightgrey', # Customize grid line color - gridwidth=0.5, # Customize grid line width - ), - plot_bgcolor='rgba(0,0,0,0)', # Transparent background - paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background - font=dict(size=14), # Increase font size for better readability - legend=dict( - orientation='h', # Horizontal legend - yanchor='bottom', - y=-0.3, # Adjusts how far below the plot it appears - xanchor='center', - x=0.5, - title_text=None, # Removes legend title for a cleaner look - ), - ) - - if isinstance(path, pathlib.Path): - path = path.as_posix() - if show: - plotly.offline.plot(fig, filename=path) - elif save: # If show, the file is saved anyway - fig.write_html(path) - return fig - - -def with_matplotlib( - data: pd.DataFrame, - mode: Literal['bar', 'line'] = 'bar', - colors: Union[List[str], str] = 'viridis', - figsize: Tuple[int, int] = (12, 6), - fig: Optional[plt.Figure] = None, - ax: Optional[plt.Axes] = None, - show: bool = False, - path: Optional[Union[str, pathlib.Path]] = None, -) -> Tuple[plt.Figure, plt.Axes]: - """ - Plot a DataFrame with Matplotlib using stacked bars or stepped lines. - - Parameters - ---------- - data : pd.DataFrame - A DataFrame containing the data to plot. The index should represent - time (e.g., hours), and each column represents a separate data series. - mode : {'bar', 'line'}, default='bar' - Plotting mode. Use 'bar' for stacked bar charts or 'line' for stepped lines. - colors : List[str], str, default='viridis' - A List of colors (as str) or a name of a colorscale (e.g., 'viridis', 'plasma') to use for - coloring the data series. - figsize: Tuple[int, int], optional - Specify the size of the figure - fig : plt.Figure, optional - A Matplotlib figure object to plot on. If not provided, a new figure - will be created. - ax : plt.Axes, optional - A Matplotlib axes object to plot on. If not provided, a new axes - will be created. - show: bool - Wether to show the figure after creation. - path: Union[str, pathlib.Path] - Path to save the figure to. - - Returns - ------- - Tuple[plt.Figure, plt.Axes] - A tuple containing the Matplotlib figure and axes objects used for the plot. - - Notes - ----- - - If `mode` is 'bar', bars are stacked for both positive and negative values. - Negative values are stacked separately without extra labels in the legend. - - If `mode` is 'line', stepped lines are drawn for each data series. - - The legend is placed below the plot to accommodate multiple data series. - - Examples - -------- - >>> fig, ax = with_matplotlib(data, mode='bar', colorscale='plasma') - >>> plt.show() - """ - assert mode in ['bar', 'line'], f"'mode' must be one of {['bar', 'line']} for matplotlib" - - if fig is None or ax is None: - fig, ax = plt.subplots(figsize=figsize) - - if isinstance(colors, str): - cmap = plt.get_cmap(colors, len(data.columns)) - colors = [cmap(i) for i in range(len(data.columns))] - assert len(colors) == len(data.columns), ( - f'The number of colors does not match the provided data columns. {len(colors)=}; {len(colors)=}' - ) - - if mode == 'bar': - cumulative_positive = np.zeros(len(data)) - cumulative_negative = np.zeros(len(data)) - width = data.index.to_series().diff().dropna().min() # Minimum time difference - - for i, column in enumerate(data.columns): - positive_values = np.clip(data[column], 0, None) # Keep only positive values - negative_values = np.clip(data[column], None, 0) # Keep only negative values - # Plot positive bars - ax.bar( - data.index, - positive_values, - bottom=cumulative_positive, - color=colors[i], - label=column, - width=width, - align='center', - ) - cumulative_positive += positive_values.values - # Plot negative bars - ax.bar( - data.index, - negative_values, - bottom=cumulative_negative, - color=colors[i], - label='', # No label for negative bars - width=width, - align='center', - ) - cumulative_negative += negative_values.values - - elif mode == 'line': - for i, column in enumerate(data.columns): - ax.step(data.index, data[column], where='post', color=colors[i], label=column) - - # Aesthetics - ax.set_xlabel('Time in h', fontsize=14) - ax.grid(color='lightgrey', linestyle='-', linewidth=0.5) - ax.legend( - loc='upper center', # Place legend at the bottom center - bbox_to_anchor=(0.5, -0.15), # Adjust the position to fit below plot - ncol=5, - frameon=False, # Remove box around legend - ) - fig.tight_layout() - - if show: - plt.show() - if path is not None: - fig.savefig(path, dpi=300) - - return fig, ax - - -def heat_map_matplotlib( - data: pd.DataFrame, - color_map: str = 'viridis', - figsize: Tuple[float, float] = (12, 6), - show: bool = False, - path: Optional[Union[str, pathlib.Path]] = None, -) -> Tuple[plt.Figure, plt.Axes]: - """ - Plots a DataFrame as a heatmap using Matplotlib. The columns of the DataFrame will be displayed on the x-axis, - the index will be displayed on the y-axis, and the values will represent the 'heat' intensity in the plot. - - Parameters - ---------- - data : pd.DataFrame - A DataFrame containing the data to be visualized. The index will be used for the y-axis, and columns will be used for the x-axis. - The values in the DataFrame will be represented as colors in the heatmap. - color_map : str, optional - The colormap to use for the heatmap. Default is 'viridis'. Matplotlib supports various colormaps like 'plasma', 'inferno', 'cividis', etc. - figsize : tuple of float, optional - The size of the figure to create. Default is (12, 6), which results in a width of 12 inches and a height of 6 inches. - show: bool - Wether to show the figure after creation. - path: Union[str, pathlib.Path] - Path to save the figure to. - - Returns - ------- - tuple of (plt.Figure, plt.Axes) - A tuple containing the Matplotlib `Figure` and `Axes` objects. The `Figure` contains the overall plot, while the `Axes` is the area - where the heatmap is drawn. These can be used for further customization or saving the plot to a file. - - Notes - ----- - - The y-axis is flipped so that the first row of the DataFrame is displayed at the top of the plot. - - The color scale is normalized based on the minimum and maximum values in the DataFrame. - - The x-axis labels (periods) are placed at the top of the plot. - - The colorbar is added horizontally at the bottom of the plot, with a label. - """ - - # Get the min and max values for color normalization - color_bar_min, color_bar_max = data.min().min(), data.max().max() - - # Create the heatmap plot - fig, ax = plt.subplots(figsize=figsize) - ax.pcolormesh(data.values, cmap=color_map) - ax.invert_yaxis() # Flip the y-axis to start at the top - - # Adjust ticks and labels for x and y axes - ax.set_xticks(np.arange(len(data.columns)) + 0.5) - ax.set_xticklabels(data.columns, ha='center') - ax.set_yticks(np.arange(len(data.index)) + 0.5) - ax.set_yticklabels(data.index, va='center') - - # Add labels to the axes - ax.set_xlabel('Period', ha='center') - ax.set_ylabel('Step', va='center') - - # Position x-axis labels at the top - ax.xaxis.set_label_position('top') - ax.xaxis.set_ticks_position('top') - - # Add the colorbar - sm1 = plt.cm.ScalarMappable(cmap=color_map, norm=plt.Normalize(vmin=color_bar_min, vmax=color_bar_max)) - sm1._A = [] - fig.colorbar(sm1, ax=ax, pad=0.12, aspect=15, fraction=0.2, orientation='horizontal') - - fig.tight_layout() - if show: - plt.show() - if path is not None: - fig.savefig(path, dpi=300) - - return fig, ax - - -def heat_map_plotly( - data: pd.DataFrame, - color_map: str = 'viridis', - title: str = '', - xlabel: str = 'Periods', - ylabel: str = 'Step', - categorical_labels: bool = True, - show: bool = False, - save: bool = False, - path: Union[str, pathlib.Path] = 'temp-plot.html', -) -> go.Figure: - """ - Plots a DataFrame as a heatmap using Plotly. The columns of the DataFrame will be mapped to the x-axis, - and the index will be displayed on the y-axis. The values in the DataFrame will represent the 'heat' in the plot. - - Parameters - ---------- - data : pd.DataFrame - A DataFrame with the data to be visualized. The index will be used for the y-axis, and columns will be used for the x-axis. - The values in the DataFrame will be represented as colors in the heatmap. - color_map : str, optional - The color scale to use for the heatmap. Default is 'viridis'. Plotly supports various color scales like 'Cividis', 'Inferno', etc. - categorical_labels : bool, optional - If True, the x and y axes are treated as categorical data (i.e., the index and columns will not be interpreted as continuous data). - Default is True. If False, the axes are treated as continuous, which may be useful for time series or numeric data. - show: bool - Wether to show the figure after creation. (This includes saving the figure) - save: bool - Wether to save the figure after creation (without showing) - path: Union[str, pathlib.Path] - Path to save the figure. - - Returns - ------- - go.Figure - A Plotly figure object containing the heatmap. This can be further customized and saved - or displayed using `fig.show()`. - - Notes - ----- - The color bar is automatically scaled to the minimum and maximum values in the data. - The y-axis is reversed to display the first row at the top. - """ - - color_bar_min, color_bar_max = data.min().min(), data.max().max() # Min and max values for color scaling - # Define the figure - fig = go.Figure( - data=go.Heatmap( - z=data.values, - x=data.columns, - y=data.index, - colorscale=color_map, - zmin=color_bar_min, - zmax=color_bar_max, - colorbar=dict( - title=dict(text='Color Bar Label', side='right'), - orientation='h', - xref='container', - yref='container', - len=0.8, # Color bar length relative to plot - x=0.5, - y=0.1, - ), - ) - ) - - # Set axis labels and style - fig.update_layout( - title=title, - xaxis=dict(title=xlabel, side='top', type='category' if categorical_labels else None), - yaxis=dict(title=ylabel, autorange='reversed', type='category' if categorical_labels else None), - ) - - if isinstance(path, pathlib.Path): - path = path.as_posix() - if show: - plotly.offline.plot(fig, filename=path) - elif save: # If show, the file is saved anyway - fig.write_html(path) - - return fig - - -def reshape_to_2d(data_1d: np.ndarray, nr_of_steps_per_column: int) -> np.ndarray: - """ - Reshapes a 1D numpy array into a 2D array suitable for plotting as a colormap. - - The reshaped array will have the number of rows corresponding to the steps per column - (e.g., 24 hours per day) and columns representing time periods (e.g., days or months). - - Parameters - ---------- - data_1d : np.ndarray - A 1D numpy array with the data to reshape. - - nr_of_steps_per_column : int - The number of steps (rows) per column in the resulting 2D array. For example, - this could be 24 (for hours) or 31 (for days in a month). - - Returns - ------- - np.ndarray - The reshaped 2D array. Each internal array corresponds to one column, with the specified number of steps. - Each column might represents a time period (e.g., day, month, etc.). - """ - - # Step 1: Ensure the input is a 1D array. - if data_1d.ndim != 1: - raise ValueError('Input must be a 1D array') - - # Step 2: Convert data to float type to allow NaN padding - if data_1d.dtype != np.float64: - data_1d = data_1d.astype(np.float64) - - # Step 3: Calculate the number of columns required - total_steps = len(data_1d) - cols = len(data_1d) // nr_of_steps_per_column # Base number of columns - - # If there's a remainder, add an extra column to hold the remaining values - if total_steps % nr_of_steps_per_column != 0: - cols += 1 - - # Step 4: Pad the 1D data to match the required number of rows and columns - padded_data = np.pad( - data_1d, (0, cols * nr_of_steps_per_column - total_steps), mode='constant', constant_values=np.nan - ) - - # Step 5: Reshape the padded data into a 2D array - data_2d = padded_data.reshape(cols, nr_of_steps_per_column) - - return data_2d.T - - -def heat_map_data_from_df( - df: pd.DataFrame, - periods: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], - steps_per_period: Literal['W', 'D', 'h', '15min', 'min'], - fill: Optional[Literal['ffill', 'bfill']] = None, -) -> pd.DataFrame: - """ - Reshapes a DataFrame with a DateTime index into a 2D array for heatmap plotting, - based on a specified sample rate. - If a non-valid combination of periods and steps per period is used, falls back to numerical indices - - Parameters - ---------- - df : pd.DataFrame - A DataFrame with a DateTime index containing the data to reshape. - periods : str - The time interval of each period (columns of the heatmap), - such as 'YS' (year start), 'W' (weekly), 'D' (daily), 'h' (hourly) etc. - steps_per_period : str - The time interval within each period (rows in the heatmap), - such as 'YS' (year start), 'W' (weekly), 'D' (daily), 'h' (hourly) etc. - fill : str, optional - Method to fill missing values: 'ffill' for forward fill or 'bfill' for backward fill. - - Returns - ------- - pd.DataFrame - A DataFrame suitable for heatmap plotting, with rows representing steps within each period - and columns representing each period. - """ - assert pd.api.types.is_datetime64_any_dtype(df.index), ( - 'The index of the Dataframe must be datetime to transfrom it properly for a heatmap plot' - ) - - # Define formats for different combinations of `periods` and `steps_per_period` - formats = { - ('YS', 'W'): ('%Y', '%W'), - ('YS', 'D'): ('%Y', '%j'), # day of year - ('YS', 'h'): ('%Y', '%j %H:00'), - ('MS', 'D'): ('%Y-%m', '%d'), # day of month - ('MS', 'h'): ('%Y-%m', '%d %H:00'), - ('W', 'D'): ('%Y-w%W', '%w_%A'), # week and day of week (with prefix for proper sorting) - ('W', 'h'): ('%Y-w%W', '%w_%A %H:00'), - ('D', 'h'): ('%Y-%m-%d', '%H:00'), # Day and hour - ('D', '15min'): ('%Y-%m-%d', '%H:%MM'), # Day and hour - ('h', '15min'): ('%Y-%m-%d %H:00', '%M'), # minute of hour - ('h', 'min'): ('%Y-%m-%d %H:00', '%M'), # minute of hour - } - - minimum_time_diff_in_min = df.index.to_series().diff().min().total_seconds() / 60 # Smallest time_diff in minutes - time_intervals = {'min': 1, '15min': 15, 'h': 60, 'D': 24 * 60, 'W': 7 * 24 * 60} - if time_intervals[steps_per_period] > minimum_time_diff_in_min: - time_intervals[steps_per_period] - logger.warning( - f'To compute the heatmap, the data was aggregated from {minimum_time_diff_in_min:.2f} min to ' - f'{time_intervals[steps_per_period]:.2f} min. Mean values are displayed.' - ) - - # Select the format based on the `periods` and `steps_per_period` combination - format_pair = (periods, steps_per_period) - assert format_pair in formats, f'{format_pair} is not a valid format. Choose from {list(formats.keys())}' - period_format, step_format = formats[format_pair] - - df = df.sort_index() # Ensure DataFrame is sorted by time index - - resampled_data = df.resample(steps_per_period).mean() # Resample and fill any gaps with NaN - - if fill == 'ffill': # Apply fill method if specified - resampled_data = resampled_data.ffill() - elif fill == 'bfill': - resampled_data = resampled_data.bfill() - - resampled_data['period'] = resampled_data.index.strftime(period_format) - resampled_data['step'] = resampled_data.index.strftime(step_format) - if '%w_%A' in step_format: # SHift index of strings to ensure proper sorting - resampled_data['step'] = resampled_data['step'].apply( - lambda x: x.replace('0_Sunday', '7_Sunday') if '0_Sunday' in x else x - ) - - # Pivot the table so periods are columns and steps are indices - df_pivoted = resampled_data.pivot(columns='period', index='step', values=df.columns[0]) - - return df_pivoted - - -def visualize_network( - node_infos: dict, - edge_infos: dict, - path: Optional[Union[str, pathlib.Path]] = None, - controls: Union[ - bool, - List[Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer']], - ] = True, - show: bool = True, -) -> Optional['pyvis.network.Network']: - """ - Visualizes the network structure of a FlowSystem using PyVis, using info-dictionaries. - - Parameters: - - path (Union[bool, str, pathlib.Path], default='results/network.html'): - Path to save the HTML visualization. - - `False`: Visualization is created but not saved. - - `str` or `Path`: Specifies file path (default: 'results/network.html'). - - - controls (Union[bool, List[str]], default=True): - UI controls to add to the visualization. - - `True`: Enables all available controls. - - `List`: Specify controls, e.g., ['nodes', 'layout']. - - Options: 'nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'. - You can play with these and generate a Dictionary from it that can be applied to the network returned by this function. - network.set_options() - https://pyvis.readthedocs.io/en/latest/tutorial.html - - - show (bool, default=True): - Whether to open the visualization in the web browser. - The calculation must be saved to show it. If no path is given, it defaults to 'network.html'. - - Returns: - - Optional[pyvis.network.Network]: The `Network` instance representing the visualization, or `None` if `pyvis` is not installed. - - Usage: - - Visualize and open the network with default options: - >>> self.visualize_network() - - - Save the visualization without opening: - >>> self.visualize_network(show=False) - - - Visualize with custom controls and path: - >>> self.visualize_network(path='output/custom_network.html', controls=['nodes', 'layout']) - - Notes: - - This function requires `pyvis`. If not installed, the function prints a warning and returns `None`. - - Nodes are styled based on type (e.g., circles for buses, boxes for components) and annotated with node information. - """ - try: - from pyvis.network import Network - except ImportError: - logger.warning("Please install pyvis to visualize the network: 'pip install pyvis'") - return None - - net = Network(directed=True, height='100%' if controls is False else '800px', font_color='white') - - for node_id, node in node_infos.items(): - net.add_node( - node_id, - label=node['label'], - shape={'Bus': 'circle', 'Component': 'box'}[node['class']], - color={'Bus': '#393E46', 'Component': '#00ADB5'}[node['class']], - title=node['infos'].replace(')', '\n)'), - font={'size': 14}, - ) - - for edge in edge_infos.values(): - net.add_edge( - edge['start'], - edge['end'], - label=edge['label'], - title=edge['infos'].replace(')', '\n)'), - font={'color': '#4D4D4D', 'size': 14}, - color='#222831', - ) - - # Enhanced physics settings - net.barnes_hut(central_gravity=0.8, spring_length=50, spring_strength=0.05, gravity=-10000) - - if controls: - net.show_buttons(filter_=controls) # Adds UI buttons to control physics settings - if not show and not path: - return net - elif path: - path = pathlib.Path(path) if isinstance(path, str) else path - net.write_html(path.as_posix()) - elif show: - path = pathlib.Path('network.html') - net.write_html(path.as_posix()) - - if show: - try: - import webbrowser - - worked = webbrowser.open(f'file://{path.resolve()}', 2) - if not worked: - logger.warning( - f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}' - ) - except Exception as e: - logger.warning( - f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}: {e}' - ) diff --git a/flixOpt/results.py b/flixOpt/results.py deleted file mode 100644 index d88ea6c23..000000000 --- a/flixOpt/results.py +++ /dev/null @@ -1,563 +0,0 @@ -""" -This module contains the Results functionality of the flixOpt framework. -It provides high level functions to analyze the results of a calculation. -It leverages the plotting.py module to plot the results. -The results can also be analyzed without this module, as the results are stored in a widely supported format. -""" - -import datetime -import json -import logging -import pathlib -import timeit -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union - -import numpy as np -import pandas as pd -import plotly -import yaml - -from flixOpt import plotting, utils - -if TYPE_CHECKING: - import matplotlib.pyplot as plt - import plotly.graph_objects as go - import pyvis - -logger = logging.getLogger('flixOpt') - - -class ElementResults: - def __init__(self, infos: Dict, results: Dict): - self.all_infos = infos - self.all_results = results - self.label = self.all_infos['label'] - - def __repr__(self): - return f'{self.__class__.__name__}({self.label})' - - @property - def variables_flat(self) -> Dict[str, Union[int, float, np.ndarray]]: - return flatten_dict(self.all_results) - - -class CalculationResults: - def __init__(self, calculation_name: str, folder: str) -> None: - self.name = calculation_name - self.folder = pathlib.Path(folder) - self._path_infos = self.folder / f'{calculation_name}_infos.yaml' - self._path_data = self.folder / f'{calculation_name}_data.json' - self._path_results = self.folder / f'{calculation_name}_results.json' - - start_time = timeit.default_timer() - with open(self._path_infos, 'rb') as f: - self.calculation_infos: Dict = yaml.safe_load(f) - logger.info(f'Loading Calculation Infos from .yaml took {(timeit.default_timer() - start_time):>8.2f} seconds') - - start_time = timeit.default_timer() - with open(self._path_results, 'rb') as f: - self.all_results: Dict = json.load(f) - self.all_results = utils.convert_numeric_lists_to_arrays(self.all_results) - logger.info(f'Loading results from .json took {(timeit.default_timer() - start_time):>8.2f} seconds') - - start_time = timeit.default_timer() - with open(self._path_data, 'rb') as f: - self.all_data: Dict = json.load(f) - self.all_data = utils.convert_numeric_lists_to_arrays(self.all_data) - logger.info(f'Loading data from .json took {(timeit.default_timer() - start_time):>8.2f} seconds') - - self.component_results: Dict[str, ComponentResults] = {} - self.effect_results: Dict[str, EffectResults] = {} - self.bus_results: Dict[str, BusResults] = {} - - self.time_with_end = np.array( - [datetime.datetime.fromisoformat(date) for date in self.all_results['Time']] - ).astype('datetime64') - self.time = self.time_with_end[:-1] - self.time_intervals_in_hours = np.array(self.all_results['Time intervals in hours']) - - self._construct_component_results() - self._construct_bus_results() - self._construct_effect_results() - - def _construct_component_results(self): - comp_results = self.all_results['Components'] - comp_infos = self.all_data['Components'] - if not comp_results.keys() == comp_infos.keys(): - logger.warning(f'Missing Component or mismatched keys: {comp_results.keys() ^ comp_infos.keys()}') - - for key in comp_results.keys(): - infos, results = comp_infos.get(key, {}), comp_results.get(key, {}) - res = ComponentResults(infos, results) - self.component_results[res.label] = res - - def _construct_effect_results(self): - effect_results = self.all_results['Effects'] - effect_infos = self.all_data['Effects'] - effect_infos['penalty'] = {'label': 'Penalty'} - if not effect_results.keys() == effect_infos.keys(): - logger.warning(f'Missing Effect or mismatched keys: {effect_results.keys() ^ effect_infos.keys()}') - - for key in effect_results.keys(): - infos, results = effect_infos.get(key, {}), effect_results.get(key, {}) - res = EffectResults(infos, results) - self.effect_results[res.label] = res - - def _construct_bus_results(self): - """This has to be called after _construct_component_results(), as its using the Flows from the Components""" - bus_results = self.all_results['Buses'] - bus_infos = self.all_data['Buses'] - if not bus_results.keys() == bus_infos.keys(): - logger.warning(f'Missing Bus or mismatched keys: {bus_results.keys() ^ bus_infos.keys()}') - - for bus_label in bus_results.keys(): - infos, results = bus_infos.get(bus_label, {}), bus_results.get(bus_label, {}) - inputs = [ - flow - for flow in self.flow_results().values() - if bus_label == flow.bus_label and not flow.is_input_in_component - ] - outputs = [ - flow - for flow in self.flow_results().values() - if bus_label == flow.bus_label and flow.is_input_in_component - ] - res = BusResults(infos, results, inputs, outputs) - self.bus_results[res.label] = res - - def flow_results(self) -> Dict[str, 'FlowResults']: - return { - flow.label_full: flow for comp in self.component_results.values() for flow in comp.inputs + comp.outputs - } - - def to_dataframe( - self, - label: str, - variable_name: str = 'flow_rate', - input_factor: Optional[Literal[1, -1]] = -1, - output_factor: Optional[Literal[1, -1]] = 1, - threshold: Optional[float] = 1e-5, - with_last_time_step: bool = True, - ) -> pd.DataFrame: - """ - Convert results of a specified element to a DataFrame. - - Parameters - ---------- - label : str - The label of the element (Component, Bus, or Flow) to retrieve data for. - variable_name : str, default='flow_rate' - The name of the variable to extract from the element's data. - input_factor : Optional[Literal[1, -1]], default=-1 - Factor to apply to input values. - output_factor : Optional[Literal[1, -1]], default=1 - Factor to apply to output values. - threshold : Optional[float], default=1e-5 - Minimum absolute value for data inclusion in the DataFrame. - with_last_time_step : bool, default=True - Whether to include the last time step in the DataFrame index. - - Returns - ------- - pd.DataFrame - A DataFrame containing the specified variable's data with a datetime index. - Dataframe is empty (no index), if no values are left after filtering. - - Raises - ------ - ValueError - If no data is found for the specified variable. - """ - - comp_or_bus = {**self.component_results, **self.bus_results}.get(label, None) - flow = self.flow_results().get(label, None) - - if comp_or_bus is not None and flow is not None: - raise Exception(f'{label=} matches both a Flow and a Component/Bus. That is an internal Error!') - elif comp_or_bus is not None: - df = comp_or_bus.to_dataframe(variable_name, input_factor, output_factor) - elif flow is not None: - df = flow.to_dataframe(variable_name) - else: - raise ValueError(f'No Element found with {label=}') - - if threshold is not None: - df = df.loc[:, ((df > threshold) | (df < -1 * threshold)).any()] # Check if any value exceeds the threshold - if df.empty: # If no values are left, return an empty DataFrame - return df - - if with_last_time_step: - if len(df) == len(self.time): - df.loc[len(df)] = df.iloc[-1] - df.index = self.time_with_end - elif len(df) == len(self.time_with_end): - df.index = self.time_with_end - else: - df.index = self.time - - return df - - def plot_operation( - self, - label: str, - mode: Literal['bar', 'line', 'area', 'heatmap'] = 'area', - variable_name: str = 'flow_rate', - heatmap_periods: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', - heatmap_steps_per_period: Literal['W', 'D', 'h', '15min', 'min'] = 'h', - colors: Union[str, List[str]] = 'viridis', - engine: Literal['plotly', 'matplotlib'] = 'plotly', - invert: bool = True, - show: bool = True, - save: bool = False, - path: Union[str, pathlib.Path, Literal['auto']] = 'auto', - ) -> Union['go.Figure', Tuple['plt.Figure', 'plt.Axes']]: - """ - Plots the operation results for a specified Element using the chosen plotting engine and mode. - - Parameters - ---------- - label : str - The label of the element to plot (e.g., a component or bus). - mode : {'bar', 'line', 'area', 'heatmap'}, default='area' - The type of plot to generate. - variable_name : str, default='flow_rate' - The variable to plot from the element's data. - heatmap_periods : {'YS', 'MS', 'W', 'D', 'h', '15min', 'min'}, default='D' - The period for heatmap plotting. - heatmap_steps_per_period : {'W', 'D', 'h', '15min', 'min'}, default='h' - The steps per period for heatmap plotting. - colors : str or List[str], default='viridis' - The colors or colorscale to use for the plot. - engine : {'plotly', 'matplotlib'}, default='plotly' - The plotting engine to use. - invert : bool, default=False - Whether to invert the input and output factors. - show : bool, default=True - Whether to display the plot immediately. (This includes saving the plot to file when engine='plotly') - save : bool, default=False - Whether to save the plot to a file. - path : Union[str, pathlib.Path, Literal['auto']], default='auto' - The path to save the plot to. If 'auto', the plot is saved to an automatically named file. - - Returns - ------- - Union[go.Figure, Tuple[plt.Figure, plt.Axes]] - The generated plot object, either a Plotly figure or a Matplotlib figure and axes. - - Raises - ------ - ValueError - If an invalid engine or color configuration is provided for heatmap mode. - """ - - if mode == 'heatmap' and not np.all(self.time_intervals_in_hours == self.time_intervals_in_hours[0]): - logger.warning( - 'Heat map plotting with irregular time intervals in time series can lead to unwanted effects' - ) - if mode == 'heatmap' and not isinstance(colors, str): - raise ValueError( - f'For a heatmap, you need to pass the colors as a valid name of a colormap, not {colors=}.' - f'Try "Turbo", "Hot", or "Viridis" instead.' - ) - - title = f'{variable_name.replace("_", " ").title()} of {label}' - if path == 'auto': - file_suffix = 'html' if engine == 'plotly' else 'png' - if mode == 'heatmap': - path = self.folder / f'{title} ({mode} {heatmap_periods}-{heatmap_steps_per_period}).{file_suffix}' - else: - path = self.folder / f'{title} ({mode}).{file_suffix}' - - data = self.to_dataframe( - label, variable_name, input_factor=-1 if not invert else 1, output_factor=1 if not invert else -1 - ) - if mode == 'heatmap': - heatmap_data = plotting.heat_map_data_from_df(data, heatmap_periods, heatmap_steps_per_period, 'ffill') - - if engine == 'plotly': - if mode == 'heatmap': - return plotting.heat_map_plotly( - heatmap_data, title=title, color_map=colors, show=show, save=save, path=path - ) - else: - return plotting.with_plotly( - data=data, mode=mode, show=show, title=title, colors=colors, save=save, path=path - ) - - elif engine == 'matplotlib': - if mode == 'heatmap': - return plotting.heat_map_matplotlib( - heatmap_data, color_map=colors, show=show, path=path if save else None - ) - else: - return plotting.with_matplotlib( - data=data, mode=mode, colors=colors, show=show, path=path if save else None - ) - else: - raise ValueError(f'Unknown Engine: {engine=}') - - def plot_storage( - self, - label: str, - variable_name: str = 'flow_rate', - mode: Literal['bar', 'line', 'area'] = 'area', - colors: Union[str, List[str]] = 'viridis', - invert: bool = True, - show: bool = True, - save: bool = False, - path: Union[str, pathlib.Path, Literal['auto']] = 'auto', - ): - """ - Plots the storage operation results for a specified Storage Element, including its charge state. - - Parameters - ---------- - label : str - The label of the Storage to plot - variable_name : str, default='flow_rate' - The variable to plot from the element's data. - mode : {'bar', 'line', 'area'}, default='area' - The type of plot to generate. - colors : str or List[str], default='viridis' - The colors or colorscale to use for the plot. - invert : bool, default=True - Whether to invert the input and output factors. - show : bool, default=True - Whether to display the plot immediately. (This includes saving the plot to file when engine='plotly') - save : bool, default=False - Whether to save the plot to a file. - path : Union[str, pathlib.Path, Literal['auto']], default='auto' - The path to save the plot to. If 'auto', the plot is saved to an automatically named file. - - Returns - ------- - plotly.graph_objs.Figure - The generated Plotly figure object with the storage operation plot. - """ - fig = self.plot_operation( - label, mode, variable_name, invert=invert, engine='plotly', show=False, colors=colors, save=False - ) - fig.add_trace( - plotly.graph_objs.Scatter( - x=self.time_with_end, - y={**self.component_results, **self.bus_results}[label].variables['charge_state'], - mode='lines', - name='Charge State', - ) - ) - - title = f'{variable_name.replace("_", " ").title()} and Charge State of {label}' - fig.update_layout(title=title) - - if path == 'auto': - path = self.folder / f'{title} ({mode}).html' - path = path.as_posix() - if show: - plotly.offline.plot(fig, filename=path) - elif save: # If show, the file is saved anyway - fig.write_html(path) - - return fig - - def visualize_network( - self, - path: Union[bool, str, pathlib.Path] = 'results/network.html', - controls: Union[ - bool, - List[ - Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'] - ], - ] = True, - show: bool = True, - ) -> Optional['pyvis.network.Network']: - """ - Visualizes the network structure of a FlowSystem using PyVis, saving it as an interactive HTML file. - - Parameters - ---------- - path : Union[bool, str, pathlib.Path], default='results/network.html' - Path to save the HTML visualization. If False, the visualization is created but not saved. - controls : Union[bool, List[str]], default=True - UI controls to add to the visualization. True enables all available controls, or specify a list of controls. - show : bool, default=True - Whether to open the visualization in the web browser. - - Returns - ------- - Optional[pyvis.network.Network] - The Network instance representing the visualization, or None if pyvis is not installed. - - Notes - ----- - This function requires pyvis. If not installed, the function prints a warning and returns None. - Nodes are styled based on type (e.g., circles for buses, boxes for components) and annotated with node information. - """ - from . import plotting - - return plotting.visualize_network( - self.calculation_infos['Network']['Nodes'], self.calculation_infos['Network']['Edges'], path, controls, show - ) - - -class FlowResults(ElementResults): - def __init__(self, infos: Dict, results: Dict, label_of_component: str) -> None: - super().__init__(infos, results) - self.is_input_in_component = self.all_infos['is_input_in_component'] - self.component_label = label_of_component - self.bus_label = self.all_infos['bus']['label'] - self.label_full = f'{label_of_component}__{self.label}' - self.variables = self.all_results - - def to_dataframe(self, variable_name: str = 'flow_rate') -> pd.DataFrame: - return pd.DataFrame({variable_name: self.variables[variable_name]}) - - -class ComponentResults(ElementResults): - def __init__(self, infos: Dict, results: Dict): - super().__init__(infos, results) - inputs, outputs = self._create_flow_results() - self.inputs: List[FlowResults] = inputs - self.outputs: List[FlowResults] = outputs - self.variables = {key: val for key, val in self.all_results.items() if key not in self.inputs + self.outputs} - - def _create_flow_results(self) -> Tuple[List[FlowResults], List[FlowResults]]: - flow_infos = {flow['label']: flow for flow in self.all_infos['inputs'] + self.all_infos['outputs']} - flow_results = {flow_info['label']: self.all_results[flow_info['label']] for flow_info in flow_infos.values()} - flows = [ - FlowResults(flow_info, flow_result, self.label) - for flow_info, flow_result in zip(flow_infos.values(), flow_results.values(), strict=False) - ] - inputs = [flow for flow in flows if flow.is_input_in_component] - outputs = [flow for flow in flows if not flow.is_input_in_component] - return inputs, outputs - - def to_dataframe( - self, - variable_name: str = 'flow_rate', - input_factor: Optional[Literal[1, -1]] = -1, - output_factor: Optional[Literal[1, -1]] = 1, - ) -> pd.DataFrame: - inputs, outputs = {}, {} - if input_factor is not None: - inputs = {flow.label_full: (flow.variables[variable_name] * input_factor) for flow in self.inputs} - if output_factor is not None: - outputs = {flow.label_full: flow.variables[variable_name] * output_factor for flow in self.outputs} - - return pd.DataFrame(data={**inputs, **outputs}) - - -class BusResults(ElementResults): - def __init__(self, infos: Dict, results: Dict, inputs: List[FlowResults], outputs: List[FlowResults]): - super().__init__(infos, results) - self.inputs = inputs - self.outputs = outputs - self.variables = {key: val for key, val in self.all_results.items() if key not in self.inputs + self.outputs} - - def to_dataframe( - self, - variable_name: str = 'flow_rate', - input_factor: Optional[Literal[1, -1]] = -1, - output_factor: Optional[Literal[1, -1]] = 1, - ) -> pd.DataFrame: - inputs, outputs = {}, {} - if input_factor is not None: - inputs = {flow.label_full: (flow.variables[variable_name] * input_factor) for flow in self.inputs} - if 'excess_input' in self.variables: - inputs['Excess Input'] = self.variables['excess_input'] * input_factor - if output_factor is not None: - outputs = {flow.label_full: flow.variables[variable_name] * output_factor for flow in self.outputs} - if 'excess_output' in self.variables: - outputs['Excess Output'] = self.variables['excess_output'] * output_factor - - return pd.DataFrame(data={**inputs, **outputs}) - - -class EffectResults(ElementResults): - pass - - -def extract_single_result( - results_data: dict[str, Dict[str, Union[int, float, np.ndarray, dict]]], keys: List[str] -) -> Optional[Union[int, float, np.ndarray]]: - """Goes through a nested dictionary with the given keys. Returns the value if found. Else returns None""" - for key in keys: - if isinstance(results_data, dict): - results_data = results_data.get(key, None) - else: - return None - return results_data - - -def extract_results( - results_data: dict[str, Dict[str, Union[int, float, np.ndarray, dict]]], keys: List[str], keep_none: bool = False -) -> Dict[str, Union[int, float, np.ndarray]]: - """For each item in a dictionary, goes through its sub dictionaries. - Returns the value if found. Else returns None. If specified, removes all None values - """ - data = {kind: extract_single_result(results_data.get(kind, {}), keys) for kind in results_data.keys()} - if keep_none: - return data - else: - return {key: value for key, value in data.items() if value is not None} - - -def flatten_dict(d, parent_key='', sep='__'): - """ - Recursively flattens a nested dictionary. - - Parameters: - d (dict): The dictionary to flatten. - parent_key (str): The base key for the current recursion level. - sep (str): The separator to use when concatenating keys. - - Returns: - dict: A flattened dictionary. - """ - items = [] - for k, v in d.items(): - new_key = f'{parent_key}{sep}{k}' if parent_key else k # Combine parent key and current key - if isinstance(v, dict): # If the value is a nested dictionary, recurse - items.extend(flatten_dict(v, new_key, sep=sep).items()) - else: # Otherwise, just add the key-value pair - if new_key not in items: - items.append((new_key, v)) - else: - for i in range(100000): - new_key = f'{new_key}_#{i}' - if new_key not in items: - items.append((new_key, v)) - break - return dict(items) - - -if __name__ == '__main__': - results = CalculationResults( - 'Sim1', '/Users/felix/Documents/Dokumente - eigene/Neuer Ordner/flixOpt-Fork/examples/Ex02_complex/results' - ) - - results.to_dataframe('Kessel') - results.plot_flow_rate('Kessel__Q_fu', 'heatmap') - plotting.heat_map_plotly( - plotting.heat_map_data_from_df( - pd.DataFrame(results.component_results['Speicher'].variables['charge_state'], index=results.time_with_end), - periods='D', - steps_per_period='15min', - ) - ) - - results.plot_operation('FernwƤrme', 'area', engine='plotly') - fig = results.plot_operation('FernwƤrme', 'area', engine='plotly') - fig = plotting.with_plotly(results.to_dataframe('WƤrmelast'), 'line', fig=fig) - import plotly.offline - - plotly.offline.plot(fig) - - extract_results(results.all_results['Components'], ['Q_th', 'flow_rate']) - extract_single_result(results.all_results['Components'], ['Kessel', 'Q_th', 'flow_rate']) - - fig = plotting.with_plotly( - pd.DataFrame(extract_results(results.all_results['Components'], ['OnOff', 'on']), index=results.time), - mode='bar', - ) - fig.update_layout(barmode='group', bargap=0.2, bargroupgap=0.1) - plotly.offline.plot(fig) diff --git a/flixOpt/solvers.py b/flixOpt/solvers.py deleted file mode 100644 index 5ca4b5945..000000000 --- a/flixOpt/solvers.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -This module contains the solvers of the flixOpt framework, making them available to the end user in a compact way. -""" - -from .math_modeling import ( - CbcSolver, - CplexSolver, - GlpkSolver, - GurobiSolver, - HighsSolver, - Solver, -) - -__all__ = [ - 'Solver', - 'HighsSolver', - 'GurobiSolver', - 'CbcSolver', - 'CplexSolver', - 'GlpkSolver', -] diff --git a/flixOpt/structure.py b/flixOpt/structure.py deleted file mode 100644 index 1196e75a9..000000000 --- a/flixOpt/structure.py +++ /dev/null @@ -1,733 +0,0 @@ -""" -This module contains the core structure of the flixOpt framework. -These classes are not directly used by the end user, but are used by other modules. -""" - -import inspect -import json -import logging -import pathlib -from datetime import datetime -from io import StringIO -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union - -import numpy as np -from rich.console import Console -from rich.pretty import Pretty - -from . import utils -from .config import CONFIG -from .core import Numeric, Numeric_TS, Skalar, TimeSeries, TimeSeriesData -from .math_modeling import Equation, Inequation, MathModel, Solver, Variable, VariableTS - -if TYPE_CHECKING: # for type checking and preventing circular imports - from .elements import BusModel, ComponentModel - from .flow_system import FlowSystem - -logger = logging.getLogger('flixOpt') - - -class SystemModel(MathModel): - """ - Hier kommen die ModellingLanguage-spezifischen Sachen rein - """ - - def __init__( - self, - label: str, - modeling_language: Literal['pyomo', 'cvxpy'], - flow_system: 'FlowSystem', - time_indices: Optional[Union[List[int], range]], - ): - super().__init__(label, modeling_language) - self.flow_system = flow_system - # Zeitdaten generieren: - self.time_series, self.time_series_with_end, self.dt_in_hours, self.dt_in_hours_total = ( - flow_system.get_time_data_from_indices(time_indices) - ) - self.previous_dt_in_hours = flow_system.previous_dt_in_hours - self.nr_of_time_steps = len(self.time_series) - self.indices = range(self.nr_of_time_steps) - - self.effect_collection_model = flow_system.effect_collection.create_model(self) - self.component_models: List['ComponentModel'] = [] - self.bus_models: List['BusModel'] = [] - self.other_models: List[ElementModel] = [] - - def do_modeling(self): - self.effect_collection_model.do_modeling(self) - self.component_models = [component.create_model() for component in self.flow_system.components.values()] - self.bus_models = [bus.create_model() for bus in self.flow_system.buses.values()] - for component_model in self.component_models: - component_model.do_modeling(self) - for bus_model in self.bus_models: # Buses after Components, because FlowModels are created in ComponentModels - bus_model.do_modeling(self) - - def solve(self, solver: Solver, excess_threshold: Union[int, float] = 0.1): - """ - Parameters - ---------- - solver : Solver - An Instance of the class Solver. Choose from flixOpt.solvers - excess_threshold : float, positive! - threshold for excess: If sum(Excess)>excess_threshold a warning is raised, that an excess occurs - """ - - logger.info(f'{" starting solving ":#^80}') - logger.info(f'{self.describe_size()}') - - super().solve(solver) - - logger.info(f'Termination message: "{self.solver.termination_message}"') - - logger.info(f'{" finished solving ":#^80}') - logger.info(f'{" Main Results ":#^80}') - for effect_name, effect_results in self.main_results['Effects'].items(): - logger.info( - f'{effect_name}:\n' - f' {"operation":<15}: {effect_results["operation"]:>10.2f}\n' - f' {"invest":<15}: {effect_results["invest"]:>10.2f}\n' - f' {"sum":<15}: {effect_results["sum"]:>10.2f}' - ) - - logger.info( - # f'{"SUM":<15}: ...todo...\n' - f'{"Penalty":<17}: {self.main_results["penalty"]:>10.2f}\n' - f'{"":-^80}\n' - f'{"Objective":<17}: {self.main_results["Objective"]:>10.2f}\n' - f'{"":-^80}' - ) - - logger.info('Investment Decisions:') - logger.info( - utils.apply_formating( - data_dict={ - **self.main_results['Invest-Decisions']['invested'], - **self.main_results['Invest-Decisions']['not invested'], - }, - key_format='<30', - indent=2, - sort_by='value', - ) - ) - - for bus in self.main_results['buses with excess']: - logger.warning(f'A penalty occurred in Bus "{bus}"!') - - if self.main_results['penalty'] > 10: - logger.warning(f'A total penalty of {self.main_results["penalty"]} occurred.This might distort the results') - logger.info(f'{" End of Main Results ":#^80}') - - def description_of_variables(self, structured: bool = True) -> Dict[str, Union[str, List[str]]]: - return { - 'Components': { - label: comp.model.description_of_variables(structured) - for label, comp in self.flow_system.components.items() - }, - 'Buses': { - label: bus.model.description_of_variables(structured) for label, bus in self.flow_system.buses.items() - }, - 'Effects': self.flow_system.effect_collection.model.description_of_variables(structured), - 'Others': {model.element.label: model.description_of_variables(structured) for model in self.other_models}, - } - - def description_of_constraints(self, structured: bool = True) -> Dict[str, Union[str, List[str]]]: - return { - 'Components': { - label: comp.model.description_of_constraints(structured) - for label, comp in self.flow_system.components.items() - }, - 'Buses': { - label: bus.model.description_of_constraints(structured) for label, bus in self.flow_system.buses.items() - }, - 'Objective': self.objective.description(), - 'Effects': self.flow_system.effect_collection.model.description_of_constraints(structured), - 'Others': { - model.element.label: model.description_of_constraints(structured) for model in self.other_models - }, - } - - def results(self): - return { - 'Components': {model.element.label: model.results() for model in self.component_models}, - 'Effects': self.effect_collection_model.results(), - 'Buses': {model.element.label: model.results() for model in self.bus_models}, - 'Others': {model.element.label: model.results() for model in self.other_models}, - 'Objective': self.result_of_objective, - 'Time': self.time_series_with_end, - 'Time intervals in hours': self.dt_in_hours, - } - - @property - def main_results(self) -> Dict[str, Union[Skalar, Dict]]: - main_results = {} - effect_results = {} - main_results['Effects'] = effect_results - for effect in self.flow_system.effect_collection.effects.values(): - effect_results[f'{effect.label} [{effect.unit}]'] = { - 'operation': float(effect.model.operation.sum.result), - 'invest': float(effect.model.invest.sum.result), - 'sum': float(effect.model.all.sum.result), - } - main_results['penalty'] = float(self.effect_collection_model.penalty.sum.result) - main_results['Objective'] = self.result_of_objective - main_results['lower bound'] = self.solver.best_bound - buses_with_excess = [] - main_results['buses with excess'] = buses_with_excess - for bus in self.flow_system.buses.values(): - if bus.with_excess: - if np.sum(bus.model.excess_input.result) > 1e-3 or np.sum(bus.model.excess_output.result) > 1e-3: - buses_with_excess.append(bus.label) - - invest_decisions = {'invested': {}, 'not invested': {}} - main_results['Invest-Decisions'] = invest_decisions - from flixOpt.features import InvestmentModel - - for sub_model in self.sub_models: - if isinstance(sub_model, InvestmentModel): - invested_size = float(sub_model.size.result) # bei np.floats Probleme bei Speichern - if invested_size > 1e-3: - invest_decisions['invested'][sub_model.element.label_full] = invested_size - else: - invest_decisions['not invested'][sub_model.element.label_full] = invested_size - - return main_results - - @property - def infos(self) -> Dict: - infos = super().infos - infos['Constraints'] = self.description_of_constraints() - infos['Variables'] = self.description_of_variables() - infos['Main Results'] = self.main_results - infos['Config'] = CONFIG.to_dict() - return infos - - @property - def all_variables(self) -> Dict[str, Variable]: - all_vars = {} - for model in self.sub_models: - for label, variable in model.variables.items(): - if label in all_vars: - raise KeyError(f'Duplicate Variable found in SystemModel:{model=} {label=}; {variable=}') - all_vars[label] = variable - return all_vars - - @property - def all_constraints(self) -> Dict[str, Union[Equation, Inequation]]: - all_constr = {} - for model in self.sub_models: - for label, constr in model.constraints.items(): - if label in all_constr: - raise KeyError(f'Duplicate Constraint found in SystemModel: {label=}; {constr=}') - else: - all_constr[label] = constr - return all_constr - - @property - def all_equations(self) -> Dict[str, Equation]: - return {key: value for key, value in self.all_constraints.items() if isinstance(value, Equation)} - - @property - def all_inequations(self) -> Dict[str, Inequation]: - return {key: value for key, value in self.all_constraints.items() if isinstance(value, Inequation)} - - @property - def sub_models(self) -> List['ElementModel']: - direct_models = [self.effect_collection_model] + self.component_models + self.bus_models + self.other_models - sub_models = [sub_model for direct_model in direct_models for sub_model in direct_model.all_sub_models] - return direct_models + sub_models - - @property - def variables(self) -> List[Variable]: - """Needed for Mother class""" - return list(self.all_variables.values()) - - @property - def equations(self) -> List[Equation]: - """Needed for Mother class""" - return list(self.all_equations.values()) - - @property - def inequations(self) -> List[Inequation]: - """Needed for Mother class""" - return list(self.all_inequations.values()) - - @property - def objective(self) -> Equation: - return self.effect_collection_model.objective - - -class Interface: - """ - This class is used to collect arguments about a Model. - """ - - def transform_data(self): - raise NotImplementedError('Every Interface needs a transform_data() method') - - def infos(self, use_numpy=True, use_element_label=False) -> Dict: - """ - Generate a dictionary representation of the object's constructor arguments. - Excludes default values and empty dictionaries and lists. - Converts data to be compatible with JSON. - - Parameters: - ----------- - use_numpy bool: - Whether to convert NumPy arrays to lists. Defaults to True. - If True, numeric numpy arrays (`np.ndarray`) are preserved as-is. - If False, they are converted to lists. - use_element_label bool: - Whether to use the element label instead of the infos of the element. Defaults to False. - Note that Elements used as keys in dictionaries are always converted to their labels. - - Returns: - Dict: A dictionary representation of the object's constructor arguments. - - """ - # Get the constructor arguments and their default values - init_params = sorted( - inspect.signature(self.__init__).parameters.items(), - key=lambda x: (x[0].lower() != 'label', x[0].lower()), # Prioritize 'label' - ) - # Build a dict of attribute=value pairs, excluding defaults - details = {'class': ':'.join([cls.__name__ for cls in self.__class__.__mro__])} - for name, param in init_params: - if name == 'self': - continue - value, default = getattr(self, name, None), param.default - # Ignore default values and empty dicts and list - if np.all(value == default) or (isinstance(value, (dict, list)) and not value): - continue - details[name] = copy_and_convert_datatypes(value, use_numpy, use_element_label) - return details - - def to_json(self, path: Union[str, pathlib.Path]): - """ - Saves the element to a json file. - This not meant to be reloaded and recreate the object, but rather used to document or compare the object. - - Parameters: - ----------- - path : Union[str, pathlib.Path] - The path to the json file. - """ - data = get_compact_representation(self.infos(use_numpy=True, use_element_label=True)) - with open(path, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=4, ensure_ascii=False) - - def __repr__(self): - # Get the constructor arguments and their current values - init_signature = inspect.signature(self.__init__) - init_args = init_signature.parameters - - # Create a dictionary with argument names and their values - args_str = ', '.join(f'{name}={repr(getattr(self, name, None))}' for name in init_args if name != 'self') - return f'{self.__class__.__name__}({args_str})' - - def __str__(self): - return get_str_representation(self.infos(use_numpy=True, use_element_label=True)) - - -class Element(Interface): - """Basic Element of flixOpt""" - - def __init__(self, label: str, meta_data: Dict = None): - """ - Parameters - ---------- - label : str - label of the element - meta_data : Optional[Dict] - used to store more information about the element. Is not used internally, but saved in the results - """ - if not utils.label_is_valid(label): - logger.critical( - f"'{label}' cannot be used as a label. Leading or Trailing '_' and '__' are reserved. " - f'Use any other symbol instead' - ) - self.label = label - self.meta_data = meta_data if meta_data is not None else {} - self.used_time_series: List[TimeSeries] = [] # Used for better access - self.model: Optional[ElementModel] = None - - def _plausibility_checks(self) -> None: - """This function is used to do some basic plausibility checks for each Element during initialization""" - raise NotImplementedError('Every Element needs a _plausibility_checks() method') - - def create_model(self) -> None: - raise NotImplementedError('Every Element needs a create_model() method') - - @property - def label_full(self) -> str: - return self.label - - -class ElementModel: - """Interface to create the mathematical Models for Elements""" - - def __init__(self, element: Element, label: Optional[str] = None): - logger.debug(f'Created {self.__class__.__name__} for {element.label_full}') - self.element = element - self.variables = {} - self.constraints = {} - self.sub_models = [] - self._label = label - - def add_variables(self, *variables: Variable) -> None: - for variable in variables: - if variable.label not in self.variables.keys(): - self.variables[variable.label] = variable - elif variable in self.variables.values(): - raise Exception(f'Variable "{variable.label}" already exists') - else: - raise Exception(f'A Variable with the label "{variable.label}" already exists') - - def add_constraints(self, *constraints: Union[Equation, Inequation]) -> None: - for constraint in constraints: - if constraint.label not in self.constraints.keys(): - self.constraints[constraint.label] = constraint - else: - raise Exception(f'Constraint "{constraint.label}" already exists') - - def description_of_variables(self, structured: bool = True) -> Union[Dict[str, Union[List[str], Dict]], List[str]]: - if structured: - # Gather descriptions of this model's variables - descriptions = {'_self': [var.description() for var in self.variables.values()]} - - # Recursively gather descriptions from sub-models - for sub_model in self.sub_models: - descriptions[sub_model.label] = sub_model.description_of_variables(structured=structured) - - return descriptions - else: - return [var.description() for var in self.all_variables.values()] - - def description_of_constraints(self, structured: bool = True) -> Union[Dict[str, str], List[str]]: - if structured: - # Gather descriptions of this model's variables - descriptions = {'_self': [constr.description() for constr in self.constraints.values()]} - - # Recursively gather descriptions from sub-models - for sub_model in self.sub_models: - descriptions[sub_model.label] = sub_model.description_of_constraints(structured=structured) - - return descriptions - else: - return [eq.description() for eq in self.all_equations.values()] - - @property - def overview_of_model_size(self) -> Dict[str, int]: - all_vars, all_eqs, all_ineqs = self.all_variables, self.all_equations, self.all_inequations - return { - 'no of Euations': len(all_eqs), - 'no of Equations single': sum(eq.nr_of_single_equations for eq in all_eqs.values()), - 'no of Inequations': len(all_ineqs), - 'no of Inequations single': sum(ineq.nr_of_single_equations for ineq in all_ineqs.values()), - 'no of Variables': len(all_vars), - 'no of Variables single': sum(var.length for var in all_vars.values()), - } - - @property - def inequations(self) -> Dict[str, Inequation]: - return {name: ineq for name, ineq in self.constraints.items() if isinstance(ineq, Inequation)} - - @property - def equations(self) -> Dict[str, Equation]: - return {name: eq for name, eq in self.constraints.items() if isinstance(eq, Equation)} - - @property - def all_variables(self) -> Dict[str, Variable]: - all_vars = self.variables.copy() - for sub_model in self.sub_models: - for key, value in sub_model.all_variables.items(): - if key in all_vars: - raise KeyError(f"Duplicate key found: '{key}' in both main model and submodel!") - all_vars[key] = value - return all_vars - - @property - def all_constraints(self) -> Dict[str, Union[Equation, Inequation]]: - all_constr = self.constraints.copy() - for sub_model in self.sub_models: - for key, value in sub_model.all_constraints.items(): - if key in all_constr: - raise KeyError(f"Duplicate key found: '{key}' in both main model and submodel!") - all_constr[key] = value - return all_constr - - @property - def all_equations(self) -> Dict[str, Equation]: - all_eqs = self.equations.copy() - for sub_model in self.sub_models: - for key, value in sub_model.all_equations.items(): - if key in all_eqs: - raise KeyError(f"Duplicate key found: '{key}' in both main model and submodel!") - all_eqs[key] = value - return all_eqs - - @property - def all_inequations(self) -> Dict[str, Inequation]: - all_ineqs = self.inequations.copy() - for sub_model in self.sub_models: - for key in sub_model.all_inequations: - if key in all_ineqs: - raise KeyError(f"Duplicate key found: '{key}' in both main model and submodel!") - return all_ineqs - - @property - def all_sub_models(self) -> List['ElementModel']: - all_subs = [] - to_process = self.sub_models.copy() - for model in to_process: - all_subs.append(model) - to_process.extend(model.sub_models) - return all_subs - - def results(self) -> Dict: - return { - **{variable.label_short: variable.result for variable in self.variables.values()}, - **{model.label: model.results() for model in self.sub_models}, - } - - @property - def label_full(self) -> str: - return f'{self.element.label_full}__{self._label}' if self._label else self.element.label_full - - @property - def label(self): - return self._label or self.element.label - - -def _create_time_series( - label: str, data: Optional[Union[Numeric_TS, TimeSeries]], element: Element -) -> Optional[TimeSeries]: - """Creates a TimeSeries from Numeric Data and adds it to the list of time_series of an Element. - If the data already is a TimeSeries, nothing happens and the TimeSeries gets cleaned and returned""" - if data is None: - return None - elif isinstance(data, TimeSeries): - data.clear_indices_and_aggregated_data() - return data - else: - time_series = TimeSeries(label=f'{element.label_full}__{label}', data=data) - element.used_time_series.append(time_series) - return time_series - - -def create_equation( - label: str, element_model: ElementModel, eq_type: Literal['eq', 'ineq'] = 'eq' -) -> Union[Equation, Inequation]: - """Creates an Equation and adds it to the model of the Element""" - if eq_type == 'eq': - constr = Equation(f'{element_model.label_full}_{label}', label) - elif eq_type == 'ineq': - constr = Inequation(f'{element_model.label_full}_{label}', label) - element_model.add_constraints(constr) - return constr - - -def create_variable( - label: str, - element_model: ElementModel, - length: int, - is_binary: bool = False, - fixed_value: Optional[Numeric] = None, - lower_bound: Optional[Numeric] = None, - upper_bound: Optional[Numeric] = None, - previous_values: Optional[Numeric] = None, - avoid_use_of_variable_ts: bool = False, -) -> VariableTS: - """Creates a VariableTS and adds it to the model of the Element""" - variable_label = f'{element_model.label_full}_{label}' - if length > 1 and not avoid_use_of_variable_ts: - var = VariableTS( - variable_label, length, label, is_binary, fixed_value, lower_bound, upper_bound, previous_values - ) - logger.debug(f'Created VariableTS "{variable_label}": [{length}]') - else: - var = Variable(variable_label, length, label, is_binary, fixed_value, lower_bound, upper_bound) - logger.debug(f'Created Variable "{variable_label}": [{length}]') - element_model.add_variables(var) - return var - - -def copy_and_convert_datatypes(data: Any, use_numpy: bool = True, use_element_label: bool = False) -> Any: - """ - Converts values in a nested data structure into JSON-compatible types while preserving or transforming numpy arrays - and custom `Element` objects based on the specified options. - - The function handles various data types and transforms them into a consistent, readable format: - - Primitive types (`int`, `float`, `str`, `bool`, `None`) are returned as-is. - - Numpy scalars are converted to their corresponding Python scalar types. - - Collections (`list`, `tuple`, `set`, `dict`) are recursively processed to ensure all elements are compatible. - - Numpy arrays are preserved or converted to lists, depending on `use_numpy`. - - Custom `Element` objects can be represented either by their `label` or their initialization parameters as a dictionary. - - Timestamps (`datetime`) are converted to ISO 8601 strings. - - Parameters - ---------- - data : Any - The input data to process, which may be deeply nested and contain a mix of types. - use_numpy : bool, optional - If `True`, numeric numpy arrays (`np.ndarray`) are preserved as-is. If `False`, they are converted to lists. - Default is `True`. - use_element_label : bool, optional - If `True`, `Element` objects are represented by their `label`. If `False`, they are converted into a dictionary - based on their initialization parameters. Default is `False`. - - Returns - ------- - Any - A transformed version of the input data, containing only JSON-compatible types: - - `int`, `float`, `str`, `bool`, `None` - - `list`, `dict` - - `np.ndarray` (if `use_numpy=True`. This is NOT JSON-compatible) - - Raises - ------ - TypeError - If the data cannot be converted to the specified types. - - Examples - -------- - >>> copy_and_convert_datatypes({'a': np.array([1, 2, 3]), 'b': Element(label='example')}) - {'a': array([1, 2, 3]), 'b': {'class': 'Element', 'label': 'example'}} - - >>> copy_and_convert_datatypes({'a': np.array([1, 2, 3]), 'b': Element(label='example')}, use_numpy=False) - {'a': [1, 2, 3], 'b': {'class': 'Element', 'label': 'example'}} - - Notes - ----- - - The function gracefully handles unexpected types by issuing a warning and returning a deep copy of the data. - - Empty collections (lists, dictionaries) and default parameter values in `Element` objects are omitted from the output. - - Numpy arrays with non-numeric data types are automatically converted to lists. - """ - if isinstance(data, np.integer): # This must be checked before checking for regular int and float! - return int(data) - elif isinstance(data, np.floating): - return float(data) - - elif isinstance(data, (int, float, str, bool, type(None))): - return data - elif isinstance(data, datetime): - return data.isoformat() - - elif isinstance(data, (tuple, set)): - return copy_and_convert_datatypes([item for item in data], use_numpy, use_element_label) - elif isinstance(data, dict): - return { - copy_and_convert_datatypes(key, use_numpy, use_element_label=True): copy_and_convert_datatypes( - value, use_numpy, use_element_label - ) - for key, value in data.items() - } - elif isinstance(data, list): # Shorten arrays/lists to be readable - if use_numpy and all([isinstance(value, (int, float)) for value in data]): - return np.array([item for item in data]) - else: - return [copy_and_convert_datatypes(item, use_numpy, use_element_label) for item in data] - - elif isinstance(data, np.ndarray): - if not use_numpy: - return copy_and_convert_datatypes(data.tolist(), use_numpy, use_element_label) - elif use_numpy and np.issubdtype(data.dtype, np.number): - return data - else: - logger.critical( - f'An np.array with non-numeric content was found: {data=}.It will be converted to a list instead' - ) - return copy_and_convert_datatypes(data.tolist(), use_numpy, use_element_label) - - elif isinstance(data, TimeSeries): - return copy_and_convert_datatypes(data.active_data, use_numpy, use_element_label) - elif isinstance(data, TimeSeriesData): - return copy_and_convert_datatypes(data.data, use_numpy, use_element_label) - - elif isinstance(data, Interface): - if use_element_label and isinstance(data, Element): - return data.label - return data.infos(use_numpy, use_element_label) - else: - raise TypeError(f'copy_and_convert_datatypes() did get unexpected data of type "{type(data)}": {data=}') - - -def get_compact_representation(data: Any, array_threshold: int = 50, decimals: int = 2) -> Dict: - """ - Generate a compact json serializable representation of deeply nested data. - Numpy arrays are statistically described if they exceed a threshold and converted to lists. - - Args: - data (Any): The data to format and represent. - array_threshold (int): Maximum length of NumPy arrays to display. Longer arrays are statistically described. - decimals (int): Number of decimal places in which to describe the arrays. - - Returns: - Dict: A dictionary representation of the data - """ - - def format_np_array_if_found(value: Any) -> Any: - """Recursively processes the data, formatting NumPy arrays.""" - if isinstance(value, (int, float, str, bool, type(None))): - return value - elif isinstance(value, np.ndarray): - return describe_numpy_arrays(value) - elif isinstance(value, dict): - return {format_np_array_if_found(k): format_np_array_if_found(v) for k, v in value.items()} - elif isinstance(value, (list, tuple, set)): - return [format_np_array_if_found(v) for v in value] - else: - logger.warning( - f'Unexpected value found when trying to format numpy array numpy array: {type(value)=}; {value=}' - ) - return value - - def describe_numpy_arrays(arr: np.ndarray) -> Union[str, List]: - """Shortens NumPy arrays if they exceed the specified length.""" - - def normalized_center_of_mass(array: Any) -> float: - # position in array (0 bis 1 normiert) - positions = np.linspace(0, 1, len(array)) # weights w_i - # mass center - if np.sum(array) == 0: - return np.nan - else: - return np.sum(positions * array) / np.sum(array) - - if arr.size > array_threshold: # Calculate basic statistics - fmt = f'.{decimals}f' - return ( - f'Array (min={np.min(arr):{fmt}}, max={np.max(arr):{fmt}}, mean={np.mean(arr):{fmt}}, ' - f'median={np.median(arr):{fmt}}, std={np.std(arr):{fmt}}, len={len(arr)}, ' - f'center={normalized_center_of_mass(arr):{fmt}})' - ) - else: - return np.around(arr, decimals=decimals).tolist() - - # Process the data to handle NumPy arrays - formatted_data = format_np_array_if_found(copy_and_convert_datatypes(data, use_numpy=True)) - - return formatted_data - - -def get_str_representation(data: Any, array_threshold: int = 50, decimals: int = 2) -> str: - """ - Generate a string representation of deeply nested data using `rich.print`. - NumPy arrays are shortened to the specified length and converted to strings. - - Args: - data (Any): The data to format and represent. - array_threshold (int): Maximum length of NumPy arrays to display. Longer arrays are statistically described. - decimals (int): Number of decimal places in which to describe the arrays. - - Returns: - str: The formatted string representation of the data. - """ - - formatted_data = get_compact_representation(data, array_threshold, decimals) - - # Use Rich to format and print the data - with StringIO() as output_buffer: - console = Console(file=output_buffer, width=1000) # Adjust width as needed - console.print(Pretty(formatted_data, expand_all=True, indent_guides=True)) - return output_buffer.getvalue() diff --git a/flixOpt/utils.py b/flixOpt/utils.py deleted file mode 100644 index dcd5b96f4..000000000 --- a/flixOpt/utils.py +++ /dev/null @@ -1,134 +0,0 @@ -""" -This module contains several utility functions used throughout the flixOpt framework. -""" - -import logging -from typing import Any, Dict, List, Literal, Optional, Union - -import numpy as np - -logger = logging.getLogger('flixOpt') - - -def as_vector(value: Union[int, float, np.ndarray, List], length: int) -> np.ndarray: - """ - Macht aus Skalar einen Vektor. Vektor bleibt Vektor. - -> Idee dahinter: Aufruf aus abgespeichertem Vektor schneller, als für jede i-te Gleichung zu Checken ob Vektor oder Skalar) - - Parameters - ---------- - - aValue: skalar, list, np.array - aLen : skalar - """ - # dtype = 'float64' # -> muss mit übergeben werden, sonst entstehen evtl. int32 Reihen (dort ist kein +/-inf mƶglich) - # TODO: as_vector() -> int32 Vektoren mƶglich machen - - # Wenn Skalar oder None, return directly as array - if value is None: - return np.array([None] * length) - if np.isscalar(value): - return np.ones(length) * value - - if len(value) != length: # Wenn Vektor nicht richtige LƤnge - raise Exception(f'error in changing to {length=}; vector has already {len(value)=}') - - if isinstance(value, np.ndarray): - return value - else: - return np.array(value) - - -def is_number(number_alias: Union[int, float, str]): - """Returns True is string is a number.""" - try: - float(number_alias) - return True - except ValueError: - return False - - -def check_time_series(label: str, time_series: np.ndarray[np.datetime64]): - # check sowohl für globale Zeitreihe, als auch für chosenIndexe: - - # Zeitdifferenz: - # zweites bis Letztes - erstes bis Vorletztes - dt = time_series[1:] - time_series[0:-1] - # dt_in_hours = dt.total_seconds() / 3600 - dt_in_hours = dt / np.timedelta64(1, 'h') - - # unterschiedliche dt: - if np.max(dt_in_hours) - np.min(dt_in_hours) != 0: - logger.warning(f'{label}: !! Achtung !! unterschiedliche delta_t von {min(dt)} h bis {max(dt)} h') - # negative dt: - if np.min(dt_in_hours) < 0: - raise Exception(label + ': Zeitreihe besitzt Zurücksprünge - vermutlich Zeitumstellung nicht beseitigt!') - - -def apply_formating( - data_dict: Dict[str, Union[int, float]], - key_format: str = '<17', - value_format: str = '>10.2f', - indent: int = 0, - sort_by: Optional[Literal['key', 'value']] = None, -) -> str: - if sort_by == 'key': - sorted_keys = sorted(data_dict.keys(), key=str.lower) - elif sort_by == 'value': - sorted_keys = sorted(data_dict, key=lambda k: data_dict[k], reverse=True) - else: - sorted_keys = data_dict.keys() - - lines = [f'{indent * " "}{key:{key_format}}: {data_dict[key]:{value_format}}' for key in sorted_keys] - return '\n'.join(lines) - - -def label_is_valid(label: str) -> bool: - """Function to make sure '__' is reserved for internal splitting of labels""" - if label.startswith('_') or label.endswith('_') or '__' in label: - return False - return True - - -def convert_numeric_lists_to_arrays( - d: Union[Dict[str, Any], List[Any], tuple], -) -> Union[Dict[str, Any], List[Any], tuple]: - """ - Recursively converts all lists of numeric values in a nested dictionary to numpy arrays. - Handles nested lists, tuples, and dictionaries. Does not alter the original dictionary. - """ - - def convert_list_to_array_if_numeric(sequence: Union[List[Any], tuple]) -> Union[np.ndarray, List[Any]]: - """ - Converts a Sequence to a numpy array if all elements are numeric. - Recursively processes each element. - Does not alter the original sequence. - Returns an empty list if the sequence is empty. - """ - # Check if the list is empty - if len(sequence) == 0: - return [] - # Check if all elements are numeric in the list - elif isinstance(sequence, list) and all(isinstance(item, (int, float)) for item in sequence): - return np.array(sequence) - else: - return [ - convert_numeric_lists_to_arrays(item) if isinstance(item, (dict, list, tuple)) else item - for item in sequence - ] - - if isinstance(d, dict): - d_copy = {} # Reconstruct the dict from ground up to not modify the original dictionary.' - for key, value in d.items(): - if isinstance(value, (list, tuple)): - d_copy[key] = convert_list_to_array_if_numeric(value) - elif isinstance(value, dict): - d_copy[key] = convert_numeric_lists_to_arrays(value) # Recursively process nested dictionaries - else: - d_copy[key] = value - return d_copy - elif isinstance(d, (list, tuple)): - # If the input itself is a list or tuple, process it as a sequence - return convert_list_to_array_if_numeric(d) - else: - return d diff --git a/flixOpt/__init__.py b/flixopt/__init__.py similarity index 77% rename from flixOpt/__init__.py rename to flixopt/__init__.py index 4ae00829a..b92766449 100644 --- a/flixOpt/__init__.py +++ b/flixopt/__init__.py @@ -1,5 +1,5 @@ """ -This module bundles all common functionality of flixOpt and sets up the logging +This module bundles all common functionality of flixopt and sets up the logging """ from .commons import ( @@ -14,6 +14,10 @@ InvestParameters, LinearConverter, OnOffParameters, + Piece, + Piecewise, + PiecewiseConversion, + PiecewiseEffects, SegmentedCalculation, Sink, Source, @@ -22,7 +26,6 @@ TimeSeriesData, Transmission, change_logging_level, - create_datetime_array, linear_converters, plotting, results, diff --git a/flixOpt/aggregation.py b/flixopt/aggregation.py similarity index 60% rename from flixOpt/aggregation.py rename to flixopt/aggregation.py index d2869d9f5..f149d5f20 100644 --- a/flixOpt/aggregation.py +++ b/flixopt/aggregation.py @@ -1,42 +1,41 @@ """ -This module contains the Aggregation functionality for the flixOpt framework. +This module contains the Aggregation functionality for the flixopt framework. Through this, aggregating TimeSeriesData is possible. """ import copy import logging +import pathlib import timeit import warnings -from collections import Counter -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Union +import linopy import numpy as np import pandas as pd try: import tsam.timeseriesaggregation as tsam + TSAM_AVAILABLE = True except ImportError: TSAM_AVAILABLE = False from .components import Storage -from .core import Skalar, TimeSeries, TimeSeriesData +from .core import Scalar, TimeSeriesData from .elements import Component from .flow_system import FlowSystem -from .math_modeling import Equation, Variable, VariableTS from .structure import ( Element, - ElementModel, + Model, SystemModel, - create_equation, - create_variable, ) if TYPE_CHECKING: import plotly.graph_objects as go warnings.filterwarnings('ignore', category=DeprecationWarning) -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') class Aggregation: @@ -47,24 +46,27 @@ class Aggregation: def __init__( self, original_data: pd.DataFrame, - hours_per_time_step: Skalar, - hours_per_period: Skalar, + hours_per_time_step: Scalar, + hours_per_period: Scalar, nr_of_periods: int = 8, weights: Dict[str, float] = None, time_series_for_high_peaks: List[str] = None, time_series_for_low_peaks: List[str] = None, ): """ - Write a docstring please - - Parameters - ---------- - timeseries: pd.DataFrame - timeseries of the data with a datetime index + Args: + original_data: The original data to aggregate + hours_per_time_step: The duration of each timestep in hours. + hours_per_period: The duration of each period in hours. + nr_of_periods: The number of typical periods to use in the aggregation. + weights: The weights for aggregation. If None, all time series are equally weighted. + time_series_for_high_peaks: List of time series to use for explicitly selecting periods with high values. + time_series_for_low_peaks: List of time series to use for explicitly selecting periods with low values. """ if not TSAM_AVAILABLE: - raise ImportError("The 'tsam' package is required for clustering functionality. " - "Install it with 'pip install tsam'.") + raise ImportError( + "The 'tsam' package is required for clustering functionality. Install it with 'pip install tsam'." + ) self.original_data = copy.deepcopy(original_data) self.hours_per_time_step = hours_per_time_step self.hours_per_period = hours_per_period @@ -93,7 +95,7 @@ def cluster(self) -> None: extremePeriodMethod='new_cluster_center' if self.use_extreme_periods else 'None', # Wenn Extremperioden eingebunden werden sollen, nutze die Methode 'new_cluster_center' aus tsam - weightDict=self.weights, + weightDict={name: weight for name, weight in self.weights.items() if name in self.original_data.columns}, addPeakMax=self.time_series_for_high_peaks, addPeakMin=self.time_series_for_low_peaks, ) @@ -139,7 +141,7 @@ def describe_clusters(self) -> str: def use_extreme_periods(self): return self.time_series_for_high_peaks or self.time_series_for_low_peaks - def plot(self, colormap: str = 'viridis', show: bool = True) -> 'go.Figure': + def plot(self, colormap: str = 'viridis', show: bool = True, save: Optional[pathlib.Path] = None) -> 'go.Figure': from . import plotting df_org = self.original_data.copy().rename( @@ -151,11 +153,21 @@ def plot(self, colormap: str = 'viridis', show: bool = True) -> 'go.Figure': fig = plotting.with_plotly(df_org, 'line', colors=colormap) for trace in fig.data: trace.update(dict(line=dict(dash='dash'))) - fig = plotting.with_plotly(df_agg, 'line', colors=colormap, show=show, fig=fig) + fig = plotting.with_plotly(df_agg, 'line', colors=colormap, fig=fig) fig.update_layout( title='Original vs Aggregated Data (original = ---)', xaxis_title='Index', yaxis_title='Value' ) + + plotting.export_figure( + figure_like=fig, + default_path=pathlib.Path('aggregated data.html'), + default_filetype='.html', + user_path=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False, + ) + return fig def get_cluster_indices(self) -> Dict[str, List[np.ndarray]]: @@ -217,60 +229,6 @@ def get_equation_indices(self, skip_first_index_of_period: bool = True) -> Tuple return np.array(idx_var1), np.array(idx_var2) -class TimeSeriesCollection: - def __init__(self, time_series_list: List[TimeSeries]): - self.time_series_list = time_series_list - self.group_weights: Dict[str, float] = {} - self._unique_labels() - self._calculate_aggregation_weigths() - self.weights: Dict[str, float] = { - time_series.label: time_series.aggregation_weight for time_series in self.time_series_list - } - self.data: Dict[str, np.ndarray] = { - time_series.label: time_series.active_data for time_series in self.time_series_list - } - - if np.all(np.isclose(list(self.weights.values()), 1, atol=1e-6)): - logger.info('All Aggregation weights were set to 1') - - def _calculate_aggregation_weigths(self): - """Calculates the aggergation weights of all TimeSeries. Necessary to use groups""" - groups = [ - time_series.aggregation_group - for time_series in self.time_series_list - if time_series.aggregation_group is not None - ] - group_size = dict(Counter(groups)) - self.group_weights = {group: 1 / size for group, size in group_size.items()} - for time_series in self.time_series_list: - time_series.aggregation_weight = self.group_weights.get( - time_series.aggregation_group, time_series.aggregation_weight or 1 - ) - - def _unique_labels(self): - """Makes sure every label of the TimeSeries in time_series_list is unique""" - label_counts = Counter([time_series.label for time_series in self.time_series_list]) - duplicates = [label for label, count in label_counts.items() if count > 1] - assert duplicates == [], 'Duplicate TimeSeries labels found: {}.'.format(', '.join(duplicates)) - - def insert_data(self, data: Dict[str, np.ndarray]): - for time_series in self.time_series_list: - if time_series.label in data: - time_series.aggregated_data = data[time_series.label] - logger.debug(f'Inserted data for {time_series.label}') - - def description(self) -> str: - # TODO: - result = f'{len(self.time_series_list)} TimeSeries used for aggregation:\n' - for time_series in self.time_series_list: - result += f' -> {time_series.label} (weight: {time_series.aggregation_weight:.4f}; group: "{time_series.aggregation_group}")\n' - if self.group_weights: - result += f'Aggregation_Groups: {list(self.group_weights.keys())}\n' - else: - result += 'Warning!: no agg_types defined, i.e. all TS have weight 1 (or explicitly given weight)!\n' - return result - - class AggregationParameters: def __init__( self, @@ -286,29 +244,20 @@ def __init__( """ Initializes aggregation parameters for time series data - Parameters - ---------- - hours_per_period : float - Duration of each period in hours. - nr_of_periods : int - Number of typical periods to use in the aggregation. - fix_storage_flows : bool - Whether to aggregate storage flows (load/unload); if other flows - are fixed, fixing storage flows is usually not required. - aggregate_data_and_fix_non_binary_vars : bool - Whether to aggregate all time series data, which allows to fix all time series variables (like flow_rate), - or only fix binary variables. If False non time_series data is changed!! If True, the mathematical Problem - is simplified even further. - percentage_of_period_freedom : float, optional - Specifies the maximum percentage (0–100) of binary values within each period - that can deviate as "free variables", chosen by the solver (default is 0). - This allows binary variables to be 'partly equated' between aggregated periods. - penalty_of_period_freedom : float, optional - The penalty associated with each "free variable"; defaults to 0. Added to Penalty - time_series_for_high_peaks : list of TimeSeriesData - List of time series to use for explicitly selecting periods with high values. - time_series_for_low_peaks : list of TimeSeriesData - List of time series to use for explicitly selecting periods with low values. + Args: + hours_per_period: Duration of each period in hours. + nr_of_periods: Number of typical periods to use in the aggregation. + fix_storage_flows: Whether to aggregate storage flows (load/unload); if other flows + are fixed, fixing storage flows is usually not required. + aggregate_data_and_fix_non_binary_vars: Whether to aggregate all time series data, which allows to fix all time series variables (like flow_rate), + or only fix binary variables. If False non time_series data is changed!! If True, the mathematical Problem + is simplified even further. + percentage_of_period_freedom: Specifies the maximum percentage (0–100) of binary values within each period + that can deviate as "free variables", chosen by the solver (default is 0). + This allows binary variables to be 'partly equated' between aggregated periods. + penalty_of_period_freedom: The penalty associated with each "free variable"; defaults to 0. Added to Penalty + time_series_for_high_peaks: List of TimeSeriesData to use for explicitly selecting periods with high values. + time_series_for_low_peaks: List of TimeSeriesData to use for explicitly selecting periods with low values. """ self.hours_per_period = hours_per_period self.nr_of_periods = nr_of_periods @@ -336,13 +285,14 @@ def use_low_peaks(self): return self.time_series_for_low_peaks is not None -class AggregationModel(ElementModel): +class AggregationModel(Model): """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""" def __init__( self, + model: SystemModel, aggregation_parameters: AggregationParameters, flow_system: FlowSystem, aggregation_data: Aggregation, @@ -351,13 +301,13 @@ def __init__( """ Modeling-Element for "index-equating"-equations """ - super().__init__(Element('Aggregation'), 'Model') + super().__init__(model, label_of_element='Aggregation', label_full='Aggregation') self.flow_system = flow_system self.aggregation_parameters = aggregation_parameters self.aggregation_data = aggregation_data self.components_to_clusterize = components_to_clusterize - def do_modeling(self, system_model: SystemModel): + def do_modeling(self): if not self.components_to_clusterize: components = self.flow_system.components.values() else: @@ -365,66 +315,89 @@ def do_modeling(self, system_model: SystemModel): indices = self.aggregation_data.get_equation_indices(skip_first_index_of_period=True) + time_variables: Set[str] = {k for k, v in self._model.variables.data.items() if 'time' in v.indexes} + binary_variables: Set[str] = {k for k, v in self._model.variables.data.items() if k in self._model.binaries} + binary_time_variables: Set[str] = time_variables & binary_variables + for component in components: if isinstance(component, Storage) and not self.aggregation_parameters.fix_storage_flows: continue # Fix Nothing in The Storage - all_variables_of_component = component.model.all_variables + all_variables_of_component = set(component.model.variables) + if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: - all_relevant_variables = [v for v in all_variables_of_component.values() if isinstance(v, VariableTS)] + relevant_variables = component.model.variables[all_variables_of_component & time_variables] else: - all_relevant_variables = [ - v for v in all_variables_of_component.values() if isinstance(v, VariableTS) and v.is_binary - ] - for variable in all_relevant_variables: - self.equate_indices(variable, indices, system_model) + relevant_variables = component.model.variables[all_variables_of_component & binary_time_variables] + for variable in relevant_variables: + self._equate_indices(component.model.variables[variable], indices) penalty = self.aggregation_parameters.penalty_of_period_freedom if (self.aggregation_parameters.percentage_of_period_freedom > 0) and penalty != 0: - for label, variable in self.variables.items(): - system_model.effect_collection_model.add_share_to_penalty( - f'Aggregation_penalty__{label}', variable, penalty - ) - - def equate_indices( - self, variable: Variable, indices: Tuple[np.ndarray, np.ndarray], system_model: SystemModel - ) -> Equation: - # Gleichung: - # eq1: x(p1,t) - x(p3,t) = 0 # wobei p1 und p3 im gleichen Cluster sind und t = 0..N_p - length = len(indices[0]) + for variable in self.variables_direct.values(): + self._model.effects.add_share_to_penalty('Aggregation', variable * penalty) + + def _equate_indices(self, variable: linopy.Variable, indices: Tuple[np.ndarray, np.ndarray]) -> None: assert len(indices[0]) == len(indices[1]), 'The length of the indices must match!!' + length = len(indices[0]) - eq = create_equation(f'Equate_indices_of_{variable.label}', self) - eq.add_summand(variable, 1, indices_of_variable=indices[0]) - eq.add_summand(variable, -1, indices_of_variable=indices[1]) + # 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}', + ) # Korrektur: (bisher nur für BinƤrvariablen:) - if variable.is_binary and self.aggregation_parameters.percentage_of_period_freedom > 0: - # correction-vars (so viele wie Indexe in eq:) - var_k1 = create_variable(f'Korr1_{variable.label}', self, length, is_binary=True) - var_k0 = create_variable(f'Korr0_{variable.label}', self, length, is_binary=True) + if ( + 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_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}', + ) + # equation extends ... # --> On(p3) can be 0/1 independent of On(p1,t)! # eq1: On(p1,t) - On(p3,t) + K1(p3,t) - K0(p3,t) = 0 # --> correction On(p3) can be: # On(p1,t) = 1 -> On(p3) can be 0 -> K0=1 (,K1=0) # On(p1,t) = 0 -> On(p3) can be 1 -> K1=1 (,K0=1) - eq.add_summand(var_k1, +1) - eq.add_summand(var_k0, -1) + con.lhs += 1 * var_k1 - 1 * var_k0 # interlock var_k1 and var_K2: # eq: var_k0(t)+var_k1(t) <= 1.1 - eq_lock = create_equation(f'lock_K0andK1_{variable.label}', self, eq_type='ineq') - eq_lock.add_summand(var_k0, 1) - eq_lock.add_summand(var_k1, 1) - eq_lock.add_constant(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}', + ) # Begrenzung der Korrektur-Anzahl: # eq: sum(K) <= n_Corr_max - eq_max = create_equation(f'Nr_of_Corrections_{variable.label}', self, eq_type='ineq') - eq_max.add_summand(var_k1, 1, as_sum=True) - eq_max.add_summand(var_k0, 1, as_sum=True) - eq_max.add_constant( - round(self.aggregation_parameters.percentage_of_period_freedom / 100 * var_k1.length) - ) # Maximum - return eq + 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}', + ) diff --git a/flixopt/calculation.py b/flixopt/calculation.py new file mode 100644 index 000000000..c7367cad2 --- /dev/null +++ b/flixopt/calculation.py @@ -0,0 +1,455 @@ +""" +This module contains the Calculation functionality for the flixopt framework. +It is used to calculate a SystemModel for a given FlowSystem through a solver. +There are three different Calculation types: + 1. FullCalculation: Calculates the SystemModel for the full FlowSystem + 2. AggregatedCalculation: Calculates the SystemModel for the full FlowSystem, but aggregates the TimeSeriesData. + This simplifies the mathematical model and usually speeds up the solving process. + 3. SegmentedCalculation: Solves a SystemModel for each individual Segment of the FlowSystem. +""" + +import logging +import math +import pathlib +import timeit +from typing import Any, Dict, List, Optional, Union + +import numpy as np +import pandas as pd +import yaml + +from . import io as fx_io +from . import utils as utils +from .aggregation import AggregationModel, AggregationParameters +from .components import Storage +from .config import CONFIG +from .core import Scalar +from .elements import Component +from .features import InvestmentModel +from .flow_system import FlowSystem +from .results import CalculationResults, SegmentedCalculationResults +from .solvers import _Solver +from .structure import SystemModel, copy_and_convert_datatypes, get_compact_representation + +logger = logging.getLogger('flixopt') + + +class Calculation: + """ + class for defined way of solving a flow_system optimization + """ + + def __init__( + self, + name: str, + flow_system: FlowSystem, + active_timesteps: Optional[pd.DatetimeIndex] = None, + folder: Optional[pathlib.Path] = None, + ): + """ + Args: + name: name of calculation + flow_system: flow_system which should be calculated + active_timesteps: list with indices, which should be used for calculation. If None, then all timesteps are used. + folder: folder where results should be saved. If None, then the current working directory is used. + """ + self.name = name + self.flow_system = flow_system + self.model: Optional[SystemModel] = None + self.active_timesteps = active_timesteps + + 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) + self.results: Optional[CalculationResults] = None + + if not self.folder.exists(): + try: + self.folder.mkdir(parents=False) + except FileNotFoundError as e: + raise FileNotFoundError( + f'Folder {self.folder} and its parent do not exist. Please create them first.' + ) from e + + @property + def main_results(self) -> Dict[str, Union[Scalar, Dict]]: + from flixopt.features import InvestmentModel + + return { + 'Objective': self.model.objective.value, + 'Penalty': float(self.model.effects.penalty.total.solution.values), + 'Effects': { + f'{effect.label} [{effect.unit}]': { + 'operation': float(effect.model.operation.total.solution.values), + 'invest': float(effect.model.invest.total.solution.values), + 'total': float(effect.model.total.solution.values), + } + for effect in self.flow_system.effects + }, + 'Invest-Decisions': { + 'Invested': { + model.label_of_element: float(model.size.solution) + for component in self.flow_system.components.values() + for model in component.model.all_sub_models + if isinstance(model, InvestmentModel) and float(model.size.solution) >= CONFIG.modeling.EPSILON + }, + 'Not invested': { + model.label_of_element: float(model.size.solution) + for component in self.flow_system.components.values() + for model in component.model.all_sub_models + if isinstance(model, InvestmentModel) and float(model.size.solution) < CONFIG.modeling.EPSILON + }, + }, + 'Buses with excess': [ + { + bus.label_full: { + 'input': float(np.sum(bus.model.excess_input.solution.values)), + 'output': float(np.sum(bus.model.excess_output.solution.values)), + } + } + for bus in self.flow_system.buses.values() + if bus.with_excess + and ( + float(np.sum(bus.model.excess_input.solution.values)) > 1e-3 + or float(np.sum(bus.model.excess_output.solution.values)) > 1e-3 + ) + ], + } + + @property + def summary(self): + return { + 'Name': self.name, + 'Number of timesteps': len(self.flow_system.time_series_collection.timesteps), + 'Calculation Type': self.__class__.__name__, + 'Constraints': self.model.constraints.ncons, + 'Variables': self.model.variables.nvars, + 'Main Results': self.main_results, + 'Durations': self.durations, + 'Config': CONFIG.to_dict(), + } + + +class FullCalculation(Calculation): + """ + class for defined way of solving a flow_system optimization + """ + + def do_modeling(self) -> SystemModel: + t_start = timeit.default_timer() + self._activate_time_series() + + self.model = self.flow_system.create_model() + self.model.do_modeling() + + self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) + return self.model + + def solve(self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_main_results: bool = True): + t_start = timeit.default_timer() + + self.model.solve( + log_fn=pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log', + solver_name=solver.name, + **solver.options, + ) + self.durations['solving'] = round(timeit.default_timer() - t_start, 2) + + if self.model.status == 'warning': + # Save the model and the flow_system to file in case of infeasibility + paths = fx_io.CalculationResultsPaths(self.folder, self.name) + from .io import document_linopy_model + + document_linopy_model(self.model, paths.model_documentation) + self.flow_system.to_netcdf(paths.flow_system) + raise RuntimeError( + f'Model was infeasible. Please check {paths.model_documentation=} and {paths.flow_system=} for more information.' + ) + + # Log the formatted output + if log_main_results: + logger.info(f'{" Main Results ":#^80}') + logger.info( + '\n' + + yaml.dump( + utils.round_floats(self.main_results), + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + indent=4, + ) + ) + + self.results = CalculationResults.from_calculation(self) + + def _activate_time_series(self): + self.flow_system.transform_data() + self.flow_system.time_series_collection.activate_timesteps( + active_timesteps=self.active_timesteps, + ) + + +class AggregatedCalculation(FullCalculation): + """ + class for defined way of solving a flow_system optimization + """ + + def __init__( + self, + name: str, + flow_system: FlowSystem, + aggregation_parameters: AggregationParameters, + components_to_clusterize: Optional[List[Component]] = None, + active_timesteps: Optional[pd.DatetimeIndex] = None, + folder: Optional[pathlib.Path] = None, + ): + """ + Class for Optimizing the `FlowSystem` including: + 1. Aggregating TimeSeriesData via typical periods using tsam. + 2. Equalizing variables of typical periods. + Args: + name: name of calculation + flow_system: flow_system which should be calculated + aggregation_parameters: Parameters for aggregation. See documentation of AggregationParameters class. + components_to_clusterize: List of Components to perform aggregation on. If None, then all components are aggregated. + This means, teh variables in the components are equalized to each other, according to the typical periods + computed in the DataAggregation + active_timesteps: pd.DatetimeIndex or None + list with indices, which should be used for calculation. If None, then all timesteps are used. + folder: folder where results should be saved. If None, then the current working directory is used. + """ + super().__init__(name, flow_system, active_timesteps, folder=folder) + self.aggregation_parameters = aggregation_parameters + self.components_to_clusterize = components_to_clusterize + self.aggregation = None + + def do_modeling(self) -> SystemModel: + t_start = timeit.default_timer() + self._activate_time_series() + self._perform_aggregation() + + # Model the System + self.model = self.flow_system.create_model() + self.model.do_modeling() + # Add Aggregation Model after modeling the rest + self.aggregation = AggregationModel( + self.model, self.aggregation_parameters, self.flow_system, self.aggregation, self.components_to_clusterize + ) + self.aggregation.do_modeling() + self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) + return self.model + + def _perform_aggregation(self): + from .aggregation import Aggregation + + t_start_agg = timeit.default_timer() + + # Validation + dt_min, dt_max = ( + np.min(self.flow_system.time_series_collection.hours_per_timestep), + np.max(self.flow_system.time_series_collection.hours_per_timestep), + ) + if not dt_min == dt_max: + raise ValueError( + f'Aggregation failed due to inconsistent time step sizes:' + f'delta_t varies from {dt_min} to {dt_max} hours.' + ) + steps_per_period = ( + self.aggregation_parameters.hours_per_period + / self.flow_system.time_series_collection.hours_per_timestep.max() + ) + is_integer = ( + self.aggregation_parameters.hours_per_period + % self.flow_system.time_series_collection.hours_per_timestep.max() + ).item() == 0 + if not (steps_per_period.size == 1 and is_integer): + raise ValueError( + f'The selected {self.aggregation_parameters.hours_per_period=} does not match the time ' + f'step size of {dt_min} hours). It must be a multiple of {dt_min} hours.' + ) + + logger.info(f'{"":#^80}') + logger.info(f'{" Aggregating TimeSeries Data ":#^80}') + + # Aggregation - creation of aggregated timeseries: + self.aggregation = Aggregation( + original_data=self.flow_system.time_series_collection.to_dataframe( + include_extra_timestep=False + ), # Exclude last row (NaN) + hours_per_time_step=float(dt_min), + hours_per_period=self.aggregation_parameters.hours_per_period, + nr_of_periods=self.aggregation_parameters.nr_of_periods, + weights=self.flow_system.time_series_collection.calculate_aggregation_weights(), + time_series_for_high_peaks=self.aggregation_parameters.labels_for_high_peaks, + time_series_for_low_peaks=self.aggregation_parameters.labels_for_low_peaks, + ) + + self.aggregation.cluster() + self.aggregation.plot(show=True, save=self.folder / 'aggregation.html') + if self.aggregation_parameters.aggregate_data_and_fix_non_binary_vars: + self.flow_system.time_series_collection.insert_new_data( + self.aggregation.aggregated_data, include_extra_timestep=False + ) + self.durations['aggregation'] = round(timeit.default_timer() - t_start_agg, 2) + + +class SegmentedCalculation(Calculation): + def __init__( + self, + name: str, + flow_system: FlowSystem, + timesteps_per_segment: int, + overlap_timesteps: int, + nr_of_previous_values: int = 1, + folder: Optional[pathlib.Path] = None, + ): + """ + Dividing and Modeling the problem in (overlapping) segments. + The final values of each Segment are recognized by the following segment, effectively coupling + charge_states and flow_rates between segments. + Because of this intersection, both modeling and solving is done in one step + + Take care: + Parameters like InvestParameters, sum_of_flow_hours and other restrictions over the total time_series + don't really work in this Calculation. Lower bounds to such SUMS can lead to weird results. + This is NOT yet explicitly checked for... + + Args: + name: name of calculation + flow_system: flow_system which should be calculated + timesteps_per_segment: The number of time_steps per individual segment (without the overlap) + overlap_timesteps: The number of time_steps that are added to each individual model. Used for better + results of storages) + folder: folder where results should be saved. If None, then the current working directory is used. + """ + super().__init__(name, flow_system, folder=folder) + self.timesteps_per_segment = timesteps_per_segment + self.overlap_timesteps = overlap_timesteps + self.nr_of_previous_values = nr_of_previous_values + self.sub_calculations: List[FullCalculation] = [] + + self.all_timesteps = self.flow_system.time_series_collection.all_timesteps + self.all_timesteps_extra = self.flow_system.time_series_collection.all_timesteps_extra + + self.segment_names = [ + f'Segment_{i + 1}' for i in range(math.ceil(len(self.all_timesteps) / self.timesteps_per_segment)) + ] + self.active_timesteps_per_segment = self._calculate_timesteps_of_segment() + + assert timesteps_per_segment > 2, 'The Segment length must be greater 2, due to unwanted internal side effects' + assert self.timesteps_per_segment_with_overlap <= len(self.all_timesteps), ( + f'{self.timesteps_per_segment_with_overlap=} cant be greater than the total length {len(self.all_timesteps)}' + ) + + self.flow_system._connect_network() # Connect network to ensure that all FLows know their Component + # Storing all original start values + self._original_start_values = { + **{flow.label_full: flow.previous_flow_rate for flow in self.flow_system.flows.values()}, + **{ + comp.label_full: comp.initial_charge_state + for comp in self.flow_system.components.values() + if isinstance(comp, Storage) + }, + } + self._transfered_start_values: List[Dict[str, Any]] = [] + + def do_modeling_and_solve( + self, solver: _Solver, log_file: Optional[pathlib.Path] = None, log_main_results: bool = False + ): + logger.info(f'{"":#^80}') + logger.info(f'{" Segmented Solving ":#^80}') + + for i, (segment_name, timesteps_of_segment) in enumerate( + zip(self.segment_names, self.active_timesteps_per_segment, strict=False) + ): + if self.sub_calculations: + self._transfer_start_values(i) + + logger.info( + f'{segment_name} [{i + 1:>2}/{len(self.segment_names):<2}] ' + f'({timesteps_of_segment[0]} -> {timesteps_of_segment[-1]}):' + ) + + calculation = FullCalculation( + f'{self.name}-{segment_name}', self.flow_system, active_timesteps=timesteps_of_segment + ) + self.sub_calculations.append(calculation) + calculation.do_modeling() + invest_elements = [ + model.label_full + for component in self.flow_system.components.values() + for model in component.model.all_sub_models + if isinstance(model, InvestmentModel) + ] + if invest_elements: + logger.critical( + f'Investments are not supported in Segmented Calculation! ' + f'Following InvestmentModels were found: {invest_elements}' + ) + calculation.solve( + solver, + log_file=pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log', + log_main_results=log_main_results, + ) + + self._reset_start_values() + + for calc in self.sub_calculations: + for key, value in calc.durations.items(): + self.durations[key] += value + + self.results = SegmentedCalculationResults.from_calculation(self) + + def _transfer_start_values(self, segment_index: int): + """ + This function gets the last values of the previous solved segment and + inserts them as start values for the next segment + """ + timesteps_of_prior_segment = self.active_timesteps_per_segment[segment_index - 1] + + start = self.active_timesteps_per_segment[segment_index][0] + start_previous_values = timesteps_of_prior_segment[self.timesteps_per_segment - self.nr_of_previous_values] + end_previous_values = timesteps_of_prior_segment[self.timesteps_per_segment - 1] + + logger.debug( + f'start of next segment: {start}. indices of previous values: {start_previous_values}:{end_previous_values}' + ) + start_values_of_this_segment = {} + for flow in self.flow_system.flows.values(): + flow.previous_flow_rate = flow.model.flow_rate.solution.sel( + time=slice(start_previous_values, end_previous_values) + ).values + start_values_of_this_segment[flow.label_full] = flow.previous_flow_rate + for comp in self.flow_system.components.values(): + if isinstance(comp, Storage): + comp.initial_charge_state = comp.model.charge_state.solution.sel(time=start).item() + start_values_of_this_segment[comp.label_full] = comp.initial_charge_state + + self._transfered_start_values.append(start_values_of_this_segment) + + def _reset_start_values(self): + """This resets the start values of all Elements to its original state""" + for flow in self.flow_system.flows.values(): + flow.previous_flow_rate = self._original_start_values[flow.label_full] + for comp in self.flow_system.components.values(): + if isinstance(comp, Storage): + comp.initial_charge_state = self._original_start_values[comp.label_full] + + def _calculate_timesteps_of_segment(self) -> List[pd.DatetimeIndex]: + active_timesteps_per_segment = [] + for i, _ in enumerate(self.segment_names): + start = self.timesteps_per_segment * i + end = min(start + self.timesteps_per_segment_with_overlap, len(self.all_timesteps)) + active_timesteps_per_segment.append(self.all_timesteps[start:end]) + return active_timesteps_per_segment + + @property + def timesteps_per_segment_with_overlap(self): + return self.timesteps_per_segment + self.overlap_timesteps + + @property + def start_values_of_segments(self) -> Dict[int, Dict[str, Any]]: + """Gives an overview of the start values of all Segments""" + return { + 0: {element.label_full: value for element, value in self._original_start_values.items()}, + **{i: start_values for i, start_values in enumerate(self._transfered_start_values, 1)}, + } diff --git a/flixOpt/commons.py b/flixopt/commons.py similarity index 79% rename from flixOpt/commons.py rename to flixopt/commons.py index dfbf196e7..68412d6fe 100644 --- a/flixOpt/commons.py +++ b/flixopt/commons.py @@ -1,5 +1,5 @@ """ -This module makes the commonly used classes and functions available in the flixOpt framework. +This module makes the commonly used classes and functions available in the flixopt framework. """ from . import linear_converters, plotting, results, solvers @@ -17,8 +17,8 @@ from .core import TimeSeriesData from .effects import Effect from .elements import Bus, Flow -from .flow_system import FlowSystem, create_datetime_array -from .interface import InvestParameters, OnOffParameters +from .flow_system import FlowSystem +from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects __all__ = [ 'TimeSeriesData', @@ -34,12 +34,15 @@ 'LinearConverter', 'Transmission', 'FlowSystem', - 'create_datetime_array', 'FullCalculation', 'SegmentedCalculation', 'AggregatedCalculation', 'InvestParameters', 'OnOffParameters', + 'Piece', + 'Piecewise', + 'PiecewiseConversion', + 'PiecewiseEffects', 'AggregationParameters', 'plotting', 'results', diff --git a/flixopt/components.py b/flixopt/components.py new file mode 100644 index 000000000..bdac6d2fb --- /dev/null +++ b/flixopt/components.py @@ -0,0 +1,637 @@ +""" +This module contains the basic components of the flixopt framework. +""" + +import logging +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Set, Tuple, Union + +import linopy +import numpy as np + +from . import utils +from .core import NumericData, NumericDataTS, PlausibilityError, Scalar, TimeSeries +from .elements import Component, ComponentModel, Flow +from .features import InvestmentModel, OnOffModel, PiecewiseModel +from .interface import InvestParameters, OnOffParameters, PiecewiseConversion +from .structure import SystemModel, register_class_for_io + +if TYPE_CHECKING: + from .flow_system import FlowSystem + +logger = logging.getLogger('flixopt') + + +@register_class_for_io +class LinearConverter(Component): + """ + Converts input-Flows into output-Flows via linear conversion factors + + """ + + def __init__( + self, + label: str, + inputs: List[Flow], + outputs: List[Flow], + on_off_parameters: OnOffParameters = None, + conversion_factors: List[Dict[str, NumericDataTS]] = None, + piecewise_conversion: Optional[PiecewiseConversion] = None, + meta_data: Optional[Dict] = None, + ): + """ + Args: + label: The label of the Element. Used to identify it in the FlowSystem + inputs: The input Flows + outputs: The output Flows + on_off_parameters: Information about on and off state of LinearConverter. + Component is On/Off, if all connected Flows are On/Off. This induces an On-Variable (binary) in all Flows! + If possible, use OnOffParameters in a single Flow instead to keep the number of binary variables low. + See class OnOffParameters. + conversion_factors: linear relation between flows. + Either 'conversion_factors' or 'piecewise_conversion' can be used! + piecewise_conversion: Define a piecewise linear relation between flow rates of different flows. + Either 'conversion_factors' or 'piecewise_conversion' can be used! + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + super().__init__(label, inputs, outputs, on_off_parameters, meta_data=meta_data) + self.conversion_factors = conversion_factors or [] + self.piecewise_conversion = piecewise_conversion + + def create_model(self, model: SystemModel) -> 'LinearConverterModel': + self._plausibility_checks() + self.model = LinearConverterModel(model, self) + return self.model + + def _plausibility_checks(self) -> None: + super()._plausibility_checks() + if not self.conversion_factors and not self.piecewise_conversion: + raise PlausibilityError('Either conversion_factors or piecewise_conversion must be defined!') + if self.conversion_factors and self.piecewise_conversion: + raise PlausibilityError('Only one of conversion_factors or piecewise_conversion can be defined, not both!') + + if self.conversion_factors: + if self.degrees_of_freedom <= 0: + raise PlausibilityError( + f'Too Many conversion_factors_specified. Care that you use less conversion_factors ' + f'then inputs + outputs!! With {len(self.inputs + self.outputs)} inputs and outputs, ' + f'use not more than {len(self.inputs + self.outputs) - 1} conversion_factors!' + ) + + for conversion_factor in self.conversion_factors: + for flow in conversion_factor: + if flow not in self.flows: + raise PlausibilityError( + f'{self.label}: Flow {flow} in conversion_factors is not in inputs/outputs' + ) + if self.piecewise_conversion: + for flow in self.flows.values(): + if isinstance(flow.size, InvestParameters) and flow.size.fixed_size is None: + raise PlausibilityError( + f'piecewise_conversion (in {self.label_full}) and variable size ' + f'(in flow {flow.label_full}) do not make sense together!' + ) + + def transform_data(self, flow_system: 'FlowSystem'): + super().transform_data(flow_system) + if self.conversion_factors: + self.conversion_factors = self._transform_conversion_factors(flow_system) + if self.piecewise_conversion: + self.piecewise_conversion.transform_data(flow_system, f'{self.label_full}|PiecewiseConversion') + + def _transform_conversion_factors(self, flow_system: 'FlowSystem') -> List[Dict[str, TimeSeries]]: + """macht alle Faktoren, die nicht TimeSeries sind, zu TimeSeries""" + list_of_conversion_factors = [] + for idx, conversion_factor in enumerate(self.conversion_factors): + transformed_dict = {} + for flow, values in conversion_factor.items(): + # TODO: Might be better to use the label of the component instead of the flow + transformed_dict[flow] = flow_system.create_time_series( + f'{self.flows[flow].label_full}|conversion_factor{idx}', values + ) + list_of_conversion_factors.append(transformed_dict) + return list_of_conversion_factors + + @property + def degrees_of_freedom(self): + return len(self.inputs + self.outputs) - len(self.conversion_factors) + + +@register_class_for_io +class Storage(Component): + """ + Used to model the storage of energy or material. + """ + + def __init__( + self, + label: str, + charging: Flow, + discharging: Flow, + capacity_in_flow_hours: Union[Scalar, InvestParameters], + relative_minimum_charge_state: NumericData = 0, + relative_maximum_charge_state: NumericData = 1, + initial_charge_state: Union[Scalar, Literal['lastValueOfSim']] = 0, + minimal_final_charge_state: Optional[Scalar] = None, + maximal_final_charge_state: Optional[Scalar] = None, + eta_charge: NumericData = 1, + eta_discharge: NumericData = 1, + relative_loss_per_hour: NumericData = 0, + prevent_simultaneous_charge_and_discharge: bool = True, + meta_data: Optional[Dict] = None, + ): + """ + Storages have one incoming and one outgoing Flow each with an efficiency. + Further, storages have a `size` and a `charge_state`. + Similarly to the flow-rate of a Flow, the `size` combined with a relative upper and lower bound + limits the `charge_state` of the storage. + + For mathematical details take a look at our online documentation + + Args: + label: The label of the Element. Used to identify it in the FlowSystem + charging: ingoing flow. + discharging: outgoing flow. + capacity_in_flow_hours: nominal capacity/size of the storage + relative_minimum_charge_state: minimum relative charge state. The default is 0. + relative_maximum_charge_state: maximum relative charge state. The default is 1. + initial_charge_state: storage charge_state at the beginning. The default is 0. + minimal_final_charge_state: minimal value of chargeState at the end of timeseries. + maximal_final_charge_state: maximal value of chargeState at the end of timeseries. + eta_charge: efficiency factor of charging/loading. The default is 1. + eta_discharge: efficiency factor of uncharging/unloading. The default is 1. + relative_loss_per_hour: loss per chargeState-Unit per hour. The default is 0. + prevent_simultaneous_charge_and_discharge: If True, loading and unloading at the same time is not possible. + Increases the number of binary variables, but is recommended for easier evaluation. The default is True. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + # TODO: fixed_relative_chargeState implementieren + super().__init__( + label, + inputs=[charging], + outputs=[discharging], + prevent_simultaneous_flows=[charging, discharging] if prevent_simultaneous_charge_and_discharge else None, + meta_data=meta_data, + ) + + self.charging = charging + self.discharging = discharging + self.capacity_in_flow_hours = capacity_in_flow_hours + self.relative_minimum_charge_state: NumericDataTS = relative_minimum_charge_state + self.relative_maximum_charge_state: NumericDataTS = relative_maximum_charge_state + + self.initial_charge_state = initial_charge_state + self.minimal_final_charge_state = minimal_final_charge_state + self.maximal_final_charge_state = maximal_final_charge_state + + self.eta_charge: NumericDataTS = eta_charge + self.eta_discharge: NumericDataTS = eta_discharge + self.relative_loss_per_hour: NumericDataTS = relative_loss_per_hour + self.prevent_simultaneous_charge_and_discharge = prevent_simultaneous_charge_and_discharge + + def create_model(self, model: SystemModel) -> 'StorageModel': + self._plausibility_checks() + self.model = StorageModel(model, self) + return self.model + + def transform_data(self, flow_system: 'FlowSystem') -> None: + super().transform_data(flow_system) + self.relative_minimum_charge_state = flow_system.create_time_series( + f'{self.label_full}|relative_minimum_charge_state', + self.relative_minimum_charge_state, + needs_extra_timestep=True, + ) + self.relative_maximum_charge_state = flow_system.create_time_series( + f'{self.label_full}|relative_maximum_charge_state', + self.relative_maximum_charge_state, + needs_extra_timestep=True, + ) + self.eta_charge = flow_system.create_time_series(f'{self.label_full}|eta_charge', self.eta_charge) + self.eta_discharge = flow_system.create_time_series(f'{self.label_full}|eta_discharge', self.eta_discharge) + self.relative_loss_per_hour = flow_system.create_time_series( + f'{self.label_full}|relative_loss_per_hour', self.relative_loss_per_hour + ) + if isinstance(self.capacity_in_flow_hours, InvestParameters): + self.capacity_in_flow_hours.transform_data(flow_system) + + def _plausibility_checks(self) -> None: + """ + Check for infeasible or uncommon combinations of parameters + """ + super()._plausibility_checks() + if utils.is_number(self.initial_charge_state): + if isinstance(self.capacity_in_flow_hours, InvestParameters): + if self.capacity_in_flow_hours.fixed_size is None: + maximum_capacity = self.capacity_in_flow_hours.maximum_size + minimum_capacity = self.capacity_in_flow_hours.minimum_size + else: + maximum_capacity = self.capacity_in_flow_hours.fixed_size + minimum_capacity = self.capacity_in_flow_hours.fixed_size + else: + maximum_capacity = self.capacity_in_flow_hours + minimum_capacity = self.capacity_in_flow_hours + + # initial capacity >= allowed min for maximum_size: + minimum_inital_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=1) + # initial capacity <= allowed max for minimum_size: + maximum_inital_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=1) + + if self.initial_charge_state > maximum_inital_capacity: + raise ValueError( + f'{self.label_full}: {self.initial_charge_state=} ' + f'is above allowed maximum charge_state {maximum_inital_capacity}' + ) + if self.initial_charge_state < minimum_inital_capacity: + raise ValueError( + f'{self.label_full}: {self.initial_charge_state=} ' + f'is below allowed minimum charge_state {minimum_inital_capacity}' + ) + elif self.initial_charge_state != 'lastValueOfSim': + raise ValueError(f'{self.label_full}: {self.initial_charge_state=} has an invalid value') + + +@register_class_for_io +class Transmission(Component): + # TODO: automatic on-Value in Flows if loss_abs + # TODO: loss_abs must be: investment_size * loss_abs_rel!!! + # TODO: investmentsize only on 1 flow + # TODO: automatic investArgs for both in-flows (or alternatively both out-flows!) + # TODO: optional: capacities should be recognised for losses + + def __init__( + self, + label: str, + in1: Flow, + out1: Flow, + in2: Optional[Flow] = None, + out2: Optional[Flow] = None, + relative_losses: Optional[NumericDataTS] = None, + absolute_losses: Optional[NumericDataTS] = None, + on_off_parameters: OnOffParameters = None, + prevent_simultaneous_flows_in_both_directions: bool = True, + meta_data: Optional[Dict] = None, + ): + """ + Initializes a Transmission component (Pipe, cable, ...) that models the flows between two sides + with potential losses. + + Args: + label: The label of the Element. Used to identify it in the FlowSystem + in1: The inflow at side A. Pass InvestmentParameters here. + out1: The outflow at side B. + in2: The optional inflow at side B. + If in1 got InvestParameters, the size of this Flow will be equal to in1 (with no extra effects!) + out2: The optional outflow at side A. + relative_losses: The relative loss between inflow and outflow, e.g., 0.02 for 2% loss. + absolute_losses: The absolute loss, occur only when the Flow is on. Induces the creation of the ON-Variable + on_off_parameters: Parameters defining the on/off behavior of the component. + prevent_simultaneous_flows_in_both_directions: If True, inflow and outflow are not allowed to be both non-zero at same timestep. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + super().__init__( + label, + inputs=[flow for flow in (in1, in2) if flow is not None], + outputs=[flow for flow in (out1, out2) if flow is not None], + on_off_parameters=on_off_parameters, + prevent_simultaneous_flows=None + if in2 is None or prevent_simultaneous_flows_in_both_directions is False + else [in1, in2], + meta_data=meta_data, + ) + self.in1 = in1 + self.out1 = out1 + self.in2 = in2 + self.out2 = out2 + + self.relative_losses = relative_losses + self.absolute_losses = absolute_losses + + def _plausibility_checks(self): + super()._plausibility_checks() + # check buses: + if self.in2 is not None: + assert self.in2.bus == self.out1.bus, ( + f'Output 1 and Input 2 do not start/end at the same Bus: {self.out1.bus=}, {self.in2.bus=}' + ) + if self.out2 is not None: + assert self.out2.bus == self.in1.bus, ( + f'Input 1 and Output 2 do not start/end at the same Bus: {self.in1.bus=}, {self.out2.bus=}' + ) + # Check Investments + for flow in [self.out1, self.in2, self.out2]: + if flow is not None and isinstance(flow.size, InvestParameters): + raise ValueError( + 'Transmission currently does not support separate InvestParameters for Flows. ' + 'Please use Flow in1. The size of in2 is equal to in1. THis is handled internally' + ) + + def create_model(self, model) -> 'TransmissionModel': + self._plausibility_checks() + self.model = TransmissionModel(model, self) + return self.model + + def transform_data(self, flow_system: 'FlowSystem') -> None: + super().transform_data(flow_system) + self.relative_losses = flow_system.create_time_series( + f'{self.label_full}|relative_losses', self.relative_losses + ) + self.absolute_losses = flow_system.create_time_series( + f'{self.label_full}|absolute_losses', self.absolute_losses + ) + + +class TransmissionModel(ComponentModel): + def __init__(self, model: SystemModel, element: Transmission): + super().__init__(model, element) + 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.active_data != 0): + for flow in self.element.inputs + self.element.outputs: + if flow.on_off_parameters is None: + flow.on_off_parameters = OnOffParameters() + + # Make sure either None or both in Flows have InvestParameters + if self.element.in2 is not None: + if isinstance(self.element.in1.size, InvestParameters) and not isinstance( + self.element.in2.size, InvestParameters + ): + self.element.in2.size = InvestParameters(maximum_size=self.element.in1.size.maximum_size) + + super().do_modeling() + + # first direction + self.create_transmission_equation('dir1', self.element.in1, self.element.out1) + + # second direction: + if self.element.in2 is not None: + self.create_transmission_equation('dir2', self.element.in2, self.element.out2) + + # equate size of both directions + if isinstance(self.element.in1.size, InvestParameters) and self.element.in2 is not None: + # 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', + ) + + 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.active_data - 1), + name=f'{self.label_full}|{name}', + ), + name, + ) + + if self.element.absolute_losses is not None: + con_transmission.lhs += in_flow.model.on_off.on * self.element.absolute_losses.active_data + + return con_transmission + + +class LinearConverterModel(ComponentModel): + def __init__(self, model: SystemModel, element: LinearConverter): + super().__init__(model, element) + self.element: LinearConverter = element + self.on_off: Optional[OnOffModel] = None + self.piecewise_conversion: Optional[PiecewiseConversion] = None + + def do_modeling(self): + super().do_modeling() + + # conversion_factors: + if self.element.conversion_factors: + all_input_flows = set(self.element.inputs) + all_output_flows = set(self.element.outputs) + + # für alle linearen Gleichungen: + for i, conv_factors in enumerate(self.element.conversion_factors): + used_flows = set([self.element.flows[flow_label] for flow_label in conv_factors]) + 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].active_data for flow in used_inputs]) + == sum([flow.model.flow_rate * conv_factors[flow.label].active_data for flow in used_outputs]), + name=f'{self.label_full}|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 + for flow, piecewise in self.element.piecewise_conversion.items() + } + + self.piecewise_conversion = self.add( + PiecewiseModel( + model=self._model, + label_of_element=self.label_of_element, + piecewise_variables=piecewise_conversion, + zero_point=self.on_off.on if self.on_off is not None else False, + as_time_series=True, + ) + ) + self.piecewise_conversion.do_modeling() + + +class StorageModel(ComponentModel): + """Model of Storage""" + + def __init__(self, model: SystemModel, 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.coords_extra, name=f'{self.label_full}|charge_state' + ), + 'charge_state', + ) + self.netto_discharge = self.add( + self._model.add_variables(coords=self._model.coords, name=f'{self.label_full}|netto_discharge'), + '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', + ) + + charge_state = self.charge_state + rel_loss = self.element.relative_loss_per_hour.active_data + hours_per_step = self._model.hours_per_step + charge_rate = self.element.charging.model.flow_rate + discharge_rate = self.element.discharging.model.flow_rate + eff_charge = self.element.eta_charge.active_data + eff_discharge = self.element.eta_discharge.active_data + + 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', + ) + + 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.sub_models.append(self._investment) + self._investment.do_modeling() + + # Initial charge state + self._initial_and_final_charge_state() + + 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 utils.is_number(self.element.initial_charge_state): + self.add( + self._model.add_constraints( + self.charge_state.isel(time=0) == self.element.initial_charge_state, name=name + ), + name_short, + ) + elif self.element.initial_charge_state == 'lastValueOfSim': + self.add( + self._model.add_constraints( + self.charge_state.isel(time=0) == self.charge_state.isel(time=-1), name=name + ), + name_short, + ) + else: # TODO: Validation in Storage Class, not in Model + raise PlausibilityError( + f'initial_charge_state has undefined value: {self.element.initial_charge_state}' + ) + + if self.element.maximal_final_charge_state is not None: + self.add( + self._model.add_constraints( + self.charge_state.isel(time=-1) <= self.element.maximal_final_charge_state, + name=f'{self.label_full}|final_charge_max', + ), + 'final_charge_max', + ) + + if self.element.minimal_final_charge_state is not None: + self.add( + self._model.add_constraints( + self.charge_state.isel(time=-1) >= self.element.minimal_final_charge_state, + name=f'{self.label_full}|final_charge_min', + ), + 'final_charge_min', + ) + + @property + def absolute_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: + 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, + relative_upper_bound * self.element.capacity_in_flow_hours, + ) + else: + return ( + relative_lower_bound * self.element.capacity_in_flow_hours.minimum_size, + relative_upper_bound * self.element.capacity_in_flow_hours.maximum_size, + ) + + @property + def relative_charge_state_bounds(self) -> Tuple[NumericData, NumericData]: + return ( + self.element.relative_minimum_charge_state.active_data, + self.element.relative_maximum_charge_state.active_data, + ) + + +@register_class_for_io +class SourceAndSink(Component): + """ + class for source (output-flow) and sink (input-flow) in one commponent + """ + + def __init__( + self, + label: str, + source: Flow, + sink: Flow, + prevent_simultaneous_sink_and_source: bool = True, + meta_data: Optional[Dict] = None, + ): + """ + Args: + label: The label of the Element. Used to identify it in the FlowSystem + source: output-flow of this component + sink: input-flow of this component + prevent_simultaneous_sink_and_source: If True, inflow and outflow can not be active simultaniously. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + super().__init__( + label, + inputs=[sink], + outputs=[source], + prevent_simultaneous_flows=[sink, source] if prevent_simultaneous_sink_and_source is True else None, + meta_data=meta_data, + ) + self.source = source + self.sink = sink + self.prevent_simultaneous_sink_and_source = prevent_simultaneous_sink_and_source + + +@register_class_for_io +class Source(Component): + def __init__(self, label: str, source: Flow, meta_data: Optional[Dict] = None): + """ + Args: + label: The label of the Element. Used to identify it in the FlowSystem + source: output-flow of source + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + super().__init__(label, outputs=[source], meta_data=meta_data) + self.source = source + + +@register_class_for_io +class Sink(Component): + def __init__(self, label: str, sink: Flow, meta_data: Optional[Dict] = None): + """ + Args: + label: The label of the Element. Used to identify it in the FlowSystem + meta_data: used to store more information about the element. Is not used internally, but saved in the results + sink: input-flow of sink + """ + super().__init__(label, inputs=[sink], meta_data=meta_data) + self.sink = sink diff --git a/flixOpt/config.py b/flixopt/config.py similarity index 96% rename from flixOpt/config.py rename to flixopt/config.py index 5ba7decd1..480199072 100644 --- a/flixOpt/config.py +++ b/flixopt/config.py @@ -8,16 +8,17 @@ from rich.console import Console from rich.logging import RichHandler -logger = logging.getLogger('flixOpt') +logger = logging.getLogger('flixopt') def merge_configs(defaults: dict, overrides: dict) -> dict: """ Merge the default configuration with user-provided overrides. - - :param defaults: Default configuration dictionary. - :param overrides: User configuration dictionary. - :return: Merged configuration dictionary. + Args: + defaults: Default configuration dictionary. + overrides: User configuration dictionary. + Returns: + Merged configuration dictionary. """ for key, value in overrides.items(): if isinstance(value, dict) and key in defaults and isinstance(defaults[key], dict): @@ -224,11 +225,11 @@ def _get_logging_handler(log_file: Optional[str] = None, use_rich_handler: bool def setup_logging( default_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO', - log_file: Optional[str] = 'flixOpt.log', + log_file: Optional[str] = 'flixopt.log', use_rich_handler: bool = False, ): """Setup logging configuration""" - logger = logging.getLogger('flixOpt') # Use a specific logger name for your package + logger = logging.getLogger('flixopt') # Use a specific logger name for your package logger.setLevel(get_logging_level_by_name(default_level)) # Clear existing handlers if logger.hasHandlers(): @@ -251,7 +252,7 @@ def get_logging_level_by_name(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'E def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']): - logger = logging.getLogger('flixOpt') + logger = logging.getLogger('flixopt') logging_level = get_logging_level_by_name(level_name) logger.setLevel(logging_level) for handler in logger.handlers: diff --git a/flixOpt/config.yaml b/flixopt/config.yaml similarity index 69% rename from flixOpt/config.yaml rename to flixopt/config.yaml index cd2d649ae..e5336eeef 100644 --- a/flixOpt/config.yaml +++ b/flixopt/config.yaml @@ -1,8 +1,8 @@ -# Default configuration of flixOpt -config_name: flixOpt # Name of the config file. This has no effect on the configuration itself. +# Default configuration of flixopt +config_name: flixopt # Name of the config file. This has no effect on the configuration itself. logging: level: INFO - file: flixOpt.log + file: flixopt.log rich: false # logging output is formatted using rich. This is only advisable when using a proper terminal modeling: BIG: 10000000 # 1e notation not possible in yaml diff --git a/flixopt/core.py b/flixopt/core.py new file mode 100644 index 000000000..08be18f1d --- /dev/null +++ b/flixopt/core.py @@ -0,0 +1,970 @@ +""" +This module contains the core functionality of the flixopt framework. +It provides Datatypes, logging functionality, and some functions to transform data structures. +""" + +import inspect +import json +import logging +import pathlib +from collections import Counter +from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple, Union + +import numpy as np +import pandas as pd +import xarray as xr + +logger = logging.getLogger('flixopt') + +Scalar = Union[int, float] +"""A type representing a single number, either integer or float.""" + +NumericData = Union[int, float, np.integer, np.floating, np.ndarray, pd.Series, pd.DataFrame, xr.DataArray] +"""Represents any form of numeric data, from simple scalars to complex data structures.""" + +NumericDataTS = Union[NumericData, 'TimeSeriesData'] +"""Represents either standard numeric data or TimeSeriesData.""" + + +class PlausibilityError(Exception): + """Error for a failing Plausibility check.""" + + pass + + +class ConversionError(Exception): + """Base exception for data conversion errors.""" + + pass + + +class DataConverter: + """ + Converts various data types into xarray.DataArray with a timesteps index. + + Supports: scalars, arrays, Series, DataFrames, and DataArrays. + """ + + @staticmethod + def as_dataarray(data: NumericData, timesteps: pd.DatetimeIndex) -> xr.DataArray: + """Convert data to xarray.DataArray with specified timesteps index.""" + if not isinstance(timesteps, pd.DatetimeIndex) or len(timesteps) == 0: + raise ValueError(f'Timesteps must be a non-empty DatetimeIndex, got {type(timesteps).__name__}') + if not timesteps.name == 'time': + raise ConversionError(f'DatetimeIndex is not named correctly. Must be named "time", got {timesteps.name=}') + + coords = [timesteps] + dims = ['time'] + expected_shape = (len(timesteps),) + + try: + if isinstance(data, (int, float, np.integer, np.floating)): + return xr.DataArray(data, coords=coords, dims=dims) + elif isinstance(data, pd.DataFrame): + if not data.index.equals(timesteps): + raise ConversionError("DataFrame index doesn't match timesteps index") + if not len(data.columns) == 1: + raise ConversionError('DataFrame must have exactly one column') + return xr.DataArray(data.values.flatten(), coords=coords, dims=dims) + elif isinstance(data, pd.Series): + if not data.index.equals(timesteps): + raise ConversionError("Series index doesn't match timesteps index") + return xr.DataArray(data.values, coords=coords, dims=dims) + elif isinstance(data, np.ndarray): + if data.ndim != 1: + raise ConversionError(f'Array must be 1-dimensional, got {data.ndim}') + elif data.shape[0] != expected_shape[0]: + raise ConversionError(f"Array shape {data.shape} doesn't match expected {expected_shape}") + return xr.DataArray(data, coords=coords, dims=dims) + elif isinstance(data, xr.DataArray): + if data.dims != tuple(dims): + raise ConversionError(f"DataArray dimensions {data.dims} don't match expected {dims}") + if data.sizes[dims[0]] != len(coords[0]): + raise ConversionError( + f"DataArray length {data.sizes[dims[0]]} doesn't match expected {len(coords[0])}" + ) + return data.copy(deep=True) + else: + raise ConversionError(f'Unsupported type: {type(data).__name__}') + except Exception as e: + if isinstance(e, ConversionError): + raise + raise ConversionError(f'Converting data {type(data)} to xarray.Dataset raised an error: {str(e)}') from e + + +class TimeSeriesData: + # TODO: Move to Interface.py + def __init__(self, data: NumericData, agg_group: Optional[str] = None, agg_weight: Optional[float] = None): + """ + timeseries class for transmit timeseries AND special characteristics of timeseries, + i.g. to define weights needed in calculation_type 'aggregated' + EXAMPLE solar: + you have several solar timeseries. These should not be overweighted + compared to the remaining timeseries (i.g. heat load, price)! + fixed_relative_profile_solar1 = TimeSeriesData(sol_array_1, type = 'solar') + fixed_relative_profile_solar2 = TimeSeriesData(sol_array_2, type = 'solar') + fixed_relative_profile_solar3 = TimeSeriesData(sol_array_3, type = 'solar') + --> this 3 series of same type share one weight, i.e. internally assigned each weight = 1/3 + (instead of standard weight = 1) + + Args: + data: The timeseries data, which can be a scalar, array, or numpy array. + agg_group: The group this TimeSeriesData is a part of. agg_weight is split between members of a group. Default is None. + agg_weight: The weight for calculation_type 'aggregated', should be between 0 and 1. Default is None. + + Raises: + Exception: If both agg_group and agg_weight are set, an exception is raised. + """ + self.data = data + self.agg_group = agg_group + self.agg_weight = agg_weight + if (agg_group is not None) and (agg_weight is not None): + raise ValueError('Either or explicit can be used. Not both!') + self.label: Optional[str] = None + + def __repr__(self): + # Get the constructor arguments and their current values + init_signature = inspect.signature(self.__init__) + init_args = init_signature.parameters + + # Create a dictionary with argument names and their values + args_str = ', '.join(f'{name}={repr(getattr(self, name, None))}' for name in init_args if name != 'self') + return f'{self.__class__.__name__}({args_str})' + + def __str__(self): + return str(self.data) + + +class TimeSeries: + """ + A class representing time series data with active and stored states. + + TimeSeries provides a way to store time-indexed data and work with temporal subsets. + It supports arithmetic operations, aggregation, and JSON serialization. + + Attributes: + name (str): The name of the time series + aggregation_weight (Optional[float]): Weight used for aggregation + aggregation_group (Optional[str]): Group name for shared aggregation weighting + needs_extra_timestep (bool): Whether this series needs an extra timestep + """ + + @classmethod + def from_datasource( + cls, + data: NumericData, + name: str, + timesteps: pd.DatetimeIndex, + aggregation_weight: Optional[float] = None, + aggregation_group: Optional[str] = None, + needs_extra_timestep: bool = False, + ) -> 'TimeSeries': + """ + Initialize the TimeSeries from multiple data sources. + + Args: + data: The time series data + name: The name of the TimeSeries + timesteps: The timesteps of the TimeSeries + aggregation_weight: The weight in aggregation calculations + aggregation_group: Group this TimeSeries belongs to for aggregation weight sharing + needs_extra_timestep: Whether this series requires an extra timestep + + Returns: + A new TimeSeries instance + """ + return cls( + DataConverter.as_dataarray(data, timesteps), + name, + aggregation_weight, + aggregation_group, + needs_extra_timestep, + ) + + @classmethod + def from_json(cls, data: Optional[Dict[str, Any]] = None, path: Optional[str] = None) -> 'TimeSeries': + """ + Load a TimeSeries from a dictionary or json file. + + Args: + data: Dictionary containing TimeSeries data + path: Path to a JSON file containing TimeSeries data + + Returns: + A new TimeSeries instance + + Raises: + ValueError: If both path and data are provided or neither is provided + """ + if (path is None and data is None) or (path is not None and data is not None): + raise ValueError("Exactly one of 'path' or 'data' must be provided") + + if path is not None: + with open(path, 'r') as f: + data = json.load(f) + + # Convert ISO date strings to datetime objects + data['data']['coords']['time']['data'] = pd.to_datetime(data['data']['coords']['time']['data']) + + # Create the TimeSeries instance + return cls( + data=xr.DataArray.from_dict(data['data']), + name=data['name'], + aggregation_weight=data['aggregation_weight'], + aggregation_group=data['aggregation_group'], + needs_extra_timestep=data['needs_extra_timestep'], + ) + + def __init__( + self, + data: xr.DataArray, + name: str, + aggregation_weight: Optional[float] = None, + aggregation_group: Optional[str] = None, + needs_extra_timestep: bool = False, + ): + """ + Initialize a TimeSeries with a DataArray. + + Args: + data: The DataArray containing time series data + name: The name of the TimeSeries + aggregation_weight: The weight in aggregation calculations + aggregation_group: Group this TimeSeries belongs to for weight sharing + needs_extra_timestep: Whether this series requires an extra timestep + + Raises: + ValueError: If data doesn't have a 'time' index or has more than 1 dimension + """ + if 'time' not in data.indexes: + raise ValueError(f'DataArray must have a "time" index. Got {data.indexes}') + if data.ndim > 1: + raise ValueError(f'Number of dimensions of DataArray must be 1. Got {data.ndim}') + + self.name = name + self.aggregation_weight = aggregation_weight + self.aggregation_group = aggregation_group + self.needs_extra_timestep = needs_extra_timestep + + # Data management + self._stored_data = data.copy(deep=True) + self._backup = self._stored_data.copy(deep=True) + self._active_timesteps = self._stored_data.indexes['time'] + self._active_data = None + self._update_active_data() + + def reset(self): + """ + Reset active timesteps to the full set of stored timesteps. + """ + self.active_timesteps = None + + def restore_data(self): + """ + Restore stored_data from the backup and reset active timesteps. + """ + self._stored_data = self._backup.copy(deep=True) + self.reset() + + def to_json(self, path: Optional[pathlib.Path] = None) -> Dict[str, Any]: + """ + Save the TimeSeries to a dictionary or JSON file. + + Args: + path: Optional path to save JSON file + + Returns: + Dictionary representation of the TimeSeries + """ + data = { + 'name': self.name, + 'aggregation_weight': self.aggregation_weight, + 'aggregation_group': self.aggregation_group, + 'needs_extra_timestep': self.needs_extra_timestep, + 'data': self.active_data.to_dict(), + } + + # Convert datetime objects to ISO strings + data['data']['coords']['time']['data'] = [date.isoformat() for date in data['data']['coords']['time']['data']] + + # Save to file if path is provided + if path is not None: + indent = 4 if len(self.active_timesteps) <= 480 else None + with open(path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=indent, ensure_ascii=False) + + return data + + @property + def stats(self) -> str: + """ + Return a statistical summary of the active data. + + Returns: + String representation of data statistics + """ + return get_numeric_stats(self.active_data, padd=0) + + def _update_active_data(self): + """ + Update the active data based on active_timesteps. + """ + self._active_data = self._stored_data.sel(time=self.active_timesteps) + + @property + def all_equal(self) -> bool: + """Check if all values in the series are equal.""" + return np.unique(self.active_data.values).size == 1 + + @property + def active_timesteps(self) -> pd.DatetimeIndex: + """Get the current active timesteps.""" + return self._active_timesteps + + @active_timesteps.setter + def active_timesteps(self, timesteps: Optional[pd.DatetimeIndex]): + """ + Set active_timesteps and refresh active_data. + + Args: + timesteps: New timesteps to activate, or None to use all stored timesteps + + Raises: + TypeError: If timesteps is not a pandas DatetimeIndex or None + """ + if timesteps is None: + self._active_timesteps = self.stored_data.indexes['time'] + elif isinstance(timesteps, pd.DatetimeIndex): + self._active_timesteps = timesteps + else: + raise TypeError('active_timesteps must be a pandas DatetimeIndex or None') + + self._update_active_data() + + @property + def active_data(self) -> xr.DataArray: + """Get a view of stored_data based on active_timesteps.""" + return self._active_data + + @property + def stored_data(self) -> xr.DataArray: + """Get a copy of the full stored data.""" + return self._stored_data.copy() + + @stored_data.setter + def stored_data(self, value: NumericData): + """ + Update stored_data and refresh active_data. + + Args: + value: New data to store + """ + new_data = DataConverter.as_dataarray(value, timesteps=self.active_timesteps) + + # Skip if data is unchanged to avoid overwriting backup + if new_data.equals(self._stored_data): + return + + self._stored_data = new_data + self.active_timesteps = None # Reset to full timeline + + @property + def sel(self): + return self.active_data.sel + + @property + def isel(self): + return self.active_data.isel + + def _apply_operation(self, other, op): + """Apply an operation between this TimeSeries and another object.""" + if isinstance(other, TimeSeries): + other = other.active_data + return op(self.active_data, other) + + def __add__(self, other): + return self._apply_operation(other, lambda x, y: x + y) + + def __sub__(self, other): + return self._apply_operation(other, lambda x, y: x - y) + + def __mul__(self, other): + return self._apply_operation(other, lambda x, y: x * y) + + def __truediv__(self, other): + return self._apply_operation(other, lambda x, y: x / y) + + def __radd__(self, other): + return other + self.active_data + + def __rsub__(self, other): + return other - self.active_data + + def __rmul__(self, other): + return other * self.active_data + + def __rtruediv__(self, other): + return other / self.active_data + + def __neg__(self) -> xr.DataArray: + return -self.active_data + + def __pos__(self) -> xr.DataArray: + return +self.active_data + + def __abs__(self) -> xr.DataArray: + return abs(self.active_data) + + def __gt__(self, other): + """ + Compare if this TimeSeries is greater than another. + + Args: + other: Another TimeSeries to compare with + + Returns: + True if all values in this TimeSeries are greater than other + """ + if isinstance(other, TimeSeries): + return self.active_data > other.active_data + return self.active_data > other + + def __ge__(self, other): + """ + Compare if this TimeSeries is greater than or equal to another. + + Args: + other: Another TimeSeries to compare with + + Returns: + True if all values in this TimeSeries are greater than or equal to other + """ + if isinstance(other, TimeSeries): + return self.active_data >= other.active_data + return self.active_data >= other + + def __lt__(self, other): + """ + Compare if this TimeSeries is less than another. + + Args: + other: Another TimeSeries to compare with + + Returns: + True if all values in this TimeSeries are less than other + """ + if isinstance(other, TimeSeries): + return self.active_data < other.active_data + return self.active_data < other + + def __le__(self, other): + """ + Compare if this TimeSeries is less than or equal to another. + + Args: + other: Another TimeSeries to compare with + + Returns: + True if all values in this TimeSeries are less than or equal to other + """ + if isinstance(other, TimeSeries): + return self.active_data <= other.active_data + return self.active_data <= other + + def __eq__(self, other): + """ + Compare if this TimeSeries is equal to another. + + Args: + other: Another TimeSeries to compare with + + Returns: + True if all values in this TimeSeries are equal to other + """ + if isinstance(other, TimeSeries): + return self.active_data == other.active_data + return self.active_data == other + + def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): + """ + Handle NumPy universal functions. + + This allows NumPy functions to work with TimeSeries objects. + """ + # Convert any TimeSeries inputs to their active_data + inputs = [x.active_data if isinstance(x, TimeSeries) else x for x in inputs] + return getattr(ufunc, method)(*inputs, **kwargs) + + def __repr__(self): + """ + Get a string representation of the TimeSeries. + + Returns: + String showing TimeSeries details + """ + attrs = { + 'name': self.name, + 'aggregation_weight': self.aggregation_weight, + 'aggregation_group': self.aggregation_group, + 'needs_extra_timestep': self.needs_extra_timestep, + 'shape': self.active_data.shape, + 'time_range': f'{self.active_timesteps[0]} to {self.active_timesteps[-1]}', + } + attr_str = ', '.join(f'{k}={repr(v)}' for k, v in attrs.items()) + return f'TimeSeries({attr_str})' + + def __str__(self): + """ + Get a human-readable string representation. + + Returns: + Descriptive string with statistics + """ + return f"TimeSeries '{self.name}': {self.stats}" + + +class TimeSeriesCollection: + """ + Collection of TimeSeries objects with shared timestep management. + + TimeSeriesCollection handles multiple TimeSeries objects with synchronized + timesteps, provides operations on collections, and manages extra timesteps. + """ + + def __init__( + self, + timesteps: pd.DatetimeIndex, + hours_of_last_timestep: Optional[float] = None, + hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] = None, + ): + """ + Args: + timesteps: The timesteps of the Collection. + hours_of_last_timestep: The duration of the last time step. Uses the last time interval if not specified + hours_of_previous_timesteps: The duration of previous timesteps. + If None, the first time increment of time_series is used. + This is needed to calculate previous durations (for example consecutive_on_hours). + If you use an array, take care that its long enough to cover all previous values! + """ + # Prepare and validate timesteps + self._validate_timesteps(timesteps) + self.hours_of_previous_timesteps = self._calculate_hours_of_previous_timesteps( + timesteps, hours_of_previous_timesteps + ) + + # Set up timesteps and hours + self.all_timesteps = timesteps + self.all_timesteps_extra = self._create_timesteps_with_extra(timesteps, hours_of_last_timestep) + self.all_hours_per_timestep = self.calculate_hours_per_timestep(self.all_timesteps_extra) + + # Active timestep tracking + self._active_timesteps = None + self._active_timesteps_extra = None + self._active_hours_per_timestep = None + + # Dictionary of time series by name + self.time_series_data: Dict[str, TimeSeries] = {} + + # Aggregation + self.group_weights: Dict[str, float] = {} + self.weights: Dict[str, float] = {} + + @classmethod + def with_uniform_timesteps( + cls, start_time: pd.Timestamp, periods: int, freq: str, hours_per_step: Optional[float] = None + ) -> 'TimeSeriesCollection': + """Create a collection with uniform timesteps.""" + timesteps = pd.date_range(start_time, periods=periods, freq=freq, name='time') + return cls(timesteps, hours_of_previous_timesteps=hours_per_step) + + def create_time_series( + self, data: Union[NumericData, TimeSeriesData], name: str, needs_extra_timestep: bool = False + ) -> TimeSeries: + """ + Creates a TimeSeries from the given data and adds it to the collection. + + Args: + data: The data to create the TimeSeries from. + name: The name of the TimeSeries. + needs_extra_timestep: Whether to create an additional timestep at the end of the timesteps. + The data to create the TimeSeries from. + + Returns: + The created TimeSeries. + + """ + # Check for duplicate name + if name in self.time_series_data: + raise ValueError(f"TimeSeries '{name}' already exists in this collection") + + # Determine which timesteps to use + timesteps_to_use = self.timesteps_extra if needs_extra_timestep else self.timesteps + + # Create the time series + if isinstance(data, TimeSeriesData): + time_series = TimeSeries.from_datasource( + name=name, + data=data.data, + timesteps=timesteps_to_use, + aggregation_weight=data.agg_weight, + aggregation_group=data.agg_group, + needs_extra_timestep=needs_extra_timestep, + ) + # Connect the user time series to the created TimeSeries + data.label = name + else: + time_series = TimeSeries.from_datasource( + name=name, data=data, timesteps=timesteps_to_use, needs_extra_timestep=needs_extra_timestep + ) + + # Add to the collection + self.add_time_series(time_series) + + return time_series + + def calculate_aggregation_weights(self) -> Dict[str, float]: + """Calculate and return aggregation weights for all time series.""" + self.group_weights = self._calculate_group_weights() + self.weights = self._calculate_weights() + + if np.all(np.isclose(list(self.weights.values()), 1, atol=1e-6)): + logger.info('All Aggregation weights were set to 1') + + return self.weights + + def activate_timesteps(self, active_timesteps: Optional[pd.DatetimeIndex] = None): + """ + Update active timesteps for the collection and all time series. + If no arguments are provided, the active timesteps are reset. + + Args: + active_timesteps: The active timesteps of the model. + If None, the all timesteps of the TimeSeriesCollection are taken. + """ + if active_timesteps is None: + return self.reset() + + if not np.all(np.isin(active_timesteps, self.all_timesteps)): + raise ValueError('active_timesteps must be a subset of the timesteps of the TimeSeriesCollection') + + # Calculate derived timesteps + self._active_timesteps = active_timesteps + first_ts_index = np.where(self.all_timesteps == active_timesteps[0])[0][0] + last_ts_idx = np.where(self.all_timesteps == active_timesteps[-1])[0][0] + self._active_timesteps_extra = self.all_timesteps_extra[first_ts_index : last_ts_idx + 2] + self._active_hours_per_timestep = self.all_hours_per_timestep.isel(time=slice(first_ts_index, last_ts_idx + 1)) + + # Update all time series + self._update_time_series_timesteps() + + def reset(self): + """Reset active timesteps to defaults for all time series.""" + self._active_timesteps = None + self._active_timesteps_extra = None + self._active_hours_per_timestep = None + + for time_series in self.time_series_data.values(): + time_series.reset() + + def restore_data(self): + """Restore original data for all time series.""" + for time_series in self.time_series_data.values(): + time_series.restore_data() + + def add_time_series(self, time_series: TimeSeries): + """Add an existing TimeSeries to the collection.""" + if time_series.name in self.time_series_data: + raise ValueError(f"TimeSeries '{time_series.name}' already exists in this collection") + + self.time_series_data[time_series.name] = time_series + + def insert_new_data(self, data: pd.DataFrame, include_extra_timestep: bool = False): + """ + Update time series with new data from a DataFrame. + + Args: + data: DataFrame containing new data with timestamps as index + include_extra_timestep: Whether the provided data already includes the extra timestep, by default False + """ + if not isinstance(data, pd.DataFrame): + raise TypeError(f'data must be a pandas DataFrame, got {type(data).__name__}') + + # Check if the DataFrame index matches the expected timesteps + expected_timesteps = self.timesteps_extra if include_extra_timestep else self.timesteps + if not data.index.equals(expected_timesteps): + raise ValueError( + f'DataFrame index must match {"collection timesteps with extra timestep" if include_extra_timestep else "collection timesteps"}' + ) + + for name, ts in self.time_series_data.items(): + if name in data.columns: + if not ts.needs_extra_timestep: + # For time series without extra timestep + if include_extra_timestep: + # If data includes extra timestep but series doesn't need it, exclude the last point + ts.stored_data = data[name].iloc[:-1] + else: + # Use data as is + ts.stored_data = data[name] + else: + # For time series with extra timestep + if include_extra_timestep: + # Data already includes extra timestep + ts.stored_data = data[name] + else: + # Need to add extra timestep - extrapolate from the last value + extra_step_value = data[name].iloc[-1] + extra_step_index = pd.DatetimeIndex([self.timesteps_extra[-1]], name='time') + extra_step_series = pd.Series([extra_step_value], index=extra_step_index) + + # Combine the regular data with the extra timestep + ts.stored_data = pd.concat([data[name], extra_step_series]) + + logger.debug(f'Updated data for {name}') + + def to_dataframe( + self, filtered: Literal['all', 'constant', 'non_constant'] = 'non_constant', include_extra_timestep: bool = True + ) -> pd.DataFrame: + """ + Convert collection to DataFrame with optional filtering and timestep control. + + Args: + filtered: Filter time series by variability, by default 'non_constant' + include_extra_timestep: Whether to include the extra timestep in the result, by default True + + Returns: + DataFrame representation of the collection + """ + include_constants = filtered != 'non_constant' + ds = self.to_dataset(include_constants=include_constants) + + if not include_extra_timestep: + ds = ds.isel(time=slice(None, -1)) + + df = ds.to_dataframe() + + # Apply filtering + if filtered == 'all': + return df + elif filtered == 'constant': + return df.loc[:, df.nunique() == 1] + elif filtered == 'non_constant': + return df.loc[:, df.nunique() > 1] + else: + raise ValueError("filtered must be one of: 'all', 'constant', 'non_constant'") + + def to_dataset(self, include_constants: bool = True) -> xr.Dataset: + """ + Combine all time series into a single Dataset with all timesteps. + + Args: + include_constants: Whether to include time series with constant values, by default True + + Returns: + Dataset containing all selected time series with all timesteps + """ + # Determine which series to include + if include_constants: + series_to_include = self.time_series_data.values() + else: + series_to_include = self.non_constants + + # Create individual datasets and merge them + ds = xr.merge([ts.active_data.to_dataset(name=ts.name) for ts in series_to_include]) + + # Ensure the correct time coordinates + ds = ds.reindex(time=self.timesteps_extra) + + ds.attrs.update( + { + 'timesteps_extra': f'{self.timesteps_extra[0]} ... {self.timesteps_extra[-1]} | len={len(self.timesteps_extra)}', + 'hours_per_timestep': self._format_stats(self.hours_per_timestep), + } + ) + + return ds + + def _update_time_series_timesteps(self): + """Update active timesteps for all time series.""" + for ts in self.time_series_data.values(): + if ts.needs_extra_timestep: + ts.active_timesteps = self.timesteps_extra + else: + ts.active_timesteps = self.timesteps + + @staticmethod + def _validate_timesteps(timesteps: pd.DatetimeIndex): + """Validate timesteps format and rename if needed.""" + if not isinstance(timesteps, pd.DatetimeIndex): + raise TypeError('timesteps must be a pandas DatetimeIndex') + + if len(timesteps) < 2: + raise ValueError('timesteps must contain at least 2 timestamps') + + # Ensure timesteps has the required name + if timesteps.name != 'time': + logger.warning('Renamed timesteps to "time" (was "%s")', timesteps.name) + timesteps.name = 'time' + + @staticmethod + def _create_timesteps_with_extra( + timesteps: pd.DatetimeIndex, hours_of_last_timestep: Optional[float] + ) -> pd.DatetimeIndex: + """Create timesteps with an extra step at the end.""" + if hours_of_last_timestep is not None: + # Create the extra timestep using the specified duration + last_date = pd.DatetimeIndex([timesteps[-1] + pd.Timedelta(hours=hours_of_last_timestep)], name='time') + else: + # Use the last interval as the extra timestep duration + last_date = pd.DatetimeIndex([timesteps[-1] + (timesteps[-1] - timesteps[-2])], name='time') + + # Combine with original timesteps + return pd.DatetimeIndex(timesteps.append(last_date), name='time') + + @staticmethod + def _calculate_hours_of_previous_timesteps( + timesteps: pd.DatetimeIndex, hours_of_previous_timesteps: Optional[Union[float, np.ndarray]] + ) -> Union[float, np.ndarray]: + """Calculate duration of regular timesteps.""" + if hours_of_previous_timesteps is not None: + return hours_of_previous_timesteps + + # Calculate from the first interval + first_interval = timesteps[1] - timesteps[0] + return first_interval.total_seconds() / 3600 # Convert to hours + + @staticmethod + def calculate_hours_per_timestep(timesteps_extra: pd.DatetimeIndex) -> xr.DataArray: + """Calculate duration of each timestep.""" + # Calculate differences between consecutive timestamps + hours_per_step = np.diff(timesteps_extra) / pd.Timedelta(hours=1) + + return xr.DataArray( + data=hours_per_step, coords={'time': timesteps_extra[:-1]}, dims=('time',), name='hours_per_step' + ) + + def _calculate_group_weights(self) -> Dict[str, float]: + """Calculate weights for aggregation groups.""" + # Count series in each group + groups = [ts.aggregation_group for ts in self.time_series_data.values() if ts.aggregation_group is not None] + group_counts = Counter(groups) + + # Calculate weight for each group (1/count) + return {group: 1 / count for group, count in group_counts.items()} + + def _calculate_weights(self) -> Dict[str, float]: + """Calculate weights for all time series.""" + # Calculate weight for each time series + weights = {} + for name, ts in self.time_series_data.items(): + if ts.aggregation_group is not None: + # Use group weight + weights[name] = self.group_weights.get(ts.aggregation_group, 1) + else: + # Use individual weight or default to 1 + weights[name] = ts.aggregation_weight or 1 + + return weights + + def _format_stats(self, data) -> str: + """Format statistics for a data array.""" + if hasattr(data, 'values'): + values = data.values + else: + values = np.asarray(data) + + mean_val = np.mean(values) + min_val = np.min(values) + max_val = np.max(values) + + return f'mean: {mean_val:.2f}, min: {min_val:.2f}, max: {max_val:.2f}' + + def __getitem__(self, name: str) -> TimeSeries: + """Get a TimeSeries by name.""" + try: + return self.time_series_data[name] + except KeyError as e: + raise KeyError(f'TimeSeries "{name}" not found in the TimeSeriesCollection') from e + + def __iter__(self) -> Iterator[TimeSeries]: + """Iterate through all TimeSeries in the collection.""" + return iter(self.time_series_data.values()) + + def __len__(self) -> int: + """Get the number of TimeSeries in the collection.""" + return len(self.time_series_data) + + def __contains__(self, item: Union[str, TimeSeries]) -> bool: + """Check if a TimeSeries exists in the collection.""" + if isinstance(item, str): + return item in self.time_series_data + elif isinstance(item, TimeSeries): + return any([item is ts for ts in self.time_series_data.values()]) + return False + + @property + def non_constants(self) -> List[TimeSeries]: + """Get time series with varying values.""" + return [ts for ts in self.time_series_data.values() if not ts.all_equal] + + @property + def constants(self) -> List[TimeSeries]: + """Get time series with constant values.""" + return [ts for ts in self.time_series_data.values() if ts.all_equal] + + @property + def timesteps(self) -> pd.DatetimeIndex: + """Get the active timesteps.""" + return self.all_timesteps if self._active_timesteps is None else self._active_timesteps + + @property + def timesteps_extra(self) -> pd.DatetimeIndex: + """Get the active timesteps with extra step.""" + return self.all_timesteps_extra if self._active_timesteps_extra is None else self._active_timesteps_extra + + @property + def hours_per_timestep(self) -> xr.DataArray: + """Get the duration of each active timestep.""" + return ( + self.all_hours_per_timestep if self._active_hours_per_timestep is None else self._active_hours_per_timestep + ) + + @property + def hours_of_last_timestep(self) -> float: + """Get the duration of the last timestep.""" + return float(self.hours_per_timestep[-1].item()) + + def __repr__(self): + return f'TimeSeriesCollection:\n{self.to_dataset()}' + + def __str__(self): + longest_name = max([time_series.name for time_series in self.time_series_data], key=len) + + stats_summary = '\n'.join( + [ + f' - {time_series.name:<{len(longest_name)}}: {get_numeric_stats(time_series.active_data)}' + for time_series in self.time_series_data + ] + ) + + return ( + f'TimeSeriesCollection with {len(self.time_series_data)} series\n' + f' Time Range: {self.timesteps[0]} → {self.timesteps[-1]}\n' + f' No. of timesteps: {len(self.timesteps)} + 1 extra\n' + f' Hours per timestep: {get_numeric_stats(self.hours_per_timestep)}\n' + f' Time Series Data:\n' + f'{stats_summary}' + ) + + +def get_numeric_stats(data: xr.DataArray, decimals: int = 2, padd: int = 10) -> str: + """Calculates the mean, median, min, max, and standard deviation of a numeric DataArray.""" + format_spec = f'>{padd}.{decimals}f' if padd else f'.{decimals}f' + if np.unique(data).size == 1: + return f'{data.max().item():{format_spec}} (constant)' + mean = data.mean().item() + median = data.median().item() + min_val = data.min().item() + max_val = data.max().item() + std = data.std().item() + return f'{mean:{format_spec}} (mean), {median:{format_spec}} (median), {min_val:{format_spec}} (min), {max_val:{format_spec}} (max), {std:{format_spec}} (std)' diff --git a/flixopt/effects.py b/flixopt/effects.py new file mode 100644 index 000000000..82aa63a43 --- /dev/null +++ b/flixopt/effects.py @@ -0,0 +1,386 @@ +""" +This module contains the effects of the flixopt framework. +Furthermore, it contains the EffectCollection, which is used to collect all effects of a system. +Different Datatypes are used to represent the effects with assigned values by the user, +which are then transformed into the internal data structure. +""" + +import logging +import warnings +from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Union + +import linopy +import numpy as np +import pandas as pd + +from .core import NumericData, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection +from .features import ShareAllocationModel +from .structure import Element, ElementModel, Interface, Model, SystemModel, register_class_for_io + +if TYPE_CHECKING: + from .flow_system import FlowSystem + +logger = logging.getLogger('flixopt') + + +@register_class_for_io +class Effect(Element): + """ + Effect, i.g. costs, CO2 emissions, area, ... + Components, FLows, and so on can contribute to an Effect. One Effect is chosen as the Objective of the Optimization + """ + + def __init__( + self, + label: str, + unit: str, + description: str, + meta_data: Optional[Dict] = None, + is_standard: bool = False, + is_objective: bool = False, + specific_share_to_other_effects_operation: Optional['EffectValuesUser'] = None, + specific_share_to_other_effects_invest: Optional['EffectValuesUser'] = None, + minimum_operation: Optional[Scalar] = None, + maximum_operation: Optional[Scalar] = None, + minimum_invest: Optional[Scalar] = None, + maximum_invest: Optional[Scalar] = None, + minimum_operation_per_hour: Optional[NumericDataTS] = None, + maximum_operation_per_hour: Optional[NumericDataTS] = None, + minimum_total: Optional[Scalar] = None, + maximum_total: Optional[Scalar] = None, + ): + """ + Args: + label: The label of the Element. Used to identify it in the FlowSystem + unit: The unit of effect, i.g. €, kg_CO2, kWh_primaryEnergy + description: The long name + is_standard: true, if Standard-Effect (for direct input of value without effect (alternatively to dict)) , else false + is_objective: true, if optimization target + specific_share_to_other_effects_operation: {effectType: TS, ...}, i.g. 180 €/t_CO2, input as {costs: 180}, optional + share to other effects (only operation) + specific_share_to_other_effects_invest: {effectType: TS, ...}, i.g. 180 €/t_CO2, input as {costs: 180}, optional + share to other effects (only invest). + minimum_operation: minimal sum (only operation) of the effect. + maximum_operation: maximal sum (nur operation) of the effect. + minimum_operation_per_hour: max. value per hour (only operation) of effect (=sum of all effect-shares) for each timestep! + maximum_operation_per_hour: min. value per hour (only operation) of effect (=sum of all effect-shares) for each timestep! + minimum_invest: minimal sum (only invest) of the effect + maximum_invest: maximal sum (only invest) of the effect + minimum_total: min sum of effect (invest+operation). + maximum_total: max sum of effect (invest+operation). + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + super().__init__(label, meta_data=meta_data) + self.label = label + self.unit = unit + self.description = description + self.is_standard = is_standard + self.is_objective = is_objective + self.specific_share_to_other_effects_operation: EffectValuesUser = ( + specific_share_to_other_effects_operation or {} + ) + self.specific_share_to_other_effects_invest: EffectValuesUser = specific_share_to_other_effects_invest or {} + self.minimum_operation = minimum_operation + self.maximum_operation = maximum_operation + self.minimum_operation_per_hour: NumericDataTS = minimum_operation_per_hour + self.maximum_operation_per_hour: NumericDataTS = maximum_operation_per_hour + self.minimum_invest = minimum_invest + self.maximum_invest = maximum_invest + self.minimum_total = minimum_total + self.maximum_total = maximum_total + + def transform_data(self, flow_system: 'FlowSystem'): + self.minimum_operation_per_hour = flow_system.create_time_series( + f'{self.label_full}|minimum_operation_per_hour', self.minimum_operation_per_hour + ) + self.maximum_operation_per_hour = flow_system.create_time_series( + f'{self.label_full}|maximum_operation_per_hour', self.maximum_operation_per_hour, flow_system + ) + + self.specific_share_to_other_effects_operation = flow_system.create_effect_time_series( + f'{self.label_full}|operation->', self.specific_share_to_other_effects_operation, 'operation' + ) + + def create_model(self, model: SystemModel) -> 'EffectModel': + self._plausibility_checks() + self.model = EffectModel(model, self) + return self.model + + def _plausibility_checks(self) -> None: + # TODO: Check for plausibility + pass + + +class EffectModel(ElementModel): + def __init__(self, model: SystemModel, element: Effect): + super().__init__(model, element) + self.element: Effect = element + self.total: Optional[linopy.Variable] = None + self.invest: ShareAllocationModel = self.add( + ShareAllocationModel( + self._model, + False, + self.label_of_element, + 'invest', + label_full=f'{self.label_full}(invest)', + total_max=self.element.maximum_invest, + total_min=self.element.minimum_invest, + ) + ) + + self.operation: ShareAllocationModel = self.add( + ShareAllocationModel( + self._model, + True, + self.label_of_element, + 'operation', + label_full=f'{self.label_full}(operation)', + total_max=self.element.maximum_operation, + total_min=self.element.minimum_operation, + min_per_hour=self.element.minimum_operation_per_hour.active_data + if self.element.minimum_operation_per_hour is not None + else None, + max_per_hour=self.element.maximum_operation_per_hour.active_data + 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=None, + name=f'{self.label_full}|total', + ), + 'total', + ) + + self.add( + self._model.add_constraints( + self.total == self.operation.total.sum() + self.invest.total.sum(), name=f'{self.label_full}|total' + ), + 'total', + ) + + +EffectValuesExpr = Dict[str, linopy.LinearExpression] # Used to create Shares +EffectTimeSeries = Dict[str, TimeSeries] # Used internally to index values +EffectValuesDict = Dict[str, NumericDataTS] # How effect values are stored +EffectValuesUser = Union[NumericDataTS, Dict[str, NumericDataTS]] # User-specified Shares to Effects +""" This datatype is used to define the share to an effect by a certain attribute. """ + +EffectValuesUserScalar = Union[Scalar, Dict[str, Scalar]] # User-specified Shares to Effects +""" This datatype is used to define the share to an effect by a certain attribute. Only scalars are allowed. """ + + +class EffectCollection: + """ + Handling all Effects + """ + + def __init__(self, *effects: List[Effect]): + self._effects = {} + self._standard_effect: Optional[Effect] = None + self._objective_effect: Optional[Effect] = None + + self.model: Optional[EffectCollectionModel] = None + self.add_effects(*effects) + + def create_model(self, model: SystemModel) -> 'EffectCollectionModel': + self._plausibility_checks() + self.model = EffectCollectionModel(model, self) + return self.model + + def add_effects(self, *effects: Effect) -> None: + for effect in list(effects): + if effect in self: + raise ValueError(f'Effect with label "{effect.label=}" already added!') + if effect.is_standard: + self.standard_effect = effect + if effect.is_objective: + self.objective_effect = effect + self._effects[effect.label] = effect + logger.info(f'Registered new Effect: {effect.label}') + + def create_effect_values_dict(self, effect_values_user: EffectValuesUser) -> Optional[EffectValuesDict]: + """ + Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type. + + Examples + -------- + effect_values_user = 20 -> {None: 20} + effect_values_user = None -> None + effect_values_user = {effect1: 20, effect2: 0.3} -> {effect1: 20, effect2: 0.3} + + Returns + ------- + dict or None + A dictionary with None or Effect as the key, or None if input is None. + """ + + def get_effect_label(eff: Union[Effect, str]) -> str: + """Temporary function to get the label of an effect and warn for deprecation""" + if isinstance(eff, Effect): + warnings.warn( + f'The use of effect objects when specifying EffectValues is deprecated. ' + f'Use the label of the effect instead. Used effect: {eff.label_full}', + UserWarning, + stacklevel=2, + ) + return eff.label_full + else: + return eff + + if effect_values_user is None: + return None + if isinstance(effect_values_user, dict): + return {get_effect_label(effect): value for effect, value in effect_values_user.items()} + return {self.standard_effect.label_full: effect_values_user} + + def _plausibility_checks(self) -> None: + # Check circular loops in effects: + # TODO: Improve checks!! Only most basic case covered... + + def error_str(effect_label: str, share_ffect_label: str): + return ( + f' {effect_label} -> has share in: {share_ffect_label}\n' + f' {share_ffect_label} -> has share in: {effect_label}' + ) + + for effect in self.effects.values(): + # Effekt darf nicht selber als Share in seinen ShareEffekten auftauchen: + # operation: + for target_effect in effect.specific_share_to_other_effects_operation.keys(): + assert effect not in self[target_effect].specific_share_to_other_effects_operation.keys(), ( + f'Error: circular operation-shares \n{error_str(target_effect.label, target_effect.label)}' + ) + # invest: + for target_effect in effect.specific_share_to_other_effects_invest.keys(): + assert effect not in self[target_effect].specific_share_to_other_effects_invest.keys(), ( + f'Error: circular invest-shares \n{error_str(target_effect.label, target_effect.label)}' + ) + + def __getitem__(self, effect: Union[str, Effect]) -> 'Effect': + """ + Get an effect by label, or return the standard effect if None is passed + + Raises: + KeyError: If no effect with the given label is found. + KeyError: If no standard effect is specified. + """ + if effect is None: + return self.standard_effect + if isinstance(effect, Effect): + if effect in self: + return effect + else: + raise KeyError(f'Effect {effect} not found!') + try: + return self.effects[effect] + except KeyError as e: + raise KeyError(f'Effect "{effect}" not found! Add it to the FlowSystem first!') from e + + def __iter__(self) -> Iterator[Effect]: + return iter(self._effects.values()) + + def __len__(self) -> int: + return len(self._effects) + + def __contains__(self, item: Union[str, 'Effect']) -> bool: + """Check if the effect exists. Checks for label or object""" + if isinstance(item, str): + return item in self.effects # Check if the label exists + elif isinstance(item, Effect): + return item in self.effects.values() # Check if the object exists + return False + + @property + def effects(self) -> Dict[str, Effect]: + return self._effects + + @property + def standard_effect(self) -> Effect: + if self._standard_effect is None: + raise KeyError('No standard-effect specified!') + return self._standard_effect + + @standard_effect.setter + def standard_effect(self, value: Effect) -> None: + if self._standard_effect is not None: + raise ValueError(f'A standard-effect already exists! ({self._standard_effect.label=})') + self._standard_effect = value + + @property + def objective_effect(self) -> Effect: + if self._objective_effect is None: + raise KeyError('No objective-effect specified!') + return self._objective_effect + + @objective_effect.setter + def objective_effect(self, value: Effect) -> None: + if self._objective_effect is not None: + raise ValueError(f'An objective-effect already exists! ({self._objective_effect.label=})') + self._objective_effect = value + + +class EffectCollectionModel(Model): + """ + Handling all Effects + """ + + def __init__(self, model: SystemModel, effects: EffectCollection): + super().__init__(model, label_of_element='Effects') + self.effects = effects + self.penalty: Optional[ShareAllocationModel] = None + + def add_share_to_effects( + self, + name: str, + expressions: EffectValuesExpr, + target: Literal['operation', 'invest'], + ) -> None: + for effect, expression in expressions.items(): + if target == 'operation': + self.effects[effect].model.operation.add_share(name, expression) + elif target == 'invest': + self.effects[effect].model.invest.add_share(name, expression) + else: + raise ValueError(f'Target {target} not supported!') + + def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) -> None: + if expression.ndim != 0: + raise TypeError(f'Penalty shares must be scalar expressions! ({expression.ndim=})') + self.penalty.add_share(name, expression) + + def do_modeling(self): + for effect in self.effects: + effect.create_model(self._model) + self.penalty = self.add( + ShareAllocationModel(self._model, shares_are_time_series=False, label_of_element='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.penalty.total) + + 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.active_data, + ) + # 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, + ) diff --git a/flixopt/elements.py b/flixopt/elements.py new file mode 100644 index 000000000..a0bd8c91f --- /dev/null +++ b/flixopt/elements.py @@ -0,0 +1,575 @@ +""" +This module contains the basic elements of the flixopt framework. +""" + +import logging +import warnings +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union + +import linopy +import numpy as np + +from .config import CONFIG +from .core import NumericData, NumericDataTS, PlausibilityError, Scalar, TimeSeriesCollection +from .effects import EffectValuesUser +from .features import InvestmentModel, OnOffModel, PreventSimultaneousUsageModel +from .interface import InvestParameters, OnOffParameters +from .structure import Element, ElementModel, SystemModel, register_class_for_io + +if TYPE_CHECKING: + from .flow_system import FlowSystem + +logger = logging.getLogger('flixopt') + + +@register_class_for_io +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 + Constraints to the On/Off state are defined by the [`on_off_parameters`][flixopt.interface.OnOffParameters]. + """ + + def __init__( + self, + label: str, + inputs: Optional[List['Flow']] = None, + outputs: Optional[List['Flow']] = None, + on_off_parameters: Optional[OnOffParameters] = None, + prevent_simultaneous_flows: Optional[List['Flow']] = None, + meta_data: Optional[Dict] = None, + ): + """ + Args: + label: The label of the Element. Used to identify it in the FlowSystem + inputs: input flows. + outputs: output flows. + on_off_parameters: Information about on and off state of Component. + Component is On/Off, if all connected Flows are On/Off. This induces an On-Variable (binary) in all Flows! + If possible, use OnOffParameters in a single Flow instead to keep the number of binary variables low. + See class OnOffParameters. + prevent_simultaneous_flows: Define a Group of Flows. Only one them can be on at a time. + Induces On-Variable in all Flows! If possible, use OnOffParameters in a single Flow instead. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + super().__init__(label, meta_data=meta_data) + self.inputs: List['Flow'] = inputs or [] + self.outputs: List['Flow'] = outputs or [] + self._check_unique_flow_labels() + self.on_off_parameters = on_off_parameters + self.prevent_simultaneous_flows: List['Flow'] = prevent_simultaneous_flows or [] + + self.flows: Dict[str, Flow] = {flow.label: flow for flow in self.inputs + self.outputs} + + def create_model(self, model: SystemModel) -> 'ComponentModel': + self._plausibility_checks() + self.model = ComponentModel(model, self) + return self.model + + def transform_data(self, flow_system: 'FlowSystem') -> None: + if self.on_off_parameters is not None: + self.on_off_parameters.transform_data(flow_system, self.label_full) + + def infos(self, use_numpy=True, use_element_label: bool = False) -> Dict: + infos = super().infos(use_numpy, use_element_label) + infos['inputs'] = [flow.infos(use_numpy, use_element_label) for flow in self.inputs] + infos['outputs'] = [flow.infos(use_numpy, use_element_label) for flow in self.outputs] + return infos + + def _check_unique_flow_labels(self): + all_flow_labels = [flow.label for flow in self.inputs + self.outputs] + + if len(set(all_flow_labels)) != len(all_flow_labels): + duplicates = {label for label in all_flow_labels if all_flow_labels.count(label) > 1} + raise ValueError(f'Flow names must be unique! "{self.label_full}" got 2 or more of: {duplicates}') + + def _plausibility_checks(self) -> None: + self._check_unique_flow_labels() + + +@register_class_for_io +class Bus(Element): + """ + A Bus represents a nodal balance between the flow rates of its incoming and outgoing Flows. + """ + + def __init__( + self, label: str, excess_penalty_per_flow_hour: Optional[NumericDataTS] = 1e5, meta_data: Optional[Dict] = None + ): + """ + Args: + label: The label of the Element. Used to identify it in the FlowSystem + excess_penalty_per_flow_hour: excess costs / penalty costs (bus balance compensation) + (none/ 0 -> no penalty). The default is 1e5. + (Take care: if you use a timeseries (no scalar), timeseries is aggregated if calculation_type = aggregated!) + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + super().__init__(label, meta_data=meta_data) + self.excess_penalty_per_flow_hour = excess_penalty_per_flow_hour + self.inputs: List[Flow] = [] + self.outputs: List[Flow] = [] + + def create_model(self, model: SystemModel) -> 'BusModel': + self._plausibility_checks() + self.model = BusModel(model, self) + return self.model + + def transform_data(self, flow_system: 'FlowSystem'): + self.excess_penalty_per_flow_hour = flow_system.create_time_series( + f'{self.label_full}|excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour + ) + + def _plausibility_checks(self) -> None: + if self.excess_penalty_per_flow_hour is not None and (self.excess_penalty_per_flow_hour == 0).all(): + logger.warning(f'In Bus {self.label}, the excess_penalty_per_flow_hour is 0. Use "None" or a value > 0.') + + @property + def with_excess(self) -> bool: + return False if self.excess_penalty_per_flow_hour is None else True + + +@register_class_for_io +class Connection: + # input/output-dock (TODO: + # -> wƤre cool, damit Komponenten auch auch ohne Knoten verbindbar + # input wƤren wie Flow,aber statt bus: connectsTo -> hier andere Connection oder aber Bus (dort keine Connection, weil nicht notwendig) + + def __init__(self): + """ + This class is not yet implemented! + """ + raise NotImplementedError() + + +@register_class_for_io +class Flow(Element): + r""" + A **Flow** moves energy (or material) between a [Bus][flixopt.elements.Bus] and a [Component][flixopt.elements.Component] in a predefined direction. + The flow-rate is the main optimization variable of the **Flow**. + """ + + def __init__( + self, + label: str, + bus: str, + size: Union[Scalar, InvestParameters] = None, + fixed_relative_profile: Optional[NumericDataTS] = None, + relative_minimum: NumericDataTS = 0, + relative_maximum: NumericDataTS = 1, + effects_per_flow_hour: Optional[EffectValuesUser] = None, + on_off_parameters: Optional[OnOffParameters] = None, + flow_hours_total_max: Optional[Scalar] = None, + flow_hours_total_min: Optional[Scalar] = None, + load_factor_min: Optional[Scalar] = None, + load_factor_max: Optional[Scalar] = None, + previous_flow_rate: Optional[NumericData] = None, + meta_data: Optional[Dict] = None, + ): + r""" + Args: + label: The label of the FLow. Used to identify it in the FlowSystem. Its `full_label` consists of the label of the Component and the label of the Flow. + bus: blabel of the bus the flow is connected to. + size: size of the flow. If InvestmentParameters is used, size is optimized. + If size is None, a default value is used. + relative_minimum: min value is relative_minimum multiplied by size + relative_maximum: max value is relative_maximum multiplied by size. If size = max then relative_maximum=1 + load_factor_min: minimal load factor general: avg Flow per nominalVal/investSize + (e.g. boiler, kW/kWh=h; solarthermal: kW/m²; + def: :math:`load\_factor:= sumFlowHours/ (nominal\_val \cdot \Delta t_{tot})` + load_factor_max: maximal load factor (see minimal load factor) + effects_per_flow_hour: operational costs, costs per flow-"work" + on_off_parameters: If present, flow can be "off", i.e. be zero (only relevant if relative_minimum > 0) + Therefore a binary var "on" is used. Further, several other restrictions and effects can be modeled + through this On/Off State (See OnOffParameters) + flow_hours_total_max: maximum flow-hours ("flow-work") + (if size is not const, maybe load_factor_max is the better choice!) + flow_hours_total_min: minimum flow-hours ("flow-work") + (if size is not predefined, maybe load_factor_min is the better choice!) + fixed_relative_profile: fixed relative values for flow (if given). + flow_rate(t) := fixed_relative_profile(t) * size(t) + With this value, the flow_rate is no optimization-variable anymore. + (relative_minimum and relative_maximum are ignored) + used for fixed load or supply profiles, i.g. heat demand, wind-power, solarthermal + If the load-profile is just an upper limit, use relative_maximum instead. + previous_flow_rate: previous flow rate of the flow. Used to determine if and how long the + flow is already on / off. If None, the flow is considered to be off for one timestep. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + super().__init__(label, meta_data=meta_data) + self.size = size or CONFIG.modeling.BIG # Default size + self.relative_minimum = relative_minimum + self.relative_maximum = relative_maximum + self.fixed_relative_profile = fixed_relative_profile + + self.load_factor_min = load_factor_min + self.load_factor_max = load_factor_max + # self.positive_gradient = TimeSeries('positive_gradient', positive_gradient, self) + self.effects_per_flow_hour = effects_per_flow_hour if effects_per_flow_hour is not None else {} + self.flow_hours_total_max = flow_hours_total_max + 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.component: str = 'UnknownComponent' + self.is_input_in_component: Optional[bool] = None + if isinstance(bus, Bus): + self.bus = bus.label_full + warnings.warn( + f'Bus {bus.label} is passed as a Bus object to {self.label}. This is deprecated and will be removed ' + f'in the future. Add the Bus to the FlowSystem instead and pass its label to the Flow.', + UserWarning, + stacklevel=1, + ) + self._bus_object = bus + else: + self.bus = bus + self._bus_object = None + + def create_model(self, model: SystemModel) -> 'FlowModel': + self._plausibility_checks() + self.model = FlowModel(model, self) + return self.model + + def transform_data(self, flow_system: 'FlowSystem'): + self.relative_minimum = flow_system.create_time_series( + f'{self.label_full}|relative_minimum', self.relative_minimum + ) + self.relative_maximum = flow_system.create_time_series( + f'{self.label_full}|relative_maximum', self.relative_maximum + ) + self.fixed_relative_profile = flow_system.create_time_series( + f'{self.label_full}|fixed_relative_profile', self.fixed_relative_profile + ) + self.effects_per_flow_hour = flow_system.create_effect_time_series( + self.label_full, self.effects_per_flow_hour, 'per_flow_hour' + ) + if self.on_off_parameters is not None: + self.on_off_parameters.transform_data(flow_system, self.label_full) + if isinstance(self.size, InvestParameters): + self.size.transform_data(flow_system) + + def infos(self, use_numpy: bool = True, use_element_label: bool = False) -> Dict: + infos = super().infos(use_numpy, use_element_label) + infos['is_input_in_component'] = self.is_input_in_component + return infos + + def to_dict(self) -> Dict: + data = super().to_dict() + if isinstance(data.get('previous_flow_rate'), np.ndarray): + data['previous_flow_rate'] = data['previous_flow_rate'].tolist() + return data + + def _plausibility_checks(self) -> None: + # TODO: Incorporate into Variable? (Lower_bound can not be greater than upper bound + if np.any(self.relative_minimum > self.relative_maximum): + raise PlausibilityError(self.label_full + ': Take care, that relative_minimum <= relative_maximum!') + + if ( + self.size == CONFIG.modeling.BIG and self.fixed_relative_profile is not None + ): # Default Size --> Most likely by accident + logger.warning( + f'Flow "{self.label}" has no size assigned, but a "fixed_relative_profile". ' + f'The default size is {CONFIG.modeling.BIG}. As "flow_rate = size * fixed_relative_profile", ' + f'the resulting flow_rate will be very high. To fix this, assign a size to the Flow {self}.' + ) + + if self.fixed_relative_profile is not None and self.on_off_parameters is not None: + raise ValueError( + f'Flow {self.label} has both a fixed_relative_profile and an on_off_parameters. This is not supported. ' + f'Use relative_minimum and relative_maximum instead, ' + f'if you want to allow flows to be switched on and off.' + ) + + if (self.relative_minimum > 0).any() and self.on_off_parameters is None: + logger.warning( + f'Flow {self.label} has a relative_minimum of {self.relative_minimum.active_data} and no on_off_parameters. ' + f'This prevents the flow_rate from switching off (flow_rate = 0). ' + f'Consider using on_off_parameters to allow the flow to be switched on and off.' + ) + + @property + def label_full(self) -> str: + return f'{self.component}({self.label})' + + @property + def size_is_fixed(self) -> bool: + # Wenn kein InvestParameters existiert --> True; Wenn Investparameter, den Wert davon nehmen + return False if (isinstance(self.size, InvestParameters) and self.size.fixed_size is None) else True + + @property + def invest_is_optional(self) -> bool: + # Wenn kein InvestParameters existiert: # Investment ist nicht optional -> Keine Variable --> False + return False if (isinstance(self.size, InvestParameters) and not self.size.optional) else True + + +class FlowModel(ElementModel): + def __init__(self, model: SystemModel, 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.coords, + name=f'{self.label_full}|flow_rate', + ), + 'flow_rate', + ) + + # 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() + + # 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=None, + name=f'{self.label_full}|total_flow_hours', + ), + 'total_flow_hours', + ) + + self.add( + self._model.add_constraints( + self.total_flow_hours == (self.flow_rate * self._model.hours_per_step).sum(), + name=f'{self.label_full}|total_flow_hours', + ), + 'total_flow_hours', + ) + + # Load factor + self._create_bounds_for_load_factor() + + # Shares + self._create_shares() + + def _create_shares(self): + # Arbeitskosten: + 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 + expressions={ + effect: self.flow_rate * self._model.hours_per_step * factor.active_data + for effect, factor in self.element.effects_per_flow_hour.items() + }, + target='operation', + ) + + def _create_bounds_for_load_factor(self): + # TODO: Add Variable load_factor for better evaluation? + + # eq: var_sumFlowHours <= size * dt_tot * load_factor_max + 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() * 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, + ) + + # eq: size * sum(dt)* load_factor_min <= var_sumFlowHours + 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() * 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, + ) + + @property + def flow_rate_bounds_on(self) -> Tuple[NumericData, NumericData]: + """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 + if size.fixed_size is not None: + return relative_minimum * size.fixed_size, relative_maximum * size.fixed_size + return relative_minimum * size.minimum_size, relative_maximum * size.maximum_size + + @property + def flow_rate_lower_bound_relative(self) -> NumericData: + """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.active_data + return fixed_profile.active_data + + @property + def flow_rate_upper_bound_relative(self) -> NumericData: + """ 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.active_data + return fixed_profile.active_data + + @property + def flow_rate_lower_bound(self) -> NumericData: + """ + 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_size + return self.flow_rate_lower_bound_relative * self.element.size + + @property + def flow_rate_upper_bound(self) -> NumericData: + """ + 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_size + return self.flow_rate_upper_bound_relative * self.element.size + + +class BusModel(ElementModel): + def __init__(self, model: SystemModel, element: Bus): + super().__init__(model, element) + self.element: Bus = element + self.excess_input: Optional[linopy.Variable] = None + self.excess_output: Optional[linopy.Variable] = None + + def do_modeling(self) -> None: + # inputs == outputs + for flow in self.element.inputs + self.element.outputs: + self.add(flow.model.flow_rate, flow.label_full) + 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')) + + # Fehlerplus/-minus: + if self.element.with_excess: + excess_penalty = np.multiply( + self._model.hours_per_step, self.element.excess_penalty_per_flow_hour.active_data + ) + self.excess_input = self.add( + self._model.add_variables(lower=0, coords=self._model.coords, name=f'{self.label_full}|excess_input'), + 'excess_input', + ) + self.excess_output = self.add( + self._model.add_variables(lower=0, coords=self._model.coords, name=f'{self.label_full}|excess_output'), + '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] + if self.excess_input is not None: + inputs.append(self.excess_input.name) + if self.excess_output is not None: + outputs.append(self.excess_output.name) + return {**super().results_structure(), 'inputs': inputs, 'outputs': outputs} + + +class ComponentModel(ElementModel): + def __init__(self, model: SystemModel, element: Component): + super().__init__(model, element) + self.element: Component = element + self.on_off: Optional[OnOffModel] = None + + def do_modeling(self): + """Initiates all FlowModels""" + all_flows = self.element.inputs + self.element.outputs + if self.element.on_off_parameters: + for flow in all_flows: + if flow.on_off_parameters is None: + flow.on_off_parameters = OnOffParameters() + + if self.element.prevent_simultaneous_flows: + for flow in self.element.prevent_simultaneous_flows: + if flow.on_off_parameters is None: + 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() + + if self.element.on_off_parameters: + self.on_off = self.add( + 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], + ) + ) + + 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() + + 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], + } diff --git a/flixopt/features.py b/flixopt/features.py new file mode 100644 index 000000000..c2a62adb1 --- /dev/null +++ b/flixopt/features.py @@ -0,0 +1,1118 @@ +""" +This module contains the features of the flixopt framework. +Features extend the functionality of Elements. +""" + +import logging +from typing import Dict, List, Optional, Tuple, Union + +import linopy +import numpy as np + +from . import utils +from .config import CONFIG +from .core import NumericData, Scalar, TimeSeries +from .interface import InvestParameters, OnOffParameters, Piecewise +from .structure import Model, SystemModel + +logger = logging.getLogger('flixopt') + + +class InvestmentModel(Model): + """Class for modeling an investment""" + + def __init__( + self, + model: SystemModel, + label_of_element: str, + parameters: InvestParameters, + defining_variable: [linopy.Variable], + relative_bounds_of_defining_variable: Tuple[NumericData, NumericData], + label: Optional[str] = None, + on_variable: Optional[linopy.Variable] = None, + ): + super().__init__(model, label_of_element, label) + self.size: Optional[Union[Scalar, linopy.Variable]] = None + self.is_invested: Optional[linopy.Variable] = None + + self.piecewise_effects: Optional[PiecewiseEffectsModel] = None + + self._on_variable = on_variable + self._defining_variable = defining_variable + self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable + self.parameters = parameters + + def do_modeling(self): + if self.parameters.fixed_size and not self.parameters.optional: + self.size = self.add( + self._model.add_variables( + lower=self.parameters.fixed_size, upper=self.parameters.fixed_size, name=f'{self.label_full}|size' + ), + 'size', + ) + else: + self.size = self.add( + self._model.add_variables( + lower=0 if self.parameters.optional else self.parameters.minimum_size, + upper=self.parameters.maximum_size, + name=f'{self.label_full}|size', + ), + 'size', + ) + + # Optional + if self.parameters.optional: + self.is_invested = self.add( + self._model.add_variables(binary=True, name=f'{self.label_full}|is_invested'), 'is_invested' + ) + + self._create_bounds_for_optional_investment() + + # Bounds for defining variable + self._create_bounds_for_defining_variable() + + self._create_shares() + + def _create_shares(self): + # fix_effects: + fix_effects = self.parameters.fix_effects + if fix_effects != {}: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.is_invested * factor if self.is_invested is not None else factor + for effect, factor in fix_effects.items() + }, + target='invest', + ) + + if self.parameters.divest_effects != {} and self.parameters.optional: + # share: divest_effects - isInvested * divest_effects + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={effect: -self.is_invested * factor + factor for effect, factor in self.parameters.divest_effects.items()}, + target='invest', + ) + + 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_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}', + ) + if self._on_variable is not None: + raise ValueError( + f'Flow {self.label_full} has a fixed relative flow rate and an on_variable.' + f'This combination is currently not supported.' + ) + 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 = lb_relative * self.parameters.maximum_size + 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 ?? + + +class StateModel(Model): + """ + Handles basic on/off binary states for defining variables + """ + + def __init__( + self, + model: SystemModel, + label_of_element: str, + defining_variables: List[linopy.Variable], + defining_bounds: List[Tuple[NumericData, NumericData]], + previous_values: List[Optional[NumericData]] = None, + use_off: bool = True, + on_hours_total_min: Optional[NumericData] = 0, + on_hours_total_max: Optional[NumericData] = None, + effects_per_running_hour: Dict[str, NumericData] = None, + label: Optional[str] = None, + ): + """ + Models binary state variables based on a continous variable. + + Args: + model: The SystemModel 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 or {} + + 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.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=None, + 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(), + 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.coords, + ), + 'off', + ) + + # Constraint: on + off = 1 + self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}|off'), 'off') + + return self + + def _add_defining_constraints(self): + """Add constraints that link defining variables to the on state""" + nr_of_def_vars = len(self._defining_variables) + + if nr_of_def_vars == 1: + # Case for a single defining variable + def_var = self._defining_variables[0] + lb, ub = self._defining_bounds[0] + + # Constraint: on * lower_bound <= def_var + self.add( + self._model.add_constraints( + self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, name=f'{self.label_full}|on_con1' + ), + 'on_con1', + ) + + # Constraint: on * upper_bound >= def_var + self.add( + self._model.add_constraints(self.on * ub >= def_var, name=f'{self.label_full}|on_con2'), 'on_con2' + ) + else: + # Case for multiple defining variables + ub = sum(bound[1] for bound in self._defining_bounds) / nr_of_def_vars + lb = CONFIG.modeling.EPSILON #TODO: Can this be a bigger value? (maybe the smallest bound?) + + # Constraint: on * epsilon <= sum(all_defining_variables) + self.add( + self._model.add_constraints( + self.on * lb <= sum(self._defining_variables), name=f'{self.label_full}|on_con1' + ), + 'on_con1', + ) + + # Constraint to ensure all variables are zero when off. + # Divide by nr_of_def_vars to improve numerical stability (smaller factors) + self.add( + self._model.add_constraints( + self.on * ub >= sum([def_var / nr_of_def_vars for def_var in self._defining_variables]), + name=f'{self.label_full}|on_con2', + ), + 'on_con2', + ) + + @property + def previous_states(self) -> np.ndarray: + """Computes the previous states {0, 1} of defining variables as a binary array from their previous values.""" + return StateModel.compute_previous_states(self._previous_values, epsilon=CONFIG.modeling.EPSILON) + + @property + def previous_on_states(self) -> np.ndarray: + return self.previous_states + + @property + def previous_off_states(self): + return 1 - self.previous_states + + @staticmethod + def compute_previous_states(previous_values: List[NumericData], 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: SystemModel, + 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.coords), + 'switch_on', + ) + + self.switch_off = self.add( + self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.coords), + 'switch_off', + ) + + # Create count variable for number of switches + self.switch_on_nr = self.add( + self._model.add_variables( + upper=self._switch_on_max, + lower=0, + name=f'{self.label_full}|switch_on_nr', + ), + 'switch_on_nr', + ) + + # Add switch constraints for all entries after the first timestep + self.add( + self._model.add_constraints( + self.switch_on.isel(time=slice(1, None)) - self.switch_off.isel(time=slice(1, None)) + == self._state_variable.isel(time=slice(1, None)) - self._state_variable.isel(time=slice(None, -1)), + name=f'{self.label_full}|switch_con', + ), + 'switch_con', + ) + + # Initial switch constraint + self.add( + self._model.add_constraints( + self.switch_on.isel(time=0) - self.switch_off.isel(time=0) + == self._state_variable.isel(time=0) - self.previous_state, + name=f'{self.label_full}|initial_switch_con', + ), + 'initial_switch_con', + ) + + # Mutual exclusivity constraint + self.add( + self._model.add_constraints(self.switch_on + self.switch_off <= 1.1, name=f'{self.label_full}|switch_on_or_off'), + 'switch_on_or_off', + ) + + # Total switch-on count constraint + self.add( + self._model.add_constraints( + self.switch_on_nr == self.switch_on.sum('time'), name=f'{self.label_full}|switch_on_nr' + ), + 'switch_on_nr', + ) + + return self + + +class ConsecutiveStateModel(Model): + """ + Handles tracking consecutive durations in a state + """ + + def __init__( + self, + model: SystemModel, + label_of_element: str, + state_variable: linopy.Variable, + minimum_duration: Optional[NumericData] = None, + maximum_duration: Optional[NumericData] = None, + previous_states: Optional[NumericData] = None, + label: Optional[str] = None, + ): + """ + Model and constraint the consecutive duration of a state variable. + + Args: + model: The SystemModel that is used to create the model. + label_of_element: The label of the parent (Element). Used to construct the full label of the model. + state_variable: The state variable that is used to model the duration. state = {0, 1} + minimum_duration: The minimum duration of the state variable. + maximum_duration: The maximum duration of the state variable. + previous_states: The previous states of the state variable. + label: The label of the model. Used to construct the full label of the model. + """ + super().__init__(model, label_of_element, label) + self._state_variable = state_variable + self._previous_states = previous_states + self._minimum_duration = minimum_duration + self._maximum_duration = maximum_duration + + if isinstance(self._minimum_duration, TimeSeries): + self._minimum_duration = self._minimum_duration.active_data + if isinstance(self._maximum_duration, TimeSeries): + self._maximum_duration = self._maximum_duration.active_data + + 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.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): + 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).item() + ) + + @staticmethod + def compute_consecutive_hours_in_state( + binary_values: NumericData, 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: SystemModel, + on_off_parameters: OnOffParameters, + label_of_element: str, + defining_variables: List[linopy.Variable], + defining_bounds: List[Tuple[NumericData, NumericData]], + previous_values: List[Optional[NumericData]], + label: Optional[str] = None, + ): + """ + Constructor for OnOffModel + + Args: + model: Reference to the SystemModel + on_off_parameters: Parameters for the OnOffModel + label_of_element: Label of the Parent + defining_variables: List of Variables that are used to define the OnOffModel + defining_bounds: List of Tuples, defining the absolute bounds of each defining variable + previous_values: List of previous values of the defining variables + label: Label of the OnOffModel + """ + super().__init__(model, label_of_element, label) + self.parameters = on_off_parameters + self._defining_variables = defining_variables + self._defining_bounds = defining_bounds + self._previous_values = previous_values + + self.state_model = None + self.switch_state_model = None + self.consecutive_on_model = None + self.consecutive_off_model = None + + def do_modeling(self): + """Create all variables and constraints for the OnOffModel""" + + # Create binary state component + self.state_model = StateModel( + model=self._model, + label_of_element=self.label_of_element, + defining_variables=self._defining_variables, + defining_bounds=self._defining_bounds, + previous_values=self._previous_values, + use_off=self.parameters.use_off, + on_hours_total_min=self.parameters.on_hours_total_min, + on_hours_total_max=self.parameters.on_hours_total_max, + effects_per_running_hour=self.parameters.effects_per_running_hour, + ) + self.add(self.state_model) + self.state_model.do_modeling() + + # Create switch component if needed + if self.parameters.use_switch_on: + self.switch_state_model = SwitchStateModel( + model=self._model, + label_of_element=self.label_of_element, + state_variable=self.state_model.on, + previous_state=self.state_model.previous_on_states[-1], + switch_on_max=self.parameters.switch_on_total_max, + ) + self.add(self.switch_state_model) + self.switch_state_model.do_modeling() + + # Create consecutive on hours component if needed + if self.parameters.use_consecutive_on_hours: + self.consecutive_on_model = ConsecutiveStateModel( + model=self._model, + label_of_element=self.label_of_element, + state_variable=self.state_model.on, + minimum_duration=self.parameters.consecutive_on_hours_min, + maximum_duration=self.parameters.consecutive_on_hours_max, + previous_states=self.state_model.previous_on_states, + label='ConsecutiveOn', + ) + self.add(self.consecutive_on_model) + self.consecutive_on_model.do_modeling() + + # Create consecutive off hours component if needed + if self.parameters.use_consecutive_off_hours: + self.consecutive_off_model = ConsecutiveStateModel( + model=self._model, + label_of_element=self.label_of_element, + state_variable=self.state_model.off, + minimum_duration=self.parameters.consecutive_off_hours_min, + maximum_duration=self.parameters.consecutive_off_hours_max, + previous_states=self.state_model.previous_off_states, + label='ConsecutiveOff', + ) + self.add(self.consecutive_off_model) + self.consecutive_off_model.do_modeling() + + self._create_shares() + + def _create_shares(self): + if self.parameters.effects_per_running_hour: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.state_model.on * factor * self._model.hours_per_step + for effect, factor in self.parameters.effects_per_running_hour.items() + }, + target='operation', + ) + + if self.parameters.effects_per_switch_on: + self._model.effects.add_share_to_effects( + name=self.label_of_element, + expressions={ + effect: self.switch_state_model.switch_on * factor + for effect, factor in self.parameters.effects_per_switch_on.items() + }, + target='operation', + ) + + @property + def on(self): + return self.state_model.on + + @property + def off(self): + return self.state_model.off + + @property + def switch_on(self): + return self.switch_state_model.switch_on + + @property + def switch_off(self): + return self.switch_state_model.switch_off + + @property + def switch_on_nr(self): + return self.switch_state_model.switch_on_nr + + @property + def consecutive_on_hours(self): + return self.consecutive_on_model.duration + + @property + def consecutive_off_hours(self): + return self.consecutive_off_model.duration + + +class PieceModel(Model): + """Class for modeling a linear piece of one or more variables in parallel""" + + def __init__( + self, + model: SystemModel, + label_of_element: str, + label: 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): + self.inside_piece = self.add( + self._model.add_variables( + binary=True, + name=f'{self.label_full}|inside_piece', + coords=self._model.coords if self._as_time_series else None, + ), + 'inside_piece', + ) + + self.lambda0 = self.add( + self._model.add_variables( + lower=0, + upper=1, + name=f'{self.label_full}|lambda0', + coords=self._model.coords if self._as_time_series else None, + ), + 'lambda0', + ) + + self.lambda1 = self.add( + self._model.add_variables( + lower=0, + upper=1, + name=f'{self.label_full}|lambda1', + coords=self._model.coords if self._as_time_series else None, + ), + 'lambda1', + ) + + # 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', + ) + + +class PiecewiseModel(Model): + def __init__( + self, + model: SystemModel, + label_of_element: 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. + The relation is defined by a list of Pieces, which are assigned to the variables. + Each Piece is a tuple of (start, end). + + Args: + model: The SystemModel 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. + piecewise_variables: The variables to which the Pieces are assigned. + 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 + + def do_modeling(self): + for i in range(len(list(self._piecewise_variables.values())[0])): + new_piece = self.add( + PieceModel( + model=self._model, + label_of_element=self.label_of_element, + label=f'Piece_{i}', + as_time_series=self._as_time_series, + ) + ) + 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', + ), + 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 + if isinstance(self._zero_point, linopy.Variable): + 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.coords, binary=True, name=f'{self.label_full}|zero_point' + ), + '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', + ) + + +class ShareAllocationModel(Model): + def __init__( + self, + model: SystemModel, + shares_are_time_series: bool, + label_of_element: Optional[str] = None, + label: Optional[str] = None, + label_full: Optional[str] = None, + total_max: Optional[Scalar] = None, + total_min: Optional[Scalar] = None, + max_per_hour: Optional[NumericData] = None, + min_per_hour: Optional[NumericData] = None, + ): + super().__init__(model, label_of_element=label_of_element, label=label, label_full=label_full) + if not shares_are_time_series: # If the condition is True + assert max_per_hour is None and min_per_hour is None, ( + 'Both max_per_hour and min_per_hour cannot be used when shares_are_time_series is False' + ) + self.total_per_timestep: Optional[linopy.Variable] = None + self.total: Optional[linopy.Variable] = None + self.shares: Dict[str, linopy.Variable] = {} + self.share_constraints: Dict[str, linopy.Constraint] = {} + + self._eq_total_per_timestep: Optional[linopy.Constraint] = None + self._eq_total: Optional[linopy.Constraint] = None + + # Parameters + self._shares_are_time_series = shares_are_time_series + self._total_max = total_max if total_min is not None else np.inf + self._total_min = total_min if total_min is not None else -np.inf + 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=None, name=f'{self.label_full}|total' + ), + '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' + ) + + if self._shares_are_time_series: + self.total_per_timestep = self.add( + self._model.add_variables( + lower=-np.inf + if (self._min_per_hour is None) + else np.multiply(self._min_per_hour, self._model.hours_per_step), + upper=np.inf + if (self._max_per_hour is None) + else np.multiply(self._max_per_hour, self._model.hours_per_step), + coords=self._model.coords, + name=f'{self.label_full}|total_per_timestep', + ), + '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', + ) + + # Add it to the total + self._eq_total.lhs -= self.total_per_timestep.sum() + + def add_share( + self, + name: str, + expression: linopy.LinearExpression, + ): + """ + Add a share to the share allocation model. If the share already exists, the expression is added to the existing share. + The expression is added to the right hand side (rhs) of the constraint. + The variable representing the total share is on the left hand side (lhs) of the constraint. + var_total = sum(expressions) + + Args: + name: The name of the share. + expression: The expression of the share. Added to the right hand side of the constraint. + """ + if name in self.shares: + self.share_constraints[name].lhs -= expression + else: + self.shares[name] = self.add( + self._model.add_variables( + coords=None + if isinstance(expression, linopy.LinearExpression) + and expression.ndim == 0 + or not isinstance(expression, linopy.LinearExpression) + else self._model.coords, + name=f'{name}->{self.label_full}', + ), + name, + ) + self.share_constraints[name] = self.add( + self._model.add_constraints(self.shares[name] == expression, name=f'{name}->{self.label_full}'), name + ) + if self.shares[name].ndim == 0: + self._eq_total.lhs -= self.shares[name] + else: + self._eq_total_per_timestep.lhs -= self.shares[name] + + +class PiecewiseEffectsModel(Model): + def __init__( + self, + model: SystemModel, + 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=None, 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: SystemModel, + 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 new file mode 100644 index 000000000..93720de60 --- /dev/null +++ b/flixopt/flow_system.py @@ -0,0 +1,409 @@ +""" +This module contains the FlowSystem class, which is used to collect instances of many other classes by the end User. +""" + +import json +import logging +import pathlib +import warnings +from io import StringIO +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union + +import numpy as np +import pandas as pd +import xarray as xr +from rich.console import Console +from rich.pretty import Pretty + +from . import io as fx_io +from .core import NumericData, NumericDataTS, TimeSeries, TimeSeriesCollection, TimeSeriesData +from .effects import Effect, EffectCollection, EffectTimeSeries, EffectValuesDict, EffectValuesUser +from .elements import Bus, Component, Flow +from .structure import CLASS_REGISTRY, Element, SystemModel, get_compact_representation, get_str_representation + +if TYPE_CHECKING: + import pyvis + +logger = logging.getLogger('flixopt') + + +class FlowSystem: + """ + A FlowSystem organizes the high level Elements (Components & Effects). + """ + + def __init__( + self, + timesteps: pd.DatetimeIndex, + hours_of_last_timestep: Optional[float] = None, + hours_of_previous_timesteps: Optional[Union[int, float, np.ndarray]] = None, + ): + """ + Args: + timesteps: The timesteps of the model. + hours_of_last_timestep: The duration of the last time step. Uses the last time interval if not specified + hours_of_previous_timesteps: The duration of previous timesteps. + If None, the first time increment of time_series is used. + This is needed to calculate previous durations (for example consecutive_on_hours). + If you use an array, take care that its long enough to cover all previous values! + """ + self.time_series_collection = TimeSeriesCollection( + timesteps=timesteps, + hours_of_last_timestep=hours_of_last_timestep, + hours_of_previous_timesteps=hours_of_previous_timesteps, + ) + + # defaults: + self.components: Dict[str, Component] = {} + self.buses: Dict[str, Bus] = {} + self.effects: EffectCollection = EffectCollection() + self.model: Optional[SystemModel] = None + + self._connected = False + + @classmethod + def from_dataset(cls, ds: xr.Dataset): + timesteps_extra = pd.DatetimeIndex(ds.attrs['timesteps_extra'], name='time') + hours_of_last_timestep = TimeSeriesCollection.calculate_hours_per_timestep(timesteps_extra).isel(time=-1).item() + + flow_system = FlowSystem( + timesteps=timesteps_extra[:-1], + hours_of_last_timestep=hours_of_last_timestep, + hours_of_previous_timesteps=ds.attrs['hours_of_previous_timesteps'], + ) + + structure = fx_io.insert_dataarray({key: ds.attrs[key] for key in ['components', 'buses', 'effects']}, ds) + flow_system.add_elements( + *[Bus.from_dict(bus) for bus in structure['buses'].values()] + + [Effect.from_dict(effect) for effect in structure['effects'].values()] + + [CLASS_REGISTRY[comp['__class__']].from_dict(comp) for comp in structure['components'].values()] + ) + return flow_system + + @classmethod + def from_dict(cls, data: Dict) -> 'FlowSystem': + """ + Load a FlowSystem from a dictionary. + + Args: + data: Dictionary containing the FlowSystem data. + """ + timesteps_extra = pd.DatetimeIndex(data['timesteps_extra'], name='time') + hours_of_last_timestep = TimeSeriesCollection.calculate_hours_per_timestep(timesteps_extra).isel(time=-1).item() + + flow_system = FlowSystem( + timesteps=timesteps_extra[:-1], + hours_of_last_timestep=hours_of_last_timestep, + hours_of_previous_timesteps=data['hours_of_previous_timesteps'], + ) + + flow_system.add_elements(*[Bus.from_dict(bus) for bus in data['buses'].values()]) + + flow_system.add_elements(*[Effect.from_dict(effect) for effect in data['effects'].values()]) + + flow_system.add_elements( + *[CLASS_REGISTRY[comp['__class__']].from_dict(comp) for comp in data['components'].values()] + ) + + flow_system.transform_data() + + return flow_system + + @classmethod + def from_netcdf(cls, path: Union[str, pathlib.Path]): + """ + Load a FlowSystem from a netcdf file + """ + return cls.from_dataset(fx_io.load_dataset_from_netcdf(path)) + + def add_elements(self, *elements: Element) -> None: + """ + Add Components(Storages, Boilers, Heatpumps, ...), Buses or Effects to the FlowSystem + + Args: + *elements: childs of Element like Boiler, HeatPump, Bus,... + modeling Elements + """ + if self._connected: + warnings.warn( + 'You are adding elements to an already connected FlowSystem. This is not recommended (But it works).', + stacklevel=2, + ) + self._connected = False + for new_element in list(elements): + if isinstance(new_element, Component): + self._add_components(new_element) + elif isinstance(new_element, Effect): + self._add_effects(new_element) + elif isinstance(new_element, Bus): + self._add_buses(new_element) + else: + raise TypeError( + f'Tried to add incompatible object to FlowSystem: {type(new_element)=}: {new_element=} ' + ) + + def to_json(self, path: Union[str, pathlib.Path]): + """ + Saves the flow system to a json file. + This not meant to be reloaded and recreate the object, + but rather used to document or compare the flow_system to others. + + Args: + path: The path to the json file. + """ + with open(path, 'w', encoding='utf-8') as f: + json.dump(self.as_dict('stats'), f, indent=4, ensure_ascii=False) + + def as_dict(self, data_mode: Literal['data', 'name', 'stats'] = 'data') -> Dict: + """Convert the object to a dictionary representation.""" + data = { + 'components': { + comp.label: comp.to_dict() + for comp in sorted(self.components.values(), key=lambda component: component.label.upper()) + }, + 'buses': { + bus.label: bus.to_dict() for bus in sorted(self.buses.values(), key=lambda bus: bus.label.upper()) + }, + 'effects': { + effect.label: effect.to_dict() + for effect in sorted(self.effects, key=lambda effect: effect.label.upper()) + }, + 'timesteps_extra': [date.isoformat() for date in self.time_series_collection.timesteps_extra], + 'hours_of_previous_timesteps': self.time_series_collection.hours_of_previous_timesteps, + } + if data_mode == 'data': + return fx_io.replace_timeseries(data, 'data') + elif data_mode == 'stats': + return fx_io.remove_none_and_empty(fx_io.replace_timeseries(data, data_mode)) + return fx_io.replace_timeseries(data, data_mode) + + def as_dataset(self, constants_in_dataset: bool = False) -> xr.Dataset: + """ + Convert the FlowSystem to a xarray Dataset. + + Args: + constants_in_dataset: If True, constants are included as Dataset variables. + """ + ds = self.time_series_collection.to_dataset(include_constants=constants_in_dataset) + ds.attrs = self.as_dict(data_mode='name') + return ds + + def to_netcdf(self, path: Union[str, pathlib.Path], compression: int = 0, constants_in_dataset: bool = True): + """ + Saves the FlowSystem to a netCDF file. + Args: + path: The path to the netCDF file. + compression: The compression level to use when saving the file. + constants_in_dataset: If True, constants are included as Dataset variables. + """ + ds = self.as_dataset(constants_in_dataset=constants_in_dataset) + fx_io.save_dataset_to_netcdf(ds, path, compression=compression) + logger.info(f'Saved FlowSystem to {path}') + + def plot_network( + self, + path: Union[bool, str, pathlib.Path] = 'flow_system.html', + controls: Union[ + bool, + List[ + Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'] + ], + ] = True, + show: bool = False, + ) -> Optional['pyvis.network.Network']: + """ + Visualizes the network structure of a FlowSystem using PyVis, saving it as an interactive HTML file. + + Args: + path: Path to save the HTML visualization. + - `False`: Visualization is created but not saved. + - `str` or `Path`: Specifies file path (default: 'flow_system.html'). + controls: UI controls to add to the visualization. + - `True`: Enables all available controls. + - `List`: Specify controls, e.g., ['nodes', 'layout']. + - Options: 'nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'. + show: Whether to open the visualization in the web browser. + + Returns: + - Optional[pyvis.network.Network]: The `Network` instance representing the visualization, or `None` if `pyvis` is not installed. + + Examples: + >>> flow_system.plot_network() + >>> flow_system.plot_network(show=False) + >>> flow_system.plot_network(path='output/custom_network.html', controls=['nodes', 'layout']) + + Notes: + - This function requires `pyvis`. If not installed, the function prints a warning and returns `None`. + - Nodes are styled based on type (e.g., circles for buses, boxes for components) and annotated with node information. + """ + from . import plotting + + node_infos, edge_infos = self.network_infos() + return plotting.plot_network(node_infos, edge_infos, path, controls, show) + + def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, str]]]: + if not self._connected: + self._connect_network() + nodes = { + node.label_full: { + 'label': node.label, + 'class': 'Bus' if isinstance(node, Bus) else 'Component', + 'infos': node.__str__(), + } + for node in list(self.components.values()) + list(self.buses.values()) + } + + edges = { + flow.label_full: { + 'label': flow.label, + 'start': flow.bus if flow.is_input_in_component else flow.component, + 'end': flow.component if flow.is_input_in_component else flow.bus, + 'infos': flow.__str__(), + } + for flow in self.flows.values() + } + + return nodes, edges + + def transform_data(self): + if not self._connected: + self._connect_network() + for element in self.all_elements.values(): + element.transform_data(self) + + def create_time_series( + self, + name: str, + data: Optional[Union[NumericData, TimeSeriesData, TimeSeries]], + needs_extra_timestep: bool = False, + ) -> Optional[TimeSeries]: + """ + Tries to create a TimeSeries from NumericData Data and adds it to the time_series_collection + If the data already is a TimeSeries, nothing happens and the TimeSeries gets reset and returned + If the data is a TimeSeriesData, it is converted to a TimeSeries, and the aggregation weights are applied. + If the data is None, nothing happens. + """ + + if data is None: + return None + elif isinstance(data, TimeSeries): + data.restore_data() + if data in self.time_series_collection: + return data + return self.time_series_collection.create_time_series( + data=data.active_data, name=name, needs_extra_timestep=needs_extra_timestep + ) + return self.time_series_collection.create_time_series( + data=data, name=name, needs_extra_timestep=needs_extra_timestep + ) + + def create_effect_time_series( + self, + label_prefix: Optional[str], + effect_values: EffectValuesUser, + label_suffix: Optional[str] = None, + ) -> Optional[EffectTimeSeries]: + """ + Transform EffectValues to EffectTimeSeries. + Creates a TimeSeries for each key in the nested_values dictionary, using the value as the data. + + The resulting label of the TimeSeries is the label of the parent_element, + followed by the label of the Effect in the nested_values and the label_suffix. + If the key in the EffectValues is None, the alias 'Standard_Effect' is used + """ + effect_values: Optional[EffectValuesDict] = self.effects.create_effect_values_dict(effect_values) + if effect_values is None: + return None + + return { + effect: self.create_time_series('|'.join(filter(None, [label_prefix, effect, label_suffix])), value) + for effect, value in effect_values.items() + } + + def create_model(self) -> SystemModel: + if not self._connected: + raise RuntimeError('FlowSystem is not connected. Call FlowSystem.connect() first.') + self.model = SystemModel(self) + return self.model + + def _check_if_element_is_unique(self, element: Element) -> None: + """ + checks if element or label of element already exists in list + + Args: + element: new element to check + """ + if element in self.all_elements.values(): + raise ValueError(f'Element {element.label} already added to FlowSystem!') + # check if name is already used: + if element.label_full in self.all_elements: + raise ValueError(f'Label of Element {element.label} already used in another element!') + + def _add_effects(self, *args: Effect) -> None: + self.effects.add_effects(*args) + + def _add_components(self, *components: Component) -> None: + for new_component in list(components): + logger.info(f'Registered new Component: {new_component.label}') + self._check_if_element_is_unique(new_component) # check if already exists: + self.components[new_component.label] = new_component # Add to existing components + + def _add_buses(self, *buses: Bus): + for new_bus in list(buses): + logger.info(f'Registered new Bus: {new_bus.label}') + self._check_if_element_is_unique(new_bus) # check if already exists: + self.buses[new_bus.label] = new_bus # Add to existing components + + def _connect_network(self): + """Connects the network of components and buses. Can be rerun without changes if no elements were added""" + for component in self.components.values(): + for flow in component.inputs + component.outputs: + flow.component = component.label_full + flow.is_input_in_component = True if flow in component.inputs else False + + # Add Bus if not already added (deprecated) + if flow._bus_object is not None and flow._bus_object not in self.buses.values(): + self._add_buses(flow._bus_object) + warnings.warn( + f'The Bus {flow._bus_object.label} was added to the FlowSystem from {flow.label_full}.' + f'This is deprecated and will be removed in the future. ' + f'Please pass the Bus.label to the Flow and the Bus to the FlowSystem instead.', + UserWarning, + stacklevel=1, + ) + + # Connect Buses + bus = self.buses.get(flow.bus) + if bus is None: + raise KeyError( + f'Bus {flow.bus} not found in the FlowSystem, but used by "{flow.label_full}". ' + f'Please add it first.' + ) + if flow.is_input_in_component and flow not in bus.outputs: + bus.outputs.append(flow) + elif not flow.is_input_in_component and flow not in bus.inputs: + bus.inputs.append(flow) + logger.debug( + f'Connected {len(self.buses)} Buses and {len(self.components)} ' + f'via {len(self.flows)} Flows inside the FlowSystem.' + ) + self._connected = True + + def __repr__(self): + return f'<{self.__class__.__name__} with {len(self.components)} components and {len(self.effects)} effects>' + + def __str__(self): + with StringIO() as output_buffer: + console = Console(file=output_buffer, width=1000) # Adjust width as needed + console.print(Pretty(self.as_dict('stats'), expand_all=True, indent_guides=True)) + value = output_buffer.getvalue() + return value + + @property + def flows(self) -> Dict[str, Flow]: + set_of_flows = {flow for comp in self.components.values() for flow in comp.inputs + comp.outputs} + return {flow.label_full: flow for flow in set_of_flows} + + @property + def all_elements(self) -> Dict[str, Element]: + return {**self.components, **self.effects.effects, **self.flows, **self.buses} diff --git a/flixopt/interface.py b/flixopt/interface.py new file mode 100644 index 000000000..c38d6c619 --- /dev/null +++ b/flixopt/interface.py @@ -0,0 +1,265 @@ +""" +This module contains classes to collect Parameters for the Investment and OnOff decisions. +These are tightly connected to features.py +""" + +import logging +from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Union + +from .config import CONFIG +from .core import NumericData, NumericDataTS, Scalar +from .structure import Interface, register_class_for_io + +if TYPE_CHECKING: # for type checking and preventing circular imports + from .effects import EffectValuesUser, EffectValuesUserScalar + from .flow_system import FlowSystem + + +logger = logging.getLogger('flixopt') + + +@register_class_for_io +class Piece(Interface): + def __init__(self, start: NumericData, end: NumericData): + """ + Define a Piece, which is part of a Piecewise object. + + Args: + start: The x-values of the piece. + end: The end of the piece. + """ + self.start = start + self.end = end + + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + self.start = flow_system.create_time_series(f'{name_prefix}|start', self.start) + self.end = flow_system.create_time_series(f'{name_prefix}|end', self.end) + + +@register_class_for_io +class Piecewise(Interface): + def __init__(self, pieces: List[Piece]): + """ + Define a Piecewise, consisting of a list of Pieces. + + Args: + pieces: The pieces of the piecewise. + """ + self.pieces = pieces + + def __len__(self): + return len(self.pieces) + + def __getitem__(self, index) -> Piece: + return self.pieces[index] # Enables indexing like piecewise[i] + + def __iter__(self) -> Iterator[Piece]: + return iter(self.pieces) # Enables iteration like for piece in piecewise: ... + + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + for i, piece in enumerate(self.pieces): + piece.transform_data(flow_system, f'{name_prefix}|Piece{i}') + + +@register_class_for_io +class PiecewiseConversion(Interface): + def __init__(self, piecewises: Dict[str, Piecewise]): + """ + Define a piecewise conversion between multiple Flows. + --> "gaps" can be expressed by a piece not starting at the end of the prior piece: [(1,3), (4,5)] + --> "points" can expressed as piece with same begin and end: [(3,3), (4,4)] + + Args: + piecewises: Dict of Piecewises defining the conversion factors. flow labels as keys, piecewise as values + """ + self.piecewises = piecewises + + def items(self): + return self.piecewises.items() + + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + for name, piecewise in self.piecewises.items(): + piecewise.transform_data(flow_system, f'{name_prefix}|{name}') + + +@register_class_for_io +class PiecewiseEffects(Interface): + def __init__(self, piecewise_origin: Piecewise, piecewise_shares: Dict[str, Piecewise]): + """ + Define piecewise effects related to a variable. + + Args: + piecewise_origin: Piecewise of the related variable + piecewise_shares: Piecewise defining the shares to different Effects + """ + self.piecewise_origin = piecewise_origin + self.piecewise_shares = piecewise_shares + + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + raise NotImplementedError('PiecewiseEffects is not yet implemented for non scalar shares') + # self.piecewise_origin.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|origin') + # for name, piecewise in self.piecewise_shares.items(): + # piecewise.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|{name}') + + +@register_class_for_io +class InvestParameters(Interface): + """ + collects arguments for invest-stuff + """ + + def __init__( + self, + fixed_size: Optional[Union[int, float]] = None, + minimum_size: Optional[Union[int, float]] = None, + maximum_size: Optional[Union[int, float]] = None, + optional: bool = True, # Investition ist weglassbar + fix_effects: Optional['EffectValuesUserScalar'] = None, + specific_effects: Optional['EffectValuesUserScalar'] = None, # costs per Flow-Unit/Storage-Size/... + piecewise_effects: Optional[PiecewiseEffects] = None, + divest_effects: Optional['EffectValuesUserScalar'] = None, + ): + """ + Args: + fix_effects: Fixed investment costs if invested. (Attention: Annualize costs to chosen period!) + divest_effects: Fixed divestment costs (if not invested, e.g., demolition costs or contractual penalty). + fixed_size: Determines if the investment size is fixed. + optional: If True, investment is not forced. + specific_effects: Specific costs, e.g., in €/kW_nominal or €/m²_nominal. + Example: {costs: 3, CO2: 0.3} with costs and CO2 representing an Object of class Effect + (Attention: Annualize costs to chosen period!) + piecewise_effects: Linear piecewise relation [invest_pieces, cost_pieces]. + Example 1: + [ [5, 25, 25, 100], # size in kW + {costs: [50,250,250,800], # € + PE: [5, 25, 25, 100] # kWh_PrimaryEnergy + } + ] + Example 2 (if only standard-effect): + [ [5, 25, 25, 100], # kW # size in kW + [50,250,250,800] # value for standart effect, typically € + ] # € + (Attention: Annualize costs to chosen period!) + (Args 'specific_effects' and 'fix_effects' can be used in parallel to Investsizepieces) + minimum_size: Min nominal value (only if: size_is_fixed = False). Defaults to CONFIG.modeling.EPSILON. + maximum_size: Max nominal value (only if: size_is_fixed = False). Defaults to CONFIG.modeling.BIG. + """ + self.fix_effects: EffectValuesUser = fix_effects or {} + self.divest_effects: EffectValuesUser = divest_effects or {} + self.fixed_size = fixed_size + self.optional = optional + self.specific_effects: EffectValuesUser = specific_effects or {} + self.piecewise_effects = piecewise_effects + self._minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON + self._maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG # default maximum + + def transform_data(self, flow_system: 'FlowSystem'): + self.fix_effects = flow_system.effects.create_effect_values_dict(self.fix_effects) + self.divest_effects = flow_system.effects.create_effect_values_dict(self.divest_effects) + self.specific_effects = flow_system.effects.create_effect_values_dict(self.specific_effects) + + @property + def minimum_size(self): + return self.fixed_size or self._minimum_size + + @property + def maximum_size(self): + return self.fixed_size or self._maximum_size + + +@register_class_for_io +class OnOffParameters(Interface): + def __init__( + self, + effects_per_switch_on: Optional['EffectValuesUser'] = None, + effects_per_running_hour: Optional['EffectValuesUser'] = None, + on_hours_total_min: Optional[int] = None, + on_hours_total_max: Optional[int] = None, + consecutive_on_hours_min: Optional[NumericData] = None, + consecutive_on_hours_max: Optional[NumericData] = None, + consecutive_off_hours_min: Optional[NumericData] = None, + consecutive_off_hours_max: Optional[NumericData] = None, + switch_on_total_max: Optional[int] = None, + force_switch_on: bool = False, + ): + """ + Bundles information about the on and off state of an Element. + If no parameters are given, the default is to create a binary variable for the on state + without further constraints or effects and a variable for the total on hours. + + Args: + effects_per_switch_on: cost of one switch from off (var_on=0) to on (var_on=1), + unit i.g. in Euro + effects_per_running_hour: costs for operating, i.g. in € per hour + on_hours_total_min: min. overall sum of operating hours. + on_hours_total_max: max. overall sum of operating hours. + consecutive_on_hours_min: min sum of operating hours in one piece + (last on-time period of timeseries is not checked and can be shorter) + consecutive_on_hours_max: max sum of operating hours in one piece + consecutive_off_hours_min: min sum of non-operating hours in one piece + (last off-time period of timeseries is not checked and can be shorter) + consecutive_off_hours_max: max sum of non-operating hours in one piece + switch_on_total_max: max nr of switchOn operations + force_switch_on: force creation of switch on variable, even if there is no switch_on_total_max + """ + self.effects_per_switch_on: EffectValuesUser = effects_per_switch_on or {} + self.effects_per_running_hour: EffectValuesUser = effects_per_running_hour or {} + self.on_hours_total_min: Scalar = on_hours_total_min + self.on_hours_total_max: Scalar = on_hours_total_max + self.consecutive_on_hours_min: NumericDataTS = consecutive_on_hours_min + self.consecutive_on_hours_max: NumericDataTS = consecutive_on_hours_max + self.consecutive_off_hours_min: NumericDataTS = consecutive_off_hours_min + self.consecutive_off_hours_max: NumericDataTS = consecutive_off_hours_max + self.switch_on_total_max: Scalar = switch_on_total_max + self.force_switch_on: bool = force_switch_on + + def transform_data(self, flow_system: 'FlowSystem', name_prefix: str): + self.effects_per_switch_on = flow_system.create_effect_time_series( + name_prefix, self.effects_per_switch_on, 'per_switch_on' + ) + self.effects_per_running_hour = flow_system.create_effect_time_series( + name_prefix, self.effects_per_running_hour, 'per_running_hour' + ) + self.consecutive_on_hours_min = flow_system.create_time_series( + f'{name_prefix}|consecutive_on_hours_min', self.consecutive_on_hours_min + ) + self.consecutive_on_hours_max = flow_system.create_time_series( + f'{name_prefix}|consecutive_on_hours_max', self.consecutive_on_hours_max + ) + self.consecutive_off_hours_min = flow_system.create_time_series( + f'{name_prefix}|consecutive_off_hours_min', self.consecutive_off_hours_min + ) + self.consecutive_off_hours_max = flow_system.create_time_series( + f'{name_prefix}|consecutive_off_hours_max', self.consecutive_off_hours_max + ) + + @property + def use_off(self) -> bool: + """Determines wether the OFF Variable is needed or not""" + return self.use_consecutive_off_hours + + @property + def use_consecutive_on_hours(self) -> bool: + """Determines wether a Variable for consecutive off hours is needed or not""" + return any(param is not None for param in [self.consecutive_on_hours_min, self.consecutive_on_hours_max]) + + @property + def use_consecutive_off_hours(self) -> bool: + """Determines wether a Variable for consecutive off hours is needed or not""" + return any(param is not None for param in [self.consecutive_off_hours_min, self.consecutive_off_hours_max]) + + @property + def use_switch_on(self) -> bool: + """Determines wether a Variable for SWITCH-ON is needed or not""" + return ( + any( + param not in (None, {}) + for param in [ + self.effects_per_switch_on, + self.switch_on_total_max, + self.on_hours_total_min, + self.on_hours_total_max, + ] + ) + or self.force_switch_on + ) diff --git a/flixopt/io.py b/flixopt/io.py new file mode 100644 index 000000000..35d927136 --- /dev/null +++ b/flixopt/io.py @@ -0,0 +1,308 @@ +import importlib.util +import json +import logging +import pathlib +import re +from dataclasses import dataclass +from typing import Dict, Literal, Optional, Tuple, Union + +import linopy +import xarray as xr +import yaml + +from .core import TimeSeries + +logger = logging.getLogger('flixopt') + + +def replace_timeseries(obj, mode: Literal['name', 'stats', 'data'] = 'name'): + """Recursively replaces TimeSeries objects with their names prefixed by '::::'.""" + if isinstance(obj, dict): + return {k: replace_timeseries(v, mode) for k, v in obj.items()} + elif isinstance(obj, list): + return [replace_timeseries(v, mode) for v in obj] + elif isinstance(obj, TimeSeries): # Adjust this based on the actual class + if obj.all_equal: + return obj.active_data.values[0].item() + elif mode == 'name': + return f'::::{obj.name}' + elif mode == 'stats': + return obj.stats + elif mode == 'data': + return obj + else: + raise ValueError(f'Invalid mode {mode}') + else: + return obj + + +def insert_dataarray(obj, ds: xr.Dataset): + """Recursively inserts TimeSeries objects into a dataset.""" + if isinstance(obj, dict): + return {k: insert_dataarray(v, ds) for k, v in obj.items()} + elif isinstance(obj, list): + return [insert_dataarray(v, ds) for v in obj] + elif isinstance(obj, str) and obj.startswith('::::'): + da = ds[obj[4:]] + if da.isel(time=-1).isnull(): + return da.isel(time=slice(0, -1)) + return da + else: + return obj + + +def remove_none_and_empty(obj): + """Recursively removes None and empty dicts and lists values from a dictionary or list.""" + + if isinstance(obj, dict): + return { + k: remove_none_and_empty(v) + for k, v in obj.items() + if not (v is None or (isinstance(v, (list, dict)) and not v)) + } + + elif isinstance(obj, list): + return [remove_none_and_empty(v) for v in obj if not (v is None or (isinstance(v, (list, dict)) and not v))] + + else: + return obj + + +def _save_to_yaml(data, output_file='formatted_output.yaml'): + """ + Save dictionary data to YAML with proper multi-line string formatting. + Handles complex string patterns including backticks, special characters, + and various newline formats. + + Args: + data (dict): Dictionary containing string data + output_file (str): Path to output YAML file + """ + # Process strings to normalize all newlines and handle special patterns + processed_data = _process_complex_strings(data) + + # Define a custom representer for strings + def represent_str(dumper, data): + # Use literal block style (|) for any string with newlines + if '\n' in data: + return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|') + + # Use quoted style for strings with special characters to ensure proper parsing + elif any(char in data for char in ':`{}[]#,&*!|>%@'): + return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='"') + + # Use plain style for simple strings + return dumper.represent_scalar('tag:yaml.org,2002:str', data) + + # Add the string representer to SafeDumper + yaml.add_representer(str, represent_str, Dumper=yaml.SafeDumper) + + # Write to file with settings that ensure proper formatting + with open(output_file, 'w', encoding='utf-8') as file: + yaml.dump( + processed_data, + file, + Dumper=yaml.SafeDumper, + sort_keys=False, # Preserve dictionary order + default_flow_style=False, # Use block style for mappings + width=float('inf'), # Don't wrap long lines + allow_unicode=True, # Support Unicode characters + ) + + +def _process_complex_strings(data): + """ + Process dictionary data recursively with comprehensive string normalization. + Handles various types of strings and special formatting. + + Args: + data: The data to process (dict, list, str, or other) + + Returns: + Processed data with normalized strings + """ + if isinstance(data, dict): + return {k: _process_complex_strings(v) for k, v in data.items()} + elif isinstance(data, list): + return [_process_complex_strings(item) for item in data] + elif isinstance(data, str): + # Step 1: Normalize line endings to \n + normalized = data.replace('\r\n', '\n').replace('\r', '\n') + + # Step 2: Handle escaped newlines with robust regex + normalized = re.sub(r'(? Dict[str, str]: + """ + Convert all model variables and constraints to a structured string representation. + This can take multiple seconds for large models. + The output can be saved to a yaml file with readable formating applied. + + Args: + path (pathlib.Path, optional): Path to save the document. Defaults to None. + """ + documentation = { + 'objective': model.objective.__repr__(), + 'termination_condition': model.termination_condition, + 'status': model.status, + 'nvars': model.nvars, + 'nvarsbin': model.binaries.nvars if len(model.binaries) > 0 else 0, # Temporary, waiting for linopy to fix + 'nvarscont': model.continuous.nvars if len(model.continuous) > 0 else 0, # Temporary, waiting for linopy to fix + 'ncons': model.ncons, + 'variables': {variable_name: variable.__repr__() for variable_name, variable in model.variables.items()}, + 'constraints': { + constraint_name: constraint.__repr__() for constraint_name, constraint in model.constraints.items() + }, + 'binaries': list(model.binaries), + 'integers': list(model.integers), + 'continuous': list(model.continuous), + 'infeasible_constraints': '', + } + + if model.status == 'warning': + logger.critical(f'The model has a warning status {model.status=}. Trying to extract infeasibilities') + try: + import io + from contextlib import redirect_stdout + + f = io.StringIO() + + # Redirect stdout to our buffer + with redirect_stdout(f): + model.print_infeasibilities() + + documentation['infeasible_constraints'] = f.getvalue() + except NotImplementedError: + logger.critical( + 'Infeasible constraints could not get retrieved. This functionality is only availlable with gurobi' + ) + documentation['infeasible_constraints'] = 'Not possible to retrieve infeasible constraints' + + if path is not None: + if path.suffix not in ['.yaml', '.yml']: + raise ValueError(f'Invalid file extension for path {path}. Only .yaml and .yml are supported') + _save_to_yaml(documentation, path) + + return documentation + + +def save_dataset_to_netcdf( + ds: xr.Dataset, + path: Union[str, pathlib.Path], + compression: int = 0, +) -> None: + """ + Save a dataset to a netcdf file. Store the attrs as a json string in the 'attrs' attribute. + + Args: + ds: Dataset to save. + path: Path to save the dataset to. + compression: Compression level for the dataset (0-9). 0 means no compression. 5 is a good default. + + Raises: + ValueError: If the path has an invalid file extension. + """ + if path.suffix not in ['.nc', '.nc4']: + raise ValueError(f'Invalid file extension for path {path}. Only .nc and .nc4 are supported') + + apply_encoding = False + if compression != 0: + if importlib.util.find_spec('netCDF4') is not None: + apply_encoding = True + else: + logger.warning( + 'Dataset was exported without compression due to missing dependency "netcdf4".' + 'Install netcdf4 via `pip install netcdf4`.' + ) + ds = ds.copy(deep=True) + ds.attrs = {'attrs': json.dumps(ds.attrs)} + ds.to_netcdf( + path, + encoding=None + if not apply_encoding + else {data_var: {'zlib': True, 'complevel': 5} for data_var in ds.data_vars}, + ) + + +def load_dataset_from_netcdf(path: Union[str, pathlib.Path]) -> xr.Dataset: + """ + Load a dataset from a netcdf file. Load the attrs from the 'attrs' attribute. + + Args: + path: Path to load the dataset from. + + Returns: + Dataset: Loaded dataset. + """ + ds = xr.load_dataset(path) + ds.attrs = json.loads(ds.attrs['attrs']) + return ds + + +@dataclass +class CalculationResultsPaths: + """Container for all paths related to saving CalculationResults.""" + + folder: pathlib.Path + name: str + + def __post_init__(self): + """Initialize all path attributes.""" + self._update_paths() + + def _update_paths(self): + """Update all path attributes based on current folder and name.""" + self.linopy_model = self.folder / f'{self.name}--linopy_model.nc4' + self.solution = self.folder / f'{self.name}--solution.nc4' + self.summary = self.folder / f'{self.name}--summary.yaml' + self.network = self.folder / f'{self.name}--network.json' + self.flow_system = self.folder / f'{self.name}--flow_system.nc4' + self.model_documentation = self.folder / f'{self.name}--model_documentation.yaml' + + def all_paths(self) -> Dict[str, pathlib.Path]: + """Return a dictionary of all paths.""" + return { + 'linopy_model': self.linopy_model, + 'solution': self.solution, + 'summary': self.summary, + 'network': self.network, + 'flow_system': self.flow_system, + 'model_documentation': self.model_documentation, + } + + def create_folders(self, parents: bool = False) -> None: + """Ensure the folder exists. + Args: + parents: Whether to create the parent folders if they do not exist. + """ + if not self.folder.exists(): + try: + self.folder.mkdir(parents=parents) + except FileNotFoundError as e: + raise FileNotFoundError( + f'Folder {self.folder} and its parent do not exist. Please create them first.' + ) from e + + def update(self, new_name: Optional[str] = None, new_folder: Optional[pathlib.Path] = None) -> None: + """Update name and/or folder and refresh all paths.""" + if new_name is not None: + self.name = new_name + if new_folder is not None: + if not new_folder.is_dir() or not new_folder.exists(): + raise FileNotFoundError(f'Folder {new_folder} does not exist or is not a directory.') + self.folder = new_folder + self._update_paths() diff --git a/flixopt/linear_converters.py b/flixopt/linear_converters.py new file mode 100644 index 000000000..3fd032632 --- /dev/null +++ b/flixopt/linear_converters.py @@ -0,0 +1,331 @@ +""" +This Module contains high-level classes to easily model a FlowSystem. +""" + +import logging +from typing import Dict, Optional + +import numpy as np + +from .components import LinearConverter +from .core import NumericDataTS, TimeSeriesData +from .elements import Flow +from .interface import OnOffParameters +from .structure import register_class_for_io + +logger = logging.getLogger('flixopt') + + +@register_class_for_io +class Boiler(LinearConverter): + def __init__( + self, + label: str, + eta: NumericDataTS, + Q_fu: Flow, + Q_th: Flow, + on_off_parameters: OnOffParameters = None, + meta_data: Optional[Dict] = None, + ): + """ + Args: + label: The label of the Element. Used to identify it in the FlowSystem + eta: thermal efficiency. + Q_fu: fuel input-flow + Q_th: thermal output-flow. + on_off_parameters: Parameters defining the on/off behavior of the component. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + super().__init__( + label, + inputs=[Q_fu], + outputs=[Q_th], + conversion_factors=[{Q_fu.label: eta, Q_th.label: 1}], + on_off_parameters=on_off_parameters, + meta_data=meta_data, + ) + self.Q_fu = Q_fu + self.Q_th = Q_th + + @property + def eta(self): + return self.conversion_factors[0][self.Q_fu.label] + + @eta.setter + def eta(self, value): + check_bounds(value, 'eta', self.label_full, 0, 1) + self.conversion_factors[0][self.Q_fu.label] = value + + +@register_class_for_io +class Power2Heat(LinearConverter): + def __init__( + self, + label: str, + eta: NumericDataTS, + P_el: Flow, + Q_th: Flow, + on_off_parameters: OnOffParameters = None, + meta_data: Optional[Dict] = None, + ): + """ + Args: + label: The label of the Element. Used to identify it in the FlowSystem + eta: thermal efficiency. + P_el: electric input-flow + Q_th: thermal output-flow. + on_off_parameters: Parameters defining the on/off behavior of the component. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + super().__init__( + label, + inputs=[P_el], + outputs=[Q_th], + conversion_factors=[{P_el.label: eta, Q_th.label: 1}], + on_off_parameters=on_off_parameters, + meta_data=meta_data, + ) + + self.P_el = P_el + self.Q_th = Q_th + + @property + def eta(self): + return self.conversion_factors[0][self.P_el.label] + + @eta.setter + def eta(self, value): + check_bounds(value, 'eta', self.label_full, 0, 1) + self.conversion_factors[0][self.P_el.label] = value + + +@register_class_for_io +class HeatPump(LinearConverter): + def __init__( + self, + label: str, + COP: NumericDataTS, + P_el: Flow, + Q_th: Flow, + on_off_parameters: OnOffParameters = None, + meta_data: Optional[Dict] = None, + ): + """ + Args: + label: The label of the Element. Used to identify it in the FlowSystem + COP: Coefficient of performance. + P_el: electricity input-flow. + Q_th: thermal output-flow. + on_off_parameters: Parameters defining the on/off behavior of the component. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + super().__init__( + label, + inputs=[P_el], + outputs=[Q_th], + conversion_factors=[{P_el.label: COP, Q_th.label: 1}], + on_off_parameters=on_off_parameters, + meta_data=meta_data, + ) + self.P_el = P_el + self.Q_th = Q_th + self.COP = COP + + @property + def COP(self): # noqa: N802 + return self.conversion_factors[0][self.P_el.label] + + @COP.setter + def COP(self, value): # noqa: N802 + check_bounds(value, 'COP', self.label_full, 1, 20) + self.conversion_factors[0][self.P_el.label] = value + + +@register_class_for_io +class CoolingTower(LinearConverter): + def __init__( + self, + label: str, + specific_electricity_demand: NumericDataTS, + P_el: Flow, + Q_th: Flow, + on_off_parameters: OnOffParameters = None, + meta_data: Optional[Dict] = None, + ): + """ + Args: + label: The label of the Element. Used to identify it in the FlowSystem + specific_electricity_demand: auxiliary electricty demand per cooling power, i.g. 0.02 (2 %). + P_el: electricity input-flow. + Q_th: thermal input-flow. + on_off_parameters: Parameters defining the on/off behavior of the component. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + super().__init__( + label, + inputs=[P_el, Q_th], + outputs=[], + conversion_factors=[{P_el.label: 1, Q_th.label: -specific_electricity_demand}], + on_off_parameters=on_off_parameters, + meta_data=meta_data, + ) + + self.P_el = P_el + self.Q_th = Q_th + + check_bounds(specific_electricity_demand, 'specific_electricity_demand', self.label_full, 0, 1) + + @property + def specific_electricity_demand(self): + return -self.conversion_factors[0][self.Q_th.label] + + @specific_electricity_demand.setter + def specific_electricity_demand(self, value): + check_bounds(value, 'specific_electricity_demand', self.label_full, 0, 1) + self.conversion_factors[0][self.Q_th.label] = -value + + +@register_class_for_io +class CHP(LinearConverter): + def __init__( + self, + label: str, + eta_th: NumericDataTS, + eta_el: NumericDataTS, + Q_fu: Flow, + P_el: Flow, + Q_th: Flow, + on_off_parameters: OnOffParameters = None, + meta_data: Optional[Dict] = None, + ): + """ + Args: + label: The label of the Element. Used to identify it in the FlowSystem + eta_th: thermal efficiency. + eta_el: electrical efficiency. + Q_fu: fuel input-flow. + P_el: electricity output-flow. + Q_th: heat output-flow. + on_off_parameters: Parameters defining the on/off behavior of the component. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + heat = {Q_fu.label: eta_th, Q_th.label: 1} + electricity = {Q_fu.label: eta_el, P_el.label: 1} + + super().__init__( + label, + inputs=[Q_fu], + outputs=[Q_th, P_el], + conversion_factors=[heat, electricity], + on_off_parameters=on_off_parameters, + meta_data=meta_data, + ) + + self.Q_fu = Q_fu + self.P_el = P_el + self.Q_th = Q_th + + check_bounds(eta_el + eta_th, 'eta_th+eta_el', self.label_full, 0, 1) + + @property + def eta_th(self): + return self.conversion_factors[0][self.Q_fu.label] + + @eta_th.setter + def eta_th(self, value): + check_bounds(value, 'eta_th', self.label_full, 0, 1) + self.conversion_factors[0][self.Q_fu.label] = value + + @property + def eta_el(self): + return self.conversion_factors[1][self.Q_fu.label] + + @eta_el.setter + def eta_el(self, value): + check_bounds(value, 'eta_el', self.label_full, 0, 1) + self.conversion_factors[1][self.Q_fu.label] = value + + +@register_class_for_io +class HeatPumpWithSource(LinearConverter): + def __init__( + self, + label: str, + COP: NumericDataTS, + P_el: Flow, + Q_ab: Flow, + Q_th: Flow, + on_off_parameters: OnOffParameters = None, + meta_data: Optional[Dict] = None, + ): + """ + Args: + label: The label of the Element. Used to identify it in the FlowSystem + COP: Coefficient of performance. + Q_ab: Heatsource input-flow. + P_el: electricity input-flow. + Q_th: thermal output-flow. + on_off_parameters: Parameters defining the on/off behavior of the component. + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + + # super: + electricity = {P_el.label: COP, Q_th.label: 1} + heat_source = {Q_ab.label: COP / (COP - 1), Q_th.label: 1} + + super().__init__( + label, + inputs=[P_el, Q_ab], + outputs=[Q_th], + conversion_factors=[electricity, heat_source], + on_off_parameters=on_off_parameters, + meta_data=meta_data, + ) + self.P_el = P_el + self.Q_ab = Q_ab + self.Q_th = Q_th + + @property + def COP(self): # noqa: N802 + return self.conversion_factors[0][self.Q_th.label] + + @COP.setter + def COP(self, value): # noqa: N802 + check_bounds(value, 'COP', self.label_full, 1, 20) + self.conversion_factors[0][self.Q_th.label] = value + self.conversion_factors[1][self.Q_th.label] = value / (value - 1) + + +def check_bounds( + value: NumericDataTS, + parameter_label: str, + element_label: str, + lower_bound: NumericDataTS, + upper_bound: NumericDataTS, +) -> None: + """ + Check if the value is within the bounds. The bounds are exclusive. + If not, log a warning. + Args: + value: The value to check. + parameter_label: The label of the value. + element_label: The label of the element. + lower_bound: The lower bound. + upper_bound: The upper bound. + """ + if isinstance(value, TimeSeriesData): + value = value.data + if isinstance(lower_bound, TimeSeriesData): + lower_bound = lower_bound.data + if isinstance(upper_bound, TimeSeriesData): + upper_bound = upper_bound.data + if not np.all(value > lower_bound): + logger.warning( + f"'{element_label}.{parameter_label}' is equal or below the common lower bound {lower_bound}." + f' {parameter_label}.min={np.min(value)}; {parameter_label}={value}' + ) + if not np.all(value < upper_bound): + logger.warning( + f"'{element_label}.{parameter_label}' exceeds or matches the common upper bound {upper_bound}." + f' {parameter_label}.max={np.max(value)}; {parameter_label}={value}' + ) diff --git a/flixopt/plotting.py b/flixopt/plotting.py new file mode 100644 index 000000000..e4c440aaf --- /dev/null +++ b/flixopt/plotting.py @@ -0,0 +1,1340 @@ +""" +This module contains the plotting functionality of the flixopt framework. +It provides high level functions to plot data with plotly and matplotlib. +It's meant to be used in results.py, but is designed to be used by the end user as well. +""" + +import itertools +import logging +import pathlib +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union + +import matplotlib.colors as mcolors +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import plotly.express as px +import plotly.graph_objects as go +import plotly.offline +from plotly.exceptions import PlotlyError + +if TYPE_CHECKING: + import pyvis + +logger = logging.getLogger('flixopt') + +# Define the colors for the 'portland' colormap in matplotlib +_portland_colors = [ + [12 / 255, 51 / 255, 131 / 255], # Dark blue + [10 / 255, 136 / 255, 186 / 255], # Light blue + [242 / 255, 211 / 255, 56 / 255], # Yellow + [242 / 255, 143 / 255, 56 / 255], # Orange + [217 / 255, 30 / 255, 30 / 255], # Red +] + +# Check if the colormap already exists before registering it +if 'portland' not in plt.colormaps: + plt.colormaps.register(mcolors.LinearSegmentedColormap.from_list('portland', _portland_colors)) + + +ColorType = Union[str, List[str], Dict[str, str]] +"""Identifier for the colors to use. +Use the name of a colorscale, a list of colors or a dictionary of labels to colors. +The colors must be valid color strings (HEX or names). Depending on the Engine used, other formats are possible. +See also: +- https://htmlcolorcodes.com/color-names/ +- https://matplotlib.org/stable/tutorials/colors/colormaps.html +- https://plotly.com/python/builtin-colorscales/ +""" + +PlottingEngine = Literal['plotly', 'matplotlib'] +"""Identifier for the plotting engine to use.""" + + +class ColorProcessor: + """Class to handle color processing for different visualization engines.""" + + def __init__(self, engine: PlottingEngine = 'plotly', default_colormap: str = 'viridis'): + """ + Initialize the color processor. + + Args: + engine: The plotting engine to use ('plotly' or 'matplotlib') + default_colormap: Default colormap to use if none is specified + """ + if engine not in ['plotly', 'matplotlib']: + raise TypeError(f'engine must be "plotly" or "matplotlib", but is {engine}') + self.engine = engine + self.default_colormap = default_colormap + + def _generate_colors_from_colormap(self, colormap_name: str, num_colors: int) -> List[Any]: + """ + Generate colors from a named colormap. + + Args: + colormap_name: Name of the colormap + num_colors: Number of colors to generate + + Returns: + List of colors in the format appropriate for the engine + """ + if self.engine == 'plotly': + try: + colorscale = px.colors.get_colorscale(colormap_name) + except PlotlyError as e: + logger.warning(f"Colorscale '{colormap_name}' not found in Plotly. Using {self.default_colormap}: {e}") + colorscale = px.colors.get_colorscale(self.default_colormap) + + # Generate evenly spaced points + color_points = [i / (num_colors - 1) for i in range(num_colors)] if num_colors > 1 else [0] + return px.colors.sample_colorscale(colorscale, color_points) + + else: # matplotlib + try: + cmap = plt.get_cmap(colormap_name, num_colors) + except ValueError as e: + logger.warning( + f"Colormap '{colormap_name}' not found in Matplotlib. Using {self.default_colormap}: {e}" + ) + cmap = plt.get_cmap(self.default_colormap, num_colors) + + return [cmap(i) for i in range(num_colors)] + + def _handle_color_list(self, colors: List[str], num_labels: int) -> List[str]: + """ + Handle a list of colors, cycling if necessary. + + Args: + colors: List of color strings + num_labels: Number of labels that need colors + + Returns: + List of colors matching the number of labels + """ + if len(colors) == 0: + logger.warning(f'Empty color list provided. Using {self.default_colormap} instead.') + return self._generate_colors_from_colormap(self.default_colormap, num_labels) + + if len(colors) < num_labels: + logger.warning( + f'Not enough colors provided ({len(colors)}) for all labels ({num_labels}). Colors will cycle.' + ) + # Cycle through the colors + color_iter = itertools.cycle(colors) + return [next(color_iter) for _ in range(num_labels)] + else: + # Trim if necessary + if len(colors) > num_labels: + logger.warning( + f'More colors provided ({len(colors)}) than labels ({num_labels}). Extra colors will be ignored.' + ) + return colors[:num_labels] + + def _handle_color_dict(self, colors: Dict[str, str], labels: List[str]) -> List[str]: + """ + Handle a dictionary mapping labels to colors. + + Args: + colors: Dictionary mapping labels to colors + labels: List of labels that need colors + + Returns: + List of colors in the same order as labels + """ + if len(colors) == 0: + logger.warning(f'Empty color dictionary provided. Using {self.default_colormap} instead.') + return self._generate_colors_from_colormap(self.default_colormap, len(labels)) + + # Find missing labels + missing_labels = set(labels) - set(colors.keys()) + if missing_labels: + logger.warning( + f'Some labels have no color specified: {missing_labels}. Using {self.default_colormap} for these.' + ) + + # Generate colors for missing labels + missing_colors = self._generate_colors_from_colormap(self.default_colormap, len(missing_labels)) + + # Create a copy to avoid modifying the original + colors_copy = colors.copy() + for i, label in enumerate(missing_labels): + colors_copy[label] = missing_colors[i] + else: + colors_copy = colors + + # Create color list in the same order as labels + return [colors_copy[label] for label in labels] + + def process_colors( + self, + colors: ColorType, + labels: List[str], + return_mapping: bool = False, + ) -> Union[List[Any], Dict[str, Any]]: + """ + Process colors for the specified labels. + + Args: + colors: Color specification (colormap name, list of colors, or label-to-color mapping) + labels: List of data labels that need colors assigned + return_mapping: If True, returns a dictionary mapping labels to colors; + if False, returns a list of colors in the same order as labels + + Returns: + Either a list of colors or a dictionary mapping labels to colors + """ + if len(labels) == 0: + logger.warning('No labels provided for color assignment.') + return {} if return_mapping else [] + + # Process based on type of colors input + if isinstance(colors, str): + color_list = self._generate_colors_from_colormap(colors, len(labels)) + elif isinstance(colors, list): + color_list = self._handle_color_list(colors, len(labels)) + elif isinstance(colors, dict): + color_list = self._handle_color_dict(colors, labels) + else: + logger.warning( + f'Unsupported color specification type: {type(colors)}. Using {self.default_colormap} instead.' + ) + color_list = self._generate_colors_from_colormap(self.default_colormap, len(labels)) + + # Return either a list or a mapping + if return_mapping: + return {label: color_list[i] for i, label in enumerate(labels)} + else: + return color_list + + +def with_plotly( + data: pd.DataFrame, + mode: Literal['bar', 'line', 'area'] = 'area', + colors: ColorType = 'viridis', + title: str = '', + ylabel: str = '', + xlabel: str = 'Time in h', + fig: Optional[go.Figure] = None, +) -> go.Figure: + """ + Plot a DataFrame with Plotly, using either stacked bars or stepped lines. + + Args: + data: A DataFrame containing the data to plot, where the index represents time (e.g., hours), + and each column represents a separate data series. + mode: The plotting mode. Use 'bar' for stacked bar charts, 'line' for stepped lines, + or 'area' for stacked area charts. + colors: Color specification, can be: + - A string with a colorscale name (e.g., 'viridis', 'plasma') + - A list of color strings (e.g., ['#ff0000', '#00ff00']) + - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'}) + title: The title of the plot. + ylabel: The label for the y-axis. + fig: A Plotly figure object to plot on. If not provided, a new figure will be created. + + Returns: + A Plotly figure object containing the generated plot. + """ + assert mode in ['bar', 'line', 'area'], f"'mode' must be one of {['bar', 'line', 'area']}" + if data.empty: + return go.Figure() + + processed_colors = ColorProcessor(engine='plotly').process_colors(colors, list(data.columns)) + + fig = fig if fig is not None else go.Figure() + + if mode == 'bar': + for i, column in enumerate(data.columns): + fig.add_trace( + go.Bar( + x=data.index, + y=data[column], + name=column, + marker=dict(color=processed_colors[i]), + ) + ) + + fig.update_layout( + barmode='relative' if mode == 'bar' else None, + bargap=0, # No space between bars + bargroupgap=0, # No space between groups of bars + ) + elif mode == 'line': + for i, column in enumerate(data.columns): + fig.add_trace( + go.Scatter( + x=data.index, + y=data[column], + mode='lines', + name=column, + line=dict(shape='hv', color=processed_colors[i]), + ) + ) + elif mode == 'area': + data = data.copy() + data[(data > -1e-5) & (data < 1e-5)] = 0 # Preventing issues with plotting + # Split columns into positive, negative, and mixed categories + positive_columns = list(data.columns[(data >= 0).where(~np.isnan(data), True).all()]) + negative_columns = list(data.columns[(data <= 0).where(~np.isnan(data), True).all()]) + negative_columns = [column for column in negative_columns if column not in positive_columns] + mixed_columns = list(set(data.columns) - set(positive_columns + negative_columns)) + + if mixed_columns: + logger.warning( + f'Data for plotting stacked lines contains columns with both positive and negative values:' + f' {mixed_columns}. These can not be stacked, and are printed as simple lines' + ) + + # Get color mapping for all columns + colors_stacked = {column: processed_colors[i] for i, column in enumerate(data.columns)} + + for column in positive_columns + negative_columns: + fig.add_trace( + go.Scatter( + x=data.index, + y=data[column], + mode='lines', + name=column, + line=dict(shape='hv', color=colors_stacked[column]), + fill='tonexty', + stackgroup='pos' if column in positive_columns else 'neg', + ) + ) + + for column in mixed_columns: + fig.add_trace( + go.Scatter( + x=data.index, + y=data[column], + mode='lines', + name=column, + line=dict(shape='hv', color=colors_stacked[column], dash='dash'), + ) + ) + + # Update layout for better aesthetics + fig.update_layout( + title=title, + yaxis=dict( + title=ylabel, + showgrid=True, # Enable grid lines on the y-axis + gridcolor='lightgrey', # Customize grid line color + gridwidth=0.5, # Customize grid line width + ), + xaxis=dict( + title=xlabel, + showgrid=True, # Enable grid lines on the x-axis + gridcolor='lightgrey', # Customize grid line color + gridwidth=0.5, # Customize grid line width + ), + plot_bgcolor='rgba(0,0,0,0)', # Transparent background + paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background + font=dict(size=14), # Increase font size for better readability + legend=dict( + orientation='h', # Horizontal legend + yanchor='bottom', + y=-0.3, # Adjusts how far below the plot it appears + xanchor='center', + x=0.5, + title_text=None, # Removes legend title for a cleaner look + ), + ) + + return fig + + +def with_matplotlib( + data: pd.DataFrame, + mode: Literal['bar', 'line'] = 'bar', + colors: ColorType = 'viridis', + title: str = '', + ylabel: str = '', + xlabel: str = 'Time in h', + figsize: Tuple[int, int] = (12, 6), + fig: Optional[plt.Figure] = None, + ax: Optional[plt.Axes] = None, +) -> Tuple[plt.Figure, plt.Axes]: + """ + Plot a DataFrame with Matplotlib using stacked bars or stepped lines. + + Args: + data: A DataFrame containing the data to plot. The index should represent time (e.g., hours), + and each column represents a separate data series. + mode: Plotting mode. Use 'bar' for stacked bar charts or 'line' for stepped lines. + colors: Color specification, can be: + - A string with a colormap name (e.g., 'viridis', 'plasma') + - A list of color strings (e.g., ['#ff0000', '#00ff00']) + - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'}) + title: The title of the plot. + ylabel: The ylabel of the plot. + xlabel: The xlabel of the plot. + figsize: Specify the size of the figure + fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created. + ax: A Matplotlib axes object to plot on. If not provided, a new axes will be created. + + Returns: + A tuple containing the Matplotlib figure and axes objects used for the plot. + + Notes: + - If `mode` is 'bar', bars are stacked for both positive and negative values. + Negative values are stacked separately without extra labels in the legend. + - If `mode` is 'line', stepped lines are drawn for each data series. + - The legend is placed below the plot to accommodate multiple data series. + """ + assert mode in ['bar', 'line'], f"'mode' must be one of {['bar', 'line']} for matplotlib" + + if fig is None or ax is None: + fig, ax = plt.subplots(figsize=figsize) + + processed_colors = ColorProcessor(engine='matplotlib').process_colors(colors, list(data.columns)) + + if mode == 'bar': + cumulative_positive = np.zeros(len(data)) + cumulative_negative = np.zeros(len(data)) + width = data.index.to_series().diff().dropna().min() # Minimum time difference + + for i, column in enumerate(data.columns): + positive_values = np.clip(data[column], 0, None) # Keep only positive values + negative_values = np.clip(data[column], None, 0) # Keep only negative values + # Plot positive bars + ax.bar( + data.index, + positive_values, + bottom=cumulative_positive, + color=processed_colors[i], + label=column, + width=width, + align='center', + ) + cumulative_positive += positive_values.values + # Plot negative bars + ax.bar( + data.index, + negative_values, + bottom=cumulative_negative, + color=processed_colors[i], + label='', # No label for negative bars + width=width, + align='center', + ) + cumulative_negative += negative_values.values + + elif mode == 'line': + for i, column in enumerate(data.columns): + ax.step(data.index, data[column], where='post', color=processed_colors[i], label=column) + + # Aesthetics + ax.set_xlabel(xlabel, ha='center') + ax.set_ylabel(ylabel, va='center') + ax.set_title(title) + ax.grid(color='lightgrey', linestyle='-', linewidth=0.5) + ax.legend( + loc='upper center', # Place legend at the bottom center + bbox_to_anchor=(0.5, -0.15), # Adjust the position to fit below plot + ncol=5, + frameon=False, # Remove box around legend + ) + fig.tight_layout() + + return fig, ax + + +def heat_map_matplotlib( + data: pd.DataFrame, + color_map: str = 'viridis', + title: str = '', + xlabel: str = 'Period', + ylabel: str = 'Step', + figsize: Tuple[float, float] = (12, 6), +) -> Tuple[plt.Figure, plt.Axes]: + """ + Plots a DataFrame as a heatmap using Matplotlib. The columns of the DataFrame will be displayed on the x-axis, + the index will be displayed on the y-axis, and the values will represent the 'heat' intensity in the plot. + + Args: + data: A DataFrame containing the data to be visualized. The index will be used for the y-axis, and columns will be used for the x-axis. + The values in the DataFrame will be represented as colors in the heatmap. + color_map: The colormap to use for the heatmap. Default is 'viridis'. Matplotlib supports various colormaps like 'plasma', 'inferno', 'cividis', etc. + figsize: The size of the figure to create. Default is (12, 6), which results in a width of 12 inches and a height of 6 inches. + + Returns: + A tuple containing the Matplotlib `Figure` and `Axes` objects. The `Figure` contains the overall plot, while the `Axes` is the area + where the heatmap is drawn. These can be used for further customization or saving the plot to a file. + + Notes: + - The y-axis is flipped so that the first row of the DataFrame is displayed at the top of the plot. + - The color scale is normalized based on the minimum and maximum values in the DataFrame. + - The x-axis labels (periods) are placed at the top of the plot. + - The colorbar is added horizontally at the bottom of the plot, with a label. + """ + + # Get the min and max values for color normalization + color_bar_min, color_bar_max = data.min().min(), data.max().max() + + # Create the heatmap plot + fig, ax = plt.subplots(figsize=figsize) + ax.pcolormesh(data.values, cmap=color_map) + ax.invert_yaxis() # Flip the y-axis to start at the top + + # Adjust ticks and labels for x and y axes + ax.set_xticks(np.arange(len(data.columns)) + 0.5) + ax.set_xticklabels(data.columns, ha='center') + ax.set_yticks(np.arange(len(data.index)) + 0.5) + ax.set_yticklabels(data.index, va='center') + + # Add labels to the axes + ax.set_xlabel(xlabel, ha='center') + ax.set_ylabel(ylabel, va='center') + ax.set_title(title) + + # Position x-axis labels at the top + ax.xaxis.set_label_position('top') + ax.xaxis.set_ticks_position('top') + + # Add the colorbar + sm1 = plt.cm.ScalarMappable(cmap=color_map, norm=plt.Normalize(vmin=color_bar_min, vmax=color_bar_max)) + sm1._A = [] + fig.colorbar(sm1, ax=ax, pad=0.12, aspect=15, fraction=0.2, orientation='horizontal') + + fig.tight_layout() + + return fig, ax + + +def heat_map_plotly( + data: pd.DataFrame, + color_map: str = 'viridis', + title: str = '', + xlabel: str = 'Period', + ylabel: str = 'Step', + categorical_labels: bool = True, +) -> go.Figure: + """ + Plots a DataFrame as a heatmap using Plotly. The columns of the DataFrame will be mapped to the x-axis, + and the index will be displayed on the y-axis. The values in the DataFrame will represent the 'heat' in the plot. + + Args: + data: A DataFrame with the data to be visualized. The index will be used for the y-axis, and columns will be used for the x-axis. + The values in the DataFrame will be represented as colors in the heatmap. + color_map: The color scale to use for the heatmap. Default is 'viridis'. Plotly supports various color scales like 'Cividis', 'Inferno', etc. + categorical_labels: If True, the x and y axes are treated as categorical data (i.e., the index and columns will not be interpreted as continuous data). + Default is True. If False, the axes are treated as continuous, which may be useful for time series or numeric data. + show: Wether to show the figure after creation. (This includes saving the figure) + save: Wether to save the figure after creation (without showing) + path: Path to save the figure. + + Returns: + A Plotly figure object containing the heatmap. This can be further customized and saved + or displayed using `fig.show()`. + + Notes: + The color bar is automatically scaled to the minimum and maximum values in the data. + The y-axis is reversed to display the first row at the top. + """ + + color_bar_min, color_bar_max = data.min().min(), data.max().max() # Min and max values for color scaling + # Define the figure + fig = go.Figure( + data=go.Heatmap( + z=data.values, + x=data.columns, + y=data.index, + colorscale=color_map, + zmin=color_bar_min, + zmax=color_bar_max, + colorbar=dict( + title=dict(text='Color Bar Label', side='right'), + orientation='h', + xref='container', + yref='container', + len=0.8, # Color bar length relative to plot + x=0.5, + y=0.1, + ), + ) + ) + + # Set axis labels and style + fig.update_layout( + title=title, + xaxis=dict(title=xlabel, side='top', type='category' if categorical_labels else None), + yaxis=dict(title=ylabel, autorange='reversed', type='category' if categorical_labels else None), + ) + + return fig + + +def reshape_to_2d(data_1d: np.ndarray, nr_of_steps_per_column: int) -> np.ndarray: + """ + Reshapes a 1D numpy array into a 2D array suitable for plotting as a colormap. + + The reshaped array will have the number of rows corresponding to the steps per column + (e.g., 24 hours per day) and columns representing time periods (e.g., days or months). + + Args: + data_1d: A 1D numpy array with the data to reshape. + nr_of_steps_per_column: The number of steps (rows) per column in the resulting 2D array. For example, + this could be 24 (for hours) or 31 (for days in a month). + + Returns: + The reshaped 2D array. Each internal array corresponds to one column, with the specified number of steps. + Each column might represents a time period (e.g., day, month, etc.). + """ + + # Step 1: Ensure the input is a 1D array. + if data_1d.ndim != 1: + raise ValueError('Input must be a 1D array') + + # Step 2: Convert data to float type to allow NaN padding + if data_1d.dtype != np.float64: + data_1d = data_1d.astype(np.float64) + + # Step 3: Calculate the number of columns required + total_steps = len(data_1d) + cols = len(data_1d) // nr_of_steps_per_column # Base number of columns + + # If there's a remainder, add an extra column to hold the remaining values + if total_steps % nr_of_steps_per_column != 0: + cols += 1 + + # Step 4: Pad the 1D data to match the required number of rows and columns + padded_data = np.pad( + data_1d, (0, cols * nr_of_steps_per_column - total_steps), mode='constant', constant_values=np.nan + ) + + # Step 5: Reshape the padded data into a 2D array + data_2d = padded_data.reshape(cols, nr_of_steps_per_column) + + return data_2d.T + + +def heat_map_data_from_df( + df: pd.DataFrame, + periods: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], + steps_per_period: Literal['W', 'D', 'h', '15min', 'min'], + fill: Optional[Literal['ffill', 'bfill']] = None, +) -> pd.DataFrame: + """ + Reshapes a DataFrame with a DateTime index into a 2D array for heatmap plotting, + based on a specified sample rate. + If a non-valid combination of periods and steps per period is used, falls back to numerical indices + + Args: + df: A DataFrame with a DateTime index containing the data to reshape. + periods: The time interval of each period (columns of the heatmap), + such as 'YS' (year start), 'W' (weekly), 'D' (daily), 'h' (hourly) etc. + steps_per_period: The time interval within each period (rows in the heatmap), + such as 'YS' (year start), 'W' (weekly), 'D' (daily), 'h' (hourly) etc. + fill: Method to fill missing values: 'ffill' for forward fill or 'bfill' for backward fill. + + Returns: + A DataFrame suitable for heatmap plotting, with rows representing steps within each period + and columns representing each period. + """ + assert pd.api.types.is_datetime64_any_dtype(df.index), ( + 'The index of the Dataframe must be datetime to transfrom it properly for a heatmap plot' + ) + + # Define formats for different combinations of `periods` and `steps_per_period` + formats = { + ('YS', 'W'): ('%Y', '%W'), + ('YS', 'D'): ('%Y', '%j'), # day of year + ('YS', 'h'): ('%Y', '%j %H:00'), + ('MS', 'D'): ('%Y-%m', '%d'), # day of month + ('MS', 'h'): ('%Y-%m', '%d %H:00'), + ('W', 'D'): ('%Y-w%W', '%w_%A'), # week and day of week (with prefix for proper sorting) + ('W', 'h'): ('%Y-w%W', '%w_%A %H:00'), + ('D', 'h'): ('%Y-%m-%d', '%H:00'), # Day and hour + ('D', '15min'): ('%Y-%m-%d', '%H:%MM'), # Day and hour + ('h', '15min'): ('%Y-%m-%d %H:00', '%M'), # minute of hour + ('h', 'min'): ('%Y-%m-%d %H:00', '%M'), # minute of hour + } + + minimum_time_diff_in_min = df.index.to_series().diff().min().total_seconds() / 60 # Smallest time_diff in minutes + time_intervals = {'min': 1, '15min': 15, 'h': 60, 'D': 24 * 60, 'W': 7 * 24 * 60} + if time_intervals[steps_per_period] > minimum_time_diff_in_min: + time_intervals[steps_per_period] + logger.warning( + f'To compute the heatmap, the data was aggregated from {minimum_time_diff_in_min:.2f} min to ' + f'{time_intervals[steps_per_period]:.2f} min. Mean values are displayed.' + ) + + # Select the format based on the `periods` and `steps_per_period` combination + format_pair = (periods, steps_per_period) + assert format_pair in formats, f'{format_pair} is not a valid format. Choose from {list(formats.keys())}' + period_format, step_format = formats[format_pair] + + df = df.sort_index() # Ensure DataFrame is sorted by time index + + resampled_data = df.resample(steps_per_period).mean() # Resample and fill any gaps with NaN + + if fill == 'ffill': # Apply fill method if specified + resampled_data = resampled_data.ffill() + elif fill == 'bfill': + resampled_data = resampled_data.bfill() + + resampled_data['period'] = resampled_data.index.strftime(period_format) + resampled_data['step'] = resampled_data.index.strftime(step_format) + if '%w_%A' in step_format: # SHift index of strings to ensure proper sorting + resampled_data['step'] = resampled_data['step'].apply( + lambda x: x.replace('0_Sunday', '7_Sunday') if '0_Sunday' in x else x + ) + + # Pivot the table so periods are columns and steps are indices + df_pivoted = resampled_data.pivot(columns='period', index='step', values=df.columns[0]) + + return df_pivoted + + +def plot_network( + node_infos: dict, + edge_infos: dict, + path: Optional[Union[str, pathlib.Path]] = None, + controls: Union[ + bool, + List[Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer']], + ] = True, + show: bool = False, +) -> Optional['pyvis.network.Network']: + """ + Visualizes the network structure of a FlowSystem using PyVis, using info-dictionaries. + + Args: + path: Path to save the HTML visualization. `False`: Visualization is created but not saved. `str` or `Path`: Specifies file path (default: 'results/network.html'). + controls: UI controls to add to the visualization. `True`: Enables all available controls. `List`: Specify controls, e.g., ['nodes', 'layout']. + Options: 'nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'. + You can play with these and generate a Dictionary from it that can be applied to the network returned by this function. + network.set_options() + https://pyvis.readthedocs.io/en/latest/tutorial.html + show: Whether to open the visualization in the web browser. + The calculation must be saved to show it. If no path is given, it defaults to 'network.html'. + Returns: + The `Network` instance representing the visualization, or `None` if `pyvis` is not installed. + + Notes: + - This function requires `pyvis`. If not installed, the function prints a warning and returns `None`. + - Nodes are styled based on type (e.g., circles for buses, boxes for components) and annotated with node information. + """ + try: + from pyvis.network import Network + except ImportError: + logger.critical("Plotting the flow system network was not possible. Please install pyvis: 'pip install pyvis'") + return None + + net = Network(directed=True, height='100%' if controls is False else '800px', font_color='white') + + for node_id, node in node_infos.items(): + net.add_node( + node_id, + label=node['label'], + shape={'Bus': 'circle', 'Component': 'box'}[node['class']], + color={'Bus': '#393E46', 'Component': '#00ADB5'}[node['class']], + title=node['infos'].replace(')', '\n)'), + font={'size': 14}, + ) + + for edge in edge_infos.values(): + net.add_edge( + edge['start'], + edge['end'], + label=edge['label'], + title=edge['infos'].replace(')', '\n)'), + font={'color': '#4D4D4D', 'size': 14}, + color='#222831', + ) + + # Enhanced physics settings + net.barnes_hut(central_gravity=0.8, spring_length=50, spring_strength=0.05, gravity=-10000) + + if controls: + net.show_buttons(filter_=controls) # Adds UI buttons to control physics settings + if not show and not path: + return net + elif path: + path = pathlib.Path(path) if isinstance(path, str) else path + net.write_html(path.as_posix()) + elif show: + path = pathlib.Path('network.html') + net.write_html(path.as_posix()) + + if show: + try: + import webbrowser + + worked = webbrowser.open(f'file://{path.resolve()}', 2) + if not worked: + logger.warning( + f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}' + ) + except Exception as e: + logger.warning( + f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}: {e}' + ) + + +def pie_with_plotly( + data: pd.DataFrame, + colors: ColorType = 'viridis', + title: str = '', + legend_title: str = '', + hole: float = 0.0, + fig: Optional[go.Figure] = None, +) -> go.Figure: + """ + Create a pie chart with Plotly to visualize the proportion of values in a DataFrame. + + Args: + data: A DataFrame containing the data to plot. If multiple rows exist, + they will be summed unless a specific index value is passed. + colors: Color specification, can be: + - A string with a colorscale name (e.g., 'viridis', 'plasma') + - A list of color strings (e.g., ['#ff0000', '#00ff00']) + - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'}) + title: The title of the plot. + legend_title: The title for the legend. + hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). + fig: A Plotly figure object to plot on. If not provided, a new figure will be created. + + Returns: + A Plotly figure object containing the generated pie chart. + + Notes: + - Negative values are not appropriate for pie charts and will be converted to absolute values with a warning. + - If the data contains very small values (less than 1% of the total), they can be grouped into an "Other" category + for better readability. + - By default, the sum of all columns is used for the pie chart. For time series data, consider preprocessing. + + """ + if data.empty: + logger.warning('Empty DataFrame provided for pie chart. Returning empty figure.') + return go.Figure() + + # Create a copy to avoid modifying the original DataFrame + data_copy = data.copy() + + # Check if any negative values and warn + if (data_copy < 0).any().any(): + logger.warning('Negative values detected in data. Using absolute values for pie chart.') + data_copy = data_copy.abs() + + # If data has multiple rows, sum them to get total for each column + if len(data_copy) > 1: + data_sum = data_copy.sum() + else: + data_sum = data_copy.iloc[0] + + # Get labels (column names) and values + labels = data_sum.index.tolist() + values = data_sum.values.tolist() + + # Apply color mapping using the unified color processor + processed_colors = ColorProcessor(engine='plotly').process_colors(colors, list(data.columns)) + + # Create figure if not provided + fig = fig if fig is not None else go.Figure() + + # Add pie trace + fig.add_trace( + go.Pie( + labels=labels, + values=values, + hole=hole, + marker=dict(colors=processed_colors), + textinfo='percent+label+value', + textposition='inside', + insidetextorientation='radial', + ) + ) + + # Update layout for better aesthetics + fig.update_layout( + title=title, + legend_title=legend_title, + plot_bgcolor='rgba(0,0,0,0)', # Transparent background + paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background + font=dict(size=14), # Increase font size for better readability + ) + + return fig + + +def pie_with_matplotlib( + data: pd.DataFrame, + colors: ColorType = 'viridis', + title: str = '', + legend_title: str = 'Categories', + hole: float = 0.0, + figsize: Tuple[int, int] = (10, 8), + fig: Optional[plt.Figure] = None, + ax: Optional[plt.Axes] = None, +) -> Tuple[plt.Figure, plt.Axes]: + """ + Create a pie chart with Matplotlib to visualize the proportion of values in a DataFrame. + + Args: + data: A DataFrame containing the data to plot. If multiple rows exist, + they will be summed unless a specific index value is passed. + colors: Color specification, can be: + - A string with a colormap name (e.g., 'viridis', 'plasma') + - A list of color strings (e.g., ['#ff0000', '#00ff00']) + - A dictionary mapping column names to colors (e.g., {'Column1': '#ff0000'}) + title: The title of the plot. + legend_title: The title for the legend. + hole: Size of the hole in the center for creating a donut chart (0.0 to 1.0). + figsize: The size of the figure (width, height) in inches. + fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created. + ax: A Matplotlib axes object to plot on. If not provided, a new axes will be created. + + Returns: + A tuple containing the Matplotlib figure and axes objects used for the plot. + + Notes: + - Negative values are not appropriate for pie charts and will be converted to absolute values with a warning. + - If the data contains very small values (less than 1% of the total), they can be grouped into an "Other" category + for better readability. + - By default, the sum of all columns is used for the pie chart. For time series data, consider preprocessing. + + """ + if data.empty: + logger.warning('Empty DataFrame provided for pie chart. Returning empty figure.') + if fig is None or ax is None: + fig, ax = plt.subplots(figsize=figsize) + return fig, ax + + # Create a copy to avoid modifying the original DataFrame + data_copy = data.copy() + + # Check if any negative values and warn + if (data_copy < 0).any().any(): + logger.warning('Negative values detected in data. Using absolute values for pie chart.') + data_copy = data_copy.abs() + + # If data has multiple rows, sum them to get total for each column + if len(data_copy) > 1: + data_sum = data_copy.sum() + else: + data_sum = data_copy.iloc[0] + + # Get labels (column names) and values + labels = data_sum.index.tolist() + values = data_sum.values.tolist() + + # Apply color mapping using the unified color processor + processed_colors = ColorProcessor(engine='matplotlib').process_colors(colors, labels) + + # Create figure and axis if not provided + if fig is None or ax is None: + fig, ax = plt.subplots(figsize=figsize) + + # Draw the pie chart + wedges, texts, autotexts = ax.pie( + values, + labels=labels, + colors=processed_colors, + autopct='%1.1f%%', + startangle=90, + shadow=False, + wedgeprops=dict(width=0.5) if hole > 0 else None, # Set width for donut + ) + + # Adjust the wedgeprops to make donut hole size consistent with plotly + # For matplotlib, the hole size is determined by the wedge width + # Convert hole parameter to wedge width + if hole > 0: + # Adjust hole size to match plotly's hole parameter + # In matplotlib, wedge width is relative to the radius (which is 1) + # For plotly, hole is a fraction of the radius + wedge_width = 1 - hole + for wedge in wedges: + wedge.set_width(wedge_width) + + # Customize the appearance + # Make autopct text more visible + for autotext in autotexts: + autotext.set_fontsize(10) + autotext.set_color('white') + + # Set aspect ratio to be equal to ensure a circular pie + ax.set_aspect('equal') + + # Add title + if title: + ax.set_title(title, fontsize=16) + + # Create a legend if there are many segments + if len(labels) > 6: + ax.legend(wedges, labels, title=legend_title, loc='center left', bbox_to_anchor=(1, 0, 0.5, 1)) + + # Apply tight layout + fig.tight_layout() + + return fig, ax + + +def dual_pie_with_plotly( + data_left: pd.Series, + data_right: pd.Series, + colors: ColorType = 'viridis', + title: str = '', + subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'), + legend_title: str = '', + hole: float = 0.2, + lower_percentage_group: float = 5.0, + hover_template: str = '%{label}: %{value} (%{percent})', + text_info: str = 'percent+label', + text_position: str = 'inside', +) -> go.Figure: + """ + Create two pie charts side by side with Plotly, with consistent coloring across both charts. + + Args: + data_left: Series for the left pie chart. + data_right: Series for the right pie chart. + colors: Color specification, can be: + - A string with a colorscale name (e.g., 'viridis', 'plasma') + - A list of color strings (e.g., ['#ff0000', '#00ff00']) + - A dictionary mapping category names to colors (e.g., {'Category1': '#ff0000'}) + title: The main title of the plot. + subtitles: Tuple containing the subtitles for (left, right) charts. + legend_title: The title for the legend. + hole: Size of the hole in the center for creating donut charts (0.0 to 100). + lower_percentage_group: Whether to group small segments (below percentage (0...1)) into an "Other" category. + hover_template: Template for hover text. Use %{label}, %{value}, %{percent}. + text_info: What to show on pie segments: 'label', 'percent', 'value', 'label+percent', + 'label+value', 'percent+value', 'label+percent+value', or 'none'. + text_position: Position of text: 'inside', 'outside', 'auto', or 'none'. + + Returns: + A Plotly figure object containing the generated dual pie chart. + """ + from plotly.subplots import make_subplots + + # Check for empty data + if data_left.empty and data_right.empty: + logger.warning('Both datasets are empty. Returning empty figure.') + return go.Figure() + + # Create a subplot figure + fig = make_subplots( + rows=1, cols=2, specs=[[{'type': 'pie'}, {'type': 'pie'}]], subplot_titles=subtitles, horizontal_spacing=0.05 + ) + + # Process series to handle negative values and apply minimum percentage threshold + def preprocess_series(series: pd.Series): + """ + Preprocess a series for pie chart display by handling negative values + and grouping the smallest parts together if they collectively represent + less than the specified percentage threshold. + + Args: + series: The series to preprocess + + Returns: + A preprocessed pandas Series + """ + # Handle negative values + if (series < 0).any(): + logger.warning('Negative values detected in data. Using absolute values for pie chart.') + series = series.abs() + + # Remove zeros + series = series[series > 0] + + # Apply minimum percentage threshold if needed + if lower_percentage_group and not series.empty: + total = series.sum() + if total > 0: + # Sort series by value (ascending) + sorted_series = series.sort_values() + + # Calculate cumulative percentage contribution + cumulative_percent = (sorted_series.cumsum() / total) * 100 + + # Find entries that collectively make up less than lower_percentage_group + to_group = cumulative_percent <= lower_percentage_group + + if to_group.sum() > 1: + # Create "Other" category for the smallest values that together are < threshold + other_sum = sorted_series[to_group].sum() + + # Keep only values that aren't in the "Other" group + result_series = series[~series.index.isin(sorted_series[to_group].index)] + + # Add the "Other" category if it has a value + if other_sum > 0: + result_series['Other'] = other_sum + + return result_series + + return series + + data_left_processed = preprocess_series(data_left) + data_right_processed = preprocess_series(data_right) + + # Get unique set of all labels for consistent coloring + all_labels = sorted(set(data_left_processed.index) | set(data_right_processed.index)) + + # Get consistent color mapping for both charts using our unified function + color_map = ColorProcessor(engine='plotly').process_colors(colors, all_labels, return_mapping=True) + + # Function to create a pie trace with consistently mapped colors + def create_pie_trace(data_series, side): + if data_series.empty: + return None + + labels = data_series.index.tolist() + values = data_series.values.tolist() + trace_colors = [color_map[label] for label in labels] + + return go.Pie( + labels=labels, + values=values, + name=side, + marker_colors=trace_colors, + hole=hole, + textinfo=text_info, + textposition=text_position, + insidetextorientation='radial', + hovertemplate=hover_template, + sort=True, # Sort values by default (largest first) + ) + + # Add left pie if data exists + left_trace = create_pie_trace(data_left_processed, subtitles[0]) + if left_trace: + left_trace.domain = dict(x=[0, 0.48]) + fig.add_trace(left_trace, row=1, col=1) + + # Add right pie if data exists + right_trace = create_pie_trace(data_right_processed, subtitles[1]) + if right_trace: + right_trace.domain = dict(x=[0.52, 1]) + fig.add_trace(right_trace, row=1, col=2) + + # Update layout + fig.update_layout( + title=title, + legend_title=legend_title, + plot_bgcolor='rgba(0,0,0,0)', # Transparent background + paper_bgcolor='rgba(0,0,0,0)', # Transparent paper background + font=dict(size=14), + margin=dict(t=80, b=50, l=30, r=30), + legend=dict(orientation='h', yanchor='bottom', y=-0.2, xanchor='center', x=0.5, font=dict(size=12)), + ) + + return fig + + +def dual_pie_with_matplotlib( + data_left: pd.Series, + data_right: pd.Series, + colors: ColorType = 'viridis', + title: str = '', + subtitles: Tuple[str, str] = ('Left Chart', 'Right Chart'), + legend_title: str = '', + hole: float = 0.2, + lower_percentage_group: float = 5.0, + figsize: Tuple[int, int] = (14, 7), + fig: Optional[plt.Figure] = None, + axes: Optional[List[plt.Axes]] = None, +) -> Tuple[plt.Figure, List[plt.Axes]]: + """ + Create two pie charts side by side with Matplotlib, with consistent coloring across both charts. + Leverages the existing pie_with_matplotlib function. + + Args: + data_left: Series for the left pie chart. + data_right: Series for the right pie chart. + colors: Color specification, can be: + - A string with a colormap name (e.g., 'viridis', 'plasma') + - A list of color strings (e.g., ['#ff0000', '#00ff00']) + - A dictionary mapping category names to colors (e.g., {'Category1': '#ff0000'}) + title: The main title of the plot. + subtitles: Tuple containing the subtitles for (left, right) charts. + legend_title: The title for the legend. + hole: Size of the hole in the center for creating donut charts (0.0 to 1.0). + lower_percentage_group: Whether to group small segments (below percentage) into an "Other" category. + figsize: The size of the figure (width, height) in inches. + fig: A Matplotlib figure object to plot on. If not provided, a new figure will be created. + axes: A list of Matplotlib axes objects to plot on. If not provided, new axes will be created. + + Returns: + A tuple containing the Matplotlib figure and list of axes objects used for the plot. + """ + # Check for empty data + if data_left.empty and data_right.empty: + logger.warning('Both datasets are empty. Returning empty figure.') + if fig is None: + fig, axes = plt.subplots(1, 2, figsize=figsize) + return fig, axes + + # Create figure and axes if not provided + if fig is None or axes is None: + fig, axes = plt.subplots(1, 2, figsize=figsize) + + # Process series to handle negative values and apply minimum percentage threshold + def preprocess_series(series: pd.Series): + """ + Preprocess a series for pie chart display by handling negative values + and grouping the smallest parts together if they collectively represent + less than the specified percentage threshold. + """ + # Handle negative values + if (series < 0).any(): + logger.warning('Negative values detected in data. Using absolute values for pie chart.') + series = series.abs() + + # Remove zeros + series = series[series > 0] + + # Apply minimum percentage threshold if needed + if lower_percentage_group and not series.empty: + total = series.sum() + if total > 0: + # Sort series by value (ascending) + sorted_series = series.sort_values() + + # Calculate cumulative percentage contribution + cumulative_percent = (sorted_series.cumsum() / total) * 100 + + # Find entries that collectively make up less than lower_percentage_group + to_group = cumulative_percent <= lower_percentage_group + + if to_group.sum() > 1: + # Create "Other" category for the smallest values that together are < threshold + other_sum = sorted_series[to_group].sum() + + # Keep only values that aren't in the "Other" group + result_series = series[~series.index.isin(sorted_series[to_group].index)] + + # Add the "Other" category if it has a value + if other_sum > 0: + result_series['Other'] = other_sum + + return result_series + + return series + + # Preprocess data + data_left_processed = preprocess_series(data_left) + data_right_processed = preprocess_series(data_right) + + # Convert Series to DataFrames for pie_with_matplotlib + df_left = pd.DataFrame(data_left_processed).T if not data_left_processed.empty else pd.DataFrame() + df_right = pd.DataFrame(data_right_processed).T if not data_right_processed.empty else pd.DataFrame() + + # Get unique set of all labels for consistent coloring + all_labels = sorted(set(data_left_processed.index) | set(data_right_processed.index)) + + # Get consistent color mapping for both charts using our unified function + color_map = ColorProcessor(engine='matplotlib').process_colors(colors, all_labels, return_mapping=True) + + # Configure colors for each DataFrame based on the consistent mapping + left_colors = [color_map[col] for col in df_left.columns] if not df_left.empty else [] + right_colors = [color_map[col] for col in df_right.columns] if not df_right.empty else [] + + # Create left pie chart + if not df_left.empty: + pie_with_matplotlib(data=df_left, colors=left_colors, title=subtitles[0], hole=hole, fig=fig, ax=axes[0]) + else: + axes[0].set_title(subtitles[0]) + axes[0].axis('off') + + # Create right pie chart + if not df_right.empty: + pie_with_matplotlib(data=df_right, colors=right_colors, title=subtitles[1], hole=hole, fig=fig, ax=axes[1]) + else: + axes[1].set_title(subtitles[1]) + axes[1].axis('off') + + # Add main title + if title: + fig.suptitle(title, fontsize=16, y=0.98) + + # Adjust layout + fig.tight_layout() + + # Create a unified legend if both charts have data + if not df_left.empty and not df_right.empty: + # Remove individual legends + for ax in axes: + if ax.get_legend(): + ax.get_legend().remove() + + # Create handles for the unified legend + handles = [] + labels_for_legend = [] + + for label in all_labels: + color = color_map[label] + patch = plt.Line2D([0], [0], marker='o', color='w', markerfacecolor=color, markersize=10, label=label) + handles.append(patch) + labels_for_legend.append(label) + + # Add unified legend + fig.legend( + handles=handles, + labels=labels_for_legend, + title=legend_title, + loc='lower center', + bbox_to_anchor=(0.5, 0), + ncol=min(len(all_labels), 5), # Limit columns to 5 for readability + ) + + # Add padding at the bottom for the legend + fig.subplots_adjust(bottom=0.2) + + return fig, axes + + +def export_figure( + figure_like: Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]], + default_path: pathlib.Path, + default_filetype: Optional[str] = None, + user_path: Optional[pathlib.Path] = None, + show: bool = True, + save: bool = False, +) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: + """ + Export a figure to a file and or show it. + + Args: + figure_like: The figure to export. Can be a Plotly figure or a tuple of Matplotlib figure and axes. + default_path: The default file path if no user filename is provided. + default_filetype: The default filetype if the path doesnt end with a filetype. + user_path: An optional user-specified file path. + show: Whether to display the figure (default: True). + save: Whether to save the figure (default: False). + + Raises: + ValueError: If no default filetype is provided and the path doesn't specify a filetype. + TypeError: If the figure type is not supported. + """ + filename = user_path or default_path + filename = filename.with_name(filename.name.replace('|', '__')) + if filename.suffix == '': + if default_filetype is None: + raise ValueError('No default filetype provided') + filename = filename.with_suffix(default_filetype) + + if isinstance(figure_like, plotly.graph_objs.Figure): + fig = figure_like + if not filename.suffix == '.html': + logger.debug(f'To save a plotly figure, the filename should end with ".html". Got {filename}') + if show and not save: + fig.show() + elif save and show: + plotly.offline.plot(fig, filename=str(filename)) + elif save and not show: + fig.write_html(filename) + return figure_like + + elif isinstance(figure_like, tuple): + fig, ax = figure_like + if show: + fig.show() + if save: + fig.savefig(str(filename), dpi=300) + return fig, ax + + raise TypeError(f'Figure type not supported: {type(figure_like)}') diff --git a/flixopt/results.py b/flixopt/results.py new file mode 100644 index 000000000..223e3708e --- /dev/null +++ b/flixopt/results.py @@ -0,0 +1,898 @@ +import datetime +import json +import logging +import pathlib +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union + +import linopy +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import plotly +import xarray as xr +import yaml + +from . import io as fx_io +from . import plotting +from .core import TimeSeriesCollection + +if TYPE_CHECKING: + import pyvis + + from .calculation import Calculation, SegmentedCalculation + + +logger = logging.getLogger('flixopt') + + +class CalculationResults: + """Results container for Calculation results. + + This class is used to collect the results of a Calculation. + It provides access to component, bus, and effect + results, and includes methods for filtering, plotting, and saving results. + + The recommended way to create instances is through the class methods + `from_file()` or `from_calculation()`, rather than direct initialization. + + Attributes: + solution (xr.Dataset): Dataset containing optimization results. + flow_system (xr.Dataset): Dataset containing the flow system. + summary (Dict): Information about the calculation. + name (str): Name identifier for the calculation. + model (linopy.Model): The optimization model (if available). + folder (pathlib.Path): Path to the results directory. + components (Dict[str, ComponentResults]): Results for each component. + buses (Dict[str, BusResults]): Results for each bus. + effects (Dict[str, EffectResults]): Results for each effect. + timesteps_extra (pd.DatetimeIndex): The extended timesteps. + hours_per_timestep (xr.DataArray): Duration of each timestep in hours. + + Example: + Load results from saved files: + + >>> results = CalculationResults.from_file('results_dir', 'optimization_run_1') + >>> element_result = results['Boiler'] + >>> results.plot_heatmap('Boiler(Q_th)|flow_rate') + >>> results.to_file(compression=5) + >>> results.to_file(folder='new_results_dir', compression=5) # Save the results to a new folder + """ + + @classmethod + def from_file(cls, folder: Union[str, pathlib.Path], name: str): + """Create CalculationResults instance by loading from saved files. + + This method loads the calculation results from previously saved files, + including the solution, flow system, model (if available), and metadata. + + Args: + folder: Path to the directory containing the saved files. + name: Base name of the saved files (without file extensions). + + Returns: + CalculationResults: A new instance containing the loaded data. + + Raises: + FileNotFoundError: If required files cannot be found. + ValueError: If files exist but cannot be properly loaded. + """ + folder = pathlib.Path(folder) + paths = fx_io.CalculationResultsPaths(folder, name) + + model = None + if paths.linopy_model.exists(): + try: + logger.info(f'loading the linopy model "{name}" from file ("{paths.linopy_model}")') + model = linopy.read_netcdf(paths.linopy_model) + except Exception as e: + logger.critical(f'Could not load the linopy model "{name}" from file ("{paths.linopy_model}"): {e}') + + with open(paths.summary, 'r', encoding='utf-8') as f: + summary = yaml.load(f, Loader=yaml.FullLoader) + + return cls( + solution=fx_io.load_dataset_from_netcdf(paths.solution), + flow_system=fx_io.load_dataset_from_netcdf(paths.flow_system), + name=name, + folder=folder, + model=model, + summary=summary, + ) + + @classmethod + def from_calculation(cls, calculation: 'Calculation'): + """Create CalculationResults directly from a Calculation object. + + This method extracts the solution, flow system, and other relevant + information directly from an existing Calculation object. + + Args: + calculation: A Calculation object containing a solved model. + + Returns: + CalculationResults: A new instance containing the results from + the provided calculation. + + Raises: + AttributeError: If the calculation doesn't have required attributes. + """ + return cls( + solution=calculation.model.solution, + flow_system=calculation.flow_system.as_dataset(constants_in_dataset=True), + summary=calculation.summary, + model=calculation.model, + name=calculation.name, + folder=calculation.folder, + ) + + def __init__( + self, + solution: xr.Dataset, + flow_system: xr.Dataset, + name: str, + summary: Dict, + folder: Optional[pathlib.Path] = None, + model: Optional[linopy.Model] = None, + ): + """ + Args: + solution: The solution of the optimization. + flow_system: The flow_system that was used to create the calculation as a datatset. + name: The name of the calculation. + summary: Information about the calculation, + folder: The folder where the results are saved. + model: The linopy model that was used to solve the calculation. + """ + self.solution = solution + self.flow_system = flow_system + self.summary = summary + self.name = name + self.model = model + self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' + self.components = { + label: ComponentResults.from_json(self, infos) for label, infos in self.solution.attrs['Components'].items() + } + + self.buses = {label: BusResults.from_json(self, infos) for label, infos in self.solution.attrs['Buses'].items()} + + self.effects = { + label: EffectResults.from_json(self, infos) for label, infos in self.solution.attrs['Effects'].items() + } + + self.timesteps_extra = self.solution.indexes['time'] + self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.timesteps_extra) + + def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults']: + if key in self.components: + return self.components[key] + if key in self.buses: + return self.buses[key] + if key in self.effects: + return self.effects[key] + raise KeyError(f'No element with label {key} found.') + + @property + def storages(self) -> List['ComponentResults']: + """All storages in the results.""" + return [comp for comp in self.components.values() if comp.is_storage] + + @property + def objective(self) -> float: + """The objective result of the optimization.""" + return self.summary['Main Results']['Objective'] + + @property + def variables(self) -> linopy.Variables: + """The variables of the optimization. Only available if the linopy.Model is available.""" + if self.model is None: + raise ValueError('The linopy model is not available.') + return self.model.variables + + @property + def constraints(self) -> linopy.Constraints: + """The constraints of the optimization. Only available if the linopy.Model is available.""" + if self.model is None: + raise ValueError('The linopy model is not available.') + return self.model.constraints + + def filter_solution( + self, variable_dims: Optional[Literal['scalar', 'time']] = None, element: Optional[str] = None + ) -> xr.Dataset: + """ + Filter the solution to a specific variable dimension and element. + If no element is specified, all elements are included. + + Args: + variable_dims: The dimension of the variables to filter for. + element: The element to filter for. + """ + if element is not None: + return filter_dataset(self[element].solution, variable_dims) + return filter_dataset(self.solution, variable_dims) + + def plot_heatmap( + self, + variable_name: str, + heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', + heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', + color_map: str = 'portland', + save: Union[bool, pathlib.Path] = False, + show: bool = True, + engine: plotting.PlottingEngine = 'plotly', + ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: + return plot_heatmap( + dataarray=self.solution[variable_name], + name=variable_name, + folder=self.folder, + heatmap_timeframes=heatmap_timeframes, + heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, + color_map=color_map, + save=save, + show=show, + engine=engine, + ) + + def plot_network( + self, + controls: Union[ + bool, + List[ + Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'] + ], + ] = True, + path: Optional[pathlib.Path] = None, + show: bool = False, + ) -> 'pyvis.network.Network': + """See flixopt.flow_system.FlowSystem.plot_network""" + try: + from .flow_system import FlowSystem + + flow_system = FlowSystem.from_dataset(self.flow_system) + except Exception as e: + logger.critical(f'Could not reconstruct the flow_system from dataset: {e}') + return None + if path is None: + path = self.folder / f'{self.name}--network.html' + return flow_system.plot_network(controls=controls, path=path, show=show) + + def to_file( + self, + folder: Optional[Union[str, pathlib.Path]] = None, + name: Optional[str] = None, + compression: int = 5, + document_model: bool = True, + save_linopy_model: bool = False, + ): + """ + Save the results to a file + Args: + folder: The folder where the results should be saved. Defaults to the folder of the calculation. + name: The name of the results file. If not provided, Defaults to the name of the calculation. + compression: The compression level to use when saving the solution file (0-9). 0 means no compression. + document_model: Wether to document the mathematical formulations in the model. + save_linopy_model: Wether to save the model to file. If True, the (linopy) model is saved as a .nc4 file. + The model file size is rougly 100 times larger than the solution file. + """ + folder = self.folder if folder is None else pathlib.Path(folder) + name = self.name if name is None else name + if not folder.exists(): + try: + folder.mkdir(parents=False) + except FileNotFoundError as e: + raise FileNotFoundError( + f'Folder {folder} and its parent do not exist. Please create them first.' + ) from e + + paths = fx_io.CalculationResultsPaths(folder, name) + + fx_io.save_dataset_to_netcdf(self.solution, paths.solution, compression=compression) + fx_io.save_dataset_to_netcdf(self.flow_system, paths.flow_system, compression=compression) + + with open(paths.summary, 'w', encoding='utf-8') as f: + yaml.dump(self.summary, f, allow_unicode=True, sort_keys=False, indent=4, width=1000) + + if save_linopy_model: + if self.model is None: + logger.critical('No model in the CalculationResults. Saving the model is not possible.') + else: + self.model.to_netcdf(paths.linopy_model) + + if document_model: + if self.model is None: + logger.critical('No model in the CalculationResults. Documenting the model is not possible.') + else: + fx_io.document_linopy_model(self.model, path=paths.model_documentation) + + logger.info(f'Saved calculation results "{name}" to {paths.model_documentation.parent}') + + +class _ElementResults: + @classmethod + def from_json(cls, calculation_results, json_data: Dict) -> '_ElementResults': + return cls(calculation_results, json_data['label'], json_data['variables'], json_data['constraints']) + + def __init__( + self, calculation_results: CalculationResults, label: str, variables: List[str], constraints: List[str] + ): + self._calculation_results = calculation_results + self.label = label + self._variable_names = variables + self._constraint_names = constraints + + self.solution = self._calculation_results.solution[self._variable_names] + + @property + def variables(self) -> linopy.Variables: + """ + Returns the variables of the element. + + Raises: + ValueError: If the linopy model is not availlable. + """ + if self._calculation_results.model is None: + raise ValueError('The linopy model is not available.') + return self._calculation_results.model.variables[self._variable_names] + + @property + def constraints(self) -> linopy.Constraints: + """ + Returns the variables of the element. + + Raises: + ValueError: If the linopy model is not availlable. + """ + if self._calculation_results.model is None: + raise ValueError('The linopy model is not available.') + return self._calculation_results.model.constraints[self._constraint_names] + + def filter_solution(self, variable_dims: Optional[Literal['scalar', 'time']] = None) -> xr.Dataset: + """ + Filter the solution of the element by dimension. + + Args: + variable_dims: The dimension of the variables to filter for. + """ + return filter_dataset(self.solution, variable_dims) + + +class _NodeResults(_ElementResults): + @classmethod + def from_json(cls, calculation_results, json_data: Dict) -> '_NodeResults': + return cls( + calculation_results, + json_data['label'], + json_data['variables'], + json_data['constraints'], + json_data['inputs'], + json_data['outputs'], + ) + + def __init__( + self, + calculation_results: CalculationResults, + label: str, + variables: List[str], + constraints: List[str], + inputs: List[str], + outputs: List[str], + ): + super().__init__(calculation_results, label, variables, constraints) + self.inputs = inputs + self.outputs = outputs + + def plot_node_balance( + self, + save: Union[bool, pathlib.Path] = False, + show: bool = True, + colors: plotting.ColorType = 'viridis', + engine: plotting.PlottingEngine = 'plotly', + ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: + """ + Plots the node balance of the Component or Bus. + Args: + save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location. + show: Whether to show the plot or not. + engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. + """ + if engine == 'plotly': + figure_like = plotting.with_plotly( + self.node_balance(with_last_timestep=True).to_dataframe(), + colors=colors, + mode='area', + title=f'Flow rates of {self.label}', + ) + default_filetype = '.html' + elif engine == 'matplotlib': + figure_like = plotting.with_matplotlib( + self.node_balance(with_last_timestep=True).to_dataframe(), + colors=colors, + mode='bar', + title=f'Flow rates of {self.label}', + ) + default_filetype = '.png' + else: + raise ValueError(f'Engine "{engine}" not supported. Use "plotly" or "matplotlib"') + + return plotting.export_figure( + figure_like=figure_like, + default_path=self._calculation_results.folder / f'{self.label} (flow rates)', + default_filetype=default_filetype, + user_path=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False, + ) + + def plot_node_balance_pie( + self, + lower_percentage_group: float = 5, + colors: plotting.ColorType = 'viridis', + text_info: str = 'percent+label+value', + save: Union[bool, pathlib.Path] = False, + show: bool = True, + engine: plotting.PlottingEngine = 'plotly', + ) -> plotly.graph_objects.Figure: + """ + Plots a pie chart of the flow hours of the inputs and outputs of buses or components. + + Args: + colors: a colorscale or a list of colors to use for the plot + lower_percentage_group: The percentage of flow_hours that is grouped in "Others" (0...100) + text_info: What information to display on the pie plot + save: Whether to save the figure. + show: Whether to show the figure. + engine: Plotting engine to use. Only 'plotly' is implemented atm. + """ + inputs = ( + sanitize_dataset( + ds=self.solution[self.inputs], + threshold=1e-5, + drop_small_vars=True, + zero_small_values=True, + ) + * self._calculation_results.hours_per_timestep + ) + outputs = ( + sanitize_dataset( + ds=self.solution[self.outputs], + threshold=1e-5, + drop_small_vars=True, + zero_small_values=True, + ) + * self._calculation_results.hours_per_timestep + ) + + if engine == 'plotly': + figure_like = plotting.dual_pie_with_plotly( + inputs.to_dataframe().sum(), + outputs.to_dataframe().sum(), + colors=colors, + title=f'Flow hours of {self.label}', + text_info=text_info, + subtitles=('Inputs', 'Outputs'), + legend_title='Flows', + lower_percentage_group=lower_percentage_group, + ) + default_filetype = '.html' + elif engine == 'matplotlib': + logger.debug('Parameter text_info is not supported for matplotlib') + figure_like = plotting.dual_pie_with_matplotlib( + inputs.to_dataframe().sum(), + outputs.to_dataframe().sum(), + colors=colors, + title=f'Total flow hours of {self.label}', + subtitles=('Inputs', 'Outputs'), + legend_title='Flows', + lower_percentage_group=lower_percentage_group, + ) + default_filetype = '.png' + else: + raise ValueError(f'Engine "{engine}" not supported. Use "plotly" or "matplotlib"') + + return plotting.export_figure( + figure_like=figure_like, + default_path=self._calculation_results.folder / f'{self.label} (total flow hours)', + default_filetype=default_filetype, + user_path=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False, + ) + + def node_balance( + self, + negate_inputs: bool = True, + negate_outputs: bool = False, + threshold: Optional[float] = 1e-5, + with_last_timestep: bool = False, + ) -> xr.Dataset: + return sanitize_dataset( + ds=self.solution[self.inputs + self.outputs], + threshold=threshold, + timesteps=self._calculation_results.timesteps_extra if with_last_timestep else None, + negate=( + self.outputs + self.inputs + if negate_outputs and negate_inputs + else self.outputs + if negate_outputs + else self.inputs + if negate_inputs + else None + ), + ) + + +class BusResults(_NodeResults): + """Results for a Bus""" + + +class ComponentResults(_NodeResults): + """Results for a Component""" + + @property + def is_storage(self) -> bool: + return self._charge_state in self._variable_names + + @property + def _charge_state(self) -> str: + return f'{self.label}|charge_state' + + @property + def charge_state(self) -> xr.DataArray: + """Get the solution of the charge state of the Storage.""" + if not self.is_storage: + raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage') + return self.solution[self._charge_state] + + def plot_charge_state( + self, + save: Union[bool, pathlib.Path] = False, + show: bool = True, + colors: plotting.ColorType = 'viridis', + engine: plotting.PlottingEngine = 'plotly', + ) -> plotly.graph_objs.Figure: + """ + Plots the charge state of a Storage. + Args: + save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location. + show: Whether to show the plot or not. + colors: The c + engine: Plotting engine to use. Only 'plotly' is implemented atm. + + Raises: + ValueError: If the Component is not a Storage. + """ + if engine != 'plotly': + raise NotImplementedError( + f'Plotting engine "{engine}" not implemented for ComponentResults.plot_charge_state.' + ) + + if not self.is_storage: + raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') + + fig = plotting.with_plotly( + self.node_balance(with_last_timestep=True).to_dataframe(), + colors=colors, + mode='area', + title=f'Operation Balance of {self.label}', + ) + + # TODO: Use colors for charge state? + + charge_state = self.charge_state.to_dataframe() + fig.add_trace( + plotly.graph_objs.Scatter( + x=charge_state.index, y=charge_state.values.flatten(), mode='lines', name=self._charge_state + ) + ) + + return plotting.export_figure( + fig, + default_path=self._calculation_results.folder / f'{self.label} (charge state)', + default_filetype='.html', + user_path=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False, + ) + + def node_balance_with_charge_state( + self, negate_inputs: bool = True, negate_outputs: bool = False, threshold: Optional[float] = 1e-5 + ) -> xr.Dataset: + """ + Returns a dataset with the node balance of the Storage including its charge state. + Args: + negate_inputs: Whether to negate the inputs of the Storage. + negate_outputs: Whether to negate the outputs of the Storage. + threshold: The threshold for small values. + + Raises: + ValueError: If the Component is not a Storage. + """ + if not self.is_storage: + raise ValueError(f'Cant get charge_state. "{self.label}" is not a storage') + variable_names = self.inputs + self.outputs + [self._charge_state] + return sanitize_dataset( + ds=self.solution[variable_names], + threshold=threshold, + timesteps=self._calculation_results.timesteps_extra, + negate=( + self.outputs + self.inputs + if negate_outputs and negate_inputs + else self.outputs + if negate_outputs + else self.inputs + if negate_inputs + else None + ), + ) + + +class EffectResults(_ElementResults): + """Results for an Effect""" + + def get_shares_from(self, element: str): + """Get the shares from an Element (without subelements) to the Effect""" + return self.solution[[name for name in self._variable_names if name.startswith(f'{element}->')]] + + +class SegmentedCalculationResults: + """ + Class to store the results of a SegmentedCalculation. + """ + + @classmethod + def from_calculation(cls, calculation: 'SegmentedCalculation'): + return cls( + [calc.results for calc in calculation.sub_calculations], + all_timesteps=calculation.all_timesteps, + timesteps_per_segment=calculation.timesteps_per_segment, + overlap_timesteps=calculation.overlap_timesteps, + name=calculation.name, + folder=calculation.folder, + ) + + @classmethod + def from_file(cls, folder: Union[str, pathlib.Path], name: str): + """Create SegmentedCalculationResults directly from file""" + folder = pathlib.Path(folder) + path = folder / name + nc_file = path.with_suffix('.nc4') + logger.info(f'loading calculation "{name}" from file ("{nc_file}")') + with open(path.with_suffix('.json'), 'r', encoding='utf-8') as f: + meta_data = json.load(f) + return cls( + [CalculationResults.from_file(folder, name) for name in meta_data['sub_calculations']], + all_timesteps=pd.DatetimeIndex( + [datetime.datetime.fromisoformat(date) for date in meta_data['all_timesteps']], name='time' + ), + timesteps_per_segment=meta_data['timesteps_per_segment'], + overlap_timesteps=meta_data['overlap_timesteps'], + name=name, + folder=folder, + ) + + def __init__( + self, + segment_results: List[CalculationResults], + all_timesteps: pd.DatetimeIndex, + timesteps_per_segment: int, + overlap_timesteps: int, + name: str, + folder: Optional[pathlib.Path] = None, + ): + self.segment_results = segment_results + self.all_timesteps = all_timesteps + self.timesteps_per_segment = timesteps_per_segment + self.overlap_timesteps = overlap_timesteps + self.name = name + self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' + self.hours_per_timestep = TimeSeriesCollection.calculate_hours_per_timestep(self.all_timesteps) + + @property + def meta_data(self) -> Dict[str, Union[int, List[str]]]: + return { + 'all_timesteps': [datetime.datetime.isoformat(date) for date in self.all_timesteps], + 'timesteps_per_segment': self.timesteps_per_segment, + 'overlap_timesteps': self.overlap_timesteps, + 'sub_calculations': [calc.name for calc in self.segment_results], + } + + @property + def segment_names(self) -> List[str]: + return [segment.name for segment in self.segment_results] + + def solution_without_overlap(self, variable_name: str) -> xr.DataArray: + """Returns the solution of a variable without overlapping timesteps""" + dataarrays = [ + result.solution[variable_name].isel(time=slice(None, self.timesteps_per_segment)) + for result in self.segment_results[:-1] + ] + [self.segment_results[-1].solution[variable_name]] + return xr.concat(dataarrays, dim='time') + + def plot_heatmap( + self, + variable_name: str, + heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', + heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', + color_map: str = 'portland', + save: Union[bool, pathlib.Path] = False, + show: bool = True, + engine: plotting.PlottingEngine = 'plotly', + ) -> Union[plotly.graph_objs.Figure, Tuple[plt.Figure, plt.Axes]]: + """ + Plots a heatmap of the solution of a variable. + + Args: + variable_name: The name of the variable to plot. + heatmap_timeframes: The timeframes to use for the heatmap. + heatmap_timesteps_per_frame: The timesteps per frame to use for the heatmap. + color_map: The color map to use for the heatmap. + save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location. + show: Whether to show the plot or not. + engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. + """ + return plot_heatmap( + dataarray=self.solution_without_overlap(variable_name), + name=variable_name, + folder=self.folder, + heatmap_timeframes=heatmap_timeframes, + heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, + color_map=color_map, + save=save, + show=show, + engine=engine, + ) + + def to_file( + self, folder: Optional[Union[str, pathlib.Path]] = None, name: Optional[str] = None, compression: int = 5 + ): + """Save the results to a file""" + folder = self.folder if folder is None else pathlib.Path(folder) + name = self.name if name is None else name + path = folder / name + if not folder.exists(): + try: + folder.mkdir(parents=False) + except FileNotFoundError as e: + raise FileNotFoundError( + f'Folder {folder} and its parent do not exist. Please create them first.' + ) from e + for segment in self.segment_results: + segment.to_file(folder=folder, name=f'{name}-{segment.name}', compression=compression) + + with open(path.with_suffix('.json'), 'w', encoding='utf-8') as f: + json.dump(self.meta_data, f, indent=4, ensure_ascii=False) + logger.info(f'Saved calculation "{name}" to {path}') + + +def plot_heatmap( + dataarray: xr.DataArray, + name: str, + folder: pathlib.Path, + heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] = 'D', + heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] = 'h', + color_map: str = 'portland', + save: Union[bool, pathlib.Path] = False, + show: bool = True, + engine: plotting.PlottingEngine = 'plotly', +): + """ + Plots a heatmap of the solution of a variable. + + Args: + dataarray: The dataarray to plot. + name: The name of the variable to plot. + folder: The folder to save the plot to. + heatmap_timeframes: The timeframes to use for the heatmap. + heatmap_timesteps_per_frame: The timesteps per frame to use for the heatmap. + color_map: The color map to use for the heatmap. + save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location. + show: Whether to show the plot or not. + engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. + """ + heatmap_data = plotting.heat_map_data_from_df( + dataarray.to_dataframe(name), heatmap_timeframes, heatmap_timesteps_per_frame, 'ffill' + ) + + xlabel, ylabel = f'timeframe [{heatmap_timeframes}]', f'timesteps [{heatmap_timesteps_per_frame}]' + + if engine == 'plotly': + figure_like = plotting.heat_map_plotly( + heatmap_data, title=name, color_map=color_map, xlabel=xlabel, ylabel=ylabel + ) + default_filetype = '.html' + elif engine == 'matplotlib': + figure_like = plotting.heat_map_matplotlib( + heatmap_data, title=name, color_map=color_map, xlabel=xlabel, ylabel=ylabel + ) + default_filetype = '.png' + else: + raise ValueError(f'Engine "{engine}" not supported. Use "plotly" or "matplotlib"') + + return plotting.export_figure( + figure_like=figure_like, + default_path=folder / f'{name} ({heatmap_timeframes}-{heatmap_timesteps_per_frame})', + default_filetype=default_filetype, + user_path=None if isinstance(save, bool) else pathlib.Path(save), + show=show, + save=True if save else False, + ) + + +def sanitize_dataset( + ds: xr.Dataset, + timesteps: Optional[pd.DatetimeIndex] = None, + threshold: Optional[float] = 1e-5, + negate: Optional[List[str]] = None, + drop_small_vars: bool = True, + zero_small_values: bool = False, +) -> xr.Dataset: + """ + Sanitizes a dataset by handling small values (dropping or zeroing) and optionally reindexing the time axis. + + Args: + ds: The dataset to sanitize. + timesteps: The timesteps to reindex the dataset to. If None, the original timesteps are kept. + threshold: The threshold for small values processing. If None, no processing is done. + negate: The variables to negate. If None, no variables are negated. + drop_small_vars: If True, drops variables where all values are below threshold. + zero_small_values: If True, sets values below threshold to zero. + + Returns: + xr.Dataset: The sanitized dataset. + """ + # Create a copy to avoid modifying the original + ds = ds.copy() + + # Step 1: Negate specified variables + if negate is not None: + for var in negate: + if var in ds: + ds[var] = -ds[var] + + # Step 2: Handle small values + if threshold is not None: + ds_no_nan_abs = xr.apply_ufunc(np.abs, ds).fillna(0) # Replace NaN with 0 (below threshold) for the comparison + + # Option 1: Drop variables where all values are below threshold + if drop_small_vars: + vars_to_drop = [var for var in ds.data_vars if (ds_no_nan_abs[var] <= threshold).all()] + ds = ds.drop_vars(vars_to_drop) + + # Option 2: Set small values to zero + if zero_small_values: + for var in ds.data_vars: + # Create a boolean mask of values below threshold + mask = ds_no_nan_abs[var] <= threshold + # Only proceed if there are values to zero out + if mask.any(): + # Create a copy to ensure we don't modify data with views + ds[var] = ds[var].copy() + # Set values below threshold to zero + ds[var] = ds[var].where(~mask, 0) + + # Step 3: Reindex to specified timesteps if needed + if timesteps is not None and not ds.indexes['time'].equals(timesteps): + ds = ds.reindex({'time': timesteps}, fill_value=np.nan) + + return ds + + +def filter_dataset( + ds: xr.Dataset, + variable_dims: Optional[Literal['scalar', 'time']] = None, +) -> xr.Dataset: + """ + Filters a dataset by its dimensions. + + Args: + ds: The dataset to filter. + variable_dims: The dimension of the variables to filter for. + """ + if variable_dims is None: + return ds + + if variable_dims == 'scalar': + return ds[[name for name, da in ds.data_vars.items() if len(da.dims) == 0]] + elif variable_dims == 'time': + return ds[[name for name, da in ds.data_vars.items() if 'time' in da.dims]] + else: + raise ValueError(f'Not allowed value for "filter_dataset()": {variable_dims=}') diff --git a/flixopt/solvers.py b/flixopt/solvers.py new file mode 100644 index 000000000..aaf1641ce --- /dev/null +++ b/flixopt/solvers.py @@ -0,0 +1,77 @@ +""" +This module contains the solvers of the flixopt framework, making them available to the end user in a compact way. +""" + +import logging +from dataclasses import dataclass, field +from typing import Any, ClassVar, Dict, Optional + +logger = logging.getLogger('flixopt') + + +@dataclass +class _Solver: + """ + Abstract base class for solvers. + + Attributes: + mip_gap (float): Solver's mip gap setting. The MIP gap describes the accepted (MILP) objective, + and the lower bound, which is the theoretically optimal solution (LP) + logfile_name (str): Filename for saving the solver log. + """ + + name: ClassVar[str] + mip_gap: float + time_limit_seconds: int + extra_options: Dict[str, Any] = field(default_factory=dict) + + @property + def options(self) -> Dict[str, Any]: + """Return a dictionary of solver options.""" + return {key: value for key, value in {**self._options, **self.extra_options}.items() if value is not None} + + @property + def _options(self) -> Dict[str, Any]: + """Return a dictionary of solver options, translated to the solver's API.""" + raise NotImplementedError + + +class GurobiSolver(_Solver): + """ + Args: + mip_gap (float): Solver's mip gap setting. The MIP gap describes the accepted (MILP) objective, + and the lower bound, which is the theoretically optimal solution (LP) + time_limit_seconds (int): Solver's time limit in seconds. + extra_options (str): Filename for saving the solver log. + """ + + name: ClassVar[str] = 'gurobi' + + @property + def _options(self) -> Dict[str, Any]: + return { + 'MIPGap': self.mip_gap, + 'TimeLimit': self.time_limit_seconds, + } + + +class HighsSolver(_Solver): + """ + Args: + mip_gap (float): Solver's mip gap setting. The MIP gap describes the accepted (MILP) objective, + and the lower bound, which is the theoretically optimal solution (LP) + time_limit_seconds (int): Solver's time limit in seconds. + threads (int): Number of threads to use. + extra_options (str): Filename for saving the solver log. + """ + + threads: Optional[int] = None + name: ClassVar[str] = 'highs' + + @property + def _options(self) -> Dict[str, Any]: + return { + 'mip_rel_gap': self.mip_gap, + 'time_limit': self.time_limit_seconds, + 'threads': self.threads, + } diff --git a/flixopt/structure.py b/flixopt/structure.py new file mode 100644 index 000000000..1d0f2324f --- /dev/null +++ b/flixopt/structure.py @@ -0,0 +1,630 @@ +""" +This module contains the core structure of the flixopt framework. +These classes are not directly used by the end user, but are used by other modules. +""" + +import inspect +import json +import logging +import pathlib +from datetime import datetime +from io import StringIO +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union + +import linopy +import numpy as np +import pandas as pd +import xarray as xr +from rich.console import Console +from rich.pretty import Pretty + +from .config import CONFIG +from .core import NumericData, Scalar, TimeSeries, TimeSeriesCollection, TimeSeriesData + +if TYPE_CHECKING: # for type checking and preventing circular imports + from .effects import EffectCollectionModel + from .flow_system import FlowSystem + +logger = logging.getLogger('flixopt') + + +CLASS_REGISTRY = {} + + +def register_class_for_io(cls): + """Register a class for serialization/deserialization.""" + name = cls.__name__ + if name in CLASS_REGISTRY: + raise ValueError( + f'Class {name} already registered! Use a different Name for the class! ' + f'This error should only happen in developement' + ) + CLASS_REGISTRY[name] = cls + return cls + + +class SystemModel(linopy.Model): + """ + The SystemModel is the linopy Model that is used to create the mathematical model of the flow_system. + It is used to create and store the variables and constraints for the flow_system. + """ + + def __init__(self, flow_system: 'FlowSystem'): + """ + Args: + flow_system: The flow_system that is used to create the model. + """ + super().__init__(force_dim_names=True) + self.flow_system = flow_system + self.time_series_collection = flow_system.time_series_collection + self.effects: Optional[EffectCollectionModel] = None + + 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() + + @property + def solution(self): + solution = super().solution + solution.attrs = { + 'Components': { + comp.label_full: comp.model.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() + for bus in sorted(self.flow_system.buses.values(), key=lambda bus: bus.label_full.upper()) + }, + 'Effects': { + effect.label_full: effect.model.results_structure() + for effect in sorted(self.flow_system.effects, key=lambda effect: effect.label_full.upper()) + }, + } + return solution.reindex(time=self.time_series_collection.timesteps_extra) + + @property + def hours_per_step(self): + return self.time_series_collection.hours_per_timestep + + @property + def hours_of_previous_timesteps(self): + return self.time_series_collection.hours_of_previous_timesteps + + @property + def coords(self) -> Tuple[pd.DatetimeIndex]: + return (self.time_series_collection.timesteps,) + + @property + def coords_extra(self) -> Tuple[pd.DatetimeIndex]: + return (self.time_series_collection.timesteps_extra,) + + +class Interface: + """ + This class is used to collect arguments about a Model. Its the base class for all Elements and Models in flixopt. + """ + + def transform_data(self, flow_system: 'FlowSystem'): + """Transforms the data of the interface to match the FlowSystem's dimensions""" + raise NotImplementedError('Every Interface needs a transform_data() method') + + def infos(self, use_numpy: bool = True, use_element_label: bool = False) -> Dict: + """ + Generate a dictionary representation of the object's constructor arguments. + Excludes default values and empty dictionaries and lists. + Converts data to be compatible with JSON. + + Args: + use_numpy: Whether to convert NumPy arrays to lists. Defaults to True. + If True, numeric numpy arrays (`np.ndarray`) are preserved as-is. + If False, they are converted to lists. + use_element_label: Whether to use the element label instead of the infos of the element. Defaults to False. + Note that Elements used as keys in dictionaries are always converted to their labels. + + Returns: + A dictionary representation of the object's constructor arguments. + + """ + # Get the constructor arguments and their default values + init_params = sorted( + inspect.signature(self.__init__).parameters.items(), + key=lambda x: (x[0].lower() != 'label', x[0].lower()), # Prioritize 'label' + ) + # Build a dict of attribute=value pairs, excluding defaults + details = {'class': ':'.join([cls.__name__ for cls in self.__class__.__mro__])} + for name, param in init_params: + if name == 'self': + continue + value, default = getattr(self, name, None), param.default + # Ignore default values and empty dicts and list + if np.all(value == default) or (isinstance(value, (dict, list)) and not value): + continue + details[name] = copy_and_convert_datatypes(value, use_numpy, use_element_label) + return details + + def to_json(self, path: Union[str, pathlib.Path]): + """ + Saves the element to a json file. + This not meant to be reloaded and recreate the object, but rather used to document or compare the object. + + Args: + path: The path to the json file. + """ + data = get_compact_representation(self.infos(use_numpy=True, use_element_label=True)) + with open(path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=4, ensure_ascii=False) + + def to_dict(self) -> Dict: + """Convert the object to a dictionary representation.""" + data = {'__class__': self.__class__.__name__} + + # Get the constructor parameters + init_params = inspect.signature(self.__init__).parameters + + for name in init_params: + if name == 'self': + continue + + value = getattr(self, name, None) + data[name] = self._serialize_value(value) + + return data + + def _serialize_value(self, value: Any): + """Helper method to serialize a value based on its type.""" + if value is None: + return None + elif isinstance(value, Interface): + return value.to_dict() + elif isinstance(value, (list, tuple)): + return self._serialize_list(value) + elif isinstance(value, dict): + return self._serialize_dict(value) + else: + return value + + def _serialize_list(self, items): + """Serialize a list of items.""" + return [self._serialize_value(item) for item in items] + + def _serialize_dict(self, d): + """Serialize a dictionary of items.""" + return {k: self._serialize_value(v) for k, v in d.items()} + + @classmethod + def _deserialize_dict(cls, data: Dict) -> Union[Dict, 'Interface']: + if '__class__' in data: + class_name = data.pop('__class__') + try: + class_type = CLASS_REGISTRY[class_name] + if issubclass(class_type, Interface): + # Use _deserialize_dict to process the arguments + processed_data = {k: cls._deserialize_value(v) for k, v in data.items()} + return class_type(**processed_data) + else: + raise ValueError(f'Class "{class_name}" is not an Interface.') + except (AttributeError, KeyError) as e: + raise ValueError(f'Class "{class_name}" could not get reconstructed.') from e + else: + return {k: cls._deserialize_value(v) for k, v in data.items()} + + @classmethod + def _deserialize_list(cls, data: List) -> List: + return [cls._deserialize_value(value) for value in data] + + @classmethod + def _deserialize_value(cls, value: Any): + """Helper method to deserialize a value based on its type.""" + if value is None: + return None + elif isinstance(value, dict): + return cls._deserialize_dict(value) + elif isinstance(value, list): + return cls._deserialize_list(value) + return value + + @classmethod + def from_dict(cls, data: Dict) -> 'Interface': + """ + Create an instance from a dictionary representation. + + Args: + data: Dictionary containing the data for the object. + """ + return cls._deserialize_dict(data) + + def __repr__(self): + # Get the constructor arguments and their current values + init_signature = inspect.signature(self.__init__) + init_args = init_signature.parameters + + # Create a dictionary with argument names and their values + args_str = ', '.join(f'{name}={repr(getattr(self, name, None))}' for name in init_args if name != 'self') + return f'{self.__class__.__name__}({args_str})' + + def __str__(self): + return get_str_representation(self.infos(use_numpy=True, use_element_label=True)) + + +class Element(Interface): + """This class is the basic Element of flixopt. Every Element has a label""" + + def __init__(self, label: str, meta_data: Dict = None): + """ + Args: + label: The label of the element + meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + """ + self.label = Element._valid_label(label) + self.meta_data = meta_data if meta_data is not None else {} + self.model: Optional[ElementModel] = None + + def _plausibility_checks(self) -> None: + """This function is used to do some basic plausibility checks for each Element during initialization""" + raise NotImplementedError('Every Element needs a _plausibility_checks() method') + + def create_model(self, model: SystemModel) -> 'ElementModel': + raise NotImplementedError('Every Element needs a create_model() method') + + @property + def label_full(self) -> str: + return self.label + + @staticmethod + def _valid_label(label: str) -> str: + """ + Checks if the label is valid. If not, it is replaced by the default label + + Raises + ------ + ValueError + If the label is not valid + """ + not_allowed = ['(', ')', '|', '->', '\\', '-slash-'] # \\ is needed to check for \ + if any([sign in label for sign in not_allowed]): + raise ValueError( + f'Label "{label}" is not valid. Labels cannot contain the following characters: {not_allowed}. ' + f'Use any other symbol instead' + ) + if label.endswith(' '): + logger.warning(f'Label "{label}" ends with a space. This will be removed.') + return label.rstrip() + return label + + +class Model: + """Stores Variables and Constraints.""" + + def __init__( + self, model: SystemModel, label_of_element: str, label: str = '', label_full: Optional[str] = None + ): + """ + Args: + model: The SystemModel 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. + """ + 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 + + 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 filter_variables( + self, + filter_by: Optional[Literal['binary', 'continuous', 'integer']] = None, + length: Literal['scalar', 'time'] = None, + ): + if filter_by is None: + all_variables = self.variables + elif filter_by == 'binary': + all_variables = self.variables.binaries + elif filter_by == 'integer': + all_variables = self.variables.integers + elif filter_by == 'continuous': + all_variables = self.variables.continuous + else: + raise ValueError(f'Invalid filter_by "{filter_by}", must be one of "binary", "continous", "integer"') + if length is None: + return all_variables + elif length == 'scalar': + return all_variables[[name for name in all_variables if all_variables[name].ndim == 0]] + elif length == 'time': + 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 + + @property + def variables_direct(self) -> linopy.Variables: + return self._model.variables[self._variables_direct] + + @property + def constraints_direct(self) -> linopy.Constraints: + return self._model.constraints[self._constraints_direct] + + @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 + + @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 + + @property + def variables(self) -> linopy.Variables: + return self._model.variables[self._variables] + + @property + def constraints(self) -> linopy.Constraints: + return self._model.constraints[self._constraints] + + @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] + + +class ElementModel(Model): + """Stores the mathematical Variables and Constraints for Elements""" + + def __init__(self, model: SystemModel, element: Element): + """ + Args: + model: The SystemModel 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 + + def results_structure(self): + return { + 'label': self.label, + 'label_full': self.label_full, + 'variables': list(self.variables), + 'constraints': list(self.constraints), + } + + +def copy_and_convert_datatypes(data: Any, use_numpy: bool = True, use_element_label: bool = False) -> Any: + """ + Converts values in a nested data structure into JSON-compatible types while preserving or transforming numpy arrays + and custom `Element` objects based on the specified options. + + The function handles various data types and transforms them into a consistent, readable format: + - Primitive types (`int`, `float`, `str`, `bool`, `None`) are returned as-is. + - Numpy scalars are converted to their corresponding Python scalar types. + - Collections (`list`, `tuple`, `set`, `dict`) are recursively processed to ensure all elements are compatible. + - Numpy arrays are preserved or converted to lists, depending on `use_numpy`. + - Custom `Element` objects can be represented either by their `label` or their initialization parameters as a dictionary. + - Timestamps (`datetime`) are converted to ISO 8601 strings. + + Args: + data: The input data to process, which may be deeply nested and contain a mix of types. + use_numpy: If `True`, numeric numpy arrays (`np.ndarray`) are preserved as-is. If `False`, they are converted to lists. + Default is `True`. + use_element_label: If `True`, `Element` objects are represented by their `label`. If `False`, they are converted into a dictionary + based on their initialization parameters. Default is `False`. + + Returns: + A transformed version of the input data, containing only JSON-compatible types: + - `int`, `float`, `str`, `bool`, `None` + - `list`, `dict` + - `np.ndarray` (if `use_numpy=True`. This is NOT JSON-compatible) + + Raises: + TypeError: If the data cannot be converted to the specified types. + + Examples: + >>> copy_and_convert_datatypes({'a': np.array([1, 2, 3]), 'b': Element(label='example')}) + {'a': array([1, 2, 3]), 'b': {'class': 'Element', 'label': 'example'}} + + >>> copy_and_convert_datatypes({'a': np.array([1, 2, 3]), 'b': Element(label='example')}, use_numpy=False) + {'a': [1, 2, 3], 'b': {'class': 'Element', 'label': 'example'}} + + Notes: + - The function gracefully handles unexpected types by issuing a warning and returning a deep copy of the data. + - Empty collections (lists, dictionaries) and default parameter values in `Element` objects are omitted from the output. + - Numpy arrays with non-numeric data types are automatically converted to lists. + """ + if isinstance(data, np.integer): # This must be checked before checking for regular int and float! + return int(data) + elif isinstance(data, np.floating): + return float(data) + + elif isinstance(data, (int, float, str, bool, type(None))): + return data + elif isinstance(data, datetime): + return data.isoformat() + + elif isinstance(data, (tuple, set)): + return copy_and_convert_datatypes([item for item in data], use_numpy, use_element_label) + elif isinstance(data, dict): + return { + copy_and_convert_datatypes(key, use_numpy, use_element_label=True): copy_and_convert_datatypes( + value, use_numpy, use_element_label + ) + for key, value in data.items() + } + elif isinstance(data, list): # Shorten arrays/lists to be readable + if use_numpy and all([isinstance(value, (int, float)) for value in data]): + return np.array([item for item in data]) + else: + return [copy_and_convert_datatypes(item, use_numpy, use_element_label) for item in data] + + elif isinstance(data, np.ndarray): + if not use_numpy: + return copy_and_convert_datatypes(data.tolist(), use_numpy, use_element_label) + elif use_numpy and np.issubdtype(data.dtype, np.number): + return data + else: + logger.critical( + f'An np.array with non-numeric content was found: {data=}.It will be converted to a list instead' + ) + return copy_and_convert_datatypes(data.tolist(), use_numpy, use_element_label) + + elif isinstance(data, TimeSeries): + return copy_and_convert_datatypes(data.active_data, use_numpy, use_element_label) + elif isinstance(data, TimeSeriesData): + return copy_and_convert_datatypes(data.data, use_numpy, use_element_label) + + elif isinstance(data, Interface): + if use_element_label and isinstance(data, Element): + return data.label + return data.infos(use_numpy, use_element_label) + elif isinstance(data, xr.DataArray): + # TODO: This is a temporary basic work around + return copy_and_convert_datatypes(data.values, use_numpy, use_element_label) + else: + raise TypeError(f'copy_and_convert_datatypes() did get unexpected data of type "{type(data)}": {data=}') + + +def get_compact_representation(data: Any, array_threshold: int = 50, decimals: int = 2) -> Dict: + """ + Generate a compact json serializable representation of deeply nested data. + Numpy arrays are statistically described if they exceed a threshold and converted to lists. + + Args: + data (Any): The data to format and represent. + array_threshold (int): Maximum length of NumPy arrays to display. Longer arrays are statistically described. + decimals (int): Number of decimal places in which to describe the arrays. + + Returns: + Dict: A dictionary representation of the data + """ + + def format_np_array_if_found(value: Any) -> Any: + """Recursively processes the data, formatting NumPy arrays.""" + if isinstance(value, (int, float, str, bool, type(None))): + return value + elif isinstance(value, np.ndarray): + return describe_numpy_arrays(value) + elif isinstance(value, dict): + return {format_np_array_if_found(k): format_np_array_if_found(v) for k, v in value.items()} + elif isinstance(value, (list, tuple, set)): + return [format_np_array_if_found(v) for v in value] + else: + logger.warning( + f'Unexpected value found when trying to format numpy array numpy array: {type(value)=}; {value=}' + ) + return value + + def describe_numpy_arrays(arr: np.ndarray) -> Union[str, List]: + """Shortens NumPy arrays if they exceed the specified length.""" + + def normalized_center_of_mass(array: Any) -> float: + # position in array (0 bis 1 normiert) + positions = np.linspace(0, 1, len(array)) # weights w_i + # mass center + if np.sum(array) == 0: + return np.nan + else: + return np.sum(positions * array) / np.sum(array) + + if arr.size > array_threshold: # Calculate basic statistics + fmt = f'.{decimals}f' + return ( + f'Array (min={np.min(arr):{fmt}}, max={np.max(arr):{fmt}}, mean={np.mean(arr):{fmt}}, ' + f'median={np.median(arr):{fmt}}, std={np.std(arr):{fmt}}, len={len(arr)}, ' + f'center={normalized_center_of_mass(arr):{fmt}})' + ) + else: + return np.around(arr, decimals=decimals).tolist() + + # Process the data to handle NumPy arrays + formatted_data = format_np_array_if_found(copy_and_convert_datatypes(data, use_numpy=True)) + + return formatted_data + + +def get_str_representation(data: Any, array_threshold: int = 50, decimals: int = 2) -> str: + """ + Generate a string representation of deeply nested data using `rich.print`. + NumPy arrays are shortened to the specified length and converted to strings. + + Args: + data (Any): The data to format and represent. + array_threshold (int): Maximum length of NumPy arrays to display. Longer arrays are statistically described. + decimals (int): Number of decimal places in which to describe the arrays. + + Returns: + str: The formatted string representation of the data. + """ + + formatted_data = get_compact_representation(data, array_threshold, decimals) + + # Use Rich to format and print the data + with StringIO() as output_buffer: + console = Console(file=output_buffer, width=1000) # Adjust width as needed + console.print(Pretty(formatted_data, expand_all=True, indent_guides=True)) + return output_buffer.getvalue() diff --git a/flixopt/utils.py b/flixopt/utils.py new file mode 100644 index 000000000..bb6e8ec40 --- /dev/null +++ b/flixopt/utils.py @@ -0,0 +1,62 @@ +""" +This module contains several utility functions used throughout the flixopt framework. +""" + +import logging +from typing import Any, Dict, List, Literal, Optional, Union + +import numpy as np +import xarray as xr + +logger = logging.getLogger('flixopt') + + +def is_number(number_alias: Union[int, float, str]): + """Returns True is string is a number.""" + try: + float(number_alias) + return True + except ValueError: + return False + + +def round_floats(obj, decimals=2): + if isinstance(obj, dict): + return {k: round_floats(v, decimals) for k, v in obj.items()} + elif isinstance(obj, list): + return [round_floats(v, decimals) for v in obj] + elif isinstance(obj, float): + return round(obj, decimals) + return obj + + +def convert_dataarray( + data: xr.DataArray, mode: Literal['py', 'numpy', 'xarray', 'structure'] +) -> Union[List, np.ndarray, xr.DataArray, str]: + """ + Convert a DataArray to a different format. + + Args: + data: The DataArray to convert. + mode: The mode to convert to. + - 'py': Convert to python native types (for json) + - 'numpy': Convert to numpy array + - 'xarray': Convert to xarray.DataArray + - 'structure': Convert to strings (for structure, storing variable names) + + Returns: + The converted data. + + Raises: + ValueError: If the mode is unknown. + """ + if mode == 'numpy': + return data.values + elif mode == 'py': + return data.values.tolist() + elif mode == 'xarray': + return data + elif mode == 'structure': + return f':::{data.name}' + else: + raise ValueError(f'Unknown mode {mode}') diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 000000000..ebf4ac0d9 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,136 @@ +# Options: +# https://mkdocstrings.github.io/python/usage/configuration/docstrings/ +# https://squidfunk.github.io/mkdocs-material/setup/ + +site_name: flixopt +site_description: Energy and Material Flow Optimization Framework +site_url: https://flixopt.github.io/flixopt/ +repo_url: https://github.com/flixOpt/flixopt +repo_name: flixOpt/flixopt + + +theme: + name: material + palette: + # Light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: teal + accent: blue + toggle: + icon: material/brightness-7 + name: Switch to dark mode + # Dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: teal # Can be different from light mode + accent: blue + toggle: + icon: material/brightness-4 + name: Switch to light mode + logo: images/flixopt-icon.svg + favicon: images/flixopt-icon.svg + icon: + repo: fontawesome/brands/github + features: + - navigation.instant + - navigation.instant.progress + - navigation.tracking + - navigation.tabs + - navigation.sections + - navigation.top + - navigation.footer + - toc.follow + - navigation.indexes + - search.suggest + - search.highlight + - content.action.edit + - content.action.view + - content.code.copy + - content.code.annotate + - content.tooltips + - content.code.copy + - navigation.footer.version + +markdown_extensions: + - admonition + - codehilite + - markdown_include.include: + base_path: docs + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - attr_list + - abbr + - md_in_html + - footnotes + - tables + - pymdownx.tabbed: + alternate_style: true + - pymdownx.arithmatex: + generic: true + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.snippets: + base_path: .. + + +plugins: + - search # Enables the search functionality in the documentation + - table-reader # Allows including tables from external files + - include-markdown + - mike + - gen-files: + scripts: + - scripts/gen_ref_pages.py + - literate-nav: + nav_file: SUMMARY.md + implicit_index: true # This makes index.md the default landing page + - mkdocstrings: # Handles automatic API documentation generation + default_handler: python # Sets Python as the default language + handlers: + python: # Configuration for Python code documentation + options: + docstring_style: google # Sets google as the docstring style + modernize_annotations: true # Improves type annotations + merge_init_into_class: true # Promotes constructor parameters to class-level documentation + docstring_section_style: table # Renders parameter sections as a table (also: list, spacy) + + members_order: source # Orders members as they appear in the source code + inherited_members: false # Include members inherited from parent classes + show_if_no_docstring: false # Documents objects even if they don't have docstrings + + group_by_category: true + heading_level: 1 # Sets the base heading level for documented objects + line_length: 80 + filters: ["!^_", "^__init__$"] + show_root_heading: true # whether the documented object's name should be displayed as a heading at the beginning of its documentation + show_source: false # Shows the source code implementation from documentation + show_object_full_path: false # Displays simple class names instead of full import paths + show_docstring_attributes: true # Shows class attributes in the documentation + show_category_heading: true # Displays category headings (Methods, Attributes, etc.) for organization + show_signature: true # Shows method signatures with parameters + show_signature_annotations: true # Includes type annotations in the signatures when available + show_root_toc_entry: false # Whether to show a link to the root of the documentation in the sidebar + separate_signature: true # Displays signatures separate from descriptions for cleaner layout + + extra: + infer_type_annotations: true # Uses Python type hints to supplement docstring information + +extra: + version: + provider: mike + default: latest + +extra_javascript: + - javascripts/mathjax.js # Custom MathJax 3 CDN Configuration + - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js #MathJax 3 CDN + - https://polyfill.io/v3/polyfill.min.js?features=es6 #Support for older browsers + +watch: + - flixopt \ No newline at end of file diff --git a/pics/architecture_flixOpt-pre2.0.0.png b/pics/architecture_flixOpt-pre2.0.0.png new file mode 100644 index 000000000..1469a9de8 Binary files /dev/null and b/pics/architecture_flixOpt-pre2.0.0.png differ diff --git a/pics/architecture_flixOpt.png b/pics/architecture_flixOpt.png index 1469a9de8..d4d775f69 100644 Binary files a/pics/architecture_flixOpt.png and b/pics/architecture_flixOpt.png differ diff --git a/pics/flixopt-icon.svg b/pics/flixopt-icon.svg new file mode 100644 index 000000000..04a6a6851 --- /dev/null +++ b/pics/flixopt-icon.svg @@ -0,0 +1 @@ +flixOpt \ No newline at end of file diff --git a/pics/pics.pptx b/pics/pics.pptx index f2b752fb6..c6b41df46 100644 Binary files a/pics/pics.pptx and b/pics/pics.pptx differ diff --git a/pyproject.toml b/pyproject.toml index 36e99c42f..f3b48273f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,11 +3,11 @@ requires = ["setuptools>=61.0.0", "wheel", "setuptools_scm[toml]>=6.2"] build-backend = "setuptools.build_meta" [project] -name = "flixOpt" +name = "flixopt" dynamic = ["version"] description = "Vector based energy and material flow optimization framework in Python." readme = "README.md" -requires-python = ">=3.10, <3.13" +requires-python = ">=3.10" license = { text = "MIT License" } authors = [ { name = "Chair of Building Energy Systems and Heat Supply, TU Dresden", email = "peter.stange@tu-dresden.de" }, @@ -26,15 +26,17 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Intended Audience :: Developers", "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering", "License :: OSI Approved :: MIT License", ] dependencies = [ - "numpy >= 1.21.5, < 2", + "numpy >= 1.21.5", "PyYAML >= 6.0", - "Pyomo >= 6.4.2", + "linopy >= 0.5.1", + "netcdf4 >= 1.6.1", "rich >= 13.0.1", "highspy >= 1.5.3", # Default solver "pandas >= 2, < 3", # Used in post-processing @@ -49,23 +51,42 @@ dev = [ "ruff", "pyvis == 0.3.1", # Used for visualizing the FLowSystem "tsam >= 2.3.1", # Used for time series aggregation + "scipy >= 1.15.1", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 + "gurobipy >= 10.0", ] full = [ "pyvis == 0.3.1", # Used for visualizing the FLowSystem "tsam >= 2.3.1", # Used for time series aggregation + "scipy >= 1.15.1", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 + "streamlit >= 1.44.0", + "gurobipy >= 10.0", +] + +docs = [ + "mkdocs-material>=9.0.0", + "mkdocstrings-python", + "mkdocs-table-reader-plugin", + "mkdocs-gen-files", + "mkdocs-include-markdown-plugin", + "mkdocs-literate-nav", + "markdown-include", + "pymdown-extensions", + "pygments", + "mike", ] [project.urls] homepage = "https://tu-dresden.de/ing/maschinenwesen/iet/gewv/forschung/forschungsprojekte/flixopt" repository = "https://github.com/flixOpt/flixopt" +documentation = "https://flixopt.github.io/flixopt/" [tool.setuptools.packages.find] where = ["."] exclude = ["tests", "docs", "examples", "examples.*", "Tutorials", ".git", ".vscode", "build", ".venv", "venv/"] [tool.setuptools.package-data] -"flixOpt" = ["config.yaml"] +"flixopt" = ["config.yaml"] [tool.setuptools_scm] version_scheme = "post-release" @@ -88,8 +109,6 @@ select = ["E", "F", "W", "I", "B", "N"] # Enable linting rules by category (e.g ignore = [ # Ignore specific rules "E501", # Ignore line-length checks (use Black for formatting) "F401", # Allow unused imports in some cases (use __all__) - "N813", # Allow importing of flixOpt as fx (lowercase) - "N999", # Allow module Name flixOpt ] extend-fixable = ["B"] # Enable fix for flake8-bugbear (`B`), on top of any rules specified by `fixable`. @@ -97,7 +116,7 @@ extend-fixable = ["B"] # Enable fix for flake8-bugbear (`B`), on top of any ru [tool.ruff.lint.per-file-ignores] "tests/*.py" = ["S101"] # Ignore assertions in test files "tests/test_integration.py" = ["N806"] # Ignore NOT lowercase names in test files -"flixOpt/linear_converters.py" = ["N803"] # Parameters with NOT lowercase names +"flixopt/linear_converters.py" = ["N803"] # Parameters with NOT lowercase names [tool.ruff.format] quote-style = "single" diff --git a/scripts/gen_ref_pages.py b/scripts/gen_ref_pages.py new file mode 100644 index 000000000..13894d10a --- /dev/null +++ b/scripts/gen_ref_pages.py @@ -0,0 +1,54 @@ +"""Generate the code reference pages and navigation.""" + +import sys +from pathlib import Path + +import mkdocs_gen_files + +# Add the project root to sys.path to ensure modules can be imported +root = Path(__file__).parent.parent +sys.path.insert(0, str(root)) + +nav = mkdocs_gen_files.Nav() + +src = root / 'flixopt' +api_dir = 'api-reference' + +for path in sorted(src.rglob('*.py')): + module_path = path.relative_to(src).with_suffix('') + doc_path = path.relative_to(src).with_suffix('.md') + full_doc_path = Path(api_dir, doc_path) + + parts = tuple(module_path.parts) + + if parts[-1] == '__init__': + parts = parts[:-1] + if not parts: + continue # Skip the root __init__.py + doc_path = doc_path.with_name('index.md') + full_doc_path = full_doc_path.with_name('index.md') + elif parts[-1] == '__main__' or parts[-1].startswith('_'): + continue + + # Only add to navigation if there are actual parts + if parts: + nav[parts] = doc_path.as_posix() + + # Generate documentation file - always using the flixopt prefix + with mkdocs_gen_files.open(full_doc_path, 'w') as fd: + # Use 'flixopt.' prefix for all module references + module_id = 'flixopt.' + '.'.join(parts) + fd.write(f'::: {module_id}\n options:\n inherited_members: true\n') + + mkdocs_gen_files.set_edit_path(full_doc_path, path.relative_to(root)) + +# Create an index file for the API reference +with mkdocs_gen_files.open(f'{api_dir}/index.md', 'w') as index_file: + index_file.write('# API Reference\n\n') + index_file.write( + 'This section contains the documentation for all modules and classes in flixopt.\n' + 'For more information on how to use the classes and functions, see the [Concepts & Math](../concepts-and-math/index.md) section.\n' + ) + +with mkdocs_gen_files.open(f'{api_dir}/SUMMARY.md', 'w') as nav_file: + nav_file.writelines(nav.build_literate_nav()) diff --git a/site/release-notes/_template.txt b/site/release-notes/_template.txt new file mode 100644 index 000000000..fe85a0554 --- /dev/null +++ b/site/release-notes/_template.txt @@ -0,0 +1,32 @@ +# Release v{version} + +**Release Date:** YYYY-MM-DD + +## What's New + +* Feature 1 - Description +* Feature 2 - Description + +## Improvements + +* Improvement 1 - Description +* Improvement 2 - Description + +## Bug Fixes + +* Fixed issue with X +* Resolved problem with Y + +## Breaking Changes + +* Change 1 - Migration instructions +* Change 2 - Migration instructions + +## Deprecations + +* Feature X will be removed in v{next_version} + +## Dependencies + +* Added dependency X v1.2.3 +* Updated dependency Y to v2.0.0 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..5399be72a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,499 @@ +""" +The conftest.py file is used by pytest to define shared fixtures, hooks, and configuration +that apply to multiple test files without needing explicit imports. +It helps avoid redundancy and centralizes reusable test logic. +""" + +import os + +import linopy.testing +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +import flixopt as fx +from flixopt.structure import SystemModel + + +@pytest.fixture() +def highs_solver(): + return fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=300) + + +@pytest.fixture() +def gurobi_solver(): + return fx.solvers.GurobiSolver(mip_gap=0, time_limit_seconds=300) + + +@pytest.fixture(params=[highs_solver, gurobi_solver]) +def solver_fixture(request): + return request.getfixturevalue(request.param.__name__) + + +# Custom assertion function +def assert_almost_equal_numeric( + actual, desired, err_msg, relative_error_range_in_percent=0.011, absolute_tolerance=1e-9 +): + """ + Custom assertion function for comparing numeric values with relative and absolute tolerances + """ + relative_tol = relative_error_range_in_percent / 100 + + if isinstance(desired, (int, float)): + delta = abs(relative_tol * desired) if desired != 0 else absolute_tolerance + assert np.isclose(actual, desired, atol=delta), err_msg + else: + np.testing.assert_allclose(actual, desired, rtol=relative_tol, atol=absolute_tolerance, err_msg=err_msg) + + +@pytest.fixture +def simple_flow_system() -> fx.FlowSystem: + """ + Create a simple energy system for testing + """ + base_thermal_load = np.array([30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20]) + base_electrical_price = 1 / 1000 * np.array([80.0, 80.0, 80.0, 80, 80, 80, 80, 80, 80]) + base_timesteps = pd.date_range('2020-01-01', periods=9, freq='h', name='time') + # Define effects + costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) + co2 = fx.Effect( + 'CO2', + 'kg', + 'CO2_e-Emissionen', + specific_share_to_other_effects_operation={costs.label: 0.2}, + maximum_operation_per_hour=1000, + ) + + # Create components + boiler = fx.linear_converters.Boiler( + 'Boiler', + eta=0.5, + Q_th=fx.Flow( + 'Q_th', + bus='FernwƤrme', + size=50, + relative_minimum=5 / 50, + relative_maximum=1, + on_off_parameters=fx.OnOffParameters(), + ), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + ) + + chp = fx.linear_converters.CHP( + 'CHP_unit', + eta_th=0.5, + eta_el=0.4, + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, on_off_parameters=fx.OnOffParameters()), + Q_th=fx.Flow('Q_th', bus='FernwƤrme'), + Q_fu=fx.Flow('Q_fu', bus='Gas'), + ) + + storage = fx.Storage( + 'Speicher', + charging=fx.Flow('Q_th_load', bus='FernwƤrme', size=1e4), + discharging=fx.Flow('Q_th_unload', bus='FernwƤrme', size=1e4), + capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), + initial_charge_state=0, + relative_maximum_charge_state=1 / 100 * np.array([80.0, 70.0, 80.0, 80, 80, 80, 80, 80, 80, 80]), + eta_charge=0.9, + eta_discharge=1, + relative_loss_per_hour=0.08, + prevent_simultaneous_charge_and_discharge=True, + ) + + heat_load = fx.Sink( + 'WƤrmelast', sink=fx.Flow('Q_th_Last', bus='FernwƤrme', size=1, fixed_relative_profile=base_thermal_load) + ) + + gas_tariff = fx.Source( + 'Gastarif', source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3}) + ) + + electricity_feed_in = fx.Sink( + 'Einspeisung', sink=fx.Flow('P_el', bus='Strom', effects_per_flow_hour=-1 * base_electrical_price) + ) + + # Create flow system + flow_system = fx.FlowSystem(base_timesteps) + flow_system.add_elements(fx.Bus('Strom'), fx.Bus('FernwƤrme'), fx.Bus('Gas')) + flow_system.add_elements(storage, costs, co2, boiler, heat_load, gas_tariff, electricity_feed_in, chp) + + return flow_system + + +@pytest.fixture +def basic_flow_system() -> fx.FlowSystem: + """Create basic elements for component testing""" + flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=10, freq='h', name='time')) + thermal_load = np.array([np.random.random() for _ in range(10)]) * 180 + p_el = (np.array([np.random.random() for _ in range(10)]) + 0.5) / 1.5 * 50 + + flow_system.add_elements( + fx.Bus('Strom'), + fx.Bus('FernwƤrme'), + fx.Bus('Gas'), + fx.Effect('Costs', '€', 'Kosten', is_standard=True, is_objective=True), + fx.Sink('WƤrmelast', sink=fx.Flow('Q_th_Last', 'FernwƤrme', size=1, fixed_relative_profile=thermal_load)), + fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour=0.04)), + fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * p_el)), + ) + + return flow_system + + +@pytest.fixture +def flow_system_complex() -> fx.FlowSystem: + """ + Helper method to create a base model with configurable parameters + """ + thermal_load = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) + electrical_load = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) + flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=9, freq='h', name='time')) + # Define the components and flow_system + flow_system.add_elements( + fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), + fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={'costs': 0.2}), + fx.Effect('PE', 'kWh_PE', 'PrimƤrenergie', maximum_total=3.5e3), + fx.Bus('Strom'), + fx.Bus('FernwƤrme'), + fx.Bus('Gas'), + fx.Sink('WƤrmelast', sink=fx.Flow('Q_th_Last', 'FernwƤrme', size=1, fixed_relative_profile=thermal_load)), + fx.Source( + 'Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3}) + ), + fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * electrical_load)), + ) + + boiler = fx.linear_converters.Boiler( + 'Kessel', + eta=0.5, + on_off_parameters=fx.OnOffParameters(effects_per_running_hour={'costs': 0, 'CO2': 1000}), + Q_th=fx.Flow( + 'Q_th', + bus='FernwƤrme', + load_factor_max=1.0, + load_factor_min=0.1, + relative_minimum=5 / 50, + relative_maximum=1, + previous_flow_rate=50, + size=fx.InvestParameters( + fix_effects=1000, fixed_size=50, optional=False, specific_effects={'costs': 10, 'PE': 2} + ), + on_off_parameters=fx.OnOffParameters( + on_hours_total_min=0, + on_hours_total_max=1000, + consecutive_on_hours_max=10, + consecutive_on_hours_min=1, + consecutive_off_hours_max=10, + effects_per_switch_on=0.01, + switch_on_total_max=1000, + ), + flow_hours_total_max=1e6, + ), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), + ) + + invest_speicher = fx.InvestParameters( + fix_effects=0, + piecewise_effects=fx.PiecewiseEffects( + piecewise_origin=fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), + piecewise_shares={ + 'costs': fx.Piecewise([fx.Piece(50, 250), fx.Piece(250, 800)]), + 'PE': fx.Piecewise([fx.Piece(5, 25), fx.Piece(25, 100)]), + }, + ), + optional=False, + specific_effects={'costs': 0.01, 'CO2': 0.01}, + minimum_size=0, + maximum_size=1000, + ) + speicher = fx.Storage( + 'Speicher', + charging=fx.Flow('Q_th_load', bus='FernwƤrme', size=1e4), + discharging=fx.Flow('Q_th_unload', bus='FernwƤrme', size=1e4), + capacity_in_flow_hours=invest_speicher, + initial_charge_state=0, + maximal_final_charge_state=10, + eta_charge=0.9, + eta_discharge=1, + relative_loss_per_hour=0.08, + prevent_simultaneous_charge_and_discharge=True, + ) + + flow_system.add_elements(boiler, speicher) + + return flow_system + + +@pytest.fixture +def flow_system_base(flow_system_complex) -> fx.FlowSystem: + """ + Helper method to create a base model with configurable parameters + """ + flow_system = flow_system_complex + + flow_system.add_elements( + fx.linear_converters.CHP( + 'KWK', + eta_th=0.5, + eta_el=0.4, + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, previous_flow_rate=10), + Q_th=fx.Flow('Q_th', bus='FernwƤrme', size=1e3), + Q_fu=fx.Flow('Q_fu', bus='Gas', size=1e3), + ) + ) + + return flow_system + + +@pytest.fixture +def flow_system_piecewise_conversion(flow_system_complex) -> fx.FlowSystem: + flow_system = flow_system_complex + + flow_system.add_elements( + fx.LinearConverter( + 'KWK', + inputs=[fx.Flow('Q_fu', bus='Gas')], + outputs=[ + fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), + fx.Flow('Q_th', bus='FernwƤrme'), + ], + piecewise_conversion=fx.PiecewiseConversion( + { + 'P_el': fx.Piecewise([fx.Piece(5, 30), fx.Piece(40, 60)]), + 'Q_th': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), + 'Q_fu': fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), + } + ), + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + ) + ) + + return flow_system + + +@pytest.fixture +def flow_system_segments_of_flows_2(flow_system_complex) -> fx.FlowSystem: + """ + Use segments/Piecewise with numeric data + """ + flow_system = flow_system_complex + + flow_system.add_elements( + fx.LinearConverter( + 'KWK', + inputs=[fx.Flow('Q_fu', bus='Gas')], + outputs=[ + fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), + fx.Flow('Q_th', bus='FernwƤrme'), + ], + piecewise_conversion=fx.PiecewiseConversion( + { + 'P_el': fx.Piecewise( + [ + fx.Piece(np.linspace(5, 6, len(flow_system.time_series_collection.timesteps)), 30), + fx.Piece(40, np.linspace(60, 70, len(flow_system.time_series_collection.timesteps))), + ] + ), + 'Q_th': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), + 'Q_fu': fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), + } + ), + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + ) + ) + + return flow_system + + +@pytest.fixture +def flow_system_long(): + """ + Fixture to create and return the flow system with loaded data + """ + # Load data + filename = os.path.join(os.path.dirname(__file__), 'ressources', 'Zeitreihen2020.csv') + ts_raw = pd.read_csv(filename, index_col=0).sort_index() + data = ts_raw['2020-01-01 00:00:00':'2020-12-31 23:45:00']['2020-01-01':'2020-01-03 23:45:00'] + + # Extract data columns + electrical_load = data['P_Netz/MW'].values + thermal_load = data['Q_Netz/MW'].values + p_el = data['Strompr.€/MWh'].values + gas_price = data['Gaspr.€/MWh'].values + + thermal_load_ts, electrical_load_ts = ( + fx.TimeSeriesData(thermal_load), + fx.TimeSeriesData(electrical_load, agg_weight=0.7), + ) + p_feed_in, p_sell = ( + fx.TimeSeriesData(-(p_el - 0.5), agg_group='p_el'), + fx.TimeSeriesData(p_el + 0.5, agg_group='p_el'), + ) + + flow_system = fx.FlowSystem(pd.DatetimeIndex(data.index)) + flow_system.add_elements( + fx.Bus('Strom'), + fx.Bus('FernwƤrme'), + fx.Bus('Gas'), + fx.Bus('Kohle'), + fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), + fx.Effect('CO2', 'kg', 'CO2_e-Emissionen'), + fx.Effect('PE', 'kWh_PE', 'PrimƤrenergie'), + fx.Sink( + 'WƤrmelast', sink=fx.Flow('Q_th_Last', bus='FernwƤrme', size=1, fixed_relative_profile=thermal_load_ts) + ), + fx.Sink('Stromlast', sink=fx.Flow('P_el_Last', bus='Strom', size=1, fixed_relative_profile=electrical_load_ts)), + fx.Source( + 'Kohletarif', + source=fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={'costs': 4.6, 'CO2': 0.3}), + ), + fx.Source( + 'Gastarif', + source=fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': gas_price, 'CO2': 0.3}), + ), + fx.Sink('Einspeisung', sink=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour=p_feed_in)), + fx.Source( + 'Stromtarif', + source=fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={'costs': p_sell, 'CO2': 0.3}), + ), + ) + + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Kessel', + eta=0.85, + Q_th=fx.Flow(label='Q_th', bus='FernwƤrme'), + Q_fu=fx.Flow( + label='Q_fu', + bus='Gas', + size=95, + relative_minimum=12 / 95, + previous_flow_rate=0, + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=1000), + ), + ), + fx.linear_converters.CHP( + 'BHKW2', + eta_th=0.58, + eta_el=0.22, + on_off_parameters=fx.OnOffParameters(effects_per_switch_on=24000), + P_el=fx.Flow('P_el', bus='Strom'), + Q_th=fx.Flow('Q_th', bus='FernwƤrme'), + Q_fu=fx.Flow('Q_fu', bus='Kohle', size=288, relative_minimum=87 / 288), + ), + fx.Storage( + 'Speicher', + charging=fx.Flow('Q_th_load', size=137, bus='FernwƤrme'), + discharging=fx.Flow('Q_th_unload', size=158, bus='FernwƤrme'), + capacity_in_flow_hours=684, + initial_charge_state=137, + minimal_final_charge_state=137, + maximal_final_charge_state=158, + eta_charge=1, + eta_discharge=1, + relative_loss_per_hour=0.001, + prevent_simultaneous_charge_and_discharge=True, + ), + ) + + # Return all the necessary data + return flow_system, { + 'thermal_load_ts': thermal_load_ts, + 'electrical_load_ts': electrical_load_ts, + } + + +def create_calculation_and_solve(flow_system: fx.FlowSystem, solver, name: str, allow_infeasible: bool=False) -> fx.FullCalculation: + calculation = fx.FullCalculation(name, flow_system) + calculation.do_modeling() + try: + calculation.solve(solver) + except RuntimeError as e: + if allow_infeasible: + pass + else: + raise RuntimeError from e + return calculation + + +def create_linopy_model(flow_system: fx.FlowSystem) -> SystemModel: + calculation = fx.FullCalculation('GenericName', flow_system) + calculation.do_modeling() + return calculation.model + +@pytest.fixture(params=['h', '3h']) +def timesteps_linopy(request): + return pd.date_range('2020-01-01', periods=10, freq=request.param, name='time') + + +@pytest.fixture +def basic_flow_system_linopy(timesteps_linopy) -> fx.FlowSystem: + """Create basic elements for component testing""" + flow_system = fx.FlowSystem(timesteps_linopy) + thermal_load = np.array([np.random.random() for _ in range(10)]) * 180 + p_el = (np.array([np.random.random() for _ in range(10)]) + 0.5) / 1.5 * 50 + + flow_system.add_elements( + fx.Bus('Strom'), + fx.Bus('FernwƤrme'), + fx.Bus('Gas'), + fx.Effect('Costs', '€', 'Kosten', is_standard=True, is_objective=True), + fx.Sink('WƤrmelast', sink=fx.Flow('Q_th_Last', 'FernwƤrme', size=1, fixed_relative_profile=thermal_load)), + fx.Source('Gastarif', source=fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour=0.04)), + fx.Sink('Einspeisung', sink=fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * p_el)), + ) + + return flow_system + +def assert_conequal(actual: linopy.Constraint, desired: linopy.Constraint): + """Assert that two constraints are equal with detailed error messages.""" + name = actual.name + + try: + linopy.testing.assert_linequal(actual.lhs, desired.lhs) + except AssertionError as e: + raise AssertionError(f"{name} left-hand sides don't match:\n{e}") from e + + try: + linopy.testing.assert_linequal(actual.rhs, desired.rhs) + except AssertionError as e: + raise AssertionError(f"{name} right-hand sides don't match:\n{e}") from e + + try: + xr.testing.assert_equal(actual.sign, desired.sign) + except AssertionError as e: + raise AssertionError(f"{name} signs don't match:\nActual: {actual.sign}\nExpected: {desired.sign}") from e + + +def assert_var_equal(actual: linopy.Variable, desired: linopy.Variable): + """Assert that two variables are equal with detailed error messages.""" + name = actual.name + try: + xr.testing.assert_equal(actual.lower, desired.lower) + except AssertionError as e: + raise AssertionError(f"{name} lower bounds don't match:\nActual: {actual.lower}\nExpected: {desired.lower}") from e + + try: + xr.testing.assert_equal(actual.upper, desired.upper) + except AssertionError as e: + raise AssertionError(f"{name} upper bounds don't match:\nActual: {actual.upper}\nExpected: {desired.upper}") from e + + if actual.type != desired.type: + raise AssertionError(f"{name} types don't match: {actual.type} != {desired.type}") + + if actual.size != desired.size: + raise AssertionError(f"{name} sizes don't match: {actual.size} != {desired.size}") + + if actual.shape != desired.shape: + raise AssertionError(f"{name} shapes don't match: {actual.shape} != {desired.shape}") + + try: + xr.testing.assert_equal(actual.coords, desired.coords) + except AssertionError as e: + raise AssertionError(f"{name} coordinates don't match:\nActual: {actual.coords}\nExpected: {desired.coords}") from e + + if actual.coord_dims != desired.coord_dims: + raise AssertionError(f"{name} coordinate dimensions don't match: {actual.coord_dims} != {desired.coord_dims}") diff --git a/tests/run_all_tests.py b/tests/run_all_tests.py index c625e9f9b..5597a47f3 100644 --- a/tests/run_all_tests.py +++ b/tests/run_all_tests.py @@ -7,4 +7,4 @@ import pytest if __name__ == '__main__': - pytest.main(['-v', '--disable-warnings']) + pytest.main(['test_functional.py', '--disable-warnings']) diff --git a/tests/test_bus.py b/tests/test_bus.py new file mode 100644 index 000000000..4a41a9f9e --- /dev/null +++ b/tests/test_bus.py @@ -0,0 +1,58 @@ +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +import flixopt as fx + +from .conftest import assert_conequal, assert_var_equal, create_linopy_model + + +class TestBusModel: + """Test the FlowModel class.""" + + def test_bus(self, basic_flow_system_linopy): + """Test that flow model constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + bus = fx.Bus('TestBus', excess_penalty_per_flow_hour=None) + flow_system.add_elements(bus, + fx.Sink('WƤrmelastTest', sink=fx.Flow('Q_th_Last', 'TestBus')), + 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_conequal( + model.constraints['TestBus|balance'], + model.variables['GastarifTest(Q_Gas)|flow_rate'] == model.variables['WƤrmelastTest(Q_th_Last)|flow_rate'] + ) + + def test_bus_penalty(self, basic_flow_system_linopy): + """Test that flow model constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + bus = fx.Bus('TestBus') + flow_system.add_elements(bus, + fx.Sink('WƤrmelastTest', sink=fx.Flow('Q_th_Last', 'TestBus')), + fx.Source('GastarifTest', source=fx.Flow('Q_Gas', 'TestBus'))) + model = create_linopy_model(flow_system) + + assert set(bus.model.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_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,))) + + assert_conequal( + model.constraints['TestBus|balance'], + model.variables['GastarifTest(Q_Gas)|flow_rate'] - model.variables['WƤrmelastTest(Q_th_Last)|flow_rate'] + model.variables['TestBus|excess_input'] - model.variables['TestBus|excess_output'] == 0 + ) + + assert_conequal( + model.constraints['TestBus->Penalty'], + model.variables['TestBus->Penalty'] == (model.variables['TestBus|excess_input'] * 1e5 * model.hours_per_step).sum() + (model.variables['TestBus|excess_output'] * 1e5 * model.hours_per_step).sum(), + ) diff --git a/tests/test_component.py b/tests/test_component.py new file mode 100644 index 000000000..d87a28c29 --- /dev/null +++ b/tests/test_component.py @@ -0,0 +1,184 @@ +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +import flixopt as fx +import flixopt.elements + +from .conftest import assert_conequal, assert_var_equal, create_linopy_model + + +class TestComponentModel: + + def test_flow_label_check(self, basic_flow_system_linopy): + """Test that flow model constraints are correctly generated.""" + _ = basic_flow_system_linopy + inputs = [ + fx.Flow('Q_th_Last', 'FernwƤrme', relative_minimum=np.ones(10) * 0.1), + fx.Flow('Q_Gas', 'FernwƤrme', relative_minimum=np.ones(10) * 0.1) + ] + outputs = [ + fx.Flow('Q_th_Last', 'Gas', relative_minimum=np.ones(10) * 0.01), + fx.Flow('Q_Gas', 'Gas', relative_minimum=np.ones(10) * 0.01) + ] + with pytest.raises(ValueError, match='Flow names must be unique!'): + _ = flixopt.elements.Component('TestComponent', inputs=inputs, outputs=outputs) + + def test_component(self, basic_flow_system_linopy): + """Test that flow model constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + inputs = [ + fx.Flow('In1', 'FernwƤrme', relative_minimum=np.ones(10) * 0.1), + fx.Flow('In2', 'FernwƤrme', relative_minimum=np.ones(10) * 0.1) + ] + outputs = [ + fx.Flow('Out1', 'Gas', relative_minimum=np.ones(10) * 0.01), + fx.Flow('Out2', 'Gas', relative_minimum=np.ones(10) * 0.01) + ] + comp = flixopt.elements.Component('TestComponent', inputs=inputs, outputs=outputs) + flow_system.add_elements(comp) + _ = create_linopy_model(flow_system) + + assert {'TestComponent(In1)|flow_rate', + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In2)|flow_rate', + 'TestComponent(In2)|total_flow_hours', + 'TestComponent(Out1)|flow_rate', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out2)|flow_rate', + 'TestComponent(Out2)|total_flow_hours'} == set(comp.model.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) + + def test_on_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.time_series_collection.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), + ] + outputs = [ + fx.Flow('Out1', 'Gas', relative_minimum=np.ones(10) * 0.2, size=200), + fx.Flow('Out2', 'Gas', relative_minimum=np.ones(10) * 0.3, + relative_maximum = ub_out2, size=300), + ] + 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 { + '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', + } == set(comp.model.variables) + + assert { + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In1)|on_con1', + 'TestComponent(In1)|on_con2', + 'TestComponent(In1)|on_hours_total', + 'TestComponent(Out1)|total_flow_hours', + 'TestComponent(Out1)|on_con1', + 'TestComponent(Out1)|on_con2', + 'TestComponent(Out1)|on_hours_total', + 'TestComponent(Out2)|total_flow_hours', + 'TestComponent(Out2)|on_con1', + 'TestComponent(Out2)|on_con2', + 'TestComponent(Out2)|on_hours_total', + 'TestComponent|on_con1', + 'TestComponent|on_con2', + '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|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.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + inputs = [ + fx.Flow('In1', 'FernwƤrme', relative_minimum=np.ones(10) * 0.1, size=100), + ] + outputs = [] + 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 { + '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 { + 'TestComponent(In1)|total_flow_hours', + 'TestComponent(In1)|on_con1', + 'TestComponent(In1)|on_con2', + 'TestComponent(In1)|on_hours_total', + 'TestComponent|on_con1', + 'TestComponent|on_con2', + '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,)) + ) + assert_var_equal(model['TestComponent|on'], model.add_variables(binary=True, coords=(timesteps,))) + 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'], + ) + assert_conequal( + model.constraints['TestComponent(In1)|on_con2'], + model.variables['TestComponent(In1)|on'] * 100 >= model.variables['TestComponent(In1)|flow_rate'], + ) + + assert_conequal( + model.constraints['TestComponent|on_con1'], + model.variables['TestComponent|on'] * 0.1 * 100 <= model.variables['TestComponent(In1)|flow_rate'], + ) + assert_conequal( + model.constraints['TestComponent|on_con2'], + model.variables['TestComponent|on'] * 100 >= model.variables['TestComponent(In1)|flow_rate'], + ) + diff --git a/tests/test_dataconverter.py b/tests/test_dataconverter.py new file mode 100644 index 000000000..49f1438e7 --- /dev/null +++ b/tests/test_dataconverter.py @@ -0,0 +1,113 @@ +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +from flixopt.core import ConversionError, DataConverter # Adjust this import to match your project structure + + +@pytest.fixture +def sample_time_index(request): + index = pd.date_range('2024-01-01', periods=5, freq='D', name='time') + return index + + +def test_scalar_conversion(sample_time_index): + # Test scalar conversion + result = DataConverter.as_dataarray(42, sample_time_index) + assert isinstance(result, xr.DataArray) + assert result.shape == (len(sample_time_index),) + assert result.dims == ('time',) + assert np.all(result.values == 42) + + +def test_series_conversion(sample_time_index): + series = pd.Series([1, 2, 3, 4, 5], index=sample_time_index) + + # Test Series conversion + result = DataConverter.as_dataarray(series, sample_time_index) + assert isinstance(result, xr.DataArray) + assert result.shape == (5,) + assert result.dims == ('time',) + assert np.array_equal(result.values, series.values) + + +def test_dataframe_conversion(sample_time_index): + # Create a single-column DataFrame + df = pd.DataFrame({'A': [1, 2, 3, 4, 5]}, index=sample_time_index) + + # Test DataFrame conversion + result = DataConverter.as_dataarray(df, sample_time_index) + assert isinstance(result, xr.DataArray) + assert result.shape == (5,) + assert result.dims == ('time',) + assert np.array_equal(result.values.flatten(), df['A'].values) + + +def test_ndarray_conversion(sample_time_index): + # Test 1D array conversion + arr_1d = np.array([1, 2, 3, 4, 5]) + result = DataConverter.as_dataarray(arr_1d, sample_time_index) + assert result.shape == (5,) + assert result.dims == ('time',) + assert np.array_equal(result.values, arr_1d) + + +def test_dataarray_conversion(sample_time_index): + # Create a DataArray + original = xr.DataArray(data=np.array([1, 2, 3, 4, 5]), coords={'time': sample_time_index}, dims=['time']) + + # Test DataArray conversion + result = DataConverter.as_dataarray(original, sample_time_index) + assert result.shape == (5,) + assert result.dims == ('time',) + assert np.array_equal(result.values, original.values) + + # Ensure it's a copy + result[0] = 999 + assert original[0].item() == 1 # Original should be unchanged + + +def test_invalid_inputs(sample_time_index): + # Test invalid input type + with pytest.raises(ConversionError): + DataConverter.as_dataarray('invalid_string', sample_time_index) + + # Test mismatched Series index + mismatched_series = pd.Series([1, 2, 3, 4, 5, 6], index=pd.date_range('2025-01-01', periods=6, freq='D')) + with pytest.raises(ConversionError): + DataConverter.as_dataarray(mismatched_series, sample_time_index) + + # Test DataFrame with multiple columns + df_multi_col = pd.DataFrame({'A': [1, 2, 3, 4, 5], 'B': [6, 7, 8, 9, 10]}, index=sample_time_index) + with pytest.raises(ConversionError): + DataConverter.as_dataarray(df_multi_col, sample_time_index) + + # Test mismatched array shape + with pytest.raises(ConversionError): + DataConverter.as_dataarray(np.array([1, 2, 3]), sample_time_index) # Wrong length + + # Test multi-dimensional array + with pytest.raises(ConversionError): + DataConverter.as_dataarray(np.array([[1, 2], [3, 4]]), sample_time_index) # 2D array not allowed + + +def test_time_index_validation(): + # Test with unnamed index + unnamed_index = pd.date_range('2024-01-01', periods=5, freq='D') + with pytest.raises(ConversionError): + DataConverter.as_dataarray(42, unnamed_index) + + # Test with empty index + empty_index = pd.DatetimeIndex([], name='time') + with pytest.raises(ValueError): + DataConverter.as_dataarray(42, empty_index) + + # Test with non-DatetimeIndex + wrong_type_index = pd.Index([1, 2, 3, 4, 5], name='time') + with pytest.raises(ValueError): + DataConverter.as_dataarray(42, wrong_type_index) + + +if __name__ == '__main__': + pytest.main() diff --git a/tests/test_effect.py b/tests/test_effect.py new file mode 100644 index 000000000..5cbc04ac6 --- /dev/null +++ b/tests/test_effect.py @@ -0,0 +1,142 @@ +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +import flixopt as fx + +from .conftest import assert_conequal, assert_var_equal, create_linopy_model + + +class TestBusModel: + """Test the FlowModel class.""" + + def test_minimal(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + effect = fx.Effect('Effect1', '€', 'Testing Effect') + + flow_system.add_elements(effect) + model = create_linopy_model(flow_system) + + assert set(effect.model.variables) == {'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total',} + assert set(effect.model.constraints) == {'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total',} + + assert_var_equal(model.variables['Effect1|total'], model.add_variables()) + assert_var_equal(model.variables['Effect1(invest)|total'], model.add_variables()) + assert_var_equal(model.variables['Effect1(operation)|total'], model.add_variables()) + assert_var_equal(model.variables['Effect1(operation)|total_per_timestep'], model.add_variables(coords=(timesteps,))) + + assert_conequal(model.constraints['Effect1|total'], + model.variables['Effect1|total'] == model.variables['Effect1(operation)|total'] + model.variables['Effect1(invest)|total']) + assert_conequal(model.constraints['Effect1(invest)|total'], model.variables['Effect1(invest)|total'] == 0) + assert_conequal(model.constraints['Effect1(operation)|total'], + model.variables['Effect1(operation)|total'] == model.variables['Effect1(operation)|total_per_timestep'].sum()) + assert_conequal(model.constraints['Effect1(operation)|total_per_timestep'], + model.variables['Effect1(operation)|total_per_timestep'] ==0) + + def test_bounds(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + effect = fx.Effect('Effect1', '€', 'Testing Effect', + minimum_operation=1.0, + maximum_operation=1.1, + minimum_invest=2.0, + maximum_invest=2.1, + minimum_total=3.0, + maximum_total=3.1, + minimum_operation_per_hour=4.0, + maximum_operation_per_hour=4.1 + ) + + flow_system.add_elements(effect) + model = create_linopy_model(flow_system) + + assert set(effect.model.variables) == {'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total',} + assert set(effect.model.constraints) == {'Effect1(invest)|total', + 'Effect1(operation)|total', + 'Effect1(operation)|total_per_timestep', + 'Effect1|total',} + + assert_var_equal(model.variables['Effect1|total'], model.add_variables(lower=3.0, upper=3.1)) + assert_var_equal(model.variables['Effect1(invest)|total'], model.add_variables(lower=2.0, upper=2.1)) + assert_var_equal(model.variables['Effect1(operation)|total'], model.add_variables(lower=1.0, upper=1.1)) + assert_var_equal( + model.variables['Effect1(operation)|total_per_timestep'], model.add_variables( + lower=4.0 * model.hours_per_step, upper=4.1* model.hours_per_step, coords=(timesteps,)) + ) + + assert_conequal(model.constraints['Effect1|total'], + model.variables['Effect1|total'] == model.variables['Effect1(operation)|total'] + model.variables['Effect1(invest)|total']) + assert_conequal(model.constraints['Effect1(invest)|total'], model.variables['Effect1(invest)|total'] == 0) + assert_conequal(model.constraints['Effect1(operation)|total'], + model.variables['Effect1(operation)|total'] == model.variables['Effect1(operation)|total_per_timestep'].sum()) + assert_conequal(model.constraints['Effect1(operation)|total_per_timestep'], + model.variables['Effect1(operation)|total_per_timestep'] ==0) + + def test_shares(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + effect1 = fx.Effect('Effect1', '€', 'Testing Effect', + specific_share_to_other_effects_operation={ + 'Effect2': 1.1, + 'Effect3': 1.2 + }, + specific_share_to_other_effects_invest={ + 'Effect2': 2.1, + 'Effect3': 2.2 + } + ) + effect2 = fx.Effect('Effect2', '€', 'Testing Effect') + effect3 = fx.Effect('Effect3', '€', 'Testing Effect') + flow_system.add_elements(effect1, effect2, effect3) + model = create_linopy_model(flow_system) + + assert set(effect2.model.variables) == { + 'Effect2(invest)|total', + 'Effect2(operation)|total', + 'Effect2(operation)|total_per_timestep', + 'Effect2|total', + 'Effect1(invest)->Effect2(invest)', + 'Effect1(operation)->Effect2(operation)', + } + assert set(effect2.model.constraints) == { + 'Effect2(invest)|total', + 'Effect2(operation)|total', + 'Effect2(operation)|total_per_timestep', + 'Effect2|total', + 'Effect1(invest)->Effect2(invest)', + 'Effect1(operation)->Effect2(operation)', + } + + assert_conequal( + model.constraints['Effect2(invest)|total'], + model.variables['Effect2(invest)|total'] == model.variables['Effect1(invest)->Effect2(invest)'], + ) + + assert_conequal( + model.constraints['Effect2(operation)|total_per_timestep'], + model.variables['Effect2(operation)|total_per_timestep'] == model.variables['Effect1(operation)->Effect2(operation)'], + ) + + assert_conequal( + model.constraints['Effect1(operation)->Effect2(operation)'], + model.variables['Effect1(operation)->Effect2(operation)'] + == model.variables['Effect1(operation)|total_per_timestep'] * 1.1 + ) + + assert_conequal( + model.constraints['Effect1(invest)->Effect2(invest)'], + model.variables['Effect1(invest)->Effect2(invest)'] + == model.variables['Effect1(invest)|total'] * 2.1, + ) + + diff --git a/tests/test_examples.py b/tests/test_examples.py index 85f87d4cc..ad2846679 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -16,6 +16,7 @@ ), # Sort by parent and script name ids=lambda path: str(path.relative_to(EXAMPLES_DIR)), # Show relative file paths ) +@pytest.mark.slow def test_example_scripts(example_script): """ Test all example scripts in the examples directory. diff --git a/tests/test_flow.py b/tests/test_flow.py new file mode 100644 index 000000000..2308dbd31 --- /dev/null +++ b/tests/test_flow.py @@ -0,0 +1,1132 @@ +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +import flixopt as fx + +from .conftest import assert_conequal, assert_var_equal, create_linopy_model + + +class TestFlowModel: + """Test the FlowModel class.""" + + def test_flow_minimal(self, basic_flow_system_linopy): + """Test that flow model constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + flow = fx.Flow('WƤrme', bus='FernwƤrme', size=100) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + + model = create_linopy_model(flow_system) + + 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() + ) + assert_var_equal(flow.model.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 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']) + + def test_flow(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=100, + relative_minimum=np.linspace(0, 0.5, timesteps.size), + relative_maximum=np.linspace(0.5, 1, timesteps.size), + flow_hours_total_max=1000, + flow_hours_total_min=10, + load_factor_min=0.1, + load_factor_max=0.9, + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + # 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(), + ) + + assert_var_equal( + flow.model.total_flow_hours, + model.add_variables(lower=10, upper=1000) + ) + + assert_var_equal( + flow.model.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,)) + ) + + assert_conequal( + model.constraints['Sink(WƤrme)|load_factor_min'], + flow.model.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'] + <= 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']) + + def test_effects_per_flow_hour(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + costs_per_flow_hour = xr.DataArray(np.linspace(1,2,timesteps.size), coords=(timesteps,)) + co2_per_flow_hour = xr.DataArray(np.linspace(4, 5, timesteps.size), coords=(timesteps,)) + + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + effects_per_flow_hour={'Costs': costs_per_flow_hour, 'CO2': co2_per_flow_hour} + ) + flow_system.add_elements(fx.Sink('Sink', sink=flow), fx.Effect('CO2', 't', '')) + 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 'Sink(WƤrme)->Costs(operation)' in set(costs.model.constraints) + assert 'Sink(WƤrme)->CO2(operation)' in set(co2.model.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) + + 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) + + +class TestFlowInvestModel: + """Test the FlowModel class.""" + + def test_flow_invest(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=fx.InvestParameters(minimum_size=20, maximum_size=100, optional=False), + relative_minimum=np.linspace(0.1, 0.5, timesteps.size), + relative_maximum=np.linspace(0.5, 1, timesteps.size), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == set( + [ + 'Sink(WƤrme)|total_flow_hours', + 'Sink(WƤrme)|flow_rate', + 'Sink(WƤrme)|size', + ] + ) + assert set(flow.model.constraints) == set( + [ + 'Sink(WƤrme)|total_flow_hours', + 'Sink(WƤrme)|lb_Sink(WƤrme)|flow_rate', + 'Sink(WƤrme)|ub_Sink(WƤrme)|flow_rate', + ] + ) + + # size + assert_var_equal(model['Sink(WƤrme)|size'], model.add_variables(lower=20, upper=100)) + + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=np.linspace(0.1, 0.5, timesteps.size) * 20, + upper=np.linspace(0.5, 1, timesteps.size) * 100, + coords=(timesteps,), + ), + ) + 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'] + * 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'] + * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), + ) + + def test_flow_invest_optional(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=fx.InvestParameters(minimum_size=20, maximum_size=100, optional=True), + relative_minimum=np.linspace(0.1, 0.5, timesteps.size), + relative_maximum=np.linspace(0.5, 1, timesteps.size), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.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( + [ + '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', + ] + ) + + assert_var_equal(model['Sink(WƤrme)|size'], model.add_variables(lower=0, upper=100)) + + assert_var_equal(model['Sink(WƤrme)|is_invested'], model.add_variables(binary=True)) + + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=0, # Optional investment + upper=np.linspace(0.5, 1, timesteps.size) * 100, + coords=(timesteps,), + ), + ) + 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'] + * 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'] + * 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, + ) + 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, + ) + + def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=fx.InvestParameters(maximum_size=100, optional=True), + relative_minimum=np.linspace(0.1, 0.5, timesteps.size), + relative_maximum=np.linspace(0.5, 1, timesteps.size), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.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( + [ + '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', + ] + ) + + assert_var_equal(model['Sink(WƤrme)|size'], model.add_variables(lower=0, upper=100)) + + assert_var_equal(model['Sink(WƤrme)|is_invested'], model.add_variables(binary=True)) + + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=0, # Optional investment + upper=np.linspace(0.5, 1, timesteps.size) * 100, + coords=(timesteps,), + ), + ) + 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'] + * 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'] + * 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, + ) + 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, + ) + + def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=fx.InvestParameters(maximum_size=100, optional=False), + relative_minimum=np.linspace(0.1, 0.5, timesteps.size), + relative_maximum=np.linspace(0.5, 1, timesteps.size), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == set( + ['Sink(WƤrme)|total_flow_hours', 'Sink(WƤrme)|flow_rate', 'Sink(WƤrme)|size'] + ) + assert set(flow.model.constraints) == set( + [ + 'Sink(WƤrme)|total_flow_hours', + 'Sink(WƤrme)|lb_Sink(WƤrme)|flow_rate', + 'Sink(WƤrme)|ub_Sink(WƤrme)|flow_rate', + ] + ) + + assert_var_equal(model['Sink(WƤrme)|size'], model.add_variables(lower=1e-5, upper=100)) + + # flow_rate + assert_var_equal( + flow.model.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, + coords=(timesteps,), + ), + ) + 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'] + * 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'] + * xr.DataArray(np.linspace(0.5, 1, timesteps.size), coords=(timesteps,)), + ) + + def test_flow_invest_fixed_size(self, basic_flow_system_linopy): + """Test flow with fixed size investment.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=fx.InvestParameters(fixed_size=75, optional=False), + relative_minimum=0.2, + relative_maximum=0.9, + ) + + 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'} + + # Check that size is fixed to 75 + assert_var_equal(flow.model.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,))) + + def test_flow_invest_with_effects(self, basic_flow_system_linopy): + """Test flow with investment effects.""" + flow_system = basic_flow_system_linopy + + # Create effects + co2 = fx.Effect(label='CO2', unit='ton', description='CO2 emissions') + + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=fx.InvestParameters( + minimum_size=20, + maximum_size=100, + optional=True, + fix_effects={'Costs': 1000, 'CO2': 5}, # Fixed investment effects + specific_effects={'Costs': 500, 'CO2': 0.1}, # Specific investment effects + ), + ) + + flow_system.add_elements( fx.Sink('Sink', sink=flow), co2) + model = create_linopy_model(flow_system) + + # Check investment effects + assert 'Sink(WƤrme)->Costs(invest)' in model.variables + assert 'Sink(WƤrme)->CO2(invest)' in model.variables + + # Check fix effects (applied only when is_invested=1) + 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, + ) + + 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, + ) + + def test_flow_invest_divest_effects(self, basic_flow_system_linopy): + """Test flow with divestment effects.""" + flow_system = basic_flow_system_linopy + + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=fx.InvestParameters( + minimum_size=20, + maximum_size=100, + optional=True, + divest_effects={'Costs': 500}, # Cost incurred when NOT investing + ), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + # Check divestment effects + assert 'Sink(WƤrme)->Costs(invest)' in model.constraints + + assert_conequal( + model.constraints['Sink(WƤrme)->Costs(invest)'], + model.variables['Sink(WƤrme)->Costs(invest)'] + (model.variables['Sink(WƤrme)|is_invested'] -1) * 500 == 0 + ) + + +class TestFlowOnModel: + """Test the FlowModel class.""" + + def test_flow_on(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=100, + relative_minimum=xr.DataArray(0.2, coords=(timesteps,)), + relative_maximum=xr.DataArray(0.8, coords=(timesteps,)), + on_off_parameters=fx.OnOffParameters(), + ) + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.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( + [ + 'Sink(WƤrme)|total_flow_hours', + 'Sink(WƤrme)|on_hours_total', + 'Sink(WƤrme)|on_con1', + 'Sink(WƤrme)|on_con2', + ] + ) + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=0, + upper=0.8 * 100, + coords=(timesteps,), + ), + ) + + # OnOff + assert_var_equal( + flow.model.on_off.on, + model.add_variables(binary=True, coords=(timesteps,)), + ) + assert_var_equal( + model.variables['Sink(WƤrme)|on_hours_total'], + 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'], + ) + 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'], + ) + + 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(), + ) + + def test_effects_per_running_hour(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + costs_per_running_hour = xr.DataArray(np.linspace(1, 2, timesteps.size), coords=(timesteps,)) + co2_per_running_hour = xr.DataArray(np.linspace(4, 5, timesteps.size), coords=(timesteps,)) + + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + on_off_parameters=fx.OnOffParameters( + effects_per_running_hour={'Costs': costs_per_running_hour, 'CO2': co2_per_running_hour} + ), + ) + flow_system.add_elements(fx.Sink('Sink', sink=flow), fx.Effect('CO2', 't', '')) + 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', + 'Sink(WƤrme)|on', + 'Sink(WƤrme)|on_hours_total', + } + assert set(flow.model.constraints) == { + 'Sink(WƤrme)|total_flow_hours', + 'Sink(WƤrme)|on_con1', + 'Sink(WƤrme)|on_con2', + '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_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, + ) + + 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, + ) + + def test_consecutive_on_hours(self, basic_flow_system_linopy): + """Test flow with minimum and maximum consecutive on hours.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=100, + on_off_parameters=fx.OnOffParameters( + consecutive_on_hours_min=2, # Must run for at least 2 hours when turned on + consecutive_on_hours_max=8, # Can't run more than 8 consecutive hours + ), + ) + + 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)|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_var_equal( + model.variables['Sink(WƤrme)|ConsecutiveOn|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 + ) + + 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)) + ) + + # 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.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.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.variables['Sink(WƤrme)|on'].isel(time=slice(None, -1)) - model.variables['Sink(WƤrme)|on'].isel(time=slice(1, None))) * 2 + ) + + def test_consecutive_on_hours_previous(self, basic_flow_system_linopy): + """Test flow with minimum and maximum consecutive on hours.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=100, + on_off_parameters=fx.OnOffParameters( + consecutive_on_hours_min=2, # Must run for at least 2 hours when turned on + consecutive_on_hours_max=8, # Can't run more than 8 consecutive hours + ), + previous_flow_rate=np.array([10, 20, 30, 0, 20, 20, 30]) # Previously on for 3 steps + ) + + 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)|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_var_equal( + model.variables['Sink(WƤrme)|ConsecutiveOn|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 + ) + + 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)) + ) + + # 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.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.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.variables['Sink(WƤrme)|on'].isel(time=slice(None, -1)) - model.variables['Sink(WƤrme)|on'].isel(time=slice(1, None))) * 2 + ) + + def test_consecutive_off_hours(self, basic_flow_system_linopy): + """Test flow with minimum and maximum consecutive off hours.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=100, + on_off_parameters=fx.OnOffParameters( + consecutive_off_hours_min=4, # Must stay off for at least 4 hours when shut down + consecutive_off_hours_max=12, # Can't be off for more than 12 consecutive hours + ), + ) + + 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)|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)) + + assert_var_equal( + model.variables['Sink(WƤrme)|ConsecutiveOff|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 + ) + + 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)) + ) + + # 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.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.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.variables['Sink(WƤrme)|off'].isel(time=slice(None, -1)) - model.variables['Sink(WƤrme)|off'].isel(time=slice(1, None))) * 4 + ) + + def test_consecutive_off_hours_previous(self, basic_flow_system_linopy): + """Test flow with minimum and maximum consecutive off hours.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=100, + on_off_parameters=fx.OnOffParameters( + consecutive_off_hours_min=4, # Must stay off for at least 4 hours when shut down + consecutive_off_hours_max=12, # Can't be off for more than 12 consecutive hours + ), + previous_flow_rate=np.array([10, 20, 30, 0, 20, 0, 0]) # Previously off for 2 steps + ) + + 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)|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)) + + assert_var_equal( + model.variables['Sink(WƤrme)|ConsecutiveOff|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 + ) + + 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)) + ) + + # 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.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.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.variables['Sink(WƤrme)|off'].isel(time=slice(None, -1)) - model.variables['Sink(WƤrme)|off'].isel(time=slice(1, None))) * 4 + ) + + def test_switch_on_constraints(self, basic_flow_system_linopy): + """Test flow with constraints on the number of startups.""" + flow_system = basic_flow_system_linopy + + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=100, + on_off_parameters=fx.OnOffParameters( + switch_on_total_max=5, # Maximum 5 startups + effects_per_switch_on={'Costs': 100}, # 100 EUR startup cost + ), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + 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) + ) + + # 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)) + + # 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)) + + # 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'), + ) + + # Check that startup cost effect constraint exists + assert 'Sink(WƤrme)->Costs(operation)' in model.constraints + + # 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, + ) + + def test_on_hours_limits(self, basic_flow_system_linopy): + """Test flow with limits on total on hours.""" + flow_system = basic_flow_system_linopy + + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=100, + on_off_parameters=fx.OnOffParameters( + on_hours_total_min=20, # Minimum 20 hours of operation + on_hours_total_max=100, # Maximum 100 hours of operation + ), + ) + + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + 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)) + + # 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)) + + # 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(), + ) + + +class TestFlowOnInvestModel: + """Test the FlowModel class.""" + + def test_flow_on_invest_optional(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=fx.InvestParameters(minimum_size=20, maximum_size=200, optional=True), + relative_minimum=xr.DataArray(0.2, coords=(timesteps,)), + relative_maximum=xr.DataArray(0.8, coords=(timesteps,)), + on_off_parameters=fx.OnOffParameters(), + ) + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == set( + [ + 'Sink(WƤrme)|total_flow_hours', + 'Sink(WƤrme)|flow_rate', + 'Sink(WƤrme)|is_invested', + 'Sink(WƤrme)|size', + 'Sink(WƤrme)|on', + 'Sink(WƤrme)|on_hours_total', + ] + ) + + assert set(flow.model.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', + ] + ) + + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=0, + upper=0.8 * 200, + coords=(timesteps,), + ), + ) + + # OnOff + assert_var_equal( + flow.model.on_off.on, + model.add_variables(binary=True, coords=(timesteps,)), + ) + assert_var_equal( + model.variables['Sink(WƤrme)|on_hours_total'], + 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'], + ) + 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'], + ) + 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(), + ) + + # Investment + assert_var_equal(model['Sink(WƤrme)|size'], model.add_variables(lower=0, upper=200)) + + 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, + ) + 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, + ) + + def test_flow_on_invest_non_optional(self, basic_flow_system_linopy): + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=fx.InvestParameters(minimum_size=20, maximum_size=200, optional=False), + relative_minimum=xr.DataArray(0.2, coords=(timesteps,)), + relative_maximum=xr.DataArray(0.8, coords=(timesteps,)), + on_off_parameters=fx.OnOffParameters(), + ) + flow_system.add_elements(fx.Sink('Sink', sink=flow)) + model = create_linopy_model(flow_system) + + assert set(flow.model.variables) == set( + [ + 'Sink(WƤrme)|total_flow_hours', + 'Sink(WƤrme)|flow_rate', + 'Sink(WƤrme)|size', + 'Sink(WƤrme)|on', + 'Sink(WƤrme)|on_hours_total', + ] + ) + + assert set(flow.model.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', + ] + ) + + # flow_rate + assert_var_equal( + flow.model.flow_rate, + model.add_variables( + lower=0, + upper=0.8 * 200, + coords=(timesteps,), + ), + ) + + # OnOff + assert_var_equal( + flow.model.on_off.on, + model.add_variables(binary=True, coords=(timesteps,)), + ) + assert_var_equal( + model.variables['Sink(WƤrme)|on_hours_total'], + 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'], + ) + 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'], + ) + 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(), + ) + + # Investment + assert_var_equal(model['Sink(WƤrme)|size'], model.add_variables(lower=20, upper=200)) + + 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, + ) + 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, + ) + + +class TestFlowWithFixedProfile: + """Test Flow with fixed relative profile.""" + + def test_fixed_relative_profile(self, basic_flow_system_linopy): + """Test flow with a fixed relative profile.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + # Create a time-varying profile (e.g., for a load or renewable generation) + profile = np.sin(np.linspace(0, 2 * np.pi, len(timesteps))) * 0.5 + 0.5 # Values between 0 and 1 + + flow = fx.Flow( + 'WƤrme', bus='FernwƤrme', size=100, fixed_relative_profile=xr.DataArray(profile, coords=(timesteps,)) + ) + + 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'], + model.add_variables(lower=profile * 100, + upper=profile * 100, + coords=(timesteps,)) + ) + + + def test_fixed_profile_with_investment(self, basic_flow_system_linopy): + """Test flow with fixed profile and investment.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + # Create a fixed profile + profile = np.sin(np.linspace(0, 2 * np.pi, len(timesteps))) * 0.5 + 0.5 + + flow = fx.Flow( + 'WƤrme', + bus='FernwƤrme', + size=fx.InvestParameters(minimum_size=50, maximum_size=200, optional=True), + fixed_relative_profile=xr.DataArray(profile, coords=(timesteps,)), + ) + + 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'], + 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,)), + ) + + +if __name__ == '__main__': + pytest.main() diff --git a/tests/test_functional.py b/tests/test_functional.py index 238a776d3..5db83f656 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -1,7 +1,7 @@ """ -Unit tests for the flixOpt framework. +Unit tests for the flixopt framework. -This module defines a set of unit tests for testing the functionality of the `flixOpt` framework. +This module defines a set of unit tests for testing the functionality of the `flixopt` framework. The tests focus on verifying the correct behavior of flow systems, including component modeling, investment optimization, and operational constraints like on-off behavior. @@ -17,13 +17,12 @@ - On-off operational constraints (`TestOnOff`). """ -import unittest - import numpy as np +import pandas as pd import pytest from numpy.testing import assert_allclose -import flixOpt as fx +import flixopt as fx np.random.seed(45) @@ -62,313 +61,269 @@ def _adjust_length(self, array, new_length: int): return extended_array[:new_length] # Truncate to exact length -class BaseTest(unittest.TestCase): - """ - Base test class for setting up flow systems in flixOpt. - - Provides shared setup, utility methods, and common functionality for the other test cases. - - Methods: - - setUp: Initializes logging and default parameters. - - create_model: Creates a base flow system model with predefined buses and components. - - solve_and_load: Solves the flow system model and loads the results. - - get_element: Retrieves an element from the flow system by label. - - solver: Configures and returns a solver instance. - """ - - def setUp(self): - fx.change_logging_level('DEBUG') - self.mip_gap = 0.0001 - self.datetime_array = fx.create_datetime_array('2020-01-01', 5, 'h') - - def create_model(self, datetime_array: np.ndarray[np.datetime64]) -> fx.FlowSystem: - self.flow_system = fx.FlowSystem(datetime_array) - self.buses = { - 'FernwƤrme': fx.Bus('FernwƤrme', excess_penalty_per_flow_hour=None), - 'Gas': fx.Bus('Gas', excess_penalty_per_flow_hour=None), - } - self.flow_system.add_elements(fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True)) - data = Data(len(datetime_array)) - self.flow_system.add_elements( - fx.Sink( - label='WƤrmelast', - sink=fx.Flow( - label='WƤrme', bus=self.get_element('FernwƤrme'), fixed_relative_profile=data.thermal_demand, size=1 - ), +def flow_system_base(timesteps: pd.DatetimeIndex) -> fx.FlowSystem: + data = Data(len(timesteps)) + + flow_system = fx.FlowSystem(timesteps) + flow_system.add_elements( + fx.Bus('FernwƤrme', excess_penalty_per_flow_hour=None), + fx.Bus('Gas', excess_penalty_per_flow_hour=None), + ) + flow_system.add_elements(fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True)) + flow_system.add_elements( + fx.Sink( + label='WƤrmelast', + sink=fx.Flow(label='WƤrme', bus='FernwƤrme', fixed_relative_profile=data.thermal_demand, size=1), + ), + fx.Source(label='Gastarif', source=fx.Flow(label='Gas', bus='Gas', effects_per_flow_hour=1)), + ) + return flow_system + + +def flow_system_minimal(timesteps) -> fx.FlowSystem: + flow_system = flow_system_base(timesteps) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow('Q_th', bus='FernwƤrme'), + ) + ) + return flow_system + + +def solve_and_load(flow_system: fx.FlowSystem, solver) -> fx.results.CalculationResults: + calculation = fx.FullCalculation('Calculation', flow_system) + calculation.do_modeling() + calculation.solve(solver) + return calculation.results + + +@pytest.fixture +def time_steps_fixture(request): + return pd.date_range('2020-01-01', periods=5, freq='h') + + +def test_solve_and_load(solver_fixture, time_steps_fixture): + results = solve_and_load(flow_system_minimal(time_steps_fixture), solver_fixture) + assert results is not None + + +def test_minimal_model(solver_fixture, time_steps_fixture): + results = solve_and_load(flow_system_minimal(time_steps_fixture), solver_fixture) + assert_allclose(results.model.variables['costs|total'].solution.values, 80, rtol=1e-5, atol=1e-10) + + assert_allclose( + results.model.variables['Boiler(Q_th)|flow_rate'].solution.values, + [-0.0, 10.0, 20.0, -0.0, 10.0], + rtol=1e-5, + atol=1e-10, + ) + + assert_allclose( + results.model.variables['costs(operation)|total_per_timestep'].solution.values, + [-0.0, 20.0, 40.0, -0.0, 20.0], + rtol=1e-5, + atol=1e-10, + ) + + assert_allclose( + results.model.variables['Gastarif(Gas)->costs(operation)'].solution.values, + [-0.0, 20.0, 40.0, -0.0, 20.0], + rtol=1e-5, + atol=1e-10, + ) + + +def test_fixed_size(solver_fixture, time_steps_fixture): + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow( + 'Q_th', + bus='FernwƤrme', + size=fx.InvestParameters(fixed_size=1000, fix_effects=10, specific_effects=1), ), - fx.Source( - label='Gastarif', source=fx.Flow(label='Gas', bus=self.get_element('Gas'), effects_per_flow_hour=1) - ), - ) - return self.flow_system - - def solve_and_load(self, flow_system: fx.FlowSystem) -> fx.results.CalculationResults: - calculation = fx.FullCalculation('Calculation', flow_system) - calculation.do_modeling() - calculation.solve(self.solver, True) - results = fx.results.CalculationResults('Calculation', 'results') - return results - - def get_element(self, label: str): - return {**self.flow_system.all_elements, **self.buses}[label] - - @property - def solver(self): - """Returns a (new) solver instance with the specified parameters.""" - return fx.solvers.HighsSolver(mip_gap=self.mip_gap, time_limit_seconds=3600, solver_output_to_console=False) - - -class TestMinimal(BaseTest): - """ - Tests a minimal setup of a flow system. - - Focuses on: - - Adding basic components. - - Verifying the correct setup and results for a small system with minimal complexity. - """ - - def create_model(self, datetime_array: np.ndarray[np.datetime64]) -> fx.FlowSystem: - super().create_model(datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow('Q_th', bus=self.get_element('FernwƤrme')), - ) - ) - return self.flow_system - - def test_solve_and_load(self): - flow_system = self.create_model(self.datetime_array) - self.solve_and_load(flow_system) - - def test_results(self): - flow_system = self.create_model(self.datetime_array) - results = self.solve_and_load(flow_system) - - assert_allclose( - results.effect_results['costs'].all_results['all']['all_sum'], 80, rtol=self.mip_gap, atol=1e-10 - ) - - assert_allclose( - results.component_results['Boiler'].all_results['Q_th']['flow_rate'], - [-0.0, 10.0, 20.0, -0.0, 10.0], - rtol=self.mip_gap, - atol=1e-10, - ) - - assert_allclose( - results.effect_results['costs'].all_results['operation']['operation_sum_TS'], - [-0.0, 20.0, 40.0, -0.0, 20.0], - rtol=self.mip_gap, - atol=1e-10, - ) - - assert_allclose( - results.effect_results['costs'].all_results['operation']['Shares']['Gastarif__Gas__effects_per_flow_hour'], - [-0.0, 20.0, 40.0, -0.0, 20.0], - rtol=self.mip_gap, - atol=1e-10, - ) - - -class TestInvestment(BaseTest): - """ - Tests investment modeling and optimization in flow systems. - - Focuses on: - - Fixed size investments. - - Optimized sizing of components. - - Investment constraints, including bounds and optional investments. - - Validating cost calculations and investment decisions. - """ - - def test_fixed_size(self): - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('FernwƤrme'), - size=fx.InvestParameters(fixed_size=1000, fix_effects=10, specific_effects=1), - ), - ) - ) - - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 80 + 1000 * 1 + 10, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - assert_allclose( - boiler.model.all_variables['Boiler__Q_th__Investment_size'].result, - 1000, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__Investment_size" does not have the right value', - ) - assert_allclose( - boiler.model.all_variables['Boiler__Q_th__Investment_isInvested'].result, - 1, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__Investment_size" does not have the right value', - ) - - def test_optimize_size(self): - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('FernwƤrme'), - size=fx.InvestParameters(fix_effects=10, specific_effects=1), - ), - ) - ) - - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 80 + 20 * 1 + 10, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - assert_allclose( - boiler.Q_th.model._investment.size.result, - 20, - rtol=self.mip_gap, - 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.result, - 1, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__IsInvested" does not have the right value', - ) - - def test_size_bounds(self): - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('FernwƤrme'), - size=fx.InvestParameters(minimum_size=40, fix_effects=10, specific_effects=1), - ), - ) - ) - - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 80 + 40 * 1 + 10, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', ) - assert_allclose( - boiler.Q_th.model._investment.size.result, - 40, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__Investment_size" does not have the right value', + ) + + solve_and_load(flow_system, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + costs = flow_system.effects['costs'] + assert_allclose( + costs.model.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(), + 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(), + 1, + rtol=1e-5, + atol=1e-10, + err_msg='"Boiler__Q_th__Investment_size" does not have the right value', + ) + + +def test_optimize_size(solver_fixture, time_steps_fixture): + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow( + 'Q_th', + bus='FernwƤrme', + size=fx.InvestParameters(fix_effects=10, specific_effects=1), + ), ) - assert_allclose( - boiler.Q_th.model._investment.is_invested.result, - 1, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__IsInvested" does not have the right value', + ) + + solve_and_load(flow_system, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + costs = flow_system.effects['costs'] + assert_allclose( + costs.model.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(), + 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(), + 1, + rtol=1e-5, + atol=1e-10, + err_msg='"Boiler__Q_th__IsInvested" does not have the right value', + ) + + +def test_size_bounds(solver_fixture, time_steps_fixture): + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow( + 'Q_th', + bus='FernwƤrme', + size=fx.InvestParameters(minimum_size=40, fix_effects=10, specific_effects=1), + ), ) - - def test_optional_invest(self): - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('FernwƤrme'), - size=fx.InvestParameters(optional=True, minimum_size=40, fix_effects=10, specific_effects=1), - ), + ) + + solve_and_load(flow_system, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + costs = flow_system.effects['costs'] + assert_allclose( + costs.model.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(), + 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(), + 1, + rtol=1e-5, + atol=1e-10, + err_msg='"Boiler__Q_th__IsInvested" does not have the right value', + ) + + +def test_optional_invest(solver_fixture, time_steps_fixture): + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow( + 'Q_th', + bus='FernwƤrme', + size=fx.InvestParameters(optional=True, minimum_size=40, fix_effects=10, specific_effects=1), ), - fx.linear_converters.Boiler( - 'Boiler_optional', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('FernwƤrme'), - size=fx.InvestParameters(optional=True, minimum_size=50, fix_effects=10, specific_effects=1), - ), + ), + fx.linear_converters.Boiler( + 'Boiler_optional', + 0.5, + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow( + 'Q_th', + bus='FernwƤrme', + size=fx.InvestParameters(optional=True, minimum_size=50, fix_effects=10, specific_effects=1), ), - ) - - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - boiler_optional = self.get_element('Boiler_optional') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 80 + 40 * 1 + 10, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - assert_allclose( - boiler.Q_th.model._investment.size.result, - 40, - rtol=self.mip_gap, - 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.result, - 1, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__IsInvested" does not have the right value', - ) - - assert_allclose( - boiler_optional.Q_th.model._investment.size.result, - 0, - rtol=self.mip_gap, - 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.result, - 0, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__IsInvested" does not have the right value', - ) + ), + ) + + solve_and_load(flow_system, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + boiler_optional = flow_system.all_elements['Boiler_optional'] + costs = flow_system.effects['costs'] + assert_allclose( + costs.model.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(), + 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(), + 1, + rtol=1e-5, + atol=1e-10, + err_msg='"Boiler__Q_th__IsInvested" does not have the right value', + ) + + assert_allclose( + boiler_optional.Q_th.model._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(), + 0, + rtol=1e-5, + atol=1e-10, + err_msg='"Boiler__Q_th__IsInvested" does not have the right value', + ) def test_fixed_relative_profile(self): self.flow_system = self.create_model(self.datetime_array) @@ -397,11 +352,12 @@ def test_fixed_relative_profile(self): self.flow_system.add_elements( fx.Source( 'WƤrmequelle', - source=fx.Flow('Q_th', - bus=self.get_element('FernwƤrme'), - fixed_relative_profile=np.linspace(0, 5, len(self.datetime_array)), - size=fx.InvestParameters(optional=False, minimum_size=2, maximum_size=5), - ) + source=fx.Flow( + 'Q_th', + bus=self.get_element('FernwƤrme'), + fixed_relative_profile=np.linspace(0, 5, len(self.datetime_array)), + size=fx.InvestParameters(optional=False, minimum_size=2, maximum_size=5), + ), ) ) self.get_element('FernwƤrme').excess_penalty_per_flow_hour = 1e5 @@ -424,494 +380,407 @@ def test_fixed_relative_profile(self): ) - - -class TestOnOff(BaseTest): - """ - Tests on-off operational constraints in flow systems. - - Focuses on: - - Verifying the correct behavior of Flows that can toggle on or off. - - Testing constraints like maximum consecutive off hours. - - Validating flow rates and operational costs under on-off scenarios. - """ - - def test_on(self): - """Tests if the On Variable is correctly created and calculated in a Flow""" - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', bus=self.get_element('FernwƤrme'), size=100, on_off_parameters=fx.OnOffParameters() - ), - ) - ) - - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 80, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - - assert_allclose( - boiler.Q_th.model._on.on.result, - [0, 1, 1, 0, 1], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__on" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model.flow_rate.result, - [0, 10, 20, 0, 10], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) - - def test_off(self): - """Tests if the Off Variable is correctly created and calculated in a Flow""" - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('FernwƤrme'), - size=100, - on_off_parameters=fx.OnOffParameters(consecutive_off_hours_max=100), - ), - ) - ) - - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 80, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - - assert_allclose( - boiler.Q_th.model._on.on.result, - [0, 1, 1, 0, 1], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__on" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model._on.off.result, - 1 - boiler.Q_th.model._on.on.result, - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__off" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model.flow_rate.result, - [0, 10, 20, 0, 10], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) - - def test_switch_on_off(self): - """Tests if the Switch On/Off Variable is correctly created and calculated in a Flow""" - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('FernwƤrme'), - size=100, - on_off_parameters=fx.OnOffParameters(force_switch_on=True), - ), - ) - ) - - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 80, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - - assert_allclose( - boiler.Q_th.model._on.on.result, - [0, 1, 1, 0, 1], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__on" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model._on.switch_on.result, - [0, 1, 0, 0, 1], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__switch_on" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model._on.switch_off.result, - [0, 0, 0, 1, 0], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__switch_on" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model.flow_rate.result, - [0, 10, 20, 0, 10], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) - - def test_on_total_max(self): - """Tests if the On Total Max Variable is correctly created and calculated in a Flow""" - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('FernwƤrme'), - size=100, - on_off_parameters=fx.OnOffParameters(on_hours_total_max=1), - ), - ), - fx.linear_converters.Boiler( - 'Boiler_backup', - 0.2, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow('Q_th', bus=self.get_element('FernwƤrme'), size=100), +def test_on(solver_fixture, time_steps_fixture): + """Tests if the On Variable is correctly created and calculated in a Flow""" + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow('Q_th', bus='FernwƤrme', size=100, on_off_parameters=fx.OnOffParameters()), + ) + ) + + solve_and_load(flow_system, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + costs = flow_system.effects['costs'] + assert_allclose( + costs.model.total.solution.item(), + 80, + rtol=1e-5, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) + + assert_allclose( + boiler.Q_th.model.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, + [0, 10, 20, 0, 10], + rtol=1e-5, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) + + +def test_off(solver_fixture, time_steps_fixture): + """Tests if the Off Variable is correctly created and calculated in a Flow""" + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow( + 'Q_th', + bus='FernwƤrme', + size=100, + on_off_parameters=fx.OnOffParameters(consecutive_off_hours_max=100), ), ) - - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 140, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - - assert_allclose( - boiler.Q_th.model._on.on.result, - [0, 0, 1, 0, 0], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__on" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model.flow_rate.result, - [0, 0, 20, 0, 0], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) - - def test_on_total_bounds(self): - """Tests if the On Hours min and max are correctly created and calculated in a Flow""" - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('FernwƤrme'), - size=100, - on_off_parameters=fx.OnOffParameters(on_hours_total_max=2), - ), - ), - fx.linear_converters.Boiler( - 'Boiler_backup', - 0.2, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('FernwƤrme'), - size=100, - on_off_parameters=fx.OnOffParameters(on_hours_total_min=3), - ), + ) + + solve_and_load(flow_system, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + costs = flow_system.effects['costs'] + assert_allclose( + costs.model.total.solution.item(), + 80, + rtol=1e-5, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) + + assert_allclose( + boiler.Q_th.model.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, + 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, + [0, 10, 20, 0, 10], + rtol=1e-5, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) + + +def test_switch_on_off(solver_fixture, time_steps_fixture): + """Tests if the Switch On/Off Variable is correctly created and calculated in a Flow""" + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow( + 'Q_th', + bus='FernwƤrme', + size=100, + on_off_parameters=fx.OnOffParameters(force_switch_on=True), ), ) - self.get_element('WƤrmelast').sink.fixed_relative_profile = [0, 10, 20, 0, 12] # Else its non deterministic - - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - boiler_backup = self.get_element('Boiler_backup') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 114, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - - assert_allclose( - boiler.Q_th.model._on.on.result, - [0, 0, 1, 0, 1], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__on" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model.flow_rate.result, - [0, 0, 20, 0, 12 - 1e-5], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) - - assert_allclose( - sum(boiler_backup.Q_th.model._on.on.result), - 3, - rtol=self.mip_gap, - 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.result, - [0, 10, 1.0e-05, 0, 1.0e-05], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) - - def test_consecutive_on(self): - """Tests if the consecutive on/off hours are correctly created and calculated in a Flow""" - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('FernwƤrme'), - size=100, - on_off_parameters=fx.OnOffParameters(consecutive_on_hours_max=2, consecutive_on_hours_min=2), - ), + ) + + solve_and_load(flow_system, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + costs = flow_system.effects['costs'] + assert_allclose( + costs.model.total.solution.item(), + 80, + rtol=1e-5, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) + + assert_allclose( + boiler.Q_th.model.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, + [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, + [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, + [0, 10, 20, 0, 10], + rtol=1e-5, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) + + +def test_on_total_max(solver_fixture, time_steps_fixture): + """Tests if the On Total Max Variable is correctly created and calculated in a Flow""" + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow( + 'Q_th', + bus='FernwƤrme', + size=100, + on_off_parameters=fx.OnOffParameters(on_hours_total_max=1), ), - fx.linear_converters.Boiler( - 'Boiler_backup', - 0.2, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow('Q_th', bus=self.get_element('FernwƤrme'), size=100), - ), - ) - self.get_element('WƤrmelast').sink.fixed_relative_profile = [5, 10, 20, 18, 12] # Else its non deterministic - - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - boiler_backup = self.get_element('Boiler_backup') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 190, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - - assert_allclose( - boiler.Q_th.model._on.on.result, - [1, 1, 0, 1, 1], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__on" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model.flow_rate.result, - [5, 10, 0, 18, 12], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) - - assert_allclose( - boiler_backup.Q_th.model.flow_rate.result, - [0, 0, 20, 0, 0], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) - - def test_consecutive_off(self): - """Tests if the consecutive on hours are correctly created and calculated in a Flow""" - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow('Q_th', bus=self.get_element('FernwƤrme')), + ), + fx.linear_converters.Boiler( + 'Boiler_backup', + 0.2, + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow('Q_th', bus='FernwƤrme', size=100), + ), + ) + + solve_and_load(flow_system, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + costs = flow_system.effects['costs'] + assert_allclose( + costs.model.total.solution.item(), + 140, + rtol=1e-5, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) + + assert_allclose( + boiler.Q_th.model.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, + [0, 0, 20, 0, 0], + rtol=1e-5, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) + + +def test_on_total_bounds(solver_fixture, time_steps_fixture): + """Tests if the On Hours min and max are correctly created and calculated in a Flow""" + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow( + 'Q_th', + bus='FernwƤrme', + size=100, + on_off_parameters=fx.OnOffParameters(on_hours_total_max=2), ), - fx.linear_converters.Boiler( - 'Boiler_backup', - 0.2, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('FernwƤrme'), - size=100, - previous_flow_rate=np.array([20]), # Otherwise its Off before the start - on_off_parameters=fx.OnOffParameters(consecutive_off_hours_max=2, consecutive_off_hours_min=2), - ), + ), + fx.linear_converters.Boiler( + 'Boiler_backup', + 0.2, + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow( + 'Q_th', + bus='FernwƤrme', + size=100, + on_off_parameters=fx.OnOffParameters(on_hours_total_min=3), ), - ) - self.get_element('WƤrmelast').sink.fixed_relative_profile = [5, 0, 20, 18, 12] # Else its non deterministic - - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - boiler_backup = self.get_element('Boiler_backup') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 110, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - - assert_allclose( - boiler_backup.Q_th.model._on.on.result, - [0, 0, 1, 0, 0], - rtol=self.mip_gap, - 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.result, - [1, 1, 0, 1, 1], - rtol=self.mip_gap, - 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.result, - [0, 0, 1e-5, 0, 0], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler_backup__Q_th__flow_rate" does not have the right value', - ) - - assert_allclose( - boiler.Q_th.model.flow_rate.result, - [5, 0, 20 - 1e-5, 18, 12], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) - - def test_consecutive_on_off(self): - """Tests if the consecutive on/off hours are correctly created and calculated in a Flow""" - self.flow_system = self.create_model(self.datetime_array) - self.flow_system.add_elements( - fx.linear_converters.Boiler( - 'Boiler', - 0.5, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow( - 'Q_th', - bus=self.get_element('FernwƤrme'), - size=100, - on_off_parameters=fx.OnOffParameters(consecutive_on_hours_min=2, - consecutive_off_hours_min=2), - # Previous flow_rate is 0 by default - ), + ), + ) + flow_system.all_elements['WƤrmelast'].sink.fixed_relative_profile = np.array( + [0, 10, 20, 0, 12] + ) # Else its non deterministic + + solve_and_load(flow_system, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + boiler_backup = flow_system.all_elements['Boiler_backup'] + costs = flow_system.effects['costs'] + assert_allclose( + costs.model.total.solution.item(), + 114, + rtol=1e-5, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) + + assert_allclose( + boiler.Q_th.model.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, + [0, 0, 20, 0, 12 - 1e-5], + rtol=1e-5, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) + + assert_allclose( + sum(boiler_backup.Q_th.model.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, + [0, 10, 1.0e-05, 0, 1.0e-05], + rtol=1e-5, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) + + +def test_consecutive_on_off(solver_fixture, time_steps_fixture): + """Tests if the consecutive on/off hours are correctly created and calculated in a Flow""" + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow( + 'Q_th', + bus='FernwƤrme', + size=100, + on_off_parameters=fx.OnOffParameters(consecutive_on_hours_max=2, consecutive_on_hours_min=2), ), - fx.linear_converters.Boiler( - 'Boiler_backup', - 0.2, - Q_fu=fx.Flow('Q_fu', bus=self.get_element('Gas')), - Q_th=fx.Flow('Q_th', bus=self.get_element('FernwƤrme'), size=100), + ), + fx.linear_converters.Boiler( + 'Boiler_backup', + 0.2, + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow('Q_th', bus='FernwƤrme', size=100), + ), + ) + flow_system.all_elements['WƤrmelast'].sink.fixed_relative_profile = np.array([5, 10, 20, 18, 12]) + # Else its non deterministic + + solve_and_load(flow_system, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + boiler_backup = flow_system.all_elements['Boiler_backup'] + costs = flow_system.effects['costs'] + assert_allclose( + costs.model.total.solution.item(), + 190, + rtol=1e-5, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) + + assert_allclose( + boiler.Q_th.model.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, + [5, 10, 0, 18, 12], + rtol=1e-5, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) + + assert_allclose( + boiler_backup.Q_th.model.flow_rate.solution.values, + [0, 0, 20, 0, 0], + rtol=1e-5, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) + + +def test_consecutive_off(solver_fixture, time_steps_fixture): + """Tests if the consecutive on hours are correctly created and calculated in a Flow""" + flow_system = flow_system_base(time_steps_fixture) + flow_system.add_elements( + fx.linear_converters.Boiler( + 'Boiler', + 0.5, + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow('Q_th', bus='FernwƤrme'), + ), + fx.linear_converters.Boiler( + 'Boiler_backup', + 0.2, + Q_fu=fx.Flow('Q_fu', bus='Gas'), + Q_th=fx.Flow( + 'Q_th', + bus='FernwƤrme', + size=100, + previous_flow_rate=np.array([20]), # Otherwise its Off before the start + on_off_parameters=fx.OnOffParameters(consecutive_off_hours_max=2, consecutive_off_hours_min=2), ), - ) - self.get_element('WƤrmelast').sink.fixed_relative_profile = [5, 10, 20, 18, 12] # Else its non deterministic - - self.solve_and_load(self.flow_system) - boiler = self.get_element('Boiler') - boiler_backup = self.get_element('Boiler_backup') - costs = self.get_element('costs') - assert_allclose( - costs.model.all.sum.result, - 145, - rtol=self.mip_gap, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - - assert_allclose( - boiler.Q_th.model._on.on.result, - [0, 1, 1, 1, 1], # Still of for second timestep! - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__on" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model._on.off.result, - [1, 0, 0, 0, 0], # Still of for second timestep! - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__off" does not have the right value', - ) - assert_allclose( - boiler.Q_th.model._on.consecutive_on_hours.result, - [0, 1, 2, 3, 4], # Still of for second timestep! - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__consecutive_on_hours" does not have the right value', - ) - - assert_allclose( - boiler.Q_th.model._on.consecutive_off_hours.result, - [2, 0, 0, 0, 0], # Still of for second timestep! - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__consecutive_off_hours" does not have the right value', - ) - - assert_allclose( - boiler.Q_th.model.flow_rate.result, - [0, 10, 20, 18, 12], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) - - assert_allclose( - boiler_backup.Q_th.model.flow_rate.result, - [5, 0, 0, 0, 0], - rtol=self.mip_gap, - atol=1e-10, - err_msg='"Boiler__Q_th__flow_rate" does not have the right value', - ) + ), + ) + flow_system.all_elements['WƤrmelast'].sink.fixed_relative_profile = np.array( + [5, 0, 20, 18, 12] + ) # Else its non deterministic + + solve_and_load(flow_system, solver_fixture) + boiler = flow_system.all_elements['Boiler'] + boiler_backup = flow_system.all_elements['Boiler_backup'] + costs = flow_system.effects['costs'] + assert_allclose( + costs.model.total.solution.item(), + 110, + rtol=1e-5, + atol=1e-10, + err_msg='The total costs does not have the right value', + ) + + assert_allclose( + boiler_backup.Q_th.model.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, + [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, + [0, 0, 1e-5, 0, 0], + rtol=1e-5, + atol=1e-10, + err_msg='"Boiler_backup__Q_th__flow_rate" does not have the right value', + ) + + assert_allclose( + boiler.Q_th.model.flow_rate.solution.values, + [5, 0, 20 - 1e-5, 18, 12], + rtol=1e-5, + atol=1e-10, + err_msg='"Boiler__Q_th__flow_rate" does not have the right value', + ) if __name__ == '__main__': diff --git a/tests/test_integration.py b/tests/test_integration.py index d6d2a9135..dc203c33e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,266 +1,137 @@ -import datetime import os -import unittest -from typing import Literal import numpy as np import pandas as pd import pytest -import flixOpt as fx +import flixopt as fx -np.random.seed(45) +from .conftest import ( + assert_almost_equal_numeric, + create_calculation_and_solve, +) -class BaseTest(unittest.TestCase): - def setUp(self): - fx.change_logging_level('DEBUG') - - def get_solver(self): - return fx.solvers.HighsSolver(mip_gap=0.0001, time_limit_seconds=3600, solver_output_to_console=False) - - def assert_almost_equal_numeric( - self, actual, desired, err_msg, relative_error_range_in_percent=0.011, absolute_tolerance=1e-9 - ): # error_range etwas hƶher als mip_gap, weil unterschiedl. Bezugswerte +class TestFlowSystem: + def test_simple_flow_system(self, simple_flow_system, highs_solver): """ - Asserts that actual is almost equal to desired. - Designed for comparing float and ndarrays. Whith respect to tolerances + Test the effects of the simple energy system model """ - relative_tol = relative_error_range_in_percent / 100 - if isinstance(desired, (int, float)): - delta = abs(relative_tol * desired) if desired != 0 else absolute_tolerance - self.assertAlmostEqual(actual, desired, msg=err_msg, delta=delta) - else: - np.testing.assert_allclose(actual, desired, rtol=relative_tol, atol=absolute_tolerance) + calculation = create_calculation_and_solve(simple_flow_system, highs_solver, 'test_simple_flow_system') + effects = calculation.flow_system.effects -class TestSimple(BaseTest): - def setUp(self): - super().setUp() + # Cost assertions + assert_almost_equal_numeric( + effects['costs'].model.total.solution.item(), 81.88394666666667, 'costs doesnt match expected value' + ) - self.Q_th_Last = np.array([30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20]) - self.p_el = 1 / 1000 * np.array([80.0, 80.0, 80.0, 80, 80, 80, 80, 80, 80]) - self.aTimeSeries = datetime.datetime(2020, 1, 1) + np.arange(len(self.Q_th_Last)) * datetime.timedelta(hours=1) - self.aTimeSeries = self.aTimeSeries.astype('datetime64') + # CO2 assertions + assert_almost_equal_numeric( + effects['CO2'].model.total.solution.item(), 255.09184, 'CO2 doesnt match expected value' + ) - def test_model(self): - calculation = self.model() - effects = calculation.flow_system.effect_collection.effects + def test_model_components(self, simple_flow_system, highs_solver): + """ + Test the component flows of the simple energy system model + """ + calculation = create_calculation_and_solve(simple_flow_system, highs_solver, 'test_model_components') comps = calculation.flow_system.components - # Compare expected values with actual values - self.assert_almost_equal_numeric( - effects['costs'].model.all.sum.result, 81.88394666666667, 'costs doesnt match expected value' - ) - self.assert_almost_equal_numeric( - effects['CO2'].model.all.sum.result, 255.09184, 'CO2 doesnt match expected value' - ) - self.assert_almost_equal_numeric( - comps['Boiler'].Q_th.model.flow_rate.result, + # Boiler assertions + assert_almost_equal_numeric( + comps['Boiler'].Q_th.model.flow_rate.solution.values, [0, 0, 0, 28.4864, 35, 0, 0, 0, 0], 'Q_th doesnt match expected value', ) - self.assert_almost_equal_numeric( - comps['CHP_unit'].Q_th.model.flow_rate.result, + + # CHP unit assertions + assert_almost_equal_numeric( + comps['CHP_unit'].Q_th.model.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', ) - def test_from_results(self): - calculation = self.model(save_results=True) + def test_results_persistence(self, simple_flow_system, highs_solver): + """ + Test saving and loading results + """ + # Save results to file + calculation = create_calculation_and_solve(simple_flow_system, highs_solver, 'test_model_components') + + calculation.results.to_file() - results = fx.results.CalculationResults(calculation.name, 'results') + # Load results from file + results = fx.results.CalculationResults.from_file(calculation.folder, calculation.name) - # test effect results - self.assert_almost_equal_numeric( - results.effect_results['costs'].all_results['all']['all_sum'], + # Verify key variables from loaded results + assert_almost_equal_numeric( + results.solution['costs|total'].values, 81.88394666666667, 'costs doesnt match expected value', ) - self.assert_almost_equal_numeric( - results.effect_results['CO2'].all_results['all']['all_sum'], 255.09184, 'CO2 doesnt match expected value' - ) - self.assert_almost_equal_numeric( - results.component_results['Boiler'].variables_flat['Q_th__flow_rate'], - [0, 0, 0, 28.4864, 35, 0, 0, 0, 0], - 'Q_th doesnt match expected value', - ) - self.assert_almost_equal_numeric( - results.component_results['CHP_unit'].variables_flat['Q_th__flow_rate'], - [30.0, 26.66666667, 75.0, 75.0, 75.0, 20.0, 20.0, 20.0, 20.0], - 'Q_th doesnt match expected value', - ) + assert_almost_equal_numeric(results.solution['CO2|total'].values, 255.09184, 'CO2 doesnt match expected value') - df = results.to_dataframe('FernwƤrme', with_last_time_step=False) - comps = calculation.flow_system.components - self.assert_almost_equal_numeric( - comps['WƤrmelast'].sink.model.flow_rate.result, - df['WƤrmelast__Q_th_Last'], - 'Loaded Results and directly used results dont match, or loading didnt work properly', - ) - - def model(self, save_results=False) -> fx.FullCalculation: - # Define the components and flow_system - Strom = fx.Bus('Strom') - Fernwaerme = fx.Bus('FernwƤrme') - Gas = fx.Bus('Gas') - - costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) - CO2 = fx.Effect( - 'CO2', - 'kg', - 'CO2_e-Emissionen', - specific_share_to_other_effects_operation={costs: 0.2}, - maximum_operation_per_hour=1000, - ) - - aBoiler = fx.linear_converters.Boiler( - 'Boiler', - eta=0.5, - Q_th=fx.Flow( - 'Q_th', - bus=Fernwaerme, - size=50, - relative_minimum=5 / 50, - relative_maximum=1, - on_off_parameters=fx.OnOffParameters(), - ), - Q_fu=fx.Flow('Q_fu', bus=Gas), - ) - aKWK = fx.linear_converters.CHP( - 'CHP_unit', - eta_th=0.5, - eta_el=0.4, - P_el=fx.Flow('P_el', bus=Strom, size=60, relative_minimum=5 / 60, on_off_parameters=fx.OnOffParameters()), - Q_th=fx.Flow('Q_th', bus=Fernwaerme), - Q_fu=fx.Flow('Q_fu', bus=Gas), - ) - aSpeicher = fx.Storage( - 'Speicher', - charging=fx.Flow('Q_th_load', bus=Fernwaerme, size=1e4), - discharging=fx.Flow('Q_th_unload', bus=Fernwaerme, size=1e4), - capacity_in_flow_hours=fx.InvestParameters(fix_effects=20, fixed_size=30, optional=False), - initial_charge_state=0, - relative_maximum_charge_state=1 / 100 * np.array([80.0, 70.0, 80.0, 80, 80, 80, 80, 80, 80, 80]), - eta_charge=0.9, - eta_discharge=1, - relative_loss_per_hour=0.08, - prevent_simultaneous_charge_and_discharge=True, - ) - aWaermeLast = fx.Sink( - 'WƤrmelast', sink=fx.Flow('Q_th_Last', bus=Fernwaerme, size=1, fixed_relative_profile=self.Q_th_Last) - ) - aGasTarif = fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus=Gas, size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}) - ) - aStromEinspeisung = fx.Sink( - 'Einspeisung', sink=fx.Flow('P_el', bus=Strom, effects_per_flow_hour=-1 * self.p_el) - ) - - es = fx.FlowSystem(self.aTimeSeries, last_time_step_hours=None) - es.add_components(aSpeicher) - es.add_effects(costs, CO2) - es.add_components(aBoiler, aWaermeLast, aGasTarif) - es.add_components(aStromEinspeisung) - es.add_components(aKWK) - - time_indices = None - - print(es) - es.visualize_network() - - aCalc = fx.FullCalculation('Test_Sim', es, 'pyomo', time_indices) - aCalc.do_modeling() - - aCalc.solve(self.get_solver(), save_results=save_results) - - return aCalc - - -class TestComponents(BaseTest): - def setUp(self): - super().setUp() - self.Q_th_Last = np.array([np.random.random() for _ in range(10)]) * 180 - self.p_el = (np.array([np.random.random() for _ in range(10)]) + 0.5) / 1.5 * 50 - self.datetime_array = datetime.datetime(2020, 1, 1) + np.arange(len(self.Q_th_Last)) * datetime.timedelta( - hours=1 - ) - self.datetime_array = self.datetime_array.astype('datetime64') - - def create_basic_elements(self): - self.busses = {label: fx.Bus(label) for label in ['Strom', 'FernwƤrme', 'Gas']} - self.effects = {'Costs': fx.Effect('Costs', '€', 'Kosten', is_standard=True, is_objective=True)} - self.components = { - 'WƤrmelast': fx.Sink( - 'WƤrmelast', - sink=fx.Flow('Q_th_Last', bus=self.busses['FernwƤrme'], size=1, fixed_relative_profile=self.Q_th_Last), - ), - 'Gastarif': fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus=self.busses['Gas'], size=1000, effects_per_flow_hour=0.04) - ), - 'Einspeisung': fx.Sink( - 'Einspeisung', sink=fx.Flow('P_el', bus=self.busses['Strom'], effects_per_flow_hour=-1 * self.p_el) - ), - } - def test_transmission_basic(self): - self.create_basic_elements() - flow_system = fx.FlowSystem(self.datetime_array, last_time_step_hours=None) - flow_system.add_elements(*(list(self.effects.values()) + list(self.components.values()))) - extra_bus = fx.Bus('WƤrme lokal') +class TestComponents: + def test_transmission_basic(self, basic_flow_system, highs_solver): + """Test basic transmission functionality""" + flow_system = basic_flow_system + flow_system.add_elements(fx.Bus('WƤrme lokal')) + boiler = fx.linear_converters.Boiler( - 'Boiler', eta=0.5, Q_th=fx.Flow('Q_th', bus=extra_bus), Q_fu=fx.Flow('Q_fu', bus=self.busses['Gas']) + 'Boiler', eta=0.5, Q_th=fx.Flow('Q_th', bus='WƤrme lokal'), Q_fu=fx.Flow('Q_fu', bus='Gas') ) transmission = fx.Transmission( 'Rohr', relative_losses=0.2, absolute_losses=20, - in1=fx.Flow('Rohr1', extra_bus, size=fx.InvestParameters(specific_effects=5, maximum_size=1e6)), - out1=fx.Flow('Rohr2', self.busses['FernwƤrme'], size=1000), + in1=fx.Flow('Rohr1', 'WƤrme lokal', size=fx.InvestParameters(specific_effects=5, maximum_size=1e6)), + out1=fx.Flow('Rohr2', 'FernwƤrme', size=1000), ) flow_system.add_elements(transmission, boiler) - calculation = fx.FullCalculation('Test_Sim', flow_system) - calculation.do_modeling() - calculation.solve(self.get_solver()) - print(calculation.results()) - self.assert_almost_equal_numeric( - transmission.in1.model._on.on.result, np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), 'On does not work properly' + + _ = create_calculation_and_solve(flow_system, highs_solver, 'test_transmission_basic') + + # Assertions + assert_almost_equal_numeric( + transmission.in1.model.on_off.on.solution.values, + np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), + 'On does not work properly', ) - self.assert_almost_equal_numeric( - transmission.in1.model.flow_rate.result * 0.8 - 20, - transmission.out1.model.flow_rate.result, + assert_almost_equal_numeric( + transmission.in1.model.flow_rate.solution.values * 0.8 - 20, + transmission.out1.model.flow_rate.solution.values, 'Losses are not computed correctly', ) - def test_transmission_advanced(self): - self.create_basic_elements() - flow_system = fx.FlowSystem(self.datetime_array, last_time_step_hours=None) - flow_system.add_elements(*(list(self.effects.values()) + list(self.components.values()))) - extra_bus = fx.Bus('WƤrme lokal') + def test_transmission_advanced(self, basic_flow_system, highs_solver): + """Test advanced transmission functionality""" + flow_system = basic_flow_system + flow_system.add_elements(fx.Bus('WƤrme lokal')) boiler = fx.linear_converters.Boiler( 'Boiler_Standard', eta=0.9, - Q_th=fx.Flow( - 'Q_th', bus=self.busses['FernwƤrme'], relative_maximum=np.array([0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) - ), - Q_fu=fx.Flow('Q_fu', bus=self.busses['Gas']), + Q_th=fx.Flow('Q_th', bus='FernwƤrme', relative_maximum=np.array([0, 0, 0, 1, 1, 1, 1, 1, 1, 1])), + Q_fu=fx.Flow('Q_fu', bus='Gas'), ) boiler2 = fx.linear_converters.Boiler( - 'Boiler_backup', eta=0.4, Q_th=fx.Flow('Q_th', bus=extra_bus), Q_fu=fx.Flow('Q_fu', bus=self.busses['Gas']) + 'Boiler_backup', eta=0.4, Q_th=fx.Flow('Q_th', bus='WƤrme lokal'), Q_fu=fx.Flow('Q_fu', bus='Gas') ) last2 = fx.Sink( 'WƤrmelast2', sink=fx.Flow( 'Q_th_Last', - bus=extra_bus, + bus='WƤrme lokal', size=1, - fixed_relative_profile=self.Q_th_Last * np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]), + fixed_relative_profile=flow_system.components['WƤrmelast'].sink.fixed_relative_profile + * np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]), ), ) @@ -268,72 +139,56 @@ def test_transmission_advanced(self): 'Rohr', relative_losses=0.2, absolute_losses=20, - in1=fx.Flow('Rohr1a', bus=extra_bus, size=fx.InvestParameters(specific_effects=5, maximum_size=1000)), - out1=fx.Flow('Rohr1b', self.busses['FernwƤrme'], size=1000), - in2=fx.Flow('Rohr2a', self.busses['FernwƤrme'], size=1000), - out2=fx.Flow('Rohr2b', bus=extra_bus, size=1000), + in1=fx.Flow('Rohr1a', bus='WƤrme lokal', size=fx.InvestParameters(specific_effects=5, maximum_size=1000)), + out1=fx.Flow('Rohr1b', 'FernwƤrme', size=1000), + in2=fx.Flow('Rohr2a', 'FernwƤrme', size=1000), + out2=fx.Flow('Rohr2b', bus='WƤrme lokal', size=1000), ) flow_system.add_elements(transmission, boiler, boiler2, last2) - calculation = fx.FullCalculation('Test_Transmission', flow_system) - calculation.do_modeling() - calculation.solve(self.get_solver(), save_results=True) - results = fx.results.CalculationResults(calculation.name, 'results') - self.assert_almost_equal_numeric( - transmission.in1.model._on.on.result, np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), 'On does not work properly' + calculation = create_calculation_and_solve(flow_system, highs_solver, 'test_transmission_advanced') + + # Assertions + assert_almost_equal_numeric( + transmission.in1.model.on_off.on.solution.values, + np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), + 'On does not work properly', ) - self.assert_almost_equal_numeric( - results.to_dataframe('Rohr', with_last_time_step=False)['Rohr__Rohr1b'].values, - transmission.out1.model.flow_rate.result, + assert_almost_equal_numeric( + calculation.results.model.variables['Rohr(Rohr1b)|flow_rate'].solution.values, + transmission.out1.model.flow_rate.solution.values, 'Flow rate of Rohr__Rohr1b is not correct', ) - self.assert_almost_equal_numeric( - transmission.in1.model.flow_rate.result * 0.8 - - np.array([20 if val > 0.1 else 0 for val in transmission.in1.model.flow_rate.result]), - transmission.out1.model.flow_rate.result, + 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, 'Losses are not computed correctly', ) - self.assert_almost_equal_numeric( - transmission.in1.model._investment.size.result, - transmission.in2.model._investment.size.result, - 'THe Investments are not equated correctly', + assert_almost_equal_numeric( + transmission.in1.model._investment.size.solution.item(), + transmission.in2.model._investment.size.solution.item(), + 'The Investments are not equated correctly', ) - def tearDown(self): - self.busses = None - self.effects = None - self.components = None - self.datetime_array = None - self.Q_th_Last = None - self.p_el = None - self.datetime_array = None - -class TestComplex(BaseTest): - def setUp(self): - super().setUp() - self.Q_th_Last = np.array([30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20]) - self.P_el_Last = np.array([40.0, 40.0, 40.0, 40, 40, 40, 40, 40, 40]) - self.aTimeSeries = datetime.datetime(2020, 1, 1) + np.arange(len(self.Q_th_Last)) * datetime.timedelta(hours=1) - self.aTimeSeries = self.aTimeSeries.astype('datetime64') - self.excessCosts = None - self.useCHPwithLinearSegments = False +class TestComplex: + def test_basic_flow_system(self, flow_system_base, highs_solver): + calculation = create_calculation_and_solve(flow_system_base, highs_solver, 'test_basic_flow_system') - def test_basic(self): - calculation = self.basic_model() - effects = calculation.flow_system.effect_collection.effects - comps = calculation.flow_system.components - - # Compare expected values with actual values - self.assert_almost_equal_numeric( - effects['costs'].model.all.sum.result, -11597.873624489237, 'costs doesnt match expected value' + # Assertions + assert_almost_equal_numeric( + calculation.results.model['costs|total'].solution.item(), + -11597.873624489237, + 'costs doesnt match expected value', ) - self.assert_almost_equal_numeric( - effects['costs'].model.operation.sum_TS.result, + + assert_almost_equal_numeric( + calculation.results.model['costs(operation)|total_per_timestep'].solution.values, [ -2.38500000e03, -2.21681333e03, @@ -348,72 +203,67 @@ def test_basic(self): 'costs doesnt match expected value', ) - self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['CO2_operation'].result), + assert_almost_equal_numeric( + sum(calculation.results.model['CO2(operation)->costs(operation)'].solution.values), 258.63729669618675, 'costs doesnt match expected value', ) - self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Kessel__Q_th__switch_on_effects'].result), + assert_almost_equal_numeric( + sum(calculation.results.model['Kessel(Q_th)->costs(operation)'].solution.values), 0.01, 'costs doesnt match expected value', ) - self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Kessel__running_hour_effects'].result), + assert_almost_equal_numeric( + sum(calculation.results.model['Kessel->costs(operation)'].solution.values), -0.0, 'costs doesnt match expected value', ) - self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Gastarif__Q_Gas__effects_per_flow_hour'].result), + assert_almost_equal_numeric( + sum(calculation.results.model['Gastarif(Q_Gas)->costs(operation)'].solution.values), 39.09153113079115, 'costs doesnt match expected value', ) - self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['Einspeisung__P_el__effects_per_flow_hour'].result), + assert_almost_equal_numeric( + sum(calculation.results.model['Einspeisung(P_el)->costs(operation)'].solution.values), -14196.61245231646, 'costs doesnt match expected value', ) - self.assert_almost_equal_numeric( - sum(effects['costs'].model.operation.shares['KWK__switch_on_effects'].result), + assert_almost_equal_numeric( + sum(calculation.results.model['KWK->costs(operation)'].solution.values), 0.0, 'costs doesnt match expected value', ) - self.assert_almost_equal_numeric( - effects['costs'].model.invest.shares['Kessel__Q_th__fix_effects'].result, - 1000, - 'costs doesnt match expected value', - ) - self.assert_almost_equal_numeric( - effects['costs'].model.invest.shares['Kessel__Q_th__specific_effects'].result, - 500, + assert_almost_equal_numeric( + calculation.results.model['Kessel(Q_th)->costs(invest)'].solution.values, + 1000 + 500, 'costs doesnt match expected value', ) - self.assert_almost_equal_numeric( - effects['costs'].model.invest.shares['Speicher__specific_effects'].result, - 1, - 'costs doesnt match expected value', - ) - self.assert_almost_equal_numeric( - effects['costs'].model.invest.shares['Speicher__segmented_effects'].result, - 800, + + assert_almost_equal_numeric( + calculation.results.model['Speicher->costs(invest)'].solution.values, + 800 + 1, 'costs doesnt match expected value', ) - self.assert_almost_equal_numeric( - effects['CO2'].model.all.shares['operation'].result, 1293.1864834809337, 'CO2 doesnt match expected value' + assert_almost_equal_numeric( + calculation.results.model['CO2(operation)|total'].solution.values, + 1293.1864834809337, + 'CO2 doesnt match expected value', ) - self.assert_almost_equal_numeric( - effects['CO2'].model.all.shares['invest'].result, 0.9999999999999994, 'CO2 doesnt match expected value' + assert_almost_equal_numeric( + calculation.results.model['CO2(invest)|total'].solution.values, + 0.9999999999999994, + 'CO2 doesnt match expected value', ) - self.assert_almost_equal_numeric( - comps['Kessel'].Q_th.model.flow_rate.result, + assert_almost_equal_numeric( + calculation.results.model['Kessel(Q_th)|flow_rate'].solution.values, [0, 0, 0, 45, 0, 0, 0, 0, 0], 'Kessel doesnt match expected value', ) - self.assert_almost_equal_numeric( - comps['KWK'].Q_th.model.flow_rate.result, + assert_almost_equal_numeric( + calculation.results.model['KWK(Q_th)|flow_rate'].solution.values, [ 7.50000000e01, 6.97111111e01, @@ -427,8 +277,8 @@ def test_basic(self): ], 'KWK Q_th doesnt match expected value', ) - self.assert_almost_equal_numeric( - comps['KWK'].P_el.model.flow_rate.result, + assert_almost_equal_numeric( + calculation.results.model['KWK(P_el)|flow_rate'].solution.values, [ 6.00000000e01, 5.57688889e01, @@ -443,415 +293,92 @@ def test_basic(self): 'KWK P_el doesnt match expected value', ) - self.assert_almost_equal_numeric( - comps['Speicher'].model.netto_discharge.result, + assert_almost_equal_numeric( + calculation.results.model['Speicher|netto_discharge'].solution.values, [-45.0, -69.71111111, 15.0, -10.0, 36.06697198, -55.0, 20.0, 20.0, 20.0], 'Speicher nettoFlow doesnt match expected value', ) - self.assert_almost_equal_numeric( - comps['Speicher'].model.charge_state.result, + assert_almost_equal_numeric( + calculation.results.model['Speicher|charge_state'].solution.values, [0.0, 40.5, 100.0, 77.0, 79.84, 37.38582802, 83.89496178, 57.18336484, 32.60869565, 10.0], 'Speicher nettoFlow doesnt match expected value', ) - self.assert_almost_equal_numeric( - comps['Speicher'].model.results()['Investment']['SegmentedShares']['costs_segmented'], + assert_almost_equal_numeric( + calculation.results.model['Speicher|PiecewiseEffects|costs'].solution.values, 800, - 'Speicher investCosts_segmented_costs doesnt match expected value', + 'Speicher|PiecewiseEffects|costs doesnt match expected value', + ) + + def test_piecewise_conversion(self, flow_system_piecewise_conversion, highs_solver): + calculation = create_calculation_and_solve( + flow_system_piecewise_conversion, highs_solver, 'test_piecewise_conversion' ) - def test_segments_of_flows(self): - calculation = self.segments_of_flows_model() - effects = calculation.flow_system.effect_collection.effects + effects = calculation.flow_system.effects comps = calculation.flow_system.components # Compare expected values with actual values - self.assert_almost_equal_numeric( - effects['costs'].model.all.sum.result, -10710.997365760755, 'costs doesnt match expected value' + assert_almost_equal_numeric( + effects['costs'].model.total.solution.item(), -10710.997365760755, 'costs doesnt match expected value' ) - self.assert_almost_equal_numeric( - effects['CO2'].model.all.sum.result, 1278.7939026086956, 'CO2 doesnt match expected value' + assert_almost_equal_numeric( + effects['CO2'].model.total.solution.item(), 1278.7939026086956, 'CO2 doesnt match expected value' ) - self.assert_almost_equal_numeric( - comps['Kessel'].Q_th.model.flow_rate.result, + assert_almost_equal_numeric( + comps['Kessel'].Q_th.model.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} - self.assert_almost_equal_numeric( - kwk_flows['Q_th'].model.flow_rate.result, + assert_almost_equal_numeric( + kwk_flows['Q_th'].model.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', ) - self.assert_almost_equal_numeric( - kwk_flows['P_el'].model.flow_rate.result, + assert_almost_equal_numeric( + kwk_flows['P_el'].model.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', ) - self.assert_almost_equal_numeric( - comps['Speicher'].model.netto_discharge.result, + assert_almost_equal_numeric( + comps['Speicher'].model.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', ) - self.assert_almost_equal_numeric( - comps['Speicher'].model.results()['Investment']['SegmentedShares']['costs_segmented'], + assert_almost_equal_numeric( + comps['Speicher'].model.variables['Speicher|PiecewiseEffects|costs'].solution.values, 454.74666666666667, 'Speicher investCosts_segmented_costs doesnt match expected value', ) - def basic_model(self) -> fx.FullCalculation: - # Define the components and flow_system - Strom = fx.Bus('Strom', excess_penalty_per_flow_hour=self.excessCosts) - Fernwaerme = fx.Bus('FernwƤrme', excess_penalty_per_flow_hour=self.excessCosts) - Gas = fx.Bus('Gas', excess_penalty_per_flow_hour=self.excessCosts) - - costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) - CO2 = fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={costs: 0.2}) - PE = fx.Effect('PE', 'kWh_PE', 'PrimƤrenergie', maximum_total=3.5e3) - - aGaskessel = fx.linear_converters.Boiler( - 'Kessel', - eta=0.5, - on_off_parameters=fx.OnOffParameters(effects_per_running_hour={costs: 0, CO2: 1000}), - Q_th=fx.Flow( - 'Q_th', - bus=Fernwaerme, - load_factor_max=1.0, - load_factor_min=0.1, - relative_minimum=5 / 50, - relative_maximum=1, - previous_flow_rate=50, - size=fx.InvestParameters( - fix_effects=1000, fixed_size=50, optional=False, specific_effects={costs: 10, PE: 2} - ), - on_off_parameters=fx.OnOffParameters( - on_hours_total_min=0, - on_hours_total_max=1000, - consecutive_on_hours_max=10, - consecutive_on_hours_min=1, - consecutive_off_hours_max=10, - effects_per_switch_on=0.01, - switch_on_total_max=1000, - ), - flow_hours_total_max=1e6, - ), - Q_fu=fx.Flow('Q_fu', bus=Gas, size=200, relative_minimum=0, relative_maximum=1), - ) - - aKWK = fx.linear_converters.CHP( - 'KWK', - eta_th=0.5, - eta_el=0.4, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - P_el=fx.Flow('P_el', bus=Strom, size=60, relative_minimum=5 / 60, previous_flow_rate=10), - Q_th=fx.Flow('Q_th', bus=Fernwaerme, size=1e3), - Q_fu=fx.Flow('Q_fu', bus=Gas, size=1e3), - ) - - costsInvestsizeSegments = ([(5, 25), (25, 100)], {costs: [(50, 250), (250, 800)], PE: [(5, 25), (25, 100)]}) - invest_Speicher = fx.InvestParameters( - fix_effects=0, - effects_in_segments=costsInvestsizeSegments, - optional=False, - specific_effects={costs: 0.01, CO2: 0.01}, - minimum_size=0, - maximum_size=1000, - ) - aSpeicher = fx.Storage( - 'Speicher', - charging=fx.Flow('Q_th_load', bus=Fernwaerme, size=1e4), - discharging=fx.Flow('Q_th_unload', bus=Fernwaerme, size=1e4), - capacity_in_flow_hours=invest_Speicher, - initial_charge_state=0, - maximal_final_charge_state=10, - eta_charge=0.9, - eta_discharge=1, - relative_loss_per_hour=0.08, - prevent_simultaneous_charge_and_discharge=True, - ) - - aWaermeLast = fx.Sink( - 'WƤrmelast', - sink=fx.Flow( - 'Q_th_Last', bus=Fernwaerme, size=1, relative_minimum=0, fixed_relative_profile=self.Q_th_Last - ), - ) - aGasTarif = fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus=Gas, size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}) - ) - aStromEinspeisung = fx.Sink( - 'Einspeisung', sink=fx.Flow('P_el', bus=Strom, effects_per_flow_hour=-1 * np.array(self.P_el_Last)) - ) - - es = fx.FlowSystem(self.aTimeSeries, last_time_step_hours=None) - es.add_effects(costs, CO2, PE) - es.add_components(aGaskessel, aWaermeLast, aGasTarif, aStromEinspeisung, aKWK, aSpeicher) - - print(es) - es.visualize_network() - - aCalc = fx.FullCalculation('Sim1', es, 'pyomo', None) - aCalc.do_modeling() - - aCalc.solve(self.get_solver()) - - return aCalc - - def segments_of_flows_model(self): - # Define the components and flow_system - Strom = fx.Bus('Strom', excess_penalty_per_flow_hour=self.excessCosts) - Fernwaerme = fx.Bus('FernwƤrme', excess_penalty_per_flow_hour=self.excessCosts) - Gas = fx.Bus('Gas', excess_penalty_per_flow_hour=self.excessCosts) - - costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) - CO2 = fx.Effect('CO2', 'kg', 'CO2_e-Emissionen', specific_share_to_other_effects_operation={costs: 0.2}) - PE = fx.Effect('PE', 'kWh_PE', 'PrimƤrenergie', maximum_total=3.5e3) - - invest_Gaskessel = fx.InvestParameters( - fix_effects=1000, fixed_size=50, optional=False, specific_effects={costs: 10, PE: 2} - ) - aGaskessel = fx.linear_converters.Boiler( - 'Kessel', - eta=0.5, - on_off_parameters=fx.OnOffParameters(effects_per_running_hour={costs: 0, CO2: 1000}), - Q_th=fx.Flow( - 'Q_th', - bus=Fernwaerme, - size=invest_Gaskessel, - load_factor_max=1.0, - load_factor_min=0.1, - relative_minimum=5 / 50, - relative_maximum=1, - on_off_parameters=fx.OnOffParameters( - on_hours_total_min=0, - on_hours_total_max=1000, - consecutive_on_hours_max=10, - consecutive_off_hours_max=10, - effects_per_switch_on=0.01, - switch_on_total_max=1000, - ), - previous_flow_rate=50, - flow_hours_total_max=1e6, - ), - Q_fu=fx.Flow('Q_fu', bus=Gas, size=200, relative_minimum=0, relative_maximum=1), - ) - - P_el = fx.Flow('P_el', bus=Strom, size=60, relative_maximum=55, previous_flow_rate=10) - Q_th = fx.Flow('Q_th', bus=Fernwaerme) - Q_fu = fx.Flow('Q_fu', bus=Gas) - segmented_conversion_factors = { - P_el: [(5, 30), (40, 60)], - Q_th: [(6, 35), (45, 100)], - Q_fu: [(12, 70), (90, 200)], - } - aKWK = fx.LinearConverter( - 'KWK', - inputs=[Q_fu], - outputs=[P_el, Q_th], - segmented_conversion_factors=segmented_conversion_factors, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), - ) - - costsInvestsizeSegments = ([(5, 25), (25, 100)], {costs: [(50, 250), (250, 800)], PE: [(5, 25), (25, 100)]}) - invest_Speicher = fx.InvestParameters( - fix_effects=0, - effects_in_segments=costsInvestsizeSegments, - optional=False, - specific_effects={costs: 0.01, CO2: 0.01}, - minimum_size=0, - maximum_size=1000, - ) - aSpeicher = fx.Storage( - 'Speicher', - charging=fx.Flow('Q_th_load', bus=Fernwaerme, size=1e4), - discharging=fx.Flow('Q_th_unload', bus=Fernwaerme, size=1e4), - capacity_in_flow_hours=invest_Speicher, - initial_charge_state=0, - maximal_final_charge_state=10, - eta_charge=0.9, - eta_discharge=1, - relative_loss_per_hour=0.08, - prevent_simultaneous_charge_and_discharge=True, - ) - - aWaermeLast = fx.Sink( - 'WƤrmelast', - sink=fx.Flow( - 'Q_th_Last', bus=Fernwaerme, size=1, relative_minimum=0, fixed_relative_profile=self.Q_th_Last - ), - ) - aGasTarif = fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus=Gas, size=1000, effects_per_flow_hour={costs: 0.04, CO2: 0.3}) - ) - aStromEinspeisung = fx.Sink( - 'Einspeisung', sink=fx.Flow('P_el', bus=Strom, effects_per_flow_hour=-1 * np.array(self.P_el_Last)) - ) - - es = fx.FlowSystem(self.aTimeSeries, last_time_step_hours=None) - es.add_effects(costs, CO2, PE) - es.add_components(aGaskessel, aWaermeLast, aGasTarif, aStromEinspeisung, aKWK) - es.add_components(aSpeicher) - print(es) - es.visualize_network() - - aCalc = fx.FullCalculation('Sim1', es, 'pyomo', None) - aCalc.do_modeling() - - aCalc.solve(self.get_solver()) - - return aCalc - - -class TestModelingTypes(BaseTest): - def setUp(self): - super().setUp() - self.Q_th_Last = np.array([30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20]) - self.p_el = 1 / 1000 * np.array([80.0, 80.0, 80.0, 80, 80, 80, 80, 80, 80]) - self.aTimeSeries = ( - datetime.datetime(2020, 1, 1) + np.arange(len(self.Q_th_Last)) * datetime.timedelta(hours=1) - ).astype('datetime64') - self.max_emissions_per_hour = 1000 - - def test_full(self): - calculation = self.calculate('full') - effects = calculation.flow_system.effect_collection.effects - self.assert_almost_equal_numeric( - effects['costs'].model.all.sum.result, 343613, 'costs doesnt match expected value' - ) - - def test_aggregated(self): - calculation = self.calculate('aggregated') - effects = calculation.flow_system.effect_collection.effects - self.assert_almost_equal_numeric( - effects['costs'].model.all.sum.result, 342967.0, 'costs doesnt match expected value' - ) - - def test_segmented(self): - calculation = self.calculate('segmented') - self.assert_almost_equal_numeric( - sum(calculation.results(combined_arrays=True)['Effects']['costs']['operation']['operation_sum_TS']), - 343613, - 'costs doesnt match expected value', - ) - - def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): - doFullCalc, doSegmentedCalc, doAggregatedCalc = ( - modeling_type == 'full', - modeling_type == 'segmented', - modeling_type == 'aggregated', - ) - if not any([doFullCalc, doSegmentedCalc, doAggregatedCalc]): - raise Exception('Unknown modeling type') - - filename = os.path.join(os.path.dirname(__file__), 'ressources', 'Zeitreihen2020.csv') - ts_raw = pd.read_csv(filename, index_col=0).sort_index() - data = ts_raw['2020-01-01 00:00:00':'2020-12-31 23:45:00']['2020-01-01':'2020-01-03 23:45:00'] - P_el_Last, Q_th_Last, p_el, gP = ( - data['P_Netz/MW'].values, - data['Q_Netz/MW'].values, - data['Strompr.€/MWh'].values, - data['Gaspr.€/MWh'].values, - ) - aTimeSeries = ( - datetime.datetime(2020, 1, 1) + np.arange(len(P_el_Last)) * datetime.timedelta(hours=0.25) - ).astype('datetime64') - - Strom, Fernwaerme, Gas, Kohle = fx.Bus('Strom'), fx.Bus('FernwƤrme'), fx.Bus('Gas'), fx.Bus('Kohle') - costs, CO2, PE = ( - fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), - fx.Effect('CO2', 'kg', 'CO2_e-Emissionen'), - fx.Effect('PE', 'kWh_PE', 'PrimƤrenergie'), - ) - - aGaskessel = fx.linear_converters.Boiler( - 'Kessel', - eta=0.85, - Q_th=fx.Flow(label='Q_th', bus=Fernwaerme), - Q_fu=fx.Flow( - label='Q_fu', - bus=Gas, - size=95, - relative_minimum=12 / 95, - previous_flow_rate=0, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=1000), - ), - ) - aKWK = fx.linear_converters.CHP( - 'BHKW2', - eta_th=0.58, - eta_el=0.22, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=24000), - P_el=fx.Flow('P_el', bus=Strom), - Q_th=fx.Flow('Q_th', bus=Fernwaerme), - Q_fu=fx.Flow('Q_fu', bus=Kohle, size=288, relative_minimum=87 / 288), - ) - aSpeicher = fx.Storage( - 'Speicher', - charging=fx.Flow('Q_th_load', size=137, bus=Fernwaerme), - discharging=fx.Flow('Q_th_unload', size=158, bus=Fernwaerme), - capacity_in_flow_hours=684, - initial_charge_state=137, - minimal_final_charge_state=137, - maximal_final_charge_state=158, - eta_charge=1, - eta_discharge=1, - relative_loss_per_hour=0.001, - prevent_simultaneous_charge_and_discharge=True, - ) - - TS_Q_th_Last, TS_P_el_Last = fx.TimeSeriesData(Q_th_Last), fx.TimeSeriesData(P_el_Last, agg_weight=0.7) - aWaermeLast, aStromLast = ( - fx.Sink( - 'WƤrmelast', sink=fx.Flow('Q_th_Last', bus=Fernwaerme, size=1, fixed_relative_profile=TS_Q_th_Last) - ), - fx.Sink('Stromlast', sink=fx.Flow('P_el_Last', bus=Strom, size=1, fixed_relative_profile=TS_P_el_Last)), - ) - aKohleTarif, aGasTarif = ( - fx.Source( - 'Kohletarif', - source=fx.Flow('Q_Kohle', bus=Kohle, size=1000, effects_per_flow_hour={costs: 4.6, CO2: 0.3}), - ), - fx.Source( - 'Gastarif', source=fx.Flow('Q_Gas', bus=Gas, size=1000, effects_per_flow_hour={costs: gP, CO2: 0.3}) - ), - ) - - p_feed_in, p_sell = ( - fx.TimeSeriesData(-(p_el - 0.5), agg_group='p_el'), - fx.TimeSeriesData(p_el + 0.5, agg_group='p_el'), - ) - aStromEinspeisung, aStromTarif = ( - fx.Sink('Einspeisung', sink=fx.Flow('P_el', bus=Strom, size=1000, effects_per_flow_hour=p_feed_in)), - fx.Source( - 'Stromtarif', - source=fx.Flow('P_el', bus=Strom, size=1000, effects_per_flow_hour={costs: p_sell, CO2: 0.3}), - ), - ) - - es = fx.FlowSystem(aTimeSeries, last_time_step_hours=None) - es.add_effects(costs, CO2, PE) - es.add_components( - aGaskessel, aWaermeLast, aStromLast, aGasTarif, aKohleTarif, aStromEinspeisung, aStromTarif, aKWK, aSpeicher - ) - - print(es) - es.visualize_network() - - if doFullCalc: - calc = fx.FullCalculation('fullModel', es, 'pyomo') +@pytest.mark.slow +class TestModelingTypes: + @pytest.fixture(params=['full', 'segmented', 'aggregated']) + def modeling_calculation(self, request, flow_system_long, highs_solver): + """ + Fixture to run calculations with different modeling types + """ + # Extract flow system and data from the fixture + flow_system = flow_system_long[0] + thermal_load_ts = flow_system_long[1]['thermal_load_ts'] + electrical_load_ts = flow_system_long[1]['electrical_load_ts'] + + # Create calculation based on modeling type + modeling_type = request.param + if modeling_type == 'full': + calc = fx.FullCalculation('fullModel', flow_system) calc.do_modeling() - calc.solve(self.get_solver(), save_results=True) - elif doSegmentedCalc: - calc = fx.SegmentedCalculation( - 'segModel', es, segment_length=96, overlap_length=1, modeling_language='pyomo' - ) - calc.do_modeling_and_solve(self.get_solver(), save_results=True) - elif doAggregatedCalc: + calc.solve(highs_solver) + elif modeling_type == 'segmented': + calc = fx.SegmentedCalculation('segModel', flow_system, timesteps_per_segment=96, overlap_timesteps=1) + calc.do_modeling_and_solve(highs_solver) + elif modeling_type == 'aggregated': calc = fx.AggregatedCalculation( 'aggModel', - es, + flow_system, fx.AggregationParameters( hours_per_period=6, nr_of_periods=4, @@ -859,19 +386,40 @@ def calculate(self, modeling_type: Literal['full', 'segmented', 'aggregated']): aggregate_data_and_fix_non_binary_vars=True, percentage_of_period_freedom=0, penalty_of_period_freedom=0, - time_series_for_low_peaks=[TS_P_el_Last, TS_Q_th_Last], - time_series_for_high_peaks=[TS_Q_th_Last], + time_series_for_low_peaks=[electrical_load_ts, thermal_load_ts], + time_series_for_high_peaks=[thermal_load_ts], ), ) calc.do_modeling() - print(es) - es.visualize_network() - calc.solve(self.get_solver(), save_results=True) - else: - raise Exception('Wrong Modeling Type') + calc.solve(highs_solver) - return calc + return calc, modeling_type + + def test_modeling_types_costs(self, modeling_calculation): + """ + Test total costs for different modeling types + """ + calc, modeling_type = modeling_calculation + + expected_costs = { + 'full': 343613, + 'segmented': 343613, # Approximate value + 'aggregated': 342967.0, + } + + if modeling_type in ['full', 'aggregated']: + assert_almost_equal_numeric( + calc.results.model['costs|total'].solution.item(), + expected_costs[modeling_type], + f'Costs do not match for {modeling_type} modeling type', + ) + else: + assert_almost_equal_numeric( + calc.results.solution_without_overlap('costs(operation)|total_per_timestep').sum(), + expected_costs[modeling_type], + f'Costs do not match for {modeling_type} modeling type', + ) if __name__ == '__main__': - pytest.main(['-v', '--disable-warnings']) + pytest.main(['-v']) diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 000000000..2e6c61ccf --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,65 @@ +from typing import Dict, List, Optional, Union + +import pytest + +import flixopt as fx +from flixopt.io import CalculationResultsPaths + +from .conftest import ( + assert_almost_equal_numeric, + flow_system_base, + flow_system_long, + flow_system_segments_of_flows_2, + simple_flow_system, +) + + +@pytest.fixture(params=[flow_system_base, flow_system_segments_of_flows_2, simple_flow_system, flow_system_long]) +def flow_system(request): + fs = request.getfixturevalue(request.param.__name__) + if isinstance(fs, fx.FlowSystem): + return fs + else: + return fs[0] + +@pytest.mark.slow +def test_flow_system_file_io(flow_system, highs_solver): + calculation_0 = fx.FullCalculation('IO', flow_system=flow_system) + calculation_0.do_modeling() + calculation_0.solve(highs_solver) + + calculation_0.results.to_file() + paths = CalculationResultsPaths(calculation_0.folder, calculation_0.name) + flow_system_1 = fx.FlowSystem.from_netcdf(paths.flow_system) + + calculation_1 = fx.FullCalculation('Loaded_IO', flow_system=flow_system_1) + calculation_1.do_modeling() + calculation_1.solve(highs_solver) + + assert_almost_equal_numeric( + calculation_0.results.model.objective.value, + calculation_1.results.model.objective.value, + 'objective of loaded flow_system doesnt match the original', + ) + + assert_almost_equal_numeric( + calculation_0.results.solution['costs|total'].values, + calculation_1.results.solution['costs|total'].values, + 'costs doesnt match expected value', + ) + + +def test_flow_system_io(flow_system): + di = flow_system.as_dict() + _ = fx.FlowSystem.from_dict(di) + + ds = flow_system.as_dataset() + _ = fx.FlowSystem.from_dataset(ds) + + print(flow_system) + flow_system.__repr__() + flow_system.__str__() + + +if __name__ == '__main__': + pytest.main(['-v', '--disable-warnings']) diff --git a/tests/test_linear_converter.py b/tests/test_linear_converter.py new file mode 100644 index 000000000..aaab60dcc --- /dev/null +++ b/tests/test_linear_converter.py @@ -0,0 +1,556 @@ +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +import flixopt as fx +from flixopt.features import PiecewiseModel + +from .conftest import assert_conequal, assert_var_equal, create_linopy_model + + +class TestLinearConverterModel: + """Test the LinearConverterModel class.""" + + def test_basic_linear_converter(self, basic_flow_system_linopy): + """Test basic initialization and modeling of a LinearConverter.""" + flow_system = basic_flow_system_linopy + + # Create input and output flows + input_flow = fx.Flow('input', bus='input_bus', size=100) + output_flow = fx.Flow('output', bus='output_bus', size=100) + + # Create a simple linear converter with constant conversion factor + converter = fx.LinearConverter( + label='Converter', + inputs=[input_flow], + outputs=[output_flow], + conversion_factors=[{input_flow.label: 0.8, output_flow.label: 1.0}] + ) + + # Add to flow system + flow_system.add_elements( + fx.Bus('input_bus'), + fx.Bus('output_bus'), + converter + ) + + # Create model + model = create_linopy_model(flow_system) + + # Check variables and constraints + assert 'Converter(input)|flow_rate' in model.variables + assert 'Converter(output)|flow_rate' in model.variables + assert 'Converter|conversion_0' in model.constraints + + # 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 + ) + + def test_linear_converter_time_varying(self, basic_flow_system_linopy): + """Test a LinearConverter with time-varying conversion factors.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + # Create time-varying efficiency (e.g., temperature-dependent) + varying_efficiency = np.linspace(0.7, 0.9, len(timesteps)) + efficiency_series = xr.DataArray(varying_efficiency, coords=(timesteps,)) + + # Create input and output flows + input_flow = fx.Flow('input', bus='input_bus', size=100) + output_flow = fx.Flow('output', bus='output_bus', size=100) + + # Create a linear converter with time-varying conversion factor + converter = fx.LinearConverter( + label='Converter', + inputs=[input_flow], + outputs=[output_flow], + conversion_factors=[{input_flow.label: efficiency_series, output_flow.label: 1.0}] + ) + + # Add to flow system + flow_system.add_elements( + fx.Bus('input_bus'), + fx.Bus('output_bus'), + converter + ) + + # Create model + model = create_linopy_model(flow_system) + + # Check variables and constraints + assert 'Converter(input)|flow_rate' in model.variables + assert 'Converter(output)|flow_rate' in model.variables + assert 'Converter|conversion_0' in model.constraints + + # 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 + ) + + def test_linear_converter_multiple_factors(self, basic_flow_system_linopy): + """Test a LinearConverter with multiple conversion factors.""" + flow_system = basic_flow_system_linopy + + # Create flows + input_flow1 = fx.Flow('input1', bus='input_bus1', size=100) + input_flow2 = fx.Flow('input2', bus='input_bus2', size=100) + output_flow1 = fx.Flow('output1', bus='output_bus1', size=100) + output_flow2 = fx.Flow('output2', bus='output_bus2', size=100) + + # Create a linear converter with multiple inputs/outputs and conversion factors + converter = fx.LinearConverter( + label='Converter', + inputs=[input_flow1, input_flow2], + outputs=[output_flow1, output_flow2], + conversion_factors=[ + {input_flow1.label: 0.8, output_flow1.label: 1.0}, # input1 -> output1 + {input_flow2.label: 0.5, output_flow2.label: 1.0}, # input2 -> output2 + {input_flow1.label: 0.2, output_flow2.label: 0.3} # input1 contributes to output2 + ] + ) + + # Add to flow system + flow_system.add_elements( + fx.Bus('input_bus1'), + fx.Bus('input_bus2'), + fx.Bus('output_bus1'), + fx.Bus('output_bus2'), + converter + ) + + # Create model + model = create_linopy_model(flow_system) + + # Check constraints for each conversion factor + assert 'Converter|conversion_0' in model.constraints + assert 'Converter|conversion_1' in model.constraints + assert 'Converter|conversion_2' in model.constraints + + # 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 + ) + + # 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 + ) + + # 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 + ) + + def test_linear_converter_with_on_off(self, basic_flow_system_linopy): + """Test a LinearConverter with OnOffParameters.""" + flow_system = basic_flow_system_linopy + + # Create input and output flows + input_flow = fx.Flow('input', bus='input_bus', size=100) + output_flow = fx.Flow('output', bus='output_bus', size=100) + + # Create OnOffParameters + on_off_params = fx.OnOffParameters( + on_hours_total_min=10, + on_hours_total_max=40, + effects_per_running_hour={'Costs': 5} + ) + + # Create a linear converter with OnOffParameters + converter = fx.LinearConverter( + label='Converter', + inputs=[input_flow], + outputs=[output_flow], + conversion_factors=[{input_flow.label: 0.8, output_flow.label: 1.0}], + on_off_parameters=on_off_params + ) + + # Add to flow system + flow_system.add_elements( + fx.Bus('input_bus'), + fx.Bus('output_bus'), + converter, + ) + + # Create model + model = create_linopy_model(flow_system) + + # Verify OnOff variables and constraints + assert 'Converter|on' in model.variables + assert 'Converter|on_hours_total' in model.variables + + # 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() + ) + + # Check conversion constraint + assert_conequal( + model.constraints['Converter|conversion_0'], + input_flow.model.flow_rate * 0.8 == output_flow.model.flow_rate * 1.0 + ) + + # Check on_off effects + assert 'Converter->Costs(operation)' in model.constraints + assert_conequal( + model.constraints['Converter->Costs(operation)'], + model.variables['Converter->Costs(operation)'] == + converter.model.on_off.variables['Converter|on'] * model.hours_per_step * 5 + ) + + def test_linear_converter_multidimensional(self, basic_flow_system_linopy): + """Test LinearConverter with multiple inputs, outputs, and connections between them.""" + flow_system = basic_flow_system_linopy + + # Create a more complex setup with multiple flows + input_flow1 = fx.Flow('fuel', bus='fuel_bus', size=100) + input_flow2 = fx.Flow('electricity', bus='electricity_bus', size=50) + output_flow1 = fx.Flow('heat', bus='heat_bus', size=70) + output_flow2 = fx.Flow('cooling', bus='cooling_bus', size=30) + + # Create a CHP-like converter with more complex connections + converter = fx.LinearConverter( + label='MultiConverter', + inputs=[input_flow1, input_flow2], + outputs=[output_flow1, output_flow2], + conversion_factors=[ + # Fuel to heat (primary) + {input_flow1.label: 0.7, output_flow1.label: 1.0}, + # Electricity to cooling + {input_flow2.label: 0.3, output_flow2.label: 1.0}, + # Fuel also contributes to cooling + {input_flow1.label: 0.1, output_flow2.label: 0.5} + ] + ) + + # Add to flow system + flow_system.add_elements( + fx.Bus('fuel_bus'), + fx.Bus('electricity_bus'), + fx.Bus('heat_bus'), + fx.Bus('cooling_bus'), + converter + ) + + # Create model + model = create_linopy_model(flow_system) + + # Check all expected constraints + assert 'MultiConverter|conversion_0' in model.constraints + assert 'MultiConverter|conversion_1' in model.constraints + assert 'MultiConverter|conversion_2' in model.constraints + + # 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 + ) + + assert_conequal( + model.constraints['MultiConverter|conversion_1'], + input_flow2.model.flow_rate * 0.3 == output_flow2.model.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 + ) + + def test_edge_case_time_varying_conversion(self, basic_flow_system_linopy): + """Test edge case with extreme time-varying conversion factors.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + # Create fluctuating conversion efficiency (e.g., for a heat pump) + # Values range from very low (0.1) to very high (5.0) + fluctuating_cop = np.concatenate([ + np.linspace(0.1, 1.0, len(timesteps)//3), + np.linspace(1.0, 5.0, len(timesteps)//3), + np.linspace(5.0, 0.1, len(timesteps)//3 + len(timesteps)%3) + ]) + + # Create input and output flows + input_flow = fx.Flow('electricity', bus='electricity_bus', size=100) + output_flow = fx.Flow('heat', bus='heat_bus', size=500) # Higher maximum to allow for COP of 5 + + conversion_factors = [{ + input_flow.label: fluctuating_cop, + output_flow.label: np.ones(len(timesteps)) + }] + + # Create the converter + converter = fx.LinearConverter( + label='VariableConverter', + inputs=[input_flow], + outputs=[output_flow], + conversion_factors=conversion_factors + ) + + # Add to flow system + flow_system.add_elements( + fx.Bus('electricity_bus'), + fx.Bus('heat_bus'), + converter + ) + + # Create model + model = create_linopy_model(flow_system) + + # Check that the correct constraint was created + assert 'VariableConverter|conversion_0' in model.constraints + + # 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 + ) + + def test_piecewise_conversion(self, basic_flow_system_linopy): + """Test a LinearConverter with PiecewiseConversion.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + # Create input and output flows + input_flow = fx.Flow('input', bus='input_bus', size=100) + output_flow = fx.Flow('output', bus='output_bus', size=100) + + # Create pieces for piecewise conversion + # For input flow: two pieces from 0-50 and 50-100 + input_pieces = [ + fx.Piece(start=0, end=50), + fx.Piece(start=50, end=100) + ] + + # For output flow: two pieces from 0-30 and 30-90 + output_pieces = [ + fx.Piece(start=0, end=30), + fx.Piece(start=30, end=90) + ] + + # Create piecewise conversion + piecewise_conversion = fx.PiecewiseConversion({ + input_flow.label: fx.Piecewise(input_pieces), + output_flow.label: fx.Piecewise(output_pieces) + }) + + # Create a linear converter with piecewise conversion + converter = fx.LinearConverter( + label='Converter', + inputs=[input_flow], + outputs=[output_flow], + piecewise_conversion=piecewise_conversion + ) + + # Add to flow system + flow_system.add_elements( + fx.Bus('input_bus'), + fx.Bus('output_bus'), + converter + ) + + # 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 + + # Get the PiecewiseModel instance + piecewise_model = converter.model.piecewise_conversion + + # Check that we have the expected pieces (2 in this case) + assert len(piecewise_model.pieces) == 2 + + # Verify that variables were created for each piece + for i, _ in enumerate(piecewise_model.pieces): + # Each piece should have lambda0, lambda1, and inside_piece variables + assert f'Converter|Piece_{i}|lambda0' in model.variables + assert f'Converter|Piece_{i}|lambda1' in model.variables + assert f'Converter|Piece_{i}|inside_piece' in model.variables + lambda0 = model.variables[f'Converter|Piece_{i}|lambda0'] + lambda1 = model.variables[f'Converter|Piece_{i}|lambda1'] + inside_piece = model.variables[f'Converter|Piece_{i}|inside_piece'] + + assert_var_equal(inside_piece, model.add_variables(binary=True, coords=(timesteps,))) + assert_var_equal(lambda0, model.add_variables(lower=0, upper=1, coords=(timesteps,))) + assert_var_equal(lambda1, model.add_variables(lower=0, upper=1, coords=(timesteps,))) + + # Check that the inside_piece constraint exists + assert f'Converter|Piece_{i}|inside_piece' in model.constraints + # Check the relationship between inside_piece and lambdas + assert_conequal(model.constraints[f'Converter|Piece_{i}|inside_piece'], inside_piece == lambda0 + lambda1) + + assert_conequal( + model.constraints['Converter|Converter(input)|flow_rate|lambda'], + model.variables['Converter(input)|flow_rate'] + == + model.variables['Converter|Piece_0|lambda0'] * 0 + + model.variables['Converter|Piece_0|lambda1'] * 50 + + model.variables['Converter|Piece_1|lambda0'] * 50 + + model.variables['Converter|Piece_1|lambda1'] * 100, + ) + + assert_conequal( + model.constraints['Converter|Converter(output)|flow_rate|lambda'], + model.variables['Converter(output)|flow_rate'] + == + model.variables['Converter|Piece_0|lambda0'] * 0 + + model.variables['Converter|Piece_0|lambda1'] * 30 + + model.variables['Converter|Piece_1|lambda0'] * 30 + + model.variables['Converter|Piece_1|lambda1'] * 90, + ) + + # Check that we enforce the constraint that only one segment can be active + assert 'Converter|Converter(input)|flow_rate|single_segment' in model.constraints + + # The constraint should enforce that the sum of inside_piece variables is limited + # If there's no on_off parameter, the right-hand side should be 1 + assert_conequal( + model.constraints['Converter|Converter(input)|flow_rate|single_segment'], + sum([model.variables[f'Converter|Piece_{i}|inside_piece'] + for i in range(len(piecewise_model.pieces))]) <= 1 + ) + + + def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy): + """Test a LinearConverter with PiecewiseConversion and OnOffParameters.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + + # Create input and output flows + input_flow = fx.Flow('input', bus='input_bus', size=100) + output_flow = fx.Flow('output', bus='output_bus', size=100) + + # Create pieces for piecewise conversion + input_pieces = [ + fx.Piece(start=0, end=50), + fx.Piece(start=50, end=100) + ] + + output_pieces = [ + fx.Piece(start=0, end=30), + fx.Piece(start=30, end=90) + ] + + # Create piecewise conversion + piecewise_conversion = fx.PiecewiseConversion({ + input_flow.label: fx.Piecewise(input_pieces), + output_flow.label: fx.Piecewise(output_pieces) + }) + + # Create OnOffParameters + on_off_params = fx.OnOffParameters( + on_hours_total_min=10, + on_hours_total_max=40, + effects_per_running_hour={'Costs': 5} + ) + + # Create a linear converter with piecewise conversion and on/off parameters + converter = fx.LinearConverter( + label='Converter', + inputs=[input_flow], + outputs=[output_flow], + piecewise_conversion=piecewise_conversion, + on_off_parameters=on_off_params + ) + + # Add to flow system + flow_system.add_elements( + fx.Bus('input_bus'), + fx.Bus('output_bus'), + converter, + ) + + # 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 + + # Get the PiecewiseModel instance + piecewise_model = converter.model.piecewise_conversion + + # Check that we have the expected pieces (2 in this case) + assert len(piecewise_model.pieces) == 2 + + # Verify that the on variable was used as the zero_point for the piecewise model + # When using OnOffParameters, the zero_point should be the on variable + assert 'Converter|on' in model.variables + assert piecewise_model.zero_point is not None # Should be a variable + + # Verify that variables were created for each piece + for i, _ in enumerate(piecewise_model.pieces): + # Each piece should have lambda0, lambda1, and inside_piece variables + assert f'Converter|Piece_{i}|lambda0' in model.variables + assert f'Converter|Piece_{i}|lambda1' in model.variables + assert f'Converter|Piece_{i}|inside_piece' in model.variables + lambda0 = model.variables[f'Converter|Piece_{i}|lambda0'] + lambda1 = model.variables[f'Converter|Piece_{i}|lambda1'] + inside_piece = model.variables[f'Converter|Piece_{i}|inside_piece'] + + assert_var_equal(inside_piece, model.add_variables(binary=True, coords=(timesteps,))) + assert_var_equal(lambda0, model.add_variables(lower=0, upper=1, coords=(timesteps,))) + assert_var_equal(lambda1, model.add_variables(lower=0, upper=1, coords=(timesteps,))) + + # Check that the inside_piece constraint exists + assert f'Converter|Piece_{i}|inside_piece' in model.constraints + # Check the relationship between inside_piece and lambdas + assert_conequal(model.constraints[f'Converter|Piece_{i}|inside_piece'], inside_piece == lambda0 + lambda1) + + assert_conequal( + model.constraints['Converter|Converter(input)|flow_rate|lambda'], + model.variables['Converter(input)|flow_rate'] + == + model.variables['Converter|Piece_0|lambda0'] * 0 + + model.variables['Converter|Piece_0|lambda1'] * 50 + + model.variables['Converter|Piece_1|lambda0'] * 50 + + model.variables['Converter|Piece_1|lambda1'] * 100, + ) + + assert_conequal( + model.constraints['Converter|Converter(output)|flow_rate|lambda'], + model.variables['Converter(output)|flow_rate'] + == + model.variables['Converter|Piece_0|lambda0'] * 0 + + model.variables['Converter|Piece_0|lambda1'] * 30 + + model.variables['Converter|Piece_1|lambda0'] * 30 + + model.variables['Converter|Piece_1|lambda1'] * 90, + ) + + # Check that we enforce the constraint that only one segment can be active + assert 'Converter|Converter(input)|flow_rate|single_segment' in model.constraints + + # The constraint should enforce that the sum of inside_piece variables is limited + assert_conequal( + model.constraints['Converter|Converter(input)|flow_rate|single_segment'], + sum([model.variables[f'Converter|Piece_{i}|inside_piece'] + for i in range(len(piecewise_model.pieces))]) <= model.variables['Converter|on'] + ) + + # Also check that the OnOff model is working correctly + 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() + ) + + # Verify that the costs effect is applied + assert 'Converter->Costs(operation)' in model.constraints + assert_conequal( + model.constraints['Converter->Costs(operation)'], + model.variables['Converter->Costs(operation)'] == + converter.model.on_off.variables['Converter|on'] * model.hours_per_step * 5 + ) + + +if __name__ == '__main__': + pytest.main() diff --git a/tests/test_on_hours_computation.py b/tests/test_on_hours_computation.py new file mode 100644 index 000000000..a873bbd12 --- /dev/null +++ b/tests/test_on_hours_computation.py @@ -0,0 +1,105 @@ +import numpy as np +import pytest + +from flixopt.features import ConsecutiveStateModel, StateModel + + +class TestComputeConsecutiveDuration: + """Tests for the compute_consecutive_duration 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), + ]) + 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) + 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])), + ]) + 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) + + +class TestComputePreviousOnStates: + """Tests for the compute_previous_on_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])), + ], + ) + 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) + + @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])), + ]) + 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) + + @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,)), + ]) + def test_output_shapes(self, previous_values, expected_shape): + """Test that output array has the correct shape.""" + result = StateModel.compute_previous_states(previous_values) + assert result.shape == expected_shape diff --git a/tests/test_plots.py b/tests/test_plots.py index e6b481218..840b4e7b3 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -12,9 +12,10 @@ import plotly import pytest -from flixOpt import plotting +from flixopt import plotting +@pytest.mark.slow class TestPlots(unittest.TestCase): def setUp(self): np.random.seed(72) diff --git a/tests/test_results_plots.py b/tests/test_results_plots.py new file mode 100644 index 000000000..855944a48 --- /dev/null +++ b/tests/test_results_plots.py @@ -0,0 +1,89 @@ +import matplotlib.pyplot as plt +import pytest + +import flixopt as fx + +from .conftest import create_calculation_and_solve, simple_flow_system + + +@pytest.fixture(params=[True, False]) +def show(request): + return request.param + + +@pytest.fixture(params=[simple_flow_system]) +def flow_system(request): + return request.getfixturevalue(request.param.__name__) + + +@pytest.fixture(params=[True, False]) +def save(request): + return request.param + + +@pytest.fixture(params=['plotly', 'matplotlib']) +def plotting_engine(request): + return request.param + + +@pytest.fixture( + params=[ + 'viridis', # Test string colormap + ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff'], # Test color list + { + 'Boiler(Q_th)|flow_rate': '#ff0000', + 'Heat Demand(Q_th)|flow_rate': '#00ff00', + 'Speicher(Q_th_load)|flow_rate': '#0000ff', + }, # Test color dict + ] +) +def color_spec(request): + return request.param + +@pytest.mark.slow +def test_results_plots(flow_system, plotting_engine, show, save, color_spec): + calculation = create_calculation_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_results_plots') + results = calculation.results + + results['Boiler'].plot_node_balance(engine=plotting_engine, save=save, show=show, colors=color_spec) + + results.plot_heatmap( + 'Speicher(Q_th_load)|flow_rate', + heatmap_timeframes='D', + heatmap_timesteps_per_frame='h', + color_map='viridis', # Note: heatmap only accepts string colormap + save=show, + show=save, + engine=plotting_engine, + ) + + results['Speicher'].plot_node_balance_pie(engine=plotting_engine, save=save, show=show, colors=color_spec) + + if plotting_engine == 'matplotlib': + with pytest.raises(NotImplementedError): + results['Speicher'].plot_charge_state(engine=plotting_engine) + else: + results['Speicher'].plot_charge_state(engine=plotting_engine) + + plt.close('all') + +@pytest.mark.slow +def test_color_handling_edge_cases(flow_system, plotting_engine, show, save): + """Test edge cases for color handling""" + calculation = create_calculation_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_color_edge_cases') + results = calculation.results + + # Test with empty color list (should fall back to default) + results['Boiler'].plot_node_balance(engine=plotting_engine, save=save, show=show, colors=[]) + + # Test with invalid colormap name (should use default and log warning) + results['Boiler'].plot_node_balance(engine=plotting_engine, save=save, show=show, colors='nonexistent_colormap') + + # Test with insufficient colors for elements (should cycle colors) + results['Boiler'].plot_node_balance(engine=plotting_engine, save=save, show=show, colors=['#ff0000', '#00ff00']) + + # Test with color dict missing some elements (should use default for missing) + partial_color_dict = {'Boiler(Q_th)|flow_rate': '#ff0000'} # Missing other elements + results['Boiler'].plot_node_balance(engine=plotting_engine, save=save, show=show, colors=partial_color_dict) + + plt.close('all') diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 000000000..b88defaf6 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,399 @@ +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +import flixopt as fx + +from .conftest import assert_conequal, assert_var_equal, create_linopy_model + + +class TestStorageModel: + """Test that storage model variables and constraints are correctly generated.""" + + def test_basic_storage(self, basic_flow_system_linopy): + """Test that basic storage model variables and constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + timesteps_extra = flow_system.time_series_collection.timesteps_extra + + # Create a simple storage + storage = fx.Storage( + 'TestStorage', + charging=fx.Flow('Q_th_in', bus='FernwƤrme', size=20), + discharging=fx.Flow('Q_th_out', bus='FernwƤrme', size=20), + capacity_in_flow_hours=30, # 30 kWh storage capacity + initial_charge_state=0, # Start empty + prevent_simultaneous_charge_and_discharge=True, + ) + + flow_system.add_elements(storage) + model = create_linopy_model(flow_system) + + # Check that all expected variables exist - linopy model variables are accessed by indexing + expected_variables = { + 'TestStorage(Q_th_in)|flow_rate', + 'TestStorage(Q_th_in)|total_flow_hours', + 'TestStorage(Q_th_out)|flow_rate', + 'TestStorage(Q_th_out)|total_flow_hours', + 'TestStorage|charge_state', + 'TestStorage|netto_discharge', + } + for var_name in expected_variables: + assert var_name in model.variables, f"Missing variable: {var_name}" + + # Check that all expected constraints exist - linopy model constraints are accessed by indexing + expected_constraints = { + 'TestStorage(Q_th_in)|total_flow_hours', + 'TestStorage(Q_th_out)|total_flow_hours', + 'TestStorage|netto_discharge', + 'TestStorage|charge_state', + 'TestStorage|initial_charge_state', + } + for con_name in expected_constraints: + assert con_name in model.constraints, f"Missing constraint: {con_name}" + + # Check variable properties + assert_var_equal( + model['TestStorage(Q_th_in)|flow_rate'], + model.add_variables(lower=0, upper=20, coords=(timesteps,)) + ) + assert_var_equal( + model['TestStorage(Q_th_out)|flow_rate'], + model.add_variables(lower=0, upper=20, coords=(timesteps,)) + ) + assert_var_equal( + model['TestStorage|charge_state'], + model.add_variables(lower=0, upper=30, coords=(timesteps_extra,)) + ) + + # Check constraint formulations + assert_conequal( + model.constraints['TestStorage|netto_discharge'], + model.variables['TestStorage|netto_discharge'] == + model.variables['TestStorage(Q_th_out)|flow_rate'] - model.variables['TestStorage(Q_th_in)|flow_rate'] + ) + + charge_state = model.variables['TestStorage|charge_state'] + assert_conequal( + model.constraints['TestStorage|charge_state'], + charge_state.isel(time=slice(1, None)) + == charge_state.isel(time=slice(None, -1)) + + model.variables['TestStorage(Q_th_in)|flow_rate'] * model.hours_per_step + - model.variables['TestStorage(Q_th_out)|flow_rate'] * model.hours_per_step, + ) + # Check initial charge state constraint + assert_conequal( + model.constraints['TestStorage|initial_charge_state'], + model.variables['TestStorage|charge_state'].isel(time=0) == 0 + ) + + def test_lossy_storage(self, basic_flow_system_linopy): + """Test that basic storage model variables and constraints are correctly generated.""" + flow_system = basic_flow_system_linopy + timesteps = flow_system.time_series_collection.timesteps + timesteps_extra = flow_system.time_series_collection.timesteps_extra + + # Create a simple storage + storage = fx.Storage( + 'TestStorage', + charging=fx.Flow('Q_th_in', bus='FernwƤrme', size=20), + discharging=fx.Flow('Q_th_out', bus='FernwƤrme', size=20), + capacity_in_flow_hours=30, # 30 kWh storage capacity + initial_charge_state=0, # Start empty + eta_charge=0.9, # Charging efficiency + eta_discharge=0.8, # Discharging efficiency + relative_loss_per_hour=0.05, # 5% loss per hour + prevent_simultaneous_charge_and_discharge=True, + ) + + flow_system.add_elements(storage) + model = create_linopy_model(flow_system) + + # Check that all expected variables exist - linopy model variables are accessed by indexing + expected_variables = { + 'TestStorage(Q_th_in)|flow_rate', + 'TestStorage(Q_th_in)|total_flow_hours', + 'TestStorage(Q_th_out)|flow_rate', + 'TestStorage(Q_th_out)|total_flow_hours', + 'TestStorage|charge_state', + 'TestStorage|netto_discharge', + } + for var_name in expected_variables: + assert var_name in model.variables, f"Missing variable: {var_name}" + + # Check that all expected constraints exist - linopy model constraints are accessed by indexing + expected_constraints = { + 'TestStorage(Q_th_in)|total_flow_hours', + 'TestStorage(Q_th_out)|total_flow_hours', + 'TestStorage|netto_discharge', + 'TestStorage|charge_state', + 'TestStorage|initial_charge_state', + } + for con_name in expected_constraints: + assert con_name in model.constraints, f"Missing constraint: {con_name}" + + # Check variable properties + assert_var_equal( + model['TestStorage(Q_th_in)|flow_rate'], + model.add_variables(lower=0, upper=20, coords=(timesteps,)) + ) + assert_var_equal( + model['TestStorage(Q_th_out)|flow_rate'], + model.add_variables(lower=0, upper=20, coords=(timesteps,)) + ) + assert_var_equal( + model['TestStorage|charge_state'], + model.add_variables(lower=0, upper=30, coords=(timesteps_extra,)) + ) + + # Check constraint formulations + assert_conequal( + model.constraints['TestStorage|netto_discharge'], + model.variables['TestStorage|netto_discharge'] == + model.variables['TestStorage(Q_th_out)|flow_rate'] - model.variables['TestStorage(Q_th_in)|flow_rate'] + ) + + charge_state = model.variables['TestStorage|charge_state'] + rel_loss = 0.05 + hours_per_step = model.hours_per_step + charge_rate = model.variables['TestStorage(Q_th_in)|flow_rate'] + discharge_rate = model.variables['TestStorage(Q_th_out)|flow_rate'] + eff_charge = 0.9 + eff_discharge = 0.8 + + assert_conequal( + model.constraints['TestStorage|charge_state'], + 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, + ) + + # Check initial charge state constraint + assert_conequal( + model.constraints['TestStorage|initial_charge_state'], + model.variables['TestStorage|charge_state'].isel(time=0) == 0 + ) + + def test_storage_with_investment(self, basic_flow_system_linopy): + """Test storage with investment parameters.""" + flow_system = basic_flow_system_linopy + + # Create storage with investment parameters + storage = fx.Storage( + 'InvestStorage', + charging=fx.Flow('Q_th_in', bus='FernwƤrme', size=20), + discharging=fx.Flow('Q_th_out', bus='FernwƤrme', size=20), + capacity_in_flow_hours=fx.InvestParameters( + fix_effects=100, + specific_effects=10, + minimum_size=20, + maximum_size=100, + optional=True + ), + initial_charge_state=0, + eta_charge=0.9, + eta_discharge=0.9, + relative_loss_per_hour=0.05, + prevent_simultaneous_charge_and_discharge=True, + ) + + flow_system.add_elements(storage) + model = create_linopy_model(flow_system) + + # Check investment variables exist + for var_name in { + 'InvestStorage|charge_state', + 'InvestStorage|size', + 'InvestStorage|is_invested', + }: + 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'}: + assert con_name in model.constraints, f"Missing investment constraint: {con_name}" + + # Check variable properties + assert_var_equal( + model['InvestStorage|size'], + model.add_variables(lower=0, upper=100) + ) + assert_var_equal( + model['InvestStorage|is_invested'], + model.add_variables(binary=True) + ) + assert_conequal(model.constraints['InvestStorage|is_invested_ub'], + model.variables['InvestStorage|size'] <= model.variables['InvestStorage|is_invested'] * 100) + assert_conequal(model.constraints['InvestStorage|is_invested_lb'], + model.variables['InvestStorage|size'] >= model.variables['InvestStorage|is_invested'] * 20) + + def test_storage_with_final_state_constraints(self, basic_flow_system_linopy): + """Test storage with final state constraints.""" + flow_system = basic_flow_system_linopy + + # Create storage with final state constraints + storage = fx.Storage( + 'FinalStateStorage', + charging=fx.Flow('Q_th_in', bus='FernwƤrme', size=20), + discharging=fx.Flow('Q_th_out', bus='FernwƤrme', size=20), + capacity_in_flow_hours=30, + initial_charge_state=10, # Start with 10 kWh + minimal_final_charge_state=15, # End with at least 15 kWh + maximal_final_charge_state=25, # End with at most 25 kWh + eta_charge=0.9, + eta_discharge=0.9, + relative_loss_per_hour=0.05, + ) + + flow_system.add_elements(storage) + model = create_linopy_model(flow_system) + + # Check final state constraints exist + expected_constraints = { + 'FinalStateStorage|final_charge_min', + 'FinalStateStorage|final_charge_max', + } + + for con_name in expected_constraints: + assert con_name in model.constraints, f"Missing final state constraint: {con_name}" + + assert_conequal( + model.constraints['FinalStateStorage|initial_charge_state'], + model.variables['FinalStateStorage|charge_state'].isel(time=0) == 10, + ) + + # Check final state constraint formulations + assert_conequal( + model.constraints['FinalStateStorage|final_charge_min'], + model.variables['FinalStateStorage|charge_state'].isel(time=-1) >= 15 + ) + assert_conequal( + model.constraints['FinalStateStorage|final_charge_max'], + model.variables['FinalStateStorage|charge_state'].isel(time=-1) <= 25 + ) + + def test_storage_cyclic_initialization(self, basic_flow_system_linopy): + """Test storage with cyclic initialization.""" + flow_system = basic_flow_system_linopy + + # Create storage with cyclic initialization + storage = fx.Storage( + 'CyclicStorage', + charging=fx.Flow('Q_th_in', bus='FernwƤrme', size=20), + discharging=fx.Flow('Q_th_out', bus='FernwƤrme', size=20), + capacity_in_flow_hours=30, + initial_charge_state='lastValueOfSim', # Cyclic initialization + eta_charge=0.9, + eta_discharge=0.9, + relative_loss_per_hour=0.05, + ) + + flow_system.add_elements(storage) + model = create_linopy_model(flow_system) + + # Check cyclic constraint exists + assert 'CyclicStorage|initial_charge_state' in model.constraints, \ + "Missing cyclic initialization constraint" + + # Check cyclic constraint formulation + assert_conequal( + model.constraints['CyclicStorage|initial_charge_state'], + model.variables['CyclicStorage|charge_state'].isel(time=0) == + model.variables['CyclicStorage|charge_state'].isel(time=-1) + ) + + @pytest.mark.parametrize( + 'prevent_simultaneous', + [True, False], + ) + def test_simultaneous_charge_discharge(self, basic_flow_system_linopy, prevent_simultaneous): + """Test prevent_simultaneous_charge_and_discharge parameter.""" + flow_system = basic_flow_system_linopy + + # Create storage with or without simultaneous charge/discharge prevention + storage = fx.Storage( + 'SimultaneousStorage', + charging=fx.Flow('Q_th_in', bus='FernwƤrme', size=20), + discharging=fx.Flow('Q_th_out', bus='FernwƤrme', size=20), + capacity_in_flow_hours=30, + initial_charge_state=0, + eta_charge=0.9, + eta_discharge=0.9, + relative_loss_per_hour=0.05, + prevent_simultaneous_charge_and_discharge=prevent_simultaneous, + ) + + flow_system.add_elements(storage) + model = create_linopy_model(flow_system) + + # Binary variables should exist when preventing simultaneous operation + if prevent_simultaneous: + binary_vars = { + 'SimultaneousStorage(Q_th_in)|on', + 'SimultaneousStorage(Q_th_out)|on', + } + for var_name in binary_vars: + 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' + 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) + + @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'}), + (False, None, set(), set()), + (False, 20, set(), set()), + ], + ) + def test_investment_parameters(self, basic_flow_system_linopy, optional, minimum_size, expected_vars, expected_constraints): + """Test different investment parameter combinations.""" + flow_system = basic_flow_system_linopy + + # Create investment parameters + invest_params = { + 'fix_effects': 100, + 'specific_effects': 10, + 'optional': optional, + } + if minimum_size is not None: + invest_params['minimum_size'] = minimum_size + + # Create storage with specified investment parameters + storage = fx.Storage( + 'InvestStorage', + charging=fx.Flow('Q_th_in', bus='FernwƤrme', size=20), + discharging=fx.Flow('Q_th_out', bus='FernwƤrme', size=20), + capacity_in_flow_hours=fx.InvestParameters(**invest_params), + initial_charge_state=0, + eta_charge=0.9, + eta_discharge=0.9, + relative_loss_per_hour=0.05, + ) + + flow_system.add_elements(storage) + model = create_linopy_model(flow_system) + + # Check that expected variables exist + for var_name in expected_vars: + if optional: + assert var_name in model.variables, f"Expected variable {var_name} not found" + + # Check that expected constraints exist + for constraint_name in expected_constraints: + if optional: + assert constraint_name in model.constraints, f"Expected constraint {constraint_name} not found" + + # If optional is False, is_invested should be fixed to 1 + if not optional: + # Check that the is_invested variable exists and is fixed to 1 + if 'InvestStorage|is_invested' in model.variables: + var = model.variables['InvestStorage|is_invested'] + # Check if the lower and upper bounds are both 1 + assert var.upper == 1 and var.lower == 1, \ + "is_invested variable should be fixed to 1 when optional=False" diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py new file mode 100644 index 000000000..a8bc5fa85 --- /dev/null +++ b/tests/test_timeseries.py @@ -0,0 +1,605 @@ +import json +import tempfile +from pathlib import Path +from typing import Dict, List, Tuple + +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +from flixopt.core import ConversionError, DataConverter, TimeSeries, TimeSeriesCollection, TimeSeriesData + + +@pytest.fixture +def sample_timesteps(): + """Create a sample time index with the required 'time' name.""" + return pd.date_range('2023-01-01', periods=5, freq='D', name='time') + + +@pytest.fixture +def simple_dataarray(sample_timesteps): + """Create a simple DataArray with time dimension.""" + return xr.DataArray([10, 20, 30, 40, 50], coords={'time': sample_timesteps}, dims=['time']) + + +@pytest.fixture +def sample_timeseries(simple_dataarray): + """Create a sample TimeSeries object.""" + return TimeSeries(simple_dataarray, name='Test Series') + + +class TestTimeSeries: + """Test suite for TimeSeries class.""" + + def test_initialization(self, simple_dataarray): + """Test basic initialization of TimeSeries.""" + ts = TimeSeries(simple_dataarray, name='Test Series') + + # Check basic properties + assert ts.name == 'Test Series' + assert ts.aggregation_weight is None + assert ts.aggregation_group is None + + # Check data initialization + assert isinstance(ts.stored_data, xr.DataArray) + assert ts.stored_data.equals(simple_dataarray) + assert ts.active_data.equals(simple_dataarray) + + # Check backup was created + assert ts._backup.equals(simple_dataarray) + + # Check active timesteps + assert ts.active_timesteps.equals(simple_dataarray.indexes['time']) + + def test_initialization_with_aggregation_params(self, simple_dataarray): + """Test initialization with aggregation parameters.""" + ts = TimeSeries( + simple_dataarray, name='Weighted Series', aggregation_weight=0.5, aggregation_group='test_group' + ) + + assert ts.name == 'Weighted Series' + assert ts.aggregation_weight == 0.5 + assert ts.aggregation_group == 'test_group' + + def test_initialization_validation(self, sample_timesteps): + """Test validation during initialization.""" + # Test missing time dimension + invalid_data = xr.DataArray([1, 2, 3], dims=['invalid_dim']) + with pytest.raises(ValueError, match='must have a "time" index'): + TimeSeries(invalid_data, name='Invalid Series') + + # Test multi-dimensional data + multi_dim_data = xr.DataArray( + [[1, 2, 3], [4, 5, 6]], coords={'dim1': [0, 1], 'time': sample_timesteps[:3]}, dims=['dim1', 'time'] + ) + with pytest.raises(ValueError, match='dimensions of DataArray must be 1'): + TimeSeries(multi_dim_data, name='Multi-dim Series') + + def test_active_timesteps_getter_setter(self, sample_timeseries, sample_timesteps): + """Test active_timesteps getter and setter.""" + # Initial state should use all timesteps + assert sample_timeseries.active_timesteps.equals(sample_timesteps) + + # Set to a subset + subset_index = sample_timesteps[1:3] + sample_timeseries.active_timesteps = subset_index + assert sample_timeseries.active_timesteps.equals(subset_index) + + # Active data should reflect the subset + assert sample_timeseries.active_data.equals(sample_timeseries.stored_data.sel(time=subset_index)) + + # Reset to full index + sample_timeseries.active_timesteps = None + assert sample_timeseries.active_timesteps.equals(sample_timesteps) + + # Test invalid type + with pytest.raises(TypeError, match='must be a pandas DatetimeIndex'): + sample_timeseries.active_timesteps = 'invalid' + + def test_reset(self, sample_timeseries, sample_timesteps): + """Test reset method.""" + # Set to subset first + subset_index = sample_timesteps[1:3] + sample_timeseries.active_timesteps = subset_index + + # Reset + sample_timeseries.reset() + + # Should be back to full index + assert sample_timeseries.active_timesteps.equals(sample_timesteps) + assert sample_timeseries.active_data.equals(sample_timeseries.stored_data) + + def test_restore_data(self, sample_timeseries, simple_dataarray): + """Test restore_data method.""" + # Modify the stored data + new_data = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.active_timesteps}, dims=['time']) + + # Store original data for comparison + original_data = sample_timeseries.stored_data + + # Set new data + sample_timeseries.stored_data = new_data + assert sample_timeseries.stored_data.equals(new_data) + + # Restore from backup + sample_timeseries.restore_data() + + # Should be back to original data + assert sample_timeseries.stored_data.equals(original_data) + assert sample_timeseries.active_data.equals(original_data) + + def test_stored_data_setter(self, sample_timeseries, sample_timesteps): + """Test stored_data setter with different data types.""" + # Test with a Series + series_data = pd.Series([5, 6, 7, 8, 9], index=sample_timesteps) + sample_timeseries.stored_data = series_data + assert np.array_equal(sample_timeseries.stored_data.values, series_data.values) + + # Test with a single-column DataFrame + df_data = pd.DataFrame({'col1': [15, 16, 17, 18, 19]}, index=sample_timesteps) + sample_timeseries.stored_data = df_data + assert np.array_equal(sample_timeseries.stored_data.values, df_data['col1'].values) + + # Test with a NumPy array + array_data = np.array([25, 26, 27, 28, 29]) + sample_timeseries.stored_data = array_data + assert np.array_equal(sample_timeseries.stored_data.values, array_data) + + # Test with a scalar + sample_timeseries.stored_data = 42 + assert np.all(sample_timeseries.stored_data.values == 42) + + # Test with another DataArray + another_dataarray = xr.DataArray([30, 31, 32, 33, 34], coords={'time': sample_timesteps}, dims=['time']) + sample_timeseries.stored_data = another_dataarray + assert sample_timeseries.stored_data.equals(another_dataarray) + + def test_stored_data_setter_no_change(self, sample_timeseries): + """Test stored_data setter when data doesn't change.""" + # Get current data + current_data = sample_timeseries.stored_data + current_backup = sample_timeseries._backup + + # Set the same data + sample_timeseries.stored_data = current_data + + # Backup shouldn't change + assert sample_timeseries._backup is current_backup # Should be the same object + + def test_from_datasource(self, sample_timesteps): + """Test from_datasource class method.""" + # Test with scalar + ts_scalar = TimeSeries.from_datasource(42, 'Scalar Series', sample_timesteps) + assert np.all(ts_scalar.stored_data.values == 42) + + # Test with Series + series_data = pd.Series([1, 2, 3, 4, 5], index=sample_timesteps) + ts_series = TimeSeries.from_datasource(series_data, 'Series Data', sample_timesteps) + assert np.array_equal(ts_series.stored_data.values, series_data.values) + + # Test with aggregation parameters + ts_with_agg = TimeSeries.from_datasource( + series_data, 'Aggregated Series', sample_timesteps, aggregation_weight=0.7, aggregation_group='group1' + ) + assert ts_with_agg.aggregation_weight == 0.7 + assert ts_with_agg.aggregation_group == 'group1' + + def test_to_json_from_json(self, sample_timeseries): + """Test to_json and from_json methods.""" + # Test to_json (dictionary only) + json_dict = sample_timeseries.to_json() + assert json_dict['name'] == sample_timeseries.name + assert 'data' in json_dict + assert 'coords' in json_dict['data'] + assert 'time' in json_dict['data']['coords'] + + # Test to_json with file saving + with tempfile.TemporaryDirectory() as tmpdirname: + filepath = Path(tmpdirname) / 'timeseries.json' + sample_timeseries.to_json(filepath) + assert filepath.exists() + + # Test from_json with file loading + loaded_ts = TimeSeries.from_json(path=filepath) + assert loaded_ts.name == sample_timeseries.name + assert np.array_equal(loaded_ts.stored_data.values, sample_timeseries.stored_data.values) + + # Test from_json with dictionary + loaded_ts_dict = TimeSeries.from_json(data=json_dict) + assert loaded_ts_dict.name == sample_timeseries.name + assert np.array_equal(loaded_ts_dict.stored_data.values, sample_timeseries.stored_data.values) + + # Test validation in from_json + with pytest.raises(ValueError, match="one of 'path' or 'data'"): + TimeSeries.from_json(data=json_dict, path='dummy.json') + + def test_all_equal(self, sample_timesteps): + """Test all_equal property.""" + # All equal values + equal_data = xr.DataArray([5, 5, 5, 5, 5], coords={'time': sample_timesteps}, dims=['time']) + ts_equal = TimeSeries(equal_data, 'Equal Series') + assert ts_equal.all_equal is True + + # Not all equal + unequal_data = xr.DataArray([5, 5, 6, 5, 5], coords={'time': sample_timesteps}, dims=['time']) + ts_unequal = TimeSeries(unequal_data, 'Unequal Series') + assert ts_unequal.all_equal is False + + def test_arithmetic_operations(self, sample_timeseries): + """Test arithmetic operations.""" + # Create a second TimeSeries for testing + data2 = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.active_timesteps}, dims=['time']) + ts2 = TimeSeries(data2, 'Second Series') + + # Test operations between two TimeSeries objects + assert np.array_equal( + (sample_timeseries + ts2).values, sample_timeseries.active_data.values + ts2.active_data.values + ) + assert np.array_equal( + (sample_timeseries - ts2).values, sample_timeseries.active_data.values - ts2.active_data.values + ) + assert np.array_equal( + (sample_timeseries * ts2).values, sample_timeseries.active_data.values * ts2.active_data.values + ) + assert np.array_equal( + (sample_timeseries / ts2).values, sample_timeseries.active_data.values / ts2.active_data.values + ) + + # Test operations with DataArrays + assert np.array_equal((sample_timeseries + data2).values, sample_timeseries.active_data.values + data2.values) + assert np.array_equal((data2 + sample_timeseries).values, data2.values + sample_timeseries.active_data.values) + + # Test operations with scalars + assert np.array_equal((sample_timeseries + 5).values, sample_timeseries.active_data.values + 5) + assert np.array_equal((5 + sample_timeseries).values, 5 + sample_timeseries.active_data.values) + + # Test unary operations + assert np.array_equal((-sample_timeseries).values, -sample_timeseries.active_data.values) + assert np.array_equal((+sample_timeseries).values, +sample_timeseries.active_data.values) + assert np.array_equal((abs(sample_timeseries)).values, abs(sample_timeseries.active_data.values)) + + def test_comparison_operations(self, sample_timesteps): + """Test comparison operations.""" + data1 = xr.DataArray([10, 20, 30, 40, 50], coords={'time': sample_timesteps}, dims=['time']) + data2 = xr.DataArray([5, 10, 15, 20, 25], coords={'time': sample_timesteps}, dims=['time']) + + ts1 = TimeSeries(data1, 'Series 1') + ts2 = TimeSeries(data2, 'Series 2') + + # Test __gt__ method + assert (ts1 > ts2).all().item() + + # Test with mixed values + data3 = xr.DataArray([5, 25, 15, 45, 25], coords={'time': sample_timesteps}, dims=['time']) + ts3 = TimeSeries(data3, 'Series 3') + + assert not (ts1 > ts3).all().item() # Not all values in ts1 are greater than ts3 + + def test_numpy_ufunc(self, sample_timeseries): + """Test numpy ufunc compatibility.""" + # Test basic numpy functions + assert np.array_equal(np.add(sample_timeseries, 5).values, np.add(sample_timeseries.active_data, 5).values) + + assert np.array_equal( + np.multiply(sample_timeseries, 2).values, np.multiply(sample_timeseries.active_data, 2).values + ) + + # Test with two TimeSeries objects + data2 = xr.DataArray([1, 2, 3, 4, 5], coords={'time': sample_timeseries.active_timesteps}, dims=['time']) + ts2 = TimeSeries(data2, 'Second Series') + + assert np.array_equal( + np.add(sample_timeseries, ts2).values, np.add(sample_timeseries.active_data, ts2.active_data).values + ) + + def test_sel_and_isel_properties(self, sample_timeseries): + """Test sel and isel properties.""" + # Test that sel property works + selected = sample_timeseries.sel(time=sample_timeseries.active_timesteps[0]) + assert selected.item() == sample_timeseries.active_data.values[0] + + # Test that isel property works + indexed = sample_timeseries.isel(time=0) + assert indexed.item() == sample_timeseries.active_data.values[0] + + +@pytest.fixture +def sample_collection(sample_timesteps): + """Create a sample TimeSeriesCollection.""" + return TimeSeriesCollection(sample_timesteps) + + +@pytest.fixture +def populated_collection(sample_collection): + """Create a TimeSeriesCollection with test data.""" + # Add a constant time series + sample_collection.create_time_series(42, 'constant_series') + + # Add a varying time series + varying_data = np.array([10, 20, 30, 40, 50]) + sample_collection.create_time_series(varying_data, 'varying_series') + + # Add a time series with extra timestep + sample_collection.create_time_series( + np.array([1, 2, 3, 4, 5, 6]), 'extra_timestep_series', needs_extra_timestep=True + ) + + # Add series with aggregation settings + sample_collection.create_time_series( + TimeSeriesData(np.array([5, 5, 5, 5, 5]), agg_group='group1'), 'group1_series1' + ) + sample_collection.create_time_series( + TimeSeriesData(np.array([6, 6, 6, 6, 6]), agg_group='group1'), 'group1_series2' + ) + sample_collection.create_time_series( + TimeSeriesData(np.array([10, 10, 10, 10, 10]), agg_weight=0.5), 'weighted_series' + ) + + return sample_collection + + +class TestTimeSeriesCollection: + """Test suite for TimeSeriesCollection.""" + + def test_initialization(self, sample_timesteps): + """Test basic initialization.""" + collection = TimeSeriesCollection(sample_timesteps) + + assert collection.all_timesteps.equals(sample_timesteps) + assert len(collection.all_timesteps_extra) == len(sample_timesteps) + 1 + assert isinstance(collection.all_hours_per_timestep, xr.DataArray) + assert len(collection) == 0 + + def test_initialization_with_custom_hours(self, sample_timesteps): + """Test initialization with custom hour settings.""" + # Test with last timestep duration + last_timestep_hours = 12 + collection = TimeSeriesCollection(sample_timesteps, hours_of_last_timestep=last_timestep_hours) + + # Verify the last timestep duration + extra_step_delta = collection.all_timesteps_extra[-1] - collection.all_timesteps_extra[-2] + assert extra_step_delta == pd.Timedelta(hours=last_timestep_hours) + + # Test with previous timestep duration + hours_per_step = 8 + collection2 = TimeSeriesCollection(sample_timesteps, hours_of_previous_timesteps=hours_per_step) + + assert collection2.hours_of_previous_timesteps == hours_per_step + + def test_create_time_series(self, sample_collection): + """Test creating time series.""" + # Test scalar + ts1 = sample_collection.create_time_series(42, 'scalar_series') + assert ts1.name == 'scalar_series' + assert np.all(ts1.active_data.values == 42) + + # Test numpy array + data = np.array([1, 2, 3, 4, 5]) + ts2 = sample_collection.create_time_series(data, 'array_series') + assert np.array_equal(ts2.active_data.values, data) + + # Test with TimeSeriesData + ts3 = sample_collection.create_time_series(TimeSeriesData(10, agg_weight=0.7), 'weighted_series') + assert ts3.aggregation_weight == 0.7 + + # Test with extra timestep + ts4 = sample_collection.create_time_series(5, 'extra_series', needs_extra_timestep=True) + assert ts4.needs_extra_timestep + assert len(ts4.active_data) == len(sample_collection.timesteps_extra) + + # Test duplicate name + with pytest.raises(ValueError, match='already exists'): + sample_collection.create_time_series(1, 'scalar_series') + + def test_access_time_series(self, populated_collection): + """Test accessing time series.""" + # Test __getitem__ + ts = populated_collection['varying_series'] + assert ts.name == 'varying_series' + + # Test __contains__ with string + assert 'constant_series' in populated_collection + assert 'nonexistent_series' not in populated_collection + + # Test __contains__ with TimeSeries object + assert populated_collection['varying_series'] in populated_collection + + # Test __iter__ + names = [ts.name for ts in populated_collection] + assert len(names) == 6 + assert 'varying_series' in names + + # Test access to non-existent series + with pytest.raises(KeyError): + populated_collection['nonexistent_series'] + + def test_constants_and_non_constants(self, populated_collection): + """Test constants and non_constants properties.""" + # Test constants + constants = populated_collection.constants + assert len(constants) == 4 # constant_series, group1_series1, group1_series2, weighted_series + assert all(ts.all_equal for ts in constants) + + # Test non_constants + non_constants = populated_collection.non_constants + assert len(non_constants) == 2 # varying_series, extra_timestep_series + assert all(not ts.all_equal for ts in non_constants) + + # Test modifying a series changes the results + populated_collection['constant_series'].stored_data = np.array([1, 2, 3, 4, 5]) + updated_constants = populated_collection.constants + assert len(updated_constants) == 3 # One less constant + assert 'constant_series' not in [ts.name for ts in updated_constants] + + def test_timesteps_properties(self, populated_collection, sample_timesteps): + """Test timestep-related properties.""" + # Test default (all) timesteps + assert populated_collection.timesteps.equals(sample_timesteps) + assert len(populated_collection.timesteps_extra) == len(sample_timesteps) + 1 + + # Test activating a subset + subset = sample_timesteps[1:3] + populated_collection.activate_timesteps(subset) + + assert populated_collection.timesteps.equals(subset) + assert len(populated_collection.timesteps_extra) == len(subset) + 1 + + # Check that time series were updated + assert populated_collection['varying_series'].active_timesteps.equals(subset) + assert populated_collection['extra_timestep_series'].active_timesteps.equals( + populated_collection.timesteps_extra + ) + + # Test reset + populated_collection.reset() + assert populated_collection.timesteps.equals(sample_timesteps) + + def test_to_dataframe_and_dataset(self, populated_collection): + """Test conversion to DataFrame and Dataset.""" + # Test to_dataset + ds = populated_collection.to_dataset() + assert isinstance(ds, xr.Dataset) + assert len(ds.data_vars) == 6 + + # Test to_dataframe with different filters + df_all = populated_collection.to_dataframe(filtered='all') + assert len(df_all.columns) == 6 + + df_constant = populated_collection.to_dataframe(filtered='constant') + assert len(df_constant.columns) == 4 + + df_non_constant = populated_collection.to_dataframe(filtered='non_constant') + assert len(df_non_constant.columns) == 2 + + # Test invalid filter + with pytest.raises(ValueError): + populated_collection.to_dataframe(filtered='invalid') + + def test_calculate_aggregation_weights(self, populated_collection): + """Test aggregation weight calculation.""" + weights = populated_collection.calculate_aggregation_weights() + + # Group weights should be 0.5 each (1/2) + assert populated_collection.group_weights['group1'] == 0.5 + + # Series in group1 should have weight 0.5 + assert weights['group1_series1'] == 0.5 + assert weights['group1_series2'] == 0.5 + + # Series with explicit weight should have that weight + assert weights['weighted_series'] == 0.5 + + # Series without group or weight should have weight 1 + assert weights['constant_series'] == 1 + + def test_insert_new_data(self, populated_collection, sample_timesteps): + """Test inserting new data.""" + # Create new data + new_data = pd.DataFrame( + { + 'constant_series': [100, 100, 100, 100, 100], + 'varying_series': [5, 10, 15, 20, 25], + # extra_timestep_series is omitted to test partial updates + }, + index=sample_timesteps, + ) + + # Insert data + populated_collection.insert_new_data(new_data) + + # Verify updates + assert np.all(populated_collection['constant_series'].active_data.values == 100) + assert np.array_equal(populated_collection['varying_series'].active_data.values, np.array([5, 10, 15, 20, 25])) + + # Series not in the DataFrame should be unchanged + assert np.array_equal( + populated_collection['extra_timestep_series'].active_data.values[:-1], np.array([1, 2, 3, 4, 5]) + ) + + # Test with mismatched index + bad_index = pd.date_range('2023-02-01', periods=5, freq='D', name='time') + bad_data = pd.DataFrame({'constant_series': [1, 1, 1, 1, 1]}, index=bad_index) + + with pytest.raises(ValueError, match='must match collection timesteps'): + populated_collection.insert_new_data(bad_data) + + def test_restore_data(self, populated_collection): + """Test restoring original data.""" + # Capture original data + original_values = {name: ts.stored_data.copy() for name, ts in populated_collection.time_series_data.items()} + + # Modify data + new_data = pd.DataFrame( + { + name: np.ones(len(populated_collection.timesteps)) * 999 + for name in populated_collection.time_series_data + if not populated_collection[name].needs_extra_timestep + }, + index=populated_collection.timesteps, + ) + + populated_collection.insert_new_data(new_data) + + # Verify data was changed + assert np.all(populated_collection['constant_series'].active_data.values == 999) + + # Restore data + populated_collection.restore_data() + + # Verify data was restored + for name, original in original_values.items(): + restored = populated_collection[name].stored_data + assert np.array_equal(restored.values, original.values) + + def test_class_method_with_uniform_timesteps(self): + """Test the with_uniform_timesteps class method.""" + collection = TimeSeriesCollection.with_uniform_timesteps( + start_time=pd.Timestamp('2023-01-01'), periods=24, freq='h', hours_per_step=1 + ) + + assert len(collection.timesteps) == 24 + assert collection.hours_of_previous_timesteps == 1 + assert (collection.timesteps[1] - collection.timesteps[0]) == pd.Timedelta(hours=1) + + def test_hours_per_timestep(self, populated_collection): + """Test hours_per_timestep calculation.""" + # Standard case - uniform timesteps + hours = populated_collection.hours_per_timestep.values + assert np.allclose(hours, 24) # Default is daily timesteps + + # Create non-uniform timesteps + non_uniform_times = pd.DatetimeIndex( + [ + pd.Timestamp('2023-01-01'), + pd.Timestamp('2023-01-02'), + pd.Timestamp('2023-01-03 12:00:00'), # 1.5 days from previous + pd.Timestamp('2023-01-04'), # 0.5 days from previous + pd.Timestamp('2023-01-06'), # 2 days from previous + ], + name='time', + ) + + collection = TimeSeriesCollection(non_uniform_times) + hours = collection.hours_per_timestep.values + + # Expected hours between timestamps + expected = np.array([24, 36, 12, 48, 48]) + assert np.allclose(hours, expected) + + def test_validation_and_errors(self, sample_timesteps): + """Test validation and error handling.""" + # Test non-DatetimeIndex + with pytest.raises(TypeError, match='must be a pandas DatetimeIndex'): + TimeSeriesCollection(pd.Index([1, 2, 3, 4, 5])) + + # Test too few timesteps + with pytest.raises(ValueError, match='must contain at least 2 timestamps'): + TimeSeriesCollection(pd.DatetimeIndex([pd.Timestamp('2023-01-01')], name='time')) + + # Test invalid active_timesteps + collection = TimeSeriesCollection(sample_timesteps) + invalid_timesteps = pd.date_range('2024-01-01', periods=3, freq='D', name='time') + + with pytest.raises(ValueError, match='must be a subset'): + collection.activate_timesteps(invalid_timesteps)