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.
- flixopt/__init__.py +57 -49
- flixopt/carrier.py +159 -0
- flixopt/clustering/__init__.py +51 -0
- flixopt/clustering/base.py +1746 -0
- flixopt/clustering/intercluster_helpers.py +201 -0
- flixopt/color_processing.py +372 -0
- flixopt/comparison.py +819 -0
- flixopt/components.py +848 -270
- flixopt/config.py +853 -496
- flixopt/core.py +111 -98
- flixopt/effects.py +294 -284
- flixopt/elements.py +484 -223
- flixopt/features.py +220 -118
- flixopt/flow_system.py +2026 -389
- flixopt/interface.py +504 -286
- flixopt/io.py +1718 -55
- flixopt/linear_converters.py +291 -230
- flixopt/modeling.py +304 -181
- flixopt/network_app.py +2 -1
- flixopt/optimization.py +788 -0
- flixopt/optimize_accessor.py +373 -0
- flixopt/plot_result.py +143 -0
- flixopt/plotting.py +1177 -1034
- flixopt/results.py +1331 -372
- flixopt/solvers.py +12 -4
- flixopt/statistics_accessor.py +2412 -0
- flixopt/stats_accessor.py +75 -0
- flixopt/structure.py +954 -120
- flixopt/topology_accessor.py +676 -0
- flixopt/transform_accessor.py +2277 -0
- flixopt/types.py +120 -0
- flixopt-6.0.0rc7.dist-info/METADATA +290 -0
- flixopt-6.0.0rc7.dist-info/RECORD +36 -0
- {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/WHEEL +1 -1
- flixopt/aggregation.py +0 -382
- flixopt/calculation.py +0 -672
- flixopt/commons.py +0 -51
- flixopt/utils.py +0 -86
- flixopt-3.0.1.dist-info/METADATA +0 -209
- flixopt-3.0.1.dist-info/RECORD +0 -26
- {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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 .
|
|
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
|
|
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
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
'
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
|
183
|
-
|
|
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.
|
|
438
|
+
coords = dict(self.flow_system.indexes)
|
|
212
439
|
else:
|
|
213
|
-
|
|
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(
|
|
488
|
+
transform_data(): Transform data to match FlowSystem dimensions
|
|
272
489
|
"""
|
|
273
490
|
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
928
|
-
|
|
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
|
|
1075
|
-
return self._model.
|
|
1906
|
+
def timestep_duration(self):
|
|
1907
|
+
return self._model.timestep_duration
|
|
1076
1908
|
|
|
1077
1909
|
def _do_modeling(self):
|
|
1078
|
-
"""
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1961
|
+
result += f' * {name} [{type_name}] ({var_count}v/{con_count}c)\n'
|
|
1128
1962
|
|
|
1129
|
-
return
|
|
1963
|
+
return result
|
|
1130
1964
|
|
|
1131
1965
|
def items(self) -> ItemsView[str, Submodel]:
|
|
1132
1966
|
return self.data.items()
|