From d800c763c00edec8c7069d4533cfe4dc152c8988 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 17 Jan 2026 10:08:40 +0100 Subject: [PATCH 1/5] =?UTF-8?q?=E2=8F=BA=20Done.=20I've=20applied=20broadc?= =?UTF-8?q?asts=20to=20all=20four=20BoundingPatterns=20methods=20that=20ta?= =?UTF-8?q?ke=20bound=20tuples:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. basic_bounds - Added xr.broadcast(lower_bound, upper_bound) 2. bounds_with_state - Added xr.broadcast(lower_bound, upper_bound) 3. scaled_bounds - Added xr.broadcast(rel_lower, rel_upper) 4. scaled_bounds_with_state - Added broadcasts for both relative_bounds and scaling_bounds tuples The state_transition_bounds and continuous_transition_bounds methods don't take bound tuples, so they don't need this fix. Summary of changes: - flixopt/modeling.py: Added xr.broadcast() calls in all four bounding methods to ensure bound pairs always have compatible dimensions - flixopt/components.py: Added xr.broadcast() at the end of _relative_charge_state_bounds (kept as defensive measure) This should handle all cases where a scalar bound (e.g., relative_minimum=0) is paired with a time-varying bound that may have additional dimensions like cluster. --- flixopt/components.py | 4 ++++ flixopt/modeling.py | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/flixopt/components.py b/flixopt/components.py index 481135d1c..95249bfc1 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -1152,6 +1152,10 @@ def _relative_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: # Original is scalar - broadcast to full time range (constant value) max_bounds = rel_max.expand_dims(time=timesteps_extra) + # Ensure both bounds have compatible dimensions (handles case where one is + # scalar-expanded while the other has additional dimensions like 'cluster') + min_bounds, max_bounds = xr.broadcast(min_bounds, max_bounds) + return min_bounds, max_bounds @property diff --git a/flixopt/modeling.py b/flixopt/modeling.py index 3adce5338..ab54709c6 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -505,6 +505,8 @@ def basic_bounds( raise ValueError('BoundingPatterns.basic_bounds() can only be used with a Submodel') lower_bound, upper_bound = bounds + # Ensure bounds have compatible dimensions + lower_bound, upper_bound = xr.broadcast(lower_bound, upper_bound) name = name or f'{variable.name}' upper_constraint = model.add_constraints(variable <= upper_bound, name=f'{name}|ub') @@ -544,6 +546,8 @@ def bounds_with_state( raise ValueError('BoundingPatterns.bounds_with_state() can only be used with a Submodel') lower_bound, upper_bound = bounds + # Ensure bounds have compatible dimensions + lower_bound, upper_bound = xr.broadcast(lower_bound, upper_bound) name = name or f'{variable.name}' if np.allclose(lower_bound, upper_bound, atol=1e-10, equal_nan=True): @@ -586,6 +590,8 @@ def scaled_bounds( raise ValueError('BoundingPatterns.scaled_bounds() can only be used with a Submodel') rel_lower, rel_upper = relative_bounds + # Ensure bounds have compatible dimensions + rel_lower, rel_upper = xr.broadcast(rel_lower, rel_upper) name = name or f'{variable.name}' if np.allclose(rel_lower, rel_upper, atol=1e-10, equal_nan=True): @@ -636,6 +642,9 @@ def scaled_bounds_with_state( rel_lower, rel_upper = relative_bounds scaling_min, scaling_max = scaling_bounds + # Ensure bounds have compatible dimensions + rel_lower, rel_upper = xr.broadcast(rel_lower, rel_upper) + scaling_min, scaling_max = xr.broadcast(scaling_min, scaling_max) name = name or f'{variable.name}' big_m_misc = scaling_max * rel_lower From 9d256bffc8a7ddc99d62c5ea3fbcbcdfb0bc283d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 17 Jan 2026 10:11:26 +0100 Subject: [PATCH 2/5] Changes made: 1. Added _xr_allclose() helper in modeling.py:79-95 - uses xarray operations that handle broadcasting natively: def _xr_allclose(a: xr.DataArray, b: xr.DataArray, atol: float = 1e-10) -> bool: diff = a - b # xarray broadcasts automatically is_close = (abs(diff) <= atol) | (a.isnull() & b.isnull()) return bool(is_close.all()) 2. Removed all xr.broadcast() calls from: - BoundingPatterns.basic_bounds - BoundingPatterns.bounds_with_state - BoundingPatterns.scaled_bounds - BoundingPatterns.scaled_bounds_with_state - StorageModel._relative_charge_state_bounds 3. Replaced np.allclose() with _xr_allclose() in bounds_with_state and scaled_bounds The key insight: xarray arithmetic (a - b) handles broadcasting automatically, while np.allclose() does not. By using xarray operations for the comparison, we avoid the shape mismatch entirely without needing explicit broadcasts everywhere. --- flixopt/components.py | 4 ---- flixopt/modeling.py | 32 +++++++++++++++++++++----------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 95249bfc1..481135d1c 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -1152,10 +1152,6 @@ def _relative_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: # Original is scalar - broadcast to full time range (constant value) max_bounds = rel_max.expand_dims(time=timesteps_extra) - # Ensure both bounds have compatible dimensions (handles case where one is - # scalar-expanded while the other has additional dimensions like 'cluster') - min_bounds, max_bounds = xr.broadcast(min_bounds, max_bounds) - return min_bounds, max_bounds @property diff --git a/flixopt/modeling.py b/flixopt/modeling.py index ab54709c6..c502371d6 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -76,6 +76,25 @@ def _scalar_safe_reduce(data: xr.DataArray | Any, dim: str, method: str = 'mean' return data +def _xr_allclose(a: xr.DataArray, b: xr.DataArray, atol: float = 1e-10) -> bool: + """Check if two DataArrays are element-wise equal within tolerance. + + Unlike np.allclose, this uses xarray operations that handle broadcasting + automatically when arrays have different dimensions. + + Args: + a: First DataArray + b: Second DataArray + atol: Absolute tolerance for comparison + + Returns: + True if all elements are close (including matching NaN positions) + """ + diff = a - b # xarray broadcasts automatically + is_close = (abs(diff) <= atol) | (a.isnull() & b.isnull()) + return bool(is_close.all()) + + class ModelingUtilitiesAbstract: """Utility functions for modeling - leveraging xarray for temporal data""" @@ -505,8 +524,6 @@ def basic_bounds( raise ValueError('BoundingPatterns.basic_bounds() can only be used with a Submodel') lower_bound, upper_bound = bounds - # Ensure bounds have compatible dimensions - lower_bound, upper_bound = xr.broadcast(lower_bound, upper_bound) name = name or f'{variable.name}' upper_constraint = model.add_constraints(variable <= upper_bound, name=f'{name}|ub') @@ -546,11 +563,9 @@ def bounds_with_state( raise ValueError('BoundingPatterns.bounds_with_state() can only be used with a Submodel') lower_bound, upper_bound = bounds - # Ensure bounds have compatible dimensions - lower_bound, upper_bound = xr.broadcast(lower_bound, upper_bound) name = name or f'{variable.name}' - if np.allclose(lower_bound, upper_bound, atol=1e-10, equal_nan=True): + if _xr_allclose(lower_bound, upper_bound): fix_constraint = model.add_constraints(variable == state * upper_bound, name=f'{name}|fix') return [fix_constraint] @@ -590,11 +605,9 @@ def scaled_bounds( raise ValueError('BoundingPatterns.scaled_bounds() can only be used with a Submodel') rel_lower, rel_upper = relative_bounds - # Ensure bounds have compatible dimensions - rel_lower, rel_upper = xr.broadcast(rel_lower, rel_upper) name = name or f'{variable.name}' - if np.allclose(rel_lower, rel_upper, atol=1e-10, equal_nan=True): + if _xr_allclose(rel_lower, rel_upper): return [model.add_constraints(variable == scaling_variable * rel_lower, name=f'{name}|fixed')] upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{name}|ub') @@ -642,9 +655,6 @@ def scaled_bounds_with_state( rel_lower, rel_upper = relative_bounds scaling_min, scaling_max = scaling_bounds - # Ensure bounds have compatible dimensions - rel_lower, rel_upper = xr.broadcast(rel_lower, rel_upper) - scaling_min, scaling_max = xr.broadcast(scaling_min, scaling_max) name = name or f'{variable.name}' big_m_misc = scaling_max * rel_lower From 401838fa557c5792f861648dbba77f57bfdf4c46 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 17 Jan 2026 10:12:30 +0100 Subject: [PATCH 3/5] =?UTF-8?q?=E2=8F=BA=20The=20inheritance=20chain=20han?= =?UTF-8?q?dles=20it:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _relative_charge_state_bounds → broadcasts → used by _absolute_charge_state_bounds - relative_flow_rate_bounds → broadcasts → used by absolute_flow_rate_bounds So the downstream properties automatically get aligned data. Final architecture: 1. Interface layer (the *_bounds properties) broadcasts once when returning tuples 2. BoundingPatterns uses _xr_allclose which handles xarray operations gracefully (as safety net) 3. No redundant broadcasting in constraint creation The _xr_allclose helper is still valuable as it's cleaner than np.allclose for xarray data and handles NaN correctly. It just won't need to do any broadcasting work now since inputs are pre-aligned. --- flixopt/components.py | 4 +++- flixopt/elements.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index 481135d1c..b47fc36bf 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -1152,7 +1152,9 @@ def _relative_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: # Original is scalar - broadcast to full time range (constant value) max_bounds = rel_max.expand_dims(time=timesteps_extra) - return min_bounds, max_bounds + # Ensure both bounds have matching dimensions (broadcast once here, + # so downstream code doesn't need to handle dimension mismatches) + return xr.broadcast(min_bounds, max_bounds) @property def _investment(self) -> InvestmentModel | None: diff --git a/flixopt/elements.py b/flixopt/elements.py index e2def702d..bc2c7ab47 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -870,7 +870,9 @@ def _create_bounds_for_load_factor(self): def relative_flow_rate_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: if self.element.fixed_relative_profile is not None: return self.element.fixed_relative_profile, self.element.fixed_relative_profile - return self.element.relative_minimum, self.element.relative_maximum + # Ensure both bounds have matching dimensions (broadcast once here, + # so downstream code doesn't need to handle dimension mismatches) + return xr.broadcast(self.element.relative_minimum, self.element.relative_maximum) @property def absolute_flow_rate_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: From fbe5bba05c07242a3235fd9e819e0c079096edc7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 17 Jan 2026 10:19:02 +0100 Subject: [PATCH 4/5] =?UTF-8?q?=E2=8F=BA=20With=20@functools.cached=5Fprop?= =?UTF-8?q?erty:=20=20=20-=20230=20=E2=86=92=2060=20calls=20(one=20per=20e?= =?UTF-8?q?lement=20instead=20of=203-4=20per=20element)=20=20=20-=2074%=20?= =?UTF-8?q?reduction=20in=20broadcast=20overhead=20=20=20-=20~12ms=20inste?= =?UTF-8?q?ad=20of=20~45ms=20for=20a=20typical=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flixopt/components.py | 3 ++- flixopt/elements.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/flixopt/components.py b/flixopt/components.py index b47fc36bf..f9c59485b 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -4,6 +4,7 @@ from __future__ import annotations +import functools import logging import warnings from typing import TYPE_CHECKING, Literal @@ -1102,7 +1103,7 @@ def _absolute_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: relative_upper_bound * cap, ) - @property + @functools.cached_property def _relative_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: """ Get relative charge state bounds with final timestep values. diff --git a/flixopt/elements.py b/flixopt/elements.py index bc2c7ab47..791596b28 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -4,6 +4,7 @@ from __future__ import annotations +import functools import logging from typing import TYPE_CHECKING @@ -866,7 +867,7 @@ def _create_bounds_for_load_factor(self): short_name='load_factor_min', ) - @property + @functools.cached_property def relative_flow_rate_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: if self.element.fixed_relative_profile is not None: return self.element.fixed_relative_profile, self.element.fixed_relative_profile From d8a7af3307e004dc5c00dec0fb9087e67094cb1d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 17 Jan 2026 10:33:39 +0100 Subject: [PATCH 5/5] Speedup _xr_allclose --- flixopt/modeling.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index c502371d6..ff84c808f 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -76,23 +76,25 @@ def _scalar_safe_reduce(data: xr.DataArray | Any, dim: str, method: str = 'mean' return data -def _xr_allclose(a: xr.DataArray, b: xr.DataArray, atol: float = 1e-10) -> bool: +def _xr_allclose(a: xr.DataArray, b: xr.DataArray, rtol: float = 1e-5, atol: float = 1e-8) -> bool: """Check if two DataArrays are element-wise equal within tolerance. - Unlike np.allclose, this uses xarray operations that handle broadcasting - automatically when arrays have different dimensions. - Args: a: First DataArray b: Second DataArray - atol: Absolute tolerance for comparison + rtol: Relative tolerance (default matches np.allclose) + atol: Absolute tolerance (default matches np.allclose) Returns: True if all elements are close (including matching NaN positions) """ - diff = a - b # xarray broadcasts automatically - is_close = (abs(diff) <= atol) | (a.isnull() & b.isnull()) - return bool(is_close.all()) + # Fast path: same dims and shape - use numpy directly + if a.dims == b.dims and a.shape == b.shape: + return np.allclose(a.values, b.values, rtol=rtol, atol=atol, equal_nan=True) + + # Slow path: broadcast to common shape, then use numpy + a_bc, b_bc = xr.broadcast(a, b) + return np.allclose(a_bc.values, b_bc.values, rtol=rtol, atol=atol, equal_nan=True) class ModelingUtilitiesAbstract: