Skip to content

Math Model API

The math_model module contains the internals for building and solving the mixed-integer linear program (MILP) that powers the optimization.

Model Builder

Builder for constructing linopy optimization models from energy system parameters.

This module provides the EnergyAlgebraicModelBuilder that assembles variables, constraints, and objectives into a solvable MILP model.

EnergyAlgebraicModelBuilder

Builder class for constructing algebraic energy system optimization models.

This class takes a validated energy system configuration and builds a complete linopy optimization model including variables, constraints, and objectives ready for solving.

The builder ensures the model is constructed only once and prevents multiple builds of the same instance.

Source code in src/odys/math_model/model_builder.py
 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
class EnergyAlgebraicModelBuilder:
    """Builder class for constructing algebraic energy system optimization models.

    This class takes a validated energy system configuration and builds
    a complete linopy optimization model including variables, constraints,
    and objectives ready for solving.

    The builder ensures the model is constructed only once and prevents
    multiple builds of the same instance.
    """

    def __init__(
        self,
        energy_system_parameters: EnergySystemParameters,
    ) -> None:
        """Initialize the model builder with validated energy system.

        Args:
            energy_system_parameters: Parameters of the energy system,
                containing all assets, demand profiles, and constraints.
        """
        self._milp_model = EnergyMILPModel(energy_system_parameters)
        self._model_is_built: bool = False

    def build(self) -> EnergyMILPModel:
        """Build the complete optimization model with variables, constraints, and objective.

        Returns:
            The fully constructed EnergyMILPModel ready for solving.

        Raises:
            AttributeError: If the model has already been built.

        """
        if self._model_is_built:
            msg = "Model has already been built."
            raise AttributeError(msg)
        self._add_model_variables()
        self._add_model_constraints()
        self._add_model_objective()
        self._model_is_built = True

        return self._milp_model

    def _add_model_variables(self) -> None:
        variables_to_add = []
        if self._milp_model.parameters.generators:
            variables_to_add.extend(GENERATOR_VARIABLES)

        if self._milp_model.parameters.batteries:
            variables_to_add.extend(BATTERY_VARIABLES)

        if self._milp_model.parameters.markets:
            variables_to_add.extend(MARKET_VARIABLES)

        for variable in variables_to_add:
            linopy_variable = self._get_linopy_variable_params(variable)
            self.add_variable_to_model(linopy_variable)

    def _get_linopy_variable_params(self, variable: ModelVariable) -> LinopyVariableParameters:
        coordinates = {}
        dimensions = []
        indeces = []

        for dimension in variable.dimensions:
            index = self.get_index_for_dimension(dimension)
            coordinates |= index.coordinates
            dimensions.append(index.dimension)
            indeces.append(index)

        return LinopyVariableParameters(
            name=variable.var_name,
            coords=coordinates,
            dims=dimensions,
            lower=get_variable_lower_bound(
                indeces=indeces,
                lower_bound_type=variable.lower_bound_type,
                is_binary=variable.is_binary,
            ),
            binary=variable.is_binary,
        )

    def get_index_for_dimension(self, dimension: ModelDimension) -> ModelIndex:
        """Return the model index corresponding to a given dimension.

        Args:
            dimension: The model dimension to look up.

        Raises:
            ValueError: If no index exists for the given dimension.

        """
        index = self._dimension_to_index_mapping.get(dimension)
        if index is None:
            msg = f"No index found for dimension '{dimension}'."
            raise ValueError(msg)
        return index

    @cached_property
    def _dimension_to_index_mapping(self) -> dict[ModelDimension, ModelIndex | None]:
        return {
            ModelDimension.Scenarios: self._milp_model.indices.scenarios,
            ModelDimension.Time: self._milp_model.indices.time,
            ModelDimension.Generators: self._milp_model.indices.generators,
            ModelDimension.Batteries: self._milp_model.indices.batteries,
            ModelDimension.Loads: self._milp_model.indices.loads,
            ModelDimension.Markets: self._milp_model.indices.markets,
        }

    def add_variable_to_model(self, variable: LinopyVariableParameters) -> None:
        """Add a variable to the underlying linopy model."""
        self._milp_model.linopy_model.add_variables(
            name=variable.name,
            coords=variable.coords,
            dims=variable.dims,
            lower=variable.lower,
            binary=variable.binary,
        )

    def _add_model_constraints(self) -> None:
        self._add_generator_constraints()
        self._add_battery_constraints()
        self._add_market_constraints()
        self._add_scenario_constraints()

    def _add_battery_constraints(self) -> None:
        constraints = BatteryConstraints(milp_model=self._milp_model).all
        self._add_set_of_contraints_to_model(constraints)

    def _add_generator_constraints(self) -> None:
        constraints = GeneratorConstraints(self._milp_model).all
        self._add_set_of_contraints_to_model(constraints)

    def _add_market_constraints(self) -> None:
        constraints = MarketConstraints(milp_model=self._milp_model).all
        self._add_set_of_contraints_to_model(constraints)

    def _add_scenario_constraints(self) -> None:
        constraints = ScenarioConstraints(
            milp_model=self._milp_model,
        ).all
        self._add_set_of_contraints_to_model(constraints)

    def _add_set_of_contraints_to_model(self, constraints: Iterable[ModelConstraint]) -> None:
        for constraint in constraints:
            self._milp_model.linopy_model.add_constraints(
                constraint.constraint,
                name=constraint.name,
            )

    def _add_model_objective(self) -> None:
        objective = ObjectiveFunction(milp_model=self._milp_model).profit
        self._milp_model.linopy_model.add_objective(objective, sense="max")

__init__(energy_system_parameters)

Initialize the model builder with validated energy system.

Parameters:

Name Type Description Default
energy_system_parameters EnergySystemParameters

Parameters of the energy system, containing all assets, demand profiles, and constraints.

required
Source code in src/odys/math_model/model_builder.py
50
51
52
53
54
55
56
57
58
59
60
61
def __init__(
    self,
    energy_system_parameters: EnergySystemParameters,
) -> None:
    """Initialize the model builder with validated energy system.

    Args:
        energy_system_parameters: Parameters of the energy system,
            containing all assets, demand profiles, and constraints.
    """
    self._milp_model = EnergyMILPModel(energy_system_parameters)
    self._model_is_built: bool = False

add_variable_to_model(variable)

Add a variable to the underlying linopy model.

Source code in src/odys/math_model/model_builder.py
148
149
150
151
152
153
154
155
156
def add_variable_to_model(self, variable: LinopyVariableParameters) -> None:
    """Add a variable to the underlying linopy model."""
    self._milp_model.linopy_model.add_variables(
        name=variable.name,
        coords=variable.coords,
        dims=variable.dims,
        lower=variable.lower,
        binary=variable.binary,
    )

build()

Build the complete optimization model with variables, constraints, and objective.

Returns:

Type Description
EnergyMILPModel

The fully constructed EnergyMILPModel ready for solving.

Raises:

Type Description
AttributeError

If the model has already been built.

Source code in src/odys/math_model/model_builder.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def build(self) -> EnergyMILPModel:
    """Build the complete optimization model with variables, constraints, and objective.

    Returns:
        The fully constructed EnergyMILPModel ready for solving.

    Raises:
        AttributeError: If the model has already been built.

    """
    if self._model_is_built:
        msg = "Model has already been built."
        raise AttributeError(msg)
    self._add_model_variables()
    self._add_model_constraints()
    self._add_model_objective()
    self._model_is_built = True

    return self._milp_model

get_index_for_dimension(dimension)

Return the model index corresponding to a given dimension.

Parameters:

Name Type Description Default
dimension ModelDimension

The model dimension to look up.

required

Raises:

Type Description
ValueError

If no index exists for the given dimension.

Source code in src/odys/math_model/model_builder.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
def get_index_for_dimension(self, dimension: ModelDimension) -> ModelIndex:
    """Return the model index corresponding to a given dimension.

    Args:
        dimension: The model dimension to look up.

    Raises:
        ValueError: If no index exists for the given dimension.

    """
    index = self._dimension_to_index_mapping.get(dimension)
    if index is None:
        msg = f"No index found for dimension '{dimension}'."
        raise ValueError(msg)
    return index

MILP Model

MILP model representation for energy system optimization.

This module provides the EnergyMILPModel class that wraps a linopy Model with typed accessors for energy system decision variables.

EnergyMILPModel

Wrapper around a linopy Model with typed variable accessors for energy systems.

Source code in src/odys/math_model/milp_model.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
class EnergyMILPModel:
    """Wrapper around a linopy Model with typed variable accessors for energy systems."""

    def __init__(self, parameters: EnergySystemParameters) -> None:
        """Initialize the MILP model with energy system parameters.

        Args:
            parameters: Validated energy system parameters.

        """
        self._parameters = parameters
        self._linopy_model = Model(force_dim_names=True)

    @cached_property
    def indices(self) -> EnergyModelIndices:
        """Return all dimension indices for the model."""
        return EnergyModelIndices(
            scenarios=self._parameters.scenarios.scenario_index,
            time=self._parameters.scenarios.time_index,
            generators=self._parameters.generators.index if self._parameters.generators is not None else None,
            batteries=self._parameters.batteries.index if self._parameters.batteries is not None else None,
            loads=self._parameters.loads.index if self._parameters.loads is not None else None,
            markets=self._parameters.markets.index if self._parameters.markets is not None else None,
        )

    @property
    def linopy_model(self) -> Model:
        """Return the underlying linopy model."""
        return self._linopy_model

    @property
    def parameters(self) -> EnergySystemParameters:
        """Return the energy system parameters."""
        return self._parameters

    @property
    def generator_power(self) -> Variable:
        """Return the generator power output variable."""
        return self._linopy_model.variables[ModelVariable.GENERATOR_POWER.var_name]

    @property
    def generator_status(self) -> Variable:
        """Return the generator on/off status variable."""
        return self._linopy_model.variables[ModelVariable.GENERATOR_STATUS.var_name]

    @property
    def generator_startup(self) -> Variable:
        """Return the generator startup indicator variable."""
        return self._linopy_model.variables[ModelVariable.GENERATOR_STARTUP.var_name]

    @property
    def generator_shutdown(self) -> Variable:
        """Return the generator shutdown indicator variable."""
        return self._linopy_model.variables[ModelVariable.GENERATOR_SHUTDOWN.var_name]

    @property
    def battery_power_in(self) -> Variable:
        """Return the battery charging power variable."""
        return self._linopy_model.variables[ModelVariable.BATTERY_POWER_IN.var_name]

    @property
    def battery_power_net(self) -> Variable:
        """Return the battery net power variable (charge - discharge)."""
        return self._linopy_model.variables[ModelVariable.BATTERY_POWER_NET.var_name]

    @property
    def battery_power_out(self) -> Variable:
        """Return the battery discharging power variable."""
        return self._linopy_model.variables[ModelVariable.BATTERY_POWER_OUT.var_name]

    @property
    def battery_soc(self) -> Variable:
        """Return the battery state of charge variable."""
        return self._linopy_model.variables[ModelVariable.BATTERY_SOC.var_name]

    @property
    def battery_charge_mode(self) -> Variable:
        """Return the battery charge/discharge mode indicator variable."""
        return self._linopy_model.variables[ModelVariable.BATTERY_CHARGE_MODE.var_name]

    @property
    def market_sell_volume(self) -> Variable:
        """Return the market sell volume variable."""
        return self._linopy_model.variables[ModelVariable.MARKET_SELL.var_name]

    @property
    def market_buy_volume(self) -> Variable:
        """Return the market buy volume variable."""
        return self._linopy_model.variables[ModelVariable.MARKET_BUY.var_name]

    @property
    def market_trade_mode(self) -> Variable:
        """Return the market buy/sell mode indicator variable."""
        return self._linopy_model.variables[ModelVariable.MARKET_TRADE_MODE.var_name]

battery_charge_mode property

Return the battery charge/discharge mode indicator variable.

battery_power_in property

Return the battery charging power variable.

battery_power_net property

Return the battery net power variable (charge - discharge).

battery_power_out property

Return the battery discharging power variable.

battery_soc property

Return the battery state of charge variable.

generator_power property

Return the generator power output variable.

generator_shutdown property

Return the generator shutdown indicator variable.

generator_startup property

Return the generator startup indicator variable.

generator_status property

Return the generator on/off status variable.

indices cached property

Return all dimension indices for the model.

linopy_model property

Return the underlying linopy model.

market_buy_volume property

Return the market buy volume variable.

market_sell_volume property

Return the market sell volume variable.

market_trade_mode property

Return the market buy/sell mode indicator variable.

parameters property

Return the energy system parameters.

__init__(parameters)

Initialize the MILP model with energy system parameters.

Parameters:

Name Type Description Default
parameters EnergySystemParameters

Validated energy system parameters.

required
Source code in src/odys/math_model/milp_model.py
37
38
39
40
41
42
43
44
45
def __init__(self, parameters: EnergySystemParameters) -> None:
    """Initialize the MILP model with energy system parameters.

    Args:
        parameters: Validated energy system parameters.

    """
    self._parameters = parameters
    self._linopy_model = Model(force_dim_names=True)

EnergyModelIndices

Bases: BaseModel

Collection of all dimension indices used in the optimization model.

Source code in src/odys/math_model/milp_model.py
21
22
23
24
25
26
27
28
29
30
31
class EnergyModelIndices(BaseModel):
    """Collection of all dimension indices used in the optimization model."""

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

    scenarios: ScenarioIndex
    time: TimeIndex
    generators: GeneratorIndex | None
    batteries: BatteryIndex | None
    loads: LoadIndex | None
    markets: MarketIndex | None

Sets

Set and index definitions for the optimization model dimensions.

ModelDimension

Bases: StrEnum

Dimension names used as axes in the optimization model.

Source code in src/odys/math_model/model_components/sets.py
10
11
12
13
14
15
16
17
18
class ModelDimension(StrEnum):
    """Dimension names used as axes in the optimization model."""

    Scenarios = "scenario"
    Time = "time"
    Generators = "generator"
    Batteries = "battery"
    Loads = "load"
    Markets = "market"

ModelIndex

Bases: BaseModel, ABC

Energy Model Set.

Source code in src/odys/math_model/model_components/sets.py
21
22
23
24
25
26
27
28
29
30
31
class ModelIndex(BaseModel, ABC):  # pyright: ignore[reportUnsafeMultipleInheritance]
    """Energy Model Set."""

    dimension: ClassVar[ModelDimension]
    model_config = ConfigDict(frozen=True)
    values: tuple[str, ...]

    @property
    def coordinates(self) -> dict[str, list[str]]:
        """Gets coordinates for xarray objects."""
        return {f"{self.dimension}": list(self.values)}

coordinates property

Gets coordinates for xarray objects.

Variables

Variable definitions for energy system optimization models.

This module defines variable names and types used in energy system optimization models.

BoundType

Bases: Enum

Lower bound type for optimization variables.

Source code in src/odys/math_model/model_components/variables.py
14
15
16
17
18
class BoundType(Enum):
    """Lower bound type for optimization variables."""

    NON_NEGATIVE = "non_negative"
    UNBOUNDED = "unbounded"

ModelVariable

Bases: Enum

All decision variables in the energy system optimization model.

Source code in src/odys/math_model/model_components/variables.py
 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
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
@unique
class ModelVariable(Enum):
    """All decision variables in the energy system optimization model."""

    GENERATOR_POWER = VariableSpec(
        name="generator_power",
        is_binary=False,
        dimensions=[ModelDimension.Scenarios, ModelDimension.Time, ModelDimension.Generators],
        lower_bound_type=BoundType.NON_NEGATIVE,
    )
    GENERATOR_STATUS = VariableSpec(
        name="generator_status",
        is_binary=True,
        dimensions=[ModelDimension.Scenarios, ModelDimension.Time, ModelDimension.Generators],
        lower_bound_type=BoundType.UNBOUNDED,
    )
    GENERATOR_STARTUP = VariableSpec(
        name="generator_startup",
        is_binary=True,
        dimensions=[ModelDimension.Scenarios, ModelDimension.Time, ModelDimension.Generators],
        lower_bound_type=BoundType.UNBOUNDED,
    )
    GENERATOR_SHUTDOWN = VariableSpec(
        name="generator_shutdown",
        is_binary=True,
        dimensions=[ModelDimension.Scenarios, ModelDimension.Time, ModelDimension.Generators],
        lower_bound_type=BoundType.UNBOUNDED,
    )
    BATTERY_POWER_IN = VariableSpec(
        name="battery_power_in",
        is_binary=False,
        dimensions=[ModelDimension.Scenarios, ModelDimension.Time, ModelDimension.Batteries],
        lower_bound_type=BoundType.NON_NEGATIVE,
    )
    BATTERY_POWER_NET = VariableSpec(
        name="battery_net_power",
        is_binary=False,
        dimensions=[ModelDimension.Scenarios, ModelDimension.Time, ModelDimension.Batteries],
        lower_bound_type=BoundType.UNBOUNDED,
    )
    BATTERY_POWER_OUT = VariableSpec(
        name="battery_power_out",
        is_binary=False,
        dimensions=[ModelDimension.Scenarios, ModelDimension.Time, ModelDimension.Batteries],
        lower_bound_type=BoundType.NON_NEGATIVE,
    )
    BATTERY_SOC = VariableSpec(
        name="battery_soc",
        is_binary=False,
        dimensions=[ModelDimension.Scenarios, ModelDimension.Time, ModelDimension.Batteries],
        lower_bound_type=BoundType.NON_NEGATIVE,
    )
    BATTERY_CHARGE_MODE = VariableSpec(
        name="battery_charge_mode",
        is_binary=True,
        dimensions=[ModelDimension.Scenarios, ModelDimension.Time, ModelDimension.Batteries],
        lower_bound_type=BoundType.UNBOUNDED,
    )
    MARKET_SELL = VariableSpec(
        name="market_sell_volume",
        is_binary=False,
        dimensions=[ModelDimension.Scenarios, ModelDimension.Time, ModelDimension.Markets],
        lower_bound_type=BoundType.NON_NEGATIVE,
    )
    MARKET_BUY = VariableSpec(
        name="market_buy_volume",
        is_binary=False,
        dimensions=[ModelDimension.Scenarios, ModelDimension.Time, ModelDimension.Markets],
        lower_bound_type=BoundType.NON_NEGATIVE,
    )
    MARKET_TRADE_MODE = VariableSpec(
        name="market_trade_mode",
        is_binary=True,
        dimensions=[ModelDimension.Scenarios, ModelDimension.Time, ModelDimension.Markets],
        lower_bound_type=BoundType.UNBOUNDED,
    )

    @property
    def var_name(self) -> str:
        """Return the variable name used in the linopy model."""
        return self.value.name

    @property
    def dimensions(self) -> list[ModelDimension]:
        """Return the dimensions this variable is defined over."""
        return self.value.dimensions

    @property
    def asset_dimension(self) -> ModelDimension | None:
        """Get the asset dimension (Generators or Batteries) if present."""
        for dim in self.value.dimensions:
            if dim in (ModelDimension.Generators, ModelDimension.Batteries):
                return dim
        return None

    @property
    def lower_bound_type(self) -> BoundType:
        """Return the lower bound type for this variable."""
        return self.value.lower_bound_type

    @property
    def is_binary(self) -> bool:
        """Return whether this variable is binary."""
        return self.value.is_binary

asset_dimension property

Get the asset dimension (Generators or Batteries) if present.

dimensions property

Return the dimensions this variable is defined over.

is_binary property

Return whether this variable is binary.

lower_bound_type property

Return the lower bound type for this variable.

var_name property

Return the variable name used in the linopy model.

VariableSpec

Bases: BaseModel

Specification for an optimization variable (name, type, dimensions, bounds).

Source code in src/odys/math_model/model_components/variables.py
21
22
23
24
25
26
27
28
29
class VariableSpec(BaseModel):
    """Specification for an optimization variable (name, type, dimensions, bounds)."""

    model_config = ConfigDict()

    name: str
    is_binary: bool
    dimensions: list[ModelDimension]
    lower_bound_type: BoundType

Objectives

Objective function definitions for energy system optimization models.

This module defines objective function names and types used in energy system optimization models.

ObjectiveFunction

Builds the objective function for the energy system optimization model.

Source code in src/odys/math_model/model_components/objectives.py
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
class ObjectiveFunction:
    """Builds the objective function for the energy system optimization model."""

    def __init__(self, milp_model: EnergyMILPModel) -> None:
        """Initialize with the MILP model to build the objective from."""
        self._model = milp_model

    @property
    def profit(self) -> linopy.LinearExpression:
        """Build the total profit expression (market revenue minus operating costs)."""
        profit = 0

        if self._model.parameters.scenarios.market_prices is not None:
            profit += self.get_market_revenue()

        if self._model.parameters.generators is not None:
            profit += -self.get_operating_costs()

        if isinstance(profit, int) and profit == 0:
            msg = "No terms added to profit"
            raise ValueError(msg)
        return profit

    def get_market_revenue(self) -> linopy.LinearExpression:
        """Calculate expected market revenue across all scenarios."""
        return (
            (self._model.market_sell_volume - self._model.market_buy_volume)  # pyrefly: ignore
            * self._model.parameters.scenarios.market_prices  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOperatorIssue]
            * self._model.parameters.scenarios.scenario_probabilities
        ).sum([ModelDimension.Scenarios, ModelDimension.Time, ModelDimension.Markets])

    def get_operating_costs(self) -> linopy.LinearExpression:
        """Calculate total generator operating costs (variable + startup)."""
        return (
            self._model.generator_power * self._model.parameters.generators.variable_cost  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
            + self._model.generator_startup * self._model.parameters.generators.startup_cost  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        ).sum([ModelDimension.Scenarios, ModelDimension.Time, ModelDimension.Generators])

profit property

Build the total profit expression (market revenue minus operating costs).

__init__(milp_model)

Initialize with the MILP model to build the objective from.

Source code in src/odys/math_model/model_components/objectives.py
16
17
18
def __init__(self, milp_model: EnergyMILPModel) -> None:
    """Initialize with the MILP model to build the objective from."""
    self._model = milp_model

get_market_revenue()

Calculate expected market revenue across all scenarios.

Source code in src/odys/math_model/model_components/objectives.py
36
37
38
39
40
41
42
def get_market_revenue(self) -> linopy.LinearExpression:
    """Calculate expected market revenue across all scenarios."""
    return (
        (self._model.market_sell_volume - self._model.market_buy_volume)  # pyrefly: ignore
        * self._model.parameters.scenarios.market_prices  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOperatorIssue]
        * self._model.parameters.scenarios.scenario_probabilities
    ).sum([ModelDimension.Scenarios, ModelDimension.Time, ModelDimension.Markets])

get_operating_costs()

Calculate total generator operating costs (variable + startup).

Source code in src/odys/math_model/model_components/objectives.py
44
45
46
47
48
49
def get_operating_costs(self) -> linopy.LinearExpression:
    """Calculate total generator operating costs (variable + startup)."""
    return (
        self._model.generator_power * self._model.parameters.generators.variable_cost  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        + self._model.generator_startup * self._model.parameters.generators.startup_cost  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
    ).sum([ModelDimension.Scenarios, ModelDimension.Time, ModelDimension.Generators])

Constraints

Model Constraint

Named constraint wrapper for linopy constraints.

ModelConstraint

Bases: BaseModel

A named linopy constraint ready to be added to the model.

Source code in src/odys/math_model/model_components/constraints/model_constraint.py
 7
 8
 9
10
11
12
13
class ModelConstraint(BaseModel):
    """A named linopy constraint ready to be added to the model."""

    model_config = ConfigDict(arbitrary_types_allowed=True)

    constraint: linopy.Constraint
    name: str

Generator Constraints

Generator-related constraints for the optimization model.

GeneratorConstraints

Builds constraints for generator power limits, ramping, startup/shutdown, and min uptime.

Source code in src/odys/math_model/model_components/constraints/generator_constraints.py
  7
  8
  9
 10
 11
 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
 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
class GeneratorConstraints:
    """Builds constraints for generator power limits, ramping, startup/shutdown, and min uptime."""

    def __init__(self, milp_model: EnergyMILPModel) -> None:
        """Initialize with the MILP model containing generator variables and parameters."""
        self.model = milp_model
        self.params = milp_model.parameters.generators

    def _validate_generator_parameters_exist(self) -> None:
        if self.params is None:
            msg = "No generator parameters specified."
            raise ValueError(msg)

    @property
    def all(self) -> tuple[ModelConstraint, ...]:
        """Return all generator constraints, or an empty tuple if no generators exist."""
        if self.params is None:
            return ()
        return (
            self._get_generator_max_power_constraint(),
            self._get_generator_status_constraint(),
            self._get_generator_startup_lower_bound_constraint(),
            self._get_generator_startup_upper_bound_1_constraint(),
            self._get_generator_startup_upper_bound_2_constraint(),
            self._get_generator_shutdown_lower_bound_constraint(),
            self._get_generator_shutdown_upper_bound_1_constraint(),
            self._get_generator_shutdown_upper_bound_2_constraint(),
            *self._get_min_uptime_constraint(),
            self._get_min_power_constraint(),
            self._get_max_ramp_up_constraint(),
            self._get_max_ramp_down_constraint(),
        )

    def _get_generator_max_power_constraint(self) -> ModelConstraint:
        """Generator power limit constraint.

        This constraint ensures that each generator's power output does not
        exceed its nominal power capacity.
        """
        self._validate_generator_parameters_exist()
        constraint = self.model.generator_power - self.model.generator_status * self.params.nominal_power <= 0  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        return ModelConstraint(
            constraint=constraint,
            name="generator_max_power_constraint",
        )

    def _get_generator_status_constraint(self) -> ModelConstraint:
        self._validate_generator_parameters_exist()
        epsilon = 1e-5 * self.params.nominal_power  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        constraint = self.model.generator_power >= self.model.generator_status * epsilon
        return ModelConstraint(
            constraint=constraint,
            name="generator_status_constraint",
        )

    def _get_generator_startup_lower_bound_constraint(self) -> ModelConstraint:
        constraint = self.model.generator_startup >= self.model.generator_status - self.model.generator_status.shift(
            time=1,
        )
        return ModelConstraint(
            constraint=constraint,
            name="generator_startup_lower_bound_constraint",
        )

    def _get_generator_startup_upper_bound_1_constraint(self) -> ModelConstraint:
        return ModelConstraint(
            constraint=self.model.generator_startup <= self.model.generator_status,
            name="generator_startup_upper_bound_1_constraint",
        )

    def _get_generator_startup_upper_bound_2_constraint(self) -> ModelConstraint:
        return ModelConstraint(
            constraint=self.model.generator_startup + self.model.generator_status.shift(time=1) <= 1.0,
            name="generator_startup_upper_bound_2_constraint",
        )

    def _get_generator_shutdown_lower_bound_constraint(self) -> ModelConstraint:
        constraint = (
            self.model.generator_shutdown >= self.model.generator_status.shift(time=1) - self.model.generator_status
        )
        return ModelConstraint(
            constraint=constraint,
            name="generator_shutdown_lower_bound_constraint",
        )

    def _get_generator_shutdown_upper_bound_1_constraint(self) -> ModelConstraint:
        return ModelConstraint(
            constraint=self.model.generator_shutdown <= self.model.generator_status.shift(time=1),
            name="generator_shutdown_upper_bound_1_constraint",
        )

    def _get_generator_shutdown_upper_bound_2_constraint(self) -> ModelConstraint:
        return ModelConstraint(
            constraint=self.model.generator_shutdown + self.model.generator_status <= 1.0,
            name="generator_shutdown_upper_bound_2_constraint",
        )

    def _get_min_uptime_constraint(self) -> list[ModelConstraint]:
        self._validate_generator_parameters_exist()
        constraints = []
        for generator in self.params.index.values:  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
            min_up_time = int(self.params.min_up_time.sel(generator=generator))  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
            generator_status = self.model.generator_status.sel(generator=generator)
            generator_shutdown = self.model.generator_shutdown.sel(generator=generator)
            constraint_generator = generator_status.rolling(
                time=min_up_time,
            ).sum() >= min_up_time * generator_shutdown.shift(time=-1)
            constraints.append(
                ModelConstraint(
                    constraint=constraint_generator,
                    name=f"generator_min_uptime_{generator}_constraint",
                ),
            )

        return constraints

    def _get_min_power_constraint(self) -> ModelConstraint:
        self._validate_generator_parameters_exist()
        return ModelConstraint(
            constraint=self.model.generator_power >= self.params.min_power * self.model.generator_status,  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
            name="generator_min_power_constraint",
        )

    def _get_max_ramp_up_constraint(self) -> ModelConstraint:
        self._validate_generator_parameters_exist()
        max_ramp_up = self.params.max_ramp_up.fillna(self.params.nominal_power)  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        return ModelConstraint(
            constraint=self.model.generator_power - self.model.generator_power.shift(time=1) <= max_ramp_up,
            name="generator_max_ramp_up_constraint",
        )

    def _get_max_ramp_down_constraint(self) -> ModelConstraint:
        self._validate_generator_parameters_exist()
        max_ramp_down = self.params.max_ramp_down.fillna(self.params.nominal_power)  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        constraint = self.model.generator_power.shift(time=1) - self.model.generator_power <= max_ramp_down

        return ModelConstraint(
            constraint=constraint,
            name="generator_max_ramp_down_constraint",
        )

all property

Return all generator constraints, or an empty tuple if no generators exist.

__init__(milp_model)

Initialize with the MILP model containing generator variables and parameters.

Source code in src/odys/math_model/model_components/constraints/generator_constraints.py
10
11
12
13
def __init__(self, milp_model: EnergyMILPModel) -> None:
    """Initialize with the MILP model containing generator variables and parameters."""
    self.model = milp_model
    self.params = milp_model.parameters.generators

Battery Constraints

Battery-related constraints for the optimization model.

BatteryConstraints

Builds constraints for battery charge/discharge, SOC dynamics, and power limits.

Source code in src/odys/math_model/model_components/constraints/battery_constraints.py
  8
  9
 10
 11
 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
 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
class BatteryConstraints:
    """Builds constraints for battery charge/discharge, SOC dynamics, and power limits."""

    def __init__(self, milp_model: EnergyMILPModel) -> None:
        """Initialize with the MILP model containing battery variables and parameters."""
        self.model = milp_model
        self.params = milp_model.parameters.batteries

    def _validate_battery_parameters_exist(self) -> None:
        if self.params is None:
            msg = "No battery parameters specified."
            raise ValueError(msg)

    @property
    def all(self) -> tuple[ModelConstraint, ...]:
        """Return all battery constraints, or an empty tuple if no batteries exist."""
        if self.params is None:
            return ()
        return (
            self._get_battery_max_charge_constraint(),
            self._get_battery_max_discharge_constraint(),
            self._get_battery_soc_dynamics_constraint(),
            self._get_battery_soc_start_constraint(),
            self._get_battery_soc_end_constraint(),
            self._get_battery_soc_min_constriant(),
            self._get_battery_soc_max_constriant(),
            self._get_battery_capacity_constraint(),
            self._get_battery_net_power_constraint(),
        )

    def _get_battery_max_charge_constraint(self) -> ModelConstraint:
        # var_battery_discharge <= (1 - var_battery_mode) * param_battery_max_power # noqa: ERA001
        constraint = self.model.battery_power_in <= self.model.battery_charge_mode * self.params.max_power  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        return ModelConstraint(
            constraint=constraint,
            name="battery_max_charge_constraint",
        )

    def _get_battery_max_discharge_constraint(self) -> ModelConstraint:
        # var_battery_discharge <= (1 - var_battery_mode) * param_battery_max_power # noqa: ERA001
        self._validate_battery_parameters_exist()
        constraint = (
            self.model.battery_power_out + self.model.battery_charge_mode * self.params.max_power  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
            <= self.params.max_power  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        )
        return ModelConstraint(
            constraint=constraint,
            name="battery_max_discharge_constraint",
        )

    def _get_battery_soc_dynamics_constraint(self) -> ModelConstraint:
        self._validate_battery_parameters_exist()
        time_coords = self.model.battery_soc.coords[ModelDimension.Time.value]
        constraint_expr = self.model.battery_soc - (
            self.model.battery_soc.shift(time=1)
            + self.params.efficiency_charging * self.model.battery_power_in / self.params.capacity  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
            - 1 / self.params.efficiency_discharging * self.model.battery_power_out / self.params.capacity  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        )

        constraint = constraint_expr.where(time_coords > time_coords[0]) == 0
        return ModelConstraint(
            constraint=constraint,
            name="battery_soc_dynamics_constraint",
        )

    def _get_battery_soc_start_constraint(self) -> ModelConstraint:
        self._validate_battery_parameters_exist()
        t0 = self.model.battery_soc.coords[ModelDimension.Time.value][0]

        soc_t0 = self.model.battery_soc.sel(time=t0)
        charge_t0 = self.model.battery_power_in.sel(time=t0)
        discharge_t0 = self.model.battery_power_out.sel(time=t0)

        constraint_expr = (
            soc_t0
            - self.params.soc_start  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
            - self.params.efficiency_charging * charge_t0 / self.params.capacity  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
            + 1 / self.params.efficiency_discharging * discharge_t0 / self.params.capacity  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        )

        constraint = constraint_expr == 0
        return ModelConstraint(
            constraint=constraint,
            name="battery_soc_start_constraint",
        )

    def _get_battery_soc_end_constraint(self) -> ModelConstraint:
        self._validate_battery_parameters_exist()
        time_coords = self.model.battery_soc.coords[ModelDimension.Time.value]
        last_time = time_coords.values[-1]
        constr_expression = self.model.battery_soc.sel(time=last_time) - self.params.soc_end  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        constraint = constr_expression == 0
        return ModelConstraint(
            constraint=constraint,
            name="battery_soc_end_constraint",
        )

    def _get_battery_soc_min_constriant(self) -> ModelConstraint:
        self._validate_battery_parameters_exist()
        expression = self.model.battery_soc >= self.params.soc_min  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        return ModelConstraint(
            constraint=expression,
            name="batter_soc_min_constraint",
        )

    def _get_battery_soc_max_constriant(self) -> ModelConstraint:
        self._validate_battery_parameters_exist()
        expression = self.model.battery_soc <= self.params.soc_max  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        return ModelConstraint(
            constraint=expression,
            name="batter_soc_max_constraint",
        )

    def _get_battery_capacity_constraint(self) -> ModelConstraint:
        self._validate_battery_parameters_exist()
        constraint = self.model.battery_soc <= 1  # pyrefly: ignore
        return ModelConstraint(
            constraint=constraint,
            name="battery_capacity_constraint",
        )

    def _get_battery_net_power_constraint(self) -> ModelConstraint:
        self._validate_battery_parameters_exist()
        constraint = self.model.battery_power_net == self.model.battery_power_in - self.model.battery_power_out
        return ModelConstraint(
            constraint=constraint,
            name="battery_net_power_constraint",
        )

all property

Return all battery constraints, or an empty tuple if no batteries exist.

__init__(milp_model)

Initialize with the MILP model containing battery variables and parameters.

Source code in src/odys/math_model/model_components/constraints/battery_constraints.py
11
12
13
14
def __init__(self, milp_model: EnergyMILPModel) -> None:
    """Initialize with the MILP model containing battery variables and parameters."""
    self.model = milp_model
    self.params = milp_model.parameters.batteries

Scenario Constraints

Scenario-level constraints for the optimization model.

ScenarioConstraints

Builds power balance, available capacity, and non-anticipativity constraints.

Source code in src/odys/math_model/model_components/constraints/scenario_constraints.py
 9
10
11
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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
class ScenarioConstraints:
    """Builds power balance, available capacity, and non-anticipativity constraints."""

    def __init__(
        self,
        milp_model: EnergyMILPModel,
    ) -> None:
        """Initialize with the MILP model containing scenario-level variables and parameters."""
        self.model = milp_model
        self.scenario_params = milp_model.parameters.scenarios
        self.market_params = milp_model.parameters.markets
        self._include_generators = bool(milp_model.parameters.generators)
        self._include_batteries = bool(milp_model.parameters.batteries)
        self._include_markets = bool(milp_model.parameters.markets)

    @property
    def all(self) -> tuple[ModelConstraint, ...]:
        """Return all scenario-level constraints (power balance, capacity, non-anticipativity)."""
        constraints = [
            self._get_power_balance_constraint(),
        ]

        if self._include_generators and self.scenario_params.available_capacity_profiles is not None:
            constraints.append(self._get_available_capacity_profiles_constraint())

        if self._include_markets:
            constraints += self._get_non_anticipativity_constraint()
        return tuple(constraints)

    def _get_power_balance_constraint(self) -> ModelConstraint:
        """Linopy power balance constraint ensuring supply equals demand.

        This constraint ensures that at each time period and scenario, the total power
        generation plus battery discharge equals the demand plus battery charging.
        """
        lhs = 0
        if self._include_generators:
            lhs += self.model.generator_power.sum(ModelDimension.Generators)

        if self._include_batteries:
            lhs += self.model.battery_power_out.sum(ModelDimension.Batteries)
            lhs += -self.model.battery_power_in.sum(ModelDimension.Batteries)

        if self._include_markets:
            lhs += self.model.market_buy_volume.sum(ModelDimension.Markets)
            lhs += -self.model.market_sell_volume.sum(ModelDimension.Markets)

        if self.scenario_params.load_profiles is not None:
            lhs += -self.scenario_params.load_profiles

        return ModelConstraint(
            name="power_balance_constraint",
            constraint=lhs == 0,  # ty: ignore  # pyright: ignore[reportArgumentType]
        )

    def _get_available_capacity_profiles_constraint(self) -> ModelConstraint:
        var_generator_power = self.model.generator_power
        expression = var_generator_power <= self.scenario_params.available_capacity_profiles  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOperatorIssue]
        return ModelConstraint(
            name="available_capacity_constraint",
            constraint=expression,
        )

    def _get_non_anticipativity_constraint(self) -> list[ModelConstraint]:
        """Non-anticipativity constraint ensuring variables have same values across scenarios.

        This constraint enforces that decision variables take the same values across
        all scenarios, reflecting that decisions are made before uncertainty is revealed.
        Only applies to markets where stage_fixed is True.
        """
        constraints = []
        stage_fixed_markets = self.market_params.stage_fixed  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]

        for market_var in MARKET_VARIABLES:
            linopy_var = self.model.linopy_model.variables[market_var.var_name]
            market_with_fixed_stage_var = linopy_var.where(stage_fixed_markets, drop=True)
            market_with_fixed_stage_first_scenario_var = market_with_fixed_stage_var.isel({ModelDimension.Scenarios: 0})
            expression = market_with_fixed_stage_var - market_with_fixed_stage_first_scenario_var == 0
            constraints.append(
                ModelConstraint(
                    name=f"non_anticipativity_{market_var.var_name}_constraint",
                    constraint=expression,
                ),
            )

        return constraints

all property

Return all scenario-level constraints (power balance, capacity, non-anticipativity).

__init__(milp_model)

Initialize with the MILP model containing scenario-level variables and parameters.

Source code in src/odys/math_model/model_components/constraints/scenario_constraints.py
12
13
14
15
16
17
18
19
20
21
22
def __init__(
    self,
    milp_model: EnergyMILPModel,
) -> None:
    """Initialize with the MILP model containing scenario-level variables and parameters."""
    self.model = milp_model
    self.scenario_params = milp_model.parameters.scenarios
    self.market_params = milp_model.parameters.markets
    self._include_generators = bool(milp_model.parameters.generators)
    self._include_batteries = bool(milp_model.parameters.batteries)
    self._include_markets = bool(milp_model.parameters.markets)

Market Constraints

Market-related constraints for the optimization model.

MarketConstraints

Builds constraints for market trading volumes, mutual exclusivity, and trade direction.

Source code in src/odys/math_model/model_components/constraints/market_constraints.py
 8
 9
10
11
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
79
80
81
82
83
84
85
86
87
88
89
class MarketConstraints:
    """Builds constraints for market trading volumes, mutual exclusivity, and trade direction."""

    def __init__(self, milp_model: EnergyMILPModel) -> None:
        """Initialize with the MILP model containing market variables and parameters."""
        self.model = milp_model
        self.params = milp_model.parameters.markets

    def _validate_market_parameters_exist(self) -> None:
        if self.params is None:
            msg = "No parameters specified."
            raise ValueError(msg)

    @property
    def all(self) -> tuple[ModelConstraint, ...]:
        """Return all market constraints, or an empty tuple if no markets exist."""
        if self.params is None:
            return ()
        constraints = [
            self._get_market_max_buy_volume_constraint(),
            self._get_market_max_sell_volume_constraint(),
            self._get_market_mutual_exclusivity_buy_constraint(),
            self._get_market_mutual_exclusivity_sell_constraint(),
            *self._get_trade_direction_constraints(),
        ]

        return tuple(constraints)

    def _get_market_max_sell_volume_constraint(self) -> ModelConstraint:
        constraint = self.model.market_sell_volume <= self.params.max_volume  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        return ModelConstraint(
            constraint=constraint,
            name="market_max_sell_volume_constraint",
        )

    def _get_market_max_buy_volume_constraint(self) -> ModelConstraint:
        constraint = self.model.market_buy_volume <= self.params.max_volume  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        return ModelConstraint(
            constraint=constraint,
            name="market_max_buy_volume_constraint",
        )

    def _get_market_mutual_exclusivity_sell_constraint(self) -> ModelConstraint:
        constraint = self.model.market_sell_volume <= self.model.market_trade_mode * self.params.max_volume  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        return ModelConstraint(
            constraint=constraint,
            name="market_mutual_exclusivity_sell_constraint",
        )

    def _get_market_mutual_exclusivity_buy_constraint(self) -> ModelConstraint:
        constraint = (
            self.model.market_buy_volume + self.model.market_trade_mode * self.params.max_volume  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
            <= self.params.max_volume  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        )
        return ModelConstraint(
            constraint=constraint,
            name="market_mutual_exclusivity_buy_constraint",
        )

    def _get_trade_direction_constraints(self) -> list[ModelConstraint]:
        """Generate constraints based on trade_direction parameter for each market."""
        constraints = []

        buy_only_mask = self.params.trade_direction == TradeDirection.BUY  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        sell_constraint = self.model.market_sell_volume.where(buy_only_mask, drop=True) == 0  # pyrefly: ignore
        constraints.append(
            ModelConstraint(
                constraint=sell_constraint,
                name="market_buy_only_constraint",
            ),
        )

        sell_only_mask = self.params.trade_direction == TradeDirection.SELL  # ty: ignore # pyrefly: ignore  # pyright: ignore[reportOptionalMemberAccess]
        buy_constraint = self.model.market_buy_volume.where(sell_only_mask, drop=True) == 0  # pyrefly: ignore
        constraints.append(
            ModelConstraint(
                constraint=buy_constraint,
                name="market_sell_only_constraint",
            ),
        )

        return constraints

all property

Return all market constraints, or an empty tuple if no markets exist.

__init__(milp_model)

Initialize with the MILP model containing market variables and parameters.

Source code in src/odys/math_model/model_components/constraints/market_constraints.py
11
12
13
14
def __init__(self, milp_model: EnergyMILPModel) -> None:
    """Initialize with the MILP model containing market variables and parameters."""
    self.model = milp_model
    self.params = milp_model.parameters.markets

Parameters

Energy System Parameters

Parameter definitions for energy system optimization models.

This module defines parameter names and types used in energy system optimization models.

EnergySystemParameters

Bases: BaseModel

Collection of all energy system parameters for optimization models.

Source code in src/odys/math_model/model_components/parameters/parameters.py
16
17
18
19
20
21
22
23
24
25
class EnergySystemParameters(BaseModel):
    """Collection of all energy system parameters for optimization models."""

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

    generators: GeneratorParameters | None
    batteries: BatteryParameters | None
    loads: LoadParameters | None
    markets: MarketParameters | None
    scenarios: ScenarioParameters

Generator Parameters

Generator parameters for the mathematical optimization model.

GeneratorIndex

Bases: ModelIndex

Index for generator components in the optimization model.

Source code in src/odys/math_model/model_components/parameters/generator_parameters.py
12
13
14
15
class GeneratorIndex(ModelIndex):
    """Index for generator components in the optimization model."""

    dimension: ClassVar[ModelDimension] = ModelDimension.Generators

GeneratorParameters

Parameters for generator assets in the energy system model.

Source code in src/odys/math_model/model_components/parameters/generator_parameters.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
class GeneratorParameters:
    """Parameters for generator assets in the energy system model."""

    def __init__(self, generators: Sequence[PowerGenerator]) -> None:
        """Initialize generator parameters.

        Args:
            generators: Sequence of power generator objects.
        """
        self._index = GeneratorIndex(
            values=tuple(gen.name for gen in generators),
        )
        data = {
            "nominal_power": [gen.nominal_power for gen in generators],
            "variable_cost": [gen.variable_cost for gen in generators],
            "min_up_time": [gen.min_up_time for gen in generators],
            "min_power": [gen.min_power for gen in generators],
            "startup_cost": [gen.startup_cost for gen in generators],
            "max_ramp_up": [gen.ramp_up for gen in generators],
            "max_ramp_down": [gen.ramp_down for gen in generators],
        }
        dim = self._index.dimension
        self._dataset = xr.Dataset(
            {name: (dim, values) for name, values in data.items()},
            coords=self._index.coordinates,
        )

    @property
    def index(self) -> GeneratorIndex:
        """Return the generator index."""
        return self._index

    @property
    def nominal_power(self) -> xr.DataArray:
        """Return generator nominal power data."""
        return self._dataset["nominal_power"]

    @property
    def variable_cost(self) -> xr.DataArray:
        """Return generator variable cost data."""
        return self._dataset["variable_cost"]

    @property
    def min_up_time(self) -> xr.DataArray:
        """Return generator minimum up time data."""
        return self._dataset["min_up_time"]

    @property
    def min_power(self) -> xr.DataArray:
        """Return generator minimum power data."""
        return self._dataset["min_power"]

    @property
    def startup_cost(self) -> xr.DataArray:
        """Return generator startup cost data."""
        return self._dataset["startup_cost"]

    @property
    def max_ramp_up(self) -> xr.DataArray:
        """Return generator maximum ramp up rate data."""
        return self._dataset["max_ramp_up"]

    @property
    def max_ramp_down(self) -> xr.DataArray:
        """Return generator maximum ramp down rate data."""
        return self._dataset["max_ramp_down"]

index property

Return the generator index.

max_ramp_down property

Return generator maximum ramp down rate data.

max_ramp_up property

Return generator maximum ramp up rate data.

min_power property

Return generator minimum power data.

min_up_time property

Return generator minimum up time data.

nominal_power property

Return generator nominal power data.

startup_cost property

Return generator startup cost data.

variable_cost property

Return generator variable cost data.

__init__(generators)

Initialize generator parameters.

Parameters:

Name Type Description Default
generators Sequence[PowerGenerator]

Sequence of power generator objects.

required
Source code in src/odys/math_model/model_components/parameters/generator_parameters.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def __init__(self, generators: Sequence[PowerGenerator]) -> None:
    """Initialize generator parameters.

    Args:
        generators: Sequence of power generator objects.
    """
    self._index = GeneratorIndex(
        values=tuple(gen.name for gen in generators),
    )
    data = {
        "nominal_power": [gen.nominal_power for gen in generators],
        "variable_cost": [gen.variable_cost for gen in generators],
        "min_up_time": [gen.min_up_time for gen in generators],
        "min_power": [gen.min_power for gen in generators],
        "startup_cost": [gen.startup_cost for gen in generators],
        "max_ramp_up": [gen.ramp_up for gen in generators],
        "max_ramp_down": [gen.ramp_down for gen in generators],
    }
    dim = self._index.dimension
    self._dataset = xr.Dataset(
        {name: (dim, values) for name, values in data.items()},
        coords=self._index.coordinates,
    )

Battery Parameters

Battery parameters for the mathematical optimization model.

BatteryIndex

Bases: ModelIndex

Index for battery components in the optimization model.

Source code in src/odys/math_model/model_components/parameters/battery_parameters.py
12
13
14
15
class BatteryIndex(ModelIndex):
    """Index for battery components in the optimization model."""

    dimension: ClassVar[ModelDimension] = ModelDimension.Batteries

BatteryParameters

Parameters for battery assets in the energy system model.

Source code in src/odys/math_model/model_components/parameters/battery_parameters.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
class BatteryParameters:
    """Parameters for battery assets in the energy system model."""

    def __init__(self, batteries: Sequence[Battery]) -> None:
        """Initialize battery parameters.

        Args:
            batteries: Sequence of battery objects.
        """
        self._index = BatteryIndex(
            values=tuple(battery.name for battery in batteries),
        )
        data = {
            "capacity": [battery.capacity for battery in batteries],
            "max_power": [battery.max_power for battery in batteries],
            "efficiency_charging": [battery.efficiency_charging for battery in batteries],
            "efficiency_discharging": [battery.efficiency_discharging for battery in batteries],
            "soc_start": [battery.soc_start for battery in batteries],
            "soc_end": [battery.soc_end for battery in batteries],
            "soc_min": [battery.soc_min for battery in batteries],
            "soc_max": [battery.soc_max for battery in batteries],
        }
        dim = self._index.dimension
        self._dataset = xr.Dataset(
            {name: (dim, values) for name, values in data.items()},
            coords=self._index.coordinates,
        )

    @property
    def index(self) -> BatteryIndex:
        """Return the battery index."""
        return self._index

    @property
    def capacity(self) -> xr.DataArray:
        """Return battery capacity data."""
        return self._dataset["capacity"]

    @property
    def max_power(self) -> xr.DataArray:
        """Return battery maximum power data."""
        return self._dataset["max_power"]

    @property
    def efficiency_charging(self) -> xr.DataArray:
        """Return battery charging efficiency data."""
        return self._dataset["efficiency_charging"]

    @property
    def efficiency_discharging(self) -> xr.DataArray:
        """Return battery discharging efficiency data."""
        return self._dataset["efficiency_discharging"]

    @property
    def soc_start(self) -> xr.DataArray:
        """Return battery initial state of charge data."""
        return self._dataset["soc_start"]

    @property
    def soc_end(self) -> xr.DataArray:
        """Return battery final state of charge data."""
        return self._dataset["soc_end"]

    @property
    def soc_min(self) -> xr.DataArray:
        """Return battery minimum state of charge data."""
        return self._dataset["soc_min"]

    @property
    def soc_max(self) -> xr.DataArray:
        """Return battery maximum state of charge data."""
        return self._dataset["soc_max"]

capacity property

Return battery capacity data.

efficiency_charging property

Return battery charging efficiency data.

efficiency_discharging property

Return battery discharging efficiency data.

index property

Return the battery index.

max_power property

Return battery maximum power data.

soc_end property

Return battery final state of charge data.

soc_max property

Return battery maximum state of charge data.

soc_min property

Return battery minimum state of charge data.

soc_start property

Return battery initial state of charge data.

__init__(batteries)

Initialize battery parameters.

Parameters:

Name Type Description Default
batteries Sequence[Battery]

Sequence of battery objects.

required
Source code in src/odys/math_model/model_components/parameters/battery_parameters.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
def __init__(self, batteries: Sequence[Battery]) -> None:
    """Initialize battery parameters.

    Args:
        batteries: Sequence of battery objects.
    """
    self._index = BatteryIndex(
        values=tuple(battery.name for battery in batteries),
    )
    data = {
        "capacity": [battery.capacity for battery in batteries],
        "max_power": [battery.max_power for battery in batteries],
        "efficiency_charging": [battery.efficiency_charging for battery in batteries],
        "efficiency_discharging": [battery.efficiency_discharging for battery in batteries],
        "soc_start": [battery.soc_start for battery in batteries],
        "soc_end": [battery.soc_end for battery in batteries],
        "soc_min": [battery.soc_min for battery in batteries],
        "soc_max": [battery.soc_max for battery in batteries],
    }
    dim = self._index.dimension
    self._dataset = xr.Dataset(
        {name: (dim, values) for name, values in data.items()},
        coords=self._index.coordinates,
    )

Load Parameters

Load parameters for the mathematical optimization model.

LoadIndex

Bases: ModelIndex

Index for load components in the optimization model.

Source code in src/odys/math_model/model_components/parameters/load_parameters.py
10
11
12
13
class LoadIndex(ModelIndex):
    """Index for load components in the optimization model."""

    dimension: ClassVar[ModelDimension] = ModelDimension.Loads

LoadParameters

Parameters for load assets in the energy system model.

Source code in src/odys/math_model/model_components/parameters/load_parameters.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class LoadParameters:
    """Parameters for load assets in the energy system model."""

    def __init__(self, loads: Sequence[Load]) -> None:
        """Initialize load parameters.

        Args:
            loads: Sequence of load objects.
        """
        self._loads = loads
        self._index = LoadIndex(
            values=tuple(gen.name for gen in self._loads),
        )

    @property
    def index(self) -> LoadIndex:
        """Return the load index."""
        return self._index

index property

Return the load index.

__init__(loads)

Initialize load parameters.

Parameters:

Name Type Description Default
loads Sequence[Load]

Sequence of load objects.

required
Source code in src/odys/math_model/model_components/parameters/load_parameters.py
19
20
21
22
23
24
25
26
27
28
def __init__(self, loads: Sequence[Load]) -> None:
    """Initialize load parameters.

    Args:
        loads: Sequence of load objects.
    """
    self._loads = loads
    self._index = LoadIndex(
        values=tuple(gen.name for gen in self._loads),
    )

Market Parameters

Market parameters for the mathematical optimization model.

MarketIndex

Bases: ModelIndex

Index for market components in the optimization model.

Source code in src/odys/math_model/model_components/parameters/market_parameters.py
12
13
14
15
class MarketIndex(ModelIndex):
    """Index for market components in the optimization model."""

    dimension: ClassVar[ModelDimension] = ModelDimension.Markets

MarketParameters

Parameters for energy market components in the energy system model.

Source code in src/odys/math_model/model_components/parameters/market_parameters.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
class MarketParameters:
    """Parameters for energy market components in the energy system model."""

    def __init__(self, markets: Sequence[EnergyMarket]) -> None:
        """Initialize market parameters.

        Args:
            markets: Sequence of energy market objects.
        """
        self._index = MarketIndex(values=tuple(market.name for market in markets))
        data = {
            "max_volume": [market.max_trading_volume_per_step for market in markets],
            "stage_fixed": [market.stage_fixed for market in markets],
            "trade_direction": [market.trade_direction for market in markets],
        }
        dim = self._index.dimension
        self._dataset = xr.Dataset(
            {name: (dim, values) for name, values in data.items()},
            coords=self._index.coordinates,
        )

    @property
    def index(self) -> MarketIndex:
        """Return the market index."""
        return self._index

    @property
    def max_volume(self) -> xr.DataArray:
        """Return maximum trading volume per time step."""
        return self._dataset["max_volume"]

    @property
    def stage_fixed(self) -> xr.DataArray:
        """Return whether each market's variables are fixed across scenarios."""
        return self._dataset["stage_fixed"]

    @property
    def trade_direction(self) -> xr.DataArray:
        """Return the allowed trade direction (buy, sell, or both) per market."""
        return self._dataset["trade_direction"]

index property

Return the market index.

max_volume property

Return maximum trading volume per time step.

stage_fixed property

Return whether each market's variables are fixed across scenarios.

trade_direction property

Return the allowed trade direction (buy, sell, or both) per market.

__init__(markets)

Initialize market parameters.

Parameters:

Name Type Description Default
markets Sequence[EnergyMarket]

Sequence of energy market objects.

required
Source code in src/odys/math_model/model_components/parameters/market_parameters.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def __init__(self, markets: Sequence[EnergyMarket]) -> None:
    """Initialize market parameters.

    Args:
        markets: Sequence of energy market objects.
    """
    self._index = MarketIndex(values=tuple(market.name for market in markets))
    data = {
        "max_volume": [market.max_trading_volume_per_step for market in markets],
        "stage_fixed": [market.stage_fixed for market in markets],
        "trade_direction": [market.trade_direction for market in markets],
    }
    dim = self._index.dimension
    self._dataset = xr.Dataset(
        {name: (dim, values) for name, values in data.items()},
        coords=self._index.coordinates,
    )

Scenario Parameters

Scenario parameters for the mathematical optimization model.

ScenarioIndex

Bases: ModelIndex

Index for scenario components in the optimization model.

Source code in src/odys/math_model/model_components/parameters/scenario_parameters.py
23
24
25
26
class ScenarioIndex(ModelIndex):
    """Index for scenario components in the optimization model."""

    dimension: ClassVar[ModelDimension] = ModelDimension.Scenarios

ScenarioParameters

Parameters for scenarios in the energy system model.

Source code in src/odys/math_model/model_components/parameters/scenario_parameters.py
 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
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
class ScenarioParameters:
    """Parameters for scenarios in the energy system model."""

    def __init__(  # noqa: PLR0913
        self,
        number_of_timesteps: int,
        scenarios: Sequence[StochasticScenario],
        generators_index: GeneratorIndex | None,
        batteries_index: BatteryIndex | None,
        markets_index: MarketIndex | None,
        loads_index: LoadIndex | None,
    ) -> None:
        """Initialize scenario parameters.

        Args:
            number_of_timesteps: Number of time steps in the scenarios.
            scenarios: Sequence of stochastic scenario objects.
            generators_index: Optional generator index.
            batteries_index: Optional battery index.
            markets_index: Optional market index.
            loads_index: Optional load index.
        """
        self._number_of_timesteps = number_of_timesteps
        self._scenarios = scenarios
        self._generators_index = generators_index
        self._batteries_index = batteries_index
        self._markets_index = markets_index
        self._loads_index = loads_index
        self._time_index = TimeIndex(values=tuple(str(time_step) for time_step in range(number_of_timesteps)))
        self._scenario_index = ScenarioIndex(values=tuple(scenario.name for scenario in self._scenarios))

    @property
    def time_index(self) -> TimeIndex:
        """Return the time index."""
        return self._time_index

    @property
    def scenario_index(self) -> ScenarioIndex:
        """Return the scenario index."""
        return self._scenario_index

    @property
    def load_profiles(self) -> xr.DataArray | None:
        """Return load profiles across scenarios and time."""
        if self._loads_index is None:
            return None
        all_load_profiles = []
        for scenario in self._scenarios:
            scenario_load_profiles_mapping = scenario.load_profiles or {}
            scenario_load_profiles_array = [
                scenario_load_profiles_mapping.get(load_name) for load_name in self._loads_index.values
            ]
            all_load_profiles.append(scenario_load_profiles_array)

        return xr.DataArray(
            data=all_load_profiles,
            coords=self._scenario_index.coordinates | self._loads_index.coordinates | self._time_index.coordinates,
        )

    @property
    def market_prices(self) -> xr.DataArray | None:
        """Return market prices across scenarios and time."""
        if self._markets_index is None:
            return None
        all_market_prices = []
        for scenario in self._scenarios:
            scenario_market_prices_mapping = scenario.market_prices or {}
            scenario_market_prices_array = [
                scenario_market_prices_mapping.get(market_name) for market_name in self._markets_index.values
            ]
            all_market_prices.append(scenario_market_prices_array)

        return xr.DataArray(
            data=all_market_prices,
            coords=self._scenario_index.coordinates | self._markets_index.coordinates | self._time_index.coordinates,
        )

    @property
    def available_capacity_profiles(self) -> xr.DataArray | None:
        """Return available capacity profiles for generators across scenarios and time."""
        if self._generators_index is None:
            return None
        all_capacity_profiles = []

        for scenario in self._scenarios:
            profiles = scenario.available_capacity_profiles or {}
            scenario_complete_capacity_profiles = [
                profiles.get(gen_name, [np.inf] * self._number_of_timesteps)
                for gen_name in self._generators_index.values
            ]
            all_capacity_profiles.append(scenario_complete_capacity_profiles)

        return xr.DataArray(
            data=all_capacity_profiles,
            coords=self._scenario_index.coordinates | self._generators_index.coordinates | self._time_index.coordinates,
        )

    @property
    def scenario_probabilities(self) -> xr.DataArray:
        """Returns scenario probabilities as xarray DataArray."""
        return xr.DataArray(
            data=[scenario.probability for scenario in self._scenarios],
            coords=self._scenario_index.coordinates,
        )

available_capacity_profiles property

Return available capacity profiles for generators across scenarios and time.

load_profiles property

Return load profiles across scenarios and time.

market_prices property

Return market prices across scenarios and time.

scenario_index property

Return the scenario index.

scenario_probabilities property

Returns scenario probabilities as xarray DataArray.

time_index property

Return the time index.

__init__(number_of_timesteps, scenarios, generators_index, batteries_index, markets_index, loads_index)

Initialize scenario parameters.

Parameters:

Name Type Description Default
number_of_timesteps int

Number of time steps in the scenarios.

required
scenarios Sequence[StochasticScenario]

Sequence of stochastic scenario objects.

required
generators_index GeneratorIndex | None

Optional generator index.

required
batteries_index BatteryIndex | None

Optional battery index.

required
markets_index MarketIndex | None

Optional market index.

required
loads_index LoadIndex | None

Optional load index.

required
Source code in src/odys/math_model/model_components/parameters/scenario_parameters.py
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
def __init__(  # noqa: PLR0913
    self,
    number_of_timesteps: int,
    scenarios: Sequence[StochasticScenario],
    generators_index: GeneratorIndex | None,
    batteries_index: BatteryIndex | None,
    markets_index: MarketIndex | None,
    loads_index: LoadIndex | None,
) -> None:
    """Initialize scenario parameters.

    Args:
        number_of_timesteps: Number of time steps in the scenarios.
        scenarios: Sequence of stochastic scenario objects.
        generators_index: Optional generator index.
        batteries_index: Optional battery index.
        markets_index: Optional market index.
        loads_index: Optional load index.
    """
    self._number_of_timesteps = number_of_timesteps
    self._scenarios = scenarios
    self._generators_index = generators_index
    self._batteries_index = batteries_index
    self._markets_index = markets_index
    self._loads_index = loads_index
    self._time_index = TimeIndex(values=tuple(str(time_step) for time_step in range(number_of_timesteps)))
    self._scenario_index = ScenarioIndex(values=tuple(scenario.name for scenario in self._scenarios))

TimeIndex

Bases: ModelIndex

Index for time components in the optimization model.

Source code in src/odys/math_model/model_components/parameters/scenario_parameters.py
17
18
19
20
class TimeIndex(ModelIndex):
    """Index for time components in the optimization model."""

    dimension: ClassVar[ModelDimension] = ModelDimension.Time

Linopy Converter

Utilities for converting model variables into linopy-compatible parameters.

LinopyVariableParameters

Bases: BaseModel

Parameters needed to add a variable to a linopy model.

Source code in src/odys/math_model/model_components/linopy_converter.py
14
15
16
17
18
19
20
21
22
23
class LinopyVariableParameters(BaseModel):
    """Parameters needed to add a variable to a linopy model."""

    model_config = ConfigDict(arbitrary_types_allowed=True)

    name: str
    coords: Mapping[str, list[str]]
    dims: Sequence[str]
    lower: np.ndarray | float
    binary: bool

get_variable_lower_bound(indeces, lower_bound_type, *, is_binary)

Calculate lower bounds for a variable.

Parameters:

Name Type Description Default
indeces list[ModelIndex]

List of dimension sets for the variable

required
lower_bound_type BoundType

Type of lower bound

required
is_binary bool

Whether the variable is binary

required

Returns:

Type Description
ndarray | float

Lower bound value or array

Source code in src/odys/math_model/model_components/linopy_converter.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def get_variable_lower_bound(
    indeces: list[ModelIndex],
    lower_bound_type: BoundType,
    *,
    is_binary: bool,
) -> np.ndarray | float:
    """Calculate lower bounds for a variable.

    Args:
        indeces: List of dimension sets for the variable
        lower_bound_type: Type of lower bound
        is_binary: Whether the variable is binary

    Returns:
        Lower bound value or array
    """
    if is_binary:
        return -np.inf  # Required by linopy.add_variable when variable is binary

    shape = tuple(len(dim_set.values) for dim_set in indeces)

    if lower_bound_type == BoundType.UNBOUNDED:
        return np.full(shape, -np.inf, dtype=float)
    return np.full(shape, 0, dtype=float)