Skip to content

Commit 988d013

Browse files
committed
ClusterStructure.plot() now works for all cases using fxplot:
| Case | Layout | |------------------|-------------------------------------------------------------------------| | Simple (no dims) | Single heatmap | | Periods only | Vertical stack (1 column) | | Scenarios only | Horizontal layout | | Both | Grid (periods as rows, scenarios as columns via facet_cols=n_scenarios) | | File | Issue | Fix | |-------------------------------------------------|-----------------------------------------------------------|-----------------------------------------------------| | docs/notebooks/03-investment-optimization.ipynb | Division by zero when solar_size = 0 | Added guard: if solar_size > 0 else float('nan') | | flixopt/clustering/base.py:181-235 | ClusterStructure.plot() crashes for multi-period/scenario | Added NotImplementedError with helpful message | | flixopt/components.py:1256-1265 | Obsolete linopy LP writer bug workaround | Removed + 0.0 workaround (fixed in linopy >= 0.5.1) | | flixopt/dataset_plot_accessor.py:742-780 | to_duration_curve crashes on variables without time dim | Added guard to skip non-time variables | | flixopt/features.py:234-236 | Critical: Startup count ignores cluster weighting | Now multiplies by cluster_weight before summing | | flixopt/structure.py:268-281 | scenario_weights docstring misleading | Updated docstring to accurately describe behavior | Nitpick Fixes | File | Fix | |----------------------------------------------------|-------------------------------------------------------------------------------------------| | docs/notebooks/data/generate_example_systems.py | Fixed type hint pd.DataFrame → pd.Series for _elec_prices, added timezone guard | | flixopt/statistics_accessor.py:2103-2132 | Added detailed comment explaining secondary y-axis offset strategy | | tests/test_cluster_reduce_expand.py | Moved import xarray as xr to top of file | | docs/notebooks/data/generate_realistic_profiles.py | Clarified warnings.resetwarnings() comment | | flixopt/transform_accessor.py | Removed unused cluster_coords and time_coords params from _combine_slices_to_dataarray_2d | | flixopt/comparison.py | Added error handling to _concat_property for FlowSystems lacking optimization data |
1 parent c33da5c commit 988d013

11 files changed

Lines changed: 68 additions & 43 deletions

docs/notebooks/03-investment-optimization.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@
234234
" {\n",
235235
" 'Solar [kW]': solar_size,\n",
236236
" 'Tank [kWh]': tank_size,\n",
237-
" 'Ratio [kWh/kW]': tank_size / solar_size,\n",
237+
" 'Ratio [kWh/kW]': tank_size / solar_size if solar_size > 0 else float('nan'),\n",
238238
" },\n",
239239
" index=['Optimal Size'],\n",
240240
").T"

docs/notebooks/data/generate_example_systems.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555

5656
# Lazy-loaded shared data with error handling
5757
_weather: pd.DataFrame | None = None
58-
_elec_prices: pd.DataFrame | None = None
58+
_elec_prices: pd.Series | None = None
5959

6060

6161
def _get_weather() -> pd.DataFrame:
@@ -71,13 +71,15 @@ def _get_weather() -> pd.DataFrame:
7171
return _weather
7272

7373

74-
def _get_elec_prices() -> pd.DataFrame:
74+
def _get_elec_prices() -> pd.Series:
7575
"""Get electricity prices, loading lazily on first access."""
7676
global _elec_prices
7777
if _elec_prices is None:
7878
try:
7979
_elec_prices = load_electricity_prices()
80-
_elec_prices.index = _elec_prices.index.tz_localize(None) # Remove timezone
80+
# Remove timezone if present (guard against both tz-aware and tz-naive indices)
81+
if _elec_prices.index.tz is not None:
82+
_elec_prices.index = _elec_prices.index.tz_localize(None)
8183
except FileNotFoundError as e:
8284
raise FileNotFoundError(
8385
f'Electricity price data not found. Ensure price data exists in {DATA_DIR}. Original error: {e}'

docs/notebooks/data/generate_realistic_profiles.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@
2525
import pvlib
2626
from demandlib import bdew
2727

28-
warnings.resetwarnings() # Reset to default behavior due to weird dependency behaviour
28+
# Reset warnings to default after imports. Some dependencies (demandlib, pvlib)
29+
# may configure warnings during import. This ensures consistent warning behavior
30+
# when this module is used in different contexts (scripts, notebooks, tests).
31+
warnings.resetwarnings()
2932

3033
# Data directory
3134
DATA_DIR = Path(__file__).parent / 'raw'

flixopt/clustering/base.py

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,8 @@ def plot(self, colors: str | list[str] | None = None, show: bool | None = None)
182182
"""Plot cluster assignment visualization.
183183
184184
Shows which cluster each original period belongs to, and the
185-
number of occurrences per cluster.
185+
number of occurrences per cluster. For multi-period/scenario structures,
186+
creates a faceted grid plot.
186187
187188
Args:
188189
colors: Colorscale name (str) or list of colors.
@@ -198,34 +199,42 @@ def plot(self, colors: str | list[str] | None = None, show: bool | None = None)
198199
n_clusters = (
199200
int(self.n_clusters) if isinstance(self.n_clusters, (int, np.integer)) else int(self.n_clusters.values)
200201
)
202+
colorscale = colors or CONFIG.Plotting.default_sequential_colorscale
201203

202-
cluster_order = self.get_cluster_order_for_slice()
203-
204-
# Build DataArray for fxplot heatmap
205-
cluster_da = xr.DataArray(
206-
cluster_order.reshape(1, -1),
207-
dims=['y', 'original_cluster'],
208-
coords={'y': ['Cluster'], 'original_cluster': range(1, len(cluster_order) + 1)},
209-
name='cluster_assignment',
204+
# Build DataArray with 1-based original_cluster coords
205+
cluster_da = self.cluster_order.assign_coords(
206+
original_cluster=np.arange(1, self.cluster_order.sizes['original_cluster'] + 1)
210207
)
211208

212-
# Use fxplot.heatmap for smart defaults
213-
colorscale = colors or CONFIG.Plotting.default_sequential_colorscale
214-
fig = cluster_da.fxplot.heatmap(
209+
has_period = 'period' in cluster_da.dims
210+
has_scenario = 'scenario' in cluster_da.dims
211+
212+
# Transpose for heatmap: first dim = y-axis, second dim = x-axis
213+
if has_period:
214+
cluster_da = cluster_da.transpose('period', 'original_cluster', ...)
215+
elif has_scenario:
216+
cluster_da = cluster_da.transpose('scenario', 'original_cluster', ...)
217+
218+
# Data to return (without dummy dims)
219+
ds = xr.Dataset({'cluster_order': cluster_da})
220+
221+
# For plotting: add dummy y-dim if needed (heatmap requires 2D)
222+
if not has_period and not has_scenario:
223+
plot_da = cluster_da.expand_dims(y=['']).transpose('y', 'original_cluster')
224+
plot_ds = xr.Dataset({'cluster_order': plot_da})
225+
else:
226+
plot_ds = ds
227+
228+
fig = plot_ds.fxplot.heatmap(
215229
colors=colorscale,
216-
title=f'Cluster Assignment ({self.n_original_clusters} periods {n_clusters} clusters)',
230+
title=f'Cluster Assignment ({self.n_original_clusters}{n_clusters} clusters)',
217231
)
218-
fig.update_yaxes(showticklabels=False)
232+
219233
fig.update_coloraxes(colorbar_title='Cluster')
234+
if not has_period and not has_scenario:
235+
fig.update_yaxes(showticklabels=False)
220236

221-
# Build data for PlotResult
222-
data = xr.Dataset(
223-
{
224-
'cluster_order': self.cluster_order,
225-
'cluster_occurrences': self.cluster_occurrences,
226-
}
227-
)
228-
plot_result = PlotResult(data=data, figure=fig)
237+
plot_result = PlotResult(data=ds, figure=fig)
229238

230239
if show is None:
231240
show = CONFIG.Plotting.default_show

flixopt/comparison.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,14 @@ def _concat_property(self, prop_name: str) -> xr.Dataset:
223223
"""Concatenate a statistics property across all cases."""
224224
datasets = []
225225
for fs, name in zip(self._comp._systems, self._comp._names, strict=True):
226-
ds = getattr(fs.statistics, prop_name)
227-
datasets.append(ds.expand_dims(case=[name]))
226+
try:
227+
ds = getattr(fs.statistics, prop_name)
228+
datasets.append(ds.expand_dims(case=[name]))
229+
except RuntimeError as e:
230+
warnings.warn(f"Skipping case '{name}': {e}", stacklevel=3)
231+
continue
232+
if not datasets:
233+
return xr.Dataset()
228234
return xr.concat(datasets, dim='case', join='outer', fill_value=float('nan'))
229235

230236
def _merge_dict_property(self, prop_name: str) -> dict[str, str]:

flixopt/dataset_plot_accessor.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,9 @@ def to_duration_curve(self, *, normalize: bool = True) -> xr.Dataset:
761761
sorted_ds = self._ds.copy()
762762
for var in sorted_ds.data_vars:
763763
da = sorted_ds[var]
764+
if 'time' not in da.dims:
765+
# Skip variables without time dimension (e.g., scalar metadata)
766+
continue
764767
time_axis = da.dims.index('time')
765768
# Sort along time axis (descending) - use flip for correct axis
766769
sorted_values = np.flip(np.sort(da.values, axis=time_axis), axis=time_axis)

flixopt/features.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,9 +231,12 @@ def _do_modeling(self):
231231
coords=self._model.get_coords(('period', 'scenario')),
232232
short_name='startup_count',
233233
)
234+
# Apply cluster_weight to count startups correctly in clustered systems.
235+
# A startup in a cluster with weight 10 represents 10 actual startups.
234236
# Sum over all temporal dimensions (time, and cluster if present)
235237
startup_temporal_dims = [d for d in self.startup.dims if d not in ('period', 'scenario')]
236-
self.add_constraints(count == self.startup.sum(startup_temporal_dims), short_name='startup_count')
238+
weighted_startup = self.startup * self._model.weights.get('cluster', 1.0)
239+
self.add_constraints(count == weighted_startup.sum(startup_temporal_dims), short_name='startup_count')
237240

238241
# 5. Consecutive active duration (uptime) using existing pattern
239242
if self.parameters.use_uptime_tracking:

flixopt/statistics_accessor.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2103,8 +2103,12 @@ def storage(
21032103
# Get the primary y-axes from the bar figure to create matching secondary axes
21042104
primary_yaxes = [key for key in fig.layout if key.startswith('yaxis')]
21052105

2106-
# For each primary y-axis, create a secondary y-axis
2107-
# Use +100 offset to ensure secondary axes don't conflict with plotly's auto-generated axis numbers
2106+
# For each primary y-axis, create a secondary y-axis.
2107+
# Secondary axis numbering strategy:
2108+
# - Primary axes are named 'yaxis', 'yaxis2', 'yaxis3', etc. (plotly auto-generates these for facets)
2109+
# - We use +100 offset (yaxis101, yaxis102, ...) to avoid conflicts with plotly's auto-numbering
2110+
# - Each secondary axis 'overlays' its corresponding primary axis and anchors to the same x-axis
2111+
# - This allows charge_state lines to share the subplot with power bars but use independent scales
21082112
for i, primary_key in enumerate(sorted(primary_yaxes, key=lambda x: int(x[5:]) if x[5:] else 0)):
21092113
primary_num = primary_key[5:] if primary_key[5:] else '1'
21102114
secondary_num = int(primary_num) + 100

flixopt/structure.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,10 +266,12 @@ def sum_temporal(self, data: xr.DataArray) -> xr.DataArray:
266266

267267
@property
268268
def scenario_weights(self) -> xr.DataArray:
269-
"""
270-
Scenario weights of model (always normalized, via FlowSystem).
269+
"""Scenario weights of model.
271270
272-
Returns unit weights if no scenarios defined or no explicit weights set.
271+
Returns:
272+
- Scalar 1 if no scenarios defined
273+
- Unit weights (all 1.0) if scenarios exist but no explicit weights set
274+
- Normalized explicit weights if set via FlowSystem.scenario_weights
273275
"""
274276
if self.flow_system.scenarios is None:
275277
return xr.DataArray(1)

flixopt/transform_accessor.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -925,8 +925,6 @@ def _build_cluster_weight_for_key(key: tuple) -> xr.DataArray:
925925
da = self._combine_slices_to_dataarray_2d(
926926
slices=typical_das[name],
927927
original_da=original_da,
928-
cluster_coords=cluster_coords,
929-
time_coords=time_coords,
930928
periods=periods,
931929
scenarios=scenarios,
932930
)
@@ -1160,8 +1158,6 @@ def _combine_slices_to_dataarray_generic(
11601158
def _combine_slices_to_dataarray_2d(
11611159
slices: dict[tuple, xr.DataArray],
11621160
original_da: xr.DataArray,
1163-
cluster_coords: np.ndarray,
1164-
time_coords: np.ndarray,
11651161
periods: list,
11661162
scenarios: list,
11671163
) -> xr.DataArray:
@@ -1170,8 +1166,6 @@ def _combine_slices_to_dataarray_2d(
11701166
Args:
11711167
slices: Dict mapping (period, scenario) tuples to DataArrays with (cluster, time) dims.
11721168
original_da: Original DataArray to get attrs from.
1173-
cluster_coords: Cluster coordinate values.
1174-
time_coords: Within-cluster time coordinate values.
11751169
periods: List of period labels ([None] if no periods dimension).
11761170
scenarios: List of scenario labels ([None] if no scenarios dimension).
11771171

0 commit comments

Comments
 (0)