Skip to content

Commit 3945200

Browse files
authored
Feature/simplify structure (#464)
* refactoring to tighten the coupling between FlowSystem and Elements * implemented the helper methods in the Interface base class * made calling .do_modeling() optional * eliminated all circular dependencies in the Submodel architecture by implementing a two-phase modeling pattern: Phase 1 - Variable Creation (_create_variables()): - Called during __init__ - Creates all variables and submodels - No constraints allowed (to avoid circular dependencies) Phase 2 - Constraint Creation (_create_constraints()): - Called from FlowSystemModel.do_modeling() after all models exist - Safely accesses variables from child/sibling models - Creates all constraints and relationships * Added explicit calls to _create_constraints() for nested submodel * Fix * Revert changes * Revert changes * Revert changes * Revert changes * Revert changes * Add more validation to add_elements() * Fixed inconsistent argument passing for _fit_effect_coords * Refactored _set_flow_system to be a method on each Interface subclass * Add missing _set_flow_system * Add missing type hints * Change timing of validate_system_integrity() and improve cache invalidation * 1. calculation.py - Modeling State Consistency Updated the modeled property to use the _modeled flag instead of checking self.model is not None, ensuring consistent state tracking throughout the Calculation class. 2. features.py - PieceModel Type Hints Improved the dims parameter type from FlowSystemDimensions | None to Collection[FlowSystemDimensions] | None in both PieceModel and PiecewiseModel to accurately reflect actual usage with tuples like ('period', 'scenario'). 3. features.py - ShareAllocationModel Error Message Updated the validation error message from "Both max_per_hour and min_per_hour cannot be used..." to "max_per_hour and min_per_hour require 'time' dimension in dims" to match the actual condition being checked. 4. features.py - PiecewiseModel Zero-Point Documentation Added clarifying comments explaining that the zero_point binary variable acts as a gate: when enabled (=1), at most one segment is active; when disabled (=0), all segments remain inactive. 5. effects.py - Penalty Temporal Coupling Added a comment in EffectCollectionModel._do_modeling noting that penalty shares are added after the objective is set, explaining the temporal coupling in the design. 6. structure.py - Interface Documentation Updated the class-level docstring to reflect the correct transform_data(name_prefix='') signature instead of the outdated transform_data(flow_system). 7. components.py - Model Class Docstrings Replaced generic docstrings with specific descriptions: - TransmissionModel: "Create transmission efficiency equations and optional absolute loss constraints for both flow directions" - LinearConverterModel: "Create linear conversion equations or piecewise conversion constraints between input and output flows" - StorageModel: "Create charge state variables, energy balance equations, and optional investment submodels" All changes improve code clarity, consistency, and maintainability without altering functionality. * Update CHANGELOG.md * Update CHANGELOG.md
1 parent 2033d52 commit 3945200

9 files changed

Lines changed: 416 additions & 160 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -51,31 +51,35 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp
5151
5252
## [Unreleased] - ????-??-??
5353
54-
**Summary**:
54+
**Summary**: Internal architecture improvements to simplify FlowSystem-Element coupling and eliminate circular dependencies.
5555
5656
If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOpt/flixOpt/releases/tag/v3.0.0) and [Migration Guide](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v3/).
5757
5858
### ✨ Added
59-
60-
### 💥 Breaking Changes
59+
- **Auto-modeling**: `Calculation.solve()` now automatically calls `do_modeling()` if not already done, making the explicit `do_modeling()` call optional for simpler workflows
60+
- **System validation**: Added `_validate_system_integrity()` to validate cross-element references (e.g., Flow.bus) immediately after transformation, providing clearer error messages
61+
- **Element registration validation**: Added checks to prevent elements from being assigned to multiple FlowSystems simultaneously
62+
- **Helper methods in Interface base class**: Added `_fit_coords()` and `_fit_effect_coords()` convenience wrappers for cleaner data transformation code
63+
- **FlowSystem property in Interface**: Added `flow_system` property to access the linked FlowSystem with clear error messages if not yet linked
6164
6265
### ♻️ Changed
63-
64-
### 🗑️ Deprecated
65-
66-
### 🔥 Removed
66+
- **Refactored FlowSystem-Element coupling**:
67+
- Introduced `_set_flow_system()` method in Interface base class to propagate FlowSystem reference to nested Interface objects
68+
- Each Interface subclass now explicitly propagates the reference to its nested interfaces (e.g., Component → OnOffParameters, Flow → InvestParameters)
69+
- Elements can now access FlowSystem via `self.flow_system` property instead of passing it through every method call
70+
- **Simplified transform_data() signature**: Removed `flow_system` parameter from `transform_data()` methods - FlowSystem reference is now accessed via `self.flow_system` property
71+
- **Two-phase modeling pattern within _do_modeling()**: Clarified the pattern where `_do_modeling()` creates nested submodels first (so their variables exist), then creates constraints that reference those variables - eliminates circular dependencies in Submodel architecture
72+
- **Improved cache invalidation**: Cache invalidation in `add_elements()` now happens once after all additions rather than per element
73+
- **Better logging**: Centralized element registration logging to show element type and full label
6774
6875
### 🐛 Fixed
69-
70-
### 🔒 Security
71-
72-
### 📦 Dependencies
73-
74-
### 📝 Docs
76+
- Fixed inconsistent argument passing in `_fit_effect_coords()` - standardized all calls to use named arguments (`prefix=`, `effect_values=`, `suffix=`) instead of mix of positional and named arguments
7577
7678
### 👷 Development
77-
78-
### 🚧 Known Issues
79+
- **Eliminated circular dependencies**: Implemented two-phase modeling pattern within `_do_modeling()` where nested submodels are created first (creating their variables), then constraints are created that can safely reference those submodel variables
80+
- Added comprehensive docstrings to `_do_modeling()` methods explaining the pattern: "Create variables, constraints, and nested submodels"
81+
- Added missing type hints throughout the codebase
82+
- Improved code organization by making FlowSystem reference propagation explicit and traceable
7983
8084
---
8185

flixopt/calculation.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,6 @@ def __init__(
9898
raise NotADirectoryError(f'Path {self.folder} exists and is not a directory.')
9999
self.folder.mkdir(parents=False, exist_ok=True)
100100

101-
self._modeled = False
102-
103101
@property
104102
def main_results(self) -> dict[str, int | float | dict]:
105103
from flixopt.features import InvestmentModel
@@ -228,6 +226,11 @@ def fix_sizes(self, ds: xr.Dataset, decimal_rounding: int | None = 5) -> FullCal
228226
def solve(
229227
self, solver: _Solver, log_file: pathlib.Path | None = None, log_main_results: bool | None = None
230228
) -> FullCalculation:
229+
# Auto-call do_modeling() if not already done
230+
if not self.modeled:
231+
logger.info('Model not yet created. Calling do_modeling() automatically.')
232+
self.do_modeling()
233+
231234
t_start = timeit.default_timer()
232235

233236
self.model.solve(

flixopt/components.py

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ def create_model(self, model: FlowSystemModel) -> LinearConverterModel:
181181
self.submodel = LinearConverterModel(model, self)
182182
return self.submodel
183183

184+
def _set_flow_system(self, flow_system) -> None:
185+
"""Propagate flow_system reference to parent Component and piecewise_conversion."""
186+
super()._set_flow_system(flow_system)
187+
if self.piecewise_conversion is not None:
188+
self.piecewise_conversion._set_flow_system(flow_system)
189+
184190
def _plausibility_checks(self) -> None:
185191
super()._plausibility_checks()
186192
if not self.conversion_factors and not self.piecewise_conversion:
@@ -211,23 +217,23 @@ def _plausibility_checks(self) -> None:
211217
f'({flow.label_full}).'
212218
)
213219

214-
def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
220+
def transform_data(self, name_prefix: str = '') -> None:
215221
prefix = '|'.join(filter(None, [name_prefix, self.label_full]))
216-
super().transform_data(flow_system, prefix)
222+
super().transform_data(prefix)
217223
if self.conversion_factors:
218-
self.conversion_factors = self._transform_conversion_factors(flow_system)
224+
self.conversion_factors = self._transform_conversion_factors()
219225
if self.piecewise_conversion:
220226
self.piecewise_conversion.has_time_dim = True
221-
self.piecewise_conversion.transform_data(flow_system, f'{prefix}|PiecewiseConversion')
227+
self.piecewise_conversion.transform_data(f'{prefix}|PiecewiseConversion')
222228

223-
def _transform_conversion_factors(self, flow_system: FlowSystem) -> list[dict[str, xr.DataArray]]:
229+
def _transform_conversion_factors(self) -> list[dict[str, xr.DataArray]]:
224230
"""Converts all conversion factors to internal datatypes"""
225231
list_of_conversion_factors = []
226232
for idx, conversion_factor in enumerate(self.conversion_factors):
227233
transformed_dict = {}
228234
for flow, values in conversion_factor.items():
229235
# TODO: Might be better to use the label of the component instead of the flow
230-
ts = flow_system.fit_to_model_coords(f'{self.flows[flow].label_full}|conversion_factor{idx}', values)
236+
ts = self._fit_coords(f'{self.flows[flow].label_full}|conversion_factor{idx}', values)
231237
if ts is None:
232238
raise PlausibilityError(f'{self.label_full}: conversion factor for flow "{flow}" must not be None')
233239
transformed_dict[flow] = ts
@@ -433,46 +439,48 @@ def create_model(self, model: FlowSystemModel) -> StorageModel:
433439
self.submodel = StorageModel(model, self)
434440
return self.submodel
435441

436-
def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
442+
def _set_flow_system(self, flow_system) -> None:
443+
"""Propagate flow_system reference to parent Component and capacity_in_flow_hours if it's InvestParameters."""
444+
super()._set_flow_system(flow_system)
445+
if isinstance(self.capacity_in_flow_hours, InvestParameters):
446+
self.capacity_in_flow_hours._set_flow_system(flow_system)
447+
448+
def transform_data(self, name_prefix: str = '') -> None:
437449
prefix = '|'.join(filter(None, [name_prefix, self.label_full]))
438-
super().transform_data(flow_system, prefix)
439-
self.relative_minimum_charge_state = flow_system.fit_to_model_coords(
440-
f'{prefix}|relative_minimum_charge_state',
441-
self.relative_minimum_charge_state,
450+
super().transform_data(prefix)
451+
self.relative_minimum_charge_state = self._fit_coords(
452+
f'{prefix}|relative_minimum_charge_state', self.relative_minimum_charge_state
442453
)
443-
self.relative_maximum_charge_state = flow_system.fit_to_model_coords(
444-
f'{prefix}|relative_maximum_charge_state',
445-
self.relative_maximum_charge_state,
446-
)
447-
self.eta_charge = flow_system.fit_to_model_coords(f'{prefix}|eta_charge', self.eta_charge)
448-
self.eta_discharge = flow_system.fit_to_model_coords(f'{prefix}|eta_discharge', self.eta_discharge)
449-
self.relative_loss_per_hour = flow_system.fit_to_model_coords(
450-
f'{prefix}|relative_loss_per_hour', self.relative_loss_per_hour
454+
self.relative_maximum_charge_state = self._fit_coords(
455+
f'{prefix}|relative_maximum_charge_state', self.relative_maximum_charge_state
451456
)
457+
self.eta_charge = self._fit_coords(f'{prefix}|eta_charge', self.eta_charge)
458+
self.eta_discharge = self._fit_coords(f'{prefix}|eta_discharge', self.eta_discharge)
459+
self.relative_loss_per_hour = self._fit_coords(f'{prefix}|relative_loss_per_hour', self.relative_loss_per_hour)
452460
if not isinstance(self.initial_charge_state, str):
453-
self.initial_charge_state = flow_system.fit_to_model_coords(
461+
self.initial_charge_state = self._fit_coords(
454462
f'{prefix}|initial_charge_state', self.initial_charge_state, dims=['period', 'scenario']
455463
)
456-
self.minimal_final_charge_state = flow_system.fit_to_model_coords(
464+
self.minimal_final_charge_state = self._fit_coords(
457465
f'{prefix}|minimal_final_charge_state', self.minimal_final_charge_state, dims=['period', 'scenario']
458466
)
459-
self.maximal_final_charge_state = flow_system.fit_to_model_coords(
467+
self.maximal_final_charge_state = self._fit_coords(
460468
f'{prefix}|maximal_final_charge_state', self.maximal_final_charge_state, dims=['period', 'scenario']
461469
)
462-
self.relative_minimum_final_charge_state = flow_system.fit_to_model_coords(
470+
self.relative_minimum_final_charge_state = self._fit_coords(
463471
f'{prefix}|relative_minimum_final_charge_state',
464472
self.relative_minimum_final_charge_state,
465473
dims=['period', 'scenario'],
466474
)
467-
self.relative_maximum_final_charge_state = flow_system.fit_to_model_coords(
475+
self.relative_maximum_final_charge_state = self._fit_coords(
468476
f'{prefix}|relative_maximum_final_charge_state',
469477
self.relative_maximum_final_charge_state,
470478
dims=['period', 'scenario'],
471479
)
472480
if isinstance(self.capacity_in_flow_hours, InvestParameters):
473-
self.capacity_in_flow_hours.transform_data(flow_system, f'{prefix}|InvestParameters')
481+
self.capacity_in_flow_hours.transform_data(f'{prefix}|InvestParameters')
474482
else:
475-
self.capacity_in_flow_hours = flow_system.fit_to_model_coords(
483+
self.capacity_in_flow_hours = self._fit_coords(
476484
f'{prefix}|capacity_in_flow_hours', self.capacity_in_flow_hours, dims=['period', 'scenario']
477485
)
478486

@@ -719,11 +727,11 @@ def create_model(self, model) -> TransmissionModel:
719727
self.submodel = TransmissionModel(model, self)
720728
return self.submodel
721729

722-
def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
730+
def transform_data(self, name_prefix: str = '') -> None:
723731
prefix = '|'.join(filter(None, [name_prefix, self.label_full]))
724-
super().transform_data(flow_system, prefix)
725-
self.relative_losses = flow_system.fit_to_model_coords(f'{prefix}|relative_losses', self.relative_losses)
726-
self.absolute_losses = flow_system.fit_to_model_coords(f'{prefix}|absolute_losses', self.absolute_losses)
732+
super().transform_data(prefix)
733+
self.relative_losses = self._fit_coords(f'{prefix}|relative_losses', self.relative_losses)
734+
self.absolute_losses = self._fit_coords(f'{prefix}|absolute_losses', self.absolute_losses)
727735

728736

729737
class TransmissionModel(ComponentModel):
@@ -738,7 +746,7 @@ def __init__(self, model: FlowSystemModel, element: Transmission):
738746
super().__init__(model, element)
739747

740748
def _do_modeling(self):
741-
"""Initiates all FlowModels"""
749+
"""Create transmission efficiency equations and optional absolute loss constraints for both flow directions"""
742750
super()._do_modeling()
743751

744752
# first direction
@@ -779,8 +787,10 @@ def __init__(self, model: FlowSystemModel, element: LinearConverter):
779787
super().__init__(model, element)
780788

781789
def _do_modeling(self):
790+
"""Create linear conversion equations or piecewise conversion constraints between input and output flows"""
782791
super()._do_modeling()
783-
# conversion_factors:
792+
793+
# Create conversion factor constraints if specified
784794
if self.element.conversion_factors:
785795
all_input_flows = set(self.element.inputs)
786796
all_output_flows = set(self.element.outputs)
@@ -826,8 +836,10 @@ def __init__(self, model: FlowSystemModel, element: Storage):
826836
super().__init__(model, element)
827837

828838
def _do_modeling(self):
839+
"""Create charge state variables, energy balance equations, and optional investment submodels"""
829840
super()._do_modeling()
830841

842+
# Create variables
831843
lb, ub = self._absolute_charge_state_bounds
832844
self.add_variables(
833845
lower=lb,
@@ -838,6 +850,7 @@ def _do_modeling(self):
838850

839851
self.add_variables(coords=self._model.get_coords(), short_name='netto_discharge')
840852

853+
# Create constraints (can now access flow.submodel.flow_rate)
841854
# netto_discharge:
842855
# eq: nettoFlow(t) - discharging(t) + charging(t) = 0
843856
self.add_constraints(
@@ -862,6 +875,7 @@ def _do_modeling(self):
862875
short_name='charge_state',
863876
)
864877

878+
# Create InvestmentModel and bounding constraints for investment
865879
if isinstance(self.element.capacity_in_flow_hours, InvestParameters):
866880
self.add_submodels(
867881
InvestmentModel(
@@ -883,6 +897,7 @@ def _do_modeling(self):
883897
# Initial charge state
884898
self._initial_and_final_charge_state()
885899

900+
# Balanced sizes
886901
if self.element.balanced:
887902
self.add_constraints(
888903
self.element.charging.submodel._investment.size * 1

flixopt/effects.py

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -339,43 +339,40 @@ def maximum_operation_per_hour(self, value):
339339
)
340340
self.maximum_per_hour = value
341341

342-
def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
342+
def transform_data(self, name_prefix: str = '') -> None:
343343
prefix = '|'.join(filter(None, [name_prefix, self.label_full]))
344-
self.minimum_per_hour = flow_system.fit_to_model_coords(f'{prefix}|minimum_per_hour', self.minimum_per_hour)
344+
self.minimum_per_hour = self._fit_coords(f'{prefix}|minimum_per_hour', self.minimum_per_hour)
345+
self.maximum_per_hour = self._fit_coords(f'{prefix}|maximum_per_hour', self.maximum_per_hour)
345346

346-
self.maximum_per_hour = flow_system.fit_to_model_coords(f'{prefix}|maximum_per_hour', self.maximum_per_hour)
347-
348-
self.share_from_temporal = flow_system.fit_effects_to_model_coords(
349-
label_prefix=None,
347+
self.share_from_temporal = self._fit_effect_coords(
348+
prefix=None,
350349
effect_values=self.share_from_temporal,
351-
label_suffix=f'(temporal)->{prefix}(temporal)',
350+
suffix=f'(temporal)->{prefix}(temporal)',
352351
dims=['time', 'period', 'scenario'],
353352
)
354-
self.share_from_periodic = flow_system.fit_effects_to_model_coords(
355-
label_prefix=None,
353+
self.share_from_periodic = self._fit_effect_coords(
354+
prefix=None,
356355
effect_values=self.share_from_periodic,
357-
label_suffix=f'(periodic)->{prefix}(periodic)',
356+
suffix=f'(periodic)->{prefix}(periodic)',
358357
dims=['period', 'scenario'],
359358
)
360359

361-
self.minimum_temporal = flow_system.fit_to_model_coords(
360+
self.minimum_temporal = self._fit_coords(
362361
f'{prefix}|minimum_temporal', self.minimum_temporal, dims=['period', 'scenario']
363362
)
364-
self.maximum_temporal = flow_system.fit_to_model_coords(
363+
self.maximum_temporal = self._fit_coords(
365364
f'{prefix}|maximum_temporal', self.maximum_temporal, dims=['period', 'scenario']
366365
)
367-
self.minimum_periodic = flow_system.fit_to_model_coords(
366+
self.minimum_periodic = self._fit_coords(
368367
f'{prefix}|minimum_periodic', self.minimum_periodic, dims=['period', 'scenario']
369368
)
370-
self.maximum_periodic = flow_system.fit_to_model_coords(
369+
self.maximum_periodic = self._fit_coords(
371370
f'{prefix}|maximum_periodic', self.maximum_periodic, dims=['period', 'scenario']
372371
)
373-
self.minimum_total = flow_system.fit_to_model_coords(
374-
f'{prefix}|minimum_total',
375-
self.minimum_total,
376-
dims=['period', 'scenario'],
372+
self.minimum_total = self._fit_coords(
373+
f'{prefix}|minimum_total', self.minimum_total, dims=['period', 'scenario']
377374
)
378-
self.maximum_total = flow_system.fit_to_model_coords(
375+
self.maximum_total = self._fit_coords(
379376
f'{prefix}|maximum_total', self.maximum_total, dims=['period', 'scenario']
380377
)
381378

@@ -396,6 +393,9 @@ def __init__(self, model: FlowSystemModel, element: Effect):
396393
super().__init__(model, element)
397394

398395
def _do_modeling(self):
396+
"""Create variables, constraints, and nested submodels"""
397+
super()._do_modeling()
398+
399399
self.total: linopy.Variable | None = None
400400
self.periodic: ShareAllocationModel = self.add_submodels(
401401
ShareAllocationModel(
@@ -661,16 +661,26 @@ def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) -
661661
self.penalty.add_share(name, expression, dims=())
662662

663663
def _do_modeling(self):
664+
"""Create variables, constraints, and nested submodels"""
664665
super()._do_modeling()
666+
667+
# Create EffectModel for each effect
665668
for effect in self.effects.values():
666669
effect.create_model(self._model)
670+
671+
# Create penalty allocation model
667672
self.penalty = self.add_submodels(
668673
ShareAllocationModel(self._model, dims=(), label_of_element='Penalty'),
669674
short_name='penalty',
670675
)
671676

677+
# Add cross-effect shares
672678
self._add_share_between_effects()
673679

680+
# Set objective
681+
# Note: penalty.total is used here, but penalty shares from buses/components
682+
# are added later via add_share_to_penalty(). The ShareAllocationModel supports
683+
# this pattern - shares can be added after the objective is defined.
674684
self._model.add_objective(
675685
(self.effects.objective_effect.submodel.total * self._model.weights).sum() + self.penalty.total.sum()
676686
)

0 commit comments

Comments
 (0)