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
11 changes: 10 additions & 1 deletion flixopt/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -966,7 +966,16 @@ def _create_effects_dataset(self, mode: Literal['temporal', 'periodic', 'total']
label = f'{effect}{suffix[mode]}'
computed = ds[effect].sum('component')
found = self.solution[label]
if not np.allclose(computed.values, found.fillna(0).values):
if set(computed.dims) != set(found.dims):
logger.critical(
f'Results for {effect}({mode}) in effects_dataset doesnt match {label}: '
f'dimension mismatch {computed.dims=} vs {found.dims=}'
)
elif not np.allclose(
computed.fillna(0).values,
found.transpose(*computed.dims).fillna(0).values,
equal_nan=True,
):
logger.critical(
f'Results for {effect}({mode}) in effects_dataset doesnt match {label}\n{computed=}\n, {found=}'
)
Expand Down
11 changes: 10 additions & 1 deletion flixopt/statistics_accessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -935,7 +935,16 @@ def get_contributor_type(contributor: str) -> str:
if label in solution:
computed = ds[effect].sum('contributor')
found = solution[label]
if not np.allclose(computed.fillna(0).values, found.fillna(0).values, equal_nan=True):
if set(computed.dims) != set(found.dims):
logger.critical(
f'Results for {effect}({mode}) in effects_dataset doesnt match {label}: '
f'dimension mismatch {computed.dims=} vs {found.dims=}'
)
elif not np.allclose(
computed.fillna(0).values,
found.transpose(*computed.dims).fillna(0).values,
equal_nan=True,
):
Comment on lines +938 to +947

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
git ls-files | rg '^(flixopt/statistics_accessor.py|flixopt/results.py)$' || true

echo "== stats_accessor relevant lines =="
if [ -f flixopt/statistics_accessor.py ]; then
  sed -n '900,970p' flixopt/statistics_accessor.py | cat -n | sed 's/^/stats_accessor: /'
fi

echo "== results relevant lines =="
if [ -f flixopt/results.py ]; then
  sed -n '930,990p' flixopt/results.py | cat -n | sed 's/^/results: /'
fi

echo "== search comparison helper occurrences =="
rg -n "computed\.dims|found\.transpose|np\.allclose|fillna\(0\)\.values|sizes" flixopt/statistics_accessor.py flixopt/results.py || true

echo "== probe xarray dimension-name equality vs size broadcasting with numpy allclose =="
python3 - <<'PY'
import numpy as np
try:
    import xarray as xr
    a = xr.DataArray(np.arange(3*4), dims=['x','y'], coords={'x':[1,2,3],'y':[1,2,3,4]}).fillna(np.nan)
    b = xr.DataArray(np.arange(3*5), dims=['x','y'], coords={'x':[1,2,3],'y':[1,2,3,4,5]}).fillna(np.nan)
    print("xarray available", xr.__version__)
    print("dims equal sets:", set(a.dims) == set(b.dims))
    try:
        np.allclose(a.fillna(0).values, b.transpose(*a.dims).fillna(0).values, equal_nan=True)
        print("allclose returned")
    except Exception as e:
        print("allclose exception:", type(e).__name__, str(e))
except Exception as e:
    print("xarray probe unavailable:", type(e).__name__, str(e))
PY

Repository: flixOpt/flixopt

Length of output: 13925


🌐 Web query:

NumPy allclose ValueError arrays not same shape documentation

💡 Result:

The function numpy.allclose does not raise a ValueError when provided with arrays of different shapes [1][2]. Instead, it uses standard NumPy broadcasting rules to perform the comparison [1][3]. Because of this, numpy.allclose can return True for arrays with different shapes if they are broadcast-compatible and their elements satisfy the tolerance criteria [1][4]. If your goal is to require that both the shapes and the values match, you should use numpy.testing.assert_allclose instead [5][3]. Unlike numpy.allclose, numpy.testing.assert_allclose explicitly checks that the shapes of the two inputs are the same and raises an AssertionError if they do not match [5][3].

Citations:


🌐 Web query:

NumPy ufunc broadcasting rules incompatible shapes ValueError shape

💡 Result:

The ValueError: operands could not be broadcast together with shapes is raised when NumPy attempts to perform element-wise operations (via universal functions or ufuncs) on arrays that do not satisfy the required broadcasting compatibility rules [1][2][3]. Broadcasting Rules To determine if two arrays are compatible, NumPy compares their shapes element-wise, starting from the trailing (rightmost) dimension and moving backward toward the leftmost dimension [1][4][5]. Two dimensions are compatible if: 1. They are equal, or 2. One of them is 1 [1][6][5]. If the arrays have a different number of dimensions, the shape of the array with fewer dimensions is prepended with 1s until the number of dimensions matches [6][5]. If at any point the trailing dimensions are unequal and neither is 1, NumPy raises the ValueError [1][5]. Common Causes and How to Resolve This error typically occurs when the dimensions do not align for the intended element-wise operation [7][8]. To fix it, you must align the shapes by: 1. Adding dimensions: Use np.newaxis or None to convert a 1D array of shape (N,) into a column vector of shape (N, 1), which can then broadcast across a 2D array [8][5]. 2. Reshaping: Explicitly use.reshape to match the required dimensions [8][5]. 3. Reordering: If dimensions are swapped (e.g., trying to broadcast a (N, 1) array where a (1, N) is expected), use.transpose or.moveaxis to reorder them [8]. 4. Verifying Operation: If you intended to perform matrix multiplication instead of element-wise broadcasting, use the @ operator or np.matmul instead of standard arithmetic operators [7][8]. Top Results: [1], [8], [5]

Citations:


🌐 Web query:

NumPy allclose implementation calls subtract absolute less than

💡 Result:

The NumPy implementation of allclose uses the following element-wise comparison to determine if two arrays are close [1][2]: absolute(a - b) <= (atol + rtol * absolute(b)) This implementation computes the absolute difference between input arrays a and b and compares it against the sum of the absolute tolerance (atol) and the scaled relative tolerance (rtol * absolute(b)) [1]. Because this equation uses the absolute value of b, it is asymmetric, meaning allclose(a, b) can yield a different result than allclose(b, a) in some cases [1][2]. This logic aligns with the core requirement of checking if the difference between two values is less than or equal to a combined absolute and relative threshold [1][3]. The function is designed to handle floating-point comparisons where absolute tolerance provides a floor for small values, while relative tolerance scales with the magnitude of the reference value b [1][4].

Citations:


Guard incompatible comparison sizes instead of relying on np.allclose.

These checks only ensure dimension names match; if computed and solution[label] have the same dim set but incompatible sizes, np.allclose(..., equal_nan=True) will raise for incompatible shapes and for nan=True implementations before logging. Align found, compare computed.sizes with the aligned array, and log size mismatches before the value comparison in both affected paths.

📍 Affects 2 files
  • flixopt/statistics_accessor.py#L938-L947 (this comment)
  • flixopt/results.py#L969-L978
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@flixopt/statistics_accessor.py` around lines 938 - 947, In the comparison
paths around the statistics accessor dimension check and the corresponding
results comparison, align the found array to computed.dims, compare
computed.sizes with the aligned array’s sizes, and log a size mismatch before
calling np.allclose. Apply this in both flixopt/statistics_accessor.py:938-947
and flixopt/results.py:969-978, preserving the existing dimension-mismatch and
value-comparison behavior while preventing incompatible shapes from reaching
np.allclose.

logger.critical(
f'Results for {effect}({mode}) in effects_dataset doesnt match {label}\n{computed=}\n, {found=}'
)
Expand Down
118 changes: 118 additions & 0 deletions tests/test_clustering/test_clustered_roundtrip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Round-trip regression matrix for clustered FlowSystems.

Derived from property-based fuzzing (save -> load -> expand) that exercised random
combinations of periods, scenarios, storage (all initial-charge modes), converters and
optimize on/off. These curated cases pin the representative combinations so the
save/load/expand path stays intact across dimension layouts.
"""

import numpy as np
import pandas as pd
import pytest

import flixopt as fx

SOLVER = fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=60, log_to_console=False)


def _build_system(n_days, periods, scenarios, storage, storage_init, converter):
hours = n_days * 24
timesteps = pd.date_range('2024-01-01', periods=hours, freq='h')
period_idx = pd.Index(periods, name='period') if periods else None
scenario_idx = pd.Index(scenarios, name='scenario') if scenarios else None

daily = 0.5 + 0.5 * np.sin(np.linspace(0, 2 * np.pi, 24))
profile = np.clip(np.tile(daily, n_days) * 0.9, 0.01, None)

extra = {'weight_of_last_period': 1.0} if period_idx is not None else {}
fs = fx.FlowSystem(timesteps, periods=period_idx, scenarios=scenario_idx, **extra)
elements = [
fx.Bus('heat'),
fx.Effect('costs', 'EUR', 'costs', is_objective=True, is_standard=True),
fx.Sink('demand', inputs=[fx.Flow('in', bus='heat', fixed_relative_profile=profile, size=10)]),
fx.Source('grid', outputs=[fx.Flow('g', bus='heat', size=1000, effects_per_flow_hour={'costs': 0.1})]),
]
if converter:
elements += [
fx.Bus('gas'),
fx.Source('gas_src', outputs=[fx.Flow('gs', bus='gas', size=1000, effects_per_flow_hour={'costs': 0.05})]),
fx.LinearConverter(
'boiler',
inputs=[fx.Flow('bi', bus='gas', size=1000)],
outputs=[fx.Flow('bo', bus='heat', size=1000)],
conversion_factors=[{'bi': 1.0, 'bo': 0.9}],
),
]
if storage:
init = {'equals_final': 'equals_final', 'none': None, 'value': 2.0}[storage_init]
elements.append(
fx.Storage(
'battery',
charging=fx.Flow('sc', bus='heat', size=10),
discharging=fx.Flow('sd', bus='heat', size=10),
capacity_in_flow_hours=20,
initial_charge_state=init,
)
)
fs.add_elements(*elements)
return fs


# (label, n_days, periods, scenarios, storage, storage_init, converter, n_clusters)
CONFIGS = [
('plain', 3, None, None, False, 'none', False, 2),
('periods', 4, [2020, 2025], None, False, 'none', False, 2),
('scenarios', 4, None, ['a', 'b'], False, 'none', False, 2),
('periods+scenarios', 3, [2020, 2025, 2030], ['a', 'b'], False, 'none', False, 2),
('storage-cyclic', 4, None, None, True, 'equals_final', False, 2),
('storage-free', 4, None, None, True, 'none', False, 2),
('storage-value', 4, None, None, True, 'value', False, 3),
('converter', 3, None, None, False, 'none', True, 2),
('storage+conv+scen', 4, None, ['a', 'b'], True, 'equals_final', True, 2),
('storage+periods', 4, [2020, 2025], None, True, 'value', False, 2),
('k1', 3, None, None, True, 'equals_final', False, 1),
('k-max', 3, None, None, False, 'none', False, 3),
]


@pytest.mark.parametrize('cfg', CONFIGS, ids=[c[0] for c in CONFIGS])
def test_clustered_roundtrip_and_expand(cfg, tmp_path):
label, n_days, periods, scenarios, storage, storage_init, converter, n_clusters = cfg
fs = _build_system(n_days, periods, scenarios, storage, storage_init, converter)
clustered = fs.transform.cluster(n_clusters=n_clusters, cluster_duration='1D')
clustered.optimize(SOLVER)

costs_before = float(clustered.solution['costs'].sum().item())

path = tmp_path / f'{label}.nc4'
clustered.to_netcdf(path)
reloaded = fx.FlowSystem.from_netcdf(path)

assert reloaded.clustering is not None
assert reloaded.clustering.n_clusters == n_clusters
assert len(reloaded.timesteps) == len(clustered.timesteps)

costs_after = float(reloaded.solution['costs'].sum().item())
assert np.isclose(costs_before, costs_after, rtol=1e-6, atol=1e-4)

# effect-total validation must not crash on any dimension layout
reloaded.stats._create_effects_dataset('total')

expanded = reloaded.transform.expand()
assert len(expanded.timesteps) == n_days * 24


@pytest.mark.parametrize('cfg', CONFIGS, ids=[c[0] for c in CONFIGS])
def test_clustered_structure_only_roundtrip(cfg, tmp_path):
"""Round-trip of an unsolved (no-solution) clustered system must also reload."""
label, n_days, periods, scenarios, storage, storage_init, converter, n_clusters = cfg
fs = _build_system(n_days, periods, scenarios, storage, storage_init, converter)
clustered = fs.transform.cluster(n_clusters=n_clusters, cluster_duration='1D')

path = tmp_path / f'{label}_structure.nc4'
clustered.to_netcdf(path)
reloaded = fx.FlowSystem.from_netcdf(path)

assert reloaded.clustering is not None
assert reloaded.clustering.n_clusters == n_clusters
assert len(reloaded.timesteps) == len(clustered.timesteps)
53 changes: 53 additions & 0 deletions tests/test_effects_dataset_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Regression tests for the effect-total consistency check in ``_create_effects_dataset``.

The check compares ``ds[effect].sum(...)`` against ``solution[label]``. When those two
arrays carry the same dimensions in a different order (e.g. a clustered system expanded
back produces ``(scenario, period)`` where the computed total is ``(period, scenario)``),
comparing the raw ``.values`` with ``np.allclose`` used to raise a ``ValueError`` on the
shape mismatch -- turning a soft warning into a hard crash. The comparison must instead be
dimension-aware.
"""

import numpy as np
import pandas as pd
import pytest

import flixopt as fx


@pytest.fixture
def solved_multiperiod_scenario_system():
"""A solved system with 3 periods x 2 scenarios (different sizes on purpose)."""
timesteps = pd.date_range('2024-01-01', periods=24, freq='h')
periods = pd.Index([2024, 2025, 2026], name='period')
scenarios = pd.Index(['high', 'low'], name='scenario')

fs = fx.FlowSystem(timesteps, periods=periods, scenarios=scenarios)
fs.add_elements(
fx.Bus('heat'),
fx.Effect('costs', unit='EUR', description='costs', is_objective=True, is_standard=True),
fx.Sink('demand', inputs=[fx.Flow('in', bus='heat', fixed_relative_profile=np.ones(24), size=10)]),
fx.Source('source', outputs=[fx.Flow('out', bus='heat', size=50, effects_per_flow_hour={'costs': 0.05})]),
)
fs.optimize(fx.solvers.HighsSolver(mip_gap=0, time_limit_seconds=60, log_to_console=False))
return fs


@pytest.mark.parametrize('mode', ['temporal', 'periodic', 'total'])
def test_effects_dataset_tolerates_transposed_solution_dims(solved_multiperiod_scenario_system, mode):
"""Transposing an effect total in the solution must not crash the validation."""
fs = solved_multiperiod_scenario_system
suffix = {'temporal': '(temporal)|per_timestep', 'periodic': '(periodic)', 'total': ''}[mode]
label = f'costs{suffix}'
assert label in fs.solution

found = fs.solution[label]
transposable = [d for d in found.dims if d != 'time']
assert len(transposable) >= 2 # need >=2 non-time dims to reorder into a shape mismatch

# Force the opposite dim order the bug is about.
reordered = [d for d in reversed(found.dims)]
fs._solution[label] = found.transpose(*reordered)

ds = fs.stats._create_effects_dataset(mode)
assert 'costs' in ds
Loading