flixopt 3.0.1__py3-none-any.whl → 6.0.0rc7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. flixopt/__init__.py +57 -49
  2. flixopt/carrier.py +159 -0
  3. flixopt/clustering/__init__.py +51 -0
  4. flixopt/clustering/base.py +1746 -0
  5. flixopt/clustering/intercluster_helpers.py +201 -0
  6. flixopt/color_processing.py +372 -0
  7. flixopt/comparison.py +819 -0
  8. flixopt/components.py +848 -270
  9. flixopt/config.py +853 -496
  10. flixopt/core.py +111 -98
  11. flixopt/effects.py +294 -284
  12. flixopt/elements.py +484 -223
  13. flixopt/features.py +220 -118
  14. flixopt/flow_system.py +2026 -389
  15. flixopt/interface.py +504 -286
  16. flixopt/io.py +1718 -55
  17. flixopt/linear_converters.py +291 -230
  18. flixopt/modeling.py +304 -181
  19. flixopt/network_app.py +2 -1
  20. flixopt/optimization.py +788 -0
  21. flixopt/optimize_accessor.py +373 -0
  22. flixopt/plot_result.py +143 -0
  23. flixopt/plotting.py +1177 -1034
  24. flixopt/results.py +1331 -372
  25. flixopt/solvers.py +12 -4
  26. flixopt/statistics_accessor.py +2412 -0
  27. flixopt/stats_accessor.py +75 -0
  28. flixopt/structure.py +954 -120
  29. flixopt/topology_accessor.py +676 -0
  30. flixopt/transform_accessor.py +2277 -0
  31. flixopt/types.py +120 -0
  32. flixopt-6.0.0rc7.dist-info/METADATA +290 -0
  33. flixopt-6.0.0rc7.dist-info/RECORD +36 -0
  34. {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/WHEEL +1 -1
  35. flixopt/aggregation.py +0 -382
  36. flixopt/calculation.py +0 -672
  37. flixopt/commons.py +0 -51
  38. flixopt/utils.py +0 -86
  39. flixopt-3.0.1.dist-info/METADATA +0 -209
  40. flixopt-3.0.1.dist-info/RECORD +0 -26
  41. {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/licenses/LICENSE +0 -0
  42. {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/top_level.txt +0 -0
flixopt/structure.py CHANGED
@@ -8,34 +8,140 @@ from __future__ import annotations
8
8
  import inspect
9
9
  import json
10
10
  import logging
11
+ import pathlib
12
+ import re
13
+ import warnings
11
14
  from dataclasses import dataclass
12
- from io import StringIO
15
+ from difflib import get_close_matches
16
+ from enum import Enum
13
17
  from typing import (
14
18
  TYPE_CHECKING,
15
19
  Any,
20
+ ClassVar,
21
+ Generic,
16
22
  Literal,
23
+ TypeVar,
17
24
  )
18
25
 
19
26
  import linopy
20
27
  import numpy as np
21
28
  import pandas as pd
22
29
  import xarray as xr
23
- from rich.console import Console
24
- from rich.pretty import Pretty
25
30
 
26
31
  from . import io as fx_io
27
- from .core import TimeSeriesData, get_dataarray_stats
32
+ from .config import DEPRECATION_REMOVAL_VERSION
33
+ from .core import FlowSystemDimensions, TimeSeriesData, get_dataarray_stats
28
34
 
29
35
  if TYPE_CHECKING: # for type checking and preventing circular imports
30
- import pathlib
31
36
  from collections.abc import Collection, ItemsView, Iterator
32
37
 
33
38
  from .effects import EffectCollectionModel
34
39
  from .flow_system import FlowSystem
40
+ from .types import Effect_TPS, Numeric_TPS, NumericOrBool
35
41
 
36
42
  logger = logging.getLogger('flixopt')
37
43
 
38
44
 
45
+ def _ensure_coords(
46
+ data: xr.DataArray | float | int,
47
+ coords: xr.Coordinates | dict,
48
+ ) -> xr.DataArray | float:
49
+ """Broadcast data to coords if needed.
50
+
51
+ This is used at the linopy interface to ensure bounds are properly broadcasted
52
+ to the target variable shape. Linopy needs at least one bound to have all
53
+ dimensions to determine the variable shape.
54
+
55
+ Note: Infinity values (-inf, inf) are kept as scalars because linopy uses
56
+ special checks like `if (lower != -inf)` that fail with DataArrays.
57
+ """
58
+ # Handle both dict and xr.Coordinates
59
+ if isinstance(coords, dict):
60
+ coord_dims = list(coords.keys())
61
+ else:
62
+ coord_dims = list(coords.dims)
63
+
64
+ # Keep infinity values as scalars (linopy uses them for special checks)
65
+ if not isinstance(data, xr.DataArray):
66
+ if np.isinf(data):
67
+ return data
68
+ # Finite scalar - create full DataArray
69
+ return xr.DataArray(data, coords=coords, dims=coord_dims)
70
+
71
+ if set(data.dims) == set(coord_dims):
72
+ # Has all dims - ensure correct order
73
+ if data.dims != tuple(coord_dims):
74
+ return data.transpose(*coord_dims)
75
+ return data
76
+
77
+ # Broadcast to full coords (broadcast_like ensures correct dim order)
78
+ template = xr.DataArray(coords=coords, dims=coord_dims)
79
+ return data.broadcast_like(template)
80
+
81
+
82
+ class VariableCategory(Enum):
83
+ """Fine-grained variable categories - names mirror variable names.
84
+
85
+ Each variable type has its own category for precise handling during
86
+ segment expansion and statistics calculation.
87
+ """
88
+
89
+ # === State variables ===
90
+ CHARGE_STATE = 'charge_state' # Storage SOC (interpolate between boundaries)
91
+ SOC_BOUNDARY = 'soc_boundary' # Intercluster SOC boundaries
92
+
93
+ # === Rate/Power variables ===
94
+ FLOW_RATE = 'flow_rate' # Flow rate (kW)
95
+ NETTO_DISCHARGE = 'netto_discharge' # Storage net discharge
96
+ VIRTUAL_FLOW = 'virtual_flow' # Bus penalty slack variables
97
+
98
+ # === Binary state ===
99
+ STATUS = 'status' # On/off status (persists through segment)
100
+ INACTIVE = 'inactive' # Complementary inactive status
101
+
102
+ # === Binary events ===
103
+ STARTUP = 'startup' # Startup event
104
+ SHUTDOWN = 'shutdown' # Shutdown event
105
+
106
+ # === Effect variables ===
107
+ PER_TIMESTEP = 'per_timestep' # Effect per timestep
108
+ SHARE = 'share' # All temporal contributions (flow, active, startup)
109
+ TOTAL = 'total' # Effect total (per period/scenario)
110
+ TOTAL_OVER_PERIODS = 'total_over_periods' # Effect total over all periods
111
+
112
+ # === Investment ===
113
+ SIZE = 'size' # Generic investment size (for backwards compatibility)
114
+ FLOW_SIZE = 'flow_size' # Flow investment size
115
+ STORAGE_SIZE = 'storage_size' # Storage capacity size
116
+ INVESTED = 'invested' # Invested yes/no binary
117
+
118
+ # === Counting/Duration ===
119
+ STARTUP_COUNT = 'startup_count' # Count of startups
120
+ DURATION = 'duration' # Duration tracking (uptime/downtime)
121
+
122
+ # === Piecewise linearization ===
123
+ INSIDE_PIECE = 'inside_piece' # Binary segment selection
124
+ LAMBDA0 = 'lambda0' # Interpolation weight
125
+ LAMBDA1 = 'lambda1' # Interpolation weight
126
+ ZERO_POINT = 'zero_point' # Zero point handling
127
+
128
+ # === Other ===
129
+ OTHER = 'other' # Uncategorized
130
+
131
+
132
+ # === Logical Groupings for Segment Expansion ===
133
+ # Default behavior (not listed): repeat value within segment
134
+
135
+ EXPAND_INTERPOLATE: set[VariableCategory] = {VariableCategory.CHARGE_STATE}
136
+ """State variables that should be interpolated between segment boundaries."""
137
+
138
+ EXPAND_DIVIDE: set[VariableCategory] = {VariableCategory.PER_TIMESTEP, VariableCategory.SHARE}
139
+ """Segment totals that should be divided by expansion factor to preserve sums."""
140
+
141
+ EXPAND_FIRST_TIMESTEP: set[VariableCategory] = {VariableCategory.STARTUP, VariableCategory.SHUTDOWN}
142
+ """Binary events that should appear only at the first timestep of the segment."""
143
+
144
+
39
145
  CLASS_REGISTRY = {}
40
146
 
41
147
 
@@ -86,17 +192,35 @@ class FlowSystemModel(linopy.Model, SubmodelsMixin):
86
192
 
87
193
  Args:
88
194
  flow_system: The flow_system that is used to create the model.
89
- normalize_weights: Whether to automatically normalize the weights to sum up to 1 when solving.
90
195
  """
91
196
 
92
- def __init__(self, flow_system: FlowSystem, normalize_weights: bool):
197
+ def __init__(self, flow_system: FlowSystem):
93
198
  super().__init__(force_dim_names=True)
94
199
  self.flow_system = flow_system
95
- self.normalize_weights = normalize_weights
96
200
  self.effects: EffectCollectionModel | None = None
97
201
  self.submodels: Submodels = Submodels({})
202
+ self.variable_categories: dict[str, VariableCategory] = {}
203
+
204
+ def add_variables(
205
+ self,
206
+ lower: xr.DataArray | float = -np.inf,
207
+ upper: xr.DataArray | float = np.inf,
208
+ coords: xr.Coordinates | None = None,
209
+ **kwargs,
210
+ ) -> linopy.Variable:
211
+ """Override to ensure bounds are broadcasted to coords shape.
212
+
213
+ Linopy uses the union of all DataArray dimensions to determine variable shape.
214
+ This override ensures at least one bound has all target dimensions when coords
215
+ is provided, allowing internal data to remain compact (scalars, 1D arrays).
216
+ """
217
+ if coords is not None:
218
+ lower = _ensure_coords(lower, coords)
219
+ upper = _ensure_coords(upper, coords)
220
+ return super().add_variables(lower=lower, upper=upper, coords=coords, **kwargs)
98
221
 
99
222
  def do_modeling(self):
223
+ # Create all element models
100
224
  self.effects = self.flow_system.effects.create_model(self)
101
225
  for component in self.flow_system.components.values():
102
226
  component.create_model(self)
@@ -106,6 +230,16 @@ class FlowSystemModel(linopy.Model, SubmodelsMixin):
106
230
  # Add scenario equality constraints after all elements are modeled
107
231
  self._add_scenario_equality_constraints()
108
232
 
233
+ # Populate _variable_names and _constraint_names on each Element
234
+ self._populate_element_variable_names()
235
+
236
+ def _populate_element_variable_names(self):
237
+ """Populate _variable_names and _constraint_names on each Element from its submodel."""
238
+ for element in self.flow_system.values():
239
+ if element.submodel is not None:
240
+ element._variable_names = list(element.submodel.variables)
241
+ element._constraint_names = list(element.submodel.constraints)
242
+
109
243
  def _add_scenario_equality_for_parameter_type(
110
244
  self,
111
245
  parameter_type: Literal['flow_rate', 'size'],
@@ -154,38 +288,130 @@ class FlowSystemModel(linopy.Model, SubmodelsMixin):
154
288
 
155
289
  @property
156
290
  def solution(self):
157
- solution = super().solution
291
+ """Build solution dataset, reindexing to timesteps_extra for consistency."""
292
+ # Suppress the linopy warning about coordinate mismatch.
293
+ # This warning is expected when storage charge_state has one more timestep than other variables.
294
+ with warnings.catch_warnings():
295
+ warnings.filterwarnings(
296
+ 'ignore',
297
+ category=UserWarning,
298
+ message='Coordinates across variables not equal',
299
+ )
300
+ solution = super().solution
158
301
  solution['objective'] = self.objective.value
302
+ # Store attrs as JSON strings for netCDF compatibility
159
303
  solution.attrs = {
160
- 'Components': {
161
- comp.label_full: comp.submodel.results_structure()
162
- for comp in sorted(
163
- self.flow_system.components.values(), key=lambda component: component.label_full.upper()
164
- )
165
- },
166
- 'Buses': {
167
- bus.label_full: bus.submodel.results_structure()
168
- for bus in sorted(self.flow_system.buses.values(), key=lambda bus: bus.label_full.upper())
169
- },
170
- 'Effects': {
171
- effect.label_full: effect.submodel.results_structure()
172
- for effect in sorted(self.flow_system.effects, key=lambda effect: effect.label_full.upper())
173
- },
174
- 'Flows': {
175
- flow.label_full: flow.submodel.results_structure()
176
- for flow in sorted(self.flow_system.flows.values(), key=lambda flow: flow.label_full.upper())
177
- },
304
+ 'Components': json.dumps(
305
+ {
306
+ comp.label_full: comp.submodel.results_structure()
307
+ for comp in sorted(
308
+ self.flow_system.components.values(), key=lambda component: component.label_full.upper()
309
+ )
310
+ }
311
+ ),
312
+ 'Buses': json.dumps(
313
+ {
314
+ bus.label_full: bus.submodel.results_structure()
315
+ for bus in sorted(self.flow_system.buses.values(), key=lambda bus: bus.label_full.upper())
316
+ }
317
+ ),
318
+ 'Effects': json.dumps(
319
+ {
320
+ effect.label_full: effect.submodel.results_structure()
321
+ for effect in sorted(
322
+ self.flow_system.effects.values(), key=lambda effect: effect.label_full.upper()
323
+ )
324
+ }
325
+ ),
326
+ 'Flows': json.dumps(
327
+ {
328
+ flow.label_full: flow.submodel.results_structure()
329
+ for flow in sorted(self.flow_system.flows.values(), key=lambda flow: flow.label_full.upper())
330
+ }
331
+ ),
178
332
  }
179
- return solution.reindex(time=self.flow_system.timesteps_extra)
333
+ # Ensure solution is always indexed by timesteps_extra for consistency.
334
+ # Variables without extra timestep data will have NaN at the final timestep.
335
+ if 'time' in solution.coords:
336
+ if not solution.indexes['time'].equals(self.flow_system.timesteps_extra):
337
+ solution = solution.reindex(time=self.flow_system.timesteps_extra)
338
+ return solution
180
339
 
181
340
  @property
182
- def hours_per_step(self):
183
- return self.flow_system.hours_per_timestep
341
+ def timestep_duration(self) -> xr.DataArray:
342
+ """Duration of each timestep in hours."""
343
+ return self.flow_system.timestep_duration
184
344
 
185
345
  @property
186
346
  def hours_of_previous_timesteps(self):
187
347
  return self.flow_system.hours_of_previous_timesteps
188
348
 
349
+ @property
350
+ def dims(self) -> list[str]:
351
+ """Active dimension names."""
352
+ return self.flow_system.dims
353
+
354
+ @property
355
+ def indexes(self) -> dict[str, pd.Index]:
356
+ """Indexes for active dimensions."""
357
+ return self.flow_system.indexes
358
+
359
+ @property
360
+ def weights(self) -> dict[str, xr.DataArray]:
361
+ """Weights for active dimensions (unit weights if not set).
362
+
363
+ Scenario weights are always normalized (handled by FlowSystem).
364
+ """
365
+ return self.flow_system.weights
366
+
367
+ @property
368
+ def temporal_dims(self) -> list[str]:
369
+ """Temporal dimensions for summing over time.
370
+
371
+ Returns ['time', 'cluster'] for clustered systems, ['time'] otherwise.
372
+ """
373
+ return self.flow_system.temporal_dims
374
+
375
+ @property
376
+ def temporal_weight(self) -> xr.DataArray:
377
+ """Combined temporal weight (timestep_duration × cluster_weight)."""
378
+ return self.flow_system.temporal_weight
379
+
380
+ def sum_temporal(self, data: xr.DataArray) -> xr.DataArray:
381
+ """Sum data over temporal dimensions with full temporal weighting.
382
+
383
+ Example:
384
+ >>> total_energy = model.sum_temporal(flow_rate)
385
+ """
386
+ return self.flow_system.sum_temporal(data)
387
+
388
+ @property
389
+ def scenario_weights(self) -> xr.DataArray:
390
+ """Scenario weights of model.
391
+
392
+ Returns:
393
+ - Scalar 1 if no scenarios defined
394
+ - Unit weights (all 1.0) if scenarios exist but no explicit weights set
395
+ - Normalized explicit weights if set via FlowSystem.scenario_weights
396
+ """
397
+ if self.flow_system.scenarios is None:
398
+ return xr.DataArray(1)
399
+
400
+ if self.flow_system.scenario_weights is None:
401
+ return self.flow_system._unit_weight('scenario')
402
+
403
+ return self.flow_system.scenario_weights
404
+
405
+ @property
406
+ def objective_weights(self) -> xr.DataArray:
407
+ """
408
+ Objective weights of model (period_weights × scenario_weights).
409
+ """
410
+ period_weights = self.flow_system.effects.objective_effect.submodel.period_weights
411
+ scenario_weights = self.scenario_weights
412
+
413
+ return period_weights * scenario_weights
414
+
189
415
  def get_coords(
190
416
  self,
191
417
  dims: Collection[str] | None = None,
@@ -196,7 +422,8 @@ class FlowSystemModel(linopy.Model, SubmodelsMixin):
196
422
 
197
423
  Args:
198
424
  dims: The dimensions to include in the coordinates. If None, includes all dimensions
199
- extra_timestep: If True, uses extra timesteps instead of regular timesteps
425
+ extra_timestep: If True, uses extra timesteps instead of regular timesteps.
426
+ For clustered FlowSystems, extends time by 1 (for charge_state boundaries).
200
427
 
201
428
  Returns:
202
429
  The coordinates of the model, or None if no coordinates are available
@@ -208,28 +435,20 @@ class FlowSystemModel(linopy.Model, SubmodelsMixin):
208
435
  raise ValueError('extra_timestep=True requires "time" to be included in dims')
209
436
 
210
437
  if dims is None:
211
- coords = dict(self.flow_system.coords)
438
+ coords = dict(self.flow_system.indexes)
212
439
  else:
213
- coords = {k: v for k, v in self.flow_system.coords.items() if k in dims}
440
+ # In clustered systems, 'time' is always paired with 'cluster'
441
+ # So when 'time' is requested, also include 'cluster' if available
442
+ effective_dims = set(dims)
443
+ if 'time' in dims and 'cluster' in self.flow_system.indexes:
444
+ effective_dims.add('cluster')
445
+ coords = {k: v for k, v in self.flow_system.indexes.items() if k in effective_dims}
214
446
 
215
447
  if extra_timestep and coords:
216
448
  coords['time'] = self.flow_system.timesteps_extra
217
449
 
218
450
  return xr.Coordinates(coords) if coords else None
219
451
 
220
- @property
221
- def weights(self) -> int | xr.DataArray:
222
- """Returns the weights of the FlowSystem. Normalizes to 1 if normalize_weights is True"""
223
- if self.flow_system.weights is not None:
224
- weights = self.flow_system.weights
225
- else:
226
- weights = self.flow_system.fit_to_model_coords('weights', 1, dims=['period', 'scenario'])
227
-
228
- if not self.normalize_weights:
229
- return weights
230
-
231
- return weights / weights.sum()
232
-
233
452
  def __repr__(self) -> str:
234
453
  """
235
454
  Return a string representation of the FlowSystemModel, borrowed from linopy.Model.
@@ -243,9 +462,7 @@ class FlowSystemModel(linopy.Model, SubmodelsMixin):
243
462
  }
244
463
 
245
464
  # Format sections with headers and underlines
246
- formatted_sections = []
247
- for section_header, section_content in sections.items():
248
- formatted_sections.append(f'{section_header}\n{"-" * len(section_header)}\n{section_content}')
465
+ formatted_sections = fx_io.format_sections_with_headers(sections)
249
466
 
250
467
  title = f'FlowSystemModel ({self.type})'
251
468
  all_sections = '\n'.join(formatted_sections)
@@ -268,21 +485,133 @@ class Interface:
268
485
  - Recursive handling of complex nested structures
269
486
 
270
487
  Subclasses must implement:
271
- transform_data(flow_system): Transform data to match FlowSystem dimensions
488
+ transform_data(): Transform data to match FlowSystem dimensions
272
489
  """
273
490
 
274
- def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
491
+ # Class-level defaults for attributes set by link_to_flow_system()
492
+ # These provide type hints and default values without requiring __init__ in subclasses
493
+ _flow_system: FlowSystem | None = None
494
+ _prefix: str = ''
495
+
496
+ def transform_data(self) -> None:
275
497
  """Transform the data of the interface to match the FlowSystem's dimensions.
276
498
 
277
- Args:
278
- flow_system: The FlowSystem containing timing and dimensional information
279
- name_prefix: The prefix to use for the names of the variables. Defaults to '', which results in no prefix.
499
+ Uses `self._prefix` (set during `link_to_flow_system()`) to name transformed data.
280
500
 
281
501
  Raises:
282
502
  NotImplementedError: Must be implemented by subclasses
503
+
504
+ Note:
505
+ The FlowSystem reference is available via self._flow_system (for Interface objects)
506
+ or self.flow_system property (for Element objects). Elements must be registered
507
+ to a FlowSystem before calling this method.
283
508
  """
284
509
  raise NotImplementedError('Every Interface subclass needs a transform_data() method')
285
510
 
511
+ @property
512
+ def prefix(self) -> str:
513
+ """The prefix used for naming transformed data (e.g., 'Boiler(Q_th)|status_parameters')."""
514
+ return self._prefix
515
+
516
+ def _sub_prefix(self, name: str) -> str:
517
+ """Build a prefix for a nested interface by appending name to current prefix."""
518
+ return f'{self._prefix}|{name}' if self._prefix else name
519
+
520
+ def link_to_flow_system(self, flow_system: FlowSystem, prefix: str = '') -> None:
521
+ """Link this interface and all nested interfaces to a FlowSystem.
522
+
523
+ This method is called automatically during element registration to enable
524
+ elements to access FlowSystem properties without passing the reference
525
+ through every method call. It also sets the prefix used for naming
526
+ transformed data.
527
+
528
+ Subclasses with nested Interface objects should override this method
529
+ to propagate the link to their nested interfaces by calling
530
+ `super().link_to_flow_system(flow_system, prefix)` first, then linking
531
+ nested objects with appropriate prefixes.
532
+
533
+ Args:
534
+ flow_system: The FlowSystem to link to
535
+ prefix: The prefix for naming transformed data (e.g., 'Boiler(Q_th)')
536
+
537
+ Examples:
538
+ Override in a subclass with nested interfaces:
539
+
540
+ ```python
541
+ def link_to_flow_system(self, flow_system, prefix: str = '') -> None:
542
+ super().link_to_flow_system(flow_system, prefix)
543
+ if self.nested_interface is not None:
544
+ self.nested_interface.link_to_flow_system(flow_system, f'{prefix}|nested' if prefix else 'nested')
545
+ ```
546
+
547
+ Creating an Interface dynamically during modeling:
548
+
549
+ ```python
550
+ # In a Model class
551
+ if flow.status_parameters is None:
552
+ flow.status_parameters = StatusParameters()
553
+ flow.status_parameters.link_to_flow_system(self._model.flow_system, f'{flow.label_full}')
554
+ ```
555
+ """
556
+ self._flow_system = flow_system
557
+ self._prefix = prefix
558
+
559
+ @property
560
+ def flow_system(self) -> FlowSystem:
561
+ """Access the FlowSystem this interface is linked to.
562
+
563
+ Returns:
564
+ The FlowSystem instance this interface belongs to.
565
+
566
+ Raises:
567
+ RuntimeError: If interface has not been linked to a FlowSystem yet.
568
+
569
+ Note:
570
+ For Elements, this is set during add_elements().
571
+ For parameter classes, this is set recursively when the parent Element is registered.
572
+ """
573
+ if self._flow_system is None:
574
+ raise RuntimeError(
575
+ f'{self.__class__.__name__} is not linked to a FlowSystem. '
576
+ f'Ensure the parent element is registered via flow_system.add_elements() first.'
577
+ )
578
+ return self._flow_system
579
+
580
+ def _fit_coords(
581
+ self, name: str, data: NumericOrBool | None, dims: Collection[FlowSystemDimensions] | None = None
582
+ ) -> xr.DataArray | None:
583
+ """Convenience wrapper for FlowSystem.fit_to_model_coords().
584
+
585
+ Args:
586
+ name: The name for the data variable
587
+ data: The data to transform
588
+ dims: Optional dimension names
589
+
590
+ Returns:
591
+ Transformed data aligned to FlowSystem coordinates
592
+ """
593
+ return self.flow_system.fit_to_model_coords(name, data, dims=dims)
594
+
595
+ def _fit_effect_coords(
596
+ self,
597
+ prefix: str | None,
598
+ effect_values: Effect_TPS | Numeric_TPS | None,
599
+ suffix: str | None = None,
600
+ dims: Collection[FlowSystemDimensions] | None = None,
601
+ ) -> Effect_TPS | None:
602
+ """Convenience wrapper for FlowSystem.fit_effects_to_model_coords().
603
+
604
+ Args:
605
+ prefix: Label prefix for effect names
606
+ effect_values: The effect values to transform
607
+ suffix: Optional label suffix
608
+ dims: Optional dimension names
609
+
610
+ Returns:
611
+ Transformed effect values aligned to FlowSystem coordinates
612
+ """
613
+ return self.flow_system.fit_effects_to_model_coords(prefix, effect_values, suffix, dims=dims)
614
+
286
615
  def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]:
287
616
  """
288
617
  Convert all DataArrays to references and extract them.
@@ -424,6 +753,7 @@ class Interface:
424
753
  current_value: Any = None,
425
754
  transform: callable = None,
426
755
  check_conflict: bool = True,
756
+ additional_warning_message: str = '',
427
757
  ) -> Any:
428
758
  """
429
759
  Handle a deprecated keyword argument by issuing a warning and returning the appropriate value.
@@ -439,6 +769,7 @@ class Interface:
439
769
  check_conflict: Whether to check if both old and new parameters are specified (default: True).
440
770
  Note: For parameters with non-None default values (e.g., bool parameters with default=False),
441
771
  set check_conflict=False since we cannot distinguish between an explicit value and the default.
772
+ additional_warning_message: Add a custom message which gets appended with a line break to the default warning.
442
773
 
443
774
  Returns:
444
775
  The value to use (either from old parameter or current_value)
@@ -461,8 +792,18 @@ class Interface:
461
792
 
462
793
  old_value = kwargs.pop(old_name, None)
463
794
  if old_value is not None:
795
+ # Build base warning message
796
+ base_warning = f'The use of the "{old_name}" argument is deprecated. Use the "{new_name}" argument instead. Will be removed in v{DEPRECATION_REMOVAL_VERSION}.'
797
+
798
+ # Append additional message on a new line if provided
799
+ if additional_warning_message:
800
+ # Normalize whitespace: strip leading/trailing whitespace
801
+ extra_msg = additional_warning_message.strip()
802
+ if extra_msg:
803
+ base_warning += '\n' + extra_msg
804
+
464
805
  warnings.warn(
465
- f'The use of the "{old_name}" argument is deprecated. Use the "{new_name}" argument instead.',
806
+ base_warning,
466
807
  DeprecationWarning,
467
808
  stacklevel=3, # Stack: this method -> __init__ -> caller
468
809
  )
@@ -507,6 +848,33 @@ class Interface:
507
848
  unexpected_params = ', '.join(f"'{param}'" for param in extra_kwargs.keys())
508
849
  raise TypeError(f'{class_name}.__init__() got unexpected keyword argument(s): {unexpected_params}')
509
850
 
851
+ @staticmethod
852
+ def _has_value(param: Any) -> bool:
853
+ """Check if a parameter has a meaningful value.
854
+
855
+ Args:
856
+ param: The parameter to check.
857
+
858
+ Returns:
859
+ False for:
860
+ - None
861
+ - Empty collections (dict, list, tuple, set, frozenset)
862
+
863
+ True for all other values, including:
864
+ - Non-empty collections
865
+ - xarray DataArrays (even if they contain NaN/empty data)
866
+ - Scalar values (0, False, empty strings, etc.)
867
+ - NumPy arrays (even if empty - use .size to check those explicitly)
868
+ """
869
+ if param is None:
870
+ return False
871
+
872
+ # Check for empty collections (but not strings, arrays, or DataArrays)
873
+ if isinstance(param, (dict, list, tuple, set, frozenset)) and len(param) == 0:
874
+ return False
875
+
876
+ return True
877
+
510
878
  @classmethod
511
879
  def _resolve_dataarray_reference(
512
880
  cls, reference: str, arrays_dict: dict[str, xr.DataArray]
@@ -530,8 +898,11 @@ class Interface:
530
898
 
531
899
  array = arrays_dict[array_name]
532
900
 
533
- # Handle null values with warning
534
- if array.isnull().any():
901
+ # Handle null values with warning (use numpy for performance - 200x faster than xarray)
902
+ has_nulls = (np.issubdtype(array.dtype, np.floating) and np.any(np.isnan(array.values))) or (
903
+ array.dtype == object and pd.isna(array.values).any()
904
+ )
905
+ if has_nulls:
535
906
  logger.error(f"DataArray '{array_name}' contains null values. Dropping all-null along present dims.")
536
907
  if 'time' in array.dims:
537
908
  array = array.dropna(dim='time', how='all')
@@ -586,7 +957,34 @@ class Interface:
586
957
  resolved_nested_data = cls._resolve_reference_structure(nested_data, arrays_dict)
587
958
 
588
959
  try:
589
- return nested_class(**resolved_nested_data)
960
+ # Get valid constructor parameters for this class
961
+ init_params = set(inspect.signature(nested_class.__init__).parameters.keys())
962
+
963
+ # Check for deferred init attributes (defined as class attribute on Element subclasses)
964
+ # These are serialized but set after construction, not passed to child __init__
965
+ deferred_attr_names = getattr(nested_class, '_deferred_init_attrs', set())
966
+ deferred_attrs = {k: v for k, v in resolved_nested_data.items() if k in deferred_attr_names}
967
+ constructor_data = {k: v for k, v in resolved_nested_data.items() if k not in deferred_attr_names}
968
+
969
+ # Check for unknown parameters - these could be typos or renamed params
970
+ unknown_params = set(constructor_data.keys()) - init_params
971
+ if unknown_params:
972
+ raise TypeError(
973
+ f'{class_name}.__init__() got unexpected keyword arguments: {unknown_params}. '
974
+ f'This may indicate renamed parameters that need conversion. '
975
+ f'Valid parameters are: {init_params - {"self"}}'
976
+ )
977
+
978
+ # Create instance with constructor parameters
979
+ instance = nested_class(**constructor_data)
980
+
981
+ # Set internal attributes after construction
982
+ for attr_name, attr_value in deferred_attrs.items():
983
+ setattr(instance, attr_name, attr_value)
984
+
985
+ return instance
986
+ except TypeError as e:
987
+ raise ValueError(f'Failed to create instance of {class_name}: {e}') from e
590
988
  except Exception as e:
591
989
  raise ValueError(f'Failed to create instance of {class_name}: {e}') from e
592
990
  else:
@@ -662,18 +1060,29 @@ class Interface:
662
1060
  f'Original Error: {e}'
663
1061
  ) from e
664
1062
 
665
- def to_netcdf(self, path: str | pathlib.Path, compression: int = 0):
1063
+ def to_netcdf(self, path: str | pathlib.Path, compression: int = 5, overwrite: bool = False):
666
1064
  """
667
1065
  Save the object to a NetCDF file.
668
1066
 
669
1067
  Args:
670
- path: Path to save the NetCDF file
1068
+ path: Path to save the NetCDF file. Parent directories are created if they don't exist.
671
1069
  compression: Compression level (0-9)
1070
+ overwrite: If True, overwrite existing file. If False, raise error if file exists.
672
1071
 
673
1072
  Raises:
1073
+ FileExistsError: If overwrite=False and file already exists.
674
1074
  ValueError: If serialization fails
675
1075
  IOError: If file cannot be written
676
1076
  """
1077
+ path = pathlib.Path(path)
1078
+
1079
+ # Check if file exists (unless overwrite is True)
1080
+ if not overwrite and path.exists():
1081
+ raise FileExistsError(f'File already exists: {path}. Use overwrite=True to overwrite existing file.')
1082
+
1083
+ # Create parent directories if they don't exist
1084
+ path.parent.mkdir(parents=True, exist_ok=True)
1085
+
677
1086
  try:
678
1087
  ds = self.to_dataset()
679
1088
  fx_io.save_dataset_to_netcdf(ds, path, compression=compression)
@@ -707,7 +1116,19 @@ class Interface:
707
1116
  reference_structure.pop('__class__', None)
708
1117
 
709
1118
  # Create arrays dictionary from dataset variables
710
- arrays_dict = {name: array for name, array in ds.data_vars.items()}
1119
+ # Use ds.variables with coord_cache for faster DataArray construction
1120
+ variables = ds.variables
1121
+ coord_cache = {k: ds.coords[k] for k in ds.coords}
1122
+ coord_names = set(coord_cache)
1123
+ arrays_dict = {
1124
+ name: xr.DataArray(
1125
+ variables[name],
1126
+ coords={k: coord_cache[k] for k in variables[name].dims if k in coord_cache},
1127
+ name=name,
1128
+ )
1129
+ for name in variables
1130
+ if name not in coord_names
1131
+ }
711
1132
 
712
1133
  # Resolve all references using the centralized method
713
1134
  resolved_params = cls._resolve_reference_structure(reference_structure, arrays_dict)
@@ -788,47 +1209,13 @@ class Interface:
788
1209
  try:
789
1210
  # Use the stats mode for JSON export (cleaner output)
790
1211
  data = self.get_structure(clean=True, stats=True)
791
- with open(path, 'w', encoding='utf-8') as f:
792
- json.dump(data, f, indent=4, ensure_ascii=False)
1212
+ fx_io.save_json(data, path)
793
1213
  except Exception as e:
794
1214
  raise OSError(f'Failed to save {self.__class__.__name__} to JSON file {path}: {e}') from e
795
1215
 
796
1216
  def __repr__(self):
797
1217
  """Return a detailed string representation for debugging."""
798
- try:
799
- # Get the constructor arguments and their current values
800
- init_signature = inspect.signature(self.__init__)
801
- init_args = init_signature.parameters
802
-
803
- # Create a dictionary with argument names and their values, with better formatting
804
- args_parts = []
805
- for name in init_args:
806
- if name == 'self':
807
- continue
808
- value = getattr(self, name, None)
809
- # Truncate long representations
810
- value_repr = repr(value)
811
- if len(value_repr) > 50:
812
- value_repr = value_repr[:47] + '...'
813
- args_parts.append(f'{name}={value_repr}')
814
-
815
- args_str = ', '.join(args_parts)
816
- return f'{self.__class__.__name__}({args_str})'
817
- except Exception:
818
- # Fallback if introspection fails
819
- return f'{self.__class__.__name__}(<repr_failed>)'
820
-
821
- def __str__(self):
822
- """Return a user-friendly string representation."""
823
- try:
824
- data = self.get_structure(clean=True, stats=True)
825
- with StringIO() as output_buffer:
826
- console = Console(file=output_buffer, width=1000) # Adjust width as needed
827
- console.print(Pretty(data, expand_all=True, indent_guides=True))
828
- return output_buffer.getvalue()
829
- except Exception:
830
- # Fallback if structure generation fails
831
- return f'{self.__class__.__name__} instance'
1218
+ return fx_io.build_repr_from_init(self, excluded_params={'self', 'label', 'kwargs'})
832
1219
 
833
1220
  def copy(self) -> Interface:
834
1221
  """
@@ -856,15 +1243,36 @@ class Interface:
856
1243
  class Element(Interface):
857
1244
  """This class is the basic Element of flixopt. Every Element has a label"""
858
1245
 
859
- def __init__(self, label: str, meta_data: dict | None = None):
1246
+ submodel: ElementModel | None
1247
+
1248
+ # Attributes that are serialized but set after construction (not passed to child __init__)
1249
+ # These are internal state populated during modeling, not user-facing parameters
1250
+ _deferred_init_attrs: ClassVar[set[str]] = {'_variable_names', '_constraint_names'}
1251
+
1252
+ def __init__(
1253
+ self,
1254
+ label: str,
1255
+ meta_data: dict | None = None,
1256
+ color: str | None = None,
1257
+ _variable_names: list[str] | None = None,
1258
+ _constraint_names: list[str] | None = None,
1259
+ ):
860
1260
  """
861
1261
  Args:
862
1262
  label: The label of the element
863
1263
  meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types.
1264
+ color: Optional color for visualizations (e.g., '#FF6B6B'). If not provided, a color will be automatically assigned during FlowSystem.connect_and_transform().
1265
+ _variable_names: Internal. Variable names for this element (populated after modeling).
1266
+ _constraint_names: Internal. Constraint names for this element (populated after modeling).
864
1267
  """
865
1268
  self.label = Element._valid_label(label)
866
1269
  self.meta_data = meta_data if meta_data is not None else {}
867
- self.submodel: ElementModel | None = None
1270
+ self.color = color
1271
+ self.submodel = None
1272
+ self._flow_system: FlowSystem | None = None
1273
+ # Variable/constraint names - populated after modeling, serialized for results
1274
+ self._variable_names: list[str] = _variable_names if _variable_names is not None else []
1275
+ self._constraint_names: list[str] = _constraint_names if _constraint_names is not None else []
868
1276
 
869
1277
  def _plausibility_checks(self) -> None:
870
1278
  """This function is used to do some basic plausibility checks for each Element during initialization.
@@ -878,15 +1286,50 @@ class Element(Interface):
878
1286
  def label_full(self) -> str:
879
1287
  return self.label
880
1288
 
1289
+ @property
1290
+ def solution(self) -> xr.Dataset:
1291
+ """Solution data for this element's variables.
1292
+
1293
+ Returns a view into FlowSystem.solution containing only this element's variables.
1294
+
1295
+ Raises:
1296
+ ValueError: If no solution is available (optimization not run or not solved).
1297
+ """
1298
+ if self._flow_system is None:
1299
+ raise ValueError(f'Element "{self.label}" is not linked to a FlowSystem.')
1300
+ if self._flow_system.solution is None:
1301
+ raise ValueError(f'No solution available for "{self.label}". Run optimization first or load results.')
1302
+ if not self._variable_names:
1303
+ raise ValueError(f'No variable names available for "{self.label}". Element may not have been modeled yet.')
1304
+ return self._flow_system.solution[self._variable_names]
1305
+
1306
+ def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]:
1307
+ """
1308
+ Override to include _variable_names and _constraint_names in serialization.
1309
+
1310
+ These attributes are defined in Element but may not be in subclass constructors,
1311
+ so we need to add them explicitly.
1312
+ """
1313
+ reference_structure, all_extracted_arrays = super()._create_reference_structure()
1314
+
1315
+ # Always include variable/constraint names for solution access after loading
1316
+ if self._variable_names:
1317
+ reference_structure['_variable_names'] = self._variable_names
1318
+ if self._constraint_names:
1319
+ reference_structure['_constraint_names'] = self._constraint_names
1320
+
1321
+ return reference_structure, all_extracted_arrays
1322
+
1323
+ def __repr__(self) -> str:
1324
+ """Return string representation."""
1325
+ return fx_io.build_repr_from_init(self, excluded_params={'self', 'label', 'kwargs'}, skip_default_size=True)
1326
+
881
1327
  @staticmethod
882
1328
  def _valid_label(label: str) -> str:
883
- """
884
- Checks if the label is valid. If not, it is replaced by the default label
1329
+ """Checks if the label is valid. If not, it is replaced by the default label.
885
1330
 
886
- Raises
887
- ------
888
- ValueError
889
- If the label is not valid
1331
+ Raises:
1332
+ ValueError: If the label is not valid.
890
1333
  """
891
1334
  not_allowed = ['(', ')', '|', '->', '\\', '-slash-'] # \\ is needed to check for \
892
1335
  if any([sign in label for sign in not_allowed]):
@@ -900,6 +1343,378 @@ class Element(Interface):
900
1343
  return label
901
1344
 
902
1345
 
1346
+ # Precompiled regex pattern for natural sorting
1347
+ _NATURAL_SPLIT = re.compile(r'(\d+)')
1348
+
1349
+
1350
+ def _natural_sort_key(text):
1351
+ """Sort key for natural ordering (e.g., bus1, bus2, bus10 instead of bus1, bus10, bus2)."""
1352
+ return [int(c) if c.isdigit() else c.lower() for c in _NATURAL_SPLIT.split(text)]
1353
+
1354
+
1355
+ # Type variable for containers
1356
+ T = TypeVar('T')
1357
+
1358
+
1359
+ class ContainerMixin(dict[str, T]):
1360
+ """
1361
+ Mixin providing shared container functionality with nice repr and error messages.
1362
+
1363
+ Subclasses must implement _get_label() to extract the label from elements.
1364
+ """
1365
+
1366
+ def __init__(
1367
+ self,
1368
+ elements: list[T] | dict[str, T] | None = None,
1369
+ element_type_name: str = 'elements',
1370
+ truncate_repr: int | None = None,
1371
+ item_name: str | None = None,
1372
+ ):
1373
+ """
1374
+ Args:
1375
+ elements: Initial elements to add (list or dict)
1376
+ element_type_name: Name for display (e.g., 'components', 'buses')
1377
+ truncate_repr: Maximum number of items to show in repr. If None, show all items. Default: None
1378
+ item_name: Singular name for error messages (e.g., 'Component', 'Carrier').
1379
+ If None, inferred from first added item's class name.
1380
+ """
1381
+ super().__init__()
1382
+ self._element_type_name = element_type_name
1383
+ self._truncate_repr = truncate_repr
1384
+ self._item_name = item_name
1385
+
1386
+ if elements is not None:
1387
+ if isinstance(elements, dict):
1388
+ for element in elements.values():
1389
+ self.add(element)
1390
+ else:
1391
+ for element in elements:
1392
+ self.add(element)
1393
+
1394
+ def _get_label(self, element: T) -> str:
1395
+ """
1396
+ Extract label from element. Must be implemented by subclasses.
1397
+
1398
+ Args:
1399
+ element: Element to get label from
1400
+
1401
+ Returns:
1402
+ Label string
1403
+ """
1404
+ raise NotImplementedError('Subclasses must implement _get_label()')
1405
+
1406
+ def _get_item_name(self) -> str:
1407
+ """Get the singular item name for error messages.
1408
+
1409
+ Returns the explicitly set item_name, or infers from the first item's class name.
1410
+ Falls back to 'Item' if container is empty and no name was set.
1411
+ """
1412
+ if self._item_name is not None:
1413
+ return self._item_name
1414
+ # Infer from first item's class name
1415
+ if self:
1416
+ first_item = next(iter(self.values()))
1417
+ return first_item.__class__.__name__
1418
+ return 'Item'
1419
+
1420
+ def add(self, element: T) -> None:
1421
+ """Add an element to the container."""
1422
+ label = self._get_label(element)
1423
+ if label in self:
1424
+ item_name = element.__class__.__name__
1425
+ raise ValueError(
1426
+ f'{item_name} with label "{label}" already exists in {self._element_type_name}. '
1427
+ f'Each {item_name.lower()} must have a unique label.'
1428
+ )
1429
+ self[label] = element
1430
+
1431
+ def __setitem__(self, label: str, element: T) -> None:
1432
+ """Set element with validation."""
1433
+ element_label = self._get_label(element)
1434
+ if label != element_label:
1435
+ raise ValueError(
1436
+ f'Key "{label}" does not match element label "{element_label}". '
1437
+ f'Use the correct label as key or use .add() method.'
1438
+ )
1439
+ super().__setitem__(label, element)
1440
+
1441
+ def __getitem__(self, label: str) -> T:
1442
+ """
1443
+ Get element by label with helpful error messages.
1444
+
1445
+ Args:
1446
+ label: Label of the element to retrieve
1447
+
1448
+ Returns:
1449
+ The element with the given label
1450
+
1451
+ Raises:
1452
+ KeyError: If element is not found, with suggestions for similar labels
1453
+ """
1454
+ try:
1455
+ return super().__getitem__(label)
1456
+ except KeyError:
1457
+ # Provide helpful error with close matches suggestions
1458
+ item_name = self._get_item_name()
1459
+ suggestions = get_close_matches(label, self.keys(), n=3, cutoff=0.6)
1460
+ error_msg = f'{item_name} "{label}" not found in {self._element_type_name}.'
1461
+ if suggestions:
1462
+ error_msg += f' Did you mean: {", ".join(suggestions)}?'
1463
+ else:
1464
+ available = list(self.keys())
1465
+ if len(available) <= 5:
1466
+ error_msg += f' Available: {", ".join(available)}'
1467
+ else:
1468
+ error_msg += f' Available: {", ".join(available[:5])} ... (+{len(available) - 5} more)'
1469
+ raise KeyError(error_msg) from None
1470
+
1471
+ def _get_repr(self, max_items: int | None = None) -> str:
1472
+ """
1473
+ Get string representation with optional truncation.
1474
+
1475
+ Args:
1476
+ max_items: Maximum number of items to show. If None, uses instance default (self._truncate_repr).
1477
+ If still None, shows all items.
1478
+
1479
+ Returns:
1480
+ Formatted string representation
1481
+ """
1482
+ # Use provided max_items, or fall back to instance default
1483
+ limit = max_items if max_items is not None else self._truncate_repr
1484
+
1485
+ count = len(self)
1486
+ title = f'{self._element_type_name.capitalize()} ({count} item{"s" if count != 1 else ""})'
1487
+
1488
+ if not self:
1489
+ r = fx_io.format_title_with_underline(title)
1490
+ r += '<empty>\n'
1491
+ else:
1492
+ r = fx_io.format_title_with_underline(title)
1493
+ sorted_names = sorted(self.keys(), key=_natural_sort_key)
1494
+
1495
+ if limit is not None and limit > 0 and len(sorted_names) > limit:
1496
+ # Show truncated list
1497
+ for name in sorted_names[:limit]:
1498
+ r += f' * {name}\n'
1499
+ r += f' ... (+{len(sorted_names) - limit} more)\n'
1500
+ else:
1501
+ # Show all items
1502
+ for name in sorted_names:
1503
+ r += f' * {name}\n'
1504
+
1505
+ return r
1506
+
1507
+ def __repr__(self) -> str:
1508
+ """Return a string representation using the instance's truncate_repr setting."""
1509
+ return self._get_repr()
1510
+
1511
+
1512
+ class ElementContainer(ContainerMixin[T]):
1513
+ """
1514
+ Container for Element objects (Component, Bus, Flow, Effect).
1515
+
1516
+ Uses element.label_full for keying.
1517
+ """
1518
+
1519
+ def _get_label(self, element: T) -> str:
1520
+ """Extract label_full from Element."""
1521
+ return element.label_full
1522
+
1523
+
1524
+ class ResultsContainer(ContainerMixin[T]):
1525
+ """
1526
+ Container for Results objects (ComponentResults, BusResults, etc).
1527
+
1528
+ Uses element.label for keying.
1529
+ """
1530
+
1531
+ def _get_label(self, element: T) -> str:
1532
+ """Extract label from Results object."""
1533
+ return element.label
1534
+
1535
+
1536
+ T_element = TypeVar('T_element')
1537
+
1538
+
1539
+ class CompositeContainerMixin(Generic[T_element]):
1540
+ """
1541
+ Mixin providing unified dict-like access across multiple typed containers.
1542
+
1543
+ This mixin enables classes that manage multiple containers (e.g., components,
1544
+ buses, effects, flows) to provide a unified interface for accessing elements
1545
+ across all containers, as if they were a single collection.
1546
+
1547
+ Type Parameter:
1548
+ T_element: The type of elements stored in the containers. Can be a union type
1549
+ for containers holding multiple types (e.g., 'ComponentResults | BusResults').
1550
+
1551
+ Key Features:
1552
+ - Dict-like access: `obj['element_name']` searches all containers
1553
+ - Iteration: `for label in obj:` iterates over all elements
1554
+ - Membership: `'element' in obj` checks across all containers
1555
+ - Standard dict methods: keys(), values(), items()
1556
+ - Grouped display: Formatted repr showing elements by type
1557
+ - Type hints: Full IDE and type checker support
1558
+
1559
+ Subclasses must implement:
1560
+ _get_container_groups() -> dict[str, dict]:
1561
+ Returns a dictionary mapping group names (e.g., 'Components', 'Buses')
1562
+ to container dictionaries. Containers are displayed in the order returned.
1563
+
1564
+ Example:
1565
+ ```python
1566
+ class MySystem(CompositeContainerMixin[Component | Bus]):
1567
+ def __init__(self):
1568
+ self.components = {'Boiler': Component(...), 'CHP': Component(...)}
1569
+ self.buses = {'Heat': Bus(...), 'Power': Bus(...)}
1570
+
1571
+ def _get_container_groups(self):
1572
+ return {
1573
+ 'Components': self.components,
1574
+ 'Buses': self.buses,
1575
+ }
1576
+
1577
+
1578
+ system = MySystem()
1579
+ comp = system['Boiler'] # Type: Component | Bus (with proper IDE support)
1580
+ 'Heat' in system # True
1581
+ labels = system.keys() # Type: list[str]
1582
+ elements = system.values() # Type: list[Component | Bus]
1583
+ ```
1584
+
1585
+ Integration with ContainerMixin:
1586
+ This mixin is designed to work alongside ContainerMixin-based containers
1587
+ (ElementContainer, ResultsContainer) by aggregating them into a unified
1588
+ interface while preserving their individual functionality.
1589
+ """
1590
+
1591
+ def _get_container_groups(self) -> dict[str, ContainerMixin[Any]]:
1592
+ """
1593
+ Return ordered dict of container groups to aggregate.
1594
+
1595
+ Returns:
1596
+ Dictionary mapping group names to container objects (e.g., ElementContainer, ResultsContainer).
1597
+ Group names should be capitalized (e.g., 'Components', 'Buses').
1598
+ Order determines display order in __repr__.
1599
+
1600
+ Example:
1601
+ ```python
1602
+ return {
1603
+ 'Components': self.components,
1604
+ 'Buses': self.buses,
1605
+ 'Effects': self.effects,
1606
+ }
1607
+ ```
1608
+ """
1609
+ raise NotImplementedError('Subclasses must implement _get_container_groups()')
1610
+
1611
+ def __getitem__(self, key: str) -> T_element:
1612
+ """
1613
+ Get element by label, searching all containers.
1614
+
1615
+ Args:
1616
+ key: Element label to find
1617
+
1618
+ Returns:
1619
+ The element with the given label
1620
+
1621
+ Raises:
1622
+ KeyError: If element not found, with helpful suggestions
1623
+ """
1624
+ # Search all containers in order
1625
+ for container in self._get_container_groups().values():
1626
+ if key in container:
1627
+ return container[key]
1628
+
1629
+ # Element not found - provide helpful error
1630
+ all_elements = {}
1631
+ for container in self._get_container_groups().values():
1632
+ all_elements.update(container)
1633
+
1634
+ suggestions = get_close_matches(key, all_elements.keys(), n=3, cutoff=0.6)
1635
+ error_msg = f'Element "{key}" not found.'
1636
+
1637
+ if suggestions:
1638
+ error_msg += f' Did you mean: {", ".join(suggestions)}?'
1639
+ else:
1640
+ available = list(all_elements.keys())
1641
+ if len(available) <= 5:
1642
+ error_msg += f' Available: {", ".join(available)}'
1643
+ else:
1644
+ error_msg += f' Available: {", ".join(available[:5])} ... (+{len(available) - 5} more)'
1645
+
1646
+ raise KeyError(error_msg)
1647
+
1648
+ def __iter__(self):
1649
+ """Iterate over all element labels across all containers."""
1650
+ for container in self._get_container_groups().values():
1651
+ yield from container.keys()
1652
+
1653
+ def __len__(self) -> int:
1654
+ """Return total count of elements across all containers."""
1655
+ return sum(len(container) for container in self._get_container_groups().values())
1656
+
1657
+ def __contains__(self, key: str) -> bool:
1658
+ """Check if element exists in any container."""
1659
+ return any(key in container for container in self._get_container_groups().values())
1660
+
1661
+ def keys(self) -> list[str]:
1662
+ """Return all element labels across all containers."""
1663
+ return list(self)
1664
+
1665
+ def values(self) -> list[T_element]:
1666
+ """Return all element objects across all containers."""
1667
+ vals = []
1668
+ for container in self._get_container_groups().values():
1669
+ vals.extend(container.values())
1670
+ return vals
1671
+
1672
+ def items(self) -> list[tuple[str, T_element]]:
1673
+ """Return (label, element) pairs for all elements."""
1674
+ items = []
1675
+ for container in self._get_container_groups().values():
1676
+ items.extend(container.items())
1677
+ return items
1678
+
1679
+ def _format_grouped_containers(self, title: str | None = None) -> str:
1680
+ """
1681
+ Format containers as grouped string representation using each container's repr.
1682
+
1683
+ Args:
1684
+ title: Optional title for the representation. If None, no title is shown.
1685
+
1686
+ Returns:
1687
+ Formatted string with groups and their elements.
1688
+ Empty groups are automatically hidden.
1689
+
1690
+ Example output:
1691
+ ```
1692
+ Components (1 item)
1693
+ -------------------
1694
+ * Boiler
1695
+
1696
+ Buses (2 items)
1697
+ ---------------
1698
+ * Heat
1699
+ * Power
1700
+ ```
1701
+ """
1702
+ parts = []
1703
+
1704
+ if title:
1705
+ parts.append(fx_io.format_title_with_underline(title))
1706
+
1707
+ container_groups = self._get_container_groups()
1708
+ for container in container_groups.values():
1709
+ if container: # Only show non-empty groups
1710
+ if parts: # Add spacing between sections
1711
+ parts.append('')
1712
+ # Use container's __repr__ which respects its truncate_repr setting
1713
+ parts.append(repr(container).rstrip('\n'))
1714
+
1715
+ return '\n'.join(parts)
1716
+
1717
+
903
1718
  class Submodel(SubmodelsMixin):
904
1719
  """Stores Variables and Constraints. Its a subset of a FlowSystemModel.
905
1720
  Variables and constraints are stored in the main FlowSystemModel, and are referenced here.
@@ -924,8 +1739,22 @@ class Submodel(SubmodelsMixin):
924
1739
  logger.debug(f'Creating {self.__class__.__name__} "{self.label_full}"')
925
1740
  self._do_modeling()
926
1741
 
927
- def add_variables(self, short_name: str = None, **kwargs) -> linopy.Variable:
928
- """Create and register a variable in one step"""
1742
+ def add_variables(
1743
+ self,
1744
+ short_name: str = None,
1745
+ category: VariableCategory = None,
1746
+ **kwargs: Any,
1747
+ ) -> linopy.Variable:
1748
+ """Create and register a variable in one step.
1749
+
1750
+ Args:
1751
+ short_name: Short name for the variable (used as suffix in full name).
1752
+ category: Category for segment expansion handling. See VariableCategory.
1753
+ **kwargs: Additional arguments passed to linopy.Model.add_variables().
1754
+
1755
+ Returns:
1756
+ The created linopy Variable.
1757
+ """
929
1758
  if kwargs.get('name') is None:
930
1759
  if short_name is None:
931
1760
  raise ValueError('Short name must be provided when no name is given')
@@ -933,6 +1762,11 @@ class Submodel(SubmodelsMixin):
933
1762
 
934
1763
  variable = self._model.add_variables(**kwargs)
935
1764
  self.register_variable(variable, short_name)
1765
+
1766
+ # Register category in FlowSystemModel for segment expansion handling
1767
+ if category is not None:
1768
+ self._model.variable_categories[variable.name] = category
1769
+
936
1770
  return variable
937
1771
 
938
1772
  def add_constraints(self, expression, short_name: str = None, **kwargs) -> linopy.Constraint:
@@ -1061,9 +1895,7 @@ class Submodel(SubmodelsMixin):
1061
1895
  }
1062
1896
 
1063
1897
  # Format sections with headers and underlines
1064
- formatted_sections = []
1065
- for section_header, section_content in sections.items():
1066
- formatted_sections.append(f'{section_header}\n{"-" * len(section_header)}\n{section_content}')
1898
+ formatted_sections = fx_io.format_sections_with_headers(sections)
1067
1899
 
1068
1900
  model_string = f'Submodel "{self.label_of_model}":'
1069
1901
  all_sections = '\n'.join(formatted_sections)
@@ -1071,11 +1903,16 @@ class Submodel(SubmodelsMixin):
1071
1903
  return f'{model_string}\n{"=" * len(model_string)}\n\n{all_sections}'
1072
1904
 
1073
1905
  @property
1074
- def hours_per_step(self):
1075
- return self._model.hours_per_step
1906
+ def timestep_duration(self):
1907
+ return self._model.timestep_duration
1076
1908
 
1077
1909
  def _do_modeling(self):
1078
- """Called at the end of initialization. Override in subclasses to create variables and constraints."""
1910
+ """
1911
+ Override in subclasses to create variables, constraints, and submodels.
1912
+
1913
+ This method is called during __init__. Create all nested submodels first
1914
+ (so their variables exist), then create constraints that reference those variables.
1915
+ """
1079
1916
  pass
1080
1917
 
1081
1918
 
@@ -1107,7 +1944,7 @@ class Submodels:
1107
1944
  def __repr__(self) -> str:
1108
1945
  """Simple representation of the submodels collection."""
1109
1946
  if not self.data:
1110
- return 'flixopt.structure.Submodels:\n----------------------------\n <empty>\n'
1947
+ return fx_io.format_title_with_underline('flixopt.structure.Submodels') + ' <empty>\n'
1111
1948
 
1112
1949
  total_vars = sum(len(submodel.variables) for submodel in self.data.values())
1113
1950
  total_cons = sum(len(submodel.constraints) for submodel in self.data.values())
@@ -1115,18 +1952,15 @@ class Submodels:
1115
1952
  title = (
1116
1953
  f'flixopt.structure.Submodels ({total_vars} vars, {total_cons} constraints, {len(self.data)} submodels):'
1117
1954
  )
1118
- underline = '-' * len(title)
1119
1955
 
1120
- if not self.data:
1121
- return f'{title}\n{underline}\n <empty>\n'
1122
- sub_models_string = ''
1956
+ result = fx_io.format_title_with_underline(title)
1123
1957
  for name, submodel in self.data.items():
1124
1958
  type_name = submodel.__class__.__name__
1125
1959
  var_count = len(submodel.variables)
1126
1960
  con_count = len(submodel.constraints)
1127
- sub_models_string += f'\n * {name} [{type_name}] ({var_count}v/{con_count}c)'
1961
+ result += f' * {name} [{type_name}] ({var_count}v/{con_count}c)\n'
1128
1962
 
1129
- return f'{title}\n{underline}{sub_models_string}\n'
1963
+ return result
1130
1964
 
1131
1965
  def items(self) -> ItemsView[str, Submodel]:
1132
1966
  return self.data.items()