Skip to content

Commit 4dfc86c

Browse files
committed
Merge branch 'feature/test-math' into feature/element-data-classes
# Conflicts: # CHANGELOG.md # flixopt/comparison.py # flixopt/components.py # tests/flow_system/test_flow_system_locking.py # tests/superseded/math/test_bus.py # tests/superseded/math/test_effect.py # tests/superseded/math/test_flow.py # tests/superseded/math/test_linear_converter.py
2 parents 427be5a + de1901a commit 4dfc86c

59 files changed

Lines changed: 5938 additions & 140 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,50 @@ Complete rewrite of the model building pipeline using batched operations instead
8888

8989
---
9090

91+
## [6.0.3] - Upcoming
92+
93+
**Summary**: Bugfix release fixing `cluster_weight` loss during NetCDF roundtrip for manually constructed clustered FlowSystems.
94+
95+
### 🐛 Fixed
96+
97+
- **Clustering IO**: `cluster_weight` is now preserved during NetCDF roundtrip for manually constructed clustered FlowSystems (i.e. `FlowSystem(..., clusters=..., cluster_weight=...)`). Previously, `cluster_weight` was silently dropped to `None` during `save->reload->solve`, causing incorrect objective values. Systems created via `.transform.cluster()` were not affected.
98+
99+
### 👷 Development
100+
101+
- **New `test_math/` test suite**: Comprehensive mathematical correctness tests with exact, hand-calculated assertions. Each test runs in 3 IO modes (solve, save→reload→solve, solve→save→reload) via the `optimize` fixture:
102+
- `test_flow.py` — flow bounds, merit order, relative min/max, on/off hours
103+
- `test_flow_invest.py` — investment sizing, fixed-size, optional invest, piecewise invest
104+
- `test_flow_status.py` — startup costs, switch-on/off constraints, status penalties
105+
- `test_bus.py` — bus balance, excess/shortage penalties
106+
- `test_effects.py` — effect aggregation, periodic/temporal effects, multi-effect objectives
107+
- `test_components.py` — SourceAndSink, converters, links, combined heat-and-power
108+
- `test_conversion.py` — linear converter balance, multi-input/output, efficiency
109+
- `test_piecewise.py` — piecewise-linear efficiency, segment selection
110+
- `test_storage.py` — charge/discharge, SOC tracking, final charge state, losses
111+
- `test_multi_period.py` — period weights, invest across periods
112+
- `test_scenarios.py` — scenario weights, scenario-independent flows
113+
- `test_clustering.py` — exact per-timestep flow_rates, effects, and charge_state in clustered systems (incl. non-equal cluster weights to cover IO roundtrip)
114+
- `test_validation.py` — plausibility checks and error messages
115+
116+
---
117+
118+
## [6.0.2] - 2026-02-05
119+
120+
**Summary**: Patch release which improves `Comparison` coordinate handling.
121+
122+
### 🐛 Fixed
123+
124+
- **Comparison Coordinates**: Fixed `component` coordinate becoming `(case, contributor)` shaped after concatenation in `Comparison` class. Non-index coordinates are now properly merged before concat in `solution`, `inputs`, and all statistics properties. Added warning when coordinate mappings conflict (#599)
125+
126+
### 📝 Docs
127+
128+
- **Docs Workflow**: Added `workflow_dispatch` inputs for manual docs deployment with version selection (#599)
129+
130+
### 👷 Development
131+
132+
- Updated dev dependencies to newer versions
133+
---
134+
91135
## [6.0.1] - 2026-02-04
92136

93137
**Summary**: Bugfix release addressing clustering issues with multi-period systems and ExtremeConfig.

CITATION.cff

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ cff-version: 1.2.0
22
message: "If you use this software, please cite it as below and consider citing the related publication."
33
type: software
44
title: "flixopt"
5-
version: 6.0.0
6-
date-released: 2026-02-04
5+
version: 6.0.2
6+
date-released: 2026-02-05
77
url: "https://github.com/flixOpt/flixopt"
88
repository-code: "https://github.com/flixOpt/flixopt"
99
license: MIT

docs/notebooks/08d-clustering-multiperiod.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,7 @@
525525
"id": "29",
526526
"metadata": {},
527527
"source": [
528-
"<cell_type>markdown</cell_type>## Summary\n",
528+
"## Summary\n",
529529
"\n",
530530
"You learned how to:\n",
531531
"\n",

docs/notebooks/08f-clustering-segmentation.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,7 @@
527527
"id": "33",
528528
"metadata": {},
529529
"source": [
530-
"<cell_type>markdown</cell_type>## API Reference\n",
530+
"## API Reference\n",
531531
"\n",
532532
"### SegmentConfig Parameters\n",
533533
"\n",

flixopt/comparison.py

Lines changed: 84 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,72 @@
2727
_CASE_SLOTS = frozenset(slot for slots in SLOT_ORDERS.values() for slot in slots)
2828

2929

30+
def _extract_nonindex_coords(
31+
*dataarrays: xr.DataArray,
32+
) -> tuple[list[xr.DataArray], dict[str, tuple[str, dict]]]:
33+
"""Extract and merge non-index coords, returning cleaned dataarrays and merged mappings.
34+
35+
Non-index coords (like `component` on `contributor` dim) cause concat conflicts.
36+
This extracts them, merges the mappings, and returns dataarrays without them.
37+
"""
38+
if not dataarrays:
39+
return [], {}
40+
41+
# Find non-index coords and collect mappings
42+
merged: dict[str, tuple[str, dict]] = {}
43+
coords_to_drop: set[str] = set()
44+
45+
for da in dataarrays:
46+
for name, coord in da.coords.items():
47+
if len(coord.dims) != 1:
48+
continue
49+
dim = coord.dims[0]
50+
if dim == name or dim not in da.coords:
51+
continue
52+
53+
coords_to_drop.add(name)
54+
if name not in merged:
55+
merged[name] = (dim, {})
56+
elif merged[name][0] != dim:
57+
warnings.warn(
58+
f"Coordinate '{name}' appears on different dims: "
59+
f"'{merged[name][0]}' vs '{dim}'. Dropping this coordinate.",
60+
stacklevel=4,
61+
)
62+
continue
63+
64+
for dv, cv in zip(da.coords[dim].values, coord.values, strict=False):
65+
if dv not in merged[name][1]:
66+
merged[name][1][dv] = cv
67+
elif merged[name][1][dv] != cv:
68+
warnings.warn(
69+
f"Coordinate '{name}' has conflicting values for dim value '{dv}': "
70+
f"'{merged[name][1][dv]}' vs '{cv}'. Keeping first value.",
71+
stacklevel=4,
72+
)
73+
74+
# Drop these coords from dataarrays
75+
result = list(dataarrays)
76+
if coords_to_drop:
77+
result = [da.drop_vars(coords_to_drop, errors='ignore') for da in result]
78+
79+
return result, merged
80+
81+
82+
def _apply_merged_coords(da: xr.DataArray, merged: dict[str, tuple[str, dict]]) -> xr.DataArray:
83+
"""Apply merged coord mappings to concatenated dataarray."""
84+
if not merged:
85+
return da
86+
87+
new_coords = {}
88+
for name, (dim, mapping) in merged.items():
89+
if dim not in da.dims:
90+
continue
91+
new_coords[name] = (dim, [mapping.get(dv, dv) for dv in da.coords[dim].values])
92+
93+
return da.assign_coords(new_coords)
94+
95+
3096
def _apply_slot_defaults(plotly_kwargs: dict, defaults: dict[str, str | None]) -> None:
3197
"""Apply default slot assignments to plotly kwargs.
3298
@@ -254,12 +320,10 @@ def solution(self) -> xr.Dataset:
254320
self._require_solutions()
255321
datasets = [fs.solution for fs in self._systems]
256322
self._warn_mismatched_dimensions(datasets)
257-
self._solution = xr.concat(
258-
[ds.expand_dims(case=[name]) for ds, name in zip(datasets, self._names, strict=True)],
259-
dim='case',
260-
join='outer',
261-
fill_value=float('nan'),
262-
)
323+
expanded = [ds.expand_dims(case=[name]) for ds, name in zip(datasets, self._names, strict=True)]
324+
expanded, merged_coords = _extract_nonindex_coords(*expanded)
325+
result = xr.concat(expanded, dim='case', join='outer', coords='minimal', fill_value=float('nan'))
326+
self._solution = _apply_merged_coords(result, merged_coords)
263327
return self._solution
264328

265329
@property
@@ -322,12 +386,10 @@ def inputs(self) -> xr.Dataset:
322386
if self._inputs is None:
323387
datasets = [fs.to_dataset(include_solution=False) for fs in self._systems]
324388
self._warn_mismatched_dimensions(datasets)
325-
self._inputs = xr.concat(
326-
[ds.expand_dims(case=[name]) for ds, name in zip(datasets, self._names, strict=True)],
327-
dim='case',
328-
join='outer',
329-
fill_value=float('nan'),
330-
)
389+
expanded = [ds.expand_dims(case=[name]) for ds, name in zip(datasets, self._names, strict=True)]
390+
expanded, merged_coords = _extract_nonindex_coords(*expanded)
391+
result = xr.concat(expanded, dim='case', join='outer', coords='minimal', fill_value=float('nan'))
392+
self._inputs = _apply_merged_coords(result, merged_coords)
331393
return self._inputs
332394

333395

@@ -372,7 +434,11 @@ def _concat_property(self, prop_name: str) -> xr.DataArray:
372434
continue
373435
if not arrays:
374436
return xr.DataArray()
375-
return xr.concat(arrays, dim='case', join='outer', fill_value=float('nan'), coords='minimal', compat='override')
437+
arrays, merged_coords = _extract_nonindex_coords(*arrays)
438+
result = xr.concat(
439+
arrays, dim='case', join='outer', fill_value=float('nan'), coords='minimal', compat='override'
440+
)
441+
return _apply_merged_coords(result, merged_coords)
376442

377443
def _merge_dict_property(self, prop_name: str) -> dict[str, str]:
378444
"""Merge a dict property from all cases (later cases override)."""
@@ -526,9 +592,11 @@ def _combine_data(self, method_name: str, *args, **kwargs) -> tuple[xr.DataArray
526592
if not arrays:
527593
return xr.DataArray(dims=[]), ''
528594

529-
return xr.concat(
530-
arrays, dim='case', join='outer', fill_value=float('nan'), coords='minimal', compat='override'
531-
), title
595+
arrays, merged_coords = _extract_nonindex_coords(*arrays)
596+
combined = xr.concat(
597+
arrays, dim='case', join='outer', coords='minimal', fill_value=float('nan'), compat='override'
598+
)
599+
return _apply_merged_coords(combined, merged_coords), title
532600

533601
def _finalize(self, da: xr.DataArray, fig, show: bool | None) -> PlotResult:
534602
"""Handle show and return PlotResult."""

flixopt/io.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1617,15 +1617,12 @@ def _create_flow_system(
16171617
# Extract cluster index if present (clustered FlowSystem)
16181618
clusters = ds.indexes.get('cluster')
16191619

1620-
# For clustered datasets, cluster_weight is (cluster,) shaped - set separately
1621-
if clusters is not None:
1622-
cluster_weight_for_constructor = None
1623-
else:
1624-
cluster_weight_for_constructor = (
1625-
cls._resolve_dataarray_reference(reference_structure['cluster_weight'], arrays_dict)
1626-
if 'cluster_weight' in reference_structure
1627-
else None
1628-
)
1620+
# Resolve cluster_weight if present in reference structure
1621+
cluster_weight_for_constructor = (
1622+
cls._resolve_dataarray_reference(reference_structure['cluster_weight'], arrays_dict)
1623+
if 'cluster_weight' in reference_structure
1624+
else None
1625+
)
16291626

16301627
# Resolve scenario_weights only if scenario dimension exists
16311628
scenario_weights = None

pyproject.toml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ dependencies = [
3737
"xarray >= 2024.2.0, < 2026.0", # CalVer: allow through next calendar year
3838
# Optimization and data handling
3939
"linopy >= 0.5.1, < 0.6", # Widened from patch pin to minor range
40-
"netcdf4 >= 1.6.1, < 1.7.4", # 1.7.4 missing wheels, revert to < 2 later
40+
"netcdf4 >=1.6.1, <1.7.5", # 1.7.4 missing wheels, revert to < 2 later
4141
# Utilities
4242
"pyyaml >= 6.0.0, < 7",
4343
"colorlog >= 6.8.0, < 7",
@@ -81,16 +81,16 @@ dev = [
8181
"pytest==8.4.2",
8282
"pytest-xdist==3.8.0",
8383
"nbformat==5.10.4",
84-
"ruff==0.14.10",
85-
"pre-commit==4.3.0",
84+
"ruff==0.14.14",
85+
"pre-commit==4.5.1",
8686
"pyvis==0.3.2",
8787
"scipy==1.16.3", # 1.16.1+ required for Python 3.14 wheels
8888
"gurobipy==12.0.3; python_version < '3.14'", # No Python 3.14 wheels yet
8989
"dash==3.3.0",
9090
"dash-cytoscape==1.0.2",
9191
"dash-daq==0.6.0",
9292
"networkx==3.0.0",
93-
"werkzeug==3.1.4",
93+
"werkzeug==3.1.5",
9494
]
9595

9696
# Documentation building
@@ -176,7 +176,7 @@ extend-fixable = ["B"] # Enable fix for flake8-bugbear (`B`), on top of any ru
176176
# Apply rule exceptions to specific files or directories
177177
[tool.ruff.lint.per-file-ignores]
178178
"tests/*.py" = ["S101"] # Ignore assertions in test files
179-
"tests/test_integration.py" = ["N806"] # Ignore NOT lowercase names in test files
179+
"tests/superseded/test_integration.py" = ["N806"] # Ignore NOT lowercase names in test files
180180
"flixopt/linear_converters.py" = ["N803"] # Parameters with NOT lowercase names
181181

182182
[tool.ruff.format]
@@ -193,7 +193,7 @@ markers = [
193193
"examples: marks example tests (run only on releases)",
194194
"deprecated_api: marks tests using deprecated Optimization/Results API (remove in v6.0.0)",
195195
]
196-
addopts = '-m "not examples"' # Skip examples by default
196+
addopts = '-m "not examples" --ignore=tests/superseded' # Skip examples and superseded tests by default
197197

198198
# Warning filter configuration for pytest
199199
# Filters are processed in order; first match wins

tests/conftest.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -417,11 +417,8 @@ def gas_with_costs():
417417
# ============================================================================
418418

419419

420-
@pytest.fixture
421-
def simple_flow_system() -> fx.FlowSystem:
422-
"""
423-
Create a simple energy system for testing
424-
"""
420+
def build_simple_flow_system() -> fx.FlowSystem:
421+
"""Create a simple energy system for testing (factory function)."""
425422
base_timesteps = pd.date_range('2020-01-01', periods=9, freq='h', name='time')
426423
timesteps_length = len(base_timesteps)
427424
base_thermal_load = LoadProfiles.thermal_simple(timesteps_length)
@@ -448,6 +445,12 @@ def simple_flow_system() -> fx.FlowSystem:
448445
return flow_system
449446

450447

448+
@pytest.fixture
449+
def simple_flow_system() -> fx.FlowSystem:
450+
"""Create a simple energy system for testing."""
451+
return build_simple_flow_system()
452+
453+
451454
@pytest.fixture
452455
def simple_flow_system_scenarios() -> fx.FlowSystem:
453456
"""

tests/flow_system/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)