diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 000000000..26b27e608 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,108 @@ +name: Docs + +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'mkdocs.yml' + - 'flixopt/**' + pull_request: + paths: + - 'docs/**' + - 'mkdocs.yml' + workflow_dispatch: + workflow_call: + inputs: + deploy: + type: boolean + default: false + version: + type: string + required: false + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + PYTHON_VERSION: "3.11" + MPLBACKEND: Agg + PLOTLY_RENDERER: json + +jobs: + build: + name: Build documentation + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: astral-sh/setup-uv@v7 + with: + version: "0.9.10" + enable-cache: true + + - uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Extract changelog + run: | + cp CHANGELOG.md docs/changelog.md + python scripts/format_changelog.py + + - name: Install dependencies + run: uv pip install --system ".[docs,full]" + + - name: Build docs + run: mkdocs build --strict + + - uses: actions/upload-artifact@v4 + with: + name: docs + path: site/ + retention-days: 7 + + deploy: + name: Deploy documentation + needs: build + if: ${{ inputs.deploy == true && inputs.version != '' }} + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: astral-sh/setup-uv@v7 + with: + version: "0.9.10" + enable-cache: true + + - uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install mike + run: uv pip install --system mike + + - uses: actions/download-artifact@v4 + with: + name: docs + path: site/ + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Deploy docs + run: | + VERSION=${{ inputs.version }} + VERSION=${VERSION#v} + mike deploy --push --update-aliases --no-build $VERSION latest + mike set-default --push latest diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8d0d0de1f..2bf49bfc1 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -80,7 +80,7 @@ jobs: name: Run tests needs: [check-preparation] if: needs.check-preparation.outputs.prepared == 'true' - uses: ./.github/workflows/test.yaml + uses: ./.github/workflows/tests.yaml build: name: Build package @@ -188,37 +188,7 @@ jobs: name: Deploy documentation needs: [create-release] if: "!contains(github.ref, 'alpha') && !contains(github.ref, 'beta') && !contains(github.ref, 'rc')" - runs-on: ubuntu-24.04 - permissions: - contents: write - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - uses: astral-sh/setup-uv@v6 - with: - version: "0.9.10" - - - uses: actions/setup-python@v6 - with: - python-version: ${{ env.PYTHON_VERSION }} - - - name: Extract changelog - run: | - uv pip install --system packaging - python scripts/extract_changelog.py - - - name: Install docs dependencies - run: uv pip install --system ".[docs]" - - - name: Configure Git - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - - - name: Deploy docs - run: | - VERSION=${GITHUB_REF#refs/tags/v} - mike deploy --push --update-aliases $VERSION latest - mike set-default --push latest + uses: ./.github/workflows/docs.yaml + with: + deploy: true + version: ${{ github.ref_name }} diff --git a/.github/workflows/test.yaml b/.github/workflows/tests.yaml similarity index 94% rename from .github/workflows/test.yaml rename to .github/workflows/tests.yaml index 395fc766c..e8713993b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/tests.yaml @@ -4,9 +4,9 @@ on: push: branches: [main] pull_request: - branches: ["*"] + branches: ["**"] workflow_dispatch: - workflow_call: # Allow release.yaml to call this workflow + workflow_call: # Allow release.yaml to call this workflow. concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -45,7 +45,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.10', '3.11', '3.12', '3.13'] + python-version: ['3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v5 diff --git a/.gitignore b/.gitignore index cc2179b07..169c1a587 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ venv/ .DS_Store lib/ temp-plot.html +.cache +site/ +*.egg-info +uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e39033067..cee9e379e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,10 +7,17 @@ repos: - id: check-yaml exclude: ^mkdocs\.yml$ # Skip mkdocs.yml - id: check-added-large-files + exclude: ^examples/resources/Zeitreihen2020\.csv$ - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.12.4 hooks: - - id: ruff-check - args: [ --fix ] - - id: ruff-format + - id: ruff-check + args: [ --fix ] + - id: ruff-format + + - repo: https://github.com/kynan/nbstripout + rev: 0.8.2 + hooks: + - id: nbstripout + files: ^docs/examples.*\.ipynb$ diff --git a/CHANGELOG.md b/CHANGELOG.md index 604772818..63804b551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ This contains all commits, PRs, and contributors. Therefore, the Changelog should focus on the user-facing changes. Please remove all irrelevant sections before releasing. -Please keep the format of the changelog consistent with the other releases, so the extraction for mkdocs works. +Please keep the format of the changelog consistent: ## [VERSION] - YYYY-MM-DD --- ## [Template] - ????-??-?? @@ -49,38 +49,372 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp --- -## [Unreleased] - ????-??-?? +Until here --> -**Summary**: +## [Upcoming] - v5.0.0 -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](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v3/). +**Summary**: This is a major release that fundamentally reimagines how users interact with flixopt. The new **FlowSystem-centric API** dramatically simplifies workflows by integrating optimization, results access, and visualization directly into the FlowSystem object. This release also completes the terminology standardization (OnOff → Status) and **removes all deprecated items from v4.x**. + +!!! tip "Migration Guide" + + See the [Migration Guide v5](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v5/) for step-by-step upgrade instructions. ### ✨ Added +**FlowSystem-Centric Architecture**: The FlowSystem is now the central hub for all operations: + +```python +import flixopt as fx + +# Create and configure your system +flow_system = fx.FlowSystem(timesteps) +flow_system.add_elements(boiler, heat_bus, costs) + +# Optimize directly on FlowSystem (no more Optimization class!) +flow_system.optimize(fx.solvers.HighsSolver()) + +# Access results via solution Dataset +total_costs = flow_system.solution['costs'].item() +flow_rate = flow_system.solution['Boiler(Q_th)|flow_rate'].values + +# Plot with new accessor API +flow_system.statistics.plot.balance('HeatBus') +flow_system.statistics.plot.sankey.flows() +``` + +**New Accessor-Based API**: Four accessor patterns provide organized, discoverable interfaces: + +| Accessor | Purpose | Example | +|----------|---------|---------| +| `flow_system.statistics` | Data access (flow rates, sizes, effects) | `flow_system.statistics.flow_rates` | +| `flow_system.statistics.plot` | Visualization methods | `flow_system.statistics.plot.balance('Bus')` | +| `flow_system.transform` | FlowSystem transformations | `flow_system.transform.cluster(params)` | +| `flow_system.topology` | Network structure & visualization | `flow_system.topology.plot_network()` | + +**Statistics Accessor**: Access aggregated results data with clean, consistent naming: + +```python +stats = flow_system.statistics + +# Flow data (clean labels, no |flow_rate suffix needed) +stats.flow_rates['Boiler(Q_th)'] +stats.flow_hours['Boiler(Q_th)'] +stats.sizes['Boiler(Q_th)'] +stats.charge_states['Battery'] + +# Effect breakdown by contributor +stats.temporal_effects['costs'] # Per timestep, per contributor +stats.periodic_effects['costs'] # Investment costs per contributor +stats.total_effects['costs'] # Total per contributor +``` + +**Comprehensive Plotting API**: All plots return `PlotResult` objects with chainable methods: + +```python +# Balance plots for buses and components +flow_system.statistics.plot.balance('ElectricityBus') +flow_system.statistics.plot.balance('Boiler', mode='area') + +# Storage visualization with charge state +flow_system.statistics.plot.storage('Battery') + +# Heatmaps with automatic time reshaping +flow_system.statistics.plot.heatmap('Boiler(Q_th)|flow_rate', reshape=('D', 'h')) + +# Flow-based Sankey diagrams +flow_system.statistics.plot.sankey.flows() +flow_system.statistics.plot.sankey.flows(select={'bus': 'ElectricityBus'}) + +# Effect contribution Sankey +flow_system.statistics.plot.sankey.effects('costs') + +# Method chaining for customization and export +flow_system.statistics.plot.balance('Bus') \ + .update(title='Custom Title', height=600) \ + .to_html('plot.html') \ + .to_csv('data.csv') \ + .show() +``` + +**Carrier Management**: New `Carrier` class for consistent styling across visualizations: + +```python +# Define custom carriers +electricity = fx.Carrier('electricity', '#FFD700', 'kW', 'Electrical power') +district_heat = fx.Carrier('district_heat', '#FF6B6B', 'kW_th') + +# Register with FlowSystem +flow_system.add_carrier(electricity) + +# Use with buses (reference by name) +elec_bus = fx.Bus('MainGrid', carrier='electricity') + +# Or use predefined carriers from CONFIG +fx.CONFIG.Carriers.electricity +fx.CONFIG.Carriers.heat +``` + +**Transform Accessor**: Transformations that create new FlowSystem instances: + +```python +# Time selection and resampling +fs_subset = flow_system.transform.sel(time=slice('2023-01-01', '2023-06-30')) +fs_resampled = flow_system.transform.resample(time='4h', method='mean') + +# Clustered optimization +params = fx.ClusteringParameters(hours_per_period=24, nr_of_periods=8) +clustered_fs = flow_system.transform.cluster(params) +clustered_fs.optimize(solver) +``` + +**Rolling Horizon Optimization**: Decompose large operational problems into sequential segments: + +```python +# Solve with rolling horizon (replaces SegmentedOptimization) +segments = flow_system.optimize.rolling_horizon( + solver, + horizon=192, # Timesteps per segment + overlap=48, # Lookahead for storage optimization +) + +# Combined solution available on original FlowSystem +total_cost = flow_system.solution['costs'].item() + +# Individual segments also available +for seg in segments: + print(seg.solution['costs'].item()) +``` + +**Solution Persistence**: FlowSystem now stores and persists solutions: + +```python +# Optimize and save with solution +flow_system.optimize(solver) +flow_system.to_netcdf('results/my_model.nc4') + +# Load FlowSystem with solution intact +loaded_fs = fx.FlowSystem.from_netcdf('results/my_model.nc4') +print(loaded_fs.solution['costs'].item()) # Solution is available! + +# Migrate old result files +fs = fx.FlowSystem.from_old_results('results_folder', 'my_model') +``` + +**FlowSystem Locking**: FlowSystem automatically locks after optimization to prevent accidental modifications: + +```python +flow_system.optimize(solver) + +# This would raise an error: +# flow_system.add_elements(new_component) # Locked! + +# Clear solution to unlock for modifications +flow_system.solution = None # Now you can modify +``` + +**NetCDF Improvements**: +- Default compression level 5 for smaller files +- `overwrite=False` parameter to prevent accidental overwrites +- Solution data included in FlowSystem NetCDF files +- Automatic name assignment from filename + +**PlotResult Class**: All plotting methods return a `PlotResult` object containing both: +- `data`: An xarray Dataset with the prepared data +- `figure`: A Plotly Figure object + +**Component color parameter**: Components now accept a `color` parameter for consistent visualization styling. + ### 💥 Breaking Changes +**Removed: Optimization and Results Classes** - Use FlowSystem methods instead: + +```python +# Old (v4.x) +optimization = fx.Optimization('model', flow_system) +optimization.do_modeling() +optimization.solve(solver) +results = optimization.results +costs = results.model['costs'].solution.item() + +# New (v5.0) +flow_system.optimize(solver) +costs = flow_system.solution['costs'].item() +``` + +**Renamed `OnOffParameters` → `StatusParameters`**: Complete terminology update to align with industry standards (PyPSA, unit commitment). This is a clean breaking change with no backwards compatibility wrapper. + +| Old Term | New Term | +|----------|----------| +| `OnOffParameters` | `StatusParameters` | +| `on_off_parameters` | `status_parameters` | +| `on` variable | `status` | +| `switch_on` | `startup` | +| `switch_off` | `shutdown` | +| `switch_on_nr` | `startup_count` | +| `on_hours_total` | `active_hours` | +| `consecutive_on_hours` | `uptime` | +| `consecutive_off_hours` | `downtime` | +| `effects_per_switch_on` | `effects_per_startup` | +| `effects_per_running_hour` | `effects_per_active_hour` | +| `consecutive_on_hours_min` | `min_uptime` | +| `consecutive_on_hours_max` | `max_uptime` | +| `consecutive_off_hours_min` | `min_downtime` | +| `consecutive_off_hours_max` | `max_downtime` | +| `switch_on_total_max` | `startup_limit` | +| `force_switch_on` | `force_startup_tracking` | +| `on_hours_min` | `active_hours_min` | +| `on_hours_max` | `active_hours_max` | + +**Bus imbalance terminology and default changed**: +- `excess_penalty_per_flow_hour` → `imbalance_penalty_per_flow_hour` +- Default changed from `1e5` to `None` (strict balance) +- `with_excess` → `allows_imbalance` +- `excess_input` → `virtual_supply` +- `excess_output` → `virtual_demand` + +**Transform methods moved** from FlowSystem to TransformAccessor: + +```python +# Old (deprecated, still works with warning) +fs_subset = flow_system.sel(time=slice('2023-01-01', '2023-06-30')) + +# New (recommended) +fs_subset = flow_system.transform.sel(time=slice('2023-01-01', '2023-06-30')) +``` + +**Storage charge_state changes**: +- `charge_state` no longer has an extra timestep +- Final charge state is now a separate variable: `charge_state|final` + +**Effect.description** now defaults to `''` (empty string) instead of `None`. + +**Stricter I/O**: NetCDF loading is stricter to prevent silent errors. Missing or corrupted data now raises explicit errors. + +**Validation**: Component with `status_parameters` now validates that all flows have sizes (required for big-M constraints). + ### ♻️ Changed -### 🗑️ Deprecated +- Renamed `BusModel.excess_input` → `virtual_supply` and `BusModel.excess_output` → `virtual_demand` for clearer semantics +- Renamed `Bus.excess_penalty_per_flow_hour` → `imbalance_penalty_per_flow_hour` +- Renamed `Bus.with_excess` → `allows_imbalance` +- Results class deprecated in favor of `flow_system.solution` and `flow_system.statistics` +- All plotting methods (`flow_rates()`, `flow_hours()`, etc.) deprecated in favor of statistics accessor -### 🔥 Removed +### 🗑️ Deprecated -### 🐛 Fixed +- `Results` class → Access results via `flow_system.solution` after optimization +- `results.flow_rates()` → Use `flow_system.statistics.flow_rates` +- `results.flow_hours()` → Use `flow_system.statistics.flow_hours` +- `flow_system.sel()` → Use `flow_system.transform.sel()` +- `flow_system.isel()` → Use `flow_system.transform.isel()` +- `flow_system.resample()` → Use `flow_system.transform.resample()` -### 🔒 Security +### 🔥 Removed -### 📦 Dependencies +**Modules removed:** +- `calculation.py` module - Use `flow_system.optimize()` instead + +**Classes removed:** +- `Calculation`, `FullCalculation` → Use `flow_system.optimize()` +- `AggregatedCalculation` → Use `flow_system.transform.cluster()` + `optimize()` +- `SegmentedCalculation`, `SegmentedOptimization` → Use `flow_system.optimize.rolling_horizon()` +- `Aggregation` → Use `Clustering` +- `AggregationParameters` → Use `ClusteringParameters` +- `AggregationModel` → Use `ClusteringModel` +- `CalculationResults` → Use `flow_system.solution` +- `SegmentedCalculationResults` → Use `SegmentedResults` +- `OnOffParameters` → Use `StatusParameters` + +**Functions removed:** +- `change_logging_level()` → Use `CONFIG.Logging.enable_console()` + +**Properties removed:** +- `FlowSystem.all_elements` → Use dict-like interface (`flow_system['label']`, `.keys()`, `.values()`, `.items()`) +- `FlowSystem.weights` → Use `scenario_weights` + +**Features removed:** +- Passing `Bus` objects directly to `Flow` → Pass bus label string instead and add Bus to FlowSystem +- Using `Effect` objects in `EffectValues` → Use effect label strings instead + +**All deprecated parameters from v4.x removed:** + +| Class | Old Parameter | New Parameter | +|-------|--------------|---------------| +| TimeSeriesData | `agg_group` | `aggregation_group` | +| TimeSeriesData | `agg_weight` | `aggregation_weight` | +| Effect | `minimum_operation` | `minimum_temporal` | +| Effect | `maximum_operation` | `maximum_temporal` | +| Effect | `minimum_invest` | `minimum_periodic` | +| Effect | `maximum_invest` | `maximum_periodic` | +| Flow | `flow_hours_total_max` | `flow_hours_max` | +| Flow | `flow_hours_total_min` | `flow_hours_min` | +| InvestParameters | `fix_effects` | `effects_of_investment` | +| InvestParameters | `specific_effects` | `effects_of_investment_per_size` | +| InvestParameters | `divest_effects` | `effects_of_retirement` | +| InvestParameters | `optional` | `mandatory` (inverted) | +| Storage | `"lastValueOfSim"` | `"equals_final"` | +| Source | `source` | `outputs` | +| Sink | `sink` | `inputs` | +| LinearConverters | `Q_fu` | `fuel_flow` | +| LinearConverters | `P_el` | `electrical_flow` | +| LinearConverters | `Q_th` | `thermal_flow` | +| LinearConverters | `eta` | `thermal_efficiency` | +| LinearConverters | `COP` | `cop` | ### 📝 Docs +**Comprehensive Tutorial Notebooks** - 12 new Jupyter notebooks covering all major use cases: + +1. **01-Quickstart** - Minimal working example +2. **02-Heat System** - District heating with storage +3. **03-Investment Optimization** - Optimal equipment sizing +4. **04-Operational Constraints** - Startup costs, uptime/downtime +5. **05-Multi-Carrier System** - CHP producing electricity and heat +6. **06a-Time-Varying Parameters** - Temperature-dependent COP +7. **06b-Piecewise Conversion** - Load-dependent efficiency +8. **06c-Piecewise Effects** - Economies of scale +9. **07-Scenarios and Periods** - Multi-year planning +10. **08-Large-Scale Optimization** - Resampling and two-stage +11. **09-Plotting and Data Access** - Visualization guide +12. **10-Transmission** - Connecting sites with pipelines/cables + +**New Documentation Pages:** +- Migration Guide v5 - Step-by-step upgrade instructions +- Results & Plotting Guide - Comprehensive plotting documentation +- Building Models Guide - Component selection and modeling patterns +- FAQ - Common questions and answers +- Troubleshooting - Problem diagnosis and solutions + ### 👷 Development -### 🚧 Known Issues +**New Test Suites:** +- `test_flow_system_locking.py` - FlowSystem locking behavior +- `test_solution_and_plotting.py` - Statistics accessor and plotting +- `test_solution_persistence.py` - Solution save/load +- `test_io_conversion.py` - Old file format conversion +- `test_topology_accessor.py` - Network visualization + +**CI Improvements:** +- Separate docs build and deploy workflow +- Improved test organization with deprecated tests in separate folder + +### Migration Checklist + +| Task | Action | +|------|--------| +| Replace `Optimization` class | Use `flow_system.optimize(solver)` | +| Replace `SegmentedOptimization` class | Use `flow_system.optimize.rolling_horizon(solver, ...)` | +| Replace `Results` access | Use `flow_system.solution['var_name']` | +| Update `OnOffParameters` | Rename to `StatusParameters` with new parameter names | +| Update `on_off_parameters` | Rename to `status_parameters` | +| Update Bus excess parameters | Use `imbalance_penalty_per_flow_hour` | +| Update deprecated parameters | See removal table above | +| Update I/O code | Use `to_netcdf()` / `from_netcdf()` on FlowSystem | +| Update transform methods | Use `flow_system.transform.sel/isel/resample()` | +| Migrate old result files | Use `FlowSystem.from_old_results(folder, name)` | --- -Until here --> - ## [4.3.5] - 2025-11-29 **Summary**: Fix zenodo again diff --git a/README.md b/README.md index 6d049819d..713ec2a99 100644 --- a/README.md +++ b/README.md @@ -96,10 +96,10 @@ boiler = fx.Boiler("Boiler", eta=0.9, ...) ### Key Features **Multi-criteria optimization:** Model costs, emissions, resource use - any custom metric. Optimize single objectives or use weighted combinations and ε-constraints. -→ [Effects documentation](https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/effects-penalty-objective/) +→ [Effects documentation](https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/effects-and-dimensions/) -**Performance at any scale:** Choose optimization modes without changing your model - Optimization, SegmentedOptimization, or ClusteredOptimization (using [TSAM](https://github.com/FZJ-IEK3-VSA/tsam)). -→ [Optimization modes](https://flixopt.github.io/flixopt/latest/api-reference/optimization/) +**Performance at any scale:** Choose optimization modes without changing your model - full optimization, rolling horizon, or clustering (using [TSAM](https://github.com/FZJ-IEK3-VSA/tsam)). +→ [Scaling notebooks](https://flixopt.github.io/flixopt/latest/notebooks/08a-aggregation/) **Built for reproducibility:** Self-contained NetCDF result files with complete model information. Load results months later - everything is preserved. → [Results documentation](https://flixopt.github.io/flixopt/latest/api-reference/results/) diff --git a/bot-test.txt b/bot-test.txt deleted file mode 100644 index 4a67d9913..000000000 --- a/bot-test.txt +++ /dev/null @@ -1 +0,0 @@ -This is a test file to register the bot diff --git a/docs/examples/00-Minimal Example.md b/docs/examples/00-Minimal Example.md deleted file mode 100644 index a568cd9c9..000000000 --- a/docs/examples/00-Minimal Example.md +++ /dev/null @@ -1,5 +0,0 @@ -# Minimal Example - -```python -{! ../examples/00_Minmal/minimal_example.py !} -``` diff --git a/docs/examples/01-Basic Example.md b/docs/examples/01-Basic Example.md deleted file mode 100644 index 6c6bfbee3..000000000 --- a/docs/examples/01-Basic Example.md +++ /dev/null @@ -1,5 +0,0 @@ -# Simple example - -```python -{! ../examples/01_Simple/simple_example.py !} -``` diff --git a/docs/examples/02-Complex Example.md b/docs/examples/02-Complex Example.md deleted file mode 100644 index 48868cdb0..000000000 --- a/docs/examples/02-Complex Example.md +++ /dev/null @@ -1,10 +0,0 @@ -# Complex example -This saves the results of a calculation to file and reloads them to analyze the results -## Build the Model -```python -{! ../examples/02_Complex/complex_example.py !} -``` -## Load the Results from file -```python -{! ../examples/02_Complex/complex_example_results.py !} -``` diff --git a/docs/examples/03-Optimization Modes.md b/docs/examples/03-Optimization Modes.md deleted file mode 100644 index 880366906..000000000 --- a/docs/examples/03-Optimization Modes.md +++ /dev/null @@ -1,5 +0,0 @@ -# Optimization Modes Comparison -**Note:** This example relies on time series data. You can find it in the `examples` folder of the FlixOpt repository. -```python -{! ../examples/03_Optimization_modes/example_optimization_modes.py !} -``` diff --git a/docs/examples/04-Scenarios.md b/docs/examples/04-Scenarios.md deleted file mode 100644 index b528bb6f3..000000000 --- a/docs/examples/04-Scenarios.md +++ /dev/null @@ -1,5 +0,0 @@ -# Scenario example - -```python -{! ../examples/04_Scenarios/scenario_example.py !} -``` diff --git a/docs/examples/05-Two-stage-optimization.md b/docs/examples/05-Two-stage-optimization.md deleted file mode 100644 index 5cb94e325..000000000 --- a/docs/examples/05-Two-stage-optimization.md +++ /dev/null @@ -1,5 +0,0 @@ -# Two-stage optimization - -```python -{! ../examples/05_Two-stage-optimization/two_stage_optimization.py !} -``` diff --git a/docs/examples/index.md b/docs/examples/index.md deleted file mode 100644 index b5534b8e3..000000000 --- a/docs/examples/index.md +++ /dev/null @@ -1,14 +0,0 @@ -# Examples - -Here you can find a collection of examples that demonstrate how to use FlixOpt. - -We work on improving this gallery. If you have something to share, please contact us! - -## Available Examples - -1. [Minimal Example](00-Minimal Example.md) - The simplest possible FlixOpt model -2. [Simple Example](01-Basic Example.md) - A basic example with more features -3. [Complex Example](02-Complex Example.md) - A comprehensive example with result saving and loading -4. [Optimization Modes](03-Optimization Modes.md) - Comparison of different optimization modes -5. [Scenarios](04-Scenarios.md) - Working with scenarios in FlixOpt -6. [Two-stage Optimization](05-Two-stage-optimization.md) - Two-stage optimization approach diff --git a/docs/getting-started.md b/docs/getting-started.md deleted file mode 100644 index 0cdd2a5a7..000000000 --- a/docs/getting-started.md +++ /dev/null @@ -1,65 +0,0 @@ -# Getting Started with FlixOpt - -This guide will help you install FlixOpt, understand its basic concepts, and run your first optimization model. - -## Installation - -### Basic Installation - -Install FlixOpt directly into your environment using pip: - -```bash -pip install flixopt -``` - -This provides the core functionality with the HiGHS solver included. - -### Full Installation - -For all features including interactive network visualizations and time series aggregation: - -```bash -pip install "flixopt[full]" -``` - -## Logging - -FlixOpt uses Python's standard logging module with optional colored output via [colorlog](https://github.com/borntyping/python-colorlog). Logging is silent by default but can be easily configured. - -```python -from flixopt import CONFIG - -# Enable colored console logging -CONFIG.Logging.enable_console('INFO') - -# Or use a preset configuration for exploring -CONFIG.exploring() -``` - -For advanced logging configuration, you can use Python's standard logging module directly: - -```python -import logging -logging.basicConfig(level=logging.DEBUG) -``` - -For more details on logging configuration, see the [`CONFIG.Logging`][flixopt.config.CONFIG.Logging] documentation. - -## Basic Workflow - -Working with FlixOpt follows a general pattern: - -1. **Create a [`FlowSystem`][flixopt.flow_system.FlowSystem]** with a time series -2. **Define [`Effects`][flixopt.effects.Effect]** (costs, emissions, etc.) -3. **Define [`Buses`][flixopt.elements.Bus]** as connection points in your system -4. **Add [`Components`][flixopt.components]** like converters, storage, sources/sinks with their Flows -5. **Run [`Optimizations`][flixopt.optimization]** to optimize your system -6. **Analyze [`Results`][flixopt.results]** using built-in or external visualization tools - -## Next Steps - -Now that you've installed FlixOpt and understand the basic workflow, you can: - -- Learn about the [core concepts of flixopt](user-guide/core-concepts.md) -- Explore some [examples](examples/index.md) -- Check the [API reference](api-reference/index.md) for detailed documentation diff --git a/docs/home/citing.md b/docs/home/citing.md new file mode 100644 index 000000000..a4f900d18 --- /dev/null +++ b/docs/home/citing.md @@ -0,0 +1,29 @@ +# Citing flixOpt + +If you use flixOpt in your research, please cite it. + +## Citation + +When referencing flixOpt in academic publications, please use look here: [flixopt citation](https://zenodo.org/records/17756895) + +## Publications + +If you've published research using flixOpt, please let us know! We'd love to feature it here. + +### List of Publications + +Coming soon: A list of academic publications that have used flixOpt. + +## Contributing Back + +If flixOpt helped your research: + +- Share your model as an example +- Report issues or contribute code +- Improve documentation + +See the [Contributing Guide](../contribute.md). + +## License + +flixOpt is released under the MIT License. See [License](license.md) for details. diff --git a/docs/home/installation.md b/docs/home/installation.md new file mode 100644 index 000000000..d61022074 --- /dev/null +++ b/docs/home/installation.md @@ -0,0 +1,91 @@ +# Installation + +This guide covers installing flixOpt and its dependencies. + + +## Basic Installation + +Install flixOpt directly into your environment using pip: + +```bash +pip install flixopt +``` + +This provides the core functionality with the HiGHS solver included. + +## Full Installation + +For all features including interactive network visualizations and time series aggregation: + +```bash +pip install "flixopt[full]" +``` + +## Development Installation + +If you want to contribute to flixOpt or work with the latest development version: + +```bash +git clone https://github.com/flixOpt/flixopt.git +cd flixopt +pip install -e ".[full,dev,docs]" +``` + +## Solver Installation + +### HiGHS (Included) + +The HiGHS solver is included with flixOpt and works out of the box. No additional installation is required. + +### Gurobi (Optional) + +For academic use, Gurobi offers free licenses: + +1. Register for an academic license at [gurobi.com](https://www.gurobi.com/academia/) +2. Install Gurobi: + ```bash + pip install gurobipy + ``` +3. Activate your license following Gurobi's instructions + +## Verification + +Verify your installation by running: + +```python +import flixopt +print(flixopt.__version__) +``` + +## Logging Configuration + +flixOpt uses Python's standard logging module with optional colored output via [colorlog](https://github.com/borntyping/python-colorlog). Logging is silent by default but can be easily configured: + +```python +from flixopt import CONFIG + +# Enable colored console logging +CONFIG.Logging.enable_console('INFO') + +# Or use a preset configuration for exploring +CONFIG.exploring() +``` + +Since flixOpt uses Python's standard logging, you can also configure it directly: + +```python +import logging + +# Get the flixopt logger and configure it +logger = logging.getLogger('flixopt') +logger.setLevel(logging.DEBUG) +logger.addHandler(logging.StreamHandler()) +``` + +For more details on logging configuration, see the [`CONFIG.Logging`][flixopt.config.CONFIG.Logging] documentation. + +## Next Steps + +- Follow the [Quick Start](quick-start.md) guide +- Explore the [Minimal Example](../notebooks/01-quickstart.ipynb) +- Read about [Core Concepts](../user-guide/core-concepts.md) diff --git a/docs/home/license.md b/docs/home/license.md new file mode 100644 index 000000000..e0b0266a4 --- /dev/null +++ b/docs/home/license.md @@ -0,0 +1,43 @@ +# License + +flixOpt is released under the MIT License. + +## MIT License + +```text +MIT License + +Copyright (c) 2022 Chair of Building Energy Systems and Heat Supply - TU Dresden + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +## What This Means + +The MIT License is a permissive open-source license that allows you to: + +✅ **Use** flixOpt for any purpose, including commercial applications +✅ **Modify** the source code to fit your needs +✅ **Distribute** copies of flixOpt +✅ **Sublicense** under different terms +✅ **Use privately** without making your modifications public + +## Contributing + +By contributing to flixOpt, you agree that your contributions will be licensed under the MIT License. See our [Contributing Guide](../contribute.md) for more information. diff --git a/docs/home/quick-start.md b/docs/home/quick-start.md new file mode 100644 index 000000000..7bbc88172 --- /dev/null +++ b/docs/home/quick-start.md @@ -0,0 +1,156 @@ +# Quick Start + +Get up and running with flixOpt in 5 minutes! This guide walks you through creating and solving your first energy system optimization. + +## Installation + +First, install flixOpt: + +```bash +pip install "flixopt[full]" +``` + +## Your First Model + +Let's create a simple energy system with a generator, demand, and battery storage. + +### 1. Import flixOpt + +```python +import flixopt as fx +import numpy as np +import pandas as pd +``` + +### 2. Define your time horizon + +```python +# 24h period with hourly timesteps +timesteps = pd.date_range('2024-01-01', periods=24, freq='h') +``` + +### 2. Set Up the Flow System + +```python +# Create the flow system +flow_system = fx.FlowSystem(timesteps) + +# Define an effect to minimize (costs) +costs = fx.Effect('costs', 'EUR', 'Minimize total system costs', is_objective=True) +flow_system.add_elements(costs) +``` + +### 4. Add Components + +```python +# Electricity bus +electricity_bus = fx.Bus('electricity') + +# Solar generator with time-varying output +solar_profile = np.array([0, 0, 0, 0, 0, 0, 0.2, 0.5, 0.8, 1.0, + 1.0, 0.9, 0.8, 0.7, 0.5, 0.3, 0.1, 0, + 0, 0, 0, 0, 0, 0]) + +solar = fx.Source( + 'solar', + outputs=[fx.Flow( + 'power', + bus='electricity', + size=100, # 100 kW capacity + relative_maximum=solar_profile + ) +]) + +# Demand +demand_profile = np.array([30, 25, 20, 20, 25, 35, 50, 70, 80, 75, + 70, 65, 60, 65, 70, 80, 90, 95, 85, 70, + 60, 50, 40, 35]) + +demand = fx.Sink('demand', inputs=[ + fx.Flow('consumption', + bus='electricity', + size=1, + fixed_relative_profile=demand_profile) +]) + +# Battery storage +battery = fx.Storage( + 'battery', + charging=fx.Flow('charge', bus='electricity', size=50), + discharging=fx.Flow('discharge', bus='electricity', size=50), + capacity_in_flow_hours=100, # 100 kWh capacity + initial_charge_state=50, # Start at 50% + eta_charge=0.95, + eta_discharge=0.95, +) + +# Add all components to system +flow_system.add_elements(solar, demand, battery, electricity_bus) +``` + +### 5. Visualize and Run Optimization + +```python +# Optional: visualize your system structure +flow_system.topology.plot(path='system.html') + +# Run optimization +flow_system.optimize(fx.solvers.HighsSolver()) +``` + +### 6. Access and Visualize Results + +```python +# Access raw solution data +print(flow_system.solution) + +# Use statistics for aggregated data +print(flow_system.statistics.flow_hours) + +# Access component-specific results +print(flow_system.components['battery'].solution) + +# Visualize results +flow_system.statistics.plot.balance('electricity') +flow_system.statistics.plot.storage('battery') +``` + +### 7. Save Results (Optional) + +```python +# Save the flow system (includes inputs and solution) +flow_system.to_netcdf('results/solar_battery.nc') + +# Load it back later +loaded_fs = fx.FlowSystem.from_netcdf('results/solar_battery.nc') +``` + +## What's Next? + +Now that you've created your first model, you can: + +- **Learn the concepts** - Read the [Core Concepts](../user-guide/core-concepts.md) guide +- **Explore examples** - Check out more [Examples](../notebooks/index.md) +- **Deep dive** - Study the [Mathematical Formulation](../user-guide/mathematical-notation/index.md) +- **Build complex models** - Use [Recipes](../user-guide/recipes/index.md) for common patterns + +## Common Workflow + +Most flixOpt projects follow this pattern: + +1. **Define time series** - Set up the temporal resolution +2. **Create flow system** - Initialize with time series and effects +3. **Add buses** - Define connection points +4. **Add components** - Create generators, storage, converters, loads +5. **Verify structure** - Use `flow_system.topology.plot()` to visualize +6. **Run optimization** - Call `flow_system.optimize(solver)` +7. **Analyze results** - Via `flow_system.statistics` and `.solution` +8. **Visualize** - Use `flow_system.statistics.plot.*` methods + +## Tips + +- Start simple and add complexity incrementally +- Use meaningful names for components and flows +- Check solver status before analyzing results +- Enable logging during development for debugging +- Visualize results to verify model behavior diff --git a/docs/home/users.md b/docs/home/users.md new file mode 100644 index 000000000..d27f99576 --- /dev/null +++ b/docs/home/users.md @@ -0,0 +1,27 @@ +# Who Uses flixOpt? + +flixOpt is developed and used primarily in academic research for energy system optimization. + +## Primary Users + +- **Researchers** - Energy system modeling and optimization studies +- **Students** - Master's and PhD thesis projects +- **Engineers** - Feasibility studies and system planning + +## Typical Applications + +- Dispatch optimization with renewable integration +- Capacity expansion planning +- Battery and thermal storage sizing +- District heating network optimization +- Combined heat and power (CHP) systems +- Multi-energy systems and sector coupling + +## Get Involved + +Using flixOpt in your research? Consider: + +- [Citing flixOpt](citing.md) in your publications +- Sharing your model as an example +- Contributing to the codebase +- Joining [discussions](https://github.com/flixOpt/flixopt/discussions) diff --git a/docs/index.md b/docs/index.md index 3467bb394..330a33fca 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,8 +1,5 @@ --- title: Home -hide: - - navigation - - toc ---
@@ -16,8 +13,8 @@ hide:

Model, optimize, and analyze complex energy systems with a powerful Python framework designed for flexibility and performance.

- 🚀 Get Started - 💡 View Examples + 🚀 Get Started + 💡 View Examples ⭐ GitHub

@@ -25,36 +22,44 @@ hide: ## :material-map-marker-path: Quick Navigation -" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 23 + }, + { + "cell_type": "code", + "id": "53", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:17.970954Z", + "start_time": "2025-12-13T14:13:17.939809Z" + } + }, + "source": [ + "# Complex system with multiple carriers\n", + "complex_sys.statistics.plot.sankey.flows()" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "PlotResult(data= Size: 3kB\n", + "Dimensions: (link: 10)\n", + "Coordinates:\n", + " * link (link) int64 80B 0 1 2 3 4 5 6 7 8 9\n", + " source (link) \n", + "
" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 24 + }, + { + "cell_type": "markdown", + "id": "54", + "metadata": {}, + "source": [ + "### 6.2 Sizes Sankey\n", + "\n", + "Capacity/size allocation:" + ] + }, + { + "cell_type": "code", + "id": "55", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:17.993818Z", + "start_time": "2025-12-13T14:13:17.977340Z" + } + }, + "source": [ + "multiperiod.statistics.plot.sankey.sizes()" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "PlotResult(data= Size: 120B\n", + "Dimensions: (link: 1)\n", + "Coordinates:\n", + " * link (link) int64 8B 0\n", + " source (link) \n", + "
" + ] + }, + "execution_count": 25, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 25 + }, + { + "cell_type": "markdown", + "id": "56", + "metadata": {}, + "source": [ + "### 6.3 Peak Flow Sankey\n", + "\n", + "Maximum flow rates (peak power):" + ] + }, + { + "cell_type": "code", + "id": "57", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:18.029364Z", + "start_time": "2025-12-13T14:13:18.001651Z" + } + }, + "source": [ + "simple.statistics.plot.sankey.peak_flow()" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "PlotResult(data= Size: 1kB\n", + "Dimensions: (link: 6)\n", + "Coordinates:\n", + " * link (link) int64 48B 0 1 2 3 4 5\n", + " source (link) \n", + "
" + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 26 + }, + { + "cell_type": "markdown", + "id": "58", + "metadata": {}, + "source": [ + "### 6.4 Effects Sankey\n", + "\n", + "Cost/emission allocation:" + ] + }, + { + "cell_type": "code", + "id": "59", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:18.051137Z", + "start_time": "2025-12-13T14:13:18.037718Z" + } + }, + "source": [ + "simple.statistics.plot.sankey.effects(select={'effect': 'costs'})" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "PlotResult(data= Size: 184B\n", + "Dimensions: (link: 1)\n", + "Coordinates:\n", + " * link (link) int64 8B 0\n", + " source (link) \n", + "
" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 27 + }, + { + "cell_type": "code", + "id": "60", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:18.072870Z", + "start_time": "2025-12-13T14:13:18.057665Z" + } + }, + "source": [ + "# CO2 allocation in complex system\n", + "complex_sys.statistics.plot.sankey.effects(select={'effect': 'CO2'})" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "PlotResult(data= Size: 488B\n", + "Dimensions: (link: 2)\n", + "Coordinates:\n", + " * link (link) int64 16B 0 1\n", + " source (link) \n", + "
" + ] + }, + "execution_count": 28, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 28 + }, + { + "cell_type": "markdown", + "id": "61", + "metadata": {}, + "source": [ + "### 6.5 Filtering with `select`\n", + "\n", + "Filter Sankey to specific buses or carriers:" + ] + }, + { + "cell_type": "code", + "id": "62", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:18.102271Z", + "start_time": "2025-12-13T14:13:18.087615Z" + } + }, + "source": [ + "# Only heat flows\n", + "complex_sys.statistics.plot.sankey.flows(select={'bus': 'Heat'})" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "PlotResult(data= Size: 576B\n", + "Dimensions: (link: 3)\n", + "Coordinates:\n", + " * link (link) int64 24B 0 1 2\n", + " source (link) \n", + "
" + ] + }, + "execution_count": 29, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 29 + }, + { + "cell_type": "markdown", + "id": "63", + "metadata": {}, + "source": [ + "## 7. Topology Visualization\n", + "\n", + "Visualize the system structure (no solution data required)." + ] + }, + { + "cell_type": "markdown", + "id": "64", + "metadata": {}, + "source": [ + "### 7.1 Topology Plot\n", + "\n", + "Sankey-style network diagram:" + ] + }, + { + "cell_type": "code", + "id": "65", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:18.129663Z", + "start_time": "2025-12-13T14:13:18.109005Z" + } + }, + "source": [ + "simple.topology.plot()" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "PlotResult(data= Size: 1kB\n", + "Dimensions: (link: 6)\n", + "Coordinates:\n", + " * link (link) ',\n", + " 'label': [Boiler(Gas), Boiler(Heat), GasGrid(Gas),\n", + " Office(Heat), ThermalStorage(Charge),\n", + " ThermalStorage(Discharge)],\n", + " 'source': [5, 4, 0, 1, 1, 2],\n", + " 'target': [4, 1, 5, 3, 2, 1],\n", + " 'value': [1, 1, 1, 1, 1, 1]},\n", + " 'node': {'color': [#636EFA, #D62728, #00CC96, #AB63FA, #EF553B,\n", + " #1F77B4],\n", + " 'customdata': [Source('GasGrid')
outputs:
*\n", + " Flow('GasGrid(Gas)', bus='Gas', size=500.0,\n", + " effects_per_flow_hour={'costs': ~0.1}),\n", + " Bus('Heat', carrier='heat')
inputs:
\n", + " * Flow('Boiler(Heat)', bus='Heat',\n", + " size=150.0)
*\n", + " Flow('ThermalStorage(Discharge)', bus='Heat',\n", + " size=100.0,\n", + " status_parameters=StatusParameters())
\n", + " outputs:
*\n", + " Flow('ThermalStorage(Charge)', bus='Heat',\n", + " size=100.0,\n", + " status_parameters=StatusParameters())
\n", + " * Flow('Office(Heat)', bus='Heat', size=1.0,\n", + " fixed_relative_profile=20.0-92.3),\n", + " Storage('ThermalStorage',\n", + " capacity_in_flow_hours=500.0,\n", + " initial_charge_state=250.0,\n", + " minimal_final_charge_state=200.0,\n", + " eta_charge=1.0, eta_discharge=1.0,\n", + " relative_loss_per_hour=0.0)
inputs:
\n", + " * Flow('ThermalStorage(Charge)', bus='Heat',\n", + " size=100.0,\n", + " status_parameters=StatusParameters())
\n", + " outputs:
*\n", + " Flow('ThermalStorage(Discharge)', bus='Heat',\n", + " size=100.0,\n", + " status_parameters=StatusParameters()),\n", + " Sink('Office')
inputs:
*\n", + " Flow('Office(Heat)', bus='Heat', size=1.0,\n", + " fixed_relative_profile=20.0-92.3),\n", + " Boiler('Boiler', thermal_efficiency=0.9,\n", + " fuel_flow=Flow('Boiler(Gas)', bus='Gas'),\n", + " thermal_flow=Flow('Boiler(Heat)', bus='Heat',\n", + " size=150.0))
inputs:
*\n", + " Flow('Boiler(Gas)', bus='Gas')
\n", + " outputs:
* Flow('Boiler(Heat)',\n", + " bus='Heat', size=150.0), Bus('Gas',\n", + " carrier='gas')
inputs:
*\n", + " Flow('GasGrid(Gas)', bus='Gas', size=500.0,\n", + " effects_per_flow_hour={'costs': ~0.1})
\n", + " outputs:
* Flow('Boiler(Gas)',\n", + " bus='Gas')],\n", + " 'hovertemplate': '%{customdata}',\n", + " 'label': [GasGrid, Heat, ThermalStorage, Office, Boiler,\n", + " Gas],\n", + " 'line': {'color': 'black', 'width': 0.5},\n", + " 'pad': 15,\n", + " 'thickness': 20},\n", + " 'type': 'sankey'}],\n", + " 'layout': {'template': '...', 'title': {'text': 'Flow System Topology'}}\n", + "}))" + ], + "text/html": [ + "
\n", + "
" + ] + }, + "execution_count": 30, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 30 + }, + { + "cell_type": "code", + "id": "66", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:18.157403Z", + "start_time": "2025-12-13T14:13:18.136357Z" + } + }, + "source": [ + "complex_sys.topology.plot(title='Complex System Topology')" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "PlotResult(data= Size: 3kB\n", + "Dimensions: (link: 14)\n", + "Coordinates:\n", + " * link (link) ',\n", + " 'label': [BackupBoiler(Gas), BackupBoiler(Heat), CHP(El),\n", + " CHP(Gas), CHP(Heat), ElDemand(El),\n", + " ElectricityExport(El), ElectricityImport(El),\n", + " GasGrid(Gas), HeatDemand(Heat), HeatPump(El),\n", + " HeatPump(Heat), HeatStorage(Charge),\n", + " HeatStorage(Discharge)],\n", + " 'source': [11, 1, 9, 11, 9, 0, 0, 10, 2, 6, 0, 3, 6, 8],\n", + " 'target': [1, 6, 0, 9, 6, 4, 5, 0, 11, 7, 3, 6, 8, 6],\n", + " 'value': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]},\n", + " 'node': {'color': [#FECB52, #19D3F3, #636EFA, #FFA15A, #FF97FF,\n", + " #00CC96, #D62728, #B6E880, #FF6692, #AB63FA,\n", + " #EF553B, #1F77B4],\n", + " 'customdata': [Bus('Electricity',\n", + " carrier='electricity')
inputs:
*\n", + " Flow('ElectricityImport(El)',\n", + " bus='Electricity', size=100.0,\n", + " effects_per_flow_hour={'costs': 0.1-0.2,\n", + " 'CO2': 0.3-0.4})
* Flow('CHP(El)',\n", + " bus='Electricity', size=80.0,\n", + " status_parameters=StatusParameters())
\n", + " outputs:
*\n", + " Flow('ElectricityExport(El)',\n", + " bus='Electricity', size=50.0,\n", + " effects_per_flow_hour={'costs': -0.2--\n", + " 0.1})
* Flow('HeatPump(El)',\n", + " bus='Electricity')
*\n", + " Flow('ElDemand(El)', bus='Electricity',\n", + " size=1.0, fixed_relative_profile=10.0-42.3),\n", + " Boiler('BackupBoiler',\n", + " thermal_efficiency=0.9,\n", + " fuel_flow=Flow('BackupBoiler(Gas)',\n", + " bus='Gas'),\n", + " thermal_flow=Flow('BackupBoiler(Heat)',\n", + " bus='Heat', size=80.0))
inputs:
*\n", + " Flow('BackupBoiler(Gas)', bus='Gas')
\n", + " outputs:
* Flow('BackupBoiler(Heat)',\n", + " bus='Heat', size=80.0), Source('GasGrid')
\n", + " outputs:
* Flow('GasGrid(Gas)',\n", + " bus='Gas', size=300.0,\n", + " effects_per_flow_hour={'costs': 0.1, 'CO2':\n", + " 0.2}), HeatPump('HeatPump', cop=3.5,\n", + " electrical_flow=Flow('HeatPump(El)',\n", + " bus='Electricity'),\n", + " thermal_flow=Flow('HeatPump(Heat)',\n", + " bus='Heat', size=InvestP...)
inputs:
\n", + " * Flow('HeatPump(El)', bus='Electricity')
\n", + " outputs:
* Flow('HeatPump(Heat)',\n", + " bus='Heat',\n", + " size=InvestParameters(minimum_size=0.0,\n", + " maximum_size...), Sink('ElDemand')
\n", + " inputs:
* Flow('ElDemand(El)',\n", + " bus='Electricity', size=1.0,\n", + " fixed_relative_profile=10.0-42.3),\n", + " Sink('ElectricityExport')
inputs:
\n", + " * Flow('ElectricityExport(El)',\n", + " bus='Electricity', size=50.0,\n", + " effects_per_flow_hour={'costs': -0.2--0.1}),\n", + " Bus('Heat', carrier='heat')
inputs:
\n", + " * Flow('CHP(Heat)', bus='Heat', size=85.0,\n", + " status_parameters=StatusParameters())
\n", + " * Flow('HeatPump(Heat)', bus='Heat',\n", + " size=InvestParameters(minimum_size=0.0,\n", + " maximum_size...)
*\n", + " Flow('BackupBoiler(Heat)', bus='Heat',\n", + " size=80.0)
*\n", + " Flow('HeatStorage(Discharge)', bus='Heat',\n", + " size=50.0,\n", + " status_parameters=StatusParameters())
\n", + " outputs:
* Flow('HeatStorage(Charge)',\n", + " bus='Heat', size=50.0,\n", + " status_parameters=StatusParameters())
\n", + " * Flow('HeatDemand(Heat)', bus='Heat',\n", + " size=1.0, fixed_relative_profile=20.0-87.5),\n", + " Sink('HeatDemand')
inputs:
*\n", + " Flow('HeatDemand(Heat)', bus='Heat',\n", + " size=1.0, fixed_relative_profile=20.0-87.5),\n", + " Storage('HeatStorage', capacity_in_flow_hours\n", + " =InvestParameters(minimum_size=0.0,\n", + " maximum_size..., eta_charge=1.0,\n", + " eta_discharge=1.0)
inputs:
*\n", + " Flow('HeatStorage(Charge)', bus='Heat',\n", + " size=50.0,\n", + " status_parameters=StatusParameters())
\n", + " outputs:
*\n", + " Flow('HeatStorage(Discharge)', bus='Heat',\n", + " size=50.0,\n", + " status_parameters=StatusParameters()),\n", + " LinearConverter('CHP', status_parameters=Stat\n", + " usParameters(effects_per_active_hour={'cost..\n", + " ., piecewise_conversion=PiecewiseConversion(p\n", + " iecewises={'Gas': Piecewis...)
\n", + " inputs:
* Flow('CHP(Gas)', bus='Gas',\n", + " size=200.0,\n", + " status_parameters=StatusParameters())
\n", + " outputs:
* Flow('CHP(El)',\n", + " bus='Electricity', size=80.0,\n", + " status_parameters=StatusParameters())
\n", + " * Flow('CHP(Heat)', bus='Heat', size=85.0,\n", + " status_parameters=StatusParameters()),\n", + " Source('ElectricityImport')
outputs:
\n", + " * Flow('ElectricityImport(El)',\n", + " bus='Electricity', size=100.0,\n", + " effects_per_flow_hour={'costs': 0.1-0.2,\n", + " 'CO2': 0.3-0.4}), Bus('Gas',\n", + " carrier='gas')
inputs:
*\n", + " Flow('GasGrid(Gas)', bus='Gas', size=300.0,\n", + " effects_per_flow_hour={'costs': 0.1, 'CO2':\n", + " 0.2})
outputs:
* Flow('CHP(Gas)',\n", + " bus='Gas', size=200.0,\n", + " status_parameters=StatusParameters())
\n", + " * Flow('BackupBoiler(Gas)', bus='Gas')],\n", + " 'hovertemplate': '%{customdata}',\n", + " 'label': [Electricity, BackupBoiler, GasGrid, HeatPump,\n", + " ElDemand, ElectricityExport, Heat, HeatDemand,\n", + " HeatStorage, CHP, ElectricityImport, Gas],\n", + " 'line': {'color': 'black', 'width': 0.5},\n", + " 'pad': 15,\n", + " 'thickness': 20},\n", + " 'type': 'sankey'}],\n", + " 'layout': {'template': '...', 'title': {'text': 'Complex System Topology'}}\n", + "}))" + ], + "text/html": [ + "
\n", + "
" + ] + }, + "execution_count": 31, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 31 + }, + { + "cell_type": "markdown", + "id": "67", + "metadata": {}, + "source": [ + "### 7.2 Topology Info\n", + "\n", + "Get node and edge information programmatically:" + ] + }, + { + "cell_type": "code", + "id": "68", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:18.168871Z", + "start_time": "2025-12-13T14:13:18.165083Z" + } + }, + "source": [ + "nodes, edges = simple.topology.infos()\n", + "\n", + "print('Nodes:')\n", + "for label, info in nodes.items():\n", + " print(f' {label}: {info[\"class\"]}')\n", + "\n", + "print('\\nEdges (flows):')\n", + "for label, info in edges.items():\n", + " print(f' {info[\"start\"]} -> {info[\"end\"]}: {label}')" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Nodes:\n", + " GasGrid: Component\n", + " Boiler: Component\n", + " ThermalStorage: Component\n", + " Office: Component\n", + " Gas: Bus\n", + " Heat: Bus\n", + "\n", + "Edges (flows):\n", + " Gas -> Boiler: Boiler(Gas)\n", + " Boiler -> Heat: Boiler(Heat)\n", + " GasGrid -> Gas: GasGrid(Gas)\n", + " Heat -> Office: Office(Heat)\n", + " Heat -> ThermalStorage: ThermalStorage(Charge)\n", + " ThermalStorage -> Heat: ThermalStorage(Discharge)\n" + ] + } + ], + "execution_count": 32 + }, + { + "cell_type": "markdown", + "id": "69", + "metadata": {}, + "source": [ + "## 8. Multi-Period/Scenario Data\n", + "\n", + "Working with multi-dimensional results:" + ] + }, + { + "cell_type": "code", + "id": "70", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:18.194588Z", + "start_time": "2025-12-13T14:13:18.191374Z" + } + }, + "source": [ + "print('Multiperiod system dimensions:')\n", + "print(f' Periods: {list(multiperiod.periods)}')\n", + "print(f' Scenarios: {list(multiperiod.scenarios)}')\n", + "print(f' Solution dims: {dict(multiperiod.solution.sizes)}')" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Multiperiod system dimensions:\n", + " Periods: [2024, 2025, 2026]\n", + " Scenarios: ['high_demand', 'low_demand']\n", + " Solution dims: {'scenario': 2, 'period': 3, 'time': 49}\n" + ] + } + ], + "execution_count": 33 + }, + { + "cell_type": "code", + "id": "71", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:18.325331Z", + "start_time": "2025-12-13T14:13:18.199791Z" + } + }, + "source": [ + "# Balance plot with faceting by scenario\n", + "multiperiod.statistics.plot.balance('Heat')" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "PlotResult(data= Size: 10kB\n", + "Dimensions: (time: 49, period: 3, scenario: 2)\n", + "Coordinates:\n", + " * time (time) datetime64[ns] 392B 2024-01-01 ... 2024...\n", + " * period (period) int64 24B 2024 2025 2026\n", + " * scenario (scenario) scena' ... '}
value=%{y}'),\n", + " 'legendgroup': 'Boiler(Heat)',\n", + " 'marker': {'color': '#EF553B', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Boiler(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x4',\n", + " 'y': {'bdata': ('5JuWpeU9RsDiqeLGgqdEwF3XQkqFnk' ... 'rxMNlDwFu20eeOpEfAAAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y4'},\n", + " {'hovertemplate': ('variable=Boiler(Heat)
scena' ... '}
value=%{y}'),\n", + " 'legendgroup': 'Boiler(Heat)',\n", + " 'marker': {'color': '#EF553B', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Boiler(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x5',\n", + " 'y': {'bdata': ('5JuWpeU9RsDiqeLGgqdEwF3XQkqFnk' ... 'rxMNlDwFu20eeOpEfAAAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y5'},\n", + " {'hovertemplate': ('variable=Boiler(Heat)
scena' ... '}
value=%{y}'),\n", + " 'legendgroup': 'Boiler(Heat)',\n", + " 'marker': {'color': '#EF553B', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Boiler(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x6',\n", + " 'y': {'bdata': ('5JuWpeU9RsDiqeLGgqdEwFvXQkqFnk' ... 'rxMNlDwFy20eeOpEfAAAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y6'},\n", + " {'hovertemplate': ('variable=Boiler(Heat)
scena' ... '}
value=%{y}'),\n", + " 'legendgroup': 'Boiler(Heat)',\n", + " 'marker': {'color': '#EF553B', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Boiler(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('EgPMGubHPsD7i30z/HU4wBwgRYDluD' ... 'Vm3JI8wDayyUAFXDnAAAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'},\n", + " {'hovertemplate': ('variable=Boiler(Heat)
scena' ... '}
value=%{y}'),\n", + " 'legendgroup': 'Boiler(Heat)',\n", + " 'marker': {'color': '#EF553B', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Boiler(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x2',\n", + " 'y': {'bdata': ('EgPMGubHPsD7i30z/HU4wBwgRYDluD' ... 'Vm3JI8wDayyUAFXDnAAAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y2'},\n", + " {'hovertemplate': ('variable=Boiler(Heat)
scena' ... '}
value=%{y}'),\n", + " 'legendgroup': 'Boiler(Heat)',\n", + " 'marker': {'color': '#EF553B', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Boiler(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x3',\n", + " 'y': {'bdata': ('EgPMGubHPsD7i30z/HU4wBwgRYDluD' ... 'Vm3JI8wDayyUAFXDnAAAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y3'},\n", + " {'hovertemplate': ('variable=ThermalStorage(Discha' ... '}
value=%{y}'),\n", + " 'legendgroup': 'ThermalStorage(Discharge)',\n", + " 'marker': {'color': '#00CC96', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Discharge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x4',\n", + " 'y': {'bdata': ('iAK1fqVASD1j/UqBWr9nPQo++OCDj2' ... 'jgg89hPWP9SoFav2g9AAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y4'},\n", + " {'hovertemplate': ('variable=ThermalStorage(Discha' ... '}
value=%{y}'),\n", + " 'legendgroup': 'ThermalStorage(Discharge)',\n", + " 'marker': {'color': '#00CC96', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Discharge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x5',\n", + " 'y': {'bdata': ('iAK1fqVASD1j/UqBWr9nPQo++OCDj2' ... 'qBWr9oPWP9SoFav2g9AAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y5'},\n", + " {'hovertemplate': ('variable=ThermalStorage(Discha' ... '}
value=%{y}'),\n", + " 'legendgroup': 'ThermalStorage(Discharge)',\n", + " 'marker': {'color': '#00CC96', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Discharge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x6',\n", + " 'y': {'bdata': ('iAK1fqVASD1j/UqBWr9oPby8nSExr2' ... 'qBWr9oPQo++OCDz2E9AAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y6'},\n", + " {'hovertemplate': ('variable=ThermalStorage(Discha' ... '}
value=%{y}'),\n", + " 'legendgroup': 'ThermalStorage(Discharge)',\n", + " 'marker': {'color': '#00CC96', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Discharge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('AAAAAAAAAIC3nSExb8dkPbedITFvx2' ... 'Exb8dkPbedITFvx2Q9AAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'},\n", + " {'hovertemplate': ('variable=ThermalStorage(Discha' ... '}
value=%{y}'),\n", + " 'legendgroup': 'ThermalStorage(Discharge)',\n", + " 'marker': {'color': '#00CC96', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Discharge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x2',\n", + " 'y': {'bdata': ('AAAAAAAAAIC3nSExb8dkPbedITFvx2' ... 'Exb8dkPbedITFvx2Q9AAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y2'},\n", + " {'hovertemplate': ('variable=ThermalStorage(Discha' ... '}
value=%{y}'),\n", + " 'legendgroup': 'ThermalStorage(Discharge)',\n", + " 'marker': {'color': '#00CC96', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Discharge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x3',\n", + " 'y': {'bdata': ('AAAAAAAAAIC3nSExb8dkPbedITFvx2' ... 'Exb8dkPbedITFvp2U9AAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y3'},\n", + " {'hovertemplate': ('variable=ThermalStorage(Charge' ... '}
value=%{y}'),\n", + " 'legendgroup': 'ThermalStorage(Charge)',\n", + " 'marker': {'color': '#00CC96', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Charge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x4',\n", + " 'y': {'bdata': ('iAK1fqVASb1j/UqBWr9ovQo++OCDT2' ... 'jgg49ivWP9SoFav2m9AAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y4'},\n", + " {'hovertemplate': ('variable=ThermalStorage(Charge' ... '}
value=%{y}'),\n", + " 'legendgroup': 'ThermalStorage(Charge)',\n", + " 'marker': {'color': '#00CC96', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Charge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x5',\n", + " 'y': {'bdata': ('iAK1fqVASb1j/UqBWr9ovQo++OCDT2' ... 'qBWr9pvWP9SoFav2m9AAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y5'},\n", + " {'hovertemplate': ('variable=ThermalStorage(Charge' ... '}
value=%{y}'),\n", + " 'legendgroup': 'ThermalStorage(Charge)',\n", + " 'marker': {'color': '#00CC96', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Charge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x6',\n", + " 'y': {'bdata': ('iAK1fqVASb1j/UqBWr9pvby8nSEx72' ... 'qBWr9pvQo++OCDj2K9AAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y6'},\n", + " {'hovertemplate': ('variable=ThermalStorage(Charge' ... '}
value=%{y}'),\n", + " 'legendgroup': 'ThermalStorage(Charge)',\n", + " 'marker': {'color': '#00CC96', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Charge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('AAAAAAAAAAC3nSExb6dlvbedITFvp2' ... 'Exb6dlvbedITFvp2W9AAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'},\n", + " {'hovertemplate': ('variable=ThermalStorage(Charge' ... '}
value=%{y}'),\n", + " 'legendgroup': 'ThermalStorage(Charge)',\n", + " 'marker': {'color': '#00CC96', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Charge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x2',\n", + " 'y': {'bdata': ('AAAAAAAAAAC3nSExb6dlvbedITFvp2' ... 'Exb6dlvbedITFvp2W9AAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y2'},\n", + " {'hovertemplate': ('variable=ThermalStorage(Charge' ... '}
value=%{y}'),\n", + " 'legendgroup': 'ThermalStorage(Charge)',\n", + " 'marker': {'color': '#00CC96', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Charge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x3',\n", + " 'y': {'bdata': ('AAAAAAAAAAC3nSExb6dlvbedITFvp2' ... 'Exb6dlvbedITFvh2a9AAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y3'},\n", + " {'hovertemplate': ('variable=Building(Heat)
sce' ... '}
value=%{y}'),\n", + " 'legendgroup': 'Building(Heat)',\n", + " 'marker': {'color': '#AB63FA', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Building(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x4',\n", + " 'y': {'bdata': ('5ZuWpeU9RkDmqeLGgqdEQGDXQkqFnk' ... 'rxMNlDQF+20eeOpEdAAAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y4'},\n", + " {'hovertemplate': ('variable=Building(Heat)
sce' ... '}
value=%{y}'),\n", + " 'legendgroup': 'Building(Heat)',\n", + " 'marker': {'color': '#AB63FA', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Building(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x5',\n", + " 'y': {'bdata': ('5ZuWpeU9RkDmqeLGgqdEQGDXQkqFnk' ... 'rxMNlDQF+20eeOpEdAAAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y5'},\n", + " {'hovertemplate': ('variable=Building(Heat)
sce' ... '}
value=%{y}'),\n", + " 'legendgroup': 'Building(Heat)',\n", + " 'marker': {'color': '#AB63FA', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Building(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x6',\n", + " 'y': {'bdata': ('5ZuWpeU9RkDmqeLGgqdEQGDXQkqFnk' ... 'rxMNlDQF+20eeOpEdAAAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y6'},\n", + " {'hovertemplate': ('variable=Building(Heat)
sce' ... '}
value=%{y}'),\n", + " 'legendgroup': 'Building(Heat)',\n", + " 'marker': {'color': '#AB63FA', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Building(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('EgPMGubHPkACjH0z/HU4QCMgRYDluD' ... 'Vm3JI8QD2yyUAFXDlAAAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'},\n", + " {'hovertemplate': ('variable=Building(Heat)
sce' ... '}
value=%{y}'),\n", + " 'legendgroup': 'Building(Heat)',\n", + " 'marker': {'color': '#AB63FA', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Building(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x2',\n", + " 'y': {'bdata': ('EgPMGubHPkACjH0z/HU4QCMgRYDluD' ... 'Vm3JI8QD2yyUAFXDlAAAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y2'},\n", + " {'hovertemplate': ('variable=Building(Heat)
sce' ... '}
value=%{y}'),\n", + " 'legendgroup': 'Building(Heat)',\n", + " 'marker': {'color': '#AB63FA', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Building(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': False,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x3',\n", + " 'y': {'bdata': ('EgPMGubHPkACjH0z/HU4QCMgRYDluD' ... 'Vm3JI8QD2yyUAFXDlAAAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y3'}],\n", + " 'layout': {'annotations': [{'font': {},\n", + " 'showarrow': False,\n", + " 'text': 'period=2024',\n", + " 'x': 0.15666666666666665,\n", + " 'xanchor': 'center',\n", + " 'xref': 'paper',\n", + " 'y': 1.0,\n", + " 'yanchor': 'bottom',\n", + " 'yref': 'paper'},\n", + " {'font': {},\n", + " 'showarrow': False,\n", + " 'text': 'period=2025',\n", + " 'x': 0.49,\n", + " 'xanchor': 'center',\n", + " 'xref': 'paper',\n", + " 'y': 1.0,\n", + " 'yanchor': 'bottom',\n", + " 'yref': 'paper'},\n", + " {'font': {},\n", + " 'showarrow': False,\n", + " 'text': 'period=2026',\n", + " 'x': 0.8233333333333333,\n", + " 'xanchor': 'center',\n", + " 'xref': 'paper',\n", + " 'y': 1.0,\n", + " 'yanchor': 'bottom',\n", + " 'yref': 'paper'},\n", + " {'font': {},\n", + " 'showarrow': False,\n", + " 'text': 'scenario=low_demand',\n", + " 'textangle': 90,\n", + " 'x': 0.98,\n", + " 'xanchor': 'left',\n", + " 'xref': 'paper',\n", + " 'y': 0.2425,\n", + " 'yanchor': 'middle',\n", + " 'yref': 'paper'},\n", + " {'font': {},\n", + " 'showarrow': False,\n", + " 'text': 'scenario=high_demand',\n", + " 'textangle': 90,\n", + " 'x': 0.98,\n", + " 'xanchor': 'left',\n", + " 'xref': 'paper',\n", + " 'y': 0.7575000000000001,\n", + " 'yanchor': 'middle',\n", + " 'yref': 'paper'}],\n", + " 'bargap': 0,\n", + " 'bargroupgap': 0,\n", + " 'barmode': 'relative',\n", + " 'legend': {'title': {'text': 'variable'}, 'tracegroupgap': 0},\n", + " 'template': '...',\n", + " 'title': {'text': 'Heat (flow_rate)'},\n", + " 'xaxis': {'anchor': 'y', 'domain': [0.0, 0.3133333333333333], 'title': {'text': 'time'}},\n", + " 'xaxis2': {'anchor': 'y2',\n", + " 'domain': [0.3333333333333333, 0.6466666666666666],\n", + " 'matches': 'x',\n", + " 'title': {'text': 'time'}},\n", + " 'xaxis3': {'anchor': 'y3', 'domain': [0.6666666666666666, 0.98], 'matches': 'x', 'title': {'text': 'time'}},\n", + " 'xaxis4': {'anchor': 'y4', 'domain': [0.0, 0.3133333333333333], 'matches': 'x', 'showticklabels': False},\n", + " 'xaxis5': {'anchor': 'y5',\n", + " 'domain': [0.3333333333333333, 0.6466666666666666],\n", + " 'matches': 'x',\n", + " 'showticklabels': False},\n", + " 'xaxis6': {'anchor': 'y6', 'domain': [0.6666666666666666, 0.98], 'matches': 'x', 'showticklabels': False},\n", + " 'yaxis': {'anchor': 'x', 'domain': [0.0, 0.485], 'title': {'text': 'value'}},\n", + " 'yaxis2': {'anchor': 'x2', 'domain': [0.0, 0.485], 'matches': 'y', 'showticklabels': False},\n", + " 'yaxis3': {'anchor': 'x3', 'domain': [0.0, 0.485], 'matches': 'y', 'showticklabels': False},\n", + " 'yaxis4': {'anchor': 'x4', 'domain': [0.515, 1.0], 'matches': 'y', 'title': {'text': 'value'}},\n", + " 'yaxis5': {'anchor': 'x5', 'domain': [0.515, 1.0], 'matches': 'y', 'showticklabels': False},\n", + " 'yaxis6': {'anchor': 'x6', 'domain': [0.515, 1.0], 'matches': 'y', 'showticklabels': False}}\n", + "}))" + ], + "text/html": [ + "
\n", + "
" + ] + }, + "execution_count": 34, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 34 + }, + { + "cell_type": "code", + "id": "72", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:18.395048Z", + "start_time": "2025-12-13T14:13:18.341709Z" + } + }, + "source": [ + "# Filter to specific scenario/period\n", + "multiperiod.statistics.plot.balance('Heat', select={'scenario': 'high_demand', 'period': 2024})" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "PlotResult(data= Size: 2kB\n", + "Dimensions: (time: 49)\n", + "Coordinates:\n", + " * time (time) datetime64[ns] 392B 2024-01-01 ... 2024...\n", + "Data variables:\n", + " Boiler(Heat) (time) float64 392B -44.48 -41.31 ... -47.29 nan\n", + " ThermalStorage(Discharge) (time) float64 392B 1.723e-13 6.749e-13 ... nan\n", + " ThermalStorage(Charge) (time) float64 392B -1.794e-13 -7.034e-13 ... nan\n", + " Building(Heat) (time) float64 392B 44.48 41.31 ... 47.29 nan, figure=Figure({\n", + " 'data': [{'hovertemplate': 'variable=Boiler(Heat)
time=%{x}
value=%{y}',\n", + " 'legendgroup': 'Boiler(Heat)',\n", + " 'marker': {'color': '#EF553B', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Boiler(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('5JuWpeU9RsDiqeLGgqdEwF3XQkqFnk' ... 'rxMNlDwFu20eeOpEfAAAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'},\n", + " {'hovertemplate': 'variable=ThermalStorage(Discharge)
time=%{x}
value=%{y}',\n", + " 'legendgroup': 'ThermalStorage(Discharge)',\n", + " 'marker': {'color': '#00CC96', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Discharge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('iAK1fqVASD1j/UqBWr9nPQo++OCDj2' ... 'jgg89hPWP9SoFav2g9AAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'},\n", + " {'hovertemplate': 'variable=ThermalStorage(Charge)
time=%{x}
value=%{y}',\n", + " 'legendgroup': 'ThermalStorage(Charge)',\n", + " 'marker': {'color': '#00CC96', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Charge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('iAK1fqVASb1j/UqBWr9ovQo++OCDT2' ... 'jgg49ivWP9SoFav2m9AAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'},\n", + " {'hovertemplate': 'variable=Building(Heat)
time=%{x}
value=%{y}',\n", + " 'legendgroup': 'Building(Heat)',\n", + " 'marker': {'color': '#AB63FA', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Building(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-01T00:00:00.000000000', '2024-01-01T01:00:00.000000000',\n", + " '2024-01-01T02:00:00.000000000', '2024-01-01T03:00:00.000000000',\n", + " '2024-01-01T04:00:00.000000000', '2024-01-01T05:00:00.000000000',\n", + " '2024-01-01T06:00:00.000000000', '2024-01-01T07:00:00.000000000',\n", + " '2024-01-01T08:00:00.000000000', '2024-01-01T09:00:00.000000000',\n", + " '2024-01-01T10:00:00.000000000', '2024-01-01T11:00:00.000000000',\n", + " '2024-01-01T12:00:00.000000000', '2024-01-01T13:00:00.000000000',\n", + " '2024-01-01T14:00:00.000000000', '2024-01-01T15:00:00.000000000',\n", + " '2024-01-01T16:00:00.000000000', '2024-01-01T17:00:00.000000000',\n", + " '2024-01-01T18:00:00.000000000', '2024-01-01T19:00:00.000000000',\n", + " '2024-01-01T20:00:00.000000000', '2024-01-01T21:00:00.000000000',\n", + " '2024-01-01T22:00:00.000000000', '2024-01-01T23:00:00.000000000',\n", + " '2024-01-02T00:00:00.000000000', '2024-01-02T01:00:00.000000000',\n", + " '2024-01-02T02:00:00.000000000', '2024-01-02T03:00:00.000000000',\n", + " '2024-01-02T04:00:00.000000000', '2024-01-02T05:00:00.000000000',\n", + " '2024-01-02T06:00:00.000000000', '2024-01-02T07:00:00.000000000',\n", + " '2024-01-02T08:00:00.000000000', '2024-01-02T09:00:00.000000000',\n", + " '2024-01-02T10:00:00.000000000', '2024-01-02T11:00:00.000000000',\n", + " '2024-01-02T12:00:00.000000000', '2024-01-02T13:00:00.000000000',\n", + " '2024-01-02T14:00:00.000000000', '2024-01-02T15:00:00.000000000',\n", + " '2024-01-02T16:00:00.000000000', '2024-01-02T17:00:00.000000000',\n", + " '2024-01-02T18:00:00.000000000', '2024-01-02T19:00:00.000000000',\n", + " '2024-01-02T20:00:00.000000000', '2024-01-02T21:00:00.000000000',\n", + " '2024-01-02T22:00:00.000000000', '2024-01-02T23:00:00.000000000',\n", + " '2024-01-03T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('5ZuWpeU9RkDmqeLGgqdEQGDXQkqFnk' ... 'rxMNlDQF+20eeOpEdAAAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'}],\n", + " 'layout': {'bargap': 0,\n", + " 'bargroupgap': 0,\n", + " 'barmode': 'relative',\n", + " 'legend': {'title': {'text': 'variable'}, 'tracegroupgap': 0},\n", + " 'template': '...',\n", + " 'title': {'text': 'Heat (flow_rate)'},\n", + " 'xaxis': {'anchor': 'y', 'domain': [0.0, 1.0], 'title': {'text': 'time'}},\n", + " 'yaxis': {'anchor': 'x', 'domain': [0.0, 1.0], 'title': {'text': 'value'}}}\n", + "}))" + ], + "text/html": [ + "
\n", + "
" + ] + }, + "execution_count": 35, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 35 + }, + { + "cell_type": "code", + "id": "73", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:18.481894Z", + "start_time": "2025-12-13T14:13:18.459661Z" + } + }, + "source": [ + "# Sankey aggregates across all dimensions by default\n", + "multiperiod.statistics.plot.sankey.flows()" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "PlotResult(data= Size: 592B\n", + "Dimensions: (link: 4)\n", + "Coordinates:\n", + " * link (link) int64 32B 0 1 2 3\n", + " source (link) \n", + "
" + ] + }, + "execution_count": 36, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 36 + }, + { + "cell_type": "markdown", + "id": "74", + "metadata": {}, + "source": [ + "## 9. Color Customization\n", + "\n", + "Colors can be customized in multiple ways:" + ] + }, + { + "cell_type": "code", + "id": "75", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:18.553613Z", + "start_time": "2025-12-13T14:13:18.488703Z" + } + }, + "source": [ + "# Using a colorscale name\n", + "simple.statistics.plot.balance('Heat', colors='Set2')" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "PlotResult(data= Size: 7kB\n", + "Dimensions: (time: 169)\n", + "Coordinates:\n", + " * time (time) datetime64[ns] 1kB 2024-01-15 ... 2024-...\n", + "Data variables:\n", + " Boiler(Heat) (time) float64 1kB -32.48 -29.31 ... -124.5 nan\n", + " ThermalStorage(Discharge) (time) float64 1kB -0.0 5.275e-13 ... nan\n", + " ThermalStorage(Charge) (time) float64 1kB 0.0 -3.748e-13 ... 100.0 nan\n", + " Office(Heat) (time) float64 1kB 32.48 29.31 ... 24.48 nan, figure=Figure({\n", + " 'data': [{'hovertemplate': 'variable=Boiler(Heat)
time=%{x}
value=%{y}',\n", + " 'legendgroup': 'Boiler(Heat)',\n", + " 'marker': {'color': '#66c2a5', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Boiler(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-15T00:00:00.000000000', '2024-01-15T01:00:00.000000000',\n", + " '2024-01-15T02:00:00.000000000', '2024-01-15T03:00:00.000000000',\n", + " '2024-01-15T04:00:00.000000000', '2024-01-15T05:00:00.000000000',\n", + " '2024-01-15T06:00:00.000000000', '2024-01-15T07:00:00.000000000',\n", + " '2024-01-15T08:00:00.000000000', '2024-01-15T09:00:00.000000000',\n", + " '2024-01-15T10:00:00.000000000', '2024-01-15T11:00:00.000000000',\n", + " '2024-01-15T12:00:00.000000000', '2024-01-15T13:00:00.000000000',\n", + " '2024-01-15T14:00:00.000000000', '2024-01-15T15:00:00.000000000',\n", + " '2024-01-15T16:00:00.000000000', '2024-01-15T17:00:00.000000000',\n", + " '2024-01-15T18:00:00.000000000', '2024-01-15T19:00:00.000000000',\n", + " '2024-01-15T20:00:00.000000000', '2024-01-15T21:00:00.000000000',\n", + " '2024-01-15T22:00:00.000000000', '2024-01-15T23:00:00.000000000',\n", + " '2024-01-16T00:00:00.000000000', '2024-01-16T01:00:00.000000000',\n", + " '2024-01-16T02:00:00.000000000', '2024-01-16T03:00:00.000000000',\n", + " '2024-01-16T04:00:00.000000000', '2024-01-16T05:00:00.000000000',\n", + " '2024-01-16T06:00:00.000000000', '2024-01-16T07:00:00.000000000',\n", + " '2024-01-16T08:00:00.000000000', '2024-01-16T09:00:00.000000000',\n", + " '2024-01-16T10:00:00.000000000', '2024-01-16T11:00:00.000000000',\n", + " '2024-01-16T12:00:00.000000000', '2024-01-16T13:00:00.000000000',\n", + " '2024-01-16T14:00:00.000000000', '2024-01-16T15:00:00.000000000',\n", + " '2024-01-16T16:00:00.000000000', '2024-01-16T17:00:00.000000000',\n", + " '2024-01-16T18:00:00.000000000', '2024-01-16T19:00:00.000000000',\n", + " '2024-01-16T20:00:00.000000000', '2024-01-16T21:00:00.000000000',\n", + " '2024-01-16T22:00:00.000000000', '2024-01-16T23:00:00.000000000',\n", + " '2024-01-17T00:00:00.000000000', '2024-01-17T01:00:00.000000000',\n", + " '2024-01-17T02:00:00.000000000', '2024-01-17T03:00:00.000000000',\n", + " '2024-01-17T04:00:00.000000000', '2024-01-17T05:00:00.000000000',\n", + " '2024-01-17T06:00:00.000000000', '2024-01-17T07:00:00.000000000',\n", + " '2024-01-17T08:00:00.000000000', '2024-01-17T09:00:00.000000000',\n", + " '2024-01-17T10:00:00.000000000', '2024-01-17T11:00:00.000000000',\n", + " '2024-01-17T12:00:00.000000000', '2024-01-17T13:00:00.000000000',\n", + " '2024-01-17T14:00:00.000000000', '2024-01-17T15:00:00.000000000',\n", + " '2024-01-17T16:00:00.000000000', '2024-01-17T17:00:00.000000000',\n", + " '2024-01-17T18:00:00.000000000', '2024-01-17T19:00:00.000000000',\n", + " '2024-01-17T20:00:00.000000000', '2024-01-17T21:00:00.000000000',\n", + " '2024-01-17T22:00:00.000000000', '2024-01-17T23:00:00.000000000',\n", + " '2024-01-18T00:00:00.000000000', '2024-01-18T01:00:00.000000000',\n", + " '2024-01-18T02:00:00.000000000', '2024-01-18T03:00:00.000000000',\n", + " '2024-01-18T04:00:00.000000000', '2024-01-18T05:00:00.000000000',\n", + " '2024-01-18T06:00:00.000000000', '2024-01-18T07:00:00.000000000',\n", + " '2024-01-18T08:00:00.000000000', '2024-01-18T09:00:00.000000000',\n", + " '2024-01-18T10:00:00.000000000', '2024-01-18T11:00:00.000000000',\n", + " '2024-01-18T12:00:00.000000000', '2024-01-18T13:00:00.000000000',\n", + " '2024-01-18T14:00:00.000000000', '2024-01-18T15:00:00.000000000',\n", + " '2024-01-18T16:00:00.000000000', '2024-01-18T17:00:00.000000000',\n", + " '2024-01-18T18:00:00.000000000', '2024-01-18T19:00:00.000000000',\n", + " '2024-01-18T20:00:00.000000000', '2024-01-18T21:00:00.000000000',\n", + " '2024-01-18T22:00:00.000000000', '2024-01-18T23:00:00.000000000',\n", + " '2024-01-19T00:00:00.000000000', '2024-01-19T01:00:00.000000000',\n", + " '2024-01-19T02:00:00.000000000', '2024-01-19T03:00:00.000000000',\n", + " '2024-01-19T04:00:00.000000000', '2024-01-19T05:00:00.000000000',\n", + " '2024-01-19T06:00:00.000000000', '2024-01-19T07:00:00.000000000',\n", + " '2024-01-19T08:00:00.000000000', '2024-01-19T09:00:00.000000000',\n", + " '2024-01-19T10:00:00.000000000', '2024-01-19T11:00:00.000000000',\n", + " '2024-01-19T12:00:00.000000000', '2024-01-19T13:00:00.000000000',\n", + " '2024-01-19T14:00:00.000000000', '2024-01-19T15:00:00.000000000',\n", + " '2024-01-19T16:00:00.000000000', '2024-01-19T17:00:00.000000000',\n", + " '2024-01-19T18:00:00.000000000', '2024-01-19T19:00:00.000000000',\n", + " '2024-01-19T20:00:00.000000000', '2024-01-19T21:00:00.000000000',\n", + " '2024-01-19T22:00:00.000000000', '2024-01-19T23:00:00.000000000',\n", + " '2024-01-20T00:00:00.000000000', '2024-01-20T01:00:00.000000000',\n", + " '2024-01-20T02:00:00.000000000', '2024-01-20T03:00:00.000000000',\n", + " '2024-01-20T04:00:00.000000000', '2024-01-20T05:00:00.000000000',\n", + " '2024-01-20T06:00:00.000000000', '2024-01-20T07:00:00.000000000',\n", + " '2024-01-20T08:00:00.000000000', '2024-01-20T09:00:00.000000000',\n", + " '2024-01-20T10:00:00.000000000', '2024-01-20T11:00:00.000000000',\n", + " '2024-01-20T12:00:00.000000000', '2024-01-20T13:00:00.000000000',\n", + " '2024-01-20T14:00:00.000000000', '2024-01-20T15:00:00.000000000',\n", + " '2024-01-20T16:00:00.000000000', '2024-01-20T17:00:00.000000000',\n", + " '2024-01-20T18:00:00.000000000', '2024-01-20T19:00:00.000000000',\n", + " '2024-01-20T20:00:00.000000000', '2024-01-20T21:00:00.000000000',\n", + " '2024-01-20T22:00:00.000000000', '2024-01-20T23:00:00.000000000',\n", + " '2024-01-21T00:00:00.000000000', '2024-01-21T01:00:00.000000000',\n", + " '2024-01-21T02:00:00.000000000', '2024-01-21T03:00:00.000000000',\n", + " '2024-01-21T04:00:00.000000000', '2024-01-21T05:00:00.000000000',\n", + " '2024-01-21T06:00:00.000000000', '2024-01-21T07:00:00.000000000',\n", + " '2024-01-21T08:00:00.000000000', '2024-01-21T09:00:00.000000000',\n", + " '2024-01-21T10:00:00.000000000', '2024-01-21T11:00:00.000000000',\n", + " '2024-01-21T12:00:00.000000000', '2024-01-21T13:00:00.000000000',\n", + " '2024-01-21T14:00:00.000000000', '2024-01-21T15:00:00.000000000',\n", + " '2024-01-21T16:00:00.000000000', '2024-01-21T17:00:00.000000000',\n", + " '2024-01-21T18:00:00.000000000', '2024-01-21T19:00:00.000000000',\n", + " '2024-01-21T20:00:00.000000000', '2024-01-21T21:00:00.000000000',\n", + " '2024-01-21T22:00:00.000000000', '2024-01-21T23:00:00.000000000',\n", + " '2024-01-22T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('5ZuWpeU9QMD3U8WNBU89wHjXQkqFnk' ... '////8zwPW5+Ef5Hl/AAAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'},\n", + " {'hovertemplate': 'variable=ThermalStorage(Discharge)
time=%{x}
value=%{y}',\n", + " 'legendgroup': 'ThermalStorage(Discharge)',\n", + " 'marker': {'color': '#fc8d62', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Discharge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-15T00:00:00.000000000', '2024-01-15T01:00:00.000000000',\n", + " '2024-01-15T02:00:00.000000000', '2024-01-15T03:00:00.000000000',\n", + " '2024-01-15T04:00:00.000000000', '2024-01-15T05:00:00.000000000',\n", + " '2024-01-15T06:00:00.000000000', '2024-01-15T07:00:00.000000000',\n", + " '2024-01-15T08:00:00.000000000', '2024-01-15T09:00:00.000000000',\n", + " '2024-01-15T10:00:00.000000000', '2024-01-15T11:00:00.000000000',\n", + " '2024-01-15T12:00:00.000000000', '2024-01-15T13:00:00.000000000',\n", + " '2024-01-15T14:00:00.000000000', '2024-01-15T15:00:00.000000000',\n", + " '2024-01-15T16:00:00.000000000', '2024-01-15T17:00:00.000000000',\n", + " '2024-01-15T18:00:00.000000000', '2024-01-15T19:00:00.000000000',\n", + " '2024-01-15T20:00:00.000000000', '2024-01-15T21:00:00.000000000',\n", + " '2024-01-15T22:00:00.000000000', '2024-01-15T23:00:00.000000000',\n", + " '2024-01-16T00:00:00.000000000', '2024-01-16T01:00:00.000000000',\n", + " '2024-01-16T02:00:00.000000000', '2024-01-16T03:00:00.000000000',\n", + " '2024-01-16T04:00:00.000000000', '2024-01-16T05:00:00.000000000',\n", + " '2024-01-16T06:00:00.000000000', '2024-01-16T07:00:00.000000000',\n", + " '2024-01-16T08:00:00.000000000', '2024-01-16T09:00:00.000000000',\n", + " '2024-01-16T10:00:00.000000000', '2024-01-16T11:00:00.000000000',\n", + " '2024-01-16T12:00:00.000000000', '2024-01-16T13:00:00.000000000',\n", + " '2024-01-16T14:00:00.000000000', '2024-01-16T15:00:00.000000000',\n", + " '2024-01-16T16:00:00.000000000', '2024-01-16T17:00:00.000000000',\n", + " '2024-01-16T18:00:00.000000000', '2024-01-16T19:00:00.000000000',\n", + " '2024-01-16T20:00:00.000000000', '2024-01-16T21:00:00.000000000',\n", + " '2024-01-16T22:00:00.000000000', '2024-01-16T23:00:00.000000000',\n", + " '2024-01-17T00:00:00.000000000', '2024-01-17T01:00:00.000000000',\n", + " '2024-01-17T02:00:00.000000000', '2024-01-17T03:00:00.000000000',\n", + " '2024-01-17T04:00:00.000000000', '2024-01-17T05:00:00.000000000',\n", + " '2024-01-17T06:00:00.000000000', '2024-01-17T07:00:00.000000000',\n", + " '2024-01-17T08:00:00.000000000', '2024-01-17T09:00:00.000000000',\n", + " '2024-01-17T10:00:00.000000000', '2024-01-17T11:00:00.000000000',\n", + " '2024-01-17T12:00:00.000000000', '2024-01-17T13:00:00.000000000',\n", + " '2024-01-17T14:00:00.000000000', '2024-01-17T15:00:00.000000000',\n", + " '2024-01-17T16:00:00.000000000', '2024-01-17T17:00:00.000000000',\n", + " '2024-01-17T18:00:00.000000000', '2024-01-17T19:00:00.000000000',\n", + " '2024-01-17T20:00:00.000000000', '2024-01-17T21:00:00.000000000',\n", + " '2024-01-17T22:00:00.000000000', '2024-01-17T23:00:00.000000000',\n", + " '2024-01-18T00:00:00.000000000', '2024-01-18T01:00:00.000000000',\n", + " '2024-01-18T02:00:00.000000000', '2024-01-18T03:00:00.000000000',\n", + " '2024-01-18T04:00:00.000000000', '2024-01-18T05:00:00.000000000',\n", + " '2024-01-18T06:00:00.000000000', '2024-01-18T07:00:00.000000000',\n", + " '2024-01-18T08:00:00.000000000', '2024-01-18T09:00:00.000000000',\n", + " '2024-01-18T10:00:00.000000000', '2024-01-18T11:00:00.000000000',\n", + " '2024-01-18T12:00:00.000000000', '2024-01-18T13:00:00.000000000',\n", + " '2024-01-18T14:00:00.000000000', '2024-01-18T15:00:00.000000000',\n", + " '2024-01-18T16:00:00.000000000', '2024-01-18T17:00:00.000000000',\n", + " '2024-01-18T18:00:00.000000000', '2024-01-18T19:00:00.000000000',\n", + " '2024-01-18T20:00:00.000000000', '2024-01-18T21:00:00.000000000',\n", + " '2024-01-18T22:00:00.000000000', '2024-01-18T23:00:00.000000000',\n", + " '2024-01-19T00:00:00.000000000', '2024-01-19T01:00:00.000000000',\n", + " '2024-01-19T02:00:00.000000000', '2024-01-19T03:00:00.000000000',\n", + " '2024-01-19T04:00:00.000000000', '2024-01-19T05:00:00.000000000',\n", + " '2024-01-19T06:00:00.000000000', '2024-01-19T07:00:00.000000000',\n", + " '2024-01-19T08:00:00.000000000', '2024-01-19T09:00:00.000000000',\n", + " '2024-01-19T10:00:00.000000000', '2024-01-19T11:00:00.000000000',\n", + " '2024-01-19T12:00:00.000000000', '2024-01-19T13:00:00.000000000',\n", + " '2024-01-19T14:00:00.000000000', '2024-01-19T15:00:00.000000000',\n", + " '2024-01-19T16:00:00.000000000', '2024-01-19T17:00:00.000000000',\n", + " '2024-01-19T18:00:00.000000000', '2024-01-19T19:00:00.000000000',\n", + " '2024-01-19T20:00:00.000000000', '2024-01-19T21:00:00.000000000',\n", + " '2024-01-19T22:00:00.000000000', '2024-01-19T23:00:00.000000000',\n", + " '2024-01-20T00:00:00.000000000', '2024-01-20T01:00:00.000000000',\n", + " '2024-01-20T02:00:00.000000000', '2024-01-20T03:00:00.000000000',\n", + " '2024-01-20T04:00:00.000000000', '2024-01-20T05:00:00.000000000',\n", + " '2024-01-20T06:00:00.000000000', '2024-01-20T07:00:00.000000000',\n", + " '2024-01-20T08:00:00.000000000', '2024-01-20T09:00:00.000000000',\n", + " '2024-01-20T10:00:00.000000000', '2024-01-20T11:00:00.000000000',\n", + " '2024-01-20T12:00:00.000000000', '2024-01-20T13:00:00.000000000',\n", + " '2024-01-20T14:00:00.000000000', '2024-01-20T15:00:00.000000000',\n", + " '2024-01-20T16:00:00.000000000', '2024-01-20T17:00:00.000000000',\n", + " '2024-01-20T18:00:00.000000000', '2024-01-20T19:00:00.000000000',\n", + " '2024-01-20T20:00:00.000000000', '2024-01-20T21:00:00.000000000',\n", + " '2024-01-20T22:00:00.000000000', '2024-01-20T23:00:00.000000000',\n", + " '2024-01-21T00:00:00.000000000', '2024-01-21T01:00:00.000000000',\n", + " '2024-01-21T02:00:00.000000000', '2024-01-21T03:00:00.000000000',\n", + " '2024-01-21T04:00:00.000000000', '2024-01-21T05:00:00.000000000',\n", + " '2024-01-21T06:00:00.000000000', '2024-01-21T07:00:00.000000000',\n", + " '2024-01-21T08:00:00.000000000', '2024-01-21T09:00:00.000000000',\n", + " '2024-01-21T10:00:00.000000000', '2024-01-21T11:00:00.000000000',\n", + " '2024-01-21T12:00:00.000000000', '2024-01-21T13:00:00.000000000',\n", + " '2024-01-21T14:00:00.000000000', '2024-01-21T15:00:00.000000000',\n", + " '2024-01-21T16:00:00.000000000', '2024-01-21T17:00:00.000000000',\n", + " '2024-01-21T18:00:00.000000000', '2024-01-21T19:00:00.000000000',\n", + " '2024-01-21T20:00:00.000000000', '2024-01-21T21:00:00.000000000',\n", + " '2024-01-21T22:00:00.000000000', '2024-01-21T23:00:00.000000000',\n", + " '2024-01-22T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('AAAAAAAAAIAKPvjgg49iPby8nSEx72' ... 'AAAAAgvWP9SoFav2g9AAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'},\n", + " {'hovertemplate': 'variable=ThermalStorage(Charge)
time=%{x}
value=%{y}',\n", + " 'legendgroup': 'ThermalStorage(Charge)',\n", + " 'marker': {'color': '#8da0cb', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Charge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-15T00:00:00.000000000', '2024-01-15T01:00:00.000000000',\n", + " '2024-01-15T02:00:00.000000000', '2024-01-15T03:00:00.000000000',\n", + " '2024-01-15T04:00:00.000000000', '2024-01-15T05:00:00.000000000',\n", + " '2024-01-15T06:00:00.000000000', '2024-01-15T07:00:00.000000000',\n", + " '2024-01-15T08:00:00.000000000', '2024-01-15T09:00:00.000000000',\n", + " '2024-01-15T10:00:00.000000000', '2024-01-15T11:00:00.000000000',\n", + " '2024-01-15T12:00:00.000000000', '2024-01-15T13:00:00.000000000',\n", + " '2024-01-15T14:00:00.000000000', '2024-01-15T15:00:00.000000000',\n", + " '2024-01-15T16:00:00.000000000', '2024-01-15T17:00:00.000000000',\n", + " '2024-01-15T18:00:00.000000000', '2024-01-15T19:00:00.000000000',\n", + " '2024-01-15T20:00:00.000000000', '2024-01-15T21:00:00.000000000',\n", + " '2024-01-15T22:00:00.000000000', '2024-01-15T23:00:00.000000000',\n", + " '2024-01-16T00:00:00.000000000', '2024-01-16T01:00:00.000000000',\n", + " '2024-01-16T02:00:00.000000000', '2024-01-16T03:00:00.000000000',\n", + " '2024-01-16T04:00:00.000000000', '2024-01-16T05:00:00.000000000',\n", + " '2024-01-16T06:00:00.000000000', '2024-01-16T07:00:00.000000000',\n", + " '2024-01-16T08:00:00.000000000', '2024-01-16T09:00:00.000000000',\n", + " '2024-01-16T10:00:00.000000000', '2024-01-16T11:00:00.000000000',\n", + " '2024-01-16T12:00:00.000000000', '2024-01-16T13:00:00.000000000',\n", + " '2024-01-16T14:00:00.000000000', '2024-01-16T15:00:00.000000000',\n", + " '2024-01-16T16:00:00.000000000', '2024-01-16T17:00:00.000000000',\n", + " '2024-01-16T18:00:00.000000000', '2024-01-16T19:00:00.000000000',\n", + " '2024-01-16T20:00:00.000000000', '2024-01-16T21:00:00.000000000',\n", + " '2024-01-16T22:00:00.000000000', '2024-01-16T23:00:00.000000000',\n", + " '2024-01-17T00:00:00.000000000', '2024-01-17T01:00:00.000000000',\n", + " '2024-01-17T02:00:00.000000000', '2024-01-17T03:00:00.000000000',\n", + " '2024-01-17T04:00:00.000000000', '2024-01-17T05:00:00.000000000',\n", + " '2024-01-17T06:00:00.000000000', '2024-01-17T07:00:00.000000000',\n", + " '2024-01-17T08:00:00.000000000', '2024-01-17T09:00:00.000000000',\n", + " '2024-01-17T10:00:00.000000000', '2024-01-17T11:00:00.000000000',\n", + " '2024-01-17T12:00:00.000000000', '2024-01-17T13:00:00.000000000',\n", + " '2024-01-17T14:00:00.000000000', '2024-01-17T15:00:00.000000000',\n", + " '2024-01-17T16:00:00.000000000', '2024-01-17T17:00:00.000000000',\n", + " '2024-01-17T18:00:00.000000000', '2024-01-17T19:00:00.000000000',\n", + " '2024-01-17T20:00:00.000000000', '2024-01-17T21:00:00.000000000',\n", + " '2024-01-17T22:00:00.000000000', '2024-01-17T23:00:00.000000000',\n", + " '2024-01-18T00:00:00.000000000', '2024-01-18T01:00:00.000000000',\n", + " '2024-01-18T02:00:00.000000000', '2024-01-18T03:00:00.000000000',\n", + " '2024-01-18T04:00:00.000000000', '2024-01-18T05:00:00.000000000',\n", + " '2024-01-18T06:00:00.000000000', '2024-01-18T07:00:00.000000000',\n", + " '2024-01-18T08:00:00.000000000', '2024-01-18T09:00:00.000000000',\n", + " '2024-01-18T10:00:00.000000000', '2024-01-18T11:00:00.000000000',\n", + " '2024-01-18T12:00:00.000000000', '2024-01-18T13:00:00.000000000',\n", + " '2024-01-18T14:00:00.000000000', '2024-01-18T15:00:00.000000000',\n", + " '2024-01-18T16:00:00.000000000', '2024-01-18T17:00:00.000000000',\n", + " '2024-01-18T18:00:00.000000000', '2024-01-18T19:00:00.000000000',\n", + " '2024-01-18T20:00:00.000000000', '2024-01-18T21:00:00.000000000',\n", + " '2024-01-18T22:00:00.000000000', '2024-01-18T23:00:00.000000000',\n", + " '2024-01-19T00:00:00.000000000', '2024-01-19T01:00:00.000000000',\n", + " '2024-01-19T02:00:00.000000000', '2024-01-19T03:00:00.000000000',\n", + " '2024-01-19T04:00:00.000000000', '2024-01-19T05:00:00.000000000',\n", + " '2024-01-19T06:00:00.000000000', '2024-01-19T07:00:00.000000000',\n", + " '2024-01-19T08:00:00.000000000', '2024-01-19T09:00:00.000000000',\n", + " '2024-01-19T10:00:00.000000000', '2024-01-19T11:00:00.000000000',\n", + " '2024-01-19T12:00:00.000000000', '2024-01-19T13:00:00.000000000',\n", + " '2024-01-19T14:00:00.000000000', '2024-01-19T15:00:00.000000000',\n", + " '2024-01-19T16:00:00.000000000', '2024-01-19T17:00:00.000000000',\n", + " '2024-01-19T18:00:00.000000000', '2024-01-19T19:00:00.000000000',\n", + " '2024-01-19T20:00:00.000000000', '2024-01-19T21:00:00.000000000',\n", + " '2024-01-19T22:00:00.000000000', '2024-01-19T23:00:00.000000000',\n", + " '2024-01-20T00:00:00.000000000', '2024-01-20T01:00:00.000000000',\n", + " '2024-01-20T02:00:00.000000000', '2024-01-20T03:00:00.000000000',\n", + " '2024-01-20T04:00:00.000000000', '2024-01-20T05:00:00.000000000',\n", + " '2024-01-20T06:00:00.000000000', '2024-01-20T07:00:00.000000000',\n", + " '2024-01-20T08:00:00.000000000', '2024-01-20T09:00:00.000000000',\n", + " '2024-01-20T10:00:00.000000000', '2024-01-20T11:00:00.000000000',\n", + " '2024-01-20T12:00:00.000000000', '2024-01-20T13:00:00.000000000',\n", + " '2024-01-20T14:00:00.000000000', '2024-01-20T15:00:00.000000000',\n", + " '2024-01-20T16:00:00.000000000', '2024-01-20T17:00:00.000000000',\n", + " '2024-01-20T18:00:00.000000000', '2024-01-20T19:00:00.000000000',\n", + " '2024-01-20T20:00:00.000000000', '2024-01-20T21:00:00.000000000',\n", + " '2024-01-20T22:00:00.000000000', '2024-01-20T23:00:00.000000000',\n", + " '2024-01-21T00:00:00.000000000', '2024-01-21T01:00:00.000000000',\n", + " '2024-01-21T02:00:00.000000000', '2024-01-21T03:00:00.000000000',\n", + " '2024-01-21T04:00:00.000000000', '2024-01-21T05:00:00.000000000',\n", + " '2024-01-21T06:00:00.000000000', '2024-01-21T07:00:00.000000000',\n", + " '2024-01-21T08:00:00.000000000', '2024-01-21T09:00:00.000000000',\n", + " '2024-01-21T10:00:00.000000000', '2024-01-21T11:00:00.000000000',\n", + " '2024-01-21T12:00:00.000000000', '2024-01-21T13:00:00.000000000',\n", + " '2024-01-21T14:00:00.000000000', '2024-01-21T15:00:00.000000000',\n", + " '2024-01-21T16:00:00.000000000', '2024-01-21T17:00:00.000000000',\n", + " '2024-01-21T18:00:00.000000000', '2024-01-21T19:00:00.000000000',\n", + " '2024-01-21T20:00:00.000000000', '2024-01-21T21:00:00.000000000',\n", + " '2024-01-21T22:00:00.000000000', '2024-01-21T23:00:00.000000000',\n", + " '2024-01-22T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('AAAAAAAAAAAUfPDBB19avby8nSEx72' ... 'AAAAAAANj//////1hAAAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'},\n", + " {'hovertemplate': 'variable=Office(Heat)
time=%{x}
value=%{y}',\n", + " 'legendgroup': 'Office(Heat)',\n", + " 'marker': {'color': '#e78ac3', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Office(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-15T00:00:00.000000000', '2024-01-15T01:00:00.000000000',\n", + " '2024-01-15T02:00:00.000000000', '2024-01-15T03:00:00.000000000',\n", + " '2024-01-15T04:00:00.000000000', '2024-01-15T05:00:00.000000000',\n", + " '2024-01-15T06:00:00.000000000', '2024-01-15T07:00:00.000000000',\n", + " '2024-01-15T08:00:00.000000000', '2024-01-15T09:00:00.000000000',\n", + " '2024-01-15T10:00:00.000000000', '2024-01-15T11:00:00.000000000',\n", + " '2024-01-15T12:00:00.000000000', '2024-01-15T13:00:00.000000000',\n", + " '2024-01-15T14:00:00.000000000', '2024-01-15T15:00:00.000000000',\n", + " '2024-01-15T16:00:00.000000000', '2024-01-15T17:00:00.000000000',\n", + " '2024-01-15T18:00:00.000000000', '2024-01-15T19:00:00.000000000',\n", + " '2024-01-15T20:00:00.000000000', '2024-01-15T21:00:00.000000000',\n", + " '2024-01-15T22:00:00.000000000', '2024-01-15T23:00:00.000000000',\n", + " '2024-01-16T00:00:00.000000000', '2024-01-16T01:00:00.000000000',\n", + " '2024-01-16T02:00:00.000000000', '2024-01-16T03:00:00.000000000',\n", + " '2024-01-16T04:00:00.000000000', '2024-01-16T05:00:00.000000000',\n", + " '2024-01-16T06:00:00.000000000', '2024-01-16T07:00:00.000000000',\n", + " '2024-01-16T08:00:00.000000000', '2024-01-16T09:00:00.000000000',\n", + " '2024-01-16T10:00:00.000000000', '2024-01-16T11:00:00.000000000',\n", + " '2024-01-16T12:00:00.000000000', '2024-01-16T13:00:00.000000000',\n", + " '2024-01-16T14:00:00.000000000', '2024-01-16T15:00:00.000000000',\n", + " '2024-01-16T16:00:00.000000000', '2024-01-16T17:00:00.000000000',\n", + " '2024-01-16T18:00:00.000000000', '2024-01-16T19:00:00.000000000',\n", + " '2024-01-16T20:00:00.000000000', '2024-01-16T21:00:00.000000000',\n", + " '2024-01-16T22:00:00.000000000', '2024-01-16T23:00:00.000000000',\n", + " '2024-01-17T00:00:00.000000000', '2024-01-17T01:00:00.000000000',\n", + " '2024-01-17T02:00:00.000000000', '2024-01-17T03:00:00.000000000',\n", + " '2024-01-17T04:00:00.000000000', '2024-01-17T05:00:00.000000000',\n", + " '2024-01-17T06:00:00.000000000', '2024-01-17T07:00:00.000000000',\n", + " '2024-01-17T08:00:00.000000000', '2024-01-17T09:00:00.000000000',\n", + " '2024-01-17T10:00:00.000000000', '2024-01-17T11:00:00.000000000',\n", + " '2024-01-17T12:00:00.000000000', '2024-01-17T13:00:00.000000000',\n", + " '2024-01-17T14:00:00.000000000', '2024-01-17T15:00:00.000000000',\n", + " '2024-01-17T16:00:00.000000000', '2024-01-17T17:00:00.000000000',\n", + " '2024-01-17T18:00:00.000000000', '2024-01-17T19:00:00.000000000',\n", + " '2024-01-17T20:00:00.000000000', '2024-01-17T21:00:00.000000000',\n", + " '2024-01-17T22:00:00.000000000', '2024-01-17T23:00:00.000000000',\n", + " '2024-01-18T00:00:00.000000000', '2024-01-18T01:00:00.000000000',\n", + " '2024-01-18T02:00:00.000000000', '2024-01-18T03:00:00.000000000',\n", + " '2024-01-18T04:00:00.000000000', '2024-01-18T05:00:00.000000000',\n", + " '2024-01-18T06:00:00.000000000', '2024-01-18T07:00:00.000000000',\n", + " '2024-01-18T08:00:00.000000000', '2024-01-18T09:00:00.000000000',\n", + " '2024-01-18T10:00:00.000000000', '2024-01-18T11:00:00.000000000',\n", + " '2024-01-18T12:00:00.000000000', '2024-01-18T13:00:00.000000000',\n", + " '2024-01-18T14:00:00.000000000', '2024-01-18T15:00:00.000000000',\n", + " '2024-01-18T16:00:00.000000000', '2024-01-18T17:00:00.000000000',\n", + " '2024-01-18T18:00:00.000000000', '2024-01-18T19:00:00.000000000',\n", + " '2024-01-18T20:00:00.000000000', '2024-01-18T21:00:00.000000000',\n", + " '2024-01-18T22:00:00.000000000', '2024-01-18T23:00:00.000000000',\n", + " '2024-01-19T00:00:00.000000000', '2024-01-19T01:00:00.000000000',\n", + " '2024-01-19T02:00:00.000000000', '2024-01-19T03:00:00.000000000',\n", + " '2024-01-19T04:00:00.000000000', '2024-01-19T05:00:00.000000000',\n", + " '2024-01-19T06:00:00.000000000', '2024-01-19T07:00:00.000000000',\n", + " '2024-01-19T08:00:00.000000000', '2024-01-19T09:00:00.000000000',\n", + " '2024-01-19T10:00:00.000000000', '2024-01-19T11:00:00.000000000',\n", + " '2024-01-19T12:00:00.000000000', '2024-01-19T13:00:00.000000000',\n", + " '2024-01-19T14:00:00.000000000', '2024-01-19T15:00:00.000000000',\n", + " '2024-01-19T16:00:00.000000000', '2024-01-19T17:00:00.000000000',\n", + " '2024-01-19T18:00:00.000000000', '2024-01-19T19:00:00.000000000',\n", + " '2024-01-19T20:00:00.000000000', '2024-01-19T21:00:00.000000000',\n", + " '2024-01-19T22:00:00.000000000', '2024-01-19T23:00:00.000000000',\n", + " '2024-01-20T00:00:00.000000000', '2024-01-20T01:00:00.000000000',\n", + " '2024-01-20T02:00:00.000000000', '2024-01-20T03:00:00.000000000',\n", + " '2024-01-20T04:00:00.000000000', '2024-01-20T05:00:00.000000000',\n", + " '2024-01-20T06:00:00.000000000', '2024-01-20T07:00:00.000000000',\n", + " '2024-01-20T08:00:00.000000000', '2024-01-20T09:00:00.000000000',\n", + " '2024-01-20T10:00:00.000000000', '2024-01-20T11:00:00.000000000',\n", + " '2024-01-20T12:00:00.000000000', '2024-01-20T13:00:00.000000000',\n", + " '2024-01-20T14:00:00.000000000', '2024-01-20T15:00:00.000000000',\n", + " '2024-01-20T16:00:00.000000000', '2024-01-20T17:00:00.000000000',\n", + " '2024-01-20T18:00:00.000000000', '2024-01-20T19:00:00.000000000',\n", + " '2024-01-20T20:00:00.000000000', '2024-01-20T21:00:00.000000000',\n", + " '2024-01-20T22:00:00.000000000', '2024-01-20T23:00:00.000000000',\n", + " '2024-01-21T00:00:00.000000000', '2024-01-21T01:00:00.000000000',\n", + " '2024-01-21T02:00:00.000000000', '2024-01-21T03:00:00.000000000',\n", + " '2024-01-21T04:00:00.000000000', '2024-01-21T05:00:00.000000000',\n", + " '2024-01-21T06:00:00.000000000', '2024-01-21T07:00:00.000000000',\n", + " '2024-01-21T08:00:00.000000000', '2024-01-21T09:00:00.000000000',\n", + " '2024-01-21T10:00:00.000000000', '2024-01-21T11:00:00.000000000',\n", + " '2024-01-21T12:00:00.000000000', '2024-01-21T13:00:00.000000000',\n", + " '2024-01-21T14:00:00.000000000', '2024-01-21T15:00:00.000000000',\n", + " '2024-01-21T16:00:00.000000000', '2024-01-21T17:00:00.000000000',\n", + " '2024-01-21T18:00:00.000000000', '2024-01-21T19:00:00.000000000',\n", + " '2024-01-21T20:00:00.000000000', '2024-01-21T21:00:00.000000000',\n", + " '2024-01-21T22:00:00.000000000', '2024-01-21T23:00:00.000000000',\n", + " '2024-01-22T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('5ZuWpeU9QEDMU8WNBU89QGDXQkqFnk' ... 'AAAAA0QK7n4h/lezhAAAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'}],\n", + " 'layout': {'bargap': 0,\n", + " 'bargroupgap': 0,\n", + " 'barmode': 'relative',\n", + " 'legend': {'title': {'text': 'variable'}, 'tracegroupgap': 0},\n", + " 'template': '...',\n", + " 'title': {'text': 'Heat (flow_rate)'},\n", + " 'xaxis': {'anchor': 'y', 'domain': [0.0, 1.0], 'title': {'text': 'time'}},\n", + " 'yaxis': {'anchor': 'x', 'domain': [0.0, 1.0], 'title': {'text': 'value'}}}\n", + "}))" + ], + "text/html": [ + "
\n", + "
" + ] + }, + "execution_count": 37, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 37 + }, + { + "cell_type": "code", + "id": "76", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:18.619651Z", + "start_time": "2025-12-13T14:13:18.562286Z" + } + }, + "source": [ + "# Using a list of colors\n", + "simple.statistics.plot.balance('Heat', colors=['#e41a1c', '#377eb8', '#4daf4a', '#984ea3'])" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "PlotResult(data= Size: 7kB\n", + "Dimensions: (time: 169)\n", + "Coordinates:\n", + " * time (time) datetime64[ns] 1kB 2024-01-15 ... 2024-...\n", + "Data variables:\n", + " Boiler(Heat) (time) float64 1kB -32.48 -29.31 ... -124.5 nan\n", + " ThermalStorage(Discharge) (time) float64 1kB -0.0 5.275e-13 ... nan\n", + " ThermalStorage(Charge) (time) float64 1kB 0.0 -3.748e-13 ... 100.0 nan\n", + " Office(Heat) (time) float64 1kB 32.48 29.31 ... 24.48 nan, figure=Figure({\n", + " 'data': [{'hovertemplate': 'variable=Boiler(Heat)
time=%{x}
value=%{y}',\n", + " 'legendgroup': 'Boiler(Heat)',\n", + " 'marker': {'color': '#e41a1c', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Boiler(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-15T00:00:00.000000000', '2024-01-15T01:00:00.000000000',\n", + " '2024-01-15T02:00:00.000000000', '2024-01-15T03:00:00.000000000',\n", + " '2024-01-15T04:00:00.000000000', '2024-01-15T05:00:00.000000000',\n", + " '2024-01-15T06:00:00.000000000', '2024-01-15T07:00:00.000000000',\n", + " '2024-01-15T08:00:00.000000000', '2024-01-15T09:00:00.000000000',\n", + " '2024-01-15T10:00:00.000000000', '2024-01-15T11:00:00.000000000',\n", + " '2024-01-15T12:00:00.000000000', '2024-01-15T13:00:00.000000000',\n", + " '2024-01-15T14:00:00.000000000', '2024-01-15T15:00:00.000000000',\n", + " '2024-01-15T16:00:00.000000000', '2024-01-15T17:00:00.000000000',\n", + " '2024-01-15T18:00:00.000000000', '2024-01-15T19:00:00.000000000',\n", + " '2024-01-15T20:00:00.000000000', '2024-01-15T21:00:00.000000000',\n", + " '2024-01-15T22:00:00.000000000', '2024-01-15T23:00:00.000000000',\n", + " '2024-01-16T00:00:00.000000000', '2024-01-16T01:00:00.000000000',\n", + " '2024-01-16T02:00:00.000000000', '2024-01-16T03:00:00.000000000',\n", + " '2024-01-16T04:00:00.000000000', '2024-01-16T05:00:00.000000000',\n", + " '2024-01-16T06:00:00.000000000', '2024-01-16T07:00:00.000000000',\n", + " '2024-01-16T08:00:00.000000000', '2024-01-16T09:00:00.000000000',\n", + " '2024-01-16T10:00:00.000000000', '2024-01-16T11:00:00.000000000',\n", + " '2024-01-16T12:00:00.000000000', '2024-01-16T13:00:00.000000000',\n", + " '2024-01-16T14:00:00.000000000', '2024-01-16T15:00:00.000000000',\n", + " '2024-01-16T16:00:00.000000000', '2024-01-16T17:00:00.000000000',\n", + " '2024-01-16T18:00:00.000000000', '2024-01-16T19:00:00.000000000',\n", + " '2024-01-16T20:00:00.000000000', '2024-01-16T21:00:00.000000000',\n", + " '2024-01-16T22:00:00.000000000', '2024-01-16T23:00:00.000000000',\n", + " '2024-01-17T00:00:00.000000000', '2024-01-17T01:00:00.000000000',\n", + " '2024-01-17T02:00:00.000000000', '2024-01-17T03:00:00.000000000',\n", + " '2024-01-17T04:00:00.000000000', '2024-01-17T05:00:00.000000000',\n", + " '2024-01-17T06:00:00.000000000', '2024-01-17T07:00:00.000000000',\n", + " '2024-01-17T08:00:00.000000000', '2024-01-17T09:00:00.000000000',\n", + " '2024-01-17T10:00:00.000000000', '2024-01-17T11:00:00.000000000',\n", + " '2024-01-17T12:00:00.000000000', '2024-01-17T13:00:00.000000000',\n", + " '2024-01-17T14:00:00.000000000', '2024-01-17T15:00:00.000000000',\n", + " '2024-01-17T16:00:00.000000000', '2024-01-17T17:00:00.000000000',\n", + " '2024-01-17T18:00:00.000000000', '2024-01-17T19:00:00.000000000',\n", + " '2024-01-17T20:00:00.000000000', '2024-01-17T21:00:00.000000000',\n", + " '2024-01-17T22:00:00.000000000', '2024-01-17T23:00:00.000000000',\n", + " '2024-01-18T00:00:00.000000000', '2024-01-18T01:00:00.000000000',\n", + " '2024-01-18T02:00:00.000000000', '2024-01-18T03:00:00.000000000',\n", + " '2024-01-18T04:00:00.000000000', '2024-01-18T05:00:00.000000000',\n", + " '2024-01-18T06:00:00.000000000', '2024-01-18T07:00:00.000000000',\n", + " '2024-01-18T08:00:00.000000000', '2024-01-18T09:00:00.000000000',\n", + " '2024-01-18T10:00:00.000000000', '2024-01-18T11:00:00.000000000',\n", + " '2024-01-18T12:00:00.000000000', '2024-01-18T13:00:00.000000000',\n", + " '2024-01-18T14:00:00.000000000', '2024-01-18T15:00:00.000000000',\n", + " '2024-01-18T16:00:00.000000000', '2024-01-18T17:00:00.000000000',\n", + " '2024-01-18T18:00:00.000000000', '2024-01-18T19:00:00.000000000',\n", + " '2024-01-18T20:00:00.000000000', '2024-01-18T21:00:00.000000000',\n", + " '2024-01-18T22:00:00.000000000', '2024-01-18T23:00:00.000000000',\n", + " '2024-01-19T00:00:00.000000000', '2024-01-19T01:00:00.000000000',\n", + " '2024-01-19T02:00:00.000000000', '2024-01-19T03:00:00.000000000',\n", + " '2024-01-19T04:00:00.000000000', '2024-01-19T05:00:00.000000000',\n", + " '2024-01-19T06:00:00.000000000', '2024-01-19T07:00:00.000000000',\n", + " '2024-01-19T08:00:00.000000000', '2024-01-19T09:00:00.000000000',\n", + " '2024-01-19T10:00:00.000000000', '2024-01-19T11:00:00.000000000',\n", + " '2024-01-19T12:00:00.000000000', '2024-01-19T13:00:00.000000000',\n", + " '2024-01-19T14:00:00.000000000', '2024-01-19T15:00:00.000000000',\n", + " '2024-01-19T16:00:00.000000000', '2024-01-19T17:00:00.000000000',\n", + " '2024-01-19T18:00:00.000000000', '2024-01-19T19:00:00.000000000',\n", + " '2024-01-19T20:00:00.000000000', '2024-01-19T21:00:00.000000000',\n", + " '2024-01-19T22:00:00.000000000', '2024-01-19T23:00:00.000000000',\n", + " '2024-01-20T00:00:00.000000000', '2024-01-20T01:00:00.000000000',\n", + " '2024-01-20T02:00:00.000000000', '2024-01-20T03:00:00.000000000',\n", + " '2024-01-20T04:00:00.000000000', '2024-01-20T05:00:00.000000000',\n", + " '2024-01-20T06:00:00.000000000', '2024-01-20T07:00:00.000000000',\n", + " '2024-01-20T08:00:00.000000000', '2024-01-20T09:00:00.000000000',\n", + " '2024-01-20T10:00:00.000000000', '2024-01-20T11:00:00.000000000',\n", + " '2024-01-20T12:00:00.000000000', '2024-01-20T13:00:00.000000000',\n", + " '2024-01-20T14:00:00.000000000', '2024-01-20T15:00:00.000000000',\n", + " '2024-01-20T16:00:00.000000000', '2024-01-20T17:00:00.000000000',\n", + " '2024-01-20T18:00:00.000000000', '2024-01-20T19:00:00.000000000',\n", + " '2024-01-20T20:00:00.000000000', '2024-01-20T21:00:00.000000000',\n", + " '2024-01-20T22:00:00.000000000', '2024-01-20T23:00:00.000000000',\n", + " '2024-01-21T00:00:00.000000000', '2024-01-21T01:00:00.000000000',\n", + " '2024-01-21T02:00:00.000000000', '2024-01-21T03:00:00.000000000',\n", + " '2024-01-21T04:00:00.000000000', '2024-01-21T05:00:00.000000000',\n", + " '2024-01-21T06:00:00.000000000', '2024-01-21T07:00:00.000000000',\n", + " '2024-01-21T08:00:00.000000000', '2024-01-21T09:00:00.000000000',\n", + " '2024-01-21T10:00:00.000000000', '2024-01-21T11:00:00.000000000',\n", + " '2024-01-21T12:00:00.000000000', '2024-01-21T13:00:00.000000000',\n", + " '2024-01-21T14:00:00.000000000', '2024-01-21T15:00:00.000000000',\n", + " '2024-01-21T16:00:00.000000000', '2024-01-21T17:00:00.000000000',\n", + " '2024-01-21T18:00:00.000000000', '2024-01-21T19:00:00.000000000',\n", + " '2024-01-21T20:00:00.000000000', '2024-01-21T21:00:00.000000000',\n", + " '2024-01-21T22:00:00.000000000', '2024-01-21T23:00:00.000000000',\n", + " '2024-01-22T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('5ZuWpeU9QMD3U8WNBU89wHjXQkqFnk' ... '////8zwPW5+Ef5Hl/AAAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'},\n", + " {'hovertemplate': 'variable=ThermalStorage(Discharge)
time=%{x}
value=%{y}',\n", + " 'legendgroup': 'ThermalStorage(Discharge)',\n", + " 'marker': {'color': '#377eb8', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Discharge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-15T00:00:00.000000000', '2024-01-15T01:00:00.000000000',\n", + " '2024-01-15T02:00:00.000000000', '2024-01-15T03:00:00.000000000',\n", + " '2024-01-15T04:00:00.000000000', '2024-01-15T05:00:00.000000000',\n", + " '2024-01-15T06:00:00.000000000', '2024-01-15T07:00:00.000000000',\n", + " '2024-01-15T08:00:00.000000000', '2024-01-15T09:00:00.000000000',\n", + " '2024-01-15T10:00:00.000000000', '2024-01-15T11:00:00.000000000',\n", + " '2024-01-15T12:00:00.000000000', '2024-01-15T13:00:00.000000000',\n", + " '2024-01-15T14:00:00.000000000', '2024-01-15T15:00:00.000000000',\n", + " '2024-01-15T16:00:00.000000000', '2024-01-15T17:00:00.000000000',\n", + " '2024-01-15T18:00:00.000000000', '2024-01-15T19:00:00.000000000',\n", + " '2024-01-15T20:00:00.000000000', '2024-01-15T21:00:00.000000000',\n", + " '2024-01-15T22:00:00.000000000', '2024-01-15T23:00:00.000000000',\n", + " '2024-01-16T00:00:00.000000000', '2024-01-16T01:00:00.000000000',\n", + " '2024-01-16T02:00:00.000000000', '2024-01-16T03:00:00.000000000',\n", + " '2024-01-16T04:00:00.000000000', '2024-01-16T05:00:00.000000000',\n", + " '2024-01-16T06:00:00.000000000', '2024-01-16T07:00:00.000000000',\n", + " '2024-01-16T08:00:00.000000000', '2024-01-16T09:00:00.000000000',\n", + " '2024-01-16T10:00:00.000000000', '2024-01-16T11:00:00.000000000',\n", + " '2024-01-16T12:00:00.000000000', '2024-01-16T13:00:00.000000000',\n", + " '2024-01-16T14:00:00.000000000', '2024-01-16T15:00:00.000000000',\n", + " '2024-01-16T16:00:00.000000000', '2024-01-16T17:00:00.000000000',\n", + " '2024-01-16T18:00:00.000000000', '2024-01-16T19:00:00.000000000',\n", + " '2024-01-16T20:00:00.000000000', '2024-01-16T21:00:00.000000000',\n", + " '2024-01-16T22:00:00.000000000', '2024-01-16T23:00:00.000000000',\n", + " '2024-01-17T00:00:00.000000000', '2024-01-17T01:00:00.000000000',\n", + " '2024-01-17T02:00:00.000000000', '2024-01-17T03:00:00.000000000',\n", + " '2024-01-17T04:00:00.000000000', '2024-01-17T05:00:00.000000000',\n", + " '2024-01-17T06:00:00.000000000', '2024-01-17T07:00:00.000000000',\n", + " '2024-01-17T08:00:00.000000000', '2024-01-17T09:00:00.000000000',\n", + " '2024-01-17T10:00:00.000000000', '2024-01-17T11:00:00.000000000',\n", + " '2024-01-17T12:00:00.000000000', '2024-01-17T13:00:00.000000000',\n", + " '2024-01-17T14:00:00.000000000', '2024-01-17T15:00:00.000000000',\n", + " '2024-01-17T16:00:00.000000000', '2024-01-17T17:00:00.000000000',\n", + " '2024-01-17T18:00:00.000000000', '2024-01-17T19:00:00.000000000',\n", + " '2024-01-17T20:00:00.000000000', '2024-01-17T21:00:00.000000000',\n", + " '2024-01-17T22:00:00.000000000', '2024-01-17T23:00:00.000000000',\n", + " '2024-01-18T00:00:00.000000000', '2024-01-18T01:00:00.000000000',\n", + " '2024-01-18T02:00:00.000000000', '2024-01-18T03:00:00.000000000',\n", + " '2024-01-18T04:00:00.000000000', '2024-01-18T05:00:00.000000000',\n", + " '2024-01-18T06:00:00.000000000', '2024-01-18T07:00:00.000000000',\n", + " '2024-01-18T08:00:00.000000000', '2024-01-18T09:00:00.000000000',\n", + " '2024-01-18T10:00:00.000000000', '2024-01-18T11:00:00.000000000',\n", + " '2024-01-18T12:00:00.000000000', '2024-01-18T13:00:00.000000000',\n", + " '2024-01-18T14:00:00.000000000', '2024-01-18T15:00:00.000000000',\n", + " '2024-01-18T16:00:00.000000000', '2024-01-18T17:00:00.000000000',\n", + " '2024-01-18T18:00:00.000000000', '2024-01-18T19:00:00.000000000',\n", + " '2024-01-18T20:00:00.000000000', '2024-01-18T21:00:00.000000000',\n", + " '2024-01-18T22:00:00.000000000', '2024-01-18T23:00:00.000000000',\n", + " '2024-01-19T00:00:00.000000000', '2024-01-19T01:00:00.000000000',\n", + " '2024-01-19T02:00:00.000000000', '2024-01-19T03:00:00.000000000',\n", + " '2024-01-19T04:00:00.000000000', '2024-01-19T05:00:00.000000000',\n", + " '2024-01-19T06:00:00.000000000', '2024-01-19T07:00:00.000000000',\n", + " '2024-01-19T08:00:00.000000000', '2024-01-19T09:00:00.000000000',\n", + " '2024-01-19T10:00:00.000000000', '2024-01-19T11:00:00.000000000',\n", + " '2024-01-19T12:00:00.000000000', '2024-01-19T13:00:00.000000000',\n", + " '2024-01-19T14:00:00.000000000', '2024-01-19T15:00:00.000000000',\n", + " '2024-01-19T16:00:00.000000000', '2024-01-19T17:00:00.000000000',\n", + " '2024-01-19T18:00:00.000000000', '2024-01-19T19:00:00.000000000',\n", + " '2024-01-19T20:00:00.000000000', '2024-01-19T21:00:00.000000000',\n", + " '2024-01-19T22:00:00.000000000', '2024-01-19T23:00:00.000000000',\n", + " '2024-01-20T00:00:00.000000000', '2024-01-20T01:00:00.000000000',\n", + " '2024-01-20T02:00:00.000000000', '2024-01-20T03:00:00.000000000',\n", + " '2024-01-20T04:00:00.000000000', '2024-01-20T05:00:00.000000000',\n", + " '2024-01-20T06:00:00.000000000', '2024-01-20T07:00:00.000000000',\n", + " '2024-01-20T08:00:00.000000000', '2024-01-20T09:00:00.000000000',\n", + " '2024-01-20T10:00:00.000000000', '2024-01-20T11:00:00.000000000',\n", + " '2024-01-20T12:00:00.000000000', '2024-01-20T13:00:00.000000000',\n", + " '2024-01-20T14:00:00.000000000', '2024-01-20T15:00:00.000000000',\n", + " '2024-01-20T16:00:00.000000000', '2024-01-20T17:00:00.000000000',\n", + " '2024-01-20T18:00:00.000000000', '2024-01-20T19:00:00.000000000',\n", + " '2024-01-20T20:00:00.000000000', '2024-01-20T21:00:00.000000000',\n", + " '2024-01-20T22:00:00.000000000', '2024-01-20T23:00:00.000000000',\n", + " '2024-01-21T00:00:00.000000000', '2024-01-21T01:00:00.000000000',\n", + " '2024-01-21T02:00:00.000000000', '2024-01-21T03:00:00.000000000',\n", + " '2024-01-21T04:00:00.000000000', '2024-01-21T05:00:00.000000000',\n", + " '2024-01-21T06:00:00.000000000', '2024-01-21T07:00:00.000000000',\n", + " '2024-01-21T08:00:00.000000000', '2024-01-21T09:00:00.000000000',\n", + " '2024-01-21T10:00:00.000000000', '2024-01-21T11:00:00.000000000',\n", + " '2024-01-21T12:00:00.000000000', '2024-01-21T13:00:00.000000000',\n", + " '2024-01-21T14:00:00.000000000', '2024-01-21T15:00:00.000000000',\n", + " '2024-01-21T16:00:00.000000000', '2024-01-21T17:00:00.000000000',\n", + " '2024-01-21T18:00:00.000000000', '2024-01-21T19:00:00.000000000',\n", + " '2024-01-21T20:00:00.000000000', '2024-01-21T21:00:00.000000000',\n", + " '2024-01-21T22:00:00.000000000', '2024-01-21T23:00:00.000000000',\n", + " '2024-01-22T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('AAAAAAAAAIAKPvjgg49iPby8nSEx72' ... 'AAAAAgvWP9SoFav2g9AAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'},\n", + " {'hovertemplate': 'variable=ThermalStorage(Charge)
time=%{x}
value=%{y}',\n", + " 'legendgroup': 'ThermalStorage(Charge)',\n", + " 'marker': {'color': '#4daf4a', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Charge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-15T00:00:00.000000000', '2024-01-15T01:00:00.000000000',\n", + " '2024-01-15T02:00:00.000000000', '2024-01-15T03:00:00.000000000',\n", + " '2024-01-15T04:00:00.000000000', '2024-01-15T05:00:00.000000000',\n", + " '2024-01-15T06:00:00.000000000', '2024-01-15T07:00:00.000000000',\n", + " '2024-01-15T08:00:00.000000000', '2024-01-15T09:00:00.000000000',\n", + " '2024-01-15T10:00:00.000000000', '2024-01-15T11:00:00.000000000',\n", + " '2024-01-15T12:00:00.000000000', '2024-01-15T13:00:00.000000000',\n", + " '2024-01-15T14:00:00.000000000', '2024-01-15T15:00:00.000000000',\n", + " '2024-01-15T16:00:00.000000000', '2024-01-15T17:00:00.000000000',\n", + " '2024-01-15T18:00:00.000000000', '2024-01-15T19:00:00.000000000',\n", + " '2024-01-15T20:00:00.000000000', '2024-01-15T21:00:00.000000000',\n", + " '2024-01-15T22:00:00.000000000', '2024-01-15T23:00:00.000000000',\n", + " '2024-01-16T00:00:00.000000000', '2024-01-16T01:00:00.000000000',\n", + " '2024-01-16T02:00:00.000000000', '2024-01-16T03:00:00.000000000',\n", + " '2024-01-16T04:00:00.000000000', '2024-01-16T05:00:00.000000000',\n", + " '2024-01-16T06:00:00.000000000', '2024-01-16T07:00:00.000000000',\n", + " '2024-01-16T08:00:00.000000000', '2024-01-16T09:00:00.000000000',\n", + " '2024-01-16T10:00:00.000000000', '2024-01-16T11:00:00.000000000',\n", + " '2024-01-16T12:00:00.000000000', '2024-01-16T13:00:00.000000000',\n", + " '2024-01-16T14:00:00.000000000', '2024-01-16T15:00:00.000000000',\n", + " '2024-01-16T16:00:00.000000000', '2024-01-16T17:00:00.000000000',\n", + " '2024-01-16T18:00:00.000000000', '2024-01-16T19:00:00.000000000',\n", + " '2024-01-16T20:00:00.000000000', '2024-01-16T21:00:00.000000000',\n", + " '2024-01-16T22:00:00.000000000', '2024-01-16T23:00:00.000000000',\n", + " '2024-01-17T00:00:00.000000000', '2024-01-17T01:00:00.000000000',\n", + " '2024-01-17T02:00:00.000000000', '2024-01-17T03:00:00.000000000',\n", + " '2024-01-17T04:00:00.000000000', '2024-01-17T05:00:00.000000000',\n", + " '2024-01-17T06:00:00.000000000', '2024-01-17T07:00:00.000000000',\n", + " '2024-01-17T08:00:00.000000000', '2024-01-17T09:00:00.000000000',\n", + " '2024-01-17T10:00:00.000000000', '2024-01-17T11:00:00.000000000',\n", + " '2024-01-17T12:00:00.000000000', '2024-01-17T13:00:00.000000000',\n", + " '2024-01-17T14:00:00.000000000', '2024-01-17T15:00:00.000000000',\n", + " '2024-01-17T16:00:00.000000000', '2024-01-17T17:00:00.000000000',\n", + " '2024-01-17T18:00:00.000000000', '2024-01-17T19:00:00.000000000',\n", + " '2024-01-17T20:00:00.000000000', '2024-01-17T21:00:00.000000000',\n", + " '2024-01-17T22:00:00.000000000', '2024-01-17T23:00:00.000000000',\n", + " '2024-01-18T00:00:00.000000000', '2024-01-18T01:00:00.000000000',\n", + " '2024-01-18T02:00:00.000000000', '2024-01-18T03:00:00.000000000',\n", + " '2024-01-18T04:00:00.000000000', '2024-01-18T05:00:00.000000000',\n", + " '2024-01-18T06:00:00.000000000', '2024-01-18T07:00:00.000000000',\n", + " '2024-01-18T08:00:00.000000000', '2024-01-18T09:00:00.000000000',\n", + " '2024-01-18T10:00:00.000000000', '2024-01-18T11:00:00.000000000',\n", + " '2024-01-18T12:00:00.000000000', '2024-01-18T13:00:00.000000000',\n", + " '2024-01-18T14:00:00.000000000', '2024-01-18T15:00:00.000000000',\n", + " '2024-01-18T16:00:00.000000000', '2024-01-18T17:00:00.000000000',\n", + " '2024-01-18T18:00:00.000000000', '2024-01-18T19:00:00.000000000',\n", + " '2024-01-18T20:00:00.000000000', '2024-01-18T21:00:00.000000000',\n", + " '2024-01-18T22:00:00.000000000', '2024-01-18T23:00:00.000000000',\n", + " '2024-01-19T00:00:00.000000000', '2024-01-19T01:00:00.000000000',\n", + " '2024-01-19T02:00:00.000000000', '2024-01-19T03:00:00.000000000',\n", + " '2024-01-19T04:00:00.000000000', '2024-01-19T05:00:00.000000000',\n", + " '2024-01-19T06:00:00.000000000', '2024-01-19T07:00:00.000000000',\n", + " '2024-01-19T08:00:00.000000000', '2024-01-19T09:00:00.000000000',\n", + " '2024-01-19T10:00:00.000000000', '2024-01-19T11:00:00.000000000',\n", + " '2024-01-19T12:00:00.000000000', '2024-01-19T13:00:00.000000000',\n", + " '2024-01-19T14:00:00.000000000', '2024-01-19T15:00:00.000000000',\n", + " '2024-01-19T16:00:00.000000000', '2024-01-19T17:00:00.000000000',\n", + " '2024-01-19T18:00:00.000000000', '2024-01-19T19:00:00.000000000',\n", + " '2024-01-19T20:00:00.000000000', '2024-01-19T21:00:00.000000000',\n", + " '2024-01-19T22:00:00.000000000', '2024-01-19T23:00:00.000000000',\n", + " '2024-01-20T00:00:00.000000000', '2024-01-20T01:00:00.000000000',\n", + " '2024-01-20T02:00:00.000000000', '2024-01-20T03:00:00.000000000',\n", + " '2024-01-20T04:00:00.000000000', '2024-01-20T05:00:00.000000000',\n", + " '2024-01-20T06:00:00.000000000', '2024-01-20T07:00:00.000000000',\n", + " '2024-01-20T08:00:00.000000000', '2024-01-20T09:00:00.000000000',\n", + " '2024-01-20T10:00:00.000000000', '2024-01-20T11:00:00.000000000',\n", + " '2024-01-20T12:00:00.000000000', '2024-01-20T13:00:00.000000000',\n", + " '2024-01-20T14:00:00.000000000', '2024-01-20T15:00:00.000000000',\n", + " '2024-01-20T16:00:00.000000000', '2024-01-20T17:00:00.000000000',\n", + " '2024-01-20T18:00:00.000000000', '2024-01-20T19:00:00.000000000',\n", + " '2024-01-20T20:00:00.000000000', '2024-01-20T21:00:00.000000000',\n", + " '2024-01-20T22:00:00.000000000', '2024-01-20T23:00:00.000000000',\n", + " '2024-01-21T00:00:00.000000000', '2024-01-21T01:00:00.000000000',\n", + " '2024-01-21T02:00:00.000000000', '2024-01-21T03:00:00.000000000',\n", + " '2024-01-21T04:00:00.000000000', '2024-01-21T05:00:00.000000000',\n", + " '2024-01-21T06:00:00.000000000', '2024-01-21T07:00:00.000000000',\n", + " '2024-01-21T08:00:00.000000000', '2024-01-21T09:00:00.000000000',\n", + " '2024-01-21T10:00:00.000000000', '2024-01-21T11:00:00.000000000',\n", + " '2024-01-21T12:00:00.000000000', '2024-01-21T13:00:00.000000000',\n", + " '2024-01-21T14:00:00.000000000', '2024-01-21T15:00:00.000000000',\n", + " '2024-01-21T16:00:00.000000000', '2024-01-21T17:00:00.000000000',\n", + " '2024-01-21T18:00:00.000000000', '2024-01-21T19:00:00.000000000',\n", + " '2024-01-21T20:00:00.000000000', '2024-01-21T21:00:00.000000000',\n", + " '2024-01-21T22:00:00.000000000', '2024-01-21T23:00:00.000000000',\n", + " '2024-01-22T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('AAAAAAAAAAAUfPDBB19avby8nSEx72' ... 'AAAAAAANj//////1hAAAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'},\n", + " {'hovertemplate': 'variable=Office(Heat)
time=%{x}
value=%{y}',\n", + " 'legendgroup': 'Office(Heat)',\n", + " 'marker': {'color': '#984ea3', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Office(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-15T00:00:00.000000000', '2024-01-15T01:00:00.000000000',\n", + " '2024-01-15T02:00:00.000000000', '2024-01-15T03:00:00.000000000',\n", + " '2024-01-15T04:00:00.000000000', '2024-01-15T05:00:00.000000000',\n", + " '2024-01-15T06:00:00.000000000', '2024-01-15T07:00:00.000000000',\n", + " '2024-01-15T08:00:00.000000000', '2024-01-15T09:00:00.000000000',\n", + " '2024-01-15T10:00:00.000000000', '2024-01-15T11:00:00.000000000',\n", + " '2024-01-15T12:00:00.000000000', '2024-01-15T13:00:00.000000000',\n", + " '2024-01-15T14:00:00.000000000', '2024-01-15T15:00:00.000000000',\n", + " '2024-01-15T16:00:00.000000000', '2024-01-15T17:00:00.000000000',\n", + " '2024-01-15T18:00:00.000000000', '2024-01-15T19:00:00.000000000',\n", + " '2024-01-15T20:00:00.000000000', '2024-01-15T21:00:00.000000000',\n", + " '2024-01-15T22:00:00.000000000', '2024-01-15T23:00:00.000000000',\n", + " '2024-01-16T00:00:00.000000000', '2024-01-16T01:00:00.000000000',\n", + " '2024-01-16T02:00:00.000000000', '2024-01-16T03:00:00.000000000',\n", + " '2024-01-16T04:00:00.000000000', '2024-01-16T05:00:00.000000000',\n", + " '2024-01-16T06:00:00.000000000', '2024-01-16T07:00:00.000000000',\n", + " '2024-01-16T08:00:00.000000000', '2024-01-16T09:00:00.000000000',\n", + " '2024-01-16T10:00:00.000000000', '2024-01-16T11:00:00.000000000',\n", + " '2024-01-16T12:00:00.000000000', '2024-01-16T13:00:00.000000000',\n", + " '2024-01-16T14:00:00.000000000', '2024-01-16T15:00:00.000000000',\n", + " '2024-01-16T16:00:00.000000000', '2024-01-16T17:00:00.000000000',\n", + " '2024-01-16T18:00:00.000000000', '2024-01-16T19:00:00.000000000',\n", + " '2024-01-16T20:00:00.000000000', '2024-01-16T21:00:00.000000000',\n", + " '2024-01-16T22:00:00.000000000', '2024-01-16T23:00:00.000000000',\n", + " '2024-01-17T00:00:00.000000000', '2024-01-17T01:00:00.000000000',\n", + " '2024-01-17T02:00:00.000000000', '2024-01-17T03:00:00.000000000',\n", + " '2024-01-17T04:00:00.000000000', '2024-01-17T05:00:00.000000000',\n", + " '2024-01-17T06:00:00.000000000', '2024-01-17T07:00:00.000000000',\n", + " '2024-01-17T08:00:00.000000000', '2024-01-17T09:00:00.000000000',\n", + " '2024-01-17T10:00:00.000000000', '2024-01-17T11:00:00.000000000',\n", + " '2024-01-17T12:00:00.000000000', '2024-01-17T13:00:00.000000000',\n", + " '2024-01-17T14:00:00.000000000', '2024-01-17T15:00:00.000000000',\n", + " '2024-01-17T16:00:00.000000000', '2024-01-17T17:00:00.000000000',\n", + " '2024-01-17T18:00:00.000000000', '2024-01-17T19:00:00.000000000',\n", + " '2024-01-17T20:00:00.000000000', '2024-01-17T21:00:00.000000000',\n", + " '2024-01-17T22:00:00.000000000', '2024-01-17T23:00:00.000000000',\n", + " '2024-01-18T00:00:00.000000000', '2024-01-18T01:00:00.000000000',\n", + " '2024-01-18T02:00:00.000000000', '2024-01-18T03:00:00.000000000',\n", + " '2024-01-18T04:00:00.000000000', '2024-01-18T05:00:00.000000000',\n", + " '2024-01-18T06:00:00.000000000', '2024-01-18T07:00:00.000000000',\n", + " '2024-01-18T08:00:00.000000000', '2024-01-18T09:00:00.000000000',\n", + " '2024-01-18T10:00:00.000000000', '2024-01-18T11:00:00.000000000',\n", + " '2024-01-18T12:00:00.000000000', '2024-01-18T13:00:00.000000000',\n", + " '2024-01-18T14:00:00.000000000', '2024-01-18T15:00:00.000000000',\n", + " '2024-01-18T16:00:00.000000000', '2024-01-18T17:00:00.000000000',\n", + " '2024-01-18T18:00:00.000000000', '2024-01-18T19:00:00.000000000',\n", + " '2024-01-18T20:00:00.000000000', '2024-01-18T21:00:00.000000000',\n", + " '2024-01-18T22:00:00.000000000', '2024-01-18T23:00:00.000000000',\n", + " '2024-01-19T00:00:00.000000000', '2024-01-19T01:00:00.000000000',\n", + " '2024-01-19T02:00:00.000000000', '2024-01-19T03:00:00.000000000',\n", + " '2024-01-19T04:00:00.000000000', '2024-01-19T05:00:00.000000000',\n", + " '2024-01-19T06:00:00.000000000', '2024-01-19T07:00:00.000000000',\n", + " '2024-01-19T08:00:00.000000000', '2024-01-19T09:00:00.000000000',\n", + " '2024-01-19T10:00:00.000000000', '2024-01-19T11:00:00.000000000',\n", + " '2024-01-19T12:00:00.000000000', '2024-01-19T13:00:00.000000000',\n", + " '2024-01-19T14:00:00.000000000', '2024-01-19T15:00:00.000000000',\n", + " '2024-01-19T16:00:00.000000000', '2024-01-19T17:00:00.000000000',\n", + " '2024-01-19T18:00:00.000000000', '2024-01-19T19:00:00.000000000',\n", + " '2024-01-19T20:00:00.000000000', '2024-01-19T21:00:00.000000000',\n", + " '2024-01-19T22:00:00.000000000', '2024-01-19T23:00:00.000000000',\n", + " '2024-01-20T00:00:00.000000000', '2024-01-20T01:00:00.000000000',\n", + " '2024-01-20T02:00:00.000000000', '2024-01-20T03:00:00.000000000',\n", + " '2024-01-20T04:00:00.000000000', '2024-01-20T05:00:00.000000000',\n", + " '2024-01-20T06:00:00.000000000', '2024-01-20T07:00:00.000000000',\n", + " '2024-01-20T08:00:00.000000000', '2024-01-20T09:00:00.000000000',\n", + " '2024-01-20T10:00:00.000000000', '2024-01-20T11:00:00.000000000',\n", + " '2024-01-20T12:00:00.000000000', '2024-01-20T13:00:00.000000000',\n", + " '2024-01-20T14:00:00.000000000', '2024-01-20T15:00:00.000000000',\n", + " '2024-01-20T16:00:00.000000000', '2024-01-20T17:00:00.000000000',\n", + " '2024-01-20T18:00:00.000000000', '2024-01-20T19:00:00.000000000',\n", + " '2024-01-20T20:00:00.000000000', '2024-01-20T21:00:00.000000000',\n", + " '2024-01-20T22:00:00.000000000', '2024-01-20T23:00:00.000000000',\n", + " '2024-01-21T00:00:00.000000000', '2024-01-21T01:00:00.000000000',\n", + " '2024-01-21T02:00:00.000000000', '2024-01-21T03:00:00.000000000',\n", + " '2024-01-21T04:00:00.000000000', '2024-01-21T05:00:00.000000000',\n", + " '2024-01-21T06:00:00.000000000', '2024-01-21T07:00:00.000000000',\n", + " '2024-01-21T08:00:00.000000000', '2024-01-21T09:00:00.000000000',\n", + " '2024-01-21T10:00:00.000000000', '2024-01-21T11:00:00.000000000',\n", + " '2024-01-21T12:00:00.000000000', '2024-01-21T13:00:00.000000000',\n", + " '2024-01-21T14:00:00.000000000', '2024-01-21T15:00:00.000000000',\n", + " '2024-01-21T16:00:00.000000000', '2024-01-21T17:00:00.000000000',\n", + " '2024-01-21T18:00:00.000000000', '2024-01-21T19:00:00.000000000',\n", + " '2024-01-21T20:00:00.000000000', '2024-01-21T21:00:00.000000000',\n", + " '2024-01-21T22:00:00.000000000', '2024-01-21T23:00:00.000000000',\n", + " '2024-01-22T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('5ZuWpeU9QEDMU8WNBU89QGDXQkqFnk' ... 'AAAAA0QK7n4h/lezhAAAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'}],\n", + " 'layout': {'bargap': 0,\n", + " 'bargroupgap': 0,\n", + " 'barmode': 'relative',\n", + " 'legend': {'title': {'text': 'variable'}, 'tracegroupgap': 0},\n", + " 'template': '...',\n", + " 'title': {'text': 'Heat (flow_rate)'},\n", + " 'xaxis': {'anchor': 'y', 'domain': [0.0, 1.0], 'title': {'text': 'time'}},\n", + " 'yaxis': {'anchor': 'x', 'domain': [0.0, 1.0], 'title': {'text': 'value'}}}\n", + "}))" + ], + "text/html": [ + "
\n", + "
" + ] + }, + "execution_count": 38, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 38 + }, + { + "cell_type": "code", + "id": "77", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:18.672843Z", + "start_time": "2025-12-13T14:13:18.628572Z" + } + }, + "source": [ + "# Using a dictionary for specific labels\n", + "simple.statistics.plot.balance(\n", + " 'Heat',\n", + " colors={\n", + " 'Boiler(Heat)': 'orangered',\n", + " 'ThermalStorage(Charge)': 'steelblue',\n", + " 'ThermalStorage(Discharge)': 'lightblue',\n", + " 'Office(Heat)': 'forestgreen',\n", + " },\n", + ")" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "PlotResult(data= Size: 7kB\n", + "Dimensions: (time: 169)\n", + "Coordinates:\n", + " * time (time) datetime64[ns] 1kB 2024-01-15 ... 2024-...\n", + "Data variables:\n", + " Boiler(Heat) (time) float64 1kB -32.48 -29.31 ... -124.5 nan\n", + " ThermalStorage(Discharge) (time) float64 1kB -0.0 5.275e-13 ... nan\n", + " ThermalStorage(Charge) (time) float64 1kB 0.0 -3.748e-13 ... 100.0 nan\n", + " Office(Heat) (time) float64 1kB 32.48 29.31 ... 24.48 nan, figure=Figure({\n", + " 'data': [{'hovertemplate': 'variable=Boiler(Heat)
time=%{x}
value=%{y}',\n", + " 'legendgroup': 'Boiler(Heat)',\n", + " 'marker': {'color': 'orangered', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Boiler(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-15T00:00:00.000000000', '2024-01-15T01:00:00.000000000',\n", + " '2024-01-15T02:00:00.000000000', '2024-01-15T03:00:00.000000000',\n", + " '2024-01-15T04:00:00.000000000', '2024-01-15T05:00:00.000000000',\n", + " '2024-01-15T06:00:00.000000000', '2024-01-15T07:00:00.000000000',\n", + " '2024-01-15T08:00:00.000000000', '2024-01-15T09:00:00.000000000',\n", + " '2024-01-15T10:00:00.000000000', '2024-01-15T11:00:00.000000000',\n", + " '2024-01-15T12:00:00.000000000', '2024-01-15T13:00:00.000000000',\n", + " '2024-01-15T14:00:00.000000000', '2024-01-15T15:00:00.000000000',\n", + " '2024-01-15T16:00:00.000000000', '2024-01-15T17:00:00.000000000',\n", + " '2024-01-15T18:00:00.000000000', '2024-01-15T19:00:00.000000000',\n", + " '2024-01-15T20:00:00.000000000', '2024-01-15T21:00:00.000000000',\n", + " '2024-01-15T22:00:00.000000000', '2024-01-15T23:00:00.000000000',\n", + " '2024-01-16T00:00:00.000000000', '2024-01-16T01:00:00.000000000',\n", + " '2024-01-16T02:00:00.000000000', '2024-01-16T03:00:00.000000000',\n", + " '2024-01-16T04:00:00.000000000', '2024-01-16T05:00:00.000000000',\n", + " '2024-01-16T06:00:00.000000000', '2024-01-16T07:00:00.000000000',\n", + " '2024-01-16T08:00:00.000000000', '2024-01-16T09:00:00.000000000',\n", + " '2024-01-16T10:00:00.000000000', '2024-01-16T11:00:00.000000000',\n", + " '2024-01-16T12:00:00.000000000', '2024-01-16T13:00:00.000000000',\n", + " '2024-01-16T14:00:00.000000000', '2024-01-16T15:00:00.000000000',\n", + " '2024-01-16T16:00:00.000000000', '2024-01-16T17:00:00.000000000',\n", + " '2024-01-16T18:00:00.000000000', '2024-01-16T19:00:00.000000000',\n", + " '2024-01-16T20:00:00.000000000', '2024-01-16T21:00:00.000000000',\n", + " '2024-01-16T22:00:00.000000000', '2024-01-16T23:00:00.000000000',\n", + " '2024-01-17T00:00:00.000000000', '2024-01-17T01:00:00.000000000',\n", + " '2024-01-17T02:00:00.000000000', '2024-01-17T03:00:00.000000000',\n", + " '2024-01-17T04:00:00.000000000', '2024-01-17T05:00:00.000000000',\n", + " '2024-01-17T06:00:00.000000000', '2024-01-17T07:00:00.000000000',\n", + " '2024-01-17T08:00:00.000000000', '2024-01-17T09:00:00.000000000',\n", + " '2024-01-17T10:00:00.000000000', '2024-01-17T11:00:00.000000000',\n", + " '2024-01-17T12:00:00.000000000', '2024-01-17T13:00:00.000000000',\n", + " '2024-01-17T14:00:00.000000000', '2024-01-17T15:00:00.000000000',\n", + " '2024-01-17T16:00:00.000000000', '2024-01-17T17:00:00.000000000',\n", + " '2024-01-17T18:00:00.000000000', '2024-01-17T19:00:00.000000000',\n", + " '2024-01-17T20:00:00.000000000', '2024-01-17T21:00:00.000000000',\n", + " '2024-01-17T22:00:00.000000000', '2024-01-17T23:00:00.000000000',\n", + " '2024-01-18T00:00:00.000000000', '2024-01-18T01:00:00.000000000',\n", + " '2024-01-18T02:00:00.000000000', '2024-01-18T03:00:00.000000000',\n", + " '2024-01-18T04:00:00.000000000', '2024-01-18T05:00:00.000000000',\n", + " '2024-01-18T06:00:00.000000000', '2024-01-18T07:00:00.000000000',\n", + " '2024-01-18T08:00:00.000000000', '2024-01-18T09:00:00.000000000',\n", + " '2024-01-18T10:00:00.000000000', '2024-01-18T11:00:00.000000000',\n", + " '2024-01-18T12:00:00.000000000', '2024-01-18T13:00:00.000000000',\n", + " '2024-01-18T14:00:00.000000000', '2024-01-18T15:00:00.000000000',\n", + " '2024-01-18T16:00:00.000000000', '2024-01-18T17:00:00.000000000',\n", + " '2024-01-18T18:00:00.000000000', '2024-01-18T19:00:00.000000000',\n", + " '2024-01-18T20:00:00.000000000', '2024-01-18T21:00:00.000000000',\n", + " '2024-01-18T22:00:00.000000000', '2024-01-18T23:00:00.000000000',\n", + " '2024-01-19T00:00:00.000000000', '2024-01-19T01:00:00.000000000',\n", + " '2024-01-19T02:00:00.000000000', '2024-01-19T03:00:00.000000000',\n", + " '2024-01-19T04:00:00.000000000', '2024-01-19T05:00:00.000000000',\n", + " '2024-01-19T06:00:00.000000000', '2024-01-19T07:00:00.000000000',\n", + " '2024-01-19T08:00:00.000000000', '2024-01-19T09:00:00.000000000',\n", + " '2024-01-19T10:00:00.000000000', '2024-01-19T11:00:00.000000000',\n", + " '2024-01-19T12:00:00.000000000', '2024-01-19T13:00:00.000000000',\n", + " '2024-01-19T14:00:00.000000000', '2024-01-19T15:00:00.000000000',\n", + " '2024-01-19T16:00:00.000000000', '2024-01-19T17:00:00.000000000',\n", + " '2024-01-19T18:00:00.000000000', '2024-01-19T19:00:00.000000000',\n", + " '2024-01-19T20:00:00.000000000', '2024-01-19T21:00:00.000000000',\n", + " '2024-01-19T22:00:00.000000000', '2024-01-19T23:00:00.000000000',\n", + " '2024-01-20T00:00:00.000000000', '2024-01-20T01:00:00.000000000',\n", + " '2024-01-20T02:00:00.000000000', '2024-01-20T03:00:00.000000000',\n", + " '2024-01-20T04:00:00.000000000', '2024-01-20T05:00:00.000000000',\n", + " '2024-01-20T06:00:00.000000000', '2024-01-20T07:00:00.000000000',\n", + " '2024-01-20T08:00:00.000000000', '2024-01-20T09:00:00.000000000',\n", + " '2024-01-20T10:00:00.000000000', '2024-01-20T11:00:00.000000000',\n", + " '2024-01-20T12:00:00.000000000', '2024-01-20T13:00:00.000000000',\n", + " '2024-01-20T14:00:00.000000000', '2024-01-20T15:00:00.000000000',\n", + " '2024-01-20T16:00:00.000000000', '2024-01-20T17:00:00.000000000',\n", + " '2024-01-20T18:00:00.000000000', '2024-01-20T19:00:00.000000000',\n", + " '2024-01-20T20:00:00.000000000', '2024-01-20T21:00:00.000000000',\n", + " '2024-01-20T22:00:00.000000000', '2024-01-20T23:00:00.000000000',\n", + " '2024-01-21T00:00:00.000000000', '2024-01-21T01:00:00.000000000',\n", + " '2024-01-21T02:00:00.000000000', '2024-01-21T03:00:00.000000000',\n", + " '2024-01-21T04:00:00.000000000', '2024-01-21T05:00:00.000000000',\n", + " '2024-01-21T06:00:00.000000000', '2024-01-21T07:00:00.000000000',\n", + " '2024-01-21T08:00:00.000000000', '2024-01-21T09:00:00.000000000',\n", + " '2024-01-21T10:00:00.000000000', '2024-01-21T11:00:00.000000000',\n", + " '2024-01-21T12:00:00.000000000', '2024-01-21T13:00:00.000000000',\n", + " '2024-01-21T14:00:00.000000000', '2024-01-21T15:00:00.000000000',\n", + " '2024-01-21T16:00:00.000000000', '2024-01-21T17:00:00.000000000',\n", + " '2024-01-21T18:00:00.000000000', '2024-01-21T19:00:00.000000000',\n", + " '2024-01-21T20:00:00.000000000', '2024-01-21T21:00:00.000000000',\n", + " '2024-01-21T22:00:00.000000000', '2024-01-21T23:00:00.000000000',\n", + " '2024-01-22T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('5ZuWpeU9QMD3U8WNBU89wHjXQkqFnk' ... '////8zwPW5+Ef5Hl/AAAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'},\n", + " {'hovertemplate': 'variable=ThermalStorage(Discharge)
time=%{x}
value=%{y}',\n", + " 'legendgroup': 'ThermalStorage(Discharge)',\n", + " 'marker': {'color': 'lightblue', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Discharge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-15T00:00:00.000000000', '2024-01-15T01:00:00.000000000',\n", + " '2024-01-15T02:00:00.000000000', '2024-01-15T03:00:00.000000000',\n", + " '2024-01-15T04:00:00.000000000', '2024-01-15T05:00:00.000000000',\n", + " '2024-01-15T06:00:00.000000000', '2024-01-15T07:00:00.000000000',\n", + " '2024-01-15T08:00:00.000000000', '2024-01-15T09:00:00.000000000',\n", + " '2024-01-15T10:00:00.000000000', '2024-01-15T11:00:00.000000000',\n", + " '2024-01-15T12:00:00.000000000', '2024-01-15T13:00:00.000000000',\n", + " '2024-01-15T14:00:00.000000000', '2024-01-15T15:00:00.000000000',\n", + " '2024-01-15T16:00:00.000000000', '2024-01-15T17:00:00.000000000',\n", + " '2024-01-15T18:00:00.000000000', '2024-01-15T19:00:00.000000000',\n", + " '2024-01-15T20:00:00.000000000', '2024-01-15T21:00:00.000000000',\n", + " '2024-01-15T22:00:00.000000000', '2024-01-15T23:00:00.000000000',\n", + " '2024-01-16T00:00:00.000000000', '2024-01-16T01:00:00.000000000',\n", + " '2024-01-16T02:00:00.000000000', '2024-01-16T03:00:00.000000000',\n", + " '2024-01-16T04:00:00.000000000', '2024-01-16T05:00:00.000000000',\n", + " '2024-01-16T06:00:00.000000000', '2024-01-16T07:00:00.000000000',\n", + " '2024-01-16T08:00:00.000000000', '2024-01-16T09:00:00.000000000',\n", + " '2024-01-16T10:00:00.000000000', '2024-01-16T11:00:00.000000000',\n", + " '2024-01-16T12:00:00.000000000', '2024-01-16T13:00:00.000000000',\n", + " '2024-01-16T14:00:00.000000000', '2024-01-16T15:00:00.000000000',\n", + " '2024-01-16T16:00:00.000000000', '2024-01-16T17:00:00.000000000',\n", + " '2024-01-16T18:00:00.000000000', '2024-01-16T19:00:00.000000000',\n", + " '2024-01-16T20:00:00.000000000', '2024-01-16T21:00:00.000000000',\n", + " '2024-01-16T22:00:00.000000000', '2024-01-16T23:00:00.000000000',\n", + " '2024-01-17T00:00:00.000000000', '2024-01-17T01:00:00.000000000',\n", + " '2024-01-17T02:00:00.000000000', '2024-01-17T03:00:00.000000000',\n", + " '2024-01-17T04:00:00.000000000', '2024-01-17T05:00:00.000000000',\n", + " '2024-01-17T06:00:00.000000000', '2024-01-17T07:00:00.000000000',\n", + " '2024-01-17T08:00:00.000000000', '2024-01-17T09:00:00.000000000',\n", + " '2024-01-17T10:00:00.000000000', '2024-01-17T11:00:00.000000000',\n", + " '2024-01-17T12:00:00.000000000', '2024-01-17T13:00:00.000000000',\n", + " '2024-01-17T14:00:00.000000000', '2024-01-17T15:00:00.000000000',\n", + " '2024-01-17T16:00:00.000000000', '2024-01-17T17:00:00.000000000',\n", + " '2024-01-17T18:00:00.000000000', '2024-01-17T19:00:00.000000000',\n", + " '2024-01-17T20:00:00.000000000', '2024-01-17T21:00:00.000000000',\n", + " '2024-01-17T22:00:00.000000000', '2024-01-17T23:00:00.000000000',\n", + " '2024-01-18T00:00:00.000000000', '2024-01-18T01:00:00.000000000',\n", + " '2024-01-18T02:00:00.000000000', '2024-01-18T03:00:00.000000000',\n", + " '2024-01-18T04:00:00.000000000', '2024-01-18T05:00:00.000000000',\n", + " '2024-01-18T06:00:00.000000000', '2024-01-18T07:00:00.000000000',\n", + " '2024-01-18T08:00:00.000000000', '2024-01-18T09:00:00.000000000',\n", + " '2024-01-18T10:00:00.000000000', '2024-01-18T11:00:00.000000000',\n", + " '2024-01-18T12:00:00.000000000', '2024-01-18T13:00:00.000000000',\n", + " '2024-01-18T14:00:00.000000000', '2024-01-18T15:00:00.000000000',\n", + " '2024-01-18T16:00:00.000000000', '2024-01-18T17:00:00.000000000',\n", + " '2024-01-18T18:00:00.000000000', '2024-01-18T19:00:00.000000000',\n", + " '2024-01-18T20:00:00.000000000', '2024-01-18T21:00:00.000000000',\n", + " '2024-01-18T22:00:00.000000000', '2024-01-18T23:00:00.000000000',\n", + " '2024-01-19T00:00:00.000000000', '2024-01-19T01:00:00.000000000',\n", + " '2024-01-19T02:00:00.000000000', '2024-01-19T03:00:00.000000000',\n", + " '2024-01-19T04:00:00.000000000', '2024-01-19T05:00:00.000000000',\n", + " '2024-01-19T06:00:00.000000000', '2024-01-19T07:00:00.000000000',\n", + " '2024-01-19T08:00:00.000000000', '2024-01-19T09:00:00.000000000',\n", + " '2024-01-19T10:00:00.000000000', '2024-01-19T11:00:00.000000000',\n", + " '2024-01-19T12:00:00.000000000', '2024-01-19T13:00:00.000000000',\n", + " '2024-01-19T14:00:00.000000000', '2024-01-19T15:00:00.000000000',\n", + " '2024-01-19T16:00:00.000000000', '2024-01-19T17:00:00.000000000',\n", + " '2024-01-19T18:00:00.000000000', '2024-01-19T19:00:00.000000000',\n", + " '2024-01-19T20:00:00.000000000', '2024-01-19T21:00:00.000000000',\n", + " '2024-01-19T22:00:00.000000000', '2024-01-19T23:00:00.000000000',\n", + " '2024-01-20T00:00:00.000000000', '2024-01-20T01:00:00.000000000',\n", + " '2024-01-20T02:00:00.000000000', '2024-01-20T03:00:00.000000000',\n", + " '2024-01-20T04:00:00.000000000', '2024-01-20T05:00:00.000000000',\n", + " '2024-01-20T06:00:00.000000000', '2024-01-20T07:00:00.000000000',\n", + " '2024-01-20T08:00:00.000000000', '2024-01-20T09:00:00.000000000',\n", + " '2024-01-20T10:00:00.000000000', '2024-01-20T11:00:00.000000000',\n", + " '2024-01-20T12:00:00.000000000', '2024-01-20T13:00:00.000000000',\n", + " '2024-01-20T14:00:00.000000000', '2024-01-20T15:00:00.000000000',\n", + " '2024-01-20T16:00:00.000000000', '2024-01-20T17:00:00.000000000',\n", + " '2024-01-20T18:00:00.000000000', '2024-01-20T19:00:00.000000000',\n", + " '2024-01-20T20:00:00.000000000', '2024-01-20T21:00:00.000000000',\n", + " '2024-01-20T22:00:00.000000000', '2024-01-20T23:00:00.000000000',\n", + " '2024-01-21T00:00:00.000000000', '2024-01-21T01:00:00.000000000',\n", + " '2024-01-21T02:00:00.000000000', '2024-01-21T03:00:00.000000000',\n", + " '2024-01-21T04:00:00.000000000', '2024-01-21T05:00:00.000000000',\n", + " '2024-01-21T06:00:00.000000000', '2024-01-21T07:00:00.000000000',\n", + " '2024-01-21T08:00:00.000000000', '2024-01-21T09:00:00.000000000',\n", + " '2024-01-21T10:00:00.000000000', '2024-01-21T11:00:00.000000000',\n", + " '2024-01-21T12:00:00.000000000', '2024-01-21T13:00:00.000000000',\n", + " '2024-01-21T14:00:00.000000000', '2024-01-21T15:00:00.000000000',\n", + " '2024-01-21T16:00:00.000000000', '2024-01-21T17:00:00.000000000',\n", + " '2024-01-21T18:00:00.000000000', '2024-01-21T19:00:00.000000000',\n", + " '2024-01-21T20:00:00.000000000', '2024-01-21T21:00:00.000000000',\n", + " '2024-01-21T22:00:00.000000000', '2024-01-21T23:00:00.000000000',\n", + " '2024-01-22T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('AAAAAAAAAIAKPvjgg49iPby8nSEx72' ... 'AAAAAgvWP9SoFav2g9AAAAAAAA+P8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'},\n", + " {'hovertemplate': 'variable=ThermalStorage(Charge)
time=%{x}
value=%{y}',\n", + " 'legendgroup': 'ThermalStorage(Charge)',\n", + " 'marker': {'color': 'steelblue', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'ThermalStorage(Charge)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-15T00:00:00.000000000', '2024-01-15T01:00:00.000000000',\n", + " '2024-01-15T02:00:00.000000000', '2024-01-15T03:00:00.000000000',\n", + " '2024-01-15T04:00:00.000000000', '2024-01-15T05:00:00.000000000',\n", + " '2024-01-15T06:00:00.000000000', '2024-01-15T07:00:00.000000000',\n", + " '2024-01-15T08:00:00.000000000', '2024-01-15T09:00:00.000000000',\n", + " '2024-01-15T10:00:00.000000000', '2024-01-15T11:00:00.000000000',\n", + " '2024-01-15T12:00:00.000000000', '2024-01-15T13:00:00.000000000',\n", + " '2024-01-15T14:00:00.000000000', '2024-01-15T15:00:00.000000000',\n", + " '2024-01-15T16:00:00.000000000', '2024-01-15T17:00:00.000000000',\n", + " '2024-01-15T18:00:00.000000000', '2024-01-15T19:00:00.000000000',\n", + " '2024-01-15T20:00:00.000000000', '2024-01-15T21:00:00.000000000',\n", + " '2024-01-15T22:00:00.000000000', '2024-01-15T23:00:00.000000000',\n", + " '2024-01-16T00:00:00.000000000', '2024-01-16T01:00:00.000000000',\n", + " '2024-01-16T02:00:00.000000000', '2024-01-16T03:00:00.000000000',\n", + " '2024-01-16T04:00:00.000000000', '2024-01-16T05:00:00.000000000',\n", + " '2024-01-16T06:00:00.000000000', '2024-01-16T07:00:00.000000000',\n", + " '2024-01-16T08:00:00.000000000', '2024-01-16T09:00:00.000000000',\n", + " '2024-01-16T10:00:00.000000000', '2024-01-16T11:00:00.000000000',\n", + " '2024-01-16T12:00:00.000000000', '2024-01-16T13:00:00.000000000',\n", + " '2024-01-16T14:00:00.000000000', '2024-01-16T15:00:00.000000000',\n", + " '2024-01-16T16:00:00.000000000', '2024-01-16T17:00:00.000000000',\n", + " '2024-01-16T18:00:00.000000000', '2024-01-16T19:00:00.000000000',\n", + " '2024-01-16T20:00:00.000000000', '2024-01-16T21:00:00.000000000',\n", + " '2024-01-16T22:00:00.000000000', '2024-01-16T23:00:00.000000000',\n", + " '2024-01-17T00:00:00.000000000', '2024-01-17T01:00:00.000000000',\n", + " '2024-01-17T02:00:00.000000000', '2024-01-17T03:00:00.000000000',\n", + " '2024-01-17T04:00:00.000000000', '2024-01-17T05:00:00.000000000',\n", + " '2024-01-17T06:00:00.000000000', '2024-01-17T07:00:00.000000000',\n", + " '2024-01-17T08:00:00.000000000', '2024-01-17T09:00:00.000000000',\n", + " '2024-01-17T10:00:00.000000000', '2024-01-17T11:00:00.000000000',\n", + " '2024-01-17T12:00:00.000000000', '2024-01-17T13:00:00.000000000',\n", + " '2024-01-17T14:00:00.000000000', '2024-01-17T15:00:00.000000000',\n", + " '2024-01-17T16:00:00.000000000', '2024-01-17T17:00:00.000000000',\n", + " '2024-01-17T18:00:00.000000000', '2024-01-17T19:00:00.000000000',\n", + " '2024-01-17T20:00:00.000000000', '2024-01-17T21:00:00.000000000',\n", + " '2024-01-17T22:00:00.000000000', '2024-01-17T23:00:00.000000000',\n", + " '2024-01-18T00:00:00.000000000', '2024-01-18T01:00:00.000000000',\n", + " '2024-01-18T02:00:00.000000000', '2024-01-18T03:00:00.000000000',\n", + " '2024-01-18T04:00:00.000000000', '2024-01-18T05:00:00.000000000',\n", + " '2024-01-18T06:00:00.000000000', '2024-01-18T07:00:00.000000000',\n", + " '2024-01-18T08:00:00.000000000', '2024-01-18T09:00:00.000000000',\n", + " '2024-01-18T10:00:00.000000000', '2024-01-18T11:00:00.000000000',\n", + " '2024-01-18T12:00:00.000000000', '2024-01-18T13:00:00.000000000',\n", + " '2024-01-18T14:00:00.000000000', '2024-01-18T15:00:00.000000000',\n", + " '2024-01-18T16:00:00.000000000', '2024-01-18T17:00:00.000000000',\n", + " '2024-01-18T18:00:00.000000000', '2024-01-18T19:00:00.000000000',\n", + " '2024-01-18T20:00:00.000000000', '2024-01-18T21:00:00.000000000',\n", + " '2024-01-18T22:00:00.000000000', '2024-01-18T23:00:00.000000000',\n", + " '2024-01-19T00:00:00.000000000', '2024-01-19T01:00:00.000000000',\n", + " '2024-01-19T02:00:00.000000000', '2024-01-19T03:00:00.000000000',\n", + " '2024-01-19T04:00:00.000000000', '2024-01-19T05:00:00.000000000',\n", + " '2024-01-19T06:00:00.000000000', '2024-01-19T07:00:00.000000000',\n", + " '2024-01-19T08:00:00.000000000', '2024-01-19T09:00:00.000000000',\n", + " '2024-01-19T10:00:00.000000000', '2024-01-19T11:00:00.000000000',\n", + " '2024-01-19T12:00:00.000000000', '2024-01-19T13:00:00.000000000',\n", + " '2024-01-19T14:00:00.000000000', '2024-01-19T15:00:00.000000000',\n", + " '2024-01-19T16:00:00.000000000', '2024-01-19T17:00:00.000000000',\n", + " '2024-01-19T18:00:00.000000000', '2024-01-19T19:00:00.000000000',\n", + " '2024-01-19T20:00:00.000000000', '2024-01-19T21:00:00.000000000',\n", + " '2024-01-19T22:00:00.000000000', '2024-01-19T23:00:00.000000000',\n", + " '2024-01-20T00:00:00.000000000', '2024-01-20T01:00:00.000000000',\n", + " '2024-01-20T02:00:00.000000000', '2024-01-20T03:00:00.000000000',\n", + " '2024-01-20T04:00:00.000000000', '2024-01-20T05:00:00.000000000',\n", + " '2024-01-20T06:00:00.000000000', '2024-01-20T07:00:00.000000000',\n", + " '2024-01-20T08:00:00.000000000', '2024-01-20T09:00:00.000000000',\n", + " '2024-01-20T10:00:00.000000000', '2024-01-20T11:00:00.000000000',\n", + " '2024-01-20T12:00:00.000000000', '2024-01-20T13:00:00.000000000',\n", + " '2024-01-20T14:00:00.000000000', '2024-01-20T15:00:00.000000000',\n", + " '2024-01-20T16:00:00.000000000', '2024-01-20T17:00:00.000000000',\n", + " '2024-01-20T18:00:00.000000000', '2024-01-20T19:00:00.000000000',\n", + " '2024-01-20T20:00:00.000000000', '2024-01-20T21:00:00.000000000',\n", + " '2024-01-20T22:00:00.000000000', '2024-01-20T23:00:00.000000000',\n", + " '2024-01-21T00:00:00.000000000', '2024-01-21T01:00:00.000000000',\n", + " '2024-01-21T02:00:00.000000000', '2024-01-21T03:00:00.000000000',\n", + " '2024-01-21T04:00:00.000000000', '2024-01-21T05:00:00.000000000',\n", + " '2024-01-21T06:00:00.000000000', '2024-01-21T07:00:00.000000000',\n", + " '2024-01-21T08:00:00.000000000', '2024-01-21T09:00:00.000000000',\n", + " '2024-01-21T10:00:00.000000000', '2024-01-21T11:00:00.000000000',\n", + " '2024-01-21T12:00:00.000000000', '2024-01-21T13:00:00.000000000',\n", + " '2024-01-21T14:00:00.000000000', '2024-01-21T15:00:00.000000000',\n", + " '2024-01-21T16:00:00.000000000', '2024-01-21T17:00:00.000000000',\n", + " '2024-01-21T18:00:00.000000000', '2024-01-21T19:00:00.000000000',\n", + " '2024-01-21T20:00:00.000000000', '2024-01-21T21:00:00.000000000',\n", + " '2024-01-21T22:00:00.000000000', '2024-01-21T23:00:00.000000000',\n", + " '2024-01-22T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('AAAAAAAAAAAUfPDBB19avby8nSEx72' ... 'AAAAAAANj//////1hAAAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'},\n", + " {'hovertemplate': 'variable=Office(Heat)
time=%{x}
value=%{y}',\n", + " 'legendgroup': 'Office(Heat)',\n", + " 'marker': {'color': 'forestgreen', 'line': {'width': 0}, 'pattern': {'shape': ''}},\n", + " 'name': 'Office(Heat)',\n", + " 'orientation': 'v',\n", + " 'showlegend': True,\n", + " 'textposition': 'auto',\n", + " 'type': 'bar',\n", + " 'x': array(['2024-01-15T00:00:00.000000000', '2024-01-15T01:00:00.000000000',\n", + " '2024-01-15T02:00:00.000000000', '2024-01-15T03:00:00.000000000',\n", + " '2024-01-15T04:00:00.000000000', '2024-01-15T05:00:00.000000000',\n", + " '2024-01-15T06:00:00.000000000', '2024-01-15T07:00:00.000000000',\n", + " '2024-01-15T08:00:00.000000000', '2024-01-15T09:00:00.000000000',\n", + " '2024-01-15T10:00:00.000000000', '2024-01-15T11:00:00.000000000',\n", + " '2024-01-15T12:00:00.000000000', '2024-01-15T13:00:00.000000000',\n", + " '2024-01-15T14:00:00.000000000', '2024-01-15T15:00:00.000000000',\n", + " '2024-01-15T16:00:00.000000000', '2024-01-15T17:00:00.000000000',\n", + " '2024-01-15T18:00:00.000000000', '2024-01-15T19:00:00.000000000',\n", + " '2024-01-15T20:00:00.000000000', '2024-01-15T21:00:00.000000000',\n", + " '2024-01-15T22:00:00.000000000', '2024-01-15T23:00:00.000000000',\n", + " '2024-01-16T00:00:00.000000000', '2024-01-16T01:00:00.000000000',\n", + " '2024-01-16T02:00:00.000000000', '2024-01-16T03:00:00.000000000',\n", + " '2024-01-16T04:00:00.000000000', '2024-01-16T05:00:00.000000000',\n", + " '2024-01-16T06:00:00.000000000', '2024-01-16T07:00:00.000000000',\n", + " '2024-01-16T08:00:00.000000000', '2024-01-16T09:00:00.000000000',\n", + " '2024-01-16T10:00:00.000000000', '2024-01-16T11:00:00.000000000',\n", + " '2024-01-16T12:00:00.000000000', '2024-01-16T13:00:00.000000000',\n", + " '2024-01-16T14:00:00.000000000', '2024-01-16T15:00:00.000000000',\n", + " '2024-01-16T16:00:00.000000000', '2024-01-16T17:00:00.000000000',\n", + " '2024-01-16T18:00:00.000000000', '2024-01-16T19:00:00.000000000',\n", + " '2024-01-16T20:00:00.000000000', '2024-01-16T21:00:00.000000000',\n", + " '2024-01-16T22:00:00.000000000', '2024-01-16T23:00:00.000000000',\n", + " '2024-01-17T00:00:00.000000000', '2024-01-17T01:00:00.000000000',\n", + " '2024-01-17T02:00:00.000000000', '2024-01-17T03:00:00.000000000',\n", + " '2024-01-17T04:00:00.000000000', '2024-01-17T05:00:00.000000000',\n", + " '2024-01-17T06:00:00.000000000', '2024-01-17T07:00:00.000000000',\n", + " '2024-01-17T08:00:00.000000000', '2024-01-17T09:00:00.000000000',\n", + " '2024-01-17T10:00:00.000000000', '2024-01-17T11:00:00.000000000',\n", + " '2024-01-17T12:00:00.000000000', '2024-01-17T13:00:00.000000000',\n", + " '2024-01-17T14:00:00.000000000', '2024-01-17T15:00:00.000000000',\n", + " '2024-01-17T16:00:00.000000000', '2024-01-17T17:00:00.000000000',\n", + " '2024-01-17T18:00:00.000000000', '2024-01-17T19:00:00.000000000',\n", + " '2024-01-17T20:00:00.000000000', '2024-01-17T21:00:00.000000000',\n", + " '2024-01-17T22:00:00.000000000', '2024-01-17T23:00:00.000000000',\n", + " '2024-01-18T00:00:00.000000000', '2024-01-18T01:00:00.000000000',\n", + " '2024-01-18T02:00:00.000000000', '2024-01-18T03:00:00.000000000',\n", + " '2024-01-18T04:00:00.000000000', '2024-01-18T05:00:00.000000000',\n", + " '2024-01-18T06:00:00.000000000', '2024-01-18T07:00:00.000000000',\n", + " '2024-01-18T08:00:00.000000000', '2024-01-18T09:00:00.000000000',\n", + " '2024-01-18T10:00:00.000000000', '2024-01-18T11:00:00.000000000',\n", + " '2024-01-18T12:00:00.000000000', '2024-01-18T13:00:00.000000000',\n", + " '2024-01-18T14:00:00.000000000', '2024-01-18T15:00:00.000000000',\n", + " '2024-01-18T16:00:00.000000000', '2024-01-18T17:00:00.000000000',\n", + " '2024-01-18T18:00:00.000000000', '2024-01-18T19:00:00.000000000',\n", + " '2024-01-18T20:00:00.000000000', '2024-01-18T21:00:00.000000000',\n", + " '2024-01-18T22:00:00.000000000', '2024-01-18T23:00:00.000000000',\n", + " '2024-01-19T00:00:00.000000000', '2024-01-19T01:00:00.000000000',\n", + " '2024-01-19T02:00:00.000000000', '2024-01-19T03:00:00.000000000',\n", + " '2024-01-19T04:00:00.000000000', '2024-01-19T05:00:00.000000000',\n", + " '2024-01-19T06:00:00.000000000', '2024-01-19T07:00:00.000000000',\n", + " '2024-01-19T08:00:00.000000000', '2024-01-19T09:00:00.000000000',\n", + " '2024-01-19T10:00:00.000000000', '2024-01-19T11:00:00.000000000',\n", + " '2024-01-19T12:00:00.000000000', '2024-01-19T13:00:00.000000000',\n", + " '2024-01-19T14:00:00.000000000', '2024-01-19T15:00:00.000000000',\n", + " '2024-01-19T16:00:00.000000000', '2024-01-19T17:00:00.000000000',\n", + " '2024-01-19T18:00:00.000000000', '2024-01-19T19:00:00.000000000',\n", + " '2024-01-19T20:00:00.000000000', '2024-01-19T21:00:00.000000000',\n", + " '2024-01-19T22:00:00.000000000', '2024-01-19T23:00:00.000000000',\n", + " '2024-01-20T00:00:00.000000000', '2024-01-20T01:00:00.000000000',\n", + " '2024-01-20T02:00:00.000000000', '2024-01-20T03:00:00.000000000',\n", + " '2024-01-20T04:00:00.000000000', '2024-01-20T05:00:00.000000000',\n", + " '2024-01-20T06:00:00.000000000', '2024-01-20T07:00:00.000000000',\n", + " '2024-01-20T08:00:00.000000000', '2024-01-20T09:00:00.000000000',\n", + " '2024-01-20T10:00:00.000000000', '2024-01-20T11:00:00.000000000',\n", + " '2024-01-20T12:00:00.000000000', '2024-01-20T13:00:00.000000000',\n", + " '2024-01-20T14:00:00.000000000', '2024-01-20T15:00:00.000000000',\n", + " '2024-01-20T16:00:00.000000000', '2024-01-20T17:00:00.000000000',\n", + " '2024-01-20T18:00:00.000000000', '2024-01-20T19:00:00.000000000',\n", + " '2024-01-20T20:00:00.000000000', '2024-01-20T21:00:00.000000000',\n", + " '2024-01-20T22:00:00.000000000', '2024-01-20T23:00:00.000000000',\n", + " '2024-01-21T00:00:00.000000000', '2024-01-21T01:00:00.000000000',\n", + " '2024-01-21T02:00:00.000000000', '2024-01-21T03:00:00.000000000',\n", + " '2024-01-21T04:00:00.000000000', '2024-01-21T05:00:00.000000000',\n", + " '2024-01-21T06:00:00.000000000', '2024-01-21T07:00:00.000000000',\n", + " '2024-01-21T08:00:00.000000000', '2024-01-21T09:00:00.000000000',\n", + " '2024-01-21T10:00:00.000000000', '2024-01-21T11:00:00.000000000',\n", + " '2024-01-21T12:00:00.000000000', '2024-01-21T13:00:00.000000000',\n", + " '2024-01-21T14:00:00.000000000', '2024-01-21T15:00:00.000000000',\n", + " '2024-01-21T16:00:00.000000000', '2024-01-21T17:00:00.000000000',\n", + " '2024-01-21T18:00:00.000000000', '2024-01-21T19:00:00.000000000',\n", + " '2024-01-21T20:00:00.000000000', '2024-01-21T21:00:00.000000000',\n", + " '2024-01-21T22:00:00.000000000', '2024-01-21T23:00:00.000000000',\n", + " '2024-01-22T00:00:00.000000000'], dtype='datetime64[ns]'),\n", + " 'xaxis': 'x',\n", + " 'y': {'bdata': ('5ZuWpeU9QEDMU8WNBU89QGDXQkqFnk' ... 'AAAAA0QK7n4h/lezhAAAAAAAAA+H8='),\n", + " 'dtype': 'f8'},\n", + " 'yaxis': 'y'}],\n", + " 'layout': {'bargap': 0,\n", + " 'bargroupgap': 0,\n", + " 'barmode': 'relative',\n", + " 'legend': {'title': {'text': 'variable'}, 'tracegroupgap': 0},\n", + " 'template': '...',\n", + " 'title': {'text': 'Heat (flow_rate)'},\n", + " 'xaxis': {'anchor': 'y', 'domain': [0.0, 1.0], 'title': {'text': 'time'}},\n", + " 'yaxis': {'anchor': 'x', 'domain': [0.0, 1.0], 'title': {'text': 'value'}}}\n", + "}))" + ], + "text/html": [ + "
\n", + "
" + ] + }, + "execution_count": 39, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 39 + }, + { + "cell_type": "markdown", + "id": "78", + "metadata": {}, + "source": [ + "## 10. Exporting Results\n", + "\n", + "Plots return a `PlotResult` with data and figure that can be exported:" + ] + }, + { + "cell_type": "code", + "id": "79", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:18.710193Z", + "start_time": "2025-12-13T14:13:18.681521Z" + } + }, + "source": [ + "# Get plot result\n", + "result = simple.statistics.plot.balance('Heat')\n", + "\n", + "print('PlotResult contains:')\n", + "print(f' data: {type(result.data).__name__} with vars {list(result.data.data_vars)}')\n", + "print(f' figure: {type(result.figure).__name__}')" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "PlotResult contains:\n", + " data: Dataset with vars ['Boiler(Heat)', 'ThermalStorage(Discharge)', 'ThermalStorage(Charge)', 'Office(Heat)']\n", + " figure: Figure\n" + ] + } + ], + "execution_count": 40 + }, + { + "cell_type": "code", + "id": "80", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:18.736577Z", + "start_time": "2025-12-13T14:13:18.723621Z" + } + }, + "source": [ + "# Export data to pandas DataFrame\n", + "df = result.data.to_dataframe()\n", + "df.head()" + ], + "outputs": [ + { + "data": { + "text/plain": [ + " Boiler(Heat) ThermalStorage(Discharge) \\\n", + "time \n", + "2024-01-15 00:00:00 -32.483571 -0.000000e+00 \n", + "2024-01-15 01:00:00 -29.308678 5.275242e-13 \n", + "2024-01-15 02:00:00 -33.238443 -7.086767e-13 \n", + "2024-01-15 03:00:00 -101.411593 -3.516828e-13 \n", + "2024-01-15 04:00:00 -128.829233 -5.613288e-13 \n", + "\n", + " ThermalStorage(Charge) Office(Heat) \n", + "time \n", + "2024-01-15 00:00:00 0.000000e+00 32.483571 \n", + "2024-01-15 01:00:00 -3.747575e-13 29.308678 \n", + "2024-01-15 02:00:00 8.792069e-13 33.238443 \n", + "2024-01-15 03:00:00 6.379644e+01 37.615149 \n", + "2024-01-15 04:00:00 1.000000e+02 28.829233 " + ], + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Boiler(Heat)ThermalStorage(Discharge)ThermalStorage(Charge)Office(Heat)
time
2024-01-15 00:00:00-32.483571-0.000000e+000.000000e+0032.483571
2024-01-15 01:00:00-29.3086785.275242e-13-3.747575e-1329.308678
2024-01-15 02:00:00-33.238443-7.086767e-138.792069e-1333.238443
2024-01-15 03:00:00-101.411593-3.516828e-136.379644e+0137.615149
2024-01-15 04:00:00-128.829233-5.613288e-131.000000e+0228.829233
\n", + "
" + ] + }, + "execution_count": 41, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 41 + }, + { + "cell_type": "code", + "id": "81", + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-13T14:13:18.774445Z", + "start_time": "2025-12-13T14:13:18.771181Z" + } + }, + "source": [ + "# Export figure to HTML (interactive)\n", + "# result.figure.write_html('balance_plot.html')\n", + "\n", + "# Export figure to image\n", + "# result.figure.write_image('balance_plot.png', scale=2)" + ], + "outputs": [], + "execution_count": 42 + }, + { + "cell_type": "markdown", + "id": "85", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "### Data Access\n", + "\n", + "| Property | Description |\n", + "|----------|-------------|\n", + "| `statistics.flow_rates` | Time series of flow rates (power) |\n", + "| `statistics.flow_hours` | Energy values (rate × duration) |\n", + "| `statistics.sizes` | Component/flow capacities |\n", + "| `statistics.charge_states` | Storage charge levels |\n", + "| `statistics.temporal_effects` | Effects per timestep |\n", + "| `statistics.periodic_effects` | Effects per period |\n", + "| `statistics.total_effects` | Aggregated effect totals |\n", + "| `topology.carrier_colors` | Cached carrier color mapping |\n", + "| `topology.component_colors` | Cached component color mapping |\n", + "| `topology.bus_colors` | Cached bus color mapping |\n", + "\n", + "### Plot Methods\n", + "\n", + "| Method | Description |\n", + "|--------|-------------|\n", + "| `plot.balance(node)` | Stacked bar of in/outflows |\n", + "| `plot.carrier_balance(carrier)` | Balance for all flows of a carrier |\n", + "| `plot.flows(variables)` | Time series line/area plot |\n", + "| `plot.storage(component)` | Combined charge state and flows |\n", + "| `plot.charge_states(component)` | Charge state time series |\n", + "| `plot.sizes()` | Bar chart of sizes |\n", + "| `plot.effects(effect)` | Bar chart of effect contributions |\n", + "| `plot.duration_curve(variables)` | Sorted duration curve |\n", + "| `plot.heatmap(variable)` | 2D time-reshaped heatmap |\n", + "| `plot.sankey.flows()` | Energy flow Sankey |\n", + "| `plot.sankey.sizes()` | Capacity Sankey |\n", + "| `plot.sankey.peak_flow()` | Peak power Sankey |\n", + "| `plot.sankey.effects(effect)` | Effect allocation Sankey |\n", + "| `topology.plot()` | System structure diagram |" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/notebooks/10-transmission.ipynb b/docs/notebooks/10-transmission.ipynb new file mode 100644 index 000000000..8c74e4e8c --- /dev/null +++ b/docs/notebooks/10-transmission.ipynb @@ -0,0 +1,403 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Transmission\n", + "\n", + "Model energy or material transport between locations with losses.\n", + "\n", + "This notebook covers:\n", + "\n", + "- **Transmission component**: Connecting sites with pipelines, cables, or conveyors\n", + "- **Transmission losses**: Relative losses (proportional) and absolute losses (fixed)\n", + "- **Bidirectional flow**: Two-way transmission with flow direction constraints\n", + "- **Capacity optimization**: Sizing transmission infrastructure" + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "import plotly.express as px\n", + "import xarray as xr\n", + "\n", + "import flixopt as fx\n", + "\n", + "fx.CONFIG.notebook()" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "## The Problem: Connecting Two Sites\n", + "\n", + "Consider a district heating network with two sites:\n", + "\n", + "- **Site A**: Has a large gas boiler (cheap production)\n", + "- **Site B**: Has a smaller electric boiler (expensive, but flexible)\n", + "\n", + "A district heating pipe connects both sites. The question: How should heat flow between sites to minimize total costs?\n", + "\n", + "### Transmission Characteristics\n", + "\n", + "| Parameter | Value | Description |\n", + "|-----------|-------|-------------|\n", + "| Relative losses | 5% | Heat loss proportional to flow (pipe heat loss) |\n", + "| Capacity | 200 kW | Maximum transmission rate |\n", + "| Bidirectional | Yes | Heat can flow A→B or B→A |" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## Define Time Series Data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "# One week simulation\n", + "timesteps = pd.date_range('2024-01-22', periods=168, freq='h')\n", + "hours = np.arange(168)\n", + "hour_of_day = hours % 24\n", + "\n", + "# Site A: Industrial facility with steady demand\n", + "demand_a_base = 150\n", + "demand_a_variation = 30 * np.sin(hour_of_day * np.pi / 12) # Day/night cycle\n", + "demand_a = demand_a_base + demand_a_variation\n", + "\n", + "# Site B: Office building with peak during work hours\n", + "demand_b = np.where(\n", + " (hour_of_day >= 8) & (hour_of_day <= 18),\n", + " 180, # Daytime: 180 kW\n", + " 80, # Nighttime: 80 kW\n", + ")\n", + "# Add weekly pattern (lower on weekends)\n", + "day_of_week = (hours // 24) % 7\n", + "demand_b = np.where(day_of_week >= 5, demand_b * 0.6, demand_b) # Weekend reduction" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "# Visualize demand profiles\n", + "fig = px.line(\n", + " x=timesteps.tolist() * 2,\n", + " y=np.concatenate([demand_a, demand_b]),\n", + " color=['Site A (Industrial)'] * 168 + ['Site B (Office)'] * 168,\n", + " title='Heat Demand at Both Sites',\n", + " labels={'x': 'Time', 'y': 'Heat Demand [kW]', 'color': 'Site'},\n", + ")\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "## Example 1: Unidirectional Transmission\n", + "\n", + "Start with a simple case: heat flows only from Site A to Site B." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": "fs_unidirectional = fx.FlowSystem(timesteps)\nfs_unidirectional.add_carriers(\n fx.Carrier('gas', '#3498db', 'kW'),\n fx.Carrier('electricity', '#f1c40f', 'kW'),\n fx.Carrier('heat', '#e74c3c', 'kW'),\n)\nfs_unidirectional.add_elements(\n # === Buses (one per site) ===\n fx.Bus('Heat_A', carrier='heat'), # Site A heat network\n fx.Bus('Heat_B', carrier='heat'), # Site B heat network\n fx.Bus('Gas', carrier='gas'), # Gas supply network\n fx.Bus('Electricity', carrier='electricity'), # Electricity grid\n # === Effect ===\n fx.Effect('costs', '€', 'Operating Costs', is_standard=True, is_objective=True),\n # === External supplies ===\n fx.Source('GasSupply', outputs=[fx.Flow('Gas', bus='Gas', size=1000, effects_per_flow_hour=0.06)]),\n fx.Source('ElecGrid', outputs=[fx.Flow('Elec', bus='Electricity', size=500, effects_per_flow_hour=0.25)]),\n # === Site A: Large gas boiler (cheap) ===\n fx.LinearConverter(\n 'GasBoiler_A',\n inputs=[fx.Flow('Gas', bus='Gas', size=500)],\n outputs=[fx.Flow('Heat', bus='Heat_A', size=400)],\n conversion_factors=[{'Gas': 1, 'Heat': 0.92}], # 92% efficiency\n ),\n # === Site B: Small electric boiler (expensive but flexible) ===\n fx.LinearConverter(\n 'ElecBoiler_B',\n inputs=[fx.Flow('Elec', bus='Electricity', size=250)],\n outputs=[fx.Flow('Heat', bus='Heat_B', size=250)],\n conversion_factors=[{'Elec': 1, 'Heat': 0.99}], # 99% efficiency\n ),\n # === Transmission: A → B (unidirectional) ===\n fx.Transmission(\n 'Pipe_A_to_B',\n in1=fx.Flow('from_A', bus='Heat_A', size=200), # Input from Site A\n out1=fx.Flow('to_B', bus='Heat_B', size=200), # Output to Site B\n relative_losses=0.05, # 5% heat loss in pipe\n ),\n # === Demands ===\n fx.Sink('Demand_A', inputs=[fx.Flow('Heat', bus='Heat_A', size=1, fixed_relative_profile=demand_a)]),\n fx.Sink('Demand_B', inputs=[fx.Flow('Heat', bus='Heat_B', size=1, fixed_relative_profile=demand_b)]),\n)\n\nfs_unidirectional.optimize(fx.solvers.HighsSolver())" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "# View results\n", + "print(f'Total cost: {fs_unidirectional.solution[\"costs\"].item():.2f} €')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "# Heat balance at Site A\n", + "fs_unidirectional.statistics.plot.balance('Heat_A')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "# Heat balance at Site B\n", + "fs_unidirectional.statistics.plot.balance('Heat_B')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "# Energy flow overview\n", + "fs_unidirectional.statistics.plot.sankey.flows()" + ] + }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "### Observations\n", + "\n", + "- The optimizer uses the **cheaper gas boiler at Site A** as much as possible\n", + "- Heat is transmitted to Site B (despite 5% losses) because gas is much cheaper than electricity\n", + "- The electric boiler at Site B only runs when transmission capacity is insufficient" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "## Example 2: Bidirectional Transmission\n", + "\n", + "Now allow heat to flow in **both directions**. This is useful when:\n", + "- Both sites have generation capacity\n", + "- Demand patterns differ between sites\n", + "- Prices or availability vary over time" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "# Add a heat pump at Site B (cheaper during certain hours)\n", + "# Electricity price varies: cheap at night, expensive during day\n", + "elec_price = np.where(\n", + " (hour_of_day >= 22) | (hour_of_day <= 6),\n", + " 0.08, # Night: 0.08 €/kWh\n", + " 0.25, # Day: 0.25 €/kWh\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": "fs_bidirectional = fx.FlowSystem(timesteps)\nfs_bidirectional.add_carriers(\n fx.Carrier('gas', '#3498db', 'kW'),\n fx.Carrier('electricity', '#f1c40f', 'kW'),\n fx.Carrier('heat', '#e74c3c', 'kW'),\n)\nfs_bidirectional.add_elements(\n # === Buses ===\n fx.Bus('Heat_A', carrier='heat'),\n fx.Bus('Heat_B', carrier='heat'),\n fx.Bus('Gas', carrier='gas'),\n fx.Bus('Electricity', carrier='electricity'),\n # === Effect ===\n fx.Effect('costs', '€', 'Operating Costs', is_standard=True, is_objective=True),\n # === External supplies ===\n fx.Source('GasSupply', outputs=[fx.Flow('Gas', bus='Gas', size=1000, effects_per_flow_hour=0.06)]),\n fx.Source('ElecGrid', outputs=[fx.Flow('Elec', bus='Electricity', size=500, effects_per_flow_hour=elec_price)]),\n # === Site A: Gas boiler ===\n fx.LinearConverter(\n 'GasBoiler_A',\n inputs=[fx.Flow('Gas', bus='Gas', size=500)],\n outputs=[fx.Flow('Heat', bus='Heat_A', size=400)],\n conversion_factors=[{'Gas': 1, 'Heat': 0.92}],\n ),\n # === Site B: Heat pump (efficient with variable electricity price) ===\n fx.LinearConverter(\n 'HeatPump_B',\n inputs=[fx.Flow('Elec', bus='Electricity', size=100)],\n outputs=[fx.Flow('Heat', bus='Heat_B', size=350)],\n conversion_factors=[{'Elec': 1, 'Heat': 3.5}], # COP = 3.5\n ),\n # === BIDIRECTIONAL Transmission ===\n fx.Transmission(\n 'Pipe_AB',\n # Direction 1: A → B\n in1=fx.Flow('from_A', bus='Heat_A', size=200),\n out1=fx.Flow('to_B', bus='Heat_B', size=200),\n # Direction 2: B → A\n in2=fx.Flow('from_B', bus='Heat_B', size=200),\n out2=fx.Flow('to_A', bus='Heat_A', size=200),\n relative_losses=0.05,\n prevent_simultaneous_flows_in_both_directions=True, # Can't flow both ways at once\n ),\n # === Demands ===\n fx.Sink('Demand_A', inputs=[fx.Flow('Heat', bus='Heat_A', size=1, fixed_relative_profile=demand_a)]),\n fx.Sink('Demand_B', inputs=[fx.Flow('Heat', bus='Heat_B', size=1, fixed_relative_profile=demand_b)]),\n)\n\nfs_bidirectional.optimize(fx.solvers.HighsSolver())" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "# Compare costs\n", + "print(f'Unidirectional cost: {fs_unidirectional.solution[\"costs\"].item():.2f} €')\n", + "print(f'Bidirectional cost: {fs_bidirectional.solution[\"costs\"].item():.2f} €')\n", + "savings = fs_unidirectional.solution['costs'].item() - fs_bidirectional.solution['costs'].item()\n", + "print(f'Savings from bidirectional: {savings:.2f} €')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": {}, + "outputs": [], + "source": [ + "# Visualize transmission flows in both directions using xarray\n", + "flow_data = xr.Dataset(\n", + " {\n", + " 'A_to_B': fs_bidirectional.solution['Pipe_AB(from_A)|flow_rate'],\n", + " 'B_to_A': fs_bidirectional.solution['Pipe_AB(from_B)|flow_rate'],\n", + " }\n", + ")\n", + "\n", + "fig = px.line(\n", + " x=list(flow_data['time'].values) * 2,\n", + " y=np.concatenate([flow_data['A_to_B'].values, flow_data['B_to_A'].values]),\n", + " color=['A → B'] * len(flow_data['time']) + ['B → A'] * len(flow_data['time']),\n", + " title='Transmission Flow Direction Over Time',\n", + " labels={'x': 'Time', 'y': 'Flow Rate [kW]', 'color': 'Direction'},\n", + ")\n", + "fig" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [], + "source": [ + "# Heat balance at Site B showing bidirectional flows\n", + "fs_bidirectional.statistics.plot.balance('Heat_B')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20", + "metadata": {}, + "outputs": [], + "source": [ + "# Energy flow overview\n", + "fs_bidirectional.statistics.plot.sankey.flows()" + ] + }, + { + "cell_type": "markdown", + "id": "21", + "metadata": {}, + "source": [ + "### Observations\n", + "\n", + "- During **cheap electricity hours** (night): Heat pump at Site B produces heat, some flows to Site A\n", + "- During **expensive electricity hours** (day): Gas boiler at Site A supplies both sites\n", + "- The bidirectional transmission enables **load shifting** and **arbitrage** between sites" + ] + }, + { + "cell_type": "markdown", + "id": "22", + "metadata": {}, + "source": [ + "## Example 3: Transmission Capacity Optimization\n", + "\n", + "What's the **optimal pipe capacity**? Let the optimizer decide." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": "# Daily amortized pipe cost (simplified)\nPIPE_COST_PER_KW = 0.05 # €/kW/day capacity cost\n\nfs_invest = fx.FlowSystem(timesteps)\nfs_invest.add_carriers(\n fx.Carrier('gas', '#3498db', 'kW'),\n fx.Carrier('electricity', '#f1c40f', 'kW'),\n fx.Carrier('heat', '#e74c3c', 'kW'),\n)\nfs_invest.add_elements(\n # === Buses ===\n fx.Bus('Heat_A', carrier='heat'),\n fx.Bus('Heat_B', carrier='heat'),\n fx.Bus('Gas', carrier='gas'),\n fx.Bus('Electricity', carrier='electricity'),\n # === Effect ===\n fx.Effect('costs', '€', 'Operating Costs', is_standard=True, is_objective=True),\n # === External supplies ===\n fx.Source('GasSupply', outputs=[fx.Flow('Gas', bus='Gas', size=1000, effects_per_flow_hour=0.06)]),\n fx.Source('ElecGrid', outputs=[fx.Flow('Elec', bus='Electricity', size=500, effects_per_flow_hour=elec_price)]),\n # === Site A: Gas boiler ===\n fx.LinearConverter(\n 'GasBoiler_A',\n inputs=[fx.Flow('Gas', bus='Gas', size=500)],\n outputs=[fx.Flow('Heat', bus='Heat_A', size=400)],\n conversion_factors=[{'Gas': 1, 'Heat': 0.92}],\n ),\n # === Site B: Heat pump ===\n fx.LinearConverter(\n 'HeatPump_B',\n inputs=[fx.Flow('Elec', bus='Electricity', size=100)],\n outputs=[fx.Flow('Heat', bus='Heat_B', size=350)],\n conversion_factors=[{'Elec': 1, 'Heat': 3.5}],\n ),\n # === Site B: Backup electric boiler ===\n fx.LinearConverter(\n 'ElecBoiler_B',\n inputs=[fx.Flow('Elec', bus='Electricity', size=200)],\n outputs=[fx.Flow('Heat', bus='Heat_B', size=200)],\n conversion_factors=[{'Elec': 1, 'Heat': 0.99}],\n ),\n # === Transmission with INVESTMENT OPTIMIZATION ===\n # Investment parameters are passed via 'size' parameter\n fx.Transmission(\n 'Pipe_AB',\n in1=fx.Flow(\n 'from_A',\n bus='Heat_A',\n size=fx.InvestParameters(\n effects_of_investment_per_size={'costs': PIPE_COST_PER_KW * 7}, # Weekly cost\n minimum_size=0,\n maximum_size=300,\n ),\n ),\n out1=fx.Flow('to_B', bus='Heat_B'),\n in2=fx.Flow(\n 'from_B',\n bus='Heat_B',\n size=fx.InvestParameters(\n effects_of_investment_per_size={'costs': PIPE_COST_PER_KW * 7},\n minimum_size=0,\n maximum_size=300,\n ),\n ),\n out2=fx.Flow('to_A', bus='Heat_A'),\n relative_losses=0.05,\n balanced=True, # Same capacity in both directions\n prevent_simultaneous_flows_in_both_directions=True,\n ),\n # === Demands ===\n fx.Sink('Demand_A', inputs=[fx.Flow('Heat', bus='Heat_A', size=1, fixed_relative_profile=demand_a)]),\n fx.Sink('Demand_B', inputs=[fx.Flow('Heat', bus='Heat_B', size=1, fixed_relative_profile=demand_b)]),\n)\n\nfs_invest.optimize(fx.solvers.HighsSolver())" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24", + "metadata": {}, + "outputs": [], + "source": [ + "# Results\n", + "optimal_capacity = fs_invest.solution['Pipe_AB(from_A)|size'].item()\n", + "total_cost = fs_invest.solution['costs'].item()\n", + "\n", + "print(f'Optimal pipe capacity: {optimal_capacity:.1f} kW')\n", + "print(f'Total cost: {total_cost:.2f} €')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [], + "source": [ + "# Effect breakdown by component\n", + "fs_invest.statistics.plot.effects()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26", + "metadata": {}, + "outputs": [], + "source": [ + "# Energy flows\n", + "fs_invest.statistics.plot.sankey.flows()" + ] + }, + { + "cell_type": "markdown", + "id": "27", + "metadata": {}, + "source": "## Key Concepts\n\n### Transmission Component Structure\n\n```python\nfx.Transmission(\n label='pipe_name',\n # Direction 1: A → B\n in1=fx.Flow('from_A', bus='Bus_A', size=100),\n out1=fx.Flow('to_B', bus='Bus_B', size=100),\n # Direction 2: B → A (optional - omit for unidirectional)\n in2=fx.Flow('from_B', bus='Bus_B', size=100),\n out2=fx.Flow('to_A', bus='Bus_A', size=100),\n # Loss parameters\n relative_losses=0.05, # 5% proportional loss\n absolute_losses=10, # 10 kW fixed loss when active (optional)\n # Operational constraints\n prevent_simultaneous_flows_in_both_directions=True,\n balanced=True, # Same capacity both directions (needs InvestParameters)\n)\n```\n\n### Loss Types\n\n| Loss Type | Formula | Use Case |\n|-----------|---------|----------|\n| **Relative** | `out = in × (1 - loss)` | Heat pipes, electrical lines |\n| **Absolute** | `out = in - loss` (when active) | Pump energy, standby losses |\n\n### Bidirectional vs Unidirectional\n\n| Configuration | Parameters | Use Case |\n|---------------|------------|----------|\n| **Unidirectional** | `in1`, `out1` only | One-way pipelines, conveyors |\n| **Bidirectional** | `in1`, `out1`, `in2`, `out2` | Power lines, reversible pipes |\n\n### Investment Optimization\n\nUse `InvestParameters` as the `size` parameter for capacity optimization:\n\n```python\nin1=fx.Flow(\n 'from_A', \n bus='Bus_A',\n size=fx.InvestParameters( # Pass InvestParameters as size\n effects_of_investment_per_size={'costs': cost_per_kw},\n minimum_size=0,\n maximum_size=500,\n ),\n)\n```" + }, + { + "cell_type": "markdown", + "id": "28", + "metadata": {}, + "source": [ + "## Common Use Cases\n", + "\n", + "| Application | Typical Losses | Notes |\n", + "|-------------|---------------|-------|\n", + "| **District heating pipe** | 2-10% relative | Temperature-dependent |\n", + "| **High voltage line** | 1-5% relative | Distance-dependent |\n", + "| **Natural gas pipeline** | 0.5-2% relative | Compressor energy as absolute loss |\n", + "| **Conveyor belt** | Fixed absolute | Motor energy consumption |\n", + "| **Hydrogen pipeline** | 1-3% relative | Compression losses |" + ] + }, + { + "cell_type": "markdown", + "id": "29", + "metadata": {}, + "source": "## Summary\n\nYou learned how to:\n\n- Create **unidirectional transmission** between two buses\n- Model **bidirectional transmission** with flow direction constraints\n- Apply **relative and absolute losses** to transmission\n- Optimize **transmission capacity** using InvestParameters\n- Analyze **multi-site energy systems** with interconnections\n\n### Next Steps\n\n- **[07-scenarios-and-periods](07-scenarios-and-periods.ipynb)**: Multi-year planning with uncertainty\n- **[08a-Aggregation](08a-aggregation.ipynb)**: Speed up large problems with time series aggregation\n- **[08b-Rolling Horizon](08b-rolling-horizon.ipynb)**: Decompose large problems into sequential segments" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/notebooks/data/generate_example_systems.py b/docs/notebooks/data/generate_example_systems.py new file mode 100644 index 000000000..556463302 --- /dev/null +++ b/docs/notebooks/data/generate_example_systems.py @@ -0,0 +1,345 @@ +"""Generate example FlowSystem files for the plotting notebook. + +This script creates three FlowSystems of varying complexity: +1. simple_system - Basic heat system (boiler + storage + sink) +2. complex_system - Multi-carrier with multiple effects and piecewise efficiency +3. multiperiod_system - System with periods and scenarios + +Run this script to regenerate the example data files. +""" + +from pathlib import Path + +import numpy as np +import pandas as pd + +import flixopt as fx + +# Output directory (same as this script) +try: + OUTPUT_DIR = Path(__file__).parent +except NameError: + # Running in notebook context (e.g., mkdocs-jupyter) + OUTPUT_DIR = Path('docs/notebooks/data') + + +def create_simple_system() -> fx.FlowSystem: + """Create a simple heat system with boiler, storage, and demand. + + Components: + - Gas boiler (150 kW) + - Thermal storage (500 kWh) + - Office heat demand + + One week, hourly resolution. + """ + # One week, hourly + timesteps = pd.date_range('2024-01-15', periods=168, freq='h') + + # Create demand pattern + hours = np.arange(168) + hour_of_day = hours % 24 + day_of_week = (hours // 24) % 7 + + base_demand = np.where((hour_of_day >= 7) & (hour_of_day <= 18), 80, 30) + weekend_factor = np.where(day_of_week >= 5, 0.5, 1.0) + + np.random.seed(42) + heat_demand = base_demand * weekend_factor + np.random.normal(0, 5, len(hours)) + heat_demand = np.clip(heat_demand, 20, 100) + + # Time-varying gas price + gas_price = np.where((hour_of_day >= 6) & (hour_of_day <= 22), 0.08, 0.05) + + fs = fx.FlowSystem(timesteps) + fs.add_carriers( + fx.Carrier('gas', '#3498db', 'kW'), + fx.Carrier('heat', '#e74c3c', 'kW'), + ) + fs.add_elements( + fx.Bus('Gas', carrier='gas'), + fx.Bus('Heat', carrier='heat'), + fx.Effect('costs', '€', 'Operating Costs', is_standard=True, is_objective=True), + fx.Source('GasGrid', outputs=[fx.Flow('Gas', bus='Gas', size=500, effects_per_flow_hour=gas_price)]), + fx.linear_converters.Boiler( + 'Boiler', + thermal_efficiency=0.92, + thermal_flow=fx.Flow('Heat', bus='Heat', size=150), + fuel_flow=fx.Flow('Gas', bus='Gas'), + ), + fx.Storage( + 'ThermalStorage', + capacity_in_flow_hours=500, + initial_charge_state=250, + minimal_final_charge_state=200, + eta_charge=0.98, + eta_discharge=0.98, + relative_loss_per_hour=0.005, + charging=fx.Flow('Charge', bus='Heat', size=100), + discharging=fx.Flow('Discharge', bus='Heat', size=100), + ), + fx.Sink('Office', inputs=[fx.Flow('Heat', bus='Heat', size=1, fixed_relative_profile=heat_demand)]), + ) + return fs + + +def create_complex_system() -> fx.FlowSystem: + """Create a complex multi-carrier system with multiple effects. + + Components: + - Gas grid (with CO2 emissions) + - Electricity grid (with time-varying price and CO2) + - CHP with piecewise efficiency + - Heat pump + - Gas boiler (backup) + - Thermal storage + - Heat demand + + Effects: costs (objective), CO2 + + Three days, hourly resolution. + """ + timesteps = pd.date_range('2024-06-01', periods=72, freq='h') + hours = np.arange(72) + hour_of_day = hours % 24 + + # Demand profiles + np.random.seed(123) + heat_demand = 50 + 30 * np.sin(2 * np.pi * hour_of_day / 24 - np.pi / 2) + np.random.normal(0, 5, 72) + heat_demand = np.clip(heat_demand, 20, 100) + + electricity_demand = 20 + 15 * np.sin(2 * np.pi * hour_of_day / 24) + np.random.normal(0, 3, 72) + electricity_demand = np.clip(electricity_demand, 10, 50) + + # Price profiles + electricity_price = np.where((hour_of_day >= 8) & (hour_of_day <= 20), 0.25, 0.12) + gas_price = 0.06 + + # CO2 factors (kg/kWh) + electricity_co2 = np.where((hour_of_day >= 8) & (hour_of_day <= 20), 0.4, 0.3) # Higher during peak + gas_co2 = 0.2 + + fs = fx.FlowSystem(timesteps) + fs.add_carriers( + fx.Carrier('gas', '#3498db', 'kW'), + fx.Carrier('electricity', '#f1c40f', 'kW'), + fx.Carrier('heat', '#e74c3c', 'kW'), + ) + fs.add_elements( + # Buses + fx.Bus('Gas', carrier='gas'), + fx.Bus('Electricity', carrier='electricity'), + fx.Bus('Heat', carrier='heat'), + # Effects + fx.Effect('costs', '€', 'Total Costs', is_standard=True, is_objective=True), + fx.Effect('CO2', 'kg', 'CO2 Emissions'), + # Gas supply + fx.Source( + 'GasGrid', + outputs=[fx.Flow('Gas', bus='Gas', size=300, effects_per_flow_hour={'costs': gas_price, 'CO2': gas_co2})], + ), + # Electricity grid (import and export) + fx.Source( + 'ElectricityImport', + outputs=[ + fx.Flow( + 'El', + bus='Electricity', + size=100, + effects_per_flow_hour={'costs': electricity_price, 'CO2': electricity_co2}, + ) + ], + ), + fx.Sink( + 'ElectricityExport', + inputs=[ + fx.Flow('El', bus='Electricity', size=50, effects_per_flow_hour={'costs': -electricity_price * 0.8}) + ], + ), + # CHP with piecewise efficiency (efficiency varies with load) + fx.LinearConverter( + 'CHP', + inputs=[fx.Flow('Gas', bus='Gas', size=200)], + outputs=[fx.Flow('El', bus='Electricity', size=80), fx.Flow('Heat', bus='Heat', size=85)], + piecewise_conversion=fx.PiecewiseConversion( + { + 'Gas': fx.Piecewise( + [ + fx.Piece(start=80, end=160), # Part load + fx.Piece(start=160, end=200), # Full load + ] + ), + 'El': fx.Piecewise( + [ + fx.Piece(start=25, end=60), # ~31-38% electrical efficiency + fx.Piece(start=60, end=80), # ~38-40% electrical efficiency + ] + ), + 'Heat': fx.Piecewise( + [ + fx.Piece(start=35, end=70), # ~44% thermal efficiency + fx.Piece(start=70, end=85), # ~43% thermal efficiency + ] + ), + } + ), + status_parameters=fx.StatusParameters(effects_per_active_hour={'costs': 2}), + ), + # Heat pump (with investment) + fx.linear_converters.HeatPump( + 'HeatPump', + thermal_flow=fx.Flow( + 'Heat', + bus='Heat', + size=fx.InvestParameters( + effects_of_investment={'costs': 500}, + effects_of_investment_per_size={'costs': 100}, + maximum_size=60, + ), + ), + electrical_flow=fx.Flow('El', bus='Electricity'), + cop=3.5, + ), + # Backup boiler + fx.linear_converters.Boiler( + 'BackupBoiler', + thermal_flow=fx.Flow('Heat', bus='Heat', size=80), + fuel_flow=fx.Flow('Gas', bus='Gas'), + thermal_efficiency=0.90, + ), + # Thermal storage (with investment) + fx.Storage( + 'HeatStorage', + capacity_in_flow_hours=fx.InvestParameters( + effects_of_investment={'costs': 200}, + effects_of_investment_per_size={'costs': 10}, + maximum_size=300, + ), + eta_charge=0.95, + eta_discharge=0.95, + charging=fx.Flow('Charge', bus='Heat', size=50), + discharging=fx.Flow('Discharge', bus='Heat', size=50), + ), + # Demands + fx.Sink('HeatDemand', inputs=[fx.Flow('Heat', bus='Heat', size=1, fixed_relative_profile=heat_demand)]), + fx.Sink( + 'ElDemand', inputs=[fx.Flow('El', bus='Electricity', size=1, fixed_relative_profile=electricity_demand)] + ), + ) + return fs + + +def create_multiperiod_system() -> fx.FlowSystem: + """Create a system with multiple periods and scenarios. + + Same structure as simple system but with: + - 3 planning periods (years 2024, 2025, 2026) + - 2 scenarios (high demand, low demand) + + Each period: 48 hours (2 days representative) + """ + timesteps = pd.date_range('2024-01-01', periods=48, freq='h') + hour_of_day = np.arange(48) % 24 + + # Period definitions (years) + periods = pd.Index([2024, 2025, 2026], name='period') + + # Scenario definitions + scenarios = pd.Index(['high_demand', 'low_demand'], name='scenario') + scenario_weights = np.array([0.3, 0.7]) + + # Base demand pattern (hourly) + base_pattern = np.where((hour_of_day >= 7) & (hour_of_day <= 18), 80.0, 35.0) + + # Scenario-specific scaling + np.random.seed(42) + high_demand = base_pattern * 1.2 + np.random.normal(0, 5, 48) + low_demand = base_pattern * 0.85 + np.random.normal(0, 3, 48) + + # Create DataFrame with scenario columns + heat_demand = pd.DataFrame( + { + 'high_demand': np.clip(high_demand, 20, 120), + 'low_demand': np.clip(low_demand, 15, 90), + }, + index=timesteps, + ) + + # Gas price varies by period (rising costs) + gas_prices = np.array([0.06, 0.08, 0.10]) # Per period + + fs = fx.FlowSystem( + timesteps, + periods=periods, + scenarios=scenarios, + scenario_weights=scenario_weights, + ) + fs.add_carriers( + fx.Carrier('gas', '#3498db', 'kW'), + fx.Carrier('heat', '#e74c3c', 'kW'), + ) + fs.add_elements( + fx.Bus('Gas', carrier='gas'), + fx.Bus('Heat', carrier='heat'), + fx.Effect('costs', '€', 'Operating Costs', is_standard=True, is_objective=True), + fx.Source('GasGrid', outputs=[fx.Flow('Gas', bus='Gas', size=500, effects_per_flow_hour=gas_prices)]), + fx.linear_converters.Boiler( + 'Boiler', + thermal_efficiency=0.92, + thermal_flow=fx.Flow( + 'Heat', + bus='Heat', + size=fx.InvestParameters( + effects_of_investment={'costs': 1000}, + effects_of_investment_per_size={'costs': 50}, + maximum_size=250, + ), + ), + fuel_flow=fx.Flow('Gas', bus='Gas'), + ), + fx.Storage( + 'ThermalStorage', + capacity_in_flow_hours=fx.InvestParameters( + effects_of_investment={'costs': 500}, + effects_of_investment_per_size={'costs': 15}, + maximum_size=400, + ), + eta_charge=0.98, + eta_discharge=0.98, + charging=fx.Flow('Charge', bus='Heat', size=80), + discharging=fx.Flow('Discharge', bus='Heat', size=80), + ), + fx.Sink('Building', inputs=[fx.Flow('Heat', bus='Heat', size=1, fixed_relative_profile=heat_demand)]), + ) + return fs + + +def main(): + """Generate all example systems and save to netCDF.""" + solver = fx.solvers.HighsSolver(log_to_console=False) + + systems = [ + ('simple_system', create_simple_system), + ('complex_system', create_complex_system), + ('multiperiod_system', create_multiperiod_system), + ] + + for name, create_func in systems: + print(f'Creating {name}...') + fs = create_func() + + print(' Optimizing...') + fs.optimize(solver) + + output_path = OUTPUT_DIR / f'{name}.nc4' + print(f' Saving to {output_path}...') + fs.to_netcdf(output_path, overwrite=True) + + print(f' Done. Objective: {fs.solution["objective"].item():.2f}') + print() + + print('All systems generated successfully!') + + +if __name__ == '__main__': + main() diff --git a/docs/notebooks/index.md b/docs/notebooks/index.md new file mode 100644 index 000000000..233f6be1b --- /dev/null +++ b/docs/notebooks/index.md @@ -0,0 +1,64 @@ +# Examples + +Learn flixopt through practical examples organized by topic. Each notebook includes a real-world user story and progressively builds your understanding. + +## Basics + +| Notebook | Description | +|----------|-------------| +| [01-Quickstart](01-quickstart.ipynb) | Minimal working example - heat a workshop with a gas boiler | +| [02-Heat System](02-heat-system.ipynb) | District heating with thermal storage and time-varying prices | + +## Investment + +| Notebook | Description | +|----------|-------------| +| [03-Sizing](03-investment-optimization.ipynb) | Size a solar heating system - let the optimizer decide equipment sizes | +| [04-Constraints](04-operational-constraints.ipynb) | Industrial boiler with startup costs, minimum uptime, and load constraints | + +## Advanced + +| Notebook | Description | +|----------|-------------| +| [05-Multi-Carrier](05-multi-carrier-system.ipynb) | Hospital with CHP producing both electricity and heat | +| [10-Transmission](10-transmission.ipynb) | Connect sites with pipelines or cables, including losses and bidirectional flow | + +## Non-Linear Modeling + +| Notebook | Description | +|----------|-------------| +| [06a-Time-Varying](06a-time-varying-parameters.ipynb) | Heat pump with temperature-dependent COP | +| [06b-Piecewise Conversion](06b-piecewise-conversion.ipynb) | Gas engine with load-dependent efficiency curves | +| [06c-Piecewise Effects](06c-piecewise-effects.ipynb) | Economies of scale in investment costs | + +## Scaling + +| Notebook | Description | +|----------|-------------| +| [07-Scenarios](07-scenarios-and-periods.ipynb) | Multi-year planning with uncertain demand scenarios | +| [08a-Aggregation](08a-aggregation.ipynb) | Speed up large problems with resampling and two-stage optimization | +| [08b-Rolling Horizon](08b-rolling-horizon.ipynb) | Decompose large problems into sequential time segments | + +## Results + +| Notebook | Description | +|----------|-------------| +| [09-Plotting](09-plotting-and-data-access.ipynb) | Access optimization results and create visualizations | + +## Key Concepts + +| Concept | Introduced In | +|---------|---------------| +| `FlowSystem`, `Bus`, `Flow` | Quickstart | +| `Storage`, time-varying prices | Heat System | +| `InvestParameters`, optimal sizing | Sizing | +| `StatusParameters`, startup costs | Constraints | +| Multi-carrier, CHP | Multi-Carrier | +| `Transmission`, losses, bidirectional | Transmission | +| Time-varying `conversion_factors` | Time-Varying Parameters | +| `PiecewiseConversion`, part-load efficiency | Piecewise Conversion | +| `PiecewiseEffects`, economies of scale | Piecewise Effects | +| Periods, scenarios, weights | Scenarios | +| `transform.resample()`, `fix_sizes()` | Aggregation | +| `optimize.rolling_horizon()` | Rolling Horizon | +| `statistics`, `topology`, plotting | Plotting | diff --git a/docs/overrides/main.html b/docs/overrides/main.html new file mode 100644 index 000000000..b245acdaa --- /dev/null +++ b/docs/overrides/main.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block content %} +{% if page.nb_url %} + + {% include ".icons/material/download.svg" %} + +{% endif %} + +{{ super() }} +{% endblock content %} diff --git a/docs/roadmap.md b/docs/roadmap.md index fbad1043c..13233f014 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -18,7 +18,7 @@ We believe optimization modeling should be **approachable for beginners** yet ** ## 🔮 Medium-term (6-12 months) - **Modeling to Generate Alternatives (MGA)** - Built-in support for exploring near-optimal solution spaces to produce more robust, diverse solutions under uncertainty. See [PyPSA](https://docs.pypsa.org/latest/user-guide/optimization/modelling-to-generate-alternatives/) and [Calliope](https://calliope.readthedocs.io/en/latest/examples/modes/) for reference implementations -- **Advanced stochastic optimization** - Build sophisticated new `Calculation` classes to perform different stochastic optimization approaches, like PyPSA's [two-stage stochastic programming and risk preferences with Conditional Value-at-Risk (CVaR)](https://docs.pypsa.org/latest/user-guide/optimization/stochastic/) +- **Advanced stochastic optimization** - Build sophisticated new `Optimization` classes to perform different stochastic optimization approaches, like PyPSA's [two-stage stochastic programming and risk preferences with Conditional Value-at-Risk (CVaR)](https://docs.pypsa.org/latest/user-guide/optimization/stochastic/) - **Enhanced component library** - More pre-built, domain-specific components (sector coupling, hydrogen systems, thermal networks, demand-side management) ## 🌟 Long-term (12+ months) diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 2992267b6..ee551b3bd 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -505,12 +505,16 @@ Home Page Inline Styles (moved from docs/index.md) ========================================================================= */ +/* Center home page content when navigation is hidden */ +/* Remove this rule as it conflicts with TOC layout */ + .hero-section { text-align: center; padding: 4rem 2rem 3rem 2rem; background: linear-gradient(135deg, rgba(0, 150, 136, 0.1) 0%, rgba(0, 121, 107, 0.1) 100%); border-radius: 1rem; - margin-bottom: 3rem; + margin: 0 auto 3rem auto; + max-width: 1200px; } .hero-section h1 { @@ -558,47 +562,6 @@ margin-top: 2rem; } -.feature-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 2rem; - margin: 3rem 0; -} - -.feature-card { - padding: 2rem; - border-radius: 0.75rem; - background: var(--md-code-bg-color); - border: 1px solid var(--md-default-fg-color--lightest); - transition: all 0.3s ease; - text-align: center; -} - -.feature-card:hover { - transform: translateY(-4px); - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); - border-color: var(--md-primary-fg-color); -} - -.feature-icon { - font-size: 3rem; - margin-bottom: 1rem; - display: block; -} - -.feature-card h3 { - margin-top: 0; - margin-bottom: 0.5rem; - font-size: 1.25rem; -} - -.feature-card p { - color: var(--md-default-fg-color--light); - margin: 0; - font-size: 0.95rem; - line-height: 1.6; -} - .stats-banner { display: flex; justify-content: space-around; @@ -631,45 +594,14 @@ } .architecture-section { - margin: 4rem 0; + margin: 4rem auto; padding: 2rem; background: var(--md-code-bg-color); border-radius: 0.75rem; + max-width: 1200px; } -.quick-links { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 1.5rem; - margin: 3rem 0; -} - -.quick-link-card { - padding: 1.5rem; - border-left: 4px solid var(--md-primary-fg-color); - background: var(--md-code-bg-color); - border-radius: 0.5rem; - transition: all 0.2s ease; - text-decoration: none; - display: block; -} - -.quick-link-card:hover { - background: var(--md-default-fg-color--lightest); - transform: translateX(4px); -} -.quick-link-card h3 { - margin: 0 0 0.5rem 0; - font-size: 1.1rem; - color: var(--md-primary-fg-color); -} - -.quick-link-card p { - margin: 0; - color: var(--md-default-fg-color--light); - font-size: 0.9rem; -} @media screen and (max-width: 768px) { .hero-section h1 { @@ -685,10 +617,6 @@ align-items: stretch; } - .feature-grid { - grid-template-columns: 1fr; - } - .stats-banner { flex-direction: column; } @@ -836,30 +764,211 @@ button:focus-visible { } /* ============================================================================ - Footer Alignment Fix + Color Swatches for Carrier Documentation + ========================================================================= */ + +/* Inline color swatch - a small colored square */ +.color-swatch { + display: inline-block; + width: 1em; + height: 1em; + border-radius: 3px; + vertical-align: middle; + margin-right: 0.3em; + border: 1px solid rgba(0, 0, 0, 0.15); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +[data-md-color-scheme="slate"] .color-swatch { + border-color: rgba(255, 255, 255, 0.2); +} + +/* ============================================================================ + Jupyter Notebook Styling (syncs with dark/light theme) ========================================================================= */ -/* Align footer with content width */ -.md-footer-meta__inner, -.md-footer__inner { - max-width: 1300px; +/* Override Jupyter notebook syntax highlighting to match Material theme */ +/* Use Material's CSS variables for consistent colors */ +.highlight-ipynb { background: var(--md-code-bg-color) !important; color: var(--md-code-fg-color) !important; } + +/* Comments */ +.highlight-ipynb .c, .highlight-ipynb .c1, .highlight-ipynb .ch, +.highlight-ipynb .cm, .highlight-ipynb .cp, .highlight-ipynb .cpf, +.highlight-ipynb .cs { color: var(--md-code-hl-comment-color, var(--md-default-fg-color--light)) !important; font-style: italic; } + +/* Keywords */ +.highlight-ipynb .k, .highlight-ipynb .kc, .highlight-ipynb .kd, +.highlight-ipynb .kn, .highlight-ipynb .kp, .highlight-ipynb .kr, +.highlight-ipynb .kt { color: var(--md-code-hl-keyword-color, #3f6ec6) !important; } + +/* Strings */ +.highlight-ipynb .s, .highlight-ipynb .s1, .highlight-ipynb .s2, +.highlight-ipynb .sa, .highlight-ipynb .sb, .highlight-ipynb .sc, +.highlight-ipynb .sd, .highlight-ipynb .se, .highlight-ipynb .sh, +.highlight-ipynb .si, .highlight-ipynb .sl, .highlight-ipynb .sr, +.highlight-ipynb .ss, .highlight-ipynb .sx { color: var(--md-code-hl-string-color, #1c7d4d) !important; } + +/* Numbers */ +.highlight-ipynb .m, .highlight-ipynb .mb, .highlight-ipynb .mf, +.highlight-ipynb .mh, .highlight-ipynb .mi, .highlight-ipynb .mo, +.highlight-ipynb .il { color: var(--md-code-hl-number-color, #d52a2a) !important; } + +/* Functions */ +.highlight-ipynb .nf, .highlight-ipynb .fm { color: var(--md-code-hl-function-color, #a846b9) !important; } + +/* Constants/Builtins */ +.highlight-ipynb .nb, .highlight-ipynb .bp, +.highlight-ipynb .kc { color: var(--md-code-hl-constant-color, #6e59d9) !important; } + +/* Special */ +.highlight-ipynb .nc, .highlight-ipynb .ne, .highlight-ipynb .nd, +.highlight-ipynb .ni { color: var(--md-code-hl-special-color, #db1457) !important; } + +/* Names/variables */ +.highlight-ipynb .n, .highlight-ipynb .nn, .highlight-ipynb .na, +.highlight-ipynb .nv, .highlight-ipynb .no { color: var(--md-code-hl-name-color, var(--md-code-fg-color)) !important; } + +/* Operators */ +.highlight-ipynb .o, .highlight-ipynb .ow { color: var(--md-code-hl-operator-color, var(--md-default-fg-color--light)) !important; } + +/* Punctuation */ +.highlight-ipynb .p, .highlight-ipynb .pm { color: var(--md-code-hl-punctuation-color, var(--md-default-fg-color--light)) !important; } + +/* Errors */ +.highlight-ipynb .err { color: var(--md-code-hl-special-color, #db1457) !important; } + +/* Notebook container */ +.jupyter-wrapper { + margin: 1rem 0; +} + +/* Code cell styling - clean and modern */ +.jupyter-wrapper .jp-CodeCell { + border-radius: 0.4rem; + margin: 0.5rem 0; + border: 1px solid var(--md-default-fg-color--lightest); + overflow: hidden; +} + +/* Input cells (code) */ +.jupyter-wrapper .jp-CodeCell .jp-InputArea { + background-color: var(--md-code-bg-color); + border: none; +} + +.jupyter-wrapper .jp-InputArea pre { + margin: 0; + padding: 0.6rem 0.8rem; + font-size: 0.55rem; + line-height: 1.4; +} + +/* Output cells */ +.jupyter-wrapper .jp-OutputArea pre { + font-size: 0.55rem; + margin: 0; +} + +/* Cell prompts (In [1]:, Out [1]:) - hide for cleaner look */ +.jupyter-wrapper .jp-InputPrompt, +.jupyter-wrapper .jp-OutputPrompt { + display: none; +} + +/* Markdown cells - blend with page, no background */ +.jupyter-wrapper .jp-MarkdownCell { + background: transparent; + border: none; + margin: 0; +} + +.jupyter-wrapper .jp-RenderedMarkdown { + padding: 0.5rem 0; +} + +/* Tables in notebooks */ +.jupyter-wrapper table { + font-size: 0.55rem; + margin: 0; + border-collapse: collapse; +} + +.jupyter-wrapper table th, +.jupyter-wrapper table td { + padding: 0.3rem 0.6rem; + border: 1px solid var(--md-default-fg-color--lightest); +} + +.jupyter-wrapper table th { + background-color: var(--md-default-fg-color--lightest); + font-weight: 600; +} + +/* Images and plots */ +.jupyter-wrapper .jp-RenderedImage img, +.jupyter-wrapper .jp-RenderedImage svg { + max-width: 100%; + height: auto; + display: block; + margin: 0 auto; +} + +/* Dark mode adjustments */ +[data-md-color-scheme="slate"] .jupyter-wrapper .jp-CodeCell { + border-color: rgba(255, 255, 255, 0.1); +} + +[data-md-color-scheme="slate"] .jupyter-wrapper table th { + background-color: rgba(255, 255, 255, 0.05); +} + +[data-md-color-scheme="slate"] .jupyter-wrapper table th, +[data-md-color-scheme="slate"] .jupyter-wrapper table td { + border-color: rgba(255, 255, 255, 0.1); +} + +/* Plotly charts - ensure proper sizing */ +.jupyter-wrapper .plotly-graph-div { margin: 0 auto; - padding-left: 1.2rem; - padding-right: 1.2rem; +} + +/* Error output styling */ +.jupyter-wrapper .jp-RenderedText[data-mime-type="application/vnd.jupyter.stderr"] { + background-color: rgba(255, 0, 0, 0.05); + color: #c7254e; + padding: 0.5rem; + border-radius: 0.3rem; +} + +[data-md-color-scheme="slate"] .jupyter-wrapper .jp-RenderedText[data-mime-type="application/vnd.jupyter.stderr"] { + background-color: rgba(255, 0, 0, 0.15); + color: #ff6b6b; +} + +/* ============================================================================ + Footer Alignment Fix + ========================================================================= */ + +/* Hide the social media footer section */ +.md-footer-meta { + display: none; +} + +/* Footer navigation content matches page width */ +.md-footer .md-grid { + max-width: 1300px !important; + padding-left: 1.2rem !important; + padding-right: 1.2rem !important; } @media screen and (min-width: 76.25em) { - .md-footer-meta__inner, - .md-footer__inner { - padding-left: 1rem; - padding-right: 1rem; + .md-footer .md-grid { + padding-left: 1rem !important; } } @media screen and (min-width: 100em) { - .md-footer-meta__inner, - .md-footer__inner { - padding-left: 2rem; - padding-right: 2rem; + .md-footer .md-grid { + padding-left: 2rem !important; } } diff --git a/docs/user-guide/building-models/choosing-components.md b/docs/user-guide/building-models/choosing-components.md new file mode 100644 index 000000000..5f07e82dc --- /dev/null +++ b/docs/user-guide/building-models/choosing-components.md @@ -0,0 +1,381 @@ +# Choosing Components + +This guide helps you select the right flixOpt component for your modeling needs. + +## Decision Tree + +```mermaid +graph TD + A[What does this element do?] --> B{Brings energy INTO system?} + B -->|Yes| C[Source] + B -->|No| D{Takes energy OUT of system?} + D -->|Yes| E[Sink] + D -->|No| F{Converts energy type?} + F -->|Yes| G[LinearConverter] + F -->|No| H{Stores energy?} + H -->|Yes| I[Storage] + H -->|No| J{Transports between locations?} + J -->|Yes| K[Transmission] + J -->|No| L[Consider custom constraints] +``` + +## Component Comparison + +| Component | Purpose | Inputs | Outputs | Key Parameters | +|-----------|---------|--------|---------|----------------| +| **Source** | External supply | None | 1+ flows | `effects_per_flow_hour` | +| **Sink** | Demand/export | 1+ flows | None | `fixed_relative_profile` | +| **SourceAndSink** | Bidirectional exchange | 1+ flows | 1+ flows | Both input and output | +| **LinearConverter** | Transform energy | 1+ flows | 1+ flows | `conversion_factors` | +| **Storage** | Time-shift energy | charge flow | discharge flow | `capacity_in_flow_hours` | +| **Transmission** | Transport energy | in1, in2 | out1, out2 | `relative_losses` | + +## Detailed Component Guide + +### Source + +**Use when:** Purchasing or importing energy/material from outside your system boundary. + +```python +fx.Source( + 'GridElectricity', + outputs=[fx.Flow('Elec', bus='Electricity', size=1000, effects_per_flow_hour=0.25)] +) +``` + +**Typical applications:** +- Grid electricity connection +- Natural gas supply +- Raw material supply +- Fuel delivery + +**Key parameters:** + +| Parameter | Purpose | +|-----------|---------| +| `outputs` | List of flows leaving this source | +| `effects_per_flow_hour` | Cost/emissions per unit | +| `invest_parameters` | For optimizing connection capacity | + +--- + +### Sink + +**Use when:** Energy/material leaves your system (demand, export, waste). + +```python +# Fixed demand (must be met) +fx.Sink( + 'Building', + inputs=[fx.Flow('Heat', bus='Heat', size=1, fixed_relative_profile=demand)] +) + +# Optional export (can sell if profitable) +fx.Sink( + 'Export', + inputs=[fx.Flow('Elec', bus='Electricity', size=100, effects_per_flow_hour=-0.15)] +) +``` + +**Typical applications:** +- Heat/electricity demand +- Product output +- Grid export +- Waste disposal + +**Key parameters:** + +| Parameter | Purpose | +|-----------|---------| +| `inputs` | List of flows entering this sink | +| `fixed_relative_profile` | Demand profile (on flow) | +| `effects_per_flow_hour` | Negative = revenue | + +--- + +### SourceAndSink + +**Use when:** Bidirectional exchange at a single point (buy AND sell from same connection). + +```python +fx.SourceAndSink( + 'GridConnection', + inputs=[fx.Flow('import', bus='Electricity', size=500, effects_per_flow_hour=0.25)], + outputs=[fx.Flow('export', bus='Electricity', size=500, effects_per_flow_hour=-0.15)], + prevent_simultaneous_flow_rates=True, # Can't buy and sell at same time +) +``` + +**Typical applications:** +- Electricity grid (buy/sell) +- Gas grid with injection capability +- Material exchange with warehouse + +--- + +### LinearConverter + +**Use when:** Transforming one energy type to another with a linear relationship. + +```python +# Single input, single output +fx.LinearConverter( + 'Boiler', + inputs=[fx.Flow('Gas', bus='Gas', size=500)], + outputs=[fx.Flow('Heat', bus='Heat', size=450)], + conversion_factors=[{'Gas': 1, 'Heat': 0.9}], +) + +# Multiple outputs (CHP) +fx.LinearConverter( + 'CHP', + inputs=[fx.Flow('Gas', bus='Gas', size=300)], + outputs=[ + fx.Flow('Elec', bus='Electricity', size=100), + fx.Flow('Heat', bus='Heat', size=150), + ], + conversion_factors=[{'Gas': 1, 'Elec': 0.35, 'Heat': 0.50}], +) + +# Multiple inputs +fx.LinearConverter( + 'CoFiringBoiler', + inputs=[ + fx.Flow('Gas', bus='Gas', size=200), + fx.Flow('Biomass', bus='Biomass', size=100), + ], + outputs=[fx.Flow('Heat', bus='Heat', size=270)], + conversion_factors=[{'Gas': 1, 'Biomass': 1, 'Heat': 0.9}], +) +``` + +**Typical applications:** +- Boilers (fuel → heat) +- Heat pumps (electricity → heat) +- Chillers (electricity → cooling) +- Turbines (fuel → electricity) +- CHPs (fuel → electricity + heat) +- Electrolyzers (electricity → hydrogen) + +**Key parameters:** + +| Parameter | Purpose | +|-----------|---------| +| `conversion_factors` | Efficiency relationship | +| `piecewise_conversion` | Non-linear efficiency curve | +| `status_parameters` | On/off behavior, startup costs | + +#### Pre-built Converters + +flixOpt includes ready-to-use converters in `flixopt.linear_converters`: + +| Class | Description | Key Parameters | +|-------|-------------|----------------| +| `Boiler` | Fuel → Heat | `thermal_efficiency` | +| `HeatPump` | Electricity → Heat | `cop` | +| `HeatPumpWithSource` | Elec + Ambient → Heat | `cop`, source flow | +| `CHP` | Fuel → Elec + Heat | `electrical_efficiency`, `thermal_efficiency` | +| `Chiller` | Electricity → Cooling | `cop` | + +```python +from flixopt.linear_converters import Boiler, HeatPump + +boiler = Boiler( + 'GasBoiler', + thermal_efficiency=0.92, + fuel_flow=fx.Flow('gas', bus='Gas', size=500, effects_per_flow_hour=0.05), + thermal_flow=fx.Flow('heat', bus='Heat', size=460), +) +``` + +--- + +### Storage + +**Use when:** Storing energy for later use. + +```python +fx.Storage( + 'Battery', + charging=fx.Flow('charge', bus='Electricity', size=100), + discharging=fx.Flow('discharge', bus='Electricity', size=100), + capacity_in_flow_hours=4, # 4 hours at full rate = 400 kWh + eta_charge=0.95, + eta_discharge=0.95, + relative_loss_per_hour=0.001, + initial_charge_state=0.5, +) +``` + +**Typical applications:** +- Batteries (electrical) +- Thermal tanks (heat/cold) +- Hydrogen storage +- Material buffers + +**Key parameters:** + +| Parameter | Purpose | +|-----------|---------| +| `charging`, `discharging` | Flows for in/out | +| `capacity_in_flow_hours` | Size (or use `InvestParameters`) | +| `eta_charge`, `eta_discharge` | Round-trip efficiency | +| `relative_loss_per_hour` | Standing losses | +| `initial_charge_state` | Starting level (0-1 or `'equals_final'`) | + +--- + +### Transmission + +**Use when:** Transporting energy between different locations. + +```python +# Unidirectional +fx.Transmission( + 'HeatPipe', + in1=fx.Flow('from_A', bus='Heat_A', size=200), + out1=fx.Flow('to_B', bus='Heat_B', size=200), + relative_losses=0.05, +) + +# Bidirectional +fx.Transmission( + 'PowerLine', + in1=fx.Flow('A_to_B', bus='Elec_A', size=100), + out1=fx.Flow('at_B', bus='Elec_B', size=100), + in2=fx.Flow('B_to_A', bus='Elec_B', size=100), + out2=fx.Flow('at_A', bus='Elec_A', size=100), + relative_losses=0.03, + prevent_simultaneous_flows_in_both_directions=True, +) +``` + +**Typical applications:** +- District heating pipes +- Power transmission lines +- Gas pipelines +- Conveyor belts + +**Key parameters:** + +| Parameter | Purpose | +|-----------|---------| +| `in1`, `out1` | Primary direction flows | +| `in2`, `out2` | Reverse direction (optional) | +| `relative_losses` | Proportional losses | +| `absolute_losses` | Fixed losses when active | +| `balanced` | Same capacity both ways | + +## Feature Combinations + +### Investment Optimization + +Add `InvestParameters` to flows to let the optimizer choose sizes: + +```python +fx.Flow( + 'Heat', + bus='Heat', + invest_parameters=fx.InvestParameters( + effects_of_investment_per_size={'costs': 100}, # €/kW + minimum_size=0, + maximum_size=1000, + ) +) +``` + +Works with: Source, Sink, LinearConverter, Storage, Transmission + +### Operational Constraints + +Add `StatusParameters` to flows for on/off behavior: + +```python +fx.Flow( + 'Heat', + bus='Heat', + size=500, + status_parameters=fx.StatusParameters( + effects_per_switch_on={'costs': 50}, # Startup cost + on_hours_min=2, # Minimum runtime + off_hours_min=1, # Minimum downtime + ) +) +``` + +Works with: All components with flows + +### Non-Linear Efficiency + +Use `PiecewiseConversion` for load-dependent efficiency: + +```python +fx.LinearConverter( + 'GasEngine', + inputs=[fx.Flow('Fuel', bus='Gas')], + outputs=[fx.Flow('Elec', bus='Electricity')], + piecewise_conversion=fx.PiecewiseConversion({ + 'Fuel': fx.Piecewise([fx.Piece(100, 200), fx.Piece(200, 300)]), + 'Elec': fx.Piecewise([fx.Piece(35, 80), fx.Piece(80, 110)]), + }), +) +``` + +Works with: LinearConverter + +## Common Modeling Patterns + +### Pattern: Parallel Redundant Units + +Model N identical units that can operate independently: + +```python +for i in range(3): + flow_system.add_elements( + fx.LinearConverter( + f'Boiler_{i}', + inputs=[fx.Flow('Gas', bus='Gas', size=100)], + outputs=[fx.Flow('Heat', bus='Heat', size=90)], + conversion_factors=[{'Gas': 1, 'Heat': 0.9}], + ) + ) +``` + +### Pattern: Heat Recovery + +Model waste heat recovery from one process to another: + +```python +# Process that generates waste heat +process = fx.LinearConverter( + 'Process', + inputs=[fx.Flow('Elec', bus='Electricity', size=100)], + outputs=[ + fx.Flow('Product', bus='Products', size=80), + fx.Flow('WasteHeat', bus='Heat', size=20), # Recovered heat + ], + conversion_factors=[{'Elec': 1, 'Product': 0.8, 'WasteHeat': 0.2}], +) +``` + +### Pattern: Fuel Switching + +Model a component that can use multiple fuels: + +```python +flex_boiler = fx.LinearConverter( + 'FlexBoiler', + inputs=[ + fx.Flow('Gas', bus='Gas', size=200, effects_per_flow_hour=0.05), + fx.Flow('Oil', bus='Oil', size=200, effects_per_flow_hour=0.08), + ], + outputs=[fx.Flow('Heat', bus='Heat', size=180)], + conversion_factors=[{'Gas': 1, 'Oil': 1, 'Heat': 0.9}], +) +``` + +## Next Steps + +- **[Building Models](index.md)** — Step-by-step modeling guide +- **[Examples](../../notebooks/index.md)** — Working code examples +- **[Mathematical Notation](../mathematical-notation/index.md)** — Constraint formulations diff --git a/docs/user-guide/building-models/index.md b/docs/user-guide/building-models/index.md new file mode 100644 index 000000000..11ff4081d --- /dev/null +++ b/docs/user-guide/building-models/index.md @@ -0,0 +1,378 @@ +# Building Models + +This guide walks you through constructing FlowSystem models step by step. By the end, you'll understand how to translate real-world energy systems into flixOpt models. + +## Overview + +Building a model follows a consistent pattern: + +```python +import pandas as pd +import flixopt as fx + +# 1. Define time horizon +timesteps = pd.date_range('2024-01-01', periods=24, freq='h') + +# 2. Create the FlowSystem +flow_system = fx.FlowSystem(timesteps) + +# 3. Add elements +flow_system.add_elements( + # Buses, Components, Effects... +) + +# 4. Optimize +flow_system.optimize(fx.solvers.HighsSolver()) +``` + +## Step 1: Define Your Time Horizon + +Every FlowSystem needs a time definition. Use pandas DatetimeIndex: + +```python +# Hourly data for one week +timesteps = pd.date_range('2024-01-01', periods=168, freq='h') + +# 15-minute intervals for one day +timesteps = pd.date_range('2024-01-01', periods=96, freq='15min') + +# Custom timestamps (e.g., from your data) +timesteps = pd.DatetimeIndex(your_data.index) +``` + +!!! tip "Time Resolution" + Higher resolution (more timesteps) gives more accurate results but increases computation time. Start with hourly data and refine if needed. + +## Step 2: Create Buses + +Buses are connection points where energy flows meet. Every bus enforces a balance: inputs = outputs. + +```python +# Basic buses +heat_bus = fx.Bus('Heat') +electricity_bus = fx.Bus('Electricity') + +# With carrier (enables automatic coloring in plots) +heat_bus = fx.Bus('Heat', carrier='heat') +gas_bus = fx.Bus('Gas', carrier='gas') +``` + +### When to Create a Bus + +| Scenario | Bus Needed? | +|----------|-------------| +| Multiple components share a resource | Yes | +| Need to track balance at a location | Yes | +| Component has external input (grid, fuel) | Often no - use `bus=None` | +| Component transforms A → B | Yes, one bus per carrier | + +### Bus Balance Modes + +By default, buses require exact balance. For systems with unavoidable imbalances: + +```python +# Allow small imbalances with penalty +heat_bus = fx.Bus( + 'Heat', + imbalance_penalty_per_flow_hour=1000, # High cost discourages imbalance +) +``` + +## Step 3: Add Components + +Components are the equipment in your system. Choose based on function: + +### Sources — External Inputs + +Use for **purchasing** energy or materials from outside: + +```python +# Grid electricity with time-varying price +grid = fx.Source( + 'Grid', + outputs=[fx.Flow('Elec', bus='Electricity', size=1000, effects_per_flow_hour=price_profile)] +) + +# Natural gas with fixed price +gas_supply = fx.Source( + 'GasSupply', + outputs=[fx.Flow('Gas', bus='Gas', size=500, effects_per_flow_hour=0.05)] +) +``` + +### Sinks — Demands + +Use for **consuming** energy or materials (demands, exports): + +```python +# Heat demand (must be met exactly) +building = fx.Sink( + 'Building', + inputs=[fx.Flow('Heat', bus='Heat', size=1, fixed_relative_profile=demand_profile)] +) + +# Optional export (can sell but not required) +export = fx.Sink( + 'Export', + inputs=[fx.Flow('Elec', bus='Electricity', size=100, effects_per_flow_hour=-0.15)] # Negative = revenue +) +``` + +### LinearConverter — Transformations + +Use for **converting** one form of energy to another: + +```python +# Gas boiler: Gas → Heat +boiler = fx.LinearConverter( + 'Boiler', + inputs=[fx.Flow('Gas', bus='Gas', size=500)], + outputs=[fx.Flow('Heat', bus='Heat', size=450)], + conversion_factors=[{'Gas': 1, 'Heat': 0.9}], # 90% efficiency +) + +# Heat pump: Electricity → Heat +heat_pump = fx.LinearConverter( + 'HeatPump', + inputs=[fx.Flow('Elec', bus='Electricity', size=100)], + outputs=[fx.Flow('Heat', bus='Heat', size=350)], + conversion_factors=[{'Elec': 1, 'Heat': 3.5}], # COP = 3.5 +) + +# CHP: Gas → Electricity + Heat (multiple outputs) +chp = fx.LinearConverter( + 'CHP', + inputs=[fx.Flow('Gas', bus='Gas', size=300)], + outputs=[ + fx.Flow('Elec', bus='Electricity', size=100), + fx.Flow('Heat', bus='Heat', size=150), + ], + conversion_factors=[{'Gas': 1, 'Elec': 0.35, 'Heat': 0.50}], +) +``` + +### Storage — Time-Shifting + +Use for **storing** energy or materials: + +```python +# Thermal storage +tank = fx.Storage( + 'ThermalTank', + charging=fx.Flow('charge', bus='Heat', size=200), + discharging=fx.Flow('discharge', bus='Heat', size=200), + capacity_in_flow_hours=10, # 10 hours at full charge/discharge rate + eta_charge=0.95, + eta_discharge=0.95, + relative_loss_per_hour=0.01, # 1% loss per hour + initial_charge_state=0.5, # Start 50% full +) +``` + +### Transmission — Transport Between Locations + +Use for **connecting** different locations: + +```python +# District heating pipe +pipe = fx.Transmission( + 'HeatPipe', + in1=fx.Flow('from_A', bus='Heat_A', size=200), + out1=fx.Flow('to_B', bus='Heat_B', size=200), + relative_losses=0.05, # 5% loss +) +``` + +## Step 4: Configure Effects + +Effects track metrics like costs, emissions, or energy use. One must be the objective: + +```python +# Operating costs (minimize this) +costs = fx.Effect( + 'costs', + '€', + 'Operating Costs', + is_standard=True, # Included by default in all effect allocations + is_objective=True, # This is what we minimize +) + +# CO2 emissions (track or constrain) +co2 = fx.Effect( + 'CO2', + 'kg', + 'CO2 Emissions', + maximum_temporal=1000, # Constraint: max 1000 kg total +) +``` + +### Linking Effects to Flows + +Effects are typically assigned per flow hour: + +```python +# Gas costs 0.05 €/kWh +fx.Flow('Gas', bus='Gas', size=500, effects_per_flow_hour={'costs': 0.05, 'CO2': 0.2}) + +# Shorthand when only one effect (the standard one) +fx.Flow('Gas', bus='Gas', size=500, effects_per_flow_hour=0.05) +``` + +## Step 5: Add Everything to FlowSystem + +Use `add_elements()` with all elements: + +```python +flow_system = fx.FlowSystem(timesteps) + +flow_system.add_elements( + # Buses + fx.Bus('Heat', carrier='heat'), + fx.Bus('Gas', carrier='gas'), + + # Effects + fx.Effect('costs', '€', is_standard=True, is_objective=True), + + # Components + fx.Source('GasGrid', outputs=[fx.Flow('Gas', bus='Gas', size=500, effects_per_flow_hour=0.05)]), + fx.LinearConverter( + 'Boiler', + inputs=[fx.Flow('Gas', bus='Gas', size=500)], + outputs=[fx.Flow('Heat', bus='Heat', size=450)], + conversion_factors=[{'Gas': 1, 'Heat': 0.9}], + ), + fx.Sink('Building', inputs=[fx.Flow('Heat', bus='Heat', size=1, fixed_relative_profile=demand)]), +) +``` + +## Common Patterns + +### Pattern 1: Simple Conversion System + +Gas → Boiler → Heat + +```python +flow_system.add_elements( + fx.Bus('Heat'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + fx.Source('Gas', outputs=[fx.Flow('gas', bus=None, size=500, effects_per_flow_hour=0.05)]), + fx.LinearConverter( + 'Boiler', + inputs=[fx.Flow('gas', bus=None, size=500)], # Inline source + outputs=[fx.Flow('heat', bus='Heat', size=450)], + conversion_factors=[{'gas': 1, 'heat': 0.9}], + ), + fx.Sink('Demand', inputs=[fx.Flow('heat', bus='Heat', size=1, fixed_relative_profile=demand)]), +) +``` + +### Pattern 2: Multiple Generation Options + +Choose between boiler, heat pump, or both: + +```python +flow_system.add_elements( + fx.Bus('Heat'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + + # Option 1: Gas boiler (cheap gas, moderate efficiency) + fx.LinearConverter('Boiler', ...), + + # Option 2: Heat pump (expensive electricity, high efficiency) + fx.LinearConverter('HeatPump', ...), + + # Demand + fx.Sink('Building', ...), +) +``` + +The optimizer chooses the cheapest mix at each timestep. + +### Pattern 3: System with Storage + +Add flexibility through storage: + +```python +flow_system.add_elements( + fx.Bus('Heat'), + fx.Effect('costs', '€', is_standard=True, is_objective=True), + + # Generation + fx.LinearConverter('Boiler', ...), + + # Storage (can shift load in time) + fx.Storage('Tank', ...), + + # Demand + fx.Sink('Building', ...), +) +``` + +## Component Selection Guide + +| I need to... | Use this component | +|-------------|-------------------| +| Buy/import energy | `Source` | +| Sell/export energy | `Sink` with negative effects | +| Meet a demand | `Sink` with `fixed_relative_profile` | +| Convert energy type | `LinearConverter` | +| Store energy | `Storage` | +| Transport between sites | `Transmission` | +| Model combined heat & power | `LinearConverter` with multiple outputs | + +For detailed component selection, see [Choosing Components](choosing-components.md). + +## Input Data Types + +flixOpt accepts various data formats for parameters: + +| Input Type | Example | Use Case | +|-----------|---------|----------| +| Scalar | `0.05` | Constant value | +| NumPy array | `np.array([...])` | Time-varying, matches timesteps | +| Pandas Series | `pd.Series([...], index=timesteps)` | Time-varying with labels | +| TimeSeriesData | `fx.TimeSeriesData(...)` | Advanced: aggregation metadata | + +```python +# All equivalent for a constant efficiency +efficiency = 0.9 +efficiency = np.full(len(timesteps), 0.9) +efficiency = pd.Series(0.9, index=timesteps) + +# Time-varying price +price = np.where(hour_of_day >= 8, 0.25, 0.10) +``` + +## Debugging Tips + +### Check Bus Balance + +If optimization fails with infeasibility: + +1. Ensure demands can be met by available generation +2. Check that flow sizes are large enough +3. Add `imbalance_penalty_per_flow_hour` to identify problematic buses + +### Verify Element Registration + +```python +# List all elements +print(flow_system.components.keys()) +print(flow_system.buses.keys()) +print(flow_system.effects.keys()) +``` + +### Inspect Model Before Solving + +```python +flow_system.build_model() +print(f"Variables: {len(flow_system.model.variables)}") +print(f"Constraints: {len(flow_system.model.constraints)}") +``` + +## Next Steps + +- **[Choosing Components](choosing-components.md)** — Decision tree for component selection +- **[Core Concepts](../core-concepts.md)** — Deeper understanding of fundamentals +- **[Examples](../../notebooks/index.md)** — Working code examples +- **[Mathematical Notation](../mathematical-notation/index.md)** — Detailed constraint formulations diff --git a/docs/user-guide/core-concepts.md b/docs/user-guide/core-concepts.md index f165f1e4e..47e4a882c 100644 --- a/docs/user-guide/core-concepts.md +++ b/docs/user-guide/core-concepts.md @@ -1,155 +1,267 @@ -# Core concepts of flixopt +# Core Concepts -FlixOpt is built around a set of core concepts that work together to represent and optimize **any system involving flows and conversions** - whether that's energy systems, material flows, supply chains, water networks, or production processes. +This page introduces the fundamental concepts of flixOpt through practical scenarios. Understanding these concepts will help you model any system involving flows and conversions. -This page provides a high-level overview of these concepts and how they interact. +## The Big Picture -## Main building blocks +Imagine you're managing a district heating system. You have: -### FlowSystem +- A **gas boiler** that burns natural gas to produce heat +- A **heat pump** that uses electricity to extract heat from the environment +- A **thermal storage tank** to buffer heat production and demand +- **Buildings** that need heat throughout the day +- Access to the **gas grid** and **electricity grid** -The [`FlowSystem`][flixopt.flow_system.FlowSystem] is the central organizing unit in FlixOpt. -Every FlixOpt model starts with creating a FlowSystem. It: +Your goal: **minimize total operating costs** while meeting all heat demands. -- Defines the timesteps for the optimization -- Contains and connects [components](#components), [buses](#buses), and [flows](#flows) -- Manages the [effects](#effects) (objectives and constraints) +This is exactly the kind of problem flixOpt solves. Let's see how each concept maps to this scenario. -FlowSystem provides two ways to access elements: +## Buses: Where Things Connect -- **Dict-like interface**: Access any element by label: `flow_system['Boiler']`, `'Boiler' in flow_system`, `flow_system.keys()` -- **Direct containers**: Access type-specific containers: `flow_system.components`, `flow_system.buses`, `flow_system.effects`, `flow_system.flows` +A [`Bus`][flixopt.elements.Bus] is a connection point where energy or material flows meet. Think of it as a junction or hub. -Element labels must be unique across all types. See the [`FlowSystem` API reference][flixopt.flow_system.FlowSystem] for detailed examples and usage patterns. +!!! example "In our heating system" + - **Heat Bus** — where heat from the boiler, heat pump, and storage meets the building demand + - **Gas Bus** — connection to the gas grid + - **Electricity Bus** — connection to the power grid -### Flows +**The key rule:** At every bus, **inputs must equal outputs** at each timestep. -[`Flow`][flixopt.elements.Flow] objects represent the movement of energy or material between a [Bus](#buses) and a [Component](#components) in a predefined direction. +$$\sum inputs = \sum outputs$$ -- Have a `size` which, generally speaking, defines how much energy or material can be moved. Usually measured in MW, kW, m³/h, etc. -- Have a `flow_rate`, which defines how fast energy or material is transported. Usually measured in MW, kW, m³/h, etc. -- Have constraints to limit the flow-rate (min/max, total flow hours, on/off etc.) -- Can have fixed profiles (for demands or renewable generation) -- Can have [Effects](#effects) associated by their use (costs, emissions, labour, ...) +This balance constraint is what makes your model physically meaningful — energy can't appear or disappear. -#### Flow Hours -While the **Flow Rate** defines the rate in which energy or material is transported, the **Flow Hours** define the amount of energy or material that is transported. -Its defined by the flow_rate times the duration of the timestep in hours. +### Carriers -Examples: +Buses can be assigned a **carrier** — a type of energy or material (electricity, heat, gas, etc.). Carriers enable automatic coloring in plots and help organize your system semantically: -| Flow Rate | Timestep | Flow Hours | -|-----------|----------|------------| -| 10 (MW) | 1 hour | 10 (MWh) | -| 10 (MW) | 6 minutes | 0.1 (MWh) | -| 10 (kg/h) | 1 hour | 10 (kg) | +```python +heat_bus = fx.Bus('HeatNetwork', carrier='heat') # Uses default heat color +elec_bus = fx.Bus('Grid', carrier='electricity') +``` -### Buses +See [Color Management](results-plotting.md#color-management) for details. -[`Bus`][flixopt.elements.Bus] objects represent nodes or connection points in a FlowSystem. They: +## Flows: What Moves Between Elements -- Balance incoming and outgoing flows -- Can represent physical networks like heat, electricity, or gas -- Handle infeasible balances gently by allowing the balance to be closed in return for a big Penalty (optional) +A [`Flow`][flixopt.elements.Flow] represents the movement of energy or material. Every flow connects a component to a bus, with a defined direction. -### Components +!!! example "In our heating system" + - Heat flowing **from** the boiler **to** the Heat Bus + - Gas flowing **from** the Gas Bus **to** the boiler + - Heat flowing **from** the Heat Bus **to** the buildings -[`Component`][flixopt.elements.Component] objects usually represent physical entities in your system that interact with [`Flows`][flixopt.elements.Flow]. The generic component types work across all domains: +Flows have: -- [`LinearConverters`][flixopt.components.LinearConverter] - Converts input flows to output flows with (piecewise) linear relationships - - *Energy: boilers, heat pumps, turbines* - - *Manufacturing: assembly lines, processing equipment* - - *Chemistry: reactors, separators* -- [`Storages`][flixopt.components.Storage] - Stores energy or material over time - - *Energy: batteries, thermal storage, gas storage* - - *Logistics: warehouses, buffer inventory* - - *Water: reservoirs, tanks* -- [`Sources`][flixopt.components.Source] / [`Sinks`][flixopt.components.Sink] / [`SourceAndSinks`][flixopt.components.SourceAndSink] - Produce or consume flows - - *Energy: demands, renewable generation* - - *Manufacturing: raw material supply, product demand* - - *Supply chain: suppliers, customers* -- [`Transmissions`][flixopt.components.Transmission] - Moves flows between locations with possible losses - - *Energy: pipelines, power lines* - - *Logistics: transport routes* - - *Water: distribution networks* +- A **size** (capacity) — *"This boiler can deliver up to 500 kW"* +- A **flow rate** — *"Right now it's running at 300 kW"* -**Pre-built specialized components** for energy systems include [`Boilers`][flixopt.linear_converters.Boiler], [`HeatPumps`][flixopt.linear_converters.HeatPump], [`CHPs`][flixopt.linear_converters.CHP], etc. These can serve as blueprints for custom domain-specific components. +## Components: The Equipment -### Effects +[`Components`][flixopt.elements.Component] are the physical (or logical) elements that transform, store, or transfer flows. -[`Effect`][flixopt.effects.Effect] objects represent impacts or metrics related to your system. While commonly used to allocate costs, they're completely flexible: +### Converters — Transform One Thing Into Another -**Energy systems:** -- Costs (investment, operation) -- Emissions (CO₂, NOx, etc.) -- Primary energy consumption +A [`LinearConverter`][flixopt.components.LinearConverter] takes inputs and produces outputs with a defined efficiency. -**Other domains:** -- Production time, labor hours (manufacturing) -- Water consumption, wastewater (process industries) -- Transport distance, vehicle utilization (logistics) -- Space consumption -- Any custom metric relevant to your domain +!!! example "In our heating system" + - **Gas Boiler**: Gas → Heat (η = 90%) + - **Heat Pump**: Electricity → Heat (COP = 3.5) -These can be freely defined and crosslink to each other (`CO₂` ──[specific CO₂-costs]─→ `Costs`). -One effect is designated as the **optimization objective** (typically Costs), while others can be constrained. -This approach allows for multi-criteria optimization using both: +The conversion relationship: - - **Weighted Sum Method**: Optimize a theoretical Effect which other Effects crosslink to - - **ε-constraint method**: Constrain effects to specific limits +$$output = \eta \cdot input$$ -### Optimization +### Storages — Save for Later -A [`FlowSystem`][flixopt.flow_system.FlowSystem] can be converted to a Model and optimized by creating an [`Optimization`][flixopt.optimization.Optimization] from it. +A [`Storage`][flixopt.components.Storage] accumulates and releases energy or material over time. -FlixOpt offers different optimization modes: +!!! example "In our heating system" + - **Thermal Tank**: Store excess heat during cheap hours, use it during expensive hours -- [`Optimization`][flixopt.optimization.Optimization] - Solves the entire problem at once -- [`SegmentedOptimization`][flixopt.optimization.SegmentedOptimization] - Solves the problem in segments (with optional overlap), improving performance for large problems -- [`ClusteredOptimization`][flixopt.optimization.ClusteredOptimization] - Uses typical periods to reduce computational requirements +The storage tracks its state over time: -### Results +$$charge(t+1) = charge(t) + charging - discharging$$ -The results of an optimization are stored in a [`Results`][flixopt.results.Results] object. -This object contains the solutions of the optimization as well as all information about the [`Optimization`][flixopt.optimization.Optimization] and the [`FlowSystem`][flixopt.flow_system.FlowSystem] it was created from. -The solution is stored as an `xarray.Dataset`, but can be accessed through their associated Component, Bus or Effect. +### Sources & Sinks — System Boundaries -This [`Results`][flixopt.results.Results] object can be saved to file and reloaded from file, allowing you to analyze the results anytime after the solve. +[`Sources`][flixopt.components.Source] and [`Sinks`][flixopt.components.Sink] connect your system to the outside world. -## How These Concepts Work Together +!!! example "In our heating system" + - **Gas Source**: Buy gas from the grid at market prices + - **Electricity Source**: Buy power at time-varying prices + - **Heat Sink**: The building demand that must be met -The process of working with FlixOpt can be divided into 3 steps: +## Effects: What You're Tracking -1. Create a [`FlowSystem`][flixopt.flow_system.FlowSystem], containing all the elements and data of your system - - Define the time horizon of your system (and optionally your periods and scenarios, see [Dimensions](mathematical-notation/dimensions.md))) - - Add [`Effects`][flixopt.effects.Effect] to represent costs, emissions, etc. - - Add [`Buses`][flixopt.elements.Bus] as connection points in your system and [`Sinks`][flixopt.components.Sink] & [`Sources`][flixopt.components.Source] as connections to the outer world (markets, power grid, ...) - - Add [`Components`][flixopt.components] like [`Boilers`][flixopt.linear_converters.Boiler], [`HeatPumps`][flixopt.linear_converters.HeatPump], [`CHPs`][flixopt.linear_converters.CHP], etc. - - Add - - [`FlowSystems`][flixopt.flow_system.FlowSystem] can also be loaded from a netCDF file* -2. Translate the model to a mathematical optimization problem - - Create an [`Optimization`][flixopt.optimization.Optimization] from your FlowSystem and choose a Solver - - ...The Optimization is translated internally to a mathematical optimization problem... - - ...and solved by the chosen solver. -3. Analyze the results - - The results are stored in a [`Results`][flixopt.results.Results] object - - This object can be saved to file and reloaded from file, retaining all information about the optimization - - As it contains the used [`FlowSystem`][flixopt.flow_system.FlowSystem], it fully documents all assumptions taken to create the results. +An [`Effect`][flixopt.effects.Effect] represents any metric you want to track or optimize. One effect is your **objective** (what you minimize or maximize), others can be **constraints**. + +!!! example "In our heating system" + - **Costs** (objective) — minimize total operating costs + - **CO₂ Emissions** (constraint) — stay below 1000 tonnes/year + - **Gas Consumption** (tracking) — report total gas used + +Effects can be linked: *"Each kg of CO₂ costs €80 in emissions trading"* — this creates a connection from the CO₂ effect to the Costs effect. + +## FlowSystem: Putting It All Together + +The [`FlowSystem`][flixopt.flow_system.FlowSystem] is your complete model. It contains all buses, components, flows, and effects, plus the **time definition** for your optimization. + +```python +import flixopt as fx + +# Define timesteps (e.g., hourly for one week) +timesteps = pd.date_range('2024-01-01', periods=168, freq='h') + +# Create the system +flow_system = fx.FlowSystem(timesteps) + +# Add elements +flow_system.add_elements(heat_bus, gas_bus, electricity_bus) +flow_system.add_elements(boiler, heat_pump, storage) +flow_system.add_elements(costs_effect, co2_effect) +``` + +## The Workflow: Model → Optimize → Analyze + +Working with flixOpt follows three steps: + +```mermaid +graph LR + A[1. Build FlowSystem] --> B[2. Run Optimization] + B --> C[3. Analyze Results] +``` + +### 1. Build Your Model + +Define your system structure, parameters, and time series data. + +### 2. Run the Optimization + +Optimize your FlowSystem with a solver: + +```python +flow_system.optimize(fx.solvers.HighsSolver()) +``` + +### 3. Analyze Results + +Access solution data directly from the FlowSystem: + +```python +# Access component solutions +boiler = flow_system.components['Boiler'] +print(boiler.solution) + +# Get total costs +total_costs = flow_system.solution['costs|total'] + +# Use statistics for aggregated data +print(flow_system.statistics.flow_hours) + +# Plot results +flow_system.statistics.plot.balance('HeatBus') +```
![FlixOpt Conceptual Usage](../images/architecture_flixOpt.png)
Conceptual Usage and IO operations of FlixOpt
-## Advanced Usage -As flixopt is build on [linopy](https://github.com/PyPSA/linopy), any model created with FlixOpt can be extended or modified using the great [linopy API](https://linopy.readthedocs.io/en/latest/api.html). -This allows to adjust your model to very specific requirements without loosing the convenience of FlixOpt. - - - - - - - - - +## Quick Reference + +| Concept | What It Represents | Real-World Example | +|---------|-------------------|-------------------| +| **Bus** | Connection point | Heat network, electrical grid | +| **Flow** | Energy/material movement | Heat delivery, gas consumption | +| **LinearConverter** | Transformation equipment | Boiler, heat pump, turbine | +| **Storage** | Time-shifting capability | Battery, thermal tank, warehouse | +| **Source/Sink** | System boundary | Grid connection, demand | +| **Effect** | Metric to track/optimize | Costs, emissions, energy use | +| **FlowSystem** | Complete model | Your entire system | + +## FlowSystem API at a Glance + +The `FlowSystem` is the central object in flixOpt. After building your model, all operations are accessed through the FlowSystem and its **accessors**: + +```python +flow_system = fx.FlowSystem(timesteps) +flow_system.add_elements(...) + +# Optimize +flow_system.optimize(solver) + +# Access results +flow_system.solution # Raw xarray Dataset +flow_system.statistics.flow_hours # Aggregated statistics +flow_system.statistics.plot.balance() # Visualization + +# Transform (returns new FlowSystem) +fs_subset = flow_system.transform.sel(time=slice(...)) + +# Inspect structure +flow_system.topology.plot() +``` + +### Accessor Overview + +| Accessor | Purpose | Key Methods | +|----------|---------|-------------| +| **`solution`** | Raw optimization results | xarray Dataset with all variables | +| **`statistics`** | Aggregated data | `flow_rates`, `flow_hours`, `sizes`, `charge_states`, `total_effects` | +| **`statistics.plot`** | Visualization | `balance()`, `heatmap()`, `sankey()`, `effects()`, `storage()` | +| **`transform`** | Create modified copies | `sel()`, `isel()`, `resample()`, `cluster()` | +| **`topology`** | Network structure | `plot()`, `start_app()`, `infos()` | + +### Element Access + +Access elements directly from the FlowSystem: + +```python +# Access by label +flow_system.components['Boiler'] # Get a component +flow_system.buses['Heat'] # Get a bus +flow_system.flows['Boiler(Q_th)'] # Get a flow +flow_system.effects['costs'] # Get an effect + +# Element-specific solutions +flow_system.components['Boiler'].solution +flow_system.flows['Boiler(Q_th)'].solution +``` + +## Beyond Energy Systems + +While our example used a heating system, flixOpt works for any flow-based optimization: + +| Domain | Buses | Components | Effects | +|--------|-------|------------|---------| +| **District Heating** | Heat, Gas, Electricity | Boilers, CHPs, Heat Pumps | Costs, CO₂ | +| **Manufacturing** | Raw Materials, Products | Machines, Assembly Lines | Costs, Time, Labor | +| **Supply Chain** | Warehouses, Locations | Transport, Storage | Costs, Distance | +| **Water Networks** | Reservoirs, Treatment | Pumps, Pipes | Costs, Energy | + +## Next Steps + +- **[Building Models](building-models/index.md)** — Step-by-step guide to constructing models +- **[Examples](../notebooks/index.md)** — Working code for common scenarios +- **[Mathematical Notation](mathematical-notation/index.md)** — Detailed constraint formulations + +## Advanced: Extending with linopy + +flixOpt is built on [linopy](https://github.com/PyPSA/linopy). You can access and extend the underlying optimization model for custom constraints: + +```python +# Build the model (without solving) +flow_system.build_model() + +# Access the linopy model +model = flow_system.model + +# Add custom constraints using linopy API +model.add_constraints(...) + +# Then solve +flow_system.solve(fx.solvers.HighsSolver()) +``` + +This allows advanced users to add domain-specific constraints while keeping flixOpt's convenience for standard modeling. diff --git a/docs/user-guide/faq.md b/docs/user-guide/faq.md new file mode 100644 index 000000000..63994180d --- /dev/null +++ b/docs/user-guide/faq.md @@ -0,0 +1,34 @@ +# Frequently Asked Questions + +## What is flixOpt? + +flixOpt is a Python framework for modeling and optimizing energy and material flow systems. It handles both operational optimization (dispatch) and investment optimization (capacity expansion). + +## Which solvers does flixOpt support? + +- **HiGHS** (default, included) +- **Gurobi** (commercial, academic licenses available) + +## How do I install flixOpt? + +```bash +pip install flixopt +``` + +For full features: +```bash +pip install "flixopt[full]" +``` + +## Do I need to install a solver separately? + +No. HiGHS is included and works out of the box. + +## Can I add custom constraints? + +Yes. You can add custom constraints directly to the optimization model using linopy. + +## Where can I get help? + +- Check [Troubleshooting](troubleshooting.md) +- Open an [issue on GitHub](https://github.com/flixOpt/flixopt/issues) diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md new file mode 100644 index 000000000..7295eedda --- /dev/null +++ b/docs/user-guide/index.md @@ -0,0 +1,83 @@ +# User Guide + +Welcome to the flixOpt User Guide! This guide will help you master energy and material flow optimization with flixOpt. + +## What is flixOpt? + +flixOpt is a comprehensive framework for modeling and optimizing energy and material flow systems. It supports: + +- **Operational Optimization** - Dispatch optimization with fixed capacities +- **Investment Optimization** - Capacity expansion planning with binary or continuous sizing +- **Multi-Period Planning** - Sequential investment decisions across multiple periods +- **Scenario Analysis** - Stochastic modeling with weighted scenarios + +## Key Features + +
+ +- :material-puzzle: **Flexible Components** + + --- + + Flow, Bus, Storage, LinearConverter - build any system topology + +- :material-cog: **Advanced Modeling** + + --- + + Investment decisions, On/Off states, Piecewise linearization + +- :material-calculator: **Multiple Solvers** + + --- + + HiGHS (default), Gurobi, CPLEX - choose what fits your needs + +- :material-chart-line: **Built-in Analysis** + + --- + + Plotting, export, and result exploration tools + +
+ +## Learning Path + +This guide follows a sequential learning path: + +| Step | Section | What You'll Learn | +|------|---------|-------------------| +| 1 | [Core Concepts](core-concepts.md) | Fundamental building blocks: FlowSystem, Bus, Flow, Components, Effects | +| 2 | [Building Models](building-models/index.md) | How to construct models step by step | +| 3 | [Running Optimizations](optimization/index.md) | Solver configuration and execution | +| 4 | [Analyzing Results](results/index.md) | Extracting and visualizing outcomes | +| 5 | [Mathematical Notation](mathematical-notation/index.md) | Deep dive into the math behind each element | +| 6 | [Recipes](recipes/index.md) | Common patterns and solutions | + +## Quick Links + +### Getting Started + +- [Quick Start](../home/quick-start.md) - Build your first model in 5 minutes +- [Minimal Example](../notebooks/01-quickstart.ipynb) - Simplest possible model +- [Core Concepts](core-concepts.md) - Understand the fundamentals + +### Reference + +- [Mathematical Notation](mathematical-notation/index.md) - Detailed specifications +- [API Reference](../api-reference/) - Complete class documentation +- [Examples](../notebooks/index.md) - Working code to learn from + +### Help + +- [FAQ](faq.md) - Frequently asked questions +- [Troubleshooting](troubleshooting.md) - Common issues and solutions +- [Community](support.md) - Get help from the community + +## Use Cases + +flixOpt handles any flow-based optimization problem: + +**Energy Systems**: Power dispatch, CHP optimization, renewable integration, battery storage, district heating + +**Industrial Applications**: Process optimization, multi-commodity networks, supply chains, resource allocation diff --git a/docs/user-guide/mathematical-notation/dimensions.md b/docs/user-guide/mathematical-notation/dimensions.md deleted file mode 100644 index e10ef5ffd..000000000 --- a/docs/user-guide/mathematical-notation/dimensions.md +++ /dev/null @@ -1,316 +0,0 @@ -# Dimensions - -FlixOpt's `FlowSystem` supports multiple dimensions for modeling optimization problems. Understanding these dimensions is crucial for interpreting the mathematical formulations presented in this documentation. - -## The Three Dimensions - -FlixOpt models can have up to three dimensions: - -1. **Time (`time`)** - **MANDATORY** - - Represents the temporal evolution of the system - - Defined via `pd.DatetimeIndex` - - Must contain at least 2 timesteps - - All optimization variables and constraints evolve over time -2. **Period (`period`)** - **OPTIONAL** - - Represents independent planning periods (e.g., years 2020, 2021, 2022) - - Defined via `pd.Index` with integer values - - Used for multi-period optimization such as investment planning across years - - Each period is independent with its own time series -3. **Scenario (`scenario`)** - **OPTIONAL** - - Represents alternative futures or uncertainty realizations (e.g., "Base Case", "High Demand") - - Defined via `pd.Index` with any labels - - Scenarios within the same period share the same time dimension - - Used for stochastic optimization or scenario comparison - ---- - -## Dimensional Structure - -**Coordinate System:** - -```python -FlowSystemDimensions = Literal['time', 'period', 'scenario'] - -coords = { - 'time': pd.DatetimeIndex, # Always present - 'period': pd.Index | None, # Optional - 'scenario': pd.Index | None # Optional -} -``` - -**Example:** -```python -import pandas as pd -import numpy as np -import flixopt as fx - -timesteps = pd.date_range('2020-01-01', periods=24, freq='h') -scenarios = pd.Index(['Base Case', 'High Demand']) -periods = pd.Index([2020, 2021, 2022]) - -flow_system = fx.FlowSystem( - timesteps=timesteps, - periods=periods, - scenarios=scenarios, - scenario_weights=np.array([0.5, 0.5]) # Scenario weights -) -``` - -This creates a system with: -- 24 time steps per scenario per period -- 2 scenarios with equal weights (0.5 each) -- 3 periods (years) -- **Total decision space:** 24 × 2 × 3 = 144 time-scenario-period combinations - ---- - -## Independence of Formulations - -**All mathematical formulations in this documentation are independent of whether periods or scenarios are present.** - -The equations shown throughout this documentation (for [Flow](elements/Flow.md), [Storage](elements/Storage.md), [Bus](elements/Bus.md), etc.) are written with only the time index $\text{t}_i$. When periods and/or scenarios are added, **the same equations apply** - they are simply expanded to additional dimensions. - -### How Dimensions Expand Formulations - -**Flow rate bounds** (from [Flow](elements/Flow.md)): - -$$ -\text{P} \cdot \text{p}^{\text{L}}_{\text{rel}}(\text{t}_{i}) \leq p(\text{t}_{i}) \leq \text{P} \cdot \text{p}^{\text{U}}_{\text{rel}}(\text{t}_{i}) -$$ - -This equation remains valid regardless of dimensions: - -| Dimensions Present | Variable Indexing | Interpretation | -|-------------------|-------------------|----------------| -| Time only | $p(\text{t}_i)$ | Flow rate at time $\text{t}_i$ | -| Time + Scenario | $p(\text{t}_i, s)$ | Flow rate at time $\text{t}_i$ in scenario $s$ | -| Time + Period | $p(\text{t}_i, y)$ | Flow rate at time $\text{t}_i$ in period $y$ | -| Time + Period + Scenario | $p(\text{t}_i, y, s)$ | Flow rate at time $\text{t}_i$ in period $y$, scenario $s$ | - -**The mathematical relationship remains identical** - only the indexing expands. - ---- - -## Independence Between Scenarios and Periods - -**There is no interconnection between scenarios and periods, except for shared investment decisions within a period.** - -### Scenario Independence - -Scenarios within a period are **operationally independent**: - -- Each scenario has its own operational variables: $p(\text{t}_i, s_1)$ and $p(\text{t}_i, s_2)$ are independent -- Scenarios cannot exchange energy, information, or resources -- Storage states are separate: $c(\text{t}_i, s_1) \neq c(\text{t}_i, s_2)$ -- Binary states (on/off) are independent: $s(\text{t}_i, s_1)$ vs $s(\text{t}_i, s_2)$ - -Scenarios are connected **only through the objective function** via weights: - -$$ -\min \quad \sum_{s \in \mathcal{S}} w_s \cdot \text{Objective}_s -$$ - -Where: -- $\mathcal{S}$ is the set of scenarios -- $w_s$ is the weight for scenario $s$ -- The optimizer balances performance across scenarios according to their weights -- **Both the objective effect and Penalty effect are weighted by $w_s$** (see [Penalty weighting](effects-penalty-objective.md#penalty)) - -### Period Independence - -Periods are **completely independent** optimization problems: - -- Each period has separate operational variables -- Each period has separate investment decisions -- No temporal coupling between periods (e.g., storage state at end of period $y$ does not affect period $y+1$) -- Periods cannot exchange resources or information - -Periods are connected **only through weighted aggregation** in the objective: - -$$ -\min \quad \sum_{y \in \mathcal{Y}} w_y \cdot \text{Objective}_y -$$ - -Where **both the objective effect and Penalty effect are weighted by $w_y$** (see [Penalty weighting](effects-penalty-objective.md#penalty)) - -### Shared Periodic Decisions: The Exception - -**Investment decisions (sizes) can be shared across all scenarios:** - -By default, sizes (e.g., Storage capacity, Thermal power, ...) are **scenario-independent** but **flow_rates are scenario-specific**. - -**Example - Flow with investment:** - -$$ -v_\text{invest}(y) = s_\text{invest}(y) \cdot \text{size}_\text{fixed} \quad \text{(one decision per period)} -$$ - -$$ -p(\text{t}_i, y, s) \leq v_\text{invest}(y) \cdot \text{rel}_\text{upper} \quad \forall s \in \mathcal{S} \quad \text{(same capacity for all scenarios)} -$$ - -**Interpretation:** -- "We decide once in period $y$ how much capacity to build" (periodic decision) -- "This capacity is then operated differently in each scenario $s$ within period $y$" (temporal decisions) -- "Periodic effects (investment) are incurred once per period, temporal effects (operational) are weighted across scenarios" - -This reflects real-world investment under uncertainty: you build capacity once (periodic/investment decision), but it operates under different conditions (temporal/operational decisions per scenario). - -**Mathematical Flexibility:** - -Variables can be either scenario-independent or scenario-specific: - -| Variable Type | Scenario-Independent | Scenario-Specific | -|---------------|---------------------|-------------------| -| **Sizes** (e.g., $\text{P}$) | $\text{P}(y)$ - Single value per period | $\text{P}(y, s)$ - Different per scenario | -| **Flow rates** (e.g., $p(\text{t}_i)$) | $p(\text{t}_i, y)$ - Same across scenarios | $p(\text{t}_i, y, s)$ - Different per scenario | - -**Use Cases:** - -*Investment problems (with InvestParameters):* -- **Sizes shared** (default): Investment under uncertainty - build capacity that performs well across all scenarios -- **Sizes vary**: Scenario-specific capacity planning where different investments can be made for each future -- **Selected sizes shared**: Mix of shared critical infrastructure and scenario-specific optional/flexible capacity - -*Dispatch problems (fixed sizes, no investments):* -- **Flow rates shared**: Robust dispatch - find a single operational strategy that works across all forecast scenarios (e.g., day-ahead unit commitment under demand/weather uncertainty) -- **Flow rates vary** (default): Scenario-adaptive dispatch - optimize operations for each scenario's specific conditions (demand, weather, prices) - -For implementation details on controlling scenario independence, see the [`FlowSystem`][flixopt.flow_system.FlowSystem] API reference. - ---- - -## Dimensional Impact on Objective Function - -The objective function aggregates effects across all dimensions with weights: - -### Time Only -$$ -\min \quad \sum_{\text{t}_i \in \mathcal{T}} \sum_{e \in \mathcal{E}} s_{e}(\text{t}_i) -$$ - -### Time + Scenario -$$ -\min \quad \sum_{s \in \mathcal{S}} w_s \cdot \left( \sum_{\text{t}_i \in \mathcal{T}} \sum_{e \in \mathcal{E}} s_{e}(\text{t}_i, s) \right) -$$ - -### Time + Period -$$ -\min \quad \sum_{y \in \mathcal{Y}} w_y \cdot \left( \sum_{\text{t}_i \in \mathcal{T}} \sum_{e \in \mathcal{E}} s_{e}(\text{t}_i, y) \right) -$$ - -### Time + Period + Scenario (Full Multi-Dimensional) -$$ -\min \quad \sum_{y \in \mathcal{Y}} \sum_{s \in \mathcal{S}} w_{y,s} \cdot \left( \sum_{\text{t}_i \in \mathcal{T}} \sum_{e \in \mathcal{E}} s_{e}(\text{t}_i, y, s) \right) -$$ - -Where: -- $\mathcal{T}$ is the set of time steps -- $\mathcal{E}$ is the set of effects (including the Penalty effect $E_\Phi$) -- $\mathcal{S}$ is the set of scenarios -- $\mathcal{Y}$ is the set of periods -- $s_{e}(\cdots)$ are the effect contributions (costs, emissions, etc.) -- $w_s, w_y, w_{y,s}$ are the dimension weights -- **Penalty effect is weighted identically to other effects** - -**See [Effects, Penalty & Objective](effects-penalty-objective.md) for complete formulations including:** -- How temporal and periodic effects expand with dimensions -- Detailed objective function for each dimensional case -- Periodic (investment) vs temporal (operational) effect handling -- Explicit Penalty weighting formulations - ---- - -## Weights - -Weights determine the relative importance of scenarios and periods in the objective function. - -### Scenario Weights - -You provide scenario weights explicitly via the `scenario_weights` parameter: - -```python -flow_system = fx.FlowSystem( - timesteps=timesteps, - scenarios=scenarios, - scenario_weights=np.array([0.3, 0.7]) # Scenario probabilities -) -``` - -**Default:** If not specified, all scenarios have equal weight (normalized to sum to 1). - -### Period Weights - -Period weights are **automatically computed** from the period index (similar to how `hours_per_timestep` is computed from the time index): - -```python -# Period weights are computed from the differences between period values -periods = pd.Index([2020, 2025, 2030, 2035]) -# → period_weights = [5, 5, 5, 5] (representing 5-year intervals) - -flow_system = fx.FlowSystem( - timesteps=timesteps, - periods=periods, - # No need to specify period weights - they're computed automatically -) -``` - -**How period weights are computed:** -- For periods `[2020, 2025, 2030, 2035]`, the weights are `[5, 5, 5, 5]` (the interval sizes) -- This ensures that when you use `.sel()` to select a subset of periods, the weights are correctly recalculated -- You can specify `weight_of_last_period` if the last period weight cannot be inferred from the index - -### Combined Weights - -When both periods and scenarios are present, the combined `weights` array (accessible via `flow_system.model.objective_weights`) is computed as: - -$$ -w_{y,s} = w_y \times \frac{w_s}{\sum_{s \in \mathcal{S}} w_s} -$$ - -Where: -- $w_y$ are the period weights (computed from period index) -- $w_s$ are the scenario weights (user-specified) -- $\mathcal{S}$ is the set of all scenarios -- The scenario weights are normalized to sum to 1 before multiplication - -**Example:** -```python -periods = pd.Index([2020, 2030, 2040]) # → period_weights = [10, 10, 10] -scenarios = pd.Index(['Base', 'High']) -scenario_weights = np.array([0.6, 0.4]) - -flow_system = fx.FlowSystem( - timesteps=timesteps, - periods=periods, - scenarios=scenarios, - scenario_weights=scenario_weights -) - -# Combined weights shape: (3 periods, 2 scenarios) -# [[6.0, 4.0], # 2020: 10 × [0.6, 0.4] -# [6.0, 4.0], # 2030: 10 × [0.6, 0.4] -# [6.0, 4.0]] # 2040: 10 × [0.6, 0.4] -``` - -**Normalization:** Set `normalize_weights=False` in `Optimization` to turn off the normalization. - ---- - -## Summary Table - -| Dimension | Required? | Independence | Typical Use Case | -|-----------|-----------|--------------|------------------| -| **time** | ✅ Yes | Variables evolve over time via constraints (e.g., storage balance) | All optimization problems | -| **scenario** | ❌ No | Fully independent operations; shared investments within period | Uncertainty modeling, risk assessment | -| **period** | ❌ No | Fully independent; no coupling between periods | Multi-year planning, long-term investment | - -**Key Principle:** All constraints and formulations operate **within** each (period, scenario) combination independently. Only the objective function couples them via weighted aggregation. - ---- - -## See Also - -- [Effects, Penalty & Objective](effects-penalty-objective.md) - How dimensions affect the objective function -- [InvestParameters](features/InvestParameters.md) - Investment decisions across scenarios -- [FlowSystem API][flixopt.flow_system.FlowSystem] - Creating multi-dimensional systems diff --git a/docs/user-guide/mathematical-notation/effects-and-dimensions.md b/docs/user-guide/mathematical-notation/effects-and-dimensions.md new file mode 100644 index 000000000..011fc810e --- /dev/null +++ b/docs/user-guide/mathematical-notation/effects-and-dimensions.md @@ -0,0 +1,415 @@ +# Effects & Dimensions + +Effects track metrics (costs, CO₂, energy). Dimensions define the structure over which effects aggregate. + +## Defining Effects + +```python +costs = fx.Effect(label='costs', unit='€', is_objective=True) +co2 = fx.Effect(label='co2', unit='kg') + +flow_system.add_elements(costs, co2) +``` + +One effect is the **objective** (minimized). Others are tracked or constrained. + +--- + +## Effect Types + +=== "Temporal" + + Accumulated over timesteps — operational costs, emissions, energy: + + - Per flow hour: $E(t) = p(t) \cdot c \cdot \Delta t$ + - Per event (startup): $E(t) = s^{start}(t) \cdot c$ + + ```python + fx.Flow(..., effects_per_flow_hour={'costs': 50}) # €50/MWh + ``` + +=== "Periodic" + + Time-independent — investment costs, fixed fees: + + $E_{per} = P \cdot c_{inv}$ + + ```python + fx.InvestParameters(effects_of_investment_per_size={'costs': 200}) # €200/kW + ``` + +=== "Total" + + Sum of periodic and temporal components. + +--- + +## Where Effects Are Contributed + +=== "Flow" + + ```python + fx.Flow( + effects_per_flow_hour={'costs': 50, 'co2': 0.2}, # Per MWh + ) + ``` + +=== "Status" + + ```python + fx.StatusParameters( + effects_per_startup={'costs': 1000}, # Per startup event + effects_per_active_hour={'costs': 10}, # Per hour while running + ) + ``` + +=== "Investment" + + ```python + fx.InvestParameters( + effects_of_investment={'costs': 50000}, # Fixed if investing + effects_of_investment_per_size={'costs': 800}, # Per kW installed + effects_of_retirement={'costs': 10000}, # If NOT investing + ) + ``` + +=== "Bus" + + ```python + fx.Bus( + excess_penalty_per_flow_hour=1e6, # Penalty for excess + shortage_penalty_per_flow_hour=1e6, # Penalty for shortage + ) + ``` + +--- + +## Dimensions + +The model operates across three dimensions: + +=== "Timesteps" + + The basic time resolution — always required: + + ```python + flow_system = fx.FlowSystem( + timesteps=pd.date_range('2024-01-01', periods=8760, freq='h'), + ) + ``` + + All variables and constraints are indexed by time. Temporal effects sum over timesteps. + +=== "Scenarios" + + Represent uncertainty (weather, prices). Operations vary per scenario, investments are shared: + + ```python + flow_system = fx.FlowSystem( + timesteps=pd.date_range('2024-01-01', periods=8760, freq='h'), + scenarios=pd.Index(['sunny_year', 'cloudy_year']), + scenario_weights=[0.7, 0.3], + ) + ``` + + Scenarios are independent — no energy or information exchange between them. + +=== "Periods" + + Sequential time blocks (years) for multi-period planning: + + ```python + flow_system = fx.FlowSystem( + timesteps=pd.date_range('2024-01-01', periods=8760, freq='h'), + periods=pd.Index([2025, 2030]), + ) + ``` + + Periods are independent — each has its own investment decisions. + +--- + +## Objective Function + +The objective aggregates effects across all dimensions with weights: + +=== "Basic" + + Single period, no scenarios: + + $$\min \quad E_{per} + \sum_t E_{temp}(t)$$ + +=== "With Scenarios" + + Investment decided once, operations weighted by probability: + + $$\min \quad E_{per} + \sum_s w_s \cdot \sum_t E_{temp}(t, s)$$ + + - $w_s$ — scenario weight (probability) + +=== "With Periods" + + Multi-year planning with discounting: + + $$\min \quad \sum_y w_y \cdot \left( E_{per}(y) + \sum_t E_{temp}(t, y) \right)$$ + + - $w_y$ — period weight (duration or discount factor) + +=== "Full" + + Periods × Scenarios: + + $$\min \quad \sum_y w_y \cdot \left( E_{per}(y) + \sum_s w_s \cdot \sum_t E_{temp}(t, y, s) \right)$$ + +The penalty effect is always included: $\min \quad E_{objective} + E_{penalty}$ + +--- + +## Weights + +=== "Scenario Weights" + + Provided explicitly — typically probabilities: + + ```python + scenario_weights=[0.6, 0.4] + ``` + + Default: equal weights, normalized to sum to 1. + +=== "Period Weights" + + Computed automatically from period index (interval sizes): + + ```python + periods = pd.Index([2020, 2025, 2030]) + # → weights: [5, 5, 5] (5-year intervals) + ``` + +=== "Combined" + + When both present: + + $w_{y,s} = w_y \cdot w_s$ + +--- + +## Constraints on Effects + +=== "Total Limit" + + Bound on aggregated effect (temporal + periodic) per period: + + ```python + fx.Effect(label='co2', unit='kg', maximum_total=100_000) + ``` + +=== "Per-Timestep Limit" + + Bound at each timestep: + + ```python + fx.Effect(label='peak', unit='kW', maximum_per_hour=500) + ``` + +=== "Periodic Limit" + + Bound on periodic component only: + + ```python + fx.Effect(label='capex', unit='€', maximum_periodic=1_000_000) + ``` + +=== "Temporal Limit" + + Bound on temporal component only: + + ```python + fx.Effect(label='opex', unit='€', maximum_temporal=500_000) + ``` + +=== "Over All Periods" + + Bound across all periods (weighted sum): + + ```python + fx.Effect(label='co2', unit='kg', maximum_over_periods=1_000_000) + ``` + +--- + +## Cross-Effects + +Effects can contribute to each other (e.g., carbon pricing): + +```python +co2 = fx.Effect(label='co2', unit='kg') + +costs = fx.Effect( + label='costs', unit='€', is_objective=True, + share_from_temporal={'co2': 0.08}, # €80/tonne +) +``` + +--- + +## Penalty Effect + +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) +``` + +Penalty is weighted identically to the objective effect across all dimensions. + +--- + +## Shared vs Independent Decisions + +=== "Investments (Sizes)" + + By default, investment decisions are **shared across scenarios** within a period: + + - Build capacity once → operate differently per scenario + - Reflects real-world investment under uncertainty + + $$P(y) \quad \text{(one decision per period, used in all scenarios)}$$ + +=== "Operations (Flows)" + + By default, operational decisions are **independent per scenario**: + + $$p(t, y, s) \quad \text{(different for each scenario)}$$ + +--- + +## Use Cases + +=== "Carbon Budget" + + Limit total CO₂ emissions across all years: + + ```python + co2 = fx.Effect( + label='co2', unit='kg', + maximum_over_periods=1_000_000, # 1000 tonnes total + ) + + # Contribute emissions from gas consumption + gas_flow = fx.Flow( + label='gas', bus=gas_bus, + effects_per_flow_hour={'co2': 0.2}, # 0.2 kg/kWh + ) + ``` + +=== "Investment Budget" + + Cap annual investment spending: + + ```python + capex = fx.Effect( + label='capex', unit='€', + maximum_periodic=5_000_000, # €5M per period + ) + + battery = fx.Storage( + ..., + capacity=fx.InvestParameters( + effects_of_investment_per_size={'capex': 600}, # €600/kWh + ), + ) + ``` + +=== "Peak Demand Charge" + + Track and limit peak power: + + ```python + peak = fx.Effect( + label='peak', unit='kW', + maximum_per_hour=1000, # Grid connection limit + ) + + grid_import = fx.Flow( + label='import', bus=elec_bus, + effects_per_flow_hour={'peak': 1}, # Track instantaneous power + ) + ``` + +=== "Carbon Pricing" + + Add CO₂ cost to objective automatically: + + ```python + co2 = fx.Effect(label='co2', unit='kg') + + costs = fx.Effect( + label='costs', unit='€', is_objective=True, + share_from_temporal={'co2': 0.08}, # €80/tonne carbon price + ) + + # Now any CO₂ contribution automatically adds to costs + ``` + +=== "Land Use Constraint" + + Limit total land area for installations: + + ```python + land = fx.Effect( + label='land', unit='m²', + maximum_periodic=50_000, # 5 hectares max + ) + + pv = fx.Source( + ..., + output=fx.Flow( + ..., + invest_parameters=fx.InvestParameters( + effects_of_investment_per_size={'land': 5}, # 5 m²/kWp + ), + ), + ) + ``` + +=== "Multi-Criteria Optimization" + + Track multiple objectives, optimize one: + + ```python + costs = fx.Effect(label='costs', unit='€', is_objective=True) + co2 = fx.Effect(label='co2', unit='kg') + primary_energy = fx.Effect(label='PE', unit='kWh') + + # All are tracked, costs is minimized + # Use maximum_total on co2 for ε-constraint method + ``` + +--- + +## Reference + +| Symbol | Type | Description | +|--------|------|-------------| +| $E_{temp}(t)$ | $\mathbb{R}$ | Temporal effect at timestep $t$ | +| $E_{per}$ | $\mathbb{R}$ | Periodic effect (per period) | +| $E$ | $\mathbb{R}$ | Total effect ($E_{per} + \sum_t E_{temp}(t)$) | +| $w_s$ | $\mathbb{R}_{\geq 0}$ | Scenario weight (probability) | +| $w_y$ | $\mathbb{R}_{> 0}$ | Period weight (duration/discount) | +| $p(t)$ | $\mathbb{R}_{\geq 0}$ | Flow rate at timestep $t$ | +| $s^{start}(t)$ | $\{0, 1\}$ | Startup indicator | +| $P$ | $\mathbb{R}_{\geq 0}$ | Investment size | +| $c$ | $\mathbb{R}$ | Effect coefficient | +| $\Delta t$ | $\mathbb{R}_{> 0}$ | Timestep duration (hours) | + +| Constraint | Python | Scope | +|-----------|--------|-------| +| Total limit | `maximum_total` | Per period | +| Timestep limit | `maximum_per_hour` | Each timestep | +| Periodic limit | `maximum_periodic` | Per period (periodic only) | +| Temporal limit | `maximum_temporal` | Per period (temporal only) | +| Global limit | `maximum_over_periods` | Across all periods | + +**Classes:** [`Effect`][flixopt.effects.Effect], [`EffectCollection`][flixopt.effects.EffectCollection] diff --git a/docs/user-guide/mathematical-notation/effects-penalty-objective.md b/docs/user-guide/mathematical-notation/effects-penalty-objective.md deleted file mode 100644 index 1c96f3613..000000000 --- a/docs/user-guide/mathematical-notation/effects-penalty-objective.md +++ /dev/null @@ -1,333 +0,0 @@ -# Effects, Penalty & Objective - -## Effects - -[`Effects`][flixopt.effects.Effect] are used to quantify system-wide impacts like costs, emissions, or resource consumption. These arise from **shares** contributed by **Elements** such as [Flows](elements/Flow.md), [Storage](elements/Storage.md), and other components. - -**Example:** - -[`Flows`][flixopt.elements.Flow] have an attribute `effects_per_flow_hour` that defines the effect contribution per flow-hour: -- Costs (€/kWh) -- Emissions (kg CO₂/kWh) -- Primary energy consumption (kWh_primary/kWh) - -Effects are categorized into two domains: - -1. **Temporal effects** - Time-dependent contributions (e.g., operational costs, hourly emissions) -2. **Periodic effects** - Time-independent contributions (e.g., investment costs, fixed annual fees) - -### Multi-Dimensional Effects - -**The formulations below are written with time index $\text{t}_i$ only, but automatically expand when periods and/or scenarios are present.** - -When the FlowSystem has additional dimensions (see [Dimensions](dimensions.md)): - -- **Temporal effects** are indexed by all present dimensions: $E_{e,\text{temp}}(\text{t}_i, y, s)$ -- **Periodic effects** are indexed by period only (scenario-independent within a period): $E_{e,\text{per}}(y)$ -- Effects are aggregated with dimension weights in the objective function - -For complete details on how dimensions affect effects and the objective, see [Dimensions](dimensions.md). - ---- - -## Effect Formulation - -### Shares from Elements - -Each element $l$ contributes shares to effect $e$ in both temporal and periodic domains: - -**Periodic shares** (time-independent): -$$ \label{eq:Share_periodic} -s_{l \rightarrow e, \text{per}} = \sum_{v \in \mathcal{V}_{l, \text{per}}} v \cdot \text{a}_{v \rightarrow e} -$$ - -**Temporal shares** (time-dependent): -$$ \label{eq:Share_temporal} -s_{l \rightarrow e, \text{temp}}(\text{t}_i) = \sum_{v \in \mathcal{V}_{l,\text{temp}}} v(\text{t}_i) \cdot \text{a}_{v \rightarrow e}(\text{t}_i) -$$ - -Where: - -- $\text{t}_i$ is the time step -- $\mathcal{V}_l$ is the set of all optimization variables of element $l$ -- $\mathcal{V}_{l, \text{per}}$ is the subset of periodic (investment-related) variables -- $\mathcal{V}_{l, \text{temp}}$ is the subset of temporal (operational) variables -- $v$ is an optimization variable -- $v(\text{t}_i)$ is the variable value at timestep $\text{t}_i$ -- $\text{a}_{v \rightarrow e}$ is the effect factor (e.g., €/kW for investment, €/kWh for operation) -- $s_{l \rightarrow e, \text{per}}$ is the periodic share of element $l$ to effect $e$ -- $s_{l \rightarrow e, \text{temp}}(\text{t}_i)$ is the temporal share of element $l$ to effect $e$ - -**Examples:** -- **Periodic share**: Investment cost = $\text{size} \cdot \text{specific\_cost}$ (€/kW) -- **Temporal share**: Operational cost = $\text{flow\_rate}(\text{t}_i) \cdot \text{price}(\text{t}_i)$ (€/kWh) - ---- - -### Cross-Effect Contributions - -Effects can contribute shares to other effects, enabling relationships like carbon pricing or resource accounting. - -An effect $x$ can contribute to another effect $e \in \mathcal{E}\backslash x$ via conversion factors: - -**Example:** CO₂ emissions (kg) → Monetary costs (€) -- Effect $x$: "CO₂ emissions" (unit: kg) -- Effect $e$: "costs" (unit: €) -- Factor $\text{r}_{x \rightarrow e}$: CO₂ price (€/kg) - -**Note:** Circular references must be avoided. - -### Total Effect Calculation - -**Periodic effects** aggregate element shares and cross-effect contributions: - -$$ \label{eq:Effect_periodic} -E_{e, \text{per}} = -\sum_{l \in \mathcal{L}} s_{l \rightarrow e,\text{per}} + -\sum_{x \in \mathcal{E}\backslash e} E_{x, \text{per}} \cdot \text{r}_{x \rightarrow e,\text{per}} -$$ - -**Temporal effects** at each timestep: - -$$ \label{eq:Effect_temporal} -E_{e, \text{temp}}(\text{t}_{i}) = -\sum_{l \in \mathcal{L}} s_{l \rightarrow e, \text{temp}}(\text{t}_i) + -\sum_{x \in \mathcal{E}\backslash e} E_{x, \text{temp}}(\text{t}_i) \cdot \text{r}_{x \rightarrow {e},\text{temp}}(\text{t}_i) -$$ - -**Total temporal effects** (sum over all timesteps): - -$$\label{eq:Effect_temporal_total} -E_{e,\text{temp},\text{tot}} = \sum_{i=1}^n E_{e,\text{temp}}(\text{t}_{i}) -$$ - -**Total effect** (combining both domains): - -$$ \label{eq:Effect_Total} -E_{e} = E_{e,\text{per}} + E_{e,\text{temp},\text{tot}} -$$ - -Where: - -- $\mathcal{L}$ is the set of all elements in the FlowSystem -- $\mathcal{E}$ is the set of all effects -- $\text{r}_{x \rightarrow e, \text{per}}$ is the periodic conversion factor from effect $x$ to effect $e$ -- $\text{r}_{x \rightarrow e, \text{temp}}(\text{t}_i)$ is the temporal conversion factor - ---- - -### Constraining Effects - -Effects can be bounded to enforce limits on costs, emissions, or other impacts: - -**Total bounds** (apply to $E_{e,\text{per}}$, $E_{e,\text{temp},\text{tot}}$, or $E_e$): - -$$ \label{eq:Bounds_Total} -E^\text{L} \leq E \leq E^\text{U} -$$ - -**Temporal bounds per timestep:** - -$$ \label{eq:Bounds_Timestep} -E_{e,\text{temp}}^\text{L}(\text{t}_i) \leq E_{e,\text{temp}}(\text{t}_i) \leq E_{e,\text{temp}}^\text{U}(\text{t}_i) -$$ - -**Implementation:** See [`Effect`][flixopt.effects.Effect] parameters: -- `minimum_temporal`, `maximum_temporal` - Total temporal bounds -- `minimum_per_hour`, `maximum_per_hour` - Hourly temporal bounds -- `minimum_periodic`, `maximum_periodic` - Periodic bounds -- `minimum_total`, `maximum_total` - Combined total bounds - ---- - -## Penalty - -Every FlixOpt model includes a special **Penalty Effect** $E_\Phi$ to: - -- Prevent infeasible problems -- Allow introducing a bias without influencing effects, simplifying results analysis - -**Key Feature:** Penalty is implemented as a standard Effect (labeled `Penalty`), so you can **add penalty contributions anywhere effects are used**: - -```python -import flixopt as fx - -# Add penalty contributions just like any other effect -on_off = fx.OnOffParameters( - effects_per_switch_on={'Penalty': 1} # Add bias against switching on this component, without adding costs -) -``` - -**Optionally Define Custom Penalty:** -Users can define their own Penalty effect with custom properties (unit, constraints, etc.): - -```python -# Define custom penalty effect (must use fx.PENALTY_EFFECT_LABEL) -custom_penalty = fx.Effect( - fx.PENALTY_EFFECT_LABEL, # Always use this constant: 'Penalty' - unit='€', - description='Penalty costs for constraint violations', - maximum_total=1e6, # Limit total penalty for debugging -) -flow_system.add_elements(custom_penalty) -``` - -If not user-defined, the Penalty effect is automatically created during modeling with default settings. - -**Periodic penalty shares** (time-independent): -$$ \label{eq:Penalty_periodic} -E_{\Phi, \text{per}} = \sum_{l \in \mathcal{L}} s_{l \rightarrow \Phi,\text{per}} -$$ - -**Temporal penalty shares** (time-dependent): -$$ \label{eq:Penalty_temporal} -E_{\Phi, \text{temp}}(\text{t}_{i}) = \sum_{l \in \mathcal{L}} s_{l \rightarrow \Phi, \text{temp}}(\text{t}_i) -$$ - -**Total penalty** (combining both domains): -$$ \label{eq:Penalty_total} -E_{\Phi} = E_{\Phi,\text{per}} + \sum_{\text{t}_i \in \mathcal{T}} E_{\Phi, \text{temp}}(\text{t}_{i}) -$$ - -Where: - -- $\mathcal{L}$ is the set of all elements -- $\mathcal{T}$ is the set of all timesteps -- $s_{l \rightarrow \Phi, \text{per}}$ is the periodic penalty share from element $l$ -- $s_{l \rightarrow \Phi, \text{temp}}(\text{t}_i)$ is the temporal penalty share from element $l$ at timestep $\text{t}_i$ - -**Primary usage:** Penalties occur in [Buses](elements/Bus.md) via the `excess_penalty_per_flow_hour` parameter, which allows nodal imbalances at a high cost, and in time series aggregation to allow period flexibility. - -**Key properties:** -- Penalty shares are added via `add_share_to_effects(name, expressions={fx.PENALTY_EFFECT_LABEL: ...}, target='temporal'/'periodic')` -- Like other effects, penalty can be constrained (e.g., `maximum_total` for debugging) -- Results include breakdown: temporal, periodic, and total penalty contributions -- Penalty is always added to the objective function (cannot be disabled) -- Access via `flow_system.effects.penalty_effect` or `flow_system.effects[fx.PENALTY_EFFECT_LABEL]` -- **Scenario weighting**: Penalty is weighted identically to the objective effect—see [Time + Scenario](#time--scenario) for details - ---- - -## Objective Function - -The optimization objective minimizes the chosen effect plus the penalty effect: - -$$ \label{eq:Objective} -\min \left( E_{\Omega} + E_{\Phi} \right) -$$ - -Where: - -- $E_{\Omega}$ is the chosen **objective effect** (see $\eqref{eq:Effect_Total}$) -- $E_{\Phi}$ is the [penalty effect](#penalty) (see $\eqref{eq:Penalty_total}$) - -One effect must be designated as the objective via `is_objective=True`. The penalty effect is automatically created and always added to the objective. - -### Multi-Criteria Optimization - -This formulation supports multiple optimization approaches: - -**1. Weighted Sum Method** -- The objective effect can incorporate other effects via cross-effect factors -- Example: Minimize costs while including carbon pricing: $\text{CO}_2 \rightarrow \text{costs}$ - -**2. ε-Constraint Method** -- Optimize one effect while constraining others -- Example: Minimize costs subject to $\text{CO}_2 \leq 1000$ kg - ---- - -## Objective with Multiple Dimensions - -When the FlowSystem includes **periods** and/or **scenarios** (see [Dimensions](dimensions.md)), the objective aggregates effects across all dimensions using weights. - -### Time Only (Base Case) - -$$ -\min \quad E_{\Omega} + E_{\Phi} = \sum_{\text{t}_i \in \mathcal{T}} E_{\Omega,\text{temp}}(\text{t}_i) + E_{\Omega,\text{per}} + E_{\Phi,\text{per}} + \sum_{\text{t}_i \in \mathcal{T}} E_{\Phi,\text{temp}}(\text{t}_i) -$$ - -Where: -- Temporal effects sum over time: $\sum_{\text{t}_i} E_{\Omega,\text{temp}}(\text{t}_i)$ and $\sum_{\text{t}_i} E_{\Phi,\text{temp}}(\text{t}_i)$ -- Periodic effects are constant: $E_{\Omega,\text{per}}$ and $E_{\Phi,\text{per}}$ - ---- - -### Time + Scenario - -$$ -\min \quad \sum_{s \in \mathcal{S}} w_s \cdot \left( E_{\Omega}(s) + E_{\Phi}(s) \right) -$$ - -Where: -- $\mathcal{S}$ is the set of scenarios -- $w_s$ is the weight for scenario $s$ (typically scenario probability) -- Periodic effects are **shared across scenarios**: $E_{\Omega,\text{per}}$ and $E_{\Phi,\text{per}}$ (same for all $s$) -- Temporal effects are **scenario-specific**: $E_{\Omega,\text{temp}}(s) = \sum_{\text{t}_i} E_{\Omega,\text{temp}}(\text{t}_i, s)$ and $E_{\Phi,\text{temp}}(s) = \sum_{\text{t}_i} E_{\Phi,\text{temp}}(\text{t}_i, s)$ - -**Interpretation:** -- Investment decisions (periodic) made once, used across all scenarios -- Operations (temporal) differ by scenario -- Objective balances expected value across scenarios -- **Both $E_{\Omega}$ (objective effect) and $E_{\Phi}$ (penalty) are weighted identically by $w_s$** - ---- - -### Time + Period - -$$ -\min \quad \sum_{y \in \mathcal{Y}} w_y \cdot \left( E_{\Omega}(y) + E_{\Phi}(y) \right) -$$ - -Where: -- $\mathcal{Y}$ is the set of periods (e.g., years) -- $w_y$ is the weight for period $y$ (typically annual discount factor) -- Each period $y$ has **independent** periodic and temporal effects (including penalty) -- Each period $y$ has **independent** investment and operational decisions -- **Both $E_{\Omega}$ (objective effect) and $E_{\Phi}$ (penalty) are weighted identically by $w_y$** - ---- - -### Time + Period + Scenario (Full Multi-Dimensional) - -$$ -\min \quad \sum_{y \in \mathcal{Y}} \left[ w_y \cdot \left( E_{\Omega,\text{per}}(y) + E_{\Phi,\text{per}}(y) \right) + \sum_{s \in \mathcal{S}} w_{y,s} \cdot \left( E_{\Omega,\text{temp}}(y,s) + E_{\Phi,\text{temp}}(y,s) \right) \right] -$$ - -Where: -- $\mathcal{S}$ is the set of scenarios -- $\mathcal{Y}$ is the set of periods -- $w_y$ is the period weight (for periodic effects) -- $w_{y,s}$ is the combined period-scenario weight (for temporal effects) -- **Periodic effects** $E_{\Omega,\text{per}}(y)$ and $E_{\Phi,\text{per}}(y)$ are period-specific but **scenario-independent** -- **Temporal effects** $E_{\Omega,\text{temp}}(y,s) = \sum_{\text{t}_i} E_{\Omega,\text{temp}}(\text{t}_i, y, s)$ and $E_{\Phi,\text{temp}}(y,s) = \sum_{\text{t}_i} E_{\Phi,\text{temp}}(\text{t}_i, y, s)$ are **fully indexed** - -**Key Principle:** -- Scenarios and periods are **operationally independent** (no energy/resource exchange) -- Coupled **only through the weighted objective function** -- **Periodic effects within a period are shared across all scenarios** (investment made once per period) -- **Temporal effects are independent per scenario** (different operations under different conditions) -- **Both $E_{\Omega}$ (objective effect) and $E_{\Phi}$ (penalty) use identical weighting** ($w_y$ for periodic, $w_{y,s}$ for temporal) - ---- - -## Summary - -| Concept | Formulation | Time Dependency | Dimension Indexing | -|---------|-------------|-----------------|-------------------| -| **Temporal share** | $s_{l \rightarrow e, \text{temp}}(\text{t}_i)$ | Time-dependent | $(t, y, s)$ when present | -| **Periodic share** | $s_{l \rightarrow e, \text{per}}$ | Time-independent | $(y)$ when periods present | -| **Total temporal effect** | $E_{e,\text{temp},\text{tot}} = \sum_{\text{t}_i} E_{e,\text{temp}}(\text{t}_i)$ | Sum over time | Depends on dimensions | -| **Total periodic effect** | $E_{e,\text{per}}$ | Constant | $(y)$ when periods present | -| **Total effect** | $E_e = E_{e,\text{per}} + E_{e,\text{temp},\text{tot}}$ | Combined | Depends on dimensions | -| **Penalty effect** | $E_\Phi = E_{\Phi,\text{per}} + E_{\Phi,\text{temp},\text{tot}}$ | Combined (same as effects) | **Weighted identically to objective effect** | -| **Objective** | $\min(E_{\Omega} + E_{\Phi})$ | With weights when multi-dimensional | See formulations above | - ---- - -## See Also - -- [Dimensions](dimensions.md) - Complete explanation of multi-dimensional modeling -- [Flow](elements/Flow.md) - Temporal effect contributions via `effects_per_flow_hour` -- [InvestParameters](features/InvestParameters.md) - Periodic effect contributions via investment -- [Effect API][flixopt.effects.Effect] - Implementation details and parameters diff --git a/docs/user-guide/mathematical-notation/elements/Bus.md b/docs/user-guide/mathematical-notation/elements/Bus.md index bfe57d234..ca089bfec 100644 --- a/docs/user-guide/mathematical-notation/elements/Bus.md +++ b/docs/user-guide/mathematical-notation/elements/Bus.md @@ -1,49 +1,75 @@ -A Bus is a simple nodal balance between its incoming and outgoing flow rates. +# Bus -$$ \label{eq:bus_balance} - \sum_{f_\text{in} \in \mathcal{F}_\text{in}} p_{f_\text{in}}(\text{t}_i) = - \sum_{f_\text{out} \in \mathcal{F}_\text{out}} p_{f_\text{out}}(\text{t}_i) -$$ +A Bus is where flows meet and must balance — inputs equal outputs at every timestep. -Optionally, a Bus can have a `excess_penalty_per_flow_hour` parameter, which allows to penaltize the balance for missing or excess flow-rates. -This is usefull as it handles a possible ifeasiblity gently. +## Carriers -This changes the balance to +Buses can optionally be assigned a **carrier** — a type of energy or material (e.g., electricity, heat, gas). Carriers enable: -$$ \label{eq:bus_balance-excess} - \sum_{f_\text{in} \in \mathcal{F}_\text{in}} p_{f_ \text{in}}(\text{t}_i) + \phi_\text{in}(\text{t}_i) = - \sum_{f_\text{out} \in \mathcal{F}_\text{out}} p_{f_\text{out}}(\text{t}_i) + \phi_\text{out}(\text{t}_i) -$$ +- **Automatic coloring** in plots based on energy type +- **Unit tracking** for better result visualization +- **Semantic grouping** of buses by type + +```python +# Assign a carrier by name (uses CONFIG.Carriers defaults) +heat_bus = fx.Bus('HeatNetwork', carrier='heat') +elec_bus = fx.Bus('Grid', carrier='electricity') + +# Or register custom carriers on the FlowSystem +biogas = fx.Carrier('biogas', color='#228B22', unit='kW', description='Biogas fuel') +flow_system.add_carrier(biogas) +gas_bus = fx.Bus('BiogasNetwork', carrier='biogas') +``` + +See [Color Management](../../../user-guide/results-plotting.md#color-management) for more on how carriers affect visualization. + +--- -The penalty term is defined as +## Basic: Balance Equation -$$ \label{eq:bus_penalty} - s_{b \rightarrow \Phi}(\text{t}_i) = - \text a_{b \rightarrow \Phi}(\text{t}_i) \cdot \Delta \text{t}_i - \cdot [ \phi_\text{in}(\text{t}_i) + \phi_\text{out}(\text{t}_i) ] +$$ +\sum_{in} p(t) = \sum_{out} p(t) $$ -With: +```python +heat_bus = fx.Bus(label='heat') +# All flows connected to this bus must balance +``` -- $\mathcal{F}_\text{in}$ and $\mathcal{F}_\text{out}$ being the set of all incoming and outgoing flows -- $p_{f_\text{in}}(\text{t}_i)$ and $p_{f_\text{out}}(\text{t}_i)$ being the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively -- $\phi_\text{in}(\text{t}_i)$ and $\phi_\text{out}(\text{t}_i)$ being the missing or excess flow-rate at time $\text{t}_i$, respectively -- $\text{t}_i$ being the time step -- $s_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty term -- $\text a_{b \rightarrow \Phi}(\text{t}_i)$ being the penalty coefficient (`excess_penalty_per_flow_hour`) +If balance can't be achieved → model is **infeasible**. --- -## Implementation +## With Imbalance Penalty + +Allow imbalance for debugging or soft constraints: -**Python Class:** [`Bus`][flixopt.elements.Bus] +$$ +\sum_{in} p(t) + \phi_{in}(t) = \sum_{out} p(t) + \phi_{out}(t) +$$ -See the API documentation for implementation details and usage examples. +The slack variables $\phi$ are penalized: $(\phi_{in} + \phi_{out}) \cdot \Delta t \cdot c_\phi$ + +```python +heat_bus = fx.Bus( + label='heat', + imbalance_penalty_per_flow_hour=1e5 # High penalty for imbalance +) +``` + +!!! tip "Debugging" + If you see a `virtual_demand` or `virtual_supply` and its non zero in results → your system couldn't meet demand. Check capacities and connections. --- -## See Also +## Reference + +| Symbol | Type | Description | +|--------|------|-------------| +| $p(t)$ | $\mathbb{R}_{\geq 0}$ | Flow rate of connected flows | +| $\phi_{in}(t)$ | $\mathbb{R}_{\geq 0}$ | Slack: virtual supply (covers shortages) | +| $\phi_{out}(t)$ | $\mathbb{R}_{\geq 0}$ | Slack: virtual demand (absorbs surplus) | +| $c_\phi$ | $\mathbb{R}_{\geq 0}$ | Penalty factor (`imbalance_penalty_per_flow_hour`) | +| $\Delta t$ | $\mathbb{R}_{> 0}$ | Timestep duration (hours) | -- [Flow](../elements/Flow.md) - Definition of flow rates in the balance -- [Effects, Penalty & Objective](../effects-penalty-objective.md) - How penalties are included in the objective function -- [Modeling Patterns](../modeling-patterns/index.md) - Mathematical building blocks +**Classes:** [`Bus`][flixopt.elements.Bus], [`BusModel`][flixopt.elements.BusModel] diff --git a/docs/user-guide/mathematical-notation/elements/Flow.md b/docs/user-guide/mathematical-notation/elements/Flow.md index 5914ba911..4f5f9dcf3 100644 --- a/docs/user-guide/mathematical-notation/elements/Flow.md +++ b/docs/user-guide/mathematical-notation/elements/Flow.md @@ -1,64 +1,131 @@ # Flow -The flow_rate is the main optimization variable of the Flow. It's limited by the size of the Flow and relative bounds \eqref{eq:flow_rate}. +A Flow is the primary optimization variable — the solver decides how much flows at each timestep. -$$ \label{eq:flow_rate} - \text P \cdot \text p^{\text{L}}_{\text{rel}}(\text{t}_{i}) - \leq p(\text{t}_{i}) \leq - \text P \cdot \text p^{\text{U}}_{\text{rel}}(\text{t}_{i}) -$$ - -With: +## Basic: Bounded Flow Rate -- $\text P$ being the size of the Flow -- $p(\text{t}_{i})$ being the flow-rate at time $\text{t}_{i}$ -- $\text p^{\text{L}}_{\text{rel}}(\text{t}_{i})$ being the relative lower bound (typically 0) -- $\text p^{\text{U}}_{\text{rel}}(\text{t}_{i})$ being the relative upper bound (typically 1) - -With $\text p^{\text{L}}_{\text{rel}}(\text{t}_{i}) = 0$ and $\text p^{\text{U}}_{\text{rel}}(\text{t}_{i}) = 1$, -equation \eqref{eq:flow_rate} simplifies to +Every flow has a **size** $P$ (capacity) and a **flow rate** $p(t)$ (what the solver optimizes): $$ - 0 \leq p(\text{t}_{i}) \leq \text P +P \cdot p_{rel}^{min} \leq p(t) \leq P \cdot p_{rel}^{max} $$ +```python +# 100 kW boiler, minimum 30% when running +heat = fx.Flow(label='heat', bus=heat_bus, size=100, relative_minimum=0.3) +# → 30 ≤ p(t) ≤ 100 +``` -This mathematical formulation can be extended by using [OnOffParameters](../features/OnOffParameters.md) -to define the on/off state of the Flow, or by using [InvestParameters](../features/InvestParameters.md) -to change the size of the Flow from a constant to an optimization variable. +!!! warning "Cannot be zero" + With `relative_minimum > 0`, the flow cannot be zero. Use `status_parameters` to allow shutdown. --- -## Mathematical Patterns Used +## Adding Features + +=== "Status" + + Allow the flow to be zero with `status_parameters`: + + $s(t) \cdot P \cdot p_{rel}^{min} \leq p(t) \leq s(t) \cdot P \cdot p_{rel}^{max}$ + + Where $s(t) \in \{0, 1\}$: inactive or active. + + ```python + generator = fx.Flow( + label='power', bus=elec_bus, size=50, + relative_minimum=0.4, + status_parameters=fx.StatusParameters( + effects_per_startup={'costs': 500}, + min_uptime=2, + ), + ) + ``` + + See [StatusParameters](../features/StatusParameters.md). + +=== "Variable Size" + + Optimize the capacity with `InvestParameters`: + + $P^{min} \leq P \leq P^{max}$ + + ```python + battery = fx.Flow( + label='power', bus=elec_bus, + size=fx.InvestParameters( + minimum_size=0, + maximum_size=1000, + specific_effects={'costs': 100_000}, + ), + ) + ``` + + See [InvestParameters](../features/InvestParameters.md). -Flow formulation uses the following modeling patterns: +=== "Flow Effects" -- **[Scaled Bounds](../modeling-patterns/bounds-and-states.md#scaled-bounds)** - Basic flow rate bounds (equation $\eqref{eq:flow_rate}$) -- **[Scaled Bounds with State](../modeling-patterns/bounds-and-states.md#scaled-bounds-with-state)** - When combined with [OnOffParameters](../features/OnOffParameters.md) -- **[Bounds with State](../modeling-patterns/bounds-and-states.md#bounds-with-state)** - Investment decisions with [InvestParameters](../features/InvestParameters.md) + Add effects per energy (flow hours) moved: + + ```python + gas = fx.Flow( + label='gas', bus=gas_bus, size=150, + effects_per_flow_hour={'costs': 50}, # €50/MWh + ) + ``` + + Flow hours: $h(t) = p(t) \cdot \Delta t$ + + +=== "Fixed Profile" + + Lock the flow to a time series (demands, renewables): + + $p(t) = P \cdot \pi(t)$ + + ```python + demand = fx.Flow( + label='demand', bus=heat_bus, size=100, + fixed_relative_profile=[0.5, 0.8, 1.0, 0.6] # π(t) + ) + ``` --- -## Implementation +## Optional Constraints -**Python Class:** [`Flow`][flixopt.elements.Flow] +=== "Load Factor" -**Key Parameters:** -- `size`: Flow size $\text{P}$ (can be fixed or variable with InvestParameters) -- `relative_minimum`, `relative_maximum`: Relative bounds $\text{p}^{\text{L}}_{\text{rel}}, \text{p}^{\text{U}}_{\text{rel}}$ -- `effects_per_flow_hour`: Operational effects (costs, emissions, etc.) -- `invest_parameters`: Optional investment modeling (see [InvestParameters](../features/InvestParameters.md)) -- `on_off_parameters`: Optional on/off operation (see [OnOffParameters](../features/OnOffParameters.md)) + Constrain average utilization: -See the [`Flow`][flixopt.elements.Flow] API documentation for complete parameter list and usage examples. + $\lambda_{min} \leq \frac{\sum_t p(t)}{P \cdot n_t} \leq \lambda_{max}$ + + ```python + fx.Flow(..., load_factor_min=0.5, load_factor_max=0.9) + ``` + +=== "Flow Hours" + + Constrain total energy: + + $h_{min} \leq \sum_t p(t) \cdot \Delta t \leq h_{max}$ + + ```python + fx.Flow(..., flow_hours_min=1000, flow_hours_max=5000) + ``` --- -## See Also +## Reference + +| Symbol | Type | Description | +|--------|------|-------------| +| $p(t)$ | $\mathbb{R}_{\geq 0}$ | Flow rate at timestep $t$ | +| $P$ | $\mathbb{R}_{\geq 0}$ | Size (capacity) — fixed or optimized | +| $s(t)$ | $\{0, 1\}$ | Binary status (with `status_parameters`) | +| $p_{rel}^{min}$ | $\mathbb{R}_{\geq 0}$ | Minimum relative flow (`relative_minimum`) | +| $p_{rel}^{max}$ | $\mathbb{R}_{\geq 0}$ | Maximum relative flow (`relative_maximum`) | +| $\pi(t)$ | $\mathbb{R}_{\geq 0}$ | Fixed profile (`fixed_relative_profile`) | +| $\Delta t$ | $\mathbb{R}_{> 0}$ | Timestep duration (hours) | -- [OnOffParameters](../features/OnOffParameters.md) - Binary on/off operation -- [InvestParameters](../features/InvestParameters.md) - Variable flow sizing -- [Bus](../elements/Bus.md) - Flow balance constraints -- [LinearConverter](../elements/LinearConverter.md) - Flow ratio constraints -- [Storage](../elements/Storage.md) - Flow integration over time -- [Modeling Patterns](../modeling-patterns/index.md) - Mathematical building blocks +**Classes:** [`Flow`][flixopt.elements.Flow], [`FlowModel`][flixopt.elements.FlowModel] diff --git a/docs/user-guide/mathematical-notation/elements/LinearConverter.md b/docs/user-guide/mathematical-notation/elements/LinearConverter.md index b007aa7f5..915537d60 100644 --- a/docs/user-guide/mathematical-notation/elements/LinearConverter.md +++ b/docs/user-guide/mathematical-notation/elements/LinearConverter.md @@ -1,50 +1,151 @@ -[`LinearConverters`][flixopt.components.LinearConverter] define a ratio between incoming and outgoing [Flows](../elements/Flow.md). +# LinearConverter -$$ \label{eq:Linear-Transformer-Ratio} - \sum_{f_{\text{in}} \in \mathcal F_{in}} \text a_{f_{\text{in}}}(\text{t}_i) \cdot p_{f_\text{in}}(\text{t}_i) = \sum_{f_{\text{out}} \in \mathcal F_{out}} \text b_{f_\text{out}}(\text{t}_i) \cdot p_{f_\text{out}}(\text{t}_i) +A LinearConverter transforms inputs into outputs with fixed ratios. + +## Basic: Conversion Equation + +$$ +\sum_{in} a_f \cdot p_f(t) = \sum_{out} b_f \cdot p_f(t) $$ -With: +=== "Boiler (η = 90%)" + + $0.9 \cdot p_{gas}(t) = p_{heat}(t)$ + + ```python + boiler = fx.LinearConverter( + label='boiler', + inputs=[fx.Flow(label='gas', bus=gas_bus, size=111)], + outputs=[fx.Flow(label='heat', bus=heat_bus, size=100)], + conversion_factors=[{'gas': 0.9, 'heat': 1}], + ) + ``` + +=== "Heat Pump (COP = 3.5)" + + $3.5 \cdot p_{el}(t) = p_{heat}(t)$ + + ```python + hp = fx.LinearConverter( + label='hp', + inputs=[fx.Flow(label='el', bus=elec_bus, size=100)], + outputs=[fx.Flow(label='heat', bus=heat_bus, size=350)], + conversion_factors=[{'el': 3.5, 'heat': 1}], + ) + ``` + +=== "CHP (35% el, 50% th)" + + Two constraints linking fuel to outputs: + + ```python + chp = fx.LinearConverter( + label='chp', + inputs=[fx.Flow(label='fuel', bus=gas_bus, size=100)], + outputs=[ + fx.Flow(label='el', bus=elec_bus, size=35), + fx.Flow(label='heat', bus=heat_bus, size=50), + ], + conversion_factors=[ + {'fuel': 0.35, 'el': 1}, + {'fuel': 0.50, 'heat': 1}, + ], + ) + ``` -- $\mathcal F_{in}$ and $\mathcal F_{out}$ being the set of all incoming and outgoing flows -- $p_{f_\text{in}}(\text{t}_i)$ and $p_{f_\text{out}}(\text{t}_i)$ being the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively -- $\text a_{f_\text{in}}(\text{t}_i)$ and $\text b_{f_\text{out}}(\text{t}_i)$ being the ratio of the flow-rate at time $\text{t}_i$ for flow $f_\text{in}$ and $f_\text{out}$, respectively +--- -With one incoming **Flow** and one outgoing **Flow**, this can be simplified to: +## Time-Varying Efficiency -$$ \label{eq:Linear-Transformer-Ratio-simple} - \text a(\text{t}_i) \cdot p_{f_\text{in}}(\text{t}_i) = p_{f_\text{out}}(\text{t}_i) -$$ +Pass a list for time-dependent conversion: + +```python +cop = np.array([3.0, 3.2, 3.5, 4.0, 3.8, ...]) # Varies with ambient temperature -where $\text a$ can be interpreted as the conversion efficiency of the **LinearConverter**. +hp = fx.LinearConverter( + ..., + conversion_factors=[{'el': cop, 'heat': 1}], +) +``` -#### Piecewise Conversion factors -The conversion efficiency can be defined as a piecewise linear approximation. See [Piecewise](../features/Piecewise.md) for more details. +--- + +## Convenience Classes + +```python +# Boiler +boiler = fx.linear_converters.Boiler( + label='boiler', eta=0.9, + Q_th=fx.Flow(label='heat', bus=heat_bus, size=100), + Q_fu=fx.Flow(label='fuel', bus=gas_bus), +) + +# Heat Pump +hp = fx.linear_converters.HeatPump( + label='hp', COP=3.5, + P_el=fx.Flow(label='el', bus=elec_bus, size=100), + Q_th=fx.Flow(label='heat', bus=heat_bus), +) + +# CHP +chp = fx.linear_converters.CHP( + label='chp', eta_el=0.35, eta_th=0.50, + P_el=fx.Flow(...), Q_th=fx.Flow(...), Q_fu=fx.Flow(...), +) +``` --- -## Implementation +## Adding Features + +=== "Status" -**Python Class:** [`LinearConverter`][flixopt.components.LinearConverter] + A component is active when any of its flows is non-zero. Add startup costs, minimum run times: -**Specialized Linear Converters:** + ```python + gen = fx.LinearConverter( + ..., + status_parameters=fx.StatusParameters( + effects_per_startup={'costs': 1000}, + min_uptime=4, + ), + ) + ``` -FlixOpt provides specialized linear converter classes for common applications: + See [StatusParameters](../features/StatusParameters.md). -- **[`HeatPump`][flixopt.linear_converters.HeatPump]** - Coefficient of Performance (COP) based conversion -- **[`Power2Heat`][flixopt.linear_converters.Power2Heat]** - Electric heating with efficiency ≤ 1 -- **[`CHP`][flixopt.linear_converters.CHP]** - Combined heat and power generation -- **[`Boiler`][flixopt.linear_converters.Boiler]** - Fuel to heat conversion +=== "Piecewise Conversion" -These classes handle the mathematical formulation automatically based on physical relationships. + For variable efficiency — all flows change together based on operating point: -See the API documentation for implementation details and usage examples. + ```python + chp = fx.LinearConverter( + label='CHP', + inputs=[fx.Flow('fuel', bus=gas_bus)], + outputs=[ + fx.Flow('el', bus=elec_bus, size=60), + fx.Flow('heat', bus=heat_bus), + ], + piecewise_conversion=fx.PiecewiseConversion({ + 'el': fx.Piecewise([fx.Piece(5, 30), fx.Piece(40, 60)]), + 'heat': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), + 'fuel': fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), + }), + ) + ``` + + See [Piecewise](../features/Piecewise.md). --- -## See Also +## Reference + +The converter creates **constraints** linking flows, not new variables. + +| Symbol | Type | Description | +|--------|------|-------------| +| $p_f(t)$ | $\mathbb{R}_{\geq 0}$ | Flow rate of flow $f$ at timestep $t$ | +| $a_f$ | $\mathbb{R}$ | Conversion factor for input flow $f$ | +| $b_f$ | $\mathbb{R}$ | Conversion factor for output flow $f$ | -- [Flow](../elements/Flow.md) - Definition of flow rates -- [Piecewise](../features/Piecewise.md) - Non-linear conversion efficiency modeling -- [InvestParameters](../features/InvestParameters.md) - Variable converter sizing -- [Modeling Patterns](../modeling-patterns/index.md) - Mathematical building blocks +**Classes:** [`LinearConverter`][flixopt.components.LinearConverter], [`LinearConverterModel`][flixopt.components.LinearConverterModel] diff --git a/docs/user-guide/mathematical-notation/elements/Storage.md b/docs/user-guide/mathematical-notation/elements/Storage.md index cd7046592..808fefaed 100644 --- a/docs/user-guide/mathematical-notation/elements/Storage.md +++ b/docs/user-guide/mathematical-notation/elements/Storage.md @@ -1,79 +1,120 @@ -# Storages -**Storages** have one incoming and one outgoing **[Flow](../elements/Flow.md)** with a charging and discharging efficiency. -A storage has a state of charge $c(\text{t}_i)$ which is limited by its `size` $\text C$ and relative bounds $\eqref{eq:Storage_Bounds}$. - -$$ \label{eq:Storage_Bounds} - \text C \cdot \text c^{\text{L}}_{\text{rel}}(\text t_{i}) - \leq c(\text{t}_i) \leq - \text C \cdot \text c^{\text{U}}_{\text{rel}}(\text t_{i}) -$$ +# Storage + +A Storage accumulates energy over time — charge now, discharge later. -Where: +## Basic: Charge Dynamics -- $\text C$ is the size of the storage -- $c(\text{t}_i)$ is the state of charge at time $\text{t}_i$ -- $\text c^{\text{L}}_{\text{rel}}(\text t_{i})$ is the relative lower bound (typically 0) -- $\text c^{\text{U}}_{\text{rel}}(\text t_{i})$ is the relative upper bound (typically 1) +$$ +c(t+1) = c(t) \cdot (1 - \dot{c}_{loss})^{\Delta t} + p_{in}(t) \cdot \Delta t \cdot \eta_{in} - p_{out}(t) \cdot \Delta t / \eta_{out} +$$ -With $\text c^{\text{L}}_{\text{rel}}(\text t_{i}) = 0$ and $\text c^{\text{U}}_{\text{rel}}(\text t_{i}) = 1$, -Equation $\eqref{eq:Storage_Bounds}$ simplifies to +```python +battery = fx.Storage( + label='battery', + charging=fx.Flow(label='charge', bus=elec_bus, size=50), + discharging=fx.Flow(label='discharge', bus=elec_bus, size=50), + capacity_in_flow_hours=200, # 200 kWh + eta_charge=0.95, + eta_discharge=0.95, +) +# Round-trip efficiency: 95% × 95% = 90.25% +``` -$$ 0 \leq c(\text t_{i}) \leq \text C $$ +--- -The state of charge $c(\text{t}_i)$ decreases by a fraction of the prior state of charge. The belonging parameter -$ \dot{ \text c}_\text{rel, loss}(\text{t}_i)$ expresses the "loss fraction per hour". The storage balance from $\text{t}_i$ to $\text t_{i+1}$ is +## Charge State Bounds $$ -\begin{align*} - c(\text{t}_{i+1}) &= c(\text{t}_{i}) \cdot (1-\dot{\text{c}}_\text{rel,loss}(\text{t}_i))^{\Delta \text{t}_{i}} \\ - &\quad + p_{f_\text{in}}(\text{t}_i) \cdot \Delta \text{t}_i \cdot \eta_\text{in}(\text{t}_i) \\ - &\quad - p_{f_\text{out}}(\text{t}_i) \cdot \Delta \text{t}_i \cdot \eta_\text{out}(\text{t}_i) - \tag{3} -\end{align*} +C \cdot c_{rel}^{min} \leq c(t) \leq C \cdot c_{rel}^{max} $$ -Where: - -- $c(\text{t}_{i+1})$ is the state of charge at time $\text{t}_{i+1}$ -- $c(\text{t}_{i})$ is the state of charge at time $\text{t}_{i}$ -- $\dot{\text{c}}_\text{rel,loss}(\text{t}_i)$ is the relative loss rate (self-discharge) per hour -- $\Delta \text{t}_{i}$ is the time step duration in hours -- $p_{f_\text{in}}(\text{t}_i)$ is the input flow rate at time $\text{t}_i$ -- $\eta_\text{in}(\text{t}_i)$ is the charging efficiency at time $\text{t}_i$ -- $p_{f_\text{out}}(\text{t}_i)$ is the output flow rate at time $\text{t}_i$ -- $\eta_\text{out}(\text{t}_i)$ is the discharging efficiency at time $\text{t}_i$ +```python +fx.Storage(..., + relative_minimum_charge_state=0.2, # Min 20% SOC + relative_maximum_charge_state=0.8, # Max 80% SOC +) +``` --- -## Mathematical Patterns Used +## Initial & Final Conditions + +=== "Fixed Start" + + ```python + fx.Storage(..., initial_charge_state=100) # Start at 100 kWh + ``` -Storage formulation uses the following modeling patterns: +=== "Cyclic" -- **[Basic Bounds](../modeling-patterns/bounds-and-states.md#basic-bounds)** - For charge state bounds (equation $\eqref{eq:Storage_Bounds}$) -- **[Scaled Bounds](../modeling-patterns/bounds-and-states.md#scaled-bounds)** - For flow rate bounds relative to storage size + Must end where it started (prevents "cheating"): -When combined with investment parameters, storage can use: -- **[Bounds with State](../modeling-patterns/bounds-and-states.md#bounds-with-state)** - Investment decisions (see [InvestParameters](../features/InvestParameters.md)) + ```python + fx.Storage(..., initial_charge_state='equals_final') + ``` + +=== "Final Bounds" + + ```python + fx.Storage(..., + minimal_final_charge_state=50, + maximal_final_charge_state=150, + ) + ``` --- -## Implementation +## Adding Features -**Python Class:** [`Storage`][flixopt.components.Storage] +=== "Self-Discharge" -**Key Parameters:** -- `capacity_in_flow_hours`: Storage capacity $\text{C}$ -- `relative_loss_per_hour`: Self-discharge rate $\dot{\text{c}}_\text{rel,loss}$ -- `initial_charge_state`: Initial charge $c(\text{t}_0)$ -- `minimal_final_charge_state`, `maximal_final_charge_state`: Final charge bounds $c(\text{t}_\text{end})$ (optional) -- `eta_charge`, `eta_discharge`: Charging/discharging efficiencies $\eta_\text{in}, \eta_\text{out}$ + ```python + tank = fx.Storage(..., + relative_loss_per_hour=0.02, # 2%/hour loss + ) + ``` -See the [`Storage`][flixopt.components.Storage] API documentation for complete parameter list and usage examples. +=== "Variable Capacity" ---- + Optimize storage size: + + ```python + battery = fx.Storage(..., + capacity_in_flow_hours=fx.InvestParameters( + minimum_size=0, + maximum_size=1000, + specific_effects={'costs': 200}, # €/kWh + ), + ) + ``` -## See Also +=== "Asymmetric Power" + + Different charge/discharge rates: + + ```python + fx.Storage( + charging=fx.Flow(..., size=100), # 100 MW pump + discharging=fx.Flow(..., size=120), # 120 MW turbine + ... + ) + ``` + +--- -- [Flow](../elements/Flow.md) - Input and output flow definitions -- [InvestParameters](../features/InvestParameters.md) - Variable storage sizing -- [Modeling Patterns](../modeling-patterns/index.md) - Mathematical building blocks +## Reference + +| Symbol | Type | Description | +|--------|------|-------------| +| $c(t)$ | $\mathbb{R}_{\geq 0}$ | Charge state at timestep $t$ | +| $C$ | $\mathbb{R}_{\geq 0}$ | Capacity (`capacity_in_flow_hours`) | +| $p_{in}(t)$ | $\mathbb{R}_{\geq 0}$ | Charging power (from `charging` flow) | +| $p_{out}(t)$ | $\mathbb{R}_{\geq 0}$ | Discharging power (from `discharging` flow) | +| $\eta_{in}$ | $\mathbb{R}_{\geq 0}$ | Charge efficiency (`eta_charge`) | +| $\eta_{out}$ | $\mathbb{R}_{\geq 0}$ | Discharge efficiency (`eta_discharge`) | +| $\dot{c}_{loss}$ | $\mathbb{R}_{\geq 0}$ | Self-discharge rate (`relative_loss_per_hour`) | +| $c_{rel}^{min}$ | $\mathbb{R}_{\geq 0}$ | Min charge state (`relative_minimum_charge_state`) | +| $c_{rel}^{max}$ | $\mathbb{R}_{\geq 0}$ | Max charge state (`relative_maximum_charge_state`) | +| $\Delta t$ | $\mathbb{R}_{> 0}$ | Timestep duration (hours) | + +**Classes:** [`Storage`][flixopt.components.Storage], [`StorageModel`][flixopt.components.StorageModel] diff --git a/docs/user-guide/mathematical-notation/features/InvestParameters.md b/docs/user-guide/mathematical-notation/features/InvestParameters.md index 14fe02c79..b6e1afe6b 100644 --- a/docs/user-guide/mathematical-notation/features/InvestParameters.md +++ b/docs/user-guide/mathematical-notation/features/InvestParameters.md @@ -1,302 +1,143 @@ # InvestParameters -[`InvestParameters`][flixopt.interface.InvestParameters] model investment decisions in optimization problems, enabling both binary (invest/don't invest) and continuous sizing choices with comprehensive cost modeling. +InvestParameters make capacity a decision variable — should we build this? How big? -## Investment Decision Types +## Basic: Size as Variable -FlixOpt supports two main types of investment decisions: - -### Binary Investment - -Fixed-size investment creating a yes/no decision (e.g., install a 100 kW generator): - -$$\label{eq:invest_binary} -v_\text{invest} = s_\text{invest} \cdot \text{size}_\text{fixed} -$$ - -With: -- $v_\text{invest}$ being the resulting investment size -- $s_\text{invest} \in \{0, 1\}$ being the binary investment decision -- $\text{size}_\text{fixed}$ being the predefined component size - -**Behavior:** -- $s_\text{invest} = 0$: no investment ($v_\text{invest} = 0$) -- $s_\text{invest} = 1$: invest at fixed size ($v_\text{invest} = \text{size}_\text{fixed}$) - ---- - -### Continuous Sizing - -Variable-size investment with bounds (e.g., battery capacity from 10-1000 kWh): - -$$\label{eq:invest_continuous} -s_\text{invest} \cdot \text{size}_\text{min} \leq v_\text{invest} \leq s_\text{invest} \cdot \text{size}_\text{max} -$$ - -With: -- $v_\text{invest}$ being the investment size variable (continuous) -- $s_\text{invest} \in \{0, 1\}$ being the binary investment decision -- $\text{size}_\text{min}$ being the minimum investment size (if investing) -- $\text{size}_\text{max}$ being the maximum investment size - -**Behavior:** -- $s_\text{invest} = 0$: no investment ($v_\text{invest} = 0$) -- $s_\text{invest} = 1$: invest with size in $[\text{size}_\text{min}, \text{size}_\text{max}]$ - -This uses the **bounds with state** pattern described in [Bounds and States](../modeling-patterns/bounds-and-states.md#bounds-with-state). - ---- - -### Optional vs. Mandatory Investment - -The `mandatory` parameter controls whether investment is required: - -**Optional Investment** (`mandatory=False`, default): -$$\label{eq:invest_optional} -s_\text{invest} \in \{0, 1\} -$$ - -The optimization can freely choose to invest or not. - -**Mandatory Investment** (`mandatory=True`): -$$\label{eq:invest_mandatory} -s_\text{invest} = 1 $$ - -The investment must occur (useful for mandatory upgrades or replacements). - ---- - -## Effect Modeling - -Investment effects (costs, emissions, etc.) are modeled using three components: - -### Fixed Effects - -One-time effects incurred if investment is made, independent of size: - -$$\label{eq:invest_fixed_effects} -E_{e,\text{fix}} = s_\text{invest} \cdot \text{fix}_e -$$ - -With: -- $E_{e,\text{fix}}$ being the fixed contribution to effect $e$ -- $\text{fix}_e$ being the fixed effect value (e.g., fixed installation cost) - -**Examples:** -- Fixed installation costs (permits, grid connection) -- One-time environmental impacts (land preparation) -- Fixed labor or administrative costs - ---- - -### Specific Effects - -Effects proportional to investment size (per-unit costs): - -$$\label{eq:invest_specific_effects} -E_{e,\text{spec}} = v_\text{invest} \cdot \text{spec}_e +P^{min} \leq P \leq P^{max} $$ -With: -- $E_{e,\text{spec}}$ being the size-dependent contribution to effect $e$ -- $\text{spec}_e$ being the specific effect value per unit size (e.g., €/kW) - -**Examples:** -- Equipment costs (€/kW) -- Material requirements (kg steel/kW) -- Recurring costs (€/kW/year maintenance) - ---- - -### Piecewise Effects - -Non-linear effect relationships using piecewise linear approximations: - -$$\label{eq:invest_piecewise_effects} -E_{e,\text{pw}} = \sum_{k=1}^{K} \lambda_k \cdot r_{e,k} -$$ - -Subject to: -$$ -v_\text{invest} = \sum_{k=1}^{K} \lambda_k \cdot v_k -$$ - -With: -- $E_{e,\text{pw}}$ being the piecewise contribution to effect $e$ -- $\lambda_k$ being the piecewise lambda variables (see [Piecewise](../features/Piecewise.md)) -- $r_{e,k}$ being the effect rate at piece $k$ -- $v_k$ being the size points defining the pieces - -**Use cases:** -- Economies of scale (bulk discounts) -- Technology learning curves -- Threshold effects (capacity tiers with different costs) - -See [Piecewise](../features/Piecewise.md) for detailed mathematical formulation. +```python +battery = fx.Storage( + ..., + capacity_in_flow_hours=fx.InvestParameters( + minimum_size=10, + maximum_size=1000, + specific_effects={'costs': 600}, # €600/kWh + ), +) +``` --- -### Retirement Effects +## Investment Modes -Effects incurred if investment is NOT made (when retiring/not replacing existing equipment): +By default, investment is **optional** — the optimizer can choose $P = 0$ (don't invest). -$$\label{eq:invest_retirement_effects} -E_{e,\text{retirement}} = (1 - s_\text{invest}) \cdot \text{retirement}_e -$$ +=== "Continuous" -With: -- $E_{e,\text{retirement}}$ being the retirement contribution to effect $e$ -- $\text{retirement}_e$ being the retirement effect value + Choose size within range (or zero): -**Behavior:** -- $s_\text{invest} = 0$: retirement effects are incurred -- $s_\text{invest} = 1$: no retirement effects + ```python + fx.InvestParameters( + minimum_size=10, + maximum_size=1000, + ) + # → P = 0 OR 10 ≤ P ≤ 1000 + ``` -**Examples:** -- Demolition or disposal costs -- Decommissioning expenses -- Contractual penalties for not investing -- Opportunity costs or lost revenues +=== "Binary" ---- + Fixed size or nothing: -### Total Investment Effects + ```python + fx.InvestParameters( + fixed_size=100, # 100 kW or 0 + ) + # → P ∈ {0, 100} + ``` -The total contribution to effect $e$ from an investment is: +=== "Mandatory" -$$\label{eq:invest_total_effects} -E_{e,\text{invest}} = E_{e,\text{fix}} + E_{e,\text{spec}} + E_{e,\text{pw}} + E_{e,\text{retirement}} -$$ + Force investment with `mandatory=True` — zero not allowed: -Effects integrate into the overall system effects as described in [Effects, Penalty & Objective](../effects-penalty-objective.md). + ```python + fx.InvestParameters( + minimum_size=50, + maximum_size=200, + mandatory=True, + ) + # → 50 ≤ P ≤ 200 (no zero option) + ``` --- -## Integration with Components +## Investment Effects -Investment parameters modify component sizing: +=== "Per-Size Cost" -### Without Investment -Component size is a fixed parameter: -$$ -\text{size} = \text{size}_\text{nominal} -$$ + Cost proportional to capacity (€/kW): -### With Investment -Component size becomes a variable: -$$ -\text{size} = v_\text{invest} -$$ + $E = P \cdot c_{spec}$ -This size variable then appears in component constraints. For example, flow rate bounds become: + ```python + fx.InvestParameters( + specific_effects={'costs': 1200}, # €1200/kW + ) + ``` -$$ -v_\text{invest} \cdot \text{rel}_\text{lower} \leq p(t) \leq v_\text{invest} \cdot \text{rel}_\text{upper} -$$ +=== "Fixed Cost" -Using the **scaled bounds** pattern from [Bounds and States](../modeling-patterns/bounds-and-states.md#scaled-bounds). + One-time cost if investing: ---- + $E = s_{inv} \cdot c_{fix}$ -## Cost Annualization + ```python + fx.InvestParameters( + effects_of_investment={'costs': 25000}, # €25k + ) + ``` -**Important:** All investment cost values must be properly weighted to match the optimization model's time horizon. +=== "Retirement Cost" -For long-term investments, costs should be annualized: + Cost if NOT investing: -$$\label{eq:annualization} -\text{cost}_\text{annual} = \frac{\text{cost}_\text{capital} \cdot r}{1 - (1 + r)^{-n}} -$$ - -With: -- $\text{cost}_\text{capital}$ being the upfront investment cost -- $r$ being the discount rate -- $n$ being the equipment lifetime in years - -**Example:** €1,000,000 equipment with 20-year life and 5% discount rate -$$ -\text{cost}_\text{annual} = \frac{1{,}000{,}000 \cdot 0.05}{1 - (1.05)^{-20}} \approx €80{,}243/\text{year} -$$ + $E = (1 - s_{inv}) \cdot c_{ret}$ ---- + ```python + fx.InvestParameters( + effects_of_retirement={'costs': 8000}, # Demolition + ) + ``` -## Implementation +=== "Piecewise Cost" -**Python Class:** [`InvestParameters`][flixopt.interface.InvestParameters] + Non-linear cost curves (e.g., economies of scale): -**Key Parameters:** -- `fixed_size`: For binary investments (mutually exclusive with continuous sizing) -- `minimum_size`, `maximum_size`: For continuous sizing -- `mandatory`: Whether investment is required (default: `False`) -- `effects_of_investment`: Fixed effects incurred when investing (replaces deprecated `fix_effects`) -- `effects_of_investment_per_size`: Per-unit effects proportional to size (replaces deprecated `specific_effects`) -- `piecewise_effects_of_investment`: Non-linear effect modeling (replaces deprecated `piecewise_effects`) -- `effects_of_retirement`: Effects for not investing (replaces deprecated `divest_effects`) + $E = f_{piecewise}(P)$ -See the [`InvestParameters`][flixopt.interface.InvestParameters] API documentation for complete parameter list and usage examples. + ```python + fx.InvestParameters( + piecewise_effects_of_investment=fx.PiecewiseEffects( + piecewise_origin=fx.Piecewise([ + fx.Piece(0, 100), + fx.Piece(100, 500), + ]), + piecewise_shares={ + 'costs': fx.Piecewise([ + fx.Piece(0, 80_000), # €800/kW for 0-100 + fx.Piece(80_000, 280_000), # €500/kW for 100-500 + ]) + }, + ), + ) + ``` -**Used in:** -- [`Flow`][flixopt.elements.Flow] - Flexible capacity decisions -- [`Storage`][flixopt.components.Storage] - Storage sizing optimization -- [`LinearConverter`][flixopt.components.LinearConverter] - Converter capacity planning -- All components supporting investment decisions + See [Piecewise](Piecewise.md) for details on the formulation. --- -## Examples +## Reference -### Binary Investment (Solar Panels) -```python -solar_investment = InvestParameters( - fixed_size=100, # 100 kW system - mandatory=False, # Optional investment (default) - effects_of_investment={'cost': 25000}, # Installation costs - effects_of_investment_per_size={'cost': 1200}, # €1200/kW -) -``` +| Symbol | Type | Description | +|--------|------|-------------| +| $P$ | $\mathbb{R}_{\geq 0}$ | Investment size (capacity) | +| $s_{inv}$ | $\{0, 1\}$ | Binary investment decision (0=no, 1=yes) | +| $P^{min}$ | $\mathbb{R}_{\geq 0}$ | Minimum size (`minimum_size`) | +| $P^{max}$ | $\mathbb{R}_{\geq 0}$ | Maximum size (`maximum_size`) | +| $c_{spec}$ | $\mathbb{R}$ | Per-size effect (`effects_of_investment_per_size`) | +| $c_{fix}$ | $\mathbb{R}$ | Fixed effect (`effects_of_investment`) | +| $c_{ret}$ | $\mathbb{R}$ | Retirement effect (`effects_of_retirement`) | -### Continuous Sizing (Battery) -```python -battery_investment = InvestParameters( - minimum_size=10, # kWh - maximum_size=1000, - mandatory=False, # Optional investment (default) - effects_of_investment={'cost': 5000}, # Grid connection - effects_of_investment_per_size={'cost': 600}, # €600/kWh -) -``` - -### With Retirement Costs (Replacement) -```python -boiler_replacement = InvestParameters( - minimum_size=50, # kW - maximum_size=200, - mandatory=False, # Optional investment (default) - effects_of_investment={'cost': 15000}, - effects_of_investment_per_size={'cost': 400}, - effects_of_retirement={'cost': 8000}, # Demolition if not replaced -) -``` - -### Economies of Scale (Piecewise) -```python -battery_investment = InvestParameters( - minimum_size=10, - maximum_size=1000, - piecewise_effects_of_investment=PiecewiseEffects( - piecewise_origin=Piecewise([ - Piece(0, 100), # Small - Piece(100, 500), # Medium - Piece(500, 1000), # Large - ]), - piecewise_shares={ - 'cost': Piecewise([ - Piece(800, 750), # €800-750/kWh - Piece(750, 600), # €750-600/kWh - Piece(600, 500), # €600-500/kWh (bulk discount) - ]) - }, - ), -) -``` +**Classes:** [`InvestParameters`][flixopt.interface.InvestParameters], [`InvestmentModel`][flixopt.features.InvestmentModel] diff --git a/docs/user-guide/mathematical-notation/features/OnOffParameters.md b/docs/user-guide/mathematical-notation/features/OnOffParameters.md deleted file mode 100644 index 6bf40fec9..000000000 --- a/docs/user-guide/mathematical-notation/features/OnOffParameters.md +++ /dev/null @@ -1,307 +0,0 @@ -# OnOffParameters - -[`OnOffParameters`][flixopt.interface.OnOffParameters] model equipment that operates in discrete on/off states rather than continuous operation. This captures realistic operational constraints including startup costs, minimum run times, cycling limitations, and maintenance scheduling. - -## Binary State Variable - -Equipment operation is modeled using a binary state variable: - -$$\label{eq:onoff_state} -s(t) \in \{0, 1\} \quad \forall t -$$ - -With: -- $s(t) = 1$: equipment is operating (on state) -- $s(t) = 0$: equipment is shutdown (off state) - -This state variable controls the equipment's operational constraints and modifies flow bounds using the **bounds with state** pattern from [Bounds and States](../modeling-patterns/bounds-and-states.md#bounds-with-state). - ---- - -## State Transitions and Switching - -State transitions are tracked using switch variables (see [State Transitions](../modeling-patterns/state-transitions.md#binary-state-transitions)): - -$$\label{eq:onoff_transitions} -s^\text{on}(t) - s^\text{off}(t) = s(t) - s(t-1) \quad \forall t > 0 -$$ - -$$\label{eq:onoff_switch_exclusivity} -s^\text{on}(t) + s^\text{off}(t) \leq 1 \quad \forall t -$$ - -With: -- $s^\text{on}(t) \in \{0, 1\}$: equals 1 when switching from off to on (startup) -- $s^\text{off}(t) \in \{0, 1\}$: equals 1 when switching from on to off (shutdown) - -**Behavior:** -- Off → On: $s^\text{on}(t) = 1, s^\text{off}(t) = 0$ -- On → Off: $s^\text{on}(t) = 0, s^\text{off}(t) = 1$ -- No change: $s^\text{on}(t) = 0, s^\text{off}(t) = 0$ - ---- - -## Effects and Costs - -### Switching Effects - -Effects incurred when equipment starts up: - -$$\label{eq:onoff_switch_effects} -E_{e,\text{switch}} = \sum_{t} s^\text{on}(t) \cdot \text{effect}_{e,\text{switch}} -$$ - -With: -- $\text{effect}_{e,\text{switch}}$ being the effect value per startup event - -**Examples:** -- Startup fuel consumption -- Wear and tear costs -- Labor costs for startup procedures -- Inrush power demands - ---- - -### Running Effects - -Effects incurred while equipment is operating: - -$$\label{eq:onoff_running_effects} -E_{e,\text{run}} = \sum_{t} s(t) \cdot \Delta t \cdot \text{effect}_{e,\text{run}} -$$ - -With: -- $\text{effect}_{e,\text{run}}$ being the effect rate per operating hour -- $\Delta t$ being the time step duration - -**Examples:** -- Fixed operating and maintenance costs -- Auxiliary power consumption -- Consumable materials -- Emissions while running - ---- - -## Operating Hour Constraints - -### Total Operating Hours - -Bounds on total operating time across the planning horizon: - -$$\label{eq:onoff_total_hours} -h_\text{min} \leq \sum_{t} s(t) \cdot \Delta t \leq h_\text{max} -$$ - -With: -- $h_\text{min}$ being the minimum total operating hours -- $h_\text{max}$ being the maximum total operating hours - -**Use cases:** -- Minimum runtime requirements (contracts, maintenance) -- Maximum runtime limits (fuel availability, permits, equipment life) - ---- - -### Consecutive Operating Hours - -**Minimum Consecutive On-Time:** - -Enforces minimum runtime once started using duration tracking (see [Duration Tracking](../modeling-patterns/duration-tracking.md#minimum-duration-constraints)): - -$$\label{eq:onoff_min_on_duration} -d^\text{on}(t) \geq (s(t-1) - s(t)) \cdot h^\text{on}_\text{min} \quad \forall t > 0 -$$ - -With: -- $d^\text{on}(t)$ being the consecutive on-time duration at time $t$ -- $h^\text{on}_\text{min}$ being the minimum required on-time - -**Behavior:** -- When shutting down at time $t$: enforces equipment was on for at least $h^\text{on}_\text{min}$ prior to the switch -- Prevents short cycling and frequent startups - -**Maximum Consecutive On-Time:** - -Limits continuous operation before requiring shutdown: - -$$\label{eq:onoff_max_on_duration} -d^\text{on}(t) \leq h^\text{on}_\text{max} \quad \forall t -$$ - -**Use cases:** -- Mandatory maintenance intervals -- Process batch time limits -- Thermal cycling requirements - ---- - -### Consecutive Shutdown Hours - -**Minimum Consecutive Off-Time:** - -Enforces minimum shutdown duration before restarting: - -$$\label{eq:onoff_min_off_duration} -d^\text{off}(t) \geq (s(t) - s(t-1)) \cdot h^\text{off}_\text{min} \quad \forall t > 0 -$$ - -With: -- $d^\text{off}(t)$ being the consecutive off-time duration at time $t$ -- $h^\text{off}_\text{min}$ being the minimum required off-time - -**Use cases:** -- Cooling periods -- Maintenance requirements -- Process stabilization - -**Maximum Consecutive Off-Time:** - -Limits shutdown duration before mandatory restart: - -$$\label{eq:onoff_max_off_duration} -d^\text{off}(t) \leq h^\text{off}_\text{max} \quad \forall t -$$ - -**Use cases:** -- Equipment preservation requirements -- Process stability needs -- Contractual minimum activity levels - ---- - -## Cycling Limits - -Maximum number of startups across the planning horizon: - -$$\label{eq:onoff_max_switches} -\sum_{t} s^\text{on}(t) \leq n_\text{max} -$$ - -With: -- $n_\text{max}$ being the maximum allowed number of startups - -**Use cases:** -- Preventing excessive equipment wear -- Grid stability requirements -- Operational complexity limits -- Maintenance budget constraints - ---- - -## Integration with Flow Bounds - -OnOffParameters modify flow rate bounds by coupling them to the on/off state. - -**Without OnOffParameters** (continuous operation): -$$ -P \cdot \text{rel}_\text{lower} \leq p(t) \leq P \cdot \text{rel}_\text{upper} -$$ - -**With OnOffParameters** (binary operation): -$$ -s(t) \cdot P \cdot \max(\varepsilon, \text{rel}_\text{lower}) \leq p(t) \leq s(t) \cdot P \cdot \text{rel}_\text{upper} -$$ - -Using the **bounds with state** pattern from [Bounds and States](../modeling-patterns/bounds-and-states.md#bounds-with-state). - -**Behavior:** -- When $s(t) = 0$: flow is forced to zero -- When $s(t) = 1$: flow follows normal bounds - ---- - -## Complete Formulation Summary - -For equipment with OnOffParameters, the complete constraint system includes: - -1. **State variable:** $s(t) \in \{0, 1\}$ -2. **Switch tracking:** $s^\text{on}(t) - s^\text{off}(t) = s(t) - s(t-1)$ -3. **Switch exclusivity:** $s^\text{on}(t) + s^\text{off}(t) \leq 1$ -4. **Duration tracking:** - - On-duration: $d^\text{on}(t)$ following duration tracking pattern - - Off-duration: $d^\text{off}(t)$ following duration tracking pattern -5. **Minimum on-time:** $d^\text{on}(t) \geq (s(t-1) - s(t)) \cdot h^\text{on}_\text{min}$ -6. **Maximum on-time:** $d^\text{on}(t) \leq h^\text{on}_\text{max}$ -7. **Minimum off-time:** $d^\text{off}(t) \geq (s(t) - s(t-1)) \cdot h^\text{off}_\text{min}$ -8. **Maximum off-time:** $d^\text{off}(t) \leq h^\text{off}_\text{max}$ -9. **Total hours:** $h_\text{min} \leq \sum_t s(t) \cdot \Delta t \leq h_\text{max}$ -10. **Cycling limit:** $\sum_t s^\text{on}(t) \leq n_\text{max}$ -11. **Flow bounds:** $s(t) \cdot P \cdot \text{rel}_\text{lower} \leq p(t) \leq s(t) \cdot P \cdot \text{rel}_\text{upper}$ - ---- - -## Implementation - -**Python Class:** [`OnOffParameters`][flixopt.interface.OnOffParameters] - -**Key Parameters:** -- `effects_per_switch_on`: Costs per startup event -- `effects_per_running_hour`: Costs per hour of operation -- `on_hours_min`, `on_hours_max`: Total runtime bounds -- `consecutive_on_hours_min`, `consecutive_on_hours_max`: Consecutive runtime bounds -- `consecutive_off_hours_min`, `consecutive_off_hours_max`: Consecutive shutdown bounds -- `switch_on_max`: Maximum number of startups -- `force_switch_on`: Create switch variables even without limits (for tracking) - -See the [`OnOffParameters`][flixopt.interface.OnOffParameters] API documentation for complete parameter list and usage examples. - -**Mathematical Patterns Used:** -- [State Transitions](../modeling-patterns/state-transitions.md#binary-state-transitions) - Switch tracking -- [Duration Tracking](../modeling-patterns/duration-tracking.md) - Consecutive time constraints -- [Bounds with State](../modeling-patterns/bounds-and-states.md#bounds-with-state) - Flow control - -**Used in:** -- [`Flow`][flixopt.elements.Flow] - On/off operation for flows -- All components supporting discrete operational states - ---- - -## Examples - -### Power Plant with Startup Costs -```python -power_plant = OnOffParameters( - effects_per_switch_on={'startup_cost': 25000}, # €25k per startup - effects_per_running_hour={'fixed_om': 125}, # €125/hour while running - consecutive_on_hours_min=8, # Minimum 8-hour run - consecutive_off_hours_min=4, # 4-hour cooling period - on_hours_max=6000, # Annual limit -) -``` - -### Batch Process with Cycling Limits -```python -batch_reactor = OnOffParameters( - effects_per_switch_on={'setup_cost': 1500}, - consecutive_on_hours_min=12, # 12-hour minimum batch - consecutive_on_hours_max=24, # 24-hour maximum batch - consecutive_off_hours_min=6, # Cleaning time - switch_on_max=200, # Max 200 batches -) -``` - -### HVAC with Cycle Prevention -```python -hvac = OnOffParameters( - effects_per_switch_on={'compressor_wear': 0.5}, - consecutive_on_hours_min=1, # Prevent short cycling - consecutive_off_hours_min=0.5, # 30-min minimum off - switch_on_max=2000, # Limit compressor starts -) -``` - -### Backup Generator with Testing Requirements -```python -backup_gen = OnOffParameters( - effects_per_switch_on={'fuel_priming': 50}, # L diesel - consecutive_on_hours_min=0.5, # 30-min test duration - consecutive_off_hours_max=720, # Test every 30 days - on_hours_min=26, # Weekly testing requirement -) -``` - ---- - -## Notes - -**Time Series Boundary:** The final time period constraints for consecutive_on_hours_min/max and consecutive_off_hours_min/max are not enforced at the end of the planning horizon. This allows optimization to end with ongoing campaigns that may be shorter/longer than specified, as they extend beyond the modeled period. diff --git a/docs/user-guide/mathematical-notation/features/Piecewise.md b/docs/user-guide/mathematical-notation/features/Piecewise.md index 688ac8cea..da6405b52 100644 --- a/docs/user-guide/mathematical-notation/features/Piecewise.md +++ b/docs/user-guide/mathematical-notation/features/Piecewise.md @@ -1,49 +1,155 @@ # Piecewise -A Piecewise is a collection of [`Pieces`][flixopt.interface.Piece], which each define a valid range for a variable $v$ +Piecewise linearization approximates non-linear relationships using connected linear segments. + +## Mathematical Formulation + +A piecewise linear function with $n$ segments uses per-segment interpolation: -$$ \label{eq:active_piece} - \beta_\text{k} = \lambda_\text{0, k} + \lambda_\text{1, k} $$ +x = \sum_{i=1}^{n} \left( \lambda_i^0 \cdot x_i^{start} + \lambda_i^1 \cdot x_i^{end} \right) +$$ + +Each segment $i$ has: -$$ \label{eq:piece} - v_\text{k} = \lambda_\text{0, k} * \text{v}_{\text{start,k}} + \lambda_\text{1,k} * \text{v}_{\text{end,k}} +- $s_i \in \{0, 1\}$ — binary indicating if segment is active +- $\lambda_i^0, \lambda_i^1 \geq 0$ — interpolation weights for segment endpoints + +Constraints ensure valid interpolation: + +$$ +\lambda_i^0 + \lambda_i^1 = s_i \quad \forall i $$ -$$ \label{eq:piecewise_in_pieces} -\sum_{k=1}^k \beta_{k} = 1 +$$ +\sum_{i=1}^{n} s_i \leq 1 $$ -With: +When segment $i$ is active ($s_i = 1$), the lambdas interpolate between $x_i^{start}$ and $x_i^{end}$. When inactive ($s_i = 0$), both lambdas are zero. -- $v$: The variable to be defined by the Piecewise -- $\text{v}_{\text{start,k}}$: the start point of the piece for variable $v$ -- $\text{v}_{\text{end,k}}$: the end point of the piece for variable $v$ -- $\beta_\text{k} \in \{0, 1\}$: defining wether the Piece $k$ is active -- $\lambda_\text{0,k} \in [0, 1]$: A variable defining the fraction of $\text{v}_{\text{start,k}}$ that is active -- $\lambda_\text{1,k} \in [0, 1]$: A variable defining the fraction of $\text{v}_{\text{end,k}}$ that is active +!!! note "Implementation Note" + This formulation is an explicit binary reformulation of SOS2 (Special Ordered Set Type 2) constraints. It produces identical results but uses more variables. We will migrate to native SOS2 constraints once [linopy](https://github.com/PyPSA/linopy) supports them. -Which can also be described as $v \in 0 \cup [\text{v}_\text{start}, \text{v}_\text{end}]$. +--- -Instead of \eqref{eq:piecewise_in_pieces}, the following constraint is used to also allow all variables to be zero: +## Building Blocks -$$ \label{eq:piecewise_in_pieces_zero} -\sum_{k=1}^k \beta_{k} = \beta_\text{zero} -$$ +=== "Piece" + + A linear segment from start to end value: + + ```python + fx.Piece(start=10, end=50) # Linear from 10 to 50 + ``` + + Values can be time-varying: + + ```python + fx.Piece( + start=np.linspace(5, 6, n_timesteps), + end=np.linspace(30, 35, n_timesteps) + ) + ``` + +=== "Piecewise" + + Multiple segments forming a piecewise linear function: + + ```python + fx.Piecewise([ + fx.Piece(0, 30), # Segment 1: 0 → 30 + fx.Piece(30, 60), # Segment 2: 30 → 60 + ]) + ``` + +=== "PiecewiseConversion" + + Synchronizes multiple flows — all interpolate at the same relative position: + + ```python + fx.PiecewiseConversion({ + 'input_flow': fx.Piecewise([...]), + 'output_flow': fx.Piecewise([...]), + }) + ``` + + All piecewise functions must have the same number of segments. + +=== "PiecewiseEffects" + + Maps a size/capacity variable to effects (costs, emissions): + + ```python + fx.PiecewiseEffects( + piecewise_origin=fx.Piecewise([...]), # Size segments + piecewise_shares={'costs': fx.Piecewise([...])}, # Effect segments + ) + ``` + +--- + +## Usage + +=== "Variable Efficiency" -With: + Converter efficiency that varies with load: -- $\beta_\text{zero} \in \{0, 1\}$. + ```python + chp = fx.LinearConverter( + ..., + piecewise_conversion=fx.PiecewiseConversion({ + 'el': fx.Piecewise([fx.Piece(5, 30), fx.Piece(40, 60)]), + 'heat': fx.Piecewise([fx.Piece(6, 35), fx.Piece(45, 100)]), + 'fuel': fx.Piecewise([fx.Piece(12, 70), fx.Piece(90, 200)]), + }), + ) + ``` -Which can also be described as $v \in \{0\} \cup [\text{v}_{\text{start_k}}, \text{v}_{\text{end_k}}]$ +=== "Economies of Scale" + Investment cost per unit decreases with size: -## Combining multiple Piecewises + ```python + fx.InvestParameters( + piecewise_effects_of_investment=fx.PiecewiseEffects( + piecewise_origin=fx.Piecewise([ + fx.Piece(0, 100), + fx.Piece(100, 500), + ]), + piecewise_shares={ + 'costs': fx.Piecewise([ + fx.Piece(0, 80_000), + fx.Piece(80_000, 280_000), + ]) + }, + ), + ) + ``` -Piecewise allows representing non-linear relationships. -This is a powerful technique in linear optimization to model non-linear behaviors while maintaining the problem's linearity. +=== "Forbidden Operating Region" -Therefore, each Piecewise must have the same number of Pieces $k$. + Equipment cannot operate in certain ranges: -The variables described in [Piecewise](#piecewise) are created for each Piece, but nor for each Piecewise. -Rather, \eqref{eq:piece} is the only constraint that is created for each Piecewise, using the start and endpoints $\text{v}_{\text{start,k}}$ and $\text{v}_{\text{end,k}}$ of each Piece for the corresponding variable $v$ + ```python + fx.PiecewiseConversion({ + 'fuel': fx.Piecewise([fx.Piece(0, 0), fx.Piece(40, 100)]), + 'power': fx.Piecewise([fx.Piece(0, 0), fx.Piece(35, 95)]), + }) + # Either off (0,0) or operating above 40% + ``` + +--- + +## Reference + +| Symbol | Type | Description | +|--------|------|-------------| +| $x$ | $\mathbb{R}$ | Interpolated variable value | +| $s_i$ | $\{0, 1\}$ | Binary: segment $i$ is active | +| $\lambda_i^0$ | $[0, 1]$ | Interpolation weight for segment start | +| $\lambda_i^1$ | $[0, 1]$ | Interpolation weight for segment end | +| $x_i^{start}$ | $\mathbb{R}$ | Start value of segment $i$ | +| $x_i^{end}$ | $\mathbb{R}$ | End value of segment $i$ | +| $n$ | $\mathbb{Z}_{> 0}$ | Number of segments | + +**Classes:** [`Piecewise`][flixopt.interface.Piecewise], [`Piece`][flixopt.interface.Piece], [`PiecewiseConversion`][flixopt.interface.PiecewiseConversion], [`PiecewiseEffects`][flixopt.interface.PiecewiseEffects] diff --git a/docs/user-guide/mathematical-notation/features/StatusParameters.md b/docs/user-guide/mathematical-notation/features/StatusParameters.md new file mode 100644 index 000000000..7b4c08f72 --- /dev/null +++ b/docs/user-guide/mathematical-notation/features/StatusParameters.md @@ -0,0 +1,114 @@ +# StatusParameters + +StatusParameters add on/off behavior to flows — startup costs, minimum run times, cycling limits. + +## Basic: Binary Status + +A status variable $s(t) \in \{0, 1\}$ controls whether equipment is active: + +```python +generator = fx.Flow( + label='power', bus=elec_bus, size=100, + relative_minimum=0.4, # 40% min when ON + status_parameters=fx.StatusParameters( + effects_per_startup={'costs': 25000}, # €25k per startup + ), +) +``` + +When $s(t) = 0$: flow is zero. When $s(t) = 1$: flow bounds apply. + +--- + +## Startup Tracking + +Detect transitions: $s^{start}(t) - s^{stop}(t) = s(t) - s(t-1)$ + +=== "Startup Costs" + + ```python + fx.StatusParameters( + effects_per_startup={'costs': 25000}, + ) + ``` + +=== "Running Costs" + + ```python + fx.StatusParameters( + effects_per_active_hour={'costs': 100}, # €/h while on + ) + ``` + +=== "Startup Limit" + + ```python + fx.StatusParameters( + startup_limit=20, # Max 20 starts per period + ) + ``` + +--- + +## Duration Constraints + +=== "Min Uptime" + + Once on, must stay on for minimum duration: + + $s^{start}(t) = 1 \Rightarrow \sum_{j=t}^{t+k} s(j) \geq T_{up}^{min}$ + + ```python + fx.StatusParameters(min_uptime=8) # 8 hours minimum + ``` + +=== "Min Downtime" + + Once off, must stay off for minimum duration: + + $s^{stop}(t) = 1 \Rightarrow \sum_{j=t}^{t+k} (1 - s(j)) \geq T_{down}^{min}$ + + ```python + fx.StatusParameters(min_downtime=4) # 4 hours cooling + ``` + +=== "Max Uptime" + + Force shutdown after limit: + + $\sum_{j=t-k}^{t} s(j) \leq T_{up}^{max}$ + + ```python + fx.StatusParameters(max_uptime=18) # Max 18h continuous + ``` + +=== "Total Hours" + + Limit total operating hours per period: + + $H^{min} \leq \sum_t s(t) \cdot \Delta t \leq H^{max}$ + + ```python + fx.StatusParameters( + active_hours_min=2000, + active_hours_max=5000, + ) + ``` + +--- + +## Reference + +| Symbol | Type | Description | +|--------|------|-------------| +| $s(t)$ | $\{0, 1\}$ | Binary status (0=off, 1=on) | +| $s^{start}(t)$ | $\{0, 1\}$ | Startup indicator | +| $s^{stop}(t)$ | $\{0, 1\}$ | Shutdown indicator | +| $T_{up}^{min}$ | $\mathbb{R}_{\geq 0}$ | Min uptime in hours (`min_uptime`) | +| $T_{up}^{max}$ | $\mathbb{R}_{\geq 0}$ | Max uptime in hours (`max_uptime`) | +| $T_{down}^{min}$ | $\mathbb{R}_{\geq 0}$ | Min downtime in hours (`min_downtime`) | +| $H^{min}$ | $\mathbb{R}_{\geq 0}$ | Min total active hours (`active_hours_min`) | +| $H^{max}$ | $\mathbb{R}_{\geq 0}$ | Max total active hours (`active_hours_max`) | +| $\Delta t$ | $\mathbb{R}_{> 0}$ | Timestep duration (hours) | + +**Classes:** [`StatusParameters`][flixopt.interface.StatusParameters], [`StatusModel`][flixopt.features.StatusModel] diff --git a/docs/user-guide/mathematical-notation/index.md b/docs/user-guide/mathematical-notation/index.md index 27e7b7e9a..95e21db5e 100644 --- a/docs/user-guide/mathematical-notation/index.md +++ b/docs/user-guide/mathematical-notation/index.md @@ -1,123 +1,107 @@ - # Mathematical Notation -This section provides the **mathematical formulations** underlying FlixOpt's optimization models. It is intended as **reference documentation** for users who want to understand the mathematical details behind the high-level FlixOpt API described in the [FlixOpt Concepts](../core-concepts.md) guide. - -**For typical usage**, refer to the [FlixOpt Concepts](../core-concepts.md) guide, [Examples](../../examples/index.md), and [API Reference](../../api-reference/index.md) - you don't need to understand these mathematical formulations to use FlixOpt effectively. - ---- - -## Naming Conventions - -FlixOpt uses the following naming conventions: - -- All optimization variables are denoted by italic letters (e.g., $x$, $y$, $z$) -- All parameters and constants are denoted by non italic small letters (e.g., $\text{a}$, $\text{b}$, $\text{c}$) -- All Sets are denoted by greek capital letters (e.g., $\mathcal{F}$, $\mathcal{E}$) -- All units of a set are denoted by greek small letters (e.g., $\mathcal{f}$, $\mathcal{e}$) -- The letter $i$ is used to denote an index (e.g., $i=1,\dots,\text n$) -- All time steps are denoted by the letter $\text{t}$ (e.g., $\text{t}_0$, $\text{t}_1$, $\text{t}_i$) +This section provides the detailed mathematical formulations behind flixOpt. It expands on the concepts introduced in [Core Concepts](../core-concepts.md) with precise equations, variables, and constraints. -## Dimensions and Time Steps +!!! tip "When to read this" + You don't need this section to use flixOpt effectively. It's here for: -FlixOpt supports multi-dimensional optimization with up to three dimensions: **time** (mandatory), **period** (optional), and **scenario** (optional). + - Understanding exactly what the solver is optimizing + - Debugging unexpected model behavior + - Extending flixOpt with custom constraints + - Academic work requiring formal notation -**All mathematical formulations in this documentation are independent of whether periods or scenarios are present.** The equations shown are written with time index $\text{t}_i$ only, but automatically expand to additional dimensions when periods/scenarios are added. +## Structure -For complete details on dimensions, their relationships, and influence on formulations, see **[Dimensions](dimensions.md)**. +The documentation follows the same structure as Core Concepts: -### Time Steps +| Core Concept | Mathematical Details | +|--------------|---------------------| +| **Buses** — where things connect | [Bus](elements/Bus.md) — balance equations, penalty terms | +| **Flows** — what moves | [Flow](elements/Flow.md) — capacity bounds, load factors, profiles | +| **Converters** — transform things | [LinearConverter](elements/LinearConverter.md) — conversion ratios | +| **Storages** — save for later | [Storage](elements/Storage.md) — charge dynamics, efficiency losses | +| **Effects** — what you track | [Effects & Dimensions](effects-and-dimensions.md) — objectives, costs, scenarios, periods | -Time steps are defined as a sequence of discrete time steps $\text{t}_i \in \mathcal{T} \quad \text{for} \quad i \in \{1, 2, \dots, \text{n}\}$ (left-aligned in its timespan). -From this sequence, the corresponding time intervals $\Delta \text{t}_i \in \Delta \mathcal{T}$ are derived as +## Notation Conventions -$$\Delta \text{t}_i = \text{t}_{i+1} - \text{t}_i \quad \text{for} \quad i \in \{1, 2, \dots, \text{n}-1\}$$ +### Variables (What the optimizer decides) -The final time interval $\Delta \text{t}_\text n$ defaults to $\Delta \text{t}_\text n = \Delta \text{t}_{\text n-1}$, but is of course customizable. -Non-equidistant time steps are also supported. +Optimization variables are shown in *italic*: ---- +| Symbol | Meaning | Example | +|--------|---------|---------| +| $p(t)$ | Flow rate at time $t$ | Heat output of a boiler | +| $c(t)$ | Charge state at time $t$ | Energy stored in a battery | +| $P$ | Size/capacity (when optimized) | Installed capacity of a heat pump | +| $s(t)$ | Binary on/off state | Whether a generator is running | -## Documentation Structure +### Parameters (What you provide) -This reference is organized to match the FlixOpt API structure: +Parameters and constants are shown in upright text: -### Elements -Mathematical formulations for core FlixOpt elements (corresponding to [`flixopt.elements`][flixopt.elements]): +| Symbol | Meaning | Example | +|--------|---------|---------| +| $\eta$ | Efficiency | Boiler thermal efficiency (0.9) | +| $\Delta t$ | Timestep duration | 1 hour | +| $p_{min}$, $p_{max}$ | Flow bounds | Min/max operating power | -- [Flow](elements/Flow.md) - Flow rate constraints and bounds -- [Bus](elements/Bus.md) - Nodal balance equations -- [Storage](elements/Storage.md) - Storage balance and charge state evolution -- [LinearConverter](elements/LinearConverter.md) - Linear conversion relationships +### Sets and Indices -**User API:** When you create a `Flow`, `Bus`, `Storage`, or `LinearConverter` in your FlixOpt model, these mathematical formulations are automatically applied. +| Symbol | Meaning | +|--------|---------| +| $t \in \mathcal{T}$ | Time steps | +| $f \in \mathcal{F}$ | Flows | +| $e \in \mathcal{E}$ | Effects | -### Features -Mathematical formulations for optional features (corresponding to parameters in FlixOpt classes): +## The Optimization Problem -- [InvestParameters](features/InvestParameters.md) - Investment decision modeling -- [OnOffParameters](features/OnOffParameters.md) - Binary on/off operation -- [Piecewise](features/Piecewise.md) - Piecewise linear approximations +At its core, flixOpt solves: -**User API:** When you pass `invest_parameters` or `on_off_parameters` to a `Flow` or component, these formulations are applied. +$$ +\min \quad objective + penalty +$$ -### System-Level -- [Effects, Penalty & Objective](effects-penalty-objective.md) - Cost allocation and objective function +**Subject to:** -**User API:** When you create [`Effect`][flixopt.effects.Effect] objects and set `effects_per_flow_hour`, these formulations govern how costs are calculated. +- Balance constraints at each bus +- Capacity bounds on each flow +- Storage dynamics over time +- Conversion relationships in converters +- Any additional effect constraints -### Modeling Patterns (Advanced) -**Internal implementation details** - These low-level patterns are used internally by Elements and Features. They are documented here for: +The following pages detail each of these components. -- Developers extending FlixOpt -- Advanced users debugging models or understanding solver behavior -- Researchers comparing mathematical formulations +## Quick Example -**Normal users do not need to read this section** - the patterns are automatically applied when you use Elements and Features: +Consider a simple system: a gas boiler connected to a heat bus serving a demand. -- [Bounds and States](modeling-patterns/bounds-and-states.md) - Variable bounding patterns -- [Duration Tracking](modeling-patterns/duration-tracking.md) - Consecutive time period tracking -- [State Transitions](modeling-patterns/state-transitions.md) - State change modeling +**Variables:** ---- +- $p_{gas}(t)$ — gas consumption at each timestep +- $p_{heat}(t)$ — heat production at each timestep -## Quick Reference +**Constraints:** -### Components Cross-Reference +1. **Conversion** (boiler efficiency 90%): + $$p_{heat}(t) = 0.9 \cdot p_{gas}(t)$$ -| Concept | Documentation | Python Class | -|---------|---------------|--------------| -| **Flow rate bounds** | [Flow](elements/Flow.md) | [`Flow`][flixopt.elements.Flow] | -| **Bus balance** | [Bus](elements/Bus.md) | [`Bus`][flixopt.elements.Bus] | -| **Storage balance** | [Storage](elements/Storage.md) | [`Storage`][flixopt.components.Storage] | -| **Linear conversion** | [LinearConverter](elements/LinearConverter.md) | [`LinearConverter`][flixopt.components.LinearConverter] | +2. **Capacity bounds** (boiler max 100 kW): + $$0 \leq p_{heat}(t) \leq 100$$ -### Features Cross-Reference +3. **Balance** (meet demand): + $$p_{heat}(t) = demand(t)$$ -| Concept | Documentation | Python Class | -|---------|---------------|--------------| -| **Binary investment** | [InvestParameters](features/InvestParameters.md) | [`InvestParameters`][flixopt.interface.InvestParameters] | -| **On/off operation** | [OnOffParameters](features/OnOffParameters.md) | [`OnOffParameters`][flixopt.interface.OnOffParameters] | -| **Piecewise segments** | [Piecewise](features/Piecewise.md) | [`Piecewise`][flixopt.interface.Piecewise] | +**Objective** (minimize gas cost at €50/MWh): +$$\min \sum_t p_{gas}(t) \cdot \Delta t \cdot 50$$ -### Modeling Patterns Cross-Reference +This simple example shows how the concepts combine. Real models have many more components, but the principles remain the same. -| Pattern | Documentation | Implementation | -|---------|---------------|----------------| -| **Basic bounds** | [bounds-and-states](modeling-patterns/bounds-and-states.md#basic-bounds) | [`BoundingPatterns.basic_bounds()`][flixopt.modeling.BoundingPatterns.basic_bounds] | -| **Bounds with state** | [bounds-and-states](modeling-patterns/bounds-and-states.md#bounds-with-state) | [`BoundingPatterns.bounds_with_state()`][flixopt.modeling.BoundingPatterns.bounds_with_state] | -| **Scaled bounds** | [bounds-and-states](modeling-patterns/bounds-and-states.md#scaled-bounds) | [`BoundingPatterns.scaled_bounds()`][flixopt.modeling.BoundingPatterns.scaled_bounds] | -| **Duration tracking** | [duration-tracking](modeling-patterns/duration-tracking.md) | [`ModelingPrimitives.consecutive_duration_tracking()`][flixopt.modeling.ModelingPrimitives.consecutive_duration_tracking] | -| **State transitions** | [state-transitions](modeling-patterns/state-transitions.md) | [`BoundingPatterns.state_transition_bounds()`][flixopt.modeling.BoundingPatterns.state_transition_bounds] | +## Next Steps -### Python Class Lookup +Start with the element that's most relevant to your question: -| Class | Documentation | API Reference | -|-------|---------------|---------------| -| `Flow` | [Flow](elements/Flow.md) | [`Flow`][flixopt.elements.Flow] | -| `Bus` | [Bus](elements/Bus.md) | [`Bus`][flixopt.elements.Bus] | -| `Storage` | [Storage](elements/Storage.md) | [`Storage`][flixopt.components.Storage] | -| `LinearConverter` | [LinearConverter](elements/LinearConverter.md) | [`LinearConverter`][flixopt.components.LinearConverter] | -| `InvestParameters` | [InvestParameters](features/InvestParameters.md) | [`InvestParameters`][flixopt.interface.InvestParameters] | -| `OnOffParameters` | [OnOffParameters](features/OnOffParameters.md) | [`OnOffParameters`][flixopt.interface.OnOffParameters] | -| `Piecewise` | [Piecewise](features/Piecewise.md) | [`Piecewise`][flixopt.interface.Piecewise] | +- **Why isn't my demand being met?** → [Bus](elements/Bus.md) (balance constraints) +- **Why is my component not running?** → [Flow](elements/Flow.md) (capacity bounds) +- **How does storage charge/discharge?** → [Storage](elements/Storage.md) (charge dynamics) +- **How are efficiencies handled?** → [LinearConverter](elements/LinearConverter.md) (conversion) +- **How are costs calculated?** → [Effects & Dimensions](effects-and-dimensions.md) diff --git a/docs/user-guide/mathematical-notation/modeling-patterns/bounds-and-states.md b/docs/user-guide/mathematical-notation/modeling-patterns/bounds-and-states.md deleted file mode 100644 index d5821948f..000000000 --- a/docs/user-guide/mathematical-notation/modeling-patterns/bounds-and-states.md +++ /dev/null @@ -1,165 +0,0 @@ -# Bounds and States - -This document describes the mathematical formulations for variable bounding patterns used throughout FlixOpt. These patterns define how optimization variables are constrained, both with and without state control. - -## Basic Bounds - -The simplest bounding pattern constrains a variable between lower and upper bounds. - -$$\label{eq:basic_bounds} -\text{lower} \leq v \leq \text{upper} -$$ - -With: -- $v$ being the optimization variable -- $\text{lower}$ being the lower bound (constant or time-dependent) -- $\text{upper}$ being the upper bound (constant or time-dependent) - -**Implementation:** [`BoundingPatterns.basic_bounds()`][flixopt.modeling.BoundingPatterns.basic_bounds] - -**Used in:** -- Storage charge state bounds (see [Storage](../elements/Storage.md)) -- Flow rate absolute bounds - ---- - -## Bounds with State - -When a variable should only be non-zero if a binary state variable is active (e.g., on/off operation, investment decisions), the bounds are controlled by the state: - -$$\label{eq:bounds_with_state} -s \cdot \max(\varepsilon, \text{lower}) \leq v \leq s \cdot \text{upper} -$$ - -With: -- $v$ being the optimization variable -- $s \in \{0, 1\}$ being the binary state variable -- $\text{lower}$ being the lower bound when active -- $\text{upper}$ being the upper bound when active -- $\varepsilon$ being a small positive number to ensure numerical stability - -**Behavior:** -- When $s = 0$: variable is forced to zero ($0 \leq v \leq 0$) -- When $s = 1$: variable can take values in $[\text{lower}, \text{upper}]$ - -**Implementation:** [`BoundingPatterns.bounds_with_state()`][flixopt.modeling.BoundingPatterns.bounds_with_state] - -**Used in:** -- Flow rates with on/off operation (see [OnOffParameters](../features/OnOffParameters.md)) -- Investment size decisions (see [InvestParameters](../features/InvestParameters.md)) - ---- - -## Scaled Bounds - -When a variable's bounds depend on another variable (e.g., flow rate scaled by component size), scaled bounds are used: - -$$\label{eq:scaled_bounds} -v_\text{scale} \cdot \text{rel}_\text{lower} \leq v \leq v_\text{scale} \cdot \text{rel}_\text{upper} -$$ - -With: -- $v$ being the optimization variable (e.g., flow rate) -- $v_\text{scale}$ being the scaling variable (e.g., component size) -- $\text{rel}_\text{lower}$ being the relative lower bound factor (typically 0) -- $\text{rel}_\text{upper}$ being the relative upper bound factor (typically 1) - -**Example:** Flow rate bounds -- If $v_\text{scale} = P$ (flow size) and $\text{rel}_\text{upper} = 1$ -- Then: $0 \leq p(t_i) \leq P$ (see [Flow](../elements/Flow.md)) - -**Implementation:** [`BoundingPatterns.scaled_bounds()`][flixopt.modeling.BoundingPatterns.scaled_bounds] - -**Used in:** -- Flow rate constraints (see [Flow](../elements/Flow.md) equation 1) -- Storage charge state constraints (see [Storage](../elements/Storage.md) equation 1) - ---- - -## Scaled Bounds with State - -Combining scaled bounds with binary state control requires a Big-M formulation to handle both the scaling and the on/off behavior: - -$$\label{eq:scaled_bounds_with_state_1} -(s - 1) \cdot M_\text{misc} + v_\text{scale} \cdot \text{rel}_\text{lower} \leq v \leq v_\text{scale} \cdot \text{rel}_\text{upper} -$$ - -$$\label{eq:scaled_bounds_with_state_2} -s \cdot M_\text{lower} \leq v \leq s \cdot M_\text{upper} -$$ - -With: -- $v$ being the optimization variable -- $v_\text{scale}$ being the scaling variable -- $s \in \{0, 1\}$ being the binary state variable -- $\text{rel}_\text{lower}$ being the relative lower bound factor -- $\text{rel}_\text{upper}$ being the relative upper bound factor -- $M_\text{misc} = v_\text{scale,max} \cdot \text{rel}_\text{lower}$ -- $M_\text{upper} = v_\text{scale,max} \cdot \text{rel}_\text{upper}$ -- $M_\text{lower} = \max(\varepsilon, v_\text{scale,min} \cdot \text{rel}_\text{lower})$ - -Where $v_\text{scale,max}$ and $v_\text{scale,min}$ are the maximum and minimum possible values of the scaling variable. - -**Behavior:** -- When $s = 0$: variable is forced to zero -- When $s = 1$: variable follows scaled bounds $v_\text{scale} \cdot \text{rel}_\text{lower} \leq v \leq v_\text{scale} \cdot \text{rel}_\text{upper}$ - -**Implementation:** [`BoundingPatterns.scaled_bounds_with_state()`][flixopt.modeling.BoundingPatterns.scaled_bounds_with_state] - -**Used in:** -- Flow rates with on/off operation and investment sizing -- Components combining [OnOffParameters](../features/OnOffParameters.md) and [InvestParameters](../features/InvestParameters.md) - ---- - -## Expression Tracking - -Sometimes it's necessary to create an auxiliary variable that equals an expression: - -$$\label{eq:expression_tracking} -v_\text{tracker} = \text{expression} -$$ - -With optional bounds: - -$$\label{eq:expression_tracking_bounds} -\text{lower} \leq v_\text{tracker} \leq \text{upper} -$$ - -With: -- $v_\text{tracker}$ being the auxiliary tracking variable -- $\text{expression}$ being a linear expression of other variables -- $\text{lower}, \text{upper}$ being optional bounds on the tracker - -**Use cases:** -- Creating named variables for complex expressions -- Bounding intermediate results -- Simplifying constraint formulations - -**Implementation:** [`ModelingPrimitives.expression_tracking_variable()`][flixopt.modeling.ModelingPrimitives.expression_tracking_variable] - ---- - -## Mutual Exclusivity - -When multiple binary variables should not be active simultaneously (at most one can be 1): - -$$\label{eq:mutual_exclusivity} -\sum_{i} s_i(t) \leq \text{tolerance} \quad \forall t -$$ - -With: -- $s_i(t) \in \{0, 1\}$ being binary state variables -- $\text{tolerance}$ being the maximum number of simultaneously active states (typically 1) -- $t$ being the time index - -**Use cases:** -- Ensuring only one operating mode is active -- Mutual exclusion of operation and maintenance states -- Enforcing single-choice decisions - -**Implementation:** [`ModelingPrimitives.mutual_exclusivity_constraint()`][flixopt.modeling.ModelingPrimitives.mutual_exclusivity_constraint] - -**Used in:** -- Operating mode selection -- Piecewise linear function segments (see [Piecewise](../features/Piecewise.md)) diff --git a/docs/user-guide/mathematical-notation/modeling-patterns/duration-tracking.md b/docs/user-guide/mathematical-notation/modeling-patterns/duration-tracking.md deleted file mode 100644 index 5d430d28c..000000000 --- a/docs/user-guide/mathematical-notation/modeling-patterns/duration-tracking.md +++ /dev/null @@ -1,159 +0,0 @@ -# Duration Tracking - -Duration tracking allows monitoring how long a binary state has been consecutively active. This is essential for modeling minimum run times, ramp-up periods, and similar time-dependent constraints. - -## Consecutive Duration Tracking - -For a binary state variable $s(t) \in \{0, 1\}$, the consecutive duration $d(t)$ tracks how long the state has been continuously active. - -### Duration Upper Bound - -The duration cannot exceed zero when the state is inactive: - -$$\label{eq:duration_upper} -d(t) \leq s(t) \cdot M \quad \forall t -$$ - -With: -- $d(t)$ being the duration variable (continuous, non-negative) -- $s(t) \in \{0, 1\}$ being the binary state variable -- $M$ being a sufficiently large constant (big-M) - -**Behavior:** -- When $s(t) = 0$: forces $d(t) \leq 0$, thus $d(t) = 0$ -- When $s(t) = 1$: allows $d(t)$ to be positive - ---- - -### Duration Accumulation - -While the state is active, the duration increases by the time step size: - -$$\label{eq:duration_accumulation_upper} -d(t+1) \leq d(t) + \Delta d(t) \quad \forall t -$$ - -$$\label{eq:duration_accumulation_lower} -d(t+1) \geq d(t) + \Delta d(t) + (s(t+1) - 1) \cdot M \quad \forall t -$$ - -With: -- $\Delta d(t)$ being the duration increment for time step $t$ (typically $\Delta t_i$ from the time series) -- $M$ being a sufficiently large constant - -**Behavior:** -- When $s(t+1) = 1$: both inequalities enforce $d(t+1) = d(t) + \Delta d(t)$ -- When $s(t+1) = 0$: only the upper bound applies, and $d(t+1) = 0$ (from equation $\eqref{eq:duration_upper}$) - ---- - -### Initial Duration - -The duration at the first time step depends on both the state and any previous duration: - -$$\label{eq:duration_initial} -d(0) = (\Delta d(0) + d_\text{prev}) \cdot s(0) -$$ - -With: -- $d_\text{prev}$ being the duration from before the optimization period -- $\Delta d(0)$ being the duration increment for the first time step - -**Behavior:** -- When $s(0) = 1$: duration continues from previous period -- When $s(0) = 0$: duration resets to zero - ---- - -### Complete Formulation - -Combining all constraints: - -$$ -\begin{align} -d(t) &\leq s(t) \cdot M && \forall t \label{eq:duration_complete_1} \\ -d(t+1) &\leq d(t) + \Delta d(t) && \forall t \label{eq:duration_complete_2} \\ -d(t+1) &\geq d(t) + \Delta d(t) + (s(t+1) - 1) \cdot M && \forall t \label{eq:duration_complete_3} \\ -d(0) &= (\Delta d(0) + d_\text{prev}) \cdot s(0) && \label{eq:duration_complete_4} -\end{align} -$$ - ---- - -## Minimum Duration Constraints - -To enforce a minimum consecutive duration (e.g., minimum run time), an additional constraint links the duration to state changes: - -$$\label{eq:minimum_duration} -d(t) \geq (s(t-1) - s(t)) \cdot d_\text{min}(t-1) \quad \forall t > 0 -$$ - -With: -- $d_\text{min}(t)$ being the required minimum duration at time $t$ - -**Behavior:** -- When shutting down ($s(t-1) = 1, s(t) = 0$): enforces $d(t-1) \geq d_\text{min}(t-1)$ -- This ensures the state was active for at least $d_\text{min}$ before turning off -- When state is constant or turning on: constraint is non-binding - ---- - -## Implementation - -**Function:** [`ModelingPrimitives.consecutive_duration_tracking()`][flixopt.modeling.ModelingPrimitives.consecutive_duration_tracking] - -See the API documentation for complete parameter list and usage details. - ---- - -## Use Cases - -### Minimum Run Time - -Ensuring equipment runs for a minimum duration once started: - -```python -# State: 1 when running, 0 when off -# Require at least 2 hours of operation -duration = modeling.consecutive_duration_tracking( - state_variable=on_state, - duration_per_step=time_step_hours, - minimum_duration=2.0 -) -``` - -### Ramp-Up Tracking - -Tracking time since startup for gradual ramp-up constraints: - -```python -# Track startup duration -startup_duration = modeling.consecutive_duration_tracking( - state_variable=on_state, - duration_per_step=time_step_hours -) -# Constrain output based on startup duration -# (additional constraints would link output to startup_duration) -``` - -### Cooldown Requirements - -Tracking time in a state before allowing transitions: - -```python -# Track maintenance duration -maintenance_duration = modeling.consecutive_duration_tracking( - state_variable=maintenance_state, - duration_per_step=time_step_hours, - minimum_duration=scheduled_maintenance_hours -) -``` - ---- - -## Used In - -This pattern is used in: -- [`OnOffParameters`](../features/OnOffParameters.md) - Minimum on/off times -- Operating mode constraints with minimum durations -- Startup/shutdown sequence modeling diff --git a/docs/user-guide/mathematical-notation/modeling-patterns/index.md b/docs/user-guide/mathematical-notation/modeling-patterns/index.md deleted file mode 100644 index 15ff8dbd2..000000000 --- a/docs/user-guide/mathematical-notation/modeling-patterns/index.md +++ /dev/null @@ -1,54 +0,0 @@ -# Modeling Patterns - -This section documents the fundamental mathematical patterns used throughout FlixOpt for constructing optimization models. These patterns are implemented in `flixopt.modeling` and provide reusable building blocks for creating constraints. - -## Overview - -The modeling patterns are organized into three categories: - -1. **[Bounds and States](bounds-and-states.md)** - Variable bounding with optional state control -2. **[Duration Tracking](duration-tracking.md)** - Tracking consecutive durations of states -3. **[State Transitions](state-transitions.md)** - Modeling state changes and transitions - -## Pattern Categories - -### Bounding Patterns - -These patterns define how optimization variables are constrained within bounds: - -- **Basic Bounds** - Simple upper and lower bounds on variables -- **Bounds with State** - Binary-controlled bounds (on/off states) -- **Scaled Bounds** - Bounds dependent on another variable (e.g., size) -- **Scaled Bounds with State** - Combination of scaling and binary control - -### Tracking Patterns - -These patterns track properties over time: - -- **Expression Tracking** - Creating auxiliary variables that track expressions -- **Consecutive Duration Tracking** - Tracking how long a state has been active -- **Mutual Exclusivity** - Ensuring only one of multiple options is active - -### Transition Patterns - -These patterns model changes between states: - -- **State Transitions** - Tracking switches between binary states (on→off, off→on) -- **Continuous Transitions** - Linking continuous variable changes to switches -- **Level Changes with Binaries** - Controlled increases/decreases in levels - -## Usage in Components - -These patterns are used throughout FlixOpt components: - -- [`Flow`][flixopt.elements.Flow] uses **scaled bounds with state** for flow rate constraints -- [`Storage`][flixopt.components.Storage] uses **basic bounds** for charge state -- [`OnOffParameters`](../features/OnOffParameters.md) uses **state transitions** for startup/shutdown -- [`InvestParameters`](../features/InvestParameters.md) uses **bounds with state** for investment decisions - -## Implementation - -All patterns are implemented in [`flixopt.modeling`][flixopt.modeling] module: - -- [`ModelingPrimitives`][flixopt.modeling.ModelingPrimitives] - Core constraint patterns -- [`BoundingPatterns`][flixopt.modeling.BoundingPatterns] - Specialized bounding patterns diff --git a/docs/user-guide/mathematical-notation/modeling-patterns/state-transitions.md b/docs/user-guide/mathematical-notation/modeling-patterns/state-transitions.md deleted file mode 100644 index dc75a8008..000000000 --- a/docs/user-guide/mathematical-notation/modeling-patterns/state-transitions.md +++ /dev/null @@ -1,227 +0,0 @@ -# State Transitions - -State transition patterns model changes between discrete states and link them to continuous variables. These patterns are essential for modeling startup/shutdown events, switching behavior, and controlled changes in system operation. - -## Binary State Transitions - -For a binary state variable $s(t) \in \{0, 1\}$, state transitions track when the state switches on or off. - -### Switch Variables - -Two binary variables track the transitions: -- $s^\text{on}(t) \in \{0, 1\}$: equals 1 when switching from off to on -- $s^\text{off}(t) \in \{0, 1\}$: equals 1 when switching from on to off - -### Transition Tracking - -The state change equals the difference between switch-on and switch-off: - -$$\label{eq:state_transition} -s^\text{on}(t) - s^\text{off}(t) = s(t) - s(t-1) \quad \forall t > 0 -$$ - -$$\label{eq:state_transition_initial} -s^\text{on}(0) - s^\text{off}(0) = s(0) - s_\text{prev} -$$ - -With: -- $s(t)$ being the binary state variable -- $s_\text{prev}$ being the state before the optimization period -- $s^\text{on}(t), s^\text{off}(t)$ being the switch variables - -**Behavior:** -- Off → On ($s(t-1)=0, s(t)=1$): $s^\text{on}(t)=1, s^\text{off}(t)=0$ -- On → Off ($s(t-1)=1, s(t)=0$): $s^\text{on}(t)=0, s^\text{off}(t)=1$ -- No change: $s^\text{on}(t)=0, s^\text{off}(t)=0$ - ---- - -### Mutual Exclusivity of Switches - -A state cannot switch on and off simultaneously: - -$$\label{eq:switch_exclusivity} -s^\text{on}(t) + s^\text{off}(t) \leq 1 \quad \forall t -$$ - -This ensures: -- At most one switch event per time step -- No simultaneous on/off switching - ---- - -### Complete State Transition Formulation - -$$ -\begin{align} -s^\text{on}(t) - s^\text{off}(t) &= s(t) - s(t-1) && \forall t > 0 \label{eq:transition_complete_1} \\ -s^\text{on}(0) - s^\text{off}(0) &= s(0) - s_\text{prev} && \label{eq:transition_complete_2} \\ -s^\text{on}(t) + s^\text{off}(t) &\leq 1 && \forall t \label{eq:transition_complete_3} \\ -s^\text{on}(t), s^\text{off}(t) &\in \{0, 1\} && \forall t \label{eq:transition_complete_4} -\end{align} -$$ - -**Implementation:** [`BoundingPatterns.state_transition_bounds()`][flixopt.modeling.BoundingPatterns.state_transition_bounds] - ---- - -## Continuous Transitions - -When a continuous variable should only change when certain switch events occur, continuous transition bounds link the variable changes to binary switches. - -### Change Bounds with Switches - -$$\label{eq:continuous_transition} --\Delta v^\text{max} \cdot (s^\text{on}(t) + s^\text{off}(t)) \leq v(t) - v(t-1) \leq \Delta v^\text{max} \cdot (s^\text{on}(t) + s^\text{off}(t)) \quad \forall t > 0 -$$ - -$$\label{eq:continuous_transition_initial} --\Delta v^\text{max} \cdot (s^\text{on}(0) + s^\text{off}(0)) \leq v(0) - v_\text{prev} \leq \Delta v^\text{max} \cdot (s^\text{on}(0) + s^\text{off}(0)) -$$ - -With: -- $v(t)$ being the continuous variable -- $v_\text{prev}$ being the value before the optimization period -- $\Delta v^\text{max}$ being the maximum allowed change -- $s^\text{on}(t), s^\text{off}(t) \in \{0, 1\}$ being switch binary variables - -**Behavior:** -- When $s^\text{on}(t) = 0$ and $s^\text{off}(t) = 0$: forces $v(t) = v(t-1)$ (no change) -- When $s^\text{on}(t) = 1$ or $s^\text{off}(t) = 1$: allows change up to $\pm \Delta v^\text{max}$ - -**Implementation:** [`BoundingPatterns.continuous_transition_bounds()`][flixopt.modeling.BoundingPatterns.continuous_transition_bounds] - ---- - -## Level Changes with Binaries - -This pattern models a level variable that can increase or decrease, with changes controlled by binary variables. This is useful for inventory management, capacity adjustments, or gradual state changes. - -### Level Evolution - -The level evolves based on increases and decreases: - -$$\label{eq:level_initial} -\ell(0) = \ell_\text{init} + \ell^\text{inc}(0) - \ell^\text{dec}(0) -$$ - -$$\label{eq:level_evolution} -\ell(t) = \ell(t-1) + \ell^\text{inc}(t) - \ell^\text{dec}(t) \quad \forall t > 0 -$$ - -With: -- $\ell(t)$ being the level variable -- $\ell_\text{init}$ being the initial level -- $\ell^\text{inc}(t)$ being the increase in level at time $t$ (non-negative) -- $\ell^\text{dec}(t)$ being the decrease in level at time $t$ (non-negative) - ---- - -### Change Bounds with Binary Control - -Changes are bounded and controlled by binary variables: - -$$\label{eq:increase_bound} -\ell^\text{inc}(t) \leq \Delta \ell^\text{max} \cdot b^\text{inc}(t) \quad \forall t -$$ - -$$\label{eq:decrease_bound} -\ell^\text{dec}(t) \leq \Delta \ell^\text{max} \cdot b^\text{dec}(t) \quad \forall t -$$ - -With: -- $\Delta \ell^\text{max}$ being the maximum change per time step -- $b^\text{inc}(t), b^\text{dec}(t) \in \{0, 1\}$ being binary control variables - ---- - -### Mutual Exclusivity of Changes - -Simultaneous increase and decrease are prevented: - -$$\label{eq:change_exclusivity} -b^\text{inc}(t) + b^\text{dec}(t) \leq 1 \quad \forall t -$$ - -This ensures: -- Level can only increase OR decrease (or stay constant) in each time step -- No simultaneous contradictory changes - ---- - -### Complete Level Change Formulation - -$$ -\begin{align} -\ell(0) &= \ell_\text{init} + \ell^\text{inc}(0) - \ell^\text{dec}(0) && \label{eq:level_complete_1} \\ -\ell(t) &= \ell(t-1) + \ell^\text{inc}(t) - \ell^\text{dec}(t) && \forall t > 0 \label{eq:level_complete_2} \\ -\ell^\text{inc}(t) &\leq \Delta \ell^\text{max} \cdot b^\text{inc}(t) && \forall t \label{eq:level_complete_3} \\ -\ell^\text{dec}(t) &\leq \Delta \ell^\text{max} \cdot b^\text{dec}(t) && \forall t \label{eq:level_complete_4} \\ -b^\text{inc}(t) + b^\text{dec}(t) &\leq 1 && \forall t \label{eq:level_complete_5} \\ -b^\text{inc}(t), b^\text{dec}(t) &\in \{0, 1\} && \forall t \label{eq:level_complete_6} -\end{align} -$$ - -**Implementation:** [`BoundingPatterns.link_changes_to_level_with_binaries()`][flixopt.modeling.BoundingPatterns.link_changes_to_level_with_binaries] - ---- - -## Use Cases - -### Startup/Shutdown Costs - -Track startup and shutdown events to apply costs: - -```python -# Create switch variables -switch_on, switch_off = modeling.state_transition_bounds( - state_variable=on_state, - previous_state=previous_on_state -) - -# Apply costs to switches -startup_cost = switch_on * startup_cost_per_event -shutdown_cost = switch_off * shutdown_cost_per_event -``` - -### Limited Switching - -Restrict the number of state changes: - -```python -# Track all switches -switch_on, switch_off = modeling.state_transition_bounds( - state_variable=on_state -) - -# Limit total switches -model.add_constraint( - (switch_on + switch_off).sum() <= max_switches -) -``` - -### Gradual Capacity Changes - -Model systems where capacity can be incrementally adjusted: - -```python -# Level represents installed capacity -level_var, increase, decrease, inc_binary, dec_binary = \ - modeling.link_changes_to_level_with_binaries( - initial_level=current_capacity, - max_change=max_capacity_change_per_period - ) - -# Constrain total increases -model.add_constraint(increase.sum() <= max_total_expansion) -``` - ---- - -## Used In - -These patterns are used in: -- [`OnOffParameters`](../features/OnOffParameters.md) - Startup/shutdown tracking and costs -- Operating mode switching with transition costs -- Investment planning with staged capacity additions -- Inventory management with controlled stock changes diff --git a/docs/user-guide/mathematical-notation/others.md b/docs/user-guide/mathematical-notation/others.md deleted file mode 100644 index bdc602308..000000000 --- a/docs/user-guide/mathematical-notation/others.md +++ /dev/null @@ -1,3 +0,0 @@ -# Work in Progress - -This is a work in progress. diff --git a/docs/user-guide/migration-guide-v5.md b/docs/user-guide/migration-guide-v5.md new file mode 100644 index 000000000..0c43e18f0 --- /dev/null +++ b/docs/user-guide/migration-guide-v5.md @@ -0,0 +1,452 @@ +# Migration Guide: v4.x → v5.0.0 + +!!! tip "Quick Start" + ```bash + pip install --upgrade flixopt + ``` + The new API is simpler and more intuitive. Review this guide to update your code. + +--- + +## Overview + +v5.0.0 introduces a streamlined API for optimization and results access. The key changes are: + +| Aspect | Old API (v4.x) | New API (v5.0.0) | +|--------|----------------|------------------| +| **Optimization** | `fx.Optimization` class | `FlowSystem.optimize()` method | +| **Results access** | `element.submodel.variable.solution` | `flow_system.solution['variable_name']` | +| **Results storage** | `Results` class | `xarray.Dataset` on `flow_system.solution` | + +--- + +## 💥 Breaking Changes in v5.0.0 + +### Optimization API + +The `Optimization` class is **deprecated** and will be removed in v6.0.0. Use `FlowSystem.optimize()` directly. + +=== "v4.x (Old)" + ```python + import flixopt as fx + + # Create flow system + flow_system = fx.FlowSystem(timesteps) + flow_system.add_elements(...) + + # Create Optimization object + optimization = fx.Optimization('my_model', flow_system) + optimization.do_modeling() + optimization.solve(fx.solvers.HighsSolver()) + + # Access results via Optimization object + results = optimization.results + costs = results.model['costs'].solution.item() + ``` + +=== "v5.0.0 (New)" + ```python + import flixopt as fx + + # Create flow system + flow_system = fx.FlowSystem(timesteps) + flow_system.add_elements(...) + + # Optimize directly on FlowSystem + flow_system.optimize(fx.solvers.HighsSolver()) + + # Access results via flow_system.solution + costs = flow_system.solution['costs'].item() + ``` + +!!! note "Two-step alternative" + If you need access to the model before solving: + ```python + flow_system.build_model() # Creates flow_system.model + flow_system.solve(fx.solvers.HighsSolver()) + ``` + +--- + +### Results Access + +Results are now accessed via `flow_system.solution`, which is an `xarray.Dataset`. + +#### Effect Values + +=== "v4.x (Old)" + ```python + # Via element reference + costs = flow_system.effects['costs'] + total_costs = costs.submodel.total.solution.item() + + # Or via results object + total_costs = optimization.results.model['costs'].solution.item() + ``` + +=== "v5.0.0 (New)" + ```python + # Direct access via solution Dataset + total_costs = flow_system.solution['costs'].item() + + # Temporal and periodic components + temporal_costs = flow_system.solution['costs(temporal)'].values + periodic_costs = flow_system.solution['costs(periodic)'].values + per_timestep = flow_system.solution['costs(temporal)|per_timestep'].values + ``` + +#### Flow Rates + +=== "v4.x (Old)" + ```python + boiler = flow_system.components['Boiler'] + flow_rate = boiler.thermal_flow.submodel.flow_rate.solution.values + ``` + +=== "v5.0.0 (New)" + ```python + flow_rate = flow_system.solution['Boiler(Q_th)|flow_rate'].values + ``` + +#### Investment Variables + +=== "v4.x (Old)" + ```python + boiler = flow_system.components['Boiler'] + size = boiler.thermal_flow.submodel.investment.size.solution.item() + invested = boiler.thermal_flow.submodel.investment.invested.solution.item() + ``` + +=== "v5.0.0 (New)" + ```python + size = flow_system.solution['Boiler(Q_th)|size'].item() + invested = flow_system.solution['Boiler(Q_th)|invested'].item() + ``` + +#### Status Variables + +=== "v4.x (Old)" + ```python + boiler = flow_system.components['Boiler'] + status = boiler.thermal_flow.submodel.status.status.solution.values + startup = boiler.thermal_flow.submodel.status.startup.solution.values + shutdown = boiler.thermal_flow.submodel.status.shutdown.solution.values + ``` + +=== "v5.0.0 (New)" + ```python + status = flow_system.solution['Boiler(Q_th)|status'].values + startup = flow_system.solution['Boiler(Q_th)|startup'].values + shutdown = flow_system.solution['Boiler(Q_th)|shutdown'].values + ``` + +#### Storage Variables + +=== "v4.x (Old)" + ```python + storage = flow_system.components['Speicher'] + charge_state = storage.submodel.charge_state.solution.values + netto_discharge = storage.submodel.netto_discharge.solution.values + ``` + +=== "v5.0.0 (New)" + ```python + charge_state = flow_system.solution['Speicher|charge_state'].values + netto_discharge = flow_system.solution['Speicher|netto_discharge'].values + final_charge = flow_system.solution['Speicher|charge_state|final'].item() + ``` + +--- + +## Variable Naming Convention + +The new API uses a consistent naming pattern: + +```text +ComponentLabel(FlowLabel)|variable_name +``` + +### Pattern Reference + +| Variable Type | Pattern | Example | +|--------------|---------|---------| +| **Flow rate** | `Component(Flow)\|flow_rate` | `Boiler(Q_th)\|flow_rate` | +| **Size** | `Component(Flow)\|size` | `Boiler(Q_th)\|size` | +| **Invested** | `Component(Flow)\|invested` | `Boiler(Q_th)\|invested` | +| **Status** | `Component(Flow)\|status` | `Boiler(Q_th)\|status` | +| **Startup** | `Component(Flow)\|startup` | `Boiler(Q_th)\|startup` | +| **Shutdown** | `Component(Flow)\|shutdown` | `Boiler(Q_th)\|shutdown` | +| **Inactive** | `Component(Flow)\|inactive` | `Boiler(Q_th)\|inactive` | +| **Active hours** | `Component(Flow)\|active_hours` | `Boiler(Q_th)\|active_hours` | +| **Total flow** | `Component(Flow)\|total_flow_hours` | `Boiler(Q_th)\|total_flow_hours` | +| **Storage charge** | `Storage\|charge_state` | `Speicher\|charge_state` | +| **Storage final** | `Storage\|charge_state\|final` | `Speicher\|charge_state\|final` | +| **Netto discharge** | `Storage\|netto_discharge` | `Speicher\|netto_discharge` | + +### Effects Pattern + +| Variable Type | Pattern | Example | +|--------------|---------|---------| +| **Total** | `effect_label` | `costs` | +| **Temporal** | `effect_label(temporal)` | `costs(temporal)` | +| **Periodic** | `effect_label(periodic)` | `costs(periodic)` | +| **Per timestep** | `effect_label(temporal)\|per_timestep` | `costs(temporal)\|per_timestep` | +| **Contribution** | `Component(Flow)->effect(temporal)` | `Gastarif(Q_Gas)->costs(temporal)` | + +--- + +## Discovering Variable Names + +Use these methods to find available variable names: + +```python +# List all variables in the solution +print(list(flow_system.solution.data_vars)) + +# Filter for specific patterns +costs_vars = [v for v in flow_system.solution.data_vars if 'costs' in v] +boiler_vars = [v for v in flow_system.solution.data_vars if 'Boiler' in v] +``` + +--- + +## Results I/O + +### Saving Results + +=== "v4.x (Old)" + ```python + optimization.results.to_file(folder='results', name='my_model') + ``` + +=== "v5.0.0 (New)" + ```python + # Save entire FlowSystem with solution + flow_system.to_netcdf('results/my_model.nc4') + + # Or save just the solution Dataset + flow_system.solution.to_netcdf('results/solution.nc4') + ``` + +### Loading Results + +=== "v4.x (Old)" + ```python + results = fx.results.Results.from_file('results', 'my_model') + ``` + +=== "v5.0.0 (New)" + ```python + import xarray as xr + + # Load FlowSystem with solution + flow_system = fx.FlowSystem.from_netcdf('results/my_model.nc4') + + # Or load just the solution + solution = xr.open_dataset('results/solution.nc4') + ``` + +### Migrating Old Result 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: + +- Loads the old multi-file format (`*--flow_system.nc4`, `*--solution.nc4`) +- Renames deprecated parameters in the FlowSystem structure (e.g., `on_off_parameters` → `status_parameters`) +- Attaches the solution data to the FlowSystem + +```python +# Load old results +flow_system = fx.FlowSystem.from_old_results('results_folder', 'my_model') + +# Access basic solution data (flow rates, sizes, charge states, etc.) +flow_system.solution['Boiler(Q_th)|flow_rate'].plot() + +# Save in new single-file format +flow_system.to_netcdf('results/my_model_migrated.nc4') +``` + +!!! warning "Limitations" + 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 variable names + - Summary metadata (solver info, timing) is not loaded + + For full compatibility, re-run optimizations with the new API. + +--- + +## Working with xarray Dataset + +The `flow_system.solution` is an `xarray.Dataset`, giving you powerful data manipulation: + +```python +# Access a single variable +costs = flow_system.solution['costs'] + +# Get values as numpy array +values = flow_system.solution['Boiler(Q_th)|flow_rate'].values + +# Get scalar value +total = flow_system.solution['costs'].item() + +# Sum over time dimension +total_flow = flow_system.solution['Boiler(Q_th)|flow_rate'].sum(dim='time') + +# Select by time +subset = flow_system.solution.sel(time=slice('2020-01-01', '2020-01-02')) + +# Convert to DataFrame +df = flow_system.solution.to_dataframe() +``` + +--- + +## Segmented & Clustered Optimization + +### Clustered Optimization (Migrated) + +Clustered optimization uses the new transform accessor: + +=== "v4.x (Old)" + ```python + calc = fx.ClusteredOptimization('model', flow_system, + fx.ClusteringParameters(...)) + calc.do_modeling_and_solve(solver) + results = calc.results + ``` + +=== "v5.0.0 (New)" + ```python + # Use transform accessor for clustering + clustered_fs = flow_system.transform.cluster(fx.ClusteringParameters(...)) + clustered_fs.optimize(solver) + # Results in clustered_fs.solution + ``` + +### Segmented / Rolling Horizon Optimization + +=== "v4.x (Old)" + ```python + calc = fx.SegmentedOptimization('model', flow_system, + timesteps_per_segment=96) + calc.do_modeling_and_solve(solver) + results = calc.results # Returns SegmentedResults + ``` + +=== "v5.0.0 (New)" + ```python + # Use optimize.rolling_horizon() method + segments = flow_system.optimize.rolling_horizon( + solver, + horizon=96, # Timesteps per segment + overlap=12, # Lookahead for storage optimization + ) + # Combined solution on original FlowSystem + flow_system.solution['costs'].item() + ``` + +--- + +## Statistics Accessor + +The new `statistics` accessor provides convenient aggregated data: + +```python +stats = flow_system.statistics + +# Flow data (clean labels, no |flow_rate suffix) +stats.flow_rates['Boiler(Q_th)'] # Not 'Boiler(Q_th)|flow_rate' +stats.flow_hours['Boiler(Q_th)'] +stats.sizes['Boiler(Q_th)'] +stats.charge_states['Battery'] + +# Effect breakdown by contributor (replaces effects_per_component) +stats.temporal_effects['costs'] # Per timestep, per contributor +stats.periodic_effects['costs'] # Investment costs per contributor +stats.total_effects['costs'] # Total per contributor + +# Group by component or component type +stats.total_effects['costs'].groupby('component').sum() +stats.total_effects['costs'].groupby('component_type').sum() +``` + +--- + +## 🔧 Quick Reference + +### Common Conversions + +| Old Pattern | New Pattern | +|-------------|-------------| +| `optimization.results.model['costs'].solution.item()` | `flow_system.solution['costs'].item()` | +| `comp.flow.submodel.flow_rate.solution.values` | `flow_system.solution['Comp(Flow)\|flow_rate'].values` | +| `comp.flow.submodel.investment.size.solution.item()` | `flow_system.solution['Comp(Flow)\|size'].item()` | +| `comp.flow.submodel.status.status.solution.values` | `flow_system.solution['Comp(Flow)\|status'].values` | +| `storage.submodel.charge_state.solution.values` | `flow_system.solution['Storage\|charge_state'].values` | +| `effects['CO2'].submodel.total.solution.item()` | `flow_system.solution['CO2'].item()` | + +--- + +## ✅ Migration Checklist + +| Task | Description | +|------|-------------| +| **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)` | +| **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` | + +--- + +## Transform Methods Moved to Accessor + +The `sel()`, `isel()`, and `resample()` methods have been moved from `FlowSystem` to the `TransformAccessor`: + +=== "Old (deprecated)" + ```python + # These still work but emit deprecation warnings + fs_subset = flow_system.sel(time=slice('2023-01-01', '2023-06-30')) + fs_indexed = flow_system.isel(time=slice(0, 24)) + fs_resampled = flow_system.resample(time='4h', method='mean') + ``` + +=== "New (recommended)" + ```python + # Use the transform accessor + fs_subset = flow_system.transform.sel(time=slice('2023-01-01', '2023-06-30')) + fs_indexed = flow_system.transform.isel(time=slice(0, 24)) + fs_resampled = flow_system.transform.resample(time='4h', method='mean') + ``` + +!!! info "Solution is dropped" + All transform methods return a **new FlowSystem without a solution**. You must re-optimize the transformed system: + ```python + fs_subset = flow_system.transform.sel(time=slice('2023-01-01', '2023-01-31')) + fs_subset.optimize(solver) # Re-optimize the subset + ``` + +--- + +## Deprecation Timeline + +| Version | Status | +|---------|--------| +| v4.x | `Optimization` and `Results` classes available | +| v5.0.0 | `Optimization` and `Results` deprecated, new API available | + +!!! warning "Update your code" + The `Optimization` and `Results` classes are deprecated and will be removed in a future version. + The `flow_system.sel()`, `flow_system.isel()`, and `flow_system.resample()` methods are deprecated + in favor of `flow_system.transform.sel/isel/resample()`. + Update your code to the new API to avoid breaking changes when upgrading. + +--- + +:material-book: [Docs](https://flixopt.github.io/flixopt/) • :material-github: [Issues](https://github.com/flixOpt/flixopt/issues) + +!!! success "Welcome to the new flixopt API! 🎉" diff --git a/docs/user-guide/optimization/index.md b/docs/user-guide/optimization/index.md new file mode 100644 index 000000000..1d36eb9ba --- /dev/null +++ b/docs/user-guide/optimization/index.md @@ -0,0 +1,352 @@ +# Running Optimizations + +This section covers how to run optimizations in flixOpt, including different optimization modes and solver configuration. + +## Verifying Your Model + +Before running an optimization, it's helpful to visualize your system structure: + +```python +# Generate an interactive network diagram +flow_system.topology.plot(path='my_system.html') + +# Or get structure info programmatically +nodes, edges = flow_system.topology.infos() +print(f"Components: {[n for n, d in nodes.items() if d['class'] == 'Component']}") +print(f"Buses: {[n for n, d in nodes.items() if d['class'] == 'Bus']}") +print(f"Flows: {list(edges.keys())}") +``` + +## Standard Optimization + +The recommended way to run an optimization is directly on the `FlowSystem`: + +```python +import flixopt as fx + +# Simple one-liner +flow_system.optimize(fx.solvers.HighsSolver()) + +# Access results directly +print(flow_system.solution['Boiler(Q_th)|flow_rate']) +print(flow_system.components['Boiler'].solution) +``` + +For more control over the optimization process, you can split model building and solving: + +```python +# Build the model first +flow_system.build_model() + +# Optionally inspect or modify the model +print(flow_system.model.constraints) + +# Then solve +flow_system.solve(fx.solvers.HighsSolver()) +``` + +**Best for:** + +- Small to medium problems +- When you need the globally optimal solution +- Problems without time-coupling simplifications + +## Clustered Optimization + +For large problems, use time series clustering to reduce computational complexity: + +```python +# Define clustering parameters +params = fx.ClusteringParameters( + hours_per_period=24, # Hours per typical period + nr_of_periods=8, # Number of typical periods + fix_storage_flows=True, + aggregate_data_and_fix_non_binary_vars=True, +) + +# Create clustered FlowSystem +clustered_fs = flow_system.transform.cluster(params) + +# Optimize the clustered system +clustered_fs.optimize(fx.solvers.HighsSolver()) + +# Access results - same structure as original +print(clustered_fs.solution) +``` + +**Best for:** + +- Investment planning problems +- Year-long optimizations +- When computational speed is critical + +**Trade-offs:** + +- Much faster solve times +- Approximates the full problem +- Best when patterns repeat (e.g., typical days) + +## Choosing an Optimization Mode + +| Mode | Problem Size | Solve Time | Solution Quality | +|------|-------------|------------|------------------| +| Standard | Small-Medium | Slow | Optimal | +| Clustered | Very Large | Fast | Approximate | + +## Transform Accessor + +The `transform` accessor provides methods to create modified copies of your FlowSystem. All transform methods return a **new FlowSystem without a solution** — you must re-optimize the transformed system. + +### Selecting Subsets + +Select a subset of your data by label or index: + +```python +# Select by label (like xarray.sel) +fs_january = flow_system.transform.sel(time=slice('2024-01-01', '2024-01-31')) +fs_scenario = flow_system.transform.sel(scenario='base') + +# Select by integer index (like xarray.isel) +fs_first_week = flow_system.transform.isel(time=slice(0, 168)) +fs_first_scenario = flow_system.transform.isel(scenario=0) + +# Re-optimize the subset +fs_january.optimize(fx.solvers.HighsSolver()) +``` + +### Resampling Time Series + +Change the temporal resolution of your FlowSystem: + +```python +# Resample to 4-hour intervals +fs_4h = flow_system.transform.resample(time='4h', method='mean') + +# Resample to daily +fs_daily = flow_system.transform.resample(time='1D', method='mean') + +# Re-optimize with new resolution +fs_4h.optimize(fx.solvers.HighsSolver()) +``` + +**Available resampling methods:** `'mean'`, `'sum'`, `'max'`, `'min'`, `'first'`, `'last'` + +### Clustering + +See [Clustered Optimization](#clustered-optimization) above. + +### Use Cases + +| Method | Use Case | +|--------|----------| +| `sel()` / `isel()` | Analyze specific time periods, scenarios, or periods | +| `resample()` | Reduce problem size, test at lower resolution | +| `cluster()` | Investment planning with typical periods | + +## Custom Constraints + +flixOpt is built on [linopy](https://github.com/PyPSA/linopy), allowing you to add custom constraints beyond what's available through the standard API. + +### Adding Custom Constraints + +To add custom constraints, build the model first, then access the underlying linopy model: + +```python +# Build the model (without solving) +flow_system.build_model() + +# Access the linopy model +model = flow_system.model + +# Access variables from the solution namespace +# Variables are named: "ElementLabel|variable_name" +boiler_flow = model.variables['Boiler(Q_th)|flow_rate'] +chp_flow = model.variables['CHP(Q_th)|flow_rate'] + +# Add a custom constraint: Boiler must produce at least as much as CHP +model.add_constraints( + boiler_flow >= chp_flow, + name='boiler_min_chp' +) + +# Solve with the custom constraint +flow_system.solve(fx.solvers.HighsSolver()) +``` + +### Common Use Cases + +**Minimum runtime constraint:** +```python +# Require component to run at least 100 hours total +on_var = model.variables['CHP|on'] # Binary on/off variable +hours = flow_system.hours_per_timestep +model.add_constraints( + (on_var * hours).sum() >= 100, + name='chp_min_runtime' +) +``` + +**Linking flows across components:** +```python +# Heat pump and boiler combined must meet minimum base load +hp_flow = model.variables['HeatPump(Q_th)|flow_rate'] +boiler_flow = model.variables['Boiler(Q_th)|flow_rate'] +model.add_constraints( + hp_flow + boiler_flow >= 50, # At least 50 kW combined + name='min_heat_supply' +) +``` + +**Seasonal constraints:** +```python +import pandas as pd + +# Different constraints for summer vs winter +summer_mask = flow_system.timesteps.month.isin([6, 7, 8]) +winter_mask = flow_system.timesteps.month.isin([12, 1, 2]) + +flow_var = model.variables['Boiler(Q_th)|flow_rate'] + +# Lower capacity in summer +model.add_constraints( + flow_var.sel(time=flow_system.timesteps[summer_mask]) <= 100, + name='summer_limit' +) +``` + +### Inspecting the Model + +Before adding constraints, inspect available variables and existing constraints: + +```python +flow_system.build_model() +model = flow_system.model + +# List all variables +print(model.variables) + +# List all constraints +print(model.constraints) + +# Get details about a specific variable +print(model.variables['Boiler(Q_th)|flow_rate']) +``` + +### Variable Naming Convention + +Variables follow this naming pattern: + +| Element Type | Pattern | Example | +|--------------|---------|---------| +| Flow rate | `Component(FlowLabel)\|flow_rate` | `Boiler(Q_th)\|flow_rate` | +| Flow size | `Component(FlowLabel)\|size` | `Boiler(Q_th)\|size` | +| On/off status | `Component\|on` | `CHP\|on` | +| Charge state | `Storage\|charge_state` | `Battery\|charge_state` | +| Effect totals | `effect_name\|total` | `costs\|total` | + +## Solver Configuration + +### Available Solvers + +| Solver | Type | Speed | License | +|--------|------|-------|---------| +| **HiGHS** | Open-source | Fast | Free | +| **Gurobi** | Commercial | Fastest | Academic/Commercial | +| **CPLEX** | Commercial | Fastest | Academic/Commercial | +| **GLPK** | Open-source | Slower | Free | + +**Recommendation:** Start with HiGHS (included by default). Use Gurobi/CPLEX for large models or when speed matters. + +### Solver Options + +```python +# Basic usage with defaults +flow_system.optimize(fx.solvers.HighsSolver()) + +# With custom options +flow_system.optimize( + fx.solvers.GurobiSolver( + time_limit_seconds=3600, + mip_gap=0.01, + extra_options={ + 'Threads': 4, + 'Presolve': 2 + } + ) +) +``` + +Common solver parameters: + +- `time_limit_seconds` - Maximum solve time +- `mip_gap` - Acceptable optimality gap (0.01 = 1%) +- `log_to_console` - Show solver output + +## Performance Tips + +### Model Size Reduction + +- Use longer timesteps where acceptable +- Use `ClusteredOptimization` for long horizons +- Remove unnecessary components +- Simplify constraint formulations + +### Solver Tuning + +- Enable presolve and cuts +- Adjust optimality tolerances for faster (approximate) solutions +- Use parallel threads when available + +### Problem Formulation + +- Avoid unnecessary binary variables +- Use continuous investment sizes when possible +- Tighten variable bounds +- Remove redundant constraints + +## Debugging + +### Infeasibility + +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) + + # Or disable penalty to enforce strict balance + electricity_bus = fx.Bus('Electricity', excess_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. + +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 + # Gurobi provides detailed infeasibility analysis + flow_system.optimize(fx.solvers.GurobiSolver()) + # If infeasible, check the model documentation file for IIS details + ``` + The infeasible constraints are saved to the model documentation file in the results folder. + +3. Check balance constraints - can supply meet demand? +4. Verify capacity limits are consistent +5. Review storage state requirements +6. Simplify model to isolate the issue + +See [Troubleshooting](../troubleshooting.md) for more details. + +### Unexpected Results + +If solutions don't match expectations: + +1. Verify input data (units, scales) +2. Enable logging: `fx.CONFIG.exploring()` +3. Visualize intermediate results +4. Start with a simpler model +5. Check constraint formulations + +## Next Steps + +- See [Examples](../../notebooks/index.md) for working code +- Learn about [Mathematical Notation](../mathematical-notation/index.md) +- Explore [Recipes](../recipes/index.md) for common patterns diff --git a/docs/user-guide/recipes/index.md b/docs/user-guide/recipes/index.md index 8ac7d1812..38c7fa001 100644 --- a/docs/user-guide/recipes/index.md +++ b/docs/user-guide/recipes/index.md @@ -1,22 +1,10 @@ # Recipes -**Coming Soon!** 🚧 +Short, focused code snippets showing **how to do specific things** in FlixOpt. Unlike full examples, recipes focus on a single concept. -This section will contain quick, copy-paste ready code snippets for common FlixOpt patterns. +## Available Recipes ---- - -## What Will Be Here? - -Short, focused code snippets showing **how to do specific things** in FlixOpt: - -- Common modeling patterns -- Integration with other tools -- Performance optimizations -- Domain-specific solutions -- Data analysis shortcuts - -Unlike full examples, recipes will be focused snippets showing a single concept. +- [Plotting Custom Data](plotting-custom-data.md) - Create faceted plots with your own xarray data using Plotly Express --- @@ -28,7 +16,7 @@ Unlike full examples, recipes will be focused snippets showing a single concept. - **Data Manipulation** - Common xarray operations for parameterization and analysis - **Investment Optimization** - Size optimization strategies - **Renewable Integration** - Solar, wind capacity optimization -- **On/Off Constraints** - Minimum runtime, startup costs +- **Status Constraints** - Minimum runtime, startup costs - **Large-Scale Problems** - Segmented and aggregated calculations - **Custom Constraints** - Extend models with linopy - **Domain-Specific Patterns** - District heating, microgrids, industrial processes @@ -37,9 +25,10 @@ Unlike full examples, recipes will be focused snippets showing a single concept. ## Want to Contribute? -**We need your help!** If you have recurring modeling patterns or clever solutions to share, please contribute via [GitHub issues](https://github.com/flixopt/flixopt/issues) or pull requests. +If you have recurring modeling patterns or clever solutions to share, please contribute via [GitHub issues](https://github.com/flixopt/flixopt/issues) or pull requests. Guidelines: + 1. Keep it short (< 100 lines of code) 2. Focus on one specific technique 3. Add brief explanation and when to use it diff --git a/docs/user-guide/recipes/plotting-custom-data.md b/docs/user-guide/recipes/plotting-custom-data.md new file mode 100644 index 000000000..3c539e6ce --- /dev/null +++ b/docs/user-guide/recipes/plotting-custom-data.md @@ -0,0 +1,125 @@ +# Plotting Custom Data + +The plot accessor (`flow_system.statistics.plot`) is designed for visualizing optimization results using element labels. If you want to create faceted plots with your own custom data (not from a FlowSystem), you can use Plotly Express directly with xarray data. + +## Faceted Plots with Custom xarray Data + +The key is converting your xarray Dataset to a long-form DataFrame that Plotly Express expects: + +```python +import xarray as xr +import pandas as pd +import plotly.express as px + +# Your custom xarray Dataset +my_data = xr.Dataset({ + 'Solar': (['time', 'scenario'], solar_values), + 'Wind': (['time', 'scenario'], wind_values), + 'Demand': (['time', 'scenario'], demand_values), +}, coords={ + 'time': timestamps, + 'scenario': ['Base', 'High RE', 'Low Demand'] +}) + +# Convert to long-form DataFrame for Plotly Express +df = ( + my_data + .to_dataframe() + .reset_index() + .melt( + id_vars=['time', 'scenario'], # Keep as columns + var_name='variable', + value_name='value' + ) +) + +# Faceted stacked bar chart +fig = px.bar( + df, + x='time', + y='value', + color='variable', + facet_col='scenario', + barmode='relative', + title='Energy Balance by Scenario' +) +fig.show() + +# Faceted line plot +fig = px.line( + df, + x='time', + y='value', + color='variable', + facet_col='scenario' +) +fig.show() + +# Faceted area chart +fig = px.area( + df, + x='time', + y='value', + color='variable', + facet_col='scenario' +) +fig.show() +``` + +## Common Plotly Express Faceting Options + +| Parameter | Description | +|-----------|-------------| +| `facet_col` | Dimension for column subplots | +| `facet_row` | Dimension for row subplots | +| `animation_frame` | Dimension for animation slider | +| `facet_col_wrap` | Number of columns before wrapping | + +```python +# Row and column facets +fig = px.line(df, x='time', y='value', color='variable', + facet_col='scenario', facet_row='region') + +# Animation over time periods +fig = px.bar(df, x='variable', y='value', color='variable', + animation_frame='period', barmode='group') + +# Wrap columns +fig = px.line(df, x='time', y='value', color='variable', + facet_col='scenario', facet_col_wrap=2) +``` + +## Heatmaps with Custom Data + +For heatmaps, you can pass 2D arrays directly to `px.imshow`: + +```python +import plotly.express as px + +# 2D data (e.g., days × hours) +heatmap_data = my_data['Solar'].sel(scenario='Base').values.reshape(365, 24) + +fig = px.imshow( + heatmap_data, + labels={'x': 'Hour', 'y': 'Day', 'color': 'Power [kW]'}, + aspect='auto', + color_continuous_scale='portland' +) +fig.show() + +# Faceted heatmaps using subplots +from plotly.subplots import make_subplots +import plotly.graph_objects as go + +scenarios = ['Base', 'High RE'] +fig = make_subplots(rows=1, cols=len(scenarios), subplot_titles=scenarios) + +for i, scenario in enumerate(scenarios, 1): + data = my_data['Solar'].sel(scenario=scenario).values.reshape(365, 24) + fig.add_trace(go.Heatmap(z=data, colorscale='portland'), row=1, col=i) + +fig.update_layout(title='Solar Output by Scenario') +fig.show() +``` + +This approach gives you full control over your visualizations while leveraging Plotly's powerful faceting capabilities. diff --git a/docs/user-guide/results-plotting.md b/docs/user-guide/results-plotting.md new file mode 100644 index 000000000..1ecd26aa1 --- /dev/null +++ b/docs/user-guide/results-plotting.md @@ -0,0 +1,545 @@ +# Plotting Results + +After solving an optimization, flixOpt provides a powerful plotting API to visualize and analyze your results. The API is designed to be intuitive and chainable, giving you quick access to common plots while still allowing deep customization. + +## The Plot Accessor + +All plotting is accessed through the `statistics.plot` accessor on your FlowSystem: + +```python +# Run optimization +flow_system.optimize(fx.solvers.HighsSolver()) + +# Access plotting via statistics +flow_system.statistics.plot.balance('ElectricityBus') +flow_system.statistics.plot.sankey.flows() +flow_system.statistics.plot.heatmap('Boiler(Q_th)|flow_rate') +``` + +## PlotResult: Data + Figure + +Every plot method returns a [`PlotResult`][flixopt.plot_result.PlotResult] object containing both: + +- **`data`**: An xarray Dataset with the prepared data +- **`figure`**: A Plotly Figure object + +This gives you full access to export data, customize the figure, or use the data for your own visualizations: + +```python +result = flow_system.statistics.plot.balance('Bus') + +# Access the xarray data +print(result.data) +result.data.to_dataframe() # Convert to pandas DataFrame +result.data.to_netcdf('balance_data.nc') # Export as netCDF + +# Access and modify the figure +result.figure.update_layout(title='Custom Title') +result.figure.show() +``` + +### Method Chaining + +All `PlotResult` methods return `self`, enabling fluent chaining: + +```python +flow_system.statistics.plot.balance('Bus') \ + .update(title='Custom Title', height=600) \ + .update_traces(opacity=0.8) \ + .to_csv('data.csv') \ + .to_html('plot.html') \ + .show() +``` + +Available methods: + +| Method | Description | +|--------|-------------| +| `.show()` | Display the figure | +| `.update(**kwargs)` | Update figure layout (passes to `fig.update_layout()`) | +| `.update_traces(**kwargs)` | Update traces (passes to `fig.update_traces()`) | +| `.to_html(path)` | Save as interactive HTML | +| `.to_image(path)` | Save as static image (png, svg, pdf) | +| `.to_csv(path)` | Export data to CSV (converts xarray to DataFrame) | +| `.to_netcdf(path)` | Export data to netCDF (native xarray format) | + +## Available Plot Methods + +### Balance Plot + +Plot the energy/material balance at a node (Bus or Component), showing inputs and outputs: + +```python +flow_system.statistics.plot.balance('ElectricityBus') +flow_system.statistics.plot.balance('Boiler', mode='area') +``` + +**Key parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `node` | str | Label of the Bus or Component | +| `mode` | `'bar'`, `'line'`, `'area'` | Visual style (default: `'bar'`) | +| `unit` | `'flow_rate'`, `'flow_hours'` | Power (kW) or energy (kWh) | +| `include` | str or list | Only include flows containing these substrings | +| `exclude` | str or list | Exclude flows containing these substrings | +| `aggregate` | `'sum'`, `'mean'`, `'max'`, `'min'` | Aggregate over time | +| `select` | dict | xarray-style data selection | + +### Storage Plot + +Visualize storage components with charge state and flow balance: + +```python +flow_system.statistics.plot.storage('Battery') +flow_system.statistics.plot.storage('ThermalStorage', mode='line') +``` + +**Key parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `component` | str | Storage component label | +| `mode` | `'bar'`, `'line'`, `'area'` | Visual style | + +### Heatmap + +Create heatmaps of time series data, with automatic time reshaping: + +```python +flow_system.statistics.plot.heatmap('Boiler(Q_th)|flow_rate') +flow_system.statistics.plot.heatmap(['CHP|on', 'Boiler|on'], facet_col='variable') +``` + +**Key parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `variables` | str or list | Variable name(s) to plot | +| `reshape` | tuple | Time reshaping pattern, e.g., `('D', 'h')` for days × hours | +| `colorscale` | str | Plotly colorscale name | + +Common reshape patterns: + +- `('D', 'h')`: Days × Hours (default) +- `('W', 'D')`: Weeks × Days +- `('MS', 'D')`: Months × Days + +### Flows Plot + +Plot flow rates filtered by nodes or components: + +```python +flow_system.statistics.plot.flows(component='Boiler') +flow_system.statistics.plot.flows(start='ElectricityBus') +flow_system.statistics.plot.flows(unit='flow_hours', aggregate='sum') +``` + +**Key parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `start` | str or list | Filter by source node(s) | +| `end` | str or list | Filter by destination node(s) | +| `component` | str or list | Filter by parent component(s) | +| `unit` | `'flow_rate'`, `'flow_hours'` | Power or energy | +| `aggregate` | str | Time aggregation | + +### Compare Plot + +Compare multiple elements side-by-side: + +```python +flow_system.statistics.plot.compare(['Boiler', 'CHP', 'HeatPump'], variable='flow_rate') +flow_system.statistics.plot.compare(['Battery1', 'Battery2'], variable='charge_state') +``` + +**Key parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `elements` | list | Element labels to compare | +| `variable` | str | Variable suffix to compare | +| `mode` | `'overlay'`, `'facet'` | Same axes or subplots | + +### Sankey Diagram + +Visualize energy/material flows as a Sankey diagram. Access via the `sankey` accessor: + +```python +# Energy flow amounts (default) +flow_system.statistics.plot.sankey.flows() +flow_system.statistics.plot.sankey.flows(select={'time': '2023-01-01 12:00'}) # specific time +flow_system.statistics.plot.sankey.flows(aggregate='mean') # mean instead of sum + +# Investment sizes/capacities +flow_system.statistics.plot.sankey.sizes() + +# Peak flow rates +flow_system.statistics.plot.sankey.peak_flow() + +# Effect contributions (costs, CO2, etc.) +flow_system.statistics.plot.sankey.effects() +flow_system.statistics.plot.sankey.effects(select={'effect': 'costs'}) +``` + +**Available methods:** + +| Method | Description | +|--------|-------------| +| `sankey.flows()` | Energy/material flow amounts | +| `sankey.sizes()` | Investment sizes/capacities | +| `sankey.peak_flow()` | Maximum flow rates | +| `sankey.effects()` | Component contributions to effects | + +**Select options for filtering:** + +```python +# Filter by bus or component +flow_system.statistics.plot.sankey.flows(select={'bus': 'HeatBus'}) +flow_system.statistics.plot.sankey.flows(select={'component': ['Boiler', 'CHP']}) + +# Filter effects by name +flow_system.statistics.plot.sankey.effects(select={'effect': 'costs'}) +flow_system.statistics.plot.sankey.effects(select={'effect': ['costs', 'CO2']}) +``` + +### Effects Plot + +Plot cost, emissions, or other effect breakdowns. Effects can be grouped by component, individual contributor (flows), or time. + +```python +flow_system.statistics.plot.effects() # Total of all effects by component +flow_system.statistics.plot.effects(effect='costs') # Just costs +flow_system.statistics.plot.effects(by='contributor') # By individual flows/components +flow_system.statistics.plot.effects(aspect='temporal', by='time') # Over time +``` + +**Key parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `aspect` | `'total'`, `'temporal'`, `'periodic'` | Which aspect to plot (default: `'total'`) | +| `effect` | str or None | Specific effect to plot (e.g., `'costs'`, `'CO2'`). If None, plots all. | +| `by` | `'component'`, `'contributor'`, `'time'` | Grouping dimension (default: `'component'`) | +| `select` | dict | xarray-style data selection | +| `colors` | dict | Color overrides for categories | +| `facet_col` | str | Dimension for column facets (default: `'scenario'`) | +| `facet_row` | str | Dimension for row facets (default: `'period'`) | + +**Grouping options:** + +- **`by='component'`**: Groups effects by parent component (e.g., all flows from a Boiler are summed together) +- **`by='contributor'`**: Shows individual contributors - flows and components that directly contribute to effects +- **`by='time'`**: Shows effects over time (only valid for `aspect='temporal'`) + +!!! note "Contributors vs Components" + Contributors include not just flows, but also components that directly contribute to effects (e.g., via `effects_per_active_hour`). The system automatically detects all contributors from the optimization solution. + +### Variable Plot + +Plot the same variable type across multiple elements for comparison: + +```python +flow_system.statistics.plot.variable('on') # All binary operation states +flow_system.statistics.plot.variable('flow_rate', include='Boiler') +flow_system.statistics.plot.variable('charge_state') # All storage charge states +``` + +**Key parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `pattern` | str | Variable suffix to match (e.g., `'on'`, `'flow_rate'`) | +| `include` | str or list | Only include elements containing these substrings | +| `exclude` | str or list | Exclude elements containing these substrings | +| `aggregate` | str | Time aggregation method | +| `mode` | `'line'`, `'bar'`, `'area'` | Visual style | + +### Duration Curve + +Plot load duration curves (sorted time series) to understand utilization patterns: + +```python +flow_system.statistics.plot.duration_curve('Boiler(Q_th)') +flow_system.statistics.plot.duration_curve(['CHP(Q_th)', 'HeatPump(Q_th)']) +flow_system.statistics.plot.duration_curve('Demand(in)', normalize=True) +``` + +**Key parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `variables` | str or list | Variable name(s) to plot | +| `normalize` | bool | Normalize x-axis to 0-100% (default: False) | +| `mode` | `'line'`, `'area'` | Visual style | + +## Common Parameters + +Most plot methods share these parameters: + +### Data Selection + +Use xarray-style selection to filter data before plotting: + +```python +# Single value +flow_system.statistics.plot.balance('Bus', select={'scenario': 'base'}) + +# Multiple values +flow_system.statistics.plot.balance('Bus', select={'scenario': ['base', 'high_demand']}) + +# Time slices +flow_system.statistics.plot.balance('Bus', select={'time': slice('2024-01', '2024-06')}) + +# Combined +flow_system.statistics.plot.balance('Bus', select={ + 'scenario': 'base', + 'time': slice('2024-01-01', '2024-01-07') +}) +``` + +### Faceting and Animation + +Control how multi-dimensional data is displayed: + +```python +# Facet by scenario +flow_system.statistics.plot.balance('Bus', facet_col='scenario') + +# Animate by period +flow_system.statistics.plot.balance('Bus', animate_by='period') + +# Both +flow_system.statistics.plot.balance('Bus', facet_col='scenario', animate_by='period') +``` + +!!! note + Facet and animation dimensions are automatically ignored if not present in the data. Defaults are `facet_col='scenario'` and `animate_by='period'` for balance plots. + +### Include/Exclude Filtering + +Filter flows using simple substring matching: + +```python +# Only show flows containing 'Q_th' +flow_system.statistics.plot.balance('Bus', include='Q_th') + +# Exclude flows containing 'Gas' or 'Grid' +flow_system.statistics.plot.balance('Bus', exclude=['Gas', 'Grid']) + +# Combine include and exclude +flow_system.statistics.plot.balance('Bus', include='Boiler', exclude='auxiliary') +``` + +### Colors + +Override colors using a dictionary: + +```python +flow_system.statistics.plot.balance('Bus', colors={ + 'Boiler(Q_th)': '#ff6b6b', + 'CHP(Q_th)': '#4ecdc4', +}) +``` + +## Color Management + +flixOpt provides centralized color management through the `flow_system.colors` accessor and carriers. This ensures consistent colors across all visualizations. + +### Carriers + +[`Carriers`][flixopt.carrier.Carrier] define energy or material types with associated colors. Built-in carriers are available in `CONFIG.Carriers`: + +| Carrier | Color | Description | +|---------|-------|-------------| +| `electricity` | `#FECB52` | Yellow - lightning/energy | +| `heat` | `#D62728` | Red - warmth/fire | +| `gas` | `#1F77B4` | Blue - natural gas | +| `hydrogen` | `#9467BD` | Purple - clean/future | +| `fuel` | `#8C564B` | Brown - fossil/oil | +| `biomass` | `#2CA02C` | Green - organic/renewable | + +Colors are from the D3/Plotly palettes for professional consistency. + +Assign carriers to buses for automatic coloring: + +```python +# Buses use carrier colors automatically +heat_bus = fx.Bus('HeatNetwork', carrier='heat') +elec_bus = fx.Bus('Grid', carrier='electricity') + +# Plots automatically use carrier colors for bus-related elements +flow_system.statistics.plot.sankey.flows() # Buses colored by carrier +``` + +### Custom Carriers + +Register custom carriers on your FlowSystem: + +```python +# Create a custom carrier +biogas = fx.Carrier('biogas', color='#228B22', unit='kW', description='Biogas fuel') +hydrogen = fx.Carrier('hydrogen', color='#00CED1', unit='kg/h') + +# Register with FlowSystem (overrides CONFIG.Carriers defaults) +flow_system.add_carrier(biogas) +flow_system.add_carrier(hydrogen) + +# Access registered carriers +flow_system.carriers # CarrierContainer with locally registered carriers +flow_system.get_carrier('biogas') # Returns Carrier object +``` + +### Color Accessor + +The `flow_system.colors` accessor provides centralized color configuration: + +```python +# Configure colors for components +flow_system.colors.setup({ + 'Boiler': '#D35400', + 'CHP': '#8E44AD', + 'HeatPump': '#27AE60', +}) + +# Or set individual colors +flow_system.colors.set_component_color('Boiler', '#D35400') +flow_system.colors.set_carrier_color('biogas', '#228B22') + +# Load from file +flow_system.colors.setup('colors.json') # or .yaml +``` + +### Context-Aware Coloring + +Plot colors are automatically resolved based on context: + +- **Bus balance plots**: Colors based on the connected component +- **Component balance plots**: Colors based on the connected bus/carrier +- **Sankey diagrams**: Buses use carrier colors, components use configured colors + +```python +# Plotting a bus balance → flows colored by their parent component +flow_system.statistics.plot.balance('ElectricityBus') + +# Plotting a component balance → flows colored by their connected bus/carrier +flow_system.statistics.plot.balance('CHP') +``` + +### Color Resolution Priority + +Colors are resolved in this order: + +1. **Explicit colors** passed to plot methods (always override) +2. **Component/bus colors** set via `flow_system.colors.setup()` +3. **Element `meta_data['color']`** if present +4. **Carrier colors** from FlowSystem or CONFIG.Carriers +5. **Default colorscale** (controlled by `CONFIG.Plotting.default_qualitative_colorscale`) + +### Persistence + +Color configurations are automatically saved with the FlowSystem: + +```python +# Colors are persisted +flow_system.to_netcdf('my_system.nc') + +# And restored +loaded = fx.FlowSystem.from_netcdf('my_system.nc') +loaded.colors # Configuration restored +``` + +### Display Control + +Control whether plots are shown automatically: + +```python +# Don't show (useful in scripts) +result = flow_system.statistics.plot.balance('Bus', show=False) + +# Show later +result.show() +``` + +The default behavior is controlled by `CONFIG.Plotting.default_show`. + +## Complete Examples + +### Analyzing a Bus Balance + +```python +# Quick overview +flow_system.statistics.plot.balance('ElectricityBus') + +# Detailed analysis with exports +result = flow_system.statistics.plot.balance( + 'ElectricityBus', + mode='area', + unit='flow_hours', + select={'time': slice('2024-06-01', '2024-06-07')}, + show=False +) + +# Access xarray data for further analysis +print(result.data) # xarray Dataset +df = result.data.to_dataframe() # Convert to pandas + +# Export data +result.to_netcdf('electricity_balance.nc') # Native xarray format +result.to_csv('electricity_balance.csv') # As CSV + +# Customize and display +result.update( + title='Electricity Balance - First Week of June', + yaxis_title='Energy [kWh]' +).show() +``` + +### Comparing Storage Units + +```python +# Compare charge states +flow_system.statistics.plot.compare( + ['Battery1', 'Battery2', 'ThermalStorage'], + variable='charge_state', + mode='overlay' +).update(title='Storage Comparison') +``` + +### Creating a Report + +```python +# Generate multiple plots for a report +plots = { + 'balance': flow_system.statistics.plot.balance('HeatBus', show=False), + 'storage': flow_system.statistics.plot.storage('ThermalStorage', show=False), + 'sankey': flow_system.statistics.plot.sankey.flows(show=False), + 'costs': flow_system.statistics.plot.effects(effect='costs', show=False), +} + +# Export all +for name, plot in plots.items(): + plot.to_html(f'report_{name}.html') + plot.to_netcdf(f'report_{name}.nc') # xarray native format +``` + +### Working with xarray Data + +The `.data` attribute returns xarray objects, giving you full access to xarray's powerful data manipulation capabilities: + +```python +result = flow_system.statistics.plot.balance('Bus', show=False) + +# Access the xarray Dataset +ds = result.data + +# Use xarray operations +ds.mean(dim='time') # Average over time +ds.sel(time='2024-06') # Select specific time +ds.to_dataframe() # Convert to pandas + +# Export options +ds.to_netcdf('data.nc') # Native xarray format +ds.to_zarr('data.zarr') # Zarr format for large datasets +``` diff --git a/docs/user-guide/results/index.md b/docs/user-guide/results/index.md new file mode 100644 index 000000000..a9b40f7f9 --- /dev/null +++ b/docs/user-guide/results/index.md @@ -0,0 +1,283 @@ +# Analyzing Results + +After running an optimization, flixOpt provides powerful tools to access, analyze, and visualize your results. + +## Accessing Solution Data + +### Raw Solution + +The `solution` property contains all optimization variables as an xarray Dataset: + +```python +# Run optimization +flow_system.optimize(fx.solvers.HighsSolver()) + +# Access the full solution dataset +solution = flow_system.solution +print(solution) + +# Access specific variables +print(solution['Boiler(Q_th)|flow_rate']) +print(solution['Battery|charge_state']) +``` + +### Element-Specific Solutions + +Access solution data for individual elements: + +```python +# Component solutions +boiler = flow_system.components['Boiler'] +print(boiler.solution) # All variables for this component + +# Flow solutions +flow = flow_system.flows['Boiler(Q_th)'] +print(flow.solution) + +# Bus solutions (if imbalance is allowed) +bus = flow_system.buses['Heat'] +print(bus.solution) +``` + +## Statistics Accessor + +The `statistics` accessor provides pre-computed aggregations for common analysis tasks: + +```python +# Access via the statistics property +stats = flow_system.statistics +``` + +### Available Data Properties + +| Property | Description | +|----------|-------------| +| `flow_rates` | All flow rate variables as xarray Dataset | +| `flow_hours` | Flow hours (flow_rate × hours_per_timestep) | +| `sizes` | All size variables (fixed and optimized) | +| `charge_states` | Storage charge state variables | +| `temporal_effects` | Temporal effects per contributor per timestep | +| `periodic_effects` | Periodic (investment) effects per contributor | +| `total_effects` | Total effects (temporal + periodic) per contributor | +| `effect_share_factors` | Conversion factors between effects | + +### Examples + +```python +# Get all flow rates +flow_rates = flow_system.statistics.flow_rates +print(flow_rates) + +# Get flow hours (energy) +flow_hours = flow_system.statistics.flow_hours +total_heat = flow_hours['Boiler(Q_th)'].sum() + +# Get sizes (capacities) +sizes = flow_system.statistics.sizes +print(f"Boiler size: {sizes['Boiler(Q_th)'].values}") + +# Get storage charge states +charge_states = flow_system.statistics.charge_states + +# Get effect breakdown by contributor +temporal = flow_system.statistics.temporal_effects +print(temporal['costs']) # Costs per contributor per timestep + +# Group by component +temporal['costs'].groupby('component').sum() +``` + +### Effect Analysis + +Analyze how effects (costs, emissions, etc.) are distributed: + +```python +# Access effects via the new properties +stats = flow_system.statistics + +# Temporal effects per timestep (costs, CO2, etc. per contributor) +stats.temporal_effects['costs'] # DataArray with dims [time, contributor] +stats.temporal_effects['costs'].sum('contributor') # Total per timestep + +# Periodic effects (investment costs, etc.) +stats.periodic_effects['costs'] # DataArray with dim [contributor] + +# Total effects (temporal + periodic combined) +stats.total_effects['costs'].sum('contributor') # Grand total + +# Group by component or component type +stats.total_effects['costs'].groupby('component').sum() +stats.total_effects['costs'].groupby('component_type').sum() +``` + +!!! tip "Contributors" + Contributors are automatically detected from the optimization solution and include: + + - **Flows**: Individual flows with `effects_per_flow_hour` + - **Components**: Components with `effects_per_active_hour` or similar direct effects + + Each contributor has associated metadata (`component` and `component_type` coordinates) for flexible groupby operations. + +## Plotting Results + +The `statistics.plot` accessor provides visualization methods: + +```python +# Balance plots +flow_system.statistics.plot.balance('HeatBus') +flow_system.statistics.plot.balance('Boiler') + +# Heatmaps +flow_system.statistics.plot.heatmap('Boiler(Q_th)|flow_rate') + +# Duration curves +flow_system.statistics.plot.duration_curve('Boiler(Q_th)') + +# Sankey diagrams +flow_system.statistics.plot.sankey() + +# Effects breakdown +flow_system.statistics.plot.effects() # Total costs by component +flow_system.statistics.plot.effects(effect='costs', by='contributor') # By individual flows +flow_system.statistics.plot.effects(aspect='temporal', by='time') # Over time +``` + +See [Plotting Results](../results-plotting.md) for comprehensive plotting documentation. + +## Network Visualization + +The `topology` accessor lets you visualize and inspect your system structure: + +### Static HTML Visualization + +Generate an interactive network diagram using PyVis: + +```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 +) +``` + +**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 | + +### Interactive App + +Launch a Dash/Cytoscape application for exploring the network: + +```python +# Start the visualization server +flow_system.topology.start_app() + +# ... interact with the visualization in your browser ... + +# Stop when done +flow_system.topology.stop_app() +``` + +!!! note "Optional Dependencies" + The interactive app requires additional packages: + ```bash + pip install flixopt[network_viz] + ``` + +### Network Structure Info + +Get node and edge information programmatically: + +```python +nodes, edges = flow_system.topology.infos() + +# nodes: dict mapping labels to properties +# {'Boiler': {'label': 'Boiler', 'class': 'Component', 'infos': '...'}, ...} + +# edges: dict mapping flow labels to properties +# {'Boiler(Q_th)': {'label': 'Q_th', 'start': 'Boiler', 'end': 'Heat', ...}, ...} + +print(f"Components and buses: {list(nodes.keys())}") +print(f"Flows: {list(edges.keys())}") +``` + +## Saving and Loading + +Save the FlowSystem (including solution) for later analysis: + +```python +# Save to NetCDF (recommended for large datasets) +flow_system.to_netcdf('results/my_system.nc') + +# Load later +loaded_fs = fx.FlowSystem.from_netcdf('results/my_system.nc') +print(loaded_fs.solution) + +# Save to JSON (human-readable, smaller datasets) +flow_system.to_json('results/my_system.json') +loaded_fs = fx.FlowSystem.from_json('results/my_system.json') +``` + +## Working with xarray + +All result data uses [xarray](https://docs.xarray.dev/), giving you powerful data manipulation: + +```python +solution = flow_system.solution + +# Select specific times +summer = solution.sel(time=slice('2024-06-01', '2024-08-31')) + +# Aggregate over dimensions +daily_avg = solution.resample(time='D').mean() + +# Convert to pandas +df = solution['Boiler(Q_th)|flow_rate'].to_dataframe() + +# Export to various formats +solution.to_netcdf('full_solution.nc') +df.to_csv('boiler_flow.csv') +``` + +## Complete Example + +```python +import flixopt as fx +import pandas as pd + +# Build and optimize +timesteps = pd.date_range('2024-01-01', periods=168, freq='h') +flow_system = fx.FlowSystem(timesteps) +# ... add elements ... +flow_system.optimize(fx.solvers.HighsSolver()) + +# Visualize network structure +flow_system.topology.plot(path='system_network.html') + +# Analyze results +print("=== Flow Statistics ===") +print(flow_system.statistics.flow_hours) + +print("\n=== Effect Breakdown ===") +print(flow_system.statistics.total_effects) + +# Create plots +flow_system.statistics.plot.balance('HeatBus') +flow_system.statistics.plot.heatmap('Boiler(Q_th)|flow_rate') + +# Save for later +flow_system.to_netcdf('results/optimized_system.nc') +``` + +## Next Steps + +- [Plotting Results](../results-plotting.md) - Detailed plotting documentation +- [Examples](../../notebooks/index.md) - Working code examples diff --git a/docs/user-guide/support.md b/docs/user-guide/support.md new file mode 100644 index 000000000..eba27c616 --- /dev/null +++ b/docs/user-guide/support.md @@ -0,0 +1,23 @@ +# Support + +## Getting Help + +**[GitHub Issues](https://github.com/flixOpt/flixopt/issues)** — Report bugs or ask questions + +When opening an issue, include: + +- Minimal reproducible example +- flixOpt version: `python -c "import flixopt; print(flixopt.__version__)"` +- Python version and OS +- Full error message + +## Resources + +- [FAQ](faq.md) — Common questions +- [Troubleshooting](troubleshooting.md) — Common issues +- [Examples](../notebooks/index.md) — Working code +- [API Reference](../api-reference/) — Technical docs + +## Contributing + +See our [Contributing Guide](../contribute.md) for how to help improve flixOpt. diff --git a/docs/user-guide/troubleshooting.md b/docs/user-guide/troubleshooting.md new file mode 100644 index 000000000..2c89be8dc --- /dev/null +++ b/docs/user-guide/troubleshooting.md @@ -0,0 +1,61 @@ +# Troubleshooting + +## Infeasible Model + +**Problem:** Solver reports the model is infeasible. + +**Solutions:** + +1. Check that supply can meet demand at all timesteps +2. Verify capacity limits are sufficient +3. Review storage initial/final states + +## Unbounded Model + +**Problem:** Solver reports the model is unbounded. + +**Solutions:** + +1. Add upper bounds to all flows +2. Ensure investment parameters have maximum sizes +3. Verify effect coefficients have correct signs + +## Unexpected Results + +**Debugging Steps:** + +1. Enable logging: + ```python + from flixopt import CONFIG + CONFIG.exploring() + ``` + +2. Start with a minimal model and add complexity incrementally + +3. Check units are consistent + +4. Visualize results to verify energy balances + +## Slow Solve Times + +**Solutions:** + +1. Use longer timesteps or aggregate time periods +2. Use Gurobi instead of HiGHS for large models +3. Set solver options: + ```python + solver = fx.solvers.GurobiSolver( + time_limit_seconds=3600, + mip_gap=0.01 + ) + ``` + +## Getting Help + +If you're stuck: + +1. Search [GitHub Issues](https://github.com/flixOpt/flixopt/issues) +2. Open a new issue with: + - Minimal reproducible example + - flixopt and Python version + - Full error message diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index 7a94b2222..207faa9a9 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -32,5 +32,5 @@ ), ) - optimization = fx.Optimization('Simulation1', flow_system).solve(fx.solvers.HighsSolver(0.01, 60)) - optimization.results['Heat'].plot_node_balance() + flow_system.optimize(fx.solvers.HighsSolver(0.01, 60)) + flow_system.statistics.plot.balance('Heat') diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index c2d6d88e1..b63260ece 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -21,7 +21,12 @@ # --- Define Energy Buses --- # These represent nodes, where the used medias are balanced (electricity, heat, and gas) - flow_system.add_elements(fx.Bus(label='Strom'), fx.Bus(label='Fernwärme'), fx.Bus(label='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 @@ -100,28 +105,22 @@ flow_system.add_elements(costs, CO2, boiler, storage, chp, heat_sink, gas_source, power_sink) # Visualize the flow system for validation purposes - flow_system.plot_network() - - # --- Define and Run Calculation --- - # Create a calculation object to model the Flow System - optimization = fx.Optimization(name='Sim1', flow_system=flow_system) - optimization.do_modeling() # Translate the model to a solvable form, creating equations and Variables + flow_system.topology.plot() - # --- Solve the Calculation and Save Results --- - optimization.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) + # --- Define and Solve Optimization --- + flow_system.optimize(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) # --- Analyze Results --- - # Colors are automatically assigned using default colormap - # Optional: Configure custom colors with - optimization.results.setup_colors() - optimization.results['Fernwärme'].plot_node_balance_pie() - optimization.results['Fernwärme'].plot_node_balance() - optimization.results['Storage'].plot_charge_state() - optimization.results.plot_heatmap('CHP(Q_th)|flow_rate') - - # Convert the results for the storage component to a dataframe and display - df = optimization.results['Storage'].node_balance_with_charge_state() - print(df) - - # Save results to file for later usage - optimization.results.to_file() + # 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/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 2913f643f..3f38ff954 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -13,9 +13,8 @@ # --- Experiment Options --- # Configure options for testing various parameters and behaviors check_penalty = False - excess_penalty = 1e5 + imbalance_penalty = 1e5 use_chp_with_piecewise_conversion = True - time_indices = None # Define specific time steps for custom optimizations, or use the entire series # --- Define Demand and Price Profiles --- # Input data for electricity and heat demands, as well as electricity price @@ -33,10 +32,11 @@ # --- 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', excess_penalty_per_flow_hour=excess_penalty), - fx.Bus('Fernwärme', excess_penalty_per_flow_hour=excess_penalty), - fx.Bus('Gas', excess_penalty_per_flow_hour=excess_penalty), + fx.Bus('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 --- @@ -47,12 +47,12 @@ # --- Define Components --- # 1. Define Boiler Component - # A gas boiler that converts fuel into thermal output, with investment and on-off parameters + # 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 - on_off_parameters=fx.OnOffParameters( - effects_per_running_hour={Costs.label: 0, CO2.label: 1000} + 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 @@ -69,14 +69,14 @@ relative_maximum=1, # Maximum part load previous_flow_rate=50, # Previous flow rate flow_hours_max=1e6, # Total energy flow limit - on_off_parameters=fx.OnOffParameters( - on_hours_min=0, # Minimum operating hours - on_hours_max=1000, # Maximum operating hours - consecutive_on_hours_max=10, # Max consecutive operating hours - consecutive_on_hours_min=np.array([1, 1, 1, 1, 1, 2, 2, 2, 2]), # min consecutive operation hours - consecutive_off_hours_max=10, # Max consecutive off hours - effects_per_switch_on=0.01, # Cost per switch-on - switch_on_max=1000, # Max number of starts + 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), @@ -88,7 +88,7 @@ 'BHKW2', thermal_efficiency=0.5, electrical_efficiency=0.4, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + 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 @@ -112,7 +112,7 @@ inputs=[Q_fu], outputs=[P_el, Q_th], piecewise_conversion=piecewise_conversion, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=0.01), + status_parameters=fx.StatusParameters(effects_per_startup={Costs.label: 0.01}), ) # 4. Define Storage Component @@ -189,22 +189,19 @@ print(flow_system) # Get a string representation of the FlowSystem try: - flow_system.start_network_app() # Start the network app + flow_system.topology.start_app() # Start the network app except ImportError as e: print(f'Network app requires extra dependencies: {e}') # --- Solve FlowSystem --- - optimization = fx.Optimization('complex example', flow_system, time_indices) - optimization.do_modeling() - - optimization.solve(fx.solvers.HighsSolver(0.01, 60)) + flow_system.optimize(fx.solvers.HighsSolver(0.01, 60)) # --- Results --- - # You can analyze results directly or save them to file and reload them later. - optimization.results.to_file() - - # But let's plot some results anyway - optimization.results.plot_heatmap('BHKW2(Q_th)|flow_rate') - optimization.results['BHKW2'].plot_node_balance() - optimization.results['Speicher'].plot_charge_state() - optimization.results['Fernwärme'].plot_node_balance_pie() + # 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/examples/02_Complex/complex_example_results.py b/examples/02_Complex/complex_example_results.py index 7f1123a26..6978caff1 100644 --- a/examples/02_Complex/complex_example_results.py +++ b/examples/02_Complex/complex_example_results.py @@ -1,5 +1,5 @@ """ -This script shows how load results of a prior calcualtion and how to analyze them. +This script shows how to load results of a prior optimization and how to analyze them. """ import flixopt as fx @@ -7,31 +7,32 @@ if __name__ == '__main__': fx.CONFIG.exploring() - # --- Load Results --- + # --- Load FlowSystem with Solution --- try: - results = fx.results.Results.from_file('results', 'complex example') + flow_system = fx.FlowSystem.from_netcdf('results/complex_example.nc') except FileNotFoundError as e: raise FileNotFoundError( - f"Results file not found in the specified directory ('results'). " + 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 --- - results.plot_network() - results['Fernwärme'].plot_node_balance() + flow_system.topology.plot() + flow_system.statistics.plot.balance('Fernwärme') # --- Detailed Plots --- - # In depth plot for individual flow rates ('__' is used as the delimiter between Component and Flow - results.plot_heatmap('Wärmelast(Q_th_Last)|flow_rate') - for bus in results.buses.values(): - bus.plot_node_balance_pie(show=False, save=f'results/{bus.label}--pie.html') - bus.plot_node_balance(show=False, save=f'results/{bus.label}--balance.html') + # 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 --- - results.plot_heatmap('BHKW2(Q_th)|on') - results.plot_heatmap('Kessel(Q_th)|on') + flow_system.statistics.plot.heatmap('BHKW2(Q_th)|status') + flow_system.statistics.plot.heatmap('Kessel(Q_th)|status') - # Dataframes from results: - fw_bus = results['Fernwärme'].node_balance().to_dataframe() - all = results.solution.to_dataframe() + # Access data as DataFrames: + print(flow_system.statistics.flow_rates.to_dataframe()) + print(flow_system.solution.to_dataframe()) diff --git a/examples/03_Optimization_modes/example_optimization_modes.py b/examples/03_Optimization_modes/example_optimization_modes.py index d3ae566e4..1f9968357 100644 --- a/examples/03_Optimization_modes/example_optimization_modes.py +++ b/examples/03_Optimization_modes/example_optimization_modes.py @@ -16,9 +16,11 @@ 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: - dataarrays.append(optimization.results.solution[variable].rename(optimization.name)) + # 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') @@ -41,7 +43,7 @@ def get_solutions(optimizations: list, variable: str) -> xr.Dataset: penalty_of_period_freedom=0, ) keep_extreme_periods = True - excess_penalty = 1e5 # or set to None if not needed + imbalance_penalty = 1e5 # or set to None if not needed # Data Import data_import = pd.read_csv( @@ -67,10 +69,10 @@ def get_solutions(optimizations: list, variable: str) -> xr.Dataset: flow_system = fx.FlowSystem(timesteps) flow_system.add_elements( - fx.Bus('Strom', excess_penalty_per_flow_hour=excess_penalty), - fx.Bus('Fernwärme', excess_penalty_per_flow_hour=excess_penalty), - fx.Bus('Gas', excess_penalty_per_flow_hour=excess_penalty), - fx.Bus('Kohle', excess_penalty_per_flow_hour=excess_penalty), + 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 @@ -91,7 +93,7 @@ def get_solutions(optimizations: list, variable: str) -> xr.Dataset: size=95, relative_minimum=12 / 95, previous_flow_rate=20, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=1000), + status_parameters=fx.StatusParameters(effects_per_startup=1000), ), ) @@ -100,7 +102,7 @@ def get_solutions(optimizations: list, variable: str) -> xr.Dataset: 'BHKW2', thermal_efficiency=0.58, electrical_efficiency=0.22, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=24000), + 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), @@ -176,7 +178,7 @@ def get_solutions(optimizations: list, variable: str) -> xr.Dataset: a_kwk, a_speicher, ) - flow_system.plot_network() + flow_system.topology.plot() # Optimizations optimizations: list[fx.Optimization | fx.ClusteredOptimization | fx.SegmentedOptimization] = [] diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index 6ae01c4f0..820336e93 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -89,7 +89,12 @@ # --- Define Energy Buses --- # These represent nodes, where the used medias are balanced (electricity, heat, and gas) - flow_system.add_elements(fx.Bus(label='Strom'), fx.Bus(label='Fernwärme'), fx.Bus(label='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 @@ -120,10 +125,10 @@ thermal_flow=fx.Flow( label='Q_th', bus='Fernwärme', - size=50, + size=100, relative_minimum=0.1, relative_maximum=1, - on_off_parameters=fx.OnOffParameters(), + status_parameters=fx.StatusParameters(), ), fuel_flow=fx.Flow(label='Q_fu', bus='Gas'), ) @@ -135,7 +140,7 @@ 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=60, relative_minimum=5 / 60, on_off_parameters=fx.OnOffParameters() + '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'), @@ -192,35 +197,18 @@ flow_system.add_elements(costs, CO2, boiler, storage, chp, heat_sink, gas_source, power_sink) # Visualize the flow system for validation purposes - flow_system.plot_network() - - # --- Define and Run Calculation --- - # Create a calculation object to model the Flow System - optimization = fx.Optimization(name='Sim1', flow_system=flow_system) - optimization.do_modeling() # Translate the model to a solvable form, creating equations and Variables - - # --- Solve the Calculation and Save Results --- - optimization.solve(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) - - optimization.results.setup_colors( - { - 'CHP': 'red', - 'Greys': ['Gastarif', 'Einspeisung', 'Heat Demand'], - 'Storage': 'blue', - 'Boiler': 'orange', - } - ) + flow_system.topology.plot() - optimization.results.plot_heatmap('CHP(Q_th)|flow_rate') + # --- Define and Solve Optimization --- + flow_system.optimize(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=30)) # --- Analyze Results --- - optimization.results['Fernwärme'].plot_node_balance(mode='stacked_bar') - optimization.results.plot_heatmap('CHP(Q_th)|flow_rate') - optimization.results['Storage'].plot_charge_state() - optimization.results['Fernwärme'].plot_node_balance_pie(select={'period': 2020, 'scenario': 'Base Case'}) - - # Convert the results for the storage component to a dataframe and display - df = optimization.results['Storage'].node_balance_with_charge_state() - - # Save results to file for later usage - optimization.results.to_file() + # 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/examples/05_Two-stage-optimization/two_stage_optimization.py b/examples/05_Two-stage-optimization/two_stage_optimization.py index d8f4e87fe..3f3278477 100644 --- a/examples/05_Two-stage-optimization/two_stage_optimization.py +++ b/examples/05_Two-stage-optimization/two_stage_optimization.py @@ -11,6 +11,7 @@ import pathlib import timeit +import numpy as np import pandas as pd import xarray as xr @@ -37,11 +38,12 @@ gas_price = filtered_data['Gaspr.€/MWh'].to_numpy() flow_system = fx.FlowSystem(timesteps) + # Carriers provide automatic color assignment in plots flow_system.add_elements( - fx.Bus('Strom'), - fx.Bus('Fernwärme'), - fx.Bus('Gas'), - fx.Bus('Kohle'), + fx.Bus('Strom', carrier='electricity'), + fx.Bus('Fernwärme', carrier='heat'), + fx.Bus('Gas', carrier='gas'), + fx.Bus('Kohle', carrier='fuel'), fx.Effect('costs', '€', 'Kosten', is_standard=True, is_objective=True), fx.Effect('CO2', 'kg', 'CO2_e-Emissionen'), fx.Effect('PE', 'kWh_PE', 'Primärenergie'), @@ -53,20 +55,18 @@ label='Q_fu', bus='Gas', size=fx.InvestParameters( - effects_of_investment_per_size={'costs': 1_000}, minimum_size=10, maximum_size=500 + effects_of_investment_per_size={'costs': 1_000}, minimum_size=10, maximum_size=600 ), relative_minimum=0.2, previous_flow_rate=20, - on_off_parameters=fx.OnOffParameters(effects_per_switch_on=300), + status_parameters=fx.StatusParameters(effects_per_startup=300), ), ), fx.linear_converters.CHP( 'BHKW2', thermal_efficiency=0.58, electrical_efficiency=0.22, - on_off_parameters=fx.OnOffParameters( - effects_per_switch_on=1_000, consecutive_on_hours_min=10, consecutive_off_hours_min=10 - ), + status_parameters=fx.StatusParameters(effects_per_startup=1_000, min_uptime=10, min_downtime=10), electrical_flow=fx.Flow('P_el', bus='Strom'), thermal_flow=fx.Flow('Q_th', bus='Fernwärme'), fuel_flow=fx.Flow( @@ -89,8 +89,8 @@ 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'), + 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)] @@ -124,34 +124,39 @@ ) # 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.results.solution) + 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 - if (calculation_dispatch.results.sizes().round(5) == calculation_sizing.results.sizes().round(5)).all().item(): + # 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') - # Optimization of both flow sizes and dispatch together + # 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 + # Comparison of results - access solutions from flow_system comparison = xr.concat( - [calculation_combined.results.solution, calculation_dispatch.results.solution], dim='mode' + [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') diff --git a/flixopt/__init__.py b/flixopt/__init__.py index 3941cb491..1e3fee5bd 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -14,10 +14,8 @@ # Import commonly used classes and functions from . import linear_converters, plotting, results, solvers - -# Import old Calculation classes for backwards compatibility (deprecated) -from .calculation import AggregatedCalculation, FullCalculation, SegmentedCalculation -from .clustering import AggregationParameters, ClusteringParameters # AggregationParameters is deprecated +from .carrier import Carrier, CarrierContainer +from .clustering import ClusteringParameters from .components import ( LinearConverter, Sink, @@ -26,20 +24,20 @@ Storage, Transmission, ) -from .config import CONFIG, change_logging_level +from .config import CONFIG from .core import TimeSeriesData from .effects import PENALTY_EFFECT_LABEL, Effect from .elements import Bus, Flow from .flow_system import FlowSystem -from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects - -# Import new Optimization classes +from .interface import InvestParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects, StatusParameters from .optimization import ClusteredOptimization, Optimization, SegmentedOptimization +from .plot_result import PlotResult __all__ = [ 'TimeSeriesData', 'CONFIG', - 'change_logging_level', + 'Carrier', + 'CarrierContainer', 'Flow', 'Bus', 'Effect', @@ -51,22 +49,17 @@ 'LinearConverter', 'Transmission', 'FlowSystem', - # New Optimization classes (preferred) 'Optimization', 'ClusteredOptimization', 'SegmentedOptimization', - # Old Calculation classes (deprecated, for backwards compatibility) - 'FullCalculation', - 'AggregatedCalculation', - 'SegmentedCalculation', 'InvestParameters', - 'OnOffParameters', + 'StatusParameters', 'Piece', 'Piecewise', 'PiecewiseConversion', 'PiecewiseEffects', 'ClusteringParameters', - 'AggregationParameters', # Deprecated, use ClusteringParameters + 'PlotResult', 'plotting', 'results', 'linear_converters', diff --git a/flixopt/calculation.py b/flixopt/calculation.py deleted file mode 100644 index 1211c6763..000000000 --- a/flixopt/calculation.py +++ /dev/null @@ -1,177 +0,0 @@ -""" -This module provides backwards-compatible aliases for the renamed Optimization classes. - -DEPRECATED: This module is deprecated. Use the optimization module instead. -The following classes have been renamed: - - Calculation -> Optimization - - FullCalculation -> Optimization (now the standard, no "Full" prefix) - - AggregatedCalculation -> ClusteredOptimization - - SegmentedCalculation -> SegmentedOptimization - -Import from flixopt.optimization or use the new names from flixopt directly. -""" - -from __future__ import annotations - -import logging -import warnings -from typing import TYPE_CHECKING - -from .config import DEPRECATION_REMOVAL_VERSION -from .optimization import ( - ClusteredOptimization as _ClusteredOptimization, -) -from .optimization import ( - Optimization as _Optimization, -) -from .optimization import ( - SegmentedOptimization as _SegmentedOptimization, -) - -if TYPE_CHECKING: - import pathlib - from typing import Annotated - - import pandas as pd - - from .clustering import AggregationParameters - from .elements import Component - from .flow_system import FlowSystem - -logger = logging.getLogger('flixopt') - - -def _deprecation_warning(old_name: str, new_name: str): - """Issue a deprecation warning for renamed classes.""" - warnings.warn( - f'{old_name} is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. Use {new_name} instead.', - DeprecationWarning, - stacklevel=3, - ) - - -class Calculation(_Optimization): - """ - DEPRECATED: Use Optimization instead. - - class for defined way of solving a flow_system optimization - - Args: - name: name of calculation - flow_system: flow_system which should be calculated - folder: folder where results should be saved. If None, then the current working directory is used. - normalize_weights: Whether to automatically normalize the weights of scenarios to sum up to 1 when solving. - active_timesteps: Deprecated. Use FlowSystem.sel(time=...) or FlowSystem.isel(time=...) instead. - """ - - def __init__( - self, - name: str, - flow_system: FlowSystem, - active_timesteps: Annotated[ - pd.DatetimeIndex | None, - 'DEPRECATED: Use flow_system.sel(time=...) or flow_system.isel(time=...) instead', - ] = None, - folder: pathlib.Path | None = None, - normalize_weights: bool = True, - ): - _deprecation_warning('Calculation', 'Optimization') - super().__init__(name, flow_system, active_timesteps, folder, normalize_weights) - - -class FullCalculation(_Optimization): - """ - DEPRECATED: Use Optimization instead (the "Full" prefix has been removed). - - FullCalculation solves the complete optimization problem using all time steps. - - This is the most comprehensive calculation type that considers every time step - in the optimization, providing the most accurate but computationally intensive solution. - - Args: - name: name of calculation - flow_system: flow_system which should be calculated - folder: folder where results should be saved. If None, then the current working directory is used. - normalize_weights: Whether to automatically normalize the weights of scenarios to sum up to 1 when solving. - active_timesteps: Deprecated. Use FlowSystem.sel(time=...) or FlowSystem.isel(time=...) instead. - """ - - def __init__( - self, - name: str, - flow_system: FlowSystem, - active_timesteps: Annotated[ - pd.DatetimeIndex | None, - 'DEPRECATED: Use flow_system.sel(time=...) or flow_system.isel(time=...) instead', - ] = None, - folder: pathlib.Path | None = None, - normalize_weights: bool = True, - ): - _deprecation_warning('FullCalculation', 'Optimization') - super().__init__(name, flow_system, active_timesteps, folder, normalize_weights) - - -class AggregatedCalculation(_ClusteredOptimization): - """ - DEPRECATED: Use ClusteredOptimization instead. - - AggregatedCalculation reduces computational complexity by clustering time series into typical periods. - - This calculation approach aggregates time series data using clustering techniques (tsam) to identify - representative time periods, significantly reducing computation time while maintaining solution accuracy. - - Args: - name: Name of the calculation - flow_system: FlowSystem to be optimized - aggregation_parameters: Parameters for aggregation. See AggregationParameters class documentation - components_to_clusterize: list of Components to perform aggregation on. If None, all components are aggregated. - This equalizes variables in the components according to the typical periods computed in the aggregation - active_timesteps: DatetimeIndex of timesteps to use for optimization. If None, all timesteps are used - folder: Folder where results should be saved. If None, current working directory is used - """ - - def __init__( - self, - name: str, - flow_system: FlowSystem, - aggregation_parameters: AggregationParameters, - components_to_clusterize: list[Component] | None = None, - active_timesteps: Annotated[ - pd.DatetimeIndex | None, - 'DEPRECATED: Use flow_system.sel(time=...) or flow_system.isel(time=...) instead', - ] = None, - folder: pathlib.Path | None = None, - ): - _deprecation_warning('AggregatedCalculation', 'ClusteredOptimization') - super().__init__(name, flow_system, aggregation_parameters, components_to_clusterize, active_timesteps, folder) - - -class SegmentedCalculation(_SegmentedOptimization): - """ - DEPRECATED: Use SegmentedOptimization instead. - - Solve large optimization problems by dividing time horizon into (overlapping) segments. - - 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). - overlap_timesteps: Number of additional timesteps added to each segment. - nr_of_previous_values: Number of previous timestep values to transfer between segments for initialization. - folder: Directory for saving results. Defaults to current working directory + 'results'. - """ - - 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, - ): - _deprecation_warning('SegmentedCalculation', 'SegmentedOptimization') - super().__init__(name, flow_system, timesteps_per_segment, overlap_timesteps, nr_of_previous_values, folder) - - -__all__ = ['Calculation', 'FullCalculation', 'AggregatedCalculation', 'SegmentedCalculation'] diff --git a/flixopt/carrier.py b/flixopt/carrier.py new file mode 100644 index 000000000..8a663eca9 --- /dev/null +++ b/flixopt/carrier.py @@ -0,0 +1,159 @@ +"""Carrier class for energy/material type definitions. + +Carriers represent types of energy or materials that flow through buses, +such as electricity, heat, gas, or water. They provide consistent styling +and metadata across visualizations. +""" + +from __future__ import annotations + +from .structure import ContainerMixin, Interface, register_class_for_io + + +@register_class_for_io +class Carrier(Interface): + """Definition of an energy or material carrier type. + + Carriers represent the type of energy or material flowing through a Bus. + They provide consistent color, unit, and description across all visualizations + and can be shared between multiple buses of the same type. + + Inherits from Interface to provide serialization capabilities. + + Args: + name: Identifier for the carrier (e.g., 'electricity', 'heat', 'gas'). + color: Hex color string for visualizations (e.g., '#FFD700'). + unit: Unit string for display (e.g., 'kW', 'kW_th', 'm³/h'). + description: Optional human-readable description. + + Examples: + Creating custom carriers: + + ```python + import flixopt as fx + + # Define custom carriers + electricity = fx.Carrier('electricity', '#FFD700', 'kW', 'Electrical power') + district_heat = fx.Carrier('district_heat', '#FF6B6B', 'kW_th', 'District heating') + hydrogen = fx.Carrier('hydrogen', '#00CED1', 'kg/h', 'Hydrogen fuel') + + # Register with FlowSystem + flow_system.add_carrier(electricity) + flow_system.add_carrier(district_heat) + + # Use with buses (just reference by name) + elec_bus = fx.Bus('MainGrid', carrier='electricity') + heat_bus = fx.Bus('HeatingNetwork', carrier='district_heat') + ``` + + Using predefined carriers from CONFIG: + + ```python + # Access built-in carriers + elec = fx.CONFIG.Carriers.electricity + heat = fx.CONFIG.Carriers.heat + + # Use directly + bus = fx.Bus('Grid', carrier='electricity') + ``` + + Adding custom carriers to CONFIG: + + ```python + # Add a new carrier globally + fx.CONFIG.Carriers.add(fx.Carrier('biogas', '#228B22', 'kW', 'Biogas')) + + # Now available as + fx.CONFIG.Carriers.biogas + ``` + + Note: + Carriers are compared by name for equality, allowing flexible usage + patterns where the same carrier type can be referenced by name string + or Carrier object interchangeably. + """ + + def __init__( + self, + name: str, + color: str = '', + unit: str = '', + description: str = '', + ) -> None: + """Initialize a Carrier. + + Args: + name: Identifier for the carrier (normalized to lowercase). + color: Hex color string for visualizations. + unit: Unit string for display. + description: Optional human-readable description. + """ + self.name = name.lower() + self.color = color + self.unit = unit + self.description = description + + def transform_data(self, name_prefix: str = '') -> None: + """Transform data to match FlowSystem dimensions. + + Carriers don't have time-series data, so this is a no-op. + + Args: + name_prefix: Ignored for Carrier. + """ + pass # Carriers have no data to transform + + @property + def label(self) -> str: + """Label for container keying (alias for name).""" + return self.name + + def __hash__(self): + return hash(self.name) + + def __eq__(self, other): + if isinstance(other, Carrier): + return self.name == other.name + if isinstance(other, str): + return self.name == other.lower() + return False + + def __repr__(self): + return f"Carrier('{self.name}', color='{self.color}', unit='{self.unit}')" + + def __str__(self): + return self.name + + +class CarrierContainer(ContainerMixin['Carrier']): + """Container for Carrier objects. + + Uses carrier.name for keying. Provides dict-like access to carriers + registered with a FlowSystem. + + Examples: + ```python + # Access via FlowSystem + carriers = flow_system.carriers + + # Dict-like access + elec = carriers['electricity'] + 'heat' in carriers # True/False + + # Iteration + for name in carriers: + print(name) + ``` + """ + + def __init__(self, carriers: list[Carrier] | dict[str, Carrier] | None = None): + """Initialize a CarrierContainer. + + Args: + carriers: Initial carriers to add. + """ + super().__init__(elements=carriers, element_type_name='carriers') + + def _get_label(self, carrier: Carrier) -> str: + """Extract name from Carrier for keying.""" + return carrier.name diff --git a/flixopt/clustering.py b/flixopt/clustering.py index 2fbd65318..d392167a1 100644 --- a/flixopt/clustering.py +++ b/flixopt/clustering.py @@ -7,15 +7,11 @@ import copy import logging -import pathlib import timeit -import warnings as _warnings from typing import TYPE_CHECKING import numpy as np -from .config import DEPRECATION_REMOVAL_VERSION - try: import tsam.timeseriesaggregation as tsam @@ -26,6 +22,7 @@ from .color_processing import process_colors from .components import Storage from .config import CONFIG +from .plot_result import PlotResult from .structure import ( FlowSystemModel, Submodel, @@ -34,7 +31,6 @@ if TYPE_CHECKING: import linopy import pandas as pd - import plotly.graph_objects as go from .core import Scalar, TimeSeriesData from .elements import Component @@ -147,8 +143,28 @@ def describe_clusters(self) -> str: def use_extreme_periods(self): return self.time_series_for_high_peaks or self.time_series_for_low_peaks - def plot(self, colormap: str | None = None, show: bool = True, save: pathlib.Path | None = None) -> go.Figure: - from . import plotting + def plot(self, colormap: str | None = None, show: bool | None = None) -> PlotResult: + """Plot original vs aggregated data comparison. + + Visualizes the original time series (dashed lines) overlaid with + the aggregated/clustered time series (solid lines) for comparison. + + Args: + colormap: Colorscale name for the time series colors. + Defaults to CONFIG.Plotting.default_qualitative_colorscale. + show: Whether to display the figure. + Defaults to CONFIG.Plotting.default_show. + + Returns: + PlotResult containing the comparison figure and underlying data. + + Examples: + >>> clustering.cluster() + >>> clustering.plot() + >>> clustering.plot(colormap='Set2', show=False).to_html('clustering.html') + """ + import plotly.express as px + import xarray as xr df_org = self.original_data.copy().rename( columns={col: f'Original - {col}' for col in self.original_data.columns} @@ -159,10 +175,17 @@ def plot(self, colormap: str | None = None, show: bool = True, save: pathlib.Pat colors = list( process_colors(colormap or CONFIG.Plotting.default_qualitative_colorscale, list(df_org.columns)).values() ) - fig = plotting.with_plotly(df_org.to_xarray(), 'line', colors=colors, xlabel='Time in h') + + # Create line plot for original data (dashed) + index_name = df_org.index.name or 'index' + df_org_long = df_org.reset_index().melt(id_vars=index_name, var_name='variable', value_name='value') + fig = px.line(df_org_long, x=index_name, y='value', color='variable', color_discrete_sequence=colors) for trace in fig.data: - trace.update(dict(line=dict(dash='dash'))) - fig2 = plotting.with_plotly(df_agg.to_xarray(), 'line', colors=colors, xlabel='Time in h') + trace.update(line=dict(dash='dash')) + + # Add aggregated data (solid lines) + df_agg_long = df_agg.reset_index().melt(id_vars=index_name, var_name='variable', value_name='value') + fig2 = px.line(df_agg_long, x=index_name, y='value', color='variable', color_discrete_sequence=colors) for trace in fig2.data: fig.add_trace(trace) @@ -172,16 +195,21 @@ def plot(self, colormap: str | None = None, show: bool = True, save: pathlib.Pat yaxis_title='Value', ) - plotting.export_figure( - figure_like=fig, - default_path=pathlib.Path('aggregated data.html'), - default_filetype='.html', - user_path=save, - show=show, - save=save is not None, + # Build xarray Dataset with both original and aggregated data + data = xr.Dataset( + { + 'original': self.original_data.to_xarray().to_array(dim='variable'), + 'aggregated': self.aggregated_data.to_xarray().to_array(dim='variable'), + } ) + result = PlotResult(data=data, figure=fig) + + if show is None: + show = CONFIG.Plotting.default_show + if show: + result.show() - return fig + return result def get_cluster_indices(self) -> dict[str, list[np.ndarray]]: """ @@ -401,39 +429,3 @@ def _equate_indices(self, variable: linopy.Variable, indices: tuple[np.ndarray, var_k0.sum(dim='time') + var_k1.sum(dim='time') <= limit, short_name=f'limit_corrections|{variable.name}', ) - - -# ===== Deprecated aliases for backward compatibility ===== - - -def _create_deprecation_warning(old_name: str, new_name: str): - """Helper to create a deprecation warning""" - _warnings.warn( - f"'{old_name}' is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. Use '{new_name}' instead.", - DeprecationWarning, - stacklevel=3, - ) - - -class Aggregation(Clustering): - """Deprecated: Use Clustering instead.""" - - def __init__(self, *args, **kwargs): - _create_deprecation_warning('Aggregation', 'Clustering') - super().__init__(*args, **kwargs) - - -class AggregationParameters(ClusteringParameters): - """Deprecated: Use ClusteringParameters instead.""" - - def __init__(self, *args, **kwargs): - _create_deprecation_warning('AggregationParameters', 'ClusteringParameters') - super().__init__(*args, **kwargs) - - -class AggregationModel(ClusteringModel): - """Deprecated: Use ClusteringModel instead.""" - - def __init__(self, *args, **kwargs): - _create_deprecation_warning('AggregationModel', 'ClusteringModel') - super().__init__(*args, **kwargs) diff --git a/flixopt/color_processing.py b/flixopt/color_processing.py index 2959acc82..62d8a9542 100644 --- a/flixopt/color_processing.py +++ b/flixopt/color_processing.py @@ -15,6 +15,57 @@ logger = logging.getLogger('flixopt') +# Type alias for flexible color input +ColorType = str | list[str] | dict[str, str] +"""Flexible color specification type supporting multiple input formats for visualization. + +Color specifications can take several forms to accommodate different use cases: + +**Named colorscales** (str): + - Standard colorscales: 'turbo', 'plasma', 'cividis', 'tab10', 'Set1' + - Energy-focused: 'portland' (custom flixopt colorscale for energy systems) + - Backend-specific maps available in Plotly and Matplotlib + +**Color Lists** (list[str]): + - Explicit color sequences: ['red', 'blue', 'green', 'orange'] + - HEX codes: ['#FF0000', '#0000FF', '#00FF00', '#FFA500'] + - Mixed formats: ['red', '#0000FF', 'green', 'orange'] + +**Label-to-Color Mapping** (dict[str, str]): + - Explicit associations: {'Wind': 'skyblue', 'Solar': 'gold', 'Gas': 'brown'} + - Ensures consistent colors across different plots and datasets + - Ideal for energy system components with semantic meaning + +Examples: + ```python + # Named colorscale + colors = 'turbo' # Automatic color generation + + # Explicit color list + colors = ['red', 'blue', 'green', '#FFD700'] + + # Component-specific mapping + colors = { + 'Wind_Turbine': 'skyblue', + 'Solar_Panel': 'gold', + 'Natural_Gas': 'brown', + 'Battery': 'green', + 'Electric_Load': 'darkred' + } + ``` + +Color Format Support: + - **Named Colors**: 'red', 'blue', 'forestgreen', 'darkorange' + - **HEX Codes**: '#FF0000', '#0000FF', '#228B22', '#FF8C00' + - **RGB Tuples**: (255, 0, 0), (0, 0, 255) [Matplotlib only] + - **RGBA**: 'rgba(255,0,0,0.8)' [Plotly only] + +References: + - HTML Color Names: https://htmlcolorcodes.com/color-names/ + - Matplotlib colorscales: https://matplotlib.org/stable/tutorials/colors/colorscales.html + - Plotly Built-in Colorscales: https://plotly.com/python/builtin-colorscales/ +""" + def _rgb_string_to_hex(color: str) -> str: """Convert Plotly RGB/RGBA string format to hex. @@ -58,6 +109,59 @@ def _rgb_string_to_hex(color: str) -> str: return color +def color_to_rgba(color: str | None, alpha: float = 1.0) -> str: + """Convert any valid color to RGBA string format. + + Handles hex colors (with or without #), named colors, and rgb/rgba strings. + + Args: + color: Color in any valid format (hex '#FF0000' or 'FF0000', + named 'red', rgb 'rgb(255,0,0)', rgba 'rgba(255,0,0,1)'). + alpha: Alpha/opacity value between 0.0 and 1.0. + + Returns: + Color in RGBA format 'rgba(R, G, B, A)'. + + Examples: + >>> color_to_rgba('#FF0000') + 'rgba(255, 0, 0, 1.0)' + >>> color_to_rgba('FF0000') + 'rgba(255, 0, 0, 1.0)' + >>> color_to_rgba('red', 0.5) + 'rgba(255, 0, 0, 0.5)' + >>> color_to_rgba('forestgreen', 0.4) + 'rgba(34, 139, 34, 0.4)' + >>> color_to_rgba(None) + 'rgba(200, 200, 200, 1.0)' + """ + if not color: + return f'rgba(200, 200, 200, {alpha})' + + try: + # Use matplotlib's robust color conversion (handles hex, named, etc.) + rgba = mcolors.to_rgba(color) + except ValueError: + # Try adding # prefix for bare hex colors (e.g., 'FF0000' -> '#FF0000') + if len(color) == 6 and all(c in '0123456789ABCDEFabcdef' for c in color): + try: + rgba = mcolors.to_rgba(f'#{color}') + except ValueError: + return f'rgba(200, 200, 200, {alpha})' + else: + return f'rgba(200, 200, 200, {alpha})' + except TypeError: + return f'rgba(200, 200, 200, {alpha})' + + r = int(round(rgba[0] * 255)) + g = int(round(rgba[1] * 255)) + b = int(round(rgba[2] * 255)) + return f'rgba({r}, {g}, {b}, {alpha})' + + +# Alias for backwards compatibility +hex_to_rgba = color_to_rgba + + def process_colors( colors: None | str | list[str] | dict[str, str], labels: list[str], diff --git a/flixopt/components.py b/flixopt/components.py index 8d1d59e03..267c144af 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -5,18 +5,16 @@ from __future__ import annotations import logging -import warnings from typing import TYPE_CHECKING, Literal import numpy as np import xarray as xr from . import io as fx_io -from .config import DEPRECATION_REMOVAL_VERSION from .core import PlausibilityError from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, PiecewiseModel -from .interface import InvestParameters, OnOffParameters, PiecewiseConversion +from .interface import InvestParameters, PiecewiseConversion, StatusParameters from .modeling import BoundingPatterns from .structure import FlowSystemModel, register_class_for_io @@ -43,16 +41,15 @@ class LinearConverter(Component): behavior approximated through piecewise linear segments. Mathematical Formulation: - See the complete mathematical model in the documentation: - [LinearConverter](../user-guide/mathematical-notation/elements/LinearConverter.md) + See Args: label: The label of the Element. Used to identify it in the FlowSystem. inputs: list of input Flows that feed into the converter. outputs: list of output Flows that are produced by the converter. - on_off_parameters: Information about on and off state of LinearConverter. - Component is On/Off if all connected Flows are On/Off. This induces an - On-Variable (binary) in all Flows! If possible, use OnOffParameters in a + status_parameters: Information about active and inactive state of LinearConverter. + Component is active/inactive if all connected Flows are active/inactive. This induces a + status variable (binary) in all Flows! If possible, use StatusParameters in a single Flow instead to keep the number of binary variables low. conversion_factors: Linear relationships between flows expressed as a list of dictionaries. Each dictionary maps flow labels to their coefficients in one @@ -169,12 +166,12 @@ def __init__( label: str, inputs: list[Flow], outputs: list[Flow], - on_off_parameters: OnOffParameters | None = None, + status_parameters: StatusParameters | None = None, conversion_factors: list[dict[str, Numeric_TPS]] | None = None, piecewise_conversion: PiecewiseConversion | None = None, meta_data: dict | None = None, ): - super().__init__(label, inputs, outputs, on_off_parameters, meta_data=meta_data) + super().__init__(label, inputs, outputs, status_parameters, meta_data=meta_data) self.conversion_factors = conversion_factors or [] self.piecewise_conversion = piecewise_conversion @@ -183,11 +180,11 @@ def create_model(self, model: FlowSystemModel) -> LinearConverterModel: self.submodel = LinearConverterModel(model, self) return self.submodel - def _set_flow_system(self, flow_system) -> None: + def link_to_flow_system(self, flow_system, prefix: str = '') -> None: """Propagate flow_system reference to parent Component and piecewise_conversion.""" - super()._set_flow_system(flow_system) + super().link_to_flow_system(flow_system, prefix) if self.piecewise_conversion is not None: - self.piecewise_conversion._set_flow_system(flow_system) + self.piecewise_conversion.link_to_flow_system(flow_system, self._sub_prefix('PiecewiseConversion')) def _plausibility_checks(self) -> None: super()._plausibility_checks() @@ -219,14 +216,13 @@ def _plausibility_checks(self) -> None: f'({flow.label_full}).' ) - def transform_data(self, name_prefix: str = '') -> None: - prefix = '|'.join(filter(None, [name_prefix, self.label_full])) - super().transform_data(prefix) + def transform_data(self) -> None: + super().transform_data() if self.conversion_factors: self.conversion_factors = self._transform_conversion_factors() if self.piecewise_conversion: self.piecewise_conversion.has_time_dim = True - self.piecewise_conversion.transform_data(f'{prefix}|PiecewiseConversion') + self.piecewise_conversion.transform_data() def _transform_conversion_factors(self) -> list[dict[str, xr.DataArray]]: """Converts all conversion factors to internal datatypes""" @@ -262,25 +258,16 @@ class Storage(Component): and investment-optimized storage systems with comprehensive techno-economic modeling. Mathematical Formulation: - See the complete mathematical model in the documentation: - [Storage](../user-guide/mathematical-notation/elements/Storage.md) - - - Equation (1): Charge state bounds - - Equation (3): Storage balance (charge state evolution) - - Variable Mapping: - - ``capacity_in_flow_hours`` → C (storage capacity) - - ``charge_state`` → c(t_i) (state of charge at time t_i) - - ``relative_loss_per_hour`` → ċ_rel,loss (self-discharge rate) - - ``eta_charge`` → η_in (charging efficiency) - - ``eta_discharge`` → η_out (discharging efficiency) + See Args: label: Element identifier used in the FlowSystem. charging: Incoming flow for loading the storage. discharging: Outgoing flow for unloading the storage. capacity_in_flow_hours: Storage capacity in flow-hours (kWh, m³, kg). - Scalar for fixed size or InvestParameters for optimization. + Scalar for fixed size, InvestParameters for optimization, or None (unbounded). + Default: None (unbounded capacity). When using InvestParameters, + maximum_size (or fixed_size) must be explicitly set for proper model scaling. relative_minimum_charge_state: Minimum charge state (0-1). Default: 0. relative_maximum_charge_state: Maximum charge state (0-1). Default: 1. initial_charge_state: Charge at start. Numeric or 'equals_final'. Default: 0. @@ -381,6 +368,11 @@ class Storage(Component): variables enforce mutual exclusivity, increasing solution time but preventing unrealistic simultaneous charging and discharging. + **Unbounded capacity**: When capacity_in_flow_hours is None (default), the storage has + unlimited capacity. Note that prevent_simultaneous_charge_and_discharge requires the + charging and discharging flows to have explicit sizes. Use prevent_simultaneous_charge_and_discharge=False + with unbounded storages, or set flow sizes explicitly. + **Units**: Flow rates and charge states are related by the concept of 'flow hours' (=flow_rate * time). With flow rates in kW, the charge state is therefore (usually) kWh. With flow rates in m3/h, the charge state is therefore in m3. @@ -393,7 +385,7 @@ def __init__( label: str, charging: Flow, discharging: Flow, - capacity_in_flow_hours: Numeric_PS | InvestParameters, + capacity_in_flow_hours: Numeric_PS | InvestParameters | None = None, relative_minimum_charge_state: Numeric_TPS = 0, relative_maximum_charge_state: Numeric_TPS = 1, initial_charge_state: Numeric_PS | Literal['equals_final'] = 0, @@ -416,14 +408,6 @@ def __init__( prevent_simultaneous_flows=[charging, discharging] if prevent_simultaneous_charge_and_discharge else None, meta_data=meta_data, ) - if isinstance(initial_charge_state, str) and initial_charge_state == 'lastValueOfSim': - warnings.warn( - f'{initial_charge_state=} is deprecated. Use "equals_final" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - initial_charge_state = 'equals_final' self.charging = charging self.discharging = discharging @@ -449,49 +433,50 @@ def create_model(self, model: FlowSystemModel) -> StorageModel: self.submodel = StorageModel(model, self) return self.submodel - def _set_flow_system(self, flow_system) -> None: + def link_to_flow_system(self, flow_system, prefix: str = '') -> None: """Propagate flow_system reference to parent Component and capacity_in_flow_hours if it's InvestParameters.""" - super()._set_flow_system(flow_system) + super().link_to_flow_system(flow_system, prefix) if isinstance(self.capacity_in_flow_hours, InvestParameters): - self.capacity_in_flow_hours._set_flow_system(flow_system) + self.capacity_in_flow_hours.link_to_flow_system(flow_system, self._sub_prefix('InvestParameters')) - def transform_data(self, name_prefix: str = '') -> None: - prefix = '|'.join(filter(None, [name_prefix, self.label_full])) - super().transform_data(prefix) + def transform_data(self) -> None: + super().transform_data() self.relative_minimum_charge_state = self._fit_coords( - f'{prefix}|relative_minimum_charge_state', self.relative_minimum_charge_state + f'{self.prefix}|relative_minimum_charge_state', self.relative_minimum_charge_state ) self.relative_maximum_charge_state = self._fit_coords( - f'{prefix}|relative_maximum_charge_state', self.relative_maximum_charge_state + f'{self.prefix}|relative_maximum_charge_state', self.relative_maximum_charge_state + ) + self.eta_charge = self._fit_coords(f'{self.prefix}|eta_charge', self.eta_charge) + self.eta_discharge = self._fit_coords(f'{self.prefix}|eta_discharge', self.eta_discharge) + self.relative_loss_per_hour = self._fit_coords( + f'{self.prefix}|relative_loss_per_hour', self.relative_loss_per_hour ) - self.eta_charge = self._fit_coords(f'{prefix}|eta_charge', self.eta_charge) - self.eta_discharge = self._fit_coords(f'{prefix}|eta_discharge', self.eta_discharge) - self.relative_loss_per_hour = self._fit_coords(f'{prefix}|relative_loss_per_hour', self.relative_loss_per_hour) if not isinstance(self.initial_charge_state, str): self.initial_charge_state = self._fit_coords( - f'{prefix}|initial_charge_state', self.initial_charge_state, dims=['period', 'scenario'] + f'{self.prefix}|initial_charge_state', self.initial_charge_state, dims=['period', 'scenario'] ) self.minimal_final_charge_state = self._fit_coords( - f'{prefix}|minimal_final_charge_state', self.minimal_final_charge_state, dims=['period', 'scenario'] + f'{self.prefix}|minimal_final_charge_state', self.minimal_final_charge_state, dims=['period', 'scenario'] ) self.maximal_final_charge_state = self._fit_coords( - f'{prefix}|maximal_final_charge_state', self.maximal_final_charge_state, dims=['period', 'scenario'] + f'{self.prefix}|maximal_final_charge_state', self.maximal_final_charge_state, dims=['period', 'scenario'] ) self.relative_minimum_final_charge_state = self._fit_coords( - f'{prefix}|relative_minimum_final_charge_state', + f'{self.prefix}|relative_minimum_final_charge_state', self.relative_minimum_final_charge_state, dims=['period', 'scenario'], ) self.relative_maximum_final_charge_state = self._fit_coords( - f'{prefix}|relative_maximum_final_charge_state', + f'{self.prefix}|relative_maximum_final_charge_state', self.relative_maximum_final_charge_state, dims=['period', 'scenario'], ) if isinstance(self.capacity_in_flow_hours, InvestParameters): - self.capacity_in_flow_hours.transform_data(f'{prefix}|InvestParameters') + self.capacity_in_flow_hours.transform_data() else: self.capacity_in_flow_hours = self._fit_coords( - f'{prefix}|capacity_in_flow_hours', self.capacity_in_flow_hours, dims=['period', 'scenario'] + f'{self.prefix}|capacity_in_flow_hours', self.capacity_in_flow_hours, dims=['period', 'scenario'] ) def _plausibility_checks(self) -> None: @@ -507,31 +492,58 @@ def _plausibility_checks(self) -> None: raise PlausibilityError(f'initial_charge_state has undefined value: {self.initial_charge_state}') initial_equals_final = True - # Use new InvestParameters methods to get capacity bounds - if isinstance(self.capacity_in_flow_hours, InvestParameters): - minimum_capacity = self.capacity_in_flow_hours.minimum_or_fixed_size - maximum_capacity = self.capacity_in_flow_hours.maximum_or_fixed_size - else: - maximum_capacity = self.capacity_in_flow_hours - minimum_capacity = self.capacity_in_flow_hours - - # Initial capacity should not constraint investment decision - minimum_initial_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=0) - maximum_initial_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=0) - - # Only perform numeric comparisons if not using 'equals_final' - if not initial_equals_final: - if (self.initial_charge_state > maximum_initial_capacity).any(): + # Capacity is required when using non-default relative bounds + if self.capacity_in_flow_hours is None: + if np.any(self.relative_minimum_charge_state > 0): + raise PlausibilityError( + f'Storage "{self.label_full}" has relative_minimum_charge_state > 0 but no capacity_in_flow_hours. ' + f'A capacity is required because the lower bound is capacity * relative_minimum_charge_state.' + ) + if np.any(self.relative_maximum_charge_state < 1): raise PlausibilityError( - f'{self.label_full}: {self.initial_charge_state=} ' - f'is constraining the investment decision. Chosse a value above {maximum_initial_capacity}' + f'Storage "{self.label_full}" has relative_maximum_charge_state < 1 but no capacity_in_flow_hours. ' + f'A capacity is required because the upper bound is capacity * relative_maximum_charge_state.' ) - if (self.initial_charge_state < minimum_initial_capacity).any(): + if self.relative_minimum_final_charge_state is not None: raise PlausibilityError( - f'{self.label_full}: {self.initial_charge_state=} ' - f'is constraining the investment decision. Chosse a value below {minimum_initial_capacity}' + f'Storage "{self.label_full}" has relative_minimum_final_charge_state but no capacity_in_flow_hours. ' + f'A capacity is required for relative final charge state constraints.' + ) + if self.relative_maximum_final_charge_state is not None: + raise PlausibilityError( + f'Storage "{self.label_full}" has relative_maximum_final_charge_state but no capacity_in_flow_hours. ' + f'A capacity is required for relative final charge state constraints.' ) + # Skip capacity-related checks if capacity is None (unbounded) + if self.capacity_in_flow_hours is not None: + # Use new InvestParameters methods to get capacity bounds + if isinstance(self.capacity_in_flow_hours, InvestParameters): + minimum_capacity = self.capacity_in_flow_hours.minimum_or_fixed_size + maximum_capacity = self.capacity_in_flow_hours.maximum_or_fixed_size + else: + maximum_capacity = self.capacity_in_flow_hours + minimum_capacity = self.capacity_in_flow_hours + + # Initial charge state should not constrain investment decision + # If initial > (min_cap * rel_max), investment is forced to increase capacity + # If initial < (max_cap * rel_min), investment is forced to decrease capacity + min_initial_at_max_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=0) + max_initial_at_min_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=0) + + # Only perform numeric comparisons if not using 'equals_final' + if not initial_equals_final: + if (self.initial_charge_state > max_initial_at_min_capacity).any(): + raise PlausibilityError( + f'{self.label_full}: {self.initial_charge_state=} ' + f'is constraining the investment decision. Choose a value <= {max_initial_at_min_capacity}.' + ) + if (self.initial_charge_state < min_initial_at_max_capacity).any(): + raise PlausibilityError( + f'{self.label_full}: {self.initial_charge_state=} ' + f'is constraining the investment decision. Choose a value >= {min_initial_at_max_capacity}.' + ) + if self.balanced: if not isinstance(self.charging.size, InvestParameters) or not isinstance( self.discharging.size, InvestParameters @@ -540,13 +552,13 @@ def _plausibility_checks(self) -> None: f'Balancing charging and discharging Flows in {self.label_full} is only possible with Investments.' ) - if (self.charging.size.minimum_size > self.discharging.size.maximum_size).any() or ( - self.charging.size.maximum_size < self.discharging.size.minimum_size + if (self.charging.size.minimum_or_fixed_size > self.discharging.size.maximum_or_fixed_size).any() or ( + self.charging.size.maximum_or_fixed_size < self.discharging.size.minimum_or_fixed_size ).any(): raise PlausibilityError( f'Balancing charging and discharging Flows in {self.label_full} need compatible minimum and maximum sizes.' - f'Got: {self.charging.size.minimum_size=}, {self.charging.size.maximum_size=} and ' - f'{self.discharging.size.minimum_size=}, {self.discharging.size.maximum_size=}.' + f'Got: {self.charging.size.minimum_or_fixed_size=}, {self.charging.size.maximum_or_fixed_size=} and ' + f'{self.discharging.size.minimum_or_fixed_size=}, {self.discharging.size.maximum_or_fixed_size=}.' ) def __repr__(self) -> str: @@ -583,8 +595,8 @@ class Transmission(Component): relative_losses: Proportional losses as fraction of throughput (e.g., 0.02 for 2% loss). Applied as: output = input × (1 - relative_losses) absolute_losses: Fixed losses that occur when transmission is active. - Automatically creates binary variables for on/off states. - on_off_parameters: Parameters defining binary operation constraints and costs. + Automatically creates binary variables for active/inactive states. + status_parameters: Parameters defining binary operation constraints and costs. prevent_simultaneous_flows_in_both_directions: If True, prevents simultaneous flow in both directions. Increases binary variables but reflects physical reality for most transmission systems. Default is True. @@ -639,7 +651,7 @@ class Transmission(Component): ) ``` - Material conveyor with on/off operation: + Material conveyor with active/inactive status: ```python conveyor_belt = Transmission( @@ -647,10 +659,10 @@ class Transmission(Component): in1=loading_station, out1=unloading_station, absolute_losses=25, # 25 kW motor power when running - on_off_parameters=OnOffParameters( - effects_per_switch_on={'maintenance': 0.1}, - consecutive_on_hours_min=2, # Minimum 2-hour operation - switch_on_max=10, # Maximum 10 starts per day + status_parameters=StatusParameters( + effects_per_startup={'maintenance': 0.1}, + min_uptime=2, # Minimum 2-hour operation + startup_limit=10, # Maximum 10 starts per period ), ) ``` @@ -664,7 +676,7 @@ class Transmission(Component): When using InvestParameters on in1, the capacity automatically applies to in2 to maintain consistent bidirectional capacity without additional investment variables. - Absolute losses force the creation of binary on/off variables, which increases + Absolute losses force the creation of binary on/inactive variables, which increases computational complexity but enables realistic modeling of equipment with standby power consumption. @@ -681,7 +693,7 @@ def __init__( out2: Flow | None = None, relative_losses: Numeric_TPS | None = None, absolute_losses: Numeric_TPS | None = None, - on_off_parameters: OnOffParameters = None, + status_parameters: StatusParameters | None = None, prevent_simultaneous_flows_in_both_directions: bool = True, balanced: bool = False, meta_data: dict | None = None, @@ -690,7 +702,7 @@ def __init__( label, inputs=[flow for flow in (in1, in2) if flow is not None], outputs=[flow for flow in (out1, out2) if flow is not None], - on_off_parameters=on_off_parameters, + status_parameters=status_parameters, prevent_simultaneous_flows=None if in2 is None or prevent_simultaneous_flows_in_both_directions is False else [in1, in2], @@ -727,8 +739,8 @@ def _plausibility_checks(self): ).any(): raise ValueError( f'Balanced Transmission needs compatible minimum and maximum sizes.' - f'Got: {self.in1.size.minimum_size=}, {self.in1.size.maximum_size=}, {self.in1.size.fixed_size=} and ' - f'{self.in2.size.minimum_size=}, {self.in2.size.maximum_size=}, {self.in2.size.fixed_size=}.' + f'Got: {self.in1.size.minimum_or_fixed_size=}, {self.in1.size.maximum_or_fixed_size=} and ' + f'{self.in2.size.minimum_or_fixed_size=}, {self.in2.size.maximum_or_fixed_size=}.' ) def create_model(self, model) -> TransmissionModel: @@ -736,11 +748,10 @@ def create_model(self, model) -> TransmissionModel: self.submodel = TransmissionModel(model, self) return self.submodel - def transform_data(self, name_prefix: str = '') -> None: - prefix = '|'.join(filter(None, [name_prefix, self.label_full])) - super().transform_data(prefix) - self.relative_losses = self._fit_coords(f'{prefix}|relative_losses', self.relative_losses) - self.absolute_losses = self._fit_coords(f'{prefix}|absolute_losses', self.absolute_losses) + def transform_data(self) -> None: + super().transform_data() + self.relative_losses = self._fit_coords(f'{self.prefix}|relative_losses', self.relative_losses) + self.absolute_losses = self._fit_coords(f'{self.prefix}|absolute_losses', self.absolute_losses) class TransmissionModel(ComponentModel): @@ -749,8 +760,11 @@ class TransmissionModel(ComponentModel): def __init__(self, model: FlowSystemModel, element: Transmission): if (element.absolute_losses is not None) and np.any(element.absolute_losses != 0): for flow in element.inputs + element.outputs: - if flow.on_off_parameters is None: - flow.on_off_parameters = OnOffParameters() + if flow.status_parameters is None: + flow.status_parameters = StatusParameters() + flow.status_parameters.link_to_flow_system( + model.flow_system, f'{flow.label_full}|status_parameters' + ) super().__init__(model, element) @@ -782,13 +796,23 @@ def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) short_name=name, ) - if self.element.absolute_losses is not None: - con_transmission.lhs += in_flow.submodel.on_off.on * self.element.absolute_losses + if (self.element.absolute_losses is not None) and np.any(self.element.absolute_losses != 0): + con_transmission.lhs += in_flow.submodel.status.status * self.element.absolute_losses return con_transmission class LinearConverterModel(ComponentModel): + """Mathematical model implementation for LinearConverter components. + + Creates optimization constraints for linear conversion relationships between + input and output flows, supporting both simple conversion factors and piecewise + non-linear approximations. + + Mathematical Formulation: + See + """ + element: LinearConverter def __init__(self, model: FlowSystemModel, element: LinearConverter): @@ -817,7 +841,7 @@ def _do_modeling(self): ) else: - # TODO: Improve Inclusion of OnOffParameters. Instead of creating a Binary in every flow, the binary could only be part of the Piece itself + # TODO: Improve Inclusion of StatusParameters. Instead of creating a Binary in every flow, the binary could only be part of the Piece itself piecewise_conversion = { self.element.flows[flow].submodel.flow_rate.name: piecewise for flow, piecewise in self.element.piecewise_conversion.items() @@ -829,7 +853,7 @@ def _do_modeling(self): label_of_element=self.label_of_element, label_of_model=f'{self.label_of_element}', piecewise_variables=piecewise_conversion, - zero_point=self.on_off.on if self.on_off is not None else False, + zero_point=self.status.status if self.status is not None else False, dims=('time', 'period', 'scenario'), ), short_name='PiecewiseConversion', @@ -837,7 +861,14 @@ def _do_modeling(self): class StorageModel(ComponentModel): - """Submodel of Storage""" + """Mathematical model implementation for Storage components. + + Creates optimization variables and constraints for charge state tracking, + storage balance equations, and optional investment sizing. + + Mathematical Formulation: + See + """ element: Storage @@ -941,15 +972,18 @@ def _initial_and_final_charge_state(self): @property def _absolute_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: relative_lower_bound, relative_upper_bound = self._relative_charge_state_bounds - if not isinstance(self.element.capacity_in_flow_hours, InvestParameters): + if self.element.capacity_in_flow_hours is None: + # Unbounded storage: lower bound is 0, upper bound is infinite + return (0, np.inf) + elif isinstance(self.element.capacity_in_flow_hours, InvestParameters): return ( - relative_lower_bound * self.element.capacity_in_flow_hours, - relative_upper_bound * self.element.capacity_in_flow_hours, + relative_lower_bound * self.element.capacity_in_flow_hours.minimum_or_fixed_size, + relative_upper_bound * self.element.capacity_in_flow_hours.maximum_or_fixed_size, ) else: return ( - relative_lower_bound * self.element.capacity_in_flow_hours.minimum_size, - relative_upper_bound * self.element.capacity_in_flow_hours.maximum_size, + relative_lower_bound * self.element.capacity_in_flow_hours, + relative_upper_bound * self.element.capacity_in_flow_hours, ) @property @@ -988,7 +1022,7 @@ def _investment(self) -> InvestmentModel | None: @property def investment(self) -> InvestmentModel | None: - """OnOff feature""" + """Investment feature""" if 'investment' not in self.submodels: return None return self.submodels['investment'] @@ -1097,22 +1131,7 @@ def __init__( outputs: list[Flow] | None = None, prevent_simultaneous_flow_rates: bool = True, meta_data: dict | None = None, - **kwargs, ): - # Handle deprecated parameters using centralized helper - outputs = self._handle_deprecated_kwarg(kwargs, 'source', 'outputs', outputs, transform=lambda x: [x]) - inputs = self._handle_deprecated_kwarg(kwargs, 'sink', 'inputs', inputs, transform=lambda x: [x]) - prevent_simultaneous_flow_rates = self._handle_deprecated_kwarg( - kwargs, - 'prevent_simultaneous_sink_and_source', - 'prevent_simultaneous_flow_rates', - prevent_simultaneous_flow_rates, - check_conflict=False, - ) - - # Validate any remaining unexpected kwargs - self._validate_kwargs(kwargs) - super().__init__( label, inputs=inputs, @@ -1122,36 +1141,6 @@ def __init__( ) self.prevent_simultaneous_flow_rates = prevent_simultaneous_flow_rates - @property - def source(self) -> Flow: - warnings.warn( - 'The source property is deprecated. Use the outputs property instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.outputs[0] - - @property - def sink(self) -> Flow: - warnings.warn( - 'The sink property is deprecated. Use the inputs property instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.inputs[0] - - @property - def prevent_simultaneous_sink_and_source(self) -> bool: - warnings.warn( - 'The prevent_simultaneous_sink_and_source property is deprecated. Use the prevent_simultaneous_flow_rates property instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.prevent_simultaneous_flow_rates - @register_class_for_io class Source(Component): @@ -1235,14 +1224,7 @@ def __init__( outputs: list[Flow] | None = None, meta_data: dict | None = None, prevent_simultaneous_flow_rates: bool = False, - **kwargs, ): - # Handle deprecated parameter using centralized helper - outputs = self._handle_deprecated_kwarg(kwargs, 'source', 'outputs', outputs, transform=lambda x: [x]) - - # Validate any remaining unexpected kwargs - self._validate_kwargs(kwargs) - self.prevent_simultaneous_flow_rates = prevent_simultaneous_flow_rates super().__init__( label, @@ -1251,16 +1233,6 @@ def __init__( prevent_simultaneous_flows=outputs if prevent_simultaneous_flow_rates else None, ) - @property - def source(self) -> Flow: - warnings.warn( - 'The source property is deprecated. Use the outputs property instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.outputs[0] - @register_class_for_io class Sink(Component): @@ -1345,29 +1317,16 @@ def __init__( inputs: list[Flow] | None = None, meta_data: dict | None = None, prevent_simultaneous_flow_rates: bool = False, - **kwargs, ): """Initialize a Sink (consumes flow from the system). - Supports legacy `sink=` keyword for backward compatibility (deprecated): if `sink` is provided - it is used as the single input flow and a DeprecationWarning is issued; specifying both - `inputs` and `sink` raises ValueError. - Args: label: Unique element label. inputs: Input flows for the sink. meta_data: Arbitrary metadata attached to the element. prevent_simultaneous_flow_rates: If True, prevents simultaneous nonzero flow rates across the element's inputs by wiring that restriction into the base Component setup. - - Note: - The deprecated `sink` kwarg is accepted for compatibility but will be removed in future releases. """ - # Handle deprecated parameter using centralized helper - inputs = self._handle_deprecated_kwarg(kwargs, 'sink', 'inputs', inputs, transform=lambda x: [x]) - - # Validate any remaining unexpected kwargs - self._validate_kwargs(kwargs) self.prevent_simultaneous_flow_rates = prevent_simultaneous_flow_rates super().__init__( @@ -1376,13 +1335,3 @@ def __init__( meta_data=meta_data, prevent_simultaneous_flows=inputs if prevent_simultaneous_flow_rates else None, ) - - @property - def sink(self) -> Flow: - warnings.warn( - 'The sink property is deprecated. Use the inputs property instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.inputs[0] diff --git a/flixopt/config.py b/flixopt/config.py index dbe2bf3c5..0280010d8 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -20,7 +20,7 @@ COLORLOG_AVAILABLE = False escape_codes = None -__all__ = ['CONFIG', 'change_logging_level', 'MultilineFormatter', 'SUCCESS_LEVEL'] +__all__ = ['CONFIG', 'MultilineFormatter', 'SUCCESS_LEVEL', 'DEPRECATION_REMOVAL_VERSION'] if COLORLOG_AVAILABLE: __all__.append('ColoredMultilineFormatter') @@ -30,7 +30,7 @@ logging.addLevelName(SUCCESS_LEVEL, 'SUCCESS') # Deprecation removal version - update this when planning the next major version -DEPRECATION_REMOVAL_VERSION = '5.0.0' +DEPRECATION_REMOVAL_VERSION = '6.0.0' class MultilineFormatter(logging.Formatter): @@ -171,6 +171,7 @@ def format(self, record): 'time_limit_seconds': 300, 'log_to_console': True, 'log_main_results': True, + 'compute_infeasibilities': True, } ), } @@ -526,6 +527,7 @@ class Solving: time_limit_seconds: Default time limit in seconds for solver runs. log_to_console: Whether solver should output to console. log_main_results: Whether to log main results after solving. + compute_infeasibilities: Whether to compute infeasibility analysis when the model is infeasible. Examples: ```python @@ -540,6 +542,7 @@ class Solving: time_limit_seconds: int = _DEFAULTS['solving']['time_limit_seconds'] log_to_console: bool = _DEFAULTS['solving']['log_to_console'] log_main_results: bool = _DEFAULTS['solving']['log_main_results'] + compute_infeasibilities: bool = _DEFAULTS['solving']['compute_infeasibilities'] class Plotting: """Plotting configuration. @@ -572,6 +575,36 @@ class Plotting: default_sequential_colorscale: str = _DEFAULTS['plotting']['default_sequential_colorscale'] default_qualitative_colorscale: str = _DEFAULTS['plotting']['default_qualitative_colorscale'] + class Carriers: + """Default carrier definitions for common energy types. + + Provides convenient defaults for carriers. Colors are from D3/Plotly palettes. + + Predefined: electricity, heat, gas, hydrogen, fuel, biomass + + Examples: + ```python + import flixopt as fx + + # Access predefined carriers + fx.CONFIG.Carriers.electricity # Carrier with color '#FECB52' + fx.CONFIG.Carriers.heat.color # '#D62728' + + # Use with buses + bus = fx.Bus('Grid', carrier='electricity') + ``` + """ + + from .carrier import Carrier + + # Default carriers - colors from D3/Plotly palettes + electricity: Carrier = Carrier('electricity', '#FECB52') # Yellow + heat: Carrier = Carrier('heat', '#D62728') # Red + gas: Carrier = Carrier('gas', '#1F77B4') # Blue + hydrogen: Carrier = Carrier('hydrogen', '#9467BD') # Purple + fuel: Carrier = Carrier('fuel', '#8C564B') # Brown + biomass: Carrier = Carrier('biomass', '#2CA02C') # Green + config_name: str = _DEFAULTS['config_name'] @classmethod @@ -598,6 +631,16 @@ def reset(cls) -> None: for key, value in _DEFAULTS['plotting'].items(): setattr(cls.Plotting, key, value) + # Reset Carriers to defaults + from .carrier import Carrier + + cls.Carriers.electricity = Carrier('electricity', '#FECB52') + cls.Carriers.heat = Carrier('heat', '#D62728') + cls.Carriers.gas = Carrier('gas', '#1F77B4') + cls.Carriers.hydrogen = Carrier('hydrogen', '#9467BD') + cls.Carriers.fuel = Carrier('fuel', '#8C564B') + cls.Carriers.biomass = Carrier('biomass', '#2CA02C') + cls.config_name = _DEFAULTS['config_name'] # Reset logging to default (silent) @@ -622,6 +665,7 @@ def to_dict(cls) -> dict: 'time_limit_seconds': cls.Solving.time_limit_seconds, 'log_to_console': cls.Solving.log_to_console, 'log_main_results': cls.Solving.log_main_results, + 'compute_infeasibilities': cls.Solving.compute_infeasibilities, }, 'plotting': { 'default_show': cls.Plotting.default_show, @@ -741,6 +785,45 @@ def browser_plotting(cls) -> type[CONFIG]: return cls + @classmethod + def notebook(cls) -> type[CONFIG]: + """Configure for Jupyter notebook environments. + + Optimizes settings for notebook usage: + - Sets plotly renderer to 'notebook' for inline display + - Disables automatic plot.show() calls (notebooks display via _repr_html_) + - Enables SUCCESS-level console logging + - Enables solver console output and main results logging + + Examples: + ```python + # At the start of your notebook + import flixopt as fx + + fx.CONFIG.notebook() + + # Now plots display inline automatically + flow_system.statistics.plot.balance('Heat') # Displays inline + ``` + """ + import plotly.io as pio + + # Set plotly to render inline in notebooks + pio.renderers.default = 'notebook' + pio.templates.default = 'plotly_white' + + # Disable default show since notebooks render via _repr_html_ + cls.Plotting.default_show = False + + # Light logging - SUCCESS level without too much noise + cls.Logging.enable_console('SUCCESS') + + # Enable solver console output and main results logging + cls.Solving.log_to_console = True + cls.Solving.log_main_results = True + + return cls + @classmethod def load_from_file(cls, config_file: str | Path) -> type[CONFIG]: """Load configuration from YAML file and apply it. @@ -808,23 +891,3 @@ def _apply_config_dict(cls, config_dict: dict) -> None: elif hasattr(cls, key) and key != 'logging': # Skip 'logging' as it requires special handling via CONFIG.Logging methods setattr(cls, key, value) - - -def change_logging_level(level_name: str | int) -> None: - """Change the logging level for the flixopt logger. - - Args: - level_name: The logging level to set (DEBUG, INFO, WARNING, ERROR, CRITICAL or logging constant). - - Examples: - >>> change_logging_level('DEBUG') # deprecated - >>> # Use this instead: - >>> CONFIG.Logging.enable_console('DEBUG') - """ - warnings.warn( - f'change_logging_level is deprecated and will be removed in version {DEPRECATION_REMOVAL_VERSION} ' - 'Use CONFIG.Logging.enable_console(level) instead.', - DeprecationWarning, - stacklevel=2, - ) - CONFIG.Logging.enable_console(level_name) diff --git a/flixopt/core.py b/flixopt/core.py index 2c5bcd6cc..a14aa6654 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -4,7 +4,6 @@ """ import logging -import warnings from itertools import permutations from typing import Any, Literal @@ -12,7 +11,6 @@ import pandas as pd import xarray as xr -from .config import DEPRECATION_REMOVAL_VERSION from .types import NumericOrBool logger = logging.getLogger('flixopt') @@ -43,10 +41,6 @@ def __init__( *args: Any, clustering_group: str | None = None, clustering_weight: float | None = None, - aggregation_group: str | None = None, - aggregation_weight: float | None = None, - agg_group: str | None = None, - agg_weight: float | None = None, **kwargs: Any, ): """ @@ -56,48 +50,8 @@ def __init__( clustering weight (1/n where n is the number of series in the group). Mutually exclusive with clustering_weight. clustering_weight: Clustering weight (0-1). Use this to assign a specific weight to a single time series. Mutually exclusive with clustering_group. - aggregation_group: Deprecated, use clustering_group instead - aggregation_weight: Deprecated, use clustering_weight instead - agg_group: Deprecated, use clustering_group instead - agg_weight: Deprecated, use clustering_weight instead **kwargs: Additional arguments passed to DataArray """ - # Handle deprecated parameters - if agg_group is not None: - warnings.warn( - f'agg_group is deprecated, use clustering_group instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - clustering_group = agg_group - if aggregation_group is not None: - warnings.warn( - f'aggregation_group is deprecated, use clustering_group instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - if clustering_group is None: - clustering_group = aggregation_group - - if agg_weight is not None: - warnings.warn( - f'agg_weight is deprecated, use clustering_weight instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - clustering_weight = agg_weight - if aggregation_weight is not None: - warnings.warn( - f'aggregation_weight is deprecated, use clustering_weight instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - if clustering_weight is None: - clustering_weight = aggregation_weight if (clustering_group is not None) and (clustering_weight is not None): raise ValueError('Use either clustering_group or clustering_weight, not both') @@ -145,40 +99,11 @@ def from_dataarray( da: xr.DataArray, clustering_group: str | None = None, clustering_weight: float | None = None, - aggregation_group: str | None = None, - aggregation_weight: float | None = None, ): """Create TimeSeriesData from DataArray, extracting metadata from attrs.""" - # Handle deprecated parameters - if aggregation_group is not None: - warnings.warn( - f'aggregation_group is deprecated, use clustering_group instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - if clustering_group is None: - clustering_group = aggregation_group - if aggregation_weight is not None: - warnings.warn( - f'aggregation_weight is deprecated, use clustering_weight instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - if clustering_weight is None: - clustering_weight = aggregation_weight - - # Get clustering metadata from attrs or parameters (try both old and new attrs keys for backward compat) - final_clustering_group = ( - clustering_group - if clustering_group is not None - else da.attrs.get('clustering_group', da.attrs.get('aggregation_group')) - ) + final_clustering_group = clustering_group if clustering_group is not None else da.attrs.get('clustering_group') final_clustering_weight = ( - clustering_weight - if clustering_weight is not None - else da.attrs.get('clustering_weight', da.attrs.get('aggregation_weight')) + clustering_weight if clustering_weight is not None else da.attrs.get('clustering_weight') ) return cls(da, clustering_group=final_clustering_group, clustering_weight=final_clustering_weight) @@ -198,48 +123,6 @@ def __repr__(self): info_str = f'TimeSeriesData({", ".join(clustering_info)})' if clustering_info else 'TimeSeriesData' return f'{info_str}\n{super().__repr__()}' - @property - def aggregation_group(self): - """Deprecated: Use clustering_group instead.""" - warnings.warn( - f'aggregation_group is deprecated, use clustering_group instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.clustering_group - - @property - def aggregation_weight(self): - """Deprecated: Use clustering_weight instead.""" - warnings.warn( - f'aggregation_weight is deprecated, use clustering_weight instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.clustering_weight - - @property - def agg_group(self): - warnings.warn( - f'agg_group is deprecated, use clustering_group instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.clustering_group - - @property - def agg_weight(self): - warnings.warn( - f'agg_weight is deprecated, use clustering_weight instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.clustering_weight - class DataConverter: """ diff --git a/flixopt/effects.py b/flixopt/effects.py index 9aff2db66..cdac7ca7d 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -8,7 +8,6 @@ from __future__ import annotations import logging -import warnings from collections import deque from typing import TYPE_CHECKING, Literal @@ -16,7 +15,6 @@ import numpy as np import xarray as xr -from .config import DEPRECATION_REMOVAL_VERSION from .core import PlausibilityError from .features import ShareAllocationModel from .structure import Element, ElementContainer, ElementModel, FlowSystemModel, Submodel, register_class_for_io @@ -34,16 +32,15 @@ @register_class_for_io class Effect(Element): - """ - Represents system-wide impacts like costs, emissions, resource consumption, or other effects. + """Represents system-wide impacts like costs, emissions, or resource consumption. - Effects capture the broader impacts of system operation and investment decisions beyond - the primary energy/material flows. Each Effect accumulates contributions from Components, - Flows, and other system elements. One Effect is typically chosen as the optimization - objective, while others can serve as constraints or tracking metrics. + Effects quantify impacts aggregating contributions from Elements across the FlowSystem. + One Effect serves as the optimization objective, while others can be constrained or tracked. + Supports operational and investment contributions, cross-effect relationships (e.g., carbon + pricing), and flexible constraint formulation. - Effects support comprehensive modeling including operational and investment contributions, - cross-effect relationships (e.g., carbon pricing), and flexible constraint formulation. + Mathematical Formulation: + See Args: label: The label of the Element. Used to identify it in the FlowSystem. @@ -190,7 +187,7 @@ def __init__( self, label: str, unit: str, - description: str, + description: str = '', meta_data: dict | None = None, is_standard: bool = False, is_objective: bool = False, @@ -207,7 +204,6 @@ def __init__( maximum_total: Numeric_PS | None = None, minimum_over_periods: Numeric_S | None = None, maximum_over_periods: Numeric_S | None = None, - **kwargs, ): super().__init__(label, meta_data=meta_data) self.unit = unit @@ -229,23 +225,6 @@ def __init__( self.share_from_temporal = share_from_temporal if share_from_temporal is not None else {} self.share_from_periodic = share_from_periodic if share_from_periodic is not None else {} - # Handle backwards compatibility for deprecated parameters using centralized helper - minimum_temporal = self._handle_deprecated_kwarg( - kwargs, 'minimum_operation', 'minimum_temporal', minimum_temporal - ) - maximum_temporal = self._handle_deprecated_kwarg( - kwargs, 'maximum_operation', 'maximum_temporal', maximum_temporal - ) - minimum_periodic = self._handle_deprecated_kwarg(kwargs, 'minimum_invest', 'minimum_periodic', minimum_periodic) - maximum_periodic = self._handle_deprecated_kwarg(kwargs, 'maximum_invest', 'maximum_periodic', maximum_periodic) - minimum_per_hour = self._handle_deprecated_kwarg( - kwargs, 'minimum_operation_per_hour', 'minimum_per_hour', minimum_per_hour - ) - maximum_per_hour = self._handle_deprecated_kwarg( - kwargs, 'maximum_operation_per_hour', 'maximum_per_hour', maximum_per_hour - ) - self._validate_kwargs(kwargs) - # Set attributes directly self.minimum_temporal = minimum_temporal self.maximum_temporal = maximum_temporal @@ -258,227 +237,56 @@ def __init__( self.minimum_over_periods = minimum_over_periods self.maximum_over_periods = maximum_over_periods - # Backwards compatible properties (deprecated) - @property - def minimum_operation(self): - """DEPRECATED: Use 'minimum_temporal' property instead.""" - warnings.warn( - f"Property 'minimum_operation' is deprecated. Use 'minimum_temporal' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.minimum_temporal - - @minimum_operation.setter - def minimum_operation(self, value): - """DEPRECATED: Use 'minimum_temporal' property instead.""" - warnings.warn( - f"Property 'minimum_operation' is deprecated. Use 'minimum_temporal' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.minimum_temporal = value - - @property - def maximum_operation(self): - """DEPRECATED: Use 'maximum_temporal' property instead.""" - warnings.warn( - f"Property 'maximum_operation' is deprecated. Use 'maximum_temporal' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.maximum_temporal - - @maximum_operation.setter - def maximum_operation(self, value): - """DEPRECATED: Use 'maximum_temporal' property instead.""" - warnings.warn( - f"Property 'maximum_operation' is deprecated. Use 'maximum_temporal' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.maximum_temporal = value - - @property - def minimum_invest(self): - """DEPRECATED: Use 'minimum_periodic' property instead.""" - warnings.warn( - f"Property 'minimum_invest' is deprecated. Use 'minimum_periodic' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.minimum_periodic - - @minimum_invest.setter - def minimum_invest(self, value): - """DEPRECATED: Use 'minimum_periodic' property instead.""" - warnings.warn( - f"Property 'minimum_invest' is deprecated. Use 'minimum_periodic' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.minimum_periodic = value - - @property - def maximum_invest(self): - """DEPRECATED: Use 'maximum_periodic' property instead.""" - warnings.warn( - f"Property 'maximum_invest' is deprecated. Use 'maximum_periodic' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.maximum_periodic - - @maximum_invest.setter - def maximum_invest(self, value): - """DEPRECATED: Use 'maximum_periodic' property instead.""" - warnings.warn( - f"Property 'maximum_invest' is deprecated. Use 'maximum_periodic' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.maximum_periodic = value - - @property - def minimum_operation_per_hour(self): - """DEPRECATED: Use 'minimum_per_hour' property instead.""" - warnings.warn( - f"Property 'minimum_operation_per_hour' is deprecated. Use 'minimum_per_hour' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.minimum_per_hour - - @minimum_operation_per_hour.setter - def minimum_operation_per_hour(self, value): - """DEPRECATED: Use 'minimum_per_hour' property instead.""" - warnings.warn( - f"Property 'minimum_operation_per_hour' is deprecated. Use 'minimum_per_hour' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.minimum_per_hour = value - - @property - def maximum_operation_per_hour(self): - """DEPRECATED: Use 'maximum_per_hour' property instead.""" - warnings.warn( - f"Property 'maximum_operation_per_hour' is deprecated. Use 'maximum_per_hour' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.maximum_per_hour - - @maximum_operation_per_hour.setter - def maximum_operation_per_hour(self, value): - """DEPRECATED: Use 'maximum_per_hour' property instead.""" - warnings.warn( - f"Property 'maximum_operation_per_hour' is deprecated. Use 'maximum_per_hour' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.maximum_per_hour = value - - @property - def minimum_total_per_period(self): - """DEPRECATED: Use 'minimum_total' property instead.""" - warnings.warn( - f"Property 'minimum_total_per_period' is deprecated. Use 'minimum_total' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.minimum_total - - @minimum_total_per_period.setter - def minimum_total_per_period(self, value): - """DEPRECATED: Use 'minimum_total' property instead.""" - warnings.warn( - f"Property 'minimum_total_per_period' is deprecated. Use 'minimum_total' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.minimum_total = value + def link_to_flow_system(self, flow_system, prefix: str = '') -> None: + """Link this effect to a FlowSystem. - @property - def maximum_total_per_period(self): - """DEPRECATED: Use 'maximum_total' property instead.""" - warnings.warn( - f"Property 'maximum_total_per_period' is deprecated. Use 'maximum_total' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.maximum_total - - @maximum_total_per_period.setter - def maximum_total_per_period(self, value): - """DEPRECATED: Use 'maximum_total' property instead.""" - warnings.warn( - f"Property 'maximum_total_per_period' is deprecated. Use 'maximum_total' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.maximum_total = value + Elements use their label_full as prefix by default, ignoring the passed prefix. + """ + super().link_to_flow_system(flow_system, self.label_full) - def transform_data(self, name_prefix: str = '') -> None: - prefix = '|'.join(filter(None, [name_prefix, self.label_full])) - self.minimum_per_hour = self._fit_coords(f'{prefix}|minimum_per_hour', self.minimum_per_hour) - self.maximum_per_hour = self._fit_coords(f'{prefix}|maximum_per_hour', self.maximum_per_hour) + def transform_data(self) -> None: + self.minimum_per_hour = self._fit_coords(f'{self.prefix}|minimum_per_hour', self.minimum_per_hour) + self.maximum_per_hour = self._fit_coords(f'{self.prefix}|maximum_per_hour', self.maximum_per_hour) self.share_from_temporal = self._fit_effect_coords( prefix=None, effect_values=self.share_from_temporal, - suffix=f'(temporal)->{prefix}(temporal)', + suffix=f'(temporal)->{self.prefix}(temporal)', dims=['time', 'period', 'scenario'], ) self.share_from_periodic = self._fit_effect_coords( prefix=None, effect_values=self.share_from_periodic, - suffix=f'(periodic)->{prefix}(periodic)', + suffix=f'(periodic)->{self.prefix}(periodic)', dims=['period', 'scenario'], ) self.minimum_temporal = self._fit_coords( - f'{prefix}|minimum_temporal', self.minimum_temporal, dims=['period', 'scenario'] + f'{self.prefix}|minimum_temporal', self.minimum_temporal, dims=['period', 'scenario'] ) self.maximum_temporal = self._fit_coords( - f'{prefix}|maximum_temporal', self.maximum_temporal, dims=['period', 'scenario'] + f'{self.prefix}|maximum_temporal', self.maximum_temporal, dims=['period', 'scenario'] ) self.minimum_periodic = self._fit_coords( - f'{prefix}|minimum_periodic', self.minimum_periodic, dims=['period', 'scenario'] + f'{self.prefix}|minimum_periodic', self.minimum_periodic, dims=['period', 'scenario'] ) self.maximum_periodic = self._fit_coords( - f'{prefix}|maximum_periodic', self.maximum_periodic, dims=['period', 'scenario'] + f'{self.prefix}|maximum_periodic', self.maximum_periodic, dims=['period', 'scenario'] ) self.minimum_total = self._fit_coords( - f'{prefix}|minimum_total', self.minimum_total, dims=['period', 'scenario'] + f'{self.prefix}|minimum_total', self.minimum_total, dims=['period', 'scenario'] ) self.maximum_total = self._fit_coords( - f'{prefix}|maximum_total', self.maximum_total, dims=['period', 'scenario'] + f'{self.prefix}|maximum_total', self.maximum_total, dims=['period', 'scenario'] ) self.minimum_over_periods = self._fit_coords( - f'{prefix}|minimum_over_periods', self.minimum_over_periods, dims=['scenario'] + f'{self.prefix}|minimum_over_periods', self.minimum_over_periods, dims=['scenario'] ) self.maximum_over_periods = self._fit_coords( - f'{prefix}|maximum_over_periods', self.maximum_over_periods, dims=['scenario'] + f'{self.prefix}|maximum_over_periods', self.maximum_over_periods, dims=['scenario'] ) self.period_weights = self._fit_coords( - f'{prefix}|period_weights', self.period_weights, dims=['period', 'scenario'] + f'{self.prefix}|period_weights', self.period_weights, dims=['period', 'scenario'] ) def create_model(self, model: FlowSystemModel) -> EffectModel: @@ -499,6 +307,16 @@ def _plausibility_checks(self) -> None: class EffectModel(ElementModel): + """Mathematical model implementation for Effects. + + Creates optimization variables and constraints for effect aggregation, + including periodic and temporal tracking, cross-effect contributions, + and effect bounds. + + Mathematical Formulation: + See + """ + element: Effect # Type hint def __init__(self, model: FlowSystemModel, element: Effect): @@ -663,21 +481,16 @@ def create_effect_values_dict(self, effect_values_user: Numeric_TPS | Effect_TPS Note: a standard effect must be defined when passing scalars or None labels. """ - def get_effect_label(eff: Effect | str) -> str: - """Temporary function to get the label of an effect and warn for deprecation""" + def get_effect_label(eff: str | None) -> str: + """Get the label of an effect""" + if eff is None: + return self.standard_effect.label if isinstance(eff, Effect): - warnings.warn( - f'The use of effect objects when specifying EffectValues is deprecated. ' - f'Use the label of the effect instead. Used effect: {eff.label_full}. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - UserWarning, - stacklevel=2, + raise TypeError( + f'Effect objects are no longer accepted when specifying EffectValues. ' + f'Use the label string instead. Got: {eff.label_full}' ) - return eff.label - elif eff is None: - return self.standard_effect.label - else: - return eff + return eff if effect_values_user is None: return None @@ -863,7 +676,7 @@ def _do_modeling(self): penalty_effect = self.effects._create_penalty_effect() # Link to FlowSystem (should already be linked, but ensure it) if penalty_effect._flow_system is None: - penalty_effect._set_flow_system(self._model.flow_system) + penalty_effect.link_to_flow_system(self._model.flow_system) # Create EffectModel for each effect for effect in self.effects.values(): diff --git a/flixopt/elements.py b/flixopt/elements.py index 5c13f17c5..2933eb95a 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -5,23 +5,21 @@ from __future__ import annotations import logging -import warnings from typing import TYPE_CHECKING import numpy as np import xarray as xr from . import io as fx_io -from .config import CONFIG, DEPRECATION_REMOVAL_VERSION +from .config import CONFIG from .core import PlausibilityError -from .features import InvestmentModel, OnOffModel -from .interface import InvestParameters, OnOffParameters +from .features import InvestmentModel, StatusModel +from .interface import InvestParameters, StatusParameters from .modeling import BoundingPatterns, ModelingPrimitives, ModelingUtilitiesAbstract from .structure import ( Element, ElementModel, FlowSystemModel, - Interface, register_class_for_io, ) @@ -58,9 +56,9 @@ class Component(Element): energy/material consumption by the component. outputs: list of output Flows leaving the component. These represent energy/material production by the component. - on_off_parameters: Defines binary operation constraints and costs when the - component has discrete on/off states. Creates binary variables for all - connected Flows. For better performance, prefer defining OnOffParameters + status_parameters: Defines binary operation constraints and costs when the + component has discrete active/inactive states. Creates binary variables for all + connected Flows. For better performance, prefer defining StatusParameters on individual Flows when possible. prevent_simultaneous_flows: list of Flows that cannot be active simultaneously. Creates binary variables to enforce mutual exclusivity. Use sparingly as @@ -70,13 +68,13 @@ class Component(Element): Note: Component operational state is determined by its connected Flows: - - Component is "on" if ANY of its Flows is active (flow_rate > 0) - - Component is "off" only when ALL Flows are inactive (flow_rate = 0) + - Component is "active" if ANY of its Flows is active (flow_rate > 0) + - Component is "inactive" only when ALL Flows are inactive (flow_rate = 0) Binary variables and constraints: - - on_off_parameters creates binary variables for ALL connected Flows + - status_parameters creates binary variables for ALL connected Flows - prevent_simultaneous_flows creates binary variables for specified Flows - - For better computational performance, prefer Flow-level OnOffParameters + - For better computational performance, prefer Flow-level StatusParameters Component is an abstract base class. In practice, use specialized subclasses: - LinearConverter: Linear input/output relationships @@ -91,14 +89,15 @@ def __init__( label: str, inputs: list[Flow] | None = None, outputs: list[Flow] | None = None, - on_off_parameters: OnOffParameters | None = None, + status_parameters: StatusParameters | None = None, prevent_simultaneous_flows: list[Flow] | None = None, meta_data: dict | None = None, + color: str | None = None, ): - super().__init__(label, meta_data=meta_data) + super().__init__(label, meta_data=meta_data, color=color) self.inputs: list[Flow] = inputs or [] self.outputs: list[Flow] = outputs or [] - self.on_off_parameters = on_off_parameters + self.status_parameters = status_parameters self.prevent_simultaneous_flows: list[Flow] = prevent_simultaneous_flows or [] self._check_unique_flow_labels() @@ -111,21 +110,23 @@ def create_model(self, model: FlowSystemModel) -> ComponentModel: self.submodel = ComponentModel(model, self) return self.submodel - def _set_flow_system(self, flow_system) -> None: - """Propagate flow_system reference to nested Interface objects and flows.""" - super()._set_flow_system(flow_system) - if self.on_off_parameters is not None: - self.on_off_parameters._set_flow_system(flow_system) + def link_to_flow_system(self, flow_system, prefix: str = '') -> None: + """Propagate flow_system reference to nested Interface objects and flows. + + Elements use their label_full as prefix by default, ignoring the passed prefix. + """ + super().link_to_flow_system(flow_system, self.label_full) + if self.status_parameters is not None: + self.status_parameters.link_to_flow_system(flow_system, self._sub_prefix('status_parameters')) for flow in self.inputs + self.outputs: - flow._set_flow_system(flow_system) + flow.link_to_flow_system(flow_system) - def transform_data(self, name_prefix: str = '') -> None: - prefix = '|'.join(filter(None, [name_prefix, self.label_full])) - if self.on_off_parameters is not None: - self.on_off_parameters.transform_data(prefix) + def transform_data(self) -> None: + if self.status_parameters is not None: + self.status_parameters.transform_data() for flow in self.inputs + self.outputs: - flow.transform_data() # Flow doesnt need the name_prefix + flow.transform_data() def _check_unique_flow_labels(self): all_flow_labels = [flow.label for flow in self.inputs + self.outputs] @@ -137,6 +138,17 @@ def _check_unique_flow_labels(self): def _plausibility_checks(self) -> None: self._check_unique_flow_labels() + # Component with status_parameters requires all flows to have sizes set + # (status_parameters are propagated to flows in _do_modeling, which need sizes for big-M constraints) + if self.status_parameters is not None: + flows_without_size = [flow.label for flow in self.inputs + self.outputs if flow.size is None] + if flows_without_size: + raise PlausibilityError( + f'Component "{self.label_full}" has status_parameters, but the following flows have no size: ' + f'{flows_without_size}. All flows need explicit sizes when the component uses status_parameters ' + f'(required for big-M constraints).' + ) + def _connect_flows(self): # Inputs for flow in self.inputs: @@ -191,49 +203,51 @@ class Bus(Element): or material flows between different Components. Mathematical Formulation: - See the complete mathematical model in the documentation: - [Bus](../user-guide/mathematical-notation/elements/Bus.md) + See Args: label: The label of the Element. Used to identify it in the FlowSystem. - excess_penalty_per_flow_hour: Penalty costs for bus balance violations. - When None, no excess/deficit is allowed (hard constraint). When set to a - value > 0, allows bus imbalances at penalty cost. Default is 1e5 (high penalty). + carrier: Name of the energy/material carrier type (e.g., 'electricity', 'heat', 'gas'). + Carriers are registered via ``flow_system.add_carrier()`` or available as + predefined defaults in CONFIG.Carriers. Used for automatic color assignment in plots. + imbalance_penalty_per_flow_hour: Penalty costs for bus balance violations. + When None (default), no imbalance is allowed (hard constraint). When set to a + value > 0, allows bus imbalances at penalty cost. meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. Examples: - Electrical bus with strict balance: + Using predefined carrier names: ```python - electricity_bus = Bus( - label='main_electrical_bus', - excess_penalty_per_flow_hour=None, # No imbalance allowed - ) + electricity_bus = Bus(label='main_grid', carrier='electricity') + heat_bus = Bus(label='district_heating', carrier='heat') ``` - Heat network with penalty for imbalances: + Registering custom carriers on FlowSystem: ```python - heat_network = Bus( - label='district_heating_network', - excess_penalty_per_flow_hour=1000, # €1000/MWh penalty for imbalance - ) + import flixopt as fx + + fs = fx.FlowSystem(timesteps) + fs.add_carrier(fx.Carrier('biogas', '#228B22', 'kW')) + biogas_bus = fx.Bus(label='biogas_network', carrier='biogas') ``` - Material flow with time-varying penalties: + Heat network with penalty for imbalances: ```python - material_hub = Bus( - label='material_processing_hub', - excess_penalty_per_flow_hour=waste_disposal_costs, # Time series + heat_bus = Bus( + label='district_heating', + carrier='heat', + imbalance_penalty_per_flow_hour=1000, ) ``` Note: - The bus balance equation enforced is: Σ(inflows) = Σ(outflows) + excess - deficit + The bus balance equation enforced is: Σ(inflows) + virtual_supply = Σ(outflows) + virtual_demand - When excess_penalty_per_flow_hour is None, excess and deficit are forced to zero. + When imbalance_penalty_per_flow_hour is None, virtual_supply and virtual_demand are forced to zero. When a penalty cost is specified, the optimization can choose to violate the balance if economically beneficial, paying the penalty. The penalty is added to the objective directly. @@ -247,11 +261,18 @@ class Bus(Element): def __init__( self, label: str, - excess_penalty_per_flow_hour: Numeric_TPS | None = 1e5, + carrier: str | None = None, + imbalance_penalty_per_flow_hour: Numeric_TPS | None = None, meta_data: dict | None = None, + **kwargs, ): super().__init__(label, meta_data=meta_data) - self.excess_penalty_per_flow_hour = excess_penalty_per_flow_hour + 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 self.inputs: list[Flow] = [] self.outputs: list[Flow] = [] @@ -260,24 +281,26 @@ def create_model(self, model: FlowSystemModel) -> BusModel: self.submodel = BusModel(model, self) return self.submodel - def _set_flow_system(self, flow_system) -> None: - """Propagate flow_system reference to nested flows.""" - super()._set_flow_system(flow_system) + def link_to_flow_system(self, flow_system, prefix: str = '') -> None: + """Propagate flow_system reference to nested flows. + + Elements use their label_full as prefix by default, ignoring the passed prefix. + """ + super().link_to_flow_system(flow_system, self.label_full) for flow in self.inputs + self.outputs: - flow._set_flow_system(flow_system) + flow.link_to_flow_system(flow_system) - def transform_data(self, name_prefix: str = '') -> None: - prefix = '|'.join(filter(None, [name_prefix, self.label_full])) - self.excess_penalty_per_flow_hour = self._fit_coords( - f'{prefix}|excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour + def transform_data(self) -> None: + self.imbalance_penalty_per_flow_hour = self._fit_coords( + f'{self.prefix}|imbalance_penalty_per_flow_hour', self.imbalance_penalty_per_flow_hour ) def _plausibility_checks(self) -> None: - if self.excess_penalty_per_flow_hour is not None: - zero_penalty = np.all(np.equal(self.excess_penalty_per_flow_hour, 0)) + if self.imbalance_penalty_per_flow_hour is not None: + zero_penalty = np.all(np.equal(self.imbalance_penalty_per_flow_hour, 0)) if zero_penalty: logger.warning( - f'In Bus {self.label_full}, the excess_penalty_per_flow_hour is 0. Use "None" or a value > 0.' + f'In Bus {self.label_full}, the imbalance_penalty_per_flow_hour is 0. Use "None" or a value > 0.' ) if len(self.inputs) == 0 and len(self.outputs) == 0: raise ValueError( @@ -285,8 +308,8 @@ def _plausibility_checks(self) -> None: ) @property - def with_excess(self) -> bool: - return False if self.excess_penalty_per_flow_hour is None else True + def allows_imbalance(self) -> bool: + return self.imbalance_penalty_per_flow_hour is not None def __repr__(self) -> str: """Return string representation.""" @@ -314,7 +337,7 @@ class Flow(Element): between a Bus and a Component in a specific direction. The flow rate is the primary optimization variable, with constraints and costs defined through various parameters. Flows can have fixed or variable sizes, operational - constraints, and complex on/off behavior. + constraints, and complex on/inactive behavior. Key Concepts: **Flow Rate**: The instantaneous rate of energy/material transfer (optimization variable) [kW, m³/h, kg/h] @@ -324,23 +347,22 @@ class Flow(Element): Integration with Parameter Classes: - **InvestParameters**: Used for `size` when flow Size is an investment decision - - **OnOffParameters**: Used for `on_off_parameters` when flow has discrete states + - **StatusParameters**: Used for `status_parameters` when flow has discrete states Mathematical Formulation: - See the complete mathematical model in the documentation: - [Flow](../user-guide/mathematical-notation/elements/Flow.md) + See Args: label: Unique flow identifier within its component. bus: Bus label this flow connects to. - size: Flow capacity. Scalar, InvestParameters, or None (uses CONFIG.Modeling.big). + size: Flow capacity. Scalar, InvestParameters, or None (unbounded). relative_minimum: Minimum flow rate as fraction of size (0-1). Default: 0. relative_maximum: Maximum flow rate as fraction of size. Default: 1. load_factor_min: Minimum average utilization (0-1). Default: 0. load_factor_max: Maximum average utilization (0-1). Default: 1. effects_per_flow_hour: Operational costs/impacts per flow-hour. Dict mapping effect names to values (e.g., {'cost': 45, 'CO2': 0.8}). - on_off_parameters: Binary operation constraints (OnOffParameters). Default: None. + status_parameters: Binary operation constraints (StatusParameters). Default: None. flow_hours_max: Maximum cumulative flow-hours per period. Alternative to load_factor_max. flow_hours_min: Minimum cumulative flow-hours per period. Alternative to load_factor_min. flow_hours_max_over_periods: Maximum weighted sum of flow-hours across ALL periods. @@ -349,7 +371,7 @@ class Flow(Element): Weighted by FlowSystem period weights. fixed_relative_profile: Predetermined pattern as fraction of size. Flow rate = size × fixed_relative_profile(t). - previous_flow_rate: Initial flow state for on/off dynamics. Default: None (off). + previous_flow_rate: Initial flow state for active/inactive status at model start. Default: None (inactive). meta_data: Additional info stored in results. Python native types only. Examples: @@ -386,13 +408,13 @@ class Flow(Element): label='heat_output', bus='heating_network', size=50, # 50 kW thermal - relative_minimum=0.3, # Minimum 15 kW output when on + relative_minimum=0.3, # Minimum 15 kW output when active effects_per_flow_hour={'electricity_cost': 25, 'maintenance': 2}, - on_off_parameters=OnOffParameters( - effects_per_switch_on={'startup_cost': 100, 'wear': 0.1}, - consecutive_on_hours_min=2, # Must run at least 2 hours - consecutive_off_hours_min=1, # Must stay off at least 1 hour - switch_on_max=200, # Maximum 200 starts per period + status_parameters=StatusParameters( + effects_per_startup={'startup_cost': 100, 'wear': 0.1}, + min_uptime=2, # Must run at least 2 hours + min_downtime=1, # Must stay inactive at least 1 hour + startup_limit=200, # Maximum 200 starts per period ), ) ``` @@ -428,13 +450,14 @@ class Flow(Element): limits across all periods. **Relative Bounds**: Set `relative_minimum > 0` only when equipment cannot - operate below that level. Use `on_off_parameters` for discrete on/off behavior. + operate below that level. Use `status_parameters` for discrete active/inactive behavior. **Fixed Profiles**: Use `fixed_relative_profile` for known exact patterns, `relative_maximum` for upper bounds on optimization variables. Notes: - - Default size (CONFIG.Modeling.big) is used when size=None + - size=None means unbounded (no capacity constraint) + - size must be set when using status_parameters or fixed_relative_profile - list inputs for previous_flow_rate are converted to NumPy arrays - Flow direction is determined by component input/output designation @@ -449,12 +472,12 @@ def __init__( self, label: str, bus: str, - size: Numeric_PS | InvestParameters = None, + size: Numeric_PS | InvestParameters | None = None, fixed_relative_profile: Numeric_TPS | None = None, relative_minimum: Numeric_TPS = 0, relative_maximum: Numeric_TPS = 1, effects_per_flow_hour: Effect_TPS | Numeric_TPS | None = None, - on_off_parameters: OnOffParameters | None = None, + status_parameters: StatusParameters | None = None, flow_hours_max: Numeric_PS | None = None, flow_hours_min: Numeric_PS | None = None, flow_hours_max_over_periods: Numeric_S | None = None, @@ -463,10 +486,9 @@ def __init__( load_factor_max: Numeric_PS | None = None, previous_flow_rate: Scalar | list[Scalar] | None = None, meta_data: dict | None = None, - **kwargs, ): super().__init__(label, meta_data=meta_data) - self.size = CONFIG.Modeling.big if size is None else size + self.size = size self.relative_minimum = relative_minimum self.relative_maximum = relative_maximum self.fixed_relative_profile = fixed_relative_profile @@ -474,122 +496,129 @@ def __init__( self.load_factor_min = load_factor_min self.load_factor_max = load_factor_max - # Handle deprecated parameters - flow_hours_max = self._handle_deprecated_kwarg( - kwargs, 'flow_hours_per_period_max', 'flow_hours_max', flow_hours_max - ) - flow_hours_min = self._handle_deprecated_kwarg( - kwargs, 'flow_hours_per_period_min', 'flow_hours_min', flow_hours_min - ) - # Also handle the older deprecated names - flow_hours_max = self._handle_deprecated_kwarg(kwargs, 'flow_hours_total_max', 'flow_hours_max', flow_hours_max) - flow_hours_min = self._handle_deprecated_kwarg(kwargs, 'flow_hours_total_min', 'flow_hours_min', flow_hours_min) - flow_hours_max_over_periods = self._handle_deprecated_kwarg( - kwargs, 'total_flow_hours_max', 'flow_hours_max_over_periods', flow_hours_max_over_periods - ) - flow_hours_min_over_periods = self._handle_deprecated_kwarg( - kwargs, 'total_flow_hours_min', 'flow_hours_min_over_periods', flow_hours_min_over_periods - ) - - # Validate any remaining unexpected kwargs - self._validate_kwargs(kwargs) - # self.positive_gradient = TimeSeries('positive_gradient', positive_gradient, self) self.effects_per_flow_hour = effects_per_flow_hour if effects_per_flow_hour is not None else {} self.flow_hours_max = flow_hours_max self.flow_hours_min = flow_hours_min self.flow_hours_max_over_periods = flow_hours_max_over_periods self.flow_hours_min_over_periods = flow_hours_min_over_periods - self.on_off_parameters = on_off_parameters + self.status_parameters = status_parameters self.previous_flow_rate = previous_flow_rate self.component: str = 'UnknownComponent' self.is_input_in_component: bool | None = None if isinstance(bus, Bus): - self.bus = bus.label_full - warnings.warn( - f'Bus {bus.label} is passed as a Bus object to {self.label}. This is deprecated and will be removed ' - f'in the future. Add the Bus to the FlowSystem instead and pass its label to the Flow. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - UserWarning, - stacklevel=1, + raise TypeError( + f'Bus {bus.label} is passed as a Bus object to Flow {self.label}. ' + f'This is no longer supported. Add the Bus to the FlowSystem and pass its label (string) to the Flow.' ) - self._bus_object = bus - else: - self.bus = bus - self._bus_object = None + self.bus = bus def create_model(self, model: FlowSystemModel) -> FlowModel: self._plausibility_checks() self.submodel = FlowModel(model, self) return self.submodel - def _set_flow_system(self, flow_system) -> None: - """Propagate flow_system reference to nested Interface objects.""" - super()._set_flow_system(flow_system) - if self.on_off_parameters is not None: - self.on_off_parameters._set_flow_system(flow_system) - if isinstance(self.size, Interface): - self.size._set_flow_system(flow_system) - - def transform_data(self, name_prefix: str = '') -> None: - prefix = '|'.join(filter(None, [name_prefix, self.label_full])) - self.relative_minimum = self._fit_coords(f'{prefix}|relative_minimum', self.relative_minimum) - self.relative_maximum = self._fit_coords(f'{prefix}|relative_maximum', self.relative_maximum) - self.fixed_relative_profile = self._fit_coords(f'{prefix}|fixed_relative_profile', self.fixed_relative_profile) - self.effects_per_flow_hour = self._fit_effect_coords(prefix, self.effects_per_flow_hour, 'per_flow_hour') + def link_to_flow_system(self, flow_system, prefix: str = '') -> None: + """Propagate flow_system reference to nested Interface objects. + + Elements use their label_full as prefix by default, ignoring the passed prefix. + """ + super().link_to_flow_system(flow_system, self.label_full) + if self.status_parameters is not None: + self.status_parameters.link_to_flow_system(flow_system, self._sub_prefix('status_parameters')) + if isinstance(self.size, InvestParameters): + self.size.link_to_flow_system(flow_system, self._sub_prefix('InvestParameters')) + + def transform_data(self) -> None: + self.relative_minimum = self._fit_coords(f'{self.prefix}|relative_minimum', self.relative_minimum) + self.relative_maximum = self._fit_coords(f'{self.prefix}|relative_maximum', self.relative_maximum) + self.fixed_relative_profile = self._fit_coords( + f'{self.prefix}|fixed_relative_profile', self.fixed_relative_profile + ) + self.effects_per_flow_hour = self._fit_effect_coords(self.prefix, self.effects_per_flow_hour, 'per_flow_hour') self.flow_hours_max = self._fit_coords( - f'{prefix}|flow_hours_max', self.flow_hours_max, dims=['period', 'scenario'] + f'{self.prefix}|flow_hours_max', self.flow_hours_max, dims=['period', 'scenario'] ) self.flow_hours_min = self._fit_coords( - f'{prefix}|flow_hours_min', self.flow_hours_min, dims=['period', 'scenario'] + f'{self.prefix}|flow_hours_min', self.flow_hours_min, dims=['period', 'scenario'] ) self.flow_hours_max_over_periods = self._fit_coords( - f'{prefix}|flow_hours_max_over_periods', self.flow_hours_max_over_periods, dims=['scenario'] + f'{self.prefix}|flow_hours_max_over_periods', self.flow_hours_max_over_periods, dims=['scenario'] ) self.flow_hours_min_over_periods = self._fit_coords( - f'{prefix}|flow_hours_min_over_periods', self.flow_hours_min_over_periods, dims=['scenario'] + f'{self.prefix}|flow_hours_min_over_periods', self.flow_hours_min_over_periods, dims=['scenario'] ) self.load_factor_max = self._fit_coords( - f'{prefix}|load_factor_max', self.load_factor_max, dims=['period', 'scenario'] + f'{self.prefix}|load_factor_max', self.load_factor_max, dims=['period', 'scenario'] ) self.load_factor_min = self._fit_coords( - f'{prefix}|load_factor_min', self.load_factor_min, dims=['period', 'scenario'] + f'{self.prefix}|load_factor_min', self.load_factor_min, dims=['period', 'scenario'] ) - if self.on_off_parameters is not None: - self.on_off_parameters.transform_data(prefix) + if self.status_parameters is not None: + self.status_parameters.transform_data() if isinstance(self.size, InvestParameters): - self.size.transform_data(prefix) - else: - self.size = self._fit_coords(f'{prefix}|size', self.size, dims=['period', 'scenario']) + self.size.transform_data() + elif self.size is not None: + self.size = self._fit_coords(f'{self.prefix}|size', self.size, dims=['period', 'scenario']) def _plausibility_checks(self) -> None: # TODO: Incorporate into Variable? (Lower_bound can not be greater than upper bound if (self.relative_minimum > self.relative_maximum).any(): raise PlausibilityError(self.label_full + ': Take care, that relative_minimum <= relative_maximum!') - if not isinstance(self.size, InvestParameters) and ( - np.any(self.size == CONFIG.Modeling.big) and self.fixed_relative_profile is not None - ): # Default Size --> Most likely by accident - logger.warning( - f'Flow "{self.label_full}" has no size assigned, but a "fixed_relative_profile". ' - f'The default size is {CONFIG.Modeling.big}. As "flow_rate = size * fixed_relative_profile", ' - f'the resulting flow_rate will be very high. To fix this, assign a size to the Flow {self}.' + # Size is required when using StatusParameters (for big-M constraints) + if self.status_parameters is not None and self.size is None: + raise PlausibilityError( + f'Flow "{self.label_full}" has status_parameters but no size defined. ' + f'A size is required when using status_parameters to bound the flow rate.' + ) + + if self.size is None and self.fixed_relative_profile is not None: + raise PlausibilityError( + f'Flow "{self.label_full}" has a fixed_relative_profile but no size defined. ' + f'A size is required because flow_rate = size * fixed_relative_profile.' ) - if self.fixed_relative_profile is not None and self.on_off_parameters is not None: + # Size is required when using non-default relative bounds (flow_rate = size * relative_bound) + if self.size is None and np.any(self.relative_minimum > 0): + raise PlausibilityError( + f'Flow "{self.label_full}" has relative_minimum > 0 but no size defined. ' + f'A size is required because the lower bound is size * relative_minimum.' + ) + + if self.size is None and np.any(self.relative_maximum < 1): + raise PlausibilityError( + f'Flow "{self.label_full}" has relative_maximum != 1 but no size defined. ' + f'A size is required because the upper bound is size * relative_maximum.' + ) + + # Size is required for load factor constraints (total_flow_hours / size) + if self.size is None and self.load_factor_min is not None: + raise PlausibilityError( + f'Flow "{self.label_full}" has load_factor_min but no size defined. ' + f'A size is required because the constraint is total_flow_hours >= size * load_factor_min * hours.' + ) + + if self.size is None and self.load_factor_max is not None: + raise PlausibilityError( + f'Flow "{self.label_full}" has load_factor_max but no size defined. ' + f'A size is required because the constraint is total_flow_hours <= size * load_factor_max * hours.' + ) + + if self.fixed_relative_profile is not None and self.status_parameters is not None: logger.warning( - f'Flow {self.label_full} has both a fixed_relative_profile and an on_off_parameters.' - f'This will allow the flow to be switched on and off, effectively differing from the fixed_flow_rate.' + f'Flow {self.label_full} has both a fixed_relative_profile and status_parameters.' + f'This will allow the flow to be switched active and inactive, effectively differing from the fixed_flow_rate.' ) - if np.any(self.relative_minimum > 0) and self.on_off_parameters is None: + if np.any(self.relative_minimum > 0) and self.status_parameters is None: logger.warning( - f'Flow {self.label_full} has a relative_minimum of {self.relative_minimum} and no on_off_parameters. ' - f'This prevents the Flow from switching off (flow_rate = 0). ' - f'Consider using on_off_parameters to allow the Flow to be switched on and off.' + f'Flow {self.label_full} has a relative_minimum of {self.relative_minimum} and no status_parameters. ' + f'This prevents the Flow from switching inactive (flow_rate = 0). ' + f'Consider using status_parameters to allow the Flow to be switched active and inactive.' ) if self.previous_flow_rate is not None: @@ -613,57 +642,21 @@ def size_is_fixed(self) -> bool: # Wenn kein InvestParameters existiert --> True; Wenn Investparameter, den Wert davon nehmen return False if (isinstance(self.size, InvestParameters) and self.size.fixed_size is None) else True - # Backwards compatible properties (deprecated) - @property - def flow_hours_total_max(self): - """DEPRECATED: Use 'flow_hours_max' property instead.""" - warnings.warn( - f"Property 'flow_hours_total_max' is deprecated. Use 'flow_hours_max' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.flow_hours_max - - @flow_hours_total_max.setter - def flow_hours_total_max(self, value): - """DEPRECATED: Use 'flow_hours_max' property instead.""" - warnings.warn( - f"Property 'flow_hours_total_max' is deprecated. Use 'flow_hours_max' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.flow_hours_max = value - - @property - def flow_hours_total_min(self): - """DEPRECATED: Use 'flow_hours_min' property instead.""" - warnings.warn( - f"Property 'flow_hours_total_min' is deprecated. Use 'flow_hours_min' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.flow_hours_min - - @flow_hours_total_min.setter - def flow_hours_total_min(self, value): - """DEPRECATED: Use 'flow_hours_min' property instead.""" - warnings.warn( - f"Property 'flow_hours_total_min' is deprecated. Use 'flow_hours_min' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.flow_hours_min = value - def _format_invest_params(self, params: InvestParameters) -> str: """Format InvestParameters for display.""" return f'size: {params.format_for_repr()}' class FlowModel(ElementModel): + """Mathematical model implementation for Flow elements. + + Creates optimization variables and constraints for flow rate bounds, + flow-hours tracking, and load factors. + + Mathematical Formulation: + See + """ + element: Flow # Type hint def __init__(self, model: FlowSystemModel, element: Flow): @@ -732,18 +725,18 @@ def _do_modeling(self): # Effects self._create_shares() - def _create_on_off_model(self): - on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords()) + def _create_status_model(self): + status = self.add_variables(binary=True, short_name='status', coords=self._model.get_coords()) self.add_submodels( - OnOffModel( + StatusModel( model=self._model, label_of_element=self.label_of_element, - parameters=self.element.on_off_parameters, - on_variable=on, - previous_states=self.previous_states, + parameters=self.element.status_parameters, + status=status, + previous_status=self.previous_status, label_of_model=self.label_of_element, ), - short_name='on_off', + short_name='status', ) def _create_investment_model(self): @@ -759,23 +752,23 @@ def _create_investment_model(self): def _constraint_flow_rate(self): """Create bounding constraints for flow_rate (models already created in _create_variables)""" - if not self.with_investment and not self.with_on_off: + if not self.with_investment and not self.with_status: # Most basic case. Already covered by direct variable bounds pass - elif self.with_on_off and not self.with_investment: - # OnOff, but no Investment - self._create_on_off_model() + elif self.with_status and not self.with_investment: + # Status, but no Investment + self._create_status_model() bounds = self.relative_flow_rate_bounds BoundingPatterns.bounds_with_state( self, variable=self.flow_rate, bounds=(bounds[0] * self.element.size, bounds[1] * self.element.size), - variable_state=self.on_off.on, + state=self.status.status, ) - elif self.with_investment and not self.with_on_off: - # Investment, but no OnOff + elif self.with_investment and not self.with_status: + # Investment, but no Status self._create_investment_model() BoundingPatterns.scaled_bounds( self, @@ -784,10 +777,10 @@ def _constraint_flow_rate(self): relative_bounds=self.relative_flow_rate_bounds, ) - elif self.with_investment and self.with_on_off: - # Investment and OnOff + elif self.with_investment and self.with_status: + # Investment and Status self._create_investment_model() - self._create_on_off_model() + self._create_status_model() BoundingPatterns.scaled_bounds_with_state( model=self, @@ -795,14 +788,14 @@ def _constraint_flow_rate(self): scaling_variable=self._investment.size, relative_bounds=self.relative_flow_rate_bounds, scaling_bounds=(self.element.size.minimum_or_fixed_size, self.element.size.maximum_or_fixed_size), - variable_state=self.on_off.on, + state=self.status.status, ) else: raise Exception('Not valid') @property - def with_on_off(self) -> bool: - return self.element.on_off_parameters is not None + def with_status(self) -> bool: + return self.element.status_parameters is not None @property def with_investment(self) -> bool: @@ -875,27 +868,30 @@ def absolute_flow_rate_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: lb_relative, ub_relative = self.relative_flow_rate_bounds lb = 0 - if not self.with_on_off: + if not self.with_status: if not self.with_investment: - # Basic case without investment and without OnOff - lb = lb_relative * self.element.size + # Basic case without investment and without Status + if self.element.size is not None: + lb = lb_relative * self.element.size elif self.with_investment and self.element.size.mandatory: # With mandatory Investment lb = lb_relative * self.element.size.minimum_or_fixed_size if self.with_investment: ub = ub_relative * self.element.size.maximum_or_fixed_size - else: + elif self.element.size is not None: ub = ub_relative * self.element.size + else: + ub = np.inf # Unbounded when size is None return lb, ub @property - def on_off(self) -> OnOffModel | None: - """OnOff feature""" - if 'on_off' not in self.submodels: + def status(self) -> StatusModel | None: + """Status feature""" + if 'status' not in self.submodels: return None - return self.submodels['on_off'] + return self.submodels['status'] @property def _investment(self) -> InvestmentModel | None: @@ -904,14 +900,14 @@ def _investment(self) -> InvestmentModel | None: @property def investment(self) -> InvestmentModel | None: - """OnOff feature""" + """Investment feature""" if 'investment' not in self.submodels: return None return self.submodels['investment'] @property - def previous_states(self) -> xr.DataArray | None: - """Previous states of the flow rate""" + def previous_status(self) -> xr.DataArray | None: + """Previous status of the flow rate""" # TODO: This would be nicer to handle in the Flow itself, and allow DataArrays as well. previous_flow_rate = self.element.previous_flow_rate if previous_flow_rate is None: @@ -927,11 +923,20 @@ def previous_states(self) -> xr.DataArray | None: class BusModel(ElementModel): + """Mathematical model implementation for Bus elements. + + Creates optimization variables and constraints for nodal balance equations, + and optional excess/deficit variables with penalty costs. + + Mathematical Formulation: + See + """ + element: Bus # Type hint def __init__(self, model: FlowSystemModel, element: Bus): - self.excess_input: linopy.Variable | None = None - self.excess_output: linopy.Variable | None = None + self.virtual_supply: linopy.Variable | None = None + self.virtual_demand: linopy.Variable | None = None super().__init__(model, element) def _do_modeling(self): @@ -944,39 +949,38 @@ def _do_modeling(self): outputs = sum([flow.submodel.flow_rate for flow in self.element.outputs]) eq_bus_balance = self.add_constraints(inputs == outputs, short_name='balance') - # Add excess to balance and penalty if needed - if self.element.with_excess: - excess_penalty = np.multiply(self._model.hours_per_step, self.element.excess_penalty_per_flow_hour) + # Add virtual supply/demand to balance and penalty if needed + if self.element.allows_imbalance: + imbalance_penalty = np.multiply(self._model.hours_per_step, self.element.imbalance_penalty_per_flow_hour) - self.excess_input = self.add_variables(lower=0, coords=self._model.get_coords(), short_name='excess_input') + self.virtual_supply = self.add_variables( + lower=0, coords=self._model.get_coords(), short_name='virtual_supply' + ) - self.excess_output = self.add_variables( - lower=0, coords=self._model.get_coords(), short_name='excess_output' + self.virtual_demand = self.add_variables( + lower=0, coords=self._model.get_coords(), short_name='virtual_demand' ) - eq_bus_balance.lhs -= -self.excess_input + self.excess_output + # Σ(inflows) + virtual_supply = Σ(outflows) + virtual_demand + eq_bus_balance.lhs += self.virtual_supply - self.virtual_demand # Add penalty shares as temporal effects (time-dependent) from .effects import PENALTY_EFFECT_LABEL + total_imbalance_penalty = (self.virtual_supply + self.virtual_demand) * imbalance_penalty self._model.effects.add_share_to_effects( name=self.label_of_element, - expressions={PENALTY_EFFECT_LABEL: self.excess_input * excess_penalty}, - target='temporal', - ) - self._model.effects.add_share_to_effects( - name=self.label_of_element, - expressions={PENALTY_EFFECT_LABEL: self.excess_output * excess_penalty}, + expressions={PENALTY_EFFECT_LABEL: total_imbalance_penalty}, target='temporal', ) def results_structure(self): inputs = [flow.submodel.flow_rate.name for flow in self.element.inputs] outputs = [flow.submodel.flow_rate.name for flow in self.element.outputs] - if self.excess_input is not None: - inputs.append(self.excess_input.name) - if self.excess_output is not None: - outputs.append(self.excess_output.name) + if self.virtual_supply is not None: + inputs.append(self.virtual_supply.name) + if self.virtual_demand is not None: + outputs.append(self.virtual_demand.name) return { **super().results_structure(), 'inputs': inputs, @@ -989,7 +993,7 @@ class ComponentModel(ElementModel): element: Component # Type hint def __init__(self, model: FlowSystemModel, element: Component): - self.on_off: OnOffModel | None = None + self.status: StatusModel | None = None super().__init__(model, element) def _do_modeling(self): @@ -998,51 +1002,58 @@ def _do_modeling(self): all_flows = self.element.inputs + self.element.outputs - # Set on_off_parameters on flows if needed - if self.element.on_off_parameters: + # Set status_parameters on flows if needed + if self.element.status_parameters: for flow in all_flows: - if flow.on_off_parameters is None: - flow.on_off_parameters = OnOffParameters() + if flow.status_parameters is None: + flow.status_parameters = StatusParameters() + flow.status_parameters.link_to_flow_system( + self._model.flow_system, f'{flow.label_full}|status_parameters' + ) if self.element.prevent_simultaneous_flows: for flow in self.element.prevent_simultaneous_flows: - if flow.on_off_parameters is None: - flow.on_off_parameters = OnOffParameters() + if flow.status_parameters is None: + flow.status_parameters = StatusParameters() + flow.status_parameters.link_to_flow_system( + self._model.flow_system, f'{flow.label_full}|status_parameters' + ) # Create FlowModels (which creates their variables and constraints) for flow in all_flows: self.add_submodels(flow.create_model(self._model), short_name=flow.label) - # Create component on variable and OnOffModel if needed - if self.element.on_off_parameters: - on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords()) + # Create component status variable and StatusModel if needed + if self.element.status_parameters: + status = self.add_variables(binary=True, short_name='status', coords=self._model.get_coords()) if len(all_flows) == 1: - self.add_constraints(on == all_flows[0].submodel.on_off.on, short_name='on') + self.add_constraints(status == all_flows[0].submodel.status.status, short_name='status') else: - flow_ons = [flow.submodel.on_off.on for flow in all_flows] + flow_statuses = [flow.submodel.status.status for flow in all_flows] # TODO: Is the EPSILON even necessary? - self.add_constraints(on <= sum(flow_ons) + CONFIG.Modeling.epsilon, short_name='on|ub') + self.add_constraints(status <= sum(flow_statuses) + CONFIG.Modeling.epsilon, short_name='status|ub') self.add_constraints( - on >= sum(flow_ons) / (len(flow_ons) + CONFIG.Modeling.epsilon), short_name='on|lb' + status >= sum(flow_statuses) / (len(flow_statuses) + CONFIG.Modeling.epsilon), + short_name='status|lb', ) - self.on_off = self.add_submodels( - OnOffModel( + self.status = self.add_submodels( + StatusModel( model=self._model, label_of_element=self.label_of_element, - parameters=self.element.on_off_parameters, - on_variable=on, + parameters=self.element.status_parameters, + status=status, label_of_model=self.label_of_element, - previous_states=self.previous_states, + previous_status=self.previous_status, ), - short_name='on_off', + short_name='status', ) if self.element.prevent_simultaneous_flows: # Simultanious Useage --> Only One FLow is On at a time, but needs a Binary for every flow ModelingPrimitives.mutual_exclusivity_constraint( self, - binary_variables=[flow.submodel.on_off.on for flow in self.element.prevent_simultaneous_flows], + binary_variables=[flow.submodel.status.status for flow in self.element.prevent_simultaneous_flows], short_name='prevent_simultaneous_use', ) @@ -1055,21 +1066,21 @@ def results_structure(self): } @property - def previous_states(self) -> xr.DataArray | None: - """Previous state of the component, derived from its flows""" - if self.element.on_off_parameters is None: - raise ValueError(f'OnOffModel not present in \n{self}\nCant access previous_states') + def previous_status(self) -> xr.DataArray | None: + """Previous status of the component, derived from its flows""" + if self.element.status_parameters is None: + raise ValueError(f'StatusModel not present in \n{self}\nCant access previous_status') - previous_states = [flow.submodel.on_off._previous_states for flow in self.element.inputs + self.element.outputs] - previous_states = [da for da in previous_states if da is not None] + previous_status = [flow.submodel.status._previous_status for flow in self.element.inputs + self.element.outputs] + previous_status = [da for da in previous_status if da is not None] - if not previous_states: # Empty list + if not previous_status: # Empty list return None - max_len = max(da.sizes['time'] for da in previous_states) + max_len = max(da.sizes['time'] for da in previous_status) - padded_previous_states = [ + padded_previous_status = [ da.assign_coords(time=range(-da.sizes['time'], 0)).reindex(time=range(-max_len, 0), fill_value=0) - for da in previous_states + for da in previous_status ] - return xr.concat(padded_previous_states, dim='flow').any(dim='flow').astype(int) + return xr.concat(padded_previous_status, dim='flow').any(dim='flow').astype(int) diff --git a/flixopt/features.py b/flixopt/features.py index 8c4bf7c70..4dfe48964 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -16,22 +16,27 @@ if TYPE_CHECKING: from collections.abc import Collection + import xarray as xr + from .core import FlowSystemDimensions - from .interface import InvestParameters, OnOffParameters, Piecewise + from .interface import InvestParameters, Piecewise, StatusParameters from .types import Numeric_PS, Numeric_TPS class InvestmentModel(Submodel): - """ - This feature model is used to model the investment of a variable. - It applies the corresponding bounds to the variable and the on/off state of the variable. + """Mathematical model implementation for investment decisions. + + Creates optimization variables and constraints for investment sizing decisions, + supporting both binary and continuous sizing with comprehensive effect modeling. + + Mathematical Formulation: + See Args: model: The optimization model instance label_of_element: The label of the parent (Element). Used to construct the full label of the model. parameters: The parameters of the feature model. label_of_model: The label of the model. This is needed to construct the full label of the model. - """ parameters: InvestParameters @@ -75,7 +80,7 @@ def _create_variables_and_constraints(self): BoundingPatterns.bounds_with_state( self, variable=self.size, - variable_state=self._variables['invested'], + state=self._variables['invested'], bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size), ) @@ -144,32 +149,40 @@ def invested(self) -> linopy.Variable | None: return self._variables['invested'] -class OnOffModel(Submodel): - """OnOff model using factory patterns""" +class StatusModel(Submodel): + """Mathematical model implementation for binary status. + + Creates optimization variables and constraints for binary status modeling, + state transitions, duration tracking, and operational effects. + + Mathematical Formulation: + See + """ def __init__( self, model: FlowSystemModel, label_of_element: str, - parameters: OnOffParameters, - on_variable: linopy.Variable, - previous_states: Numeric_TPS | None, + parameters: StatusParameters, + status: linopy.Variable, + previous_status: xr.DataArray | None, label_of_model: str | None = None, ): """ - This feature model is used to model the on/off state of flow_rate(s). It does not matter of the flow_rates are - bounded by a size variable or by a hard bound. THe used bound here is the absolute highest/lowest bound! + This feature model is used to model the status (active/inactive) state of flow_rate(s). + It does not matter if the flow_rates are bounded by a size variable or by a hard bound. + The used bound here is the absolute highest/lowest bound! Args: model: The optimization model instance label_of_element: The label of the parent (Element). Used to construct the full label of the model. parameters: The parameters of the feature model. - on_variable: The variable that determines the on state - previous_states: The previous flow_rates + status: The variable that determines the active state + previous_status: The previous flow_rates label_of_model: The label of the model. This is needed to construct the full label of the model. """ - self.on = on_variable - self._previous_states = previous_states + self.status = status + self._previous_status = previous_status self.parameters = parameters super().__init__(model, label_of_element, label_of_model=label_of_model) @@ -177,92 +190,95 @@ def _do_modeling(self): """Create variables, constraints, and nested submodels""" super()._do_modeling() - if self.parameters.use_off: - off = self.add_variables(binary=True, short_name='off', coords=self._model.get_coords()) - self.add_constraints(self.on + off == 1, short_name='complementary') + # Create a separate binary 'inactive' variable when needed for downtime tracking or explicit use + # When not needed, the expression (1 - self.status) can be used instead + if self.parameters.use_downtime_tracking: + inactive = self.add_variables(binary=True, short_name='inactive', coords=self._model.get_coords()) + self.add_constraints(self.status + inactive == 1, short_name='complementary') # 3. Total duration tracking using existing pattern ModelingPrimitives.expression_tracking_variable( self, - tracked_expression=(self.on * self._model.hours_per_step).sum('time'), + tracked_expression=(self.status * self._model.hours_per_step).sum('time'), bounds=( - self.parameters.on_hours_min if self.parameters.on_hours_min is not None else 0, - self.parameters.on_hours_max if self.parameters.on_hours_max is not None else np.inf, - ), # TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) - short_name='on_hours_total', + self.parameters.active_hours_min if self.parameters.active_hours_min is not None else 0, + self.parameters.active_hours_max + if self.parameters.active_hours_max is not None + else self._model.hours_per_step.sum('time').max().item(), + ), + short_name='active_hours', coords=['period', 'scenario'], ) # 4. Switch tracking using existing pattern - if self.parameters.use_switch_on: - self.add_variables(binary=True, short_name='switch|on', coords=self.get_coords()) - self.add_variables(binary=True, short_name='switch|off', coords=self.get_coords()) + if self.parameters.use_startup_tracking: + self.add_variables(binary=True, short_name='startup', coords=self.get_coords()) + self.add_variables(binary=True, short_name='shutdown', coords=self.get_coords()) BoundingPatterns.state_transition_bounds( self, - state_variable=self.on, - switch_on=self.switch_on, - switch_off=self.switch_off, + state=self.status, + activate=self.startup, + deactivate=self.shutdown, name=f'{self.label_of_model}|switch', - previous_state=self._previous_states.isel(time=-1) if self._previous_states is not None else 0, + previous_state=self._previous_status.isel(time=-1) if self._previous_status is not None else 0, coord='time', ) - if self.parameters.switch_on_max is not None: + if self.parameters.startup_limit is not None: count = self.add_variables( lower=0, - upper=self.parameters.switch_on_max, + upper=self.parameters.startup_limit, coords=self._model.get_coords(('period', 'scenario')), - short_name='switch|count', + short_name='startup_count', ) - self.add_constraints(count == self.switch_on.sum('time'), short_name='switch|count') + self.add_constraints(count == self.startup.sum('time'), short_name='startup_count') - # 5. Consecutive on duration using existing pattern - if self.parameters.use_consecutive_on_hours: + # 5. Consecutive active duration (uptime) using existing pattern + if self.parameters.use_uptime_tracking: ModelingPrimitives.consecutive_duration_tracking( self, - state_variable=self.on, - short_name='consecutive_on_hours', - minimum_duration=self.parameters.consecutive_on_hours_min, - maximum_duration=self.parameters.consecutive_on_hours_max, + state=self.status, + short_name='uptime', + minimum_duration=self.parameters.min_uptime, + maximum_duration=self.parameters.max_uptime, duration_per_step=self.hours_per_step, duration_dim='time', - previous_duration=self._get_previous_on_duration(), + previous_duration=self._get_previous_uptime(), ) - # 6. Consecutive off duration using existing pattern - if self.parameters.use_consecutive_off_hours: + # 6. Consecutive inactive duration (downtime) using existing pattern + if self.parameters.use_downtime_tracking: ModelingPrimitives.consecutive_duration_tracking( self, - state_variable=self.off, - short_name='consecutive_off_hours', - minimum_duration=self.parameters.consecutive_off_hours_min, - maximum_duration=self.parameters.consecutive_off_hours_max, + state=self.inactive, + short_name='downtime', + minimum_duration=self.parameters.min_downtime, + maximum_duration=self.parameters.max_downtime, duration_per_step=self.hours_per_step, duration_dim='time', - previous_duration=self._get_previous_off_duration(), + previous_duration=self._get_previous_downtime(), ) - # TODO: self._add_effects() def _add_effects(self): """Add operational effects""" - if self.parameters.effects_per_running_hour: + if self.parameters.effects_per_active_hour: self._model.effects.add_share_to_effects( name=self.label_of_element, expressions={ - effect: self.on * factor * self._model.hours_per_step - for effect, factor in self.parameters.effects_per_running_hour.items() + effect: self.status * factor * self._model.hours_per_step + for effect, factor in self.parameters.effects_per_active_hour.items() }, target='temporal', ) - if self.parameters.effects_per_switch_on: + if self.parameters.effects_per_startup: self._model.effects.add_share_to_effects( name=self.label_of_element, expressions={ - effect: self.switch_on * factor for effect, factor in self.parameters.effects_per_switch_on.items() + effect: self.startup * factor for effect, factor in self.parameters.effects_per_startup.items() }, target='temporal', ) @@ -270,55 +286,66 @@ def _add_effects(self): # Properties access variables from Submodel's tracking system @property - def on_hours_total(self) -> linopy.Variable: - """Total on hours variable""" - return self['on_hours_total'] + def active_hours(self) -> linopy.Variable: + """Total active hours variable""" + return self['active_hours'] @property - def off(self) -> linopy.Variable | None: - """Binary off state variable""" - return self.get('off') + def inactive(self) -> linopy.Variable | None: + """Binary inactive state variable. + + Note: + Only created when downtime tracking is enabled (min_downtime or max_downtime set). + For general use, prefer the expression `1 - status` instead of this variable. + """ + return self.get('inactive') @property - def switch_on(self) -> linopy.Variable | None: - """Switch on variable""" - return self.get('switch|on') + def startup(self) -> linopy.Variable | None: + """Startup variable""" + return self.get('startup') @property - def switch_off(self) -> linopy.Variable | None: - """Switch off variable""" - return self.get('switch|off') + def shutdown(self) -> linopy.Variable | None: + """Shutdown variable""" + return self.get('shutdown') @property - def switch_on_nr(self) -> linopy.Variable | None: - """Number of switch-ons variable""" - return self.get('switch|count') + def startup_count(self) -> linopy.Variable | None: + """Number of startups variable""" + return self.get('startup_count') @property - def consecutive_on_hours(self) -> linopy.Variable | None: - """Consecutive on hours variable""" - return self.get('consecutive_on_hours') + def uptime(self) -> linopy.Variable | None: + """Consecutive active hours (uptime) variable""" + return self.get('uptime') @property - def consecutive_off_hours(self) -> linopy.Variable | None: - """Consecutive off hours variable""" - return self.get('consecutive_off_hours') + def downtime(self) -> linopy.Variable | None: + """Consecutive inactive hours (downtime) variable""" + return self.get('downtime') + + def _get_previous_uptime(self): + """Get previous uptime (consecutive active hours). - def _get_previous_on_duration(self): - """Get previous on duration. Previously OFF by default, for one timestep""" + Returns 0 if no previous status is provided (assumes previously inactive). + """ hours_per_step = self._model.hours_per_step.isel(time=0).min().item() - if self._previous_states is None: + if self._previous_status is None: return 0 else: - return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_states, hours_per_step) + return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_status, hours_per_step) - def _get_previous_off_duration(self): - """Get previous off duration. Previously OFF by default, for one timestep""" + def _get_previous_downtime(self): + """Get previous downtime (consecutive inactive hours). + + Returns one timestep duration if no previous status is provided (assumes previously inactive). + """ hours_per_step = self._model.hours_per_step.isel(time=0).min().item() - if self._previous_states is None: + if self._previous_status is None: return hours_per_step else: - return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_states * -1 + 1, hours_per_step) + return ModelingUtilities.compute_consecutive_hours_in_state(1 - self._previous_status, hours_per_step) class PieceModel(Submodel): @@ -368,6 +395,15 @@ def _do_modeling(self): class PiecewiseModel(Submodel): + """Mathematical model implementation for piecewise linear approximations. + + Creates optimization variables and constraints for piecewise linear relationships, + including lambda variables, piece activation binaries, and coupling constraints. + + Mathematical Formulation: + See + """ + def __init__( self, model: FlowSystemModel, diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 52c403396..8f2dba51b 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -4,9 +4,10 @@ from __future__ import annotations +import json import logging +import pathlib import warnings -from collections import defaultdict from itertools import chain from typing import TYPE_CHECKING, Any, Literal @@ -14,7 +15,9 @@ import pandas as pd import xarray as xr +from . import __version__ from . import io as fx_io +from .components import Storage from .config import CONFIG, DEPRECATION_REMOVAL_VERSION from .core import ( ConversionError, @@ -24,16 +27,22 @@ ) from .effects import Effect, EffectCollection from .elements import Bus, Component, Flow +from .optimize_accessor import OptimizeAccessor +from .statistics_accessor import StatisticsAccessor from .structure import CompositeContainerMixin, Element, ElementContainer, FlowSystemModel, Interface +from .topology_accessor import TopologyAccessor +from .transform_accessor import TransformAccessor if TYPE_CHECKING: - import pathlib from collections.abc import Collection import pyvis + from .solvers import _Solver from .types import Effect_TPS, Numeric_S, Numeric_TPS, NumericOrBool +from .carrier import Carrier, CarrierContainer + logger = logging.getLogger('flixopt') @@ -52,7 +61,7 @@ class FlowSystem(Interface, CompositeContainerMixin[Element]): hours_of_last_timestep: Duration of the last timestep. If None, computed from the last time interval. hours_of_previous_timesteps: Duration of previous timesteps. If None, computed from the first time interval. Can be a scalar (all previous timesteps have same duration) or array (different durations). - Used to calculate previous values (e.g., consecutive_on_hours). + Used to calculate previous values (e.g., uptime and downtime). weight_of_last_period: Weight/duration of the last period. If None, computed from the last period interval. Used for calculating sums over periods in multi-period models. scenario_weights: The weights of each scenario. If None, all scenarios have the same weight (normalized to 1). @@ -76,8 +85,8 @@ class FlowSystem(Interface, CompositeContainerMixin[Element]): >>> flow_system = fx.FlowSystem(timesteps) >>> >>> # Add elements to the system - >>> boiler = fx.Component('Boiler', inputs=[heat_flow], on_off_parameters=...) - >>> heat_bus = fx.Bus('Heat', excess_penalty_per_flow_hour=1e4) + >>> boiler = fx.Component('Boiler', inputs=[heat_flow], status_parameters=...) + >>> heat_bus = fx.Bus('Heat', imbalance_penalty_per_flow_hour=1e4) >>> costs = fx.Effect('costs', is_objective=True, is_standard=True) >>> flow_system.add_elements(boiler, heat_bus, costs) @@ -142,9 +151,6 @@ class FlowSystem(Interface, CompositeContainerMixin[Element]): (components, buses, effects, flows) to find the element with the matching label. - Element labels must be unique across all container types. Attempting to add elements with duplicate labels will raise an error, ensuring each label maps to exactly one element. - - The `.all_elements` property is deprecated. Use the dict-like interface instead: - `flow_system['element']`, `'element' in flow_system`, `flow_system.keys()`, - `flow_system.values()`, or `flow_system.items()`. - Direct container access (`.components`, `.buses`, `.effects`, `.flows`) is useful when you need type-specific filtering or operations. - The `.flows` container is automatically populated from all component inputs and outputs. @@ -166,18 +172,8 @@ def __init__( scenario_weights: Numeric_S | None = None, scenario_independent_sizes: bool | list[str] = True, scenario_independent_flow_rates: bool | list[str] = False, - **kwargs, + name: str | None = None, ): - scenario_weights = self._handle_deprecated_kwarg( - kwargs, - 'weights', - 'scenario_weights', - scenario_weights, - check_conflict=True, - additional_warning_message='This might lead to later errors if your custom weights used the period dimension.', - ) - self._validate_kwargs(kwargs) - self.timesteps = self._validate_timesteps(timesteps) # Compute all time-related metadata using shared helper @@ -215,11 +211,33 @@ def __init__( self._network_app = None self._flows_cache: ElementContainer[Flow] | None = None + self._storages_cache: ElementContainer[Storage] | None = None + + # Solution dataset - populated after optimization or loaded from file + self._solution: xr.Dataset | None = None + + # Clustering info - populated by transform.cluster() + self._clustering_info: dict | None = None + + # Statistics accessor cache - lazily initialized, invalidated on new solution + self._statistics: StatisticsAccessor | None = None + + # Topology accessor cache - lazily initialized, invalidated on structure change + self._topology: TopologyAccessor | None = None + + # Carrier container - local carriers override CONFIG.Carriers + self._carriers: CarrierContainer = CarrierContainer() + + # Cached flow→carrier mapping (built lazily after connect_and_transform) + self._flow_carriers: dict[str, str] | None = None # Use properties to validate and store scenario dimension settings self.scenario_independent_sizes = scenario_independent_sizes self.scenario_independent_flow_rates = scenario_independent_flow_rates + # Optional name for identification (derived from filename on load) + self.name = name + @staticmethod def _validate_timesteps(timesteps: pd.DatetimeIndex) -> pd.DatetimeIndex: """Validate timesteps format and rename if needed.""" @@ -498,6 +516,28 @@ def _update_period_metadata( return dataset + @classmethod + def _update_scenario_metadata(cls, dataset: xr.Dataset) -> xr.Dataset: + """ + Update scenario-related attributes and data variables in dataset based on its scenario index. + + Recomputes or removes scenario weights. This ensures scenario metadata stays synchronized with the actual + scenarios after operations like selection. + + This is analogous to _update_period_metadata() for time-related metadata. + + Args: + dataset: Dataset to update (will be modified in place) + + Returns: + The same dataset with updated scenario-related attributes and data variables + """ + new_scenario_index = dataset.indexes.get('scenario') + if new_scenario_index is None or len(new_scenario_index) <= 1: + dataset.attrs.pop('scenario_weights', None) + + return dataset + def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]: """ Override Interface method to handle FlowSystem-specific serialization. @@ -538,11 +578,21 @@ def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]: return reference_structure, all_extracted_arrays - def to_dataset(self) -> xr.Dataset: + def to_dataset(self, include_solution: bool = True) -> xr.Dataset: """ Convert the FlowSystem to an xarray Dataset. Ensures FlowSystem is connected before serialization. + If a solution is present and `include_solution=True`, it will be included + in the dataset with variable names prefixed by 'solution|' to avoid conflicts + with FlowSystem configuration variables. Solution time coordinates are renamed + to 'solution_time' to preserve them independently of the FlowSystem's time coordinates. + + Args: + include_solution: Whether to include the optimization solution in the dataset. + Defaults to True. Set to False to get only the FlowSystem structure + without solution data (useful for copying or saving templates). + Returns: xr.Dataset: Dataset containing all DataArrays with structure in attributes """ @@ -550,7 +600,37 @@ def to_dataset(self) -> xr.Dataset: logger.warning('FlowSystem is not connected_and_transformed. Connecting and transforming data now.') self.connect_and_transform() - return super().to_dataset() + ds = super().to_dataset() + + # Include solution data if present and requested + if include_solution and self.solution is not None: + # Rename 'time' to 'solution_time' in solution variables to preserve full solution + # (linopy solution may have extra timesteps, e.g., for final charge states) + solution_renamed = ( + self.solution.rename({'time': 'solution_time'}) if 'time' in self.solution.dims else self.solution + ) + # Add solution variables with 'solution|' prefix to avoid conflicts + solution_vars = {f'solution|{name}': var for name, var in solution_renamed.data_vars.items()} + ds = ds.assign(solution_vars) + # Also add the solution_time coordinate if it exists + if 'solution_time' in solution_renamed.coords: + ds = ds.assign_coords(solution_time=solution_renamed.coords['solution_time']) + ds.attrs['has_solution'] = True + else: + ds.attrs['has_solution'] = False + + # Include carriers if any are registered + if self._carriers: + carriers_structure = {} + for name, carrier in self._carriers.items(): + carrier_ref, _ = carrier._create_reference_structure() + carriers_structure[name] = carrier_ref + ds.attrs['carriers'] = json.dumps(carriers_structure) + + # Add version info + ds.attrs['flixopt_version'] = __version__ + + return ds @classmethod def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: @@ -558,6 +638,10 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: Create a FlowSystem from an xarray Dataset. Handles FlowSystem-specific reconstruction logic. + If the dataset contains solution data (variables prefixed with 'solution|'), + the solution will be restored to the FlowSystem. Solution time coordinates + are renamed back from 'solution_time' to 'time'. + Args: ds: Dataset containing the FlowSystem data @@ -567,8 +651,20 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: # Get the reference structure from attrs reference_structure = dict(ds.attrs) - # Create arrays dictionary from dataset variables - arrays_dict = {name: array for name, array in ds.data_vars.items()} + # Separate solution variables from config variables + solution_prefix = 'solution|' + solution_vars = {} + config_vars = {} + for name, array in ds.data_vars.items(): + if name.startswith(solution_prefix): + # Remove prefix for solution dataset + original_name = name[len(solution_prefix) :] + solution_vars[original_name] = array + else: + config_vars[name] = array + + # Create arrays dictionary from config variables only + arrays_dict = config_vars # Create FlowSystem instance with constructor parameters flow_system = cls( @@ -583,6 +679,7 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: else None, scenario_independent_sizes=reference_structure.get('scenario_independent_sizes', True), scenario_independent_flow_rates=reference_structure.get('scenario_independent_flow_rates', False), + name=reference_structure.get('name'), ) # Restore components @@ -609,24 +706,188 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: logger.critical(f'Restoring effect {effect_label} failed.') flow_system._add_effects(effect) + # Restore solution if present + if reference_structure.get('has_solution', False) and solution_vars: + solution_ds = xr.Dataset(solution_vars) + # Rename 'solution_time' back to 'time' if present + if 'solution_time' in solution_ds.dims: + solution_ds = solution_ds.rename({'solution_time': 'time'}) + flow_system.solution = solution_ds + + # Restore carriers if present + if 'carriers' in reference_structure: + carriers_structure = json.loads(reference_structure['carriers']) + for carrier_data in carriers_structure.values(): + carrier = cls._resolve_reference_structure(carrier_data, {}) + flow_system._carriers.add(carrier) + + # Reconnect network to populate bus inputs/outputs (not stored in NetCDF). + flow_system.connect_and_transform() + return flow_system - def to_netcdf(self, path: str | pathlib.Path, compression: int = 0): + def to_netcdf(self, path: str | pathlib.Path, compression: int = 5, overwrite: bool = False): """ Save the FlowSystem to a NetCDF file. Ensures FlowSystem is connected before saving. + The FlowSystem's name is automatically set from the filename + (without extension) when saving. + Args: - path: The path to the netCDF file. - compression: The compression level to use when saving the file. + path: The path to the netCDF file. Parent directories are created if they don't exist. + compression: The compression level to use when saving the file (0-9). + overwrite: If True, overwrite existing file. If False, raise error if file exists. + + Raises: + FileExistsError: If overwrite=False and file already exists. """ if not self.connected_and_transformed: logger.warning('FlowSystem is not connected. Calling connect_and_transform() now.') self.connect_and_transform() - super().to_netcdf(path, compression) + path = pathlib.Path(path) + # Set name from filename (without extension) + self.name = path.stem + + super().to_netcdf(path, compression, overwrite) logger.info(f'Saved FlowSystem to {path}') + @classmethod + def from_netcdf(cls, path: str | pathlib.Path) -> FlowSystem: + """ + Load a FlowSystem from a NetCDF file. + + The FlowSystem's name is automatically derived from the filename + (without extension), overriding any name that may have been stored. + + Args: + path: Path to the NetCDF file + + Returns: + FlowSystem instance with name set from filename + """ + path = pathlib.Path(path) + flow_system = super().from_netcdf(path) + # Derive name from filename (without extension) + 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 convert_old_dataset, load_dataset_from_netcdf + + folder = pathlib.Path(folder) + + # Load datasets directly (old format used --flow_system.nc4 and --solution.nc4) + flow_system_path = folder / f'{name}--flow_system.nc4' + solution_path = folder / f'{name}--solution.nc4' + + flow_system_data = load_dataset_from_netcdf(flow_system_path) + solution = load_dataset_from_netcdf(solution_path) + + # Convert flow_system_data to new parameter names + convert_old_dataset(flow_system_data) + + # Reconstruct FlowSystem + flow_system = cls.from_dataset(flow_system_data) + flow_system.name = name + + # Attach solution (convert attrs from dicts to JSON strings for consistency) + 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 copy(self) -> FlowSystem: + """Create a copy of the FlowSystem without optimization state. + + Creates a new FlowSystem with copies of all elements, but without: + - The solution dataset + - The optimization model + - Element submodels and variable/constraint names + + This is useful for creating variations of a FlowSystem for different + optimization scenarios without affecting the original. + + Returns: + A new FlowSystem instance that can be modified and optimized independently. + + Examples: + >>> original = FlowSystem(timesteps) + >>> original.add_elements(boiler, bus) + >>> original.optimize(solver) # Original now has solution + >>> + >>> # Create a copy to try different parameters + >>> variant = original.copy() # No solution, can be modified + >>> variant.add_elements(new_component) + >>> variant.optimize(solver) + """ + ds = self.to_dataset(include_solution=False) + return FlowSystem.from_dataset(ds.copy(deep=True)) + + def __copy__(self): + """Support for copy.copy().""" + return self.copy() + + def __deepcopy__(self, memo): + """Support for copy.deepcopy().""" + return self.copy() + def get_structure(self, clean: bool = False, stats: bool = False) -> dict: """ Get FlowSystem structure. @@ -724,12 +985,38 @@ def fit_effects_to_model_coords( } def connect_and_transform(self): - """Transform data for all elements using the new simplified approach.""" + """Connect the network and transform all element data to model coordinates. + + This method performs the following steps: + + 1. Connects flows to buses (establishing the network topology) + 2. Registers any missing carriers from CONFIG defaults + 3. Assigns colors to elements without explicit colors + 4. Transforms all element data to xarray DataArrays aligned with + FlowSystem coordinates (time, period, scenario) + 5. Validates system integrity + + This is called automatically by :meth:`build_model` and :meth:`optimize`. + + Warning: + After this method runs, element attributes (e.g., ``flow.size``, + ``flow.relative_minimum``) contain transformed xarray DataArrays, + not the original input values. If you modify element attributes after + transformation, call :meth:`invalidate` to ensure the changes take + effect on the next optimization. + + Note: + This method is idempotent within a single model lifecycle - calling + it multiple times has no effect once ``connected_and_transformed`` + is True. Use :meth:`invalidate` to reset this flag. + """ if self.connected_and_transformed: logger.debug('FlowSystem already connected and transformed') return self._connect_network() + self._register_missing_carriers() + self._assign_element_colors() for element in chain(self.components.values(), self.effects.values(), self.buses.values()): element.transform_data() @@ -738,6 +1025,46 @@ def connect_and_transform(self): self._connected_and_transformed = True + def _register_missing_carriers(self) -> None: + """Auto-register carriers from CONFIG for buses that reference unregistered carriers.""" + for bus in self.buses.values(): + if not bus.carrier: + continue + carrier_key = bus.carrier.lower() + if carrier_key not in self._carriers: + # Try to get from CONFIG defaults (try original case first, then lowercase) + default_carrier = getattr(CONFIG.Carriers, bus.carrier, None) or getattr( + CONFIG.Carriers, carrier_key, None + ) + if default_carrier is not None: + self._carriers[carrier_key] = default_carrier + logger.debug(f"Auto-registered carrier '{carrier_key}' from CONFIG") + + def _assign_element_colors(self) -> None: + """Auto-assign colors to elements that don't have explicit colors set. + + Components and buses without explicit colors are assigned colors from the + default qualitative colorscale. This ensures zero-config color support + while still allowing users to override with explicit colors. + """ + from .color_processing import process_colors + + # Collect elements without colors (components only - buses use carrier colors) + # Use label_full for consistent keying with ElementContainer + elements_without_colors = [comp.label_full for comp in self.components.values() if comp.color is None] + + if not elements_without_colors: + return + + # Generate colors from the default colorscale + colorscale = CONFIG.Plotting.default_qualitative_colorscale + color_mapping = process_colors(colorscale, elements_without_colors) + + # Assign colors to elements + for label_full, color in color_mapping.items(): + self.components[label_full].color = color + logger.debug(f"Auto-assigned color '{color}' to component '{label_full}'") + def add_elements(self, *elements: Element) -> None: """ Add Components(Storages, Boilers, Heatpumps, ...), Buses or Effects to the FlowSystem @@ -745,13 +1072,25 @@ def add_elements(self, *elements: Element) -> None: Args: *elements: childs of Element like Boiler, HeatPump, Bus,... modeling Elements + + Raises: + RuntimeError: If the FlowSystem is locked (has a solution). + Call `reset()` to unlock it first. """ - if self.connected_and_transformed: + if self.is_locked: + raise RuntimeError( + 'Cannot add elements to a FlowSystem that has a solution. ' + 'Call `reset()` first to clear the solution and allow modifications.' + ) + + if self.model is not None: warnings.warn( - 'You are adding elements to an already connected FlowSystem. This is not recommended (But it works).', + 'Adding elements to a FlowSystem with an existing model. The model will be invalidated.', stacklevel=2, ) - self._connected_and_transformed = False + # Always invalidate when adding elements to ensure new elements get transformed + if self.model is not None or self._connected_and_transformed: + self._invalidate_model() for new_element in list(elements): # Validate element type first @@ -776,6 +1115,121 @@ def add_elements(self, *elements: Element) -> None: element_type = type(new_element).__name__ logger.info(f'Registered new {element_type}: {new_element.label_full}') + def add_carriers(self, *carriers: Carrier) -> None: + """Register a custom carrier for this FlowSystem. + + Custom carriers registered on the FlowSystem take precedence over + CONFIG.Carriers defaults when resolving colors and units for buses. + + Args: + carriers: Carrier objects defining the carrier properties. + + Raises: + RuntimeError: If the FlowSystem is locked (has a solution). + Call `reset()` to unlock it first. + + Examples: + ```python + import flixopt as fx + + fs = fx.FlowSystem(timesteps) + + # Define and register custom carriers + biogas = fx.Carrier('biogas', '#228B22', 'kW', 'Biogas fuel') + fs.add_carriers(biogas) + + # Now buses can reference this carrier by name + bus = fx.Bus('BioGasNetwork', carrier='biogas') + fs.add_elements(bus) + + # The carrier color will be used in plots automatically + ``` + """ + if self.is_locked: + raise RuntimeError( + 'Cannot add carriers to a FlowSystem that has a solution. ' + 'Call `reset()` first to clear the solution and allow modifications.' + ) + + if self.model is not None: + warnings.warn( + 'Adding carriers to a FlowSystem with an existing model. The model will be invalidated.', + stacklevel=2, + ) + # Always invalidate when adding carriers to ensure proper re-transformation + if self.model is not None or self._connected_and_transformed: + self._invalidate_model() + + for carrier in list(carriers): + if not isinstance(carrier, Carrier): + raise TypeError(f'Expected Carrier object, got {type(carrier)}') + self._carriers.add(carrier) + logger.debug(f'Adding carrier {carrier} to FlowSystem') + + def get_carrier(self, label: str) -> Carrier | None: + """Get the carrier for a bus or flow. + + Args: + label: Bus label (e.g., 'Fernwärme') or flow label (e.g., 'Boiler(Q_th)'). + + Returns: + Carrier or None if not found. + + Note: + To access a carrier directly by name, use ``flow_system.carriers['electricity']``. + + Raises: + RuntimeError: If FlowSystem is not connected_and_transformed. + """ + if not self.connected_and_transformed: + raise RuntimeError( + 'FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.' + ) + + # Try as bus label + bus = self.buses.get(label) + if bus and bus.carrier: + return self._carriers.get(bus.carrier.lower()) + + # Try as flow label + flow = self.flows.get(label) + if flow and flow.bus: + bus = self.buses.get(flow.bus) + if bus and bus.carrier: + return self._carriers.get(bus.carrier.lower()) + + return None + + @property + def carriers(self) -> CarrierContainer: + """Carriers registered on this FlowSystem.""" + return self._carriers + + @property + def flow_carriers(self) -> dict[str, str]: + """Cached mapping of flow labels to carrier names. + + Returns: + Dict mapping flow label to carrier name (lowercase). + Flows without a carrier are not included. + + Raises: + RuntimeError: If FlowSystem is not connected_and_transformed. + """ + if not self.connected_and_transformed: + raise RuntimeError( + 'FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.' + ) + + if self._flow_carriers is None: + self._flow_carriers = {} + for flow_label, flow in self.flows.items(): + bus = self.buses.get(flow.bus) + if bus and bus.carrier: + self._flow_carriers[flow_label] = bus.carrier.lower() + + return self._flow_carriers + def create_model(self, normalize_weights: bool = True) -> FlowSystemModel: """ Create a linopy model from the FlowSystem. @@ -791,124 +1245,411 @@ def create_model(self, normalize_weights: bool = True) -> FlowSystemModel: self.model = FlowSystemModel(self, normalize_weights) return self.model - 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: + def build_model(self, normalize_weights: bool = True) -> FlowSystem: """ - Visualizes the network structure of a FlowSystem using PyVis, saving it as an interactive HTML file. + Build the optimization model for this FlowSystem. + + This method prepares the FlowSystem for optimization by: + 1. Connecting and transforming all elements (if not already done) + 2. Creating the FlowSystemModel with all variables and constraints + 3. Adding clustering constraints (if this is a clustered FlowSystem) + + After calling this method, `self.model` will be available for inspection + before solving. 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. + normalize_weights: Whether to normalize scenario/period weights to sum to 1. Returns: - - 'pyvis.network.Network' | None: The `Network` instance representing the visualization, or `None` if `pyvis` is not installed. + Self, for method chaining. Examples: - >>> flow_system.plot_network() - >>> flow_system.plot_network(show=False) - >>> flow_system.plot_network(path='output/custom_network.html', controls=['nodes', 'layout']) + >>> flow_system.build_model() + >>> print(flow_system.model.variables) # Inspect variables before solving + >>> flow_system.solve(solver) + """ + self.connect_and_transform() + self.create_model(normalize_weights) + self.model.do_modeling() + + # Add clustering constraints if this is a clustered FlowSystem + if self._clustering_info is not None: + self._add_clustering_constraints() + + return self + + def _add_clustering_constraints(self) -> None: + """Add clustering constraints to the model.""" + from .clustering import ClusteringModel + + info = self._clustering_info or {} + required_keys = {'parameters', 'clustering', 'components_to_clusterize'} + missing_keys = required_keys - set(info) + if missing_keys: + raise KeyError(f'_clustering_info missing required keys: {sorted(missing_keys)}') + + clustering_model = ClusteringModel( + model=self.model, + clustering_parameters=info['parameters'], + flow_system=self, + clustering_data=info['clustering'], + components_to_clusterize=info['components_to_clusterize'], + ) + clustering_model.do_modeling() - 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. + def solve(self, solver: _Solver) -> FlowSystem: """ - from . import plotting + Solve the optimization model and populate the solution. - node_infos, edge_infos = self.network_infos() - return plotting.plot_network( - node_infos, edge_infos, path, controls, show if show is not None else CONFIG.Plotting.default_show - ) + This method solves the previously built model using the specified solver. + After solving, `self.solution` will contain the optimization results, + and each element's `.solution` property will provide access to its + specific variables. + + Args: + solver: The solver to use (e.g., HighsSolver, GurobiSolver). + + Returns: + Self, for method chaining. - def start_network_app(self): - """Visualizes the network structure of a FlowSystem using Dash, Cytoscape, and networkx. - Requires optional dependencies: dash, dash-cytoscape, dash-daq, networkx, flask, werkzeug. + Raises: + RuntimeError: If the model has not been built yet (call build_model first). + RuntimeError: If the model is infeasible. + + Examples: + >>> flow_system.build_model() + >>> flow_system.solve(HighsSolver()) + >>> print(flow_system.solution) """ - from .network_app import DASH_CYTOSCAPE_AVAILABLE, VISUALIZATION_ERROR, flow_graph, shownetwork + if self.model is None: + raise RuntimeError('Model has not been built. Call build_model() first.') - warnings.warn( - 'The network visualization is still experimental and might change in the future.', - stacklevel=2, - category=UserWarning, + self.model.solve( + solver_name=solver.name, + **solver.options, ) - if not DASH_CYTOSCAPE_AVAILABLE: - raise ImportError( - f'Network visualization requires optional dependencies. ' - f'Install with: `pip install flixopt[network_viz]`, `pip install flixopt[full]` ' - f'or: `pip install dash dash-cytoscape dash-daq networkx werkzeug`. ' - f'Original error: {VISUALIZATION_ERROR}' - ) + if self.model.termination_condition in ('infeasible', 'infeasible_or_unbounded'): + if CONFIG.Solving.compute_infeasibilities: + import io + from contextlib import redirect_stdout + + f = io.StringIO() + + # Redirect stdout to our buffer + with redirect_stdout(f): + self.model.print_infeasibilities() + + infeasibilities = f.getvalue() + logger.error('Successfully extracted infeasibilities: \n%s', infeasibilities) + raise RuntimeError(f'Model was infeasible. Status: {self.model.status}. Check your constraints and bounds.') + + # Store solution on FlowSystem for direct Element access + self.solution = self.model.solution + + logger.info(f'Optimization solved successfully. Objective: {self.model.objective.value:.4f}') + + return self + + @property + def solution(self) -> xr.Dataset | None: + """ + Access the optimization solution as an xarray Dataset. + + The solution is indexed by ``timesteps_extra`` (the original timesteps plus + one additional timestep at the end). Variables that do not have data for the + extra timestep (most variables except storage charge states) will contain + NaN values at the final timestep. + + Returns: + xr.Dataset: The solution dataset with all optimization variable results, + or None if the model hasn't been solved yet. - if not self._connected_and_transformed: - self._connect_network() + Example: + >>> flow_system.optimize(solver) + >>> flow_system.solution.isel(time=slice(None, -1)) # Exclude trailing NaN (and final charge states) + """ + return self._solution + + @solution.setter + def solution(self, value: xr.Dataset | None) -> None: + """Set the solution dataset and invalidate statistics cache.""" + self._solution = value + self._statistics = None # Invalidate cached statistics + + @property + def is_locked(self) -> bool: + """Check if the FlowSystem is locked (has a solution). + + A locked FlowSystem cannot be modified. Use `reset()` to unlock it. + """ + return self._solution is not None + + def _invalidate_model(self) -> None: + """Invalidate the model and element submodels when structure changes. + + This clears the model, resets the ``connected_and_transformed`` flag, + clears all element submodels and variable/constraint names, and invalidates + the topology accessor cache. + + Called internally by :meth:`add_elements`, :meth:`add_carriers`, + :meth:`reset`, and :meth:`invalidate`. + + See Also: + :meth:`invalidate`: Public method for manual invalidation. + :meth:`reset`: Clears solution and invalidates (for locked FlowSystems). + """ + self.model = None + self._connected_and_transformed = False + self._topology = None # Invalidate topology accessor (and its cached colors) + self._flow_carriers = None # Invalidate flow-to-carrier mapping + for element in self.values(): + element.submodel = None + element._variable_names = [] + element._constraint_names = [] + + def reset(self) -> FlowSystem: + """Clear optimization state to allow modifications. - if self._network_app is not None: - logger.warning('The network app is already running. Restarting it.') - self.stop_network_app() + This method unlocks the FlowSystem by clearing: + - The solution dataset + - The optimization model + - All element submodels and variable/constraint names + - The connected_and_transformed flag - self._network_app = shownetwork(flow_graph(self)) + After calling reset(), the FlowSystem can be modified again + (e.g., adding elements or carriers). + + Returns: + Self, for method chaining. + + Examples: + >>> flow_system.optimize(solver) # FlowSystem is now locked + >>> flow_system.add_elements(new_bus) # Raises RuntimeError + >>> flow_system.reset() # Unlock the FlowSystem + >>> flow_system.add_elements(new_bus) # Now works + """ + self.solution = None # Also clears _statistics via setter + self._invalidate_model() + return self + + def invalidate(self) -> FlowSystem: + """Invalidate the model to allow re-transformation after modifying elements. + + Call this after modifying existing element attributes (e.g., ``flow.size``, + ``flow.relative_minimum``) to ensure changes take effect on the next + optimization. The next call to :meth:`optimize` or :meth:`build_model` + will re-run :meth:`connect_and_transform`. + + Note: + Adding new elements via :meth:`add_elements` automatically invalidates + the model. This method is only needed when modifying attributes of + elements that are already part of the FlowSystem. + + Returns: + Self, for method chaining. + + Raises: + RuntimeError: If the FlowSystem has a solution. Call :meth:`reset` + first to clear the solution. + + Examples: + Modify a flow's size and re-optimize: - def stop_network_app(self): - """Stop the network visualization server.""" - from .network_app import DASH_CYTOSCAPE_AVAILABLE, VISUALIZATION_ERROR + >>> flow_system.optimize(solver) + >>> flow_system.reset() # Clear solution first + >>> flow_system.components['Boiler'].inputs[0].size = 200 + >>> flow_system.invalidate() + >>> flow_system.optimize(solver) # Re-runs connect_and_transform - if not DASH_CYTOSCAPE_AVAILABLE: - raise ImportError( - f'Network visualization requires optional dependencies. ' - f'Install with: `pip install flixopt[network_viz]`, `pip install flixopt[full]` ' - f'or: `pip install dash dash-cytoscape dash-daq networkx werkzeug`. ' - f'Original error: {VISUALIZATION_ERROR}' + Modify before first optimization: + + >>> flow_system.connect_and_transform() + >>> # Oops, need to change something + >>> flow_system.components['Boiler'].inputs[0].size = 200 + >>> flow_system.invalidate() + >>> flow_system.optimize(solver) # Changes take effect + """ + if self.is_locked: + raise RuntimeError( + 'Cannot invalidate a FlowSystem with a solution. Call `reset()` first to clear the solution.' ) + self._invalidate_model() + return self - if self._network_app is None: - logger.warning("No network app is currently running. Can't stop it") - return + @property + def optimize(self) -> OptimizeAccessor: + """ + Access optimization methods for this FlowSystem. - try: - logger.info('Stopping network visualization server...') - self._network_app.server_instance.shutdown() - logger.info('Network visualization stopped.') - except Exception as e: - logger.error(f'Failed to stop the network visualization app: {e}') - finally: - self._network_app = None + This property returns an OptimizeAccessor that can be called directly + for standard optimization, or used to access specialized optimization modes. - def network_infos(self) -> tuple[dict[str, dict[str, str]], dict[str, dict[str, str]]]: - if not self.connected_and_transformed: - self.connect_and_transform() - nodes = { - node.label_full: { - 'label': node.label, - 'class': 'Bus' if isinstance(node, Bus) else 'Component', - 'infos': node.__str__(), - } - for node in chain(self.components.values(), self.buses.values()) - } + Returns: + An OptimizeAccessor instance. - edges = { - flow.label_full: { - 'label': flow.label, - 'start': flow.bus if flow.is_input_in_component else flow.component, - 'end': flow.component if flow.is_input_in_component else flow.bus, - 'infos': flow.__str__(), - } - for flow in self.flows.values() - } + Examples: + Standard optimization (call directly): + + >>> flow_system.optimize(HighsSolver()) + >>> print(flow_system.solution['Boiler(Q_th)|flow_rate']) - return nodes, edges + Access element solutions directly: + + >>> flow_system.optimize(solver) + >>> boiler = flow_system.components['Boiler'] + >>> print(boiler.solution) + + Future specialized modes: + + >>> flow_system.optimize.clustered(solver, aggregation=params) + >>> flow_system.optimize.mga(solver, alternatives=5) + """ + return OptimizeAccessor(self) + + @property + def transform(self) -> TransformAccessor: + """ + Access transformation methods for this FlowSystem. + + This property returns a TransformAccessor that provides methods to create + transformed versions of this FlowSystem (e.g., clustered for time aggregation). + + Returns: + A TransformAccessor instance. + + Examples: + Clustered optimization: + + >>> params = ClusteringParameters(hours_per_period=24, nr_of_periods=8) + >>> clustered_fs = flow_system.transform.cluster(params) + >>> clustered_fs.optimize(solver) + >>> print(clustered_fs.solution) + """ + return TransformAccessor(self) + + @property + def statistics(self) -> StatisticsAccessor: + """ + Access statistics and plotting methods for optimization results. + + This property returns a StatisticsAccessor that provides methods to analyze + and visualize optimization results stored in this FlowSystem's solution. + + Note: + The FlowSystem must have a solution (from optimize() or solve()) before + most statistics methods can be used. + + Returns: + A cached StatisticsAccessor instance. + + Examples: + After optimization: + + >>> flow_system.optimize(solver) + >>> flow_system.statistics.plot.balance('ElectricityBus') + >>> flow_system.statistics.plot.heatmap('Boiler|on') + >>> ds = flow_system.statistics.flow_rates # Get data for analysis + """ + if self._statistics is None: + self._statistics = StatisticsAccessor(self) + return self._statistics + + @property + def topology(self) -> TopologyAccessor: + """ + Access network topology inspection and visualization methods. + + This property returns a cached TopologyAccessor that provides methods to inspect + the network structure and visualize it. The accessor is invalidated when the + FlowSystem structure changes (via reset() or invalidate()). + + Returns: + A cached TopologyAccessor instance. + + Examples: + Visualize the network: + + >>> flow_system.topology.plot() + >>> flow_system.topology.plot(path='my_network.html', show=True) + + Interactive visualization: + + >>> flow_system.topology.start_app() + >>> # ... interact with the visualization ... + >>> flow_system.topology.stop_app() + + Get network structure info: + + >>> nodes, edges = flow_system.topology.infos() + """ + if self._topology is None: + 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: """ @@ -964,24 +1705,26 @@ def _validate_system_integrity(self) -> None: def _add_effects(self, *args: Effect) -> None: for effect in args: - effect._set_flow_system(self) # Link element to FlowSystem + effect.link_to_flow_system(self) # Link element to FlowSystem self.effects.add_effects(*args) def _add_components(self, *components: Component) -> None: for new_component in list(components): - new_component._set_flow_system(self) # Link element to FlowSystem + new_component.link_to_flow_system(self) # Link element to FlowSystem self.components.add(new_component) # Add to existing components # Invalidate cache once after all additions if components: self._flows_cache = None + self._storages_cache = None def _add_buses(self, *buses: Bus): for new_bus in list(buses): - new_bus._set_flow_system(self) # Link element to FlowSystem + new_bus.link_to_flow_system(self) # Link element to FlowSystem self.buses.add(new_bus) # Add to existing buses # Invalidate cache once after all additions if buses: self._flows_cache = None + self._storages_cache = None def _connect_network(self): """Connects the network of components and buses. Can be rerun without changes if no elements were added""" @@ -990,18 +1733,6 @@ def _connect_network(self): flow.component = component.label_full flow.is_input_in_component = True if flow in component.inputs else False - # Add Bus if not already added (deprecated) - if flow._bus_object is not None and flow._bus_object.label_full not in self.buses: - warnings.warn( - f'The Bus {flow._bus_object.label_full} was added to the FlowSystem from {flow.label_full}.' - f'This is deprecated and will be removed in the future. ' - f'Please pass the Bus.label to the Flow and the Bus to the FlowSystem instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=1, - ) - self._add_buses(flow._bus_object) - # Connect Buses bus = self.buses.get(flow.bus) if bus is None: @@ -1094,27 +1825,18 @@ def flows(self) -> ElementContainer[Flow]: return self._flows_cache @property - def all_elements(self) -> dict[str, Element]: - """ - Get all elements as a dictionary. - - .. deprecated:: 3.2.0 - Use dict-like interface instead: `flow_system['element']`, `'element' in flow_system`, - `flow_system.keys()`, `flow_system.values()`, or `flow_system.items()`. - This property will be removed in v4.0.0. + def storages(self) -> ElementContainer[Storage]: + """All storage components as an ElementContainer. Returns: - Dictionary mapping element labels to element objects. + ElementContainer containing all Storage components in the FlowSystem, + sorted by label for reproducibility. """ - warnings.warn( - "The 'all_elements' property is deprecated. Use dict-like interface instead: " - "flow_system['element'], 'element' in flow_system, flow_system.keys(), " - 'flow_system.values(), or flow_system.items(). ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return {**self.components, **self.effects, **self.flows, **self.buses} + if self._storages_cache is None: + storages = [c for c in self.components.values() if isinstance(c, Storage)] + storages = sorted(storages, key=lambda s: s.label_full.lower()) + self._storages_cache = ElementContainer(storages, element_type_name='storages', truncate_repr=10) + return self._storages_cache @property def coords(self) -> dict[FlowSystemDimensions, pd.Index]: @@ -1163,32 +1885,6 @@ def scenario_weights(self, value: Numeric_S | None) -> None: self._scenario_weights = self.fit_to_model_coords('scenario_weights', value, dims=['scenario']) - @property - def weights(self) -> Numeric_S | None: - warnings.warn( - f'FlowSystem.weights is deprecated. Use FlowSystem.scenario_weights instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.scenario_weights - - @weights.setter - def weights(self, value: Numeric_S) -> None: - """ - Set weights (deprecated - sets scenario_weights). - - Args: - value: Scenario weights to set - """ - warnings.warn( - f'Setting FlowSystem.weights is deprecated. Set FlowSystem.scenario_weights instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.scenario_weights = value # Use the scenario_weights setter - def _validate_scenario_parameter(self, value: bool | list[str], param_name: str, element_type: str) -> None: """ Validate scenario parameter value. @@ -1298,29 +1994,22 @@ def _dataset_sel( Returns: xr.Dataset: Selected dataset """ - indexers = {} - if time is not None: - indexers['time'] = time - if period is not None: - indexers['period'] = period - if scenario is not None: - indexers['scenario'] = scenario - - if not indexers: - return dataset - - result = dataset.sel(**indexers) - - # Update time-related attributes if time was selected - if 'time' in indexers: - result = cls._update_time_metadata(result, hours_of_last_timestep, hours_of_previous_timesteps) - - # Update period-related attributes if period was selected - # This recalculates period_weights and weights from the new period index - if 'period' in indexers: - result = cls._update_period_metadata(result) + 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 result + 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, @@ -1331,8 +2020,8 @@ def sel( """ Select a subset of the flowsystem by label. - For power users: Use FlowSystem._dataset_sel() to chain operations on datasets - without conversion overhead. See _dataset_sel() documentation. + .. 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') @@ -1340,17 +2029,15 @@ def sel( scenario: Scenario selection (e.g., 'scenario1', or list of scenarios) Returns: - FlowSystem: New FlowSystem with selected data + FlowSystem: New FlowSystem with selected data (no solution). """ - if time is None and period is None and scenario is None: - return self.copy() - - if not self.connected_and_transformed: - self.connect_and_transform() - - ds = self.to_dataset() - ds = self._dataset_sel(ds, time=time, period=period, scenario=scenario) - return self.__class__.from_dataset(ds) + 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( @@ -1379,29 +2066,22 @@ def _dataset_isel( Returns: xr.Dataset: Selected dataset """ - indexers = {} - if time is not None: - indexers['time'] = time - if period is not None: - indexers['period'] = period - if scenario is not None: - indexers['scenario'] = scenario - - if not indexers: - return dataset - - result = dataset.isel(**indexers) - - # Update time-related attributes if time was selected - if 'time' in indexers: - result = cls._update_time_metadata(result, hours_of_last_timestep, hours_of_previous_timesteps) - - # Update period-related attributes if period was selected - # This recalculates period_weights and weights from the new period index - if 'period' in indexers: - result = cls._update_period_metadata(result) + 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 result + 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, @@ -1412,109 +2092,24 @@ def isel( """ Select a subset of the flowsystem by integer indices. - For power users: Use FlowSystem._dataset_isel() to chain operations on datasets - without conversion overhead. See _dataset_sel() documentation. + .. 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 (e.g., slice(0, 100), 50, or [0, 5, 10]) - scenario: Scenario selection by integer index (e.g., slice(0, 3), 50, or [0, 5, 10]) + period: Period selection by integer index + scenario: Scenario selection by integer index Returns: - FlowSystem: New FlowSystem with selected data - """ - if time is None and period is None and scenario is None: - return self.copy() - - if not self.connected_and_transformed: - self.connect_and_transform() - - ds = self.to_dataset() - ds = self._dataset_isel(ds, time=time, period=period, scenario=scenario) - return self.__class__.from_dataset(ds) - - @classmethod - def _resample_by_dimension_groups( - cls, - time_dataset: xr.Dataset, - time: str, - method: str, - **kwargs: Any, - ) -> xr.Dataset: + FlowSystem: New FlowSystem with selected data (no solution). """ - Resample variables grouped by their dimension structure to avoid broadcasting. - - This method groups variables by their non-time dimensions before resampling, - which provides two key benefits: - - 1. **Performance**: Resampling many variables with the same dimensions together - is significantly faster than resampling each variable individually. - - 2. **Safety**: Prevents xarray from broadcasting variables with different - dimensions into a larger dimensional space filled with NaNs, which would - cause memory bloat and computational inefficiency. - - Example: - Without grouping (problematic): - var1: (time, location, tech) shape (8000, 10, 2) - var2: (time, region) shape (8000, 5) - concat → (variable, time, location, tech, region) ← Unwanted broadcasting! - - With grouping (safe and fast): - Group 1: [var1, var3, ...] with dims (time, location, tech) - Group 2: [var2, var4, ...] with dims (time, region) - Each group resampled separately → No broadcasting, optimal performance! - - Args: - time_dataset: Dataset containing only variables with time dimension - time: Resampling frequency (e.g., '2h', '1D', '1M') - method: Resampling method name (e.g., 'mean', 'sum', 'first') - **kwargs: Additional arguments passed to xarray.resample() - - Returns: - Resampled dataset with original dimension structure preserved - """ - # Group variables by dimensions (excluding time) - dim_groups = defaultdict(list) - for var_name, var in time_dataset.data_vars.items(): - dims_key = tuple(sorted(d for d in var.dims if d != 'time')) - dim_groups[dims_key].append(var_name) - - # Handle empty case: no time-dependent variables - if not dim_groups: - return getattr(time_dataset.resample(time=time, **kwargs), method)() - - # Resample each group separately using DataArray concat (faster) - resampled_groups = [] - for var_names in dim_groups.values(): - # Skip empty groups - if not var_names: - continue - - # Concat variables into a single DataArray with 'variable' dimension - # Use combine_attrs='drop_conflicts' to handle attribute conflicts - stacked = xr.concat( - [time_dataset[name] for name in var_names], - dim=pd.Index(var_names, name='variable'), - combine_attrs='drop_conflicts', - ) - - # Resample the DataArray (faster than resampling Dataset) - resampled = getattr(stacked.resample(time=time, **kwargs), method)() - - # Convert back to Dataset using the 'variable' dimension - resampled_dataset = resampled.to_dataset(dim='variable') - resampled_groups.append(resampled_dataset) - - # Merge all resampled groups, handling empty list case - if not resampled_groups: - return time_dataset # Return empty dataset as-is - - if len(resampled_groups) == 1: - return resampled_groups[0] - - # Merge multiple groups with combine_attrs to avoid conflicts - return xr.merge(resampled_groups, combine_attrs='drop_conflicts') + 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( @@ -1545,36 +2140,47 @@ def _dataset_resample( Returns: xr.Dataset: Resampled dataset """ - # Validate method - available_methods = ['mean', 'sum', 'max', 'min', 'first', 'last', 'std', 'var', 'median', 'count'] - if method not in available_methods: - raise ValueError(f'Unsupported resampling method: {method}. Available: {available_methods}') - - # Preserve original dataset attributes (especially the reference structure) - original_attrs = dict(dataset.attrs) - - # Separate time and non-time variables - time_var_names = [v for v in dataset.data_vars if 'time' in dataset[v].dims] - non_time_var_names = [v for v in dataset.data_vars if v not in time_var_names] - - # Only resample variables that have time dimension - time_dataset = dataset[time_var_names] + 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 - # Resample with dimension grouping to avoid broadcasting - resampled_time_dataset = cls._resample_by_dimension_groups(time_dataset, freq, method, **kwargs) + 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, + ) - # Combine resampled time variables with non-time variables - if non_time_var_names: - non_time_dataset = dataset[non_time_var_names] - result = xr.merge([resampled_time_dataset, non_time_dataset]) - else: - result = resampled_time_dataset + @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. - # Restore original attributes (xr.merge can drop them) - result.attrs.update(original_attrs) + .. 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 - # Update time-related attributes based on new time index - return cls._update_time_metadata(result, hours_of_last_timestep, hours_of_previous_timesteps) + return TransformAccessor._resample_by_dimension_groups(time_dataset, time, method, **kwargs) def resample( self, @@ -1585,36 +2191,34 @@ def resample( **kwargs: Any, ) -> FlowSystem: """ - Create a resampled FlowSystem by resampling data along the time dimension (like xr.Dataset.resample()). - Only resamples data variables that have a time dimension. + Create a resampled FlowSystem by resampling data along the time dimension. - For power users: Use FlowSystem._dataset_resample() to chain operations on datasets - without conversion overhead. See _dataset_sel() documentation. + .. 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. 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. + 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 + FlowSystem: New resampled FlowSystem (no solution). """ - if not self.connected_and_transformed: - self.connect_and_transform() - - ds = self.to_dataset() - ds = self._dataset_resample( - ds, - freq=time, + 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, ) - return self.__class__.from_dataset(ds) @property def connected_and_transformed(self) -> bool: diff --git a/flixopt/interface.py b/flixopt/interface.py index b27c076a5..13a9255da 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -1,19 +1,20 @@ """ -This module contains classes to collect Parameters for the Investment and OnOff decisions. +This module contains classes to collect Parameters for the Investment and Status decisions. These are tightly connected to features.py """ from __future__ import annotations import logging -import warnings -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import numpy as np import pandas as pd +import plotly.express as px import xarray as xr -from .config import CONFIG, DEPRECATION_REMOVAL_VERSION +from .config import CONFIG +from .plot_result import PlotResult from .structure import Interface, register_class_for_io if TYPE_CHECKING: # for type checking and preventing circular imports @@ -75,16 +76,22 @@ def __init__(self, start: Numeric_TPS, end: Numeric_TPS): self.end = end self.has_time_dim = False - def transform_data(self, name_prefix: str = '') -> None: + def transform_data(self) -> None: dims = None if self.has_time_dim else ['period', 'scenario'] - self.start = self._fit_coords(f'{name_prefix}|start', self.start, dims=dims) - self.end = self._fit_coords(f'{name_prefix}|end', self.end, dims=dims) + self.start = self._fit_coords(f'{self.prefix}|start', self.start, dims=dims) + self.end = self._fit_coords(f'{self.prefix}|end', self.end, dims=dims) @register_class_for_io class Piecewise(Interface): - """ - Define a Piecewise, consisting of a list of Pieces. + """Define piecewise linear approximations for modeling non-linear relationships. + + Enables modeling of non-linear relationships through piecewise linear segments + while maintaining problem linearity. Consists of a collection of Pieces that + define valid ranges for variables. + + Mathematical Formulation: + See Args: pieces: list of Piece objects defining the linear segments. The arrangement @@ -221,15 +228,15 @@ def __getitem__(self, index) -> Piece: def __iter__(self) -> Iterator[Piece]: return iter(self.pieces) # Enables iteration like for piece in piecewise: ... - def _set_flow_system(self, flow_system) -> None: + def link_to_flow_system(self, flow_system, prefix: str = '') -> None: """Propagate flow_system reference to nested Piece objects.""" - super()._set_flow_system(flow_system) - for piece in self.pieces: - piece._set_flow_system(flow_system) - - def transform_data(self, name_prefix: str = '') -> None: + super().link_to_flow_system(flow_system, prefix) for i, piece in enumerate(self.pieces): - piece.transform_data(f'{name_prefix}|Piece{i}') + piece.link_to_flow_system(flow_system, self._sub_prefix(f'Piece{i}')) + + def transform_data(self) -> None: + for piece in self.pieces: + piece.transform_data() @register_class_for_io @@ -414,7 +421,7 @@ class PiecewiseConversion(Interface): operate in certain ranges (e.g., minimum loads, unstable regions). **Discrete Modes**: Use pieces with identical start/end values to model - equipment with fixed operating points (e.g., on/off, discrete speeds). + equipment with fixed operating points (e.g., on/inactive, discrete speeds). **Efficiency Changes**: Coordinate input and output pieces to reflect changing conversion efficiency across operating ranges. @@ -453,15 +460,151 @@ def items(self): """ return self.piecewises.items() - def _set_flow_system(self, flow_system) -> None: + def link_to_flow_system(self, flow_system, prefix: str = '') -> None: """Propagate flow_system reference to nested Piecewise objects.""" - super()._set_flow_system(flow_system) + super().link_to_flow_system(flow_system, prefix) + for name, piecewise in self.piecewises.items(): + piecewise.link_to_flow_system(flow_system, self._sub_prefix(name)) + + def transform_data(self) -> None: for piecewise in self.piecewises.values(): - piecewise._set_flow_system(flow_system) + piecewise.transform_data() - def transform_data(self, name_prefix: str = '') -> None: - for name, piecewise in self.piecewises.items(): - piecewise.transform_data(f'{name_prefix}|{name}') + def plot( + self, + x_flow: str | None = None, + title: str = '', + select: dict[str, Any] | None = None, + colorscale: str | None = None, + show: bool | None = None, + ) -> PlotResult: + """Plot multi-flow piecewise conversion with time variation visualization. + + Visualizes the piecewise linear relationships between flows. Each flow + is shown in a separate subplot (faceted by flow). Pieces are distinguished + by line dash style. If boundaries vary over time, color shows time progression. + + Note: + Requires FlowSystem to be connected and transformed (call + flow_system.connect_and_transform() first). + + Args: + x_flow: Flow label to use for X-axis. Defaults to first flow in dict. + title: Plot title. + select: xarray-style selection dict to filter data, + e.g. {'time': slice('2024-01-01', '2024-01-02')}. + colorscale: Colorscale name for time coloring (e.g., 'RdYlBu_r', 'viridis'). + Defaults to CONFIG.Plotting.default_sequential_colorscale. + show: Whether to display the figure. + Defaults to CONFIG.Plotting.default_show. + + Returns: + PlotResult containing the figure and underlying piecewise data. + + Examples: + >>> flow_system.connect_and_transform() + >>> chp.piecewise_conversion.plot(x_flow='Gas', title='CHP Curves') + >>> # Select specific time range + >>> chp.piecewise_conversion.plot(select={'time': slice(0, 12)}) + """ + if not self.flow_system.connected_and_transformed: + logger.debug('Connecting flow_system for plotting PiecewiseConversion') + self.flow_system.connect_and_transform() + + colorscale = colorscale or CONFIG.Plotting.default_sequential_colorscale + + flow_labels = list(self.piecewises.keys()) + x_label = x_flow if x_flow is not None else flow_labels[0] + if x_label not in flow_labels: + raise ValueError(f"x_flow '{x_label}' not found. Available: {flow_labels}") + + y_flows = [label for label in flow_labels if label != x_label] + if not y_flows: + raise ValueError('Need at least two flows to plot') + + x_piecewise = self.piecewises[x_label] + + # Build Dataset with all piece data + datasets = [] + for y_label in y_flows: + y_piecewise = self.piecewises[y_label] + for i, (x_piece, y_piece) in enumerate(zip(x_piecewise, y_piecewise, strict=False)): + ds = xr.Dataset( + { + x_label: xr.concat([x_piece.start, x_piece.end], dim='point'), + 'output': xr.concat([y_piece.start, y_piece.end], dim='point'), + } + ) + ds = ds.assign_coords(point=['start', 'end']) + ds['flow'] = y_label + ds['piece'] = f'Piece {i}' + datasets.append(ds) + + combined = xr.concat(datasets, dim='trace') + + # Apply selection if provided + if select: + valid_select = {k: v for k, v in select.items() if k in combined.dims or k in combined.coords} + if valid_select: + combined = combined.sel(valid_select) + + df = combined.to_dataframe().reset_index() + + # Check if values vary over time + has_time = 'time' in df.columns + varies_over_time = False + if has_time: + varies_over_time = df.groupby(['trace', 'point'])[[x_label, 'output']].nunique().max().max() > 1 + + if varies_over_time: + # Time-varying: color by time, dash by piece + df['time_idx'] = df.groupby('time').ngroup() + df['line_id'] = df['trace'].astype(str) + '_' + df['time_idx'].astype(str) + n_times = df['time_idx'].nunique() + colors = px.colors.sample_colorscale(colorscale, n_times) + + fig = px.line( + df, + x=x_label, + y='output', + color='time_idx', + line_dash='piece', + line_group='line_id', + facet_col='flow' if len(y_flows) > 1 else None, + title=title or 'Piecewise Conversion', + markers=True, + color_discrete_sequence=colors, + ) + else: + # Static: dash by piece + if has_time: + df = df.groupby(['trace', 'point', 'flow', 'piece']).first().reset_index() + df['line_id'] = df['trace'].astype(str) + + fig = px.line( + df, + x=x_label, + y='output', + line_dash='piece', + line_group='line_id', + facet_col='flow' if len(y_flows) > 1 else None, + title=title or 'Piecewise Conversion', + markers=True, + ) + + # Clean up facet titles and axis labels + fig.for_each_annotation(lambda a: a.update(text=a.text.replace('flow=', ''))) + fig.update_yaxes(title_text='') + fig.update_xaxes(title_text=x_label) + + result = PlotResult(data=combined, figure=fig) + + if show is None: + show = CONFIG.Plotting.default_show + if show: + result.show() + + return result @register_class_for_io @@ -671,17 +814,142 @@ def has_time_dim(self, value): for piecewise in self.piecewise_shares.values(): piecewise.has_time_dim = value - def _set_flow_system(self, flow_system) -> None: + def link_to_flow_system(self, flow_system, prefix: str = '') -> None: """Propagate flow_system reference to nested Piecewise objects.""" - super()._set_flow_system(flow_system) - self.piecewise_origin._set_flow_system(flow_system) + super().link_to_flow_system(flow_system, prefix) + self.piecewise_origin.link_to_flow_system(flow_system, self._sub_prefix('origin')) + for effect, piecewise in self.piecewise_shares.items(): + piecewise.link_to_flow_system(flow_system, self._sub_prefix(effect)) + + def transform_data(self) -> None: + self.piecewise_origin.transform_data() for piecewise in self.piecewise_shares.values(): - piecewise._set_flow_system(flow_system) + piecewise.transform_data() - def transform_data(self, name_prefix: str = '') -> None: - self.piecewise_origin.transform_data(f'{name_prefix}|PiecewiseEffects|origin') - for effect, piecewise in self.piecewise_shares.items(): - piecewise.transform_data(f'{name_prefix}|PiecewiseEffects|{effect}') + def plot( + self, + title: str = '', + select: dict[str, Any] | None = None, + colorscale: str | None = None, + show: bool | None = None, + ) -> PlotResult: + """Plot origin vs effect shares with time variation visualization. + + Visualizes the piecewise linear relationships between the origin variable + and its effect shares. Each effect is shown in a separate subplot (faceted + by effect). Pieces are distinguished by line dash style. + + Note: + Requires FlowSystem to be connected and transformed (call + flow_system.connect_and_transform() first). + + Args: + title: Plot title. + select: xarray-style selection dict to filter data, + e.g. {'time': slice('2024-01-01', '2024-01-02')}. + colorscale: Colorscale name for time coloring (e.g., 'RdYlBu_r', 'viridis'). + Defaults to CONFIG.Plotting.default_sequential_colorscale. + show: Whether to display the figure. + Defaults to CONFIG.Plotting.default_show. + + Returns: + PlotResult containing the figure and underlying piecewise data. + + Examples: + >>> flow_system.connect_and_transform() + >>> invest_params.piecewise_effects_of_investment.plot(title='Investment Effects') + """ + if not self.flow_system.connected_and_transformed: + logger.debug('Connecting flow_system for plotting PiecewiseEffects') + self.flow_system.connect_and_transform() + + colorscale = colorscale or CONFIG.Plotting.default_sequential_colorscale + + effect_labels = list(self.piecewise_shares.keys()) + if not effect_labels: + raise ValueError('Need at least one effect share to plot') + + # Build Dataset with all piece data + datasets = [] + for effect_label in effect_labels: + y_piecewise = self.piecewise_shares[effect_label] + for i, (x_piece, y_piece) in enumerate(zip(self.piecewise_origin, y_piecewise, strict=False)): + ds = xr.Dataset( + { + 'origin': xr.concat([x_piece.start, x_piece.end], dim='point'), + 'share': xr.concat([y_piece.start, y_piece.end], dim='point'), + } + ) + ds = ds.assign_coords(point=['start', 'end']) + ds['effect'] = effect_label + ds['piece'] = f'Piece {i}' + datasets.append(ds) + + combined = xr.concat(datasets, dim='trace') + + # Apply selection if provided + if select: + valid_select = {k: v for k, v in select.items() if k in combined.dims or k in combined.coords} + if valid_select: + combined = combined.sel(valid_select) + + df = combined.to_dataframe().reset_index() + + # Check if values vary over time + has_time = 'time' in df.columns + varies_over_time = False + if has_time: + varies_over_time = df.groupby(['trace', 'point'])[['origin', 'share']].nunique().max().max() > 1 + + if varies_over_time: + # Time-varying: color by time, dash by piece + df['time_idx'] = df.groupby('time').ngroup() + df['line_id'] = df['trace'].astype(str) + '_' + df['time_idx'].astype(str) + n_times = df['time_idx'].nunique() + colors = px.colors.sample_colorscale(colorscale, n_times) + + fig = px.line( + df, + x='origin', + y='share', + color='time_idx', + line_dash='piece', + line_group='line_id', + facet_col='effect' if len(effect_labels) > 1 else None, + title=title or 'Piecewise Effects', + markers=True, + color_discrete_sequence=colors, + ) + else: + # Static: dash by piece + if has_time: + df = df.groupby(['trace', 'point', 'effect', 'piece']).first().reset_index() + df['line_id'] = df['trace'].astype(str) + + fig = px.line( + df, + x='origin', + y='share', + line_dash='piece', + line_group='line_id', + facet_col='effect' if len(effect_labels) > 1 else None, + title=title or 'Piecewise Effects', + markers=True, + ) + + # Clean up facet titles and axis labels + fig.for_each_annotation(lambda a: a.update(text=a.text.replace('effect=', ''))) + fig.update_yaxes(title_text='') + fig.update_xaxes(title_text='Origin') + + result = PlotResult(data=combined, figure=fig) + + if show is None: + show = CONFIG.Plotting.default_show + if show: + result.show() + + return result @register_class_for_io @@ -707,14 +975,13 @@ class InvestParameters(Interface): - **Divestment Effects**: Penalties for not investing (demolition, opportunity costs) Mathematical Formulation: - See the complete mathematical model in the documentation: - [InvestParameters](../user-guide/mathematical-notation/features/InvestParameters.md) + See Args: fixed_size: Creates binary decision at this exact size. None allows continuous sizing. minimum_size: Lower bound for continuous sizing. Default: CONFIG.Modeling.epsilon. Ignored if fixed_size is specified. - maximum_size: Upper bound for continuous sizing. Default: CONFIG.Modeling.big. + maximum_size: Upper bound for continuous sizing. Required if fixed_size is not set. Ignored if fixed_size is specified. mandatory: Controls whether investment is required. When True, forces investment to occur (useful for mandatory upgrades or replacement decisions). @@ -731,18 +998,6 @@ class InvestParameters(Interface): linked_periods: Describes which periods are linked. 1 means linked, 0 means size=0. None means no linked periods. For convenience, pass a tuple containing the first and last period (2025, 2039), linking them and those in between - Deprecated Args: - fix_effects: **Deprecated**. Use `effects_of_investment` instead. - Will be removed in version 5.0.0. - specific_effects: **Deprecated**. Use `effects_of_investment_per_size` instead. - Will be removed in version 5.0.0. - divest_effects: **Deprecated**. Use `effects_of_retirement` instead. - Will be removed in version 5.0.0. - piecewise_effects: **Deprecated**. Use `piecewise_effects_of_investment` instead. - Will be removed in version 5.0.0. - optional: DEPRECATED. Use `mandatory` instead. Opposite of `mandatory`. - Will be removed in version 5.0.0. - Cost Annualization Requirements: All cost values must be properly weighted to match the optimization model's time horizon. For long-term investments, the cost values should be annualized to the corresponding operation time (annuity). @@ -899,36 +1154,7 @@ def __init__( effects_of_retirement: Effect_PS | Numeric_PS | None = None, piecewise_effects_of_investment: PiecewiseEffects | None = None, linked_periods: Numeric_PS | tuple[int, int] | None = None, - **kwargs, ): - # Handle deprecated parameters using centralized helper - effects_of_investment = self._handle_deprecated_kwarg( - kwargs, 'fix_effects', 'effects_of_investment', effects_of_investment - ) - effects_of_investment_per_size = self._handle_deprecated_kwarg( - kwargs, 'specific_effects', 'effects_of_investment_per_size', effects_of_investment_per_size - ) - effects_of_retirement = self._handle_deprecated_kwarg( - kwargs, 'divest_effects', 'effects_of_retirement', effects_of_retirement - ) - piecewise_effects_of_investment = self._handle_deprecated_kwarg( - kwargs, 'piecewise_effects', 'piecewise_effects_of_investment', piecewise_effects_of_investment - ) - # For mandatory parameter with non-None default, disable conflict checking - if 'optional' in kwargs: - warnings.warn( - 'Deprecated parameter "optional" used. Check conflicts with new parameter "mandatory" manually! ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - mandatory = self._handle_deprecated_kwarg( - kwargs, 'optional', 'mandatory', mandatory, transform=lambda x: not x, check_conflict=False - ) - - # Validate any remaining unexpected kwargs - self._validate_kwargs(kwargs) - self.effects_of_investment = effects_of_investment if effects_of_investment is not None else {} self.effects_of_retirement = effects_of_retirement if effects_of_retirement is not None else {} self.fixed_size = fixed_size @@ -938,30 +1164,36 @@ def __init__( ) self.piecewise_effects_of_investment = piecewise_effects_of_investment self.minimum_size = minimum_size if minimum_size is not None else CONFIG.Modeling.epsilon - self.maximum_size = maximum_size if maximum_size is not None else CONFIG.Modeling.big # default maximum + self.maximum_size = maximum_size self.linked_periods = linked_periods - def _set_flow_system(self, flow_system) -> None: + def link_to_flow_system(self, flow_system, prefix: str = '') -> None: """Propagate flow_system reference to nested PiecewiseEffects object if present.""" - super()._set_flow_system(flow_system) + super().link_to_flow_system(flow_system, prefix) if self.piecewise_effects_of_investment is not None: - self.piecewise_effects_of_investment._set_flow_system(flow_system) - - def transform_data(self, name_prefix: str = '') -> None: + self.piecewise_effects_of_investment.link_to_flow_system(flow_system, self._sub_prefix('PiecewiseEffects')) + + def transform_data(self) -> None: + # Validate that either fixed_size or maximum_size is set + if self.fixed_size is None and self.maximum_size is None: + raise ValueError( + f'InvestParameters in "{self.prefix}" requires either fixed_size or maximum_size to be set. ' + f'An upper bound is needed to properly scale the optimization model.' + ) self.effects_of_investment = self._fit_effect_coords( - prefix=name_prefix, + prefix=self.prefix, effect_values=self.effects_of_investment, suffix='effects_of_investment', dims=['period', 'scenario'], ) self.effects_of_retirement = self._fit_effect_coords( - prefix=name_prefix, + prefix=self.prefix, effect_values=self.effects_of_retirement, suffix='effects_of_retirement', dims=['period', 'scenario'], ) self.effects_of_investment_per_size = self._fit_effect_coords( - prefix=name_prefix, + prefix=self.prefix, effect_values=self.effects_of_investment_per_size, suffix='effects_of_investment_per_size', dims=['period', 'scenario'], @@ -969,13 +1201,13 @@ def transform_data(self, name_prefix: str = '') -> None: if self.piecewise_effects_of_investment is not None: self.piecewise_effects_of_investment.has_time_dim = False - self.piecewise_effects_of_investment.transform_data(f'{name_prefix}|PiecewiseEffects') + self.piecewise_effects_of_investment.transform_data() self.minimum_size = self._fit_coords( - f'{name_prefix}|minimum_size', self.minimum_size, dims=['period', 'scenario'] + f'{self.prefix}|minimum_size', self.minimum_size, dims=['period', 'scenario'] ) self.maximum_size = self._fit_coords( - f'{name_prefix}|maximum_size', self.maximum_size, dims=['period', 'scenario'] + f'{self.prefix}|maximum_size', self.maximum_size, dims=['period', 'scenario'] ) # Convert tuple (first_period, last_period) to DataArray if needed if isinstance(self.linked_periods, (tuple, list)): @@ -1002,77 +1234,9 @@ def transform_data(self, name_prefix: str = '') -> None: logger.debug(f'Computed {self.linked_periods=}') self.linked_periods = self._fit_coords( - f'{name_prefix}|linked_periods', self.linked_periods, dims=['period', 'scenario'] - ) - self.fixed_size = self._fit_coords(f'{name_prefix}|fixed_size', self.fixed_size, dims=['period', 'scenario']) - - @property - def optional(self) -> bool: - """DEPRECATED: Use 'mandatory' property instead. Returns the opposite of 'mandatory'.""" - import warnings - - warnings.warn( - f"Property 'optional' is deprecated. Use 'mandatory' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return not self.mandatory - - @optional.setter - def optional(self, value: bool): - """DEPRECATED: Use 'mandatory' property instead. Sets the opposite of the given value to 'mandatory'.""" - warnings.warn( - f"Property 'optional' is deprecated. Use 'mandatory' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.mandatory = not value - - @property - def fix_effects(self) -> Effect_PS | Numeric_PS: - """Deprecated property. Use effects_of_investment instead.""" - warnings.warn( - f'The fix_effects property is deprecated. Use effects_of_investment instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.effects_of_investment - - @property - def specific_effects(self) -> Effect_PS | Numeric_PS: - """Deprecated property. Use effects_of_investment_per_size instead.""" - warnings.warn( - f'The specific_effects property is deprecated. Use effects_of_investment_per_size instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.effects_of_investment_per_size - - @property - def divest_effects(self) -> Effect_PS | Numeric_PS: - """Deprecated property. Use effects_of_retirement instead.""" - warnings.warn( - f'The divest_effects property is deprecated. Use effects_of_retirement instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.effects_of_retirement - - @property - def piecewise_effects(self) -> PiecewiseEffects | None: - """Deprecated property. Use piecewise_effects_of_investment instead.""" - warnings.warn( - f'The piecewise_effects property is deprecated. Use piecewise_effects_of_investment instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, + f'{self.prefix}|linked_periods', self.linked_periods, dims=['period', 'scenario'] ) - return self.piecewise_effects_of_investment + self.fixed_size = self._fit_coords(f'{self.prefix}|fixed_size', self.fixed_size, dims=['period', 'scenario']) @property def minimum_or_fixed_size(self) -> Numeric_PS: @@ -1116,19 +1280,19 @@ def compute_linked_periods(first_period: int, last_period: int, periods: pd.Inde @register_class_for_io -class OnOffParameters(Interface): - """Define operational constraints and effects for binary on/off equipment behavior. +class StatusParameters(Interface): + """Define operational constraints and effects for binary status equipment behavior. - This class models equipment that operates in discrete states (on/off) rather than + This class models equipment that operates in discrete states (active/inactive) rather than continuous operation, capturing realistic operational constraints and associated costs. It handles complex equipment behavior including startup costs, minimum run times, cycling limitations, and maintenance scheduling requirements. Key Modeling Capabilities: - **Switching Costs**: One-time costs for starting equipment (fuel, wear, labor) - **Runtime Constraints**: Minimum and maximum continuous operation periods - **Cycling Limits**: Maximum number of starts to prevent excessive wear - **Operating Hours**: Total runtime limits and requirements over time horizon + **Startup Costs**: One-time costs for starting equipment (fuel, wear, labor) + **Runtime Constraints**: Minimum and maximum continuous operation periods (uptime/downtime) + **Cycling Limits**: Maximum number of startups to prevent excessive wear + **Operating Hours**: Total active hours limits and requirements over time horizon Typical Equipment Applications: - **Power Plants**: Combined cycle units, steam turbines with startup costs @@ -1138,46 +1302,45 @@ class OnOffParameters(Interface): - **Process Equipment**: Compressors, pumps with operational constraints Mathematical Formulation: - See the complete mathematical model in the documentation: - [OnOffParameters](../user-guide/mathematical-notation/features/OnOffParameters.md) + See Args: - effects_per_switch_on: Costs or impacts incurred for each transition from - off state (var_on=0) to on state (var_on=1). Represents startup costs, + effects_per_startup: Costs or impacts incurred for each transition from + inactive state (status=0) to active state (status=1). Represents startup costs, wear and tear, or other switching impacts. Dictionary mapping effect names to values (e.g., {'cost': 500, 'maintenance_hours': 2}). - effects_per_running_hour: Ongoing costs or impacts while equipment operates - in the on state. Includes fuel costs, labor, consumables, or emissions. + effects_per_active_hour: Ongoing costs or impacts while equipment operates + in the active state. Includes fuel costs, labor, consumables, or emissions. Dictionary mapping effect names to hourly values (e.g., {'fuel_cost': 45}). - on_hours_min: Minimum total operating hours per period. + active_hours_min: Minimum total active hours across the entire time horizon per period. Ensures equipment meets minimum utilization requirements or contractual obligations (e.g., power purchase agreements, maintenance schedules). - on_hours_max: Maximum total operating hours per period. + active_hours_max: Maximum total active hours across the entire time horizon per period. Limits equipment usage due to maintenance schedules, fuel availability, environmental permits, or equipment lifetime constraints. - consecutive_on_hours_min: Minimum continuous operating duration once started. + min_uptime: Minimum continuous operating duration once started (unit commitment term). Models minimum run times due to thermal constraints, process stability, or efficiency considerations. Can be time-varying to reflect different constraints across the planning horizon. - consecutive_on_hours_max: Maximum continuous operating duration in one campaign. + max_uptime: Maximum continuous operating duration in one campaign (unit commitment term). Models mandatory maintenance intervals, process batch sizes, or equipment thermal limits requiring periodic shutdowns. - consecutive_off_hours_min: Minimum continuous shutdown duration between operations. + min_downtime: Minimum continuous shutdown duration between operations (unit commitment term). Models cooling periods, maintenance requirements, or process constraints that prevent immediate restart after shutdown. - consecutive_off_hours_max: Maximum continuous shutdown duration before mandatory + max_downtime: Maximum continuous shutdown duration before mandatory restart. Models equipment preservation, process stability, or contractual requirements for minimum activity levels. - switch_on_max: Maximum number of startup operations per period. + startup_limit: Maximum number of startup operations across the time horizon per period.. Limits equipment cycling to reduce wear, maintenance costs, or comply with operational constraints (e.g., grid stability requirements). - force_switch_on: When True, creates switch-on variables even without explicit - switch_on_max constraint. Useful for tracking or reporting startup + force_startup_tracking: When True, creates startup variables even without explicit + startup_limit constraint. Useful for tracking or reporting startup events without enforcing limits. Note: **Time Series Boundary Handling**: The final time period constraints for - consecutive_on_hours_min/max and consecutive_off_hours_min/max are not + min_uptime/max_uptime and min_downtime/max_downtime are not enforced, allowing the optimization to end with ongoing campaigns that may be shorter than the specified minimums or longer than maximums. @@ -1185,105 +1348,105 @@ class OnOffParameters(Interface): Combined cycle power plant with startup costs and minimum run time: ```python - power_plant_operation = OnOffParameters( - effects_per_switch_on={ + power_plant_operation = StatusParameters( + effects_per_startup={ 'startup_cost': 25000, # €25,000 per startup 'startup_fuel': 150, # GJ natural gas for startup 'startup_time': 4, # Hours to reach full output 'maintenance_impact': 0.1, # Fractional life consumption }, - effects_per_running_hour={ - 'fixed_om': 125, # Fixed O&M costs while running + effects_per_active_hour={ + 'fixed_om': 125, # Fixed O&M costs while active 'auxiliary_power': 2.5, # MW parasitic loads }, - consecutive_on_hours_min=8, # Minimum 8-hour run once started - consecutive_off_hours_min=4, # Minimum 4-hour cooling period - on_hours_max=6000, # Annual operating limit + min_uptime=8, # Minimum 8-hour run once started + min_downtime=4, # Minimum 4-hour cooling period + active_hours_max=6000, # Annual operating limit ) ``` Industrial batch process with cycling limits: ```python - batch_reactor = OnOffParameters( - effects_per_switch_on={ + batch_reactor = StatusParameters( + effects_per_startup={ 'setup_cost': 1500, # Labor and materials for startup 'catalyst_consumption': 5, # kg catalyst per batch 'cleaning_chemicals': 200, # L cleaning solution }, - effects_per_running_hour={ + effects_per_active_hour={ 'steam': 2.5, # t/h process steam 'electricity': 150, # kWh electrical load 'cooling_water': 50, # m³/h cooling water }, - consecutive_on_hours_min=12, # Minimum batch size (12 hours) - consecutive_on_hours_max=24, # Maximum batch size (24 hours) - consecutive_off_hours_min=6, # Cleaning and setup time - switch_on_max=200, # Maximum 200 batches per period - on_hours_max=4000, # Maximum production time + min_uptime=12, # Minimum batch size (12 hours) + max_uptime=24, # Maximum batch size (24 hours) + min_downtime=6, # Cleaning and setup time + startup_limit=200, # Maximum 200 batches per period + active_hours_max=4000, # Maximum production time ) ``` HVAC system with thermostat control and maintenance: ```python - hvac_operation = OnOffParameters( - effects_per_switch_on={ + hvac_operation = StatusParameters( + effects_per_startup={ 'compressor_wear': 0.5, # Hours of compressor life per start 'inrush_current': 15, # kW peak demand on startup }, - effects_per_running_hour={ + effects_per_active_hour={ 'electricity': 25, # kW electrical consumption 'maintenance': 0.12, # €/hour maintenance reserve }, - consecutive_on_hours_min=1, # Minimum 1-hour run to avoid cycling - consecutive_off_hours_min=0.5, # 30-minute minimum off time - switch_on_max=2000, # Limit cycling for compressor life - on_hours_min=2000, # Minimum operation for humidity control - on_hours_max=5000, # Maximum operation for energy budget + min_uptime=1, # Minimum 1-hour run to avoid cycling + min_downtime=0.5, # 30-minute minimum inactive time + startup_limit=2000, # Limit cycling for compressor life + active_hours_min=2000, # Minimum operation for humidity control + active_hours_max=5000, # Maximum operation for energy budget ) ``` Backup generator with testing and maintenance requirements: ```python - backup_generator = OnOffParameters( - effects_per_switch_on={ + backup_generator = StatusParameters( + effects_per_startup={ 'fuel_priming': 50, # L diesel for system priming 'wear_factor': 1.0, # Start cycles impact on maintenance 'testing_labor': 2, # Hours technician time per test }, - effects_per_running_hour={ + effects_per_active_hour={ 'fuel_consumption': 180, # L/h diesel consumption 'emissions_permit': 15, # € emissions allowance cost 'noise_penalty': 25, # € noise compliance cost }, - consecutive_on_hours_min=0.5, # Minimum test duration (30 min) - consecutive_off_hours_max=720, # Maximum 30 days between tests - switch_on_max=52, # Weekly testing limit - on_hours_min=26, # Minimum annual testing (0.5h × 52) - on_hours_max=200, # Maximum runtime (emergencies + tests) + min_uptime=0.5, # Minimum test duration (30 min) + max_downtime=720, # Maximum 30 days between tests + startup_limit=52, # Weekly testing limit + active_hours_min=26, # Minimum annual testing (0.5h × 52) + active_hours_max=200, # Maximum runtime (emergencies + tests) ) ``` Peak shaving battery with cycling degradation: ```python - battery_cycling = OnOffParameters( - effects_per_switch_on={ + battery_cycling = StatusParameters( + effects_per_startup={ 'cycle_degradation': 0.01, # % capacity loss per cycle 'inverter_startup': 0.5, # kWh losses during startup }, - effects_per_running_hour={ + effects_per_active_hour={ 'standby_losses': 2, # kW standby consumption 'cooling': 5, # kW thermal management 'inverter_losses': 8, # kW conversion losses }, - consecutive_on_hours_min=1, # Minimum discharge duration - consecutive_on_hours_max=4, # Maximum continuous discharge - consecutive_off_hours_min=1, # Minimum rest between cycles - switch_on_max=365, # Daily cycling limit - force_switch_on=True, # Track all cycling events + min_uptime=1, # Minimum discharge duration + max_uptime=4, # Maximum continuous discharge + min_downtime=1, # Minimum rest between cycles + startup_limit=365, # Daily cycling limit + force_startup_tracking=True, # Track all cycling events ) ``` @@ -1299,160 +1462,73 @@ class OnOffParameters(Interface): def __init__( self, - effects_per_switch_on: Effect_TPS | Numeric_TPS | None = None, - effects_per_running_hour: Effect_TPS | Numeric_TPS | None = None, - on_hours_min: Numeric_PS | None = None, - on_hours_max: Numeric_PS | None = None, - consecutive_on_hours_min: Numeric_TPS | None = None, - consecutive_on_hours_max: Numeric_TPS | None = None, - consecutive_off_hours_min: Numeric_TPS | None = None, - consecutive_off_hours_max: Numeric_TPS | None = None, - switch_on_max: Numeric_PS | None = None, - force_switch_on: bool = False, - **kwargs, + effects_per_startup: Effect_TPS | Numeric_TPS | None = None, + effects_per_active_hour: Effect_TPS | Numeric_TPS | None = None, + active_hours_min: Numeric_PS | None = None, + active_hours_max: Numeric_PS | None = None, + min_uptime: Numeric_TPS | None = None, + max_uptime: Numeric_TPS | None = None, + min_downtime: Numeric_TPS | None = None, + max_downtime: Numeric_TPS | None = None, + startup_limit: Numeric_PS | None = None, + force_startup_tracking: bool = False, ): - # Handle deprecated parameters - on_hours_min = self._handle_deprecated_kwarg(kwargs, 'on_hours_total_min', 'on_hours_min', on_hours_min) - on_hours_max = self._handle_deprecated_kwarg(kwargs, 'on_hours_total_max', 'on_hours_max', on_hours_max) - switch_on_max = self._handle_deprecated_kwarg(kwargs, 'switch_on_total_max', 'switch_on_max', switch_on_max) - self._validate_kwargs(kwargs) - - self.effects_per_switch_on = effects_per_switch_on if effects_per_switch_on is not None else {} - self.effects_per_running_hour = effects_per_running_hour if effects_per_running_hour is not None else {} - self.on_hours_min = on_hours_min - self.on_hours_max = on_hours_max - self.consecutive_on_hours_min = consecutive_on_hours_min - self.consecutive_on_hours_max = consecutive_on_hours_max - self.consecutive_off_hours_min = consecutive_off_hours_min - self.consecutive_off_hours_max = consecutive_off_hours_max - self.switch_on_max = switch_on_max - self.force_switch_on: bool = force_switch_on - - def transform_data(self, name_prefix: str = '') -> None: - self.effects_per_switch_on = self._fit_effect_coords( - prefix=name_prefix, - effect_values=self.effects_per_switch_on, - suffix='per_switch_on', - ) - self.effects_per_running_hour = self._fit_effect_coords( - prefix=name_prefix, - effect_values=self.effects_per_running_hour, - suffix='per_running_hour', - ) - self.consecutive_on_hours_min = self._fit_coords( - f'{name_prefix}|consecutive_on_hours_min', self.consecutive_on_hours_min - ) - self.consecutive_on_hours_max = self._fit_coords( - f'{name_prefix}|consecutive_on_hours_max', self.consecutive_on_hours_max + self.effects_per_startup = effects_per_startup if effects_per_startup is not None else {} + self.effects_per_active_hour = effects_per_active_hour if effects_per_active_hour is not None else {} + self.active_hours_min = active_hours_min + self.active_hours_max = active_hours_max + self.min_uptime = min_uptime + self.max_uptime = max_uptime + self.min_downtime = min_downtime + self.max_downtime = max_downtime + self.startup_limit = startup_limit + self.force_startup_tracking: bool = force_startup_tracking + + def transform_data(self) -> None: + self.effects_per_startup = self._fit_effect_coords( + prefix=self.prefix, + effect_values=self.effects_per_startup, + suffix='per_startup', ) - self.consecutive_off_hours_min = self._fit_coords( - f'{name_prefix}|consecutive_off_hours_min', self.consecutive_off_hours_min + self.effects_per_active_hour = self._fit_effect_coords( + prefix=self.prefix, + effect_values=self.effects_per_active_hour, + suffix='per_active_hour', ) - self.consecutive_off_hours_max = self._fit_coords( - f'{name_prefix}|consecutive_off_hours_max', self.consecutive_off_hours_max + self.min_uptime = self._fit_coords(f'{self.prefix}|min_uptime', self.min_uptime) + self.max_uptime = self._fit_coords(f'{self.prefix}|max_uptime', self.max_uptime) + self.min_downtime = self._fit_coords(f'{self.prefix}|min_downtime', self.min_downtime) + self.max_downtime = self._fit_coords(f'{self.prefix}|max_downtime', self.max_downtime) + self.active_hours_max = self._fit_coords( + f'{self.prefix}|active_hours_max', self.active_hours_max, dims=['period', 'scenario'] ) - self.on_hours_max = self._fit_coords( - f'{name_prefix}|on_hours_max', self.on_hours_max, dims=['period', 'scenario'] + self.active_hours_min = self._fit_coords( + f'{self.prefix}|active_hours_min', self.active_hours_min, dims=['period', 'scenario'] ) - self.on_hours_min = self._fit_coords( - f'{name_prefix}|on_hours_min', self.on_hours_min, dims=['period', 'scenario'] + self.startup_limit = self._fit_coords( + f'{self.prefix}|startup_limit', self.startup_limit, dims=['period', 'scenario'] ) - self.switch_on_max = self._fit_coords( - f'{name_prefix}|switch_on_max', self.switch_on_max, dims=['period', 'scenario'] - ) - - @property - def use_off(self) -> bool: - """Proxy: whether OFF variable is required""" - return self.use_consecutive_off_hours @property - def use_consecutive_on_hours(self) -> bool: - """Determines whether a Variable for consecutive on hours is needed or not""" - return any(param is not None for param in [self.consecutive_on_hours_min, self.consecutive_on_hours_max]) + def use_uptime_tracking(self) -> bool: + """Determines whether a Variable for uptime (consecutive active hours) is needed or not""" + return any(param is not None for param in [self.min_uptime, self.max_uptime]) @property - def use_consecutive_off_hours(self) -> bool: - """Determines whether a Variable for consecutive off hours is needed or not""" - return any(param is not None for param in [self.consecutive_off_hours_min, self.consecutive_off_hours_max]) + def use_downtime_tracking(self) -> bool: + """Determines whether a Variable for downtime (consecutive inactive hours) is needed or not""" + return any(param is not None for param in [self.min_downtime, self.max_downtime]) @property - def use_switch_on(self) -> bool: - """Determines whether a variable for switch_on is needed or not""" - if self.force_switch_on: + def use_startup_tracking(self) -> bool: + """Determines whether a variable for startup is needed or not""" + if self.force_startup_tracking: return True return any( self._has_value(param) for param in [ - self.effects_per_switch_on, - self.switch_on_max, + self.effects_per_startup, + self.startup_limit, ] ) - - # Backwards compatible properties (deprecated) - @property - def on_hours_total_min(self): - """DEPRECATED: Use 'on_hours_min' property instead.""" - warnings.warn( - f"Property 'on_hours_total_min' is deprecated. Use 'on_hours_min' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.on_hours_min - - @on_hours_total_min.setter - def on_hours_total_min(self, value): - """DEPRECATED: Use 'on_hours_min' property instead.""" - warnings.warn( - f"Property 'on_hours_total_min' is deprecated. Use 'on_hours_min' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.on_hours_min = value - - @property - def on_hours_total_max(self): - """DEPRECATED: Use 'on_hours_max' property instead.""" - warnings.warn( - f"Property 'on_hours_total_max' is deprecated. Use 'on_hours_max' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.on_hours_max - - @on_hours_total_max.setter - def on_hours_total_max(self, value): - """DEPRECATED: Use 'on_hours_max' property instead.""" - warnings.warn( - f"Property 'on_hours_total_max' is deprecated. Use 'on_hours_max' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.on_hours_max = value - - @property - def switch_on_total_max(self): - """DEPRECATED: Use 'switch_on_max' property instead.""" - warnings.warn( - f"Property 'switch_on_total_max' is deprecated. Use 'switch_on_max' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.switch_on_max - - @switch_on_total_max.setter - def switch_on_total_max(self, value): - """DEPRECATED: Use 'switch_on_max' property instead.""" - warnings.warn( - f"Property 'switch_on_total_max' is deprecated. Use 'switch_on_max' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.switch_on_max = value diff --git a/flixopt/io.py b/flixopt/io.py index 27bc242ff..288d35db8 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -597,6 +597,237 @@ def load_dataset_from_netcdf(path: str | pathlib.Path) -> xr.Dataset: return ds +# Parameter rename mappings for backwards compatibility conversion +# Format: {old_name: new_name} +PARAMETER_RENAMES = { + # Effect parameters + 'minimum_operation': 'minimum_temporal', + 'maximum_operation': 'maximum_temporal', + 'minimum_invest': 'minimum_periodic', + 'maximum_invest': 'maximum_periodic', + 'minimum_investment': 'minimum_periodic', + 'maximum_investment': 'maximum_periodic', + 'minimum_operation_per_hour': 'minimum_per_hour', + 'maximum_operation_per_hour': 'maximum_per_hour', + # InvestParameters + 'fix_effects': 'effects_of_investment', + 'specific_effects': 'effects_of_investment_per_size', + 'divest_effects': 'effects_of_retirement', + 'piecewise_effects': 'piecewise_effects_of_investment', + # Flow/OnOffParameters + 'flow_hours_total_max': 'flow_hours_max', + 'flow_hours_total_min': 'flow_hours_min', + 'on_hours_total_max': 'on_hours_max', + 'on_hours_total_min': 'on_hours_min', + 'switch_on_total_max': 'switch_on_max', + # Bus + 'excess_penalty_per_flow_hour': 'imbalance_penalty_per_flow_hour', + # Component parameters (Source/Sink) + 'source': 'outputs', + 'sink': 'inputs', + 'prevent_simultaneous_sink_and_source': 'prevent_simultaneous_flow_rates', + # LinearConverter flow/efficiency parameters (pre-v4 files) + # These are needed for very old files that use short flow names + 'Q_fu': 'fuel_flow', + 'P_el': 'electrical_flow', + 'Q_th': 'thermal_flow', + 'Q_ab': 'heat_source_flow', + 'eta': 'thermal_efficiency', + 'eta_th': 'thermal_efficiency', + 'eta_el': 'electrical_efficiency', + 'COP': 'cop', + # Storage + # Note: 'lastValueOfSim' → 'equals_final' is a value change, not a key change + # Class renames (v4.2.0) + 'FullCalculation': 'Optimization', + 'AggregatedCalculation': 'ClusteredOptimization', + 'SegmentedCalculation': 'SegmentedOptimization', + 'CalculationResults': 'Results', + 'SegmentedCalculationResults': 'SegmentedResults', + 'Aggregation': 'Clustering', + 'AggregationParameters': 'ClusteringParameters', + 'AggregationModel': 'ClusteringModel', + # OnOffParameters → StatusParameters (class and attribute names) + 'OnOffParameters': 'StatusParameters', + 'on_off_parameters': 'status_parameters', + # StatusParameters attribute renames (applies to both Flow-level and Component-level) + 'effects_per_switch_on': 'effects_per_startup', + 'effects_per_running_hour': 'effects_per_active_hour', + 'consecutive_on_hours_min': 'min_uptime', + 'consecutive_on_hours_max': 'max_uptime', + 'consecutive_off_hours_min': 'min_downtime', + 'consecutive_off_hours_max': 'max_downtime', + 'force_switch_on': 'force_startup_tracking', + 'on_hours_min': 'active_hours_min', + 'on_hours_max': 'active_hours_max', + 'switch_on_max': 'startup_limit', + # TimeSeriesData + 'agg_group': 'aggregation_group', + 'agg_weight': 'aggregation_weight', +} + +# Value renames (for specific parameter values that changed) +VALUE_RENAMES = { + 'initial_charge_state': {'lastValueOfSim': 'equals_final'}, +} + + +# Keys that should NOT have their child keys renamed (they reference flow labels) +_FLOW_LABEL_REFERENCE_KEYS = {'piecewises', 'conversion_factors'} + +# Keys that ARE flow parameters on components (should be renamed) +_FLOW_PARAMETER_KEYS = {'Q_fu', 'P_el', 'Q_th', 'Q_ab', 'eta', 'eta_th', 'eta_el', 'COP'} + + +def _rename_keys_recursive( + obj: Any, + key_renames: dict[str, str], + value_renames: dict[str, dict], + skip_flow_renames: bool = False, +) -> Any: + """Recursively rename keys and values in nested data structures. + + Args: + obj: The object to process (dict, list, or scalar) + key_renames: Mapping of old key names to new key names + value_renames: Mapping of key names to {old_value: new_value} dicts + skip_flow_renames: If True, skip renaming flow parameter keys (for inside piecewises) + + Returns: + The processed object with renamed keys and values + """ + if isinstance(obj, dict): + new_dict = {} + for key, value in obj.items(): + # Determine if we should skip flow renames for children + child_skip_flow_renames = skip_flow_renames or key in _FLOW_LABEL_REFERENCE_KEYS + + # Rename the key if needed (skip flow params if in reference context) + if skip_flow_renames and key in _FLOW_PARAMETER_KEYS: + new_key = key # Don't rename flow labels inside piecewises etc. + else: + new_key = key_renames.get(key, key) + + # Process the value recursively + new_value = _rename_keys_recursive(value, key_renames, value_renames, child_skip_flow_renames) + + # Check if this key has value renames (lookup by renamed key, fallback to old key) + vr_key = new_key if new_key in value_renames else key + if vr_key in value_renames and isinstance(new_value, str): + new_value = value_renames[vr_key].get(new_value, new_value) + + # Handle __class__ values - rename class names + if key == '__class__' and isinstance(new_value, str): + new_value = key_renames.get(new_value, new_value) + + new_dict[new_key] = new_value + return new_dict + + elif isinstance(obj, list): + return [_rename_keys_recursive(item, key_renames, value_renames, skip_flow_renames) for item in obj] + + else: + return obj + + +def convert_old_dataset( + ds: xr.Dataset, + key_renames: dict[str, str] | None = None, + value_renames: dict[str, dict] | None = None, +) -> xr.Dataset: + """Convert an old FlowSystem dataset to use new parameter names. + + This function updates the reference structure in a dataset's attrs to use + the current parameter naming conventions. This is useful for loading + FlowSystem files saved with older versions of flixopt. + + Args: + ds: The dataset to convert (will be modified in place) + key_renames: Custom key renames to apply. If None, uses PARAMETER_RENAMES. + value_renames: Custom value renames to apply. If None, uses VALUE_RENAMES. + + Returns: + The converted dataset (same object, modified in place) + + Examples: + Convert an old netCDF file to new format: + + ```python + from flixopt import io + + # Load old file + ds = io.load_dataset_from_netcdf('old_flow_system.nc4') + + # Convert parameter names + ds = io.convert_old_dataset(ds) + + # Now load as FlowSystem + from flixopt import FlowSystem + + fs = FlowSystem.from_dataset(ds) + ``` + """ + if key_renames is None: + key_renames = PARAMETER_RENAMES + if value_renames is None: + value_renames = VALUE_RENAMES + + # Convert the attrs (reference_structure) + ds.attrs = _rename_keys_recursive(ds.attrs, key_renames, value_renames) + + return ds + + +def convert_old_netcdf( + input_path: str | pathlib.Path, + output_path: str | pathlib.Path | None = None, + compression: int = 0, +) -> xr.Dataset: + """Load an old FlowSystem netCDF file and convert to new parameter names. + + This is a convenience function that combines loading, conversion, and + optionally saving the converted dataset. + + Args: + input_path: Path to the old netCDF file + output_path: If provided, save the converted dataset to this path. + If None, only returns the converted dataset without saving. + compression: Compression level (0-9) for saving. Only used if output_path is provided. + + Returns: + The converted dataset + + Examples: + Convert and save to new file: + + ```python + from flixopt import io + + # Convert old file to new format + ds = io.convert_old_netcdf('old_system.nc4', 'new_system.nc') + ``` + + Convert and load as FlowSystem: + + ```python + from flixopt import FlowSystem, io + + ds = io.convert_old_netcdf('old_system.nc4') + fs = FlowSystem.from_dataset(ds) + ``` + """ + # Load and convert + ds = load_dataset_from_netcdf(input_path) + ds = convert_old_dataset(ds) + + # Optionally save + if output_path is not None: + save_dataset_to_netcdf(ds, output_path, compression=compression) + logger.info(f'Converted {input_path} -> {output_path}') + + return ds + + @dataclass class ResultsPaths: """Container for all paths related to saving Results.""" @@ -801,7 +1032,7 @@ def build_repr_from_init( excluded_params: Set of parameter names to exclude (e.g., {'self', 'inputs', 'outputs'}) Default excludes 'self', 'label', and 'kwargs' label_as_positional: If True and 'label' param exists, show it as first positional arg - skip_default_size: If True, skip 'size' parameter when it equals CONFIG.Modeling.big + skip_default_size: Deprecated. Previously skipped size=CONFIG.Modeling.big, now size=None is default. Returns: Formatted repr string like: ClassName("label", param=value) diff --git a/flixopt/linear_converters.py b/flixopt/linear_converters.py index 8ba7833c9..8326fe6c5 100644 --- a/flixopt/linear_converters.py +++ b/flixopt/linear_converters.py @@ -5,18 +5,16 @@ from __future__ import annotations import logging -import warnings from typing import TYPE_CHECKING import numpy as np from .components import LinearConverter -from .config import DEPRECATION_REMOVAL_VERSION from .structure import register_class_for_io if TYPE_CHECKING: from .elements import Flow - from .interface import OnOffParameters + from .interface import StatusParameters from .types import Numeric_TPS logger = logging.getLogger('flixopt') @@ -37,12 +35,9 @@ class Boiler(LinearConverter): output to fuel input energy content. fuel_flow: Fuel input-flow representing fuel consumption. thermal_flow: Thermal output-flow representing heat generation. - on_off_parameters: Parameters defining binary operation constraints and costs. + status_parameters: Parameters defining status, startup and shutdown constraints and effects meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. - eta: *Deprecated*. Use `thermal_efficiency` instead. - Q_fu: *Deprecated*. Use `fuel_flow` instead. - Q_th: *Deprecated*. Use `thermal_flow` instead. Examples: Natural gas boiler: @@ -64,9 +59,9 @@ class Boiler(LinearConverter): thermal_efficiency=seasonal_efficiency_profile, # Time-varying efficiency fuel_flow=biomass_flow, thermal_flow=district_heat_flow, - on_off_parameters=OnOffParameters( - consecutive_on_hours_min=4, # Minimum 4-hour operation - effects_per_switch_on={'startup_fuel': 50}, # Startup fuel penalty + status_parameters=StatusParameters( + min_uptime=4, # Minimum 4-hour operation + effects_per_startup={'startup_fuel': 50}, # Startup fuel penalty ), ) ``` @@ -84,16 +79,9 @@ def __init__( thermal_efficiency: Numeric_TPS | None = None, fuel_flow: Flow | None = None, thermal_flow: Flow | None = None, - on_off_parameters: OnOffParameters | None = None, + status_parameters: StatusParameters | None = None, meta_data: dict | None = None, - **kwargs, ): - # Handle deprecated parameters - fuel_flow = self._handle_deprecated_kwarg(kwargs, 'Q_fu', 'fuel_flow', fuel_flow) - thermal_flow = self._handle_deprecated_kwarg(kwargs, 'Q_th', 'thermal_flow', thermal_flow) - thermal_efficiency = self._handle_deprecated_kwarg(kwargs, 'eta', 'thermal_efficiency', thermal_efficiency) - self._validate_kwargs(kwargs) - # Validate required parameters if fuel_flow is None: raise ValueError(f"'{label}': fuel_flow is required and cannot be None") @@ -106,7 +94,7 @@ def __init__( label, inputs=[fuel_flow], outputs=[thermal_flow], - on_off_parameters=on_off_parameters, + status_parameters=status_parameters, meta_data=meta_data, ) self.fuel_flow = fuel_flow @@ -122,66 +110,6 @@ def thermal_efficiency(self, value): check_bounds(value, 'thermal_efficiency', self.label_full, 0, 1) self.conversion_factors = [{self.fuel_flow.label: value, self.thermal_flow.label: 1}] - @property - def eta(self) -> Numeric_TPS: - warnings.warn( - 'The "eta" property is deprecated. Use "thermal_efficiency" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.thermal_efficiency - - @eta.setter - def eta(self, value: Numeric_TPS) -> None: - warnings.warn( - 'The "eta" property is deprecated. Use "thermal_efficiency" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.thermal_efficiency = value - - @property - def Q_fu(self) -> Flow: # noqa: N802 - warnings.warn( - 'The "Q_fu" property is deprecated. Use "fuel_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.fuel_flow - - @Q_fu.setter - def Q_fu(self, value: Flow) -> None: # noqa: N802 - warnings.warn( - 'The "Q_fu" property is deprecated. Use "fuel_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.fuel_flow = value - - @property - def Q_th(self) -> Flow: # noqa: N802 - warnings.warn( - 'The "Q_th" property is deprecated. Use "thermal_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.thermal_flow - - @Q_th.setter - def Q_th(self, value: Flow) -> None: # noqa: N802 - warnings.warn( - 'The "Q_th" property is deprecated. Use "thermal_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.thermal_flow = value - @register_class_for_io class Power2Heat(LinearConverter): @@ -200,12 +128,9 @@ class Power2Heat(LinearConverter): electrode boilers or systems with distribution losses. electrical_flow: Electrical input-flow representing electricity consumption. thermal_flow: Thermal output-flow representing heat generation. - on_off_parameters: Parameters defining binary operation constraints and costs. + status_parameters: Parameters defining status, startup and shutdown constraints and effects meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. - eta: *Deprecated*. Use `thermal_efficiency` instead. - P_el: *Deprecated*. Use `electrical_flow` instead. - Q_th: *Deprecated*. Use `thermal_flow` instead. Examples: Electric resistance heater: @@ -227,9 +152,9 @@ class Power2Heat(LinearConverter): thermal_efficiency=0.95, # 95% efficiency including boiler losses electrical_flow=industrial_electricity, thermal_flow=process_steam_flow, - on_off_parameters=OnOffParameters( - consecutive_on_hours_min=1, # Minimum 1-hour operation - effects_per_switch_on={'startup_cost': 100}, + status_parameters=StatusParameters( + min_uptime=1, # Minimum 1-hour operation + effects_per_startup={'startup_cost': 100}, ), ) ``` @@ -249,16 +174,9 @@ def __init__( thermal_efficiency: Numeric_TPS | None = None, electrical_flow: Flow | None = None, thermal_flow: Flow | None = None, - on_off_parameters: OnOffParameters | None = None, + status_parameters: StatusParameters | None = None, meta_data: dict | None = None, - **kwargs, ): - # Handle deprecated parameters - electrical_flow = self._handle_deprecated_kwarg(kwargs, 'P_el', 'electrical_flow', electrical_flow) - thermal_flow = self._handle_deprecated_kwarg(kwargs, 'Q_th', 'thermal_flow', thermal_flow) - thermal_efficiency = self._handle_deprecated_kwarg(kwargs, 'eta', 'thermal_efficiency', thermal_efficiency) - self._validate_kwargs(kwargs) - # Validate required parameters if electrical_flow is None: raise ValueError(f"'{label}': electrical_flow is required and cannot be None") @@ -271,7 +189,7 @@ def __init__( label, inputs=[electrical_flow], outputs=[thermal_flow], - on_off_parameters=on_off_parameters, + status_parameters=status_parameters, meta_data=meta_data, ) @@ -288,66 +206,6 @@ def thermal_efficiency(self, value): check_bounds(value, 'thermal_efficiency', self.label_full, 0, 1) self.conversion_factors = [{self.electrical_flow.label: value, self.thermal_flow.label: 1}] - @property - def eta(self) -> Numeric_TPS: - warnings.warn( - 'The "eta" property is deprecated. Use "thermal_efficiency" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.thermal_efficiency - - @eta.setter - def eta(self, value: Numeric_TPS) -> None: - warnings.warn( - 'The "eta" property is deprecated. Use "thermal_efficiency" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.thermal_efficiency = value - - @property - def P_el(self) -> Flow: # noqa: N802 - warnings.warn( - 'The "P_el" property is deprecated. Use "electrical_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.electrical_flow - - @P_el.setter - def P_el(self, value: Flow) -> None: # noqa: N802 - warnings.warn( - 'The "P_el" property is deprecated. Use "electrical_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.electrical_flow = value - - @property - def Q_th(self) -> Flow: # noqa: N802 - warnings.warn( - 'The "Q_th" property is deprecated. Use "thermal_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.thermal_flow - - @Q_th.setter - def Q_th(self, value: Flow) -> None: # noqa: N802 - warnings.warn( - 'The "Q_th" property is deprecated. Use "thermal_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.thermal_flow = value - @register_class_for_io class HeatPump(LinearConverter): @@ -366,12 +224,9 @@ class HeatPump(LinearConverter): additional energy from the environment. electrical_flow: Electrical input-flow representing electricity consumption. thermal_flow: Thermal output-flow representing heat generation. - on_off_parameters: Parameters defining binary operation constraints and costs. + status_parameters: Parameters defining status, startup and shutdown constraints and effects meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. - COP: *Deprecated*. Use `cop` instead. - P_el: *Deprecated*. Use `electrical_flow` instead. - Q_th: *Deprecated*. Use `thermal_flow` instead. Examples: Air-source heat pump with constant COP: @@ -393,9 +248,9 @@ class HeatPump(LinearConverter): cop=temperature_dependent_cop, # Time-varying COP based on ground temp electrical_flow=electricity_flow, thermal_flow=radiant_heating_flow, - on_off_parameters=OnOffParameters( - consecutive_on_hours_min=2, # Avoid frequent cycling - effects_per_running_hour={'maintenance': 0.5}, + status_parameters=StatusParameters( + min_uptime=2, # Avoid frequent cycling + effects_per_active_hour={'maintenance': 0.5}, ), ) ``` @@ -414,16 +269,9 @@ def __init__( cop: Numeric_TPS | None = None, electrical_flow: Flow | None = None, thermal_flow: Flow | None = None, - on_off_parameters: OnOffParameters | None = None, + status_parameters: StatusParameters | None = None, meta_data: dict | None = None, - **kwargs, ): - # Handle deprecated parameters - electrical_flow = self._handle_deprecated_kwarg(kwargs, 'P_el', 'electrical_flow', electrical_flow) - thermal_flow = self._handle_deprecated_kwarg(kwargs, 'Q_th', 'thermal_flow', thermal_flow) - cop = self._handle_deprecated_kwarg(kwargs, 'COP', 'cop', cop) - self._validate_kwargs(kwargs) - # Validate required parameters if electrical_flow is None: raise ValueError(f"'{label}': electrical_flow is required and cannot be None") @@ -437,7 +285,7 @@ def __init__( inputs=[electrical_flow], outputs=[thermal_flow], conversion_factors=[], - on_off_parameters=on_off_parameters, + status_parameters=status_parameters, meta_data=meta_data, ) self.electrical_flow = electrical_flow @@ -453,64 +301,6 @@ def cop(self, value): check_bounds(value, 'cop', self.label_full, 1, 20) self.conversion_factors = [{self.electrical_flow.label: value, self.thermal_flow.label: 1}] - @property - def COP(self) -> Numeric_TPS: # noqa: N802 - warnings.warn( - f'The "COP" property is deprecated. Use "cop" instead. Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.cop - - @COP.setter - def COP(self, value: Numeric_TPS) -> None: # noqa: N802 - warnings.warn( - f'The "COP" property is deprecated. Use "cop" instead. Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.cop = value - - @property - def P_el(self) -> Flow: # noqa: N802 - warnings.warn( - 'The "P_el" property is deprecated. Use "electrical_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.electrical_flow - - @P_el.setter - def P_el(self, value: Flow) -> None: # noqa: N802 - warnings.warn( - 'The "P_el" property is deprecated. Use "electrical_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.electrical_flow = value - - @property - def Q_th(self) -> Flow: # noqa: N802 - warnings.warn( - 'The "Q_th" property is deprecated. Use "thermal_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.thermal_flow - - @Q_th.setter - def Q_th(self, value: Flow) -> None: # noqa: N802 - warnings.warn( - 'The "Q_th" property is deprecated. Use "thermal_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.thermal_flow = value - @register_class_for_io class CoolingTower(LinearConverter): @@ -529,11 +319,9 @@ class CoolingTower(LinearConverter): of thermal power that must be supplied as electricity for fans and pumps. electrical_flow: Electrical input-flow representing electricity consumption for fans/pumps. thermal_flow: Thermal input-flow representing waste heat to be rejected to environment. - on_off_parameters: Parameters defining binary operation constraints and costs. + status_parameters: Parameters defining status, startup and shutdown constraints and effects meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. - P_el: *Deprecated*. Use `electrical_flow` instead. - Q_th: *Deprecated*. Use `thermal_flow` instead. Examples: Industrial cooling tower: @@ -555,9 +343,9 @@ class CoolingTower(LinearConverter): specific_electricity_demand=0.015, # 1.5% auxiliary power electrical_flow=auxiliary_electricity, thermal_flow=condenser_waste_heat, - on_off_parameters=OnOffParameters( - consecutive_on_hours_min=4, # Minimum operation time - effects_per_running_hour={'water_consumption': 2.5}, # m³/h + status_parameters=StatusParameters( + min_uptime=4, # Minimum operation time + effects_per_active_hour={'water_consumption': 2.5}, # m³/h ), ) ``` @@ -578,15 +366,9 @@ def __init__( specific_electricity_demand: Numeric_TPS, electrical_flow: Flow | None = None, thermal_flow: Flow | None = None, - on_off_parameters: OnOffParameters | None = None, + status_parameters: StatusParameters | None = None, meta_data: dict | None = None, - **kwargs, ): - # Handle deprecated parameters - electrical_flow = self._handle_deprecated_kwarg(kwargs, 'P_el', 'electrical_flow', electrical_flow) - thermal_flow = self._handle_deprecated_kwarg(kwargs, 'Q_th', 'thermal_flow', thermal_flow) - self._validate_kwargs(kwargs) - # Validate required parameters if electrical_flow is None: raise ValueError(f"'{label}': electrical_flow is required and cannot be None") @@ -597,7 +379,7 @@ def __init__( label, inputs=[electrical_flow, thermal_flow], outputs=[], - on_off_parameters=on_off_parameters, + status_parameters=status_parameters, meta_data=meta_data, ) @@ -614,46 +396,6 @@ def specific_electricity_demand(self, value): check_bounds(value, 'specific_electricity_demand', self.label_full, 0, 1) self.conversion_factors = [{self.electrical_flow.label: -1, self.thermal_flow.label: value}] - @property - def P_el(self) -> Flow: # noqa: N802 - warnings.warn( - 'The "P_el" property is deprecated. Use "electrical_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.electrical_flow - - @P_el.setter - def P_el(self, value: Flow) -> None: # noqa: N802 - warnings.warn( - 'The "P_el" property is deprecated. Use "electrical_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.electrical_flow = value - - @property - def Q_th(self) -> Flow: # noqa: N802 - warnings.warn( - 'The "Q_th" property is deprecated. Use "thermal_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.thermal_flow - - @Q_th.setter - def Q_th(self, value: Flow) -> None: # noqa: N802 - warnings.warn( - 'The "Q_th" property is deprecated. Use "thermal_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.thermal_flow = value - @register_class_for_io class CHP(LinearConverter): @@ -674,14 +416,9 @@ class CHP(LinearConverter): fuel_flow: Fuel input-flow representing fuel consumption. electrical_flow: Electrical output-flow representing electricity generation. thermal_flow: Thermal output-flow representing heat generation. - on_off_parameters: Parameters defining binary operation constraints and costs. + status_parameters: Parameters defining status, startup and shutdown constraints and effects meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. - eta_th: *Deprecated*. Use `thermal_efficiency` instead. - eta_el: *Deprecated*. Use `electrical_efficiency` instead. - Q_fu: *Deprecated*. Use `fuel_flow` instead. - P_el: *Deprecated*. Use `electrical_flow` instead. - Q_th: *Deprecated*. Use `thermal_flow` instead. Examples: Natural gas CHP unit: @@ -707,10 +444,10 @@ class CHP(LinearConverter): fuel_flow=fuel_gas_flow, electrical_flow=plant_electricity, thermal_flow=process_steam, - on_off_parameters=OnOffParameters( - consecutive_on_hours_min=8, # Minimum 8-hour operation - effects_per_switch_on={'startup_cost': 5000}, - on_hours_max=6000, # Annual operating limit + status_parameters=StatusParameters( + min_uptime=8, # Minimum 8-hour operation + effects_per_startup={'startup_cost': 5000}, + active_hours_max=6000, # Annual operating limit ), ) ``` @@ -733,20 +470,9 @@ def __init__( fuel_flow: Flow | None = None, electrical_flow: Flow | None = None, thermal_flow: Flow | None = None, - on_off_parameters: OnOffParameters | None = None, + status_parameters: StatusParameters | None = None, meta_data: dict | None = None, - **kwargs, ): - # Handle deprecated parameters - fuel_flow = self._handle_deprecated_kwarg(kwargs, 'Q_fu', 'fuel_flow', fuel_flow) - electrical_flow = self._handle_deprecated_kwarg(kwargs, 'P_el', 'electrical_flow', electrical_flow) - thermal_flow = self._handle_deprecated_kwarg(kwargs, 'Q_th', 'thermal_flow', thermal_flow) - thermal_efficiency = self._handle_deprecated_kwarg(kwargs, 'eta_th', 'thermal_efficiency', thermal_efficiency) - electrical_efficiency = self._handle_deprecated_kwarg( - kwargs, 'eta_el', 'electrical_efficiency', electrical_efficiency - ) - self._validate_kwargs(kwargs) - # Validate required parameters if fuel_flow is None: raise ValueError(f"'{label}': fuel_flow is required and cannot be None") @@ -764,7 +490,7 @@ def __init__( inputs=[fuel_flow], outputs=[thermal_flow, electrical_flow], conversion_factors=[{}, {}], - on_off_parameters=on_off_parameters, + status_parameters=status_parameters, meta_data=meta_data, ) @@ -800,106 +526,6 @@ def electrical_efficiency(self, value): check_bounds(value, 'electrical_efficiency', self.label_full, 0, 1) self.conversion_factors[1] = {self.fuel_flow.label: value, self.electrical_flow.label: 1} - @property - def eta_th(self) -> Numeric_TPS: - warnings.warn( - 'The "eta_th" property is deprecated. Use "thermal_efficiency" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.thermal_efficiency - - @eta_th.setter - def eta_th(self, value: Numeric_TPS) -> None: - warnings.warn( - 'The "eta_th" property is deprecated. Use "thermal_efficiency" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.thermal_efficiency = value - - @property - def eta_el(self) -> Numeric_TPS: - warnings.warn( - 'The "eta_el" property is deprecated. Use "electrical_efficiency" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.electrical_efficiency - - @eta_el.setter - def eta_el(self, value: Numeric_TPS) -> None: - warnings.warn( - 'The "eta_el" property is deprecated. Use "electrical_efficiency" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.electrical_efficiency = value - - @property - def Q_fu(self) -> Flow: # noqa: N802 - warnings.warn( - 'The "Q_fu" property is deprecated. Use "fuel_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.fuel_flow - - @Q_fu.setter - def Q_fu(self, value: Flow) -> None: # noqa: N802 - warnings.warn( - 'The "Q_fu" property is deprecated. Use "fuel_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.fuel_flow = value - - @property - def P_el(self) -> Flow: # noqa: N802 - warnings.warn( - 'The "P_el" property is deprecated. Use "electrical_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.electrical_flow - - @P_el.setter - def P_el(self, value: Flow) -> None: # noqa: N802 - warnings.warn( - 'The "P_el" property is deprecated. Use "electrical_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.electrical_flow = value - - @property - def Q_th(self) -> Flow: # noqa: N802 - warnings.warn( - 'The "Q_th" property is deprecated. Use "thermal_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.thermal_flow - - @Q_th.setter - def Q_th(self, value: Flow) -> None: # noqa: N802 - warnings.warn( - 'The "Q_th" property is deprecated. Use "thermal_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.thermal_flow = value - @register_class_for_io class HeatPumpWithSource(LinearConverter): @@ -920,13 +546,9 @@ class HeatPumpWithSource(LinearConverter): heat_source_flow: Heat source input-flow representing thermal energy extracted from environment (ground, air, water source). thermal_flow: Thermal output-flow representing useful heat delivered to the application. - on_off_parameters: Parameters defining binary operation constraints and costs. + status_parameters: Parameters defining status, startup and shutdown constraints and effects meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. - COP: *Deprecated*. Use `cop` instead. - P_el: *Deprecated*. Use `electrical_flow` instead. - Q_ab: *Deprecated*. Use `heat_source_flow` instead. - Q_th: *Deprecated*. Use `thermal_flow` instead. Examples: Ground-source heat pump with explicit ground coupling: @@ -950,9 +572,9 @@ class HeatPumpWithSource(LinearConverter): electrical_flow=electricity_consumption, heat_source_flow=industrial_heat_extraction, # Heat extracted from a industrial process or waste water thermal_flow=heat_supply, - on_off_parameters=OnOffParameters( - consecutive_on_hours_min=0.5, # 30-minute minimum runtime - effects_per_switch_on={'costs': 1000}, + status_parameters=StatusParameters( + min_uptime=0.5, # 30-minute minimum runtime + effects_per_startup={'costs': 1000}, ), ) ``` @@ -978,17 +600,9 @@ def __init__( electrical_flow: Flow | None = None, heat_source_flow: Flow | None = None, thermal_flow: Flow | None = None, - on_off_parameters: OnOffParameters | None = None, + status_parameters: StatusParameters | None = None, meta_data: dict | None = None, - **kwargs, ): - # Handle deprecated parameters - electrical_flow = self._handle_deprecated_kwarg(kwargs, 'P_el', 'electrical_flow', electrical_flow) - heat_source_flow = self._handle_deprecated_kwarg(kwargs, 'Q_ab', 'heat_source_flow', heat_source_flow) - thermal_flow = self._handle_deprecated_kwarg(kwargs, 'Q_th', 'thermal_flow', thermal_flow) - cop = self._handle_deprecated_kwarg(kwargs, 'COP', 'cop', cop) - self._validate_kwargs(kwargs) - # Validate required parameters if electrical_flow is None: raise ValueError(f"'{label}': electrical_flow is required and cannot be None") @@ -1003,7 +617,7 @@ def __init__( label, inputs=[electrical_flow, heat_source_flow], outputs=[thermal_flow], - on_off_parameters=on_off_parameters, + status_parameters=status_parameters, meta_data=meta_data, ) self.electrical_flow = electrical_flow @@ -1025,84 +639,6 @@ def cop(self, value): {self.heat_source_flow.label: value / (value - 1), self.thermal_flow.label: 1}, ] - @property - def COP(self) -> Numeric_TPS: # noqa: N802 - warnings.warn( - f'The "COP" property is deprecated. Use "cop" instead. Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.cop - - @COP.setter - def COP(self, value: Numeric_TPS) -> None: # noqa: N802 - warnings.warn( - f'The "COP" property is deprecated. Use "cop" instead. Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.cop = value - - @property - def P_el(self) -> Flow: # noqa: N802 - warnings.warn( - 'The "P_el" property is deprecated. Use "electrical_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.electrical_flow - - @P_el.setter - def P_el(self, value: Flow) -> None: # noqa: N802 - warnings.warn( - 'The "P_el" property is deprecated. Use "electrical_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.electrical_flow = value - - @property - def Q_ab(self) -> Flow: # noqa: N802 - warnings.warn( - 'The "Q_ab" property is deprecated. Use "heat_source_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.heat_source_flow - - @Q_ab.setter - def Q_ab(self, value: Flow) -> None: # noqa: N802 - warnings.warn( - 'The "Q_ab" property is deprecated. Use "heat_source_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.heat_source_flow = value - - @property - def Q_th(self) -> Flow: # noqa: N802 - warnings.warn( - 'The "Q_th" property is deprecated. Use "thermal_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self.thermal_flow - - @Q_th.setter - def Q_th(self, value: Flow) -> None: # noqa: N802 - warnings.warn( - 'The "Q_th" property is deprecated. Use "thermal_flow" instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - self.thermal_flow = value - def check_bounds( value: Numeric_TPS, diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 01a2c2410..6b81a0a4a 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -59,16 +59,16 @@ def count_consecutive_states( """Count consecutive steps in the final active state of a binary time series. This function counts how many consecutive time steps the series remains "on" - (non-zero) at the end of the time series. If the final state is "off", returns 0. + (non-zero) at the end of the time series. If the final state is "inactive", returns 0. Args: - binary_values: Binary DataArray with values close to 0 (off) or 1 (on). + binary_values: Binary DataArray with values close to 0 (inactive) or 1 (active). dim: Dimension along which to count consecutive states. epsilon: Tolerance for zero detection. Uses CONFIG.Modeling.epsilon if None. Returns: - Sum of values in the final consecutive "on" period. Returns 0.0 if the - final state is "off". + Sum of values in the final consecutive "active" period. Returns 0.0 if the + final state is "inactive". Examples: >>> arr = xr.DataArray([0, 0, 1, 1, 1, 0, 1, 1], dims=['time']) @@ -100,11 +100,11 @@ def count_consecutive_states( if arr.size == 1: return float(arr[0]) if not np.isclose(arr[0], 0, atol=epsilon) else 0.0 - # Return 0 if final state is off + # Return 0 if final state is inactive if np.isclose(arr[-1], 0, atol=epsilon): return 0.0 - # Find the last zero position (treat NaNs as off) + # Find the last zero position (treat NaNs as inactive) arr = np.nan_to_num(arr, nan=0.0) is_zero = np.isclose(arr, 0, atol=epsilon) zero_indices = np.where(is_zero)[0] @@ -123,7 +123,7 @@ def compute_consecutive_hours_in_state( epsilon: float = None, ) -> float: """ - Computes the final consecutive duration in state 'on' (=1) in hours. + Computes the final consecutive duration in state 'active' (=1) in hours. Args: binary_values: Binary DataArray with 'time' dim, or scalar/array @@ -131,7 +131,7 @@ def compute_consecutive_hours_in_state( epsilon: Tolerance for zero detection (uses CONFIG.Modeling.epsilon if None) Returns: - The duration of the final consecutive 'on' period in hours + The duration of the final consecutive 'active' period in hours """ if not isinstance(hours_per_timestep, (int, float)): raise TypeError(f'hours_per_timestep must be a scalar, got {type(hours_per_timestep)}') @@ -159,14 +159,14 @@ def compute_previous_off_duration( previous_values: xr.DataArray, hours_per_step: xr.DataArray | float | int ) -> float: """ - Compute previous consecutive 'off' duration. + Compute previous consecutive 'inactive' duration. Args: previous_values: DataArray with 'time' dimension hours_per_step: Duration of each timestep in hours Returns: - Previous consecutive off duration in hours + Previous consecutive inactive duration in hours """ if previous_values is None or previous_values.size == 0: return 0.0 @@ -199,22 +199,28 @@ class ModelingPrimitives: @staticmethod def expression_tracking_variable( model: Submodel, - tracked_expression, + tracked_expression: linopy.expressions.LinearExpression | linopy.Variable, name: str = None, short_name: str = None, bounds: tuple[xr.DataArray, xr.DataArray] = None, coords: str | list[str] | None = None, ) -> tuple[linopy.Variable, linopy.Constraint]: - """ - Creates variable that equals a given expression. + """Creates a variable constrained to equal a given expression. Mathematical formulation: tracker = expression - lower ≤ tracker ≤ upper (if bounds provided) + lower ≤ tracker ≤ upper (if bounds provided) + + Args: + model: The submodel to add variables and constraints to + tracked_expression: Expression that the tracker variable must equal + name: Full name for the variable and constraint + short_name: Short name for display purposes + bounds: Optional (lower_bound, upper_bound) tuple for the tracker variable + coords: Coordinate dimensions for the variable (None uses all model coords) Returns: - variables: {'tracker': tracker_var} - constraints: {'tracking': constraint} + Tuple of (tracker_variable, tracking_constraint) """ if not isinstance(model, Submodel): raise ValueError('ModelingPrimitives.expression_tracking_variable() can only be used with a Submodel') @@ -238,7 +244,7 @@ def expression_tracking_variable( @staticmethod def consecutive_duration_tracking( model: Submodel, - state_variable: linopy.Variable, + state: linopy.Variable, name: str = None, short_name: str = None, minimum_duration: xr.DataArray | None = None, @@ -246,29 +252,37 @@ def consecutive_duration_tracking( duration_dim: str = 'time', duration_per_step: int | float | xr.DataArray = None, previous_duration: xr.DataArray = 0, - ) -> tuple[linopy.Variable, tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint]]: - """ - Creates consecutive duration tracking for a binary state variable. + ) -> tuple[dict[str, linopy.Variable], dict[str, linopy.Constraint]]: + """Creates consecutive duration tracking for a binary state variable. + + Tracks how long a binary state has been continuously active (=1). + Duration resets to 0 when state becomes inactive (=0). Mathematical formulation: - duration[t] ≤ state[t] * M ∀t + duration[t] ≤ state[t] · M ∀t duration[t+1] ≤ duration[t] + duration_per_step[t] ∀t - duration[t+1] ≥ duration[t] + duration_per_step[t] + (state[t+1] - 1) * M ∀t - duration[0] = (duration_per_step[0] + previous_duration) * state[0] + duration[t+1] ≥ duration[t] + duration_per_step[t] + (state[t+1] - 1) · M ∀t + duration[0] = (duration_per_step[0] + previous_duration) · state[0] If minimum_duration provided: - duration[t] ≥ (state[t-1] - state[t]) * minimum_duration[t-1] ∀t > 0 + duration[t] ≥ (state[t-1] - state[t]) · minimum_duration[t-1] ∀t > 0 + + Where M is a big-M value (sum of all duration_per_step + previous_duration). Args: - name: Name of the duration variable - state_variable: Binary state variable to track duration for - minimum_duration: Optional minimum consecutive duration - maximum_duration: Optional maximum consecutive duration - previous_duration: Duration from before first timestep + model: The submodel to add variables and constraints to + state: Binary state variable (1=active, 0=inactive) to track duration for + name: Full name for the duration variable + short_name: Short name for display purposes + minimum_duration: Optional minimum consecutive duration (enforced at state transitions) + maximum_duration: Optional maximum consecutive duration (upper bound on duration variable) + duration_dim: Dimension name to track duration along (default 'time') + duration_per_step: Time increment per step in duration_dim + previous_duration: Initial duration value before first timestep (default 0) Returns: - variables: {'duration': duration_var} - constraints: {'ub': constraint, 'forward': constraint, 'backward': constraint, ...} + Tuple of (duration_variable, constraints_dict) + where constraints_dict contains: 'ub', 'forward', 'backward', 'initial', and optionally 'lb', 'initial_lb' """ if not isinstance(model, Submodel): raise ValueError('ModelingPrimitives.consecutive_duration_tracking() can only be used with a Submodel') @@ -279,7 +293,7 @@ def consecutive_duration_tracking( duration = model.add_variables( lower=0, upper=maximum_duration if maximum_duration is not None else mega, - coords=state_variable.coords, + coords=state.coords, name=name, short_name=short_name, ) @@ -287,7 +301,7 @@ def consecutive_duration_tracking( constraints = {} # Upper bound: duration[t] ≤ state[t] * M - constraints['ub'] = model.add_constraints(duration <= state_variable * mega, name=f'{duration.name}|ub') + constraints['ub'] = model.add_constraints(duration <= state * mega, name=f'{duration.name}|ub') # Forward constraint: duration[t+1] ≤ duration[t] + duration_per_step[t] constraints['forward'] = model.add_constraints( @@ -301,14 +315,14 @@ def consecutive_duration_tracking( duration.isel({duration_dim: slice(1, None)}) >= duration.isel({duration_dim: slice(None, -1)}) + duration_per_step.isel({duration_dim: slice(None, -1)}) - + (state_variable.isel({duration_dim: slice(1, None)}) - 1) * mega, + + (state.isel({duration_dim: slice(1, None)}) - 1) * mega, name=f'{duration.name}|backward', ) # Initial condition: duration[0] = (duration_per_step[0] + previous_duration) * state[0] constraints['initial'] = model.add_constraints( duration.isel({duration_dim: 0}) - == (duration_per_step.isel({duration_dim: 0}) + previous_duration) * state_variable.isel({duration_dim: 0}), + == (duration_per_step.isel({duration_dim: 0}) + previous_duration) * state.isel({duration_dim: 0}), name=f'{duration.name}|initial', ) @@ -316,10 +330,7 @@ def consecutive_duration_tracking( if minimum_duration is not None: constraints['lb'] = model.add_constraints( duration - >= ( - state_variable.isel({duration_dim: slice(None, -1)}) - - state_variable.isel({duration_dim: slice(1, None)}) - ) + >= (state.isel({duration_dim: slice(None, -1)}) - state.isel({duration_dim: slice(1, None)})) * minimum_duration.isel({duration_dim: slice(None, -1)}), name=f'{duration.name}|lb', ) @@ -333,7 +344,7 @@ def consecutive_duration_tracking( min0 = float(minimum_duration.isel({duration_dim: 0}).max().item()) if prev > 0 and prev < min0: constraints['initial_lb'] = model.add_constraints( - state_variable.isel({duration_dim: 0}) == 1, name=f'{duration.name}|initial_lb' + state.isel({duration_dim: 0}) == 1, name=f'{duration.name}|initial_lb' ) variables = {'duration': duration} @@ -347,23 +358,21 @@ def mutual_exclusivity_constraint( tolerance: float = 1, short_name: str = 'mutual_exclusivity', ) -> linopy.Constraint: - """ - Creates mutual exclusivity constraint for binary variables. + """Creates mutual exclusivity constraint for binary variables. - Mathematical formulation: - Σ(binary_vars[i]) ≤ tolerance ∀t + Ensures at most one binary variable can be active (=1) at any time. - Ensures at most one binary variable can be 1 at any time. - Tolerance > 1.0 accounts for binary variable numerical precision. + Mathematical formulation: + Σᵢ binary_vars[i] ≤ tolerance ∀t Args: + model: The submodel to add the constraint to binary_variables: List of binary variables that should be mutually exclusive - tolerance: Upper bound - short_name: Short name of the constraint + tolerance: Upper bound on the sum (default 1, allows slight numerical tolerance) + short_name: Short name for the constraint Returns: - variables: {} (no new variables created) - constraints: {'mutual_exclusivity': constraint} + Mutual exclusivity constraint Raises: AssertionError: If fewer than 2 variables provided or variables aren't binary @@ -396,19 +405,19 @@ def basic_bounds( bounds: tuple[xr.DataArray, xr.DataArray], name: str = None, ) -> list[linopy.constraints.Constraint]: - """Create simple bounds. - variable ∈ [lower_bound, upper_bound] + """Creates simple lower and upper bounds for a variable. - Mathematical Formulation: + Mathematical formulation: lower_bound ≤ variable ≤ upper_bound Args: - model: The optimization model instance + model: The submodel to add constraints to variable: Variable to be bounded bounds: Tuple of (lower_bound, upper_bound) absolute bounds + name: Optional name prefix for constraints Returns: - List containing lower_bound and upper_bound constraints + List of [lower_constraint, upper_constraint] """ if not isinstance(model, Submodel): raise ValueError('BoundingPatterns.basic_bounds() can only be used with a Submodel') @@ -426,29 +435,28 @@ def bounds_with_state( model: Submodel, variable: linopy.Variable, bounds: tuple[xr.DataArray, xr.DataArray], - variable_state: linopy.Variable, + state: linopy.Variable, name: str = None, ) -> list[linopy.Constraint]: - """Constraint a variable to bounds, that can be escaped from to 0 by a binary variable. - variable ∈ {0, [max(ε, lower_bound), upper_bound]} + """Creates bounds controlled by a binary state variable. + + Variable is forced to 0 when state=0, bounded when state=1. - Mathematical Formulation: - - variable_state * max(ε, lower_bound) ≤ variable ≤ variable_state * upper_bound + Mathematical formulation: + state · max(ε, lower_bound) ≤ variable ≤ state · upper_bound - Use Cases: - - Investment decisions - - Unit commitment (on/off states) + Where ε is a small positive number (CONFIG.Modeling.epsilon) ensuring + numerical stability when lower_bound is 0. Args: - model: The optimization model instance + model: The submodel to add constraints to variable: Variable to be bounded - bounds: Tuple of (lower_bound, upper_bound) absolute bounds - variable_state: Binary variable controlling the bounds + bounds: Tuple of (lower_bound, upper_bound) absolute bounds when state=1 + state: Binary variable (0=force variable to 0, 1=allow bounds) + name: Optional name prefix for constraints Returns: - Tuple containing: - - variables (Dict): Empty dict - - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb' + List of [lower_constraint, upper_constraint] (or [fix_constraint] if lower=upper) """ if not isinstance(model, Submodel): raise ValueError('BoundingPatterns.bounds_with_state() can only be used with a Submodel') @@ -457,13 +465,13 @@ def bounds_with_state( name = name or f'{variable.name}' if np.allclose(lower_bound, upper_bound, atol=1e-10, equal_nan=True): - fix_constraint = model.add_constraints(variable == variable_state * upper_bound, name=f'{name}|fix') + fix_constraint = model.add_constraints(variable == state * upper_bound, name=f'{name}|fix') return [fix_constraint] epsilon = np.maximum(CONFIG.Modeling.epsilon, lower_bound) - upper_constraint = model.add_constraints(variable <= variable_state * upper_bound, name=f'{name}|ub') - lower_constraint = model.add_constraints(variable >= variable_state * epsilon, name=f'{name}|lb') + upper_constraint = model.add_constraints(variable <= state * upper_bound, name=f'{name}|ub') + lower_constraint = model.add_constraints(variable >= state * epsilon, name=f'{name}|lb') return [lower_constraint, upper_constraint] @@ -475,26 +483,22 @@ def scaled_bounds( relative_bounds: tuple[xr.DataArray, xr.DataArray], name: str = None, ) -> list[linopy.Constraint]: - """Constraint a variable by scaling bounds, dependent on another variable. - variable ∈ [lower_bound * scaling_variable, upper_bound * scaling_variable] + """Creates bounds scaled by another variable. - Mathematical Formulation: - scaling_variable * lower_factor ≤ variable ≤ scaling_variable * upper_factor + Variable is bounded relative to a scaling variable (e.g., flow rate relative to size). - Use Cases: - - Flow rates bounded by equipment capacity - - Production levels scaled by plant size + Mathematical formulation: + scaling_variable · lower_factor ≤ variable ≤ scaling_variable · upper_factor Args: - model: The optimization model instance + model: The submodel to add constraints to variable: Variable to be bounded - scaling_variable: Variable that scales the bound factors - relative_bounds: Tuple of (lower_factor, upper_factor) relative to scaling variable + scaling_variable: Variable that scales the bound factors (e.g., equipment size) + relative_bounds: Tuple of (lower_factor, upper_factor) relative to scaling_variable + name: Optional name prefix for constraints Returns: - Tuple containing: - - variables (Dict): Empty dict - - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb' + List of [lower_constraint, upper_constraint] (or [fix_constraint] if lower=upper) """ if not isinstance(model, Submodel): raise ValueError('BoundingPatterns.scaled_bounds() can only be used with a Submodel') @@ -517,33 +521,33 @@ def scaled_bounds_with_state( scaling_variable: linopy.Variable, relative_bounds: tuple[xr.DataArray, xr.DataArray], scaling_bounds: tuple[xr.DataArray, xr.DataArray], - variable_state: linopy.Variable, + state: linopy.Variable, name: str = None, ) -> list[linopy.Constraint]: - """Constraint a variable by scaling bounds with binary state control. + """Creates bounds scaled by a variable and controlled by a binary state. - variable ∈ {0, [max(ε, lower_relative_bound) * scaling_variable, upper_relative_bound * scaling_variable]} + Variable is forced to 0 when state=0, bounded relative to scaling_variable when state=1. - Mathematical Formulation (Big-M): - (variable_state - 1) * M_misc + scaling_variable * rel_lower ≤ variable ≤ scaling_variable * rel_upper - variable_state * big_m_lower ≤ variable ≤ variable_state * big_m_upper + Mathematical formulation (Big-M): + (state - 1) · M_misc + scaling_variable · rel_lower ≤ variable ≤ scaling_variable · rel_upper + state · big_m_lower ≤ variable ≤ state · big_m_upper Where: - M_misc = scaling_max * rel_lower - big_m_upper = scaling_max * rel_upper - big_m_lower = max(ε, scaling_min * rel_lower) + M_misc = scaling_max · rel_lower + big_m_upper = scaling_max · rel_upper + big_m_lower = max(ε, scaling_min · rel_lower) Args: - model: The optimization model instance + model: The submodel to add constraints to variable: Variable to be bounded - scaling_variable: Variable that scales the bound factors - relative_bounds: Tuple of (lower_factor, upper_factor) relative to scaling variable - scaling_bounds: Tuple of (scaling_min, scaling_max) bounds of the scaling variable - variable_state: Binary variable for on/off control + scaling_variable: Variable that scales the bound factors (e.g., equipment size) + relative_bounds: Tuple of (lower_factor, upper_factor) relative to scaling_variable + scaling_bounds: Tuple of (scaling_min, scaling_max) bounds of the scaling_variable + state: Binary variable (0=force variable to 0, 1=allow scaled bounds) name: Optional name prefix for constraints Returns: - List[linopy.Constraint]: List of constraint objects + List of [scaling_lower, scaling_upper, binary_lower, binary_upper] constraints """ if not isinstance(model, Submodel): raise ValueError('BoundingPatterns.scaled_bounds_with_state() can only be used with a Submodel') @@ -555,60 +559,69 @@ def scaled_bounds_with_state( big_m_misc = scaling_max * rel_lower scaling_lower = model.add_constraints( - variable >= (variable_state - 1) * big_m_misc + scaling_variable * rel_lower, name=f'{name}|lb2' + variable >= (state - 1) * big_m_misc + scaling_variable * rel_lower, name=f'{name}|lb2' ) scaling_upper = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{name}|ub2') big_m_upper = rel_upper * scaling_max big_m_lower = np.maximum(CONFIG.Modeling.epsilon, rel_lower * scaling_min) - binary_upper = model.add_constraints(variable_state * big_m_upper >= variable, name=f'{name}|ub1') - binary_lower = model.add_constraints(variable_state * big_m_lower <= variable, name=f'{name}|lb1') + binary_upper = model.add_constraints(state * big_m_upper >= variable, name=f'{name}|ub1') + binary_lower = model.add_constraints(state * big_m_lower <= variable, name=f'{name}|lb1') return [scaling_lower, scaling_upper, binary_lower, binary_upper] @staticmethod def state_transition_bounds( model: Submodel, - state_variable: linopy.Variable, - switch_on: linopy.Variable, - switch_off: linopy.Variable, + state: linopy.Variable, + activate: linopy.Variable, + deactivate: linopy.Variable, name: str, - previous_state=0, + previous_state: float | xr.DataArray = 0, coord: str = 'time', ) -> tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint]: - """ - Creates switch-on/off variables with state transition logic. + """Creates state transition constraints for binary state variables. + + Tracks transitions between active (1) and inactive (0) states using + separate binary variables for activation and deactivation events. Mathematical formulation: - switch_on[t] - switch_off[t] = state[t] - state[t-1] ∀t > 0 - switch_on[0] - switch_off[0] = state[0] - previous_state - switch_on[t] + switch_off[t] ≤ 1 ∀t - switch_on[t], switch_off[t] ∈ {0, 1} + activate[t] - deactivate[t] = state[t] - state[t-1] ∀t > 0 + activate[0] - deactivate[0] = state[0] - previous_state + activate[t] + deactivate[t] ≤ 1 ∀t + activate[t], deactivate[t] ∈ {0, 1} + + Args: + model: The submodel to add constraints to + state: Binary state variable (0=inactive, 1=active) + activate: Binary variable for transitions from inactive to active (0→1) + deactivate: Binary variable for transitions from active to inactive (1→0) + name: Base name for constraints + previous_state: State value before first timestep (default 0) + coord: Time dimension name (default 'time') Returns: - variables: {'switch_on': binary_var, 'switch_off': binary_var} - constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint} + Tuple of (transition_constraint, initial_constraint, mutex_constraint) """ if not isinstance(model, Submodel): - raise ValueError('ModelingPrimitives.state_transition_bounds() can only be used with a Submodel') + raise ValueError('BoundingPatterns.state_transition_bounds() can only be used with a Submodel') # State transition constraints for t > 0 transition = model.add_constraints( - switch_on.isel({coord: slice(1, None)}) - switch_off.isel({coord: slice(1, None)}) - == state_variable.isel({coord: slice(1, None)}) - state_variable.isel({coord: slice(None, -1)}), + activate.isel({coord: slice(1, None)}) - deactivate.isel({coord: slice(1, None)}) + == state.isel({coord: slice(1, None)}) - state.isel({coord: slice(None, -1)}), name=f'{name}|transition', ) # Initial state transition for t = 0 initial = model.add_constraints( - switch_on.isel({coord: 0}) - switch_off.isel({coord: 0}) - == state_variable.isel({coord: 0}) - previous_state, + activate.isel({coord: 0}) - deactivate.isel({coord: 0}) == state.isel({coord: 0}) - previous_state, name=f'{name}|initial', ) - # At most one switch per timestep - mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|mutex') + # At most one transition per timestep (mutual exclusivity) + mutex = model.add_constraints(activate + deactivate <= 1, name=f'{name}|mutex') return transition, initial, mutex @@ -616,63 +629,66 @@ def state_transition_bounds( def continuous_transition_bounds( model: Submodel, continuous_variable: linopy.Variable, - switch_on: linopy.Variable, - switch_off: linopy.Variable, + activate: linopy.Variable, + deactivate: linopy.Variable, name: str, max_change: float | xr.DataArray, previous_value: float | xr.DataArray = 0.0, coord: str = 'time', ) -> tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint, linopy.Constraint]: - """ - Constrains a continuous variable to only change when switch variables are active. + """Constrains a continuous variable to only change during state transitions. + + Ensures a continuous variable remains constant unless a transition event occurs. + Uses Big-M formulation to enforce change bounds. Mathematical formulation: - -max_change * (switch_on[t] + switch_off[t]) <= continuous[t] - continuous[t-1] <= max_change * (switch_on[t] + switch_off[t]) ∀t > 0 - -max_change * (switch_on[0] + switch_off[0]) <= continuous[0] - previous_value <= max_change * (switch_on[0] + switch_off[0]) - switch_on[t], switch_off[t] ∈ {0, 1} + -max_change · (activate[t] + deactivate[t]) ≤ continuous[t] - continuous[t-1] ≤ max_change · (activate[t] + deactivate[t]) ∀t > 0 + -max_change · (activate[0] + deactivate[0]) ≤ continuous[0] - previous_value ≤ max_change · (activate[0] + deactivate[0]) + activate[t], deactivate[t] ∈ {0, 1} - This ensures the continuous variable can only change when switch_on or switch_off is 1. - When both switches are 0, the variable must stay exactly constant. + Behavior: + - When activate=0 and deactivate=0: variable must stay constant + - When activate=1 or deactivate=1: variable can change within ±max_change Args: model: The submodel to add constraints to - continuous_variable: The continuous variable to constrain - switch_on: Binary variable indicating when changes are allowed (typically transitions to active state) - switch_off: Binary variable indicating when changes are allowed (typically transitions to inactive state) - name: Base name for the constraints - max_change: Maximum possible change in the continuous variable (Big-M value) - previous_value: Initial value of the continuous variable before first period - coord: Coordinate name for time dimension + continuous_variable: Continuous variable to constrain + activate: Binary variable for transitions from inactive to active (0→1) + deactivate: Binary variable for transitions from active to inactive (1→0) + name: Base name for constraints + max_change: Maximum allowed change (Big-M value, should be ≥ actual max change) + previous_value: Initial value before first timestep (default 0.0) + coord: Time dimension name (default 'time') Returns: - Tuple of constraints: (transition_upper, transition_lower, initial_upper, initial_lower) + Tuple of (transition_upper, transition_lower, initial_upper, initial_lower) constraints """ if not isinstance(model, Submodel): raise ValueError('ModelingPrimitives.continuous_transition_bounds() can only be used with a Submodel') - # Transition constraints for t > 0: continuous variable can only change when switches are active + # Transition constraints for t > 0: continuous variable can only change when transitions occur transition_upper = model.add_constraints( continuous_variable.isel({coord: slice(1, None)}) - continuous_variable.isel({coord: slice(None, -1)}) - <= max_change * (switch_on.isel({coord: slice(1, None)}) + switch_off.isel({coord: slice(1, None)})), + <= max_change * (activate.isel({coord: slice(1, None)}) + deactivate.isel({coord: slice(1, None)})), name=f'{name}|transition_ub', ) transition_lower = model.add_constraints( -(continuous_variable.isel({coord: slice(1, None)}) - continuous_variable.isel({coord: slice(None, -1)})) - <= max_change * (switch_on.isel({coord: slice(1, None)}) + switch_off.isel({coord: slice(1, None)})), + <= max_change * (activate.isel({coord: slice(1, None)}) + deactivate.isel({coord: slice(1, None)})), name=f'{name}|transition_lb', ) # Initial constraints for t = 0 initial_upper = model.add_constraints( continuous_variable.isel({coord: 0}) - previous_value - <= max_change * (switch_on.isel({coord: 0}) + switch_off.isel({coord: 0})), + <= max_change * (activate.isel({coord: 0}) + deactivate.isel({coord: 0})), name=f'{name}|initial_ub', ) initial_lower = model.add_constraints( -continuous_variable.isel({coord: 0}) + previous_value - <= max_change * (switch_on.isel({coord: 0}) + switch_off.isel({coord: 0})), + <= max_change * (activate.isel({coord: 0}) + deactivate.isel({coord: 0})), name=f'{name}|initial_lb', ) diff --git a/flixopt/optimization.py b/flixopt/optimization.py index e537029d7..48a9f5e19 100644 --- a/flixopt/optimization.py +++ b/flixopt/optimization.py @@ -17,7 +17,7 @@ import timeit import warnings from collections import Counter -from typing import TYPE_CHECKING, Annotated, Any, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable import numpy as np from tqdm import tqdm @@ -25,8 +25,8 @@ from . import io as fx_io from .clustering import Clustering, ClusteringModel, ClusteringParameters from .components import Storage -from .config import CONFIG, SUCCESS_LEVEL -from .core import DEPRECATION_REMOVAL_VERSION, DataConverter, TimeSeriesData, drop_constant_arrays +from .config import CONFIG, DEPRECATION_REMOVAL_VERSION, SUCCESS_LEVEL +from .core import DataConverter, TimeSeriesData, drop_constant_arrays from .effects import PENALTY_EFFECT_LABEL from .features import InvestmentModel from .flow_system import FlowSystem @@ -85,7 +85,6 @@ def _initialize_optimization_common( obj: Any, name: str, flow_system: FlowSystem, - active_timesteps: pd.DatetimeIndex | None = None, folder: pathlib.Path | None = None, normalize_weights: bool = True, ) -> None: @@ -99,7 +98,6 @@ def _initialize_optimization_common( obj: The optimization object being initialized name: Name of the optimization flow_system: FlowSystem to optimize - active_timesteps: DEPRECATED. Use flow_system.sel(time=...) instead folder: Directory for saving results normalize_weights: Whether to normalize scenario weights """ @@ -112,17 +110,6 @@ def _initialize_optimization_common( ) flow_system = flow_system.copy() - if active_timesteps is not None: - warnings.warn( - f"The 'active_timesteps' parameter is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. " - 'Use flow_system.sel(time=timesteps) or flow_system.isel(time=indices) before passing ' - 'the FlowSystem to the Optimization instead.', - DeprecationWarning, - stacklevel=2, - ) - flow_system = flow_system.sel(time=active_timesteps) - - obj._active_timesteps = active_timesteps # deprecated obj.normalize_weights = normalize_weights flow_system._used_in_optimization = True @@ -155,7 +142,6 @@ class 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: Whether to automatically normalize the weights of scenarios to sum up to 1 when solving. - active_timesteps: Deprecated. Use FlowSystem.sel(time=...) or FlowSystem.isel(time=...) instead. Examples: Basic usage: @@ -182,18 +168,20 @@ def __init__( self, name: str, flow_system: FlowSystem, - active_timesteps: Annotated[ - pd.DatetimeIndex | None, - 'DEPRECATED: Use flow_system.sel(time=...) or flow_system.isel(time=...) instead', - ] = None, 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, - active_timesteps=active_timesteps, folder=folder, normalize_weights=normalize_weights, ) @@ -280,6 +268,9 @@ def solve( 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 @@ -329,15 +320,15 @@ def main_results(self) -> dict[str, int | float | dict]: 'Buses with excess': [ { bus.label_full: { - 'input': bus.submodel.excess_input.solution.sum('time'), - 'output': bus.submodel.excess_output.solution.sum('time'), + '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.with_excess + if bus.allows_imbalance and ( - bus.submodel.excess_input.solution.sum().item() > 1e-3 - or bus.submodel.excess_output.solution.sum().item() > 1e-3 + bus.submodel.virtual_supply.solution.sum().item() > 1e-3 + or bus.submodel.virtual_demand.solution.sum().item() > 1e-3 ) ], } @@ -360,16 +351,6 @@ def summary(self): 'Config': CONFIG.to_dict(), } - @property - def active_timesteps(self) -> pd.DatetimeIndex | None: - warnings.warn( - f'active_timesteps is deprecated. Use flow_system.sel(time=...) or flow_system.isel(time=...) instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self._active_timesteps - @property def modeled(self) -> bool: return True if self.model is not None else False @@ -393,7 +374,6 @@ class ClusteredOptimization(Optimization): clustering_parameters: Parameters for clustering. See ClusteringParameters class documentation components_to_clusterize: list of Components to perform aggregation on. If None, all components are aggregated. This equalizes variables in the components according to the typical periods computed in the aggregation - active_timesteps: DatetimeIndex of timesteps to use for optimization. If None, all timesteps are used folder: Folder where results should be saved. If None, current working directory is used normalize_weights: Whether to automatically normalize the weights of scenarios to sum up to 1 when solving @@ -408,21 +388,25 @@ def __init__( flow_system: FlowSystem, clustering_parameters: ClusteringParameters, components_to_clusterize: list[Component] | None = None, - active_timesteps: Annotated[ - pd.DatetimeIndex | None, - 'DEPRECATED: Use flow_system.sel(time=...) or flow_system.isel(time=...) instead', - ] = None, folder: pathlib.Path | None = None, normalize_weights: bool = True, ): + warnings.warn( + f'ClusteredOptimization is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. ' + 'Use FlowSystem.transform.cluster(params) followed by FlowSystem.optimize(solver) instead. ' + 'Example: clustered_fs = flow_system.transform.cluster(params); clustered_fs.optimize(solver)', + DeprecationWarning, + stacklevel=2, + ) if flow_system.scenarios is not None: raise ValueError('Clustering is not supported for scenarios yet. Please use Optimization instead.') if flow_system.periods is not None: raise ValueError('Clustering is not supported for periods yet. Please use Optimization instead.') - super().__init__( + # Skip parent deprecation warning by calling common init directly + _initialize_optimization_common( + self, name=name, flow_system=flow_system, - active_timesteps=active_timesteps, folder=folder, normalize_weights=normalize_weights, ) @@ -485,7 +469,8 @@ def _perform_clustering(self): ) self.clustering.cluster() - self.clustering.plot(show=CONFIG.Plotting.default_show, save=self.folder / 'clustering.html') + result = self.clustering.plot(show=CONFIG.Plotting.default_show) + result.to_html(self.folder / 'clustering.html') if self.clustering_parameters.aggregate_data_and_fix_non_binary_vars: ds = self.flow_system.to_dataset() for name, series in self.clustering.aggregated_data.items(): @@ -503,26 +488,10 @@ def _perform_clustering(self): self.flow_system.connect_and_transform() self.durations['clustering'] = round(timeit.default_timer() - t_start_agg, 2) - def _perform_aggregation(self): - """Deprecated: Use _perform_clustering instead.""" - warnings.warn( - f'_perform_aggregation is deprecated, use _perform_clustering instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self._perform_clustering() - @classmethod def calculate_clustering_weights(cls, ds: xr.Dataset) -> dict[str, float]: """Calculate weights for all datavars in the dataset. Weights are pulled from the attrs of the datavars.""" - - # Support both old and new attr names for backward compatibility - groups = [ - da.attrs.get('clustering_group', da.attrs.get('aggregation_group')) - for da in ds.data_vars.values() - if 'clustering_group' in da.attrs or 'aggregation_group' in da.attrs - ] + groups = [da.attrs.get('clustering_group') for da in ds.data_vars.values() if 'clustering_group' in da.attrs] group_counts = Counter(groups) # Calculate weight for each group (1/count) @@ -530,31 +499,18 @@ def calculate_clustering_weights(cls, ds: xr.Dataset) -> dict[str, float]: weights = {} for name, da in ds.data_vars.items(): - # Try both old and new attr names - clustering_group = da.attrs.get('clustering_group', da.attrs.get('aggregation_group')) + clustering_group = da.attrs.get('clustering_group') group_weight = group_weights.get(clustering_group) if group_weight is not None: weights[name] = group_weight else: - # Try both old and new attr names for weight - weights[name] = da.attrs.get('clustering_weight', da.attrs.get('aggregation_weight', 1)) + weights[name] = da.attrs.get('clustering_weight', 1) if np.all(np.isclose(list(weights.values()), 1, atol=1e-6)): logger.info('All Clustering weights were set to 1') return weights - @classmethod - def calculate_aggregation_weights(cls, ds: xr.Dataset) -> dict[str, float]: - """Deprecated: Use calculate_clustering_weights instead.""" - warnings.warn( - f'calculate_aggregation_weights is deprecated, use calculate_clustering_weights instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return cls.calculate_clustering_weights(ds) - class SegmentedOptimization: """Solve large optimization problems by dividing time horizon into (overlapping) segments. @@ -673,7 +629,6 @@ class SegmentedOptimization: durations: dict[str, float] model: None # SegmentedOptimization doesn't use a single model normalize_weights: bool - _active_timesteps: pd.DatetimeIndex | None def __init__( self, @@ -684,11 +639,16 @@ def __init__( 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, - active_timesteps=None, folder=folder, ) self.timesteps_per_segment = timesteps_per_segment @@ -977,13 +937,3 @@ def summary(self): 'Durations': self.durations, 'Config': CONFIG.to_dict(), } - - @property - def active_timesteps(self) -> pd.DatetimeIndex | None: - warnings.warn( - f'active_timesteps is deprecated. Use flow_system.sel(time=...) or flow_system.isel(time=...) instead. ' - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - return self._active_timesteps diff --git a/flixopt/optimize_accessor.py b/flixopt/optimize_accessor.py new file mode 100644 index 000000000..f88cdf982 --- /dev/null +++ b/flixopt/optimize_accessor.py @@ -0,0 +1,361 @@ +""" +Optimization accessor for FlowSystem. + +This module provides the OptimizeAccessor class that enables the +`flow_system.optimize(...)` pattern with extensible optimization methods. +""" + +from __future__ import annotations + +import logging +import sys +from typing import TYPE_CHECKING + +import xarray as xr +from tqdm import tqdm + +from .config import CONFIG +from .io import suppress_output + +if TYPE_CHECKING: + from .flow_system import FlowSystem + from .solvers import _Solver + +logger = logging.getLogger('flixopt') + + +class OptimizeAccessor: + """ + Accessor for optimization methods on FlowSystem. + + This class provides the optimization API for FlowSystem, accessible via + `flow_system.optimize`. It supports both direct calling (standard optimization) + and method access for specialized optimization modes. + + Examples: + Standard optimization (via __call__): + + >>> flow_system.optimize(solver) + >>> print(flow_system.solution) + + Rolling horizon optimization: + + >>> segments = flow_system.optimize.rolling_horizon(solver, horizon=168) + >>> print(flow_system.solution) # Combined result + """ + + def __init__(self, flow_system: FlowSystem) -> None: + """ + Initialize the accessor with a reference to the FlowSystem. + + Args: + flow_system: The FlowSystem to optimize. + """ + self._fs = flow_system + + def __call__(self, solver: _Solver, normalize_weights: bool = True) -> FlowSystem: + """ + Build and solve the optimization model in one step. + + This is a convenience method that combines `build_model()` and `solve()`. + Use this for simple optimization workflows. For more control (e.g., inspecting + the model before solving, or adding custom constraints), use `build_model()` + and `solve()` separately. + + Args: + solver: The solver to use (e.g., HighsSolver, GurobiSolver). + normalize_weights: Whether to normalize scenario/period weights to sum to 1. + + Returns: + The FlowSystem, for method chaining. + + Examples: + Simple optimization: + + >>> flow_system.optimize(HighsSolver()) + >>> print(flow_system.solution['Boiler(Q_th)|flow_rate']) + + Access element solutions directly: + + >>> flow_system.optimize(solver) + >>> boiler = flow_system.components['Boiler'] + >>> print(boiler.solution) + + Method chaining: + + >>> solution = flow_system.optimize(solver).solution + """ + self._fs.build_model(normalize_weights) + self._fs.solve(solver) + return self._fs + + def rolling_horizon( + self, + solver: _Solver, + horizon: int = 100, + overlap: int = 0, + nr_of_previous_values: int = 1, + ) -> list[FlowSystem]: + """ + Solve the optimization using a rolling horizon approach. + + Divides the time horizon into overlapping segments that are solved sequentially. + Each segment uses final values from the previous segment as initial conditions, + ensuring dynamic continuity across the solution. The combined solution is stored + on the original FlowSystem. + + This approach is useful for: + - Large-scale problems that exceed memory limits + - Annual planning with seasonal variations + - Operational planning with limited foresight + + Args: + solver: The solver to use (e.g., HighsSolver, GurobiSolver). + horizon: Number of timesteps in each segment (excluding overlap). + Must be > 2. Larger values provide better optimization at the cost + of memory and computation time. Default: 100. + overlap: Number of additional timesteps added to each segment for lookahead. + Improves storage optimization by providing foresight. Higher values + improve solution quality but increase computational cost. Default: 0. + nr_of_previous_values: Number of previous timestep values to transfer between + segments for initialization (e.g., for uptime/downtime tracking). Default: 1. + + Returns: + List of segment FlowSystems, each with their individual solution. + The combined solution (with overlaps trimmed) is stored on the original FlowSystem. + + Raises: + ValueError: If horizon <= 2 or overlap < 0. + ValueError: If horizon + overlap > total timesteps. + ValueError: If InvestParameters are used (not supported in rolling horizon). + + Examples: + Basic rolling horizon optimization: + + >>> segments = flow_system.optimize.rolling_horizon( + ... solver, + ... horizon=168, # Weekly segments + ... overlap=24, # 1-day lookahead + ... ) + >>> print(flow_system.solution) # Combined result + + Inspect individual segments: + + >>> for i, seg in enumerate(segments): + ... print(f'Segment {i}: {seg.solution["costs"].item():.2f}') + + Note: + - InvestParameters are not supported as investment decisions require + full-horizon optimization. + - Global constraints (flow_hours_max, etc.) may produce suboptimal results + as they cannot be enforced globally across segments. + - Storage optimization may be suboptimal compared to full-horizon solutions + due to limited foresight in each segment. + """ + + # Validation + if horizon <= 2: + raise ValueError('horizon must be greater than 2 to avoid internal side effects.') + if overlap < 0: + raise ValueError('overlap must be non-negative.') + if nr_of_previous_values < 0: + raise ValueError('nr_of_previous_values must be non-negative.') + if nr_of_previous_values > horizon: + raise ValueError('nr_of_previous_values cannot exceed horizon.') + + total_timesteps = len(self._fs.timesteps) + horizon_with_overlap = horizon + overlap + + if horizon_with_overlap > total_timesteps: + raise ValueError( + f'horizon + overlap ({horizon_with_overlap}) cannot exceed total timesteps ({total_timesteps}).' + ) + + # Ensure flow system is connected + if not self._fs.connected_and_transformed: + self._fs.connect_and_transform() + + # Calculate segment indices + segment_indices = self._calculate_segment_indices(total_timesteps, horizon, overlap) + n_segments = len(segment_indices) + logger.info( + f'Starting Rolling Horizon Optimization - Segments: {n_segments}, Horizon: {horizon}, Overlap: {overlap}' + ) + + # Create and solve segments + segment_flow_systems: list[FlowSystem] = [] + + progress_bar = tqdm( + enumerate(segment_indices), + total=n_segments, + desc='Solving segments', + unit='segment', + file=sys.stdout, + disable=not CONFIG.Solving.log_to_console, + ) + + try: + for i, (start_idx, end_idx) in progress_bar: + progress_bar.set_description(f'Segment {i + 1}/{n_segments} (timesteps {start_idx}-{end_idx})') + + # Suppress output when progress bar is shown (including logger and solver) + if CONFIG.Solving.log_to_console: + # Temporarily raise logger level to suppress INFO messages + original_level = logger.level + logger.setLevel(logging.WARNING) + try: + with suppress_output(): + segment_fs = self._fs.transform.isel(time=slice(start_idx, end_idx)) + if i > 0 and nr_of_previous_values > 0: + self._transfer_state( + source_fs=segment_flow_systems[i - 1], + target_fs=segment_fs, + horizon=horizon, + nr_of_previous_values=nr_of_previous_values, + ) + segment_fs.build_model() + if i == 0: + self._check_no_investments(segment_fs) + segment_fs.solve(solver) + finally: + logger.setLevel(original_level) + else: + segment_fs = self._fs.transform.isel(time=slice(start_idx, end_idx)) + if i > 0 and nr_of_previous_values > 0: + self._transfer_state( + source_fs=segment_flow_systems[i - 1], + target_fs=segment_fs, + horizon=horizon, + nr_of_previous_values=nr_of_previous_values, + ) + segment_fs.build_model() + if i == 0: + self._check_no_investments(segment_fs) + segment_fs.solve(solver) + + segment_flow_systems.append(segment_fs) + + finally: + progress_bar.close() + + # Combine segment solutions + logger.info('Combining segment solutions...') + self._finalize_solution(segment_flow_systems, horizon) + + logger.info(f'Rolling horizon optimization completed: {n_segments} segments solved.') + + return segment_flow_systems + + def _calculate_segment_indices(self, total_timesteps: int, horizon: int, overlap: int) -> list[tuple[int, int]]: + """Calculate start and end indices for each segment.""" + segments = [] + start = 0 + while start < total_timesteps: + end = min(start + horizon + overlap, total_timesteps) + segments.append((start, end)) + start += horizon # Move by horizon (not horizon + overlap) + if end == total_timesteps: + break + return segments + + def _transfer_state( + self, + source_fs: FlowSystem, + target_fs: FlowSystem, + horizon: int, + nr_of_previous_values: int, + ) -> None: + """Transfer final state from source segment to target segment. + + Transfers: + - Flow previous_flow_rate: Last nr_of_previous_values from non-overlap portion + - Storage initial_charge_state: Charge state at end of non-overlap portion + """ + from .components import Storage + + solution = source_fs.solution + time_slice = slice(horizon - nr_of_previous_values, horizon) + + # Transfer flow rates (for uptime/downtime tracking) + for label, target_flow in target_fs.flows.items(): + var_name = f'{label}|flow_rate' + if var_name in solution: + values = solution[var_name].isel(time=time_slice).values + target_flow.previous_flow_rate = values.item() if values.size == 1 else values + + # Transfer storage charge states + for label, target_comp in target_fs.components.items(): + if isinstance(target_comp, Storage): + var_name = f'{label}|charge_state' + if var_name in solution: + target_comp.initial_charge_state = solution[var_name].isel(time=horizon).item() + + def _check_no_investments(self, segment_fs: FlowSystem) -> None: + """Check that no InvestParameters are used (not supported in rolling horizon).""" + from .features import InvestmentModel + + invest_elements = [] + for component in segment_fs.components.values(): + for model in component.submodel.all_submodels: + if isinstance(model, InvestmentModel): + invest_elements.append(model.label_full) + + if invest_elements: + raise ValueError( + f'InvestParameters are not supported in rolling horizon optimization. ' + f'Found InvestmentModels: {invest_elements}. ' + f'Use standard optimize() for problems with investments.' + ) + + def _finalize_solution( + self, + segment_flow_systems: list[FlowSystem], + horizon: int, + ) -> None: + """Combine segment solutions and compute derived values directly (no re-solve).""" + # Combine all solution variables from segments + combined_solution = self._combine_solutions(segment_flow_systems, horizon) + + # Assign combined solution to the original FlowSystem + self._fs._solution = combined_solution + + def _combine_solutions( + self, + segment_flow_systems: list[FlowSystem], + horizon: int, + ) -> xr.Dataset: + """Combine segment solutions into a single Dataset. + + - Time-dependent variables: concatenated with overlap trimming + - Effect temporal/total: recomputed from per-timestep values + - Other scalars (including periodic): NaN (not meaningful for rolling horizon) + """ + if not segment_flow_systems: + raise ValueError('No segments to combine.') + + effect_labels = set(self._fs.effects.keys()) + combined_vars: dict[str, xr.DataArray] = {} + first_solution = segment_flow_systems[0].solution + + # Step 1: Time-dependent → concatenate; Scalars → NaN + for var_name, first_var in first_solution.data_vars.items(): + if 'time' in first_var.dims: + arrays = [ + seg.solution[var_name].isel( + time=slice(None, horizon if i < len(segment_flow_systems) - 1 else None) + ) + for i, seg in enumerate(segment_flow_systems) + ] + combined_vars[var_name] = xr.concat(arrays, dim='time') + else: + combined_vars[var_name] = xr.DataArray(float('nan')) + + # Step 2: Recompute effect totals from per-timestep values + for effect in effect_labels: + per_ts = f'{effect}(temporal)|per_timestep' + if per_ts in combined_vars: + temporal_sum = combined_vars[per_ts].sum(dim='time', skipna=True) + combined_vars[f'{effect}(temporal)'] = temporal_sum + combined_vars[effect] = temporal_sum # Total = temporal (periodic is NaN/unsupported) + + return xr.Dataset(combined_vars) diff --git a/flixopt/plot_result.py b/flixopt/plot_result.py new file mode 100644 index 000000000..683fbcf3e --- /dev/null +++ b/flixopt/plot_result.py @@ -0,0 +1,143 @@ +"""Plot result container for unified plotting API. + +This module provides the PlotResult class that wraps plotting outputs +across the entire flixopt package, ensuring a consistent interface. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from pathlib import Path + + import plotly.graph_objects as go + import xarray as xr + + +@dataclass +class PlotResult: + """Container returned by all plot methods. Holds both data and figure. + + This class provides a unified interface for all plotting methods across + the flixopt package, enabling consistent method chaining and export options. + + Attributes: + data: Prepared xarray Dataset used for the plot. + figure: Plotly figure object. + + Examples: + Basic usage with chaining: + + >>> result = flow_system.statistics.plot.balance('Bus') + >>> result.show().to_html('plot.html') + + Accessing underlying data: + + >>> result = flow_system.statistics.plot.flows() + >>> df = result.data.to_dataframe() + >>> result.to_csv('data.csv') + + Customizing the figure: + + >>> result = clustering.plot() + >>> result.update(title='My Custom Title').show() + """ + + data: xr.Dataset + figure: go.Figure + + def _repr_html_(self) -> str: + """Return HTML representation for Jupyter notebook display.""" + return self.figure.to_html(full_html=False, include_plotlyjs='cdn') + + def show(self) -> PlotResult: + """Display the figure. Returns self for chaining.""" + self.figure.show() + return self + + def update(self, **layout_kwargs: Any) -> PlotResult: + """Update figure layout. Returns self for chaining. + + Args: + **layout_kwargs: Arguments passed to plotly's update_layout(). + + Returns: + Self for method chaining. + + Examples: + >>> result.update(title='New Title', height=600) + """ + self.figure.update_layout(**layout_kwargs) + return self + + def update_traces(self, **trace_kwargs: Any) -> PlotResult: + """Update figure traces. Returns self for chaining. + + Args: + **trace_kwargs: Arguments passed to plotly's update_traces(). + + Returns: + Self for method chaining. + + Examples: + >>> result.update_traces(line_width=2, marker_size=8) + """ + self.figure.update_traces(**trace_kwargs) + return self + + def to_html(self, path: str | Path) -> PlotResult: + """Save figure as interactive HTML. Returns self for chaining. + + Args: + path: File path for the HTML output. + + Returns: + Self for method chaining. + """ + self.figure.write_html(str(path)) + return self + + def to_image(self, path: str | Path, **kwargs: Any) -> PlotResult: + """Save figure as static image. Returns self for chaining. + + Args: + path: File path for the image (format inferred from extension). + **kwargs: Additional arguments passed to write_image(). + + Returns: + Self for method chaining. + + Examples: + >>> result.to_image('plot.png', scale=2) + >>> result.to_image('plot.svg') + """ + self.figure.write_image(str(path), **kwargs) + return self + + def to_csv(self, path: str | Path, **kwargs: Any) -> PlotResult: + """Export the underlying data to CSV. Returns self for chaining. + + Args: + path: File path for the CSV output. + **kwargs: Additional arguments passed to to_csv(). + + Returns: + Self for method chaining. + """ + self.data.to_dataframe().to_csv(path, **kwargs) + return self + + def to_netcdf(self, path: str | Path, **kwargs: Any) -> PlotResult: + """Export the underlying data to netCDF. Returns self for chaining. + + Args: + path: File path for the netCDF output. + **kwargs: Additional arguments passed to to_netcdf(). + + Returns: + Self for method chaining. + """ + self.data.to_netcdf(path, **kwargs) + return self diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 0a8dfbc9b..db5a3eb5c 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -39,7 +39,7 @@ import plotly.offline import xarray as xr -from .color_processing import process_colors +from .color_processing import ColorType, process_colors from .config import CONFIG if TYPE_CHECKING: @@ -66,56 +66,6 @@ plt.register_cmap(name='portland', cmap=mcolors.LinearSegmentedColormap.from_list('portland', _portland_colors)) -ColorType = str | list[str] | dict[str, str] -"""Flexible color specification type supporting multiple input formats for visualization. - -Color specifications can take several forms to accommodate different use cases: - -**Named colorscales** (str): - - Standard colorscales: 'turbo', 'plasma', 'cividis', 'tab10', 'Set1' - - Energy-focused: 'portland' (custom flixopt colorscale for energy systems) - - Backend-specific maps available in Plotly and Matplotlib - -**Color Lists** (list[str]): - - Explicit color sequences: ['red', 'blue', 'green', 'orange'] - - HEX codes: ['#FF0000', '#0000FF', '#00FF00', '#FFA500'] - - Mixed formats: ['red', '#0000FF', 'green', 'orange'] - -**Label-to-Color Mapping** (dict[str, str]): - - Explicit associations: {'Wind': 'skyblue', 'Solar': 'gold', 'Gas': 'brown'} - - Ensures consistent colors across different plots and datasets - - Ideal for energy system components with semantic meaning - -Examples: - ```python - # Named colorscale - colors = 'turbo' # Automatic color generation - - # Explicit color list - colors = ['red', 'blue', 'green', '#FFD700'] - - # Component-specific mapping - colors = { - 'Wind_Turbine': 'skyblue', - 'Solar_Panel': 'gold', - 'Natural_Gas': 'brown', - 'Battery': 'green', - 'Electric_Load': 'darkred' - } - ``` - -Color Format Support: - - **Named Colors**: 'red', 'blue', 'forestgreen', 'darkorange' - - **HEX Codes**: '#FF0000', '#0000FF', '#228B22', '#FF8C00' - - **RGB Tuples**: (255, 0, 0), (0, 0, 255) [Matplotlib only] - - **RGBA**: 'rgba(255,0,0,0.8)' [Plotly only] - -References: - - HTML Color Names: https://htmlcolorcodes.com/color-names/ - - Matplotlib colorscales: https://matplotlib.org/stable/tutorials/colors/colorscales.html - - Plotly Built-in Colorscales: https://plotly.com/python/builtin-colorscales/ -""" - PlottingEngine = Literal['plotly', 'matplotlib'] """Identifier for the plotting engine to use.""" @@ -1192,6 +1142,57 @@ def draw_pie(ax, labels, values, subtitle): return fig, axes +def heatmap_with_plotly_v2( + data: xr.DataArray, + colors: ColorType | None = None, + title: str = '', + facet_col: str | None = None, + animation_frame: str | None = None, + facet_col_wrap: int | None = None, + **imshow_kwargs: Any, +) -> go.Figure: + """ + Plot a heatmap using Plotly's imshow. + + Data should be prepared with dims in order: (y_axis, x_axis, [facet_col], [animation_frame]). + Use reshape_data_for_heatmap() to prepare time-series data before calling this. + + Args: + data: DataArray with 2-4 dimensions. First two are heatmap axes. + colors: Colorscale name ('viridis', 'plasma', etc.). + title: Plot title. + facet_col: Dimension name for subplot columns (3rd dim). + animation_frame: Dimension name for animation (4th dim). + facet_col_wrap: Max columns before wrapping (only if < n_facets). + **imshow_kwargs: Additional args for px.imshow. + + Returns: + Plotly Figure object. + """ + if data.size == 0: + return go.Figure() + + colors = colors or CONFIG.Plotting.default_sequential_colorscale + facet_col_wrap = facet_col_wrap or CONFIG.Plotting.default_facet_cols + + imshow_args: dict[str, Any] = { + 'img': data, + 'color_continuous_scale': colors, + 'title': title, + **imshow_kwargs, + } + + if facet_col and facet_col in data.dims: + imshow_args['facet_col'] = facet_col + if facet_col_wrap < data.sizes[facet_col]: + imshow_args['facet_col_wrap'] = facet_col_wrap + + if animation_frame and animation_frame in data.dims: + imshow_args['animation_frame'] = animation_frame + + return px.imshow(**imshow_args) + + def heatmap_with_plotly( data: xr.DataArray, colors: ColorType | None = None, diff --git a/flixopt/results.py b/flixopt/results.py index 6b9a1c580..16d88743a 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -2,6 +2,7 @@ import copy import datetime +import json import logging import pathlib import warnings @@ -47,6 +48,18 @@ def load_mapping_from_file(path: pathlib.Path) -> dict[str, str | list[str]]: 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.""" @@ -209,7 +222,6 @@ def __init__( summary: dict, folder: pathlib.Path | None = None, model: linopy.Model | None = None, - **kwargs, # To accept old "flow_system" parameter ): """Initialize Results with optimization data. Usually, this class is instantiated by an Optimization object via `Results.from_optimization()` @@ -222,28 +234,15 @@ def __init__( summary: Optimization metadata. folder: Results storage folder. model: Linopy optimization model. - Deprecated: - flow_system: Use flow_system_data instead. - - Note: - The legacy alias `CalculationResults` is deprecated. Use `Results` instead. """ - # Handle potential old "flow_system" parameter for backward compatibility - if 'flow_system' in kwargs and flow_system_data is None: - flow_system_data = kwargs.pop('flow_system') - warnings.warn( - "The 'flow_system' parameter is deprecated. Use 'flow_system_data' instead. " - "Access is now via '.flow_system_data', while '.flow_system' returns the restored FlowSystem. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - - # Validate that flow_system_data is provided - if flow_system_data is None: - raise TypeError( - "flow_system_data is required (or use deprecated 'flow_system' for backward compatibility)." - ) + 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 @@ -254,19 +253,25 @@ def __init__( # Create ResultsContainers for better access patterns components_dict = { - label: ComponentResults(self, **infos) for label, infos in self.solution.attrs['Components'].items() + 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 self.solution.attrs['Buses'].items()} + 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 self.solution.attrs['Effects'].items()} + 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) - if 'Flows' not in self.solution.attrs: + 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.', @@ -275,9 +280,7 @@ def __init__( flows_dict = {} self._has_flow_data = False else: - flows_dict = { - label: FlowResults(self, **infos) for label, infos in self.solution.attrs.get('Flows', {}).items() - } + 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) @@ -409,7 +412,7 @@ def setup_colors( 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) + 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 @@ -564,21 +567,40 @@ def flow_rates( ) -> xr.DataArray: """Returns a DataArray containing the flow rates of each Flow. - 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. + .. deprecated:: + Use `results.plot.all_flow_rates` (Dataset) or + `results.flows['FlowLabel'].flow_rate` (DataArray) instead. - Further usage: - Convert the dataarray to a dataframe: - >>>results.flow_rates().to_pandas() - Get the max or min over time: - >>>results.flow_rates().max('time') - Sum up the flow rates of flows with the same start and end: - >>>results.flow_rates(end='Fernwärme').groupby('start').sum(dim='flow') - To recombine filtered dataarrays, use `xr.concat` with dim 'flow': - >>>xr.concat([results.flow_rates(start='Fernwärme'), results.flow_rates(end='Fernwärme')], dim='flow') + **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: @@ -599,6 +621,32 @@ def flow_hours( ) -> 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.hours_per_timestep` 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. @@ -618,6 +666,14 @@ def flow_hours( >>>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.hours_per_timestep).rename('flow_hours') filters = {k: v for k, v in {'start': start, 'end': end, 'component': component}.items() if v is not None} @@ -630,18 +686,41 @@ def sizes( component: str | list[str] | None = None, ) -> xr.DataArray: """Returns a dataset with the sizes of the Flows. - 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.sizes().to_pandas() - To recombine filtered dataarrays, use `xr.concat` with dim 'flow': - >>>xr.concat([results.sizes(start='Fernwärme'), results.sizes(end='Fernwärme')], dim='flow') + .. 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: @@ -909,11 +988,6 @@ def plot_heatmap( | Literal['auto'] | None = 'auto', fill: Literal['ffill', 'bfill'] | None = 'ffill', - # Deprecated parameters (kept for backwards compatibility) - indexer: dict[FlowSystemDimensions, Any] | None = None, - heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] | None = None, - heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] | None = None, - color_map: str | None = None, **plot_kwargs: Any, ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]: """ @@ -1030,10 +1104,6 @@ def plot_heatmap( facet_cols=facet_cols, reshape_time=reshape_time, fill=fill, - indexer=indexer, - heatmap_timeframes=heatmap_timeframes, - heatmap_timesteps_per_frame=heatmap_timesteps_per_frame, - color_map=color_map, **plot_kwargs, ) @@ -1059,6 +1129,61 @@ def plot_network( 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, @@ -1122,47 +1247,14 @@ def to_file( logger.log(SUCCESS_LEVEL, f'Saved optimization results "{name}" to {paths.model_documentation.parent}') -class CalculationResults(Results): - """DEPRECATED: Use Results instead. - - Backwards-compatible alias for Results class. - All functionality is inherited from Results. - """ - - def __init__(self, *args, **kwargs): - # Only warn if directly instantiating CalculationResults (not subclasses) - if self.__class__.__name__ == 'CalculationResults': - warnings.warn( - f'CalculationResults is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. Use Results instead.', - DeprecationWarning, - stacklevel=2, - ) - super().__init__(*args, **kwargs) - - @classmethod - def from_calculation(cls, calculation: Optimization) -> CalculationResults: - """Create CalculationResults from a Calculation object. - - DEPRECATED: Use Results.from_optimization() instead. - Backwards-compatible method that redirects to from_optimization(). - - Args: - calculation: Calculation object with solved model. - - Returns: - CalculationResults: New instance with extracted results. - """ - return cls.from_optimization(calculation) - - 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.variable_names = variables self._constraint_names = constraints - self.solution = self._results.solution[self._variable_names] + self.solution = self._results.solution[self.variable_names] @property def variables(self) -> linopy.Variables: @@ -1173,7 +1265,7 @@ def variables(self) -> linopy.Variables: """ if self._results.model is None: raise ValueError('The linopy model is not available.') - return self._results.model.variables[self._variable_names] + return self._results.model.variables[self.variable_names] @property def constraints(self) -> linopy.Constraints: @@ -1265,8 +1357,6 @@ def plot_node_balance( facet_by: str | list[str] | None = 'scenario', animate_by: str | None = 'period', facet_cols: int | None = None, - # Deprecated parameter (kept for backwards compatibility) - indexer: dict[FlowSystemDimensions, Any] | None = None, **plot_kwargs: Any, ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]: """ @@ -1367,22 +1457,6 @@ def plot_node_balance( >>> fig.update_layout(template='plotly_dark', width=1200, height=600) >>> fig.show() """ - # Handle deprecated indexer parameter - if indexer is not None: - # Check for conflict with new parameter - if select is not None: - raise ValueError( - "Cannot use both deprecated parameter 'indexer' and new parameter 'select'. Use only 'select'." - ) - - warnings.warn( - f"The 'indexer' parameter is deprecated and will be removed in a future version. Use 'select' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - select = indexer - if engine not in {'plotly', 'matplotlib'}: raise ValueError(f'Engine "{engine}" not supported. Use one of ["plotly", "matplotlib"]') @@ -1450,8 +1524,6 @@ def plot_node_balance_pie( show: bool | None = None, engine: plotting.PlottingEngine = 'plotly', select: dict[FlowSystemDimensions, Any] | None = None, - # Deprecated parameter (kept for backwards compatibility) - indexer: 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. @@ -1501,22 +1573,6 @@ def plot_node_balance_pie( >>> results['Bus'].plot_node_balance_pie(save='figure.png', dpi=600) """ - # Handle deprecated indexer parameter - if indexer is not None: - # Check for conflict with new parameter - if select is not None: - raise ValueError( - "Cannot use both deprecated parameter 'indexer' and new parameter 'select'. Use only 'select'." - ) - - warnings.warn( - f"The 'indexer' parameter is deprecated and will be removed in a future version. Use 'select' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - select = indexer - # Extract dpi for export_figure dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi @@ -1624,8 +1680,6 @@ def node_balance( unit_type: Literal['flow_rate', 'flow_hours'] = 'flow_rate', drop_suffix: bool = False, select: dict[FlowSystemDimensions, Any] | None = None, - # Deprecated parameter (kept for backwards compatibility) - indexer: dict[FlowSystemDimensions, Any] | None = None, ) -> xr.Dataset: """ Returns a dataset with the node balance of the Component or Bus. @@ -1640,22 +1694,6 @@ def node_balance( drop_suffix: Whether to drop the suffix from the variable names. select: Optional data selection dict. Supports single values, lists, slices, and index arrays. """ - # Handle deprecated indexer parameter - if indexer is not None: - # Check for conflict with new parameter - if select is not None: - raise ValueError( - "Cannot use both deprecated parameter 'indexer' and new parameter 'select'. Use only 'select'." - ) - - warnings.warn( - f"The 'indexer' parameter is deprecated and will be removed in a future version. Use 'select' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - select = indexer - ds = self.solution[self.inputs + self.outputs] ds = sanitize_dataset( @@ -1692,7 +1730,7 @@ class ComponentResults(_NodeResults): @property def is_storage(self) -> bool: - return self._charge_state in self._variable_names + return self._charge_state in self.variable_names @property def _charge_state(self) -> str: @@ -1716,8 +1754,6 @@ def plot_charge_state( facet_by: str | list[str] | None = 'scenario', animate_by: str | None = 'period', facet_cols: int | None = None, - # Deprecated parameter (kept for backwards compatibility) - indexer: dict[FlowSystemDimensions, Any] | 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. @@ -1786,22 +1822,6 @@ def plot_charge_state( >>> results['Storage'].plot_charge_state(save='storage.png', dpi=600) """ - # Handle deprecated indexer parameter - if indexer is not None: - # Check for conflict with new parameter - if select is not None: - raise ValueError( - "Cannot use both deprecated parameter 'indexer' and new parameter 'select'. Use only 'select'." - ) - - warnings.warn( - f"The 'indexer' parameter is deprecated and will be removed in a future version. Use 'select' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - select = indexer - # Extract dpi for export_figure dpi = plot_kwargs.pop('dpi', None) # None uses CONFIG.Plotting.default_dpi @@ -1971,7 +1991,7 @@ def get_shares_from(self, element: str) -> xr.Dataset: Returns: xr.Dataset: Element shares to this effect. """ - return self.solution[[name for name in self._variable_names if name.startswith(f'{element}->')]] + return self.solution[[name for name in self.variable_names if name.startswith(f'{element}->')]] class FlowResults(_ElementResults): @@ -2169,6 +2189,12 @@ def __init__( 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 @@ -2280,10 +2306,6 @@ def plot_heatmap( animate_by: str | None = None, facet_cols: int | None = None, fill: Literal['ffill', 'bfill'] | None = 'ffill', - # Deprecated parameters (kept for backwards compatibility) - heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] | None = None, - heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] | None = None, - color_map: str | None = None, **plot_kwargs: Any, ) -> plotly.graph_objs.Figure | tuple[plt.Figure, plt.Axes]: """Plot heatmap of variable solution across segments. @@ -2302,9 +2324,6 @@ def plot_heatmap( 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'. - heatmap_timeframes: (Deprecated) Use reshape_time instead. - heatmap_timesteps_per_frame: (Deprecated) Use reshape_time instead. - color_map: (Deprecated) Use colors instead. **plot_kwargs: Additional plotting customization options. Common options: @@ -2320,41 +2339,6 @@ def plot_heatmap( Returns: Figure object. """ - # Handle deprecated parameters - if heatmap_timeframes is not None or heatmap_timesteps_per_frame is not None: - # Check for conflict with new parameter - if reshape_time != 'auto': # Check if user explicitly set reshape_time - raise ValueError( - "Cannot use both deprecated parameters 'heatmap_timeframes'/'heatmap_timesteps_per_frame' " - "and new parameter 'reshape_time'. Use only 'reshape_time'." - ) - - warnings.warn( - "The 'heatmap_timeframes' and 'heatmap_timesteps_per_frame' parameters are deprecated. " - f"Use 'reshape_time=(timeframes, timesteps_per_frame)' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - # Override reshape_time if old parameters provided - if heatmap_timeframes is not None and heatmap_timesteps_per_frame is not None: - reshape_time = (heatmap_timeframes, heatmap_timesteps_per_frame) - - if color_map is not None: - # Check for conflict with new parameter - if colors is not None: # Check if user explicitly set colors - raise ValueError( - "Cannot use both deprecated parameter 'color_map' and new parameter 'colors'. Use only 'colors'." - ) - - warnings.warn( - f"The 'color_map' parameter is deprecated. Use 'colors' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - colors = color_map - return plot_heatmap( data=self.solution_without_overlap(variable_name), name=variable_name, @@ -2412,40 +2396,6 @@ def to_file( logger.info(f'Saved optimization "{name}" to {path}') -class SegmentedCalculationResults(SegmentedResults): - """DEPRECATED: Use SegmentedResults instead. - - Backwards-compatible alias for SegmentedResults class. - All functionality is inherited from SegmentedResults. - """ - - def __init__(self, *args, **kwargs): - # Only warn if directly instantiating SegmentedCalculationResults (not subclasses) - if self.__class__.__name__ == 'SegmentedCalculationResults': - warnings.warn( - f'SegmentedCalculationResults is deprecated and will be removed in v{DEPRECATION_REMOVAL_VERSION}. ' - 'Use SegmentedResults instead.', - DeprecationWarning, - stacklevel=2, - ) - super().__init__(*args, **kwargs) - - @classmethod - def from_calculation(cls, calculation: SegmentedOptimization) -> SegmentedCalculationResults: - """Create SegmentedCalculationResults from a SegmentedCalculation object. - - DEPRECATED: Use SegmentedResults.from_optimization() instead. - Backwards-compatible method that redirects to from_optimization(). - - Args: - calculation: SegmentedCalculation object with solved model. - - Returns: - SegmentedCalculationResults: New instance with extracted results. - """ - return cls.from_optimization(calculation) - - def plot_heatmap( data: xr.DataArray | xr.Dataset, name: str | None = None, @@ -2462,11 +2412,6 @@ def plot_heatmap( | Literal['auto'] | None = 'auto', fill: Literal['ffill', 'bfill'] | None = 'ffill', - # Deprecated parameters (kept for backwards compatibility) - indexer: dict[str, Any] | None = None, - heatmap_timeframes: Literal['YS', 'MS', 'W', 'D', 'h', '15min', 'min'] | None = None, - heatmap_timesteps_per_frame: Literal['W', 'D', 'h', '15min', 'min'] | None = None, - color_map: str | None = None, **plot_kwargs: Any, ): """Plot heatmap visualization with support for multi-variable, faceting, and animation. @@ -2515,57 +2460,6 @@ def plot_heatmap( >>> plot_heatmap(dataset, animate_by='variable', reshape_time=('D', 'h')) """ - # Handle deprecated heatmap time parameters - if heatmap_timeframes is not None or heatmap_timesteps_per_frame is not None: - # Check for conflict with new parameter - if reshape_time != 'auto': # User explicitly set reshape_time - raise ValueError( - "Cannot use both deprecated parameters 'heatmap_timeframes'/'heatmap_timesteps_per_frame' " - "and new parameter 'reshape_time'. Use only 'reshape_time'." - ) - - warnings.warn( - "The 'heatmap_timeframes' and 'heatmap_timesteps_per_frame' parameters are deprecated. " - "Use 'reshape_time=(timeframes, timesteps_per_frame)' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - # Override reshape_time if both old parameters provided - if heatmap_timeframes is not None and heatmap_timesteps_per_frame is not None: - reshape_time = (heatmap_timeframes, heatmap_timesteps_per_frame) - - # Handle deprecated color_map parameter - if color_map is not None: - if colors is not None: # User explicitly set colors - raise ValueError( - "Cannot use both deprecated parameter 'color_map' and new parameter 'colors'. Use only 'colors'." - ) - - warnings.warn( - f"The 'color_map' parameter is deprecated. Use 'colors' instead." - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - colors = color_map - - # Handle deprecated indexer parameter - if indexer is not None: - # Check for conflict with new parameter - if select is not None: # User explicitly set select - raise ValueError( - "Cannot use both deprecated parameter 'indexer' and new parameter 'select'. Use only 'select'." - ) - - warnings.warn( - f"The 'indexer' parameter is deprecated. Use 'select' instead. " - f'Will be removed in v{DEPRECATION_REMOVAL_VERSION}.', - DeprecationWarning, - stacklevel=2, - ) - select = indexer - # Convert Dataset to DataArray with 'variable' dimension if isinstance(data, xr.Dataset): # Extract all data variables from the Dataset diff --git a/flixopt/statistics_accessor.py b/flixopt/statistics_accessor.py new file mode 100644 index 000000000..020435c5c --- /dev/null +++ b/flixopt/statistics_accessor.py @@ -0,0 +1,2282 @@ +"""Statistics accessor for FlowSystem. + +This module provides a user-friendly API for analyzing optimization results +directly from a FlowSystem. + +Structure: + - `.statistics` - Data/metrics access (cached xarray Datasets) + - `.statistics.plot` - Plotting methods using the statistics data + +Example: + >>> flow_system.optimize(solver) + >>> # Data access + >>> flow_system.statistics.flow_rates + >>> flow_system.statistics.flow_hours + >>> # Plotting + >>> flow_system.statistics.plot.balance('ElectricityBus') + >>> flow_system.statistics.plot.heatmap('Boiler|on') +""" + +from __future__ import annotations + +import logging +import re +from typing import TYPE_CHECKING, Any, Literal + +import numpy as np +import pandas as pd +import plotly.express as px +import plotly.graph_objects as go +import xarray as xr + +from .color_processing import ColorType, hex_to_rgba, process_colors +from .config import CONFIG +from .plot_result import PlotResult + +if TYPE_CHECKING: + from .flow_system import FlowSystem + +logger = logging.getLogger('flixopt') + +# Type aliases +SelectType = dict[str, Any] +"""xarray-style selection dict: {'time': slice(...), 'scenario': 'base'}""" + +FilterType = str | list[str] +"""For include/exclude filtering: 'Boiler' or ['Boiler', 'CHP']""" + + +# Sankey select types with Literal keys for IDE autocomplete +FlowSankeySelect = dict[Literal['flow', 'bus', 'component', 'carrier', 'time', 'period', 'scenario'], Any] +"""Select options for flow-based sankey: flow, bus, component, carrier, time, period, scenario.""" + +EffectsSankeySelect = dict[Literal['effect', 'component', 'contributor', 'period', 'scenario'], Any] +"""Select options for effects sankey: effect, component, contributor, period, scenario.""" + + +def _reshape_time_for_heatmap( + data: xr.DataArray, + reshape: tuple[str, str], + fill: Literal['ffill', 'bfill'] | None = 'ffill', +) -> xr.DataArray: + """Reshape time dimension into 2D (timeframe × timestep) for heatmap display. + + Args: + data: DataArray with 'time' dimension. + reshape: Tuple of (outer_freq, inner_freq), e.g. ('D', 'h') for days × hours. + fill: Method to fill missing values after resampling. + + Returns: + DataArray with 'time' replaced by 'timestep' and 'timeframe' dimensions. + """ + if 'time' not in data.dims: + return data + + timeframes, timesteps_per_frame = reshape + + # Define formats for different combinations + formats = { + ('YS', 'W'): ('%Y', '%W'), + ('YS', 'D'): ('%Y', '%j'), + ('YS', 'h'): ('%Y', '%j %H:00'), + ('MS', 'D'): ('%Y-%m', '%d'), + ('MS', 'h'): ('%Y-%m', '%d %H:00'), + ('W', 'D'): ('%Y-w%W', '%w_%A'), + ('W', 'h'): ('%Y-w%W', '%w_%A %H:00'), + ('D', 'h'): ('%Y-%m-%d', '%H:00'), + ('D', '15min'): ('%Y-%m-%d', '%H:%M'), + ('h', '15min'): ('%Y-%m-%d %H:00', '%M'), + ('h', 'min'): ('%Y-%m-%d %H:00', '%M'), + } + + format_pair = (timeframes, timesteps_per_frame) + if format_pair not in formats: + raise ValueError(f'{format_pair} is not a valid format. Choose from {list(formats.keys())}') + period_format, step_format = formats[format_pair] + + # Resample along time dimension + resampled = data.resample(time=timesteps_per_frame).mean() + + # Apply fill if specified + if fill == 'ffill': + resampled = resampled.ffill(dim='time') + elif fill == 'bfill': + resampled = resampled.bfill(dim='time') + + # Create period and step labels + time_values = pd.to_datetime(resampled.coords['time'].values) + period_labels = time_values.strftime(period_format) + step_labels = time_values.strftime(step_format) + + # Handle special case for weekly day format + if '%w_%A' in step_format: + step_labels = pd.Series(step_labels).replace('0_Sunday', '7_Sunday').values + + # Add period and step as coordinates + resampled = resampled.assign_coords({'timeframe': ('time', period_labels), 'timestep': ('time', step_labels)}) + + # Convert to multi-index and unstack + resampled = resampled.set_index(time=['timeframe', 'timestep']) + result = resampled.unstack('time') + + # Reorder: timestep, timeframe, then other dimensions + other_dims = [d for d in result.dims if d not in ['timestep', 'timeframe']] + return result.transpose('timestep', 'timeframe', *other_dims) + + +def _heatmap_figure( + data: xr.DataArray, + colors: str | list[str] | None = None, + title: str = '', + facet_col: str | None = None, + animation_frame: str | None = None, + facet_col_wrap: int | None = None, + **imshow_kwargs: Any, +) -> go.Figure: + """Create heatmap figure using px.imshow. + + Args: + data: DataArray with 2-4 dimensions. First two are heatmap axes. + colors: Colorscale name (str) or list of colors. Dicts are not supported + for heatmaps as color_continuous_scale requires a colorscale specification. + title: Plot title. + facet_col: Dimension for subplot columns. + animation_frame: Dimension for animation slider. + facet_col_wrap: Max columns before wrapping. + **imshow_kwargs: Additional args for px.imshow. + + Returns: + Plotly Figure. + """ + if data.size == 0: + return go.Figure() + + colors = colors or CONFIG.Plotting.default_sequential_colorscale + facet_col_wrap = facet_col_wrap or CONFIG.Plotting.default_facet_cols + + imshow_args: dict[str, Any] = { + 'img': data, + 'color_continuous_scale': colors, + 'title': title, + **imshow_kwargs, + } + + if facet_col and facet_col in data.dims: + imshow_args['facet_col'] = facet_col + if facet_col_wrap < data.sizes[facet_col]: + imshow_args['facet_col_wrap'] = facet_col_wrap + + if animation_frame and animation_frame in data.dims: + imshow_args['animation_frame'] = animation_frame + + return px.imshow(**imshow_args) + + +# --- Helper functions --- + + +def _filter_by_pattern( + names: list[str], + include: FilterType | None, + exclude: FilterType | None, +) -> list[str]: + """Filter names using substring matching.""" + result = names.copy() + if include is not None: + patterns = [include] if isinstance(include, str) else include + result = [n for n in result if any(p in n for p in patterns)] + if exclude is not None: + patterns = [exclude] if isinstance(exclude, str) else exclude + result = [n for n in result if not any(p in n for p in patterns)] + return result + + +def _apply_selection(ds: xr.Dataset, select: SelectType | None, drop: bool = True) -> xr.Dataset: + """Apply xarray-style selection to dataset. + + Args: + ds: Dataset to select from. + select: xarray-style selection dict. + drop: If True (default), drop dimensions that become scalar after selection. + This prevents auto-faceting when selecting a single value. + """ + if select is None: + return ds + valid_select = {k: v for k, v in select.items() if k in ds.dims or k in ds.coords} + if valid_select: + ds = ds.sel(valid_select, drop=drop) + return ds + + +def _filter_by_carrier(ds: xr.Dataset, carrier: str | list[str] | None) -> xr.Dataset: + """Filter dataset variables by carrier attribute. + + Args: + ds: Dataset with variables that have 'carrier' attributes. + carrier: Carrier name(s) to keep. None means no filtering. + + Returns: + Dataset containing only variables matching the carrier(s). + """ + if carrier is None: + return ds + + carriers = [carrier] if isinstance(carrier, str) else carrier + carriers = [c.lower() for c in carriers] + + matching_vars = [var for var in ds.data_vars if ds[var].attrs.get('carrier', '').lower() in carriers] + return ds[matching_vars] if matching_vars else xr.Dataset() + + +def _resolve_facets( + ds: xr.Dataset, + facet_col: str | None, + facet_row: str | None, +) -> tuple[str | None, str | None]: + """Resolve facet dimensions, returning None if not present in data.""" + actual_facet_col = facet_col if facet_col and facet_col in ds.dims else None + actual_facet_row = facet_row if facet_row and facet_row in ds.dims else None + return actual_facet_col, actual_facet_row + + +def _dataset_to_long_df(ds: xr.Dataset, value_name: str = 'value', var_name: str = 'variable') -> pd.DataFrame: + """Convert xarray Dataset to long-form DataFrame for plotly express.""" + if not ds.data_vars: + return pd.DataFrame() + if all(ds[var].ndim == 0 for var in ds.data_vars): + rows = [{var_name: var, value_name: float(ds[var].values)} for var in ds.data_vars] + return pd.DataFrame(rows) + df = ds.to_dataframe().reset_index() + # Only use coordinates that are actually present as columns after reset_index + coord_cols = [c for c in ds.coords.keys() if c in df.columns] + return df.melt(id_vars=coord_cols, var_name=var_name, value_name=value_name) + + +def _create_stacked_bar( + ds: xr.Dataset, + colors: ColorType, + title: str, + facet_col: str | None, + facet_row: str | None, + **plotly_kwargs: Any, +) -> go.Figure: + """Create a stacked bar chart from xarray Dataset.""" + df = _dataset_to_long_df(ds) + if df.empty: + return go.Figure() + x_col = 'time' if 'time' in df.columns else df.columns[0] + variables = df['variable'].unique().tolist() + color_map = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) + fig = px.bar( + df, + x=x_col, + y='value', + color='variable', + facet_col=facet_col, + facet_row=facet_row, + color_discrete_map=color_map, + title=title, + **plotly_kwargs, + ) + fig.update_layout(barmode='relative', bargap=0, bargroupgap=0) + fig.update_traces(marker_line_width=0) + return fig + + +def _create_line( + ds: xr.Dataset, + colors: ColorType, + title: str, + facet_col: str | None, + facet_row: str | None, + **plotly_kwargs: Any, +) -> go.Figure: + """Create a line chart from xarray Dataset.""" + df = _dataset_to_long_df(ds) + if df.empty: + return go.Figure() + x_col = 'time' if 'time' in df.columns else df.columns[0] + variables = df['variable'].unique().tolist() + color_map = process_colors(colors, variables, default_colorscale=CONFIG.Plotting.default_qualitative_colorscale) + return px.line( + df, + x=x_col, + y='value', + color='variable', + facet_col=facet_col, + facet_row=facet_row, + color_discrete_map=color_map, + title=title, + **plotly_kwargs, + ) + + +# --- Statistics Accessor (data only) --- + + +class StatisticsAccessor: + """Statistics accessor for FlowSystem. Access via ``flow_system.statistics``. + + This accessor provides cached data properties for optimization results. + Use ``.plot`` for visualization methods. + + Data Properties: + ``flow_rates`` : xr.Dataset + Flow rates for all flows. + ``flow_hours`` : xr.Dataset + Flow hours (energy) for all flows. + ``sizes`` : xr.Dataset + Sizes for all flows. + ``charge_states`` : xr.Dataset + Charge states for all storage components. + ``temporal_effects`` : xr.Dataset + Temporal effects per contributor per timestep. + ``periodic_effects`` : xr.Dataset + Periodic (investment) effects per contributor. + ``total_effects`` : xr.Dataset + Total effects (temporal + periodic) per contributor. + ``effect_share_factors`` : dict + Conversion factors between effects. + + Examples: + >>> flow_system.optimize(solver) + >>> flow_system.statistics.flow_rates # Get data + >>> flow_system.statistics.plot.balance('Bus') # Plot + """ + + def __init__(self, flow_system: FlowSystem) -> None: + self._fs = flow_system + # Cached data + self._flow_rates: xr.Dataset | None = None + self._flow_hours: xr.Dataset | None = None + self._flow_sizes: xr.Dataset | None = None + self._storage_sizes: xr.Dataset | None = None + self._sizes: xr.Dataset | None = None + self._charge_states: xr.Dataset | None = None + self._effect_share_factors: dict[str, dict] | None = None + self._temporal_effects: xr.Dataset | None = None + self._periodic_effects: xr.Dataset | None = None + self._total_effects: xr.Dataset | None = None + # Plotting accessor (lazy) + self._plot: StatisticsPlotAccessor | None = None + + def _require_solution(self) -> xr.Dataset: + """Get solution, raising if not available.""" + if self._fs.solution is None: + raise RuntimeError('FlowSystem has no solution. Run optimize() or solve() first.') + return self._fs.solution + + @property + def carrier_colors(self) -> dict[str, str]: + """Cached mapping of carrier name to color. + + Delegates to topology accessor for centralized color caching. + + Returns: + Dict mapping carrier names (lowercase) to hex color strings. + """ + return self._fs.topology.carrier_colors + + @property + def component_colors(self) -> dict[str, str]: + """Cached mapping of component label to color. + + Delegates to topology accessor for centralized color caching. + + Returns: + Dict mapping component labels to hex color strings. + """ + return self._fs.topology.component_colors + + @property + def bus_colors(self) -> dict[str, str]: + """Cached mapping of bus label to color (from carrier). + + Delegates to topology accessor for centralized color caching. + + Returns: + Dict mapping bus labels to hex color strings. + """ + return self._fs.topology.bus_colors + + @property + def carrier_units(self) -> dict[str, str]: + """Cached mapping of carrier name to unit string. + + Delegates to topology accessor for centralized unit caching. + + Returns: + Dict mapping carrier names (lowercase) to unit strings. + """ + return self._fs.topology.carrier_units + + @property + def effect_units(self) -> dict[str, str]: + """Cached mapping of effect label to unit string. + + Delegates to topology accessor for centralized unit caching. + + Returns: + Dict mapping effect labels to unit strings. + """ + return self._fs.topology.effect_units + + @property + def plot(self) -> StatisticsPlotAccessor: + """Access plotting methods for statistics. + + Returns: + A StatisticsPlotAccessor instance. + + Examples: + >>> flow_system.statistics.plot.balance('ElectricityBus') + >>> flow_system.statistics.plot.heatmap('Boiler|on') + """ + if self._plot is None: + self._plot = StatisticsPlotAccessor(self) + return self._plot + + @property + def flow_rates(self) -> xr.Dataset: + """All flow rates as a Dataset with flow labels as variable names. + + Each variable has attributes: + - 'carrier': carrier type (e.g., 'heat', 'electricity', 'gas') + - 'unit': carrier unit (e.g., 'kW') + """ + self._require_solution() + if self._flow_rates is None: + flow_rate_vars = [v for v in self._fs.solution.data_vars if v.endswith('|flow_rate')] + flow_carriers = self._fs.flow_carriers # Cached lookup + carrier_units = self.carrier_units # Cached lookup + data_vars = {} + for v in flow_rate_vars: + flow_label = v.replace('|flow_rate', '') + da = self._fs.solution[v].copy() + # Add carrier and unit as attributes + carrier = flow_carriers.get(flow_label) + da.attrs['carrier'] = carrier + da.attrs['unit'] = carrier_units.get(carrier, '') if carrier else '' + data_vars[flow_label] = da + self._flow_rates = xr.Dataset(data_vars) + return self._flow_rates + + @property + def flow_hours(self) -> xr.Dataset: + """All flow hours (energy) as a Dataset with flow labels as variable names. + + Each variable has attributes: + - 'carrier': carrier type (e.g., 'heat', 'electricity', 'gas') + - 'unit': energy unit (e.g., 'kWh', 'm3/s*h') + """ + self._require_solution() + if self._flow_hours is None: + hours = self._fs.hours_per_timestep + flow_rates = self.flow_rates + # Multiply and preserve/transform attributes + data_vars = {} + for var in flow_rates.data_vars: + da = flow_rates[var] * hours + da.attrs['carrier'] = flow_rates[var].attrs.get('carrier') + # Convert power unit to energy unit (e.g., 'kW' -> 'kWh', 'm3/s' -> 'm3/s*h') + power_unit = flow_rates[var].attrs.get('unit', '') + da.attrs['unit'] = f'{power_unit}*h' if power_unit else '' + data_vars[var] = da + self._flow_hours = xr.Dataset(data_vars) + return self._flow_hours + + @property + def flow_sizes(self) -> xr.Dataset: + """Flow sizes as a Dataset with flow labels as variable names.""" + self._require_solution() + if self._flow_sizes is None: + flow_labels = set(self._fs.flows.keys()) + size_vars = [ + v for v in self._fs.solution.data_vars if v.endswith('|size') and v.replace('|size', '') in flow_labels + ] + self._flow_sizes = xr.Dataset({v.replace('|size', ''): self._fs.solution[v] for v in size_vars}) + return self._flow_sizes + + @property + def storage_sizes(self) -> xr.Dataset: + """Storage capacity sizes as a Dataset with storage labels as variable names.""" + self._require_solution() + if self._storage_sizes is None: + storage_labels = set(self._fs.storages.keys()) + size_vars = [ + v + for v in self._fs.solution.data_vars + if v.endswith('|size') and v.replace('|size', '') in storage_labels + ] + self._storage_sizes = xr.Dataset({v.replace('|size', ''): self._fs.solution[v] for v in size_vars}) + return self._storage_sizes + + @property + def sizes(self) -> xr.Dataset: + """All investment sizes (flows and storage capacities) as a Dataset.""" + if self._sizes is None: + self._sizes = xr.merge([self.flow_sizes, self.storage_sizes]) + return self._sizes + + @property + def charge_states(self) -> xr.Dataset: + """All storage charge states as a Dataset with storage labels as variable names.""" + self._require_solution() + if self._charge_states is None: + charge_vars = [v for v in self._fs.solution.data_vars if v.endswith('|charge_state')] + self._charge_states = xr.Dataset( + {v.replace('|charge_state', ''): self._fs.solution[v] for v in charge_vars} + ) + return self._charge_states + + @property + def effect_share_factors(self) -> dict[str, dict]: + """Effect share factors for temporal and periodic modes. + + Returns: + Dict with 'temporal' and 'periodic' keys, each containing + conversion factors between effects. + """ + self._require_solution() + if self._effect_share_factors is None: + factors = self._fs.effects.calculate_effect_share_factors() + self._effect_share_factors = {'temporal': factors[0], 'periodic': factors[1]} + return self._effect_share_factors + + @property + def temporal_effects(self) -> xr.Dataset: + """Temporal effects per contributor per timestep. + + Returns a Dataset where each effect is a data variable with dimensions + [time, contributor] (plus period/scenario if present). + + Coordinates: + - contributor: Individual contributor labels + - component: Parent component label for groupby operations + - component_type: Component type (e.g., 'Boiler', 'Source', 'Sink') + + Examples: + >>> # Get costs per contributor per timestep + >>> statistics.temporal_effects['costs'] + >>> # Sum over all contributors to get total costs per timestep + >>> statistics.temporal_effects['costs'].sum('contributor') + >>> # Group by component + >>> statistics.temporal_effects['costs'].groupby('component').sum() + + Returns: + xr.Dataset with effects as variables and contributor dimension. + """ + self._require_solution() + if self._temporal_effects is None: + ds = self._create_effects_dataset('temporal') + dim_order = ['time', 'period', 'scenario', 'contributor'] + self._temporal_effects = ds.transpose(*dim_order, missing_dims='ignore') + return self._temporal_effects + + @property + def periodic_effects(self) -> xr.Dataset: + """Periodic (investment) effects per contributor. + + Returns a Dataset where each effect is a data variable with dimensions + [contributor] (plus period/scenario if present). + + Coordinates: + - contributor: Individual contributor labels + - component: Parent component label for groupby operations + - component_type: Component type (e.g., 'Boiler', 'Source', 'Sink') + + Examples: + >>> # Get investment costs per contributor + >>> statistics.periodic_effects['costs'] + >>> # Sum over all contributors to get total investment costs + >>> statistics.periodic_effects['costs'].sum('contributor') + >>> # Group by component + >>> statistics.periodic_effects['costs'].groupby('component').sum() + + Returns: + xr.Dataset with effects as variables and contributor dimension. + """ + self._require_solution() + if self._periodic_effects is None: + ds = self._create_effects_dataset('periodic') + dim_order = ['period', 'scenario', 'contributor'] + self._periodic_effects = ds.transpose(*dim_order, missing_dims='ignore') + return self._periodic_effects + + @property + def total_effects(self) -> xr.Dataset: + """Total effects (temporal + periodic) per contributor. + + Returns a Dataset where each effect is a data variable with dimensions + [contributor] (plus period/scenario if present). + + Coordinates: + - contributor: Individual contributor labels + - component: Parent component label for groupby operations + - component_type: Component type (e.g., 'Boiler', 'Source', 'Sink') + + Examples: + >>> # Get total costs per contributor + >>> statistics.total_effects['costs'] + >>> # Sum over all contributors to get total system costs + >>> statistics.total_effects['costs'].sum('contributor') + >>> # Group by component + >>> statistics.total_effects['costs'].groupby('component').sum() + >>> # Group by component type + >>> statistics.total_effects['costs'].groupby('component_type').sum() + + Returns: + xr.Dataset with effects as variables and contributor dimension. + """ + self._require_solution() + if self._total_effects is None: + ds = self._create_effects_dataset('total') + dim_order = ['period', 'scenario', 'contributor'] + self._total_effects = ds.transpose(*dim_order, missing_dims='ignore') + return self._total_effects + + def get_effect_shares( + self, + element: str, + effect: str, + mode: Literal['temporal', 'periodic'] | None = None, + include_flows: bool = False, + ) -> xr.Dataset: + """Retrieve individual effect shares for a specific element and effect. + + Args: + element: The element identifier (component or flow label). + effect: The effect identifier. + mode: 'temporal', 'periodic', or None for both. + include_flows: Whether to include effects from flows connected to this element. + + Returns: + xr.Dataset containing the requested effect shares. + + Raises: + ValueError: If the effect is not available or mode is invalid. + """ + self._require_solution() + + if effect not in self._fs.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._fs.solution: + ds = xr.Dataset({label: self._fs.solution[label]}) + + if include_flows: + if element not in self._fs.components: + raise ValueError(f'Only use Components when retrieving Effects including flows. Got {element}') + comp = self._fs.components[element] + flows = [f.label_full.split('|')[0] for f in comp.inputs + comp.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 _create_template_for_mode(self, mode: Literal['temporal', 'periodic', 'total']) -> xr.DataArray: + """Create a template DataArray with the correct dimensions for a given mode.""" + coords = {} + if mode == 'temporal': + coords['time'] = self._fs.timesteps + if self._fs.periods is not None: + coords['period'] = self._fs.periods + if self._fs.scenarios is not None: + coords['scenario'] = self._fs.scenarios + + 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: + """Create dataset containing effect totals for all contributors. + + Detects contributors (flows, components, etc.) from solution data variables. + Excludes effect-to-effect shares which are intermediate conversions. + Provides component and component_type coordinates for flexible groupby operations. + """ + solution = self._fs.solution + template = self._create_template_for_mode(mode) + + # Detect contributors from solution data variables + # Pattern: {contributor}->{effect}(temporal) or {contributor}->{effect}(periodic) + contributor_pattern = re.compile(r'^(.+)->(.+)\((temporal|periodic)\)$') + effect_labels = set(self._fs.effects.keys()) + + detected_contributors: set[str] = set() + for var in solution.data_vars: + match = contributor_pattern.match(str(var)) + if match: + contributor = match.group(1) + # Exclude effect-to-effect shares (e.g., costs(temporal) -> Effect1(temporal)) + base_name = contributor.split('(')[0] if '(' in contributor else contributor + if base_name not in effect_labels: + detected_contributors.add(contributor) + + contributors = sorted(detected_contributors) + + # Build metadata for each contributor + def get_parent_component(contributor: str) -> str: + if contributor in self._fs.flows: + return self._fs.flows[contributor].component + elif contributor in self._fs.components: + return contributor + return contributor + + def get_contributor_type(contributor: str) -> str: + if contributor in self._fs.flows: + parent = self._fs.flows[contributor].component + return type(self._fs.components[parent]).__name__ + elif contributor in self._fs.components: + return type(self._fs.components[contributor]).__name__ + elif contributor in self._fs.buses: + return type(self._fs.buses[contributor]).__name__ + return 'Unknown' + + parents = [get_parent_component(c) for c in contributors] + contributor_types = [get_contributor_type(c) for c in contributors] + + # Determine modes to process + modes_to_process = ['temporal', 'periodic'] if mode == 'total' else [mode] + + ds = xr.Dataset() + + for effect in self._fs.effects: + contributor_arrays = [] + + for contributor in contributors: + share_total: xr.DataArray | None = None + + for current_mode in modes_to_process: + # Get conversion factors: which source effects contribute to this target effect + conversion_factors = { + key[0]: value + for key, value in self.effect_share_factors[current_mode].items() + if key[1] == effect + } + conversion_factors[effect] = 1 # Direct contribution + + for source_effect, factor in conversion_factors.items(): + label = f'{contributor}->{source_effect}({current_mode})' + if label in solution: + da = solution[label] * factor + # For total mode, sum temporal over time + if mode == 'total' and current_mode == 'temporal' and 'time' in da.dims: + da = da.sum('time') + if share_total is None: + share_total = da + else: + share_total = share_total + da + + # If no share found, use NaN template + if share_total is None: + share_total = xr.full_like(template, np.nan, dtype=float) + + contributor_arrays.append(share_total.expand_dims(contributor=[contributor])) + + # Concatenate all contributors for this effect + da = xr.concat(contributor_arrays, dim='contributor', coords='minimal', join='outer').rename(effect) + # Add unit attribute from effect definition + da.attrs['unit'] = self.effect_units.get(effect, '') + ds[effect] = da + + # Add groupby coordinates for contributor dimension + ds = ds.assign_coords( + component=('contributor', parents), + component_type=('contributor', contributor_types), + ) + + # Validation: check totals match solution + suffix_map = {'temporal': '(temporal)|per_timestep', 'periodic': '(periodic)', 'total': ''} + for effect in self._fs.effects: + label = f'{effect}{suffix_map[mode]}' + if label in solution: + computed = ds[effect].sum('contributor') + found = solution[label] + if not np.allclose(computed.fillna(0).values, found.fillna(0).values, equal_nan=True): + logger.critical( + f'Results for {effect}({mode}) in effects_dataset doesnt match {label}\n{computed=}\n, {found=}' + ) + + return ds + + +# --- Sankey Plot Accessor --- + + +class SankeyPlotAccessor: + """Sankey diagram accessor. Access via ``flow_system.statistics.plot.sankey``. + + Provides typed methods for different sankey diagram types. + + Examples: + >>> fs.statistics.plot.sankey.flows(select={'bus': 'HeatBus'}) + >>> fs.statistics.plot.sankey.effects(select={'effect': 'costs'}) + >>> fs.statistics.plot.sankey.sizes(select={'component': 'Boiler'}) + """ + + def __init__(self, plot_accessor: StatisticsPlotAccessor) -> None: + self._plot = plot_accessor + self._stats = plot_accessor._stats + self._fs = plot_accessor._fs + + def _extract_flow_filters( + self, select: FlowSankeySelect | None + ) -> tuple[SelectType | None, list[str] | None, list[str] | None, list[str] | None, list[str] | None]: + """Extract special filters from select dict. + + Returns: + Tuple of (xarray_select, flow_filter, bus_filter, component_filter, carrier_filter). + """ + if select is None: + return None, None, None, None, None + + select = dict(select) # Copy to avoid mutating original + flow_filter = select.pop('flow', None) + bus_filter = select.pop('bus', None) + component_filter = select.pop('component', None) + carrier_filter = select.pop('carrier', None) + + # Normalize to lists + if isinstance(flow_filter, str): + flow_filter = [flow_filter] + if isinstance(bus_filter, str): + bus_filter = [bus_filter] + if isinstance(component_filter, str): + component_filter = [component_filter] + if isinstance(carrier_filter, str): + carrier_filter = [carrier_filter] + + return select if select else None, flow_filter, bus_filter, component_filter, carrier_filter + + def _build_flow_links( + self, + ds: xr.Dataset, + flow_filter: list[str] | None = None, + bus_filter: list[str] | None = None, + component_filter: list[str] | None = None, + carrier_filter: list[str] | None = None, + min_value: float = 1e-6, + ) -> tuple[set[str], dict[str, list]]: + """Build Sankey nodes and links from flow data.""" + nodes: set[str] = set() + links: dict[str, list] = {'source': [], 'target': [], 'value': [], 'label': [], 'carrier': []} + + # Normalize carrier filter to lowercase + if carrier_filter is not None: + carrier_filter = [c.lower() for c in carrier_filter] + + # Use flow_rates to get carrier names from xarray attributes (already computed) + flow_rates = self._stats.flow_rates + + for flow in self._fs.flows.values(): + label = flow.label_full + if label not in ds: + continue + + # Apply filters + if flow_filter is not None and label not in flow_filter: + continue + bus_label = flow.bus + comp_label = flow.component + if bus_filter is not None and bus_label not in bus_filter: + continue + + # Get carrier name from flow_rates xarray attribute (efficient lookup) + carrier_name = flow_rates[label].attrs.get('carrier') if label in flow_rates else None + + if carrier_filter is not None: + if carrier_name is None or carrier_name.lower() not in carrier_filter: + continue + if component_filter is not None and comp_label not in component_filter: + continue + + value = float(ds[label].values) + if abs(value) < min_value: + continue + + if flow.is_input_in_component: + source, target = bus_label, comp_label + else: + source, target = comp_label, bus_label + + nodes.add(source) + nodes.add(target) + links['source'].append(source) + links['target'].append(target) + links['value'].append(abs(value)) + links['label'].append(label) + links['carrier'].append(carrier_name) + + return nodes, links + + def _create_figure( + self, + nodes: set[str], + links: dict[str, list], + colors: ColorType | None, + title: str, + **plotly_kwargs: Any, + ) -> go.Figure: + """Create Plotly Sankey figure.""" + node_list = list(nodes) + node_indices = {n: i for i, n in enumerate(node_list)} + + # Build node colors: buses use carrier colors, components use process_colors + node_colors = self._get_node_colors(node_list, colors) + + # Build link colors from carrier colors (subtle/semi-transparent) + link_colors = self._get_link_colors(links.get('carrier', [])) + + link_dict: dict[str, Any] = dict( + source=[node_indices[s] for s in links['source']], + target=[node_indices[t] for t in links['target']], + value=links['value'], + label=links['label'], + ) + if link_colors: + link_dict['color'] = link_colors + + fig = go.Figure( + data=[ + go.Sankey( + node=dict( + pad=15, thickness=20, line=dict(color='black', width=0.5), label=node_list, color=node_colors + ), + link=link_dict, + ) + ] + ) + fig.update_layout(title=title, **plotly_kwargs) + return fig + + def _get_node_colors(self, node_list: list[str], colors: ColorType | None) -> list[str]: + """Get colors for nodes: buses use cached bus_colors, components use process_colors.""" + # Get fallback colors from process_colors + fallback_colors = process_colors(colors, node_list) + + # Use cached bus colors for efficiency + bus_colors = self._stats.bus_colors + + node_colors = [] + for node in node_list: + # Check if node is a bus with a cached color + if node in bus_colors: + node_colors.append(bus_colors[node]) + else: + # Fall back to process_colors + node_colors.append(fallback_colors[node]) + + return node_colors + + def _get_link_colors(self, carriers: list[str | None]) -> list[str]: + """Get subtle/semi-transparent colors for links based on their carriers.""" + if not carriers: + return [] + + # Use cached carrier colors for efficiency + carrier_colors = self._stats.carrier_colors + + link_colors = [] + for carrier_name in carriers: + hex_color = carrier_colors.get(carrier_name.lower()) if carrier_name else None + link_colors.append(hex_to_rgba(hex_color, alpha=0.4) if hex_color else hex_to_rgba('', alpha=0.4)) + + return link_colors + + def _finalize(self, fig: go.Figure, links: dict[str, list], show: bool | None) -> PlotResult: + """Create PlotResult and optionally show figure.""" + coords: dict[str, Any] = { + 'link': range(len(links['value'])), + 'source': ('link', links['source']), + 'target': ('link', links['target']), + 'label': ('link', links['label']), + } + # Add carrier if present + if 'carrier' in links: + coords['carrier'] = ('link', links['carrier']) + + sankey_ds = xr.Dataset({'value': ('link', links['value'])}, coords=coords) + + if show is None: + show = CONFIG.Plotting.default_show + if show: + fig.show() + + return PlotResult(data=sankey_ds, figure=fig) + + def flows( + self, + *, + aggregate: Literal['sum', 'mean'] = 'sum', + select: FlowSankeySelect | None = None, + colors: ColorType | None = None, + show: bool | None = None, + **plotly_kwargs: Any, + ) -> PlotResult: + """Plot Sankey diagram of energy/material flow amounts. + + Args: + aggregate: How to aggregate over time ('sum' or 'mean'). + select: Filter options: + - flow: filter by flow label (e.g., 'Boiler|Q_th') + - bus: filter by bus label (e.g., 'HeatBus') + - component: filter by component label (e.g., 'Boiler') + - time: select specific time (e.g., 100 or '2023-01-01') + - period, scenario: xarray dimension selection + colors: Color specification for nodes. + show: Whether to display the figure. + **plotly_kwargs: Additional arguments passed to Plotly layout. + + Returns: + PlotResult with Sankey flow data and figure. + """ + self._stats._require_solution() + xr_select, flow_filter, bus_filter, component_filter, carrier_filter = self._extract_flow_filters(select) + + ds = self._stats.flow_hours.copy() + + # Apply period/scenario weights + if 'period' in ds.dims and self._fs.period_weights is not None: + ds = ds * self._fs.period_weights + if 'scenario' in ds.dims and self._fs.scenario_weights is not None: + weights = self._fs.scenario_weights / self._fs.scenario_weights.sum() + ds = ds * weights + + ds = _apply_selection(ds, xr_select) + + # Aggregate remaining dimensions + if 'time' in ds.dims: + ds = getattr(ds, aggregate)(dim='time') + for dim in ['period', 'scenario']: + if dim in ds.dims: + ds = ds.sum(dim=dim) + + nodes, links = self._build_flow_links(ds, flow_filter, bus_filter, component_filter, carrier_filter) + fig = self._create_figure(nodes, links, colors, 'Energy Flow', **plotly_kwargs) + return self._finalize(fig, links, show) + + def sizes( + self, + *, + select: FlowSankeySelect | None = None, + max_size: float | None = None, + colors: ColorType | None = None, + show: bool | None = None, + **plotly_kwargs: Any, + ) -> PlotResult: + """Plot Sankey diagram of investment sizes/capacities. + + Args: + select: Filter options: + - flow: filter by flow label (e.g., 'Boiler|Q_th') + - bus: filter by bus label (e.g., 'HeatBus') + - component: filter by component label (e.g., 'Boiler') + - period, scenario: xarray dimension selection + max_size: Filter flows with sizes exceeding this value. + colors: Color specification for nodes. + show: Whether to display the figure. + **plotly_kwargs: Additional arguments passed to Plotly layout. + + Returns: + PlotResult with Sankey size data and figure. + """ + self._stats._require_solution() + xr_select, flow_filter, bus_filter, component_filter, carrier_filter = self._extract_flow_filters(select) + + ds = self._stats.sizes.copy() + ds = _apply_selection(ds, xr_select) + + # Collapse remaining dimensions + for dim in ['period', 'scenario']: + if dim in ds.dims: + ds = ds.max(dim=dim) + + # Apply max_size filter + if max_size is not None and ds.data_vars: + valid_labels = [lbl for lbl in ds.data_vars if float(ds[lbl].max()) < max_size] + ds = ds[valid_labels] + + nodes, links = self._build_flow_links(ds, flow_filter, bus_filter, component_filter, carrier_filter) + fig = self._create_figure(nodes, links, colors, 'Investment Sizes (Capacities)', **plotly_kwargs) + return self._finalize(fig, links, show) + + def peak_flow( + self, + *, + select: FlowSankeySelect | None = None, + colors: ColorType | None = None, + show: bool | None = None, + **plotly_kwargs: Any, + ) -> PlotResult: + """Plot Sankey diagram of peak (maximum) flow rates. + + Args: + select: Filter options: + - flow: filter by flow label (e.g., 'Boiler|Q_th') + - bus: filter by bus label (e.g., 'HeatBus') + - component: filter by component label (e.g., 'Boiler') + - time, period, scenario: xarray dimension selection + colors: Color specification for nodes. + show: Whether to display the figure. + **plotly_kwargs: Additional arguments passed to Plotly layout. + + Returns: + PlotResult with Sankey peak flow data and figure. + """ + self._stats._require_solution() + xr_select, flow_filter, bus_filter, component_filter, carrier_filter = self._extract_flow_filters(select) + + ds = self._stats.flow_rates.copy() + ds = _apply_selection(ds, xr_select) + + # Take max over all dimensions + for dim in ['time', 'period', 'scenario']: + if dim in ds.dims: + ds = ds.max(dim=dim) + + nodes, links = self._build_flow_links(ds, flow_filter, bus_filter, component_filter, carrier_filter) + fig = self._create_figure(nodes, links, colors, 'Peak Flow Rates', **plotly_kwargs) + return self._finalize(fig, links, show) + + def effects( + self, + *, + select: EffectsSankeySelect | None = None, + colors: ColorType | None = None, + show: bool | None = None, + **plotly_kwargs: Any, + ) -> PlotResult: + """Plot Sankey diagram of component contributions to effects. + + Shows how each component contributes to costs, CO2, and other effects. + + Args: + select: Filter options: + - effect: filter which effects are shown (e.g., 'costs', ['costs', 'CO2']) + - component: filter by component label (e.g., 'Boiler') + - contributor: filter by contributor label (e.g., 'Boiler|Q_th') + - period, scenario: xarray dimension selection + colors: Color specification for nodes. + show: Whether to display the figure. + **plotly_kwargs: Additional arguments passed to Plotly layout. + + Returns: + PlotResult with Sankey effects data and figure. + """ + self._stats._require_solution() + total_effects = self._stats.total_effects + + # Extract special filters from select + effect_filter: list[str] | None = None + component_filter: list[str] | None = None + contributor_filter: list[str] | None = None + xr_select: SelectType | None = None + + if select is not None: + select = dict(select) # Copy to avoid mutating + effect_filter = select.pop('effect', None) + component_filter = select.pop('component', None) + contributor_filter = select.pop('contributor', None) + xr_select = select if select else None + + # Normalize to lists + if isinstance(effect_filter, str): + effect_filter = [effect_filter] + if isinstance(component_filter, str): + component_filter = [component_filter] + if isinstance(contributor_filter, str): + contributor_filter = [contributor_filter] + + # Determine which effects to include + effect_names = list(total_effects.data_vars) + if effect_filter is not None: + effect_names = [e for e in effect_names if e in effect_filter] + + # Collect all links: component -> effect + nodes: set[str] = set() + links: dict[str, list] = {'source': [], 'target': [], 'value': [], 'label': []} + + for effect_name in effect_names: + effect_data = total_effects[effect_name] + effect_data = _apply_selection(effect_data, xr_select) + + # Sum over remaining dimensions + for dim in ['period', 'scenario']: + if dim in effect_data.dims: + effect_data = effect_data.sum(dim=dim) + + contributors = effect_data.coords['contributor'].values + components = effect_data.coords['component'].values + + for contributor, component in zip(contributors, components, strict=False): + if component_filter is not None and component not in component_filter: + continue + if contributor_filter is not None and contributor not in contributor_filter: + continue + + value = float(effect_data.sel(contributor=contributor).values) + if not np.isfinite(value) or abs(value) < 1e-6: + continue + + source = str(component) + target = f'[{effect_name}]' + + nodes.add(source) + nodes.add(target) + links['source'].append(source) + links['target'].append(target) + links['value'].append(abs(value)) + links['label'].append(f'{contributor} → {effect_name}: {value:.2f}') + + fig = self._create_figure(nodes, links, colors, 'Effect Contributions by Component', **plotly_kwargs) + return self._finalize(fig, links, show) + + +# --- Statistics Plot Accessor --- + + +class StatisticsPlotAccessor: + """Plot accessor for statistics. Access via ``flow_system.statistics.plot``. + + All methods return PlotResult with both data and figure. + """ + + def __init__(self, statistics: StatisticsAccessor) -> None: + self._stats = statistics + self._fs = statistics._fs + self._sankey: SankeyPlotAccessor | None = None + + @property + def sankey(self) -> SankeyPlotAccessor: + """Access sankey diagram methods with typed select options. + + Returns: + SankeyPlotAccessor with methods: flows(), sizes(), peak_flow(), effects() + + Examples: + >>> fs.statistics.plot.sankey.flows(select={'bus': 'HeatBus'}) + >>> fs.statistics.plot.sankey.effects(select={'effect': 'costs'}) + """ + if self._sankey is None: + self._sankey = SankeyPlotAccessor(self) + return self._sankey + + def _get_color_map_for_balance(self, node: str, flow_labels: list[str]) -> dict[str, str]: + """Build color map for balance plot. + + - Bus balance: colors from component.color (using cached component_colors) + - Component balance: colors from flow's carrier (using cached carrier_colors) + + Raises: + RuntimeError: If FlowSystem is not connected_and_transformed. + """ + if not self._fs.connected_and_transformed: + raise RuntimeError( + 'FlowSystem is not connected_and_transformed. Call FlowSystem.connect_and_transform() first.' + ) + + is_bus = node in self._fs.buses + color_map = {} + uncolored = [] + + # Get cached colors for efficient lookup + carrier_colors = self._stats.carrier_colors + component_colors = self._stats.component_colors + flow_rates = self._stats.flow_rates + + for label in flow_labels: + if is_bus: + # Use cached component colors + comp_label = self._fs.flows[label].component + color = component_colors.get(comp_label) + else: + # Use carrier name from xarray attribute (already computed) + cached colors + carrier_name = flow_rates[label].attrs.get('carrier') if label in flow_rates else None + color = carrier_colors.get(carrier_name) if carrier_name else None + + if color: + color_map[label] = color + else: + uncolored.append(label) + + if uncolored: + color_map.update(process_colors(CONFIG.Plotting.default_qualitative_colorscale, uncolored)) + + return color_map + + def _resolve_variable_names(self, variables: list[str], solution: xr.Dataset) -> list[str]: + """Resolve flow labels to variable names with fallback. + + For each variable: + 1. First check if it exists in the dataset as-is + 2. If not found and doesn't contain '|', try adding '|flow_rate' suffix + 3. If still not found, try '|charge_state' suffix (for storages) + + Args: + variables: List of flow labels or variable names. + solution: The solution dataset to check variable existence. + + Returns: + List of resolved variable names. + """ + resolved = [] + for var in variables: + if var in solution: + # Variable exists as-is, use it directly + resolved.append(var) + elif '|' not in var: + # Not found and no '|', try common suffixes + flow_rate_var = f'{var}|flow_rate' + charge_state_var = f'{var}|charge_state' + if flow_rate_var in solution: + resolved.append(flow_rate_var) + elif charge_state_var in solution: + resolved.append(charge_state_var) + else: + # Let it fail with the original name for clear error message + resolved.append(var) + else: + # Contains '|' but not in solution - let it fail with original name + resolved.append(var) + return resolved + + def balance( + self, + node: str, + *, + select: SelectType | None = None, + include: FilterType | None = None, + exclude: FilterType | None = None, + unit: Literal['flow_rate', 'flow_hours'] = 'flow_rate', + colors: ColorType | None = None, + facet_col: str | None = 'period', + facet_row: str | None = 'scenario', + show: bool | None = None, + **plotly_kwargs: Any, + ) -> PlotResult: + """Plot node balance (inputs vs outputs) for a Bus or Component. + + Args: + node: Label of the Bus or Component to plot. + select: xarray-style selection dict. + include: Only include flows containing these substrings. + exclude: Exclude flows containing these substrings. + unit: 'flow_rate' (power) or 'flow_hours' (energy). + colors: Color specification (colorscale name, color list, or label-to-color dict). + facet_col: Dimension for column facets. + facet_row: Dimension for row facets. + show: Whether to display the plot. + + Returns: + PlotResult with .data and .figure. + """ + self._stats._require_solution() + + # Get the element + if node in self._fs.buses: + element = self._fs.buses[node] + elif node in self._fs.components: + element = self._fs.components[node] + else: + raise KeyError(f"'{node}' not found in buses or components") + + input_labels = [f.label_full for f in element.inputs] + output_labels = [f.label_full for f in element.outputs] + all_labels = input_labels + output_labels + + filtered_labels = _filter_by_pattern(all_labels, include, exclude) + if not filtered_labels: + logger.warning(f'No flows remaining after filtering for node {node}') + return PlotResult(data=xr.Dataset(), figure=go.Figure()) + + # Get data from statistics + if unit == 'flow_rate': + ds = self._stats.flow_rates[[lbl for lbl in filtered_labels if lbl in self._stats.flow_rates]] + else: + ds = self._stats.flow_hours[[lbl for lbl in filtered_labels if lbl in self._stats.flow_hours]] + + # Negate inputs + for label in input_labels: + if label in ds: + ds[label] = -ds[label] + + ds = _apply_selection(ds, select) + actual_facet_col, actual_facet_row = _resolve_facets(ds, facet_col, facet_row) + + # Build color map from Element.color attributes if no colors specified + if colors is None: + colors = self._get_color_map_for_balance(node, list(ds.data_vars)) + + # Get unit label from first data variable's attributes + unit_label = '' + if ds.data_vars: + first_var = next(iter(ds.data_vars)) + unit_label = ds[first_var].attrs.get('unit', '') + + fig = _create_stacked_bar( + ds, + colors=colors, + title=f'{node} [{unit_label}]' if unit_label else node, + facet_col=actual_facet_col, + facet_row=actual_facet_row, + **plotly_kwargs, + ) + + if show is None: + show = CONFIG.Plotting.default_show + if show: + fig.show() + + return PlotResult(data=ds, figure=fig) + + def carrier_balance( + self, + carrier: str, + *, + select: SelectType | None = None, + include: FilterType | None = None, + exclude: FilterType | None = None, + unit: Literal['flow_rate', 'flow_hours'] = 'flow_rate', + colors: ColorType | None = None, + facet_col: str | None = 'period', + facet_row: str | None = 'scenario', + show: bool | None = None, + **plotly_kwargs: Any, + ) -> PlotResult: + """Plot carrier-level balance showing all flows of a carrier type. + + Shows production (positive) and consumption (negative) of a carrier + across all buses of that carrier type in the system. + + Args: + carrier: Carrier name (e.g., 'heat', 'electricity', 'gas'). + select: xarray-style selection dict. + include: Only include flows containing these substrings. + exclude: Exclude flows containing these substrings. + unit: 'flow_rate' (power) or 'flow_hours' (energy). + colors: Color specification (colorscale name, color list, or label-to-color dict). + facet_col: Dimension for column facets. + facet_row: Dimension for row facets. + show: Whether to display the plot. + + Returns: + PlotResult with .data and .figure. + + Examples: + >>> fs.statistics.plot.carrier_balance('heat') + >>> fs.statistics.plot.carrier_balance('electricity', unit='flow_hours') + + Notes: + - Inputs to carrier buses (from sources/converters) are shown as positive + - Outputs from carrier buses (to sinks/converters) are shown as negative + - Internal transfers between buses of the same carrier appear on both sides + """ + self._stats._require_solution() + carrier = carrier.lower() + + # Find all buses with this carrier + carrier_buses = [bus for bus in self._fs.buses.values() if bus.carrier == carrier] + if not carrier_buses: + raise KeyError(f"No buses found with carrier '{carrier}'") + + # Collect all flows connected to these buses + input_labels: list[str] = [] # Inputs to buses = production + output_labels: list[str] = [] # Outputs from buses = consumption + + for bus in carrier_buses: + for flow in bus.inputs: + input_labels.append(flow.label_full) + for flow in bus.outputs: + output_labels.append(flow.label_full) + + all_labels = input_labels + output_labels + filtered_labels = _filter_by_pattern(all_labels, include, exclude) + if not filtered_labels: + logger.warning(f'No flows remaining after filtering for carrier {carrier}') + return PlotResult(data=xr.Dataset(), figure=go.Figure()) + + # Get data from statistics + if unit == 'flow_rate': + ds = self._stats.flow_rates[[lbl for lbl in filtered_labels if lbl in self._stats.flow_rates]] + else: + ds = self._stats.flow_hours[[lbl for lbl in filtered_labels if lbl in self._stats.flow_hours]] + + # Negate outputs (consumption) - opposite convention from bus balance + for label in output_labels: + if label in ds: + ds[label] = -ds[label] + + ds = _apply_selection(ds, select) + actual_facet_col, actual_facet_row = _resolve_facets(ds, facet_col, facet_row) + + # Use cached component colors for flows + if colors is None: + component_colors = self._stats.component_colors + color_map = {} + uncolored = [] + for label in ds.data_vars: + flow = self._fs.flows.get(label) + if flow: + color = component_colors.get(flow.component) + if color: + color_map[label] = color + continue + uncolored.append(label) + if uncolored: + color_map.update(process_colors(CONFIG.Plotting.default_qualitative_colorscale, uncolored)) + colors = color_map + + # Get unit label from carrier or first data variable + unit_label = '' + if ds.data_vars: + first_var = next(iter(ds.data_vars)) + unit_label = ds[first_var].attrs.get('unit', '') + + fig = _create_stacked_bar( + ds, + colors=colors, + title=f'{carrier.capitalize()} Balance [{unit_label}]' if unit_label else f'{carrier.capitalize()} Balance', + facet_col=actual_facet_col, + facet_row=actual_facet_row, + **plotly_kwargs, + ) + + if show is None: + show = CONFIG.Plotting.default_show + if show: + fig.show() + + return PlotResult(data=ds, figure=fig) + + def heatmap( + self, + variables: str | list[str], + *, + select: SelectType | None = None, + reshape: tuple[str, str] | None = ('D', 'h'), + colors: str | list[str] | None = None, + facet_col: str | None = 'period', + animation_frame: str | None = 'scenario', + show: bool | None = None, + **plotly_kwargs: Any, + ) -> PlotResult: + """Plot heatmap of time series data. + + Time is reshaped into 2D (e.g., days × hours) when possible. Multiple variables + are shown as facets. If too many dimensions exist to display without data loss, + reshaping is skipped and variables are shown on the y-axis with time on x-axis. + + Args: + variables: Flow label(s) or variable name(s). Flow labels like 'Boiler(Q_th)' + are automatically resolved to 'Boiler(Q_th)|flow_rate'. Full variable + names like 'Storage|charge_state' are used as-is. + select: xarray-style selection, e.g. {'scenario': 'Base Case'}. + reshape: Time reshape frequencies as (outer, inner), e.g. ('D', 'h') for + days × hours. Set to None to disable reshaping. + colors: Colorscale name (str) or list of colors for heatmap coloring. + Dicts are not supported for heatmaps (use str or list[str]). + facet_col: Dimension for subplot columns (default: 'period'). + With multiple variables, 'variable' is used instead. + animation_frame: Dimension for animation slider (default: 'scenario'). + show: Whether to display the figure. + **plotly_kwargs: Additional arguments passed to px.imshow. + + Returns: + PlotResult with processed data and figure. + """ + solution = self._stats._require_solution() + + if isinstance(variables, str): + variables = [variables] + + # Resolve flow labels to variable names + resolved_variables = self._resolve_variable_names(variables, solution) + + ds = solution[resolved_variables] + ds = _apply_selection(ds, select) + + # Stack variables into single DataArray + variable_names = list(ds.data_vars) + dataarrays = [ds[var] for var in variable_names] + da = xr.concat(dataarrays, dim=pd.Index(variable_names, name='variable')) + + # Determine facet and animation from available dims + has_multiple_vars = 'variable' in da.dims and da.sizes['variable'] > 1 + + if has_multiple_vars: + actual_facet = 'variable' + actual_animation = ( + animation_frame + if animation_frame in da.dims + else (facet_col if facet_col in da.dims and da.sizes.get(facet_col, 1) > 1 else None) + ) + else: + actual_facet = facet_col if facet_col in da.dims and da.sizes.get(facet_col, 0) > 1 else None + actual_animation = ( + animation_frame if animation_frame in da.dims and da.sizes.get(animation_frame, 0) > 1 else None + ) + + # Count non-time dims with size > 1 (these need facet/animation slots) + extra_dims = [d for d in da.dims if d != 'time' and da.sizes[d] > 1] + used_slots = len([d for d in [actual_facet, actual_animation] if d]) + would_drop = len(extra_dims) > used_slots + + # Reshape time only if we wouldn't lose data (all extra dims fit in facet + animation) + if reshape and 'time' in da.dims and not would_drop: + da = _reshape_time_for_heatmap(da, reshape) + heatmap_dims = ['timestep', 'timeframe'] + elif has_multiple_vars: + # Can't reshape but have multiple vars: use variable + time as heatmap axes + heatmap_dims = ['variable', 'time'] + # variable is now a heatmap dim, use period/scenario for facet/animation + actual_facet = facet_col if facet_col in da.dims and da.sizes.get(facet_col, 0) > 1 else None + actual_animation = ( + animation_frame if animation_frame in da.dims and da.sizes.get(animation_frame, 0) > 1 else None + ) + else: + heatmap_dims = ['time'] if 'time' in da.dims else list(da.dims)[:1] + + # Keep only dims we need + keep_dims = set(heatmap_dims) | {d for d in [actual_facet, actual_animation] if d is not None} + for dim in [d for d in da.dims if d not in keep_dims]: + da = da.isel({dim: 0}, drop=True) if da.sizes[dim] > 1 else da.squeeze(dim, drop=True) + + # Transpose to expected order + dim_order = heatmap_dims + [d for d in [actual_facet, actual_animation] if d] + da = da.transpose(*dim_order) + + # Clear name for multiple variables (colorbar would show first var's name) + if has_multiple_vars: + da = da.rename('') + + fig = _heatmap_figure( + da, + colors=colors, + facet_col=actual_facet, + animation_frame=actual_animation, + **plotly_kwargs, + ) + + if show is None: + show = CONFIG.Plotting.default_show + if show: + fig.show() + + reshaped_ds = da.to_dataset(name='value') if isinstance(da, xr.DataArray) else da + return PlotResult(data=reshaped_ds, figure=fig) + + def flows( + self, + *, + start: str | list[str] | None = None, + end: str | list[str] | None = None, + component: str | list[str] | None = None, + select: SelectType | None = None, + unit: Literal['flow_rate', 'flow_hours'] = 'flow_rate', + colors: ColorType | None = None, + facet_col: str | None = 'period', + facet_row: str | None = 'scenario', + show: bool | None = None, + **plotly_kwargs: Any, + ) -> PlotResult: + """Plot flow rates filtered by start/end nodes or component. + + Args: + start: Filter by source node(s). + end: Filter by destination node(s). + component: Filter by parent component(s). + select: xarray-style selection. + unit: 'flow_rate' or 'flow_hours'. + colors: Color specification (colorscale name, color list, or label-to-color dict). + facet_col: Dimension for column facets. + facet_row: Dimension for row facets. + show: Whether to display. + + Returns: + PlotResult with flow data. + """ + self._stats._require_solution() + + ds = self._stats.flow_rates if unit == 'flow_rate' else self._stats.flow_hours + + # Filter by connection + if start is not None or end is not None or component is not None: + matching_labels = [] + starts = [start] if isinstance(start, str) else (start or []) + ends = [end] if isinstance(end, str) else (end or []) + components = [component] if isinstance(component, str) else (component or []) + + for flow in self._fs.flows.values(): + # Get bus label (could be string or Bus object) + bus_label = flow.bus + comp_label = flow.component + + # start/end filtering based on flow direction + if flow.is_input_in_component: + # Flow goes: bus -> component, so start=bus, end=component + if starts and bus_label not in starts: + continue + if ends and comp_label not in ends: + continue + else: + # Flow goes: component -> bus, so start=component, end=bus + if starts and comp_label not in starts: + continue + if ends and bus_label not in ends: + continue + + if components and comp_label not in components: + continue + matching_labels.append(flow.label_full) + + ds = ds[[lbl for lbl in matching_labels if lbl in ds]] + + ds = _apply_selection(ds, select) + actual_facet_col, actual_facet_row = _resolve_facets(ds, facet_col, facet_row) + + # Get unit label from first data variable's attributes + unit_label = '' + if ds.data_vars: + first_var = next(iter(ds.data_vars)) + unit_label = ds[first_var].attrs.get('unit', '') + + fig = _create_line( + ds, + colors=colors, + title=f'Flows [{unit_label}]' if unit_label else 'Flows', + facet_col=actual_facet_col, + facet_row=actual_facet_row, + **plotly_kwargs, + ) + + if show is None: + show = CONFIG.Plotting.default_show + if show: + fig.show() + + return PlotResult(data=ds, figure=fig) + + def sizes( + self, + *, + max_size: float | None = 1e6, + select: SelectType | None = None, + colors: ColorType | None = None, + facet_col: str | None = 'period', + facet_row: str | None = 'scenario', + show: bool | None = None, + **plotly_kwargs: Any, + ) -> PlotResult: + """Plot investment sizes (capacities) of flows. + + Args: + max_size: Maximum size to include (filters defaults). + select: xarray-style selection. + colors: Color specification (colorscale name, color list, or label-to-color dict). + facet_col: Dimension for column facets. + facet_row: Dimension for row facets. + show: Whether to display. + + Returns: + PlotResult with size data. + """ + self._stats._require_solution() + ds = self._stats.sizes + + ds = _apply_selection(ds, select) + + if max_size is not None and ds.data_vars: + valid_labels = [lbl for lbl in ds.data_vars if float(ds[lbl].max()) < max_size] + ds = ds[valid_labels] + + actual_facet_col, actual_facet_row = _resolve_facets(ds, facet_col, facet_row) + + df = _dataset_to_long_df(ds) + if df.empty: + fig = go.Figure() + else: + variables = df['variable'].unique().tolist() + color_map = process_colors(colors, variables) + fig = px.bar( + df, + x='variable', + y='value', + color='variable', + facet_col=actual_facet_col, + facet_row=actual_facet_row, + color_discrete_map=color_map, + title='Investment Sizes', + labels={'variable': 'Flow', 'value': 'Size'}, + **plotly_kwargs, + ) + + if show is None: + show = CONFIG.Plotting.default_show + if show: + fig.show() + + return PlotResult(data=ds, figure=fig) + + def duration_curve( + self, + variables: str | list[str], + *, + select: SelectType | None = None, + normalize: bool = False, + colors: ColorType | None = None, + facet_col: str | None = 'period', + facet_row: str | None = 'scenario', + show: bool | None = None, + **plotly_kwargs: Any, + ) -> PlotResult: + """Plot load duration curves (sorted time series). + + Args: + variables: Flow label(s) or variable name(s). Flow labels like 'Boiler(Q_th)' + are looked up in flow_rates. Full variable names like 'Boiler(Q_th)|flow_rate' + are stripped to their flow label. Other variables (e.g., 'Storage|charge_state') + are looked up in the solution directly. + select: xarray-style selection. + normalize: If True, normalize x-axis to 0-100%. + colors: Color specification (colorscale name, color list, or label-to-color dict). + facet_col: Dimension for column facets. + facet_row: Dimension for row facets. + show: Whether to display. + + Returns: + PlotResult with sorted duration curve data. + """ + solution = self._stats._require_solution() + + if isinstance(variables, str): + variables = [variables] + + # Normalize variable names: strip |flow_rate suffix for flow_rates lookup + flow_rates = self._stats.flow_rates + normalized_vars = [] + for var in variables: + # Strip |flow_rate suffix if present + if var.endswith('|flow_rate'): + var = var[: -len('|flow_rate')] + normalized_vars.append(var) + + # Try to get from flow_rates first, fall back to solution for non-flow variables + ds_parts = [] + for var in normalized_vars: + if var in flow_rates: + ds_parts.append(flow_rates[[var]]) + elif var in solution: + ds_parts.append(solution[[var]]) + else: + # Try with |flow_rate suffix as last resort + flow_rate_var = f'{var}|flow_rate' + if flow_rate_var in solution: + ds_parts.append(solution[[flow_rate_var]].rename({flow_rate_var: var})) + else: + raise KeyError(f"Variable '{var}' not found in flow_rates or solution") + + ds = xr.merge(ds_parts) + ds = _apply_selection(ds, select) + + if 'time' not in ds.dims: + raise ValueError('Duration curve requires time dimension') + + def sort_descending(arr: np.ndarray) -> np.ndarray: + return np.sort(arr)[::-1] + + result_ds = xr.apply_ufunc( + sort_descending, + ds, + input_core_dims=[['time']], + output_core_dims=[['time']], + vectorize=True, + ) + + duration_name = 'duration_pct' if normalize else 'duration' + result_ds = result_ds.rename({'time': duration_name}) + + n_timesteps = result_ds.sizes[duration_name] + duration_coord = np.linspace(0, 100, n_timesteps) if normalize else np.arange(n_timesteps) + result_ds = result_ds.assign_coords({duration_name: duration_coord}) + + actual_facet_col, actual_facet_row = _resolve_facets(result_ds, facet_col, facet_row) + + # Get unit label from first data variable's attributes + unit_label = '' + if ds.data_vars: + first_var = next(iter(ds.data_vars)) + unit_label = ds[first_var].attrs.get('unit', '') + + fig = _create_line( + result_ds, + colors=colors, + title=f'Duration Curve [{unit_label}]' if unit_label else 'Duration Curve', + facet_col=actual_facet_col, + facet_row=actual_facet_row, + **plotly_kwargs, + ) + + x_label = 'Duration [%]' if normalize else 'Timesteps' + fig.update_xaxes(title_text=x_label) + + if show is None: + show = CONFIG.Plotting.default_show + if show: + fig.show() + + return PlotResult(data=result_ds, figure=fig) + + def effects( + self, + aspect: Literal['total', 'temporal', 'periodic'] = 'total', + *, + effect: str | None = None, + by: Literal['component', 'contributor', 'time'] = 'component', + select: SelectType | None = None, + colors: ColorType | None = None, + facet_col: str | None = 'period', + facet_row: str | None = 'scenario', + show: bool | None = None, + **plotly_kwargs: Any, + ) -> PlotResult: + """Plot effect (cost, emissions, etc.) breakdown. + + Args: + aspect: Which aspect to plot - 'total', 'temporal', or 'periodic'. + effect: Specific effect name to plot (e.g., 'costs', 'CO2'). + If None, plots all effects. + by: Group by 'component', 'contributor' (individual flows), or 'time'. + select: xarray-style selection. + colors: Color specification (colorscale name, color list, or label-to-color dict). + facet_col: Dimension for column facets (ignored if not in data). + facet_row: Dimension for row facets (ignored if not in data). + show: Whether to display. + + Returns: + PlotResult with effect breakdown data. + + Examples: + >>> flow_system.statistics.plot.effects() # Total of all effects by component + >>> flow_system.statistics.plot.effects(effect='costs') # Just costs + >>> flow_system.statistics.plot.effects(by='contributor') # By individual flows + >>> flow_system.statistics.plot.effects(aspect='temporal', by='time') # Over time + """ + self._stats._require_solution() + + # Get the appropriate effects dataset based on aspect + if aspect == 'total': + effects_ds = self._stats.total_effects + elif aspect == 'temporal': + effects_ds = self._stats.temporal_effects + elif aspect == 'periodic': + effects_ds = self._stats.periodic_effects + else: + raise ValueError(f"Aspect '{aspect}' not valid. Choose from 'total', 'temporal', 'periodic'.") + + # Get available effects (data variables in the dataset) + available_effects = list(effects_ds.data_vars) + + # Filter to specific effect if requested + if effect is not None: + if effect not in available_effects: + raise ValueError(f"Effect '{effect}' not found. Available: {available_effects}") + effects_to_plot = [effect] + else: + effects_to_plot = available_effects + + # Build a combined DataArray with effect dimension + effect_arrays = [] + for eff in effects_to_plot: + da = effects_ds[eff] + if by == 'contributor': + # Keep individual contributors (flows) - no groupby + effect_arrays.append(da.expand_dims(effect=[eff])) + else: + # Group by component (sum over contributor within each component) + da_grouped = da.groupby('component').sum() + effect_arrays.append(da_grouped.expand_dims(effect=[eff])) + + combined = xr.concat(effect_arrays, dim='effect') + + # Apply selection + combined = _apply_selection(combined.to_dataset(name='value'), select)['value'] + + # Group by the specified dimension + if by == 'component': + # Sum over time if present + if 'time' in combined.dims: + combined = combined.sum(dim='time') + x_col = 'component' + color_col = 'effect' if len(effects_to_plot) > 1 else 'component' + elif by == 'contributor': + # Sum over time if present + if 'time' in combined.dims: + combined = combined.sum(dim='time') + x_col = 'contributor' + color_col = 'effect' if len(effects_to_plot) > 1 else 'contributor' + elif by == 'time': + if 'time' not in combined.dims: + raise ValueError(f"Cannot plot by 'time' for aspect '{aspect}' - no time dimension.") + # Sum over components or contributors + if 'component' in combined.dims: + combined = combined.sum(dim='component') + if 'contributor' in combined.dims: + combined = combined.sum(dim='contributor') + x_col = 'time' + color_col = 'effect' if len(effects_to_plot) > 1 else None + else: + raise ValueError(f"'by' must be one of 'component', 'contributor', 'time', got {by!r}") + + # Resolve facets + actual_facet_col, actual_facet_row = _resolve_facets(combined.to_dataset(name='value'), facet_col, facet_row) + + # Convert to DataFrame for plotly express + df = combined.to_dataframe(name='value').reset_index() + + # Build color map + if color_col and color_col in df.columns: + color_items = df[color_col].unique().tolist() + color_map = process_colors(colors, color_items) + else: + color_map = None + + # Build title with unit if single effect + effect_label = effect if effect else 'Effects' + if effect and effect in effects_ds: + unit_label = effects_ds[effect].attrs.get('unit', '') + title = f'{effect_label} [{unit_label}]' if unit_label else effect_label + else: + title = effect_label + title = f'{title} ({aspect}) by {by}' + + fig = px.bar( + df, + x=x_col, + y='value', + color=color_col, + color_discrete_map=color_map, + facet_col=actual_facet_col, + facet_row=actual_facet_row, + title=title, + **plotly_kwargs, + ) + fig.update_layout(bargap=0, bargroupgap=0) + fig.update_traces(marker_line_width=0) + + if show is None: + show = CONFIG.Plotting.default_show + if show: + fig.show() + + return PlotResult(data=combined.to_dataset(name=aspect), figure=fig) + + def charge_states( + self, + storages: str | list[str] | None = None, + *, + select: SelectType | None = None, + colors: ColorType | None = None, + facet_col: str | None = 'period', + facet_row: str | None = 'scenario', + show: bool | None = None, + **plotly_kwargs: Any, + ) -> PlotResult: + """Plot storage charge states over time. + + Args: + storages: Storage label(s) to plot. If None, plots all storages. + select: xarray-style selection. + colors: Color specification (colorscale name, color list, or label-to-color dict). + facet_col: Dimension for column facets. + facet_row: Dimension for row facets. + show: Whether to display. + + Returns: + PlotResult with charge state data. + """ + self._stats._require_solution() + ds = self._stats.charge_states + + if storages is not None: + if isinstance(storages, str): + storages = [storages] + ds = ds[[s for s in storages if s in ds]] + + ds = _apply_selection(ds, select) + actual_facet_col, actual_facet_row = _resolve_facets(ds, facet_col, facet_row) + + fig = _create_line( + ds, + colors=colors, + title='Storage Charge States', + facet_col=actual_facet_col, + facet_row=actual_facet_row, + **plotly_kwargs, + ) + fig.update_yaxes(title_text='Charge State') + + if show is None: + show = CONFIG.Plotting.default_show + if show: + fig.show() + + return PlotResult(data=ds, figure=fig) + + def storage( + self, + storage: str, + *, + select: SelectType | None = None, + unit: Literal['flow_rate', 'flow_hours'] = 'flow_rate', + colors: ColorType | None = None, + charge_state_color: str = 'black', + facet_col: str | None = 'period', + facet_row: str | None = 'scenario', + show: bool | None = None, + **plotly_kwargs: Any, + ) -> PlotResult: + """Plot storage operation: balance and charge state in vertically stacked subplots. + + Creates two subplots sharing the x-axis: + - Top: Charging/discharging flows as stacked bars (inputs negative, outputs positive) + - Bottom: Charge state over time as a line + + Args: + storage: Storage component label. + select: xarray-style selection. + unit: 'flow_rate' (power) or 'flow_hours' (energy). + colors: Color specification for flow bars. + charge_state_color: Color for the charge state line overlay. + facet_col: Dimension for column facets. + facet_row: Dimension for row facets. + show: Whether to display. + + Returns: + PlotResult with combined balance and charge state data. + + Raises: + KeyError: If storage component not found. + ValueError: If component is not a storage. + """ + self._stats._require_solution() + + # Get the storage component + if storage not in self._fs.components: + raise KeyError(f"'{storage}' not found in components") + + component = self._fs.components[storage] + + # Check if it's a storage by looking for charge_state variable + charge_state_var = f'{storage}|charge_state' + if charge_state_var not in self._fs.solution: + raise ValueError(f"'{storage}' is not a storage (no charge_state variable found)") + + # Get flow data + input_labels = [f.label_full for f in component.inputs] + output_labels = [f.label_full for f in component.outputs] + all_labels = input_labels + output_labels + + if unit == 'flow_rate': + ds = self._stats.flow_rates[[lbl for lbl in all_labels if lbl in self._stats.flow_rates]] + else: + ds = self._stats.flow_hours[[lbl for lbl in all_labels if lbl in self._stats.flow_hours]] + + # Negate outputs for balance view (discharging shown as negative) + for label in output_labels: + if label in ds: + ds[label] = -ds[label] + + # Get charge state and add to dataset + charge_state = self._fs.solution[charge_state_var].rename(storage) + ds['charge_state'] = charge_state + + # Apply selection + ds = _apply_selection(ds, select) + actual_facet_col, actual_facet_row = _resolve_facets(ds, facet_col, facet_row) + + # Build color map + flow_labels = [lbl for lbl in ds.data_vars if lbl != 'charge_state'] + if colors is None: + colors = self._get_color_map_for_balance(storage, flow_labels) + color_map = process_colors(colors, flow_labels) + color_map['charge_state'] = 'black' + + # Convert to long-form DataFrame + df = _dataset_to_long_df(ds) + + # Create figure with facets using px.bar for flows, then add charge_state line + flow_df = df[df['variable'] != 'charge_state'] + charge_df = df[df['variable'] == 'charge_state'] + + fig = px.bar( + flow_df, + x='time', + y='value', + color='variable', + facet_col=actual_facet_col, + facet_row=actual_facet_row, + color_discrete_map=color_map, + title=f'{storage} Operation ({unit})', + **plotly_kwargs, + ) + fig.update_layout(bargap=0, bargroupgap=0) + fig.update_traces(marker_line_width=0) + + # Add charge state as line on secondary y-axis using px.line, then merge traces + if not charge_df.empty: + line_fig = px.line( + charge_df, + x='time', + y='value', + facet_col=actual_facet_col, + facet_row=actual_facet_row, + ) + # Update line traces and add to main figure + for trace in line_fig.data: + trace.name = 'charge_state' + trace.line = dict(color=charge_state_color, width=2) + trace.yaxis = 'y2' + trace.showlegend = True + fig.add_trace(trace) + + # Add secondary y-axis + fig.update_layout( + yaxis2=dict( + title='Charge State', + overlaying='y', + side='right', + showgrid=False, + ) + ) + + if show is None: + show = CONFIG.Plotting.default_show + if show: + fig.show() + + return PlotResult(data=ds, figure=fig) diff --git a/flixopt/structure.py b/flixopt/structure.py index 62067e2ba..88fd9ce31 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -6,13 +6,16 @@ from __future__ import annotations import inspect +import json import logging +import pathlib import re from dataclasses import dataclass from difflib import get_close_matches from typing import ( TYPE_CHECKING, Any, + ClassVar, Generic, Literal, TypeVar, @@ -28,7 +31,6 @@ from .core import FlowSystemDimensions, TimeSeriesData, get_dataarray_stats if TYPE_CHECKING: # for type checking and preventing circular imports - import pathlib from collections.abc import Collection, ItemsView, Iterator from .effects import EffectCollectionModel @@ -108,6 +110,16 @@ def do_modeling(self): # Add scenario equality constraints after all elements are modeled self._add_scenario_equality_constraints() + # Populate _variable_names and _constraint_names on each Element + self._populate_element_variable_names() + + def _populate_element_variable_names(self): + """Populate _variable_names and _constraint_names on each Element from its submodel.""" + for element in self.flow_system.values(): + if element.submodel is not None: + element._variable_names = list(element.submodel.variables) + element._constraint_names = list(element.submodel.constraints) + def _add_scenario_equality_for_parameter_type( self, parameter_type: Literal['flow_rate', 'size'], @@ -156,29 +168,45 @@ def _add_scenario_equality_constraints(self): @property def solution(self): + """Build solution dataset, reindexing to timesteps_extra for consistency.""" solution = super().solution solution['objective'] = self.objective.value + # Store attrs as JSON strings for netCDF compatibility solution.attrs = { - 'Components': { - comp.label_full: comp.submodel.results_structure() - for comp in sorted( - self.flow_system.components.values(), key=lambda component: component.label_full.upper() - ) - }, - 'Buses': { - bus.label_full: bus.submodel.results_structure() - for bus in sorted(self.flow_system.buses.values(), key=lambda bus: bus.label_full.upper()) - }, - 'Effects': { - effect.label_full: effect.submodel.results_structure() - for effect in sorted(self.flow_system.effects.values(), key=lambda effect: effect.label_full.upper()) - }, - 'Flows': { - flow.label_full: flow.submodel.results_structure() - for flow in sorted(self.flow_system.flows.values(), key=lambda flow: flow.label_full.upper()) - }, + 'Components': json.dumps( + { + comp.label_full: comp.submodel.results_structure() + for comp in sorted( + self.flow_system.components.values(), key=lambda component: component.label_full.upper() + ) + } + ), + 'Buses': json.dumps( + { + bus.label_full: bus.submodel.results_structure() + for bus in sorted(self.flow_system.buses.values(), key=lambda bus: bus.label_full.upper()) + } + ), + 'Effects': json.dumps( + { + effect.label_full: effect.submodel.results_structure() + for effect in sorted( + self.flow_system.effects.values(), key=lambda effect: effect.label_full.upper() + ) + } + ), + 'Flows': json.dumps( + { + flow.label_full: flow.submodel.results_structure() + for flow in sorted(self.flow_system.flows.values(), key=lambda flow: flow.label_full.upper()) + } + ), } - return solution.reindex(time=self.flow_system.timesteps_extra) + # Ensure solution is always indexed by timesteps_extra for consistency. + # Variables without extra timestep data will have NaN at the final timestep. + if 'time' in solution.coords and not solution.indexes['time'].equals(self.flow_system.timesteps_extra): + solution = solution.reindex(time=self.flow_system.timesteps_extra) + return solution @property def hours_per_step(self): @@ -291,14 +319,18 @@ class Interface: - Recursive handling of complex nested structures Subclasses must implement: - transform_data(name_prefix=''): Transform data to match FlowSystem dimensions + transform_data(): Transform data to match FlowSystem dimensions """ - def transform_data(self, name_prefix: str = '') -> None: + # Class-level defaults for attributes set by link_to_flow_system() + # These provide type hints and default values without requiring __init__ in subclasses + _flow_system: FlowSystem | None = None + _prefix: str = '' + + def transform_data(self) -> None: """Transform the data of the interface to match the FlowSystem's dimensions. - Args: - name_prefix: The prefix to use for the names of the variables. Defaults to '', which results in no prefix. + Uses `self._prefix` (set during `link_to_flow_system()`) to name transformed data. Raises: NotImplementedError: Must be implemented by subclasses @@ -310,20 +342,53 @@ def transform_data(self, name_prefix: str = '') -> None: """ raise NotImplementedError('Every Interface subclass needs a transform_data() method') - def _set_flow_system(self, flow_system: FlowSystem) -> None: - """Store flow_system reference and propagate to nested Interface objects. + @property + def prefix(self) -> str: + """The prefix used for naming transformed data (e.g., 'Boiler(Q_th)|status_parameters').""" + return self._prefix + + def _sub_prefix(self, name: str) -> str: + """Build a prefix for a nested interface by appending name to current prefix.""" + return f'{self._prefix}|{name}' if self._prefix else name + + def link_to_flow_system(self, flow_system: FlowSystem, prefix: str = '') -> None: + """Link this interface and all nested interfaces to a FlowSystem. This method is called automatically during element registration to enable elements to access FlowSystem properties without passing the reference - through every method call. + through every method call. It also sets the prefix used for naming + transformed data. Subclasses with nested Interface objects should override this method - to explicitly propagate the reference to their nested interfaces. + to propagate the link to their nested interfaces by calling + `super().link_to_flow_system(flow_system, prefix)` first, then linking + nested objects with appropriate prefixes. Args: - flow_system: The FlowSystem that this interface belongs to + flow_system: The FlowSystem to link to + prefix: The prefix for naming transformed data (e.g., 'Boiler(Q_th)') + + Examples: + Override in a subclass with nested interfaces: + + ```python + def link_to_flow_system(self, flow_system, prefix: str = '') -> None: + super().link_to_flow_system(flow_system, prefix) + if self.nested_interface is not None: + self.nested_interface.link_to_flow_system(flow_system, f'{prefix}|nested' if prefix else 'nested') + ``` + + Creating an Interface dynamically during modeling: + + ```python + # In a Model class + if flow.status_parameters is None: + flow.status_parameters = StatusParameters() + flow.status_parameters.link_to_flow_system(self._model.flow_system, f'{flow.label_full}') + ``` """ self._flow_system = flow_system + self._prefix = prefix @property def flow_system(self) -> FlowSystem: @@ -339,7 +404,7 @@ def flow_system(self) -> FlowSystem: For Elements, this is set during add_elements(). For parameter classes, this is set recursively when the parent Element is registered. """ - if not hasattr(self, '_flow_system') or self._flow_system is None: + if self._flow_system is None: raise RuntimeError( f'{self.__class__.__name__} is not linked to a FlowSystem. ' f'Ensure the parent element is registered via flow_system.add_elements() first.' @@ -723,7 +788,34 @@ def _resolve_reference_structure(cls, structure, arrays_dict: dict[str, xr.DataA resolved_nested_data = cls._resolve_reference_structure(nested_data, arrays_dict) try: - return nested_class(**resolved_nested_data) + # Get valid constructor parameters for this class + init_params = set(inspect.signature(nested_class.__init__).parameters.keys()) + + # Check for deferred init attributes (defined as class attribute on Element subclasses) + # These are serialized but set after construction, not passed to child __init__ + deferred_attr_names = getattr(nested_class, '_deferred_init_attrs', set()) + deferred_attrs = {k: v for k, v in resolved_nested_data.items() if k in deferred_attr_names} + constructor_data = {k: v for k, v in resolved_nested_data.items() if k not in deferred_attr_names} + + # Check for unknown parameters - these could be typos or renamed params + unknown_params = set(constructor_data.keys()) - init_params + if unknown_params: + raise TypeError( + f'{class_name}.__init__() got unexpected keyword arguments: {unknown_params}. ' + f'This may indicate renamed parameters that need conversion. ' + f'Valid parameters are: {init_params - {"self"}}' + ) + + # Create instance with constructor parameters + instance = nested_class(**constructor_data) + + # Set internal attributes after construction + for attr_name, attr_value in deferred_attrs.items(): + setattr(instance, attr_name, attr_value) + + return instance + except TypeError as e: + raise ValueError(f'Failed to create instance of {class_name}: {e}') from e except Exception as e: raise ValueError(f'Failed to create instance of {class_name}: {e}') from e else: @@ -799,18 +891,29 @@ def to_dataset(self) -> xr.Dataset: f'Original Error: {e}' ) from e - def to_netcdf(self, path: str | pathlib.Path, compression: int = 0): + def to_netcdf(self, path: str | pathlib.Path, compression: int = 5, overwrite: bool = False): """ Save the object to a NetCDF file. Args: - path: Path to save the NetCDF file + path: Path to save the NetCDF file. Parent directories are created if they don't exist. compression: Compression level (0-9) + overwrite: If True, overwrite existing file. If False, raise error if file exists. Raises: + FileExistsError: If overwrite=False and file already exists. ValueError: If serialization fails IOError: If file cannot be written """ + path = pathlib.Path(path) + + # Check if file exists (unless overwrite is True) + if not overwrite and path.exists(): + raise FileExistsError(f'File already exists: {path}. Use overwrite=True to overwrite existing file.') + + # Create parent directories if they don't exist + path.parent.mkdir(parents=True, exist_ok=True) + try: ds = self.to_dataset() fx_io.save_dataset_to_netcdf(ds, path, compression=compression) @@ -961,16 +1064,34 @@ class Element(Interface): submodel: ElementModel | None - def __init__(self, label: str, meta_data: dict | None = None): + # Attributes that are serialized but set after construction (not passed to child __init__) + # These are internal state populated during modeling, not user-facing parameters + _deferred_init_attrs: ClassVar[set[str]] = {'_variable_names', '_constraint_names'} + + def __init__( + self, + label: str, + meta_data: dict | None = None, + color: str | None = None, + _variable_names: list[str] | None = None, + _constraint_names: list[str] | None = None, + ): """ Args: label: The label of the element meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types. + color: Optional color for visualizations (e.g., '#FF6B6B'). If not provided, a color will be automatically assigned during FlowSystem.connect_and_transform(). + _variable_names: Internal. Variable names for this element (populated after modeling). + _constraint_names: Internal. Constraint names for this element (populated after modeling). """ self.label = Element._valid_label(label) self.meta_data = meta_data if meta_data is not None else {} + self.color = color self.submodel = None self._flow_system: FlowSystem | None = None + # Variable/constraint names - populated after modeling, serialized for results + self._variable_names: list[str] = _variable_names if _variable_names is not None else [] + self._constraint_names: list[str] = _constraint_names if _constraint_names is not None else [] def _plausibility_checks(self) -> None: """This function is used to do some basic plausibility checks for each Element during initialization. @@ -984,6 +1105,40 @@ def create_model(self, model: FlowSystemModel) -> ElementModel: def label_full(self) -> str: return self.label + @property + def solution(self) -> xr.Dataset: + """Solution data for this element's variables. + + Returns a view into FlowSystem.solution containing only this element's variables. + + Raises: + ValueError: If no solution is available (optimization not run or not solved). + """ + if self._flow_system is None: + raise ValueError(f'Element "{self.label}" is not linked to a FlowSystem.') + if self._flow_system.solution is None: + raise ValueError(f'No solution available for "{self.label}". Run optimization first or load results.') + if not self._variable_names: + raise ValueError(f'No variable names available for "{self.label}". Element may not have been modeled yet.') + return self._flow_system.solution[self._variable_names] + + def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]: + """ + Override to include _variable_names and _constraint_names in serialization. + + These attributes are defined in Element but may not be in subclass constructors, + so we need to add them explicitly. + """ + reference_structure, all_extracted_arrays = super()._create_reference_structure() + + # Always include variable/constraint names for solution access after loading + if self._variable_names: + reference_structure['_variable_names'] = self._variable_names + if self._constraint_names: + reference_structure['_constraint_names'] = self._constraint_names + + return reference_structure, all_extracted_arrays + def __repr__(self) -> str: """Return string representation.""" return fx_io.build_repr_from_init(self, excluded_params={'self', 'label', 'kwargs'}, skip_default_size=True) @@ -1032,16 +1187,20 @@ def __init__( elements: list[T] | dict[str, T] | None = None, element_type_name: str = 'elements', truncate_repr: int | None = None, + item_name: str | None = None, ): """ Args: elements: Initial elements to add (list or dict) element_type_name: Name for display (e.g., 'components', 'buses') truncate_repr: Maximum number of items to show in repr. If None, show all items. Default: None + item_name: Singular name for error messages (e.g., 'Component', 'Carrier'). + If None, inferred from first added item's class name. """ super().__init__() self._element_type_name = element_type_name self._truncate_repr = truncate_repr + self._item_name = item_name if elements is not None: if isinstance(elements, dict): @@ -1063,13 +1222,28 @@ def _get_label(self, element: T) -> str: """ raise NotImplementedError('Subclasses must implement _get_label()') + def _get_item_name(self) -> str: + """Get the singular item name for error messages. + + Returns the explicitly set item_name, or infers from the first item's class name. + Falls back to 'Item' if container is empty and no name was set. + """ + if self._item_name is not None: + return self._item_name + # Infer from first item's class name + if self: + first_item = next(iter(self.values())) + return first_item.__class__.__name__ + return 'Item' + def add(self, element: T) -> None: """Add an element to the container.""" label = self._get_label(element) if label in self: + item_name = element.__class__.__name__ raise ValueError( - f'Element with label "{label}" already exists in {self._element_type_name}. ' - f'Each element must have a unique label.' + f'{item_name} with label "{label}" already exists in {self._element_type_name}. ' + f'Each {item_name.lower()} must have a unique label.' ) self[label] = element @@ -1100,8 +1274,9 @@ def __getitem__(self, label: str) -> T: return super().__getitem__(label) except KeyError: # Provide helpful error with close matches suggestions + item_name = self._get_item_name() suggestions = get_close_matches(label, self.keys(), n=3, cutoff=0.6) - error_msg = f'Element "{label}" not found in {self._element_type_name}.' + error_msg = f'{item_name} "{label}" not found in {self._element_type_name}.' if suggestions: error_msg += f' Did you mean: {", ".join(suggestions)}?' else: diff --git a/flixopt/topology_accessor.py b/flixopt/topology_accessor.py new file mode 100644 index 000000000..eb5f05876 --- /dev/null +++ b/flixopt/topology_accessor.py @@ -0,0 +1,579 @@ +""" +Topology accessor for FlowSystem. + +This module provides the TopologyAccessor class that enables the +`flow_system.topology` pattern for network structure inspection and visualization. +""" + +from __future__ import annotations + +import logging +import pathlib +import warnings +from itertools import chain +from typing import TYPE_CHECKING, Any, Literal + +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 .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. + + This class provides the topology API for FlowSystem, accessible via + `flow_system.topology`. It offers methods to inspect the network structure + and visualize it. + + Examples: + Visualize the network: + + >>> flow_system.topology.plot() + >>> flow_system.topology.plot(path='my_network.html', show=True) + + Interactive visualization: + + >>> flow_system.topology.start_app() + >>> # ... interact with the visualization ... + >>> flow_system.topology.stop_app() + + Get network structure info: + + >>> nodes, edges = flow_system.topology.infos() + """ + + def __init__(self, flow_system: FlowSystem) -> None: + """ + Initialize the accessor with a reference to the FlowSystem. + + Args: + flow_system: The FlowSystem to inspect. + """ + self._fs = flow_system + + # Cached color mappings (lazily initialized) + self._carrier_colors: dict[str, str] | None = None + self._component_colors: dict[str, str] | None = None + self._bus_colors: dict[str, str] | None = None + + # Cached unit mappings (lazily initialized) + self._carrier_units: dict[str, str] | None = None + self._effect_units: dict[str, str] | None = None + + @property + def carrier_colors(self) -> dict[str, str]: + """Cached mapping of carrier name to hex color. + + Returns: + Dict mapping carrier names (lowercase) to hex color strings. + Only carriers with a color defined are included. + + Examples: + >>> fs.topology.carrier_colors + {'electricity': '#FECB52', 'heat': '#D62728', 'gas': '#1F77B4'} + """ + if self._carrier_colors is None: + self._carrier_colors = {name: carrier.color for name, carrier in self._fs.carriers.items() if carrier.color} + return self._carrier_colors + + @property + def component_colors(self) -> dict[str, str]: + """Cached mapping of component label to hex color. + + Returns: + Dict mapping component labels to hex color strings. + Only components with a color defined are included. + + Examples: + >>> fs.topology.component_colors + {'Boiler': '#1f77b4', 'CHP': '#ff7f0e', 'HeatPump': '#2ca02c'} + """ + if self._component_colors is None: + self._component_colors = {label: comp.color for label, comp in self._fs.components.items() if comp.color} + return self._component_colors + + @property + def bus_colors(self) -> dict[str, str]: + """Cached mapping of bus label to hex color (from carrier). + + Bus colors are derived from their associated carrier's color. + + Returns: + Dict mapping bus labels to hex color strings. + Only buses with a carrier that has a color defined are included. + + Examples: + >>> fs.topology.bus_colors + {'ElectricityBus': '#FECB52', 'HeatBus': '#D62728'} + """ + if self._bus_colors is None: + carrier_colors = self.carrier_colors + self._bus_colors = {} + for label, bus in self._fs.buses.items(): + if bus.carrier: + color = carrier_colors.get(bus.carrier.lower()) + if color: + self._bus_colors[label] = color + return self._bus_colors + + @property + def carrier_units(self) -> dict[str, str]: + """Cached mapping of carrier name to unit string. + + Returns: + Dict mapping carrier names (lowercase) to unit strings. + Carriers without a unit defined return an empty string. + + Examples: + >>> fs.topology.carrier_units + {'electricity': 'kW', 'heat': 'kW', 'gas': 'kW'} + """ + if self._carrier_units is None: + self._carrier_units = {name: carrier.unit or '' for name, carrier in self._fs.carriers.items()} + return self._carrier_units + + @property + def effect_units(self) -> dict[str, str]: + """Cached mapping of effect label to unit string. + + Returns: + Dict mapping effect labels to unit strings. + Effects without a unit defined return an empty string. + + Examples: + >>> fs.topology.effect_units + {'costs': '€', 'CO2': 'kg'} + """ + if self._effect_units is None: + self._effect_units = {effect.label: effect.unit or '' for effect in self._fs.effects.values()} + return self._effect_units + + def infos(self) -> tuple[dict[str, dict[str, str]], dict[str, dict[str, str]]]: + """ + Get network topology information as dictionaries. + + Returns node and edge information suitable for visualization or analysis. + + Returns: + Tuple of (nodes_dict, edges_dict) where: + - nodes_dict maps node labels to their properties (label, class, infos) + - edges_dict maps edge labels to their properties (label, start, end, infos) + + Examples: + >>> nodes, edges = flow_system.topology.infos() + >>> print(nodes.keys()) # All component and bus labels + >>> print(edges.keys()) # All flow labels + """ + from .elements import Bus + + if not self._fs.connected_and_transformed: + self._fs.connect_and_transform() + + nodes = { + node.label_full: { + 'label': node.label, + 'class': 'Bus' if isinstance(node, Bus) else 'Component', + 'infos': node.__str__(), + } + for node in chain(self._fs.components.values(), self._fs.buses.values()) + } + + # Use cached colors for efficient lookup + flow_carriers = self._fs.flow_carriers + carrier_colors = self.carrier_colors + + edges = {} + for flow in self._fs.flows.values(): + carrier_name = flow_carriers.get(flow.label_full) + edges[flow.label_full] = { + 'label': flow.label, + 'start': flow.bus if flow.is_input_in_component else flow.component, + 'end': flow.component if flow.is_input_in_component else flow.bus, + 'infos': flow.__str__(), + 'carrier_color': carrier_colors.get(carrier_name) if carrier_name else None, + } + + return nodes, edges + + def plot( + self, + colors: ColorType | None = None, + show: bool | None = None, + **plotly_kwargs: Any, + ) -> PlotResult: + """ + Visualize the network structure as a Sankey diagram using Plotly. + + Creates a Sankey diagram showing the topology of the flow system, + with buses and components as nodes, and flows as links between them. + All links have equal width since no solution data is used. + + Args: + colors: Color specification for nodes (buses). + - `None`: Uses default color palette based on buses. + - `str`: Plotly colorscale name (e.g., 'Viridis', 'Blues'). + - `list`: List of colors to cycle through. + - `dict`: Maps bus labels to specific colors. + Links inherit colors from their connected bus. + show: Whether to display the figure in the browser. + - `None`: Uses default from CONFIG.Plotting.default_show. + **plotly_kwargs: Additional arguments passed to Plotly layout. + + Returns: + PlotResult containing the Sankey diagram figure and topology data + (source, target, value for each link). + + Examples: + >>> flow_system.topology.plot() + >>> flow_system.topology.plot(show=True) + >>> flow_system.topology.plot(colors='Viridis') + >>> flow_system.topology.plot(colors={'ElectricityBus': 'gold', 'HeatBus': 'red'}) + + Notes: + This visualization shows the network structure without optimization results. + For visualizations that include flow values, use `flow_system.statistics.plot.sankey.flows()` + after running an optimization. + + 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: + self._fs.connect_and_transform() + + # Build nodes and links from topology + nodes: set[str] = set() + links: dict[str, list] = { + 'source': [], + 'target': [], + 'value': [], + 'label': [], + 'customdata': [], # For hover text + 'color': [], # Carrier-based colors + } + + # Collect node hover info (format repr for HTML display) + node_hover: dict[str, str] = {} + for comp in self._fs.components.values(): + node_hover[comp.label] = repr(comp).replace('\n', '
') + for bus in self._fs.buses.values(): + node_hover[bus.label] = repr(bus).replace('\n', '
') + + # Use cached colors for efficient lookup + flow_carriers = self._fs.flow_carriers + carrier_colors = self.carrier_colors + + for flow in self._fs.flows.values(): + bus_label = flow.bus + comp_label = flow.component + + if flow.is_input_in_component: + source = bus_label + target = comp_label + else: + source = comp_label + target = bus_label + + nodes.add(source) + nodes.add(target) + links['source'].append(source) + links['target'].append(target) + links['value'].append(1) # Equal width for all links (no solution data) + links['label'].append(flow.label_full) + links['customdata'].append(repr(flow).replace('\n', '
')) # Flow repr for hover + + # Get carrier color for this flow (subtle/semi-transparent) using cached colors + carrier_name = flow_carriers.get(flow.label_full) + color = carrier_colors.get(carrier_name) if carrier_name else None + links['color'].append(hex_to_rgba(color, alpha=0.4) if color else hex_to_rgba('', alpha=0.4)) + + # Create figure + node_list = list(nodes) + node_indices = {n: i for i, n in enumerate(node_list)} + + # Get colors for buses and components using cached colors + bus_colors_cached = self.bus_colors + component_colors_cached = self.component_colors + + # If user provided colors, process them for buses + if colors is not None: + bus_labels = [bus.label for bus in self._fs.buses.values()] + bus_color_map = process_colors(colors, bus_labels) + else: + bus_color_map = bus_colors_cached + + # Assign colors to nodes: buses get their color, components get their color or neutral gray + node_colors = [] + for node in node_list: + if node in bus_color_map: + node_colors.append(bus_color_map[node]) + elif node in component_colors_cached: + node_colors.append(component_colors_cached[node]) + else: + # Fallback - use a neutral gray + node_colors.append('#808080') + + # Build hover text for nodes + node_customdata = [node_hover.get(node, node) for node in node_list] + + fig = go.Figure( + data=[ + go.Sankey( + node=dict( + pad=15, + thickness=20, + line=dict(color='black', width=0.5), + label=node_list, + color=node_colors, + customdata=node_customdata, + hovertemplate='%{customdata}', + ), + link=dict( + source=[node_indices[s] for s in links['source']], + target=[node_indices[t] for t in links['target']], + value=links['value'], + label=links['label'], + customdata=links['customdata'], + hovertemplate='%{customdata}', + color=links['color'], # Carrier-based colors + ), + ) + ] + ) + title = plotly_kwargs.pop('title', 'Flow System Topology') + fig.update_layout(title=title, **plotly_kwargs) + + # Build xarray Dataset with topology data + data = xr.Dataset( + { + 'source': ('link', links['source']), + 'target': ('link', links['target']), + 'value': ('link', links['value']), + }, + coords={'link': links['label']}, + ) + result = PlotResult(data=data, figure=fig) + + if show is None: + show = CONFIG.Plotting.default_show + if show: + result.show() + + 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. + + Launches a web-based interactive visualization server that allows + exploring the network structure dynamically. + + Raises: + ImportError: If required dependencies are not installed. + + Examples: + >>> flow_system.topology.start_app() + >>> # ... interact with the visualization in browser ... + >>> flow_system.topology.stop_app() + + Notes: + Requires optional dependencies: dash, dash-cytoscape, dash-daq, + networkx, flask, werkzeug. + Install with: `pip install flixopt[network_viz]` or `pip install flixopt[full]` + """ + from .network_app import DASH_CYTOSCAPE_AVAILABLE, VISUALIZATION_ERROR, flow_graph, shownetwork + + warnings.warn( + 'The network visualization is still experimental and might change in the future.', + stacklevel=2, + category=UserWarning, + ) + + if not DASH_CYTOSCAPE_AVAILABLE: + raise ImportError( + f'Network visualization requires optional dependencies. ' + f'Install with: `pip install flixopt[network_viz]`, `pip install flixopt[full]` ' + f'or: `pip install dash dash-cytoscape dash-daq networkx werkzeug`. ' + f'Original error: {VISUALIZATION_ERROR}' + ) + + if not self._fs._connected_and_transformed: + self._fs._connect_network() + + if self._fs._network_app is not None: + logger.warning('The network app is already running. Restarting it.') + self.stop_app() + + self._fs._network_app = shownetwork(flow_graph(self._fs)) + + def stop_app(self) -> None: + """ + Stop the interactive network visualization server. + + Examples: + >>> flow_system.topology.stop_app() + """ + if self._fs._network_app is None: + logger.warning("No network app is currently running. Can't stop it") + return + + try: + logger.info('Stopping network visualization server...') + self._fs._network_app.server_instance.shutdown() + logger.info('Network visualization stopped.') + except Exception as e: + logger.error(f'Failed to stop the network visualization app: {e}') + finally: + self._fs._network_app = None diff --git a/flixopt/transform_accessor.py b/flixopt/transform_accessor.py new file mode 100644 index 000000000..eaec1a3b6 --- /dev/null +++ b/flixopt/transform_accessor.py @@ -0,0 +1,703 @@ +""" +Transform accessor for FlowSystem. + +This module provides the TransformAccessor class that enables +transformations on FlowSystem like clustering, selection, and resampling. +""" + +from __future__ import annotations + +import logging +from collections import defaultdict +from typing import TYPE_CHECKING, Any, Literal + +import pandas as pd +import xarray as xr + +if TYPE_CHECKING: + import numpy as np + + from .clustering import ClusteringParameters + from .flow_system import FlowSystem + +logger = logging.getLogger('flixopt') + + +class TransformAccessor: + """ + Accessor for transformation methods on FlowSystem. + + This class provides transformations that create new FlowSystem instances + with modified structure or data, accessible via `flow_system.transform`. + + Examples: + Clustered optimization: + + >>> clustered_fs = flow_system.transform.cluster(params) + >>> clustered_fs.optimize(solver) + >>> print(clustered_fs.solution) + + Future MGA: + + >>> mga_fs = flow_system.transform.mga(alternatives=5) + >>> mga_fs.optimize(solver) + """ + + def __init__(self, flow_system: FlowSystem) -> None: + """ + Initialize the accessor with a reference to the FlowSystem. + + Args: + flow_system: The FlowSystem to transform. + """ + self._fs = flow_system + + def cluster( + self, + parameters: ClusteringParameters, + components_to_clusterize: list | None = None, + ) -> FlowSystem: + """ + Create a clustered FlowSystem for time series aggregation. + + This method creates a new FlowSystem that can be optimized with + clustered time series data. The clustering reduces computational + complexity by identifying representative time periods. + + The returned FlowSystem: + - Has the same timesteps as the original (clustering works via constraints, not reduction) + - Has aggregated time series data (if `aggregate_data_and_fix_non_binary_vars=True`) + - Will have clustering constraints added during `build_model()` + + Args: + parameters: Clustering parameters specifying period duration, + number of periods, and aggregation settings. + components_to_clusterize: List of components to apply clustering to. + If None, all components are clustered. + + Returns: + A new FlowSystem configured for clustered optimization. + + Raises: + ValueError: If timestep sizes are inconsistent. + ValueError: If hours_per_period is not a multiple of timestep size. + + Examples: + Basic clustered optimization: + + >>> from flixopt import ClusteringParameters + >>> params = ClusteringParameters( + ... hours_per_period=24, + ... nr_of_periods=8, + ... fix_storage_flows=True, + ... aggregate_data_and_fix_non_binary_vars=True, + ... ) + >>> clustered_fs = flow_system.transform.cluster(params) + >>> clustered_fs.optimize(solver) + >>> print(clustered_fs.solution) + + With model modifications: + + >>> clustered_fs = flow_system.transform.cluster(params) + >>> clustered_fs.build_model() + >>> clustered_fs.model.add_constraints(...) + >>> clustered_fs.solve(solver) + """ + import numpy as np + + from .clustering import Clustering + from .core import DataConverter, TimeSeriesData, drop_constant_arrays + + # Validation + dt_min = float(self._fs.hours_per_timestep.min().item()) + dt_max = float(self._fs.hours_per_timestep.max().item()) + if dt_min != dt_max: + raise ValueError( + f'Clustering failed due to inconsistent time step sizes: ' + f'delta_t varies from {dt_min} to {dt_max} hours.' + ) + ratio = parameters.hours_per_period / dt_max + if not np.isclose(ratio, round(ratio), atol=1e-9): + raise ValueError( + f'The selected hours_per_period={parameters.hours_per_period} does not match the time ' + f'step size of {dt_max} hours. It must be an integer multiple of {dt_max} hours.' + ) + + logger.info(f'{"":#^80}') + logger.info(f'{" Clustering TimeSeries Data ":#^80}') + + # Get dataset representation + ds = self._fs.to_dataset() + temporaly_changing_ds = drop_constant_arrays(ds, dim='time') + + # Perform clustering + clustering = Clustering( + original_data=temporaly_changing_ds.to_dataframe(), + hours_per_time_step=float(dt_min), + hours_per_period=parameters.hours_per_period, + nr_of_periods=parameters.nr_of_periods, + weights=self._calculate_clustering_weights(temporaly_changing_ds), + time_series_for_high_peaks=parameters.labels_for_high_peaks, + time_series_for_low_peaks=parameters.labels_for_low_peaks, + ) + clustering.cluster() + + # Create new FlowSystem (with aggregated data if requested) + if parameters.aggregate_data_and_fix_non_binary_vars: + # Note: A second to_dataset() call is required here because: + # 1. The first 'ds' (line 124) was processed by drop_constant_arrays() + # 2. We need the full unprocessed dataset to apply aggregated data modifications + # 3. The clustering used 'temporaly_changing_ds' for input, not the full 'ds' + ds = self._fs.to_dataset() + for name, series in clustering.aggregated_data.items(): + da = DataConverter.to_dataarray(series, self._fs.coords).rename(name).assign_attrs(ds[name].attrs) + if TimeSeriesData.is_timeseries_data(da): + da = TimeSeriesData.from_dataarray(da) + ds[name] = da + + from .flow_system import FlowSystem + + clustered_fs = FlowSystem.from_dataset(ds) + else: + # Copy without data modification + clustered_fs = self._fs.copy() + + # Store clustering info for later use + clustered_fs._clustering_info = { + 'parameters': parameters, + 'clustering': clustering, + 'components_to_clusterize': components_to_clusterize, + 'original_fs': self._fs, + } + + return clustered_fs + + @staticmethod + def _calculate_clustering_weights(ds) -> dict[str, float]: + """Calculate weights for clustering based on dataset attributes.""" + from collections import Counter + + import numpy as np + + groups = [da.attrs.get('clustering_group') for da in ds.data_vars.values() if 'clustering_group' in da.attrs] + group_counts = Counter(groups) + + # Calculate weight for each group (1/count) + group_weights = {group: 1 / count for group, count in group_counts.items()} + + weights = {} + for name, da in ds.data_vars.items(): + clustering_group = da.attrs.get('clustering_group') + group_weight = group_weights.get(clustering_group) + if group_weight is not None: + weights[name] = group_weight + else: + weights[name] = da.attrs.get('clustering_weight', 1) + + if np.all(np.isclose(list(weights.values()), 1, atol=1e-6)): + logger.info('All Clustering weights were set to 1') + + return weights + + 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. + + Creates a new FlowSystem with data selected along the specified dimensions. + The returned FlowSystem has no solution (it must be re-optimized). + + 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). + + Examples: + >>> # Select specific time range + >>> fs_jan = flow_system.transform.sel(time=slice('2023-01-01', '2023-01-31')) + >>> fs_jan.optimize(solver) + + >>> # Select single scenario + >>> fs_base = flow_system.transform.sel(scenario='Base Case') + """ + from .flow_system import FlowSystem + + if time is None and period is None and scenario is None: + result = self._fs.copy() + result.solution = None + return result + + if not self._fs.connected_and_transformed: + self._fs.connect_and_transform() + + ds = self._fs.to_dataset() + ds = self._dataset_sel(ds, time=time, period=period, scenario=scenario) + return FlowSystem.from_dataset(ds) # from_dataset doesn't include solution + + 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. + + Creates a new FlowSystem with data selected along the specified dimensions. + The returned FlowSystem has no solution (it must be re-optimized). + + 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). + + Examples: + >>> # Select first 24 timesteps + >>> fs_day1 = flow_system.transform.isel(time=slice(0, 24)) + >>> fs_day1.optimize(solver) + + >>> # Select first scenario + >>> fs_first = flow_system.transform.isel(scenario=0) + """ + from .flow_system import FlowSystem + + if time is None and period is None and scenario is None: + result = self._fs.copy() + result.solution = None + return result + + if not self._fs.connected_and_transformed: + self._fs.connect_and_transform() + + ds = self._fs.to_dataset() + ds = self._dataset_isel(ds, time=time, period=period, scenario=scenario) + return FlowSystem.from_dataset(ds) # from_dataset doesn't include solution + + 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, + fill_gaps: Literal['ffill', 'bfill', 'interpolate'] | None = None, + **kwargs: Any, + ) -> FlowSystem: + """ + Create a resampled FlowSystem by resampling data along the time dimension. + + Creates a new FlowSystem with resampled time series data. + The returned FlowSystem has no solution (it must be re-optimized). + + 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. + 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. + fill_gaps: Strategy for filling gaps (NaN values) that arise when resampling + irregular timesteps to regular intervals. Options: 'ffill' (forward fill), + 'bfill' (backward fill), 'interpolate' (linear interpolation). + If None (default), raises an error when gaps are detected. + **kwargs: Additional arguments passed to xarray.resample() + + Returns: + FlowSystem: New resampled FlowSystem (no solution). + + Raises: + ValueError: If resampling creates gaps and fill_gaps is not specified. + + Examples: + >>> # Resample to 4-hour intervals + >>> fs_4h = flow_system.transform.resample(time='4h', method='mean') + >>> fs_4h.optimize(solver) + + >>> # Resample to daily with max values + >>> fs_daily = flow_system.transform.resample(time='1D', method='max') + """ + from .flow_system import FlowSystem + + if not self._fs.connected_and_transformed: + self._fs.connect_and_transform() + + ds = self._fs.to_dataset() + ds = self._dataset_resample( + ds, + freq=time, + method=method, + hours_of_last_timestep=hours_of_last_timestep, + hours_of_previous_timesteps=hours_of_previous_timesteps, + fill_gaps=fill_gaps, + **kwargs, + ) + return FlowSystem.from_dataset(ds) # from_dataset doesn't include solution + + # --- Class methods for dataset operations (can be called without instance) --- + + @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. + + 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. + hours_of_previous_timesteps: Duration of previous timesteps. + + Returns: + xr.Dataset: Selected dataset + """ + from .flow_system import FlowSystem + + indexers = {} + if time is not None: + indexers['time'] = time + if period is not None: + indexers['period'] = period + if scenario is not None: + indexers['scenario'] = scenario + + if not indexers: + return dataset + + result = dataset.sel(**indexers) + + if 'time' in indexers: + result = FlowSystem._update_time_metadata(result, hours_of_last_timestep, hours_of_previous_timesteps) + + if 'period' in indexers: + result = FlowSystem._update_period_metadata(result) + + if 'scenario' in indexers: + result = FlowSystem._update_scenario_metadata(result) + + return result + + @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. + + Args: + dataset: xarray Dataset from FlowSystem.to_dataset() + time: Time selection by index + period: Period selection by index + scenario: Scenario selection by index + hours_of_last_timestep: Duration of the last timestep. + hours_of_previous_timesteps: Duration of previous timesteps. + + Returns: + xr.Dataset: Selected dataset + """ + from .flow_system import FlowSystem + + indexers = {} + if time is not None: + indexers['time'] = time + if period is not None: + indexers['period'] = period + if scenario is not None: + indexers['scenario'] = scenario + + if not indexers: + return dataset + + result = dataset.isel(**indexers) + + if 'time' in indexers: + result = FlowSystem._update_time_metadata(result, hours_of_last_timestep, hours_of_previous_timesteps) + + if 'period' in indexers: + result = FlowSystem._update_period_metadata(result) + + if 'scenario' in indexers: + result = FlowSystem._update_scenario_metadata(result) + + return result + + @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, + fill_gaps: Literal['ffill', 'bfill', 'interpolate'] | None = None, + **kwargs: Any, + ) -> xr.Dataset: + """ + Resample dataset along time dimension. + + 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. + hours_of_previous_timesteps: Duration of previous timesteps after resampling. + fill_gaps: Strategy for filling gaps (NaN values) that arise when resampling + irregular timesteps to regular intervals. Options: 'ffill' (forward fill), + 'bfill' (backward fill), 'interpolate' (linear interpolation). + If None (default), raises an error when gaps are detected. + **kwargs: Additional arguments passed to xarray.resample() + + Returns: + xr.Dataset: Resampled dataset + + Raises: + ValueError: If resampling creates gaps and fill_gaps is not specified. + """ + from .flow_system import FlowSystem + + available_methods = ['mean', 'sum', 'max', 'min', 'first', 'last', 'std', 'var', 'median', 'count'] + if method not in available_methods: + raise ValueError(f'Unsupported resampling method: {method}. Available: {available_methods}') + + original_attrs = dict(dataset.attrs) + + time_var_names = [v for v in dataset.data_vars if 'time' in dataset[v].dims] + non_time_var_names = [v for v in dataset.data_vars if v not in time_var_names] + + time_dataset = dataset[time_var_names] + resampled_time_dataset = cls._resample_by_dimension_groups(time_dataset, freq, method, **kwargs) + + # Handle NaN values that may arise from resampling irregular timesteps to regular intervals. + # When irregular data (e.g., [00:00, 01:00, 03:00]) is resampled to regular intervals (e.g., '1h'), + # bins without data (e.g., 02:00) get NaN. + if resampled_time_dataset.isnull().any().to_array().any(): + if fill_gaps is None: + # Find which variables have NaN values for a helpful error message + vars_with_nans = [ + name for name in resampled_time_dataset.data_vars if resampled_time_dataset[name].isnull().any() + ] + raise ValueError( + f'Resampling created gaps (NaN values) in variables: {vars_with_nans}. ' + f'This typically happens when resampling irregular timesteps to regular intervals. ' + f"Specify fill_gaps='ffill', 'bfill', or 'interpolate' to handle gaps, " + f'or resample to a coarser frequency.' + ) + elif fill_gaps == 'ffill': + resampled_time_dataset = resampled_time_dataset.ffill(dim='time').bfill(dim='time') + elif fill_gaps == 'bfill': + resampled_time_dataset = resampled_time_dataset.bfill(dim='time').ffill(dim='time') + elif fill_gaps == 'interpolate': + resampled_time_dataset = resampled_time_dataset.interpolate_na(dim='time', method='linear') + # Handle edges that can't be interpolated + resampled_time_dataset = resampled_time_dataset.ffill(dim='time').bfill(dim='time') + + if non_time_var_names: + non_time_dataset = dataset[non_time_var_names] + result = xr.merge([resampled_time_dataset, non_time_dataset]) + else: + result = resampled_time_dataset + + result.attrs.update(original_attrs) + return FlowSystem._update_time_metadata(result, hours_of_last_timestep, hours_of_previous_timesteps) + + @staticmethod + def _resample_by_dimension_groups( + time_dataset: xr.Dataset, + time: str, + method: str, + **kwargs: Any, + ) -> xr.Dataset: + """ + Resample variables grouped by their dimension structure to avoid broadcasting. + + Groups variables by their non-time dimensions before resampling for performance + and to prevent xarray from broadcasting variables with different dimensions. + + Args: + time_dataset: Dataset containing only variables with time dimension + time: Resampling frequency (e.g., '2h', '1D', '1M') + method: Resampling method name (e.g., 'mean', 'sum', 'first') + **kwargs: Additional arguments passed to xarray.resample() + + Returns: + Resampled dataset with original dimension structure preserved + """ + dim_groups = defaultdict(list) + for var_name, var in time_dataset.data_vars.items(): + dims_key = tuple(sorted(d for d in var.dims if d != 'time')) + dim_groups[dims_key].append(var_name) + + # Note: defaultdict is always truthy, so we check length explicitly + if len(dim_groups) == 0: + return getattr(time_dataset.resample(time=time, **kwargs), method)() + + resampled_groups = [] + for var_names in dim_groups.values(): + if not var_names: + continue + + stacked = xr.concat( + [time_dataset[name] for name in var_names], + dim=pd.Index(var_names, name='variable'), + combine_attrs='drop_conflicts', + ) + resampled = getattr(stacked.resample(time=time, **kwargs), method)() + resampled_dataset = resampled.to_dataset(dim='variable') + resampled_groups.append(resampled_dataset) + + if not resampled_groups: + # No data variables to resample, but still resample coordinates + return getattr(time_dataset.resample(time=time, **kwargs), method)() + + if len(resampled_groups) == 1: + return resampled_groups[0] + + return xr.merge(resampled_groups, combine_attrs='drop_conflicts') + + def fix_sizes( + self, + sizes: xr.Dataset | dict[str, float] | None = None, + decimal_rounding: int | None = 5, + ) -> FlowSystem: + """ + Create a new FlowSystem with investment sizes fixed to specified values. + + This is useful for two-stage optimization workflows: + 1. Solve a sizing problem (possibly resampled for speed) + 2. Fix sizes and solve dispatch at full resolution + + The returned FlowSystem has InvestParameters with fixed_size set, + making those sizes mandatory rather than decision variables. + + Args: + sizes: The sizes to fix. Can be: + - None: Uses sizes from this FlowSystem's solution (must be solved) + - xr.Dataset: Dataset with size variables (e.g., from statistics.sizes) + - dict: Mapping of component names to sizes (e.g., {'Boiler(Q_fu)': 100}) + decimal_rounding: Number of decimal places to round sizes to. + Rounding helps avoid numerical infeasibility. Set to None to disable. + + Returns: + FlowSystem: New FlowSystem with fixed sizes (no solution). + + Raises: + ValueError: If no sizes provided and FlowSystem has no solution. + KeyError: If a specified size doesn't match any InvestParameters. + + Examples: + Two-stage optimization: + + >>> # Stage 1: Size with resampled data + >>> fs_sizing = flow_system.transform.resample('2h') + >>> fs_sizing.optimize(solver) + >>> + >>> # Stage 2: Fix sizes and optimize at full resolution + >>> fs_dispatch = flow_system.transform.fix_sizes(fs_sizing.statistics.sizes) + >>> fs_dispatch.optimize(solver) + + Using a dict: + + >>> fs_fixed = flow_system.transform.fix_sizes( + ... { + ... 'Boiler(Q_fu)': 100, + ... 'Storage': 500, + ... } + ... ) + >>> fs_fixed.optimize(solver) + """ + from .flow_system import FlowSystem + from .interface import InvestParameters + + # Get sizes from solution if not provided + if sizes is None: + if self._fs.solution is None: + raise ValueError( + 'No sizes provided and FlowSystem has no solution. ' + 'Either provide sizes or optimize the FlowSystem first.' + ) + sizes = self._fs.statistics.sizes + + # Convert dict to Dataset format + if isinstance(sizes, dict): + sizes = xr.Dataset({k: xr.DataArray(v) for k, v in sizes.items()}) + + # Apply rounding + if decimal_rounding is not None: + sizes = sizes.round(decimal_rounding) + + # Create copy of FlowSystem + if not self._fs.connected_and_transformed: + self._fs.connect_and_transform() + + ds = self._fs.to_dataset() + new_fs = FlowSystem.from_dataset(ds) + + # Fix sizes in the new FlowSystem's InvestParameters + # Note: statistics.sizes returns keys without '|size' suffix (e.g., 'Boiler(Q_fu)') + # but dicts may have either format + for size_var in sizes.data_vars: + # Normalize: strip '|size' suffix if present + base_name = size_var.replace('|size', '') if size_var.endswith('|size') else size_var + fixed_value = float(sizes[size_var].item()) + + # Find matching element with InvestParameters + found = False + + # Check flows + for flow in new_fs.flows.values(): + if flow.label_full == base_name and isinstance(flow.size, InvestParameters): + flow.size.fixed_size = fixed_value + flow.size.mandatory = True + found = True + logger.debug(f'Fixed size of {base_name} to {fixed_value}') + break + + # Check storage capacity + if not found: + for component in new_fs.components.values(): + if hasattr(component, 'capacity_in_flow_hours'): + if component.label == base_name and isinstance( + component.capacity_in_flow_hours, InvestParameters + ): + component.capacity_in_flow_hours.fixed_size = fixed_value + component.capacity_in_flow_hours.mandatory = True + found = True + logger.debug(f'Fixed size of {base_name} to {fixed_value}') + break + + if not found: + logger.warning( + f'Size variable "{base_name}" not found as InvestParameters in FlowSystem. ' + f'It may be a fixed-size component or the name may not match.' + ) + + return new_fs + + # Future methods can be added here: + # + # def mga(self, alternatives: int = 5) -> FlowSystem: + # """Create a FlowSystem configured for Modeling to Generate Alternatives.""" + # ... diff --git a/mkdocs.yml b/mkdocs.yml index 0adba464d..551fac523 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,41 +7,81 @@ site_description: Energy and Material Flow Optimization Framework site_url: https://flixopt.github.io/flixopt/ repo_url: https://github.com/flixOpt/flixopt repo_name: flixOpt/flixopt +edit_uri: edit/main/docs/ nav: - - Home: index.md + - Home: + - Home: index.md + - Getting Started: + - Installation: home/installation.md + - Quick Start: home/quick-start.md + - About: + - Users: home/users.md + - Citing: home/citing.md + - License: home/license.md + - User Guide: - - Getting Started: getting-started.md + - Overview: user-guide/index.md - Core Concepts: user-guide/core-concepts.md - - Migration to v3.0.0: user-guide/migration-guide-v3.md + - Building Models: + - Overview: user-guide/building-models/index.md + - Choosing Components: user-guide/building-models/choosing-components.md + - Running Optimizations: user-guide/optimization/index.md + - Analyzing Results: user-guide/results/index.md + - Plotting Results: user-guide/results-plotting.md - Mathematical Notation: - Overview: user-guide/mathematical-notation/index.md - - Dimensions: user-guide/mathematical-notation/dimensions.md - - Elements: - - Flow: user-guide/mathematical-notation/elements/Flow.md - - Bus: user-guide/mathematical-notation/elements/Bus.md - - Storage: user-guide/mathematical-notation/elements/Storage.md - - LinearConverter: user-guide/mathematical-notation/elements/LinearConverter.md - - Features: - - InvestParameters: user-guide/mathematical-notation/features/InvestParameters.md - - OnOffParameters: user-guide/mathematical-notation/features/OnOffParameters.md - - Piecewise: user-guide/mathematical-notation/features/Piecewise.md - - Effects, Penalty & Objective: user-guide/mathematical-notation/effects-penalty-objective.md - - Modeling Patterns: - - Overview: user-guide/mathematical-notation/modeling-patterns/index.md - - Bounds and States: user-guide/mathematical-notation/modeling-patterns/bounds-and-states.md - - Duration Tracking: user-guide/mathematical-notation/modeling-patterns/duration-tracking.md - - State Transitions: user-guide/mathematical-notation/modeling-patterns/state-transitions.md - - Recipes: user-guide/recipes/index.md - - Roadmap: roadmap.md - - Examples: examples/ - - Contribute: contribute.md + - Bus: user-guide/mathematical-notation/elements/Bus.md + - Flow: user-guide/mathematical-notation/elements/Flow.md + - LinearConverter: user-guide/mathematical-notation/elements/LinearConverter.md + - Storage: user-guide/mathematical-notation/elements/Storage.md + - Effects & Dimensions: user-guide/mathematical-notation/effects-and-dimensions.md + - Investment: user-guide/mathematical-notation/features/InvestParameters.md + - Status: user-guide/mathematical-notation/features/StatusParameters.md + - Piecewise: user-guide/mathematical-notation/features/Piecewise.md + - Recipes: + - user-guide/recipes/index.md + - Plotting Custom Data: user-guide/recipes/plotting-custom-data.md + - Support: + - FAQ: user-guide/faq.md + - Troubleshooting: user-guide/troubleshooting.md + - Community: user-guide/support.md + - Migration & Updates: + - Migration Guide v5: user-guide/migration-guide-v5.md + - Migration Guide v3: user-guide/migration-guide-v3.md + - Release Notes: changelog.md + - Roadmap: roadmap.md + + - Examples: + - Overview: notebooks/index.md + - Basics: + - Quickstart: notebooks/01-quickstart.ipynb + - Heat System: notebooks/02-heat-system.ipynb + - Investment: + - Sizing: notebooks/03-investment-optimization.ipynb + - Constraints: notebooks/04-operational-constraints.ipynb + - Advanced: + - Multi-Carrier: notebooks/05-multi-carrier-system.ipynb + - Transmission: notebooks/10-transmission.ipynb + - Non-Linear Modeling: + - Time-Varying Parameters: notebooks/06a-time-varying-parameters.ipynb + - Piecewise Conversion: notebooks/06b-piecewise-conversion.ipynb + - Piecewise Effects: notebooks/06c-piecewise-effects.ipynb + - Scaling: + - Scenarios: notebooks/07-scenarios-and-periods.ipynb + - Aggregation: notebooks/08a-aggregation.ipynb + - Rolling Horizon: notebooks/08b-rolling-horizon.ipynb + - Results: + - Plotting: notebooks/09-plotting-and-data-access.ipynb + - API Reference: api-reference/ - - Release Notes: changelog/ + + - Contributing: contribute.md theme: name: material language: en + custom_dir: docs/overrides palette: # Palette toggle for automatic mode @@ -128,7 +168,7 @@ markdown_extensions: - toc: permalink: true permalink_title: Anchor link to this section - toc_depth: 2 + toc_depth: 3 title: On this page # Code blocks @@ -147,6 +187,9 @@ markdown_extensions: - name: mermaid class: mermaid format: !!python/name:pymdownx.superfences.fence_code_format + - name: plotly + class: mkdocs-plotly + format: !!python/name:mkdocs_plotly_plugin.fences.fence_plotly # Enhanced content - pymdownx.details @@ -185,6 +228,14 @@ plugins: - search: separator: '[\s\u200b\-_,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])' + - mkdocs-jupyter: + execute: true # Execute notebooks during build + allow_errors: false + include_source: true + include_requirejs: true + + - plotly + - table-reader - include-markdown @@ -310,6 +361,7 @@ extra_css: extra_javascript: - javascripts/mathjax.js + - javascripts/plotly-instant.js - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js - https://polyfill.io/v3/polyfill.min.js?features=es6 diff --git a/pyproject.toml b/pyproject.toml index 258b0ab7f..c178c428b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "flixopt" dynamic = ["version"] description = "Progressive flow system optimization in Python - start simple, scale to complex." readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" license = "MIT" authors = [ { name = "Chair of Building Energy Systems and Heat Supply, TU Dresden", email = "peter.stange@tu-dresden.de" }, @@ -22,10 +22,10 @@ maintainers = [ keywords = ["optimization", "energy systems", "numerical analysis"] classifiers = [ "Development Status :: 4 - Beta", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Intended Audience :: Developers", "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering", @@ -42,14 +42,11 @@ dependencies = [ "pyyaml >= 6.0.0, < 7", "colorlog >= 6.8.0, < 7", "tqdm >= 4.66.0, < 5", - "tomli >= 2.0.1, < 3; python_version < '3.11'", # Only needed with python 3.10 or earlier # Default solver "highspy >= 1.5.3, < 2", # Visualization "matplotlib >= 3.5.2, < 4", "plotly >= 5.15.0, < 7", - # Fix for numexpr compatibility issue with numpy 1.26.4 on Python 3.10 - "numexpr >= 2.8.4, < 2.14; python_version < '3.11'", # Avoid 2.14.0 on older Python ] [project.optional-dependencies] @@ -68,7 +65,7 @@ full = [ "pyvis==0.3.2", # Visualizing FlowSystem Network "tsam >= 2.3.1, < 3", # Time series aggregation "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, < 13", + "gurobipy >= 10.0.0, < 14; python_version < '3.14'", # No Python 3.14 wheels yet (expected Q1 2026) "dash >= 3.0.0, < 4", # Visualizing FlowSystem Network as app "dash-cytoscape >= 1.0.0, < 2", # Visualizing FlowSystem Network as app "dash-daq >= 0.6.0, < 1", # Visualizing FlowSystem Network as app @@ -86,8 +83,8 @@ dev = [ "pre-commit==4.3.0", "pyvis==0.3.2", "tsam==2.3.9", - "scipy==1.15.1", - "gurobipy==12.0.3", + "scipy==1.16.3", # 1.16.1+ required for Python 3.14 wheels + "gurobipy==12.0.3; python_version < '3.14'", # No Python 3.14 wheels yet "dash==3.3.0", "dash-cytoscape==1.0.2", "dash-daq==0.6.0", @@ -104,12 +101,15 @@ docs = [ "mkdocs-gen-files==0.5.0", "mkdocs-include-markdown-plugin==7.2.0", "mkdocs-literate-nav==0.6.2", + "mkdocs-plotly-plugin==0.1.3", + "mkdocs-jupyter==0.25.1", "markdown-include==0.8.1", "pymdown-extensions==10.16.1", "pygments==2.19.2", "mike==2.1.3", "mkdocs-git-revision-date-localized-plugin==1.5.0", "mkdocs-minify-plugin==0.8.0", + "notebook>=7.5.0", ] [project.urls] @@ -131,7 +131,7 @@ include-package-data = true version_scheme = "post-release" [tool.ruff] -target-version = "py310" # Adjust to your minimum version +target-version = "py311" # Minimum supported version # Files or directories to exclude (e.g., virtual environments, cache, build artifacts) exclude = [ "venv", # Virtual environments @@ -186,6 +186,7 @@ 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"' # Skip examples by default @@ -196,6 +197,14 @@ filterwarnings = [ # === Default behavior: show all warnings === "default", + # === Ignore specific deprecation warnings for backward compatibility tests === + # These are raised by deprecated classes (Optimization, Results) used in tests/deprecated/ + "ignore:Results is deprecated:DeprecationWarning:flixopt", + "ignore:Optimization is deprecated:DeprecationWarning:flixopt", + "ignore:SegmentedOptimization is deprecated:DeprecationWarning:flixopt", + "ignore:SegmentedResults is deprecated:DeprecationWarning:flixopt", + "ignore:ClusteredOptimization is deprecated:DeprecationWarning:flixopt", + # === Treat flixopt warnings as errors (strict mode for our code) === # This ensures we catch deprecations, future changes, and user warnings in our own code "error::DeprecationWarning:flixopt", diff --git a/scripts/extract_changelog.py b/scripts/extract_changelog.py deleted file mode 100644 index 44790fec6..000000000 --- a/scripts/extract_changelog.py +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env python3 -""" -Extract individual releases from CHANGELOG.md to docs/changelog/ -Simple script to create one file per release. -""" - -import re -from pathlib import Path - -from packaging.version import InvalidVersion, Version -from packaging.version import parse as parse_version - - -def extract_releases(): - """Extract releases from CHANGELOG.md and save to individual files.""" - - changelog_path = Path('CHANGELOG.md') - output_dir = Path('docs/changelog') - - if not changelog_path.exists(): - print('❌ CHANGELOG.md not found') - return - - # Create output directory - output_dir.mkdir(parents=True, exist_ok=True) - - # Read changelog - with open(changelog_path, encoding='utf-8') as f: - content = f.read() - - # Remove template section (HTML comments) - content = re.sub(r'', '', content, flags=re.DOTALL) - - # Split by release headers - sections = re.split(r'^## \[', content, flags=re.MULTILINE) - - releases = [] - for section in sections[1:]: # Skip first empty section - # Extract version and date from start of section - match = re.match(r'([^\]]+)\] - ([^\n]+)\n(.*)', section, re.DOTALL) - if match: - version, date, release_content = match.groups() - releases.append((version, date.strip(), release_content.strip())) - - print(f'🔍 Found {len(releases)} releases') - - # Sort releases by version (oldest first) to keep existing file prefixes stable. - def version_key(release): - try: - return parse_version(release[0]) - except InvalidVersion: - return parse_version('0.0.0') # fallback for invalid versions - - releases.sort(key=version_key, reverse=False) - - # Show what we captured for debugging - if releases: - print(f'🔧 First release content length: {len(releases[0][2])}') - - for i, (version_str, date, release_content) in enumerate(releases): - # Clean up version for filename with numeric prefix (newest first) - index = 99999 - i # Newest first, while keeping the same file names for old releases - prefix = f'{index:05d}' # Zero-padded 5-digit number - filename = f'{prefix}-v{version_str.replace(" ", "-")}.md' - filepath = output_dir / filename - - # Clean up content - remove trailing --- separators and emojis from headers - cleaned_content = re.sub(r'\s*---\s*$', '', release_content.strip()) - - # Generate navigation links - nav_links = [] - - # Previous version (older release) - if i > 0: - prev_index = 99999 - (i - 1) - prev_version = releases[i - 1][0] - prev_filename = f'{prev_index:05d}-v{prev_version.replace(" ", "-")}.md' - nav_links.append(f'← [Previous: {prev_version}]({prev_filename})') - - # Next version (newer release) - if i < len(releases) - 1: - next_index = 99999 - (i + 1) - next_version = releases[i + 1][0] - next_filename = f'{next_index:05d}-v{next_version.replace(" ", "-")}.md' - nav_links.append(f'[Next: {next_version}]({next_filename}) →') - - # Always add link back to index - nav_links.append('[📋 All Releases](index.md)') - # Add GitHub tag link only for valid PEP 440 versions (skip e.g. "Unreleased") - ver_obj = parse_version(version_str) - if isinstance(ver_obj, Version): - nav_links.append(f'[🏷️ GitHub Release](https://github.com/flixOpt/flixopt/releases/tag/v{version_str})') - # Create content with navigation - content_lines = [ - f'# {version_str} - {date.strip()}', - '', - ' | '.join(nav_links), - '', - '---', - '', - cleaned_content, - '', - '---', - '', - ' | '.join(nav_links), - ] - - # Write file - with open(filepath, 'w', encoding='utf-8') as f: - f.write('\n'.join(content_lines)) - - print(f'✅ Created {filename}') - - print(f'🎉 Extracted {len(releases)} releases to docs/changelog/') - - -def extract_index(): - changelog_path = Path('CHANGELOG.md') - output_dir = Path('docs/changelog') - index_path = output_dir / 'index.md' - - if not changelog_path.exists(): - print('❌ CHANGELOG.md not found') - return - - # Create output directory - output_dir.mkdir(parents=True, exist_ok=True) - - # Read changelog - with open(changelog_path, encoding='utf-8') as f: - content = f.read() - - intro_match = re.search(r'# Changelog\s+([\s\S]*?)(?=