diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4dc7e91a3..a3a599070 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -74,8 +74,8 @@ jobs: - name: Run tests run: | if [[ "${{ github.event.pull_request.draft }}" == "true" ]]; then - # Draft PR: skip examples, slow, and deprecated_api - pytest -v --numprocesses=auto -m "not examples and not slow and not deprecated_api" + # Draft PR: skip examples and slow tests + pytest -v --numprocesses=auto -m "not examples and not slow" else # Ready PR & main push: examples excluded via addopts pytest -v --numprocesses=auto diff --git a/.gitignore b/.gitignore index 49fa19800..bc8dc1a78 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ temp-plot.html site/ *.egg-info uv.lock + +# test artifacts written to cwd +fs.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 5639ac1d3..1bfeeaaca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,25 @@ For more details regarding the individual PRs and contributors, please refer to !!! tip + If upgrading from v7.x, see the [Migration Guide v8](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v8/). If upgrading from v5.x, see the [Migration Guide v6](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v6/). If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOpt/flixOpt/releases/tag/v3.0.0) and [Migration Guide v3](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v3/). --- +## [Unreleased] + +**Summary**: Removes all v4-era APIs that were deprecated in v5/v6 and scheduled for removal in v7.0.0. Code that runs warning-free on v7.x runs unchanged on v8. See the [Migration Guide v8](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v8/). + +### 💥 Removed + +- **`Optimization` / `SegmentedOptimization` / `Results`** — use `flow_system.optimize(solver)` and read results via `flow_system.solution` / `flow_system.stats` +- **`FlowSystem.sel()` / `isel()` / `resample()` / `coords`** — use `flow_system.transform.sel/isel/resample()` and `flow_system.indexes` +- **`FlowSystem.plot_network()` / `network_infos()` / `start_network_app()` / `stop_network_app()`** and the PyVis-based `topology.plot_legacy()` — use `flow_system.topology.plot()` / `infos()` / `start_app()` / `stop_app()`; **pyvis is no longer a dependency** +- **`FlowSystem.from_old_results()`** — re-run old optimizations with the current API (`from_old_dataset()` still loads pre-v5 configuration files and no longer warns) +- **`Bus(excess_penalty_per_flow_hour=...)`** — now raises `TypeError`; use `imbalance_penalty_per_flow_hour` +- **`normalize_weights` parameter** on `create_model` / `build_model` / `optimize()` — weights are always normalized + ## [7.2.1](https://github.com/flixOpt/flixopt/compare/v7.2.0...v7.2.1) (2026-07-21) diff --git a/docs/user-guide/glossary.md b/docs/user-guide/glossary.md index 03a1cadd2..fd3b2f041 100644 --- a/docs/user-guide/glossary.md +++ b/docs/user-guide/glossary.md @@ -88,8 +88,7 @@ Key concepts and terminology used throughout flixOpt. | **uptime** | Continuous duration equipment operates. Can be constrained with `min_uptime`, `max_uptime` in StatusParameters. | | **downtime** | Continuous duration equipment is off. Can be constrained with `min_downtime`, `max_downtime` in StatusParameters. | | **flow_hours** | Total energy delivered by a flow: sum of flow_rate * timestep_duration. Can be constrained with `flow_hours_min`, `flow_hours_max`. | -| **excess_penalty** | Penalty applied when bus has more supply than demand. Soft constraint alternative to strict balance. | -| **shortage_penalty** | Penalty applied when bus has more demand than supply (unmet demand). | +| **imbalance_penalty** | Penalty applied when a bus balance is violated (virtual supply/demand). Soft constraint alternative to strict balance. | ## Weights and Aggregation diff --git a/docs/user-guide/mathematical-notation/effects-and-dimensions.md b/docs/user-guide/mathematical-notation/effects-and-dimensions.md index 011fc810e..3f03696b3 100644 --- a/docs/user-guide/mathematical-notation/effects-and-dimensions.md +++ b/docs/user-guide/mathematical-notation/effects-and-dimensions.md @@ -77,8 +77,7 @@ One effect is the **objective** (minimized). Others are tracked or constrained. ```python fx.Bus( - excess_penalty_per_flow_hour=1e6, # Penalty for excess - shortage_penalty_per_flow_hour=1e6, # Penalty for shortage + imbalance_penalty_per_flow_hour=1e6, # Penalty for excess and shortage ) ``` @@ -259,7 +258,7 @@ A built-in `Penalty` effect enables soft constraints and prevents infeasibility: ```python fx.StatusParameters(effects_per_startup={'Penalty': 1}) -fx.Bus(label='heat', excess_penalty_per_flow_hour=1e5) +fx.Bus(label='heat', imbalance_penalty_per_flow_hour=1e5) ``` Penalty is weighted identically to the objective effect across all dimensions. diff --git a/docs/user-guide/migration-guide-v5.md b/docs/user-guide/migration-guide-v5.md index 0c43e18f0..25e24c2e0 100644 --- a/docs/user-guide/migration-guide-v5.md +++ b/docs/user-guide/migration-guide-v5.md @@ -246,20 +246,22 @@ boiler_vars = [v for v in flow_system.solution.data_vars if 'Boiler' in v] solution = xr.open_dataset('results/solution.nc4') ``` -### Migrating Old Result Files +### Migrating Old Files -If you have result files saved with the old API (v4.x), you can migrate them to the new format using `FlowSystem.from_old_results()`. This method: +If you have FlowSystem configurations saved with the old API (v4.x), load them with `FlowSystem.from_old_dataset()`. This method: -- Loads the old multi-file format (`*--flow_system.nc4`, `*--solution.nc4`) +- Loads the old `*--flow_system.nc4` file - Renames deprecated parameters in the FlowSystem structure (e.g., `on_off_parameters` → `status_parameters`) -- Attaches the solution data to the FlowSystem +- Returns a FlowSystem without solution data + +Old *result* files can no longer be loaded (`from_old_results()` was removed in v8) — re-run the optimization with the current API instead. ```python -# Load old results -flow_system = fx.FlowSystem.from_old_results('results_folder', 'my_model') +# Load an old FlowSystem configuration +flow_system = fx.FlowSystem.from_old_dataset('results_folder/my_model--flow_system.nc4') -# Access basic solution data (flow rates, sizes, charge states, etc.) -flow_system.solution['Boiler(Q_th)|flow_rate'].plot() +# Re-optimize with the current API +flow_system.optimize(solver) # Save in new single-file format flow_system.to_netcdf('results/my_model_migrated.nc4') @@ -396,7 +398,7 @@ stats.total_effects['costs'].groupby('component_type').sum() | **Replace Optimization class** | Use `flow_system.optimize(solver)` instead | | **Update results access** | Use `flow_system.solution['var_name']` pattern | | **Update I/O code** | Use `to_netcdf()` / `from_netcdf()` | -| **Migrate old result files** | Use `FlowSystem.from_old_results(folder, name)` | +| **Migrate old files** | Use `FlowSystem.from_old_dataset(path)` for configurations; re-run optimizations for results | | **Update transform methods** | Use `flow_system.transform.sel/isel/resample()` instead | | **Test thoroughly** | Verify results match v4.x outputs | | **Remove deprecated imports** | Remove `fx.Optimization`, `fx.Results` | diff --git a/docs/user-guide/migration-guide-v7.md b/docs/user-guide/migration-guide-v7.md index 5b4962224..be38a6c66 100644 --- a/docs/user-guide/migration-guide-v7.md +++ b/docs/user-guide/migration-guide-v7.md @@ -9,6 +9,11 @@ The `transform.cluster()` call signature is unchanged — only the `Clustering` result object and a few helpers changed. +!!! info "Upgrading to v8?" + v8.0.0 removes the long-deprecated v4-era APIs (`Optimization`/`Results`, + `FlowSystem.sel/isel/resample`, PyVis network plotting). See the + [Migration Guide v8](migration-guide-v8.md). + --- ## Overview diff --git a/docs/user-guide/migration-guide-v8.md b/docs/user-guide/migration-guide-v8.md new file mode 100644 index 000000000..779475325 --- /dev/null +++ b/docs/user-guide/migration-guide-v8.md @@ -0,0 +1,135 @@ +# Migration Guide: v7.x → v8.0.0 + +!!! tip "Quick Start" + ```bash + pip install --upgrade flixopt + ``` + v8.0.0 removes the long-deprecated v4-era APIs. If your code runs on v7.x + **without `DeprecationWarning`s**, it will run unchanged on v8.0.0 — this + guide is only relevant if you still use the old entry points below. + +--- + +## Overview + +Every API removed in v8.0.0 has been deprecated since v5/v6 and has emitted a +`DeprecationWarning` pointing to its replacement ever since. The replacements +are unchanged — nothing new to learn, only old spellings to drop: + +| Removed | Replacement | +|---------|-------------| +| `fx.Optimization`, `fx.SegmentedOptimization` | `flow_system.optimize(solver)` or `build_model()` + `solve(solver)` | +| `fx.results` / `Results` classes | `flow_system.solution`, `flow_system.stats` | +| `FlowSystem.sel()` / `isel()` / `resample()` | `flow_system.transform.sel()` / `isel()` / `resample()` | +| `FlowSystem.coords` | `flow_system.indexes` | +| `FlowSystem.plot_network()`, `network_infos()` | `flow_system.topology.plot()`, `topology.infos()` | +| `start_network_app()` / `stop_network_app()` | `topology.start_app()` / `topology.stop_app()` | +| `topology.plot_legacy()` (PyVis) | `topology.plot()` (Plotly) | +| `FlowSystem.from_old_results()` | re-run the optimization with the current API | +| `Bus(excess_penalty_per_flow_hour=...)` | `Bus(imbalance_penalty_per_flow_hour=...)` | +| `normalize_weights` on `optimize()` / `build_model()` / `create_model()` | remove the argument — weights are always normalized | + +--- + +## 💥 Breaking Changes in v8.0.0 + +### Optimization & Results classes removed + +The pre-v5 workflow objects are gone. Solve directly on the `FlowSystem` and +read results from it: + +=== "v7.x and earlier (removed)" + ```python + import flixopt as fx + + optimization = fx.Optimization('my_run', flow_system) + optimization.do_modeling() + optimization.solve(fx.solvers.HighsSolver()) + + results = fx.results.Results.from_optimization(optimization) + results.flow_rates() + ``` + +=== "v8.0.0" + ```python + import flixopt as fx + + flow_system.optimize(fx.solvers.HighsSolver()) + + flow_system.solution['Boiler(Q_th)|flow_rate'] + flow_system.stats.flow_rates + ``` + +`SegmentedOptimization` is removed without a direct replacement yet; a new +segmented (rolling-horizon) API is planned. + +### Data methods moved to the `transform` accessor + +=== "v7.x and earlier (removed)" + ```python + fs_jan = flow_system.sel(time='2020-01') + fs_2h = flow_system.resample('2h', method='mean') + ``` + +=== "v8.0.0" + ```python + fs_jan = flow_system.transform.sel(time='2020-01') + fs_2h = flow_system.transform.resample('2h', method='mean') + ``` + +### Network visualization is Plotly-only + +`plot_network()`, the PyVis-based `topology.plot_legacy()`, and the network +app wrappers on `FlowSystem` are removed. **PyVis is no longer a dependency.** + +=== "v7.x and earlier (removed)" + ```python + flow_system.plot_network(show=True) + flow_system.start_network_app() + ``` + +=== "v8.0.0" + ```python + flow_system.topology.plot() + flow_system.topology.start_app() + ``` + +### Renamed `Bus` argument is no longer bridged + +Passing the old name now raises a `TypeError` instead of warning: + +=== "v7.x and earlier (removed)" + ```python + fx.Bus('Heat', excess_penalty_per_flow_hour=1e5) + ``` + +=== "v8.0.0" + ```python + fx.Bus('Heat', imbalance_penalty_per_flow_hour=1e5) + ``` + +### Old result files can no longer be loaded + +`FlowSystem.from_old_results()` (the loader for pre-v5 `*--flow_system.nc4` + +`*--solution.nc4` result pairs) is removed — re-run those optimizations with +the current API. + +!!! info "Old *configuration* files still load" + `FlowSystem.from_old_dataset()` remains fully supported (and no longer warns): it loads a pre-v5 + `*--flow_system.nc4` configuration, renames old parameters, and returns a + `FlowSystem` ready to re-optimize. Files saved with v5+ + (`to_netcdf`/`from_netcdf`) are unaffected by any of this. + + `from_old_dataset()` is planned for removal in **v9** — migrate pre-v5 + files to the current single-file format before then. + +--- + +## Checklist + +1. Run your project on v7.x with warnings visible: + `python -W once::DeprecationWarning your_script.py` +2. Fix every `DeprecationWarning` using the table above. +3. Upgrade: `pip install --upgrade flixopt`. + +If step 1 is silent, v8.0.0 is a drop-in upgrade. diff --git a/docs/user-guide/optimization/index.md b/docs/user-guide/optimization/index.md index 12571ad67..6ebb43909 100644 --- a/docs/user-guide/optimization/index.md +++ b/docs/user-guide/optimization/index.md @@ -362,13 +362,13 @@ If your model has no feasible solution: 1. **Enable excess penalties on buses** to allow balance violations: ```python - # Allow imbalance with high penalty cost (default is 1e5) - heat_bus = fx.Bus('Heat', excess_penalty_per_flow_hour=1e5) + # Allow imbalance by setting a high diagnostic penalty (default is None = strict balance) + heat_bus = fx.Bus('Heat', imbalance_penalty_per_flow_hour=1e5) # Or disable penalty to enforce strict balance - electricity_bus = fx.Bus('Electricity', excess_penalty_per_flow_hour=None) + electricity_bus = fx.Bus('Electricity', imbalance_penalty_per_flow_hour=None) ``` - When `excess_penalty_per_flow_hour` is set, the optimization can violate bus balance constraints by paying a penalty, helping identify which constraints cause infeasibility. + When `imbalance_penalty_per_flow_hour` is set, the optimization can violate bus balance constraints by paying a penalty, helping identify which constraints cause infeasibility. 2. **Use Gurobi for infeasibility analysis** - When using GurobiSolver and the model is infeasible, flixOpt automatically extracts and logs the Irreducible Inconsistent Subsystem (IIS): ```python diff --git a/docs/user-guide/results/index.md b/docs/user-guide/results/index.md index 04296181c..d0b53337c 100644 --- a/docs/user-guide/results/index.md +++ b/docs/user-guide/results/index.md @@ -148,29 +148,25 @@ See [Plotting Results](../results-plotting.md) for comprehensive plotting docume The `topology` accessor lets you visualize and inspect your system structure: -### Static HTML Visualization +### Sankey Diagram -Generate an interactive network diagram using PyVis: +Visualize the network structure as an interactive Plotly Sankey diagram: ```python -# Default: saves to 'flow_system.html' and opens in browser -flow_system.topology.plot() - -# Custom options -flow_system.topology.plot( - path='output/my_network.html', - controls=['nodes', 'layout', 'physics'], - show=True -) +# Show in the browser +flow_system.topology.plot(show=True) + +# Custom colors, export to HTML +result = flow_system.topology.plot(colors={'Heat': 'red', 'Electricity': 'gold'}) +result.figure.write_html('output/my_network.html') ``` **Parameters:** | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `path` | str, Path, or False | `'flow_system.html'` | Where to save the HTML file | -| `controls` | bool or list | `True` | UI controls to show | -| `show` | bool | `None` | Whether to open in browser | +| `colors` | str, list, or dict | `None` | Colorscale name, color list, or bus-to-color mapping | +| `show` | bool | `None` | Whether to open in browser (default from `CONFIG.Plotting.default_show`) | ### Interactive App diff --git a/flixopt/__init__.py b/flixopt/__init__.py index d488225d3..225ad35f5 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -17,7 +17,7 @@ # - xr.Dataset.fxstats (from stats_accessor) import xarray_plotly as _xpx # noqa: F401 -from . import clustering, linear_converters, plotting, results, solvers, tutorials +from . import clustering, linear_converters, plotting, solvers, tutorials from . import stats_accessor as _fxstats # noqa: F401 from .carrier import Carrier, CarrierContainer from .comparison import Comparison @@ -35,7 +35,6 @@ from .elements import Bus, Flow from .flow_system import FlowSystem from .interface import InvestParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects, StatusParameters -from .optimization import Optimization, SegmentedOptimization from .plot_result import PlotResult __all__ = [ @@ -55,8 +54,6 @@ 'LinearConverter', 'Transmission', 'FlowSystem', - 'Optimization', - 'SegmentedOptimization', 'InvestParameters', 'StatusParameters', 'Piece', @@ -66,7 +63,6 @@ 'PlotResult', 'clustering', 'plotting', - 'results', 'linear_converters', 'solvers', 'tutorials', diff --git a/flixopt/elements.py b/flixopt/elements.py index 446ef4bd7..141c70739 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -313,9 +313,6 @@ def __init__( **kwargs, ): super().__init__(label, meta_data=meta_data) - imbalance_penalty_per_flow_hour = self._handle_deprecated_kwarg( - kwargs, 'excess_penalty_per_flow_hour', 'imbalance_penalty_per_flow_hour', imbalance_penalty_per_flow_hour - ) self._validate_kwargs(kwargs) self.carrier = carrier.lower() if carrier else None # Store as lowercase string self.imbalance_penalty_per_flow_hour = imbalance_penalty_per_flow_hour diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index df71e2ff5..b0a3988cd 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -4,12 +4,11 @@ from __future__ import annotations -import json import logging import pathlib import warnings from itertools import chain -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING import numpy as np import pandas as pd @@ -17,7 +16,7 @@ from . import io as fx_io from .components import Storage -from .config import CONFIG, DEPRECATION_REMOVAL_VERSION +from .config import CONFIG from .core import ( ConversionError, DataConverter, @@ -42,8 +41,6 @@ if TYPE_CHECKING: from collections.abc import Collection - import pyvis - from .clustering import Clustering from .solvers import _Solver from .types import Effect_TPS, Numeric_S, Numeric_TPS, NumericOrBool @@ -140,21 +137,10 @@ class FlowSystem(Interface, CompositeContainerMixin[Element]): >>> >>> # Flows are automatically collected from all components - Power user pattern - Efficient chaining without conversion overhead: + Slicing and resampling via the transform accessor: - >>> # Instead of chaining (causes multiple conversions): - >>> result = flow_system.sel(time='2020-01').resample('2h') # Slow - >>> - >>> # Use dataset methods directly (single conversion): - >>> ds = flow_system.to_dataset() - >>> ds = FlowSystem._dataset_sel(ds, time='2020-01') - >>> ds = flow_system._dataset_resample(ds, freq='2h', method='mean') - >>> result = FlowSystem.from_dataset(ds) # Fast! - >>> - >>> # Available dataset methods: - >>> # - FlowSystem._dataset_sel(dataset, time=..., period=..., scenario=...) - >>> # - FlowSystem._dataset_isel(dataset, time=..., period=..., scenario=...) - >>> # - flow_system._dataset_resample(dataset, freq=..., method=..., **kwargs) + >>> result = flow_system.transform.sel(time='2020-01') + >>> result = flow_system.transform.resample('2h') >>> for flow in flow_system.flows.values(): ... print(f'{flow.label_full}: {flow.size}') >>> @@ -252,7 +238,6 @@ def __init__( self.model: FlowSystemModel | None = None self._connected_and_transformed = False - self._used_in_optimization = False self._network_app = None self._flows_cache: ElementContainer[Flow] | None = None @@ -819,81 +804,6 @@ def from_netcdf(cls, path: str | pathlib.Path) -> FlowSystem: flow_system.name = path.stem return flow_system - @classmethod - def from_old_results(cls, folder: str | pathlib.Path, name: str) -> FlowSystem: - """ - Load a FlowSystem from old-format Results files (pre-v5 API). - - This method loads results saved with the deprecated Results API - (which used multiple files: ``*--flow_system.nc4``, ``*--solution.nc4``) - and converts them to a FlowSystem with the solution attached. - - The method performs the following: - - - Loads the old multi-file format - - Renames deprecated parameters in the FlowSystem structure - (e.g., ``on_off_parameters`` → ``status_parameters``) - - Attaches the solution data to the FlowSystem - - Args: - folder: Directory containing the saved result files - name: Base name of the saved files (without extensions) - - Returns: - FlowSystem instance with solution attached - - Warning: - This is a best-effort migration for accessing old results: - - - **Solution variable names are NOT renamed** - only basic variables - work (flow rates, sizes, charge states, effect totals) - - Advanced variable access may require using the original names - - Summary metadata (solver info, timing) is not loaded - - For full compatibility, re-run optimizations with the new API. - - Examples: - ```python - # Load old results - fs = FlowSystem.from_old_results('results_folder', 'my_optimization') - - # Access basic solution data - fs.solution['Boiler(Q_th)|flow_rate'].plot() - - # Save in new single-file format - fs.to_netcdf('my_optimization.nc') - ``` - - Deprecated: - This method will be removed in v6. - """ - warnings.warn( - f'from_old_results() is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. ' - 'This utility is only for migrating results from flixopt versions before v5.', - DeprecationWarning, - stacklevel=2, - ) - from flixopt.io import load_dataset_from_netcdf - - folder = pathlib.Path(folder) - flow_system_path = folder / f'{name}--flow_system.nc4' - solution_path = folder / f'{name}--solution.nc4' - - # Load FlowSystem using from_old_dataset (suppress its deprecation warning) - with warnings.catch_warnings(): - warnings.simplefilter('ignore', DeprecationWarning) - flow_system = cls.from_old_dataset(flow_system_path) - flow_system.name = name - - # Attach solution (convert attrs from dicts to JSON strings for consistency) - solution = load_dataset_from_netcdf(solution_path) - for key in ['Components', 'Buses', 'Effects', 'Flows']: - if key in solution.attrs and isinstance(solution.attrs[key], dict): - solution.attrs[key] = json.dumps(solution.attrs[key]) - flow_system.solution = solution - - return flow_system - @classmethod def from_old_dataset(cls, path: str | pathlib.Path) -> FlowSystem: """ @@ -901,8 +811,8 @@ def from_old_dataset(cls, path: str | pathlib.Path) -> FlowSystem: This method loads a FlowSystem saved with older versions of flixopt (the ``*--flow_system.nc4`` file) and converts parameter names to the - current API. Unlike :meth:`from_old_results`, this does not require - a solution file and returns a FlowSystem without solution data. + current API. It does not require a solution file and returns a + FlowSystem without solution data. The method performs the following: @@ -931,16 +841,7 @@ def from_old_dataset(cls, path: str | pathlib.Path) -> FlowSystem: # Save in new single-file format fs.to_netcdf('my_run.nc') ``` - - Deprecated: - This method will be removed in v6. """ - warnings.warn( - f'from_old_dataset() is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. ' - 'This utility is only for migrating FlowSystems from flixopt versions before v5.', - DeprecationWarning, - stacklevel=2, - ) from flixopt.io import convert_old_dataset, load_dataset_from_netcdf path = pathlib.Path(path) @@ -1344,20 +1245,8 @@ def flow_carriers(self) -> dict[str, str]: return self._flow_carriers - def create_model(self, normalize_weights: bool | None = None) -> FlowSystemModel: - """ - Create a linopy model from the FlowSystem. - - Args: - normalize_weights: Deprecated. Scenario weights are now always normalized in FlowSystem. - """ - if normalize_weights is not None: - warnings.warn( - f'\n\nnormalize_weights parameter is deprecated and will be removed in {DEPRECATION_REMOVAL_VERSION}. ' - 'Scenario weights are now always normalized when set on FlowSystem.\n', - DeprecationWarning, - stacklevel=2, - ) + def create_model(self) -> FlowSystemModel: + """Create a linopy model from the FlowSystem.""" if not self.connected_and_transformed: raise RuntimeError( 'FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.' @@ -1366,7 +1255,7 @@ def create_model(self, normalize_weights: bool | None = None) -> FlowSystemModel self.model = FlowSystemModel(self) return self.model - def build_model(self, normalize_weights: bool | None = None) -> FlowSystem: + def build_model(self) -> FlowSystem: """ Build the optimization model for this FlowSystem. @@ -1379,9 +1268,6 @@ def build_model(self, normalize_weights: bool | None = None) -> FlowSystem: After calling this method, `self.model` will be available for inspection before solving. - Args: - normalize_weights: Deprecated. Scenario weights are now always normalized in FlowSystem. - Returns: Self, for method chaining. @@ -1390,13 +1276,6 @@ def build_model(self, normalize_weights: bool | None = None) -> FlowSystem: >>> print(flow_system.model.variables) # Inspect variables before solving >>> flow_system.solve(solver) """ - if normalize_weights is not None: - warnings.warn( - f'\n\nnormalize_weights parameter is deprecated and will be removed in {DEPRECATION_REMOVAL_VERSION}. ' - 'Scenario weights are now always normalized when set on FlowSystem.\n', - DeprecationWarning, - stacklevel=2, - ) self.connect_and_transform() self.create_model() @@ -1788,70 +1667,6 @@ def topology(self) -> TopologyAccessor: self._topology = TopologyAccessor(self) return self._topology - def plot_network( - self, - path: bool | str | pathlib.Path = 'flow_system.html', - controls: bool - | list[ - Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'] - ] = True, - show: bool | None = None, - ) -> pyvis.network.Network | None: - """ - Deprecated: Use `flow_system.topology.plot()` instead. - - Visualizes the network structure of a FlowSystem using PyVis. - """ - warnings.warn( - f'plot_network() is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. ' - 'Use flow_system.topology.plot() instead.', - DeprecationWarning, - stacklevel=2, - ) - return self.topology.plot_legacy(path=path, controls=controls, show=show) - - def start_network_app(self) -> None: - """ - Deprecated: Use `flow_system.topology.start_app()` instead. - - Visualizes the network structure using Dash and Cytoscape. - """ - warnings.warn( - f'start_network_app() is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. ' - 'Use flow_system.topology.start_app() instead.', - DeprecationWarning, - stacklevel=2, - ) - self.topology.start_app() - - def stop_network_app(self) -> None: - """ - Deprecated: Use `flow_system.topology.stop_app()` instead. - - Stop the network visualization server. - """ - warnings.warn( - f'stop_network_app() is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. ' - 'Use flow_system.topology.stop_app() instead.', - DeprecationWarning, - stacklevel=2, - ) - self.topology.stop_app() - - def network_infos(self) -> tuple[dict[str, dict[str, str]], dict[str, dict[str, str]]]: - """ - Deprecated: Use `flow_system.topology.infos()` instead. - - Get network topology information as dictionaries. - """ - warnings.warn( - f'network_infos() is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. ' - 'Use flow_system.topology.infos() instead.', - DeprecationWarning, - stacklevel=2, - ) - return self.topology.infos() - def _check_if_element_is_unique(self, element: Element) -> None: """ checks if element or label of element already exists in list @@ -2113,27 +1928,6 @@ def temporal_weight(self) -> xr.DataArray: cluster_weight = self.weights.get('cluster', self.cluster_weight if self.cluster_weight is not None else 1.0) return self.weights['time'] * cluster_weight - @property - def coords(self) -> dict[FlowSystemDimensions, pd.Index]: - """Active coordinates for variable creation. - - .. deprecated:: - Use :attr:`indexes` instead. - - Returns a dict of dimension names to coordinate arrays. When clustered, - includes 'cluster' dimension before 'time'. - - Returns: - Dict mapping dimension names to coordinate arrays. - """ - warnings.warn( - f'FlowSystem.coords is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. ' - 'Use FlowSystem.indexes instead.', - DeprecationWarning, - stacklevel=2, - ) - return self.indexes - @property def _use_true_cluster_dims(self) -> bool: """Check if true (cluster, time) dimensions should be used.""" @@ -2170,10 +1964,6 @@ def n_timesteps(self) -> int: return self.clustering.timesteps_per_cluster return len(self.timesteps) - @property - def used_in_calculation(self) -> bool: - return self._used_in_optimization - @property def scenario_weights(self) -> xr.DataArray | None: """ @@ -2366,270 +2156,6 @@ def scenario_independent_flow_rates(self, value: bool | list[str]) -> None: self._validate_scenario_parameter(value, 'scenario_independent_flow_rates', 'Flow.label_full') self._scenario_independent_flow_rates = value - @classmethod - def _dataset_sel( - cls, - dataset: xr.Dataset, - time: str | slice | list[str] | pd.Timestamp | pd.DatetimeIndex | None = None, - period: int | slice | list[int] | pd.Index | None = None, - scenario: str | slice | list[str] | pd.Index | None = None, - hours_of_last_timestep: int | float | None = None, - hours_of_previous_timesteps: int | float | np.ndarray | None = None, - ) -> xr.Dataset: - """ - Select subset of dataset by label (for power users to avoid conversion overhead). - - This method operates directly on xarray Datasets, allowing power users to chain - operations efficiently without repeated FlowSystem conversions: - - Example: - # Power user pattern (single conversion): - >>> ds = flow_system.to_dataset() - >>> ds = FlowSystem._dataset_sel(ds, time='2020-01') - >>> ds = FlowSystem._dataset_resample(ds, freq='2h', method='mean') - >>> result = FlowSystem.from_dataset(ds) - - # vs. simple pattern (multiple conversions): - >>> result = flow_system.sel(time='2020-01').resample('2h') - - Args: - dataset: xarray Dataset from FlowSystem.to_dataset() - time: Time selection (e.g., '2020-01', slice('2020-01-01', '2020-06-30')) - period: Period selection (e.g., 2020, slice(2020, 2022)) - scenario: Scenario selection (e.g., 'Base Case', ['Base Case', 'High Demand']) - hours_of_last_timestep: Duration of the last timestep. If None, computed from the selected time index. - hours_of_previous_timesteps: Duration of previous timesteps. If None, computed from the selected time index. - Can be a scalar or array. - - Returns: - xr.Dataset: Selected dataset - """ - warnings.warn( - f'\n_dataset_sel() is deprecated and will be removed in {DEPRECATION_REMOVAL_VERSION}. ' - 'Use TransformAccessor._dataset_sel() instead.', - DeprecationWarning, - stacklevel=2, - ) - from .transform_accessor import TransformAccessor - - return TransformAccessor._dataset_sel( - dataset, - time=time, - period=period, - scenario=scenario, - hours_of_last_timestep=hours_of_last_timestep, - hours_of_previous_timesteps=hours_of_previous_timesteps, - ) - - def sel( - self, - time: str | slice | list[str] | pd.Timestamp | pd.DatetimeIndex | None = None, - period: int | slice | list[int] | pd.Index | None = None, - scenario: str | slice | list[str] | pd.Index | None = None, - ) -> FlowSystem: - """ - Select a subset of the flowsystem by label. - - .. deprecated:: - Use ``flow_system.transform.sel()`` instead. Will be removed in v6.0.0. - - Args: - time: Time selection (e.g., slice('2023-01-01', '2023-12-31'), '2023-06-15') - period: Period selection (e.g., slice(2023, 2024), or list of periods) - scenario: Scenario selection (e.g., 'scenario1', or list of scenarios) - - Returns: - FlowSystem: New FlowSystem with selected data (no solution). - """ - warnings.warn( - f'\nsel() is deprecated and will be removed in {DEPRECATION_REMOVAL_VERSION}. ' - 'Use flow_system.transform.sel() instead.', - DeprecationWarning, - stacklevel=2, - ) - return self.transform.sel(time=time, period=period, scenario=scenario) - - @classmethod - def _dataset_isel( - cls, - dataset: xr.Dataset, - time: int | slice | list[int] | None = None, - period: int | slice | list[int] | None = None, - scenario: int | slice | list[int] | None = None, - hours_of_last_timestep: int | float | None = None, - hours_of_previous_timesteps: int | float | np.ndarray | None = None, - ) -> xr.Dataset: - """ - Select subset of dataset by integer index (for power users to avoid conversion overhead). - - See _dataset_sel() for usage pattern. - - Args: - dataset: xarray Dataset from FlowSystem.to_dataset() - time: Time selection by index (e.g., slice(0, 100), [0, 5, 10]) - period: Period selection by index - scenario: Scenario selection by index - hours_of_last_timestep: Duration of the last timestep. If None, computed from the selected time index. - hours_of_previous_timesteps: Duration of previous timesteps. If None, computed from the selected time index. - Can be a scalar or array. - - Returns: - xr.Dataset: Selected dataset - """ - warnings.warn( - f'\n_dataset_isel() is deprecated and will be removed in {DEPRECATION_REMOVAL_VERSION}. ' - 'Use TransformAccessor._dataset_isel() instead.', - DeprecationWarning, - stacklevel=2, - ) - from .transform_accessor import TransformAccessor - - return TransformAccessor._dataset_isel( - dataset, - time=time, - period=period, - scenario=scenario, - hours_of_last_timestep=hours_of_last_timestep, - hours_of_previous_timesteps=hours_of_previous_timesteps, - ) - - def isel( - self, - time: int | slice | list[int] | None = None, - period: int | slice | list[int] | None = None, - scenario: int | slice | list[int] | None = None, - ) -> FlowSystem: - """ - Select a subset of the flowsystem by integer indices. - - .. deprecated:: - Use ``flow_system.transform.isel()`` instead. Will be removed in v6.0.0. - - Args: - time: Time selection by integer index (e.g., slice(0, 100), 50, or [0, 5, 10]) - period: Period selection by integer index - scenario: Scenario selection by integer index - - Returns: - FlowSystem: New FlowSystem with selected data (no solution). - """ - warnings.warn( - f'\nisel() is deprecated and will be removed in {DEPRECATION_REMOVAL_VERSION}. ' - 'Use flow_system.transform.isel() instead.', - DeprecationWarning, - stacklevel=2, - ) - return self.transform.isel(time=time, period=period, scenario=scenario) - - @classmethod - def _dataset_resample( - cls, - dataset: xr.Dataset, - freq: str, - method: Literal['mean', 'sum', 'max', 'min', 'first', 'last', 'std', 'var', 'median', 'count'] = 'mean', - hours_of_last_timestep: int | float | None = None, - hours_of_previous_timesteps: int | float | np.ndarray | None = None, - **kwargs: Any, - ) -> xr.Dataset: - """ - Resample dataset along time dimension (for power users to avoid conversion overhead). - Preserves only the attrs of the Dataset. - - Uses optimized _resample_by_dimension_groups() to avoid broadcasting issues. - See _dataset_sel() for usage pattern. - - Args: - dataset: xarray Dataset from FlowSystem.to_dataset() - freq: Resampling frequency (e.g., '2h', '1D', '1M') - method: Resampling method (e.g., 'mean', 'sum', 'first') - hours_of_last_timestep: Duration of the last timestep after resampling. If None, computed from the last time interval. - hours_of_previous_timesteps: Duration of previous timesteps after resampling. If None, computed from the first time interval. - Can be a scalar or array. - **kwargs: Additional arguments passed to xarray.resample() - - Returns: - xr.Dataset: Resampled dataset - """ - warnings.warn( - f'\n_dataset_resample() is deprecated and will be removed in {DEPRECATION_REMOVAL_VERSION}. ' - 'Use TransformAccessor._dataset_resample() instead.', - DeprecationWarning, - stacklevel=2, - ) - from .transform_accessor import TransformAccessor - - return TransformAccessor._dataset_resample( - dataset, - freq=freq, - method=method, - hours_of_last_timestep=hours_of_last_timestep, - hours_of_previous_timesteps=hours_of_previous_timesteps, - **kwargs, - ) - - @classmethod - def _resample_by_dimension_groups( - cls, - time_dataset: xr.Dataset, - time: str, - method: str, - **kwargs: Any, - ) -> xr.Dataset: - """ - Resample variables grouped by their dimension structure to avoid broadcasting. - - .. deprecated:: - Use ``TransformAccessor._resample_by_dimension_groups()`` instead. - Will be removed in v6.0.0. - """ - warnings.warn( - f'\n_resample_by_dimension_groups() is deprecated and will be removed in {DEPRECATION_REMOVAL_VERSION}. ' - 'Use TransformAccessor._resample_by_dimension_groups() instead.', - DeprecationWarning, - stacklevel=2, - ) - from .transform_accessor import TransformAccessor - - return TransformAccessor._resample_by_dimension_groups(time_dataset, time, method, **kwargs) - - def resample( - self, - time: str, - method: Literal['mean', 'sum', 'max', 'min', 'first', 'last', 'std', 'var', 'median', 'count'] = 'mean', - hours_of_last_timestep: int | float | None = None, - hours_of_previous_timesteps: int | float | np.ndarray | None = None, - **kwargs: Any, - ) -> FlowSystem: - """ - Create a resampled FlowSystem by resampling data along the time dimension. - - .. deprecated:: - Use ``flow_system.transform.resample()`` instead. Will be removed in v6.0.0. - - Args: - time: Resampling frequency (e.g., '3h', '2D', '1M') - method: Resampling method. Recommended: 'mean', 'first', 'last', 'max', 'min' - hours_of_last_timestep: Duration of the last timestep after resampling. - hours_of_previous_timesteps: Duration of previous timesteps after resampling. - **kwargs: Additional arguments passed to xarray.resample() - - Returns: - FlowSystem: New resampled FlowSystem (no solution). - """ - warnings.warn( - f'\nresample() is deprecated and will be removed in {DEPRECATION_REMOVAL_VERSION}. ' - 'Use flow_system.transform.resample() instead.', - DeprecationWarning, - stacklevel=2, - ) - return self.transform.resample( - time=time, - method=method, - hours_of_last_timestep=hours_of_last_timestep, - hours_of_previous_timesteps=hours_of_previous_timesteps, - **kwargs, - ) - @property def connected_and_transformed(self) -> bool: return self._connected_and_transformed diff --git a/flixopt/optimization.py b/flixopt/optimization.py deleted file mode 100644 index 4f5da92fa..000000000 --- a/flixopt/optimization.py +++ /dev/null @@ -1,798 +0,0 @@ -""" -This module contains the Optimization functionality for the flixopt framework. -It is used to optimize a FlowSystemModel for a given FlowSystem through a solver. - -There are two Optimization types: - 1. Optimization: Optimizes the FlowSystemModel for the full FlowSystem - 2. SegmentedOptimization: Solves a FlowSystemModel for each individual Segment of the FlowSystem. - -For time series aggregation (clustering), use FlowSystem.transform.cluster() instead. -""" - -from __future__ import annotations - -import logging -import math -import pathlib -import sys -import timeit -import warnings -from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable - -from tqdm import tqdm - -from . import io as fx_io -from .components import Storage -from .config import CONFIG, DEPRECATION_REMOVAL_VERSION, SUCCESS_LEVEL -from .effects import PENALTY_EFFECT_LABEL -from .features import InvestmentModel -from .results import Results, SegmentedResults - -if TYPE_CHECKING: - import pandas as pd - import xarray as xr - - from .flow_system import FlowSystem - from .solvers import _Solver - from .structure import FlowSystemModel - -logger = logging.getLogger('flixopt') - - -@runtime_checkable -class OptimizationProtocol(Protocol): - """ - Protocol defining the interface that all optimization types should implement. - - This protocol ensures type consistency across different optimization approaches - without forcing them into an artificial inheritance hierarchy. - - Attributes: - name: Name of the optimization - flow_system: FlowSystem being optimized - folder: Directory where results are saved - results: Results object after solving - durations: Dictionary tracking time spent in different phases - """ - - name: str - flow_system: FlowSystem - folder: pathlib.Path - results: Results | SegmentedResults | None - durations: dict[str, float] - - @property - def modeled(self) -> bool: - """Returns True if the optimization has been modeled.""" - ... - - @property - def main_results(self) -> dict[str, int | float | dict]: - """Returns main results including objective, effects, and investment decisions.""" - ... - - @property - def summary(self) -> dict: - """Returns summary information about the optimization.""" - ... - - -def _initialize_optimization_common( - obj: Any, - name: str, - flow_system: FlowSystem, - folder: pathlib.Path | None = None, - normalize_weights: bool | None = None, -) -> None: - """ - Shared initialization logic for all optimization types. - - This helper function encapsulates common initialization code to avoid duplication - across Optimization and SegmentedOptimization. - - Args: - obj: The optimization object being initialized - name: Name of the optimization - flow_system: FlowSystem to optimize - folder: Directory for saving results - normalize_weights: Deprecated. Scenario weights are now always normalized in FlowSystem. - """ - obj.name = name - - if flow_system.used_in_calculation: - logger.warning( - f'This FlowSystem is already used in an optimization:\n{flow_system}\n' - f'Creating a copy of the FlowSystem for Optimization "{obj.name}".' - ) - flow_system = flow_system.copy() - - # normalize_weights is deprecated but kept for backwards compatibility - if normalize_weights is not None: - warnings.warn( - f'\n\nnormalize_weights parameter is deprecated and will be removed in {DEPRECATION_REMOVAL_VERSION}. ' - 'Scenario weights are now always normalized when set on FlowSystem.\n', - DeprecationWarning, - stacklevel=3, - ) - obj.normalize_weights = True # Always True now - - flow_system._used_in_optimization = True - - obj.flow_system = flow_system - obj.model = None - - obj.durations = {'modeling': 0.0, 'solving': 0.0, 'saving': 0.0} - obj.folder = pathlib.Path.cwd() / 'results' if folder is None else pathlib.Path(folder) - obj.results = None - - if obj.folder.exists() and not obj.folder.is_dir(): - raise NotADirectoryError(f'Path {obj.folder} exists and is not a directory.') - # Create folder and any necessary parent directories - obj.folder.mkdir(parents=True, exist_ok=True) - - -class Optimization: - """ - Standard optimization that solves the complete problem using all time steps. - - This is the default optimization approach that considers every time step, - providing the most accurate but computationally intensive solution. - - For large problems, consider using FlowSystem.transform.cluster() (time aggregation) - or SegmentedOptimization (temporal decomposition) instead. - - Args: - name: name of optimization - flow_system: flow_system which should be optimized - folder: folder where results should be saved. If None, then the current working directory is used. - normalize_weights: Deprecated. Scenario weights are now always normalized in FlowSystem. - - Examples: - Basic usage: - ```python - from flixopt import Optimization - - opt = Optimization(name='my_optimization', flow_system=energy_system, folder=Path('results')) - opt.do_modeling() - opt.solve(solver=gurobi) - results = opt.results - ``` - """ - - # Attributes set by __init__ / _initialize_optimization_common - name: str - flow_system: FlowSystem - folder: pathlib.Path - results: Results | None - durations: dict[str, float] - model: FlowSystemModel | None - normalize_weights: bool - - def __init__( - self, - name: str, - flow_system: FlowSystem, - folder: pathlib.Path | None = None, - normalize_weights: bool = True, - ): - warnings.warn( - f'Optimization is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. ' - 'Use FlowSystem.optimize(solver) or FlowSystem.build_model() + FlowSystem.solve(solver) instead. ' - 'Access results via FlowSystem.solution.', - DeprecationWarning, - stacklevel=2, - ) - _initialize_optimization_common( - self, - name=name, - flow_system=flow_system, - folder=folder, - normalize_weights=normalize_weights, - ) - - def do_modeling(self) -> Optimization: - t_start = timeit.default_timer() - self.flow_system.connect_and_transform() - - self.model = self.flow_system.create_model() - self.model.do_modeling() - - self.durations['modeling'] = round(timeit.default_timer() - t_start, 2) - return self - - def fix_sizes(self, ds: xr.Dataset | None = None, decimal_rounding: int | None = 5) -> Optimization: - """Fix the sizes of the optimizations to specified values. - - Args: - ds: The dataset that contains the variable names mapped to their sizes. If None, the dataset is loaded from the results. - decimal_rounding: The number of decimal places to round the sizes to. If no rounding is applied, numerical errors might lead to infeasibility. - """ - if not self.modeled: - raise RuntimeError('Model was not created. Call do_modeling() first.') - - if ds is None: - if self.results is None: - raise RuntimeError('No dataset provided and no results available to load sizes from.') - ds = self.results.solution - - if decimal_rounding is not None: - ds = ds.round(decimal_rounding) - - for name, da in ds.data_vars.items(): - if '|size' not in name: - continue - if name not in self.model.variables: - logger.debug(f'Variable {name} not found in calculation model. Skipping.') - continue - - con = self.model.add_constraints( - self.model[name] == da, - name=f'{name}-fixed', - ) - logger.debug(f'Fixed "{name}":\n{con}') - - return self - - def solve( - self, solver: _Solver, log_file: pathlib.Path | None = None, log_main_results: bool | None = None - ) -> Optimization: - # Auto-call do_modeling() if not already done - if not self.modeled: - logger.info('Model not yet created. Calling do_modeling() automatically.') - self.do_modeling() - - t_start = timeit.default_timer() - - log_fn = pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log' - if CONFIG.Solving.capture_solver_log: - with fx_io.stream_solver_log(log_fn=log_fn) as log_path: - self.model.solve( - log_fn=log_path, - solver_name=solver.name, - progress=False, - **solver.options, - ) - else: - self.model.solve( - log_fn=log_fn, - solver_name=solver.name, - progress=CONFIG.Solving.log_to_console, - **solver.options, - ) - self.durations['solving'] = round(timeit.default_timer() - t_start, 2) - logger.log(SUCCESS_LEVEL, f'Model solved with {solver.name} in {self.durations["solving"]:.2f} seconds.') - logger.info(f'Model status after solve: {self.model.status}') - - if self.model.status == 'warning': - # Save the model and the flow_system to file in case of infeasibility - self.folder.mkdir(parents=True, exist_ok=True) - paths = fx_io.ResultsPaths(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, overwrite=True) - raise RuntimeError( - f'Model was infeasible. Please check {paths.model_documentation=} and {paths.flow_system=} for more information.' - ) - - # Log the formatted output - should_log = log_main_results if log_main_results is not None else CONFIG.Solving.log_main_results - if should_log and logger.isEnabledFor(logging.INFO): - logger.log( - SUCCESS_LEVEL, - f'{" Main Results ":#^80}\n' + fx_io.format_yaml_string(self.main_results, compact_numeric_lists=True), - ) - - # Store solution on FlowSystem for direct Element access - self.flow_system.solution = self.model.solution - - self.results = Results.from_optimization(self) - - return self - - @property - def main_results(self) -> dict[str, int | float | dict]: - if self.model is None: - raise RuntimeError('Optimization has not been solved yet. Call solve() before accessing main_results.') - - try: - penalty_effect = self.flow_system.effects.penalty_effect - penalty_section = { - 'temporal': penalty_effect.submodel.temporal.total.solution.values, - 'periodic': penalty_effect.submodel.periodic.total.solution.values, - 'total': penalty_effect.submodel.total.solution.values, - } - except KeyError: - penalty_section = {'temporal': 0.0, 'periodic': 0.0, 'total': 0.0} - - main_results = { - 'Objective': self.model.objective.value, - 'Penalty': penalty_section, - 'Effects': { - f'{effect.label} [{effect.unit}]': { - 'temporal': effect.submodel.temporal.total.solution.values, - 'periodic': effect.submodel.periodic.total.solution.values, - 'total': effect.submodel.total.solution.values, - } - for effect in sorted(self.flow_system.effects.values(), key=lambda e: e.label_full.upper()) - if effect.label_full != PENALTY_EFFECT_LABEL - }, - 'Invest-Decisions': { - 'Invested': { - model.label_of_element: model.size.solution - for component in self.flow_system.components.values() - for model in component.submodel.all_submodels - if isinstance(model, InvestmentModel) - and model.size.solution.max().item() >= CONFIG.Modeling.epsilon - }, - 'Not invested': { - model.label_of_element: model.size.solution - for component in self.flow_system.components.values() - for model in component.submodel.all_submodels - if isinstance(model, InvestmentModel) and model.size.solution.max().item() < CONFIG.Modeling.epsilon - }, - }, - 'Buses with excess': [ - { - bus.label_full: { - 'virtual_supply': bus.submodel.virtual_supply.solution.sum('time'), - 'virtual_demand': bus.submodel.virtual_demand.solution.sum('time'), - } - } - for bus in self.flow_system.buses.values() - if bus.allows_imbalance - and ( - bus.submodel.virtual_supply.solution.sum().item() > 1e-3 - or bus.submodel.virtual_demand.solution.sum().item() > 1e-3 - ) - ], - } - - return fx_io.round_nested_floats(main_results) - - @property - def summary(self): - if self.model is None: - raise RuntimeError('Optimization has not been solved yet. Call solve() before accessing summary.') - - return { - 'Name': self.name, - 'Number of timesteps': len(self.flow_system.timesteps), - 'Optimization 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(), - } - - @property - def modeled(self) -> bool: - return True if self.model is not None else False - - -class SegmentedOptimization: - """Solve large optimization problems by dividing time horizon into (overlapping) segments. - - This class addresses memory and computational limitations of large-scale optimization - problems by decomposing the time horizon into smaller overlapping segments that are - solved sequentially. Each segment uses final values from the previous segment as - initial conditions, ensuring dynamic continuity across the solution. - - Key Concepts: - **Temporal Decomposition**: Divides long time horizons into manageable segments - **Overlapping Windows**: Segments share timesteps to improve storage dynamics - **Value Transfer**: Final states of one segment become initial states of the next - **Sequential Solving**: Each segment solved independently but with coupling - - Limitations and Constraints: - **Investment Parameters**: InvestParameters are not supported in segmented optimizations - as investment decisions must be made for the entire time horizon, not per segment. - - **Global Constraints**: Time-horizon-wide constraints (flow_hours_total_min/max, - load_factor_min/max) may produce suboptimal results as they cannot be enforced - globally across segments. - - **Storage Dynamics**: While overlap helps, storage optimization may be suboptimal - compared to full-horizon solutions due to limited foresight in each segment. - - Args: - name: Unique identifier for the calculation, used in result files and logging. - flow_system: The FlowSystem to optimize, containing all components, flows, and buses. - timesteps_per_segment: Number of timesteps in each segment (excluding overlap). - Must be > 2 to avoid internal side effects. Larger values provide better - optimization at the cost of memory and computation time. - overlap_timesteps: Number of additional timesteps added to each segment. - Improves storage optimization by providing lookahead. Higher values - improve solution quality but increase computational cost. - nr_of_previous_values: Number of previous timestep values to transfer between - segments for initialization. Typically 1 is sufficient. - folder: Directory for saving results. Defaults to current working directory + 'results'. - - Examples: - Annual optimization with monthly segments: - - ```python - # 8760 hours annual data with monthly segments (730 hours) and 48-hour overlap - segmented_calc = SegmentedOptimization( - name='annual_energy_system', - flow_system=energy_system, - timesteps_per_segment=730, # ~1 month - overlap_timesteps=48, # 2 days overlap - folder=Path('results/segmented'), - ) - segmented_calc.do_modeling_and_solve(solver='gurobi') - ``` - - Weekly optimization with daily overlap: - - ```python - # Weekly segments for detailed operational planning - weekly_calc = SegmentedOptimization( - name='weekly_operations', - flow_system=industrial_system, - timesteps_per_segment=168, # 1 week (hourly data) - overlap_timesteps=24, # 1 day overlap - nr_of_previous_values=1, - ) - ``` - - Large-scale system with minimal overlap: - - ```python - # Large system with minimal overlap for computational efficiency - large_calc = SegmentedOptimization( - name='large_scale_grid', - flow_system=grid_system, - timesteps_per_segment=100, # Shorter segments - overlap_timesteps=5, # Minimal overlap - ) - ``` - - Design Considerations: - **Segment Size**: Balance between solution quality and computational efficiency. - Larger segments provide better optimization but require more memory and time. - - **Overlap Duration**: More overlap improves storage dynamics and reduces - end-effects but increases computational cost. Typically 5-10% of segment length. - - **Storage Systems**: Systems with large storage components benefit from longer - overlaps to capture charge/discharge cycles effectively. - - **Investment Decisions**: Use Optimization for problems requiring investment - optimization, as SegmentedOptimization cannot handle investment parameters. - - Common Use Cases: - - **Annual Planning**: Long-term planning with seasonal variations - - **Large Networks**: Spatially or temporally large energy systems - - **Memory-Limited Systems**: When full optimization exceeds available memory - - **Operational Planning**: Detailed short-term optimization with limited foresight - - **Sensitivity Analysis**: Quick approximate solutions for parameter studies - - Performance Tips: - - Start with Optimization and use this class if memory issues occur - - Use longer overlaps for systems with significant storage - - Monitor solution quality at segment boundaries for discontinuities - - Warning: - The evaluation of the solution is a bit more complex than Optimization - due to the overlapping individual solutions. - - """ - - # Attributes set by __init__ / _initialize_optimization_common - name: str - flow_system: FlowSystem - folder: pathlib.Path - results: SegmentedResults | None - durations: dict[str, float] - model: None # SegmentedOptimization doesn't use a single model - normalize_weights: bool - - def __init__( - self, - name: str, - flow_system: FlowSystem, - timesteps_per_segment: int, - overlap_timesteps: int, - nr_of_previous_values: int = 1, - folder: pathlib.Path | None = None, - ): - warnings.warn( - f'SegmentedOptimization is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. ' - 'A replacement API for segmented optimization will be provided in a future release.', - DeprecationWarning, - stacklevel=2, - ) - _initialize_optimization_common( - self, - name=name, - flow_system=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 - - # Validate overlap_timesteps early - if self.overlap_timesteps < 0: - raise ValueError('overlap_timesteps must be non-negative.') - - # Validate timesteps_per_segment early (before using in arithmetic) - if self.timesteps_per_segment <= 2: - raise ValueError('timesteps_per_segment must be greater than 2 due to internal side effects.') - - # Validate nr_of_previous_values - if self.nr_of_previous_values < 0: - raise ValueError('nr_of_previous_values must be non-negative.') - if self.nr_of_previous_values > self.timesteps_per_segment: - raise ValueError('nr_of_previous_values cannot exceed timesteps_per_segment.') - - self.sub_optimizations: list[Optimization] = [] - - self.segment_names = [ - f'Segment_{i + 1}' for i in range(math.ceil(len(self.all_timesteps) / self.timesteps_per_segment)) - ] - self._timesteps_per_segment = self._calculate_timesteps_per_segment() - - if self.timesteps_per_segment_with_overlap > len(self.all_timesteps): - raise ValueError( - f'timesteps_per_segment_with_overlap ({self.timesteps_per_segment_with_overlap}) ' - f'cannot exceed total timesteps ({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 _create_sub_optimizations(self): - for i, (segment_name, timesteps_of_segment) in enumerate( - zip(self.segment_names, self._timesteps_per_segment, strict=True) - ): - calc = Optimization(f'{self.name}-{segment_name}', self.flow_system.sel(time=timesteps_of_segment)) - calc.flow_system._connect_network() # Connect to have Correct names of Flows! - - self.sub_optimizations.append(calc) - logger.info( - f'{segment_name} [{i + 1:>2}/{len(self.segment_names):<2}] ' - f'({timesteps_of_segment[0]} -> {timesteps_of_segment[-1]}):' - ) - - def _solve_single_segment( - self, - i: int, - optimization: Optimization, - solver: _Solver, - log_file: pathlib.Path | None, - log_main_results: bool, - suppress_output: bool, - ) -> None: - """Solve a single segment optimization.""" - if i > 0 and self.nr_of_previous_values > 0: - self._transfer_start_values(i) - - optimization.do_modeling() - - # Check for unsupported Investments, but only in first run - if i == 0: - invest_elements = [ - model.label_full - for component in optimization.flow_system.components.values() - for model in component.submodel.all_submodels - if isinstance(model, InvestmentModel) - ] - if invest_elements: - raise ValueError( - f'Investments are not supported in SegmentedOptimization. ' - f'Found InvestmentModels: {invest_elements}. ' - f'Please use Optimization instead for problems with investments.' - ) - - log_path = pathlib.Path(log_file) if log_file is not None else self.folder / f'{self.name}.log' - - if suppress_output: - with fx_io.suppress_output(): - optimization.solve(solver, log_file=log_path, log_main_results=log_main_results) - else: - optimization.solve(solver, log_file=log_path, log_main_results=log_main_results) - - def do_modeling_and_solve( - self, - solver: _Solver, - log_file: pathlib.Path | None = None, - log_main_results: bool = False, - show_individual_solves: bool = False, - ) -> SegmentedOptimization: - """Model and solve all segments of the segmented optimization. - - This method creates sub-optimizations for each time segment, then iteratively - models and solves each segment. It supports two output modes: a progress bar - for compact output, or detailed individual solve information. - - Args: - solver: The solver instance to use for optimization (e.g., Gurobi, HiGHS). - log_file: Optional path to the solver log file. If None, defaults to - folder/name.log. - log_main_results: Whether to log main results (objective, effects, etc.) - after each segment solve. Defaults to False. - show_individual_solves: If True, shows detailed output for each segment - solve with logger messages. If False (default), shows a compact progress - bar with suppressed solver output for cleaner display. - - Returns: - Self, for method chaining. - - Note: - The method automatically transfers all start values between segments to ensure - continuity of storage states and flow rates across segment boundaries. - """ - logger.info(f'{"":#^80}') - logger.info(f'{" Segmented Solving ":#^80}') - self._create_sub_optimizations() - - if show_individual_solves: - # Path 1: Show individual solves with detailed output - for i, optimization in enumerate(self.sub_optimizations): - logger.info( - f'Solving segment {i + 1}/{len(self.sub_optimizations)}: ' - f'{optimization.flow_system.timesteps[0]} -> {optimization.flow_system.timesteps[-1]}' - ) - self._solve_single_segment(i, optimization, solver, log_file, log_main_results, suppress_output=False) - else: - # Path 2: Show only progress bar with suppressed output - progress_bar = tqdm( - enumerate(self.sub_optimizations), - total=len(self.sub_optimizations), - desc='Solving segments', - unit='segment', - file=sys.stdout, - disable=not CONFIG.Solving.log_to_console, - ) - - try: - for i, optimization in progress_bar: - progress_bar.set_description( - f'Solving ({optimization.flow_system.timesteps[0]} -> {optimization.flow_system.timesteps[-1]})' - ) - self._solve_single_segment( - i, optimization, solver, log_file, log_main_results, suppress_output=True - ) - finally: - progress_bar.close() - - for calc in self.sub_optimizations: - for key, value in calc.durations.items(): - self.durations[key] += value - - logger.log(SUCCESS_LEVEL, f'Model solved with {solver.name} in {self.durations["solving"]:.2f} seconds.') - - self.results = SegmentedResults.from_optimization(self) - - return self - - def _transfer_start_values(self, i: 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.sub_optimizations[i - 1].flow_system.timesteps_extra - - start = self.sub_optimizations[i].flow_system.timesteps[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}' - ) - current_flow_system = self.sub_optimizations[i - 1].flow_system - next_flow_system = self.sub_optimizations[i].flow_system - - start_values_of_this_segment = {} - - for current_flow in current_flow_system.flows.values(): - next_flow = next_flow_system.flows[current_flow.label_full] - next_flow.previous_flow_rate = current_flow.submodel.flow_rate.solution.sel( - time=slice(start_previous_values, end_previous_values) - ).values - start_values_of_this_segment[current_flow.label_full] = next_flow.previous_flow_rate - - for current_comp in current_flow_system.components.values(): - next_comp = next_flow_system.components[current_comp.label_full] - if isinstance(next_comp, Storage): - next_comp.initial_charge_state = current_comp.submodel.charge_state.solution.sel(time=start).item() - start_values_of_this_segment[current_comp.label_full] = next_comp.initial_charge_state - - self._transfered_start_values.append(start_values_of_this_segment) - - def _calculate_timesteps_per_segment(self) -> list[pd.DatetimeIndex]: - 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)) - timesteps_per_segment.append(self.all_timesteps[start:end]) - return 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) -> list[dict[str, Any]]: - """Gives an overview of the start values of all Segments""" - return [{name: value for name, value in self._original_start_values.items()}] + [ - start_values for start_values in self._transfered_start_values - ] - - @property - def all_timesteps(self) -> pd.DatetimeIndex: - return self.flow_system.timesteps - - @property - def modeled(self) -> bool: - """Returns True if all segments have been modeled.""" - if len(self.sub_optimizations) == 0: - return False - return all(calc.modeled for calc in self.sub_optimizations) - - @property - def main_results(self) -> dict[str, int | float | dict]: - """Aggregated main results from all segments. - - Note: - For SegmentedOptimization, results are aggregated from SegmentedResults - which handles the overlapping segments properly. Individual segment results - should not be summed directly as they contain overlapping timesteps. - - The objective value shown is the sum of all segment objectives and includes - double-counting from overlapping regions. It does not represent a true - full-horizon objective value. - """ - if self.results is None: - raise RuntimeError( - 'SegmentedOptimization has not been solved yet. ' - 'Call do_modeling_and_solve() first to access main_results.' - ) - - # Use SegmentedResults to get the proper aggregated solution - return { - 'Note': 'SegmentedOptimization results are aggregated via SegmentedResults', - 'Number of segments': len(self.sub_optimizations), - 'Total timesteps': len(self.all_timesteps), - 'Objective (sum of segments, includes overlaps)': sum( - calc.model.objective.value for calc in self.sub_optimizations if calc.modeled - ), - } - - @property - def summary(self): - """Summary of the segmented optimization with aggregated information from all segments.""" - if len(self.sub_optimizations) == 0: - raise RuntimeError( - 'SegmentedOptimization has no segments yet. Call do_modeling_and_solve() first to access summary.' - ) - - # Aggregate constraints and variables from all segments - total_constraints = sum(calc.model.constraints.ncons for calc in self.sub_optimizations if calc.modeled) - total_variables = sum(calc.model.variables.nvars for calc in self.sub_optimizations if calc.modeled) - - return { - 'Name': self.name, - 'Number of timesteps': len(self.flow_system.timesteps), - 'Optimization Type': self.__class__.__name__, - 'Number of segments': len(self.sub_optimizations), - 'Timesteps per segment': self.timesteps_per_segment, - 'Overlap timesteps': self.overlap_timesteps, - 'Constraints (total across segments)': total_constraints, - 'Variables (total across segments)': total_variables, - 'Main Results': self.main_results if self.results else 'Not yet solved', - 'Durations': self.durations, - 'Config': CONFIG.to_dict(), - } diff --git a/flixopt/optimize_accessor.py b/flixopt/optimize_accessor.py index c87f1e713..0ba6b57eb 100644 --- a/flixopt/optimize_accessor.py +++ b/flixopt/optimize_accessor.py @@ -59,7 +59,6 @@ def __call__( solver: _Solver, before_solve: Callable[[FlowSystem], None] | None = None, progress: bool = True, - normalize_weights: bool | None = None, ) -> FlowSystem: """ Build and solve the optimization model in one step. @@ -74,7 +73,6 @@ def __call__( after building the model and before solving. Use this to add custom constraints via `flow_system.model.add_constraints()`. progress: Whether to show a tqdm progress bar during solving. - normalize_weights: Deprecated. Scenario weights are now always normalized in FlowSystem. Returns: The FlowSystem, for method chaining. @@ -109,17 +107,6 @@ def __call__( >>> solution = flow_system.optimize(solver).solution """ - if normalize_weights is not None: - import warnings - - from .config import DEPRECATION_REMOVAL_VERSION - - warnings.warn( - f'\n\nnormalize_weights parameter is deprecated and will be removed in {DEPRECATION_REMOVAL_VERSION}. ' - 'Scenario weights are now always normalized when set on FlowSystem.\n', - DeprecationWarning, - stacklevel=2, - ) self._fs.build_model() if before_solve is not None: before_solve(self._fs) diff --git a/flixopt/plotting.py b/flixopt/plotting.py index db5a3eb5c..d6bc09c02 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -26,7 +26,6 @@ from __future__ import annotations import logging -import pathlib from typing import TYPE_CHECKING, Any, Literal import matplotlib @@ -43,7 +42,7 @@ from .config import CONFIG if TYPE_CHECKING: - import pyvis + import pathlib logger = logging.getLogger('flixopt') @@ -772,90 +771,6 @@ def reshape_data_for_heatmap( return result -def plot_network( - node_infos: dict, - edge_infos: dict, - path: str | pathlib.Path | None = None, - controls: bool - | list[ - Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'] - ] = True, - show: bool = False, -) -> pyvis.network.Network | None: - """ - 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.error(f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}') - except Exception as e: - logger.error( - f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}: {e}' - ) - - def preprocess_data_for_pie( data: xr.Dataset | pd.DataFrame | pd.Series, lower_percentage_threshold: float = 5.0, diff --git a/flixopt/results.py b/flixopt/results.py deleted file mode 100644 index 8ec860244..000000000 --- a/flixopt/results.py +++ /dev/null @@ -1,2811 +0,0 @@ -from __future__ import annotations - -import copy -import datetime -import json -import logging -import pathlib -import warnings -from typing import TYPE_CHECKING, Any, Literal - -import linopy -import numpy as np -import pandas as pd -import xarray as xr - -from . import io as fx_io -from . import plotting -from .color_processing import process_colors -from .config import CONFIG, DEPRECATION_REMOVAL_VERSION, SUCCESS_LEVEL -from .flow_system import FlowSystem -from .structure import CompositeContainerMixin, ResultsContainer - -if TYPE_CHECKING: - import matplotlib.pyplot as plt - import plotly - import pyvis - - from .core import FlowSystemDimensions - from .optimization import Optimization, SegmentedOptimization - -logger = logging.getLogger('flixopt') - - -def load_mapping_from_file(path: pathlib.Path) -> dict[str, str | list[str]]: - """Load color mapping from JSON or YAML file. - - Tries loader based on file suffix first, with fallback to the other format. - - Args: - path: Path to config file (.json or .yaml/.yml) - - Returns: - Dictionary mapping components to colors or colorscales to component lists - - Raises: - ValueError: If file cannot be loaded as JSON or YAML - """ - return fx_io.load_config_file(path) - - -def _get_solution_attr(solution: xr.Dataset, key: str) -> dict: - """Get an attribute from solution, decoding JSON if necessary. - - Solution attrs are stored as JSON strings for netCDF compatibility. - This helper handles both JSON strings and dicts (for backward compatibility). - """ - value = solution.attrs.get(key, {}) - if isinstance(value, str): - return json.loads(value) - return value - - -class _FlowSystemRestorationError(Exception): - """Exception raised when a FlowSystem cannot be restored from dataset.""" - - pass - - -class Results(CompositeContainerMixin['ComponentResults | BusResults | EffectResults | FlowResults']): - """Comprehensive container for optimization results and analysis tools. - - This class provides unified access to all optimization results including flow rates, - component states, bus balances, and system effects. It offers powerful analysis - capabilities through filtering, plotting, and export functionality, making it - the primary interface for post-processing optimization results. - - Key Features: - **Unified Access**: Single interface to all solution variables and constraints - **Element Results**: Direct access to component, bus, and effect-specific results - **Visualization**: Built-in plotting methods for heatmaps, time series, and networks - **Persistence**: Save/load functionality with compression for large datasets - **Analysis Tools**: Filtering, aggregation, and statistical analysis methods - - Result Organization: - - **Components**: Equipment-specific results (flows, states, constraints) - - **Buses**: Network node balances and energy flows - - **Effects**: System-wide impacts (costs, emissions, resource consumption) - - **Solution**: Raw optimization variables and their values - - **Metadata**: Optimization parameters, timing, and system configuration - - Attributes: - solution: Dataset containing all optimization variable solutions - flow_system_data: Dataset with complete system configuration and parameters. Restore the used FlowSystem for further analysis. - summary: Optimization metadata including solver status, timing, and statistics - name: Unique identifier for this optimization - model: Original linopy optimization model (if available) - folder: Directory path for result storage and loading - components: Dictionary mapping component labels to ComponentResults objects - buses: Dictionary mapping bus labels to BusResults objects - effects: Dictionary mapping effect names to EffectResults objects - timesteps_extra: Extended time index including boundary conditions - timestep_duration: Duration of each timestep in hours for proper energy calculations - - Examples: - Load and analyze saved results: - - ```python - # Load results from file - results = Results.from_file('results', 'annual_optimization') - - # Access specific component results - boiler_results = results['Boiler_01'] - heat_pump_results = results['HeatPump_02'] - - # Plot component flow rates - results.plot_heatmap('Boiler_01(Natural_Gas)|flow_rate') - results['Boiler_01'].plot_node_balance() - - # Access raw solution dataarrays - electricity_flows = results.solution[['Generator_01(Grid)|flow_rate', 'HeatPump_02(Grid)|flow_rate']] - - # Filter and analyze results - peak_demand_hours = results.filter_solution(variable_dims='time') - costs_solution = results.effects['cost'].solution - ``` - - Advanced filtering and aggregation: - - ```python - # Filter by variable type - scalar_results = results.filter_solution(variable_dims='scalar') - time_series = results.filter_solution(variable_dims='time') - - # Custom data analysis leveraging xarray - peak_power = results.solution['Generator_01(Grid)|flow_rate'].max() - avg_efficiency = ( - results.solution['HeatPump(Heat)|flow_rate'] / results.solution['HeatPump(Electricity)|flow_rate'] - ).mean() - ``` - - Configure automatic color management for plots: - - ```python - # Dict-based configuration: - results.setup_colors({'Solar*': 'Oranges', 'Wind*': 'Blues', 'Battery': 'green'}) - - # All plots automatically use configured colors (colors=None is the default) - results['ElectricityBus'].plot_node_balance() - results['Battery'].plot_charge_state() - - # Override when needed - results['ElectricityBus'].plot_node_balance(colors='turbo') # Ignores setup - ``` - - Design Patterns: - **Factory Methods**: Use `from_file()` and `from_optimization()` for creation or access directly from `Optimization.results` - **Dictionary Access**: Use `results[element_label]` for element-specific results - **Lazy Loading**: Results objects created on-demand for memory efficiency - **Unified Interface**: Consistent API across different result types - - """ - - model: linopy.Model | None - - @classmethod - def from_file(cls, folder: str | pathlib.Path, name: str) -> Results: - """Load Results from saved files. - - Args: - folder: Directory containing saved files. - name: Base name of saved files (without extensions). - - Returns: - Results: Loaded instance. - """ - folder = pathlib.Path(folder) - paths = fx_io.ResultsPaths(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}') - - summary = fx_io.load_yaml(paths.summary) - - return cls( - solution=fx_io.load_dataset_from_netcdf(paths.solution), - flow_system_data=fx_io.load_dataset_from_netcdf(paths.flow_system), - name=name, - folder=folder, - model=model, - summary=summary, - ) - - @classmethod - def from_optimization(cls, optimization: Optimization) -> Results: - """Create Results from an Optimization instance. - - Args: - optimization: The Optimization instance to extract results from. - - Returns: - Results: New instance containing the optimization results. - """ - return cls( - solution=optimization.model.solution, - flow_system_data=optimization.flow_system.to_dataset(), - summary=optimization.summary, - model=optimization.model, - name=optimization.name, - folder=optimization.folder, - ) - - def __init__( - self, - solution: xr.Dataset, - flow_system_data: xr.Dataset, - name: str, - summary: dict, - folder: pathlib.Path | None = None, - model: linopy.Model | None = None, - ): - """Initialize Results with optimization data. - Usually, this class is instantiated by an Optimization object via `Results.from_optimization()` - or by loading from file using `Results.from_file()`. - - Args: - solution: Optimization solution dataset. - flow_system_data: Flow system configuration dataset. - name: Optimization name. - summary: Optimization metadata. - folder: Results storage folder. - model: Linopy optimization model. - """ - warnings.warn( - f'Results is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. ' - 'Access results directly via FlowSystem.solution after optimization, or use the ' - '.plot accessor on FlowSystem and its components (e.g., flow_system.plot.heatmap(...)). ' - 'To load old result files, use FlowSystem.from_old_results(folder, name).', - DeprecationWarning, - stacklevel=2, - ) - - self.solution = solution - self.flow_system_data = flow_system_data - self.summary = summary - self.name = name - self.model = model - self.folder = pathlib.Path(folder) if folder is not None else pathlib.Path.cwd() / 'results' - - # Create ResultsContainers for better access patterns - components_dict = { - label: ComponentResults(self, **infos) - for label, infos in _get_solution_attr(self.solution, 'Components').items() - } - self.components = ResultsContainer( - elements=components_dict, element_type_name='component results', truncate_repr=10 - ) - - buses_dict = { - label: BusResults(self, **infos) for label, infos in _get_solution_attr(self.solution, 'Buses').items() - } - self.buses = ResultsContainer(elements=buses_dict, element_type_name='bus results', truncate_repr=10) - - effects_dict = { - label: EffectResults(self, **infos) for label, infos in _get_solution_attr(self.solution, 'Effects').items() - } - self.effects = ResultsContainer(elements=effects_dict, element_type_name='effect results', truncate_repr=10) - - flows_attr = _get_solution_attr(self.solution, 'Flows') - if not flows_attr: - warnings.warn( - 'No Data about flows found in the results. This data is only included since v2.2.0. Some functionality ' - 'is not availlable. We recommend to evaluate your results with a version <2.2.0.', - stacklevel=2, - ) - flows_dict = {} - self._has_flow_data = False - else: - flows_dict = {label: FlowResults(self, **infos) for label, infos in flows_attr.items()} - self._has_flow_data = True - self.flows = ResultsContainer(elements=flows_dict, element_type_name='flow results', truncate_repr=10) - - self.timesteps_extra = self.solution.indexes['time'] - self.timestep_duration = FlowSystem.calculate_timestep_duration(self.timesteps_extra) - self.scenarios = self.solution.indexes['scenario'] if 'scenario' in self.solution.indexes else None - self.periods = self.solution.indexes['period'] if 'period' in self.solution.indexes else None - - self._effect_share_factors = None - self._flow_system = None - - self._flow_rates = None - self._flow_hours = None - self._sizes = None - self._effects_per_component = None - - self.colors: dict[str, str] = {} - - def _get_container_groups(self) -> dict[str, ResultsContainer]: - """Return ordered container groups for CompositeContainerMixin.""" - return { - 'Components': self.components, - 'Buses': self.buses, - 'Effects': self.effects, - 'Flows': self.flows, - } - - def __repr__(self) -> str: - """Return grouped representation of all results.""" - r = fx_io.format_title_with_underline(self.__class__.__name__, '=') - r += f'Name: "{self.name}"\nFolder: {self.folder}\n' - # Add grouped container view - r += '\n' + self._format_grouped_containers() - return r - - @property - def storages(self) -> list[ComponentResults]: - """Get all storage components in the results.""" - return [comp for comp in self.components.values() if comp.is_storage] - - @property - def objective(self) -> float: - """Get optimization objective value.""" - # Deprecated. Fallback - if 'objective' not in self.solution: - logger.warning('Objective not found in solution. Fallback to summary (rounded value). This is deprecated') - return self.summary['Main Results']['Objective'] - - return self.solution['objective'].item() - - @property - def variables(self) -> linopy.Variables: - """Get optimization variables (requires linopy model).""" - if self.model is None: - raise ValueError('The linopy model is not available.') - return self.model.variables - - @property - def constraints(self) -> linopy.Constraints: - """Get optimization constraints (requires linopy model).""" - if self.model is None: - raise ValueError('The linopy model is not available.') - return self.model.constraints - - @property - def effect_share_factors(self): - if self._effect_share_factors is None: - effect_share_factors = self.flow_system.effects.calculate_effect_share_factors() - self._effect_share_factors = {'temporal': effect_share_factors[0], 'periodic': effect_share_factors[1]} - return self._effect_share_factors - - @property - def flow_system(self) -> FlowSystem: - """The restored flow_system that was used to create the optimization. - Contains all input parameters.""" - if self._flow_system is None: - # Temporarily disable all logging to suppress messages during restoration - flixopt_logger = logging.getLogger('flixopt') - original_level = flixopt_logger.level - flixopt_logger.setLevel(logging.CRITICAL + 1) # Disable all logging - try: - self._flow_system = FlowSystem.from_dataset(self.flow_system_data) - self._flow_system._connect_network() - except Exception as e: - flixopt_logger.setLevel(original_level) # Re-enable before logging - logger.critical( - f'Not able to restore FlowSystem from dataset. Some functionality is not availlable. {e}' - ) - raise _FlowSystemRestorationError(f'Not able to restore FlowSystem from dataset. {e}') from e - finally: - flixopt_logger.setLevel(original_level) # Restore original level - return self._flow_system - - def setup_colors( - self, - config: dict[str, str | list[str]] | str | pathlib.Path | None = None, - default_colorscale: str | None = None, - ) -> dict[str, str]: - """ - Setup colors for all variables across all elements. Overwrites existing ones. - - Args: - config: Configuration for color assignment. Can be: - - dict: Maps components to colors/colorscales: - * 'component1': 'red' # Single component to single color - * 'component1': '#FF0000' # Single component to hex color - - OR maps colorscales to multiple components: - * 'colorscale_name': ['component1', 'component2'] # Colorscale across components - - str: Path to a JSON/YAML config file or a colorscale name to apply to all - - Path: Path to a JSON/YAML config file - - None: Use default_colorscale for all components - default_colorscale: Default colorscale for unconfigured components (default: 'turbo') - - Examples: - setup_colors({ - # Direct component-to-color mappings - 'Boiler1': '#FF0000', - 'CHP': 'darkred', - # Colorscale for multiple components - 'Oranges': ['Solar1', 'Solar2'], - 'Blues': ['Wind1', 'Wind2'], - 'Greens': ['Battery1', 'Battery2', 'Battery3'], - }) - - Returns: - Complete variable-to-color mapping dictionary - """ - - def get_all_variable_names(comp: str) -> list[str]: - """Collect all variables from the component, including flows and flow_hours.""" - comp_object = self.components[comp] - var_names = [comp] + list(comp_object.variable_names) - for flow in comp_object.flows: - var_names.extend([flow, f'{flow}|flow_hours']) - return var_names - - # Set default colorscale if not provided - if default_colorscale is None: - default_colorscale = CONFIG.Plotting.default_qualitative_colorscale - - # Handle different config input types - if config is None: - # Apply default colorscale to all components - config_dict = {} - elif isinstance(config, (str, pathlib.Path)): - # Try to load from file first - config_path = pathlib.Path(config) - if config_path.exists(): - # Load config from file using helper - config_dict = load_mapping_from_file(config_path) - else: - # Treat as colorscale name to apply to all components - all_components = list(self.components.keys()) - config_dict = {config: all_components} - elif isinstance(config, dict): - config_dict = config - else: - raise TypeError(f'config must be dict, str, Path, or None, got {type(config)}') - - # Step 1: Build component-to-color mapping - component_colors: dict[str, str] = {} - - # Track which components are configured - configured_components = set() - - # Process each configuration entry - for key, value in config_dict.items(): - # Check if value is a list (colorscale -> [components]) - # or a string (component -> color OR colorscale -> [components]) - - if isinstance(value, list): - # key is colorscale, value is list of components - # Format: 'Blues': ['Wind1', 'Wind2'] - components = value - colorscale_name = key - - # Validate components exist - for component in components: - if component not in self.components: - raise ValueError(f"Component '{component}' not found") - - configured_components.update(components) - - # Use process_colors to get one color per component from the colorscale - colors_for_components = process_colors(colorscale_name, components) - component_colors.update(colors_for_components) - - elif isinstance(value, str): - # Check if key is an existing component - if key in self.components: - # Format: 'CHP': 'red' (component -> color) - component, color = key, value - - configured_components.add(component) - component_colors[component] = color - else: - raise ValueError(f"Component '{key}' not found") - else: - raise TypeError(f'Config value must be str or list, got {type(value)}') - - # Step 2: Assign colors to remaining unconfigured components - remaining_components = list(set(self.components.keys()) - configured_components) - if remaining_components: - # Use default colorscale to assign one color per remaining component - default_colors = process_colors(default_colorscale, remaining_components) - component_colors.update(default_colors) - - # Step 3: Build variable-to-color mapping - # Clear existing colors to avoid stale keys - self.colors = {} - # Each component's variables all get the same color as the component - for component, color in component_colors.items(): - variable_names = get_all_variable_names(component) - for var_name in variable_names: - self.colors[var_name] = color - - return self.colors - - def filter_solution( - self, - variable_dims: Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly'] | None = None, - element: str | None = None, - timesteps: pd.DatetimeIndex | None = None, - scenarios: pd.Index | None = None, - contains: str | list[str] | None = None, - startswith: str | list[str] | None = None, - ) -> xr.Dataset: - """Filter solution by variable dimension and/or element. - - Args: - variable_dims: The dimension of which to get variables from. - - 'scalar': Get scalar variables (without dimensions) - - 'time': Get time-dependent variables (with a time dimension) - - 'scenario': Get scenario-dependent variables (with ONLY a scenario dimension) - - 'timeonly': Get time-dependent variables (with ONLY a time dimension) - - 'scenarioonly': Get scenario-dependent variables (with ONLY a scenario dimension) - element: The element to filter for. - timesteps: Optional time indexes to select. Can be: - - pd.DatetimeIndex: Multiple timesteps - - str/pd.Timestamp: Single timestep - Defaults to all available timesteps. - scenarios: Optional scenario indexes to select. Can be: - - pd.Index: Multiple scenarios - - str/int: Single scenario (int is treated as a label, not an index position) - Defaults to all available scenarios. - contains: Filter variables that contain this string or strings. - If a list is provided, variables must contain ALL strings in the list. - startswith: Filter variables that start with this string or strings. - If a list is provided, variables must start with ANY of the strings in the list. - """ - return filter_dataset( - self.solution if element is None else self[element].solution, - variable_dims=variable_dims, - timesteps=timesteps, - scenarios=scenarios, - contains=contains, - startswith=startswith, - ) - - @property - def effects_per_component(self) -> xr.Dataset: - """Returns a dataset containing effect results for each mode, aggregated by Component - - Returns: - An xarray Dataset with an additional component dimension and effects as variables. - """ - if self._effects_per_component is None: - self._effects_per_component = xr.Dataset( - { - mode: self._create_effects_dataset(mode).to_dataarray('effect', name=mode) - for mode in ['temporal', 'periodic', 'total'] - } - ) - dim_order = ['time', 'period', 'scenario', 'component', 'effect'] - self._effects_per_component = self._effects_per_component.transpose(*dim_order, missing_dims='ignore') - - return self._effects_per_component - - def flow_rates( - self, - start: str | list[str] | None = None, - end: str | list[str] | None = None, - component: str | list[str] | None = None, - ) -> xr.DataArray: - """Returns a DataArray containing the flow rates of each Flow. - - .. deprecated:: - Use `results.plot.all_flow_rates` (Dataset) or - `results.flows['FlowLabel'].flow_rate` (DataArray) instead. - - **Note**: The new API differs from this method: - - - Returns ``xr.Dataset`` (not ``DataArray``) with flow labels as variable names - - No ``'flow'`` dimension - each flow is a separate variable - - No filtering parameters - filter using these alternatives:: - - # Select specific flows by label - ds = results.plot.all_flow_rates - ds[['Boiler(Q_th)', 'CHP(Q_th)']] - - # Filter by substring in label - ds[[v for v in ds.data_vars if 'Boiler' in v]] - - # Filter by bus (start/end) - get flows connected to a bus - results['Fernwärme'].inputs # list of input flow labels - results['Fernwärme'].outputs # list of output flow labels - ds[results['Fernwärme'].inputs] # Dataset with only inputs to bus - - # Filter by component - get flows of a component - results['Boiler'].inputs # list of input flow labels - results['Boiler'].outputs # list of output flow labels - """ - warnings.warn( - 'results.flow_rates() is deprecated. ' - 'Use results.plot.all_flow_rates instead (returns Dataset, not DataArray). ' - 'Note: The new API has no filtering parameters and uses flow labels as variable names. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - if not self._has_flow_data: - raise ValueError('Flow data is not available in this results object (pre-v2.2.0).') - if self._flow_rates is None: - self._flow_rates = self._assign_flow_coords( - xr.concat( - [flow.flow_rate.rename(flow.label) for flow in self.flows.values()], - dim=pd.Index(self.flows.keys(), name='flow'), - ) - ).rename('flow_rates') - filters = {k: v for k, v in {'start': start, 'end': end, 'component': component}.items() if v is not None} - return filter_dataarray_by_coord(self._flow_rates, **filters) - - def flow_hours( - self, - start: str | list[str] | None = None, - end: str | list[str] | None = None, - component: str | list[str] | None = None, - ) -> xr.DataArray: - """Returns a DataArray containing the flow hours of each Flow. - - .. deprecated:: - Use `results.plot.all_flow_hours` (Dataset) or - `results.flows['FlowLabel'].flow_rate * results.timestep_duration` instead. - - **Note**: The new API differs from this method: - - - Returns ``xr.Dataset`` (not ``DataArray``) with flow labels as variable names - - No ``'flow'`` dimension - each flow is a separate variable - - No filtering parameters - filter using these alternatives:: - - # Select specific flows by label - ds = results.plot.all_flow_hours - ds[['Boiler(Q_th)', 'CHP(Q_th)']] - - # Filter by substring in label - ds[[v for v in ds.data_vars if 'Boiler' in v]] - - # Filter by bus (start/end) - get flows connected to a bus - results['Fernwärme'].inputs # list of input flow labels - results['Fernwärme'].outputs # list of output flow labels - ds[results['Fernwärme'].inputs] # Dataset with only inputs to bus - - # Filter by component - get flows of a component - results['Boiler'].inputs # list of input flow labels - results['Boiler'].outputs # list of output flow labels - - Flow hours represent the total energy/material transferred over time, - calculated by multiplying flow rates by the duration of each timestep. - - Args: - start: Optional source node(s) to filter by. Can be a single node name or a list of names. - end: Optional destination node(s) to filter by. Can be a single node name or a list of names. - component: Optional component(s) to filter by. Can be a single component name or a list of names. - - Further usage: - Convert the dataarray to a dataframe: - >>>results.flow_hours().to_pandas() - Sum up the flow hours over time: - >>>results.flow_hours().sum('time') - Sum up the flow hours of flows with the same start and end: - >>>results.flow_hours(end='Fernwärme').groupby('start').sum(dim='flow') - To recombine filtered dataarrays, use `xr.concat` with dim 'flow': - >>>xr.concat([results.flow_hours(start='Fernwärme'), results.flow_hours(end='Fernwärme')], dim='flow') - - """ - warnings.warn( - 'results.flow_hours() is deprecated. ' - 'Use results.plot.all_flow_hours instead (returns Dataset, not DataArray). ' - 'Note: The new API has no filtering parameters and uses flow labels as variable names. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - if self._flow_hours is None: - self._flow_hours = (self.flow_rates() * self.timestep_duration).rename('flow_hours') - filters = {k: v for k, v in {'start': start, 'end': end, 'component': component}.items() if v is not None} - return filter_dataarray_by_coord(self._flow_hours, **filters) - - def sizes( - self, - start: str | list[str] | None = None, - end: str | list[str] | None = None, - component: str | list[str] | None = None, - ) -> xr.DataArray: - """Returns a dataset with the sizes of the Flows. - - .. deprecated:: - Use `results.plot.all_sizes` (Dataset) or - `results.flows['FlowLabel'].size` (DataArray) instead. - - **Note**: The new API differs from this method: - - - Returns ``xr.Dataset`` (not ``DataArray``) with flow labels as variable names - - No ``'flow'`` dimension - each flow is a separate variable - - No filtering parameters - filter using these alternatives:: - - # Select specific flows by label - ds = results.plot.all_sizes - ds[['Boiler(Q_th)', 'CHP(Q_th)']] - - # Filter by substring in label - ds[[v for v in ds.data_vars if 'Boiler' in v]] - - # Filter by bus (start/end) - get flows connected to a bus - results['Fernwärme'].inputs # list of input flow labels - results['Fernwärme'].outputs # list of output flow labels - ds[results['Fernwärme'].inputs] # Dataset with only inputs to bus - - # Filter by component - get flows of a component - results['Boiler'].inputs # list of input flow labels - results['Boiler'].outputs # list of output flow labels - """ - warnings.warn( - 'results.sizes() is deprecated. ' - 'Use results.plot.all_sizes instead (returns Dataset, not DataArray). ' - 'Note: The new API has no filtering parameters and uses flow labels as variable names. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - if not self._has_flow_data: - raise ValueError('Flow data is not available in this results object (pre-v2.2.0).') - if self._sizes is None: - self._sizes = self._assign_flow_coords( - xr.concat( - [flow.size.rename(flow.label) for flow in self.flows.values()], - dim=pd.Index(self.flows.keys(), name='flow'), - ) - ).rename('flow_sizes') - filters = {k: v for k, v in {'start': start, 'end': end, 'component': component}.items() if v is not None} - return filter_dataarray_by_coord(self._sizes, **filters) - - def _assign_flow_coords(self, da: xr.DataArray): - # Add start and end coordinates - flows_list = list(self.flows.values()) - da = da.assign_coords( - { - 'start': ('flow', [flow.start for flow in flows_list]), - 'end': ('flow', [flow.end for flow in flows_list]), - 'component': ('flow', [flow.component for flow in flows_list]), - } - ) - - # Ensure flow is the last dimension if needed - existing_dims = [d for d in da.dims if d != 'flow'] - da = da.transpose(*(existing_dims + ['flow'])) - return da - - def get_effect_shares( - self, - element: str, - effect: str, - mode: Literal['temporal', 'periodic'] | None = None, - include_flows: bool = False, - ) -> xr.Dataset: - """Retrieves individual effect shares for a specific element and effect. - Either for temporal, investment, or both modes combined. - Only includes the direct shares. - - Args: - element: The element identifier for which to retrieve effect shares. - effect: The effect identifier for which to retrieve shares. - mode: Optional. The mode to retrieve shares for. Can be 'temporal', 'periodic', - or None to retrieve both. Defaults to None. - - Returns: - An xarray Dataset containing the requested effect shares. If mode is None, - returns a merged Dataset containing both temporal and investment shares. - - Raises: - ValueError: If the specified effect is not available or if mode is invalid. - """ - if effect not in self.effects: - raise ValueError(f'Effect {effect} is not available.') - - if mode is None: - return xr.merge( - [ - self.get_effect_shares( - element=element, effect=effect, mode='temporal', include_flows=include_flows - ), - self.get_effect_shares( - element=element, effect=effect, mode='periodic', include_flows=include_flows - ), - ] - ) - - if mode not in ['temporal', 'periodic']: - raise ValueError(f'Mode {mode} is not available. Choose between "temporal" and "periodic".') - - ds = xr.Dataset() - - label = f'{element}->{effect}({mode})' - if label in self.solution: - ds = xr.Dataset({label: self.solution[label]}) - - if include_flows: - if element not in self.components: - raise ValueError(f'Only use Components when retrieving Effects including flows. Got {element}') - flows = [ - label.split('|')[0] for label in self.components[element].inputs + self.components[element].outputs - ] - return xr.merge( - [ds] - + [ - self.get_effect_shares(element=flow, effect=effect, mode=mode, include_flows=False) - for flow in flows - ] - ) - - return ds - - def _compute_effect_total( - self, - element: str, - effect: str, - mode: Literal['temporal', 'periodic', 'total'] = 'total', - include_flows: bool = False, - ) -> xr.DataArray: - """Calculates the total effect for a specific element and effect. - - This method computes the total direct and indirect effects for a given element - and effect, considering the conversion factors between different effects. - - Args: - element: The element identifier for which to calculate total effects. - effect: The effect identifier to calculate. - mode: The optimization mode. Options are: - 'temporal': Returns temporal effects. - 'periodic': Returns investment-specific effects. - 'total': Returns the sum of temporal effects and periodic effects. Defaults to 'total'. - include_flows: Whether to include effects from flows connected to this element. - - Returns: - An xarray DataArray containing the total effects, named with pattern - '{element}->{effect}' for mode='total' or '{element}->{effect}({mode})' - for other modes. - - Raises: - ValueError: If the specified effect is not available. - """ - if effect not in self.effects: - raise ValueError(f'Effect {effect} is not available.') - - if mode == 'total': - temporal = self._compute_effect_total( - element=element, effect=effect, mode='temporal', include_flows=include_flows - ) - periodic = self._compute_effect_total( - element=element, effect=effect, mode='periodic', include_flows=include_flows - ) - if periodic.isnull().all() and temporal.isnull().all(): - return xr.DataArray(np.nan) - if temporal.isnull().all(): - return periodic.rename(f'{element}->{effect}') - temporal = temporal.sum('time') - if periodic.isnull().all(): - return temporal.rename(f'{element}->{effect}') - return periodic + temporal - - total = xr.DataArray(0) - share_exists = False - - relevant_conversion_factors = { - key[0]: value for key, value in self.effect_share_factors[mode].items() if key[1] == effect - } - relevant_conversion_factors[effect] = 1 # Share to itself is 1 - - for target_effect, conversion_factor in relevant_conversion_factors.items(): - label = f'{element}->{target_effect}({mode})' - if label in self.solution: - share_exists = True - da = self.solution[label] - total = da * conversion_factor + total - - if include_flows: - if element not in self.components: - raise ValueError(f'Only use Components when retrieving Effects including flows. Got {element}') - flows = [ - label.split('|')[0] for label in self.components[element].inputs + self.components[element].outputs - ] - for flow in flows: - label = f'{flow}->{target_effect}({mode})' - if label in self.solution: - share_exists = True - da = self.solution[label] - total = da * conversion_factor + total - if not share_exists: - total = xr.DataArray(np.nan) - return total.rename(f'{element}->{effect}({mode})') - - def _create_template_for_mode(self, mode: Literal['temporal', 'periodic', 'total']) -> xr.DataArray: - """Create a template DataArray with the correct dimensions for a given mode. - - Args: - mode: The optimization mode ('temporal', 'periodic', or 'total'). - - Returns: - A DataArray filled with NaN, with dimensions appropriate for the mode. - """ - coords = {} - if mode == 'temporal': - coords['time'] = self.timesteps_extra - if self.periods is not None: - coords['period'] = self.periods - if self.scenarios is not None: - coords['scenario'] = self.scenarios - - # Create template with appropriate shape - if coords: - shape = tuple(len(coords[dim]) for dim in coords) - return xr.DataArray(np.full(shape, np.nan, dtype=float), coords=coords, dims=list(coords.keys())) - else: - return xr.DataArray(np.nan) - - def _create_effects_dataset(self, mode: Literal['temporal', 'periodic', 'total']) -> xr.Dataset: - """Creates a dataset containing effect totals for all components (including their flows). - The dataset does contain the direct as well as the indirect effects of each component. - - Args: - mode: The optimization mode ('temporal', 'periodic', or 'total'). - - Returns: - An xarray Dataset with components as dimension and effects as variables. - """ - # Create template with correct dimensions for this mode - template = self._create_template_for_mode(mode) - - ds = xr.Dataset() - all_arrays = {} - components_list = list(self.components) - - # Collect arrays for all effects and components - for effect in self.effects: - effect_arrays = [] - for component in components_list: - da = self._compute_effect_total(element=component, effect=effect, mode=mode, include_flows=True) - effect_arrays.append(da) - - all_arrays[effect] = effect_arrays - - # Process all effects: expand scalar NaN arrays to match template dimensions - for effect in self.effects: - dataarrays = all_arrays[effect] - component_arrays = [] - - for component, arr in zip(components_list, dataarrays, strict=False): - # Expand scalar NaN arrays to match template dimensions - if not arr.dims and np.isnan(arr.item()): - arr = xr.full_like(template, np.nan, dtype=float).rename(arr.name) - - component_arrays.append(arr.expand_dims(component=[component])) - - ds[effect] = xr.concat(component_arrays, dim='component', coords='minimal', join='outer').rename(effect) - - # For now include a test to ensure correctness - suffix = { - 'temporal': '(temporal)|per_timestep', - 'periodic': '(periodic)', - 'total': '', - } - for effect in self.effects: - label = f'{effect}{suffix[mode]}' - computed = ds[effect].sum('component') - found = self.solution[label] - if not np.allclose(computed.values, found.fillna(0).values): - logger.critical( - f'Results for {effect}({mode}) in effects_dataset doesnt match {label}\n{computed=}\n, {found=}' - ) - - return ds - - def plot_heatmap( - self, - variable_name: str | list[str], - save: bool | pathlib.Path = False, - show: bool | None = None, - colors: plotting.ColorType | None = None, - engine: plotting.PlottingEngine = 'plotly', - select: dict[FlowSystemDimensions, Any] | None = None, - facet_by: str | list[str] | None = 'scenario', - animate_by: str | None = 'period', - facet_cols: int | None = None, - reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] - | Literal['auto'] - | None = 'auto', - fill: Literal['ffill', 'bfill'] | None = 'ffill', - **plot_kwargs: Any, - ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]: - """ - Plots a heatmap visualization of a variable using imshow or time-based reshaping. - - Supports multiple visualization features that can be combined: - - **Multi-variable**: Plot multiple variables on a single heatmap (creates 'variable' dimension) - - **Time reshaping**: Converts 'time' dimension into 2D (e.g., hours vs days) - - **Faceting**: Creates subplots for different dimension values - - **Animation**: Animates through dimension values (Plotly only) - - Args: - variable_name: The name of the variable to plot, or a list of variable names. - When a list is provided, variables are combined into a single DataArray - with a new 'variable' dimension. - 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: Color scheme for the heatmap. See `flixopt.plotting.ColorType` for options. - engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. - select: Optional data selection dict. Supports single values, lists, slices, and index arrays. - Applied BEFORE faceting/animation/reshaping. - facet_by: Dimension(s) to create facets (subplots) for. Can be a single dimension name (str) - or list of dimensions. Each unique value combination creates a subplot. Ignored if not found. - animate_by: Dimension to animate over (Plotly only). Creates animation frames that cycle through - dimension values. Only one dimension can be animated. Ignored if not found. - facet_cols: Number of columns in the facet grid layout (default: 3). - reshape_time: Time reshaping configuration (default: 'auto'): - - 'auto': Automatically applies ('D', 'h') when only 'time' dimension remains - - Tuple: Explicit reshaping, e.g. ('D', 'h') for days vs hours, - ('MS', 'D') for months vs days, ('W', 'h') for weeks vs hours - - None: Disable auto-reshaping (will error if only 1D time data) - Supported timeframes: 'YS', 'MS', 'W', 'D', 'h', '15min', 'min' - fill: Method to fill missing values after reshape: 'ffill' (forward fill) or 'bfill' (backward fill). - Default is 'ffill'. - **plot_kwargs: Additional plotting customization options. - Common options: - - - **dpi** (int): Export resolution for saved plots. Default: 300. - - For heatmaps specifically: - - - **vmin** (float): Minimum value for color scale (both engines). - - **vmax** (float): Maximum value for color scale (both engines). - - For Matplotlib heatmaps: - - - **imshow_kwargs** (dict): Additional kwargs for matplotlib's imshow (e.g., interpolation, aspect). - - **cbar_kwargs** (dict): Additional kwargs for colorbar customization. - - Examples: - Direct imshow mode (default): - - >>> results.plot_heatmap('Battery|charge_state', select={'scenario': 'base'}) - - Facet by scenario: - - >>> results.plot_heatmap('Boiler(Qth)|flow_rate', facet_by='scenario', facet_cols=2) - - Animate by period: - - >>> results.plot_heatmap('Boiler(Qth)|flow_rate', select={'scenario': 'base'}, animate_by='period') - - Time reshape mode - daily patterns: - - >>> results.plot_heatmap('Boiler(Qth)|flow_rate', select={'scenario': 'base'}, reshape_time=('D', 'h')) - - Combined: time reshaping with faceting and animation: - - >>> results.plot_heatmap( - ... 'Boiler(Qth)|flow_rate', facet_by='scenario', animate_by='period', reshape_time=('D', 'h') - ... ) - - Multi-variable heatmap (variables as one axis): - - >>> results.plot_heatmap( - ... ['Boiler(Q_th)|flow_rate', 'CHP(Q_th)|flow_rate', 'HeatStorage|charge_state'], - ... select={'scenario': 'base', 'period': 1}, - ... reshape_time=None, - ... ) - - Multi-variable with time reshaping: - - >>> results.plot_heatmap( - ... ['Boiler(Q_th)|flow_rate', 'CHP(Q_th)|flow_rate'], - ... facet_by='scenario', - ... animate_by='period', - ... reshape_time=('D', 'h'), - ... ) - - High-resolution export with custom color range: - - >>> results.plot_heatmap('Battery|charge_state', save=True, dpi=600, vmin=0, vmax=100) - - Matplotlib heatmap with custom imshow settings: - - >>> results.plot_heatmap( - ... 'Boiler(Q_th)|flow_rate', - ... engine='matplotlib', - ... imshow_kwargs={'interpolation': 'bilinear', 'aspect': 'auto'}, - ... ) - """ - # Delegate to module-level plot_heatmap function - return plot_heatmap( - data=self.solution[variable_name], - name=variable_name if isinstance(variable_name, str) else None, - folder=self.folder, - colors=colors, - save=save, - show=show, - engine=engine, - select=select, - facet_by=facet_by, - animate_by=animate_by, - facet_cols=facet_cols, - reshape_time=reshape_time, - fill=fill, - **plot_kwargs, - ) - - def plot_network( - self, - controls: ( - bool - | list[ - Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'] - ] - ) = True, - path: pathlib.Path | None = None, - show: bool | None = None, - ) -> pyvis.network.Network | None: - """Plot interactive network visualization of the system. - - Args: - controls: Enable/disable interactive controls. - path: Save path for network HTML. - show: Whether to display the plot. If None, uses CONFIG.Plotting.default_show. - """ - if path is None: - path = self.folder / f'{self.name}--network.html' - return self.flow_system.plot_network(controls=controls, path=path, show=show) - - def to_flow_system(self) -> FlowSystem: - """Convert Results to a FlowSystem with solution attached. - - This method migrates results from the deprecated Results format to the - new FlowSystem-based format, enabling use of the modern API. - - Note: - For loading old results files directly, consider using - ``FlowSystem.from_old_results(folder, name)`` instead. - - Returns: - FlowSystem: A FlowSystem instance with the solution data attached. - - Caveats: - - The linopy model is NOT attached (only the solution data) - - Element submodels are NOT recreated (no re-optimization without - calling build_model() first) - - Variable/constraint names on elements are NOT restored - - Examples: - Convert loaded Results to FlowSystem: - - ```python - # Load old results - results = Results.from_file('results', 'my_optimization') - - # Convert to FlowSystem - flow_system = results.to_flow_system() - - # Use new API - flow_system.plot.heatmap() - flow_system.solution.to_netcdf('solution.nc') - - # Save in new single-file format - flow_system.to_netcdf('my_optimization.nc') - ``` - """ - from flixopt.io import convert_old_dataset - - # Convert flow_system_data to new parameter names - convert_old_dataset(self.flow_system_data) - - # Reconstruct FlowSystem from stored data - flow_system = FlowSystem.from_dataset(self.flow_system_data) - - # Convert solution attrs from dicts to JSON strings for consistency with new format - # The _get_solution_attr helper handles both formats, but we normalize here - solution = self.solution.copy() - for key in ['Components', 'Buses', 'Effects', 'Flows']: - if key in solution.attrs and isinstance(solution.attrs[key], dict): - solution.attrs[key] = json.dumps(solution.attrs[key]) - - flow_system.solution = solution - return flow_system - - def to_file( - self, - folder: str | pathlib.Path | None = None, - name: str | None = None, - compression: int = 5, - document_model: bool = True, - save_linopy_model: bool = False, - overwrite: bool = False, - ): - """Save results to files. - - Args: - folder: Save folder (defaults to optimization folder). - name: File name (defaults to optimization name). - compression: Compression level 0-9. - document_model: Whether to document model formulations as yaml. - save_linopy_model: Whether to save linopy model file. - overwrite: If False, raise error if results files already exist. If True, overwrite existing files. - - Raises: - FileExistsError: If overwrite=False and result files already exist. - """ - folder = self.folder if folder is None else pathlib.Path(folder) - name = self.name if name is None else name - - # Ensure folder exists, creating parent directories as needed - folder.mkdir(parents=True, exist_ok=True) - - paths = fx_io.ResultsPaths(folder, name) - - # Check if files already exist (unless overwrite is True) - if not overwrite: - existing_files = [] - for file_path in paths.all_paths().values(): - if file_path.exists(): - existing_files.append(file_path.name) - - if existing_files: - raise FileExistsError( - f'Results files already exist in {folder}: {", ".join(existing_files)}. ' - f'Use overwrite=True to overwrite existing files.' - ) - - fx_io.save_dataset_to_netcdf(self.solution, paths.solution, compression=compression) - fx_io.save_dataset_to_netcdf(self.flow_system_data, paths.flow_system, compression=compression) - - fx_io.save_yaml(self.summary, paths.summary, compact_numeric_lists=True) - - if save_linopy_model: - if self.model is None: - logger.critical('No model in the Results. Saving the model is not possible.') - else: - self.model.to_netcdf(paths.linopy_model, engine='netcdf4') - - if document_model: - if self.model is None: - logger.critical('No model in the Results. Documenting the model is not possible.') - else: - fx_io.document_linopy_model(self.model, path=paths.model_documentation) - - logger.log(SUCCESS_LEVEL, f'Saved optimization results "{name}" to {paths.model_documentation.parent}') - - -class _ElementResults: - def __init__(self, results: Results, label: str, variables: list[str], constraints: list[str]): - self._results = results - self.label = label - self.variable_names = variables - self._constraint_names = constraints - - self.solution = self._results.solution[self.variable_names] - - @property - def variables(self) -> linopy.Variables: - """Get element variables (requires linopy model). - - Raises: - ValueError: If linopy model is unavailable. - """ - if self._results.model is None: - raise ValueError('The linopy model is not available.') - return self._results.model.variables[self.variable_names] - - @property - def constraints(self) -> linopy.Constraints: - """Get element constraints (requires linopy model). - - Raises: - ValueError: If linopy model is unavailable. - """ - if self._results.model is None: - raise ValueError('The linopy model is not available.') - return self._results.model.constraints[self._constraint_names] - - def __repr__(self) -> str: - """Return string representation with element info and dataset preview.""" - class_name = self.__class__.__name__ - header = f'{class_name}: "{self.label}"' - sol = self.solution.copy(deep=False) - sol.attrs = {} - return f'{header}\n{"-" * len(header)}\n{repr(sol)}' - - def filter_solution( - self, - variable_dims: Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly'] | None = None, - timesteps: pd.DatetimeIndex | None = None, - scenarios: pd.Index | None = None, - contains: str | list[str] | None = None, - startswith: str | list[str] | None = 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 which to get variables from. - - 'scalar': Get scalar variables (without dimensions) - - 'time': Get time-dependent variables (with a time dimension) - - 'scenario': Get scenario-dependent variables (with ONLY a scenario dimension) - - 'timeonly': Get time-dependent variables (with ONLY a time dimension) - - 'scenarioonly': Get scenario-dependent variables (with ONLY a scenario dimension) - timesteps: Optional time indexes to select. Can be: - - pd.DatetimeIndex: Multiple timesteps - - str/pd.Timestamp: Single timestep - Defaults to all available timesteps. - scenarios: Optional scenario indexes to select. Can be: - - pd.Index: Multiple scenarios - - str/int: Single scenario (int is treated as a label, not an index position) - Defaults to all available scenarios. - contains: Filter variables that contain this string or strings. - If a list is provided, variables must contain ALL strings in the list. - startswith: Filter variables that start with this string or strings. - If a list is provided, variables must start with ANY of the strings in the list. - """ - return filter_dataset( - self.solution, - variable_dims=variable_dims, - timesteps=timesteps, - scenarios=scenarios, - contains=contains, - startswith=startswith, - ) - - -class _NodeResults(_ElementResults): - def __init__( - self, - results: Results, - label: str, - variables: list[str], - constraints: list[str], - inputs: list[str], - outputs: list[str], - flows: list[str], - ): - super().__init__(results, label, variables, constraints) - self.inputs = inputs - self.outputs = outputs - self.flows = flows - - def plot_node_balance( - self, - save: bool | pathlib.Path = False, - show: bool | None = None, - colors: plotting.ColorType | None = None, - engine: plotting.PlottingEngine = 'plotly', - select: dict[FlowSystemDimensions, Any] | None = None, - unit_type: Literal['flow_rate', 'flow_hours'] = 'flow_rate', - mode: Literal['area', 'stacked_bar', 'line'] = 'stacked_bar', - drop_suffix: bool = True, - facet_by: str | list[str] | None = 'scenario', - animate_by: str | None = 'period', - facet_cols: int | None = None, - **plot_kwargs: Any, - ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]: - """ - Plots the node balance of the Component or Bus with optional faceting and animation. - - Args: - save: Whether to save the plot or not. If a path is provided, the plot will be saved at that location. - show: Whether to show the plot or not. - colors: The colors to use for the plot. See `flixopt.plotting.ColorType` for options. - engine: The engine to use for plotting. Can be either 'plotly' or 'matplotlib'. - select: Optional data selection dict. Supports: - - Single values: {'scenario': 'base', 'period': 2024} - - Multiple values: {'scenario': ['base', 'high', 'renewable']} - - Slices: {'time': slice('2024-01', '2024-06')} - - Index arrays: {'time': time_array} - Note: Applied BEFORE faceting/animation. - unit_type: The unit type to use for the dataset. Can be 'flow_rate' or 'flow_hours'. - - 'flow_rate': Returns the flow_rates of the Node. - - 'flow_hours': Returns the flow_hours of the Node. [flow_hours(t) = flow_rate(t) * dt(t)]. Renames suffixes to |flow_hours. - mode: The plotting mode. Use 'stacked_bar' for stacked bar charts, 'line' for stepped lines, or 'area' for stacked area charts. - drop_suffix: Whether to drop the suffix from the variable names. - facet_by: Dimension(s) to create facets (subplots) for. Can be a single dimension name (str) - or list of dimensions. Each unique value combination creates a subplot. Ignored if not found. - Example: 'scenario' creates one subplot per scenario. - Example: ['period', 'scenario'] creates a grid of subplots for each scenario-period combination. - animate_by: Dimension to animate over (Plotly only). Creates animation frames that cycle through - dimension values. Only one dimension can be animated. Ignored if not found. - facet_cols: Number of columns in the facet grid layout (default: 3). - **plot_kwargs: Additional plotting customization options passed to underlying plotting functions. - - Common options: - - - **dpi** (int): Export resolution in dots per inch. Default: 300. - - **For Plotly engine** (`engine='plotly'`): - - - Any Plotly Express parameter for px.bar()/px.line()/px.area() - Example: `range_y=[0, 100]`, `line_shape='linear'` - - **For Matplotlib engine** (`engine='matplotlib'`): - - - **plot_kwargs** (dict): Customize plot via `ax.bar()` or `ax.step()`. - Example: `plot_kwargs={'linewidth': 3, 'alpha': 0.7, 'edgecolor': 'black'}` - - See :func:`flixopt.plotting.with_plotly` and :func:`flixopt.plotting.with_matplotlib` - for complete parameter reference. - - Note: For Plotly, you can further customize the returned figure using `fig.update_traces()` - and `fig.update_layout()` after calling this method. - - Examples: - Basic plot (current behavior): - - >>> results['Boiler'].plot_node_balance() - - Facet by scenario: - - >>> results['Boiler'].plot_node_balance(facet_by='scenario', facet_cols=2) - - Animate by period: - - >>> results['Boiler'].plot_node_balance(animate_by='period') - - Facet by scenario AND animate by period: - - >>> results['Boiler'].plot_node_balance(facet_by='scenario', animate_by='period') - - Select single scenario, then facet by period: - - >>> results['Boiler'].plot_node_balance(select={'scenario': 'base'}, facet_by='period') - - Select multiple scenarios and facet by them: - - >>> results['Boiler'].plot_node_balance( - ... select={'scenario': ['base', 'high', 'renewable']}, facet_by='scenario' - ... ) - - Time range selection (summer months only): - - >>> results['Boiler'].plot_node_balance(select={'time': slice('2024-06', '2024-08')}, facet_by='scenario') - - High-resolution export for publication: - - >>> results['Boiler'].plot_node_balance(engine='matplotlib', save='figure.png', dpi=600) - - Plotly Express customization (e.g., set y-axis range): - - >>> results['Boiler'].plot_node_balance(range_y=[0, 100]) - - Custom matplotlib appearance: - - >>> results['Boiler'].plot_node_balance(engine='matplotlib', plot_kwargs={'linewidth': 3, 'alpha': 0.7}) - - Further customize Plotly figure after creation: - - >>> fig = results['Boiler'].plot_node_balance(mode='line', show=False) - >>> fig.update_traces(line={'width': 5, 'dash': 'dot'}) - >>> fig.update_layout(template='plotly_dark', width=1200, height=600) - >>> fig.show() - """ - if engine not in {'plotly', 'matplotlib'}: - raise ValueError(f'Engine "{engine}" not supported. Use one of ["plotly", "matplotlib"]') - - # Extract dpi for export_figure - dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi - - # Don't pass select/indexer to node_balance - we'll apply it afterwards - ds = self.node_balance(with_last_timestep=False, unit_type=unit_type, drop_suffix=drop_suffix) - - ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True) - - # Matplotlib requires only 'time' dimension; check for extras after selection - if engine == 'matplotlib': - extra_dims = [d for d in ds.dims if d != 'time'] - if extra_dims: - raise ValueError( - f'Matplotlib engine only supports a single time axis, but found extra dimensions: {extra_dims}. ' - f'Please use select={{...}} to reduce dimensions or switch to engine="plotly" for faceting/animation.' - ) - suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' - - title = ( - f'{self.label} (flow rates){suffix}' if unit_type == 'flow_rate' else f'{self.label} (flow hours){suffix}' - ) - - if engine == 'plotly': - figure_like = plotting.with_plotly( - ds, - facet_by=facet_by, - animate_by=animate_by, - colors=colors if colors is not None else self._results.colors, - mode=mode, - title=title, - facet_cols=facet_cols, - xlabel='Time in h', - **plot_kwargs, - ) - default_filetype = '.html' - else: - figure_like = plotting.with_matplotlib( - ds, - colors=colors if colors is not None else self._results.colors, - mode=mode, - title=title, - **plot_kwargs, - ) - default_filetype = '.png' - - return plotting.export_figure( - figure_like=figure_like, - default_path=self._results.folder / title, - default_filetype=default_filetype, - user_path=None if isinstance(save, bool) else pathlib.Path(save), - show=show, - save=True if save else False, - dpi=dpi, - ) - - def plot_node_balance_pie( - self, - lower_percentage_group: float = 5, - colors: plotting.ColorType | None = None, - text_info: str = 'percent+label+value', - save: bool | pathlib.Path = False, - show: bool | None = None, - engine: plotting.PlottingEngine = 'plotly', - select: dict[FlowSystemDimensions, Any] | None = None, - **plot_kwargs: Any, - ) -> plotly.graph_objs.Figure | tuple[plt.Figure, list[plt.Axes]]: - """Plot pie chart of flow hours distribution. - - Note: - Pie charts require scalar data (no extra dimensions beyond time). - If your data has dimensions like 'scenario' or 'period', either: - - - Use `select` to choose specific values: `select={'scenario': 'base', 'period': 2024}` - - Let auto-selection choose the first value (a warning will be logged) - - Args: - lower_percentage_group: Percentage threshold for "Others" grouping. - colors: Color scheme. Also see plotly. - text_info: Information to display on pie slices. - save: Whether to save plot. - show: Whether to display plot. - engine: Plotting engine ('plotly' or 'matplotlib'). - select: Optional data selection dict. Supports single values, lists, slices, and index arrays. - Use this to select specific scenario/period before creating the pie chart. - **plot_kwargs: Additional plotting customization options. - - Common options: - - - **dpi** (int): Export resolution in dots per inch. Default: 300. - - **hover_template** (str): Hover text template (Plotly only). - Example: `hover_template='%{label}: %{value} (%{percent})'` - - **text_position** (str): Text position ('inside', 'outside', 'auto'). - - **hole** (float): Size of donut hole (0.0 to 1.0). - - See :func:`flixopt.plotting.dual_pie_with_plotly` for complete reference. - - Examples: - Basic usage (auto-selects first scenario/period if present): - - >>> results['Bus'].plot_node_balance_pie() - - Explicitly select a scenario and period: - - >>> results['Bus'].plot_node_balance_pie(select={'scenario': 'high_demand', 'period': 2030}) - - Create a donut chart with custom hover text: - - >>> results['Bus'].plot_node_balance_pie(hole=0.4, hover_template='%{label}: %{value:.2f} (%{percent})') - - High-resolution export: - - >>> results['Bus'].plot_node_balance_pie(save='figure.png', dpi=600) - """ - # Extract dpi for export_figure - dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi - - inputs = sanitize_dataset( - ds=self.solution[self.inputs] * self._results.timestep_duration, - threshold=1e-5, - drop_small_vars=True, - zero_small_values=True, - drop_suffix='|', - ) - outputs = sanitize_dataset( - ds=self.solution[self.outputs] * self._results.timestep_duration, - threshold=1e-5, - drop_small_vars=True, - zero_small_values=True, - drop_suffix='|', - ) - - inputs, suffix_parts_in = _apply_selection_to_data(inputs, select=select, drop=True) - outputs, suffix_parts_out = _apply_selection_to_data(outputs, select=select, drop=True) - suffix_parts = suffix_parts_in + suffix_parts_out - - # Sum over time dimension - inputs = inputs.sum('time') - outputs = outputs.sum('time') - - # Auto-select first value for any remaining dimensions (scenario, period, etc.) - # Pie charts need scalar data, so we automatically reduce extra dimensions - extra_dims_inputs = [dim for dim in inputs.dims if dim != 'time'] - extra_dims_outputs = [dim for dim in outputs.dims if dim != 'time'] - extra_dims = sorted(set(extra_dims_inputs + extra_dims_outputs)) - - if extra_dims: - auto_select = {} - for dim in extra_dims: - # Get first value of this dimension - if dim in inputs.coords: - first_val = inputs.coords[dim].values[0] - elif dim in outputs.coords: - first_val = outputs.coords[dim].values[0] - else: - continue - auto_select[dim] = first_val - logger.info( - f'Pie chart auto-selected {dim}={first_val} (first value). ' - f'Use select={{"{dim}": value}} to choose a different value.' - ) - - # Apply auto-selection only for coords present in each dataset - inputs = inputs.sel({k: v for k, v in auto_select.items() if k in inputs.coords}) - outputs = outputs.sel({k: v for k, v in auto_select.items() if k in outputs.coords}) - - # Update suffix with auto-selected values - auto_suffix_parts = [f'{dim}={val}' for dim, val in auto_select.items()] - suffix_parts.extend(auto_suffix_parts) - - suffix = '--' + '-'.join(sorted(set(suffix_parts))) if suffix_parts else '' - title = f'{self.label} (total flow hours){suffix}' - - if engine == 'plotly': - figure_like = plotting.dual_pie_with_plotly( - data_left=inputs, - data_right=outputs, - colors=colors if colors is not None else self._results.colors, - title=title, - text_info=text_info, - subtitles=('Inputs', 'Outputs'), - legend_title='Flows', - lower_percentage_group=lower_percentage_group, - **plot_kwargs, - ) - default_filetype = '.html' - elif engine == 'matplotlib': - logger.debug('Parameter text_info is not supported for matplotlib') - figure_like = plotting.dual_pie_with_matplotlib( - data_left=inputs.to_pandas(), - data_right=outputs.to_pandas(), - colors=colors if colors is not None else self._results.colors, - title=title, - subtitles=('Inputs', 'Outputs'), - legend_title='Flows', - lower_percentage_group=lower_percentage_group, - **plot_kwargs, - ) - 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._results.folder / title, - default_filetype=default_filetype, - user_path=None if isinstance(save, bool) else pathlib.Path(save), - show=show, - save=True if save else False, - dpi=dpi, - ) - - def node_balance( - self, - negate_inputs: bool = True, - negate_outputs: bool = False, - threshold: float | None = 1e-5, - with_last_timestep: bool = False, - unit_type: Literal['flow_rate', 'flow_hours'] = 'flow_rate', - drop_suffix: bool = False, - select: dict[FlowSystemDimensions, Any] | None = None, - ) -> xr.Dataset: - """ - Returns a dataset with the node balance of the Component or Bus. - Args: - negate_inputs: Whether to negate the input flow_rates of the Node. - negate_outputs: Whether to negate the output flow_rates of the Node. - threshold: The threshold for small values. Variables with all values below the threshold are dropped. - with_last_timestep: Whether to include the last timestep in the dataset. - unit_type: The unit type to use for the dataset. Can be 'flow_rate' or 'flow_hours'. - - 'flow_rate': Returns the flow_rates of the Node. - - 'flow_hours': Returns the flow_hours of the Node. [flow_hours(t) = flow_rate(t) * dt(t)]. Renames suffixes to |flow_hours. - drop_suffix: Whether to drop the suffix from the variable names. - select: Optional data selection dict. Supports single values, lists, slices, and index arrays. - """ - ds = self.solution[self.inputs + self.outputs] - - ds = sanitize_dataset( - ds=ds, - threshold=threshold, - timesteps=self._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 - ), - drop_suffix='|' if drop_suffix else None, - ) - - ds, _ = _apply_selection_to_data(ds, select=select, drop=True) - - if unit_type == 'flow_hours': - ds = ds * self._results.timestep_duration - ds = ds.rename_vars({var: var.replace('flow_rate', 'flow_hours') for var in ds.data_vars}) - - return ds - - -class BusResults(_NodeResults): - """Results container for energy/material balance nodes in the system.""" - - -class ComponentResults(_NodeResults): - """Results container for individual system components with specialized analysis tools.""" - - @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 storage charge state solution.""" - 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: bool | pathlib.Path = False, - show: bool | None = None, - colors: plotting.ColorType | None = None, - engine: plotting.PlottingEngine = 'plotly', - mode: Literal['area', 'stacked_bar', 'line'] = 'area', - select: dict[FlowSystemDimensions, Any] | None = None, - facet_by: str | list[str] | None = 'scenario', - animate_by: str | None = 'period', - facet_cols: int | None = None, - **plot_kwargs: Any, - ) -> plotly.graph_objs.Figure: - """Plot storage charge state over time, combined with the node balance with optional faceting and animation. - - 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: Color scheme. Also see plotly. - engine: Plotting engine to use. Only 'plotly' is implemented atm. - mode: The plotting mode. Use 'stacked_bar' for stacked bar charts, 'line' for stepped lines, or 'area' for stacked area charts. - select: Optional data selection dict. Supports single values, lists, slices, and index arrays. - Applied BEFORE faceting/animation. - facet_by: Dimension(s) to create facets (subplots) for. Can be a single dimension name (str) - or list of dimensions. Each unique value combination creates a subplot. Ignored if not found. - animate_by: Dimension to animate over (Plotly only). Creates animation frames that cycle through - dimension values. Only one dimension can be animated. Ignored if not found. - facet_cols: Number of columns in the facet grid layout (default: 3). - **plot_kwargs: Additional plotting customization options passed to underlying plotting functions. - - Common options: - - - **dpi** (int): Export resolution in dots per inch. Default: 300. - - **For Plotly engine:** - - - Any Plotly Express parameter for px.bar()/px.line()/px.area() - Example: `range_y=[0, 100]`, `line_shape='linear'` - - **For Matplotlib engine:** - - - **plot_kwargs** (dict): Customize plot via `ax.bar()` or `ax.step()`. - - See :func:`flixopt.plotting.with_plotly` and :func:`flixopt.plotting.with_matplotlib` - for complete parameter reference. - - Note: For Plotly, you can further customize the returned figure using `fig.update_traces()` - and `fig.update_layout()` after calling this method. - - Raises: - ValueError: If component is not a storage. - - Examples: - Basic plot: - - >>> results['Storage'].plot_charge_state() - - Facet by scenario: - - >>> results['Storage'].plot_charge_state(facet_by='scenario', facet_cols=2) - - Animate by period: - - >>> results['Storage'].plot_charge_state(animate_by='period') - - Facet by scenario AND animate by period: - - >>> results['Storage'].plot_charge_state(facet_by='scenario', animate_by='period') - - Custom layout after creation: - - >>> fig = results['Storage'].plot_charge_state(show=False) - >>> fig.update_layout(template='plotly_dark', height=800) - >>> fig.show() - - High-resolution export: - - >>> results['Storage'].plot_charge_state(save='storage.png', dpi=600) - """ - # Extract dpi for export_figure - dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi - - # Extract charge state line color (for overlay customization) - overlay_color = plot_kwargs.pop('charge_state_line_color', 'black') - - if not self.is_storage: - raise ValueError(f'Cant plot charge_state. "{self.label}" is not a storage') - - # Get node balance and charge state - ds = self.node_balance(with_last_timestep=True).fillna(0) - charge_state_da = self.charge_state - - # Apply select filtering - ds, suffix_parts = _apply_selection_to_data(ds, select=select, drop=True) - charge_state_da, _ = _apply_selection_to_data(charge_state_da, select=select, drop=True) - suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' - - title = f'Operation Balance of {self.label}{suffix}' - - if engine == 'plotly': - # Plot flows (node balance) with the specified mode - figure_like = plotting.with_plotly( - ds, - facet_by=facet_by, - animate_by=animate_by, - colors=colors if colors is not None else self._results.colors, - mode=mode, - title=title, - facet_cols=facet_cols, - xlabel='Time in h', - **plot_kwargs, - ) - - # Prepare charge_state as Dataset for plotting - charge_state_ds = xr.Dataset({self._charge_state: charge_state_da}) - - # Plot charge_state with mode='line' to get Scatter traces - charge_state_fig = plotting.with_plotly( - charge_state_ds, - facet_by=facet_by, - animate_by=animate_by, - colors=colors if colors is not None else self._results.colors, - mode='line', # Always line for charge_state - title='', # No title needed for this temp figure - facet_cols=facet_cols, - xlabel='Time in h', - **plot_kwargs, - ) - - # Add charge_state traces to the main figure - # This preserves subplot assignments and animation frames - for trace in charge_state_fig.data: - trace.line.width = 2 # Make charge_state line more prominent - trace.line.shape = 'linear' # Smooth line for charge state (not stepped like flows) - trace.line.color = overlay_color - figure_like.add_trace(trace) - - # Also add traces from animation frames if they exist - # Both figures use the same animate_by parameter, so they should have matching frames - if hasattr(charge_state_fig, 'frames') and charge_state_fig.frames: - # Add charge_state traces to each frame - for i, frame in enumerate(charge_state_fig.frames): - if i < len(figure_like.frames): - for trace in frame.data: - trace.line.width = 2 - trace.line.shape = 'linear' # Smooth line for charge state - trace.line.color = overlay_color - figure_like.frames[i].data = figure_like.frames[i].data + (trace,) - - default_filetype = '.html' - elif engine == 'matplotlib': - # Matplotlib requires only 'time' dimension; check for extras after selection - extra_dims = [d for d in ds.dims if d != 'time'] - if extra_dims: - raise ValueError( - f'Matplotlib engine only supports a single time axis, but found extra dimensions: {extra_dims}. ' - f'Please use select={{...}} to reduce dimensions or switch to engine="plotly" for faceting/animation.' - ) - # For matplotlib, plot flows (node balance), then add charge_state as line - fig, ax = plotting.with_matplotlib( - ds, - colors=colors if colors is not None else self._results.colors, - mode=mode, - title=title, - **plot_kwargs, - ) - - # Add charge_state as a line overlay - charge_state_df = charge_state_da.to_dataframe() - ax.plot( - charge_state_df.index, - charge_state_df.values.flatten(), - label=self._charge_state, - linewidth=2, - color=overlay_color, - ) - # Recreate legend with the same styling as with_matplotlib - handles, labels = ax.get_legend_handles_labels() - ax.legend( - handles, - labels, - loc='upper center', - bbox_to_anchor=(0.5, -0.15), - ncol=5, - frameon=False, - ) - fig.tight_layout() - - figure_like = fig, ax - default_filetype = '.png' - - return plotting.export_figure( - figure_like=figure_like, - default_path=self._results.folder / title, - default_filetype=default_filetype, - user_path=None if isinstance(save, bool) else pathlib.Path(save), - show=show, - save=True if save else False, - dpi=dpi, - ) - - def node_balance_with_charge_state( - self, negate_inputs: bool = True, negate_outputs: bool = False, threshold: float | None = 1e-5 - ) -> xr.Dataset: - """Get storage node balance including charge state. - - Args: - negate_inputs: Whether to negate input flows. - negate_outputs: Whether to negate output flows. - threshold: Threshold for small values. - - Returns: - xr.Dataset: Node balance with charge state. - - Raises: - ValueError: If 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._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) -> xr.Dataset: - """Get effect shares from specific element. - - Args: - element: Element label to get shares from. - - Returns: - xr.Dataset: Element shares to this effect. - """ - return self.solution[[name for name in self.variable_names if name.startswith(f'{element}->')]] - - -class FlowResults(_ElementResults): - def __init__( - self, - results: Results, - label: str, - variables: list[str], - constraints: list[str], - start: str, - end: str, - component: str, - ): - super().__init__(results, label, variables, constraints) - self.start = start - self.end = end - self.component = component - - @property - def flow_rate(self) -> xr.DataArray: - return self.solution[f'{self.label}|flow_rate'] - - @property - def flow_hours(self) -> xr.DataArray: - return (self.flow_rate * self._results.timestep_duration).rename(f'{self.label}|flow_hours') - - @property - def size(self) -> xr.DataArray: - name = f'{self.label}|size' - if name in self.solution: - return self.solution[name] - try: - return self._results.flow_system.flows[self.label].size.rename(name) - except _FlowSystemRestorationError: - logger.critical(f'Size of flow {self.label}.size not availlable. Returning NaN') - return xr.DataArray(np.nan).rename(name) - - -class SegmentedResults: - """Results container for segmented optimization optimizations with temporal decomposition. - - This class manages results from SegmentedOptimization runs where large optimization - problems are solved by dividing the time horizon into smaller, overlapping segments. - It provides unified access to results across all segments while maintaining the - ability to analyze individual segment behavior. - - Key Features: - **Unified Time Series**: Automatically assembles results from all segments into - continuous time series, removing overlaps and boundary effects - **Segment Analysis**: Access individual segment results for debugging and validation - **Consistency Checks**: Verify solution continuity at segment boundaries - **Memory Efficiency**: Handles large datasets that exceed single-segment memory limits - - Temporal Handling: - The class manages the complex task of combining overlapping segment solutions - into coherent time series, ensuring proper treatment of: - - Storage state continuity between segments - - Flow rate transitions at segment boundaries - - Aggregated results over the full time horizon - - Examples: - Load and analyze segmented results: - - ```python - # Load segmented optimization results - results = SegmentedResults.from_file('results', 'annual_segmented') - - # Access unified results across all segments - full_timeline = results.all_timesteps - total_segments = len(results.segment_results) - - # Analyze individual segments - for i, segment in enumerate(results.segment_results): - print(f'Segment {i + 1}: {len(segment.solution.time)} timesteps') - segment_costs = segment.effects['cost'].total_value - - # Check solution continuity at boundaries - segment_boundaries = results.get_boundary_analysis() - max_discontinuity = segment_boundaries['max_storage_jump'] - ``` - - Create from segmented optimization: - - ```python - # After running segmented optimization - segmented_opt = SegmentedOptimization( - name='annual_system', - flow_system=system, - timesteps_per_segment=730, # Monthly segments - overlap_timesteps=48, # 2-day overlap - ) - segmented_opt.do_modeling_and_solve(solver='gurobi') - - # Extract unified results - results = SegmentedResults.from_optimization(segmented_opt) - - # Save combined results - results.to_file(compression=5) - ``` - - Performance analysis across segments: - - ```python - # Compare segment solve times - solve_times = [seg.summary['durations']['solving'] for seg in results.segment_results] - avg_solve_time = sum(solve_times) / len(solve_times) - - # Verify solution quality consistency - segment_objectives = [seg.summary['objective_value'] for seg in results.segment_results] - - # Storage continuity analysis - if 'Battery' in results.segment_results[0].components: - storage_continuity = results.check_storage_continuity('Battery') - ``` - - Design Considerations: - **Boundary Effects**: Monitor solution quality at segment interfaces where - foresight is limited compared to full-horizon optimization. - - **Memory Management**: Individual segment results are maintained for detailed - analysis while providing unified access for system-wide metrics. - - **Validation Tools**: Built-in methods to verify temporal consistency and - identify potential issues from segmentation approach. - - Common Use Cases: - - **Large-Scale Analysis**: Annual or multi-period optimization results - - **Memory-Constrained Systems**: Results from systems exceeding hardware limits - - **Segment Validation**: Verifying segmentation approach effectiveness - - **Performance Monitoring**: Comparing segmented vs. full-horizon solutions - - **Debugging**: Identifying issues specific to temporal decomposition - - """ - - @classmethod - def from_optimization(cls, optimization: SegmentedOptimization) -> SegmentedResults: - """Create SegmentedResults from a SegmentedOptimization instance. - - Args: - optimization: The SegmentedOptimization instance to extract results from. - - Returns: - SegmentedResults: New instance containing the optimization results. - """ - return cls( - [calc.results for calc in optimization.sub_optimizations], - all_timesteps=optimization.all_timesteps, - timesteps_per_segment=optimization.timesteps_per_segment, - overlap_timesteps=optimization.overlap_timesteps, - name=optimization.name, - folder=optimization.folder, - ) - - @classmethod - def from_file(cls, folder: str | pathlib.Path, name: str) -> SegmentedResults: - """Load SegmentedResults from saved files. - - Args: - folder: Directory containing saved files. - name: Base name of saved files. - - Returns: - SegmentedResults: Loaded instance. - """ - folder = pathlib.Path(folder) - path = folder / name - meta_data_path = path.with_suffix('.json') - logger.info(f'loading segemented optimization meta data from file ("{meta_data_path}")') - meta_data = fx_io.load_json(meta_data_path) - - # Handle both new 'sub_optimizations' and legacy 'sub_calculations' keys - sub_names = meta_data.get('sub_optimizations') or meta_data.get('sub_calculations') - if sub_names is None: - raise KeyError( - "Missing 'sub_optimizations' (or legacy 'sub_calculations') key in segmented results metadata." - ) - - return cls( - [Results.from_file(folder, sub_name) for sub_name in sub_names], - 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[Results], - all_timesteps: pd.DatetimeIndex, - timesteps_per_segment: int, - overlap_timesteps: int, - name: str, - folder: pathlib.Path | None = None, - ): - warnings.warn( - f'SegmentedResults is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. ' - 'A replacement API for segmented optimization will be provided in a future release.', - DeprecationWarning, - stacklevel=2, - ) - 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._colors = {} - - @property - def meta_data(self) -> dict[str, 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_optimizations': [calc.name for calc in self.segment_results], - } - - @property - def segment_names(self) -> list[str]: - return [segment.name for segment in self.segment_results] - - @property - def colors(self) -> dict[str, str]: - return self._colors - - @colors.setter - def colors(self, colors: dict[str, str]): - """Applies colors to all segments""" - self._colors = colors - for segment in self.segment_results: - segment.colors = copy.deepcopy(colors) - - def setup_colors( - self, - config: dict[str, str | list[str]] | str | pathlib.Path | None = None, - default_colorscale: str | None = None, - ) -> dict[str, str]: - """ - Setup colors for all variables across all segment results. - - This method applies the same color configuration to all segments, ensuring - consistent visualization across the entire segmented optimization. The color - mapping is propagated to each segment's Results instance. - - Args: - config: Configuration for color assignment. Can be: - - dict: Maps components to colors/colorscales: - * 'component1': 'red' # Single component to single color - * 'component1': '#FF0000' # Single component to hex color - - OR maps colorscales to multiple components: - * 'colorscale_name': ['component1', 'component2'] # Colorscale across components - - str: Path to a JSON/YAML config file or a colorscale name to apply to all - - Path: Path to a JSON/YAML config file - - None: Use default_colorscale for all components - default_colorscale: Default colorscale for unconfigured components (default: 'turbo') - - Examples: - ```python - # Apply colors to all segments - segmented_results.setup_colors( - { - 'CHP': 'red', - 'Blues': ['Storage1', 'Storage2'], - 'Oranges': ['Solar1', 'Solar2'], - } - ) - - # Use a single colorscale for all components in all segments - segmented_results.setup_colors('portland') - ``` - - Returns: - Complete variable-to-color mapping dictionary from the first segment - (all segments will have the same mapping) - """ - if not self.segment_results: - raise ValueError('No segment_results available; cannot setup colors on an empty SegmentedResults.') - - self.colors = self.segment_results[0].setup_colors(config=config, default_colorscale=default_colorscale) - - return self.colors - - def solution_without_overlap(self, variable_name: str) -> xr.DataArray: - """Get variable solution removing segment overlaps. - - Args: - variable_name: Name of variable to extract. - - Returns: - xr.DataArray: Continuous solution without overlaps. - """ - 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, - reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] - | Literal['auto'] - | None = 'auto', - colors: plotting.ColorType | None = None, - save: bool | pathlib.Path = False, - show: bool | None = None, - engine: plotting.PlottingEngine = 'plotly', - facet_by: str | list[str] | None = None, - animate_by: str | None = None, - facet_cols: int | None = None, - fill: Literal['ffill', 'bfill'] | None = 'ffill', - **plot_kwargs: Any, - ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]: - """Plot heatmap of variable solution across segments. - - Args: - variable_name: Variable to plot. - reshape_time: Time reshaping configuration (default: 'auto'): - - 'auto': Automatically applies ('D', 'h') when only 'time' dimension remains - - Tuple like ('D', 'h'): Explicit reshaping (days vs hours) - - None: Disable time reshaping - colors: Color scheme. See plotting.ColorType for options. - save: Whether to save plot. - show: Whether to display plot. - engine: Plotting engine. - facet_by: Dimension(s) to create facets (subplots) for. - animate_by: Dimension to animate over (Plotly only). - facet_cols: Number of columns in the facet grid layout. - fill: Method to fill missing values: 'ffill' or 'bfill'. - **plot_kwargs: Additional plotting customization options. - Common options: - - - **dpi** (int): Export resolution for saved plots. Default: 300. - - **vmin** (float): Minimum value for color scale. - - **vmax** (float): Maximum value for color scale. - - For Matplotlib heatmaps: - - - **imshow_kwargs** (dict): Additional kwargs for matplotlib's imshow. - - **cbar_kwargs** (dict): Additional kwargs for colorbar customization. - - Returns: - Figure object. - """ - return plot_heatmap( - data=self.solution_without_overlap(variable_name), - name=variable_name, - folder=self.folder, - reshape_time=reshape_time, - colors=colors, - save=save, - show=show, - engine=engine, - facet_by=facet_by, - animate_by=animate_by, - facet_cols=facet_cols, - fill=fill, - **plot_kwargs, - ) - - def to_file( - self, - folder: str | pathlib.Path | None = None, - name: str | None = None, - compression: int = 5, - overwrite: bool = False, - ): - """Save segmented results to files. - - Args: - folder: Save folder (defaults to instance folder). - name: File name (defaults to instance name). - compression: Compression level 0-9. - overwrite: If False, raise error if results files already exist. If True, overwrite existing files. - - Raises: - FileExistsError: If overwrite=False and result files already exist. - """ - folder = self.folder if folder is None else pathlib.Path(folder) - name = self.name if name is None else name - path = folder / name - - # Ensure folder exists, creating parent directories as needed - folder.mkdir(parents=True, exist_ok=True) - - # Check if metadata file already exists (unless overwrite is True) - metadata_file = path.with_suffix('.json') - if not overwrite and metadata_file.exists(): - raise FileExistsError( - f'Segmented results file already exists: {metadata_file}. ' - f'Use overwrite=True to overwrite existing files.' - ) - - # Save segments (they will check for overwrite themselves) - for segment in self.segment_results: - segment.to_file(folder=folder, name=segment.name, compression=compression, overwrite=overwrite) - - fx_io.save_json(self.meta_data, metadata_file) - logger.info(f'Saved optimization "{name}" to {path}') - - -def plot_heatmap( - data: xr.DataArray | xr.Dataset, - name: str | None = None, - folder: pathlib.Path | None = None, - colors: plotting.ColorType | None = None, - save: bool | pathlib.Path = False, - show: bool | None = None, - engine: plotting.PlottingEngine = 'plotly', - select: dict[str, Any] | None = None, - facet_by: str | list[str] | None = None, - animate_by: str | None = None, - facet_cols: int | None = None, - reshape_time: tuple[Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'], Literal['W', 'D', 'h', '15min', 'min']] - | Literal['auto'] - | None = 'auto', - fill: Literal['ffill', 'bfill'] | None = 'ffill', - **plot_kwargs: Any, -): - """Plot heatmap visualization with support for multi-variable, faceting, and animation. - - This function provides a standalone interface to the heatmap plotting capabilities, - supporting the same modern features as Results.plot_heatmap(). - - Args: - data: Data to plot. Can be a single DataArray or an xarray Dataset. - When a Dataset is provided, all data variables are combined along a new 'variable' dimension. - name: Optional name for the title. If not provided, uses the DataArray name or - generates a default title for Datasets. - folder: Save folder for the plot. Defaults to current directory if not provided. - colors: Color scheme for the heatmap. See `flixopt.plotting.ColorType` for options. - 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'. - select: Optional data selection dict. Supports single values, lists, slices, and index arrays. - facet_by: Dimension(s) to create facets (subplots) for. Can be a single dimension name (str) - or list of dimensions. Each unique value combination creates a subplot. - animate_by: Dimension to animate over (Plotly only). Creates animation frames. - facet_cols: Number of columns in the facet grid layout (default: 3). - reshape_time: Time reshaping configuration (default: 'auto'): - - 'auto': Automatically applies ('D', 'h') when only 'time' dimension remains - - Tuple: Explicit reshaping, e.g. ('D', 'h') for days vs hours - - None: Disable auto-reshaping - fill: Method to fill missing values after reshape: 'ffill' (forward fill) or 'bfill' (backward fill). - Default is 'ffill'. - - Examples: - Single DataArray with time reshaping: - - >>> plot_heatmap(data, name='Temperature', folder=Path('.'), reshape_time=('D', 'h')) - - Dataset with multiple variables (facet by variable): - - >>> dataset = xr.Dataset({'Boiler': data1, 'CHP': data2, 'Storage': data3}) - >>> plot_heatmap( - ... dataset, - ... folder=Path('.'), - ... facet_by='variable', - ... reshape_time=('D', 'h'), - ... ) - - Dataset with animation by variable: - - >>> plot_heatmap(dataset, animate_by='variable', reshape_time=('D', 'h')) - """ - # Convert Dataset to DataArray with 'variable' dimension - if isinstance(data, xr.Dataset): - # Extract all data variables from the Dataset - variable_names = list(data.data_vars) - dataarrays = [data[var] for var in variable_names] - - # Combine into single DataArray with 'variable' dimension - data = xr.concat(dataarrays, dim='variable') - data = data.assign_coords(variable=variable_names) - - # Use Dataset variable names for title if name not provided - if name is None: - title_name = f'Heatmap of {len(variable_names)} variables' - else: - title_name = name - else: - # Single DataArray - if name is None: - title_name = data.name if data.name else 'Heatmap' - else: - title_name = name - - # Apply select filtering - data, suffix_parts = _apply_selection_to_data(data, select=select, drop=True) - suffix = '--' + '-'.join(suffix_parts) if suffix_parts else '' - - # Matplotlib heatmaps require at most 2D data - # Time dimension will be reshaped to 2D (timeframe × timestep), so can't have other dims alongside it - if engine == 'matplotlib': - dims = list(data.dims) - - # If 'time' dimension exists and will be reshaped, we can't have any other dimensions - if 'time' in dims and len(dims) > 1 and reshape_time is not None: - extra_dims = [d for d in dims if d != 'time'] - raise ValueError( - f'Matplotlib heatmaps with time reshaping cannot have additional dimensions. ' - f'Found extra dimensions: {extra_dims}. ' - f'Use select={{...}} to reduce to time only, use "reshape_time=None" or switch to engine="plotly" or use for multi-dimensional support.' - ) - # If no 'time' dimension (already reshaped or different data), allow at most 2 dimensions - elif 'time' not in dims and len(dims) > 2: - raise ValueError( - f'Matplotlib heatmaps support at most 2 dimensions, but data has {len(dims)}: {dims}. ' - f'Use select={{...}} to reduce dimensions or switch to engine="plotly".' - ) - - # Build title - title = f'{title_name}{suffix}' - if isinstance(reshape_time, tuple): - timeframes, timesteps_per_frame = reshape_time - title += f' ({timeframes} vs {timesteps_per_frame})' - - # Extract dpi before passing to plotting functions - dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi - - # Plot with appropriate engine - if engine == 'plotly': - figure_like = plotting.heatmap_with_plotly( - data=data, - facet_by=facet_by, - animate_by=animate_by, - colors=colors, - title=title, - facet_cols=facet_cols, - reshape_time=reshape_time, - fill=fill, - **plot_kwargs, - ) - default_filetype = '.html' - elif engine == 'matplotlib': - figure_like = plotting.heatmap_with_matplotlib( - data=data, - colors=colors, - title=title, - reshape_time=reshape_time, - fill=fill, - **plot_kwargs, - ) - default_filetype = '.png' - else: - raise ValueError(f'Engine "{engine}" not supported. Use "plotly" or "matplotlib"') - - # Set default folder if not provided - if folder is None: - folder = pathlib.Path('.') - - return plotting.export_figure( - figure_like=figure_like, - default_path=folder / title, - default_filetype=default_filetype, - user_path=None if isinstance(save, bool) else pathlib.Path(save), - show=show, - save=True if save else False, - dpi=dpi, - ) - - -def sanitize_dataset( - ds: xr.Dataset, - timesteps: pd.DatetimeIndex | None = None, - threshold: float | None = 1e-5, - negate: list[str] | None = None, - drop_small_vars: bool = True, - zero_small_values: bool = False, - drop_suffix: str | None = None, -) -> xr.Dataset: - """Clean dataset by handling small values and reindexing time. - - Args: - ds: Dataset to sanitize. - timesteps: Time index for reindexing (optional). - threshold: Threshold for small values processing. - negate: Variables to negate. - drop_small_vars: Whether to drop variables below threshold. - zero_small_values: Whether to zero values below threshold. - drop_suffix: Drop suffix of data var names. Split by the provided str. - """ - # 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().item()] - 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 bool(mask.any().item()): - # 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) - - if drop_suffix is not None: - if not isinstance(drop_suffix, str): - raise ValueError(f'Only pass str values to drop suffixes. Got {drop_suffix}') - unique_dict = {} - for var in ds.data_vars: - new_name = var.split(drop_suffix)[0] - - # If name already exists, keep original name - if new_name in unique_dict.values(): - unique_dict[var] = var - else: - unique_dict[var] = new_name - ds = ds.rename(unique_dict) - - return ds - - -def filter_dataset( - ds: xr.Dataset, - variable_dims: Literal['scalar', 'time', 'scenario', 'timeonly', 'scenarioonly'] | None = None, - timesteps: pd.DatetimeIndex | str | pd.Timestamp | None = None, - scenarios: pd.Index | str | int | None = None, - contains: str | list[str] | None = None, - startswith: str | list[str] | None = None, -) -> xr.Dataset: - """Filter dataset by variable dimensions, indexes, and with string filters for variable names. - - Args: - ds: The dataset to filter. - variable_dims: The dimension of which to get variables from. - - 'scalar': Get scalar variables (without dimensions) - - 'time': Get time-dependent variables (with a time dimension) - - 'scenario': Get scenario-dependent variables (with ONLY a scenario dimension) - - 'timeonly': Get time-dependent variables (with ONLY a time dimension) - - 'scenarioonly': Get scenario-dependent variables (with ONLY a scenario dimension) - timesteps: Optional time indexes to select. Can be: - - pd.DatetimeIndex: Multiple timesteps - - str/pd.Timestamp: Single timestep - Defaults to all available timesteps. - scenarios: Optional scenario indexes to select. Can be: - - pd.Index: Multiple scenarios - - str/int: Single scenario (int is treated as a label, not an index position) - Defaults to all available scenarios. - contains: Filter variables that contain this string or strings. - If a list is provided, variables must contain ALL strings in the list. - startswith: Filter variables that start with this string or strings. - If a list is provided, variables must start with ANY of the strings in the list. - """ - # First filter by dimensions - filtered_ds = ds.copy() - if variable_dims is not None: - if variable_dims == 'scalar': - filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if not filtered_ds[v].dims]] - elif variable_dims == 'time': - filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if 'time' in filtered_ds[v].dims]] - elif variable_dims == 'scenario': - filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if 'scenario' in filtered_ds[v].dims]] - elif variable_dims == 'timeonly': - filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if filtered_ds[v].dims == ('time',)]] - elif variable_dims == 'scenarioonly': - filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if filtered_ds[v].dims == ('scenario',)]] - else: - raise ValueError(f'Unknown variable_dims "{variable_dims}" for filter_dataset') - - # Filter by 'contains' parameter - if contains is not None: - if isinstance(contains, str): - # Single string - keep variables that contain this string - filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if contains in v]] - elif isinstance(contains, list) and all(isinstance(s, str) for s in contains): - # List of strings - keep variables that contain ALL strings in the list - filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if all(s in v for s in contains)]] - else: - raise TypeError(f"'contains' must be a string or list of strings, got {type(contains)}") - - # Filter by 'startswith' parameter - if startswith is not None: - if isinstance(startswith, str): - # Single string - keep variables that start with this string - filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if v.startswith(startswith)]] - elif isinstance(startswith, list) and all(isinstance(s, str) for s in startswith): - # List of strings - keep variables that start with ANY of the strings in the list - filtered_ds = filtered_ds[[v for v in filtered_ds.data_vars if any(v.startswith(s) for s in startswith)]] - else: - raise TypeError(f"'startswith' must be a string or list of strings, got {type(startswith)}") - - # Handle time selection if needed - if timesteps is not None and 'time' in filtered_ds.dims: - try: - filtered_ds = filtered_ds.sel(time=timesteps) - except KeyError as e: - available_times = set(filtered_ds.indexes['time']) - requested_times = set([timesteps]) if not isinstance(timesteps, pd.Index) else set(timesteps) - missing_times = requested_times - available_times - raise ValueError( - f'Timesteps not found in dataset: {missing_times}. Available times: {available_times}' - ) from e - - # Handle scenario selection if needed - if scenarios is not None and 'scenario' in filtered_ds.dims: - try: - filtered_ds = filtered_ds.sel(scenario=scenarios) - except KeyError as e: - available_scenarios = set(filtered_ds.indexes['scenario']) - requested_scenarios = set([scenarios]) if not isinstance(scenarios, pd.Index) else set(scenarios) - missing_scenarios = requested_scenarios - available_scenarios - raise ValueError( - f'Scenarios not found in dataset: {missing_scenarios}. Available scenarios: {available_scenarios}' - ) from e - - return filtered_ds - - -def filter_dataarray_by_coord(da: xr.DataArray, **kwargs: str | list[str] | None) -> xr.DataArray: - """Filter flows by node and component attributes. - - Filters are applied in the order they are specified. All filters must match for an edge to be included. - - To recombine filtered dataarrays, use `xr.concat`. - - xr.concat([res.sizes(start='Fernwärme'), res.sizes(end='Fernwärme')], dim='flow') - - Args: - da: Flow DataArray with network metadata coordinates. - **kwargs: Coord filters as name=value pairs. - - Returns: - Filtered DataArray with matching edges. - - Raises: - AttributeError: If required coordinates are missing. - ValueError: If specified nodes don't exist or no matches found. - """ - - # Helper function to process filters - def apply_filter(array, coord_name: str, coord_values: Any | list[Any]): - # Verify coord exists - if coord_name not in array.coords: - raise AttributeError(f"Missing required coordinate '{coord_name}'") - - # Normalize to list for sequence-like inputs (excluding strings) - if isinstance(coord_values, str): - val_list = [coord_values] - elif isinstance(coord_values, (list, tuple, np.ndarray, pd.Index)): - val_list = list(coord_values) - else: - val_list = [coord_values] - - # Verify coord_values exist - available = set(array[coord_name].values) - missing = [v for v in val_list if v not in available] - if missing: - raise ValueError(f'{coord_name.title()} value(s) not found: {missing}') - - # Apply filter - return array.where( - array[coord_name].isin(val_list) if len(val_list) > 1 else array[coord_name] == val_list[0], - drop=True, - ) - - # Apply filters from kwargs - filters = {k: v for k, v in kwargs.items() if v is not None} - try: - for coord, values in filters.items(): - da = apply_filter(da, coord, values) - except ValueError as e: - raise ValueError(f'No edges match criteria: {filters}') from e - - # Verify results exist - if da.size == 0: - raise ValueError(f'No edges match criteria: {filters}') - - return da - - -def _apply_selection_to_data( - data: xr.DataArray | xr.Dataset, - select: dict[str, Any] | None = None, - drop=False, -) -> tuple[xr.DataArray | xr.Dataset, list[str]]: - """ - Apply selection to data. - - Args: - data: xarray Dataset or DataArray - select: Optional selection dict - drop: Whether to drop dimensions after selection - - Returns: - Tuple of (selected_data, selection_string) - """ - selection_string = [] - - if select: - data = data.sel(select, drop=drop) - selection_string.extend(f'{dim}={val}' for dim, val in select.items()) - - return data, selection_string diff --git a/flixopt/structure.py b/flixopt/structure.py index 983681951..c53982b52 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -29,7 +29,6 @@ import xarray as xr from . import io as fx_io -from .config import DEPRECATION_REMOVAL_VERSION from .core import FlowSystemDimensions, TimeSeriesData, get_dataarray_stats if TYPE_CHECKING: # for type checking and preventing circular imports @@ -758,79 +757,6 @@ def _extract_dataarrays_recursive(self, obj, context_name: str = '') -> tuple[An else: return self._serialize_to_basic_types(obj), extracted_arrays - def _handle_deprecated_kwarg( - self, - kwargs: dict, - old_name: str, - new_name: str, - current_value: Any = None, - transform: callable = None, - check_conflict: bool = True, - additional_warning_message: str = '', - ) -> Any: - """ - Handle a deprecated keyword argument by issuing a warning and returning the appropriate value. - - This centralizes the deprecation pattern used across multiple classes (Source, Sink, InvestParameters, etc.). - - Args: - kwargs: Dictionary of keyword arguments to check and modify - old_name: Name of the deprecated parameter - new_name: Name of the replacement parameter - current_value: Current value of the new parameter (if already set) - transform: Optional callable to transform the old value before returning (e.g., lambda x: [x] to wrap in list) - check_conflict: Whether to check if both old and new parameters are specified (default: True). - Note: For parameters with non-None default values (e.g., bool parameters with default=False), - set check_conflict=False since we cannot distinguish between an explicit value and the default. - additional_warning_message: Add a custom message which gets appended with a line break to the default warning. - - Returns: - The value to use (either from old parameter or current_value) - - Raises: - ValueError: If both old and new parameters are specified and check_conflict is True - - Example: - # For parameters where None is the default (conflict checking works): - value = self._handle_deprecated_kwarg(kwargs, 'old_param', 'new_param', current_value) - - # For parameters with non-None defaults (disable conflict checking): - mandatory = self._handle_deprecated_kwarg( - kwargs, 'optional', 'mandatory', mandatory, - transform=lambda x: not x, - check_conflict=False # Cannot detect if mandatory was explicitly passed - ) - """ - import warnings - - old_value = kwargs.pop(old_name, None) - if old_value is not None: - # Build base warning message - base_warning = f'The use of the "{old_name}" argument is deprecated. Use the "{new_name}" argument instead. Will be removed in v{DEPRECATION_REMOVAL_VERSION}.' - - # Append additional message on a new line if provided - if additional_warning_message: - # Normalize whitespace: strip leading/trailing whitespace - extra_msg = additional_warning_message.strip() - if extra_msg: - base_warning += '\n' + extra_msg - - warnings.warn( - base_warning, - DeprecationWarning, - stacklevel=3, # Stack: this method -> __init__ -> caller - ) - # Check for conflicts: only raise error if both were explicitly provided - if check_conflict and current_value is not None: - raise ValueError(f'Either {old_name} or {new_name} can be specified, but not both.') - - # Apply transformation if provided - if transform is not None: - return transform(old_value) - return old_value - - return current_value - def _validate_kwargs(self, kwargs: dict, class_name: str = None) -> None: """ Validate that no unexpected keyword arguments are present in kwargs. @@ -1557,18 +1483,6 @@ def _get_label(self, element: T) -> str: return element.label_full -class ResultsContainer(ContainerMixin[T]): - """ - Container for Results objects (ComponentResults, BusResults, etc). - - Uses element.label for keying. - """ - - def _get_label(self, element: T) -> str: - """Extract label from Results object.""" - return element.label - - class FlowContainer(ContainerMixin[T]): """Container for Flow objects with dual access: by index or by label_full. @@ -1709,7 +1623,7 @@ def _get_container_groups(self): Integration with ContainerMixin: This mixin is designed to work alongside ContainerMixin-based containers - (ElementContainer, ResultsContainer) by aggregating them into a unified + (e.g. ElementContainer) by aggregating them into a unified interface while preserving their individual functionality. """ @@ -1718,7 +1632,7 @@ def _get_container_groups(self) -> dict[str, ContainerMixin[Any]]: Return ordered dict of container groups to aggregate. Returns: - Dictionary mapping group names to container objects (e.g., ElementContainer, ResultsContainer). + Dictionary mapping group names to container objects (e.g., ElementContainer). Group names should be capitalized (e.g., 'Components', 'Buses'). Order determines display order in __repr__. diff --git a/flixopt/topology_accessor.py b/flixopt/topology_accessor.py index a994fb045..1334b95d9 100644 --- a/flixopt/topology_accessor.py +++ b/flixopt/topology_accessor.py @@ -8,106 +8,23 @@ from __future__ import annotations import logging -import pathlib import warnings from itertools import chain -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any import plotly.graph_objects as go import xarray as xr from .color_processing import ColorType, hex_to_rgba, process_colors -from .config import CONFIG, DEPRECATION_REMOVAL_VERSION +from .config import CONFIG from .plot_result import PlotResult if TYPE_CHECKING: - import pyvis - from .flow_system import FlowSystem logger = logging.getLogger('flixopt') -def _plot_network( - node_infos: dict, - edge_infos: dict, - path: str | pathlib.Path | None = None, - controls: bool - | list[ - Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'] - ] = True, - show: bool = False, -) -> pyvis.network.Network | None: - """Visualize network structure using PyVis. - - Args: - node_infos: Dictionary of node information. - edge_infos: Dictionary of edge information. - path: Path to save HTML visualization. - controls: UI controls to add. True for all, or list of specific controls. - show: Whether to open in browser. - - Returns: - Network instance, or None if pyvis not installed. - """ - 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(): - # Use carrier color if available, otherwise default gray - edge_color = edge.get('carrier_color', '#222831') or '#222831' - net.add_edge( - edge['start'], - edge['end'], - label=edge['label'], - title=edge['infos'].replace(')', '\n)'), - font={'color': '#4D4D4D', 'size': 14}, - color=edge_color, - ) - - net.barnes_hut(central_gravity=0.8, spring_length=50, spring_strength=0.05, gravity=-10000) - - if controls: - net.show_buttons(filter_=controls) - 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.error(f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}') - except Exception as e: - logger.error( - f'Showing the network in the Browser went wrong. Open it manually. Its saved under {path}: {e}' - ) - - return net - - class TopologyAccessor: """ Accessor for network topology inspection and visualization on FlowSystem. @@ -449,7 +366,6 @@ def plot( Hover over nodes and links to see detailed element information. See Also: - - `plot_legacy()`: Previous PyVis-based network visualization. - `statistics.plot.sankey.flows()`: Sankey with actual flow values from optimization. """ if not self._fs.connected_and_transformed: @@ -575,65 +491,6 @@ def plot( return result - def plot_legacy( - self, - path: bool | str | pathlib.Path = 'flow_system.html', - controls: bool - | list[ - Literal['nodes', 'edges', 'layout', 'interaction', 'manipulation', 'physics', 'selection', 'renderer'] - ] = True, - show: bool | None = None, - ) -> pyvis.network.Network | None: - """ - Visualize the network structure using PyVis, saving it as an interactive HTML file. - - .. deprecated:: - Use `plot()` instead for the new Plotly-based Sankey visualization. - This method is kept for backwards compatibility. - - 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: - The `pyvis.network.Network` instance representing the visualization, - or `None` if `pyvis` is not installed. - - Examples: - >>> flow_system.topology.plot_legacy() - >>> flow_system.topology.plot_legacy(show=False) - >>> flow_system.topology.plot_legacy(path='output/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 (circles for buses, boxes for components) - and annotated with node information. - """ - warnings.warn( - f'This method is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. ' - 'Use flow_system.topology.plot() instead.', - DeprecationWarning, - stacklevel=2, - ) - node_infos, edge_infos = self.infos() - # Normalize path=False to None for _plot_network compatibility - normalized_path = None if path is False else path - return _plot_network( - node_infos, - edge_infos, - normalized_path, - controls, - show if show is not None else CONFIG.Plotting.default_show, - ) - def start_app(self) -> None: """ Start an interactive network visualization using Dash and Cytoscape. diff --git a/mkdocs.yml b/mkdocs.yml index 4274cf97b..c9d76904e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -51,6 +51,7 @@ nav: - Troubleshooting: user-guide/troubleshooting.md - Community: user-guide/support.md - Migration & Updates: + - Migration Guide v8: user-guide/migration-guide-v8.md - Migration Guide v7: user-guide/migration-guide-v7.md - Migration Guide v6: user-guide/migration-guide-v6.md - Migration Guide v5: user-guide/migration-guide-v5.md diff --git a/pyproject.toml b/pyproject.toml index fbc374e19..53527a198 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,6 @@ tutorials = [ full = [ "tsam_xarray >= 0.6.4, < 1", # Time series aggregation for clustering (wraps tsam); 0.6.4 adds aggregate(dim_names=) "tsam >= 3.4.0, < 4", # Directly imported for ClusterConfig, ExtremeConfig, SegmentConfig - "pyvis==0.3.2", # Visualizing FlowSystem Network "scipy >= 1.15.1, < 2", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 "gurobipy >= 10.0.0, < 14; python_version < '3.14'", # No Python 3.14 wheels yet (expected Q1 2026) "dash >= 3.0.0, < 5", # Visualizing FlowSystem Network as app @@ -94,7 +93,6 @@ dev = [ "nbformat==5.10.4", "ruff==0.15.21", "pre-commit==4.6.0", - "pyvis==0.3.2", "scipy==1.16.3", # 1.16.1+ required for Python 3.14 wheels "highspy==1.13.1", # Latest known-good. Bump once HiGHS#2975 fix ships in 1.14.1+ "gurobipy==12.0.3; python_version < '3.14'", # No Python 3.14 wheels yet @@ -204,7 +202,6 @@ keep-runtime-typing = false # Allow pyupgrade to drop runtime typing; prefer po markers = [ "slow: marks tests as slow", "examples: marks example tests (run only on releases)", - "deprecated_api: marks tests using deprecated Optimization/Results API (remove in v6.0.0)", ] addopts = '-m "not examples" --ignore=tests/superseded' # Skip examples and superseded tests by default diff --git a/tests/conftest.py b/tests/conftest.py index b3950cc35..8b2fcb22b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -728,19 +728,6 @@ def assert_almost_equal_numeric( np.testing.assert_allclose(actual, desired, rtol=relative_tol, atol=absolute_tolerance, err_msg=err_msg) -def create_optimization_and_solve( - flow_system: fx.FlowSystem, solver, name: str, allow_infeasible: bool = False -) -> fx.Optimization: - optimization = fx.Optimization(name, flow_system) - optimization.do_modeling() - try: - optimization.solve(solver) - except RuntimeError: - if not allow_infeasible: - raise - return optimization - - def create_linopy_model(flow_system: fx.FlowSystem) -> FlowSystemModel: """ Create a FlowSystemModel from a FlowSystem by performing the modeling phase. diff --git a/tests/deprecated/__init__.py b/tests/deprecated/__init__.py deleted file mode 100644 index 7a05453a2..000000000 --- a/tests/deprecated/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Tests for deprecated Optimization/Results API. - -This folder contains tests for the deprecated API that will be removed in v6.0.0. -Delete this entire folder when the deprecation cycle ends. -""" diff --git a/tests/deprecated/conftest.py b/tests/deprecated/conftest.py deleted file mode 100644 index ff0538073..000000000 --- a/tests/deprecated/conftest.py +++ /dev/null @@ -1,892 +0,0 @@ -""" -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. - -This folder contains tests for the deprecated Optimization/Results API. -Delete this entire folder when the deprecation cycle ends in v6.0.0. -""" - -import os -import warnings -from collections.abc import Iterable - -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 FlowSystemModel - -# ============================================================================ -# SOLVER FIXTURES -# ============================================================================ - - -@pytest.fixture() -def highs_solver(): - return fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=300) - - -@pytest.fixture() -def gurobi_solver(): - pytest.importorskip('gurobipy', reason='Gurobi not available in this environment') - return fx.solvers.GurobiSolver(mip_gap=0, time_limit_seconds=300) - - -@pytest.fixture(params=[highs_solver, gurobi_solver], ids=['highs', 'gurobi']) -def solver_fixture(request): - return request.getfixturevalue(request.param.__name__) - - -# ================================= -# COORDINATE CONFIGURATION FIXTURES -# ================================= - - -@pytest.fixture( - params=[ - { - 'timesteps': pd.date_range('2020-01-01', periods=10, freq='h', name='time'), - 'periods': None, - 'scenarios': None, - }, - { - 'timesteps': pd.date_range('2020-01-01', periods=10, freq='h', name='time'), - 'periods': None, - 'scenarios': pd.Index(['A', 'B'], name='scenario'), - }, - { - 'timesteps': pd.date_range('2020-01-01', periods=10, freq='h', name='time'), - 'periods': pd.Index([2020, 2030, 2040], name='period'), - 'scenarios': None, - }, - { - 'timesteps': pd.date_range('2020-01-01', periods=10, freq='h', name='time'), - 'periods': pd.Index([2020, 2030, 2040], name='period'), - 'scenarios': pd.Index(['A', 'B'], name='scenario'), - }, - ], - ids=['time_only', 'time+scenarios', 'time+periods', 'time+periods+scenarios'], -) -def coords_config(request): - """Coordinate configurations for parametrized testing.""" - return request.param - - -# ============================================================================ -# HIERARCHICAL ELEMENT LIBRARY -# ============================================================================ - - -class Buses: - """Standard buses used across flow systems""" - - @staticmethod - def electricity(): - return fx.Bus('Strom') - - @staticmethod - def heat(): - return fx.Bus('Fernwärme') - - @staticmethod - def gas(): - return fx.Bus('Gas') - - @staticmethod - def coal(): - return fx.Bus('Kohle') - - @staticmethod - def defaults(): - """Get all standard buses at once""" - return [Buses.electricity(), Buses.heat(), Buses.gas()] - - -class Effects: - """Standard effects used across flow systems""" - - @staticmethod - def costs(): - return fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) - - @staticmethod - def costs_with_co2_share(): - return fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True, share_from_temporal={'CO2': 0.2}) - - @staticmethod - def co2(): - return fx.Effect('CO2', 'kg', 'CO2_e-Emissionen') - - @staticmethod - def primary_energy(): - return fx.Effect('PE', 'kWh_PE', 'Primärenergie') - - -class Converters: - """Energy conversion components""" - - class Boilers: - @staticmethod - def simple(): - """Simple boiler from simple_flow_system""" - return fx.linear_converters.Boiler( - 'Boiler', - thermal_efficiency=0.5, - thermal_flow=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=50, - relative_minimum=5 / 50, - relative_maximum=1, - status_parameters=fx.StatusParameters(), - ), - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - ) - - @staticmethod - def complex(): - """Complex boiler with investment parameters from flow_system_complex""" - return fx.linear_converters.Boiler( - 'Kessel', - thermal_efficiency=0.5, - status_parameters=fx.StatusParameters(effects_per_active_hour={'costs': 0, 'CO2': 1000}), - thermal_flow=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( - effects_of_investment=1000, - fixed_size=50, - mandatory=True, - effects_of_investment_per_size={'costs': 10, 'PE': 2}, - ), - status_parameters=fx.StatusParameters( - active_hours_min=0, - active_hours_max=1000, - max_uptime=10, - min_uptime=1, - max_downtime=10, - effects_per_startup=0.01, - startup_limit=1000, - ), - flow_hours_max=1e6, - ), - fuel_flow=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), - ) - - class CHPs: - @staticmethod - def simple(): - """Simple CHP from simple_flow_system""" - return fx.linear_converters.CHP( - 'CHP_unit', - thermal_efficiency=0.5, - electrical_efficiency=0.4, - electrical_flow=fx.Flow( - 'P_el', bus='Strom', size=60, relative_minimum=5 / 60, status_parameters=fx.StatusParameters() - ), - thermal_flow=fx.Flow('Q_th', bus='Fernwärme'), - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - ) - - @staticmethod - def base(): - """CHP from flow_system_base""" - return fx.linear_converters.CHP( - 'KWK', - thermal_efficiency=0.5, - electrical_efficiency=0.4, - status_parameters=fx.StatusParameters(effects_per_startup=0.01), - electrical_flow=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, previous_flow_rate=10), - thermal_flow=fx.Flow('Q_th', bus='Fernwärme', size=1e3), - fuel_flow=fx.Flow('Q_fu', bus='Gas', size=1e3), - ) - - class LinearConverters: - @staticmethod - def piecewise(): - """Piecewise converter from flow_system_piecewise_conversion""" - return fx.LinearConverter( - 'KWK', - inputs=[fx.Flow('Q_fu', bus='Gas', size=200)], - outputs=[ - fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), - fx.Flow('Q_th', bus='Fernwärme', size=100), - ], - 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)]), - } - ), - status_parameters=fx.StatusParameters(effects_per_startup=0.01), - ) - - @staticmethod - def segments(timesteps_length): - """Segments converter with time-varying piecewise conversion""" - return fx.LinearConverter( - 'KWK', - inputs=[fx.Flow('Q_fu', bus='Gas', size=200)], - outputs=[ - fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), - fx.Flow('Q_th', bus='Fernwärme', size=100), - ], - piecewise_conversion=fx.PiecewiseConversion( - { - 'P_el': fx.Piecewise( - [ - fx.Piece(np.linspace(5, 6, timesteps_length), 30), - fx.Piece(40, np.linspace(60, 70, timesteps_length)), - ] - ), - 'Q_th': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), - 'Q_fu': fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), - } - ), - status_parameters=fx.StatusParameters(effects_per_startup=0.01), - ) - - -class Storage: - """Energy storage components""" - - @staticmethod - def simple(timesteps_length=9): - """Simple storage from simple_flow_system""" - # Create pattern [80.0, 70.0, 80.0] and repeat/slice to match timesteps_length - pattern = [80.0, 70.0, 80.0, 80, 80, 80, 80, 80, 80] - charge_state_values = (pattern * ((timesteps_length // len(pattern)) + 1))[:timesteps_length] - - return 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(effects_of_investment=20, fixed_size=30, mandatory=True), - initial_charge_state=0, - relative_maximum_charge_state=1 / 100 * np.array(charge_state_values), - relative_maximum_final_charge_state=0.8, - eta_charge=0.9, - eta_discharge=1, - relative_loss_per_hour=0.08, - prevent_simultaneous_charge_and_discharge=True, - ) - - @staticmethod - def complex(): - """Complex storage with piecewise investment from flow_system_complex""" - invest_speicher = fx.InvestParameters( - effects_of_investment=0, - piecewise_effects_of_investment=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)]), - }, - ), - mandatory=True, - effects_of_investment_per_size={'costs': 0.01, 'CO2': 0.01}, - minimum_size=0, - maximum_size=1000, - ) - return 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, - ) - - -class LoadProfiles: - """Standard load and price profiles""" - - @staticmethod - def thermal_simple(timesteps_length=9): - # Create pattern and repeat/slice to match timesteps_length - pattern = [30.0, 0.0, 90.0, 110, 110, 20, 20, 20, 20] - values = (pattern * ((timesteps_length // len(pattern)) + 1))[:timesteps_length] - return np.array(values) - - @staticmethod - def thermal_complex(): - return np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) - - @staticmethod - def electrical_simple(timesteps_length=9): - # Create array of 80.0 repeated to match timesteps_length - return np.array([80.0 / 1000] * timesteps_length) - - @staticmethod - def electrical_scenario(): - return np.array([0.08, 0.1, 0.15]) - - @staticmethod - def electrical_complex(timesteps_length=9): - # Create array of 40 repeated to match timesteps_length - return np.array([40] * timesteps_length) - - @staticmethod - def random_thermal(length=10, seed=42): - rng = np.random.default_rng(seed) - return rng.random(length) * 180 - - @staticmethod - def random_electrical(length=10, seed=42): - rng = np.random.default_rng(seed) - return (rng.random(length) + 0.5) / 1.5 * 50 - - -class Sinks: - """Energy sinks (loads)""" - - @staticmethod - def heat_load(thermal_profile): - """Create thermal heat load sink""" - return fx.Sink( - 'Wärmelast', inputs=[fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=thermal_profile)] - ) - - @staticmethod - def electricity_feed_in(electrical_price_profile): - """Create electricity feed-in sink""" - return fx.Sink( - 'Einspeisung', inputs=[fx.Flow('P_el', bus='Strom', effects_per_flow_hour=-1 * electrical_price_profile)] - ) - - @staticmethod - def electricity_load(electrical_profile): - """Create electrical load sink (for flow_system_long)""" - return fx.Sink( - 'Stromlast', inputs=[fx.Flow('P_el_Last', bus='Strom', size=1, fixed_relative_profile=electrical_profile)] - ) - - -class Sources: - """Energy sources""" - - @staticmethod - def gas_with_costs_and_co2(): - """Standard gas tariff with CO2 emissions""" - source = Sources.gas_with_costs() - source.outputs[0].effects_per_flow_hour = {'costs': 0.04, 'CO2': 0.3} - return source - - @staticmethod - def gas_with_costs(): - """Simple gas tariff without CO2""" - return fx.Source( - 'Gastarif', outputs=[fx.Flow(label='Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': 0.04})] - ) - - -# ============================================================================ -# RECREATED FIXTURES USING HIERARCHICAL LIBRARY -# ============================================================================ - - -@pytest.fixture -def simple_flow_system() -> fx.FlowSystem: - """ - Create a simple energy system for testing - """ - base_timesteps = pd.date_range('2020-01-01', periods=9, freq='h', name='time') - timesteps_length = len(base_timesteps) - base_thermal_load = LoadProfiles.thermal_simple(timesteps_length) - base_electrical_price = LoadProfiles.electrical_simple(timesteps_length) - - # Define effects - costs = Effects.costs_with_co2_share() - co2 = Effects.co2() - co2.maximum_per_hour = 1000 - - # Create components - boiler = Converters.Boilers.simple() - chp = Converters.CHPs.simple() - storage = Storage.simple(timesteps_length) - heat_load = Sinks.heat_load(base_thermal_load) - gas_tariff = Sources.gas_with_costs_and_co2() - electricity_feed_in = Sinks.electricity_feed_in(base_electrical_price) - - # Create flow system - flow_system = fx.FlowSystem(base_timesteps) - flow_system.add_elements(*Buses.defaults()) - flow_system.add_elements(storage, costs, co2, boiler, heat_load, gas_tariff, electricity_feed_in, chp) - - return flow_system - - -@pytest.fixture -def simple_flow_system_scenarios() -> fx.FlowSystem: - """ - Create a simple energy system for testing - """ - base_timesteps = pd.date_range('2020-01-01', periods=9, freq='h', name='time') - timesteps_length = len(base_timesteps) - base_thermal_load = LoadProfiles.thermal_simple(timesteps_length) - base_electrical_price = LoadProfiles.electrical_scenario() - - # Define effects - costs = Effects.costs_with_co2_share() - co2 = Effects.co2() - co2.maximum_per_hour = 1000 - - # Create components - boiler = Converters.Boilers.simple() - chp = Converters.CHPs.simple() - storage = Storage.simple(timesteps_length) - heat_load = Sinks.heat_load(base_thermal_load) - gas_tariff = Sources.gas_with_costs_and_co2() - electricity_feed_in = Sinks.electricity_feed_in(base_electrical_price) - - # Create flow system - flow_system = fx.FlowSystem( - base_timesteps, scenarios=pd.Index(['A', 'B', 'C']), scenario_weights=np.array([0.5, 0.25, 0.25]) - ) - flow_system.add_elements(*Buses.defaults()) - 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 = LoadProfiles.random_thermal(10) - p_el = LoadProfiles.random_electrical(10) - - costs = Effects.costs() - heat_load = Sinks.heat_load(thermal_load) - gas_source = Sources.gas_with_costs() - electricity_sink = Sinks.electricity_feed_in(p_el) - - flow_system.add_elements(*Buses.defaults()) - flow_system.add_elements(costs, heat_load, gas_source, electricity_sink) - - return flow_system - - -@pytest.fixture -def flow_system_complex() -> fx.FlowSystem: - """ - Helper method to create a base model with configurable parameters - """ - thermal_load = LoadProfiles.thermal_complex() - electrical_load = LoadProfiles.electrical_complex() - flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=9, freq='h', name='time')) - - # Define the components and flow_system - costs = Effects.costs() - co2 = Effects.co2() - costs.share_from_temporal = {'CO2': 0.2} - pe = Effects.primary_energy() - pe.maximum_total = 3.5e3 - - heat_load = Sinks.heat_load(thermal_load) - gas_tariff = Sources.gas_with_costs_and_co2() - electricity_feed_in = Sinks.electricity_feed_in(electrical_load) - - flow_system.add_elements(*Buses.defaults()) - flow_system.add_elements(costs, co2, pe, heat_load, gas_tariff, electricity_feed_in) - - boiler = Converters.Boilers.complex() - speicher = Storage.complex() - - 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 - chp = Converters.CHPs.base() - flow_system.add_elements(chp) - return flow_system - - -@pytest.fixture -def flow_system_piecewise_conversion(flow_system_complex) -> fx.FlowSystem: - flow_system = flow_system_complex - converter = Converters.LinearConverters.piecewise() - flow_system.add_elements(converter) - 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 - converter = Converters.LinearConverters.segments(len(flow_system.timesteps)) - flow_system.add_elements(converter) - return flow_system - - -@pytest.fixture -def flow_system_long(): - """ - Special fixture with CSV data loading - kept separate for backward compatibility - Uses library components where possible, but has special elements inline - """ - # Load data - use parent folder's ressources - filename = os.path.join(os.path.dirname(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), - ) - p_feed_in, p_sell = ( - fx.TimeSeriesData(-(p_el - 0.5)), - fx.TimeSeriesData(p_el + 0.5), - ) - - flow_system = fx.FlowSystem(pd.DatetimeIndex(data.index)) - flow_system.add_elements( - *Buses.defaults(), - Buses.coal(), - Effects.costs(), - Effects.co2(), - Effects.primary_energy(), - fx.Sink( - 'Wärmelast', inputs=[fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=thermal_load_ts)] - ), - fx.Sink( - 'Stromlast', inputs=[fx.Flow('P_el_Last', bus='Strom', size=1, fixed_relative_profile=electrical_load_ts)] - ), - fx.Source( - 'Kohletarif', - outputs=[fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={'costs': 4.6, 'CO2': 0.3})], - ), - fx.Source( - 'Gastarif', - outputs=[fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': gas_price, 'CO2': 0.3})], - ), - fx.Sink('Einspeisung', inputs=[fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour=p_feed_in)]), - fx.Source( - 'Stromtarif', - outputs=[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', - thermal_efficiency=0.85, - thermal_flow=fx.Flow(label='Q_th', bus='Fernwärme'), - fuel_flow=fx.Flow( - label='Q_fu', - bus='Gas', - size=95, - relative_minimum=12 / 95, - previous_flow_rate=0, - status_parameters=fx.StatusParameters(effects_per_startup=1000), - ), - ), - fx.linear_converters.CHP( - 'BHKW2', - thermal_efficiency=(eta_th := 0.58), - electrical_efficiency=(eta_el := 0.22), - status_parameters=fx.StatusParameters(effects_per_startup=24000), - fuel_flow=fx.Flow( - 'Q_fu', bus='Kohle', size=(fuel_size := 288), relative_minimum=87 / fuel_size, previous_flow_rate=0 - ), - electrical_flow=fx.Flow('P_el', bus='Strom', size=fuel_size * eta_el), - thermal_flow=fx.Flow('Q_th', bus='Fernwärme', size=fuel_size * eta_th), - ), - 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, - } - - -@pytest.fixture(params=['h', '3h'], ids=['hourly', '3-hourly']) -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) - - n = len(flow_system.timesteps) - thermal_load = LoadProfiles.random_thermal(n) - p_el = LoadProfiles.random_electrical(n) - - costs = Effects.costs() - heat_load = Sinks.heat_load(thermal_load) - gas_source = Sources.gas_with_costs() - electricity_sink = Sinks.electricity_feed_in(p_el) - - flow_system.add_elements(*Buses.defaults()) - flow_system.add_elements(costs, heat_load, gas_source, electricity_sink) - - return flow_system - - -@pytest.fixture -def basic_flow_system_linopy_coords(coords_config) -> fx.FlowSystem: - """Create basic elements for component testing with coordinate parametrization.""" - flow_system = fx.FlowSystem(**coords_config) - - thermal_load = LoadProfiles.random_thermal(10) - p_el = LoadProfiles.random_electrical(10) - - costs = Effects.costs() - heat_load = Sinks.heat_load(thermal_load) - gas_source = Sources.gas_with_costs() - electricity_sink = Sinks.electricity_feed_in(p_el) - - flow_system.add_elements(*Buses.defaults()) - flow_system.add_elements(costs, heat_load, gas_source, electricity_sink) - - return flow_system - - -# ============================================================================ -# UTILITY FUNCTIONS (kept for backward compatibility) -# ============================================================================ - - -# Custom assertion function -def assert_almost_equal_numeric( - actual, desired, err_msg, relative_error_range_in_percent=0.011, absolute_tolerance=1e-7 -): - """ - Custom assertion function for comparing numeric values with relative and absolute tolerances. - - Handles the extra timestep in solutions by trimming actual arrays to match desired length - when the extra values are NaN (from storage charge_state variables using extra_timestep). - """ - 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: - actual = np.asarray(actual) - desired = np.asarray(desired) - # Handle extra timestep: trim actual to desired length if extra values are NaN - if actual.shape != desired.shape and actual.ndim == 1 and desired.ndim == 1: - if len(actual) > len(desired): - extra = actual[len(desired) :] - if np.all(np.isnan(extra)): - # Warn if trimming more than the expected single extra timestep - if len(extra) > 1: - warnings.warn( - f'Trimming {len(extra)} NaN values from actual array (expected 1)', - stacklevel=2, - ) - actual = actual[: len(desired)] - np.testing.assert_allclose(actual, desired, rtol=relative_tol, atol=absolute_tolerance, err_msg=err_msg) - - -def create_optimization_and_solve( - flow_system: fx.FlowSystem, solver, name: str, allow_infeasible: bool = False -) -> fx.Optimization: - optimization = fx.Optimization(name, flow_system) - optimization.do_modeling() - try: - optimization.solve(solver) - except RuntimeError: - if not allow_infeasible: - raise - return optimization - - -def create_linopy_model(flow_system: fx.FlowSystem) -> FlowSystemModel: - """ - Create a FlowSystemModel from a FlowSystem by performing the modeling phase. - - Args: - flow_system: The FlowSystem to build the model from. - - Returns: - FlowSystemModel: The built model from FlowSystem.build_model(). - """ - flow_system.build_model() - return flow_system.model - - -def assert_conequal(actual: linopy.Constraint, desired: linopy.Constraint): - """Assert that two constraints are equal with detailed error messages.""" - - try: - linopy.testing.assert_linequal(actual.lhs, desired.lhs) - except AssertionError as e: - raise AssertionError(f"{actual.name} left-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"{actual.name} signs don't match:\n{e}") from e - - try: - xr.testing.assert_equal(actual.rhs, desired.rhs) - except AssertionError as e: - raise AssertionError(f"{actual.name} right-hand sides don't match:\n{e}") 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}") - - -def assert_sets_equal(set1: Iterable, set2: Iterable, msg=''): - """Assert two sets are equal with custom error message.""" - set1, set2 = set(set1), set(set2) - - extra = set1 - set2 - missing = set2 - set1 - - if extra or missing: - parts = [] - if extra: - parts.append(f'Extra: {sorted(extra, key=repr)}') - if missing: - parts.append(f'Missing: {sorted(missing, key=repr)}') - - error_msg = ', '.join(parts) - if msg: - error_msg = f'{msg}: {error_msg}' - - raise AssertionError(error_msg) - - -# ============================================================================ -# PLOTTING CLEANUP FIXTURES -# ============================================================================ - - -@pytest.fixture(autouse=True) -def cleanup_figures(): - """ - Cleanup matplotlib figures after each test. - - This fixture runs automatically after every test to: - - Close all matplotlib figures to prevent memory leaks - """ - yield - # Close all matplotlib figures - import matplotlib.pyplot as plt - - plt.close('all') - - -@pytest.fixture(scope='session', autouse=True) -def set_test_environment(): - """ - Configure plotting for test environment. - - This fixture runs once per test session to: - - Set matplotlib to use non-interactive 'Agg' backend - - Set plotly to use non-interactive 'json' renderer - - Prevent GUI windows from opening during tests - """ - import matplotlib - - matplotlib.use('Agg') # Use non-interactive backend - - import plotly.io as pio - - pio.renderers.default = 'json' # Use non-interactive renderer - - fx.CONFIG.Plotting.default_show = False - - yield - - -# ============================================================================ -# DEPRECATED API MARKERS -# ============================================================================ - - -def pytest_collection_modifyitems(items): - """Auto-apply markers to all tests in the deprecated folder. - - This hook adds: - - deprecated_api marker for filtering - - filterwarnings to ignore DeprecationWarning - """ - for item in items: - # Only apply to tests in this folder - if 'deprecated' in str(item.fspath): - item.add_marker(pytest.mark.deprecated_api) - item.add_marker(pytest.mark.filterwarnings('ignore::DeprecationWarning')) diff --git a/tests/deprecated/examples/00_Minmal/minimal_example.py b/tests/deprecated/examples/00_Minmal/minimal_example.py deleted file mode 100644 index 207faa9a9..000000000 --- a/tests/deprecated/examples/00_Minmal/minimal_example.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -This script shows how to use the flixopt framework to model a super minimalistic energy system in the most concise way possible. -THis can also be used to create proposals for new features, bug reports etc -""" - -import numpy as np -import pandas as pd - -import flixopt as fx - -if __name__ == '__main__': - fx.CONFIG.silent() - flow_system = fx.FlowSystem(pd.date_range('2020-01-01', periods=3, freq='h')) - - flow_system.add_elements( - fx.Bus('Heat'), - fx.Bus('Gas'), - fx.Effect('Costs', '€', 'Cost', is_standard=True, is_objective=True), - fx.linear_converters.Boiler( - 'Boiler', - thermal_efficiency=0.5, - thermal_flow=fx.Flow(label='Heat', bus='Heat', size=50), - fuel_flow=fx.Flow(label='Gas', bus='Gas'), - ), - fx.Sink( - 'Sink', - inputs=[fx.Flow(label='Demand', bus='Heat', size=1, fixed_relative_profile=np.array([30, 0, 20]))], - ), - fx.Source( - 'Source', - outputs=[fx.Flow(label='Gas', bus='Gas', size=1000, effects_per_flow_hour=0.04)], - ), - ) - - flow_system.optimize(fx.solvers.HighsSolver(0.01, 60)) - flow_system.statistics.plot.balance('Heat') diff --git a/tests/deprecated/examples/01_Simple/simple_example.py b/tests/deprecated/examples/01_Simple/simple_example.py deleted file mode 100644 index b63260ece..000000000 --- a/tests/deprecated/examples/01_Simple/simple_example.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -This script shows how to use the flixopt framework to model a simple energy system. -""" - -import numpy as np -import pandas as pd - -import flixopt as fx - -if __name__ == '__main__': - fx.CONFIG.exploring() - - # --- Create Time Series Data --- - # Heat demand profile (e.g., kW) over time and corresponding power prices - heat_demand_per_h = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) - 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 - 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) - # Carriers provide automatic color assignment in plots (yellow for electricity, red for heat, etc.) - flow_system.add_elements( - fx.Bus(label='Strom', carrier='electricity'), - fx.Bus(label='Fernwärme', carrier='heat'), - fx.Bus(label='Gas', carrier='gas'), - ) - - # --- Define Effects (Objective and CO2 Emissions) --- - # Cost effect: used as the optimization objective --> minimizing costs - costs = fx.Effect( - label='costs', - unit='€', - description='Kosten', - is_standard=True, # standard effect: no explicit value needed for costs - is_objective=True, # Minimizing costs as the optimization objective - share_from_temporal={'CO2': 0.2}, - ) - - # CO2 emissions effect with an associated cost impact - CO2 = fx.Effect( - label='CO2', - unit='kg', - description='CO2_e-Emissionen', - maximum_per_hour=1000, # Max CO2 emissions per hour - ) - - # --- Define Flow System Components --- - # Boiler: Converts fuel (gas) into thermal energy (heat) - boiler = fx.linear_converters.Boiler( - label='Boiler', - thermal_efficiency=0.5, - thermal_flow=fx.Flow(label='Q_th', bus='Fernwärme', size=50, relative_minimum=0.1, relative_maximum=1), - fuel_flow=fx.Flow(label='Q_fu', bus='Gas'), - ) - - # Combined Heat and Power (CHP): Generates both electricity and heat from fuel - chp = fx.linear_converters.CHP( - label='CHP', - thermal_efficiency=0.5, - electrical_efficiency=0.4, - electrical_flow=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60), - thermal_flow=fx.Flow('Q_th', bus='Fernwärme'), - fuel_flow=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='Fernwärme', size=1000), - discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1000), - capacity_in_flow_hours=fx.InvestParameters(effects_of_investment=20, fixed_size=30, mandatory=True), - 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]), - relative_maximum_final_charge_state=0.8, - eta_charge=0.9, - eta_discharge=1, # Efficiency factors for charging/discharging - relative_loss_per_hour=0.08, # 8% loss per hour. Absolute loss depends on current charge state - prevent_simultaneous_charge_and_discharge=True, # Prevent charging and discharging at the same time - ) - - # Heat Demand Sink: Represents a fixed heat demand profile - heat_sink = fx.Sink( - label='Heat Demand', - inputs=[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', - outputs=[ - 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', inputs=[fx.Flow(label='P_el', bus='Strom', effects_per_flow_hour=-1 * power_prices)] - ) - - # --- Build the Flow System --- - # 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.topology.plot() - - # --- Define and Solve Optimization --- - flow_system.optimize(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) - - # --- Analyze Results --- - # Plotting through statistics accessor - returns PlotResult with .data and .figure - flow_system.statistics.plot.balance('Fernwärme') - flow_system.statistics.plot.balance('Storage') - flow_system.statistics.plot.heatmap('CHP(Q_th)') - flow_system.statistics.plot.heatmap('Storage') - - # Access data as xarray Datasets - print(flow_system.statistics.flow_rates) - print(flow_system.statistics.charge_states) - - # Duration curve and effects analysis - flow_system.statistics.plot.duration_curve('Boiler(Q_th)') - print(flow_system.statistics.temporal_effects) diff --git a/tests/deprecated/examples/02_Complex/complex_example.py b/tests/deprecated/examples/02_Complex/complex_example.py deleted file mode 100644 index f21fd0533..000000000 --- a/tests/deprecated/examples/02_Complex/complex_example.py +++ /dev/null @@ -1,207 +0,0 @@ -""" -This script shows how to use the flixopt framework to model a more complex energy system. -""" - -import numpy as np -import pandas as pd - -import flixopt as fx - -if __name__ == '__main__': - fx.CONFIG.exploring() - - # --- Experiment Options --- - # Configure options for testing various parameters and behaviors - check_penalty = False - imbalance_penalty = 1e5 - use_chp_with_piecewise_conversion = True - - # --- Define Demand and Price Profiles --- - # Input data for electricity and heat demands, as well as electricity price - electricity_demand = np.array([70, 80, 90, 90, 90, 90, 90, 90, 90]) - heat_demand = ( - np.array([30, 0, 90, 110, 2000, 20, 20, 20, 20]) - if check_penalty - else np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) - ) - electricity_price = np.array([40, 40, 40, 40, 40, 40, 40, 40, 40]) - - # --- 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 node balances (inputs=outputs) for the different energy carriers (electricity, heat, gas) in the system - # Carriers provide automatic color assignment in plots (yellow for electricity, red for heat, blue for gas) - flow_system.add_elements( - fx.Bus('Strom', carrier='electricity', imbalance_penalty_per_flow_hour=imbalance_penalty), - fx.Bus('Fernwärme', carrier='heat', imbalance_penalty_per_flow_hour=imbalance_penalty), - fx.Bus('Gas', carrier='gas', imbalance_penalty_per_flow_hour=imbalance_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, share_from_temporal={'CO2': 0.2}) - CO2 = fx.Effect('CO2', 'kg', 'CO2_e-Emissionen') - PE = fx.Effect('PE', 'kWh_PE', 'Primärenergie', maximum_total=3.5e3) - - # --- Define Components --- - # 1. Define Boiler Component - # A gas boiler that converts fuel into thermal output, with investment and on-inactive parameters - Gaskessel = fx.linear_converters.Boiler( - 'Kessel', - thermal_efficiency=0.5, # Efficiency ratio - status_parameters=fx.StatusParameters( - effects_per_active_hour={Costs.label: 0, CO2.label: 1000} - ), # CO2 emissions per hour - thermal_flow=fx.Flow( - label='Q_th', # Thermal output - bus='Fernwärme', # Linked bus - size=fx.InvestParameters( - effects_of_investment=1000, # Fixed investment costs - fixed_size=50, # Fixed size - mandatory=True, # Forced investment - effects_of_investment_per_size={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) - relative_minimum=5 / 50, # Minimum part load - relative_maximum=1, # Maximum part load - previous_flow_rate=50, # Previous flow rate - flow_hours_max=1e6, # Total energy flow limit - status_parameters=fx.StatusParameters( - active_hours_min=0, # Minimum operating hours - active_hours_max=1000, # Maximum operating hours - max_uptime=10, # Max consecutive operating hours - min_uptime=np.array([1, 1, 1, 1, 1, 2, 2, 2, 2]), # min consecutive operation hours - max_downtime=10, # Max consecutive inactive hours - effects_per_startup={Costs.label: 0.01}, # Cost per startup - startup_limit=1000, # Max number of starts - ), - ), - fuel_flow=fx.Flow(label='Q_fu', bus='Gas', size=200), - ) - - # 2. Define CHP Unit - # Combined Heat and Power unit that generates both electricity and heat from fuel - bhkw = fx.linear_converters.CHP( - 'BHKW2', - thermal_efficiency=0.5, - electrical_efficiency=0.4, - status_parameters=fx.StatusParameters(effects_per_startup={Costs.label: 0.01}), - electrical_flow=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60), - thermal_flow=fx.Flow('Q_th', bus='Fernwärme', size=1e3), - fuel_flow=fx.Flow('Q_fu', bus='Gas', size=1e3, previous_flow_rate=20), # The CHP was ON previously - ) - - # 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', size=100) # Size required for status_parameters - Q_fu = fx.Flow('Q_fu', bus='Gas', size=200) # Size required for status_parameters - 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], - piecewise_conversion=piecewise_conversion, - status_parameters=fx.StatusParameters(effects_per_startup={Costs.label: 0.01}), - ) - - # 4. Define Storage Component - # 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='Fernwärme', size=1e4), - discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1e4), - capacity_in_flow_hours=fx.InvestParameters( - piecewise_effects_of_investment=segmented_investment_effects, # Investment effects - mandatory=True, # Forced investment - minimum_size=0, - maximum_size=1000, # Optimizing between 0 and 1000 kWh - ), - initial_charge_state=0, # Initial charge state - maximal_final_charge_state=10, # Maximum final charge state - eta_charge=0.9, - eta_discharge=1, # Charge/discharge efficiency - relative_loss_per_hour=0.08, # Energy loss per hour, relative to current charge state - prevent_simultaneous_charge_and_discharge=True, # Prevent simultaneous charge/discharge - ) - - # 5. Define Sinks and Sources - # 5.a) Heat demand profile - Waermelast = fx.Sink( - 'Wärmelast', - inputs=[ - fx.Flow( - 'Q_th_Last', # Heat sink - bus='Fernwärme', # Linked bus - size=1, - fixed_relative_profile=heat_demand, # Fixed demand profile - ) - ], - ) - - # 5.b) Gas tariff - Gasbezug = fx.Source( - 'Gastarif', - outputs=[ - fx.Flow( - 'Q_Gas', - bus='Gas', # Gas source - size=1000, # Nominal size - effects_per_flow_hour={Costs.label: 0.04, CO2.label: 0.3}, - ) - ], - ) - - # 5.c) Feed-in of electricity - Stromverkauf = fx.Sink( - 'Einspeisung', - inputs=[ - fx.Flow( - 'P_el', - 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 flow system - flow_system.add_elements(Costs, CO2, PE, Gaskessel, Waermelast, Gasbezug, Stromverkauf, speicher) - flow_system.add_elements(bhkw_2) if use_chp_with_piecewise_conversion else flow_system.add_elements(bhkw) - - print(flow_system) # Get a string representation of the FlowSystem - try: - flow_system.topology.start_app() # Start the network app - except ImportError as e: - print(f'Network app requires extra dependencies: {e}') - - # --- Solve FlowSystem --- - flow_system.optimize(fx.solvers.HighsSolver(0.01, 60)) - - # --- Results --- - # Save the flow system with solution to file for later analysis - flow_system.to_netcdf('results/complex_example.nc') - - # Plot results using the statistics accessor - flow_system.statistics.plot.heatmap('BHKW2(Q_th)') # Flow label - auto-resolves to flow_rate - flow_system.statistics.plot.balance('BHKW2') - flow_system.statistics.plot.heatmap('Speicher') # Storage label - auto-resolves to charge_state - flow_system.statistics.plot.balance('Fernwärme') diff --git a/tests/deprecated/examples/02_Complex/complex_example_results.py b/tests/deprecated/examples/02_Complex/complex_example_results.py deleted file mode 100644 index 6978caff1..000000000 --- a/tests/deprecated/examples/02_Complex/complex_example_results.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -This script shows how to load results of a prior optimization and how to analyze them. -""" - -import flixopt as fx - -if __name__ == '__main__': - fx.CONFIG.exploring() - - # --- Load FlowSystem with Solution --- - try: - flow_system = fx.FlowSystem.from_netcdf('results/complex_example.nc') - except FileNotFoundError as e: - raise FileNotFoundError( - f"Results file not found ('results/complex_example.nc'). " - f"Please ensure that the file is generated by running 'complex_example.py'. " - f'Original error: {e}' - ) from e - - # --- Basic overview --- - flow_system.topology.plot() - flow_system.statistics.plot.balance('Fernwärme') - - # --- Detailed Plots --- - # In-depth plot for individual flow rates - flow_system.statistics.plot.heatmap('Wärmelast(Q_th_Last)|flow_rate') - - # Plot balances for all buses - for bus in flow_system.buses.values(): - flow_system.statistics.plot.balance(bus.label).to_html(f'results/{bus.label}--balance.html') - - # --- Plotting internal variables manually --- - flow_system.statistics.plot.heatmap('BHKW2(Q_th)|status') - flow_system.statistics.plot.heatmap('Kessel(Q_th)|status') - - # Access data as DataFrames: - print(flow_system.statistics.flow_rates.to_dataframe()) - print(flow_system.solution.to_dataframe()) diff --git a/tests/deprecated/examples/03_Optimization_modes/example_optimization_modes.py b/tests/deprecated/examples/03_Optimization_modes/example_optimization_modes.py deleted file mode 100644 index 95797888e..000000000 --- a/tests/deprecated/examples/03_Optimization_modes/example_optimization_modes.py +++ /dev/null @@ -1,265 +0,0 @@ -""" -This script demonstrates how to use the different calculation types in the flixopt framework -to model the same energy system. The results will be compared to each other. -""" - -import pathlib -import timeit - -import pandas as pd -import xarray as xr - -import flixopt as fx - - -# Get solutions for plotting for different optimizations -def get_solutions(optimizations: list, variable: str) -> xr.Dataset: - dataarrays = [] - for optimization in optimizations: - if optimization.name == 'Segmented': - # SegmentedOptimization requires special handling to remove overlaps - dataarrays.append(optimization.results.solution_without_overlap(variable).rename(optimization.name)) - else: - # For Full and Clustered, access solution from the flow_system - dataarrays.append(optimization.flow_system.solution[variable].rename(optimization.name)) - return xr.merge(dataarrays, join='outer') - - -if __name__ == '__main__': - fx.CONFIG.exploring() - - # Calculation Types - full, segmented, aggregated = True, True, True - - # Segmented Properties - segment_length, overlap_length = 96, 1 - - # Clustering Properties - n_clusters = 4 - cluster_duration = '6h' - keep_extreme_periods = True - imbalance_penalty = 1e5 # or set to None if not needed - - # Data Import - data_import = pd.read_csv( - pathlib.Path(__file__).parents[4] / 'docs' / 'notebooks' / 'data' / 'Zeitreihen2020.csv', index_col=0 - ).sort_index() - filtered_data = data_import['2020-01-01':'2020-01-07 23:45:00'] - # filtered_data = data_import[0:500] # Alternatively filter by index - - filtered_data.index = pd.to_datetime(filtered_data.index) - timesteps = filtered_data.index - - # Access specific columns and convert to 1D-numpy array - electricity_demand = filtered_data['P_Netz/MW'].to_numpy() - heat_demand = filtered_data['Q_Netz/MW'].to_numpy() - electricity_price = filtered_data['Strompr.€/MWh'].to_numpy() - gas_price = filtered_data['Gaspr.€/MWh'].to_numpy() - - # TimeSeriesData objects - TS_heat_demand = fx.TimeSeriesData(heat_demand) - TS_electricity_demand = fx.TimeSeriesData(electricity_demand) - TS_electricity_price_sell = fx.TimeSeriesData(-(electricity_price - 0.5)) - TS_electricity_price_buy = fx.TimeSeriesData(electricity_price + 0.5) - - flow_system = fx.FlowSystem(timesteps) - flow_system.add_elements( - fx.Bus('Strom', carrier='electricity', imbalance_penalty_per_flow_hour=imbalance_penalty), - fx.Bus('Fernwärme', carrier='heat', imbalance_penalty_per_flow_hour=imbalance_penalty), - fx.Bus('Gas', carrier='gas', imbalance_penalty_per_flow_hour=imbalance_penalty), - fx.Bus('Kohle', carrier='fuel', imbalance_penalty_per_flow_hour=imbalance_penalty), - ) - - # Effects - costs = fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True) - CO2 = fx.Effect('CO2', 'kg', 'CO2_e-Emissionen') - PE = fx.Effect('PE', 'kWh_PE', 'Primärenergie') - - # Component Definitions - - # 1. Boiler - a_gaskessel = fx.linear_converters.Boiler( - 'Kessel', - thermal_efficiency=0.85, - thermal_flow=fx.Flow(label='Q_th', bus='Fernwärme'), - fuel_flow=fx.Flow( - label='Q_fu', - bus='Gas', - size=95, - relative_minimum=12 / 95, - previous_flow_rate=20, - status_parameters=fx.StatusParameters(effects_per_startup=1000), - ), - ) - - # 2. CHP - a_kwk = fx.linear_converters.CHP( - 'BHKW2', - thermal_efficiency=0.58, - electrical_efficiency=0.22, - status_parameters=fx.StatusParameters(effects_per_startup=24000), - electrical_flow=fx.Flow('P_el', bus='Strom', size=200), - thermal_flow=fx.Flow('Q_th', bus='Fernwärme', size=200), - fuel_flow=fx.Flow('Q_fu', bus='Kohle', size=288, relative_minimum=87 / 288, previous_flow_rate=100), - ) - - # 3. Storage - a_speicher = fx.Storage( - 'Speicher', - 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, - 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', inputs=[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', inputs=[fx.Flow('P_el_Last', bus='Strom', size=1, fixed_relative_profile=TS_electricity_demand)] - ) - - # Gas Tariff - a_gas_tarif = fx.Source( - 'Gastarif', - outputs=[ - 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', - outputs=[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', inputs=[fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour=TS_electricity_price_sell)] - ) - - a_strom_tarif = fx.Source( - 'Stromtarif', - outputs=[ - fx.Flow( - 'P_el', - bus='Strom', - size=1000, - effects_per_flow_hour={costs.label: TS_electricity_price_buy, CO2.label: 0.3}, - ) - ], - ) - - # Flow System Setup - flow_system.add_elements(costs, CO2, PE) - flow_system.add_elements( - a_gaskessel, - a_waermelast, - a_strom_last, - a_gas_tarif, - a_kohle_tarif, - a_strom_einspeisung, - a_strom_tarif, - a_kwk, - a_speicher, - ) - flow_system.topology.plot() - - # Optimizations - optimizations: list[fx.Optimization | fx.SegmentedOptimization] = [] - - if full: - optimization = fx.Optimization('Full', flow_system.copy()) - optimization.do_modeling() - optimization.solve(fx.solvers.HighsSolver(0.01 / 100, 60)) - optimizations.append(optimization) - - if segmented: - optimization = fx.SegmentedOptimization('Segmented', flow_system.copy(), segment_length, overlap_length) - optimization.do_modeling_and_solve(fx.solvers.HighsSolver(0.01 / 100, 60)) - optimizations.append(optimization) - - if aggregated: - # Use the transform.cluster() API with tsam 3.0 - from tsam import ExtremeConfig - - extremes = None - if keep_extreme_periods: - extremes = ExtremeConfig( - method='new_cluster', - max_value=['Wärmelast(Q_th_Last)|fixed_relative_profile'], - min_value=[ - 'Stromlast(P_el_Last)|fixed_relative_profile', - 'Wärmelast(Q_th_Last)|fixed_relative_profile', - ], - ) - - clustered_fs = flow_system.copy().transform.cluster( - n_clusters=n_clusters, - cluster_duration=cluster_duration, - extremes=extremes, - ) - t_start = timeit.default_timer() - clustered_fs.optimize(fx.solvers.HighsSolver(0.01 / 100, 60)) - solve_duration = timeit.default_timer() - t_start - - # Wrap in a simple object for compatibility with comparison code - class ClusteredResult: - def __init__(self, name, fs, duration): - self.name = name - self.flow_system = fs - self.durations = {'total': duration} - - optimization = ClusteredResult('Clustered', clustered_fs, solve_duration) - optimizations.append(optimization) - - # --- Plotting for comparison --- - fx.plotting.with_plotly( - get_solutions(optimizations, 'Speicher|charge_state'), - mode='line', - title='Charge State Comparison', - ylabel='Charge state', - xlabel='Time in h', - ).write_html('results/Charge State.html') - - fx.plotting.with_plotly( - get_solutions(optimizations, 'BHKW2(Q_th)|flow_rate'), - mode='line', - title='BHKW2(Q_th) Flow Rate Comparison', - ylabel='Flow rate', - xlabel='Time in h', - ).write_html('results/BHKW2 Thermal Power.html') - - fx.plotting.with_plotly( - get_solutions(optimizations, 'costs(temporal)|per_timestep'), - mode='line', - title='Operation Cost Comparison', - ylabel='Costs [€]', - xlabel='Time in h', - ).write_html('results/Operation Costs.html') - - fx.plotting.with_plotly( - get_solutions(optimizations, 'costs(temporal)|per_timestep').sum('time'), - mode='stacked_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 optimizations], index=[calc.name for calc in optimizations] - ).to_xarray(), - mode='stacked_bar', - ).update_layout(title='Duration Comparison', xaxis_title='Optimization type', yaxis_title='Time (s)').write_html( - 'results/Speed Comparison.html' - ) diff --git a/tests/deprecated/examples/04_Scenarios/scenario_example.py b/tests/deprecated/examples/04_Scenarios/scenario_example.py deleted file mode 100644 index 820336e93..000000000 --- a/tests/deprecated/examples/04_Scenarios/scenario_example.py +++ /dev/null @@ -1,214 +0,0 @@ -""" -This script shows how to use the flixopt framework to model a simple energy system. -""" - -import numpy as np -import pandas as pd - -import flixopt as fx - -if __name__ == '__main__': - fx.CONFIG.exploring() - - # Create datetime array starting from '2020-01-01' for one week - timesteps = pd.date_range('2020-01-01', periods=24 * 7, freq='h') - scenarios = pd.Index(['Base Case', 'High Demand']) - periods = pd.Index([2020, 2021, 2022]) - - # --- Create Time Series Data --- - # Realistic daily patterns: morning/evening peaks, night/midday lows - np.random.seed(42) - n_hours = len(timesteps) - - # Heat demand: 24-hour patterns (kW) for Base Case and High Demand scenarios - base_daily_pattern = np.array( - [22, 20, 18, 18, 20, 25, 40, 70, 95, 110, 85, 65, 60, 58, 62, 68, 75, 88, 105, 125, 130, 122, 95, 35] - ) - high_daily_pattern = np.array( - [28, 25, 22, 22, 24, 30, 52, 88, 118, 135, 105, 80, 75, 72, 75, 82, 92, 108, 128, 148, 155, 145, 115, 48] - ) - - # Tile and add variation - base_demand = np.tile(base_daily_pattern, n_hours // 24 + 1)[:n_hours] * ( - 1 + np.random.uniform(-0.05, 0.05, n_hours) - ) - high_demand = np.tile(high_daily_pattern, n_hours // 24 + 1)[:n_hours] * ( - 1 + np.random.uniform(-0.07, 0.07, n_hours) - ) - - heat_demand_per_h = pd.DataFrame({'Base Case': base_demand, 'High Demand': high_demand}, index=timesteps) - - # Power prices: hourly factors (night low, peak high) and period escalation (2020-2022) - hourly_price_factors = np.array( - [ - 0.70, - 0.65, - 0.62, - 0.60, - 0.62, - 0.70, - 0.95, - 1.15, - 1.30, - 1.25, - 1.10, - 1.00, - 0.95, - 0.90, - 0.88, - 0.92, - 1.00, - 1.10, - 1.25, - 1.40, - 1.35, - 1.20, - 0.95, - 0.80, - ] - ) - period_base_prices = np.array([0.075, 0.095, 0.135]) # €/kWh for 2020, 2021, 2022 - - price_series = np.zeros((n_hours, 3)) - for period_idx, base_price in enumerate(period_base_prices): - price_series[:, period_idx] = ( - np.tile(hourly_price_factors, n_hours // 24 + 1)[:n_hours] - * base_price - * (1 + np.random.uniform(-0.03, 0.03, n_hours)) - ) - - power_prices = price_series.mean(axis=0) - - # Scenario weights: probability of each scenario occurring - # Base Case: 60% probability, High Demand: 40% probability - scenario_weights = np.array([0.6, 0.4]) - - flow_system = fx.FlowSystem( - timesteps=timesteps, periods=periods, scenarios=scenarios, scenario_weights=scenario_weights - ) - - # --- Define Energy Buses --- - # These represent nodes, where the used medias are balanced (electricity, heat, and gas) - # Carriers provide automatic color assignment in plots (yellow for electricity, red for heat, blue for gas) - flow_system.add_elements( - fx.Bus(label='Strom', carrier='electricity'), - fx.Bus(label='Fernwärme', carrier='heat'), - fx.Bus(label='Gas', carrier='gas'), - ) - - # --- Define Effects (Objective and CO2 Emissions) --- - # Cost effect: used as the optimization objective --> minimizing costs - costs = fx.Effect( - label='costs', - unit='€', - description='Kosten', - is_standard=True, # standard effect: no explicit value needed for costs - is_objective=True, # Minimizing costs as the optimization objective - share_from_temporal={'CO2': 0.2}, # Carbon price: 0.2 €/kg CO2 (e.g., carbon tax) - ) - - # CO2 emissions effect with constraint - # Maximum of 1000 kg CO2/hour represents a regulatory or voluntary emissions limit - CO2 = fx.Effect( - label='CO2', - unit='kg', - description='CO2_e-Emissionen', - maximum_per_hour=1000, # Regulatory emissions limit: 1000 kg CO2/hour - ) - - # --- Define Flow System Components --- - # Boiler: Converts fuel (gas) into thermal energy (heat) - # Modern condensing gas boiler with realistic efficiency - boiler = fx.linear_converters.Boiler( - label='Boiler', - thermal_efficiency=0.92, # Realistic efficiency for modern condensing gas boiler (92%) - thermal_flow=fx.Flow( - label='Q_th', - bus='Fernwärme', - size=100, - relative_minimum=0.1, - relative_maximum=1, - status_parameters=fx.StatusParameters(), - ), - fuel_flow=fx.Flow(label='Q_fu', bus='Gas'), - ) - - # Combined Heat and Power (CHP): Generates both electricity and heat from fuel - # Modern CHP unit with realistic efficiencies (total efficiency ~88%) - chp = fx.linear_converters.CHP( - label='CHP', - thermal_efficiency=0.48, # Realistic thermal efficiency (48%) - electrical_efficiency=0.40, # Realistic electrical efficiency (40%) - electrical_flow=fx.Flow( - 'P_el', bus='Strom', size=80, relative_minimum=5 / 80, status_parameters=fx.StatusParameters() - ), - thermal_flow=fx.Flow('Q_th', bus='Fernwärme'), - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - ) - - # Storage: Thermal energy storage system with charging and discharging capabilities - # Realistic thermal storage parameters (e.g., insulated hot water tank) - storage = fx.Storage( - label='Storage', - 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(effects_of_investment=20, fixed_size=30, mandatory=True), - initial_charge_state=0, # Initial storage state: empty - relative_maximum_final_charge_state=np.array([0.8, 0.5, 0.1]), - eta_charge=0.95, # Realistic charging efficiency (~95%) - eta_discharge=0.98, # Realistic discharging efficiency (~98%) - relative_loss_per_hour=np.array([0.008, 0.015]), # Realistic thermal losses: 0.8-1.5% per hour - prevent_simultaneous_charge_and_discharge=True, # Prevent charging and discharging at the same time - ) - - # Heat Demand Sink: Represents a fixed heat demand profile - heat_sink = fx.Sink( - label='Heat Demand', - inputs=[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 - # Realistic gas prices varying by period (reflecting 2020-2022 energy crisis) - # 2020: 0.04 €/kWh, 2021: 0.06 €/kWh, 2022: 0.11 €/kWh - gas_prices_per_period = np.array([0.04, 0.06, 0.11]) - - # CO2 emissions factor for natural gas: ~0.202 kg CO2/kWh (realistic value) - gas_co2_emissions = 0.202 - - gas_source = fx.Source( - label='Gastarif', - outputs=[ - fx.Flow( - label='Q_Gas', - bus='Gas', - size=1000, - effects_per_flow_hour={costs.label: gas_prices_per_period, CO2.label: gas_co2_emissions}, - ) - ], - ) - - # Power Sink: Represents the export of electricity to the grid - power_sink = fx.Sink( - label='Einspeisung', inputs=[fx.Flow(label='P_el', bus='Strom', effects_per_flow_hour=-1 * power_prices)] - ) - - # --- Build the Flow System --- - # 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.topology.plot() - - # --- Define and Solve Optimization --- - flow_system.optimize(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) - - # --- Analyze Results --- - # Plotting through statistics accessor - returns PlotResult with .data and .figure - flow_system.statistics.plot.heatmap('CHP(Q_th)') # Flow label - auto-resolves to flow_rate - flow_system.statistics.plot.balance('Fernwärme') - flow_system.statistics.plot.balance('Storage') - flow_system.statistics.plot.heatmap('Storage') # Storage label - auto-resolves to charge_state - - # Access data as xarray Datasets - print(flow_system.statistics.flow_rates) - print(flow_system.statistics.charge_states) diff --git a/tests/deprecated/examples/05_Two-stage-optimization/two_stage_optimization.py b/tests/deprecated/examples/05_Two-stage-optimization/two_stage_optimization.py deleted file mode 100644 index 155c6303f..000000000 --- a/tests/deprecated/examples/05_Two-stage-optimization/two_stage_optimization.py +++ /dev/null @@ -1,192 +0,0 @@ -""" -This script demonstrates how to use downsampling of a FlowSystem to effectively reduce the size of a model. -This can be very useful when working with large models or during development, -as it can drastically reduce the computational time. -This leads to faster results and easier debugging. -A common use case is to optimize the investments of a model with a downsampled version of the original model, and then fix the computed sizes when calculating the actual dispatch. -While the final optimum might differ from the global optimum, the solving will be much faster. -""" - -import logging -import pathlib -import timeit - -import numpy as np -import pandas as pd -import xarray as xr - -import flixopt as fx - -logger = logging.getLogger('flixopt') - -if __name__ == '__main__': - fx.CONFIG.exploring() - - # Data Import - data_import = pd.read_csv( - pathlib.Path(__file__).parents[4] / 'docs' / 'notebooks' / 'data' / 'Zeitreihen2020.csv', index_col=0 - ).sort_index() - filtered_data = data_import[:500] - - filtered_data.index = pd.to_datetime(filtered_data.index) - timesteps = filtered_data.index - - # Access specific columns and convert to 1D-numpy array - electricity_demand = filtered_data['P_Netz/MW'].to_numpy() - heat_demand = filtered_data['Q_Netz/MW'].to_numpy() - electricity_price = filtered_data['Strompr.€/MWh'].to_numpy() - gas_price = filtered_data['Gaspr.€/MWh'].to_numpy() - - flow_system = fx.FlowSystem(timesteps) - # Carriers provide automatic color assignment in plots - # Bus imbalance penalties allow slack when two-stage sizing doesn't meet peak demand - imbalance_penalty = 1e5 - flow_system.add_elements( - fx.Bus('Strom', carrier='electricity', imbalance_penalty_per_flow_hour=imbalance_penalty), - fx.Bus('Fernwärme', carrier='heat', imbalance_penalty_per_flow_hour=imbalance_penalty), - fx.Bus('Gas', carrier='gas', imbalance_penalty_per_flow_hour=imbalance_penalty), - fx.Bus('Kohle', carrier='fuel', imbalance_penalty_per_flow_hour=imbalance_penalty), - 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.linear_converters.Boiler( - 'Kessel', - thermal_efficiency=0.85, - thermal_flow=fx.Flow(label='Q_th', bus='Fernwärme'), - fuel_flow=fx.Flow( - label='Q_fu', - bus='Gas', - size=fx.InvestParameters( - effects_of_investment_per_size={'costs': 1_000}, minimum_size=10, maximum_size=600 - ), - relative_minimum=0.2, - previous_flow_rate=20, - status_parameters=fx.StatusParameters(effects_per_startup=300), - ), - ), - fx.linear_converters.CHP( - 'BHKW2', - thermal_efficiency=0.58, - electrical_efficiency=0.22, - status_parameters=fx.StatusParameters(effects_per_startup=1_000, min_uptime=10, min_downtime=10), - electrical_flow=fx.Flow( - 'P_el', bus='Strom', size=1000 - ), # Large size for big-M (won't constrain optimization) - thermal_flow=fx.Flow( - 'Q_th', bus='Fernwärme', size=1000 - ), # Large size for big-M (won't constrain optimization) - fuel_flow=fx.Flow( - 'Q_fu', - bus='Kohle', - size=fx.InvestParameters( - effects_of_investment_per_size={'costs': 3_000}, minimum_size=10, maximum_size=500 - ), - relative_minimum=0.3, - previous_flow_rate=100, - ), - ), - fx.Storage( - 'Speicher', - capacity_in_flow_hours=fx.InvestParameters( - minimum_size=10, maximum_size=1000, effects_of_investment_per_size={'costs': 60} - ), - initial_charge_state='equals_final', - eta_charge=1, - eta_discharge=1, - relative_loss_per_hour=0.001, - prevent_simultaneous_charge_and_discharge=True, - charging=fx.Flow('Q_th_load', size=200, bus='Fernwärme'), - discharging=fx.Flow('Q_th_unload', size=200, bus='Fernwärme'), - ), - fx.Sink( - 'Wärmelast', inputs=[fx.Flow('Q_th_Last', bus='Fernwärme', size=1, fixed_relative_profile=heat_demand)] - ), - fx.Source( - 'Gastarif', - outputs=[fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': gas_price, 'CO2': 0.3})], - ), - fx.Source( - 'Kohletarif', - outputs=[fx.Flow('Q_Kohle', bus='Kohle', size=1000, effects_per_flow_hour={'costs': 4.6, 'CO2': 0.3})], - ), - fx.Source( - 'Einspeisung', - outputs=[ - fx.Flow( - 'P_el', bus='Strom', size=1000, effects_per_flow_hour={'costs': electricity_price + 0.5, 'CO2': 0.3} - ) - ], - ), - fx.Sink( - 'Stromlast', - inputs=[fx.Flow('P_el_Last', bus='Strom', size=1, fixed_relative_profile=electricity_demand)], - ), - fx.Source( - 'Stromtarif', - outputs=[ - fx.Flow('P_el', bus='Strom', size=1000, effects_per_flow_hour={'costs': electricity_price, 'CO2': 0.3}) - ], - ), - ) - - # Separate optimization of flow sizes and dispatch - # Stage 1: Optimize sizes using downsampled (2h) data - start = timeit.default_timer() - calculation_sizing = fx.Optimization('Sizing', flow_system.resample('2h')) - calculation_sizing.do_modeling() - calculation_sizing.solve(fx.solvers.HighsSolver(0.1 / 100, 60)) - timer_sizing = timeit.default_timer() - start - - # Stage 2: Optimize dispatch with fixed sizes from Stage 1 - start = timeit.default_timer() - calculation_dispatch = fx.Optimization('Dispatch', flow_system) - calculation_dispatch.do_modeling() - calculation_dispatch.fix_sizes(calculation_sizing.flow_system.solution) - calculation_dispatch.solve(fx.solvers.HighsSolver(0.1 / 100, 60)) - timer_dispatch = timeit.default_timer() - start - - # Verify sizes were correctly fixed - dispatch_sizes = calculation_dispatch.flow_system.statistics.sizes - sizing_sizes = calculation_sizing.flow_system.statistics.sizes - if np.allclose(dispatch_sizes.to_dataarray(), sizing_sizes.to_dataarray(), rtol=1e-5): - logger.info('Sizes were correctly equalized') - else: - raise RuntimeError('Sizes were not correctly equalized') - - # Combined optimization: optimize both sizes and dispatch together - start = timeit.default_timer() - calculation_combined = fx.Optimization('Combined', flow_system) - calculation_combined.do_modeling() - calculation_combined.solve(fx.solvers.HighsSolver(0.1 / 100, 600)) - timer_combined = timeit.default_timer() - start - - # Comparison of results - access solutions from flow_system - comparison = xr.concat( - [calculation_combined.flow_system.solution, calculation_dispatch.flow_system.solution], dim='mode' - ).assign_coords(mode=['Combined', 'Two-stage']) - comparison['Duration [s]'] = xr.DataArray([timer_combined, timer_sizing + timer_dispatch], dims='mode') - - comparison_main = comparison[ - [ - 'Duration [s]', - 'costs', - 'costs(periodic)', - 'costs(temporal)', - 'BHKW2(Q_fu)|size', - 'Kessel(Q_fu)|size', - 'Speicher|size', - ] - ] - comparison_main = xr.concat( - [ - comparison_main, - ( - (comparison_main.sel(mode='Two-stage') - comparison_main.sel(mode='Combined')) - / comparison_main.sel(mode='Combined') - * 100 - ).assign_coords(mode='Diff [%]'), - ], - dim='mode', - ) - - print(comparison_main.to_pandas().T.round(2)) diff --git a/tests/deprecated/test_bus.py b/tests/deprecated/test_bus.py deleted file mode 100644 index 9bb7ddbe3..000000000 --- a/tests/deprecated/test_bus.py +++ /dev/null @@ -1,105 +0,0 @@ -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_coords, coords_config): - """Test that flow model constraints are correctly generated.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - bus = fx.Bus('TestBus', imbalance_penalty_per_flow_hour=None) - flow_system.add_elements( - bus, - fx.Sink('WärmelastTest', inputs=[fx.Flow('Q_th_Last', 'TestBus')]), - fx.Source('GastarifTest', outputs=[fx.Flow('Q_Gas', 'TestBus')]), - ) - model = create_linopy_model(flow_system) - - assert set(bus.submodel.variables) == {'WärmelastTest(Q_th_Last)|flow_rate', 'GastarifTest(Q_Gas)|flow_rate'} - assert set(bus.submodel.constraints) == {'TestBus|balance'} - - assert_conequal( - model.constraints['TestBus|balance'], - 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_coords, coords_config): - """Test that flow model constraints are correctly generated.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - bus = fx.Bus('TestBus', imbalance_penalty_per_flow_hour=1e5) - flow_system.add_elements( - bus, - fx.Sink('WärmelastTest', inputs=[fx.Flow('Q_th_Last', 'TestBus')]), - fx.Source('GastarifTest', outputs=[fx.Flow('Q_Gas', 'TestBus')]), - ) - model = create_linopy_model(flow_system) - - assert set(bus.submodel.variables) == { - 'TestBus|virtual_supply', - 'TestBus|virtual_demand', - 'WärmelastTest(Q_th_Last)|flow_rate', - 'GastarifTest(Q_Gas)|flow_rate', - } - assert set(bus.submodel.constraints) == {'TestBus|balance'} - - assert_var_equal( - model.variables['TestBus|virtual_supply'], model.add_variables(lower=0, coords=model.get_coords()) - ) - assert_var_equal( - model.variables['TestBus|virtual_demand'], model.add_variables(lower=0, coords=model.get_coords()) - ) - - 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|virtual_supply'] - - model.variables['TestBus|virtual_demand'] - == 0, - ) - - # Penalty is now added as shares to the Penalty effect's temporal model - # Check that the penalty shares exist - assert 'TestBus->Penalty(temporal)' in model.constraints - assert 'TestBus->Penalty(temporal)' in model.variables - - # The penalty share should equal the imbalance (virtual_supply + virtual_demand) times the penalty cost - # Let's verify the total penalty contribution by checking the effect's temporal model - penalty_effect = flow_system.effects.penalty_effect - assert penalty_effect.submodel is not None - assert 'TestBus' in penalty_effect.submodel.temporal.shares - - assert_conequal( - model.constraints['TestBus->Penalty(temporal)'], - model.variables['TestBus->Penalty(temporal)'] - == model.variables['TestBus|virtual_supply'] * 1e5 * model.timestep_duration - + model.variables['TestBus|virtual_demand'] * 1e5 * model.timestep_duration, - ) - - def test_bus_with_coords(self, basic_flow_system_linopy_coords, coords_config): - """Test bus behavior across different coordinate configurations.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - bus = fx.Bus('TestBus', imbalance_penalty_per_flow_hour=None) - flow_system.add_elements( - bus, - fx.Sink('WärmelastTest', inputs=[fx.Flow('Q_th_Last', 'TestBus')]), - fx.Source('GastarifTest', outputs=[fx.Flow('Q_Gas', 'TestBus')]), - ) - model = create_linopy_model(flow_system) - - # Same core assertions as your existing test - assert set(bus.submodel.variables) == {'WärmelastTest(Q_th_Last)|flow_rate', 'GastarifTest(Q_Gas)|flow_rate'} - assert set(bus.submodel.constraints) == {'TestBus|balance'} - - assert_conequal( - model.constraints['TestBus|balance'], - model.variables['GastarifTest(Q_Gas)|flow_rate'] == model.variables['WärmelastTest(Q_th_Last)|flow_rate'], - ) - - # Just verify coordinate dimensions are correct - gas_var = model.variables['GastarifTest(Q_Gas)|flow_rate'] - if flow_system.scenarios is not None: - assert 'scenario' in gas_var.dims - assert 'time' in gas_var.dims diff --git a/tests/deprecated/test_component.py b/tests/deprecated/test_component.py deleted file mode 100644 index f81ca270e..000000000 --- a/tests/deprecated/test_component.py +++ /dev/null @@ -1,632 +0,0 @@ -import numpy as np -import pytest - -import flixopt as fx -import flixopt.elements - -from .conftest import ( - assert_almost_equal_numeric, - assert_conequal, - assert_sets_equal, - assert_var_equal, - create_linopy_model, -) - - -class TestComponentModel: - def test_flow_label_check(self): - """Test that flow model constraints are correctly generated.""" - 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_coords, coords_config): - """Test that flow model constraints are correctly generated.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - inputs = [ - fx.Flow('In1', 'Fernwärme', size=100, relative_minimum=np.ones(10) * 0.1), - fx.Flow('In2', 'Fernwärme', size=100, relative_minimum=np.ones(10) * 0.1), - ] - outputs = [ - fx.Flow('Out1', 'Gas', size=100, relative_minimum=np.ones(10) * 0.01), - fx.Flow('Out2', 'Gas', size=100, 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_sets_equal( - set(comp.submodel.variables), - { - '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', - }, - msg='Incorrect variables', - ) - - assert_sets_equal( - set(comp.submodel.constraints), - { - 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In2)|total_flow_hours', - 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out2)|total_flow_hours', - }, - msg='Incorrect constraints', - ) - - def test_on_with_multiple_flows(self, basic_flow_system_linopy_coords, coords_config): - """Test that flow model constraints are correctly generated.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - 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, status_parameters=fx.StatusParameters() - ) - flow_system.add_elements(comp) - model = create_linopy_model(flow_system) - - assert_sets_equal( - set(comp.submodel.variables), - { - 'TestComponent(In1)|flow_rate', - 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|status', - 'TestComponent(In1)|active_hours', - 'TestComponent(Out1)|flow_rate', - 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out1)|status', - 'TestComponent(Out1)|active_hours', - 'TestComponent(Out2)|flow_rate', - 'TestComponent(Out2)|total_flow_hours', - 'TestComponent(Out2)|status', - 'TestComponent(Out2)|active_hours', - 'TestComponent|status', - 'TestComponent|active_hours', - }, - msg='Incorrect variables', - ) - - assert_sets_equal( - set(comp.submodel.constraints), - { - 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|flow_rate|lb', - 'TestComponent(In1)|flow_rate|ub', - 'TestComponent(In1)|active_hours', - 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out1)|flow_rate|lb', - 'TestComponent(Out1)|flow_rate|ub', - 'TestComponent(Out1)|active_hours', - 'TestComponent(Out2)|total_flow_hours', - 'TestComponent(Out2)|flow_rate|lb', - 'TestComponent(Out2)|flow_rate|ub', - 'TestComponent(Out2)|active_hours', - 'TestComponent|status|lb', - 'TestComponent|status|ub', - 'TestComponent|active_hours', - }, - msg='Incorrect constraints', - ) - - upper_bound_flow_rate = outputs[1].relative_maximum - - # Data stays in minimal form (1D array stays 1D) - assert upper_bound_flow_rate.dims == ('time',) - - assert_var_equal( - model['TestComponent(Out2)|flow_rate'], - model.add_variables(lower=0, upper=300 * upper_bound_flow_rate, coords=model.get_coords()), - ) - assert_var_equal(model['TestComponent|status'], model.add_variables(binary=True, coords=model.get_coords())) - assert_var_equal( - model['TestComponent(Out2)|status'], model.add_variables(binary=True, coords=model.get_coords()) - ) - - assert_conequal( - model.constraints['TestComponent(Out2)|flow_rate|lb'], - model.variables['TestComponent(Out2)|flow_rate'] - >= model.variables['TestComponent(Out2)|status'] * 0.3 * 300, - ) - assert_conequal( - model.constraints['TestComponent(Out2)|flow_rate|ub'], - model.variables['TestComponent(Out2)|flow_rate'] - <= model.variables['TestComponent(Out2)|status'] * 300 * upper_bound_flow_rate, - ) - - assert_conequal( - model.constraints['TestComponent|status|lb'], - model.variables['TestComponent|status'] - >= ( - model.variables['TestComponent(In1)|status'] - + model.variables['TestComponent(Out1)|status'] - + model.variables['TestComponent(Out2)|status'] - ) - / (3 + 1e-5), - ) - assert_conequal( - model.constraints['TestComponent|status|ub'], - model.variables['TestComponent|status'] - <= ( - model.variables['TestComponent(In1)|status'] - + model.variables['TestComponent(Out1)|status'] - + model.variables['TestComponent(Out2)|status'] - ) - + 1e-5, - ) - - def test_on_with_single_flow(self, basic_flow_system_linopy_coords, coords_config): - """Test that flow model constraints are correctly generated.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - 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, status_parameters=fx.StatusParameters() - ) - flow_system.add_elements(comp) - model = create_linopy_model(flow_system) - - assert_sets_equal( - set(comp.submodel.variables), - { - 'TestComponent(In1)|flow_rate', - 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|status', - 'TestComponent(In1)|active_hours', - 'TestComponent|status', - 'TestComponent|active_hours', - }, - msg='Incorrect variables', - ) - - assert_sets_equal( - set(comp.submodel.constraints), - { - 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|flow_rate|lb', - 'TestComponent(In1)|flow_rate|ub', - 'TestComponent(In1)|active_hours', - 'TestComponent|status', - 'TestComponent|active_hours', - }, - msg='Incorrect constraints', - ) - - assert_var_equal( - model['TestComponent(In1)|flow_rate'], model.add_variables(lower=0, upper=100, coords=model.get_coords()) - ) - assert_var_equal(model['TestComponent|status'], model.add_variables(binary=True, coords=model.get_coords())) - assert_var_equal( - model['TestComponent(In1)|status'], model.add_variables(binary=True, coords=model.get_coords()) - ) - - assert_conequal( - model.constraints['TestComponent(In1)|flow_rate|lb'], - model.variables['TestComponent(In1)|flow_rate'] >= model.variables['TestComponent(In1)|status'] * 0.1 * 100, - ) - assert_conequal( - model.constraints['TestComponent(In1)|flow_rate|ub'], - model.variables['TestComponent(In1)|flow_rate'] <= model.variables['TestComponent(In1)|status'] * 100, - ) - - assert_conequal( - model.constraints['TestComponent|status'], - model.variables['TestComponent|status'] == model.variables['TestComponent(In1)|status'], - ) - - def test_previous_states_with_multiple_flows(self, basic_flow_system_linopy_coords, coords_config): - """Test that flow model constraints are correctly generated.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - ub_out2 = np.linspace(1, 1.5, 10).round(2) - inputs = [ - fx.Flow( - 'In1', - 'Fernwärme', - relative_minimum=np.ones(10) * 0.1, - size=100, - previous_flow_rate=np.array([0, 0, 1e-6, 1e-5, 1e-4, 3, 4]), - ), - ] - outputs = [ - fx.Flow('Out1', 'Gas', relative_minimum=np.ones(10) * 0.2, size=200, previous_flow_rate=[3, 4, 5]), - fx.Flow( - 'Out2', - 'Gas', - relative_minimum=np.ones(10) * 0.3, - relative_maximum=ub_out2, - size=300, - previous_flow_rate=20, - ), - ] - comp = flixopt.elements.Component( - 'TestComponent', inputs=inputs, outputs=outputs, status_parameters=fx.StatusParameters() - ) - flow_system.add_elements(comp) - model = create_linopy_model(flow_system) - - assert_sets_equal( - set(comp.submodel.variables), - { - 'TestComponent(In1)|flow_rate', - 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|status', - 'TestComponent(In1)|active_hours', - 'TestComponent(Out1)|flow_rate', - 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out1)|status', - 'TestComponent(Out1)|active_hours', - 'TestComponent(Out2)|flow_rate', - 'TestComponent(Out2)|total_flow_hours', - 'TestComponent(Out2)|status', - 'TestComponent(Out2)|active_hours', - 'TestComponent|status', - 'TestComponent|active_hours', - }, - msg='Incorrect variables', - ) - - assert_sets_equal( - set(comp.submodel.constraints), - { - 'TestComponent(In1)|total_flow_hours', - 'TestComponent(In1)|flow_rate|lb', - 'TestComponent(In1)|flow_rate|ub', - 'TestComponent(In1)|active_hours', - 'TestComponent(Out1)|total_flow_hours', - 'TestComponent(Out1)|flow_rate|lb', - 'TestComponent(Out1)|flow_rate|ub', - 'TestComponent(Out1)|active_hours', - 'TestComponent(Out2)|total_flow_hours', - 'TestComponent(Out2)|flow_rate|lb', - 'TestComponent(Out2)|flow_rate|ub', - 'TestComponent(Out2)|active_hours', - 'TestComponent|status|lb', - 'TestComponent|status|ub', - 'TestComponent|active_hours', - }, - msg='Incorrect constraints', - ) - - upper_bound_flow_rate = outputs[1].relative_maximum - - # Data stays in minimal form (1D array stays 1D) - assert upper_bound_flow_rate.dims == ('time',) - - assert_var_equal( - model['TestComponent(Out2)|flow_rate'], - model.add_variables(lower=0, upper=300 * upper_bound_flow_rate, coords=model.get_coords()), - ) - assert_var_equal(model['TestComponent|status'], model.add_variables(binary=True, coords=model.get_coords())) - assert_var_equal( - model['TestComponent(Out2)|status'], model.add_variables(binary=True, coords=model.get_coords()) - ) - - assert_conequal( - model.constraints['TestComponent(Out2)|flow_rate|lb'], - model.variables['TestComponent(Out2)|flow_rate'] - >= model.variables['TestComponent(Out2)|status'] * 0.3 * 300, - ) - assert_conequal( - model.constraints['TestComponent(Out2)|flow_rate|ub'], - model.variables['TestComponent(Out2)|flow_rate'] - <= model.variables['TestComponent(Out2)|status'] * 300 * upper_bound_flow_rate, - ) - - assert_conequal( - model.constraints['TestComponent|status|lb'], - model.variables['TestComponent|status'] - >= ( - model.variables['TestComponent(In1)|status'] - + model.variables['TestComponent(Out1)|status'] - + model.variables['TestComponent(Out2)|status'] - ) - / (3 + 1e-5), - ) - assert_conequal( - model.constraints['TestComponent|status|ub'], - model.variables['TestComponent|status'] - <= ( - model.variables['TestComponent(In1)|status'] - + model.variables['TestComponent(Out1)|status'] - + model.variables['TestComponent(Out2)|status'] - ) - + 1e-5, - ) - - @pytest.mark.parametrize( - 'in1_previous_flow_rate, out1_previous_flow_rate, out2_previous_flow_rate, previous_on_hours', - [ - (None, None, None, 0), - (np.array([0, 1e-6, 1e-4, 5]), None, None, 2), - (np.array([0, 5, 0, 5]), None, None, 1), - (np.array([0, 5, 0, 0]), 3, 0, 1), - (np.array([0, 0, 2, 0, 4, 5]), [3, 4, 5], None, 4), - ], - ) - def test_previous_states_with_multiple_flows_parameterized( - self, - basic_flow_system_linopy_coords, - coords_config, - in1_previous_flow_rate, - out1_previous_flow_rate, - out2_previous_flow_rate, - previous_on_hours, - ): - """Test that flow model constraints are correctly generated with different previous flow rates and constraint factors.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - ub_out2 = np.linspace(1, 1.5, 10).round(2) - inputs = [ - fx.Flow( - 'In1', - 'Fernwärme', - relative_minimum=np.ones(10) * 0.1, - size=100, - previous_flow_rate=in1_previous_flow_rate, - status_parameters=fx.StatusParameters(min_uptime=3), - ), - ] - outputs = [ - fx.Flow( - 'Out1', 'Gas', relative_minimum=np.ones(10) * 0.2, size=200, previous_flow_rate=out1_previous_flow_rate - ), - fx.Flow( - 'Out2', - 'Gas', - relative_minimum=np.ones(10) * 0.3, - relative_maximum=ub_out2, - size=300, - previous_flow_rate=out2_previous_flow_rate, - ), - ] - comp = flixopt.elements.Component( - 'TestComponent', - inputs=inputs, - outputs=outputs, - status_parameters=fx.StatusParameters(min_uptime=3), - ) - flow_system.add_elements(comp) - create_linopy_model(flow_system) - - # Check if any flow has previous_flow_rate set (determines if initial constraint exists) - has_previous = any( - x is not None for x in [in1_previous_flow_rate, out1_previous_flow_rate, out2_previous_flow_rate] - ) - if has_previous: - assert_conequal( - comp.submodel.constraints['TestComponent|uptime|initial'], - comp.submodel.variables['TestComponent|uptime'].isel(time=0) - == comp.submodel.variables['TestComponent|status'].isel(time=0) * (previous_on_hours + 1), - ) - else: - assert 'TestComponent|uptime|initial' not in comp.submodel.constraints - - -class TestTransmissionModel: - 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', - thermal_efficiency=0.5, - thermal_flow=fx.Flow('Q_th', bus='Wärme lokal'), - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - ) - - transmission = fx.Transmission( - 'Rohr', - relative_losses=0.2, - absolute_losses=20, - in1=fx.Flow( - 'Rohr1', 'Wärme lokal', size=fx.InvestParameters(effects_of_investment_per_size=5, maximum_size=1e6) - ), - out1=fx.Flow('Rohr2', 'Fernwärme', size=1000), - ) - - flow_system.add_elements(transmission, boiler) - - flow_system.optimize(highs_solver) - - # Assertions - assert_almost_equal_numeric( - transmission.in1.submodel.status.status.solution.values, - np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), - 'Status does not work properly', - ) - - assert_almost_equal_numeric( - transmission.in1.submodel.flow_rate.solution.values * 0.8 - 20, - transmission.out1.submodel.flow_rate.solution.values, - 'Losses are not computed correctly', - ) - - def test_transmission_balanced(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', - thermal_efficiency=0.9, - thermal_flow=fx.Flow( - 'Q_th', bus='Fernwärme', size=1000, relative_maximum=np.array([0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) - ), - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - ) - - boiler2 = fx.linear_converters.Boiler( - 'Boiler_backup', - thermal_efficiency=0.4, - thermal_flow=fx.Flow('Q_th', bus='Wärme lokal'), - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - ) - - last2 = fx.Sink( - 'Wärmelast2', - inputs=[ - fx.Flow( - 'Q_th_Last', - bus='Wärme lokal', - size=1, - fixed_relative_profile=flow_system.components['Wärmelast'].inputs[0].fixed_relative_profile - * np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]), - ) - ], - ) - - transmission = fx.Transmission( - 'Rohr', - relative_losses=0.2, - absolute_losses=20, - in1=fx.Flow( - 'Rohr1a', - bus='Wärme lokal', - size=fx.InvestParameters(effects_of_investment_per_size=5, maximum_size=1000), - ), - out1=fx.Flow('Rohr1b', 'Fernwärme', size=1000), - in2=fx.Flow('Rohr2a', 'Fernwärme', size=fx.InvestParameters(maximum_size=1000)), - out2=fx.Flow('Rohr2b', bus='Wärme lokal', size=1000), - balanced=True, - ) - - flow_system.add_elements(transmission, boiler, boiler2, last2) - - flow_system.optimize(highs_solver) - - # Assertions - assert_almost_equal_numeric( - transmission.in1.submodel.status.status.solution.values, - np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), - 'Status does not work properly', - ) - - assert_almost_equal_numeric( - flow_system.model.variables['Rohr(Rohr1b)|flow_rate'].solution.values, - transmission.out1.submodel.flow_rate.solution.values, - 'Flow rate of Rohr__Rohr1b is not correct', - ) - - assert_almost_equal_numeric( - transmission.in1.submodel.flow_rate.solution.values * 0.8 - - np.array([20 if val > 0.1 else 0 for val in transmission.in1.submodel.flow_rate.solution.values]), - transmission.out1.submodel.flow_rate.solution.values, - 'Losses are not computed correctly', - ) - - assert_almost_equal_numeric( - transmission.in1.submodel._investment.size.solution.item(), - transmission.in2.submodel._investment.size.solution.item(), - 'The Investments are not equated correctly', - ) - - def test_transmission_unbalanced(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', - thermal_efficiency=0.9, - thermal_flow=fx.Flow( - 'Q_th', bus='Fernwärme', size=1000, relative_maximum=np.array([0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) - ), - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - ) - - boiler2 = fx.linear_converters.Boiler( - 'Boiler_backup', - thermal_efficiency=0.4, - thermal_flow=fx.Flow('Q_th', bus='Wärme lokal'), - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - ) - - last2 = fx.Sink( - 'Wärmelast2', - inputs=[ - fx.Flow( - 'Q_th_Last', - bus='Wärme lokal', - size=1, - fixed_relative_profile=flow_system.components['Wärmelast'].inputs[0].fixed_relative_profile - * np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]), - ) - ], - ) - - transmission = fx.Transmission( - 'Rohr', - relative_losses=0.2, - absolute_losses=20, - in1=fx.Flow( - 'Rohr1a', - bus='Wärme lokal', - size=fx.InvestParameters(effects_of_investment_per_size=50, maximum_size=1000), - ), - out1=fx.Flow('Rohr1b', 'Fernwärme', size=1000), - in2=fx.Flow( - 'Rohr2a', - 'Fernwärme', - size=fx.InvestParameters( - effects_of_investment_per_size=100, minimum_size=10, maximum_size=1000, mandatory=True - ), - ), - out2=fx.Flow('Rohr2b', bus='Wärme lokal', size=1000), - balanced=False, - ) - - flow_system.add_elements(transmission, boiler, boiler2, last2) - - flow_system.optimize(highs_solver) - - # Assertions - assert_almost_equal_numeric( - transmission.in1.submodel.status.status.solution.values, - np.array([1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), - 'Status does not work properly', - ) - - assert_almost_equal_numeric( - flow_system.model.variables['Rohr(Rohr1b)|flow_rate'].solution.values, - transmission.out1.submodel.flow_rate.solution.values, - 'Flow rate of Rohr__Rohr1b is not correct', - ) - - assert_almost_equal_numeric( - transmission.in1.submodel.flow_rate.solution.values * 0.8 - - np.array([20 if val > 0.1 else 0 for val in transmission.in1.submodel.flow_rate.solution.values]), - transmission.out1.submodel.flow_rate.solution.values, - 'Losses are not computed correctly', - ) - - assert transmission.in1.submodel._investment.size.solution.item() > 11 - - assert_almost_equal_numeric( - transmission.in2.submodel._investment.size.solution.item(), - 10, - 'Sizing does not work properly', - ) diff --git a/tests/deprecated/test_config.py b/tests/deprecated/test_config.py deleted file mode 100644 index 04ed04e25..000000000 --- a/tests/deprecated/test_config.py +++ /dev/null @@ -1,282 +0,0 @@ -"""Tests for the config module.""" - -import logging -import sys - -import pytest - -from flixopt.config import CONFIG, SUCCESS_LEVEL, MultilineFormatter - -logger = logging.getLogger('flixopt') - - -@pytest.mark.xdist_group(name='config_tests') -class TestConfigModule: - """Test the CONFIG class and logging setup.""" - - def setup_method(self): - """Reset CONFIG to defaults before each test.""" - CONFIG.reset() - - def teardown_method(self): - """Clean up after each test.""" - CONFIG.reset() - - def test_config_defaults(self): - """Test that CONFIG has correct default values.""" - assert CONFIG.Modeling.big == 10_000_000 - assert CONFIG.Modeling.epsilon == 1e-5 - assert CONFIG.Solving.mip_gap == 0.01 - assert CONFIG.Solving.time_limit_seconds == 300 - assert CONFIG.config_name == 'flixopt' - - def test_silent_by_default(self, capfd): - """Test that flixopt is silent by default.""" - logger.info('should not appear') - captured = capfd.readouterr() - assert 'should not appear' not in captured.out - - def test_enable_console_logging(self, capfd): - """Test enabling console logging.""" - CONFIG.Logging.enable_console('INFO') - logger.info('test message') - captured = capfd.readouterr() - assert 'test message' in captured.out - - def test_enable_file_logging(self, tmp_path): - """Test enabling file logging.""" - log_file = tmp_path / 'test.log' - CONFIG.Logging.enable_file('INFO', str(log_file)) - logger.info('test file message') - - assert log_file.exists() - assert 'test file message' in log_file.read_text() - - def test_console_and_file_together(self, tmp_path, capfd): - """Test logging to both console and file.""" - log_file = tmp_path / 'test.log' - CONFIG.Logging.enable_console('INFO') - CONFIG.Logging.enable_file('INFO', str(log_file)) - - logger.info('test both') - - # Check both outputs - assert 'test both' in capfd.readouterr().out - assert 'test both' in log_file.read_text() - - def test_disable_logging(self, capfd): - """Test disabling logging.""" - CONFIG.Logging.enable_console('INFO') - CONFIG.Logging.disable() - - logger.info('should not appear') - assert 'should not appear' not in capfd.readouterr().out - - def test_custom_success_level(self, capfd): - """Test custom SUCCESS log level.""" - CONFIG.Logging.enable_console('INFO') - logger.log(SUCCESS_LEVEL, 'success message') - assert 'success message' in capfd.readouterr().out - - def test_success_level_as_minimum(self, capfd): - """Test setting SUCCESS as minimum log level.""" - CONFIG.Logging.enable_console('SUCCESS') - - # INFO should not appear (level 20 < 25) - logger.info('info message') - assert 'info message' not in capfd.readouterr().out - - # SUCCESS should appear (level 25) - logger.log(SUCCESS_LEVEL, 'success message') - assert 'success message' in capfd.readouterr().out - - # WARNING should appear (level 30 > 25) - logger.warning('warning message') - assert 'warning message' in capfd.readouterr().out - - def test_success_level_numeric(self, capfd): - """Test setting SUCCESS level using numeric value.""" - CONFIG.Logging.enable_console(25) - logger.log(25, 'success with numeric level') - assert 'success with numeric level' in capfd.readouterr().out - - def test_success_level_constant(self, capfd): - """Test using SUCCESS_LEVEL constant.""" - CONFIG.Logging.enable_console(SUCCESS_LEVEL) - logger.log(SUCCESS_LEVEL, 'success with constant') - assert 'success with constant' in capfd.readouterr().out - assert SUCCESS_LEVEL == 25 - - def test_success_file_logging(self, tmp_path): - """Test SUCCESS level with file logging.""" - log_file = tmp_path / 'test_success.log' - CONFIG.Logging.enable_file('SUCCESS', str(log_file)) - - # INFO should not be logged - logger.info('info not logged') - - # SUCCESS should be logged - logger.log(SUCCESS_LEVEL, 'success logged to file') - - content = log_file.read_text() - assert 'info not logged' not in content - assert 'success logged to file' in content - - def test_success_color_customization(self, capfd): - """Test customizing SUCCESS level color.""" - CONFIG.Logging.enable_console('SUCCESS') - - # Customize SUCCESS color - CONFIG.Logging.set_colors( - { - 'SUCCESS': 'bold_green,bg_black', - 'WARNING': 'yellow', - } - ) - - logger.log(SUCCESS_LEVEL, 'colored success') - output = capfd.readouterr().out - assert 'colored success' in output - - def test_multiline_formatting(self): - """Test that multi-line messages get box borders.""" - formatter = MultilineFormatter() - record = logging.LogRecord('test', logging.INFO, '', 1, 'Line 1\nLine 2\nLine 3', (), None) - formatted = formatter.format(record) - assert '┌─' in formatted - assert '└─' in formatted - - def test_console_stderr(self, capfd): - """Test logging to stderr.""" - CONFIG.Logging.enable_console('INFO', stream=sys.stderr) - logger.info('stderr test') - assert 'stderr test' in capfd.readouterr().err - - def test_non_colored_output(self, capfd): - """Test non-colored console output.""" - CONFIG.Logging.enable_console('INFO', colored=False) - logger.info('plain text') - assert 'plain text' in capfd.readouterr().out - - def test_preset_exploring(self, capfd): - """Test exploring preset.""" - CONFIG.exploring() - logger.info('exploring') - assert 'exploring' in capfd.readouterr().out - assert CONFIG.Solving.log_to_console is False - - def test_preset_debug(self, capfd): - """Test debug preset.""" - CONFIG.debug() - logger.debug('debug') - assert 'debug' in capfd.readouterr().out - - def test_preset_production(self, tmp_path): - """Test production preset.""" - log_file = tmp_path / 'prod.log' - CONFIG.production(str(log_file)) - logger.info('production') - - assert log_file.exists() - assert 'production' in log_file.read_text() - assert CONFIG.Plotting.default_show is False - - def test_preset_silent(self, capfd): - """Test silent preset.""" - CONFIG.silent() - logger.info('should not appear') - assert 'should not appear' not in capfd.readouterr().out - - def test_config_reset(self): - """Test that reset() restores defaults and disables logging.""" - CONFIG.Modeling.big = 99999999 - CONFIG.Logging.enable_console('DEBUG') - - CONFIG.reset() - - assert CONFIG.Modeling.big == 10_000_000 - assert len(logger.handlers) == 0 - - def test_config_to_dict(self): - """Test converting CONFIG to dictionary.""" - config_dict = CONFIG.to_dict() - assert config_dict['modeling']['big'] == 10_000_000 - assert config_dict['solving']['mip_gap'] == 0.01 - - def test_attribute_modification(self): - """Test modifying config attributes.""" - CONFIG.Modeling.big = 12345678 - CONFIG.Solving.mip_gap = 0.001 - - assert CONFIG.Modeling.big == 12345678 - assert CONFIG.Solving.mip_gap == 0.001 - - def test_exception_logging(self, capfd): - """Test that exceptions are properly logged with tracebacks.""" - CONFIG.Logging.enable_console('INFO') - - try: - raise ValueError('Test exception') - except ValueError: - logger.exception('An error occurred') - - captured = capfd.readouterr().out - assert 'An error occurred' in captured - assert 'ValueError' in captured - assert 'Test exception' in captured - assert 'Traceback' in captured - - def test_exception_logging_non_colored(self, capfd): - """Test that exceptions are properly logged with tracebacks in non-colored mode.""" - CONFIG.Logging.enable_console('INFO', colored=False) - - try: - raise ValueError('Test exception non-colored') - except ValueError: - logger.exception('An error occurred') - - captured = capfd.readouterr().out - assert 'An error occurred' in captured - assert 'ValueError: Test exception non-colored' in captured - assert 'Traceback' in captured - - def test_enable_file_preserves_custom_handlers(self, tmp_path, capfd): - """Test that enable_file preserves custom non-file handlers.""" - # Add a custom console handler first - CONFIG.Logging.enable_console('INFO') - logger.info('console test') - assert 'console test' in capfd.readouterr().out - - # Now add file logging - should keep the console handler - log_file = tmp_path / 'test.log' - CONFIG.Logging.enable_file('INFO', str(log_file)) - - logger.info('both outputs') - - # Check console still works - console_output = capfd.readouterr().out - assert 'both outputs' in console_output - - # Check file was created and has the message - assert log_file.exists() - assert 'both outputs' in log_file.read_text() - - def test_enable_file_removes_duplicate_file_handlers(self, tmp_path): - """Test that enable_file removes existing file handlers to avoid duplicates.""" - log_file = tmp_path / 'test.log' - - # Enable file logging twice - CONFIG.Logging.enable_file('INFO', str(log_file)) - CONFIG.Logging.enable_file('INFO', str(log_file)) - - logger.info('duplicate test') - - # Count file handlers - should only be 1 - from logging.handlers import RotatingFileHandler - - file_handlers = [h for h in logger.handlers if isinstance(h, (logging.FileHandler, RotatingFileHandler))] - assert len(file_handlers) == 1 - - # Message should appear only once in the file - log_content = log_file.read_text() - assert log_content.count('duplicate test') == 1 diff --git a/tests/deprecated/test_cycle_detection.py b/tests/deprecated/test_cycle_detection.py deleted file mode 100644 index 753a9a3e5..000000000 --- a/tests/deprecated/test_cycle_detection.py +++ /dev/null @@ -1,200 +0,0 @@ -import pytest - -from flixopt.effects import detect_cycles - - -def test_empty_graph(): - """Test that an empty graph has no cycles.""" - assert detect_cycles({}) == [] - - -def test_single_node(): - """Test that a graph with a single node and no edges has no cycles.""" - assert detect_cycles({'A': []}) == [] - - -def test_self_loop(): - """Test that a graph with a self-loop has a cycle.""" - cycles = detect_cycles({'A': ['A']}) - assert len(cycles) == 1 - assert cycles[0] == ['A', 'A'] - - -def test_simple_cycle(): - """Test that a simple cycle is detected.""" - graph = {'A': ['B'], 'B': ['C'], 'C': ['A']} - cycles = detect_cycles(graph) - assert len(cycles) == 1 - assert cycles[0] == ['A', 'B', 'C', 'A'] or cycles[0] == ['B', 'C', 'A', 'B'] or cycles[0] == ['C', 'A', 'B', 'C'] - - -def test_no_cycles(): - """Test that a directed acyclic graph has no cycles.""" - graph = {'A': ['B', 'C'], 'B': ['D', 'E'], 'C': ['F'], 'D': [], 'E': [], 'F': []} - assert detect_cycles(graph) == [] - - -def test_multiple_cycles(): - """Test that a graph with multiple cycles is detected.""" - graph = {'A': ['B', 'D'], 'B': ['C'], 'C': ['A'], 'D': ['E'], 'E': ['D']} - cycles = detect_cycles(graph) - assert len(cycles) == 2 - - # Check that both cycles are detected (order might vary) - cycle_strings = [','.join(cycle) for cycle in cycles] - assert ( - any('A,B,C,A' in s for s in cycle_strings) - or any('B,C,A,B' in s for s in cycle_strings) - or any('C,A,B,C' in s for s in cycle_strings) - ) - assert any('D,E,D' in s for s in cycle_strings) or any('E,D,E' in s for s in cycle_strings) - - -def test_hidden_cycle(): - """Test that a cycle hidden deep in the graph is detected.""" - graph = { - 'A': ['B', 'C'], - 'B': ['D'], - 'C': ['E'], - 'D': ['F'], - 'E': ['G'], - 'F': ['H'], - 'G': ['I'], - 'H': ['J'], - 'I': ['K'], - 'J': ['L'], - 'K': ['M'], - 'L': ['N'], - 'M': ['N'], - 'N': ['O'], - 'O': ['P'], - 'P': ['Q'], - 'Q': ['O'], # Hidden cycle O->P->Q->O - } - cycles = detect_cycles(graph) - assert len(cycles) == 1 - - # Check that the O-P-Q cycle is detected - cycle = cycles[0] - assert 'O' in cycle and 'P' in cycle and 'Q' in cycle - - # Check that they appear in the correct order - o_index = cycle.index('O') - p_index = cycle.index('P') - q_index = cycle.index('Q') - - # Check the cycle order is correct (allowing for different starting points) - cycle_len = len(cycle) - assert ( - (p_index == (o_index + 1) % cycle_len and q_index == (p_index + 1) % cycle_len) - or (q_index == (o_index + 1) % cycle_len and p_index == (q_index + 1) % cycle_len) - or (o_index == (p_index + 1) % cycle_len and q_index == (o_index + 1) % cycle_len) - ) - - -def test_disconnected_graph(): - """Test with a disconnected graph.""" - graph = {'A': ['B'], 'B': ['C'], 'C': [], 'D': ['E'], 'E': ['F'], 'F': []} - assert detect_cycles(graph) == [] - - -def test_disconnected_graph_with_cycle(): - """Test with a disconnected graph containing a cycle in one component.""" - graph = { - 'A': ['B'], - 'B': ['C'], - 'C': [], - 'D': ['E'], - 'E': ['F'], - 'F': ['D'], # Cycle in D->E->F->D - } - cycles = detect_cycles(graph) - assert len(cycles) == 1 - - # Check that the D-E-F cycle is detected - cycle = cycles[0] - assert 'D' in cycle and 'E' in cycle and 'F' in cycle - - # Check if they appear in the correct order - d_index = cycle.index('D') - e_index = cycle.index('E') - f_index = cycle.index('F') - - # Check the cycle order is correct (allowing for different starting points) - cycle_len = len(cycle) - assert ( - (e_index == (d_index + 1) % cycle_len and f_index == (e_index + 1) % cycle_len) - or (f_index == (d_index + 1) % cycle_len and e_index == (f_index + 1) % cycle_len) - or (d_index == (e_index + 1) % cycle_len and f_index == (d_index + 1) % cycle_len) - ) - - -def test_complex_dag(): - """Test with a complex directed acyclic graph.""" - graph = { - 'A': ['B', 'C', 'D'], - 'B': ['E', 'F'], - 'C': ['E', 'G'], - 'D': ['G', 'H'], - 'E': ['I', 'J'], - 'F': ['J', 'K'], - 'G': ['K', 'L'], - 'H': ['L', 'M'], - 'I': ['N'], - 'J': ['N', 'O'], - 'K': ['O', 'P'], - 'L': ['P', 'Q'], - 'M': ['Q'], - 'N': ['R'], - 'O': ['R', 'S'], - 'P': ['S'], - 'Q': ['S'], - 'R': [], - 'S': [], - } - assert detect_cycles(graph) == [] - - -def test_missing_node_in_connections(): - """Test behavior when a node referenced in edges doesn't have its own key.""" - graph = { - 'A': ['B', 'C'], - 'B': ['D'], - # C and D don't have their own entries - } - assert detect_cycles(graph) == [] - - -def test_non_string_keys(): - """Test with non-string keys to ensure the algorithm is generic.""" - graph = {1: [2, 3], 2: [4], 3: [4], 4: []} - assert detect_cycles(graph) == [] - - graph_with_cycle = {1: [2], 2: [3], 3: [1]} - cycles = detect_cycles(graph_with_cycle) - assert len(cycles) == 1 - assert cycles[0] == [1, 2, 3, 1] or cycles[0] == [2, 3, 1, 2] or cycles[0] == [3, 1, 2, 3] - - -def test_complex_network_with_many_nodes(): - """Test with a large network to check performance and correctness.""" - graph = {} - # Create a large DAG - for i in range(100): - # Connect each node to the next few nodes - graph[i] = [j for j in range(i + 1, min(i + 5, 100))] - - # No cycles in this arrangement - assert detect_cycles(graph) == [] - - # Add a single back edge to create a cycle - graph[99] = [0] # This creates a cycle - cycles = detect_cycles(graph) - assert len(cycles) >= 1 - # The cycle might include many nodes, but must contain both 0 and 99 - any_cycle_has_both = any(0 in cycle and 99 in cycle for cycle in cycles) - assert any_cycle_has_both - - -if __name__ == '__main__': - pytest.main(['-v']) diff --git a/tests/deprecated/test_effect.py b/tests/deprecated/test_effect.py deleted file mode 100644 index 1cf625c1b..000000000 --- a/tests/deprecated/test_effect.py +++ /dev/null @@ -1,371 +0,0 @@ -import numpy as np -import pytest -import xarray as xr - -import flixopt as fx - -from .conftest import ( - assert_conequal, - assert_sets_equal, - assert_var_equal, - create_linopy_model, - create_optimization_and_solve, -) - - -class TestEffectModel: - """Test the FlowModel class.""" - - def test_minimal(self, basic_flow_system_linopy_coords, coords_config): - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - effect = fx.Effect('Effect1', '€', 'Testing Effect') - - flow_system.add_elements(effect) - model = create_linopy_model(flow_system) - - assert_sets_equal( - set(effect.submodel.variables), - { - 'Effect1(periodic)', - 'Effect1(temporal)', - 'Effect1(temporal)|per_timestep', - 'Effect1', - }, - msg='Incorrect variables', - ) - - assert_sets_equal( - set(effect.submodel.constraints), - { - 'Effect1(periodic)', - 'Effect1(temporal)', - 'Effect1(temporal)|per_timestep', - 'Effect1', - }, - msg='Incorrect constraints', - ) - - assert_var_equal( - model.variables['Effect1'], model.add_variables(coords=model.get_coords(['period', 'scenario'])) - ) - assert_var_equal( - model.variables['Effect1(periodic)'], model.add_variables(coords=model.get_coords(['period', 'scenario'])) - ) - assert_var_equal( - model.variables['Effect1(temporal)'], - model.add_variables(coords=model.get_coords(['period', 'scenario'])), - ) - assert_var_equal( - model.variables['Effect1(temporal)|per_timestep'], model.add_variables(coords=model.get_coords()) - ) - - assert_conequal( - model.constraints['Effect1'], - model.variables['Effect1'] == model.variables['Effect1(temporal)'] + model.variables['Effect1(periodic)'], - ) - # In minimal/bounds tests with no contributing components, periodic totals should be zero - assert_conequal(model.constraints['Effect1(periodic)'], model.variables['Effect1(periodic)'] == 0) - assert_conequal( - model.constraints['Effect1(temporal)'], - model.variables['Effect1(temporal)'] == model.variables['Effect1(temporal)|per_timestep'].sum('time'), - ) - assert_conequal( - model.constraints['Effect1(temporal)|per_timestep'], - model.variables['Effect1(temporal)|per_timestep'] == 0, - ) - - def test_bounds(self, basic_flow_system_linopy_coords, coords_config): - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - effect = fx.Effect( - 'Effect1', - '€', - 'Testing Effect', - minimum_temporal=1.0, - maximum_temporal=1.1, - minimum_periodic=2.0, - maximum_periodic=2.1, - minimum_total=3.0, - maximum_total=3.1, - minimum_per_hour=4.0, - maximum_per_hour=4.1, - ) - - flow_system.add_elements(effect) - model = create_linopy_model(flow_system) - - assert_sets_equal( - set(effect.submodel.variables), - { - 'Effect1(periodic)', - 'Effect1(temporal)', - 'Effect1(temporal)|per_timestep', - 'Effect1', - }, - msg='Incorrect variables', - ) - - assert_sets_equal( - set(effect.submodel.constraints), - { - 'Effect1(periodic)', - 'Effect1(temporal)', - 'Effect1(temporal)|per_timestep', - 'Effect1', - }, - msg='Incorrect constraints', - ) - - assert_var_equal( - model.variables['Effect1'], - model.add_variables(lower=3.0, upper=3.1, coords=model.get_coords(['period', 'scenario'])), - ) - assert_var_equal( - model.variables['Effect1(periodic)'], - model.add_variables(lower=2.0, upper=2.1, coords=model.get_coords(['period', 'scenario'])), - ) - assert_var_equal( - model.variables['Effect1(temporal)'], - model.add_variables(lower=1.0, upper=1.1, coords=model.get_coords(['period', 'scenario'])), - ) - assert_var_equal( - model.variables['Effect1(temporal)|per_timestep'], - model.add_variables( - lower=4.0 * model.timestep_duration, - upper=4.1 * model.timestep_duration, - coords=model.get_coords(['time', 'period', 'scenario']), - ), - ) - - assert_conequal( - model.constraints['Effect1'], - model.variables['Effect1'] == model.variables['Effect1(temporal)'] + model.variables['Effect1(periodic)'], - ) - # In minimal/bounds tests with no contributing components, periodic totals should be zero - assert_conequal(model.constraints['Effect1(periodic)'], model.variables['Effect1(periodic)'] == 0) - assert_conequal( - model.constraints['Effect1(temporal)'], - model.variables['Effect1(temporal)'] == model.variables['Effect1(temporal)|per_timestep'].sum('time'), - ) - assert_conequal( - model.constraints['Effect1(temporal)|per_timestep'], - model.variables['Effect1(temporal)|per_timestep'] == 0, - ) - - def test_shares(self, basic_flow_system_linopy_coords, coords_config): - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - effect1 = fx.Effect( - 'Effect1', - '€', - 'Testing Effect', - ) - effect2 = fx.Effect( - 'Effect2', - '€', - 'Testing Effect', - share_from_temporal={'Effect1': 1.1}, - share_from_periodic={'Effect1': 2.1}, - ) - effect3 = fx.Effect( - 'Effect3', - '€', - 'Testing Effect', - share_from_temporal={'Effect1': 1.2}, - share_from_periodic={'Effect1': 2.2}, - ) - flow_system.add_elements(effect1, effect2, effect3) - model = create_linopy_model(flow_system) - - assert_sets_equal( - set(effect2.submodel.variables), - { - 'Effect2(periodic)', - 'Effect2(temporal)', - 'Effect2(temporal)|per_timestep', - 'Effect2', - 'Effect1(periodic)->Effect2(periodic)', - 'Effect1(temporal)->Effect2(temporal)', - }, - msg='Incorrect variables for effect2', - ) - - assert_sets_equal( - set(effect2.submodel.constraints), - { - 'Effect2(periodic)', - 'Effect2(temporal)', - 'Effect2(temporal)|per_timestep', - 'Effect2', - 'Effect1(periodic)->Effect2(periodic)', - 'Effect1(temporal)->Effect2(temporal)', - }, - msg='Incorrect constraints for effect2', - ) - - assert_conequal( - model.constraints['Effect2(periodic)'], - model.variables['Effect2(periodic)'] == model.variables['Effect1(periodic)->Effect2(periodic)'], - ) - - assert_conequal( - model.constraints['Effect2(temporal)|per_timestep'], - model.variables['Effect2(temporal)|per_timestep'] - == model.variables['Effect1(temporal)->Effect2(temporal)'], - ) - - assert_conequal( - model.constraints['Effect1(temporal)->Effect2(temporal)'], - model.variables['Effect1(temporal)->Effect2(temporal)'] - == model.variables['Effect1(temporal)|per_timestep'] * 1.1, - ) - - assert_conequal( - model.constraints['Effect1(periodic)->Effect2(periodic)'], - model.variables['Effect1(periodic)->Effect2(periodic)'] == model.variables['Effect1(periodic)'] * 2.1, - ) - - -class TestEffectResults: - @pytest.mark.filterwarnings('ignore::DeprecationWarning') - def test_shares(self, basic_flow_system_linopy_coords, coords_config): - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - effect1 = fx.Effect('Effect1', '€', 'Testing Effect', share_from_temporal={'costs': 0.5}) - effect2 = fx.Effect( - 'Effect2', - '€', - 'Testing Effect', - share_from_temporal={'Effect1': 1.1}, - share_from_periodic={'Effect1': 2.1}, - ) - effect3 = fx.Effect( - 'Effect3', - '€', - 'Testing Effect', - share_from_temporal={'Effect1': 1.2, 'Effect2': 5}, - share_from_periodic={'Effect1': 2.2}, - ) - flow_system.add_elements( - effect1, - effect2, - effect3, - fx.linear_converters.Boiler( - 'Boiler', - thermal_efficiency=0.5, - thermal_flow=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=fx.InvestParameters( - effects_of_investment_per_size=10, minimum_size=20, maximum_size=200, mandatory=True - ), - ), - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - ), - ) - - results = create_optimization_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 60), 'Sim1').results - - effect_share_factors = { - 'temporal': { - ('costs', 'Effect1'): 0.5, - ('costs', 'Effect2'): 0.5 * 1.1, - ('costs', 'Effect3'): 0.5 * 1.1 * 5 + 0.5 * 1.2, # This is where the issue lies - ('Effect1', 'Effect2'): 1.1, - ('Effect1', 'Effect3'): 1.2 + 1.1 * 5, - ('Effect2', 'Effect3'): 5, - }, - 'periodic': { - ('Effect1', 'Effect2'): 2.1, - ('Effect1', 'Effect3'): 2.2, - }, - } - for key, value in effect_share_factors['temporal'].items(): - np.testing.assert_allclose(results.effect_share_factors['temporal'][key].values, value) - - for key, value in effect_share_factors['periodic'].items(): - np.testing.assert_allclose(results.effect_share_factors['periodic'][key].values, value) - - xr.testing.assert_allclose( - results.effects_per_component['temporal'].sum('component').sel(effect='costs', drop=True), - results.solution['costs(temporal)|per_timestep'].fillna(0), - ) - - xr.testing.assert_allclose( - results.effects_per_component['temporal'].sum('component').sel(effect='Effect1', drop=True), - results.solution['Effect1(temporal)|per_timestep'].fillna(0), - ) - - xr.testing.assert_allclose( - results.effects_per_component['temporal'].sum('component').sel(effect='Effect2', drop=True), - results.solution['Effect2(temporal)|per_timestep'].fillna(0), - ) - - xr.testing.assert_allclose( - results.effects_per_component['temporal'].sum('component').sel(effect='Effect3', drop=True), - results.solution['Effect3(temporal)|per_timestep'].fillna(0), - ) - - # periodic mode checks - xr.testing.assert_allclose( - results.effects_per_component['periodic'].sum('component').sel(effect='costs', drop=True), - results.solution['costs(periodic)'], - ) - - xr.testing.assert_allclose( - results.effects_per_component['periodic'].sum('component').sel(effect='Effect1', drop=True), - results.solution['Effect1(periodic)'], - ) - - xr.testing.assert_allclose( - results.effects_per_component['periodic'].sum('component').sel(effect='Effect2', drop=True), - results.solution['Effect2(periodic)'], - ) - - xr.testing.assert_allclose( - results.effects_per_component['periodic'].sum('component').sel(effect='Effect3', drop=True), - results.solution['Effect3(periodic)'], - ) - - # Total mode checks - xr.testing.assert_allclose( - results.effects_per_component['total'].sum('component').sel(effect='costs', drop=True), - results.solution['costs'], - ) - - xr.testing.assert_allclose( - results.effects_per_component['total'].sum('component').sel(effect='Effect1', drop=True), - results.solution['Effect1'], - ) - - xr.testing.assert_allclose( - results.effects_per_component['total'].sum('component').sel(effect='Effect2', drop=True), - results.solution['Effect2'], - ) - - xr.testing.assert_allclose( - results.effects_per_component['total'].sum('component').sel(effect='Effect3', drop=True), - results.solution['Effect3'], - ) - - -class TestPenaltyAsObjective: - """Test that Penalty cannot be set as the objective effect.""" - - def test_penalty_cannot_be_created_as_objective(self): - """Test that creating a Penalty effect with is_objective=True raises ValueError.""" - import pytest - - with pytest.raises(ValueError, match='Penalty.*cannot be set as the objective'): - fx.Effect('Penalty', '€', 'Test Penalty', is_objective=True) - - def test_penalty_cannot_be_set_as_objective_via_setter(self): - """Test that setting Penalty as objective via setter raises ValueError.""" - import pandas as pd - import pytest - - # Create a fresh flow system without pre-existing objective - flow_system = fx.FlowSystem(timesteps=pd.date_range('2020-01-01', periods=10, freq='h')) - penalty_effect = fx.Effect('Penalty', '€', 'Test Penalty', is_objective=False) - - flow_system.add_elements(penalty_effect) - - with pytest.raises(ValueError, match='Penalty.*cannot be set as the objective'): - flow_system.effects.objective_effect = penalty_effect diff --git a/tests/deprecated/test_effects_shares_summation.py b/tests/deprecated/test_effects_shares_summation.py deleted file mode 100644 index 312934732..000000000 --- a/tests/deprecated/test_effects_shares_summation.py +++ /dev/null @@ -1,225 +0,0 @@ -import pytest -import xarray as xr - -from flixopt.effects import calculate_all_conversion_paths - - -def test_direct_conversions(): - """Test direct conversions with simple scalar values.""" - conversion_dict = {'A': {'B': xr.DataArray(2.0)}, 'B': {'C': xr.DataArray(3.0)}} - - result = calculate_all_conversion_paths(conversion_dict) - - # Check direct conversions - assert ('A', 'B') in result - assert ('B', 'C') in result - assert result[('A', 'B')].item() == 2.0 - assert result[('B', 'C')].item() == 3.0 - - # Check indirect conversion - assert ('A', 'C') in result - assert result[('A', 'C')].item() == 6.0 # 2.0 * 3.0 - - -def test_multiple_paths(): - """Test multiple paths between nodes that should be summed.""" - conversion_dict = { - 'A': {'B': xr.DataArray(2.0), 'C': xr.DataArray(3.0)}, - 'B': {'D': xr.DataArray(4.0)}, - 'C': {'D': xr.DataArray(5.0)}, - } - - result = calculate_all_conversion_paths(conversion_dict) - - # A to D should sum two paths: A->B->D (2*4=8) and A->C->D (3*5=15) - assert ('A', 'D') in result - assert result[('A', 'D')].item() == 8.0 + 15.0 - - -def test_xarray_conversions(): - """Test with xarray DataArrays that have dimensions.""" - # Create DataArrays with a time dimension - time_points = [1, 2, 3] - a_to_b = xr.DataArray([2.0, 2.1, 2.2], dims='time', coords={'time': time_points}) - b_to_c = xr.DataArray([3.0, 3.1, 3.2], dims='time', coords={'time': time_points}) - - conversion_dict = {'A': {'B': a_to_b}, 'B': {'C': b_to_c}} - - result = calculate_all_conversion_paths(conversion_dict) - - # Check indirect conversion preserves dimensions - assert ('A', 'C') in result - assert result[('A', 'C')].dims == ('time',) - - # Check values at each time point - for i, t in enumerate(time_points): - expected = a_to_b.values[i] * b_to_c.values[i] - assert pytest.approx(result[('A', 'C')].sel(time=t).item()) == expected - - -def test_long_paths(): - """Test with longer paths (more than one intermediate node).""" - conversion_dict = { - 'A': {'B': xr.DataArray(2.0)}, - 'B': {'C': xr.DataArray(3.0)}, - 'C': {'D': xr.DataArray(4.0)}, - 'D': {'E': xr.DataArray(5.0)}, - } - - result = calculate_all_conversion_paths(conversion_dict) - - # Check the full path A->B->C->D->E - assert ('A', 'E') in result - expected = 2.0 * 3.0 * 4.0 * 5.0 # 120.0 - assert result[('A', 'E')].item() == expected - - -def test_diamond_paths(): - """Test with a diamond shape graph with multiple paths to the same destination.""" - conversion_dict = { - 'A': {'B': xr.DataArray(2.0), 'C': xr.DataArray(3.0)}, - 'B': {'D': xr.DataArray(4.0)}, - 'C': {'D': xr.DataArray(5.0)}, - 'D': {'E': xr.DataArray(6.0)}, - } - - result = calculate_all_conversion_paths(conversion_dict) - - # A to E should go through both paths: - # A->B->D->E (2*4*6=48) and A->C->D->E (3*5*6=90) - assert ('A', 'E') in result - expected = 48.0 + 90.0 # 138.0 - assert result[('A', 'E')].item() == expected - - -def test_effect_shares_example(): - """Test the specific example from the effects share factors test.""" - # Create the conversion dictionary based on test example - conversion_dict = { - 'costs': {'Effect1': xr.DataArray(0.5)}, - 'Effect1': {'Effect2': xr.DataArray(1.1), 'Effect3': xr.DataArray(1.2)}, - 'Effect2': {'Effect3': xr.DataArray(5.0)}, - } - - result = calculate_all_conversion_paths(conversion_dict) - - # Test direct paths - assert result[('costs', 'Effect1')].item() == 0.5 - assert result[('Effect1', 'Effect2')].item() == 1.1 - assert result[('Effect2', 'Effect3')].item() == 5.0 - - # Test indirect paths - # costs -> Effect2 = costs -> Effect1 -> Effect2 = 0.5 * 1.1 - assert result[('costs', 'Effect2')].item() == 0.5 * 1.1 - - # costs -> Effect3 has two paths: - # 1. costs -> Effect1 -> Effect3 = 0.5 * 1.2 = 0.6 - # 2. costs -> Effect1 -> Effect2 -> Effect3 = 0.5 * 1.1 * 5 = 2.75 - # Total = 0.6 + 2.75 = 3.35 - assert result[('costs', 'Effect3')].item() == 0.5 * 1.2 + 0.5 * 1.1 * 5 - - # Effect1 -> Effect3 has two paths: - # 1. Effect1 -> Effect2 -> Effect3 = 1.1 * 5.0 = 5.5 - # 2. Effect1 -> Effect3 = 1.2 - # Total = 0.6 + 2.75 = 3.35 - assert result[('Effect1', 'Effect3')].item() == 1.2 + 1.1 * 5.0 - - -def test_empty_conversion_dict(): - """Test with an empty conversion dictionary.""" - result = calculate_all_conversion_paths({}) - assert len(result) == 0 - - -def test_no_indirect_paths(): - """Test with a dictionary that has no indirect paths.""" - conversion_dict = {'A': {'B': xr.DataArray(2.0)}, 'C': {'D': xr.DataArray(3.0)}} - - result = calculate_all_conversion_paths(conversion_dict) - - # Only direct paths should exist - assert len(result) == 2 - assert ('A', 'B') in result - assert ('C', 'D') in result - assert result[('A', 'B')].item() == 2.0 - assert result[('C', 'D')].item() == 3.0 - - -def test_complex_network(): - """Test with a complex network of many nodes and multiple paths, without circular references.""" - # Create a directed acyclic graph with many nodes - # Structure resembles a layered network with multiple paths - conversion_dict = { - 'A': {'B': xr.DataArray(1.5), 'C': xr.DataArray(2.0), 'D': xr.DataArray(0.5)}, - 'B': {'E': xr.DataArray(3.0), 'F': xr.DataArray(1.2)}, - 'C': {'E': xr.DataArray(0.8), 'G': xr.DataArray(2.5)}, - 'D': {'G': xr.DataArray(1.8), 'H': xr.DataArray(3.2)}, - 'E': {'I': xr.DataArray(0.7), 'J': xr.DataArray(1.4)}, - 'F': {'J': xr.DataArray(2.2), 'K': xr.DataArray(0.9)}, - 'G': {'K': xr.DataArray(1.6), 'L': xr.DataArray(2.8)}, - 'H': {'L': xr.DataArray(0.4), 'M': xr.DataArray(1.1)}, - 'I': {'N': xr.DataArray(2.3)}, - 'J': {'N': xr.DataArray(1.9), 'O': xr.DataArray(0.6)}, - 'K': {'O': xr.DataArray(3.5), 'P': xr.DataArray(1.3)}, - 'L': {'P': xr.DataArray(2.7), 'Q': xr.DataArray(0.8)}, - 'M': {'Q': xr.DataArray(2.1)}, - 'N': {'R': xr.DataArray(1.7)}, - 'O': {'R': xr.DataArray(2.9), 'S': xr.DataArray(1.0)}, - 'P': {'S': xr.DataArray(2.4)}, - 'Q': {'S': xr.DataArray(1.5)}, - } - - result = calculate_all_conversion_paths(conversion_dict) - - # Check some direct paths - assert result[('A', 'B')].item() == 1.5 - assert result[('D', 'H')].item() == 3.2 - assert result[('G', 'L')].item() == 2.8 - - # Check some two-step paths - assert result[('A', 'E')].item() == 1.5 * 3.0 + 2.0 * 0.8 # A->B->E + A->C->E - assert result[('B', 'J')].item() == 3.0 * 1.4 + 1.2 * 2.2 # B->E->J + B->F->J - - # Check some three-step paths - # A->B->E->I - # A->C->E->I - expected_a_to_i = 1.5 * 3.0 * 0.7 + 2.0 * 0.8 * 0.7 - assert pytest.approx(result[('A', 'I')].item()) == expected_a_to_i - - # Check some four-step paths - # A->B->E->I->N - # A->C->E->I->N - expected_a_to_n = 1.5 * 3.0 * 0.7 * 2.3 + 2.0 * 0.8 * 0.7 * 2.3 - expected_a_to_n += 1.5 * 3.0 * 1.4 * 1.9 + 2.0 * 0.8 * 1.4 * 1.9 # A->B->E->J->N + A->C->E->J->N - expected_a_to_n += 1.5 * 1.2 * 2.2 * 1.9 # A->B->F->J->N - assert pytest.approx(result[('A', 'N')].item()) == expected_a_to_n - - # Check a very long path from A to S - # This should include: - # A->B->E->J->O->S - # A->B->F->K->O->S - # A->C->E->J->O->S - # A->C->G->K->O->S - # A->D->G->K->O->S - # A->D->H->L->P->S - # A->D->H->M->Q->S - # And many more - assert ('A', 'S') in result - - # There are many paths to R from A - check their existence - assert ('A', 'R') in result - - # Check that there's no direct path from A to R - # But there should be indirect paths - assert ('A', 'R') in result - assert 'A' not in conversion_dict.get('R', {}) - - # Count the number of paths calculated to verify algorithm explored all connections - # In a DAG with 19 nodes (A through S), the maximum number of pairs is 19*18 = 342 - # But we won't have all possible connections due to the structure - # Just verify we have a reasonable number - assert len(result) > 50 - - -if __name__ == '__main__': - pytest.main() diff --git a/tests/deprecated/test_examples.py b/tests/deprecated/test_examples.py deleted file mode 100644 index 995ce3004..000000000 --- a/tests/deprecated/test_examples.py +++ /dev/null @@ -1,94 +0,0 @@ -import os -import subprocess -import sys -from contextlib import contextmanager -from pathlib import Path - -import pytest - -# Path to the examples directory (now in tests/deprecated/examples/) -EXAMPLES_DIR = Path(__file__).parent / 'examples' - -# Examples that have dependencies and must run in sequence -DEPENDENT_EXAMPLES = ( - '02_Complex/complex_example.py', - '02_Complex/complex_example_results.py', -) - - -@contextmanager -def working_directory(path): - """Context manager for changing the working directory.""" - original_cwd = os.getcwd() - try: - os.chdir(path) - yield - finally: - os.chdir(original_cwd) - - -@pytest.mark.parametrize( - 'example_script', - sorted( - [p for p in EXAMPLES_DIR.rglob('*.py') if str(p.relative_to(EXAMPLES_DIR)) not in DEPENDENT_EXAMPLES], - key=lambda path: (str(path.parent), path.name), - ), - ids=lambda path: str(path.relative_to(EXAMPLES_DIR)).replace(os.sep, '/'), -) -@pytest.mark.examples -def test_independent_examples(example_script): - """ - Test independent example scripts. - Ensures they run without errors. - Changes the current working directory to the directory of the example script. - Runs them alphabetically. - This imitates behaviour of running the script directly. - """ - with working_directory(example_script.parent): - timeout = 800 - # Set environment variable to disable interactive plotting - env = os.environ.copy() - env['FLIXOPT_CI'] = 'true' - try: - result = subprocess.run( - [sys.executable, example_script.name], - capture_output=True, - text=True, - timeout=timeout, - env=env, - ) - except subprocess.TimeoutExpired: - pytest.fail(f'Script {example_script} timed out after {timeout} seconds') - - assert result.returncode == 0, ( - f'Script {example_script} failed:\nSTDERR:\n{result.stderr}\nSTDOUT:\n{result.stdout}' - ) - - -@pytest.mark.examples -def test_dependent_examples(): - """Test examples that must run in order (complex_example.py generates data for complex_example_results.py).""" - for script_path in DEPENDENT_EXAMPLES: - script_full_path = EXAMPLES_DIR / script_path - - with working_directory(script_full_path.parent): - timeout = 600 - # Set environment variable to disable interactive plotting - env = os.environ.copy() - env['FLIXOPT_CI'] = 'true' - try: - result = subprocess.run( - [sys.executable, script_full_path.name], - capture_output=True, - text=True, - timeout=timeout, - env=env, - ) - except subprocess.TimeoutExpired: - pytest.fail(f'Script {script_path} timed out after {timeout} seconds') - - assert result.returncode == 0, f'{script_path} failed:\nSTDERR:\n{result.stderr}\nSTDOUT:\n{result.stdout}' - - -if __name__ == '__main__': - pytest.main(['-v', '--disable-warnings', '-m', 'examples']) diff --git a/tests/deprecated/test_flow.py b/tests/deprecated/test_flow.py deleted file mode 100644 index 69922482a..000000000 --- a/tests/deprecated/test_flow.py +++ /dev/null @@ -1,1350 +0,0 @@ -import numpy as np -import pytest -import xarray as xr - -import flixopt as fx - -from .conftest import assert_conequal, assert_sets_equal, assert_var_equal, create_linopy_model - - -class TestFlowModel: - """Test the FlowModel class.""" - - def test_flow_minimal(self, basic_flow_system_linopy_coords, coords_config): - """Test that flow model constraints are correctly generated.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - flow = fx.Flow('Wärme', bus='Fernwärme', size=100) - - flow_system.add_elements(fx.Sink('Sink', inputs=[flow])) - - model = create_linopy_model(flow_system) - - assert_conequal( - model.constraints['Sink(Wärme)|total_flow_hours'], - flow.submodel.variables['Sink(Wärme)|total_flow_hours'] - == (flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.timestep_duration).sum('time'), - ) - assert_var_equal(flow.submodel.flow_rate, model.add_variables(lower=0, upper=100, coords=model.get_coords())) - assert_var_equal( - flow.submodel.total_flow_hours, - model.add_variables(lower=0, coords=model.get_coords(['period', 'scenario'])), - ) - - assert_sets_equal( - set(flow.submodel.variables), - {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate'}, - msg='Incorrect variables', - ) - assert_sets_equal(set(flow.submodel.constraints), {'Sink(Wärme)|total_flow_hours'}, msg='Incorrect constraints') - - def test_flow(self, basic_flow_system_linopy_coords, coords_config): - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.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_max=1000, - flow_hours_min=10, - load_factor_min=0.1, - load_factor_max=0.9, - ) - - flow_system.add_elements(fx.Sink('Sink', inputs=[flow])) - model = create_linopy_model(flow_system) - - # total_flow_hours - assert_conequal( - model.constraints['Sink(Wärme)|total_flow_hours'], - flow.submodel.variables['Sink(Wärme)|total_flow_hours'] - == (flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.timestep_duration).sum('time'), - ) - - assert_var_equal( - flow.submodel.total_flow_hours, - model.add_variables(lower=10, upper=1000, coords=model.get_coords(['period', 'scenario'])), - ) - - # Data stays in minimal form (not broadcast to all model dimensions) - assert flow.relative_minimum.dims == ('time',) # Only time dimension - assert flow.relative_maximum.dims == ('time',) # Only time dimension - - assert_var_equal( - flow.submodel.flow_rate, - model.add_variables( - lower=flow.relative_minimum * 100, - upper=flow.relative_maximum * 100, - coords=model.get_coords(), - ), - ) - - assert_conequal( - model.constraints['Sink(Wärme)|load_factor_min'], - flow.submodel.variables['Sink(Wärme)|total_flow_hours'] >= model.timestep_duration.sum('time') * 0.1 * 100, - ) - - assert_conequal( - model.constraints['Sink(Wärme)|load_factor_max'], - flow.submodel.variables['Sink(Wärme)|total_flow_hours'] <= model.timestep_duration.sum('time') * 0.9 * 100, - ) - - assert_sets_equal( - set(flow.submodel.variables), - {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate'}, - msg='Incorrect variables', - ) - assert_sets_equal( - set(flow.submodel.constraints), - {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|load_factor_max', 'Sink(Wärme)|load_factor_min'}, - msg='Incorrect constraints', - ) - - def test_effects_per_flow_hour(self, basic_flow_system_linopy_coords, coords_config): - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.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', inputs=[flow]), fx.Effect('CO2', 't', '')) - model = create_linopy_model(flow_system) - costs, co2 = flow_system.effects['costs'], flow_system.effects['CO2'] - - assert_sets_equal( - set(flow.submodel.variables), - {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate'}, - msg='Incorrect variables', - ) - assert_sets_equal(set(flow.submodel.constraints), {'Sink(Wärme)|total_flow_hours'}, msg='Incorrect constraints') - - assert 'Sink(Wärme)->costs(temporal)' in set(costs.submodel.constraints) - assert 'Sink(Wärme)->CO2(temporal)' in set(co2.submodel.constraints) - - assert_conequal( - model.constraints['Sink(Wärme)->costs(temporal)'], - model.variables['Sink(Wärme)->costs(temporal)'] - == flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.timestep_duration * costs_per_flow_hour, - ) - - assert_conequal( - model.constraints['Sink(Wärme)->CO2(temporal)'], - model.variables['Sink(Wärme)->CO2(temporal)'] - == flow.submodel.variables['Sink(Wärme)|flow_rate'] * model.timestep_duration * co2_per_flow_hour, - ) - - -class TestFlowInvestModel: - """Test the FlowModel class.""" - - def test_flow_invest(self, basic_flow_system_linopy_coords, coords_config): - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps - - flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - size=fx.InvestParameters(minimum_size=20, maximum_size=100, mandatory=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', inputs=[flow])) - model = create_linopy_model(flow_system) - - assert_sets_equal( - set(flow.submodel.variables), - { - 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|flow_rate', - 'Sink(Wärme)|size', - }, - msg='Incorrect variables', - ) - assert_sets_equal( - set(flow.submodel.constraints), - { - 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|flow_rate|ub', - 'Sink(Wärme)|flow_rate|lb', - }, - msg='Incorrect constraints', - ) - - # size - assert_var_equal( - model['Sink(Wärme)|size'], - model.add_variables(lower=20, upper=100, coords=model.get_coords(['period', 'scenario'])), - ) - - # Data stays in minimal form (not broadcast to all model dimensions) - assert flow.relative_minimum.dims == ('time',) # Only time dimension - assert flow.relative_maximum.dims == ('time',) # Only time dimension - - # flow_rate - assert_var_equal( - flow.submodel.flow_rate, - model.add_variables( - lower=flow.relative_minimum * 20, - upper=flow.relative_maximum * 100, - coords=model.get_coords(), - ), - ) - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lb'], - flow.submodel.variables['Sink(Wärme)|flow_rate'] - >= flow.submodel.variables['Sink(Wärme)|size'] * flow.relative_minimum, - ) - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.submodel.variables['Sink(Wärme)|flow_rate'] - <= flow.submodel.variables['Sink(Wärme)|size'] * flow.relative_maximum, - ) - - def test_flow_invest_optional(self, basic_flow_system_linopy_coords, coords_config): - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps - - flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - size=fx.InvestParameters(minimum_size=20, maximum_size=100, mandatory=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', inputs=[flow])) - model = create_linopy_model(flow_system) - - assert_sets_equal( - set(flow.submodel.variables), - {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|invested'}, - msg='Incorrect variables', - ) - assert_sets_equal( - set(flow.submodel.constraints), - { - 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|size|lb', - 'Sink(Wärme)|size|ub', - 'Sink(Wärme)|flow_rate|lb', - 'Sink(Wärme)|flow_rate|ub', - }, - msg='Incorrect constraints', - ) - - assert_var_equal( - model['Sink(Wärme)|size'], - model.add_variables(lower=0, upper=100, coords=model.get_coords(['period', 'scenario'])), - ) - - assert_var_equal( - model['Sink(Wärme)|invested'], - model.add_variables(binary=True, coords=model.get_coords(['period', 'scenario'])), - ) - - # Data stays in minimal form (not broadcast to all model dimensions) - assert flow.relative_minimum.dims == ('time',) # Only time dimension - assert flow.relative_maximum.dims == ('time',) # Only time dimension - - # flow_rate - assert_var_equal( - flow.submodel.flow_rate, - model.add_variables( - lower=0, # Optional investment - upper=flow.relative_maximum * 100, - coords=model.get_coords(), - ), - ) - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lb'], - flow.submodel.variables['Sink(Wärme)|flow_rate'] - >= flow.submodel.variables['Sink(Wärme)|size'] * flow.relative_minimum, - ) - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.submodel.variables['Sink(Wärme)|flow_rate'] - <= flow.submodel.variables['Sink(Wärme)|size'] * flow.relative_maximum, - ) - - # Is invested - assert_conequal( - model.constraints['Sink(Wärme)|size|ub'], - flow.submodel.variables['Sink(Wärme)|size'] <= flow.submodel.variables['Sink(Wärme)|invested'] * 100, - ) - assert_conequal( - model.constraints['Sink(Wärme)|size|lb'], - flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|invested'] * 20, - ) - - def test_flow_invest_optional_wo_min_size(self, basic_flow_system_linopy_coords, coords_config): - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps - - flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - size=fx.InvestParameters(maximum_size=100, mandatory=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', inputs=[flow])) - model = create_linopy_model(flow_system) - - assert_sets_equal( - set(flow.submodel.variables), - {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size', 'Sink(Wärme)|invested'}, - msg='Incorrect variables', - ) - assert_sets_equal( - set(flow.submodel.constraints), - { - 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|size|ub', - 'Sink(Wärme)|size|lb', - 'Sink(Wärme)|flow_rate|lb', - 'Sink(Wärme)|flow_rate|ub', - }, - msg='Incorrect constraints', - ) - - assert_var_equal( - model['Sink(Wärme)|size'], - model.add_variables(lower=0, upper=100, coords=model.get_coords(['period', 'scenario'])), - ) - - assert_var_equal( - model['Sink(Wärme)|invested'], - model.add_variables(binary=True, coords=model.get_coords(['period', 'scenario'])), - ) - - # Data stays in minimal form (not broadcast to all model dimensions) - assert flow.relative_minimum.dims == ('time',) # Only time dimension - assert flow.relative_maximum.dims == ('time',) # Only time dimension - - # flow_rate - assert_var_equal( - flow.submodel.flow_rate, - model.add_variables( - lower=0, # Optional investment - upper=flow.relative_maximum * 100, - coords=model.get_coords(), - ), - ) - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lb'], - flow.submodel.variables['Sink(Wärme)|flow_rate'] - >= flow.submodel.variables['Sink(Wärme)|size'] * flow.relative_minimum, - ) - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.submodel.variables['Sink(Wärme)|flow_rate'] - <= flow.submodel.variables['Sink(Wärme)|size'] * flow.relative_maximum, - ) - - # Is invested - assert_conequal( - model.constraints['Sink(Wärme)|size|ub'], - flow.submodel.variables['Sink(Wärme)|size'] <= flow.submodel.variables['Sink(Wärme)|invested'] * 100, - ) - assert_conequal( - model.constraints['Sink(Wärme)|size|lb'], - flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|invested'] * 1e-5, - ) - - def test_flow_invest_wo_min_size_non_optional(self, basic_flow_system_linopy_coords, coords_config): - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps - - flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - size=fx.InvestParameters(maximum_size=100, mandatory=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', inputs=[flow])) - model = create_linopy_model(flow_system) - - assert_sets_equal( - set(flow.submodel.variables), - {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size'}, - msg='Incorrect variables', - ) - assert_sets_equal( - set(flow.submodel.constraints), - { - 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|flow_rate|lb', - 'Sink(Wärme)|flow_rate|ub', - }, - msg='Incorrect constraints', - ) - - assert_var_equal( - model['Sink(Wärme)|size'], - model.add_variables(lower=1e-5, upper=100, coords=model.get_coords(['period', 'scenario'])), - ) - - # Data stays in minimal form (not broadcast to all model dimensions) - assert flow.relative_minimum.dims == ('time',) # Only time dimension - assert flow.relative_maximum.dims == ('time',) # Only time dimension - - # flow_rate - assert_var_equal( - flow.submodel.flow_rate, - model.add_variables( - lower=flow.relative_minimum * 1e-5, - upper=flow.relative_maximum * 100, - coords=model.get_coords(), - ), - ) - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lb'], - flow.submodel.variables['Sink(Wärme)|flow_rate'] - >= flow.submodel.variables['Sink(Wärme)|size'] * flow.relative_minimum, - ) - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.submodel.variables['Sink(Wärme)|flow_rate'] - <= flow.submodel.variables['Sink(Wärme)|size'] * flow.relative_maximum, - ) - - def test_flow_invest_fixed_size(self, basic_flow_system_linopy_coords, coords_config): - """Test flow with fixed size investment.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - size=fx.InvestParameters(fixed_size=75, mandatory=True), - relative_minimum=0.2, - relative_maximum=0.9, - ) - - flow_system.add_elements(fx.Sink('Sink', inputs=[flow])) - model = create_linopy_model(flow_system) - - assert_sets_equal( - set(flow.submodel.variables), - {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|size'}, - msg='Incorrect variables', - ) - - # Check that size is fixed to 75 - assert_var_equal( - flow.submodel.variables['Sink(Wärme)|size'], - model.add_variables(lower=75, upper=75, coords=model.get_coords(['period', 'scenario'])), - ) - - # Check flow rate bounds - assert_var_equal( - flow.submodel.flow_rate, model.add_variables(lower=0.2 * 75, upper=0.9 * 75, coords=model.get_coords()) - ) - - def test_flow_invest_with_effects(self, basic_flow_system_linopy_coords, coords_config): - """Test flow with investment effects.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - # 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, - mandatory=False, - effects_of_investment={'costs': 1000, 'CO2': 5}, # Fixed investment effects - effects_of_investment_per_size={'costs': 500, 'CO2': 0.1}, # Specific investment effects - ), - ) - - flow_system.add_elements(fx.Sink('Sink', inputs=[flow]), co2) - model = create_linopy_model(flow_system) - - # Check investment effects - assert 'Sink(Wärme)->costs(periodic)' in model.variables - assert 'Sink(Wärme)->CO2(periodic)' in model.variables - - # Check fix effects (applied only when invested=1) - assert_conequal( - model.constraints['Sink(Wärme)->costs(periodic)'], - model.variables['Sink(Wärme)->costs(periodic)'] - == flow.submodel.variables['Sink(Wärme)|invested'] * 1000 - + flow.submodel.variables['Sink(Wärme)|size'] * 500, - ) - - assert_conequal( - model.constraints['Sink(Wärme)->CO2(periodic)'], - model.variables['Sink(Wärme)->CO2(periodic)'] - == flow.submodel.variables['Sink(Wärme)|invested'] * 5 + flow.submodel.variables['Sink(Wärme)|size'] * 0.1, - ) - - def test_flow_invest_divest_effects(self, basic_flow_system_linopy_coords, coords_config): - """Test flow with divestment effects.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - size=fx.InvestParameters( - minimum_size=20, - maximum_size=100, - mandatory=False, - effects_of_retirement={'costs': 500}, # Cost incurred when NOT investing - ), - ) - - flow_system.add_elements(fx.Sink('Sink', inputs=[flow])) - model = create_linopy_model(flow_system) - - # Check divestment effects - assert 'Sink(Wärme)->costs(periodic)' in model.constraints - - assert_conequal( - model.constraints['Sink(Wärme)->costs(periodic)'], - model.variables['Sink(Wärme)->costs(periodic)'] + (model.variables['Sink(Wärme)|invested'] - 1) * 500 == 0, - ) - - -class TestFlowOnModel: - """Test the FlowModel class.""" - - def test_flow_on(self, basic_flow_system_linopy_coords, coords_config): - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - size=100, - relative_minimum=0.2, - relative_maximum=0.8, - status_parameters=fx.StatusParameters(), - ) - flow_system.add_elements(fx.Sink('Sink', inputs=[flow])) - model = create_linopy_model(flow_system) - - assert_sets_equal( - set(flow.submodel.variables), - {'Sink(Wärme)|total_flow_hours', 'Sink(Wärme)|flow_rate', 'Sink(Wärme)|status', 'Sink(Wärme)|active_hours'}, - msg='Incorrect variables', - ) - - assert_sets_equal( - set(flow.submodel.constraints), - { - 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|active_hours', - 'Sink(Wärme)|flow_rate|lb', - 'Sink(Wärme)|flow_rate|ub', - }, - msg='Incorrect constraints', - ) - # flow_rate - assert_var_equal( - flow.submodel.flow_rate, - model.add_variables( - lower=0, - upper=0.8 * 100, - coords=model.get_coords(), - ), - ) - - # Status - assert_var_equal( - flow.submodel.status.status, - model.add_variables(binary=True, coords=model.get_coords()), - ) - # Upper bound is total hours when active_hours_max is not specified - total_hours = model.timestep_duration.sum('time') - assert_var_equal( - model.variables['Sink(Wärme)|active_hours'], - model.add_variables(lower=0, upper=total_hours, coords=model.get_coords(['period', 'scenario'])), - ) - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lb'], - flow.submodel.variables['Sink(Wärme)|flow_rate'] - >= flow.submodel.variables['Sink(Wärme)|status'] * 0.2 * 100, - ) - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|ub'], - flow.submodel.variables['Sink(Wärme)|flow_rate'] - <= flow.submodel.variables['Sink(Wärme)|status'] * 0.8 * 100, - ) - - assert_conequal( - model.constraints['Sink(Wärme)|active_hours'], - flow.submodel.variables['Sink(Wärme)|active_hours'] - == (flow.submodel.variables['Sink(Wärme)|status'] * model.timestep_duration).sum('time'), - ) - - def test_effects_per_active_hour(self, basic_flow_system_linopy_coords, coords_config): - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.timesteps - - costs_per_running_hour = np.linspace(1, 2, timesteps.size) - co2_per_running_hour = np.linspace(4, 5, timesteps.size) - - flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - size=100, - status_parameters=fx.StatusParameters( - effects_per_active_hour={'costs': costs_per_running_hour, 'CO2': co2_per_running_hour} - ), - ) - flow_system.add_elements(fx.Sink('Sink', inputs=[flow]), fx.Effect('CO2', 't', '')) - model = create_linopy_model(flow_system) - costs, co2 = flow_system.effects['costs'], flow_system.effects['CO2'] - - assert_sets_equal( - set(flow.submodel.variables), - { - 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|flow_rate', - 'Sink(Wärme)|status', - 'Sink(Wärme)|active_hours', - }, - msg='Incorrect variables', - ) - assert_sets_equal( - set(flow.submodel.constraints), - { - 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|flow_rate|lb', - 'Sink(Wärme)|flow_rate|ub', - 'Sink(Wärme)|active_hours', - }, - msg='Incorrect constraints', - ) - - assert 'Sink(Wärme)->costs(temporal)' in set(costs.submodel.constraints) - assert 'Sink(Wärme)->CO2(temporal)' in set(co2.submodel.constraints) - - costs_per_running_hour = flow.status_parameters.effects_per_active_hour['costs'] - co2_per_running_hour = flow.status_parameters.effects_per_active_hour['CO2'] - - # Data stays in minimal form (1D array stays 1D) - assert costs_per_running_hour.dims == ('time',) - assert co2_per_running_hour.dims == ('time',) - - assert_conequal( - model.constraints['Sink(Wärme)->costs(temporal)'], - model.variables['Sink(Wärme)->costs(temporal)'] - == flow.submodel.variables['Sink(Wärme)|status'] * model.timestep_duration * costs_per_running_hour, - ) - - assert_conequal( - model.constraints['Sink(Wärme)->CO2(temporal)'], - model.variables['Sink(Wärme)->CO2(temporal)'] - == flow.submodel.variables['Sink(Wärme)|status'] * model.timestep_duration * co2_per_running_hour, - ) - - def test_consecutive_on_hours(self, basic_flow_system_linopy_coords, coords_config): - """Test flow with minimum and maximum consecutive on hours.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - size=100, - previous_flow_rate=0, # Required to get initial constraint - status_parameters=fx.StatusParameters( - min_uptime=2, # Must run for at least 2 hours when turned on - max_uptime=8, # Can't run more than 8 consecutive hours - ), - ) - - flow_system.add_elements(fx.Sink('Sink', inputs=[flow])) - model = create_linopy_model(flow_system) - - assert {'Sink(Wärme)|uptime', 'Sink(Wärme)|status'}.issubset(set(flow.submodel.variables)) - - assert_sets_equal( - { - 'Sink(Wärme)|uptime|ub', - 'Sink(Wärme)|uptime|forward', - 'Sink(Wärme)|uptime|backward', - 'Sink(Wärme)|uptime|initial', - 'Sink(Wärme)|uptime|lb', - } - & set(flow.submodel.constraints), - { - 'Sink(Wärme)|uptime|ub', - 'Sink(Wärme)|uptime|forward', - 'Sink(Wärme)|uptime|backward', - 'Sink(Wärme)|uptime|initial', - 'Sink(Wärme)|uptime|lb', - }, - msg='Missing uptime constraints', - ) - - assert_var_equal( - model.variables['Sink(Wärme)|uptime'], - model.add_variables(lower=0, upper=8, coords=model.get_coords()), - ) - - mega = model.timestep_duration.sum('time') - - assert_conequal( - model.constraints['Sink(Wärme)|uptime|ub'], - model.variables['Sink(Wärme)|uptime'] <= model.variables['Sink(Wärme)|status'] * mega, - ) - - assert_conequal( - model.constraints['Sink(Wärme)|uptime|forward'], - model.variables['Sink(Wärme)|uptime'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|uptime'].isel(time=slice(None, -1)) - + model.timestep_duration.isel(time=slice(None, -1)), - ) - - # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG - assert_conequal( - model.constraints['Sink(Wärme)|uptime|backward'], - model.variables['Sink(Wärme)|uptime'].isel(time=slice(1, None)) - >= model.variables['Sink(Wärme)|uptime'].isel(time=slice(None, -1)) - + model.timestep_duration.isel(time=slice(None, -1)) - + (model.variables['Sink(Wärme)|status'].isel(time=slice(1, None)) - 1) * mega, - ) - - assert_conequal( - model.constraints['Sink(Wärme)|uptime|initial'], - model.variables['Sink(Wärme)|uptime'].isel(time=0) - == model.variables['Sink(Wärme)|status'].isel(time=0) * model.timestep_duration.isel(time=0), - ) - - assert_conequal( - model.constraints['Sink(Wärme)|uptime|lb'], - model.variables['Sink(Wärme)|uptime'] - >= ( - model.variables['Sink(Wärme)|status'].isel(time=slice(None, -1)) - - model.variables['Sink(Wärme)|status'].isel(time=slice(1, None)) - ) - * 2, - ) - - def test_consecutive_on_hours_previous(self, basic_flow_system_linopy_coords, coords_config): - """Test flow with minimum and maximum uptime.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - size=100, - status_parameters=fx.StatusParameters( - min_uptime=2, # Must run for at least 2 hours when active - max_uptime=8, # Can't run more than 8 consecutive hours - ), - previous_flow_rate=np.array([10, 20, 30, 0, 20, 20, 30]), # Previously active for 3 steps - ) - - flow_system.add_elements(fx.Sink('Sink', inputs=[flow])) - model = create_linopy_model(flow_system) - - assert {'Sink(Wärme)|uptime', 'Sink(Wärme)|status'}.issubset(set(flow.submodel.variables)) - - assert_sets_equal( - { - 'Sink(Wärme)|uptime|lb', - 'Sink(Wärme)|uptime|forward', - 'Sink(Wärme)|uptime|backward', - 'Sink(Wärme)|uptime|initial', - } - & set(flow.submodel.constraints), - { - 'Sink(Wärme)|uptime|lb', - 'Sink(Wärme)|uptime|forward', - 'Sink(Wärme)|uptime|backward', - 'Sink(Wärme)|uptime|initial', - }, - msg='Missing uptime constraints for previous states', - ) - - assert_var_equal( - model.variables['Sink(Wärme)|uptime'], - model.add_variables(lower=0, upper=8, coords=model.get_coords()), - ) - - mega = model.timestep_duration.sum('time') + model.timestep_duration.isel(time=0) * 3 - - assert_conequal( - model.constraints['Sink(Wärme)|uptime|ub'], - model.variables['Sink(Wärme)|uptime'] <= model.variables['Sink(Wärme)|status'] * mega, - ) - - assert_conequal( - model.constraints['Sink(Wärme)|uptime|forward'], - model.variables['Sink(Wärme)|uptime'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|uptime'].isel(time=slice(None, -1)) - + model.timestep_duration.isel(time=slice(None, -1)), - ) - - # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG - assert_conequal( - model.constraints['Sink(Wärme)|uptime|backward'], - model.variables['Sink(Wärme)|uptime'].isel(time=slice(1, None)) - >= model.variables['Sink(Wärme)|uptime'].isel(time=slice(None, -1)) - + model.timestep_duration.isel(time=slice(None, -1)) - + (model.variables['Sink(Wärme)|status'].isel(time=slice(1, None)) - 1) * mega, - ) - - assert_conequal( - model.constraints['Sink(Wärme)|uptime|initial'], - model.variables['Sink(Wärme)|uptime'].isel(time=0) - == model.variables['Sink(Wärme)|status'].isel(time=0) * (model.timestep_duration.isel(time=0) * (1 + 3)), - ) - - assert_conequal( - model.constraints['Sink(Wärme)|uptime|lb'], - model.variables['Sink(Wärme)|uptime'] - >= ( - model.variables['Sink(Wärme)|status'].isel(time=slice(None, -1)) - - model.variables['Sink(Wärme)|status'].isel(time=slice(1, None)) - ) - * 2, - ) - - def test_consecutive_off_hours(self, basic_flow_system_linopy_coords, coords_config): - """Test flow with minimum and maximum consecutive inactive hours.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - size=100, - previous_flow_rate=0, # System was OFF for 1 hour before start - required for initial constraint - status_parameters=fx.StatusParameters( - min_downtime=4, # Must stay inactive for at least 4 hours when shut down - max_downtime=12, # Can't be inactive for more than 12 consecutive hours - ), - ) - - flow_system.add_elements(fx.Sink('Sink', inputs=[flow])) - model = create_linopy_model(flow_system) - - assert {'Sink(Wärme)|downtime', 'Sink(Wärme)|inactive'}.issubset(set(flow.submodel.variables)) - - assert_sets_equal( - { - 'Sink(Wärme)|downtime|ub', - 'Sink(Wärme)|downtime|forward', - 'Sink(Wärme)|downtime|backward', - 'Sink(Wärme)|downtime|initial', - 'Sink(Wärme)|downtime|lb', - } - & set(flow.submodel.constraints), - { - 'Sink(Wärme)|downtime|ub', - 'Sink(Wärme)|downtime|forward', - 'Sink(Wärme)|downtime|backward', - 'Sink(Wärme)|downtime|initial', - 'Sink(Wärme)|downtime|lb', - }, - msg='Missing consecutive inactive hours constraints', - ) - - assert_var_equal( - model.variables['Sink(Wärme)|downtime'], - model.add_variables(lower=0, upper=12, coords=model.get_coords()), - ) - - mega = ( - model.timestep_duration.sum('time') + model.timestep_duration.isel(time=0) * 1 - ) # previously inactive for 1h - - assert_conequal( - model.constraints['Sink(Wärme)|downtime|ub'], - model.variables['Sink(Wärme)|downtime'] <= model.variables['Sink(Wärme)|inactive'] * mega, - ) - - assert_conequal( - model.constraints['Sink(Wärme)|downtime|forward'], - model.variables['Sink(Wärme)|downtime'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|downtime'].isel(time=slice(None, -1)) - + model.timestep_duration.isel(time=slice(None, -1)), - ) - - # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG - assert_conequal( - model.constraints['Sink(Wärme)|downtime|backward'], - model.variables['Sink(Wärme)|downtime'].isel(time=slice(1, None)) - >= model.variables['Sink(Wärme)|downtime'].isel(time=slice(None, -1)) - + model.timestep_duration.isel(time=slice(None, -1)) - + (model.variables['Sink(Wärme)|inactive'].isel(time=slice(1, None)) - 1) * mega, - ) - - assert_conequal( - model.constraints['Sink(Wärme)|downtime|initial'], - model.variables['Sink(Wärme)|downtime'].isel(time=0) - == model.variables['Sink(Wärme)|inactive'].isel(time=0) * (model.timestep_duration.isel(time=0) * (1 + 1)), - ) - - assert_conequal( - model.constraints['Sink(Wärme)|downtime|lb'], - model.variables['Sink(Wärme)|downtime'] - >= ( - model.variables['Sink(Wärme)|inactive'].isel(time=slice(None, -1)) - - model.variables['Sink(Wärme)|inactive'].isel(time=slice(1, None)) - ) - * 4, - ) - - def test_consecutive_off_hours_previous(self, basic_flow_system_linopy_coords, coords_config): - """Test flow with minimum and maximum consecutive inactive hours.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - size=100, - status_parameters=fx.StatusParameters( - min_downtime=4, # Must stay inactive for at least 4 hours when shut down - max_downtime=12, # Can't be inactive for more than 12 consecutive hours - ), - previous_flow_rate=np.array([10, 20, 30, 0, 20, 0, 0]), # Previously inactive for 2 steps - ) - - flow_system.add_elements(fx.Sink('Sink', inputs=[flow])) - model = create_linopy_model(flow_system) - - assert {'Sink(Wärme)|downtime', 'Sink(Wärme)|inactive'}.issubset(set(flow.submodel.variables)) - - assert_sets_equal( - { - 'Sink(Wärme)|downtime|ub', - 'Sink(Wärme)|downtime|forward', - 'Sink(Wärme)|downtime|backward', - 'Sink(Wärme)|downtime|initial', - 'Sink(Wärme)|downtime|lb', - } - & set(flow.submodel.constraints), - { - 'Sink(Wärme)|downtime|ub', - 'Sink(Wärme)|downtime|forward', - 'Sink(Wärme)|downtime|backward', - 'Sink(Wärme)|downtime|initial', - 'Sink(Wärme)|downtime|lb', - }, - msg='Missing consecutive inactive hours constraints for previous states', - ) - - assert_var_equal( - model.variables['Sink(Wärme)|downtime'], - model.add_variables(lower=0, upper=12, coords=model.get_coords()), - ) - - mega = model.timestep_duration.sum('time') + model.timestep_duration.isel(time=0) * 2 - - assert_conequal( - model.constraints['Sink(Wärme)|downtime|ub'], - model.variables['Sink(Wärme)|downtime'] <= model.variables['Sink(Wärme)|inactive'] * mega, - ) - - assert_conequal( - model.constraints['Sink(Wärme)|downtime|forward'], - model.variables['Sink(Wärme)|downtime'].isel(time=slice(1, None)) - <= model.variables['Sink(Wärme)|downtime'].isel(time=slice(None, -1)) - + model.timestep_duration.isel(time=slice(None, -1)), - ) - - # eq: duration(t) >= duration(t - 1) + dt(t) + (On(t) - 1) * BIG - assert_conequal( - model.constraints['Sink(Wärme)|downtime|backward'], - model.variables['Sink(Wärme)|downtime'].isel(time=slice(1, None)) - >= model.variables['Sink(Wärme)|downtime'].isel(time=slice(None, -1)) - + model.timestep_duration.isel(time=slice(None, -1)) - + (model.variables['Sink(Wärme)|inactive'].isel(time=slice(1, None)) - 1) * mega, - ) - - assert_conequal( - model.constraints['Sink(Wärme)|downtime|initial'], - model.variables['Sink(Wärme)|downtime'].isel(time=0) - == model.variables['Sink(Wärme)|inactive'].isel(time=0) * (model.timestep_duration.isel(time=0) * (1 + 2)), - ) - - assert_conequal( - model.constraints['Sink(Wärme)|downtime|lb'], - model.variables['Sink(Wärme)|downtime'] - >= ( - model.variables['Sink(Wärme)|inactive'].isel(time=slice(None, -1)) - - model.variables['Sink(Wärme)|inactive'].isel(time=slice(1, None)) - ) - * 4, - ) - - def test_switch_on_constraints(self, basic_flow_system_linopy_coords, coords_config): - """Test flow with constraints on the number of startups.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - size=100, - previous_flow_rate=0, # Required for initial constraint - status_parameters=fx.StatusParameters( - startup_limit=5, # Maximum 5 startups - effects_per_startup={'costs': 100}, # 100 EUR startup cost - ), - ) - - flow_system.add_elements(fx.Sink('Sink', inputs=[flow])) - model = create_linopy_model(flow_system) - - # Check that variables exist - assert {'Sink(Wärme)|startup', 'Sink(Wärme)|shutdown', 'Sink(Wärme)|startup_count'}.issubset( - set(flow.submodel.variables) - ) - - # Check that constraints exist - assert_sets_equal( - { - 'Sink(Wärme)|switch|transition', - 'Sink(Wärme)|switch|initial', - 'Sink(Wärme)|switch|mutex', - 'Sink(Wärme)|startup_count', - } - & set(flow.submodel.constraints), - { - 'Sink(Wärme)|switch|transition', - 'Sink(Wärme)|switch|initial', - 'Sink(Wärme)|switch|mutex', - 'Sink(Wärme)|startup_count', - }, - msg='Missing switch constraints', - ) - - # Check startup_count variable bounds - assert_var_equal( - flow.submodel.variables['Sink(Wärme)|startup_count'], - model.add_variables(lower=0, upper=5, coords=model.get_coords(['period', 'scenario'])), - ) - - # Verify startup_count constraint (limits number of startups) - assert_conequal( - model.constraints['Sink(Wärme)|startup_count'], - flow.submodel.variables['Sink(Wärme)|startup_count'] - == flow.submodel.variables['Sink(Wärme)|startup'].sum('time'), - ) - - # Check that startup cost effect constraint exists - assert 'Sink(Wärme)->costs(temporal)' in model.constraints - - # Verify the startup cost effect constraint - assert_conequal( - model.constraints['Sink(Wärme)->costs(temporal)'], - model.variables['Sink(Wärme)->costs(temporal)'] == flow.submodel.variables['Sink(Wärme)|startup'] * 100, - ) - - def test_on_hours_limits(self, basic_flow_system_linopy_coords, coords_config): - """Test flow with limits on total active hours.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - size=100, - status_parameters=fx.StatusParameters( - active_hours_min=20, # Minimum 20 hours of operation - active_hours_max=100, # Maximum 100 hours of operation - ), - ) - - flow_system.add_elements(fx.Sink('Sink', inputs=[flow])) - model = create_linopy_model(flow_system) - - # Check that variables exist - assert {'Sink(Wärme)|status', 'Sink(Wärme)|active_hours'}.issubset(set(flow.submodel.variables)) - - # Check that constraints exist - assert 'Sink(Wärme)|active_hours' in model.constraints - - # Check active_hours variable bounds - assert_var_equal( - flow.submodel.variables['Sink(Wärme)|active_hours'], - model.add_variables(lower=20, upper=100, coords=model.get_coords(['period', 'scenario'])), - ) - - # Check active_hours constraint - assert_conequal( - model.constraints['Sink(Wärme)|active_hours'], - flow.submodel.variables['Sink(Wärme)|active_hours'] - == (flow.submodel.variables['Sink(Wärme)|status'] * model.timestep_duration).sum('time'), - ) - - -class TestFlowOnInvestModel: - """Test the FlowModel class.""" - - def test_flow_on_invest_optional(self, basic_flow_system_linopy_coords, coords_config): - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - size=fx.InvestParameters(minimum_size=20, maximum_size=200, mandatory=False), - relative_minimum=0.2, - relative_maximum=0.8, - status_parameters=fx.StatusParameters(), - ) - flow_system.add_elements(fx.Sink('Sink', inputs=[flow])) - model = create_linopy_model(flow_system) - - assert_sets_equal( - set(flow.submodel.variables), - { - 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|flow_rate', - 'Sink(Wärme)|invested', - 'Sink(Wärme)|size', - 'Sink(Wärme)|status', - 'Sink(Wärme)|active_hours', - }, - msg='Incorrect variables', - ) - - assert_sets_equal( - set(flow.submodel.constraints), - { - 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|active_hours', - 'Sink(Wärme)|flow_rate|lb1', - 'Sink(Wärme)|flow_rate|ub1', - 'Sink(Wärme)|size|lb', - 'Sink(Wärme)|size|ub', - 'Sink(Wärme)|flow_rate|lb2', - 'Sink(Wärme)|flow_rate|ub2', - }, - msg='Incorrect constraints', - ) - - # flow_rate - assert_var_equal( - flow.submodel.flow_rate, - model.add_variables( - lower=0, - upper=0.8 * 200, - coords=model.get_coords(), - ), - ) - - # Status - assert_var_equal( - flow.submodel.status.status, - model.add_variables(binary=True, coords=model.get_coords()), - ) - # Upper bound is total hours when active_hours_max is not specified - total_hours = model.timestep_duration.sum('time') - assert_var_equal( - model.variables['Sink(Wärme)|active_hours'], - model.add_variables(lower=0, upper=total_hours, coords=model.get_coords(['period', 'scenario'])), - ) - assert_conequal( - model.constraints['Sink(Wärme)|size|lb'], - flow.submodel.variables['Sink(Wärme)|size'] >= flow.submodel.variables['Sink(Wärme)|invested'] * 20, - ) - assert_conequal( - model.constraints['Sink(Wärme)|size|ub'], - flow.submodel.variables['Sink(Wärme)|size'] <= flow.submodel.variables['Sink(Wärme)|invested'] * 200, - ) - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lb1'], - flow.submodel.variables['Sink(Wärme)|status'] * 0.2 * 20 - <= flow.submodel.variables['Sink(Wärme)|flow_rate'], - ) - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|ub1'], - flow.submodel.variables['Sink(Wärme)|status'] * 0.8 * 200 - >= flow.submodel.variables['Sink(Wärme)|flow_rate'], - ) - assert_conequal( - model.constraints['Sink(Wärme)|active_hours'], - flow.submodel.variables['Sink(Wärme)|active_hours'] - == (flow.submodel.variables['Sink(Wärme)|status'] * model.timestep_duration).sum('time'), - ) - - # Investment - assert_var_equal( - model['Sink(Wärme)|size'], - model.add_variables(lower=0, upper=200, coords=model.get_coords(['period', 'scenario'])), - ) - - mega = 0.2 * 200 # Relative minimum * maximum size - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lb2'], - flow.submodel.variables['Sink(Wärme)|flow_rate'] - >= flow.submodel.variables['Sink(Wärme)|status'] * mega - + flow.submodel.variables['Sink(Wärme)|size'] * 0.2 - - mega, - ) - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|ub2'], - flow.submodel.variables['Sink(Wärme)|flow_rate'] <= flow.submodel.variables['Sink(Wärme)|size'] * 0.8, - ) - - def test_flow_on_invest_non_optional(self, basic_flow_system_linopy_coords, coords_config): - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - flow = fx.Flow( - 'Wärme', - bus='Fernwärme', - size=fx.InvestParameters(minimum_size=20, maximum_size=200, mandatory=True), - relative_minimum=0.2, - relative_maximum=0.8, - status_parameters=fx.StatusParameters(), - ) - flow_system.add_elements(fx.Sink('Sink', inputs=[flow])) - model = create_linopy_model(flow_system) - - assert_sets_equal( - set(flow.submodel.variables), - { - 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|flow_rate', - 'Sink(Wärme)|size', - 'Sink(Wärme)|status', - 'Sink(Wärme)|active_hours', - }, - msg='Incorrect variables', - ) - - assert_sets_equal( - set(flow.submodel.constraints), - { - 'Sink(Wärme)|total_flow_hours', - 'Sink(Wärme)|active_hours', - 'Sink(Wärme)|flow_rate|lb1', - 'Sink(Wärme)|flow_rate|ub1', - 'Sink(Wärme)|flow_rate|lb2', - 'Sink(Wärme)|flow_rate|ub2', - }, - msg='Incorrect constraints', - ) - - # flow_rate - assert_var_equal( - flow.submodel.flow_rate, - model.add_variables( - lower=0, - upper=0.8 * 200, - coords=model.get_coords(), - ), - ) - - # Status - assert_var_equal( - flow.submodel.status.status, - model.add_variables(binary=True, coords=model.get_coords()), - ) - # Upper bound is total hours when active_hours_max is not specified - total_hours = model.timestep_duration.sum('time') - assert_var_equal( - model.variables['Sink(Wärme)|active_hours'], - model.add_variables(lower=0, upper=total_hours, coords=model.get_coords(['period', 'scenario'])), - ) - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lb1'], - flow.submodel.variables['Sink(Wärme)|status'] * 0.2 * 20 - <= flow.submodel.variables['Sink(Wärme)|flow_rate'], - ) - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|ub1'], - flow.submodel.variables['Sink(Wärme)|status'] * 0.8 * 200 - >= flow.submodel.variables['Sink(Wärme)|flow_rate'], - ) - assert_conequal( - model.constraints['Sink(Wärme)|active_hours'], - flow.submodel.variables['Sink(Wärme)|active_hours'] - == (flow.submodel.variables['Sink(Wärme)|status'] * model.timestep_duration).sum('time'), - ) - - # Investment - assert_var_equal( - model['Sink(Wärme)|size'], - model.add_variables(lower=20, upper=200, coords=model.get_coords(['period', 'scenario'])), - ) - - mega = 0.2 * 200 # Relative minimum * maximum size - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|lb2'], - flow.submodel.variables['Sink(Wärme)|flow_rate'] - >= flow.submodel.variables['Sink(Wärme)|status'] * mega - + flow.submodel.variables['Sink(Wärme)|size'] * 0.2 - - mega, - ) - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|ub2'], - flow.submodel.variables['Sink(Wärme)|flow_rate'] <= flow.submodel.variables['Sink(Wärme)|size'] * 0.8, - ) - - -class TestFlowWithFixedProfile: - """Test Flow with fixed relative profile.""" - - def test_fixed_relative_profile(self, basic_flow_system_linopy_coords, coords_config): - """Test flow with a fixed relative profile.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.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=profile, - ) - - flow_system.add_elements(fx.Sink('Sink', inputs=[flow])) - model = create_linopy_model(flow_system) - - assert_var_equal( - flow.submodel.variables['Sink(Wärme)|flow_rate'], - model.add_variables( - lower=flow.fixed_relative_profile * 100, - upper=flow.fixed_relative_profile * 100, - coords=model.get_coords(), - ), - ) - - def test_fixed_profile_with_investment(self, basic_flow_system_linopy_coords, coords_config): - """Test flow with fixed profile and investment.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.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, mandatory=False), - fixed_relative_profile=profile, - ) - - flow_system.add_elements(fx.Sink('Sink', inputs=[flow])) - model = create_linopy_model(flow_system) - - assert_var_equal( - flow.submodel.variables['Sink(Wärme)|flow_rate'], - model.add_variables(lower=0, upper=flow.fixed_relative_profile * 200, coords=model.get_coords()), - ) - - # The constraint should link flow_rate to size * profile - assert_conequal( - model.constraints['Sink(Wärme)|flow_rate|fixed'], - flow.submodel.variables['Sink(Wärme)|flow_rate'] - == flow.submodel.variables['Sink(Wärme)|size'] * flow.fixed_relative_profile, - ) - - -if __name__ == '__main__': - pytest.main() diff --git a/tests/deprecated/test_flow_system_resample.py b/tests/deprecated/test_flow_system_resample.py deleted file mode 100644 index 549f05208..000000000 --- a/tests/deprecated/test_flow_system_resample.py +++ /dev/null @@ -1,313 +0,0 @@ -"""Integration tests for FlowSystem.resample() - verifies correct data resampling and structure preservation.""" - -import numpy as np -import pandas as pd -import pytest -from numpy.testing import assert_allclose - -import flixopt as fx - - -@pytest.fixture -def simple_fs(): - """Simple FlowSystem with basic components.""" - timesteps = pd.date_range('2023-01-01', periods=24, freq='h') - fs = fx.FlowSystem(timesteps) - fs.add_elements( - fx.Bus('heat'), fx.Effect('costs', unit='€', description='costs', is_objective=True, is_standard=True) - ) - fs.add_elements( - fx.Sink( - label='demand', - inputs=[fx.Flow(label='in', bus='heat', fixed_relative_profile=np.linspace(10, 20, 24), size=1)], - ), - fx.Source( - label='source', outputs=[fx.Flow(label='out', bus='heat', size=50, effects_per_flow_hour={'costs': 0.05})] - ), - ) - return fs - - -@pytest.fixture -def complex_fs(): - """FlowSystem with complex elements (storage, piecewise, invest).""" - timesteps = pd.date_range('2023-01-01', periods=48, freq='h') - fs = fx.FlowSystem(timesteps) - - fs.add_elements( - fx.Bus('heat'), - fx.Bus('elec'), - fx.Effect('costs', unit='€', description='costs', is_objective=True, is_standard=True), - ) - - # Storage - fs.add_elements( - fx.Storage( - label='battery', - charging=fx.Flow('charge', bus='elec', size=10), - discharging=fx.Flow('discharge', bus='elec', size=10), - capacity_in_flow_hours=fx.InvestParameters(fixed_size=100), - ) - ) - - # Piecewise converter - converter = fx.linear_converters.Boiler( - 'boiler', thermal_efficiency=0.9, fuel_flow=fx.Flow('gas', bus='elec'), thermal_flow=fx.Flow('heat', bus='heat') - ) - converter.thermal_flow.size = 100 - fs.add_elements(converter) - - # Component with investment - fs.add_elements( - fx.Source( - label='pv', - outputs=[ - fx.Flow( - 'gen', - bus='elec', - size=fx.InvestParameters(maximum_size=1000, effects_of_investment_per_size={'costs': 100}), - ) - ], - ) - ) - - return fs - - -# === Basic Functionality === - - -@pytest.mark.parametrize('freq,method', [('2h', 'mean'), ('4h', 'sum'), ('6h', 'first')]) -def test_basic_resample(simple_fs, freq, method): - """Test basic resampling preserves structure.""" - fs_r = simple_fs.resample(freq, method=method) - assert len(fs_r.components) == len(simple_fs.components) - assert len(fs_r.buses) == len(simple_fs.buses) - assert len(fs_r.timesteps) < len(simple_fs.timesteps) - - -@pytest.mark.parametrize( - 'method,expected', - [ - ('mean', [15.0, 35.0]), - ('sum', [30.0, 70.0]), - ('first', [10.0, 30.0]), - ('last', [20.0, 40.0]), - ], -) -def test_resample_methods(method, expected): - """Test different resampling methods.""" - ts = pd.date_range('2023-01-01', periods=4, freq='h') - fs = fx.FlowSystem(ts) - fs.add_elements(fx.Bus('b'), fx.Effect('costs', unit='€', description='costs', is_objective=True, is_standard=True)) - fs.add_elements( - fx.Sink( - label='s', - inputs=[fx.Flow(label='in', bus='b', fixed_relative_profile=np.array([10.0, 20.0, 30.0, 40.0]), size=1)], - ) - ) - - fs_r = fs.resample('2h', method=method) - assert_allclose(fs_r.flows['s(in)'].fixed_relative_profile.values, expected, rtol=1e-10) - - -def test_structure_preserved(simple_fs): - """Test all structural elements preserved.""" - fs_r = simple_fs.resample('2h', method='mean') - assert set(simple_fs.components.keys()) == set(fs_r.components.keys()) - assert set(simple_fs.buses.keys()) == set(fs_r.buses.keys()) - assert set(simple_fs.effects.keys()) == set(fs_r.effects.keys()) - - # Flow connections preserved - for label in simple_fs.flows.keys(): - assert simple_fs.flows[label].bus == fs_r.flows[label].bus - assert simple_fs.flows[label].component == fs_r.flows[label].component - - -def test_time_metadata_updated(simple_fs): - """Test time metadata correctly updated.""" - fs_r = simple_fs.resample('3h', method='mean') - assert len(fs_r.timesteps) == 8 - assert_allclose(fs_r.timestep_duration.values, 3.0) - assert fs_r.hours_of_last_timestep == 3.0 - - -# === Advanced Dimensions === - - -@pytest.mark.parametrize( - 'dim_name,dim_value', - [ - ('periods', pd.Index([2023, 2024], name='period')), - ('scenarios', pd.Index(['base', 'high'], name='scenario')), - ], -) -def test_with_dimensions(simple_fs, dim_name, dim_value): - """Test resampling preserves period/scenario dimensions.""" - fs = fx.FlowSystem(simple_fs.timesteps, **{dim_name: dim_value}) - fs.add_elements(fx.Bus('h'), fx.Effect('costs', unit='€', description='costs', is_objective=True, is_standard=True)) - fs.add_elements( - fx.Sink(label='d', inputs=[fx.Flow(label='in', bus='h', fixed_relative_profile=np.ones(24), size=1)]) - ) - - fs_r = fs.resample('2h', method='mean') - assert getattr(fs_r, dim_name) is not None - pd.testing.assert_index_equal(getattr(fs_r, dim_name), dim_value) - - -# === Complex Elements === - - -def test_storage_resample(complex_fs): - """Test storage component resampling.""" - fs_r = complex_fs.resample('4h', method='mean') - assert 'battery' in fs_r.components - storage = fs_r.components['battery'] - assert storage.charging.label == 'charge' - assert storage.discharging.label == 'discharge' - - -def test_converter_resample(complex_fs): - """Test converter component resampling.""" - fs_r = complex_fs.resample('4h', method='mean') - assert 'boiler' in fs_r.components - boiler = fs_r.components['boiler'] - assert hasattr(boiler, 'thermal_efficiency') - - -def test_invest_resample(complex_fs): - """Test investment parameters preserved.""" - fs_r = complex_fs.resample('4h', method='mean') - pv_flow = fs_r.flows['pv(gen)'] - assert isinstance(pv_flow.size, fx.InvestParameters) - assert pv_flow.size.maximum_size == 1000 - - -# === Modeling Integration === - - -@pytest.mark.filterwarnings('ignore::DeprecationWarning') -@pytest.mark.parametrize('with_dim', [None, 'periods', 'scenarios']) -def test_modeling(with_dim): - """Test resampled FlowSystem can be modeled.""" - ts = pd.date_range('2023-01-01', periods=48, freq='h') - kwargs = {} - if with_dim == 'periods': - kwargs['periods'] = pd.Index([2023, 2024], name='period') - elif with_dim == 'scenarios': - kwargs['scenarios'] = pd.Index(['base', 'high'], name='scenario') - - fs = fx.FlowSystem(ts, **kwargs) - fs.add_elements(fx.Bus('h'), fx.Effect('costs', unit='€', description='costs', is_objective=True, is_standard=True)) - fs.add_elements( - fx.Sink( - label='d', inputs=[fx.Flow(label='in', bus='h', fixed_relative_profile=np.linspace(10, 30, 48), size=1)] - ), - fx.Source(label='s', outputs=[fx.Flow(label='out', bus='h', size=100, effects_per_flow_hour={'costs': 0.05})]), - ) - - fs_r = fs.resample('4h', method='mean') - calc = fx.Optimization('test', fs_r) - calc.do_modeling() - - assert calc.model is not None - assert len(calc.model.variables) > 0 - - -@pytest.mark.filterwarnings('ignore::DeprecationWarning') -def test_model_structure_preserved(): - """Test model structure (var/constraint types) preserved.""" - ts = pd.date_range('2023-01-01', periods=48, freq='h') - fs = fx.FlowSystem(ts) - fs.add_elements(fx.Bus('h'), fx.Effect('costs', unit='€', description='costs', is_objective=True, is_standard=True)) - fs.add_elements( - fx.Sink( - label='d', inputs=[fx.Flow(label='in', bus='h', fixed_relative_profile=np.linspace(10, 30, 48), size=1)] - ), - fx.Source(label='s', outputs=[fx.Flow(label='out', bus='h', size=100, effects_per_flow_hour={'costs': 0.05})]), - ) - - calc_orig = fx.Optimization('orig', fs) - calc_orig.do_modeling() - - fs_r = fs.resample('4h', method='mean') - calc_r = fx.Optimization('resamp', fs_r) - calc_r.do_modeling() - - # Same number of variable/constraint types - assert len(calc_orig.model.variables) == len(calc_r.model.variables) - assert len(calc_orig.model.constraints) == len(calc_r.model.constraints) - - # Same names - assert set(calc_orig.model.variables.labels.data_vars.keys()) == set(calc_r.model.variables.labels.data_vars.keys()) - assert set(calc_orig.model.constraints.labels.data_vars.keys()) == set( - calc_r.model.constraints.labels.data_vars.keys() - ) - - -# === Advanced Features === - - -def test_dataset_roundtrip(simple_fs): - """Test dataset serialization.""" - fs_r = simple_fs.resample('2h', method='mean') - assert fx.FlowSystem.from_dataset(fs_r.to_dataset()) == fs_r - - -def test_dataset_chaining(simple_fs): - """Test power user pattern.""" - ds = simple_fs.to_dataset() - ds = fx.FlowSystem._dataset_sel(ds, time='2023-01-01') - ds = fx.FlowSystem._dataset_resample(ds, freq='2h', method='mean') - fs_result = fx.FlowSystem.from_dataset(ds) - - fs_simple = simple_fs.sel(time='2023-01-01').resample('2h', method='mean') - assert fs_result == fs_simple - - -@pytest.mark.parametrize('freq,exp_len', [('2h', 84), ('6h', 28), ('1D', 7)]) -def test_frequencies(freq, exp_len): - """Test various frequencies.""" - ts = pd.date_range('2023-01-01', periods=168, freq='h') - fs = fx.FlowSystem(ts) - fs.add_elements(fx.Bus('b'), fx.Effect('costs', unit='€', description='costs', is_objective=True, is_standard=True)) - fs.add_elements( - fx.Sink(label='s', inputs=[fx.Flow(label='in', bus='b', fixed_relative_profile=np.ones(168), size=1)]) - ) - - assert len(fs.resample(freq, method='mean').timesteps) == exp_len - - -def test_irregular_timesteps_error(): - """Test that resampling irregular timesteps to finer resolution raises error without fill_gaps.""" - ts = pd.DatetimeIndex(['2023-01-01 00:00', '2023-01-01 01:00', '2023-01-01 03:00'], name='time') - fs = fx.FlowSystem(ts) - fs.add_elements(fx.Bus('b'), fx.Effect('costs', unit='€', description='costs', is_objective=True, is_standard=True)) - fs.add_elements( - fx.Sink(label='s', inputs=[fx.Flow(label='in', bus='b', fixed_relative_profile=np.ones(3), size=1)]) - ) - - with pytest.raises(ValueError, match='Resampling created gaps'): - fs.resample('1h', method='mean') - - -def test_irregular_timesteps_with_fill_gaps(): - """Test that resampling irregular timesteps works with explicit fill_gaps strategy.""" - ts = pd.DatetimeIndex(['2023-01-01 00:00', '2023-01-01 01:00', '2023-01-01 03:00'], name='time') - fs = fx.FlowSystem(ts) - fs.add_elements(fx.Bus('b'), fx.Effect('costs', unit='€', description='costs', is_objective=True, is_standard=True)) - fs.add_elements( - fx.Sink( - label='s', inputs=[fx.Flow(label='in', bus='b', fixed_relative_profile=np.array([1.0, 2.0, 4.0]), size=1)] - ) - ) - - # Test with ffill (using deprecated method) - fs_r = fs.resample('1h', method='mean', fill_gaps='ffill') - assert len(fs_r.timesteps) == 4 - # Gap at 02:00 should be filled with previous value (2.0) - assert_allclose(fs_r.flows['s(in)'].fixed_relative_profile.values, [1.0, 2.0, 2.0, 4.0]) - - -if __name__ == '__main__': - pytest.main(['-v', __file__]) diff --git a/tests/deprecated/test_functional.py b/tests/deprecated/test_functional.py deleted file mode 100644 index 14be26a4c..000000000 --- a/tests/deprecated/test_functional.py +++ /dev/null @@ -1,747 +0,0 @@ -""" -Unit tests for 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 status behavior. - -### Approach: -1. **Setup**: Each test initializes a flow system with a set of predefined elements and parameters. -2. **Model Creation**: Test-specific flow systems are constructed using `create_model` with datetime arrays. -3. **Solution**: The models are solved using the `solve_and_load` method, which performs modeling, solves the optimization problem, and loads the results. -4. **Validation**: Results are validated using assertions, primarily `assert_allclose`, to ensure model outputs match expected values with a specified tolerance. - -Tests group related cases by their functional focus: -- Minimal modeling setup (`TestMinimal` class) -- Investment behavior (`TestInvestment` class) -- Status operational constraints (functions: `test_startup_shutdown`, `test_consecutive_uptime_downtime`, etc.) -""" - -import numpy as np -import pandas as pd -import pytest -from numpy.testing import assert_allclose - -import flixopt as fx -from tests.deprecated.conftest import assert_almost_equal_numeric - -np.random.seed(45) - - -class Data: - """ - Generates time series data for testing. - - Attributes: - length (int): The desired length of the data. - thermal_demand (np.ndarray): Thermal demand time series data. - electricity_demand (np.ndarray): Electricity demand time series data. - """ - - def __init__(self, length: int): - """ - Initialize the data generator with a specified length. - - Args: - length (int): Length of the time series data to generate. - """ - self.length = length - - self.thermal_demand = np.arange(0, 30, 10) - self.electricity_demand = np.arange(1, 10.1, 1) - - self.thermal_demand = self._adjust_length(self.thermal_demand, length) - self.electricity_demand = self._adjust_length(self.electricity_demand, length) - - def _adjust_length(self, array, new_length: int): - if len(array) >= new_length: - return array[:new_length] - else: - repeats = (new_length + len(array) - 1) // len(array) # Calculate how many times to repeat - extended_array = np.tile(array, repeats) # Repeat the array - return extended_array[:new_length] # Truncate to exact length - - -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', imbalance_penalty_per_flow_hour=None), - fx.Bus('Gas', imbalance_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', - inputs=[fx.Flow(label='Wärme', bus='Fernwärme', fixed_relative_profile=data.thermal_demand, size=1)], - ), - fx.Source(label='Gastarif', outputs=[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', - thermal_efficiency=0.5, - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - thermal_flow=fx.Flow('Q_th', bus='Fernwärme'), - ) - ) - return flow_system - - -def solve_and_load(flow_system: fx.FlowSystem, solver) -> fx.FlowSystem: - """Optimize the flow system and return it with the solution.""" - flow_system.optimize(solver) - return flow_system - - -@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): - flow_system = solve_and_load(flow_system_minimal(time_steps_fixture), solver_fixture) - assert flow_system.solution is not None - - -def test_minimal_model(solver_fixture, time_steps_fixture): - flow_system = solve_and_load(flow_system_minimal(time_steps_fixture), solver_fixture) - - assert_allclose(flow_system.solution['costs'].values, 80, rtol=1e-5, atol=1e-10) - - # Use assert_almost_equal_numeric to handle extra timestep with NaN - assert_almost_equal_numeric( - flow_system.solution['Boiler(Q_th)|flow_rate'].values, - [-0.0, 10.0, 20.0, -0.0, 10.0], - 'Boiler flow_rate doesnt match expected value', - ) - - assert_almost_equal_numeric( - flow_system.solution['costs(temporal)|per_timestep'].values, - [-0.0, 20.0, 40.0, -0.0, 20.0], - 'costs per_timestep doesnt match expected value', - ) - - assert_almost_equal_numeric( - flow_system.solution['Gastarif(Gas)->costs(temporal)'].values, - [-0.0, 20.0, 40.0, -0.0, 20.0], - 'Gastarif costs doesnt match expected value', - ) - - -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', - thermal_efficiency=0.5, - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - thermal_flow=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=fx.InvestParameters(fixed_size=1000, effects_of_investment=10, effects_of_investment_per_size=1), - ), - ) - ) - - solve_and_load(flow_system, solver_fixture) - boiler = flow_system['Boiler'] - costs = flow_system.effects['costs'] - assert_allclose( - costs.submodel.total.solution.item(), - 80 + 1000 * 1 + 10, - rtol=1e-5, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - assert_allclose( - boiler.thermal_flow.submodel.investment.size.solution.item(), - 1000, - rtol=1e-5, - atol=1e-10, - err_msg='"Boiler__Q_th__Investment_size" does not have the right value', - ) - assert_allclose( - boiler.thermal_flow.submodel.investment.invested.solution.item(), - 1, - rtol=1e-5, - atol=1e-10, - err_msg='"Boiler__Q_th__invested" 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', - thermal_efficiency=0.5, - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - thermal_flow=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=fx.InvestParameters(effects_of_investment=10, effects_of_investment_per_size=1, maximum_size=100), - ), - ) - ) - - solve_and_load(flow_system, solver_fixture) - boiler = flow_system['Boiler'] - costs = flow_system.effects['costs'] - assert_allclose( - costs.submodel.total.solution.item(), - 80 + 20 * 1 + 10, - rtol=1e-5, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - assert_allclose( - boiler.thermal_flow.submodel.investment.size.solution.item(), - 20, - rtol=1e-5, - atol=1e-10, - err_msg='"Boiler__Q_th__Investment_size" does not have the right value', - ) - assert_allclose( - boiler.thermal_flow.submodel.investment.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', - thermal_efficiency=0.5, - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - thermal_flow=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=fx.InvestParameters( - minimum_size=40, maximum_size=100, effects_of_investment=10, effects_of_investment_per_size=1 - ), - ), - ) - ) - - solve_and_load(flow_system, solver_fixture) - boiler = flow_system['Boiler'] - costs = flow_system.effects['costs'] - assert_allclose( - costs.submodel.total.solution.item(), - 80 + 40 * 1 + 10, - rtol=1e-5, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - assert_allclose( - boiler.thermal_flow.submodel.investment.size.solution.item(), - 40, - rtol=1e-5, - atol=1e-10, - err_msg='"Boiler__Q_th__Investment_size" does not have the right value', - ) - assert_allclose( - boiler.thermal_flow.submodel.investment.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', - thermal_efficiency=0.5, - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - thermal_flow=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=fx.InvestParameters( - mandatory=False, - minimum_size=40, - maximum_size=100, - effects_of_investment=10, - effects_of_investment_per_size=1, - ), - ), - ), - fx.linear_converters.Boiler( - 'Boiler_optional', - thermal_efficiency=0.5, - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - thermal_flow=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=fx.InvestParameters( - mandatory=False, - minimum_size=50, - maximum_size=100, - effects_of_investment=10, - effects_of_investment_per_size=1, - ), - ), - ), - ) - - solve_and_load(flow_system, solver_fixture) - boiler = flow_system['Boiler'] - boiler_optional = flow_system['Boiler_optional'] - costs = flow_system.effects['costs'] - assert_allclose( - costs.submodel.total.solution.item(), - 80 + 40 * 1 + 10, - rtol=1e-5, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - assert_allclose( - boiler.thermal_flow.submodel.investment.size.solution.item(), - 40, - rtol=1e-5, - atol=1e-10, - err_msg='"Boiler__Q_th__Investment_size" does not have the right value', - ) - assert_allclose( - boiler.thermal_flow.submodel.investment.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.thermal_flow.submodel.investment.size.solution.item(), - 0, - rtol=1e-5, - atol=1e-10, - err_msg='"Boiler__Q_th__Investment_size" does not have the right value', - ) - assert_allclose( - boiler_optional.thermal_flow.submodel.investment.invested.solution.item(), - 0, - rtol=1e-5, - atol=1e-10, - err_msg='"Boiler__Q_th__IsInvested" does not have the right value', - ) - - -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', - thermal_efficiency=0.5, - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - thermal_flow=fx.Flow('Q_th', bus='Fernwärme', size=100, status_parameters=fx.StatusParameters()), - ) - ) - - solve_and_load(flow_system, solver_fixture) - boiler = flow_system['Boiler'] - costs = flow_system.effects['costs'] - assert_allclose( - costs.submodel.total.solution.item(), - 80, - rtol=1e-5, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - - assert_allclose( - boiler.thermal_flow.submodel.status.status.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.thermal_flow.submodel.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', - thermal_efficiency=0.5, - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - thermal_flow=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=100, - status_parameters=fx.StatusParameters(max_downtime=100), - ), - ) - ) - - solve_and_load(flow_system, solver_fixture) - boiler = flow_system['Boiler'] - costs = flow_system.effects['costs'] - assert_allclose( - costs.submodel.total.solution.item(), - 80, - rtol=1e-5, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - - assert_allclose( - boiler.thermal_flow.submodel.status.status.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.thermal_flow.submodel.status.inactive.solution.values, - 1 - boiler.thermal_flow.submodel.status.status.solution.values, - rtol=1e-5, - atol=1e-10, - err_msg='"Boiler__Q_th__off" does not have the right value', - ) - assert_allclose( - boiler.thermal_flow.submodel.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_startup_shutdown(solver_fixture, time_steps_fixture): - """Tests if the startup/shutdown 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', - thermal_efficiency=0.5, - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - thermal_flow=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=100, - status_parameters=fx.StatusParameters(force_startup_tracking=True), - ), - ) - ) - - solve_and_load(flow_system, solver_fixture) - boiler = flow_system['Boiler'] - costs = flow_system.effects['costs'] - assert_allclose( - costs.submodel.total.solution.item(), - 80, - rtol=1e-5, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - - assert_allclose( - boiler.thermal_flow.submodel.status.status.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.thermal_flow.submodel.status.startup.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.thermal_flow.submodel.status.shutdown.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.thermal_flow.submodel.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', - thermal_efficiency=0.5, - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - thermal_flow=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=100, - status_parameters=fx.StatusParameters(active_hours_max=1), - ), - ), - fx.linear_converters.Boiler( - 'Boiler_backup', - thermal_efficiency=0.2, - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - thermal_flow=fx.Flow('Q_th', bus='Fernwärme', size=100), - ), - ) - - solve_and_load(flow_system, solver_fixture) - boiler = flow_system['Boiler'] - costs = flow_system.effects['costs'] - assert_allclose( - costs.submodel.total.solution.item(), - 140, - rtol=1e-5, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - - assert_allclose( - boiler.thermal_flow.submodel.status.status.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.thermal_flow.submodel.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', - thermal_efficiency=0.5, - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - thermal_flow=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=100, - status_parameters=fx.StatusParameters(active_hours_max=2), - ), - ), - fx.linear_converters.Boiler( - 'Boiler_backup', - thermal_efficiency=0.2, - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - thermal_flow=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=100, - status_parameters=fx.StatusParameters(active_hours_min=3), - ), - ), - ) - flow_system['Wärmelast'].inputs[0].fixed_relative_profile = np.array( - [0, 10, 20, 0, 12] - ) # Else its non deterministic - - solve_and_load(flow_system, solver_fixture) - boiler = flow_system['Boiler'] - boiler_backup = flow_system['Boiler_backup'] - costs = flow_system.effects['costs'] - assert_allclose( - costs.submodel.total.solution.item(), - 114, - rtol=1e-5, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - - assert_allclose( - boiler.thermal_flow.submodel.status.status.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.thermal_flow.submodel.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.thermal_flow.submodel.status.status.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.thermal_flow.submodel.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_uptime_downtime(solver_fixture, time_steps_fixture): - """Tests if the consecutive uptime/downtime 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', - thermal_efficiency=0.5, - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - thermal_flow=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=100, - previous_flow_rate=0, # Required for initial constraint - status_parameters=fx.StatusParameters(max_uptime=2, min_uptime=2), - ), - ), - fx.linear_converters.Boiler( - 'Boiler_backup', - thermal_efficiency=0.2, - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - thermal_flow=fx.Flow('Q_th', bus='Fernwärme', size=100), - ), - ) - flow_system['Wärmelast'].inputs[0].fixed_relative_profile = np.array([5, 10, 20, 18, 12]) - # Else its non deterministic - - solve_and_load(flow_system, solver_fixture) - boiler = flow_system['Boiler'] - boiler_backup = flow_system['Boiler_backup'] - costs = flow_system.effects['costs'] - assert_allclose( - costs.submodel.total.solution.item(), - 190, - rtol=1e-5, - atol=1e-10, - err_msg='The total costs does not have the right value', - ) - - assert_allclose( - boiler.thermal_flow.submodel.status.status.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.thermal_flow.submodel.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.thermal_flow.submodel.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', - thermal_efficiency=0.5, - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - thermal_flow=fx.Flow('Q_th', bus='Fernwärme'), - ), - fx.linear_converters.Boiler( - 'Boiler_backup', - thermal_efficiency=0.2, - fuel_flow=fx.Flow('Q_fu', bus='Gas'), - thermal_flow=fx.Flow( - 'Q_th', - bus='Fernwärme', - size=100, - previous_flow_rate=np.array([20]), # Otherwise its Off before the start - status_parameters=fx.StatusParameters(max_downtime=2, min_downtime=2), - ), - ), - ) - flow_system['Wärmelast'].inputs[0].fixed_relative_profile = np.array( - [5, 0, 20, 18, 12] - ) # Else its non deterministic - - solve_and_load(flow_system, solver_fixture) - boiler = flow_system['Boiler'] - boiler_backup = flow_system['Boiler_backup'] - costs = flow_system.effects['costs'] - assert_allclose( - costs.submodel.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.thermal_flow.submodel.status.status.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.thermal_flow.submodel.status.inactive.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.thermal_flow.submodel.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.thermal_flow.submodel.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__': - pytest.main(['-v', '--disable-warnings']) diff --git a/tests/deprecated/test_heatmap_reshape.py b/tests/deprecated/test_heatmap_reshape.py deleted file mode 100644 index 092adff4e..000000000 --- a/tests/deprecated/test_heatmap_reshape.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Test reshape_data_for_heatmap() for common use cases.""" - -import numpy as np -import pandas as pd -import pytest -import xarray as xr - -from flixopt.plotting import reshape_data_for_heatmap - -# Set random seed for reproducible tests -np.random.seed(42) - - -@pytest.fixture -def hourly_week_data(): - """Typical use case: hourly data for a week.""" - time = pd.date_range('2024-01-01', periods=168, freq='h') - data = np.random.rand(168) * 100 - return xr.DataArray(data, dims=['time'], coords={'time': time}, name='power') - - -def test_daily_hourly_pattern(): - """Most common use case: reshape hourly data into days × hours for daily patterns.""" - time = pd.date_range('2024-01-01', periods=72, freq='h') - data = np.random.rand(72) * 100 - da = xr.DataArray(data, dims=['time'], coords={'time': time}) - - result = reshape_data_for_heatmap(da, reshape_time=('D', 'h')) - - assert 'timeframe' in result.dims and 'timestep' in result.dims - assert result.sizes['timeframe'] == 3 # 3 days - assert result.sizes['timestep'] == 24 # 24 hours - - -def test_weekly_daily_pattern(hourly_week_data): - """Common use case: reshape hourly data into weeks × days.""" - result = reshape_data_for_heatmap(hourly_week_data, reshape_time=('W', 'D')) - - assert 'timeframe' in result.dims and 'timestep' in result.dims - # 168 hours = 7 days = 1 week - assert result.sizes['timeframe'] == 1 # 1 week - assert result.sizes['timestep'] == 7 # 7 days - - -def test_with_irregular_data(): - """Real-world use case: data with missing timestamps needs filling.""" - time = pd.date_range('2024-01-01', periods=100, freq='15min') - data = np.random.rand(100) - # Randomly drop 30% to simulate real data gaps - keep = np.sort(np.random.choice(100, 70, replace=False)) # Must be sorted - da = xr.DataArray(data[keep], dims=['time'], coords={'time': time[keep]}) - - result = reshape_data_for_heatmap(da, reshape_time=('h', 'min'), fill='ffill') - - assert 'timeframe' in result.dims and 'timestep' in result.dims - # 100 * 15min = 1500min = 25h; reshaped to hours × minutes - assert result.sizes['timeframe'] == 25 # 25 hours - assert result.sizes['timestep'] == 60 # 60 minutes per hour - # Should handle irregular data without errors - - -def test_multidimensional_scenarios(): - """Use case: data with scenarios/periods that need to be preserved.""" - time = pd.date_range('2024-01-01', periods=48, freq='h') - scenarios = ['base', 'high'] - data = np.random.rand(48, 2) * 100 - - da = xr.DataArray(data, dims=['time', 'scenario'], coords={'time': time, 'scenario': scenarios}, name='demand') - - result = reshape_data_for_heatmap(da, reshape_time=('D', 'h')) - - # Should preserve scenario dimension - assert 'scenario' in result.dims - assert result.sizes['scenario'] == 2 - # 48 hours = 2 days × 24 hours - assert result.sizes['timeframe'] == 2 # 2 days - assert result.sizes['timestep'] == 24 # 24 hours - - -def test_no_reshape_returns_unchanged(): - """Use case: when reshape_time=None, return data as-is.""" - time = pd.date_range('2024-01-01', periods=24, freq='h') - da = xr.DataArray(np.random.rand(24), dims=['time'], coords={'time': time}) - - result = reshape_data_for_heatmap(da, reshape_time=None) - - xr.testing.assert_equal(result, da) - - -if __name__ == '__main__': - pytest.main([__file__, '-v']) diff --git a/tests/deprecated/test_integration.py b/tests/deprecated/test_integration.py deleted file mode 100644 index e49c977bc..000000000 --- a/tests/deprecated/test_integration.py +++ /dev/null @@ -1,315 +0,0 @@ -"""Tests for deprecated Optimization/Results API - ported from feature/v5. - -This module contains the original integration tests from feature/v5 that use the -deprecated Optimization class. These tests will be removed in v6.0.0. - -For new tests, use FlowSystem.optimize(solver) instead. -""" - -import pytest - -import flixopt as fx - -from ..conftest import ( - assert_almost_equal_numeric, - create_optimization_and_solve, -) - - -class TestFlowSystem: - def test_simple_flow_system(self, simple_flow_system, highs_solver): - """ - Test the effects of the simple energy system model - """ - optimization = create_optimization_and_solve(simple_flow_system, highs_solver, 'test_simple_flow_system') - - effects = optimization.flow_system.effects - - # Cost assertions - assert_almost_equal_numeric( - effects['costs'].submodel.total.solution.item(), 81.88394666666667, 'costs doesnt match expected value' - ) - - # CO2 assertions - assert_almost_equal_numeric( - effects['CO2'].submodel.total.solution.item(), 255.09184, 'CO2 doesnt match expected value' - ) - - def test_model_components(self, simple_flow_system, highs_solver): - """ - Test the component flows of the simple energy system model - """ - optimization = create_optimization_and_solve(simple_flow_system, highs_solver, 'test_model_components') - comps = optimization.flow_system.components - - # Boiler assertions - assert_almost_equal_numeric( - comps['Boiler'].thermal_flow.submodel.flow_rate.solution.values, - [0, 0, 0, 28.4864, 35, 0, 0, 0, 0], - 'Q_th doesnt match expected value', - ) - - # CHP unit assertions - assert_almost_equal_numeric( - comps['CHP_unit'].thermal_flow.submodel.flow_rate.solution.values, - [30.0, 26.66666667, 75.0, 75.0, 75.0, 20.0, 20.0, 20.0, 20.0], - 'Q_th doesnt match expected value', - ) - - def test_results_persistence(self, simple_flow_system, highs_solver): - """ - Test saving and loading results - """ - # Save results to file - optimization = create_optimization_and_solve(simple_flow_system, highs_solver, 'test_model_components') - - optimization.results.to_file(overwrite=True) - - # Load results from file - results = fx.results.Results.from_file(optimization.folder, optimization.name) - - # Verify key variables from loaded results - assert_almost_equal_numeric( - results.solution['costs'].values, - 81.88394666666667, - 'costs doesnt match expected value', - ) - assert_almost_equal_numeric(results.solution['CO2'].values, 255.09184, 'CO2 doesnt match expected value') - - -class TestComplex: - def test_basic_flow_system(self, flow_system_base, highs_solver): - optimization = create_optimization_and_solve(flow_system_base, highs_solver, 'test_basic_flow_system') - - # Assertions - assert_almost_equal_numeric( - optimization.results.model['costs'].solution.item(), - -11597.873624489237, - 'costs doesnt match expected value', - ) - - assert_almost_equal_numeric( - optimization.results.model['costs(temporal)|per_timestep'].solution.values, - [ - -2.38500000e03, - -2.21681333e03, - -2.38500000e03, - -2.17599000e03, - -2.35107029e03, - -2.38500000e03, - 0.00000000e00, - -1.68897826e-10, - -2.16914486e-12, - ], - 'costs doesnt match expected value', - ) - - assert_almost_equal_numeric( - sum(optimization.results.model['CO2(temporal)->costs(temporal)'].solution.values), - 258.63729669618675, - 'costs doesnt match expected value', - ) - assert_almost_equal_numeric( - sum(optimization.results.model['Kessel(Q_th)->costs(temporal)'].solution.values), - 0.01, - 'costs doesnt match expected value', - ) - assert_almost_equal_numeric( - sum(optimization.results.model['Kessel->costs(temporal)'].solution.values), - -0.0, - 'costs doesnt match expected value', - ) - assert_almost_equal_numeric( - sum(optimization.results.model['Gastarif(Q_Gas)->costs(temporal)'].solution.values), - 39.09153113079115, - 'costs doesnt match expected value', - ) - assert_almost_equal_numeric( - sum(optimization.results.model['Einspeisung(P_el)->costs(temporal)'].solution.values), - -14196.61245231646, - 'costs doesnt match expected value', - ) - assert_almost_equal_numeric( - sum(optimization.results.model['KWK->costs(temporal)'].solution.values), - 0.0, - 'costs doesnt match expected value', - ) - - assert_almost_equal_numeric( - optimization.results.model['Kessel(Q_th)->costs(periodic)'].solution.values, - 1000 + 500, - 'costs doesnt match expected value', - ) - - assert_almost_equal_numeric( - optimization.results.model['Speicher->costs(periodic)'].solution.values, - 800 + 1, - 'costs doesnt match expected value', - ) - - assert_almost_equal_numeric( - optimization.results.model['CO2(temporal)'].solution.values, - 1293.1864834809337, - 'CO2 doesnt match expected value', - ) - assert_almost_equal_numeric( - optimization.results.model['CO2(periodic)'].solution.values, - 0.9999999999999994, - 'CO2 doesnt match expected value', - ) - assert_almost_equal_numeric( - optimization.results.model['Kessel(Q_th)|flow_rate'].solution.values, - [0, 0, 0, 45, 0, 0, 0, 0, 0], - 'Kessel doesnt match expected value', - ) - - assert_almost_equal_numeric( - optimization.results.model['KWK(Q_th)|flow_rate'].solution.values, - [ - 7.50000000e01, - 6.97111111e01, - 7.50000000e01, - 7.50000000e01, - 7.39330280e01, - 7.50000000e01, - 0.00000000e00, - 3.12638804e-14, - 3.83693077e-14, - ], - 'KWK Q_th doesnt match expected value', - ) - assert_almost_equal_numeric( - optimization.results.model['KWK(P_el)|flow_rate'].solution.values, - [ - 6.00000000e01, - 5.57688889e01, - 6.00000000e01, - 6.00000000e01, - 5.91464224e01, - 6.00000000e01, - 0.00000000e00, - 2.50111043e-14, - 3.06954462e-14, - ], - 'KWK P_el doesnt match expected value', - ) - - assert_almost_equal_numeric( - optimization.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', - ) - assert_almost_equal_numeric( - optimization.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', - ) - - assert_almost_equal_numeric( - optimization.results.model['Speicher|PiecewiseEffects|costs'].solution.values, - 800, - 'Speicher|PiecewiseEffects|costs doesnt match expected value', - ) - - def test_piecewise_conversion(self, flow_system_piecewise_conversion, highs_solver): - optimization = create_optimization_and_solve( - flow_system_piecewise_conversion, highs_solver, 'test_piecewise_conversion' - ) - - effects = optimization.flow_system.effects - comps = optimization.flow_system.components - - # Compare expected values with actual values - assert_almost_equal_numeric( - effects['costs'].submodel.total.solution.item(), -10710.997365760755, 'costs doesnt match expected value' - ) - assert_almost_equal_numeric( - effects['CO2'].submodel.total.solution.item(), 1278.7939026086956, 'CO2 doesnt match expected value' - ) - assert_almost_equal_numeric( - comps['Kessel'].thermal_flow.submodel.flow_rate.solution.values, - [0, 0, 0, 45, 0, 0, 0, 0, 0], - 'Kessel doesnt match expected value', - ) - kwk_flows = {flow.label: flow for flow in (comps['KWK'].inputs + comps['KWK'].outputs).values()} - assert_almost_equal_numeric( - kwk_flows['Q_th'].submodel.flow_rate.solution.values, - [45.0, 45.0, 64.5962087, 100.0, 61.3136, 45.0, 45.0, 12.86469565, 0.0], - 'KWK Q_th doesnt match expected value', - ) - assert_almost_equal_numeric( - kwk_flows['P_el'].submodel.flow_rate.solution.values, - [40.0, 40.0, 47.12589407, 60.0, 45.93221818, 40.0, 40.0, 10.91784108, -0.0], - 'KWK P_el doesnt match expected value', - ) - - assert_almost_equal_numeric( - comps['Speicher'].submodel.netto_discharge.solution.values, - [-15.0, -45.0, 25.4037913, -35.0, 48.6864, -25.0, -25.0, 7.13530435, 20.0], - 'Speicher nettoFlow doesnt match expected value', - ) - - assert_almost_equal_numeric( - comps['Speicher'].submodel.variables['Speicher|PiecewiseEffects|costs'].solution.values, - 454.74666666666667, - 'Speicher investcosts_segmented_costs doesnt match expected value', - ) - - -@pytest.mark.slow -class TestModelingTypes: - # Note: 'aggregated' case removed - ClusteredOptimization has been replaced by - # FlowSystem.transform.cluster(). See tests/test_clustering/ for new clustering tests. - @pytest.fixture(params=['full', 'segmented']) - def modeling_calculation(self, request, flow_system_long, highs_solver): - """ - Fixture to run optimizations with different modeling types - """ - # Extract flow system and data from the fixture - flow_system = flow_system_long[0] - - # Create calculation based on modeling type - modeling_type = request.param - if modeling_type == 'full': - calc = fx.Optimization('fullModel', flow_system) - calc.do_modeling() - calc.solve(highs_solver) - elif modeling_type == 'segmented': - calc = fx.SegmentedOptimization('segModel', flow_system, timesteps_per_segment=96, overlap_timesteps=1) - calc.do_modeling_and_solve(highs_solver) - - 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 - } - - if modeling_type == 'full': - assert_almost_equal_numeric( - calc.results.model['costs'].solution.item(), - expected_costs[modeling_type], - f'costs do not match for {modeling_type} modeling type', - ) - elif modeling_type == 'segmented': - assert_almost_equal_numeric( - calc.results.solution_without_overlap('costs(temporal)|per_timestep').sum(), - expected_costs[modeling_type], - f'costs do not match for {modeling_type} modeling type', - ) - - def test_segmented_io(self, modeling_calculation): - calc, modeling_type = modeling_calculation - if modeling_type == 'segmented': - calc.results.to_file(overwrite=True) - _ = fx.results.SegmentedResults.from_file(calc.folder, calc.name) - - -if __name__ == '__main__': - pytest.main(['-v']) diff --git a/tests/deprecated/test_io.py b/tests/deprecated/test_io.py deleted file mode 100644 index 9a00549d7..000000000 --- a/tests/deprecated/test_io.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Tests for I/O functionality. - -Tests for deprecated Results.to_file() and Results.from_file() API -have been moved to tests/deprecated/test_results_io.py. -""" - -import pytest - -import flixopt as fx - -from .conftest import ( - flow_system_base, - flow_system_long, - flow_system_segments_of_flows_2, - simple_flow_system, - simple_flow_system_scenarios, -) - - -@pytest.fixture( - params=[ - flow_system_base, - simple_flow_system_scenarios, - 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] - - -def test_flow_system_io(flow_system): - flow_system.to_json('fs.json') - - ds = flow_system.to_dataset() - new_fs = fx.FlowSystem.from_dataset(ds) - - assert flow_system == new_fs - - print(flow_system) - flow_system.__repr__() - flow_system.__str__() - - -def test_suppress_output_file_descriptors(tmp_path): - """Test that suppress_output() redirects file descriptors to /dev/null.""" - import os - - from flixopt.io import suppress_output - - # Create temporary files to capture output - test_file = tmp_path / 'test_output.txt' - - # Test that FD 1 (stdout) is redirected during suppression - with open(test_file, 'w') as f: - original_stdout_fd = os.dup(1) # Save original stdout FD - try: - # Redirect FD 1 to our test file - os.dup2(f.fileno(), 1) - os.write(1, b'before suppression\n') - - with suppress_output(): - # Inside suppress_output, writes should go to /dev/null, not our file - os.write(1, b'during suppression\n') - - # After suppress_output, writes should go to our file again - os.write(1, b'after suppression\n') - finally: - # Restore original stdout - os.dup2(original_stdout_fd, 1) - os.close(original_stdout_fd) - - # Read the file and verify content - content = test_file.read_text() - assert 'before suppression' in content - assert 'during suppression' not in content # This should NOT be in the file - assert 'after suppression' in content - - -def test_suppress_output_python_level(): - """Test that Python-level stdout/stderr continue to work after suppress_output().""" - import io - import sys - - from flixopt.io import suppress_output - - # Create a StringIO to capture Python-level output - captured_output = io.StringIO() - - # After suppress_output exits, Python streams should be functional - with suppress_output(): - pass # Just enter and exit the context - - # Redirect sys.stdout to our StringIO - old_stdout = sys.stdout - try: - sys.stdout = captured_output - print('test message') - finally: - sys.stdout = old_stdout - - # Verify Python-level stdout works - assert 'test message' in captured_output.getvalue() - - -def test_suppress_output_exception_handling(): - """Test that suppress_output() properly restores streams even on exception.""" - import sys - - from flixopt.io import suppress_output - - # Save original file descriptors - original_stdout_fd = sys.stdout.fileno() - original_stderr_fd = sys.stderr.fileno() - - try: - with suppress_output(): - raise ValueError('Test exception') - except ValueError: - pass - - # Verify streams are restored after exception - assert sys.stdout.fileno() == original_stdout_fd - assert sys.stderr.fileno() == original_stderr_fd - - # Verify we can still write to stdout/stderr - sys.stdout.write('test after exception\n') - sys.stdout.flush() - - -def test_suppress_output_c_level(): - """Test that suppress_output() suppresses C-level output (file descriptor level).""" - import os - import sys - - from flixopt.io import suppress_output - - # This test verifies that even low-level C writes are suppressed - # by writing directly to file descriptor 1 (stdout) - with suppress_output(): - # Try to write directly to FD 1 (stdout) - should be suppressed - os.write(1, b'C-level stdout write\n') - # Try to write directly to FD 2 (stderr) - should be suppressed - os.write(2, b'C-level stderr write\n') - - # After exiting context, ensure streams work - sys.stdout.write('After C-level test\n') - sys.stdout.flush() - - -def test_tqdm_cleanup_on_exception(): - """Test that tqdm progress bar is properly cleaned up even when exceptions occur. - - This test verifies the pattern used in SegmentedCalculation where a try/finally - block ensures progress_bar.close() is called even if an exception occurs. - """ - from tqdm import tqdm - - # Create a progress bar (disabled to avoid output during tests) - items = enumerate(range(5)) - progress_bar = tqdm(items, total=5, desc='Test progress', disable=True) - - # Track whether cleanup was called - cleanup_called = False - exception_raised = False - - try: - try: - for idx, _ in progress_bar: - if idx == 2: - raise ValueError('Test exception') - finally: - # This should always execute, even with exception - progress_bar.close() - cleanup_called = True - except ValueError: - exception_raised = True - - # Verify both that the exception was raised AND cleanup happened - assert exception_raised, 'Test exception should have been raised' - assert cleanup_called, 'Cleanup should have been called even with exception' - - # Verify that close() is idempotent - calling it again should not raise - progress_bar.close() # Should not raise even if already closed - - -if __name__ == '__main__': - pytest.main(['-v', '--disable-warnings']) diff --git a/tests/deprecated/test_linear_converter.py b/tests/deprecated/test_linear_converter.py deleted file mode 100644 index 76a45553e..000000000 --- a/tests/deprecated/test_linear_converter.py +++ /dev/null @@ -1,502 +0,0 @@ -import numpy as np -import pytest -import xarray as xr - -import flixopt as fx - -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_coords, coords_config): - """Test basic initialization and modeling of a LinearConverter.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - # 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.submodel.flow_rate * 0.8 == output_flow.submodel.flow_rate * 1.0, - ) - - def test_linear_converter_time_varying(self, basic_flow_system_linopy_coords, coords_config): - """Test a LinearConverter with time-varying conversion factors.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.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.submodel.flow_rate * efficiency_series == output_flow.submodel.flow_rate * 1.0, - ) - - def test_linear_converter_multiple_factors(self, basic_flow_system_linopy_coords, coords_config): - """Test a LinearConverter with multiple conversion factors.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - # 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.submodel.flow_rate * 0.8 == output_flow1.submodel.flow_rate * 1.0, - ) - - # Check conversion constraint 2 (input2 * 0.5 == output2 * 1.0) - assert_conequal( - model.constraints['Converter|conversion_1'], - input_flow2.submodel.flow_rate * 0.5 == output_flow2.submodel.flow_rate * 1.0, - ) - - # Check conversion constraint 3 (input1 * 0.2 == output2 * 0.3) - assert_conequal( - model.constraints['Converter|conversion_2'], - input_flow1.submodel.flow_rate * 0.2 == output_flow2.submodel.flow_rate * 0.3, - ) - - def test_linear_converter_with_status(self, basic_flow_system_linopy_coords, coords_config): - """Test a LinearConverter with StatusParameters.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - # 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 StatusParameters - status_params = fx.StatusParameters( - active_hours_min=10, active_hours_max=40, effects_per_active_hour={'costs': 5} - ) - - # Create a linear converter with StatusParameters - converter = fx.LinearConverter( - label='Converter', - inputs=[input_flow], - outputs=[output_flow], - conversion_factors=[{input_flow.label: 0.8, output_flow.label: 1.0}], - status_parameters=status_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 Status variables and constraints - assert 'Converter|status' in model.variables - assert 'Converter|active_hours' in model.variables - - # Check active_hours constraint - assert_conequal( - model.constraints['Converter|active_hours'], - model.variables['Converter|active_hours'] - == (model.variables['Converter|status'] * model.timestep_duration).sum('time'), - ) - - # Check conversion constraint - assert_conequal( - model.constraints['Converter|conversion_0'], - input_flow.submodel.flow_rate * 0.8 == output_flow.submodel.flow_rate * 1.0, - ) - - # Check status effects - assert 'Converter->costs(temporal)' in model.constraints - assert_conequal( - model.constraints['Converter->costs(temporal)'], - model.variables['Converter->costs(temporal)'] - == model.variables['Converter|status'] * model.timestep_duration * 5, - ) - - def test_linear_converter_multidimensional(self, basic_flow_system_linopy_coords, coords_config): - """Test LinearConverter with multiple inputs, outputs, and connections between them.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - # 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.submodel.flow_rate * 0.7 == output_flow1.submodel.flow_rate * 1.0, - ) - - assert_conequal( - model.constraints['MultiConverter|conversion_1'], - input_flow2.submodel.flow_rate * 0.3 == output_flow2.submodel.flow_rate * 1.0, - ) - - assert_conequal( - model.constraints['MultiConverter|conversion_2'], - input_flow1.submodel.flow_rate * 0.1 == output_flow2.submodel.flow_rate * 0.5, - ) - - def test_edge_case_time_varying_conversion(self, basic_flow_system_linopy_coords, coords_config): - """Test edge case with extreme time-varying conversion factors.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - timesteps = flow_system.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 - - factor = converter.conversion_factors[0]['electricity'] - - # Data stays in minimal form (1D array stays 1D) - assert factor.dims == ('time',) - - # Verify the constraint has the time-varying coefficient - assert_conequal( - model.constraints['VariableConverter|conversion_0'], - input_flow.submodel.flow_rate * factor == output_flow.submodel.flow_rate * 1.0, - ) - - def test_piecewise_conversion(self, basic_flow_system_linopy_coords, coords_config): - """Test a LinearConverter with PiecewiseConversion.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - # 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 submodel - assert converter.submodel.piecewise_conversion is not None - - # Get the PiecewiseModel instance - piecewise_model = converter.submodel.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=model.get_coords())) - assert_var_equal(lambda0, model.add_variables(lower=0, upper=1, coords=model.get_coords())) - assert_var_equal(lambda1, model.add_variables(lower=0, upper=1, coords=model.get_coords())) - - # 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 status 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_status(self, basic_flow_system_linopy_coords, coords_config): - """Test a LinearConverter with PiecewiseConversion and StatusParameters.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - # 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 StatusParameters - status_params = fx.StatusParameters( - active_hours_min=10, active_hours_max=40, effects_per_active_hour={'costs': 5} - ) - - # Create a linear converter with piecewise conversion and status parameters - converter = fx.LinearConverter( - label='Converter', - inputs=[input_flow], - outputs=[output_flow], - piecewise_conversion=piecewise_conversion, - status_parameters=status_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 submodel - assert converter.submodel.piecewise_conversion is not None - - # Get the PiecewiseModel instance - piecewise_model = converter.submodel.piecewise_conversion - - # Check that we have the expected pieces (2 in this case) - assert len(piecewise_model.pieces) == 2 - - # Verify that the status variable was used as the zero_point for the piecewise model - # When using StatusParameters, the zero_point should be the status variable - assert 'Converter|status' 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=model.get_coords())) - assert_var_equal(lambda0, model.add_variables(lower=0, upper=1, coords=model.get_coords())) - assert_var_equal(lambda1, model.add_variables(lower=0, upper=1, coords=model.get_coords())) - - # 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|status'], - ) - - # Also check that the Status model is working correctly - assert 'Converter|active_hours' in model.constraints - assert_conequal( - model.constraints['Converter|active_hours'], - model['Converter|active_hours'] == (model['Converter|status'] * model.timestep_duration).sum('time'), - ) - - # Verify that the costs effect is applied - assert 'Converter->costs(temporal)' in model.constraints - assert_conequal( - model.constraints['Converter->costs(temporal)'], - model.variables['Converter->costs(temporal)'] - == model.variables['Converter|status'] * model.timestep_duration * 5, - ) - - -if __name__ == '__main__': - pytest.main() diff --git a/tests/deprecated/test_network_app.py b/tests/deprecated/test_network_app.py deleted file mode 100644 index f3f250797..000000000 --- a/tests/deprecated/test_network_app.py +++ /dev/null @@ -1,24 +0,0 @@ -import pytest - -import flixopt as fx - -from .conftest import ( - flow_system_long, - flow_system_segments_of_flows_2, - simple_flow_system, -) - - -@pytest.fixture(params=[simple_flow_system, flow_system_segments_of_flows_2, flow_system_long]) -def flow_system(request): - fs = request.getfixturevalue(request.param.__name__) - if isinstance(fs, fx.FlowSystem): - return fs - else: - return fs[0] - - -def test_network_app(flow_system): - """Test that flow model constraints are correctly generated.""" - flow_system.start_network_app() - flow_system.stop_network_app() diff --git a/tests/deprecated/test_on_hours_computation.py b/tests/deprecated/test_on_hours_computation.py deleted file mode 100644 index 578fd7792..000000000 --- a/tests/deprecated/test_on_hours_computation.py +++ /dev/null @@ -1,99 +0,0 @@ -import numpy as np -import pytest -import xarray as xr - -from flixopt.modeling import ModelingUtilities - - -class TestComputeConsecutiveDuration: - """Tests for the compute_consecutive_hours_in_state static method.""" - - @pytest.mark.parametrize( - 'binary_values, hours_per_timestep, expected', - [ - # Case 1: Single timestep DataArrays - (xr.DataArray([1], dims=['time']), 5, 5), - (xr.DataArray([0], dims=['time']), 3, 0), - # Case 2: Array binary, scalar hours - (xr.DataArray([0, 0, 1, 1, 1, 0], dims=['time']), 2, 0), - (xr.DataArray([0, 1, 1, 0, 1, 1], dims=['time']), 1, 2), - (xr.DataArray([1, 1, 1], dims=['time']), 2, 6), - # Case 3: Edge cases - (xr.DataArray([1], dims=['time']), 4, 4), - (xr.DataArray([0], dims=['time']), 3, 0), - # Case 4: More complex patterns - (xr.DataArray([1, 0, 0, 1, 1, 1], dims=['time']), 2, 6), # 3 consecutive at end * 2 hours - (xr.DataArray([0, 1, 1, 1, 0, 0], dims=['time']), 1, 0), # ends with 0 - ], - ) - def test_compute_duration(self, binary_values, hours_per_timestep, expected): - """Test compute_consecutive_hours_in_state with various inputs.""" - result = ModelingUtilities.compute_consecutive_hours_in_state(binary_values, hours_per_timestep) - assert np.isclose(result, expected) - - @pytest.mark.parametrize( - 'binary_values, hours_per_timestep', - [ - # Case: hours_per_timestep must be scalar - (xr.DataArray([1, 1, 1, 1, 1], dims=['time']), np.array([1, 2])), - ], - ) - def test_compute_duration_raises_error(self, binary_values, hours_per_timestep): - """Test error conditions.""" - with pytest.raises(TypeError): - ModelingUtilities.compute_consecutive_hours_in_state(binary_values, hours_per_timestep) - - -class TestComputePreviousOnStates: - """Tests for the compute_previous_states static method.""" - - @pytest.mark.parametrize( - 'previous_values, expected', - [ - # Case 1: Single value DataArrays - (xr.DataArray([0], dims=['time']), xr.DataArray([0], dims=['time'])), - (xr.DataArray([1], dims=['time']), xr.DataArray([1], dims=['time'])), - (xr.DataArray([0.001], dims=['time']), xr.DataArray([1], dims=['time'])), # Using default epsilon - (xr.DataArray([1e-4], dims=['time']), xr.DataArray([1], dims=['time'])), - (xr.DataArray([1e-8], dims=['time']), xr.DataArray([0], dims=['time'])), - # Case 1: Multiple timestep DataArrays - (xr.DataArray([0, 5, 0], dims=['time']), xr.DataArray([0, 1, 0], dims=['time'])), - (xr.DataArray([0.1, 0, 0.3], dims=['time']), xr.DataArray([1, 0, 1], dims=['time'])), - (xr.DataArray([0, 0, 0], dims=['time']), xr.DataArray([0, 0, 0], dims=['time'])), - (xr.DataArray([0.1, 0, 0.2], dims=['time']), xr.DataArray([1, 0, 1], dims=['time'])), - ], - ) - def test_compute_previous_on_states(self, previous_values, expected): - """Test compute_previous_states with various inputs.""" - result = ModelingUtilities.compute_previous_states(previous_values) - xr.testing.assert_equal(result, expected) - - @pytest.mark.parametrize( - 'previous_values, epsilon, expected', - [ - # Testing with different epsilon values - (xr.DataArray([1e-6, 1e-4, 1e-2], dims=['time']), 1e-3, xr.DataArray([0, 0, 1], dims=['time'])), - (xr.DataArray([1e-6, 1e-4, 1e-2], dims=['time']), 1e-5, xr.DataArray([0, 1, 1], dims=['time'])), - (xr.DataArray([1e-6, 1e-4, 1e-2], dims=['time']), 1e-1, xr.DataArray([0, 0, 0], dims=['time'])), - # Mixed case with custom epsilon - (xr.DataArray([0.05, 0.005, 0.0005], dims=['time']), 0.01, xr.DataArray([1, 0, 0], dims=['time'])), - ], - ) - def test_compute_previous_on_states_with_epsilon(self, previous_values, epsilon, expected): - """Test compute_previous_states with custom epsilon values.""" - result = ModelingUtilities.compute_previous_states(previous_values, epsilon) - xr.testing.assert_equal(result, expected) - - @pytest.mark.parametrize( - 'previous_values, expected_shape', - [ - # Check that output shapes match expected dimensions - (xr.DataArray([0, 1, 0, 1], dims=['time']), (4,)), - (xr.DataArray([0, 1], dims=['time']), (2,)), - (xr.DataArray([1, 0], dims=['time']), (2,)), - ], - ) - def test_output_shapes(self, previous_values, expected_shape): - """Test that output array has the correct shape.""" - result = ModelingUtilities.compute_previous_states(previous_values) - assert result.shape == expected_shape diff --git a/tests/deprecated/test_plotting_api.py b/tests/deprecated/test_plotting_api.py deleted file mode 100644 index 141623cae..000000000 --- a/tests/deprecated/test_plotting_api.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Smoke tests for plotting API robustness improvements.""" - -import numpy as np -import pandas as pd -import pytest -import xarray as xr - -from flixopt import plotting - - -@pytest.fixture -def sample_dataset(): - """Create a sample xarray Dataset for testing.""" - rng = np.random.default_rng(0) - time = np.arange(10) - data = xr.Dataset( - { - 'var1': (['time'], rng.random(10)), - 'var2': (['time'], rng.random(10)), - 'var3': (['time'], rng.random(10)), - }, - coords={'time': time}, - ) - return data - - -@pytest.fixture -def sample_dataframe(): - """Create a sample pandas DataFrame for testing.""" - rng = np.random.default_rng(1) - time = np.arange(10) - df = pd.DataFrame({'var1': rng.random(10), 'var2': rng.random(10), 'var3': rng.random(10)}, index=time) - df.index.name = 'time' - return df - - -def test_kwargs_passthrough_plotly(sample_dataset): - """Test that px_kwargs are passed through and figure can be customized after creation.""" - # Test that px_kwargs are passed through - fig = plotting.with_plotly( - sample_dataset, - mode='line', - range_y=[0, 100], - ) - assert list(fig.layout.yaxis.range) == [0, 100] - - # Test that figure can be customized after creation - fig.update_traces(line={'width': 5}) - fig.update_layout(width=1200, height=600) - assert fig.layout.width == 1200 - assert fig.layout.height == 600 - assert all(getattr(t, 'line', None) and t.line.width == 5 for t in fig.data) - - -def test_dataframe_support_plotly(sample_dataframe): - """Test that DataFrames are accepted by plotting functions.""" - fig = plotting.with_plotly(sample_dataframe, mode='line') - assert fig is not None - - -def test_data_validation_non_numeric(): - """Test that validation catches non-numeric data.""" - data = xr.Dataset({'var1': (['time'], ['a', 'b', 'c'])}, coords={'time': [0, 1, 2]}) - - with pytest.raises(TypeError, match='non-?numeric'): - plotting.with_plotly(data) - - -def test_ensure_dataset_invalid_type(): - """Test that invalid types raise error via the public API.""" - with pytest.raises(TypeError, match='xr\\.Dataset|pd\\.DataFrame'): - plotting.with_plotly([1, 2, 3], mode='line') - - -@pytest.mark.parametrize( - 'engine,mode,data_type', - [ - *[ - (e, m, dt) - for e in ['plotly', 'matplotlib'] - for m in ['stacked_bar', 'line', 'area', 'grouped_bar'] - for dt in ['dataset', 'dataframe', 'series'] - if not (e == 'matplotlib' and m in ['area', 'grouped_bar']) - ], - ], -) -def test_all_data_types_and_modes(engine, mode, data_type): - """Test that Dataset, DataFrame, and Series work with all plotting modes.""" - time = pd.date_range('2020-01-01', periods=5, freq='h') - - data = { - 'dataset': xr.Dataset( - {'A': (['time'], [1, 2, 3, 4, 5]), 'B': (['time'], [5, 4, 3, 2, 1])}, coords={'time': time} - ), - 'dataframe': pd.DataFrame({'A': [1, 2, 3, 4, 5], 'B': [5, 4, 3, 2, 1]}, index=time), - 'series': pd.Series([1, 2, 3, 4, 5], index=time, name='A'), - }[data_type] - - if engine == 'plotly': - fig = plotting.with_plotly(data, mode=mode) - assert fig is not None and len(fig.data) > 0 - else: - fig, ax = plotting.with_matplotlib(data, mode=mode) - assert fig is not None and ax is not None - - -@pytest.mark.parametrize( - 'engine,data_type', [(e, dt) for e in ['plotly', 'matplotlib'] for dt in ['dataset', 'dataframe', 'series']] -) -def test_pie_plots(engine, data_type): - """Test pie charts with all data types, including automatic summing.""" - time = pd.date_range('2020-01-01', periods=5, freq='h') - - # Single-value data - single_data = { - 'dataset': xr.Dataset({'A': xr.DataArray(10), 'B': xr.DataArray(20), 'C': xr.DataArray(30)}), - 'dataframe': pd.DataFrame({'A': [10], 'B': [20], 'C': [30]}), - 'series': pd.Series({'A': 10, 'B': 20, 'C': 30}), - }[data_type] - - # Multi-dimensional data (for summing test) - multi_data = { - 'dataset': xr.Dataset( - {'A': (['time'], [1, 2, 3, 4, 5]), 'B': (['time'], [5, 5, 5, 5, 5])}, coords={'time': time} - ), - 'dataframe': pd.DataFrame({'A': [1, 2, 3, 4, 5], 'B': [5, 5, 5, 5, 5]}, index=time), - 'series': pd.Series([1, 2, 3, 4, 5], index=time, name='A'), - }[data_type] - - for data in [single_data, multi_data]: - if engine == 'plotly': - fig = plotting.dual_pie_with_plotly(data, data) - assert fig is not None and len(fig.data) >= 2 - if data is multi_data and data_type != 'series': - assert sum(fig.data[0].values) == pytest.approx(40) - else: - fig, axes = plotting.dual_pie_with_matplotlib(data, data) - assert fig is not None and len(axes) == 2 diff --git a/tests/deprecated/test_resample_equivalence.py b/tests/deprecated/test_resample_equivalence.py deleted file mode 100644 index 19144b6a1..000000000 --- a/tests/deprecated/test_resample_equivalence.py +++ /dev/null @@ -1,310 +0,0 @@ -""" -Tests to ensure the dimension grouping optimization in _resample_by_dimension_groups -is equivalent to naive Dataset resampling. - -These tests verify that the optimization (grouping variables by dimensions before -resampling) produces identical results to simply calling Dataset.resample() directly. -""" - -import numpy as np -import pandas as pd -import pytest -import xarray as xr - -import flixopt as fx - - -def naive_dataset_resample(dataset: xr.Dataset, freq: str, method: str) -> xr.Dataset: - """ - Naive resampling: simply call Dataset.resample().method() directly. - - This is the straightforward approach without dimension grouping optimization. - """ - return getattr(dataset.resample(time=freq), method)() - - -def create_dataset_with_mixed_dimensions(n_timesteps=48, seed=42): - """ - Create a dataset with variables having different dimension structures. - - This mimics realistic data with: - - Variables with only time dimension - - Variables with time + one other dimension - - Variables with time + multiple dimensions - """ - np.random.seed(seed) - timesteps = pd.date_range('2020-01-01', periods=n_timesteps, freq='h') - - ds = xr.Dataset( - coords={ - 'time': timesteps, - 'component': ['comp1', 'comp2'], - 'bus': ['bus1', 'bus2'], - 'scenario': ['base', 'alt'], - } - ) - - # Variable with only time dimension - ds['total_demand'] = xr.DataArray( - np.random.randn(n_timesteps), - dims=['time'], - coords={'time': ds.time}, - ) - - # Variable with time + component - ds['component_flow'] = xr.DataArray( - np.random.randn(n_timesteps, 2), - dims=['time', 'component'], - coords={'time': ds.time, 'component': ds.component}, - ) - - # Variable with time + bus - ds['bus_balance'] = xr.DataArray( - np.random.randn(n_timesteps, 2), - dims=['time', 'bus'], - coords={'time': ds.time, 'bus': ds.bus}, - ) - - # Variable with time + component + bus - ds['flow_on_bus'] = xr.DataArray( - np.random.randn(n_timesteps, 2, 2), - dims=['time', 'component', 'bus'], - coords={'time': ds.time, 'component': ds.component, 'bus': ds.bus}, - ) - - # Variable with time + scenario - ds['scenario_demand'] = xr.DataArray( - np.random.randn(n_timesteps, 2), - dims=['time', 'scenario'], - coords={'time': ds.time, 'scenario': ds.scenario}, - ) - - # Variable with time + component + scenario - ds['component_scenario_flow'] = xr.DataArray( - np.random.randn(n_timesteps, 2, 2), - dims=['time', 'component', 'scenario'], - coords={'time': ds.time, 'component': ds.component, 'scenario': ds.scenario}, - ) - - return ds - - -@pytest.mark.parametrize('method', ['mean', 'sum', 'max', 'min', 'first', 'last']) -@pytest.mark.parametrize('freq', ['2h', '4h', '1D']) -def test_resample_equivalence_mixed_dimensions(method, freq): - """ - Test that _resample_by_dimension_groups produces same results as naive resampling. - - Uses a dataset with variables having different dimension structures. - """ - ds = create_dataset_with_mixed_dimensions(n_timesteps=100) - - # Method 1: Optimized approach (with dimension grouping) - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, freq, method) - - # Method 2: Naive approach (direct Dataset resampling) - result_naive = naive_dataset_resample(ds, freq, method) - - # Compare results - xr.testing.assert_allclose(result_optimized, result_naive) - - -@pytest.mark.parametrize('method', ['mean', 'sum', 'max', 'min', 'first', 'last', 'std', 'var', 'median']) -def test_resample_equivalence_single_dimension(method): - """ - Test with variables having only time dimension. - """ - timesteps = pd.date_range('2020-01-01', periods=48, freq='h') - - ds = xr.Dataset(coords={'time': timesteps}) - ds['var1'] = xr.DataArray(np.random.randn(48), dims=['time'], coords={'time': ds.time}) - ds['var2'] = xr.DataArray(np.random.randn(48) * 10, dims=['time'], coords={'time': ds.time}) - ds['var3'] = xr.DataArray(np.random.randn(48) / 5, dims=['time'], coords={'time': ds.time}) - - # Optimized approach - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, '2h', method) - - # Naive approach - result_naive = naive_dataset_resample(ds, '2h', method) - - # Compare results - xr.testing.assert_allclose(result_optimized, result_naive) - - -def test_resample_equivalence_empty_dataset(): - """ - Test with an empty dataset (edge case). - """ - timesteps = pd.date_range('2020-01-01', periods=48, freq='h') - ds = xr.Dataset(coords={'time': timesteps}) - - # Both should handle empty dataset gracefully - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, '2h', 'mean') - result_naive = naive_dataset_resample(ds, '2h', 'mean') - - xr.testing.assert_allclose(result_optimized, result_naive) - - -def test_resample_equivalence_single_variable(): - """ - Test with a single variable. - """ - timesteps = pd.date_range('2020-01-01', periods=48, freq='h') - ds = xr.Dataset(coords={'time': timesteps}) - ds['single_var'] = xr.DataArray(np.random.randn(48), dims=['time'], coords={'time': ds.time}) - - # Test multiple methods - for method in ['mean', 'sum', 'max', 'min']: - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, '3h', method) - result_naive = naive_dataset_resample(ds, '3h', method) - - xr.testing.assert_allclose(result_optimized, result_naive) - - -def test_resample_equivalence_with_nans(): - """ - Test with NaN values to ensure they're handled consistently. - """ - timesteps = pd.date_range('2020-01-01', periods=48, freq='h') - - ds = xr.Dataset(coords={'time': timesteps, 'component': ['a', 'b']}) - - # Create variable with some NaN values - data = np.random.randn(48, 2) - data[5:10, 0] = np.nan - data[20:25, 1] = np.nan - - ds['var_with_nans'] = xr.DataArray( - data, dims=['time', 'component'], coords={'time': ds.time, 'component': ds.component} - ) - - # Test with methods that handle NaNs - for method in ['mean', 'sum', 'max', 'min', 'first', 'last']: - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, '2h', method) - result_naive = naive_dataset_resample(ds, '2h', method) - - xr.testing.assert_allclose(result_optimized, result_naive) - - -def test_resample_equivalence_different_dimension_orders(): - """ - Test that dimension order doesn't affect the equivalence. - """ - timesteps = pd.date_range('2020-01-01', periods=48, freq='h') - - ds = xr.Dataset( - coords={ - 'time': timesteps, - 'x': ['x1', 'x2'], - 'y': ['y1', 'y2'], - } - ) - - # Variable with time first - ds['var_time_first'] = xr.DataArray( - np.random.randn(48, 2, 2), - dims=['time', 'x', 'y'], - coords={'time': ds.time, 'x': ds.x, 'y': ds.y}, - ) - - # Variable with time in middle - ds['var_time_middle'] = xr.DataArray( - np.random.randn(2, 48, 2), - dims=['x', 'time', 'y'], - coords={'x': ds.x, 'time': ds.time, 'y': ds.y}, - ) - - # Variable with time last - ds['var_time_last'] = xr.DataArray( - np.random.randn(2, 2, 48), - dims=['x', 'y', 'time'], - coords={'x': ds.x, 'y': ds.y, 'time': ds.time}, - ) - - for method in ['mean', 'sum', 'max', 'min']: - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, '2h', method) - result_naive = naive_dataset_resample(ds, '2h', method) - - xr.testing.assert_allclose(result_optimized, result_naive) - - -def test_resample_equivalence_multiple_variables_same_dims(): - """ - Test with multiple variables sharing the same dimensions. - - This is the key optimization case - variables with same dims should be - grouped and resampled together. - """ - timesteps = pd.date_range('2020-01-01', periods=48, freq='h') - - ds = xr.Dataset(coords={'time': timesteps, 'location': ['A', 'B', 'C']}) - - # Multiple variables with same dimensions (time, location) - for i in range(3): - ds[f'var_{i}'] = xr.DataArray( - np.random.randn(48, 3), - dims=['time', 'location'], - coords={'time': ds.time, 'location': ds.location}, - ) - - for method in ['mean', 'sum', 'max', 'min']: - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, '2h', method) - result_naive = naive_dataset_resample(ds, '2h', method) - - xr.testing.assert_allclose(result_optimized, result_naive) - - -def test_resample_equivalence_large_dataset(): - """ - Test with a larger, more realistic dataset. - """ - timesteps = pd.date_range('2020-01-01', periods=168, freq='h') # One week - - ds = xr.Dataset( - coords={ - 'time': timesteps, - 'component': [f'comp_{i}' for i in range(5)], - 'bus': [f'bus_{i}' for i in range(3)], - } - ) - - # Various variable types - ds['simple_var'] = xr.DataArray(np.random.randn(168), dims=['time'], coords={'time': ds.time}) - ds['component_var'] = xr.DataArray( - np.random.randn(168, 5), dims=['time', 'component'], coords={'time': ds.time, 'component': ds.component} - ) - ds['bus_var'] = xr.DataArray(np.random.randn(168, 3), dims=['time', 'bus'], coords={'time': ds.time, 'bus': ds.bus}) - ds['complex_var'] = xr.DataArray( - np.random.randn(168, 5, 3), - dims=['time', 'component', 'bus'], - coords={'time': ds.time, 'component': ds.component, 'bus': ds.bus}, - ) - - # Test with a subset of methods (to keep test time reasonable) - for method in ['mean', 'sum', 'first']: - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, '1D', method) - result_naive = naive_dataset_resample(ds, '1D', method) - - xr.testing.assert_allclose(result_optimized, result_naive) - - -def test_resample_equivalence_with_kwargs(): - """ - Test that kwargs are properly forwarded to resample(). - - Verifies that additional arguments like label and closed are correctly - passed through the optimization path. - """ - timesteps = pd.date_range('2020-01-01', periods=48, freq='h') - ds = xr.Dataset(coords={'time': timesteps}) - ds['var'] = xr.DataArray(np.random.randn(48), dims=['time'], coords={'time': ds.time}) - - kwargs = {'label': 'right', 'closed': 'right'} - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, '2h', 'mean', **kwargs) - result_naive = ds.resample(time='2h', **kwargs).mean() - - xr.testing.assert_allclose(result_optimized, result_naive) - - -if __name__ == '__main__': - pytest.main(['-v', __file__]) diff --git a/tests/deprecated/test_results_io.py b/tests/deprecated/test_results_io.py deleted file mode 100644 index a42ca542b..000000000 --- a/tests/deprecated/test_results_io.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Tests for deprecated Results I/O functionality - ported from feature/v5. - -This module contains the original test_flow_system_file_io test from feature/v5 -that uses the deprecated Optimization/Results API. This test will be removed in v6.0.0. - -For new tests, use FlowSystem.solution.to_netcdf() instead. -""" - -import uuid - -import pytest - -import flixopt as fx -from flixopt.io import ResultsPaths - -from ..conftest import ( - assert_almost_equal_numeric, - flow_system_base, - flow_system_long, - flow_system_segments_of_flows_2, - simple_flow_system, - simple_flow_system_scenarios, -) - - -@pytest.fixture( - params=[ - flow_system_base, - simple_flow_system_scenarios, - 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, request): - # Use UUID to ensure unique names across parallel test workers - unique_id = uuid.uuid4().hex[:12] - worker_id = getattr(request.config, 'workerinput', {}).get('workerid', 'main') - test_id = f'{worker_id}-{unique_id}' - - calculation_0 = fx.Optimization(f'IO-{test_id}', flow_system=flow_system) - calculation_0.do_modeling() - calculation_0.solve(highs_solver) - calculation_0.flow_system.plot_network() - - calculation_0.results.to_file() - paths = ResultsPaths(calculation_0.folder, calculation_0.name) - flow_system_1 = fx.FlowSystem.from_netcdf(paths.flow_system) - - calculation_1 = fx.Optimization(f'Loaded_IO-{test_id}', flow_system=flow_system_1) - calculation_1.do_modeling() - calculation_1.solve(highs_solver) - calculation_1.flow_system.plot_network() - - 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'].values, - calculation_1.results.solution['costs'].values, - 'costs doesnt match expected value', - ) diff --git a/tests/deprecated/test_results_overwrite.py b/tests/deprecated/test_results_overwrite.py deleted file mode 100644 index 731368e78..000000000 --- a/tests/deprecated/test_results_overwrite.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Tests for deprecated Results.to_file() overwrite protection - ported from feature/v5. - -This module contains the original overwrite protection tests from feature/v5 -that use the deprecated Optimization/Results API. These tests will be removed in v6.0.0. - -For new tests, use FlowSystem.to_netcdf() instead. -""" - -import pathlib -import tempfile - -import pytest - -import flixopt as fx - - -def test_results_overwrite_protection(simple_flow_system, highs_solver): - """Test that Results.to_file() prevents accidental overwriting.""" - with tempfile.TemporaryDirectory() as tmpdir: - test_folder = pathlib.Path(tmpdir) / 'results' - - # Run optimization - opt = fx.Optimization('test_results', simple_flow_system, folder=test_folder) - opt.do_modeling() - opt.solve(highs_solver) - - # First save should succeed - opt.results.to_file(compression=0, document_model=False, save_linopy_model=False) - - # Second save without overwrite should fail - with pytest.raises(FileExistsError, match='Results files already exist'): - opt.results.to_file(compression=0, document_model=False, save_linopy_model=False) - - # Third save with overwrite should succeed - opt.results.to_file(compression=0, document_model=False, save_linopy_model=False, overwrite=True) - - -def test_results_overwrite_to_different_folder(simple_flow_system, highs_solver): - """Test that saving to different folder works without overwrite flag.""" - with tempfile.TemporaryDirectory() as tmpdir: - test_folder1 = pathlib.Path(tmpdir) / 'results1' - test_folder2 = pathlib.Path(tmpdir) / 'results2' - - # Run optimization - opt = fx.Optimization('test_results', simple_flow_system, folder=test_folder1) - opt.do_modeling() - opt.solve(highs_solver) - - # Save to first folder - opt.results.to_file(compression=0, document_model=False, save_linopy_model=False) - - # Save to different folder should work without overwrite flag - opt.results.to_file(folder=test_folder2, compression=0, document_model=False, save_linopy_model=False) - - -def test_results_overwrite_with_different_name(simple_flow_system, highs_solver): - """Test that saving with different name works without overwrite flag.""" - with tempfile.TemporaryDirectory() as tmpdir: - test_folder = pathlib.Path(tmpdir) / 'results' - - # Run optimization - opt = fx.Optimization('test_results', simple_flow_system, folder=test_folder) - opt.do_modeling() - opt.solve(highs_solver) - - # Save with first name - opt.results.to_file(compression=0, document_model=False, save_linopy_model=False) - - # Save with different name should work without overwrite flag - opt.results.to_file(name='test_results_v2', compression=0, document_model=False, save_linopy_model=False) diff --git a/tests/deprecated/test_results_plots.py b/tests/deprecated/test_results_plots.py deleted file mode 100644 index f68f5ec07..000000000 --- a/tests/deprecated/test_results_plots.py +++ /dev/null @@ -1,97 +0,0 @@ -import matplotlib.pyplot as plt -import pytest - -import flixopt as fx - -from .conftest import create_optimization_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=[ - 'turbo', # 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): - optimization = create_optimization_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_results_plots') - results = optimization.results - - results['Boiler'].plot_node_balance(engine=plotting_engine, save=save, show=show, colors=color_spec) - - # Matplotlib doesn't support faceting/animation, so disable them for matplotlib engine - heatmap_kwargs = { - 'reshape_time': ('D', 'h'), - 'colors': 'turbo', # Note: heatmap only accepts string colormap - 'save': save, - 'show': show, - 'engine': plotting_engine, - } - if plotting_engine == 'matplotlib': - heatmap_kwargs['facet_by'] = None - heatmap_kwargs['animate_by'] = None - - results.plot_heatmap('Speicher(Q_th_load)|flow_rate', **heatmap_kwargs) - - results['Speicher'].plot_node_balance_pie(engine=plotting_engine, save=save, show=show, colors=color_spec) - - # Matplotlib doesn't support faceting/animation for plot_charge_state, and 'area' mode - charge_state_kwargs = {'engine': plotting_engine} - if plotting_engine == 'matplotlib': - charge_state_kwargs['facet_by'] = None - charge_state_kwargs['animate_by'] = None - charge_state_kwargs['mode'] = 'stacked_bar' # 'area' not supported by matplotlib - results['Speicher'].plot_charge_state(**charge_state_kwargs) - - plt.close('all') - - -@pytest.mark.slow -def test_color_handling_edge_cases(flow_system, plotting_engine, show, save): - """Test edge cases for color handling""" - optimization = create_optimization_and_solve(flow_system, fx.solvers.HighsSolver(0.01, 30), 'test_color_edge_cases') - results = optimization.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/deprecated/test_scenarios.py b/tests/deprecated/test_scenarios.py deleted file mode 100644 index 2699647ad..000000000 --- a/tests/deprecated/test_scenarios.py +++ /dev/null @@ -1,780 +0,0 @@ -import importlib.util - -import numpy as np -import pandas as pd -import pytest -import xarray as xr -from linopy.testing import assert_linequal - -import flixopt as fx -from flixopt import Effect, InvestParameters, Sink, Source, Storage -from flixopt.elements import Bus, Flow -from flixopt.flow_system import FlowSystem - -from .conftest import create_linopy_model - -GUROBI_AVAILABLE = importlib.util.find_spec('gurobipy') is not None - - -@pytest.fixture -def test_system(): - """Create a basic test system with scenarios.""" - # Create a two-day time index with hourly resolution - timesteps = pd.date_range('2023-01-01', periods=48, freq='h', name='time') - - # Create two scenarios - scenarios = pd.Index(['Scenario A', 'Scenario B'], name='scenario') - - # Create scenario weights - scenario_weights = np.array([0.7, 0.3]) - - # Create a flow system with scenarios - flow_system = FlowSystem( - timesteps=timesteps, - scenarios=scenarios, - scenario_weights=scenario_weights, - ) - - # Create demand profiles that differ between scenarios - # Scenario A: Higher demand in first day, lower in second day - # Scenario B: Lower demand in first day, higher in second day - demand_profile_a = np.concatenate( - [ - np.sin(np.linspace(0, 2 * np.pi, 24)) * 5 + 10, # Day 1, max ~15 - np.sin(np.linspace(0, 2 * np.pi, 24)) * 2 + 5, # Day 2, max ~7 - ] - ) - - demand_profile_b = np.concatenate( - [ - np.sin(np.linspace(0, 2 * np.pi, 24)) * 2 + 5, # Day 1, max ~7 - np.sin(np.linspace(0, 2 * np.pi, 24)) * 5 + 10, # Day 2, max ~15 - ] - ) - - # Stack the profiles into a 2D array (time, scenario) - demand_profiles = np.column_stack([demand_profile_a, demand_profile_b]) - - # Create the necessary model elements - # Create buses - electricity_bus = Bus('Electricity') - - # Create a demand sink with scenario-dependent profiles - demand = Flow(label='Demand', bus=electricity_bus.label_full, fixed_relative_profile=demand_profiles) - demand_sink = Sink('Demand', inputs=[demand]) - - # Create a power source with investment option - power_gen = Flow( - label='Generation', - bus=electricity_bus.label_full, - size=InvestParameters( - minimum_size=0, - maximum_size=20, - effects_of_investment_per_size={'costs': 100}, # €/kW - ), - effects_per_flow_hour={'costs': 20}, # €/MWh - ) - generator = Source('Generator', outputs=[power_gen]) - - # Create a storage for electricity - storage_charge = Flow(label='Charge', bus=electricity_bus.label_full, size=10) - storage_discharge = Flow(label='Discharge', bus=electricity_bus.label_full, size=10) - storage = Storage( - label='Battery', - charging=storage_charge, - discharging=storage_discharge, - capacity_in_flow_hours=InvestParameters( - minimum_size=0, - maximum_size=50, - effects_of_investment_per_size={'costs': 50}, # €/kWh - ), - eta_charge=0.95, - eta_discharge=0.95, - initial_charge_state='equals_final', - ) - - # Create effects and objective - cost_effect = Effect(label='costs', unit='€', description='Total costs', is_standard=True, is_objective=True) - - # Add all elements to the flow system - flow_system.add_elements(electricity_bus, generator, demand_sink, storage, cost_effect) - - # Return the created system and its components - return { - 'flow_system': flow_system, - 'timesteps': timesteps, - 'scenarios': scenarios, - 'electricity_bus': electricity_bus, - 'demand': demand, - 'demand_sink': demand_sink, - 'generator': generator, - 'power_gen': power_gen, - 'storage': storage, - 'storage_charge': storage_charge, - 'storage_discharge': storage_discharge, - 'cost_effect': cost_effect, - } - - -@pytest.fixture -def flow_system_complex_scenarios() -> 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'), - scenarios=pd.Index(['A', 'B', 'C'], name='scenario'), - ) - # Define the components and flow_system - flow_system.add_elements( - fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True, share_from_temporal={'CO2': 0.2}), - fx.Effect('CO2', 'kg', 'CO2_e-Emissionen'), - 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', inputs=[fx.Flow('Q_th_Last', 'Fernwärme', size=1, fixed_relative_profile=thermal_load)]), - fx.Source( - 'Gastarif', outputs=[fx.Flow('Q_Gas', 'Gas', size=1000, effects_per_flow_hour={'costs': 0.04, 'CO2': 0.3})] - ), - fx.Sink('Einspeisung', inputs=[fx.Flow('P_el', 'Strom', effects_per_flow_hour=-1 * electrical_load)]), - ) - - boiler = fx.linear_converters.Boiler( - 'Kessel', - thermal_efficiency=0.5, - status_parameters=fx.StatusParameters(effects_per_active_hour={'costs': 0, 'CO2': 1000}), - thermal_flow=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( - effects_of_investment=1000, - fixed_size=50, - mandatory=True, - effects_of_investment_per_size={'costs': 10, 'PE': 2}, - ), - status_parameters=fx.StatusParameters( - active_hours_min=0, - active_hours_max=1000, - max_uptime=10, - min_uptime=1, - max_downtime=10, - effects_per_startup=0.01, - startup_limit=1000, - ), - flow_hours_max=1e6, - ), - fuel_flow=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), - ) - - invest_speicher = fx.InvestParameters( - effects_of_investment=0, - piecewise_effects_of_investment=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)]), - }, - ), - mandatory=True, - effects_of_investment_per_size={'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_piecewise_conversion_scenarios(flow_system_complex_scenarios) -> fx.FlowSystem: - """ - Use segments/Piecewise with numeric data - """ - flow_system = flow_system_complex_scenarios - - flow_system.add_elements( - fx.LinearConverter( - 'KWK', - inputs=[fx.Flow('Q_fu', bus='Gas', size=200)], - outputs=[ - fx.Flow('P_el', bus='Strom', size=60, relative_maximum=55, previous_flow_rate=10), - fx.Flow('Q_th', bus='Fernwärme', size=100), - ], - piecewise_conversion=fx.PiecewiseConversion( - { - 'P_el': fx.Piecewise( - [ - fx.Piece(np.linspace(5, 6, len(flow_system.timesteps)), 30), - fx.Piece(40, np.linspace(60, 70, len(flow_system.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)]), - } - ), - status_parameters=fx.StatusParameters(effects_per_startup=0.01), - ) - ) - - return flow_system - - -def test_weights(flow_system_piecewise_conversion_scenarios): - """Test that scenario weights are correctly used in the model.""" - scenarios = flow_system_piecewise_conversion_scenarios.scenarios - scenario_weights = np.linspace(0.5, 1, len(scenarios)) - scenario_weights_da = xr.DataArray( - scenario_weights, - dims=['scenario'], - coords={'scenario': scenarios}, - ) - flow_system_piecewise_conversion_scenarios.scenario_weights = scenario_weights_da - model = create_linopy_model(flow_system_piecewise_conversion_scenarios) - normalized_weights = scenario_weights / sum(scenario_weights) - np.testing.assert_allclose(model.objective_weights.values, normalized_weights) - # Penalty is now an effect with temporal and periodic components - penalty_total = flow_system_piecewise_conversion_scenarios.effects.penalty_effect.submodel.total - assert_linequal( - model.objective.expression, - (model.variables['costs'] * normalized_weights).sum() + (penalty_total * normalized_weights).sum(), - ) - assert np.isclose(model.objective_weights.sum().item(), 1) - - -def test_weights_io(flow_system_piecewise_conversion_scenarios): - """Test that scenario weights are correctly used in the model.""" - scenarios = flow_system_piecewise_conversion_scenarios.scenarios - scenario_weights = np.linspace(0.5, 1, len(scenarios)) - scenario_weights_da = xr.DataArray( - scenario_weights, - dims=['scenario'], - coords={'scenario': scenarios}, - ) - normalized_scenario_weights_da = scenario_weights_da / scenario_weights_da.sum() - flow_system_piecewise_conversion_scenarios.scenario_weights = scenario_weights_da - - model = create_linopy_model(flow_system_piecewise_conversion_scenarios) - np.testing.assert_allclose(model.objective_weights.values, normalized_scenario_weights_da) - # Penalty is now an effect with temporal and periodic components - penalty_total = flow_system_piecewise_conversion_scenarios.effects.penalty_effect.submodel.total - assert_linequal( - model.objective.expression, - (model.variables['costs'] * normalized_scenario_weights_da).sum() - + (penalty_total * normalized_scenario_weights_da).sum(), - ) - assert np.isclose(model.objective_weights.sum().item(), 1.0) - - -def test_scenario_dimensions_in_variables(flow_system_piecewise_conversion_scenarios): - """Test that all time variables are correctly broadcasted to scenario dimensions.""" - model = create_linopy_model(flow_system_piecewise_conversion_scenarios) - for var in model.variables: - assert model.variables[var].dims in [('time', 'scenario'), ('scenario',), ()] - - -@pytest.mark.skipif(not GUROBI_AVAILABLE, reason='Gurobi solver not installed') -def test_full_scenario_optimization(flow_system_piecewise_conversion_scenarios): - """Test a full optimization with scenarios and verify results.""" - scenarios = flow_system_piecewise_conversion_scenarios.scenarios - weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) - flow_system_piecewise_conversion_scenarios.scenario_weights = weights - - # Optimize using new API - flow_system_piecewise_conversion_scenarios.optimize(fx.solvers.GurobiSolver(mip_gap=0.01, time_limit_seconds=60)) - - # Verify solution exists and has scenario dimension - assert flow_system_piecewise_conversion_scenarios.solution is not None - assert 'scenario' in flow_system_piecewise_conversion_scenarios.solution.dims - - -@pytest.mark.skip(reason='This test is taking too long with highs and is too big for gurobipy free') -def test_io_persistence(flow_system_piecewise_conversion_scenarios, tmp_path): - """Test a full optimization with scenarios and verify results.""" - scenarios = flow_system_piecewise_conversion_scenarios.scenarios - weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) - flow_system_piecewise_conversion_scenarios.scenario_weights = weights - - # Optimize using new API - flow_system_piecewise_conversion_scenarios.optimize(fx.solvers.HighsSolver(mip_gap=0.001, time_limit_seconds=60)) - original_objective = flow_system_piecewise_conversion_scenarios.solution['objective'].item() - - # Save and restore - filepath = tmp_path / 'flow_system_scenarios.nc4' - flow_system_piecewise_conversion_scenarios.to_netcdf(filepath) - flow_system_2 = fx.FlowSystem.from_netcdf(filepath) - - # Re-optimize restored flow system - flow_system_2.optimize(fx.solvers.HighsSolver(mip_gap=0.001, time_limit_seconds=60)) - - np.testing.assert_allclose(original_objective, flow_system_2.solution['objective'].item(), rtol=0.001) - - -@pytest.mark.skipif(not GUROBI_AVAILABLE, reason='Gurobi solver not installed') -def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): - """Test scenario selection/subsetting functionality.""" - flow_system_full = flow_system_piecewise_conversion_scenarios - scenarios = flow_system_full.scenarios - scenario_weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) - flow_system_full.scenario_weights = scenario_weights - flow_system = flow_system_full.sel(scenario=scenarios[0:2]) - - assert flow_system.scenarios.equals(flow_system_full.scenarios[0:2]) - - # Scenario weights are always normalized - subset is re-normalized to sum to 1 - subset_weights = flow_system_full.scenario_weights[0:2] - expected_normalized = subset_weights / subset_weights.sum() - np.testing.assert_allclose(flow_system.scenario_weights.values, expected_normalized.values) - - # Optimize using new API - flow_system.optimize( - fx.solvers.GurobiSolver(mip_gap=0.01, time_limit_seconds=60), - ) - - # Penalty has same structure as other effects: 'Penalty' is the total, 'Penalty(temporal)' and 'Penalty(periodic)' are components - np.testing.assert_allclose( - flow_system.solution['objective'].item(), - ( - (flow_system.solution['costs'] * flow_system.scenario_weights).sum() - + (flow_system.solution['Penalty'] * flow_system.scenario_weights).sum() - ).item(), - ) ## Account for rounding errors - - assert flow_system.solution.indexes['scenario'].equals(flow_system_full.scenarios[0:2]) - - -def test_sizes_per_scenario_default(): - """Test that scenario_independent_sizes defaults to True (sizes equalized) and flow_rates to False (vary).""" - timesteps = pd.date_range('2023-01-01', periods=24, freq='h') - scenarios = pd.Index(['base', 'high'], name='scenario') - - fs = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) - - assert fs.scenario_independent_sizes is True - assert fs.scenario_independent_flow_rates is False - - -def test_sizes_per_scenario_bool(): - """Test scenario_independent_sizes with boolean values.""" - timesteps = pd.date_range('2023-01-01', periods=24, freq='h') - scenarios = pd.Index(['base', 'high'], name='scenario') - - # Test False (vary per scenario) - fs1 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, scenario_independent_sizes=False) - assert fs1.scenario_independent_sizes is False - - # Test True (equalized across scenarios) - fs2 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, scenario_independent_sizes=True) - assert fs2.scenario_independent_sizes is True - - -def test_sizes_per_scenario_list(): - """Test scenario_independent_sizes with list of element labels.""" - timesteps = pd.date_range('2023-01-01', periods=24, freq='h') - scenarios = pd.Index(['base', 'high'], name='scenario') - - fs = fx.FlowSystem( - timesteps=timesteps, - scenarios=scenarios, - scenario_independent_sizes=['solar->grid', 'battery->grid'], - ) - - assert fs.scenario_independent_sizes == ['solar->grid', 'battery->grid'] - - -def test_flow_rates_per_scenario_default(): - """Test that scenario_independent_flow_rates defaults to False (flow rates vary by scenario).""" - timesteps = pd.date_range('2023-01-01', periods=24, freq='h') - scenarios = pd.Index(['base', 'high'], name='scenario') - - fs = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) - - assert fs.scenario_independent_flow_rates is False - - -def test_flow_rates_per_scenario_bool(): - """Test scenario_independent_flow_rates with boolean values.""" - timesteps = pd.date_range('2023-01-01', periods=24, freq='h') - scenarios = pd.Index(['base', 'high'], name='scenario') - - # Test False (vary per scenario) - fs1 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, scenario_independent_flow_rates=False) - assert fs1.scenario_independent_flow_rates is False - - # Test True (equalized across scenarios) - fs2 = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios, scenario_independent_flow_rates=True) - assert fs2.scenario_independent_flow_rates is True - - -def test_scenario_parameters_property_setters(): - """Test that scenario parameters can be changed via property setters.""" - timesteps = pd.date_range('2023-01-01', periods=24, freq='h') - scenarios = pd.Index(['base', 'high'], name='scenario') - - fs = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) - - # Change scenario_independent_sizes - fs.scenario_independent_sizes = True - assert fs.scenario_independent_sizes is True - - fs.scenario_independent_sizes = ['component1', 'component2'] - assert fs.scenario_independent_sizes == ['component1', 'component2'] - - # Change scenario_independent_flow_rates - fs.scenario_independent_flow_rates = True - assert fs.scenario_independent_flow_rates is True - - fs.scenario_independent_flow_rates = ['flow1', 'flow2'] - assert fs.scenario_independent_flow_rates == ['flow1', 'flow2'] - - -def test_scenario_parameters_validation(): - """Test that scenario parameters are validated correctly.""" - timesteps = pd.date_range('2023-01-01', periods=24, freq='h') - scenarios = pd.Index(['base', 'high'], name='scenario') - - fs = fx.FlowSystem(timesteps=timesteps, scenarios=scenarios) - - # Test invalid type - with pytest.raises(TypeError, match='must be bool or list'): - fs.scenario_independent_sizes = 'invalid' - - # Test invalid list content - with pytest.raises(ValueError, match='must contain only strings'): - fs.scenario_independent_sizes = [1, 2, 3] - - -def test_size_equality_constraints(): - """Test that size equality constraints are created when scenario_independent_sizes=True.""" - timesteps = pd.date_range('2023-01-01', periods=24, freq='h') - scenarios = pd.Index(['base', 'high'], name='scenario') - - fs = fx.FlowSystem( - timesteps=timesteps, - scenarios=scenarios, - scenario_independent_sizes=True, # Sizes should be equalized - scenario_independent_flow_rates=False, # Flow rates can vary - ) - - bus = fx.Bus('grid') - source = fx.Source( - label='solar', - outputs=[ - fx.Flow( - label='out', - bus='grid', - size=fx.InvestParameters( - minimum_size=10, - maximum_size=100, - effects_of_investment_per_size={'cost': 100}, - ), - ) - ], - ) - - fs.add_elements(bus, source, fx.Effect('cost', 'Total cost', '€', is_objective=True)) - - fs.build_model() - - # Check that size equality constraint exists - constraint_names = [str(c) for c in fs.model.constraints] - size_constraints = [c for c in constraint_names if 'scenario_independent' in c and 'size' in c] - - assert len(size_constraints) > 0, 'Size equality constraint should exist' - - -def test_flow_rate_equality_constraints(): - """Test that flow_rate equality constraints are created when scenario_independent_flow_rates=True.""" - timesteps = pd.date_range('2023-01-01', periods=24, freq='h') - scenarios = pd.Index(['base', 'high'], name='scenario') - - fs = fx.FlowSystem( - timesteps=timesteps, - scenarios=scenarios, - scenario_independent_sizes=False, # Sizes can vary - scenario_independent_flow_rates=True, # Flow rates should be equalized - ) - - bus = fx.Bus('grid') - source = fx.Source( - label='solar', - outputs=[ - fx.Flow( - label='out', - bus='grid', - size=fx.InvestParameters( - minimum_size=10, - maximum_size=100, - effects_of_investment_per_size={'cost': 100}, - ), - ) - ], - ) - - fs.add_elements(bus, source, fx.Effect('cost', 'Total cost', '€', is_objective=True)) - - fs.build_model() - - # Check that flow_rate equality constraint exists - constraint_names = [str(c) for c in fs.model.constraints] - flow_rate_constraints = [c for c in constraint_names if 'scenario_independent' in c and 'flow_rate' in c] - - assert len(flow_rate_constraints) > 0, 'Flow rate equality constraint should exist' - - -def test_selective_scenario_independence(): - """Test selective scenario independence with specific element lists.""" - timesteps = pd.date_range('2023-01-01', periods=24, freq='h') - scenarios = pd.Index(['base', 'high'], name='scenario') - - fs = fx.FlowSystem( - timesteps=timesteps, - scenarios=scenarios, - scenario_independent_sizes=['solar(out)'], # Only solar size is equalized - scenario_independent_flow_rates=['demand(in)'], # Only demand flow_rate is equalized - ) - - bus = fx.Bus('grid') - source = fx.Source( - label='solar', - outputs=[ - fx.Flow( - label='out', - bus='grid', - size=fx.InvestParameters( - minimum_size=10, maximum_size=100, effects_of_investment_per_size={'cost': 100} - ), - ) - ], - ) - sink = fx.Sink( - label='demand', - inputs=[fx.Flow(label='in', bus='grid', size=50)], - ) - - fs.add_elements(bus, source, sink, fx.Effect('cost', 'Total cost', '€', is_objective=True)) - - fs.build_model() - - constraint_names = [str(c) for c in fs.model.constraints] - - # Solar SHOULD have size constraints (it's in the list, so equalized) - solar_size_constraints = [c for c in constraint_names if 'solar(out)|size' in c and 'scenario_independent' in c] - assert len(solar_size_constraints) > 0 - - # Solar should NOT have flow_rate constraints (not in the list, so varies per scenario) - solar_flow_constraints = [ - c for c in constraint_names if 'solar(out)|flow_rate' in c and 'scenario_independent' in c - ] - assert len(solar_flow_constraints) == 0 - - # Demand should NOT have size constraints (no InvestParameters, size is fixed) - demand_size_constraints = [c for c in constraint_names if 'demand(in)|size' in c and 'scenario_independent' in c] - assert len(demand_size_constraints) == 0 - - # Demand SHOULD have flow_rate constraints (it's in the list, so equalized) - demand_flow_constraints = [ - c for c in constraint_names if 'demand(in)|flow_rate' in c and 'scenario_independent' in c - ] - assert len(demand_flow_constraints) > 0 - - -def test_scenario_parameters_io_persistence(): - """Test that scenario_independent_sizes and scenario_independent_flow_rates persist through IO operations.""" - - timesteps = pd.date_range('2023-01-01', periods=24, freq='h') - scenarios = pd.Index(['base', 'high'], name='scenario') - - # Create FlowSystem with custom scenario parameters - fs_original = fx.FlowSystem( - timesteps=timesteps, - scenarios=scenarios, - scenario_independent_sizes=['solar(out)'], - scenario_independent_flow_rates=True, - ) - - bus = fx.Bus('grid') - source = fx.Source( - label='solar', - outputs=[ - fx.Flow( - label='out', - bus='grid', - size=fx.InvestParameters( - minimum_size=10, maximum_size=100, effects_of_investment_per_size={'cost': 100} - ), - ) - ], - ) - - fs_original.add_elements(bus, source, fx.Effect('cost', 'Total cost', '€', is_objective=True)) - - # Save to dataset - fs_original.connect_and_transform() - ds = fs_original.to_dataset() - - # Load from dataset - fs_loaded = fx.FlowSystem.from_dataset(ds) - - # Verify parameters persisted - assert fs_loaded.scenario_independent_sizes == fs_original.scenario_independent_sizes - assert fs_loaded.scenario_independent_flow_rates == fs_original.scenario_independent_flow_rates - - -def test_scenario_parameters_io_with_calculation(tmp_path): - """Test that scenario parameters persist through full calculation IO.""" - timesteps = pd.date_range('2023-01-01', periods=24, freq='h') - scenarios = pd.Index(['base', 'high'], name='scenario') - - fs = fx.FlowSystem( - timesteps=timesteps, - scenarios=scenarios, - scenario_independent_sizes=True, - scenario_independent_flow_rates=['demand(in)'], - ) - - bus = fx.Bus('grid') - source = fx.Source( - label='solar', - outputs=[ - fx.Flow( - label='out', - bus='grid', - size=fx.InvestParameters( - minimum_size=10, maximum_size=100, effects_of_investment_per_size={'cost': 100} - ), - ) - ], - ) - sink = fx.Sink( - label='demand', - inputs=[fx.Flow(label='in', bus='grid', size=50)], - ) - - fs.add_elements(bus, source, sink, fx.Effect('cost', 'Total cost', '€', is_objective=True)) - - # Solve using new API - fs.optimize(fx.solvers.HighsSolver(mip_gap=0.01, time_limit_seconds=60)) - original_model = fs.model - - # Save and restore - filepath = tmp_path / 'flow_system_scenarios.nc4' - fs.to_netcdf(filepath) - fs_loaded = fx.FlowSystem.from_netcdf(filepath) - - # Verify parameters persisted - assert fs_loaded.scenario_independent_sizes == fs.scenario_independent_sizes - assert fs_loaded.scenario_independent_flow_rates == fs.scenario_independent_flow_rates - - # Verify constraints are recreated correctly when building model - fs_loaded.build_model() - - constraint_names1 = [str(c) for c in original_model.constraints] - constraint_names2 = [str(c) for c in fs_loaded.model.constraints] - - size_constraints1 = [c for c in constraint_names1 if 'scenario_independent' in c and 'size' in c] - size_constraints2 = [c for c in constraint_names2 if 'scenario_independent' in c and 'size' in c] - - assert len(size_constraints1) == len(size_constraints2) - - -def test_weights_io_persistence(): - """Test that weights persist through IO operations (to_dataset/from_dataset).""" - timesteps = pd.date_range('2023-01-01', periods=24, freq='h') - scenarios = pd.Index(['base', 'mid', 'high'], name='scenario') - custom_scenario_weights = np.array([0.3, 0.5, 0.2]) - - # Create FlowSystem with custom scenario weights - fs_original = fx.FlowSystem( - timesteps=timesteps, - scenarios=scenarios, - scenario_weights=custom_scenario_weights, - ) - - bus = fx.Bus('grid') - source = fx.Source( - label='solar', - outputs=[ - fx.Flow( - label='out', - bus='grid', - size=fx.InvestParameters( - minimum_size=10, maximum_size=100, effects_of_investment_per_size={'cost': 100} - ), - ) - ], - ) - - fs_original.add_elements(bus, source, fx.Effect('cost', 'Total cost', '€', is_objective=True)) - - # Save to dataset - fs_original.connect_and_transform() - ds = fs_original.to_dataset() - - # Load from dataset - fs_loaded = fx.FlowSystem.from_dataset(ds) - - # Verify weights persisted correctly - np.testing.assert_allclose(fs_loaded.scenario_weights.values, fs_original.scenario_weights.values) - assert fs_loaded.scenario_weights.dims == fs_original.scenario_weights.dims - - -def test_weights_selection(): - """Test that weights are correctly sliced when using FlowSystem.sel().""" - timesteps = pd.date_range('2023-01-01', periods=24, freq='h') - scenarios = pd.Index(['base', 'mid', 'high'], name='scenario') - custom_scenario_weights = np.array([0.3, 0.5, 0.2]) - - # Create FlowSystem with custom scenario weights - fs_full = fx.FlowSystem( - timesteps=timesteps, - scenarios=scenarios, - scenario_weights=custom_scenario_weights, - ) - - bus = fx.Bus('grid') - source = fx.Source( - label='solar', - outputs=[ - fx.Flow( - label='out', - bus='grid', - size=10, - ) - ], - ) - - fs_full.add_elements(bus, source, fx.Effect('cost', 'Total cost', '€', is_objective=True)) - - # Select a subset of scenarios - fs_subset = fs_full.sel(scenario=['base', 'high']) - - # Verify weights are correctly sliced - assert fs_subset.scenarios.equals(pd.Index(['base', 'high'], name='scenario')) - # Scenario weights are always normalized - subset is re-normalized to sum to 1 - subset_weights = np.array([0.3, 0.2]) # Original weights for selected scenarios - expected_normalized = subset_weights / subset_weights.sum() - np.testing.assert_allclose(fs_subset.scenario_weights.values, expected_normalized) - - # Verify weights are 1D with just scenario dimension (no period dimension) - assert fs_subset.scenario_weights.dims == ('scenario',) diff --git a/tests/deprecated/test_storage.py b/tests/deprecated/test_storage.py deleted file mode 100644 index 3fd47fbf8..000000000 --- a/tests/deprecated/test_storage.py +++ /dev/null @@ -1,490 +0,0 @@ -import numpy as np -import pytest - -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_coords, coords_config): - """Test that basic storage model variables and constraints are correctly generated.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - # 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=model.get_coords()) - ) - assert_var_equal( - model['TestStorage(Q_th_out)|flow_rate'], model.add_variables(lower=0, upper=20, coords=model.get_coords()) - ) - assert_var_equal( - model['TestStorage|charge_state'], - model.add_variables(lower=0, upper=30, coords=model.get_coords(extra_timestep=True)), - ) - - # 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.timestep_duration - - model.variables['TestStorage(Q_th_out)|flow_rate'] * model.timestep_duration, - ) - # 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_coords, coords_config): - """Test that basic storage model variables and constraints are correctly generated.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - # 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=model.get_coords()) - ) - assert_var_equal( - model['TestStorage(Q_th_out)|flow_rate'], model.add_variables(lower=0, upper=20, coords=model.get_coords()) - ) - assert_var_equal( - model['TestStorage|charge_state'], - model.add_variables(lower=0, upper=30, coords=model.get_coords(extra_timestep=True)), - ) - - # 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 - timestep_duration = model.timestep_duration - 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) ** timestep_duration - + charge_rate * eff_charge * timestep_duration - - discharge_rate / eff_discharge * timestep_duration, - ) - - # Check initial charge state constraint - assert_conequal( - model.constraints['TestStorage|initial_charge_state'], - model.variables['TestStorage|charge_state'].isel(time=0) == 0, - ) - - def test_charge_state_bounds(self, basic_flow_system_linopy_coords, coords_config): - """Test that basic storage model variables and constraints are correctly generated.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - # 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=3, - prevent_simultaneous_charge_and_discharge=True, - relative_maximum_charge_state=np.array([0.14, 0.22, 0.3, 0.38, 0.46, 0.54, 0.62, 0.7, 0.78, 0.86]), - relative_minimum_charge_state=np.array([0.07, 0.11, 0.15, 0.19, 0.23, 0.27, 0.31, 0.35, 0.39, 0.43]), - ) - - 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=model.get_coords()) - ) - assert_var_equal( - model['TestStorage(Q_th_out)|flow_rate'], model.add_variables(lower=0, upper=20, coords=model.get_coords()) - ) - assert_var_equal( - model['TestStorage|charge_state'], - model.add_variables( - lower=storage.relative_minimum_charge_state.reindex( - time=model.get_coords(extra_timestep=True)['time'] - ).ffill('time') - * 30, - upper=storage.relative_maximum_charge_state.reindex( - time=model.get_coords(extra_timestep=True)['time'] - ).ffill('time') - * 30, - coords=model.get_coords(extra_timestep=True), - ), - ) - - # 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.timestep_duration - - model.variables['TestStorage(Q_th_out)|flow_rate'] * model.timestep_duration, - ) - # Check initial charge state constraint - assert_conequal( - model.constraints['TestStorage|initial_charge_state'], - model.variables['TestStorage|charge_state'].isel(time=0) == 3, - ) - - def test_storage_with_investment(self, basic_flow_system_linopy_coords, coords_config): - """Test storage with investment parameters.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - # 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( - effects_of_investment=100, - effects_of_investment_per_size=10, - minimum_size=20, - maximum_size=100, - mandatory=False, - ), - 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|invested', - }: - assert var_name in model.variables, f'Missing investment variable: {var_name}' - - # Check investment constraints exist - for con_name in {'InvestStorage|size|ub', 'InvestStorage|size|lb'}: - assert con_name in model.constraints, f'Missing investment constraint: {con_name}' - - # Check variable properties - assert_var_equal( - model['InvestStorage|size'], - model.add_variables(lower=0, upper=100, coords=model.get_coords(['period', 'scenario'])), - ) - assert_var_equal( - model['InvestStorage|invested'], - model.add_variables(binary=True, coords=model.get_coords(['period', 'scenario'])), - ) - assert_conequal( - model.constraints['InvestStorage|size|ub'], - model.variables['InvestStorage|size'] <= model.variables['InvestStorage|invested'] * 100, - ) - assert_conequal( - model.constraints['InvestStorage|size|lb'], - model.variables['InvestStorage|size'] >= model.variables['InvestStorage|invested'] * 20, - ) - - def test_storage_with_final_state_constraints(self, basic_flow_system_linopy_coords, coords_config): - """Test storage with final state constraints.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - # 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_coords, coords_config): - """Test storage with cyclic initialization.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - # 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='equals_final', # 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_coords, coords_config, prevent_simultaneous): - """Test prevent_simultaneous_charge_and_discharge parameter.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - # 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)|status', - 'SimultaneousStorage(Q_th_out)|status', - } - 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|prevent_simultaneous_use' - assert constraint_name in model.constraints, 'Missing constraint to prevent simultaneous operation' - - assert_conequal( - model.constraints['SimultaneousStorage|prevent_simultaneous_use'], - model.variables['SimultaneousStorage(Q_th_in)|status'] - + model.variables['SimultaneousStorage(Q_th_out)|status'] - <= 1, - ) - - @pytest.mark.parametrize( - 'mandatory,minimum_size,expected_vars,expected_constraints', - [ - (False, None, {'InvestStorage|invested'}, {'InvestStorage|size|lb'}), - (False, 20, {'InvestStorage|invested'}, {'InvestStorage|size|lb'}), - (True, None, set(), set()), - (True, 20, set(), set()), - ], - ) - def test_investment_parameters( - self, - basic_flow_system_linopy_coords, - coords_config, - mandatory, - minimum_size, - expected_vars, - expected_constraints, - ): - """Test different investment parameter combinations.""" - flow_system, coords_config = basic_flow_system_linopy_coords, coords_config - - # Create investment parameters - invest_params = { - 'effects_of_investment': 100, - 'effects_of_investment_per_size': 10, - 'mandatory': mandatory, - 'maximum_size': 100, - } - 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 not mandatory: # Optional investment (mandatory=False) - 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 not mandatory: # Optional investment (mandatory=False) - assert constraint_name in model.constraints, f'Expected constraint {constraint_name} not found' - - # If mandatory is True, invested should be fixed to 1 - if mandatory: - # Check that the invested variable exists and is fixed to 1 - if 'InvestStorage|invested' in model.variables: - var = model.variables['InvestStorage|invested'] - # Check if the lower and upper bounds are both 1 - assert var.upper == 1 and var.lower == 1, 'invested variable should be fixed to 1 when mandatory=True' diff --git a/tests/deprecated/test_timeseries.py b/tests/deprecated/test_timeseries.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/flow_system/test_flow_system_resample.py b/tests/flow_system/test_flow_system_resample.py index dd5e19176..d00e56939 100644 --- a/tests/flow_system/test_flow_system_resample.py +++ b/tests/flow_system/test_flow_system_resample.py @@ -1,4 +1,4 @@ -"""Integration tests for FlowSystem.resample() - verifies correct data resampling and structure preservation.""" +"""Integration tests for FlowSystem.transform.resample() - verifies correct data resampling and structure preservation.""" import numpy as np import pandas as pd @@ -80,7 +80,7 @@ def complex_fs(): @pytest.mark.parametrize('freq,method', [('2h', 'mean'), ('4h', 'sum'), ('6h', 'first')]) def test_basic_resample(simple_fs, freq, method): """Test basic resampling preserves structure.""" - fs_r = simple_fs.resample(freq, method=method) + fs_r = simple_fs.transform.resample(freq, method=method) assert len(fs_r.components) == len(simple_fs.components) assert len(fs_r.buses) == len(simple_fs.buses) assert len(fs_r.timesteps) < len(simple_fs.timesteps) @@ -107,13 +107,13 @@ def test_resample_methods(method, expected): ) ) - fs_r = fs.resample('2h', method=method) + fs_r = fs.transform.resample('2h', method=method) assert_allclose(fs_r.flows['s(in)'].fixed_relative_profile.values, expected, rtol=1e-10) def test_structure_preserved(simple_fs): """Test all structural elements preserved.""" - fs_r = simple_fs.resample('2h', method='mean') + fs_r = simple_fs.transform.resample('2h', method='mean') assert set(simple_fs.components.keys()) == set(fs_r.components.keys()) assert set(simple_fs.buses.keys()) == set(fs_r.buses.keys()) assert set(simple_fs.effects.keys()) == set(fs_r.effects.keys()) @@ -126,7 +126,7 @@ def test_structure_preserved(simple_fs): def test_time_metadata_updated(simple_fs): """Test time metadata correctly updated.""" - fs_r = simple_fs.resample('3h', method='mean') + fs_r = simple_fs.transform.resample('3h', method='mean') assert len(fs_r.timesteps) == 8 assert_allclose(fs_r.timestep_duration.values, 3.0) assert fs_r.hours_of_last_timestep == 3.0 @@ -150,7 +150,7 @@ def test_with_dimensions(simple_fs, dim_name, dim_value): fx.Sink(label='d', inputs=[fx.Flow(label='in', bus='h', fixed_relative_profile=np.ones(24), size=1)]) ) - fs_r = fs.resample('2h', method='mean') + fs_r = fs.transform.resample('2h', method='mean') assert getattr(fs_r, dim_name) is not None pd.testing.assert_index_equal(getattr(fs_r, dim_name), dim_value) @@ -160,7 +160,7 @@ def test_with_dimensions(simple_fs, dim_name, dim_value): def test_storage_resample(complex_fs): """Test storage component resampling.""" - fs_r = complex_fs.resample('4h', method='mean') + fs_r = complex_fs.transform.resample('4h', method='mean') assert 'battery' in fs_r.components storage = fs_r.components['battery'] assert storage.charging.label == 'charge' @@ -169,7 +169,7 @@ def test_storage_resample(complex_fs): def test_converter_resample(complex_fs): """Test converter component resampling.""" - fs_r = complex_fs.resample('4h', method='mean') + fs_r = complex_fs.transform.resample('4h', method='mean') assert 'boiler' in fs_r.components boiler = fs_r.components['boiler'] assert hasattr(boiler, 'thermal_efficiency') @@ -177,7 +177,7 @@ def test_converter_resample(complex_fs): def test_invest_resample(complex_fs): """Test investment parameters preserved.""" - fs_r = complex_fs.resample('4h', method='mean') + fs_r = complex_fs.transform.resample('4h', method='mean') pv_flow = fs_r.flows['pv(gen)'] assert isinstance(pv_flow.size, fx.InvestParameters) assert pv_flow.size.maximum_size == 1000 @@ -205,7 +205,7 @@ def test_modeling(with_dim): fx.Source(label='s', outputs=[fx.Flow(label='out', bus='h', size=100, effects_per_flow_hour={'costs': 0.05})]), ) - fs_r = fs.resample('4h', method='mean') + fs_r = fs.transform.resample('4h', method='mean') fs_r.build_model() assert fs_r.model is not None @@ -226,7 +226,7 @@ def test_model_structure_preserved(): fs.build_model() - fs_r = fs.resample('4h', method='mean') + fs_r = fs.transform.resample('4h', method='mean') fs_r.build_model() # Same number of variable/constraint types @@ -243,18 +243,20 @@ def test_model_structure_preserved(): def test_dataset_roundtrip(simple_fs): """Test dataset serialization.""" - fs_r = simple_fs.resample('2h', method='mean') + fs_r = simple_fs.transform.resample('2h', method='mean') assert fx.FlowSystem.from_dataset(fs_r.to_dataset()) == fs_r def test_dataset_chaining(simple_fs): """Test power user pattern.""" + from flixopt.transform_accessor import TransformAccessor + ds = simple_fs.to_dataset() - ds = fx.FlowSystem._dataset_sel(ds, time='2023-01-01') - ds = fx.FlowSystem._dataset_resample(ds, freq='2h', method='mean') + ds = TransformAccessor._dataset_sel(ds, time='2023-01-01') + ds = TransformAccessor._dataset_resample(ds, freq='2h', method='mean') fs_result = fx.FlowSystem.from_dataset(ds) - fs_simple = simple_fs.sel(time='2023-01-01').resample('2h', method='mean') + fs_simple = simple_fs.transform.sel(time='2023-01-01').transform.resample('2h', method='mean') assert fs_result == fs_simple @@ -268,7 +270,7 @@ def test_frequencies(freq, exp_len): fx.Sink(label='s', inputs=[fx.Flow(label='in', bus='b', fixed_relative_profile=np.ones(168), size=1)]) ) - assert len(fs.resample(freq, method='mean').timesteps) == exp_len + assert len(fs.transform.resample(freq, method='mean').timesteps) == exp_len def test_irregular_timesteps_error(): diff --git a/tests/flow_system/test_resample_equivalence.py b/tests/flow_system/test_resample_equivalence.py index 19144b6a1..7520b5407 100644 --- a/tests/flow_system/test_resample_equivalence.py +++ b/tests/flow_system/test_resample_equivalence.py @@ -11,7 +11,7 @@ import pytest import xarray as xr -import flixopt as fx +from flixopt.transform_accessor import TransformAccessor def naive_dataset_resample(dataset: xr.Dataset, freq: str, method: str) -> xr.Dataset: @@ -100,7 +100,7 @@ def test_resample_equivalence_mixed_dimensions(method, freq): ds = create_dataset_with_mixed_dimensions(n_timesteps=100) # Method 1: Optimized approach (with dimension grouping) - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, freq, method) + result_optimized = TransformAccessor._resample_by_dimension_groups(ds, freq, method) # Method 2: Naive approach (direct Dataset resampling) result_naive = naive_dataset_resample(ds, freq, method) @@ -122,7 +122,7 @@ def test_resample_equivalence_single_dimension(method): ds['var3'] = xr.DataArray(np.random.randn(48) / 5, dims=['time'], coords={'time': ds.time}) # Optimized approach - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, '2h', method) + result_optimized = TransformAccessor._resample_by_dimension_groups(ds, '2h', method) # Naive approach result_naive = naive_dataset_resample(ds, '2h', method) @@ -139,7 +139,7 @@ def test_resample_equivalence_empty_dataset(): ds = xr.Dataset(coords={'time': timesteps}) # Both should handle empty dataset gracefully - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, '2h', 'mean') + result_optimized = TransformAccessor._resample_by_dimension_groups(ds, '2h', 'mean') result_naive = naive_dataset_resample(ds, '2h', 'mean') xr.testing.assert_allclose(result_optimized, result_naive) @@ -155,7 +155,7 @@ def test_resample_equivalence_single_variable(): # Test multiple methods for method in ['mean', 'sum', 'max', 'min']: - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, '3h', method) + result_optimized = TransformAccessor._resample_by_dimension_groups(ds, '3h', method) result_naive = naive_dataset_resample(ds, '3h', method) xr.testing.assert_allclose(result_optimized, result_naive) @@ -180,7 +180,7 @@ def test_resample_equivalence_with_nans(): # Test with methods that handle NaNs for method in ['mean', 'sum', 'max', 'min', 'first', 'last']: - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, '2h', method) + result_optimized = TransformAccessor._resample_by_dimension_groups(ds, '2h', method) result_naive = naive_dataset_resample(ds, '2h', method) xr.testing.assert_allclose(result_optimized, result_naive) @@ -222,7 +222,7 @@ def test_resample_equivalence_different_dimension_orders(): ) for method in ['mean', 'sum', 'max', 'min']: - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, '2h', method) + result_optimized = TransformAccessor._resample_by_dimension_groups(ds, '2h', method) result_naive = naive_dataset_resample(ds, '2h', method) xr.testing.assert_allclose(result_optimized, result_naive) @@ -248,7 +248,7 @@ def test_resample_equivalence_multiple_variables_same_dims(): ) for method in ['mean', 'sum', 'max', 'min']: - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, '2h', method) + result_optimized = TransformAccessor._resample_by_dimension_groups(ds, '2h', method) result_naive = naive_dataset_resample(ds, '2h', method) xr.testing.assert_allclose(result_optimized, result_naive) @@ -282,7 +282,7 @@ def test_resample_equivalence_large_dataset(): # Test with a subset of methods (to keep test time reasonable) for method in ['mean', 'sum', 'first']: - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, '1D', method) + result_optimized = TransformAccessor._resample_by_dimension_groups(ds, '1D', method) result_naive = naive_dataset_resample(ds, '1D', method) xr.testing.assert_allclose(result_optimized, result_naive) @@ -300,7 +300,7 @@ def test_resample_equivalence_with_kwargs(): ds['var'] = xr.DataArray(np.random.randn(48), dims=['time'], coords={'time': ds.time}) kwargs = {'label': 'right', 'closed': 'right'} - result_optimized = fx.FlowSystem._resample_by_dimension_groups(ds, '2h', 'mean', **kwargs) + result_optimized = TransformAccessor._resample_by_dimension_groups(ds, '2h', 'mean', **kwargs) result_naive = ds.resample(time='2h', **kwargs).mean() xr.testing.assert_allclose(result_optimized, result_naive) diff --git a/tests/io/test_io_conversion.py b/tests/io/test_io_conversion.py index c1f2d9d4b..6ba91c3d9 100644 --- a/tests/io/test_io_conversion.py +++ b/tests/io/test_io_conversion.py @@ -5,6 +5,7 @@ import pytest import xarray as xr +import flixopt as fx from flixopt.io import ( PARAMETER_RENAMES, VALUE_RENAMES, @@ -627,152 +628,26 @@ def test_effect_with_all_old_parameters(self): assert result['unit'] == '€' -class TestFlowSystemFromOldResults: - """Tests for FlowSystem.from_old_results() method.""" +class TestFromOldDatasetRealFiles: + """Regression coverage for FlowSystem.from_old_dataset() against the real pre-v5 files. - def test_load_old_results_from_resources(self): - """Test loading old results files from test resources.""" - import pathlib + These fixtures are the only proof that the pre-v5 file bridge survives schema + changes in the current API. The bridge is scheduled for removal in v9. + """ - import flixopt as fx + RESOURCES = pathlib.Path(__file__).parent.parent / 'ressources' - resources_path = pathlib.Path(__file__).parent.parent / 'ressources' - - # Load old results using new method - fs = fx.FlowSystem.from_old_results(resources_path, 'Sim1') - - # Verify FlowSystem was loaded - assert fs is not None - assert fs.name == 'Sim1' - - # Verify solution was attached - assert fs.solution is not None - assert len(fs.solution.data_vars) > 0 - - def test_old_results_can_be_saved_new_format(self, tmp_path): - """Test that old results can be saved in new single-file format.""" - import pathlib - - import flixopt as fx - - resources_path = pathlib.Path(__file__).parent.parent / 'ressources' - - # Load old results - fs = fx.FlowSystem.from_old_results(resources_path, 'Sim1') - - # Save in new format - new_path = tmp_path / 'migrated.nc' - fs.to_netcdf(new_path) - - # Verify the new file exists and can be loaded - assert new_path.exists() - loaded = fx.FlowSystem.from_netcdf(new_path) - assert loaded is not None - assert loaded.solution is not None - - -class TestV4APIConversion: - """Tests for converting v4 API result files to the new format.""" - - V4_API_PATH = pathlib.Path(__file__).parent.parent / 'ressources' / 'v4-api' - - # All result names in the v4-api folder - V4_RESULT_NAMES = [ - '00_minimal', - '01_simple', - '02_complex', - '04_scenarios', - 'io_flow_system_base', - 'io_flow_system_long', - 'io_flow_system_segments', - 'io_simple_flow_system', - 'io_simple_flow_system_scenarios', - ] - - @pytest.mark.parametrize('result_name', V4_RESULT_NAMES) - def test_v4_results_can_be_loaded(self, result_name): - """Test that v4 API results can be loaded.""" - import flixopt as fx - - fs = fx.FlowSystem.from_old_results(self.V4_API_PATH, result_name) - - # Verify FlowSystem was loaded - assert fs is not None - assert fs.name == result_name - - # Verify solution was attached - assert fs.solution is not None - assert len(fs.solution.data_vars) > 0 - - # Verify we have components + @pytest.mark.parametrize( + 'file', sorted(p.name for p in RESOURCES.rglob('*--flow_system.nc4')), ids=lambda f: f.split('--')[0] + ) + def test_loads_every_pre_v5_file(self, file): + path = next(self.RESOURCES.rglob(file)) + fs = fx.FlowSystem.from_old_dataset(path) assert len(fs.components) > 0 + assert len(fs.buses) > 0 - @pytest.mark.parametrize('result_name', V4_RESULT_NAMES) - def test_v4_results_can_be_saved_and_reloaded(self, result_name, tmp_path): - """Test that v4 API results can be saved in new format and reloaded.""" - import flixopt as fx - - # Load old results - fs = fx.FlowSystem.from_old_results(self.V4_API_PATH, result_name) - - # Save in new format - new_path = tmp_path / f'{result_name}_migrated.nc' - fs.to_netcdf(new_path) - - # Reload and verify - loaded = fx.FlowSystem.from_netcdf(new_path) - assert loaded is not None - assert loaded.solution is not None - assert len(loaded.solution.data_vars) == len(fs.solution.data_vars) - assert len(loaded.components) == len(fs.components) - - @pytest.mark.parametrize('result_name', V4_RESULT_NAMES) - def test_v4_solution_variables_accessible(self, result_name): - """Test that solution variables from v4 results are accessible.""" - import flixopt as fx - - fs = fx.FlowSystem.from_old_results(self.V4_API_PATH, result_name) - - # Check that we can access solution variables - for var_name in list(fs.solution.data_vars)[:5]: # Check first 5 variables - var = fs.solution[var_name] - assert var is not None - # Variables should have data - assert var.size > 0 - - @pytest.mark.parametrize('result_name', V4_RESULT_NAMES) - def test_v4_reoptimized_objective_matches_original(self, result_name): - """Test that re-solving the migrated FlowSystem gives the same objective effect.""" - import flixopt as fx - - # Load old results - fs = fx.FlowSystem.from_old_results(self.V4_API_PATH, result_name) - - # Get the objective effect label - objective_effect_label = fs.effects.objective_effect.label - - # Get the original effect total from the old solution (sum for multi-scenario) - old_effect_total = float(fs.solution[objective_effect_label].values.sum()) - old_objective = float(fs.solution['objective'].values.sum()) - - # Re-solve the FlowSystem - fs.optimize(fx.solvers.HighsSolver(mip_gap=0)) - - # Get new objective effect total (sum for multi-scenario) - new_objective = float(fs.solution['objective'].item()) - new_effect_total = float(fs.solution[objective_effect_label].sum().item()) - - # Skip comparison for scenarios test case - scenario weights are now always normalized, - # which changes the objective value when loading old results with non-normalized weights - if result_name == '04_scenarios': - pytest.skip('Scenario weights are now always normalized - old results have different weights') - - # Verify objective matches (within tolerance) - assert new_objective == pytest.approx(old_objective, rel=1e-5, abs=1), ( - f'Objective mismatch for {result_name}: new={new_objective}, old={old_objective}' - ) - - assert new_effect_total == pytest.approx(old_effect_total, rel=1e-5, abs=1), ( - f'Effect {objective_effect_label} mismatch for {result_name}: ' - f'new={new_effect_total}, old={old_effect_total}' - ) + def test_loaded_system_optimizes(self, highs_solver): + path = next(self.RESOURCES.rglob('01_simple--flow_system.nc4')) + fs = fx.FlowSystem.from_old_dataset(path) + fs.optimize(highs_solver) + assert fs.solution['objective'].item() == pytest.approx(83.88, rel=1e-3) diff --git a/tests/plotting/test_network_app.py b/tests/plotting/test_network_app.py deleted file mode 100644 index bc734c43e..000000000 --- a/tests/plotting/test_network_app.py +++ /dev/null @@ -1,24 +0,0 @@ -import pytest - -import flixopt as fx - -from ..conftest import ( - flow_system_long, - flow_system_segments_of_flows_2, - simple_flow_system, -) - - -@pytest.fixture(params=[simple_flow_system, flow_system_segments_of_flows_2, flow_system_long]) -def flow_system(request): - fs = request.getfixturevalue(request.param.__name__) - if isinstance(fs, fx.FlowSystem): - return fs - else: - return fs[0] - - -def test_network_app(flow_system): - """Test that flow model constraints are correctly generated.""" - flow_system.start_network_app() - flow_system.stop_network_app() diff --git a/tests/plotting/test_topology_accessor.py b/tests/plotting/test_topology_accessor.py index 09f789b2b..7a8d6a873 100644 --- a/tests/plotting/test_topology_accessor.py +++ b/tests/plotting/test_topology_accessor.py @@ -1,8 +1,5 @@ """Tests for the TopologyAccessor class.""" -import tempfile -from pathlib import Path - import plotly.graph_objects as go import pytest @@ -118,66 +115,3 @@ def test_plot_with_colors(self, flow_system): # Should not raise flow_system.topology.plot(colors='Viridis', show=False) flow_system.topology.plot(colors=['red', 'blue', 'green'], show=False) - - -class TestTopologyPlotLegacy: - """Tests for topology.plot_legacy() method (PyVis-based).""" - - def test_plot_legacy_returns_network_or_none(self, flow_system): - """Test that plot_legacy() returns a pyvis Network or None.""" - try: - import pyvis - - result = flow_system.topology.plot_legacy(path=False, show=False) - assert result is None or isinstance(result, pyvis.network.Network) - except ImportError: - # pyvis not installed, should return None - result = flow_system.topology.plot_legacy(path=False, show=False) - assert result is None - - def test_plot_legacy_creates_html_file(self, flow_system): - """Test that plot_legacy() creates an HTML file when path is specified.""" - pytest.importorskip('pyvis') - - with tempfile.TemporaryDirectory() as tmpdir: - html_path = Path(tmpdir) / 'network.html' - flow_system.topology.plot_legacy(path=str(html_path), show=False) - assert html_path.exists() - content = html_path.read_text() - assert '' in content.lower() or '