Skip to content

Commit 5491664

Browse files
authored
Feature/v3/several improvements (#372)
* Update deprecated properties to use new aggregation attributes in `core.py`. * Refactor `drop_constant_arrays` in `core.py` to improve clarity, add type hints, and enhance logging for dropped variables. * Bugfix example_calculation_types.py and two_stage_optimization.py * Use time selection more explicitly * Refactor plausibility checks in `components.py` to handle string-based `initial_charge_state` more robustly and simplify capacity bounds retrieval using `InvestParameters`. * Refactor `create_transmission_equation` in `components.py` to handle `relative_losses` gracefully when unset and simplify the constraint definition. * Update pytest `addopts` formatting in `pyproject.toml` to work with both unix and windows * Refine null value handling when resolving dataarrays` to check for 'time' dimension before dropping all-null values. * Refactor flow system restoration to improve exception handling and ensure logger state resets. * Refactor imports in `elements.py` to remove unused `ModelingPrimitives` from `features` and include it from `modeling` instead. * Refactor `count_consecutive_states` in `modeling.py` to enhance documentation, improve edge case handling, and simplify array processing. * Refactor `drop_constant_arrays` to handle NaN cases with `skipna` and sort dropped variables for better logging; streamline logger state restoration in `results.py`. * Temp * Improve NAN handling in count_consecutive_states() * Refactor plausibility checks in `components.py` to prevent initial capacity from constraining investment decisions and improve error messaging.
1 parent 9977ea5 commit 5491664

10 files changed

Lines changed: 118 additions & 70 deletions

File tree

examples/03_Calculation_types/example_calculation_types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
# TimeSeriesData objects
4747
TS_heat_demand = fx.TimeSeriesData(heat_demand)
4848
TS_electricity_demand = fx.TimeSeriesData(electricity_demand, aggregation_weight=0.7)
49-
TS_electricity_price_sell = fx.TimeSeriesData(-(electricity_demand - 0.5), aggregation_group='p_el')
49+
TS_electricity_price_sell = fx.TimeSeriesData(-(electricity_price - 0.5), aggregation_group='p_el')
5050
TS_electricity_price_buy = fx.TimeSeriesData(electricity_price + 0.5, aggregation_group='p_el')
5151

5252
flow_system = fx.FlowSystem(timesteps)

examples/05_Two-stage-optimization/two_stage_optimization.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
calculation_sizing.solve(fx.solvers.HighsSolver(0.1 / 100, 600))
119119
timer_sizing = timeit.default_timer() - start
120120

121+
start = timeit.default_timer()
121122
calculation_dispatch = fx.FullCalculation('Sizing', flow_system)
122123
calculation_dispatch.do_modeling()
123124
calculation_dispatch.fix_sizes(calculation_sizing.results.solution)

flixopt/calculation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ def _create_sub_calculations(self):
556556
for i, (segment_name, timesteps_of_segment) in enumerate(
557557
zip(self.segment_names, self._timesteps_per_segment, strict=True)
558558
):
559-
calc = FullCalculation(f'{self.name}-{segment_name}', self.flow_system.sel(timesteps_of_segment))
559+
calc = FullCalculation(f'{self.name}-{segment_name}', self.flow_system.sel(time=timesteps_of_segment))
560560
calc.flow_system._connect_network() # Connect to have Correct names of Flows!
561561

562562
self.sub_calculations.append(calc)

flixopt/components.py

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -472,36 +472,39 @@ def _plausibility_checks(self) -> None:
472472
Check for infeasible or uncommon combinations of parameters
473473
"""
474474
super()._plausibility_checks()
475+
476+
# Validate string values and set flag
477+
initial_is_last = False
475478
if isinstance(self.initial_charge_state, str):
476-
if self.initial_charge_state != 'lastValueOfSim':
479+
if self.initial_charge_state == 'lastValueOfSim':
480+
initial_is_last = True
481+
else:
477482
raise PlausibilityError(f'initial_charge_state has undefined value: {self.initial_charge_state}')
478-
return
483+
484+
# Use new InvestParameters methods to get capacity bounds
479485
if isinstance(self.capacity_in_flow_hours, InvestParameters):
480-
if self.capacity_in_flow_hours.fixed_size is None:
481-
maximum_capacity = self.capacity_in_flow_hours.maximum_size
482-
minimum_capacity = self.capacity_in_flow_hours.minimum_size
483-
else:
484-
maximum_capacity = self.capacity_in_flow_hours.fixed_size
485-
minimum_capacity = self.capacity_in_flow_hours.fixed_size
486+
minimum_capacity = self.capacity_in_flow_hours.minimum_or_fixed_size
487+
maximum_capacity = self.capacity_in_flow_hours.maximum_or_fixed_size
486488
else:
487489
maximum_capacity = self.capacity_in_flow_hours
488490
minimum_capacity = self.capacity_in_flow_hours
489491

490-
# initial capacity >= allowed min for maximum_size:
492+
# Initial capacity should not constraint investment decision
491493
minimum_initial_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=0)
492-
# initial capacity <= allowed max for minimum_size:
493494
maximum_initial_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=0)
494495

495-
if (self.initial_charge_state > maximum_initial_capacity).any():
496-
raise ValueError(
497-
f'{self.label_full}: {self.initial_charge_state=} '
498-
f'is above allowed maximum charge_state {maximum_initial_capacity}'
499-
)
500-
if (self.initial_charge_state < minimum_initial_capacity).any():
501-
raise ValueError(
502-
f'{self.label_full}: {self.initial_charge_state=} '
503-
f'is below allowed minimum charge_state {minimum_initial_capacity}'
504-
)
496+
# Only perform numeric comparisons if not using 'lastValueOfSim'
497+
if not initial_is_last:
498+
if (self.initial_charge_state > maximum_initial_capacity).any():
499+
raise PlausibilityError(
500+
f'{self.label_full}: {self.initial_charge_state=} '
501+
f'is constraining the investment decision. Chosse a value above {maximum_initial_capacity}'
502+
)
503+
if (self.initial_charge_state < minimum_initial_capacity).any():
504+
raise PlausibilityError(
505+
f'{self.label_full}: {self.initial_charge_state=} '
506+
f'is constraining the investment decision. Chosse a value below {minimum_initial_capacity}'
507+
)
505508

506509
if self.balanced:
507510
if not isinstance(self.charging.size, InvestParameters) or not isinstance(
@@ -736,8 +739,9 @@ def _do_modeling(self):
736739
def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) -> linopy.Constraint:
737740
"""Creates an Equation for the Transmission efficiency and adds it to the model"""
738741
# eq: out(t) + on(t)*loss_abs(t) = in(t)*(1 - loss_rel(t))
742+
rel_losses = 0 if self.element.relative_losses is None else self.element.relative_losses
739743
con_transmission = self.add_constraints(
740-
out_flow.submodel.flow_rate == -in_flow.submodel.flow_rate * (self.element.relative_losses - 1),
744+
out_flow.submodel.flow_rate == in_flow.submodel.flow_rate * (1 - rel_losses),
741745
short_name=name,
742746
)
743747

flixopt/core.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,12 @@ def __repr__(self):
142142
@property
143143
def agg_group(self):
144144
warnings.warn('agg_group is deprecated, use aggregation_group instead', DeprecationWarning, stacklevel=2)
145-
return self._aggregation_group
145+
return self.aggregation_group
146146

147147
@property
148148
def agg_weight(self):
149149
warnings.warn('agg_weight is deprecated, use aggregation_weight instead', DeprecationWarning, stacklevel=2)
150-
return self._aggregation_weight
150+
return self.aggregation_weight
151151

152152

153153
TemporalDataUser = (
@@ -606,19 +606,36 @@ def get_dataarray_stats(arr: xr.DataArray) -> dict:
606606
return stats
607607

608608

609-
def drop_constant_arrays(ds: xr.Dataset, dim='time', drop_arrays_without_dim: bool = True):
610-
"""Drop variables with very low variance (near-constant)."""
609+
def drop_constant_arrays(ds: xr.Dataset, dim: str = 'time', drop_arrays_without_dim: bool = True) -> xr.Dataset:
610+
"""Drop variables with constant values along a dimension.
611+
612+
Args:
613+
ds: Input dataset to filter.
614+
dim: Dimension along which to check for constant values.
615+
drop_arrays_without_dim: If True, also drop variables that don't have the specified dimension.
616+
617+
Returns:
618+
Dataset with constant variables removed.
619+
"""
611620
drop_vars = []
612621

613622
for name, da in ds.data_vars.items():
614-
if dim in da.dims:
615-
if da.max(dim) == da.min(dim):
623+
# Skip variables without the dimension
624+
if dim not in da.dims:
625+
if drop_arrays_without_dim:
616626
drop_vars.append(name)
617627
continue
618-
elif drop_arrays_without_dim:
628+
629+
# Check if variable is constant along the dimension
630+
if (da.max(dim, skipna=True) == da.min(dim, skipna=True)).all().item():
619631
drop_vars.append(name)
620632

621-
logger.debug(f'Dropping {len(drop_vars)} arrays with constant values')
633+
if drop_vars:
634+
drop_vars = sorted(drop_vars)
635+
logger.debug(
636+
f'Dropping {len(drop_vars)} constant/dimension-less arrays: {drop_vars[:5]}{"..." if len(drop_vars) > 5 else ""}'
637+
)
638+
622639
return ds.drop_vars(drop_vars)
623640

624641

flixopt/elements.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313

1414
from .config import CONFIG
1515
from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser
16-
from .features import InvestmentModel, ModelingPrimitives, OnOffModel
16+
from .features import InvestmentModel, OnOffModel
1717
from .interface import InvestParameters, OnOffParameters
18-
from .modeling import BoundingPatterns, ModelingUtilitiesAbstract
18+
from .modeling import BoundingPatterns, ModelingPrimitives, ModelingUtilitiesAbstract
1919
from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io
2020

2121
if TYPE_CHECKING:

flixopt/modeling.py

Lines changed: 55 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -53,49 +53,67 @@ def to_binary(
5353

5454
@staticmethod
5555
def count_consecutive_states(
56-
binary_values: xr.DataArray,
56+
binary_values: xr.DataArray | np.ndarray | list[int, float],
5757
dim: str = 'time',
58-
epsilon: float = None,
58+
epsilon: float | None = None,
5959
) -> float:
60-
"""
61-
Counts the number of consecutive states in a binary time series.
60+
"""Count consecutive steps in the final active state of a binary time series.
61+
62+
This function counts how many consecutive time steps the series remains "on"
63+
(non-zero) at the end of the time series. If the final state is "off", returns 0.
6264
6365
Args:
64-
binary_values: Binary DataArray
65-
dim: Dimension to count consecutive states over
66-
epsilon: Tolerance for zero detection (uses CONFIG.modeling.EPSILON if None)
66+
binary_values: Binary DataArray with values close to 0 (off) or 1 (on).
67+
dim: Dimension along which to count consecutive states.
68+
epsilon: Tolerance for zero detection. Uses CONFIG.modeling.EPSILON if None.
6769
6870
Returns:
69-
The consecutive number of steps spent in the final state of the timeseries
70-
"""
71-
if epsilon is None:
72-
epsilon = CONFIG.modeling.EPSILON
71+
Sum of values in the final consecutive "on" period. Returns 0.0 if the
72+
final state is "off".
73+
74+
Examples:
75+
>>> arr = xr.DataArray([0, 0, 1, 1, 1, 0, 1, 1], dims=['time'])
76+
>>> ModelingUtilitiesAbstract.count_consecutive_states(arr)
77+
2.0
7378
74-
binary_values = binary_values.any(dim=[d for d in binary_values.dims if d != dim])
79+
>>> arr = [0, 0, 1, 0, 1, 1, 1, 1]
80+
>>> ModelingUtilitiesAbstract.count_consecutive_states(arr)
81+
4.0
82+
"""
83+
epsilon = epsilon or CONFIG.modeling.EPSILON
84+
85+
if isinstance(binary_values, xr.DataArray):
86+
# xarray path
87+
other_dims = [d for d in binary_values.dims if d != dim]
88+
if other_dims:
89+
binary_values = binary_values.any(dim=other_dims)
90+
arr = binary_values.values
91+
else:
92+
# numpy/array-like path
93+
arr = np.asarray(binary_values)
7594

76-
# Handle scalar case
77-
if binary_values.ndim == 0:
78-
return float(binary_values.item())
95+
# Flatten to 1D if needed
96+
arr = arr.ravel() if arr.ndim > 1 else arr
7997

80-
# Check if final state is off
81-
if np.isclose(binary_values.isel({dim: -1}), 0, atol=epsilon).all():
98+
# Handle edge cases
99+
if arr.size == 0:
82100
return 0.0
101+
if arr.size == 1:
102+
return float(arr[0]) if not np.isclose(arr[0], 0, atol=epsilon) else 0.0
83103

84-
# Find consecutive 'on' period from the end
85-
is_zero = np.isclose(binary_values, 0, atol=epsilon)
104+
# Return 0 if final state is off
105+
if np.isclose(arr[-1], 0, atol=epsilon):
106+
return 0.0
86107

87-
# Find the last zero, then sum everything after it
108+
# Find the last zero position (treat NaNs as off)
109+
arr = np.nan_to_num(arr, nan=0.0)
110+
is_zero = np.isclose(arr, 0, atol=epsilon)
88111
zero_indices = np.where(is_zero)[0]
89-
if len(zero_indices) == 0:
90-
# All 'on' - sum everything
91-
start_idx = 0
92-
else:
93-
# Start after last zero
94-
start_idx = zero_indices[-1] + 1
95112

96-
consecutive_values = binary_values.isel({dim: slice(start_idx, None)})
113+
# Calculate sum from last zero to end
114+
start_idx = zero_indices[-1] + 1 if zero_indices.size > 0 else 0
97115

98-
return float(consecutive_values.sum().item()) # TODO: Som only over one dim?
116+
return float(np.sum(arr[start_idx:]))
99117

100118

101119
class ModelingUtilities:
@@ -308,7 +326,13 @@ def consecutive_duration_tracking(
308326
)
309327

310328
# Handle initial condition for minimum duration
311-
if previous_duration > 0 and previous_duration < minimum_duration.isel({duration_dim: 0}).max():
329+
prev = (
330+
float(previous_duration)
331+
if not isinstance(previous_duration, xr.DataArray)
332+
else float(previous_duration.max().item())
333+
)
334+
min0 = float(minimum_duration.isel({duration_dim: 0}).max().item())
335+
if prev > 0 and prev < min0:
312336
constraints['initial_lb'] = model.add_constraints(
313337
state_variable.isel({duration_dim: 0}) == 1, name=f'{duration.name}|initial_lb'
314338
)
@@ -435,7 +459,7 @@ def bounds_with_state(
435459
lower_bound, upper_bound = bounds
436460
name = name or f'{variable.name}'
437461

438-
if np.all(lower_bound - upper_bound) < 1e-10:
462+
if np.allclose(lower_bound, upper_bound, atol=1e-10, equal_nan=True):
439463
fix_constraint = model.add_constraints(variable == variable_state * upper_bound, name=f'{name}|fix')
440464
return [fix_constraint]
441465

@@ -481,7 +505,7 @@ def scaled_bounds(
481505
rel_lower, rel_upper = relative_bounds
482506
name = name or f'{variable.name}'
483507

484-
if np.abs(rel_lower - rel_upper).all() < 10e-10:
508+
if np.allclose(rel_lower, rel_upper, atol=1e-10, equal_nan=True):
485509
return [model.add_constraints(variable == scaling_variable * rel_lower, name=f'{name}|fixed')]
486510

487511
upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{name}|ub')

flixopt/results.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,17 +291,18 @@ def flow_system(self) -> FlowSystem:
291291
"""The restored flow_system that was used to create the calculation.
292292
Contains all input parameters."""
293293
if self._flow_system is None:
294+
old_level = logger.level
295+
logger.level = logging.CRITICAL
294296
try:
295-
current_logger_level = logger.getEffectiveLevel()
296-
logger.setLevel(logging.CRITICAL)
297297
self._flow_system = FlowSystem.from_dataset(self.flow_system_data)
298298
self._flow_system._connect_network()
299-
logger.setLevel(current_logger_level)
300299
except Exception as e:
301300
logger.critical(
302301
f'Not able to restore FlowSystem from dataset. Some functionality is not availlable. {e}'
303302
)
304303
raise _FlowSystemRestorationError(f'Not able to restore FlowSystem from dataset. {e}') from e
304+
finally:
305+
logger.level = old_level
305306
return self._flow_system
306307

307308
def filter_solution(

flixopt/structure.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -422,8 +422,9 @@ def _resolve_dataarray_reference(
422422

423423
# Handle null values with warning
424424
if array.isnull().any():
425-
logger.warning(f"DataArray '{array_name}' contains null values. Dropping them.")
426-
array = array.dropna(dim='time', how='all')
425+
logger.warning(f"DataArray '{array_name}' contains null values. Dropping all-null along present dims.")
426+
if 'time' in array.dims:
427+
array = array.dropna(dim='time', how='all')
427428

428429
# Check if this should be restored as TimeSeriesData
429430
if TimeSeriesData.is_timeseries_data(array):

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ markers = [
182182
"slow: marks tests as slow",
183183
"examples: marks example tests (run only on releases)",
184184
]
185-
addopts = "-m 'not examples'" # Skip examples by default
185+
addopts = '-m "not examples"' # Skip examples by default
186186

187187
[tool.bandit]
188188
skips = ["B101", "B506"] # assert_used and yaml_load

0 commit comments

Comments
 (0)