Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
5a86364
Add new tests
FBumann Feb 2, 2026
a852b5a
test_invest_optional_not_built: Changed the cheap boiler from eta=1…
FBumann Feb 2, 2026
2f17284
5 new tests:
FBumann Feb 2, 2026
2fd8782
Split into multiple files
FBumann Feb 2, 2026
6b62c1c
┌──────────────────────────┬──────────────┬────────────────────────…
FBumann Feb 2, 2026
b7e479e
⏺ All 44 tests pass. Every gap is now covered. Here's the final tally:
FBumann Feb 2, 2026
6a107ef
Add more tests
FBumann Feb 3, 2026
52196d5
Add more tests for previous_flow_rate
FBumann Feb 3, 2026
279bced
Improve tests
FBumann Feb 3, 2026
54372a3
Improve tests
FBumann Feb 3, 2026
d415ec1
Improve tests
FBumann Feb 3, 2026
49ea37d
Key features now tested:
FBumann Feb 4, 2026
09e176a
add test for effect maximum periodic
FBumann Feb 4, 2026
e283783
add test for effect maximum periodic Summary of new component-level …
FBumann Feb 4, 2026
795b7ff
Add io test to solve
FBumann Feb 5, 2026
ed9240a
Add io test to solve
FBumann Feb 5, 2026
80d716e
Add io test to solve
FBumann Feb 5, 2026
6ebd351
Add io test to solve
FBumann Feb 5, 2026
ac8c22b
Ensure solution survives io
FBumann Feb 5, 2026
4a57282
typo
FBumann Feb 5, 2026
57c6fc9
Revert "typo"
FBumann Feb 5, 2026
ce318e9
Add plan file
FBumann Feb 5, 2026
ae6afb6
Add comprehensive test_math coverage for multi-period, scenarios, c…
FBumann Feb 5, 2026
efad9c9
⏺ Done. Here's a summary of what was changed:
FBumann Feb 5, 2026
78ed286
Added TestClusteringExact class with 3 tests asserting exact per-ti…
FBumann Feb 5, 2026
b4942dd
More storage tests
FBumann Feb 5, 2026
4b91731
Add multi-period tests
FBumann Feb 5, 2026
e89150b
Add clustering tests and fix issues with user set cluster weights
FBumann Feb 5, 2026
ba0f94f
Merge remote-tracking branch 'refs/remotes/origin/main' into feature/…
FBumann Feb 5, 2026
24fcd58
Update CHANGELOG.md
FBumann Feb 5, 2026
f80885b
Mark old tests as stale
FBumann Feb 5, 2026
68850eb
Update CHANGELOG.md
FBumann Feb 5, 2026
e5be97e
Mark tests as stale and move to new dir
FBumann Feb 5, 2026
fa3de4e
Move more tests to stale
FBumann Feb 5, 2026
96124b2
Change fixtures to speed up tests
FBumann Feb 5, 2026
d71f85e
Moved files into stale
FBumann Feb 5, 2026
3710435
Renamed folder
FBumann Feb 5, 2026
79c4288
Reorganize test dir
FBumann Feb 5, 2026
0eeb8ab
Reorganize test dir
FBumann Feb 5, 2026
6387a29
Rename marker
FBumann Feb 5, 2026
f73c346
2. 08d-clustering-multiperiod.ipynb (cell 29): Removed stray <cell_…
FBumann Feb 5, 2026
f4601b8
Feature/test math+more (#600)
FBumann Feb 5, 2026
de1901a
fix docstrings
FBumann Feb 5, 2026
fde2da1
1. flixopt/comparison.py:62 — Added del merged[name] after the warn…
FBumann Feb 6, 2026
d93e707
1. Line 274 — Changed "CheapBoiler runs 4 hours, ExpensiveBackup ru…
FBumann Feb 6, 2026
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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,33 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp

Until here -->

## [6.0.3] - Upcoming

**Summary**: Bugfix release fixing `cluster_weight` loss during NetCDF roundtrip for manually constructed clustered FlowSystems.

### 🐛 Fixed

- **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.

### 👷 Development

- **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:
- `test_flow.py` — flow bounds, merit order, relative min/max, on/off hours
- `test_flow_invest.py` — investment sizing, fixed-size, optional invest, piecewise invest
- `test_flow_status.py` — startup costs, switch-on/off constraints, status penalties
- `test_bus.py` — bus balance, excess/shortage penalties
- `test_effects.py` — effect aggregation, periodic/temporal effects, multi-effect objectives
- `test_components.py` — SourceAndSink, converters, links, combined heat-and-power
- `test_conversion.py` — linear converter balance, multi-input/output, efficiency
- `test_piecewise.py` — piecewise-linear efficiency, segment selection
- `test_storage.py` — charge/discharge, SOC tracking, final charge state, losses
- `test_multi_period.py` — period weights, invest across periods
- `test_scenarios.py` — scenario weights, scenario-independent flows
- `test_clustering.py` — exact per-timestep flow_rates, effects, and charge_state in clustered systems (incl. non-equal cluster weights to cover IO roundtrip)
- `test_validation.py` — plausibility checks and error messages

---

## [6.0.2] - 2026-02-05

**Summary**: Patch release which improves `Comparison` coordinate handling.
Expand Down
2 changes: 1 addition & 1 deletion docs/notebooks/08d-clustering-multiperiod.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@
"id": "29",
"metadata": {},
"source": [
"<cell_type>markdown</cell_type>## Summary\n",
"## Summary\n",
"\n",
"You learned how to:\n",
"\n",
Expand Down
2 changes: 1 addition & 1 deletion docs/notebooks/08f-clustering-segmentation.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@
"id": "33",
"metadata": {},
"source": [
"<cell_type>markdown</cell_type>## API Reference\n",
"## API Reference\n",
"\n",
"### SegmentConfig Parameters\n",
"\n",
Expand Down
8 changes: 8 additions & 0 deletions flixopt/comparison.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ def _extract_nonindex_coords(datasets: list[xr.Dataset]) -> tuple[list[xr.Datase
coords_to_drop.add(name)
if name not in merged:
merged[name] = (dim, {})
elif merged[name][0] != dim:
warnings.warn(
f"Coordinate '{name}' appears on different dims: "
f"'{merged[name][0]}' vs '{dim}'. Dropping this coordinate.",
stacklevel=4,
)
del merged[name]
continue

for dv, cv in zip(ds.coords[dim].values, coord.values, strict=False):
if dv not in merged[name][1]:
Expand Down
18 changes: 14 additions & 4 deletions flixopt/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -1144,8 +1144,13 @@ def _relative_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]:
min_final_da = min_final_da.assign_coords(time=[timesteps_extra[-1]])
min_bounds = xr.concat([rel_min, min_final_da], dim='time')
else:
# Original is scalar - broadcast to full time range (constant value)
min_bounds = rel_min.expand_dims(time=timesteps_extra)
# Original is scalar - expand to regular timesteps, then concat with final value
regular_min = rel_min.expand_dims(time=timesteps_extra[:-1])
min_final_da = (
min_final_value.expand_dims('time') if 'time' not in min_final_value.dims else min_final_value
)
min_final_da = min_final_da.assign_coords(time=[timesteps_extra[-1]])
min_bounds = xr.concat([regular_min, min_final_da], dim='time')

if 'time' in rel_max.dims:
# Original has time dim - concat with final value
Expand All @@ -1155,8 +1160,13 @@ def _relative_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]:
max_final_da = max_final_da.assign_coords(time=[timesteps_extra[-1]])
max_bounds = xr.concat([rel_max, max_final_da], dim='time')
else:
# Original is scalar - broadcast to full time range (constant value)
max_bounds = rel_max.expand_dims(time=timesteps_extra)
# Original is scalar - expand to regular timesteps, then concat with final value
regular_max = rel_max.expand_dims(time=timesteps_extra[:-1])
max_final_da = (
max_final_value.expand_dims('time') if 'time' not in max_final_value.dims else max_final_value
)
max_final_da = max_final_da.assign_coords(time=[timesteps_extra[-1]])
max_bounds = xr.concat([regular_max, max_final_da], dim='time')

# Ensure both bounds have matching dimensions (broadcast once here,
# so downstream code doesn't need to handle dimension mismatches)
Expand Down
15 changes: 6 additions & 9 deletions flixopt/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -1631,15 +1631,12 @@ def _create_flow_system(
# Extract cluster index if present (clustered FlowSystem)
clusters = ds.indexes.get('cluster')

# For clustered datasets, cluster_weight is (cluster,) shaped - set separately
if clusters is not None:
cluster_weight_for_constructor = None
else:
cluster_weight_for_constructor = (
cls._resolve_dataarray_reference(reference_structure['cluster_weight'], arrays_dict)
if 'cluster_weight' in reference_structure
else None
)
# Resolve cluster_weight if present in reference structure
cluster_weight_for_constructor = (
cls._resolve_dataarray_reference(reference_structure['cluster_weight'], arrays_dict)
if 'cluster_weight' in reference_structure
else None
)

# Resolve scenario_weights only if scenario dimension exists
scenario_weights = None
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ extend-fixable = ["B"] # Enable fix for flake8-bugbear (`B`), on top of any ru
# Apply rule exceptions to specific files or directories
[tool.ruff.lint.per-file-ignores]
"tests/*.py" = ["S101"] # Ignore assertions in test files
"tests/test_integration.py" = ["N806"] # Ignore NOT lowercase names in test files
"tests/superseded/test_integration.py" = ["N806"] # Ignore NOT lowercase names in test files
"flixopt/linear_converters.py" = ["N803"] # Parameters with NOT lowercase names

[tool.ruff.format]
Expand All @@ -193,7 +193,7 @@ markers = [
"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
addopts = '-m "not examples" --ignore=tests/superseded' # Skip examples and superseded tests by default

# Warning filter configuration for pytest
# Filters are processed in order; first match wins
Expand Down
13 changes: 8 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,11 +400,8 @@ def gas_with_costs():
# ============================================================================


@pytest.fixture
def simple_flow_system() -> fx.FlowSystem:
"""
Create a simple energy system for testing
"""
def build_simple_flow_system() -> fx.FlowSystem:
"""Create a simple energy system for testing (factory function)."""
base_timesteps = pd.date_range('2020-01-01', periods=9, freq='h', name='time')
timesteps_length = len(base_timesteps)
base_thermal_load = LoadProfiles.thermal_simple(timesteps_length)
Expand All @@ -431,6 +428,12 @@ def simple_flow_system() -> fx.FlowSystem:
return flow_system


@pytest.fixture
def simple_flow_system() -> fx.FlowSystem:
"""Create a simple energy system for testing."""
return build_simple_flow_system()


@pytest.fixture
def simple_flow_system_scenarios() -> fx.FlowSystem:
"""
Expand Down
Empty file added tests/flow_system/__init__.py
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import flixopt as fx

# Note: We use simple_flow_system fixture from conftest.py
from ..conftest import build_simple_flow_system


class TestIsLocked:
Expand Down Expand Up @@ -179,6 +179,14 @@ def test_reset_allows_reoptimization(self, simple_flow_system, highs_solver):
class TestCopy:
"""Test the copy method."""

@pytest.fixture(scope='class')
def optimized_flow_system(self):
"""Pre-optimized flow system shared across TestCopy (tests only work with copies)."""
fs = build_simple_flow_system()
solver = fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=300)
fs.optimize(solver)
return fs

def test_copy_creates_new_instance(self, simple_flow_system):
"""Copy should create a new FlowSystem instance."""
copy_fs = simple_flow_system.copy()
Expand All @@ -191,67 +199,58 @@ def test_copy_preserves_elements(self, simple_flow_system):
assert set(copy_fs.components.keys()) == set(simple_flow_system.components.keys())
assert set(copy_fs.buses.keys()) == set(simple_flow_system.buses.keys())

def test_copy_does_not_copy_solution(self, simple_flow_system, highs_solver):
def test_copy_does_not_copy_solution(self, optimized_flow_system):
"""Copy should not include the solution."""
simple_flow_system.optimize(highs_solver)
assert simple_flow_system.solution is not None
assert optimized_flow_system.solution is not None

copy_fs = simple_flow_system.copy()
copy_fs = optimized_flow_system.copy()
assert copy_fs.solution is None

def test_copy_does_not_copy_model(self, simple_flow_system, highs_solver):
def test_copy_does_not_copy_model(self, optimized_flow_system):
"""Copy should not include the model."""
simple_flow_system.optimize(highs_solver)
assert simple_flow_system.model is not None
assert optimized_flow_system.model is not None

copy_fs = simple_flow_system.copy()
copy_fs = optimized_flow_system.copy()
assert copy_fs.model is None

def test_copy_is_not_locked(self, simple_flow_system, highs_solver):
def test_copy_is_not_locked(self, optimized_flow_system):
"""Copy should not be locked even if original is."""
simple_flow_system.optimize(highs_solver)
assert simple_flow_system.is_locked is True
assert optimized_flow_system.is_locked is True

copy_fs = simple_flow_system.copy()
copy_fs = optimized_flow_system.copy()
assert copy_fs.is_locked is False

def test_copy_can_be_modified(self, simple_flow_system, highs_solver):
def test_copy_can_be_modified(self, optimized_flow_system):
"""Copy should be modifiable even if original is locked."""
simple_flow_system.optimize(highs_solver)

copy_fs = simple_flow_system.copy()
copy_fs = optimized_flow_system.copy()
new_bus = fx.Bus('NewBus')
copy_fs.add_elements(new_bus) # Should not raise
assert 'NewBus' in copy_fs.buses

def test_copy_can_be_optimized_independently(self, simple_flow_system, highs_solver):
def test_copy_can_be_optimized_independently(self, optimized_flow_system):
"""Copy can be optimized independently of original."""
simple_flow_system.optimize(highs_solver)
original_cost = simple_flow_system.solution['costs'].item()
original_cost = optimized_flow_system.solution['costs'].item()

copy_fs = simple_flow_system.copy()
copy_fs.optimize(highs_solver)
copy_fs = optimized_flow_system.copy()
solver = fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=300)
copy_fs.optimize(solver)

# Both should have solutions
assert simple_flow_system.solution is not None
assert optimized_flow_system.solution is not None
assert copy_fs.solution is not None

# Costs should be equal (same system)
assert copy_fs.solution['costs'].item() == pytest.approx(original_cost)

def test_python_copy_uses_copy_method(self, simple_flow_system, highs_solver):
def test_python_copy_uses_copy_method(self, optimized_flow_system):
"""copy.copy() should use the custom copy method."""
simple_flow_system.optimize(highs_solver)

copy_fs = copy.copy(simple_flow_system)
copy_fs = copy.copy(optimized_flow_system)
assert copy_fs.solution is None
assert copy_fs.is_locked is False

def test_python_deepcopy_uses_copy_method(self, simple_flow_system, highs_solver):
def test_python_deepcopy_uses_copy_method(self, optimized_flow_system):
"""copy.deepcopy() should use the custom copy method."""
simple_flow_system.optimize(highs_solver)

copy_fs = copy.deepcopy(simple_flow_system)
copy_fs = copy.deepcopy(optimized_flow_system)
assert copy_fs.solution is None
assert copy_fs.is_locked is False

Expand Down
Empty file added tests/io/__init__.py
Empty file.
2 changes: 1 addition & 1 deletion tests/test_io.py → tests/io/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import flixopt as fx

from .conftest import (
from ..conftest import (
flow_system_base,
flow_system_long,
flow_system_segments_of_flows_2,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ def test_load_old_results_from_resources(self):

import flixopt as fx

resources_path = pathlib.Path(__file__).parent / 'ressources'
resources_path = pathlib.Path(__file__).parent.parent / 'ressources'

# Load old results using new method
fs = fx.FlowSystem.from_old_results(resources_path, 'Sim1')
Expand All @@ -655,7 +655,7 @@ def test_old_results_can_be_saved_new_format(self, tmp_path):

import flixopt as fx

resources_path = pathlib.Path(__file__).parent / 'ressources'
resources_path = pathlib.Path(__file__).parent.parent / 'ressources'

# Load old results
fs = fx.FlowSystem.from_old_results(resources_path, 'Sim1')
Expand All @@ -674,7 +674,7 @@ def test_old_results_can_be_saved_new_format(self, tmp_path):
class TestV4APIConversion:
"""Tests for converting v4 API result files to the new format."""

V4_API_PATH = pathlib.Path(__file__).parent / 'ressources' / 'v4-api'
V4_API_PATH = pathlib.Path(__file__).parent.parent / 'ressources' / 'v4-api'

# All result names in the v4-api folder
V4_RESULT_NAMES = [
Expand Down
Empty file added tests/plotting/__init__.py
Empty file.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import flixopt as fx

from .conftest import (
from ..conftest import (
flow_system_long,
flow_system_segments_of_flows_2,
simple_flow_system,
Expand Down
File renamed without changes.
8 changes: 8 additions & 0 deletions tests/superseded/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Superseded tests — replaced by tests/test_math/.

These tests have been replaced by more thorough, analytically verified tests
in tests/test_math/. They are kept temporarily for reference and will be
deleted once confidence in the new test suite is established.

All tests in this folder are skipped via pytestmark.
"""
6 changes: 6 additions & 0 deletions tests/superseded/math/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Model-building tests superseded by tests/test_math/.

These tests verified linopy model structure (variables, constraints, bounds).
They are implicitly covered by test_math: if solutions are mathematically correct,
the model building must be correct.
"""
6 changes: 5 additions & 1 deletion tests/test_bus.py → tests/superseded/math/test_bus.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import pytest

import flixopt as fx

from .conftest import assert_conequal, assert_var_equal, create_linopy_model
from ...conftest import assert_conequal, assert_var_equal, create_linopy_model

pytestmark = pytest.mark.skip(reason='Superseded: model-building tests implicitly covered by tests/test_math/')


class TestBusModel:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import flixopt as fx
import flixopt.elements

from .conftest import (
from ...conftest import (
assert_almost_equal_numeric,
assert_conequal,
assert_dims_compatible,
Expand All @@ -13,6 +13,8 @@
create_linopy_model,
)

pytestmark = pytest.mark.skip(reason='Superseded: model-building tests implicitly covered by tests/test_math/')


class TestComponentModel:
def test_flow_label_check(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

import flixopt as fx

from .conftest import (
from ...conftest import (
assert_conequal,
assert_sets_equal,
assert_var_equal,
create_linopy_model,
)

pytestmark = pytest.mark.skip(reason='Superseded: model-building tests implicitly covered by tests/test_math/')


class TestEffectModel:
"""Test the FlowModel class."""
Expand Down
Loading