Skip to content

Energy System API

EnergySystem

Energy system configuration and optimization.

This module provides the EnergySystem class, the main entry point for configuring and optimizing energy systems.

EnergySystem

Energy system configuration and optimization orchestrator.

This class provides a high-level interface for configuring and optimizing energy systems. It handles system validation, model building, and optimization execution through external solvers.

Source code in src/odys/energy_system.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
class EnergySystem:
    """Energy system configuration and optimization orchestrator.

    This class provides a high-level interface for configuring and optimizing
    energy systems. It handles system validation, model building, and
    optimization execution through external solvers.
    """

    def __init__(  # noqa: PLR0913
        self,
        portfolio: AssetPortfolio,
        timestep: timedelta,
        number_of_steps: int,
        power_unit: str,
        scenarios: Scenario | Sequence[StochasticScenario],
        markets: EnergyMarket | Sequence[EnergyMarket] | None = None,
    ) -> None:
        """Initialize the energy system configuration and optimizer.

        Args:
            portfolio: The portfolio of energy assets (generators, batteries, etc.).
            timestep: Duration of each time period.
            number_of_steps: Number of time steps.
            power_unit: Unit used for power quantities ('W', 'kW', or 'MW').
            scenarios: Sequence of stochastic scenarios. Probabilities must add up to 1.
            markets: Optional energy markets in which assets can participate.

        """
        self._validated_model = ValidatedEnergySystem(
            portfolio=portfolio,
            markets=markets,
            timestep=timestep,
            number_of_steps=number_of_steps,
            power_unit=power_unit,  # ty: ignore  # pyright: ignore[reportArgumentType]
            scenarios=scenarios,
        )

    def optimize(self) -> OptimizationResults:
        """Optimize the energy system.

        This method solves the pre-built algebraic model using HiGHS solver.
        The model is built during optimization from the energy system configuration.

        Returns:
            OptimizationResults containing the solution and metadata.

        """
        model_builder = EnergyAlgebraicModelBuilder(
            energy_system_parameters=self._validated_model.energy_system_parameters,
        )
        milp_model = model_builder.build()
        return optimize_algebraic_model(milp_model)

__init__(portfolio, timestep, number_of_steps, power_unit, scenarios, markets=None)

Initialize the energy system configuration and optimizer.

Parameters:

Name Type Description Default
portfolio AssetPortfolio

The portfolio of energy assets (generators, batteries, etc.).

required
timestep timedelta

Duration of each time period.

required
number_of_steps int

Number of time steps.

required
power_unit str

Unit used for power quantities ('W', 'kW', or 'MW').

required
scenarios Scenario | Sequence[StochasticScenario]

Sequence of stochastic scenarios. Probabilities must add up to 1.

required
markets EnergyMarket | Sequence[EnergyMarket] | None

Optional energy markets in which assets can participate.

None
Source code in src/odys/energy_system.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def __init__(  # noqa: PLR0913
    self,
    portfolio: AssetPortfolio,
    timestep: timedelta,
    number_of_steps: int,
    power_unit: str,
    scenarios: Scenario | Sequence[StochasticScenario],
    markets: EnergyMarket | Sequence[EnergyMarket] | None = None,
) -> None:
    """Initialize the energy system configuration and optimizer.

    Args:
        portfolio: The portfolio of energy assets (generators, batteries, etc.).
        timestep: Duration of each time period.
        number_of_steps: Number of time steps.
        power_unit: Unit used for power quantities ('W', 'kW', or 'MW').
        scenarios: Sequence of stochastic scenarios. Probabilities must add up to 1.
        markets: Optional energy markets in which assets can participate.

    """
    self._validated_model = ValidatedEnergySystem(
        portfolio=portfolio,
        markets=markets,
        timestep=timestep,
        number_of_steps=number_of_steps,
        power_unit=power_unit,  # ty: ignore  # pyright: ignore[reportArgumentType]
        scenarios=scenarios,
    )

optimize()

Optimize the energy system.

This method solves the pre-built algebraic model using HiGHS solver. The model is built during optimization from the energy system configuration.

Returns:

Type Description
OptimizationResults

OptimizationResults containing the solution and metadata.

Source code in src/odys/energy_system.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def optimize(self) -> OptimizationResults:
    """Optimize the energy system.

    This method solves the pre-built algebraic model using HiGHS solver.
    The model is built during optimization from the energy system configuration.

    Returns:
        OptimizationResults containing the solution and metadata.

    """
    model_builder = EnergyAlgebraicModelBuilder(
        energy_system_parameters=self._validated_model.energy_system_parameters,
    )
    milp_model = model_builder.build()
    return optimize_algebraic_model(milp_model)

Scenarios

Scenario definitions for energy system optimization models.

Scenario

Bases: BaseModel

Scenario conditions.

Source code in src/odys/energy_system_models/scenarios.py
 8
 9
10
11
12
13
14
15
16
17
18
class Scenario(BaseModel):
    """Scenario conditions."""

    model_config = ConfigDict(strict=True)

    available_capacity_profiles: Mapping[str, Sequence[float]] | None = Field(
        default=None,
        description="Available capacity for each asset.",
    )
    load_profiles: Mapping[str, Sequence[float]] | None = Field(default=None, description="Load profiles")
    market_prices: Mapping[str, Sequence[float]] | None = Field(default=None, description="Market prices.")

StochasticScenario

Bases: Scenario

Stochastic scenario conditions.

Source code in src/odys/energy_system_models/scenarios.py
21
22
23
24
25
class StochasticScenario(Scenario):
    """Stochastic scenario conditions."""

    name: str
    probability: float = Field(ge=0, le=1, description="Probability (0-1) of the scenario.")

validate_sequence_of_stochastic_scenarios(scenarios)

Validate that scenarios probabilities add up to 1.

Parameters:

Name Type Description Default
scenarios Sequence[StochasticScenario]

Sequence of scenarios.

required

Raises:

Type Description
ValueError

If sum of probabilities is different than 1.

Source code in src/odys/energy_system_models/scenarios.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def validate_sequence_of_stochastic_scenarios(
    scenarios: Sequence[StochasticScenario],
) -> None:
    """Validate that scenarios probabilities add up to 1.

    Args:
        scenarios: Sequence of scenarios.

    Raises:
        ValueError: If sum of probabilities is different than 1.
    """
    sum_of_probabilities = sum(scenario.probability for scenario in scenarios)
    if sum_of_probabilities != 1.0:
        msg = f"Scenarios should add up to 1, but got sum = {sum_of_probabilities} instead."
        raise ValueError(msg)

    scenario_names = [scenario.name for scenario in scenarios]
    duplicated_scenario_names = {scenario for scenario in scenario_names if scenario_names.count(scenario) > 1}
    if duplicated_scenario_names:
        msg = (
            f"Scenarios must have a unique name. The following names appear more than once: {duplicated_scenario_names}"
        )
        raise ValueError(msg)

Assets

Base Asset

Base classes for energy system assets.

This module defines the base classes and interfaces for energy system assets including the EnergyAsset abstract base class.

EnergyAsset

Bases: BaseModel, ABC

Base class for all energy system assets.

This abstract class defines the common interface for energy assets like generators, batteries, and other energy system components.

Source code in src/odys/energy_system_models/assets/base.py
12
13
14
15
16
17
18
19
20
21
class EnergyAsset(BaseModel, ABC):  # pyright: ignore[reportUnsafeMultipleInheritance]
    """Base class for all energy system assets.

    This abstract class defines the common interface for energy assets
    like generators, batteries, and other energy system components.
    """

    model_config = ConfigDict(frozen=True)

    name: str

PowerGenerator

Power generator asset implementation.

This module provides the PowerGenerator class for modeling electrical generators in energy system optimization problems.

PowerGenerator

Bases: EnergyAsset

Represents a power generator in the energy system.

This class models generators with various operational constraints including nominal power, variable costs, ramp rates, and startup/shutdown costs.

Source code in src/odys/energy_system_models/assets/generator.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
class PowerGenerator(EnergyAsset):
    """Represents a power generator in the energy system.

    This class models generators with various operational constraints
    including nominal power, variable costs, ramp rates, and startup/shutdown costs.
    """

    nominal_power: float = Field(
        strict=True,
        gt=0,
        description="Nominal power of the generator in MW.",
    )

    variable_cost: float = Field(
        strict=True,
        gt=0,
        description="Variable cost of the generator in currency per MWh.",
    )

    ramp_up: float | None = Field(
        default=None,
        strict=True,
        ge=0,
        description="Ramp-up rate of the generator in MW per hour",
    )

    ramp_down: float | None = Field(
        default=None,
        strict=True,
        ge=0,
        description="Ramp-down rate of the generator in MW per hour",
    )

    min_up_time: int = Field(
        default=1,
        strict=True,
        ge=1,
        description="Minimum up time",
    )

    min_down_time: int = Field(
        default=1,
        strict=True,
        ge=1,
        description="Minimum down time",
    )

    min_power: float = Field(
        default=0.0,
        strict=True,
        ge=0,
        description="Minimum power output",
    )

    startup_cost: float = Field(
        default=0.0,
        strict=True,
        ge=0,
        description="Startup cost of the generator, in currency per MWh.",
    )

    shutdown_cost: float | None = Field(
        default=None,
        strict=True,
        ge=0,
        description="Shutdown cost of the generator, in currency per MWh",
    )

Battery

Energy storage asset implementation.

This module provides the Battery class for modeling energy storage devices in energy system optimization problems.

Battery

Bases: EnergyAsset

Represents a battery storage system in the energy system.

This class models batteries with various operational constraints including capacity, power limits, efficiency, state of charge, and degradation characteristics.

Source code in src/odys/energy_system_models/assets/storage.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
class Battery(EnergyAsset):
    """Represents a battery storage system in the energy system.

    This class models batteries with various operational constraints
    including capacity, power limits, efficiency, state of charge,
    and degradation characteristics.
    """

    capacity: float = Field(
        strict=True,
        gt=0,
        description="Battery capacity in MWh.",
    )
    max_power: float = Field(
        strict=True,
        gt=0,
        description="Maximum power in MW.",
    )
    efficiency_charging: float = Field(
        strict=True,
        gt=0,
        le=1,
        description="Charging efficiency (0-1).",
    )
    efficiency_discharging: float = Field(
        strict=True,
        gt=0,
        le=1,
        description="Discharging efficiency (0-1).",
    )
    soc_start: float = Field(
        strict=True,
        ge=0,
        le=1,
        description="Initial state of charge as a fraction of capacity (0-1).",
    )
    soc_end: float | None = Field(
        default=None,
        strict=True,
        ge=0,
        le=1,
        description="Final state of charge as a fraction of capacity (0-1).",
    )
    soc_min: float = Field(
        default=0,
        strict=True,
        ge=0,
        le=1,
        description="Minimum state of charge as a fraction of capacity (0-1).",
    )
    soc_max: float = Field(
        default=1,
        strict=True,
        ge=0,
        le=1,
        description="Maximum state of charge as a fraction of capacity (0-1).",
    )
    degradation_cost: float | None = Field(
        default=None,
        strict=True,
        ge=0,
        description="Degradation cost, in currency per MWh cycled.",
    )
    self_discharge_rate: float | None = Field(
        default=None,
        strict=True,
        ge=0,
        le=1,
        description="Self-discharge rate (0-1) per hour.",
    )

    @model_validator(mode="after")
    def _validate_soc_start_and_terminal(self) -> Self:
        for name in ("soc_start", "soc_end"):
            battery_soc = getattr(self, name)
            if battery_soc is None:
                continue

            if battery_soc < self.soc_min:
                msg = f"{name} ({battery_soc}) must be ≥ soc_min ({self.soc_min})."
                raise ValueError(msg)
            if battery_soc > self.soc_max:
                msg = f"{name} ({battery_soc}) must be ≤ soc_max ({self.soc_max})."
                raise ValueError(msg)

        return self

    @model_validator(mode="after")
    def _validate_soc_min_less_than_max(self) -> Self:
        if self.soc_min >= self.soc_max:
            msg = f"soc_min ({self.soc_min}) must be < soc_max ({self.soc_max})."
            raise ValueError(msg)
        return self

Load

Load asset definitions for energy system models.

Load

Bases: EnergyAsset

Represents a load asset in the energy system.

Source code in src/odys/energy_system_models/assets/load.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class Load(EnergyAsset):
    """Represents a load asset in the energy system."""

    type: LoadType = Field(
        default=LoadType.Fixed,
        strict=True,
        description="Type of load",
    )

    variable_cost_to_increase: float | None = Field(
        default=None,
        strict=True,
        description="Variable cost of changing the load currency per MWh.",
    )

    variable_cost_to_decrease: float | None = Field(
        default=None,
        strict=True,
        description="Variable cost of changing the load currency per MWh.",
    )

    @model_validator(mode="after")
    def _validate_type_and_variable_cost(self) -> Self:
        if self.type == LoadType.Fixed and (self.variable_cost_to_decrease or self.variable_cost_to_increase):
            msg = "`variable_cost_to_decrease` and `variable_cost_to_incrase` are fields valid only for Flexible loads."
            raise ValueError(msg)
        if self.type == LoadType.Flexible and not (self.variable_cost_to_decrease and self.variable_cost_to_increase):
            msg = "`variable_cost_to_decrease` and `variable_cost_to_incrase` must be specified for Flexible load"
            raise ValueError(msg)
        return self

LoadType

Bases: StrEnum

Load type enumeration.

Source code in src/odys/energy_system_models/assets/load.py
11
12
13
14
15
class LoadType(StrEnum):
    """Load type enumeration."""

    Fixed = "fixed"
    Flexible = "flexible"

AssetPortfolio

Asset portfolio management for energy systems.

This module provides the AssetPortfolio class for managing collections of energy system assets including generators, batteries, and other components.

AssetPortfolio

A collection of energy system assets.

This class manages a portfolio of energy assets including generators, batteries, and other energy system components. It provides methods to add, retrieve, and filter assets by type.

Source code in src/odys/energy_system_models/assets/portfolio.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
class AssetPortfolio:
    """A collection of energy system assets.

    This class manages a portfolio of energy assets including generators,
    batteries, and other energy system components. It provides methods
    to add, retrieve, and filter assets by type.
    """

    def __init__(self) -> None:
        """Initialize an empty asset portfolio."""
        self._assets: dict[str, EnergyAsset] = {}

    def add_asset(self, asset: EnergyAsset) -> None:
        """Add an energy asset to the portfolio.

        Args:
            asset: The energy asset to add to the portfolio.

        Raises:
            ValueError: If an asset with the same name already exists.
            TypeError: If the asset is not an instance of EnergyAsset.

        """
        if asset.name in self._assets:
            msg = f"Asset with name '{asset.name}' already exists."
            raise ValueError(msg)
        self._assets[asset.name] = asset

    def get_asset(self, name: str) -> EnergyAsset:
        """Retrieve an asset from the portfolio by name.

        Args:
            name: The name of the asset to retrieve.

        Returns:
            The energy asset with the specified name.

        Raises:
            KeyError: If no asset with the specified name exists.

        """
        if name not in self._assets:
            msg = f"Asset with name '{name}' does not exist."
            raise KeyError(msg)
        return self._assets[name]

    def _get_assets_by_type(self, asset_type: type[T]) -> tuple[T, ...]:
        return tuple(asset for asset in self._assets.values() if isinstance(asset, asset_type))

    @property
    def assets(self) -> MappingProxyType[str, EnergyAsset]:
        """Get a read-only view of all assets in the portfolio.

        Returns:
            A mapping proxy containing all assets indexed by name.

        """
        return MappingProxyType(self._assets)

    @property
    def generators(self) -> tuple[PowerGenerator, ...]:
        """Get all power generators in the portfolio.

        Returns:
            A tuple containing all PowerGenerator assets.

        """
        return self._get_assets_by_type(PowerGenerator)

    @property
    def batteries(self) -> tuple[Battery, ...]:
        """Get all batteries in the portfolio.

        Returns:
            A tuple containing all Battery assets.

        """
        return self._get_assets_by_type(Battery)

    @property
    def loads(self) -> tuple[Load, ...]:
        """Get all the loads in the portfolio.

        Returns:
            A tuple containing all Load assets.

        """
        return self._get_assets_by_type(Load)

assets property

Get a read-only view of all assets in the portfolio.

Returns:

Type Description
MappingProxyType[str, EnergyAsset]

A mapping proxy containing all assets indexed by name.

batteries property

Get all batteries in the portfolio.

Returns:

Type Description
tuple[Battery, ...]

A tuple containing all Battery assets.

generators property

Get all power generators in the portfolio.

Returns:

Type Description
tuple[PowerGenerator, ...]

A tuple containing all PowerGenerator assets.

loads property

Get all the loads in the portfolio.

Returns:

Type Description
tuple[Load, ...]

A tuple containing all Load assets.

__init__()

Initialize an empty asset portfolio.

Source code in src/odys/energy_system_models/assets/portfolio.py
26
27
28
def __init__(self) -> None:
    """Initialize an empty asset portfolio."""
    self._assets: dict[str, EnergyAsset] = {}

add_asset(asset)

Add an energy asset to the portfolio.

Parameters:

Name Type Description Default
asset EnergyAsset

The energy asset to add to the portfolio.

required

Raises:

Type Description
ValueError

If an asset with the same name already exists.

TypeError

If the asset is not an instance of EnergyAsset.

Source code in src/odys/energy_system_models/assets/portfolio.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
def add_asset(self, asset: EnergyAsset) -> None:
    """Add an energy asset to the portfolio.

    Args:
        asset: The energy asset to add to the portfolio.

    Raises:
        ValueError: If an asset with the same name already exists.
        TypeError: If the asset is not an instance of EnergyAsset.

    """
    if asset.name in self._assets:
        msg = f"Asset with name '{asset.name}' already exists."
        raise ValueError(msg)
    self._assets[asset.name] = asset

get_asset(name)

Retrieve an asset from the portfolio by name.

Parameters:

Name Type Description Default
name str

The name of the asset to retrieve.

required

Returns:

Type Description
EnergyAsset

The energy asset with the specified name.

Raises:

Type Description
KeyError

If no asset with the specified name exists.

Source code in src/odys/energy_system_models/assets/portfolio.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def get_asset(self, name: str) -> EnergyAsset:
    """Retrieve an asset from the portfolio by name.

    Args:
        name: The name of the asset to retrieve.

    Returns:
        The energy asset with the specified name.

    Raises:
        KeyError: If no asset with the specified name exists.

    """
    if name not in self._assets:
        msg = f"Asset with name '{name}' does not exist."
        raise KeyError(msg)
    return self._assets[name]

Markets

Energy market definitions for energy system models.

EnergyMarket

Bases: BaseModel

Represents an energy market in the energy system.

Source code in src/odys/energy_system_models/markets.py
16
17
18
19
20
21
22
23
24
25
26
27
class EnergyMarket(BaseModel):
    """Represents an energy market in the energy system."""

    model_config = ConfigDict(extra="forbid")

    name: str
    max_trading_volume_per_step: float = Field(gt=0)
    trade_direction: TradeDirection = TradeDirection.BOTH
    stage_fixed: bool = Field(
        default=False,
        description="If true, the associated variables are fixed across scenarios.",
    )

TradeDirection

Bases: StrEnum

Direction of the market positions.

Source code in src/odys/energy_system_models/markets.py
 8
 9
10
11
12
13
class TradeDirection(StrEnum):
    """Direction of the market positions."""

    BUY = "buy"
    SELL = "sell"
    BOTH = "both"

Validated Energy System

Validated energy system configuration.

This module provides the ValidatedEnergySystem class which validates and transforms user-provided energy system configurations into parameters suitable for the optimization model.

ValidatedEnergySystem

Bases: BaseModel

Represents the complete energy system configuration with validation.

This class defines the energy system including the asset portfolio, demand profile, time discretization, and available capacity profiles. It performs comprehensive validation to ensure the system is feasible:

  • Validates that capacity profile lengths match demand profile length
  • Ensures available capacity profiles are only specified for generators
  • Verifies that maximum available power can meet peak demand
  • Checks that total energy capacity can meet total energy demand

Raises:

Type Description
ValueError

If the system configuration is infeasible.

TypeError

If available capacity is specified for non-generator assets.

Source code in src/odys/energy_system_models/validated_energy_system.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
class ValidatedEnergySystem(BaseModel):
    """Represents the complete energy system configuration with validation.

    This class defines the energy system including the asset portfolio,
    demand profile, time discretization, and available capacity profiles.
    It performs comprehensive validation to ensure the system is feasible:

    - Validates that capacity profile lengths match demand profile length
    - Ensures available capacity profiles are only specified for generators
    - Verifies that maximum available power can meet peak demand
    - Checks that total energy capacity can meet total energy demand

    Raises:
        ValueError: If the system configuration is infeasible.
        TypeError: If available capacity is specified for non-generator assets.
    """

    model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True, extra="forbid")

    portfolio: AssetPortfolio
    timestep: timedelta
    number_of_steps: int
    power_unit: PowerUnit
    markets: EnergyMarket | Sequence[EnergyMarket] | None = Field(default=None, init_var=True)
    scenarios: Scenario | Sequence[StochasticScenario] = Field(init_var=True)

    @field_validator("scenarios", mode="after")
    @staticmethod
    def _validated_scenario_sequence(
        value: Scenario | list[StochasticScenario],
    ) -> Scenario | list[StochasticScenario]:
        if isinstance(value, list):
            validate_sequence_of_stochastic_scenarios(value)

        return value

    @cached_property
    def _collection_of_scenarios(self) -> tuple[StochasticScenario, ...]:
        if isinstance(self.scenarios, Scenario):
            return (
                StochasticScenario(
                    name="deterministic_scenario",
                    probability=1.0,
                    available_capacity_profiles=self.scenarios.available_capacity_profiles,
                    load_profiles=self.scenarios.load_profiles,
                    market_prices=self.scenarios.market_prices,
                ),
            )

        return tuple(self.scenarios)

    @cached_property
    def _collection_of_markets(self) -> tuple[EnergyMarket, ...]:
        if not self.markets:
            return ()
        if isinstance(self.markets, EnergyMarket):
            return (self.markets,)

        return tuple(self.markets)

    @cached_property
    def energy_system_parameters(self) -> EnergySystemParameters:
        """Parameters of the energy system."""
        return EnergySystemParameters(
            generators=self._generator_parameters,
            batteries=self._battery_parameters,
            loads=self._load_parameters,
            markets=self._market_parameters,
            scenarios=self._scenario_parameters,
        )

    @cached_property
    def _scenario_parameters(self) -> ScenarioParameters:
        generators_index = self._generator_parameters.index if self._generator_parameters else None
        batteries_index = self._battery_parameters.index if self._battery_parameters else None
        loads_index = self._load_parameters.index if self._load_parameters else None
        markets_index = self._market_parameters.index if self._market_parameters else None

        return ScenarioParameters(
            number_of_timesteps=self.number_of_steps,
            scenarios=self._collection_of_scenarios,
            generators_index=generators_index,
            batteries_index=batteries_index,
            loads_index=loads_index,
            markets_index=markets_index,
        )

    @property
    def _generator_parameters(self) -> GeneratorParameters | None:
        if len(self.portfolio.generators) == 0:
            return None
        return GeneratorParameters(generators=self.portfolio.generators)

    @property
    def _battery_parameters(self) -> BatteryParameters | None:
        if len(self.portfolio.batteries) == 0:
            return None
        return BatteryParameters(self.portfolio.batteries)

    @property
    def _load_parameters(self) -> LoadParameters | None:
        if len(self.portfolio.loads) == 0:
            return None
        return LoadParameters(loads=self.portfolio.loads)

    @property
    def _market_parameters(self) -> MarketParameters | None:
        if self.markets is None:
            return None
        return MarketParameters(self._collection_of_markets)

    @model_validator(mode="after")
    def _validate_inputs(self) -> Self:
        self._validate_load_consistent_with_scenario_load_profiles()
        self._validate_markets_consistent_with_scenario_market_prices()

        for scenario in self._collection_of_scenarios:
            self._validate_available_capacity_scenario(scenario)
            self._validate_load_profiles(scenario)

            if not self.markets:
                self._validate_enough_power_to_meet_demand(scenario)
                self._validate_enough_energy_to_meet_demand(scenario)

        return self

    def _validate_load_consistent_with_scenario_load_profiles(self) -> None:
        """Validate consistency between portfolio loads and scenario load profiles.

        If there are loads in the portfolio, each scenario must have a profile for each load.
        If there are no loads in the portfolio, all scenarios should have load_profiles=None.

        Raises:
            ValueError: If load profiles are inconsistent with portfolio loads.
        """
        has_loads = bool(self.portfolio.loads)

        for scenario in self._collection_of_scenarios:
            if has_loads:
                if scenario.load_profiles is None:
                    msg = (
                        f"Portfolio contains loads {[load.name for load in self.portfolio.loads]}, "
                        f"but scenario '{scenario.name}' has no load profiles."
                    )
                    raise ValueError(msg)

                portfolio_load_names = {load.name for load in self.portfolio.loads}
                scenario_load_names = set(scenario.load_profiles.keys())

                missing_loads = portfolio_load_names - scenario_load_names
                if missing_loads:
                    msg = f"Scenario '{scenario.name}' is missing load profiles for: {sorted(missing_loads)}"
                    raise ValueError(msg)

                extra_loads = scenario_load_names - portfolio_load_names
                if extra_loads:
                    msg = (
                        f"Scenario '{scenario.name}' has load profiles for loads not in portfolio: "
                        f"{sorted(extra_loads)}"
                    )
                    raise ValueError(msg)
            elif scenario.load_profiles is not None:
                msg = (
                    f"Portfolio contains no loads, but scenario '{scenario.name}' "
                    f"has load profiles: {list(scenario.load_profiles.keys())}"
                )
                raise ValueError(msg)

    def _validate_markets_consistent_with_scenario_market_prices(self) -> None:
        """Validate consistency between portfolio markets and scenario market prices.

        If there are markets in the portfolio, each scenario must have prices for each market.
        If there are no markets in the portfolio, all scenarios should have market_prices=None.

        Raises:
            ValueError: If market prices are inconsistent with portfolio markets.
        """
        has_markets = bool(self._collection_of_markets)

        for scenario in self._collection_of_scenarios:
            if has_markets:
                if scenario.market_prices is None:
                    msg = (
                        f"Portfolio contains markets {[market.name for market in self._collection_of_markets]}, "
                        f"but scenario '{scenario.name}' has no market prices."
                    )
                    raise ValueError(msg)

                portfolio_market_names = {market.name for market in self._collection_of_markets}
                scenario_market_names = set(scenario.market_prices.keys())

                missing_markets = portfolio_market_names - scenario_market_names
                if missing_markets:
                    msg = f"Scenario '{scenario.name}' is missing market prices for: {sorted(missing_markets)}"
                    raise ValueError(msg)

                extra_markets = scenario_market_names - portfolio_market_names
                if extra_markets:
                    msg = (
                        f"Scenario '{scenario.name}' has market prices for markets not in portfolio: "
                        f"{sorted(extra_markets)}"
                    )
                    raise ValueError(msg)
            elif scenario.market_prices is not None:
                msg = (
                    f"Portfolio contains no markets, but scenario '{scenario.name}' "
                    f"has market prices: {list(scenario.market_prices.keys())}"
                )
                raise ValueError(msg)

    def _validate_load_profiles(self, scenario: Scenario) -> None:
        """Validate that load profile lengths match the number of time steps.

        Raises:
            ValueError: If a load profile length doesn't match the number of time steps.

        """
        if scenario.load_profiles is None:
            return

        for load_name, load_profile in scenario.load_profiles.items():
            if len(load_profile) != self.number_of_steps:
                msg = (
                    f"Length of load profile {load_name} ({len(load_profile)})"
                    f" does not match the number of time steps ({self.number_of_steps})."
                )
                raise ValueError(msg)

    def _validate_available_capacity_scenario(self, scenario: Scenario) -> None:
        """Validate that available capacity profiles are only for generators.

        Raises:
            TypeError: If available capacity is specified for non-generator assets.
            ValueError: If capacity profile length doesn't match demand profile.

        """
        if scenario.available_capacity_profiles is None:
            return

        for asset_name, capacity_profile in scenario.available_capacity_profiles.items():
            asset = self.portfolio.get_asset(asset_name)
            if not isinstance(asset, PowerGenerator):
                msg = (
                    "Available capacity can only be specified for generators, "
                    f"but got '{asset_name}' of type {type(asset)}."
                )
                raise TypeError(msg)
            if len(capacity_profile) != self.number_of_steps:
                msg = (
                    f"Length of capacity profile for {asset_name} ({len(capacity_profile)})"
                    f" does not match the number of time steps ({self.number_of_steps})."
                )
                raise ValueError(msg)
            for capacity_i in capacity_profile:
                if not (0 <= capacity_i <= asset.nominal_power):
                    msg = (
                        f"Available capacity value {capacity_i} for asset '{asset_name}' is invalid. "
                        f"Values must be between 0 and the asset's nominal power ({asset.nominal_power})."
                    )
                    raise ValueError(msg)

    def _validate_enough_power_to_meet_demand(self, scenario: StochasticScenario) -> None:
        """Validate that maximum available power can meet peak demand.

        This method checks that the sum of generator nominal power and
        battery capacity can meet the maximum demand at any time period.

        Raises:
            ValueError: If maximum available power is insufficient for peak demand.

        """
        if scenario.load_profiles is None:
            msg = "Load profile is empty, there is nothing to balance."
            raise ValueError(msg)

        cumulative_generators_power = sum(gen.nominal_power for gen in self.portfolio.generators)
        # TODO: We assume full capacity can be discharged -> Needs to be limited by max power
        cumulative_battery_capacities = sum(bat.capacity for bat in self.portfolio.batteries)
        max_available_power = cumulative_generators_power + cumulative_battery_capacities

        for load_name, load_profile in scenario.load_profiles.items():
            for t, demand_t in enumerate(load_profile):
                if max_available_power < demand_t:
                    msg = (
                        f"Infeasible problem in scenario '{scenario.name}' for load '{load_name}' at time index {t}: "
                        f"Demand = {demand_t}, but maximum available generation + battery = {max_available_power}."
                    )
                    raise ValueError(msg)

    def _validate_enough_energy_to_meet_demand(self, scenario: StochasticScenario) -> None:  # noqa: ARG002
        """Validate that the system has enough energy to meet total demand.

        This method checks that the total energy available from generators
        and batteries can meet the total energy demand over the time horizon.

        """
        # TODO: Validate that:
        # sum(demand * timestep) <= sum(generator.nominal_power * timestep) + sum(battery.soc_initial - battery.soc_terminal) # noqa: ERA001, E501
        return

energy_system_parameters cached property

Parameters of the energy system.