Skip to content

Commit 0b4a395

Browse files
authored
Add FlowSystemStatus enum and restructure validation architecture (#598)
* Move validation to FlowsData mostly * Summary: Validation Split for All Interface Classes Classes Updated with validate_config() ┌──────────────────┬───────────────┬────────────────────────────────────────────────────────────────────────────┐ │ Class │ Location │ Config Checks │ ├──────────────────┼───────────────┼────────────────────────────────────────────────────────────────────────────┤ │ Component │ elements.py │ unique flow labels, status→flows.size │ ├──────────────────┼───────────────┼────────────────────────────────────────────────────────────────────────────┤ │ Bus │ elements.py │ no flows connected │ ├──────────────────┼───────────────┼────────────────────────────────────────────────────────────────────────────┤ │ Flow │ elements.py │ status→size, fixed_profile→size, load_factor→size, previous_flow_rate type │ ├──────────────────┼───────────────┼────────────────────────────────────────────────────────────────────────────┤ │ Effect │ effects.py │ period dimension for over_periods constraints │ ├──────────────────┼───────────────┼────────────────────────────────────────────────────────────────────────────┤ │ EffectCollection │ effects.py │ circular loops, unknown effect refs │ ├──────────────────┼───────────────┼────────────────────────────────────────────────────────────────────────────┤ │ LinearConverter │ components.py │ conversion_factors XOR piecewise, degrees_of_freedom, flow refs │ ├──────────────────┼───────────────┼────────────────────────────────────────────────────────────────────────────┤ │ Storage │ components.py │ initial_charge_state string, balanced→InvestParams, final_charge→capacity │ ├──────────────────┼───────────────┼────────────────────────────────────────────────────────────────────────────┤ │ Transmission │ components.py │ bus consistency, balanced→InvestParams │ └──────────────────┴───────────────┴────────────────────────────────────────────────────────────────────────────┘ *Data Classes with validate() ┌───────────────────┬────────────┬─────────────────────────────────────────────────────────────────────────┐ │ Class │ Location │ DataArray Checks │ ├───────────────────┼────────────┼─────────────────────────────────────────────────────────────────────────┤ │ FlowsData │ batched.py │ relative_min ≤ max, size required for bounds │ ├───────────────────┼────────────┼─────────────────────────────────────────────────────────────────────────┤ │ StoragesData │ batched.py │ capacity for relative bounds, initial vs capacity, balanced size compat │ ├───────────────────┼────────────┼─────────────────────────────────────────────────────────────────────────┤ │ BusesData │ batched.py │ imbalance_penalty == 0 warning │ ├───────────────────┼────────────┼─────────────────────────────────────────────────────────────────────────┤ │ TransmissionsData │ batched.py │ balanced size compatibility │ ├───────────────────┼────────────┼─────────────────────────────────────────────────────────────────────────┤ │ EffectsData │ batched.py │ delegates to validate_config() │ ├───────────────────┼────────────┼─────────────────────────────────────────────────────────────────────────┤ │ ComponentsData │ batched.py │ delegates to validate_config() │ ├───────────────────┼────────────┼─────────────────────────────────────────────────────────────────────────┤ │ ConvertersData │ batched.py │ delegates to validate_config() │ └───────────────────┴────────────┴─────────────────────────────────────────────────────────────────────────┘ Updated FlowSystem _run_plausibility_checks() now creates temporary *Data instances and calls validate() on each, which handles both config and DataArray checks in a centralized way. * Summary: Cached *Data in BatchedAccessor What Changed *1. BatchedAccessor now caches all Data classes: class BatchedAccessor: @Property def flows(self) -> FlowsData: ... @Property def storages(self) -> StoragesData: ... @Property def intercluster_storages(self) -> StoragesData: ... @Property def buses(self) -> BusesData: ... @Property def effects(self) -> EffectsData: ... @Property def components(self) -> ComponentsData: ... @Property def converters(self) -> ConvertersData: ... @Property def transmissions(self) -> TransmissionsData: ... 2. FlowSystemModel.build_model() now uses cached instances: batched = self.flow_system.batched self.effects = EffectsModel(self, batched.effects) # reuses cached self._flows_model = FlowsModel(self, batched.flows) # reuses cached # etc. 3. _run_plausibility_checks() simplified: batched = self.batched batched.flows.validate() batched.buses.validate() # etc. Benefits - No duplicate creation: Same *Data objects used for validation AND model building - Early validation: Errors caught during connect_and_transform() - Proper invalidation: _batched = None when status drops below CONNECTED - Cleaner code: No temporary object creation in validation or build_model * Temp * Improve organization of validation * Bug Found When a Bus has no flows connected, FlowsData.validate() crashed with a cryptic TypeError instead of raising a clear ValueError message. Root Cause In _run_validation(), the validation order was: 1. batched.flows.validate() ← crashed on empty DataArray operations 2. batched.buses.validate() ← would have caught the error * Add warning for no relative_minimum=0 with status * Improve validation by using helpers
1 parent fce9638 commit 0b4a395

9 files changed

Lines changed: 575 additions & 233 deletions

File tree

flixopt/batched.py

Lines changed: 366 additions & 2 deletions
Large diffs are not rendered by default.

flixopt/components.py

Lines changed: 52 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from .elements import Component, Flow
1818
from .features import MaskHelpers, stack_along_dim
1919
from .interface import InvestParameters, PiecewiseConversion, StatusParameters
20-
from .modeling import _scalar_safe_isel, _scalar_safe_reduce
20+
from .modeling import _scalar_safe_reduce
2121
from .structure import (
2222
FlowSystemModel,
2323
FlowVarName,
@@ -190,8 +190,13 @@ def link_to_flow_system(self, flow_system, prefix: str = '') -> None:
190190
if self.piecewise_conversion is not None:
191191
self.piecewise_conversion.link_to_flow_system(flow_system, self._sub_prefix('PiecewiseConversion'))
192192

193-
def _plausibility_checks(self) -> None:
194-
super()._plausibility_checks()
193+
def validate_config(self) -> None:
194+
"""Validate configuration consistency.
195+
196+
Called BEFORE transformation via FlowSystem._run_config_validation().
197+
These are simple checks that don't require DataArray operations.
198+
"""
199+
super().validate_config()
195200
if not self.conversion_factors and not self.piecewise_conversion:
196201
raise PlausibilityError('Either conversion_factors or piecewise_conversion must be defined!')
197202
if self.conversion_factors and self.piecewise_conversion:
@@ -220,6 +225,10 @@ def _plausibility_checks(self) -> None:
220225
f'({flow.label_full}).'
221226
)
222227

228+
def _plausibility_checks(self) -> None:
229+
"""Legacy validation method - delegates to validate_config()."""
230+
self.validate_config()
231+
223232
def transform_data(self) -> None:
224233
super().transform_data()
225234
if self.conversion_factors:
@@ -495,31 +504,21 @@ def transform_data(self) -> None:
495504
f'{self.prefix}|capacity_in_flow_hours', self.capacity_in_flow_hours, dims=['period', 'scenario']
496505
)
497506

498-
def _plausibility_checks(self) -> None:
499-
"""
500-
Check for infeasible or uncommon combinations of parameters
507+
def validate_config(self) -> None:
508+
"""Validate configuration consistency.
509+
510+
Called BEFORE transformation via FlowSystem._run_config_validation().
511+
These are simple checks that don't require DataArray operations.
501512
"""
502-
super()._plausibility_checks()
513+
super().validate_config()
503514

504-
# Validate string values and set flag
505-
initial_equals_final = False
515+
# Validate string values for initial_charge_state
506516
if isinstance(self.initial_charge_state, str):
507-
if not self.initial_charge_state == 'equals_final':
517+
if self.initial_charge_state != 'equals_final':
508518
raise PlausibilityError(f'initial_charge_state has undefined value: {self.initial_charge_state}')
509-
initial_equals_final = True
510519

511-
# Capacity is required when using non-default relative bounds
520+
# Capacity is required for final charge state constraints (simple None checks)
512521
if self.capacity_in_flow_hours is None:
513-
if np.any(self.relative_minimum_charge_state > 0):
514-
raise PlausibilityError(
515-
f'Storage "{self.label_full}" has relative_minimum_charge_state > 0 but no capacity_in_flow_hours. '
516-
f'A capacity is required because the lower bound is capacity * relative_minimum_charge_state.'
517-
)
518-
if np.any(self.relative_maximum_charge_state < 1):
519-
raise PlausibilityError(
520-
f'Storage "{self.label_full}" has relative_maximum_charge_state < 1 but no capacity_in_flow_hours. '
521-
f'A capacity is required because the upper bound is capacity * relative_maximum_charge_state.'
522-
)
523522
if self.relative_minimum_final_charge_state is not None:
524523
raise PlausibilityError(
525524
f'Storage "{self.label_full}" has relative_minimum_final_charge_state but no capacity_in_flow_hours. '
@@ -531,39 +530,7 @@ def _plausibility_checks(self) -> None:
531530
f'A capacity is required for relative final charge state constraints.'
532531
)
533532

534-
# Skip capacity-related checks if capacity is None (unbounded)
535-
if self.capacity_in_flow_hours is not None:
536-
# Use new InvestParameters methods to get capacity bounds
537-
if isinstance(self.capacity_in_flow_hours, InvestParameters):
538-
minimum_capacity = self.capacity_in_flow_hours.minimum_or_fixed_size
539-
maximum_capacity = self.capacity_in_flow_hours.maximum_or_fixed_size
540-
else:
541-
maximum_capacity = self.capacity_in_flow_hours
542-
minimum_capacity = self.capacity_in_flow_hours
543-
544-
# Initial charge state should not constrain investment decision
545-
# If initial > (min_cap * rel_max), investment is forced to increase capacity
546-
# If initial < (max_cap * rel_min), investment is forced to decrease capacity
547-
min_initial_at_max_capacity = maximum_capacity * _scalar_safe_isel(
548-
self.relative_minimum_charge_state, {'time': 0}
549-
)
550-
max_initial_at_min_capacity = minimum_capacity * _scalar_safe_isel(
551-
self.relative_maximum_charge_state, {'time': 0}
552-
)
553-
554-
# Only perform numeric comparisons if using a numeric initial_charge_state
555-
if not initial_equals_final and self.initial_charge_state is not None:
556-
if (self.initial_charge_state > max_initial_at_min_capacity).any():
557-
raise PlausibilityError(
558-
f'{self.label_full}: {self.initial_charge_state=} '
559-
f'is constraining the investment decision. Choose a value <= {max_initial_at_min_capacity}.'
560-
)
561-
if (self.initial_charge_state < min_initial_at_max_capacity).any():
562-
raise PlausibilityError(
563-
f'{self.label_full}: {self.initial_charge_state=} '
564-
f'is constraining the investment decision. Choose a value >= {min_initial_at_max_capacity}.'
565-
)
566-
533+
# Balanced requires InvestParameters on charging/discharging flows
567534
if self.balanced:
568535
if not isinstance(self.charging.size, InvestParameters) or not isinstance(
569536
self.discharging.size, InvestParameters
@@ -572,14 +539,12 @@ def _plausibility_checks(self) -> None:
572539
f'Balancing charging and discharging Flows in {self.label_full} is only possible with Investments.'
573540
)
574541

575-
if (self.charging.size.minimum_or_fixed_size > self.discharging.size.maximum_or_fixed_size).any() or (
576-
self.charging.size.maximum_or_fixed_size < self.discharging.size.minimum_or_fixed_size
577-
).any():
578-
raise PlausibilityError(
579-
f'Balancing charging and discharging Flows in {self.label_full} need compatible minimum and maximum sizes.'
580-
f'Got: {self.charging.size.minimum_or_fixed_size=}, {self.charging.size.maximum_or_fixed_size=} and '
581-
f'{self.discharging.size.minimum_or_fixed_size=}, {self.discharging.size.maximum_or_fixed_size=}.'
582-
)
542+
def _plausibility_checks(self) -> None:
543+
"""Legacy validation method - delegates to validate_config().
544+
545+
DataArray-based checks moved to StoragesData.validate().
546+
"""
547+
self.validate_config()
583548

584549
def __repr__(self) -> str:
585550
"""Return string representation."""
@@ -737,31 +702,38 @@ def __init__(
737702
self.absolute_losses = absolute_losses
738703
self.balanced = balanced
739704

740-
def _plausibility_checks(self):
741-
super()._plausibility_checks()
742-
# check buses:
705+
def validate_config(self) -> None:
706+
"""Validate configuration consistency.
707+
708+
Called BEFORE transformation via FlowSystem._run_config_validation().
709+
These are simple checks that don't require DataArray operations.
710+
"""
711+
super().validate_config()
712+
# Check buses consistency
743713
if self.in2 is not None:
744-
assert self.in2.bus == self.out1.bus, (
745-
f'Output 1 and Input 2 do not start/end at the same Bus: {self.out1.bus=}, {self.in2.bus=}'
746-
)
714+
if self.in2.bus != self.out1.bus:
715+
raise ValueError(
716+
f'Output 1 and Input 2 do not start/end at the same Bus: {self.out1.bus=}, {self.in2.bus=}'
717+
)
747718
if self.out2 is not None:
748-
assert self.out2.bus == self.in1.bus, (
749-
f'Input 1 and Output 2 do not start/end at the same Bus: {self.in1.bus=}, {self.out2.bus=}'
750-
)
719+
if self.out2.bus != self.in1.bus:
720+
raise ValueError(
721+
f'Input 1 and Output 2 do not start/end at the same Bus: {self.in1.bus=}, {self.out2.bus=}'
722+
)
751723

724+
# Balanced requires InvestParameters on both in-Flows
752725
if self.balanced:
753726
if self.in2 is None:
754727
raise ValueError('Balanced Transmission needs InvestParameters in both in-Flows')
755728
if not isinstance(self.in1.size, InvestParameters) or not isinstance(self.in2.size, InvestParameters):
756729
raise ValueError('Balanced Transmission needs InvestParameters in both in-Flows')
757-
if (self.in1.size.minimum_or_fixed_size > self.in2.size.maximum_or_fixed_size).any() or (
758-
self.in1.size.maximum_or_fixed_size < self.in2.size.minimum_or_fixed_size
759-
).any():
760-
raise ValueError(
761-
f'Balanced Transmission needs compatible minimum and maximum sizes.'
762-
f'Got: {self.in1.size.minimum_or_fixed_size=}, {self.in1.size.maximum_or_fixed_size=} and '
763-
f'{self.in2.size.minimum_or_fixed_size=}, {self.in2.size.maximum_or_fixed_size=}.'
764-
)
730+
731+
def _plausibility_checks(self) -> None:
732+
"""Legacy validation method - delegates to validate_config().
733+
734+
DataArray-based checks moved to TransmissionsData.validate().
735+
"""
736+
self.validate_config()
765737

766738
def _propagate_status_parameters(self) -> None:
767739
super()._propagate_status_parameters()

flixopt/effects.py

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,12 @@ def transform_data(self) -> None:
290290
f'{self.prefix}|period_weights', self.period_weights, dims=['period', 'scenario']
291291
)
292292

293-
def _plausibility_checks(self) -> None:
293+
def validate_config(self) -> None:
294+
"""Validate configuration consistency.
295+
296+
Called BEFORE transformation via FlowSystem._run_config_validation().
297+
These are simple checks that don't require DataArray operations.
298+
"""
294299
# Check that minimum_over_periods and maximum_over_periods require a period dimension
295300
if (
296301
self.minimum_over_periods is not None or self.maximum_over_periods is not None
@@ -301,6 +306,10 @@ def _plausibility_checks(self) -> None:
301306
f'the FlowSystem, or remove these constraints.'
302307
)
303308

309+
def _plausibility_checks(self) -> None:
310+
"""Legacy validation method - delegates to validate_config()."""
311+
self.validate_config()
312+
304313

305314
class EffectsModel:
306315
"""Type-level model for ALL effects with batched variables using 'effect' dimension.
@@ -813,28 +822,21 @@ def get_effect_label(eff: str | None) -> str:
813822
return {get_effect_label(effect): value for effect, value in effect_values_user.items()}
814823
return {self.standard_effect.label: effect_values_user}
815824

825+
def validate_config(self) -> None:
826+
"""Deprecated: Validation is now handled by EffectsData.validate().
827+
828+
This method is kept for backwards compatibility but does nothing.
829+
Collection-level validation (cycles, unknown refs) is now in EffectsData._validate_share_structure().
830+
"""
831+
pass
832+
816833
def _plausibility_checks(self) -> None:
817-
# Check circular loops in effects:
818-
temporal, periodic = self.calculate_effect_share_factors()
819-
820-
# Validate all referenced effects (both sources and targets) exist
821-
edges = list(temporal.keys()) + list(periodic.keys())
822-
unknown_sources = {src for src, _ in edges if src not in self}
823-
unknown_targets = {tgt for _, tgt in edges if tgt not in self}
824-
unknown = unknown_sources | unknown_targets
825-
if unknown:
826-
raise KeyError(f'Unknown effects used in effect share mappings: {sorted(unknown)}')
827-
828-
temporal_cycles = detect_cycles(tuples_to_adjacency_list([key for key in temporal]))
829-
periodic_cycles = detect_cycles(tuples_to_adjacency_list([key for key in periodic]))
830-
831-
if temporal_cycles:
832-
cycle_str = '\n'.join([' -> '.join(cycle) for cycle in temporal_cycles])
833-
raise ValueError(f'Error: circular temporal-shares detected:\n{cycle_str}')
834-
835-
if periodic_cycles:
836-
cycle_str = '\n'.join([' -> '.join(cycle) for cycle in periodic_cycles])
837-
raise ValueError(f'Error: circular periodic-shares detected:\n{cycle_str}')
834+
"""Deprecated: Legacy validation method.
835+
836+
Kept for backwards compatibility but does nothing.
837+
Validation is now handled by EffectsData.validate().
838+
"""
839+
pass
838840

839841
def __getitem__(self, effect: str | Effect | None) -> Effect:
840842
"""

0 commit comments

Comments
 (0)