Skip to content

Commit 471677d

Browse files
authored
Fix clustering expansion bugs and add ExtremeConfig validation (#590)
* Summary of Fixes 1. Fixed _interpolate_charge_state_segmented Bug (Critical) File: flixopt/transform_accessor.py:1965-2043 The function was incorrectly decoding timestep_mapping using timesteps_per_cluster. For segmented systems, timestep_mapping encodes cluster * n_segments + segment_idx, so this produced wrong cluster indices. Fix: Compute original period index and position directly from timestep indices instead of decoding from timestep_mapping. 2. Implemented EXPAND_FIRST_TIMESTEP for Segmented Systems Only File: flixopt/transform_accessor.py:2045-2100 Added _expand_first_timestep_only() method that places startup/shutdown events at the first timestep of each segment (not cluster). Key design decisions: - Segmented systems: Events placed at first timestep of each segment (timing within segment is lost) - Non-segmented systems: Normal expansion preserves timing within cluster (no special handling needed) 3. Added Tests - test_startup_shutdown_first_timestep_only: Verifies segmented systems place events at segment boundaries - test_startup_timing_preserved_non_segmented: Verifies non-segmented systems preserve timing within clusters 4. Updated Docstrings Clarified in expand() docstring that: - Binary events in segmented systems go to first timestep of each segment - Non-segmented systems preserve timing via normal expansion * Summary of Fixes 1. Fixed _interpolate_charge_state_segmented Bug (Critical) File: flixopt/transform_accessor.py:1965-2043 The function was incorrectly decoding timestep_mapping using timesteps_per_cluster. For segmented systems, timestep_mapping encodes cluster * n_segments + segment_idx, so this produced wrong cluster indices. Fix: Compute original period index and position directly from timestep indices instead of decoding from timestep_mapping. 2. Implemented EXPAND_FIRST_TIMESTEP for Segmented Systems Only File: flixopt/transform_accessor.py:2045-2100 Added _expand_first_timestep_only() method that places startup/shutdown events at the first timestep of each segment (not cluster). Key design decisions: - Segmented systems: Events placed at first timestep of each segment (timing within segment is lost) - Non-segmented systems: Normal expansion preserves timing within cluster (no special handling needed) 3. Added Tests - test_startup_shutdown_first_timestep_only: Verifies segmented systems place events at segment boundaries - test_startup_timing_preserved_non_segmented: Verifies non-segmented systems preserve timing within clusters 4. Updated Docstrings Clarified in expand() docstring that: - Binary events in segmented systems go to first timestep of each segment - Non-segmented systems preserve timing via normal expansion * Validate extremeconfig method to be "replace" * The fix now properly handles variables with both time and period (or scenario) dimensions: 1. For each missing (period_label, scenario_label) key, it selects the specific period/scenario slice from the original variable using .sel(..., drop=True) 2. Then it slices and reshapes that specific slice 3. All slices in filled_slices now have consistent dimensions ['cluster', 'time'] + other_dims without 'period' or 'scenario' coordinates This ensures all DataArrays being concatenated have the same structure. Can you try running your clustering code again? * Update tsam dep
1 parent e898eab commit 471677d

5 files changed

Lines changed: 399 additions & 28 deletions

File tree

.github/workflows/docs.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ jobs:
5858
run: uv pip install --system ".[docs,full]"
5959

6060
- name: Install tsam v3 (not yet on PyPI)
61-
run: uv pip install --system "tsam @ git+https://github.com/FBumann/tsam.git@a5e2ac516fb8470377a1893742df6696668539aa"
61+
run: uv pip install --system "tsam @ git+https://github.com/FBumann/tsam.git@71177ec3fa1b16fcecdd039ca18ceddcdfa2064a"
6262

6363
- name: Get notebook cache key
6464
id: notebook-cache-key
@@ -125,7 +125,7 @@ jobs:
125125
run: uv pip install --system ".[docs,full]"
126126

127127
- name: Install tsam v3 (not yet on PyPI)
128-
run: uv pip install --system "tsam @ git+https://github.com/FBumann/tsam.git@a5e2ac516fb8470377a1893742df6696668539aa"
128+
run: uv pip install --system "tsam @ git+https://github.com/FBumann/tsam.git@71177ec3fa1b16fcecdd039ca18ceddcdfa2064a"
129129

130130
- name: Get notebook cache key
131131
id: notebook-cache-key

.github/workflows/tests.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ jobs:
6262
run: uv pip install --system .[dev]
6363

6464
- name: Install tsam v3 (not yet on PyPI)
65-
run: uv pip install --system "tsam @ git+https://github.com/FBumann/tsam.git@a5e2ac516fb8470377a1893742df6696668539aa"
65+
run: uv pip install --system "tsam @ git+https://github.com/FBumann/tsam.git@71177ec3fa1b16fcecdd039ca18ceddcdfa2064a"
6666

6767
- name: Run tests
6868
run: pytest -v --numprocesses=auto
@@ -89,7 +89,7 @@ jobs:
8989
run: uv pip install --system .[dev]
9090

9191
- name: Install tsam v3 (not yet on PyPI)
92-
run: uv pip install --system "tsam @ git+https://github.com/FBumann/tsam.git@a5e2ac516fb8470377a1893742df6696668539aa"
92+
run: uv pip install --system "tsam @ git+https://github.com/FBumann/tsam.git@71177ec3fa1b16fcecdd039ca18ceddcdfa2064a"
9393

9494
- name: Run example tests
9595
run: pytest -v -m examples --numprocesses=auto

flixopt/transform_accessor.py

Lines changed: 153 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import xarray as xr
1818

1919
from .modeling import _scalar_safe_reduce
20-
from .structure import EXPAND_DIVIDE, EXPAND_INTERPOLATE, VariableCategory
20+
from .structure import EXPAND_DIVIDE, EXPAND_FIRST_TIMESTEP, EXPAND_INTERPOLATE, VariableCategory
2121

2222
if TYPE_CHECKING:
2323
from tsam import ClusterConfig, ExtremeConfig, SegmentConfig
@@ -569,15 +569,12 @@ def _build_reduced_dataset(
569569
)
570570
elif set(typical_das[name].keys()) != all_keys:
571571
# Partial typical slices: fill missing keys with constant values
572-
time_idx = var.dims.index('time')
573-
slices_list = [slice(None)] * len(var.dims)
574-
slices_list[time_idx] = slice(0, n_reduced_timesteps)
575-
sliced_values = var.values[tuple(slices_list)]
572+
# For multi-period/scenario data, we need to select the right slice for each key
576573

577-
other_dims = [d for d in var.dims if d != 'time']
574+
# Exclude 'period' and 'scenario' - they're handled by _combine_slices_to_dataarray_2d
575+
other_dims = [d for d in var.dims if d not in ('time', 'period', 'scenario')]
578576
other_shape = [var.sizes[d] for d in other_dims]
579577
new_shape = [actual_n_clusters, n_time_points] + other_shape
580-
reshaped_constant = sliced_values.reshape(new_shape)
581578

582579
new_coords = {'cluster': cluster_coords, 'time': time_coords}
583580
for dim in other_dims:
@@ -590,6 +587,27 @@ def _build_reduced_dataset(
590587
if key in typical_das[name]:
591588
filled_slices[key] = typical_das[name][key]
592589
else:
590+
# Select the specific period/scenario slice, then reshape
591+
period_label, scenario_label = key
592+
selector = {}
593+
if period_label is not None and 'period' in var.dims:
594+
selector['period'] = period_label
595+
if scenario_label is not None and 'scenario' in var.dims:
596+
selector['scenario'] = scenario_label
597+
598+
# Select per-key slice if needed, otherwise use full variable
599+
if selector:
600+
var_slice = ds[name].sel(**selector, drop=True)
601+
else:
602+
var_slice = ds[name]
603+
604+
# Now slice time and reshape
605+
time_idx = var_slice.dims.index('time')
606+
slices_list = [slice(None)] * len(var_slice.dims)
607+
slices_list[time_idx] = slice(0, n_reduced_timesteps)
608+
sliced_values = var_slice.values[tuple(slices_list)]
609+
reshaped_constant = sliced_values.reshape(new_shape)
610+
593611
filled_slices[key] = xr.DataArray(
594612
reshaped_constant,
595613
dims=['cluster', 'time'] + other_dims,
@@ -1432,6 +1450,20 @@ def cluster(
14321450
f'Use the corresponding cluster() parameters instead.'
14331451
)
14341452

1453+
# Validate ExtremeConfig compatibility with multi-period/scenario systems
1454+
# Methods 'new_cluster' and 'append' can produce different n_clusters per period,
1455+
# which breaks the xarray structure that requires uniform dimensions
1456+
is_multi_dimensional = has_periods or has_scenarios
1457+
if is_multi_dimensional and extremes is not None:
1458+
extreme_method = getattr(extremes, 'method', None)
1459+
if extreme_method in ('new_cluster', 'append'):
1460+
raise ValueError(
1461+
f'ExtremeConfig with method="{extreme_method}" is not supported for multi-period '
1462+
f'or multi-scenario systems because it can produce different cluster counts per '
1463+
f'period/scenario. Use method="replace" instead, which replaces existing clusters '
1464+
f'with extreme periods while maintaining the requested n_clusters.'
1465+
)
1466+
14351467
# Cluster each (period, scenario) combination using tsam directly
14361468
tsam_aggregation_results: dict[tuple, Any] = {} # AggregationResult objects
14371469
tsam_clustering_results: dict[tuple, Any] = {} # ClusteringResult objects for persistence
@@ -1482,7 +1514,7 @@ def cluster(
14821514
df_for_clustering,
14831515
n_clusters=n_clusters,
14841516
period_duration=hours_per_cluster,
1485-
timestep_duration=dt,
1517+
temporal_resolution=dt,
14861518
cluster=cluster_config,
14871519
extremes=extremes,
14881520
segments=segments,
@@ -1986,22 +2018,37 @@ def _interpolate_charge_state_segmented(
19862018
Interpolated charge_state with dims (time, ...) for original timesteps.
19872019
"""
19882020
# Get multi-dimensional properties from Clustering
1989-
timestep_mapping = clustering.timestep_mapping
19902021
segment_assignments = clustering.results.segment_assignments
19912022
segment_durations = clustering.results.segment_durations
19922023
position_within_segment = clustering.results.position_within_segment
2024+
cluster_assignments = clustering.cluster_assignments
19932025

1994-
# Decode timestep_mapping into cluster and time indices
1995-
# timestep_mapping encodes original timestep -> (cluster, position_within_cluster)
1996-
# where position_within_cluster indexes into segment_assignments/position_within_segment
1997-
# which have shape (cluster, timesteps_per_cluster)
2026+
# Compute original period index and position within period directly
2027+
# This is more reliable than decoding from timestep_mapping, which encodes
2028+
# (cluster * n_segments + segment_idx) for segmented systems
2029+
n_original_timesteps = len(original_timesteps)
19982030
timesteps_per_cluster = clustering.timesteps_per_cluster
1999-
cluster_indices = timestep_mapping // timesteps_per_cluster
2000-
time_indices = timestep_mapping % timesteps_per_cluster
2031+
n_original_clusters = clustering.n_original_clusters
2032+
2033+
# For each original timestep, compute which original period it belongs to
2034+
original_period_indices = np.minimum(
2035+
np.arange(n_original_timesteps) // timesteps_per_cluster,
2036+
n_original_clusters - 1,
2037+
)
2038+
# Position within the period (0 to timesteps_per_cluster-1)
2039+
positions_in_period = np.arange(n_original_timesteps) % timesteps_per_cluster
2040+
2041+
# Create DataArrays for indexing (with original_time dimension, coords added later)
2042+
original_period_da = xr.DataArray(original_period_indices, dims=['original_time'])
2043+
position_in_period_da = xr.DataArray(positions_in_period, dims=['original_time'])
2044+
2045+
# Map original period to cluster
2046+
cluster_indices = cluster_assignments.isel(original_cluster=original_period_da)
20012047

20022048
# Get segment index and position for each original timestep
2003-
seg_indices = segment_assignments.isel(cluster=cluster_indices, time=time_indices)
2004-
positions = position_within_segment.isel(cluster=cluster_indices, time=time_indices)
2049+
# segment_assignments has shape (cluster, time) where time = timesteps_per_cluster
2050+
seg_indices = segment_assignments.isel(cluster=cluster_indices, time=position_in_period_da)
2051+
positions = position_within_segment.isel(cluster=cluster_indices, time=position_in_period_da)
20052052
durations = segment_durations.isel(cluster=cluster_indices, segment=seg_indices)
20062053

20072054
# Calculate interpolation factor: position within segment (0 to 1)
@@ -2023,6 +2070,66 @@ def _interpolate_charge_state_segmented(
20232070

20242071
return interpolated.transpose('time', ...).assign_attrs(da.attrs)
20252072

2073+
def _expand_first_timestep_only(
2074+
self,
2075+
da: xr.DataArray,
2076+
clustering: Clustering,
2077+
original_timesteps: pd.DatetimeIndex,
2078+
) -> xr.DataArray:
2079+
"""Expand binary event variables (startup/shutdown) to first timestep of each segment.
2080+
2081+
For segmented systems, binary event variables like startup and shutdown indicate
2082+
that an event occurred somewhere in the segment. When expanding, we place the
2083+
event at the first timestep of each segment and set all other timesteps to 0.
2084+
2085+
This method is only used for segmented systems. For non-segmented systems,
2086+
the timing within the cluster is preserved by normal expansion.
2087+
2088+
Args:
2089+
da: Binary event DataArray with dims including (cluster, time).
2090+
clustering: Clustering object with segment info (must be segmented).
2091+
original_timesteps: Original timesteps to expand to.
2092+
2093+
Returns:
2094+
Expanded DataArray with event values only at first timestep of each segment.
2095+
"""
2096+
# First expand normally (repeats values)
2097+
expanded = clustering.expand_data(da, original_time=original_timesteps)
2098+
2099+
# Build mask: True only at first timestep of each segment
2100+
n_original_timesteps = len(original_timesteps)
2101+
timesteps_per_cluster = clustering.timesteps_per_cluster
2102+
n_original_clusters = clustering.n_original_clusters
2103+
2104+
position_within_segment = clustering.results.position_within_segment
2105+
cluster_assignments = clustering.cluster_assignments
2106+
2107+
# Compute original period index and position within period
2108+
original_period_indices = np.minimum(
2109+
np.arange(n_original_timesteps) // timesteps_per_cluster,
2110+
n_original_clusters - 1,
2111+
)
2112+
positions_in_period = np.arange(n_original_timesteps) % timesteps_per_cluster
2113+
2114+
# Create DataArrays for indexing (coords added later after rename)
2115+
original_period_da = xr.DataArray(original_period_indices, dims=['original_time'])
2116+
position_in_period_da = xr.DataArray(positions_in_period, dims=['original_time'])
2117+
2118+
# Map to cluster and get position within segment
2119+
cluster_indices = cluster_assignments.isel(original_cluster=original_period_da)
2120+
pos_in_segment = position_within_segment.isel(cluster=cluster_indices, time=position_in_period_da)
2121+
2122+
# Clean up and create mask
2123+
pos_in_segment = pos_in_segment.drop_vars(['cluster', 'time'], errors='ignore')
2124+
pos_in_segment = pos_in_segment.rename({'original_time': 'time'}).assign_coords(time=original_timesteps)
2125+
2126+
# First timestep of segment has position 0
2127+
is_first = pos_in_segment == 0
2128+
2129+
# Apply mask: keep value at first timestep, zero elsewhere
2130+
result = xr.where(is_first, expanded, 0)
2131+
return result.assign_attrs(da.attrs)
2132+
20262133
def expand(self) -> FlowSystem:
20272134
"""Expand a clustered FlowSystem back to full original timesteps.
20282135
@@ -2113,12 +2220,23 @@ def expand(self) -> FlowSystem:
21132220
21142221
4. **Binary status variables** - Constant within segment:
21152222
2116-
These variables cannot be meaningfully interpolated. They indicate
2117-
the dominant state or whether an event occurred during the segment.
2223+
These variables cannot be meaningfully interpolated. The status
2224+
indicates the dominant state during the segment.
2225+
2226+
- ``{flow}|status``: On/off status (0 or 1), repeated for all timesteps
2227+
2228+
5. **Binary event variables** (segmented systems only) - First timestep of segment:
21182229
2119-
- ``{flow}|status``: On/off status (0 or 1)
2120-
- ``{flow}|startup``: Startup event occurred in segment
2121-
- ``{flow}|shutdown``: Shutdown event occurred in segment
2230+
For segmented systems, these variables indicate that an event occurred
2231+
somewhere during the segment. When expanded, the event is placed at the
2232+
first timestep of each segment, with zeros elsewhere. This preserves the
2233+
total count of events while providing a reasonable temporal placement.
2234+
2235+
For non-segmented systems, the timing within the cluster is preserved
2236+
by normal expansion (no special handling needed).
2237+
2238+
- ``{flow}|startup``: Startup event
2239+
- ``{flow}|shutdown``: Shutdown event
21222240
"""
21232241
from .flow_system import FlowSystem
21242242

@@ -2162,6 +2280,13 @@ def _is_state_variable(var_name: str) -> bool:
21622280
# Fall back to pattern matching for backwards compatibility
21632281
return var_name.endswith('|charge_state')
21642282

2283+
def _is_first_timestep_variable(var_name: str) -> bool:
2284+
"""Check if a variable is a binary event (should only appear at first timestep)."""
2285+
if var_name in variable_categories:
2286+
return variable_categories[var_name] in EXPAND_FIRST_TIMESTEP
2287+
# Fall back to pattern matching for backwards compatibility
2288+
return var_name.endswith('|startup') or var_name.endswith('|shutdown')
2289+
21652290
def _append_final_state(expanded: xr.DataArray, da: xr.DataArray) -> xr.DataArray:
21662291
"""Append final state value from original data to expanded data."""
21672292
cluster_assignments = clustering.cluster_assignments
@@ -2181,12 +2306,18 @@ def expand_da(da: xr.DataArray, var_name: str = '', is_solution: bool = False) -
21812306
return da.copy()
21822307

21832308
is_state = _is_state_variable(var_name) and 'cluster' in da.dims
2309+
is_first_timestep = _is_first_timestep_variable(var_name) and 'cluster' in da.dims
21842310

21852311
# State variables in segmented systems: interpolate within segments
21862312
if is_state and clustering.is_segmented:
21872313
expanded = self._interpolate_charge_state_segmented(da, clustering, original_timesteps)
21882314
return _append_final_state(expanded, da)
21892315

2316+
# Binary events (startup/shutdown) in segmented systems: first timestep of each segment
2317+
# For non-segmented systems, timing within cluster is preserved, so normal expansion is correct
2318+
if is_first_timestep and is_solution and clustering.is_segmented:
2319+
return self._expand_first_timestep_only(da, clustering, original_timesteps)
2320+
21902321
expanded = clustering.expand_data(da, original_time=original_timesteps)
21912322

21922323
# Segment totals: divide by expansion divisor

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ network_viz = [
6363

6464
# Full feature set (everything except dev tools)
6565
# NOTE: For clustering features, install tsam v3 manually (not yet on PyPI):
66-
# pip install "tsam @ git+https://github.com/FBumann/tsam.git@a5e2ac516fb8470377a1893742df6696668539aa"
66+
# pip install "tsam @ git+https://github.com/FBumann/tsam.git@71177ec3fa1b16fcecdd039ca18ceddcdfa2064a"
6767
# This will be added back when tsam v3.0 is released on PyPI
6868
full = [
6969
"pyvis==0.3.2", # Visualizing FlowSystem Network
@@ -79,7 +79,7 @@ full = [
7979

8080
# Development tools and testing
8181
# NOTE: For clustering features, install tsam v3 manually (not yet on PyPI):
82-
# pip install "tsam @ git+https://github.com/FBumann/tsam.git@a5e2ac516fb8470377a1893742df6696668539aa"
82+
# pip install "tsam @ git+https://github.com/FBumann/tsam.git@71177ec3fa1b16fcecdd039ca18ceddcdfa2064a"
8383
dev = [
8484
"pytest==8.4.2",
8585
"pytest-xdist==3.8.0",

0 commit comments

Comments
 (0)