-
Notifications
You must be signed in to change notification settings - Fork 9
fix: dimension-aware effect-total consistency check #745
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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:
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
computedandsolution[label]have the same dim set but incompatible sizes,np.allclose(..., equal_nan=True)will raise for incompatible shapes and fornan=Trueimplementations before logging. Alignfound, comparecomputed.sizeswith 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