Skip to content

Commit 20f74f3

Browse files
authored
Merge pull request #286 from flixOpt/v3.0.0/testing
Update Tests for years and scenarios
2 parents 1716607 + e764a13 commit 20f74f3

15 files changed

Lines changed: 1086 additions & 743 deletions

flixopt/elements.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ def __init__(
199199
If the load-profile is just an upper limit, use relative_maximum instead.
200200
previous_flow_rate: previous flow rate of the flow. Used to determine if and how long the
201201
flow is already on / off. If None, the flow is considered to be off for one timestep.
202+
Currently does not support different values in different years or scenarios!
202203
meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types.
203204
"""
204205
super().__init__(label, meta_data=meta_data)
@@ -305,7 +306,8 @@ def _plausibility_checks(self) -> None:
305306
]
306307
):
307308
raise TypeError(
308-
f'previous_flow_rate must be None, a scalar, a list of scalars or a 1D-numpy-array. Got {type(self.previous_flow_rate)}'
309+
f'previous_flow_rate must be None, a scalar, a list of scalars or a 1D-numpy-array. Got {type(self.previous_flow_rate)}.'
310+
f'Different values in different years or scenarios are not yetsupported.'
309311
)
310312

311313
@property

flixopt/features.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,21 +171,22 @@ def _do_modeling(self):
171171
self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf,
172172
), # TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration())
173173
short_name='on_hours_total',
174-
coords=self.get_coords(['year', 'scenario']),
174+
coords=['year', 'scenario'],
175175
)
176176

177177
# 4. Switch tracking using existing pattern
178178
if self.parameters.use_switch_on:
179179
self.add_variables(binary=True, short_name='switch|on', coords=self.get_coords())
180180
self.add_variables(binary=True, short_name='switch|off', coords=self.get_coords())
181181

182-
ModelingPrimitives.state_transition_variables(
182+
BoundingPatterns.state_transition_bounds(
183183
self,
184184
state_variable=self.on,
185185
switch_on=self.switch_on,
186186
switch_off=self.switch_off,
187187
name=f'{self.label_of_model}|switch',
188188
previous_state=self._previous_states.isel(time=-1) if self._previous_states is not None else 0,
189+
coord='time',
189190
)
190191

191192
if self.parameters.switch_on_total_max is not None:
@@ -408,7 +409,9 @@ def _do_modeling(self):
408409
rhs = self.zero_point
409410
elif self._zero_point is True:
410411
self.zero_point = self.add_variables(
411-
coords=self._model.get_coords(), binary=True, short_name='zero_point'
412+
coords=self._model.get_coords(('year', 'scenario') if self._as_time_series else None),
413+
binary=True,
414+
short_name='zero_point',
412415
)
413416
rhs = self.zero_point
414417
else:

flixopt/interface.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,8 @@ def maximum_or_fixed_size(self) -> NonTemporalData:
246246
class OnOffParameters(Interface):
247247
def __init__(
248248
self,
249-
effects_per_switch_on: Optional['NonTemporalEffectsUser'] = None,
250-
effects_per_running_hour: Optional['NonTemporalEffectsUser'] = None,
249+
effects_per_switch_on: Optional['TemporalEffectsUser'] = None,
250+
effects_per_running_hour: Optional['TemporalEffectsUser'] = None,
251251
on_hours_total_min: Optional[int] = None,
252252
on_hours_total_max: Optional[int] = None,
253253
consecutive_on_hours_min: Optional[TemporalDataUser] = None,
@@ -339,15 +339,13 @@ def use_consecutive_off_hours(self) -> bool:
339339
@property
340340
def use_switch_on(self) -> bool:
341341
"""Determines wether a Variable for SWITCH-ON is needed or not"""
342-
return (
343-
any(
344-
param not in (None, {})
345-
for param in [
346-
self.effects_per_switch_on,
347-
self.switch_on_total_max,
348-
self.on_hours_total_min,
349-
self.on_hours_total_max,
350-
]
351-
)
352-
or self.force_switch_on
342+
if self.force_switch_on:
343+
return True
344+
345+
return any(
346+
param is not None and param != {}
347+
for param in [
348+
self.effects_per_switch_on,
349+
self.switch_on_total_max,
350+
]
353351
)

flixopt/modeling.py

Lines changed: 49 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ def expression_tracking_variable(
189189
name: str = None,
190190
short_name: str = None,
191191
bounds: Tuple[TemporalData, TemporalData] = None,
192-
coords: List[str] = None,
192+
coords: Optional[Union[str, List[str]]] = None,
193193
) -> Tuple[linopy.Variable, linopy.Constraint]:
194194
"""
195195
Creates variable that equals a given expression.
@@ -205,8 +205,6 @@ def expression_tracking_variable(
205205
if not isinstance(model, Submodel):
206206
raise ValueError('ModelingPrimitives.expression_tracking_variable() can only be used with a Submodel')
207207

208-
coords = coords or ['year', 'scenario']
209-
210208
if not bounds:
211209
tracker = model.add_variables(name=name, coords=model.get_coords(coords), short_name=short_name)
212210
else:
@@ -223,86 +221,6 @@ def expression_tracking_variable(
223221

224222
return tracker, tracking
225223

226-
@staticmethod
227-
def state_transition_variables(
228-
model: Submodel,
229-
state_variable: linopy.Variable,
230-
switch_on: linopy.Variable,
231-
switch_off: linopy.Variable,
232-
name: str,
233-
previous_state=0,
234-
) -> Tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint]:
235-
"""
236-
Creates switch-on/off variables with state transition logic.
237-
238-
Mathematical formulation:
239-
switch_on[t] - switch_off[t] = state[t] - state[t-1] ∀t > 0
240-
switch_on[0] - switch_off[0] = state[0] - previous_state
241-
switch_on[t] + switch_off[t] ≤ 1 ∀t
242-
switch_on[t], switch_off[t] ∈ {0, 1}
243-
244-
Returns:
245-
variables: {'switch_on': binary_var, 'switch_off': binary_var}
246-
constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint}
247-
"""
248-
if not isinstance(model, Submodel):
249-
raise ValueError('ModelingPrimitives.state_transition_variables() can only be used with a Submodel')
250-
251-
# State transition constraints for t > 0
252-
transition = model.add_constraints(
253-
switch_on.isel(time=slice(1, None)) - switch_off.isel(time=slice(1, None))
254-
== state_variable.isel(time=slice(1, None)) - state_variable.isel(time=slice(None, -1)),
255-
name=f'{name}|transition',
256-
)
257-
258-
# Initial state transition for t = 0
259-
initial = model.add_constraints(
260-
switch_on.isel(time=0) - switch_off.isel(time=0) == state_variable.isel(time=0) - previous_state,
261-
name=f'{name}|initial',
262-
)
263-
264-
# At most one switch per timestep
265-
mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|mutex')
266-
267-
return transition, initial, mutex
268-
269-
@staticmethod
270-
def sum_up_variable(
271-
model: Submodel,
272-
variable_to_count: linopy.Variable,
273-
name: str = None,
274-
bounds: Tuple[NonTemporalData, NonTemporalData] = None,
275-
factor: TemporalData = 1,
276-
) -> Tuple[linopy.Variable, linopy.Constraint]:
277-
"""
278-
SUms up a variable over time, applying a factor to the variable.
279-
280-
Args:
281-
model: The optimization model instance
282-
variable_to_count: The variable to be summed up
283-
name: The name of the constraint
284-
bounds: The bounds of the constraint
285-
factor: The factor to be applied to the variable
286-
"""
287-
if not isinstance(model, Submodel):
288-
raise ValueError('ModelingPrimitives.sum_up_variable() can only be used with a Submodel')
289-
290-
if bounds is None:
291-
bounds = (0, np.inf)
292-
else:
293-
bounds = (bounds[0] if bounds[0] is not None else 0, bounds[1] if bounds[1] is not None else np.inf)
294-
295-
count = model.add_variables(
296-
lower=bounds[0],
297-
upper=bounds[1],
298-
coords=model.get_coords(['year', 'scenario']),
299-
name=name,
300-
)
301-
302-
count_constraint = model.add_constraints(count == (variable_to_count * factor).sum('time'), name=name)
303-
304-
return count, count_constraint
305-
306224
@staticmethod
307225
def consecutive_duration_tracking(
308226
model: Submodel,
@@ -346,7 +264,7 @@ def consecutive_duration_tracking(
346264
duration = model.add_variables(
347265
lower=0,
348266
upper=maximum_duration if maximum_duration is not None else mega,
349-
coords=model.get_coords(['time']),
267+
coords=model.get_coords(),
350268
name=name,
351269
short_name=short_name,
352270
)
@@ -618,10 +536,55 @@ def scaled_bounds_with_state(
618536
)
619537
scaling_upper = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{name}|ub2')
620538

621-
big_m_upper = scaling_max * rel_upper
622-
big_m_lower = np.maximum(CONFIG.modeling.EPSILON, scaling_min * rel_lower)
539+
big_m_upper = rel_upper * scaling_max
540+
big_m_lower = np.maximum(CONFIG.modeling.EPSILON, rel_lower * scaling_min)
623541

624542
binary_upper = model.add_constraints(variable_state * big_m_upper >= variable, name=f'{name}|ub1')
625543
binary_lower = model.add_constraints(variable_state * big_m_lower <= variable, name=f'{name}|lb1')
626544

627545
return [scaling_lower, scaling_upper, binary_lower, binary_upper]
546+
547+
@staticmethod
548+
def state_transition_bounds(
549+
model: Submodel,
550+
state_variable: linopy.Variable,
551+
switch_on: linopy.Variable,
552+
switch_off: linopy.Variable,
553+
name: str,
554+
previous_state=0,
555+
coord: str = 'time',
556+
) -> Tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint]:
557+
"""
558+
Creates switch-on/off variables with state transition logic.
559+
560+
Mathematical formulation:
561+
switch_on[t] - switch_off[t] = state[t] - state[t-1] ∀t > 0
562+
switch_on[0] - switch_off[0] = state[0] - previous_state
563+
switch_on[t] + switch_off[t] ≤ 1 ∀t
564+
switch_on[t], switch_off[t] ∈ {0, 1}
565+
566+
Returns:
567+
variables: {'switch_on': binary_var, 'switch_off': binary_var}
568+
constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint}
569+
"""
570+
if not isinstance(model, Submodel):
571+
raise ValueError('ModelingPrimitives.state_transition_variables() can only be used with a Submodel')
572+
573+
# State transition constraints for t > 0
574+
transition = model.add_constraints(
575+
switch_on.isel({coord: slice(1, None)}) - switch_off.isel({coord: slice(1, None)})
576+
== state_variable.isel({coord: slice(1, None)}) - state_variable.isel({coord: slice(None, -1)}),
577+
name=f'{name}|transition',
578+
)
579+
580+
# Initial state transition for t = 0
581+
initial = model.add_constraints(
582+
switch_on.isel({coord: 0}) - switch_off.isel({coord: 0})
583+
== state_variable.isel({coord: 0}) - previous_state,
584+
name=f'{name}|initial',
585+
)
586+
587+
# At most one switch per timestep
588+
mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|mutex')
589+
590+
return transition, initial, mutex

flixopt/results.py

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def __init__(
200200
self._flow_rates = None
201201
self._flow_hours = None
202202
self._sizes = None
203-
self._effects_per_component = {'operation': None, 'invest': None, 'total': None}
203+
self._effects_per_component = None
204204

205205
def __getitem__(self, key: str) -> Union['ComponentResults', 'BusResults', 'EffectResults', 'FlowResults']:
206206
if key in self.components:
@@ -312,20 +312,24 @@ def filter_solution(
312312
startswith=startswith,
313313
)
314314

315-
def effects_per_component(self, mode: Literal['operation', 'invest', 'total'] = 'total') -> xr.Dataset:
316-
"""Returns a dataset containing effect totals for each components (including their flows).
317-
318-
Args:
319-
mode: Which effects to contain. (operation, invest, total)
315+
@property
316+
def effects_per_component(self) -> xr.Dataset:
317+
"""Returns a dataset containing effect results for each mode, aggregated by Component
320318
321319
Returns:
322320
An xarray Dataset with an additional component dimension and effects as variables.
323321
"""
324-
if mode not in ['operation', 'invest', 'total']:
325-
raise ValueError(f'Invalid mode {mode}')
326-
if self._effects_per_component[mode] is None:
327-
self._effects_per_component[mode] = self._create_effects_dataset(mode)
328-
return self._effects_per_component[mode]
322+
if self._effects_per_component is None:
323+
self._effects_per_component = xr.Dataset(
324+
{
325+
mode: self._create_effects_dataset(mode).to_dataarray('effect', name=mode)
326+
for mode in ['operation', 'invest', 'total']
327+
}
328+
)
329+
dim_order = ['time', 'year', 'scenario', 'component', 'effect']
330+
self._effects_per_component = self._effects_per_component.transpose(*dim_order, missing_dims='ignore')
331+
332+
return self._effects_per_component
329333

330334
def flow_rates(
331335
self,
@@ -580,7 +584,7 @@ def _compute_effect_total(
580584
total = xr.DataArray(np.nan)
581585
return total.rename(f'{element}->{effect}({mode})')
582586

583-
def _create_effects_dataset(self, mode: Literal['operation', 'invest', 'total'] = 'total') -> xr.Dataset:
587+
def _create_effects_dataset(self, mode: Literal['operation', 'invest', 'total']) -> xr.Dataset:
584588
"""Creates a dataset containing effect totals for all components (including their flows).
585589
The dataset does contain the direct as well as the indirect effects of each component.
586590
@@ -590,24 +594,44 @@ def _create_effects_dataset(self, mode: Literal['operation', 'invest', 'total']
590594
Returns:
591595
An xarray Dataset with components as dimension and effects as variables.
592596
"""
593-
# Create an empty dataset
594597
ds = xr.Dataset()
598+
all_arrays = {}
599+
template = None # Template is needed to determine the dimensions of the arrays. This handles the case of no shares for an effect
600+
601+
components_list = list(self.components)
595602

596-
# Add each effect as a variable to the dataset
603+
# First pass: collect arrays and find template
597604
for effect in self.effects:
598-
# Create a list of DataArrays, one for each component
599-
component_arrays = [
600-
self._compute_effect_total(element=component, effect=effect, mode=mode, include_flows=True).expand_dims(
601-
component=[component]
602-
) # Add component dimension to each array
603-
for component in list(self.components)
604-
]
605+
effect_arrays = []
606+
for component in components_list:
607+
da = self._compute_effect_total(element=component, effect=effect, mode=mode, include_flows=True)
608+
effect_arrays.append(da)
609+
610+
if template is None and (da.dims or not da.isnull().all()):
611+
template = da
612+
613+
all_arrays[effect] = effect_arrays
614+
615+
# Ensure we have a template
616+
if template is None:
617+
raise ValueError(
618+
f"No template with proper dimensions found for mode '{mode}'. "
619+
f'All computed arrays are scalars, which indicates a data issue.'
620+
)
621+
622+
# Second pass: process all effects (guaranteed to include all)
623+
for effect in self.effects:
624+
dataarrays = all_arrays[effect]
625+
component_arrays = []
626+
627+
for component, arr in zip(components_list, dataarrays, strict=False):
628+
# Expand scalar NaN arrays to match template dimensions
629+
if not arr.dims and np.isnan(arr.item()):
630+
arr = xr.full_like(template, np.nan, dtype=float).rename(arr.name)
631+
632+
component_arrays.append(arr.expand_dims(component=[component]))
605633

606-
# Combine all components into one DataArray for this effect
607-
if component_arrays:
608-
effect_array = xr.concat(component_arrays, dim='component', coords='minimal')
609-
# Add this effect as a variable to the dataset
610-
ds[effect] = effect_array
634+
ds[effect] = xr.concat(component_arrays, dim='component', coords='minimal')
611635

612636
# For now include a test to ensure correctness
613637
suffix = {

0 commit comments

Comments
 (0)