Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 81 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ For more details regarding the individual PRs and contributors, please refer to

!!! tip

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/).
If upgrading from v5.x, see the [Migration Guide v6](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v6/).
If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOpt/flixOpt/releases/tag/v3.0.0) and [Migration Guide v3](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v3/).

---

Expand Down Expand Up @@ -53,30 +54,37 @@ Until here -->

## [6.0.0] - Upcoming

**Summary**: Major release featuring a complete rewrite of the clustering/aggregation system with tsam integration, new `plotly` plotting accessor, FlowSystem comparison tools, and removal of deprecated v5.0 classes.
**Summary**: Major release featuring tsam v3 migration, complete rewrite of the clustering/aggregation system, 2-3x faster I/O for large systems, new `plotly` plotting accessor, FlowSystem comparison tools, and removal of deprecated v5.0 classes.

!!! warning "Breaking Changes"
This release removes `ClusteredOptimization` and `ClusteringParameters` which were deprecated in v5.0.0. Use `flow_system.transform.cluster()` instead. See [Migration](#migration-from-clusteredoptimization) below.

The clustering API now uses tsam v3's configuration objects (`ClusterConfig`, `ExtremeConfig`) instead of individual parameters. See [tsam v3 Migration](#tsam-v3-migration) below.

### Key Features

- **Clustering/Aggregation Rework** (#549, #552) - Complete rewrite with tsam integration, inter-cluster storage linking, and 4 storage modes
- **tsam v3 Migration** (#584) - Updated to tsam 3.0+ with new configuration-based API
- **Clustering/Aggregation Rework** (#549, #552, #584) - Complete rewrite with tsam integration, inter-cluster storage linking, segmentation support, and 4 storage modes
- **I/O Performance** (#584) - 2-3x faster NetCDF I/O for large systems via variable stacking
- **plotly Plotting Accessor** (#548) - Universal xarray plotting with automatic faceting
- **Comparison Module** (#550) - Compare multiple FlowSystems side-by-side
- **Improved Notebooks** (#542, #551) - Better tutorial data and faster CI execution

### ✨ Added

#### Time-Series Clustering (#549, #552)
#### Time-Series Clustering (#549, #552, #584)

Reduce large time series to representative typical periods for faster investment optimization, then expand results back to full resolution.

```python
from tsam import ClusterConfig, ExtremeConfig

# Stage 1: Cluster and optimize (fast sizing)
fs_clustered = flow_system.transform.cluster(
n_clusters=12, # 12 typical days from a year
cluster_duration='1D', # Each cluster represents one day
time_series_for_high_peaks=['HeatDemand(Q)|fixed_relative_profile'],
cluster=ClusterConfig(method='hierarchical'),
extremes=ExtremeConfig(method='new_cluster', max_value=['HeatDemand(Q)|fixed_relative_profile']),
)
fs_clustered.optimize(solver)

Expand All @@ -99,15 +107,14 @@ fs_expanded = fs_clustered.transform.expand()
|-----------|-------------|
| `n_clusters` | Number of representative periods to create |
| `cluster_duration` | Duration of each cluster (e.g., `'1D'`, `'24h'`, or hours as float) |
| `time_series_for_high_peaks` | Time series labels whose peaks should be preserved |
| `time_series_for_low_peaks` | Time series labels whose minima should be preserved |
| `cluster_method` | Algorithm: `'hierarchical'` (default), `'k_means'`, `'k_medoids'`, `'k_maxoids'`, `'averaging'` |
| `representation_method` | How to represent clusters: `'medoidRepresentation'` (default), `'meanRepresentation'`, `'distributionAndMinMaxRepresentation'` |
| `extreme_period_method` | How to handle extreme periods: `'append'`, `'new_cluster_center'`, `'replace_cluster_center'` |
| `rescale_cluster_periods` | Whether to rescale cluster periods to match original statistics (default: `True`) |
| `predef_cluster_order` | Predefined cluster assignment for reproducibility |
| `weights` | Dict mapping variable names to importance weights for clustering |
| `cluster` | `ClusterConfig` object for clustering algorithm settings (method, representation, etc.) |
| `extremes` | `ExtremeConfig` object for peak/valley preservation settings |
| `predef_cluster_assignments` | Predefined cluster assignment for reproducibility |
| `**tsam_kwargs` | Additional arguments passed to tsam |

See [tsam documentation](https://tsam.readthedocs.io/) for `ClusterConfig` and `ExtremeConfig` options.

**Key Features**:

- **Inter-cluster storage linking**: For `'intercluster'` and `'intercluster_cyclic'` modes, a `SOC_boundary` variable tracks absolute state-of-charge at period boundaries, enabling accurate seasonal storage modeling
Expand Down Expand Up @@ -142,6 +149,32 @@ charge_state = fs_expanded.solution['SeasonalPit|charge_state']
Use `'cyclic'` for short-term storage like batteries or hot water tanks where only daily patterns matter.
Use `'independent'` for quick estimates when storage behavior isn't critical.

#### Time-Series Segmentation (#584)

New `transform.segment()` method for piecewise-constant time-series approximation. Useful for reducing problem size while preserving temporal structure:

```python
# Segment time series into 24 segments per day
fs_segmented = flow_system.transform.segment(
segment_duration='1D',
n_segments=24,
)
fs_segmented.optimize(solver)
fs_expanded = fs_segmented.transform.expand()
```

#### I/O Performance Improvements (#584)

- **Variable stacking**: 2-3x faster NetCDF I/O for large systems by grouping variables with same dimensions
- **Fast DataArray construction**: Bypasses slow xarray internals (~15x faster per variable)
- **Version tracking**: Datasets now include `flixopt_version` attribute for compatibility checking

```python
# Version is automatically stored
ds = flow_system.to_dataset()
print(ds.attrs['flixopt_version']) # e.g., '6.0.0'
```

#### Plotly Accessor (#548)

New global xarray accessors for universal plotting with automatic faceting and smart dimension handling. Works on any xarray Dataset, not just flixopt results.
Expand Down Expand Up @@ -212,6 +245,41 @@ comp.diff('baseline') # vs named case

### 💥 Breaking Changes

#### tsam v3 Migration

The clustering API now uses tsam v3's configuration objects instead of individual parameters:

```python
# Old API (v5.x with tsam 2.x)
fs.transform.cluster(
n_clusters=8,
cluster_method='hierarchical',
time_series_for_high_peaks=['demand'],
)

# New API (v6.x with tsam 3.x)
from tsam import ClusterConfig, ExtremeConfig

fs.transform.cluster(
n_clusters=8,
cluster=ClusterConfig(method='hierarchical'),
extremes=ExtremeConfig(method='new_cluster', max_value=['demand']),
)
```

**Parameter mapping:**

| Old Parameter | New Parameter |
|--------------|---------------|
| `cluster_method` | `cluster=ClusterConfig(method=...)` |
| `representation_method` | `cluster=ClusterConfig(representation=...)` |
| `time_series_for_high_peaks` | `extremes=ExtremeConfig(max_value=[...])` |
| `time_series_for_low_peaks` | `extremes=ExtremeConfig(min_value=[...])` |
| `extreme_period_method` | `extremes=ExtremeConfig(method=...)` |
| `predef_cluster_order` | `predef_cluster_assignments` |

#### Other Breaking Changes

- `FlowSystem.scenario_weights` are now always normalized to sum to 1 when set (including after `.sel()` subsetting)

### ♻️ Changed
Expand Down Expand Up @@ -345,6 +413,7 @@ Note: `topology.plot()` now renders a Sankey diagram. The old PyVis visualizatio

### 📦 Dependencies

- **tsam**: Updated from `>= 2.3.1, < 3` to `>= 3.0.0, < 4` (#584)
- Updated `mkdocs-material` to v9.7.1
- Updated `mkdocstrings-python` to v1.19.0
- Updated `ruff` to v0.14.10
Expand Down
195 changes: 195 additions & 0 deletions benchmarks/benchmark_io_performance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""Benchmark script for FlowSystem IO performance.

Tests to_dataset() and from_dataset() performance with large FlowSystems.
Run this to compare performance before/after optimizations.

Usage:
python benchmarks/benchmark_io_performance.py
"""

import tempfile
import time
from typing import NamedTuple

import numpy as np
import pandas as pd

import flixopt as fx


class BenchmarkResult(NamedTuple):
"""Results from a benchmark run."""

name: str
mean_ms: float
std_ms: float
iterations: int


def create_large_flow_system(
n_timesteps: int = 2190,
n_periods: int = 12,
n_components: int = 125,
) -> fx.FlowSystem:
"""Create a large FlowSystem for benchmarking.

Args:
n_timesteps: Number of timesteps (default 2190 = ~1 year at 4h resolution).
n_periods: Number of periods (default 12).
n_components: Number of sink/source pairs (default 125).

Returns:
Configured FlowSystem.
"""
timesteps = pd.date_range('2024-01-01', periods=n_timesteps, freq='4h')
periods = pd.Index([2028 + i * 2 for i in range(n_periods)], name='period')

fs = fx.FlowSystem(timesteps=timesteps, periods=periods)
fs.add_elements(fx.Effect('Cost', '€', is_objective=True))

n_buses = 10
buses = [fx.Bus(f'Bus_{i}') for i in range(n_buses)]
fs.add_elements(*buses)

# Create demand profile with daily pattern
base_demand = 100 + 50 * np.sin(2 * np.pi * np.arange(n_timesteps) / 24)

for i in range(n_components):
bus = buses[i % n_buses]
# Add noise to create unique profiles
profile = base_demand + np.random.normal(0, 10, n_timesteps)
profile = np.clip(profile / profile.max(), 0.1, 1.0)

fs.add_elements(
fx.Sink(
f'D_{i}',
inputs=[fx.Flow(f'Q_{i}', bus=bus.label, size=100, fixed_relative_profile=profile)],
)
)
fs.add_elements(
fx.Source(
f'S_{i}',
outputs=[fx.Flow(f'P_{i}', bus=bus.label, size=500, effects_per_flow_hour={'Cost': 20 + i})],
)
)

return fs


def benchmark_function(func, iterations: int = 5, warmup: int = 1) -> BenchmarkResult:
"""Benchmark a function with multiple iterations.

Args:
func: Function to benchmark (callable with no arguments).
iterations: Number of timed iterations.
warmup: Number of warmup iterations (not timed).

Returns:
BenchmarkResult with timing statistics.
"""
# Warmup
for _ in range(warmup):
func()

# Timed runs
times = []
for _ in range(iterations):
start = time.perf_counter()
func()
elapsed = time.perf_counter() - start
times.append(elapsed)

return BenchmarkResult(
name=func.__name__ if hasattr(func, '__name__') else str(func),
mean_ms=np.mean(times) * 1000,
std_ms=np.std(times) * 1000,
iterations=iterations,
)


def run_io_benchmarks(
n_timesteps: int = 2190,
n_periods: int = 12,
n_components: int = 125,
iterations: int = 5,
) -> dict[str, BenchmarkResult]:
"""Run IO performance benchmarks.

Args:
n_timesteps: Number of timesteps for the FlowSystem.
n_periods: Number of periods.
n_components: Number of components (sink/source pairs).
iterations: Number of benchmark iterations.

Returns:
Dictionary mapping benchmark names to results.
"""
print('=' * 70)
print('FlowSystem IO Performance Benchmark')
print('=' * 70)
print('\nConfiguration:')
print(f' Timesteps: {n_timesteps}')
print(f' Periods: {n_periods}')
print(f' Components: {n_components}')
print(f' Iterations: {iterations}')

# Create FlowSystem
print('\n1. Creating FlowSystem...')
fs = create_large_flow_system(n_timesteps, n_periods, n_components)
print(f' Components: {len(fs.components)}')

# Create dataset
print('\n2. Creating dataset...')
ds = fs.to_dataset()
print(f' Variables: {len(ds.data_vars)}')
print(f' Size: {ds.nbytes / 1e6:.1f} MB')

results = {}

# Benchmark to_dataset
print('\n3. Benchmarking to_dataset()...')
result = benchmark_function(lambda: fs.to_dataset(), iterations=iterations)
results['to_dataset'] = result
print(f' Mean: {result.mean_ms:.1f}ms (std: {result.std_ms:.1f}ms)')

# Benchmark from_dataset
print('\n4. Benchmarking from_dataset()...')
result = benchmark_function(lambda: fx.FlowSystem.from_dataset(ds), iterations=iterations)
results['from_dataset'] = result
print(f' Mean: {result.mean_ms:.1f}ms (std: {result.std_ms:.1f}ms)')

# Benchmark NetCDF round-trip
print('\n5. Benchmarking NetCDF round-trip...')
with tempfile.NamedTemporaryFile(suffix='.nc', delete=False) as f:
tmp_path = f.name

def netcdf_roundtrip():
fs.to_netcdf(tmp_path, overwrite=True)
return fx.FlowSystem.from_netcdf(tmp_path)

result = benchmark_function(netcdf_roundtrip, iterations=iterations)
results['netcdf_roundtrip'] = result
print(f' Mean: {result.mean_ms:.1f}ms (std: {result.std_ms:.1f}ms)')

# Verify restoration
print('\n6. Verification...')
fs_restored = fx.FlowSystem.from_dataset(ds)
print(f' Components restored: {len(fs_restored.components)}')
print(f' Timesteps restored: {len(fs_restored.timesteps)}')
print(f' Periods restored: {len(fs_restored.periods)}')

# Summary
print('\n' + '=' * 70)
print('Summary')
print('=' * 70)
for name, res in results.items():
print(f' {name}: {res.mean_ms:.1f}ms (+/- {res.std_ms:.1f}ms)')

total_ms = results['to_dataset'].mean_ms + results['from_dataset'].mean_ms
print(f'\n Total (to + from): {total_ms:.1f}ms')

return results


if __name__ == '__main__':
run_io_benchmarks()
10 changes: 9 additions & 1 deletion docs/notebooks/01-quickstart.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,16 @@
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"version": "3.11"
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.11"
}
},
"nbformat": 4,
Expand Down
12 changes: 12 additions & 0 deletions docs/notebooks/02-heat-system.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,18 @@
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.11"
}
},
"nbformat": 4,
Expand Down
Loading