Skip to content

odys.optimization.formulations.storage

The shared battery model of battery storage and electric vehicles (StorageFormulation), a reusable part both formulations compose. Variable and constraint names carry the entity dimension as a prefix.

The variables, constraints and objective terms are written out on the Batteries formulation page.

See also Battery for the domain model and entity_arrays for the parameter arrays.

Battery physics shared by every storage-like entity type (battery storage, electric vehicles).

StorageFormulation

The battery model of one storage-like entity type, composed by that type's formulation.

It is a reusable part, not a Formulation: it holds no variables (the composing formulation does, and passes them in), and each method returns one constraint. Variable and constraint names are prefixed with the dimension name ("battery_storage", "ev"). An optional SOC drop (EV trip energy as a fraction of capacity) is subtracted at each step.

Source code in src/odys/optimization/formulations/storage.py
 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
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
class StorageFormulation:
    """The battery model of one storage-like entity type, composed by that type's formulation.

    It is a reusable part, not a `Formulation`: it holds no variables (the
    composing formulation does, and passes them in), and each method returns
    one constraint. Variable and constraint names are prefixed with the
    dimension name ("battery_storage", "ev"). An optional SOC drop (EV trip
    energy as a fraction of capacity) is subtracted at each step.
    """

    def __init__(
        self,
        coordinates: Coordinates,
        battery: BatteryArrays,
        states: BatteryStateArrays,
        context: ModelContext,
        soc_drop: xr.DataArray | None = None,
    ) -> None:
        """Initialize the battery model.

        Args:
            coordinates: The labels of the batteries along their dimension, which also names them.
            battery: The battery parameters, vectorized along that dimension.
            states: The initial and final state of charge of the batteries, from the run's conditions.
            context: The shared indexing of the problem.
            soc_drop: SOC lost at each step outside charging (EV trips), if any.
        """
        self.coordinates = coordinates
        self.battery = battery
        self.states = states
        self.context = context
        self.soc_drop = soc_drop

    @property
    def dimension(self) -> str:
        """Return the dimension the batteries are indexed along."""
        return self.coordinates.dimension

    def name(self, what: str) -> str:
        """Return the model name of a variable or constraint of this entity type, such as `ev_soc`."""
        return f"{self.dimension}_{what}"

    def create_variables(self, model: linopy.Model) -> StorageVariables:
        """Add the battery variables: powers and SOC are non-negative, net power is free, the mode is binary."""
        coords = self.context.variable_coords(self.coordinates)
        return StorageVariables(
            power_in=model.add_variables(name=self.name("power_in"), coords=coords, lower=0.0),
            net_power=model.add_variables(name=self.name("net_power"), coords=coords, lower=-np.inf),
            power_out=model.add_variables(name=self.name("power_out"), coords=coords, lower=0.0),
            soc=model.add_variables(name=self.name("soc"), coords=coords, lower=0.0),
            charge_mode=model.add_variables(name=self.name("charge_mode"), coords=coords, binary=True),
        )

    def max_charge_constraint(self, variables: StorageVariables) -> ModelConstraint:
        """Charging power is limited by `max_charge_power`, and only in charging mode."""
        return ModelConstraint(
            constraint=variables.power_in <= variables.charge_mode * self.battery.max_charge_power,
            name=self.name("max_charge_constraint"),
        )

    def max_discharge_constraint(self, variables: StorageVariables) -> ModelConstraint:
        """Discharging power is limited by `max_discharge_power`, and only outside charging mode."""
        max_discharge_power = self.battery.max_discharge_power
        return ModelConstraint(
            constraint=variables.power_out + variables.charge_mode * max_discharge_power <= max_discharge_power,
            name=self.name("max_discharge_constraint"),
        )

    def soc_dynamics_constraint(self, variables: StorageVariables) -> ModelConstraint:
        """SOC follows self-discharge, charging and discharging (with efficiencies) from step to step."""
        dt = self.context.timestep_hours
        battery = self.battery
        soc = variables.soc
        expression = soc - (
            time_shifted(soc, 1) * (1 - battery.self_discharge_rate * dt)
            + battery.efficiency_charging * variables.power_in * dt / battery.capacity
            - 1 / battery.efficiency_discharging * variables.power_out * dt / battery.capacity
        )
        if self.soc_drop is not None:
            expression += self.soc_drop
        return ModelConstraint(
            constraint=expression.isel({TIME: slice(1, None)}).to_constraint("=", 0),
            name=self.name("soc_dynamics_constraint"),
        )

    def soc_start_constraint(self, variables: StorageVariables) -> ModelConstraint:
        """The SOC after the first step starts from `soc_start`, less self-discharge and the SOC drop at t=0."""
        dt = self.context.timestep_hours
        battery = self.battery
        first_step = {TIME: variables.soc.coords[TIME.value].values[0]}
        expression = (
            variables.soc.sel(first_step)
            - self.states.soc_start * (1 - battery.self_discharge_rate * dt)
            - battery.efficiency_charging * variables.power_in.sel(first_step) * dt / battery.capacity
            + 1 / battery.efficiency_discharging * variables.power_out.sel(first_step) * dt / battery.capacity
        )
        if self.soc_drop is not None:
            expression += self.soc_drop.isel({TIME: 0})
        return ModelConstraint(
            constraint=expression.to_constraint("=", 0),
            name=self.name("soc_start_constraint"),
        )

    def soc_end_constraint(self, variables: StorageVariables) -> list[ModelConstraint]:
        """The SOC at the last step equals `soc_end`, for the batteries that set one."""
        has_soc_end = self.states.soc_end.notnull()
        if not has_soc_end.any():
            return []
        last_step = {TIME: variables.soc.coords[TIME.value].values[-1]}
        with_soc_end = {self.dimension: has_soc_end}
        expression = variables.soc.sel(last_step).sel(with_soc_end) - self.states.soc_end.sel(with_soc_end)
        return [
            ModelConstraint(
                constraint=expression.to_constraint("=", 0),
                name=self.name("soc_end_constraint"),
            ),
        ]

    def soc_min_constraint(self, variables: StorageVariables) -> ModelConstraint:
        """The SOC never falls below `soc_min`."""
        return ModelConstraint(
            constraint=variables.soc >= self.battery.soc_min,
            name=self.name("soc_min_constraint"),
        )

    def soc_max_constraint(self, variables: StorageVariables) -> ModelConstraint:
        """The SOC never exceeds `soc_max`."""
        return ModelConstraint(
            constraint=variables.soc <= self.battery.soc_max,
            name=self.name("soc_max_constraint"),
        )

    def capacity_constraint(self, variables: StorageVariables) -> ModelConstraint:
        """The SOC never exceeds a full battery (1)."""
        return ModelConstraint(
            constraint=(1 * variables.soc).to_constraint("<=", 1),
            name=self.name("capacity_constraint"),
        )

    def net_power_constraint(self, variables: StorageVariables) -> ModelConstraint:
        """Net power is charging minus discharging power."""
        return ModelConstraint(
            constraint=(variables.net_power - variables.power_in + variables.power_out).to_constraint("=", 0),
            name=self.name("net_power_constraint"),
        )

    def power_injection(self, variables: StorageVariables) -> linopy.LinearExpression:
        """Return discharging minus charging power, summed over the batteries."""
        discharge = variables.power_out.sum(self.dimension)
        injection: linopy.LinearExpression = discharge - variables.power_in.sum(self.dimension)
        return injection

    def degradation_cost(self, variables: StorageVariables) -> linopy.LinearExpression:
        """Return the degradation cost per scenario: throughput times step length times cost per MWh."""
        throughput = (variables.power_in + variables.power_out) * self.context.timestep_hours
        cost: linopy.LinearExpression = (throughput * self.battery.degradation_cost).sum([TIME, self.dimension])
        return cost

    def dispatch_data(self, solution: xr.Dataset) -> xr.Dataset:
        """Return the net power, state of charge and charge mode of the batteries, read from the solution."""
        return xr.Dataset({what: solution[self.name(what)] for what in ("net_power", "soc", "charge_mode")})

dimension property

Return the dimension the batteries are indexed along.

__init__(coordinates, battery, states, context, soc_drop=None)

Initialize the battery model.

Parameters:

Name Type Description Default
coordinates Coordinates

The labels of the batteries along their dimension, which also names them.

required
battery BatteryArrays

The battery parameters, vectorized along that dimension.

required
states BatteryStateArrays

The initial and final state of charge of the batteries, from the run's conditions.

required
context ModelContext

The shared indexing of the problem.

required
soc_drop DataArray | None

SOC lost at each step outside charging (EV trips), if any.

None
Source code in src/odys/optimization/formulations/storage.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def __init__(
    self,
    coordinates: Coordinates,
    battery: BatteryArrays,
    states: BatteryStateArrays,
    context: ModelContext,
    soc_drop: xr.DataArray | None = None,
) -> None:
    """Initialize the battery model.

    Args:
        coordinates: The labels of the batteries along their dimension, which also names them.
        battery: The battery parameters, vectorized along that dimension.
        states: The initial and final state of charge of the batteries, from the run's conditions.
        context: The shared indexing of the problem.
        soc_drop: SOC lost at each step outside charging (EV trips), if any.
    """
    self.coordinates = coordinates
    self.battery = battery
    self.states = states
    self.context = context
    self.soc_drop = soc_drop

capacity_constraint(variables)

The SOC never exceeds a full battery (1).

Source code in src/odys/optimization/formulations/storage.py
162
163
164
165
166
167
def capacity_constraint(self, variables: StorageVariables) -> ModelConstraint:
    """The SOC never exceeds a full battery (1)."""
    return ModelConstraint(
        constraint=(1 * variables.soc).to_constraint("<=", 1),
        name=self.name("capacity_constraint"),
    )

create_variables(model)

Add the battery variables: powers and SOC are non-negative, net power is free, the mode is binary.

Source code in src/odys/optimization/formulations/storage.py
72
73
74
75
76
77
78
79
80
81
def create_variables(self, model: linopy.Model) -> StorageVariables:
    """Add the battery variables: powers and SOC are non-negative, net power is free, the mode is binary."""
    coords = self.context.variable_coords(self.coordinates)
    return StorageVariables(
        power_in=model.add_variables(name=self.name("power_in"), coords=coords, lower=0.0),
        net_power=model.add_variables(name=self.name("net_power"), coords=coords, lower=-np.inf),
        power_out=model.add_variables(name=self.name("power_out"), coords=coords, lower=0.0),
        soc=model.add_variables(name=self.name("soc"), coords=coords, lower=0.0),
        charge_mode=model.add_variables(name=self.name("charge_mode"), coords=coords, binary=True),
    )

degradation_cost(variables)

Return the degradation cost per scenario: throughput times step length times cost per MWh.

Source code in src/odys/optimization/formulations/storage.py
182
183
184
185
186
def degradation_cost(self, variables: StorageVariables) -> linopy.LinearExpression:
    """Return the degradation cost per scenario: throughput times step length times cost per MWh."""
    throughput = (variables.power_in + variables.power_out) * self.context.timestep_hours
    cost: linopy.LinearExpression = (throughput * self.battery.degradation_cost).sum([TIME, self.dimension])
    return cost

dispatch_data(solution)

Return the net power, state of charge and charge mode of the batteries, read from the solution.

Source code in src/odys/optimization/formulations/storage.py
188
189
190
def dispatch_data(self, solution: xr.Dataset) -> xr.Dataset:
    """Return the net power, state of charge and charge mode of the batteries, read from the solution."""
    return xr.Dataset({what: solution[self.name(what)] for what in ("net_power", "soc", "charge_mode")})

max_charge_constraint(variables)

Charging power is limited by max_charge_power, and only in charging mode.

Source code in src/odys/optimization/formulations/storage.py
83
84
85
86
87
88
def max_charge_constraint(self, variables: StorageVariables) -> ModelConstraint:
    """Charging power is limited by `max_charge_power`, and only in charging mode."""
    return ModelConstraint(
        constraint=variables.power_in <= variables.charge_mode * self.battery.max_charge_power,
        name=self.name("max_charge_constraint"),
    )

max_discharge_constraint(variables)

Discharging power is limited by max_discharge_power, and only outside charging mode.

Source code in src/odys/optimization/formulations/storage.py
90
91
92
93
94
95
96
def max_discharge_constraint(self, variables: StorageVariables) -> ModelConstraint:
    """Discharging power is limited by `max_discharge_power`, and only outside charging mode."""
    max_discharge_power = self.battery.max_discharge_power
    return ModelConstraint(
        constraint=variables.power_out + variables.charge_mode * max_discharge_power <= max_discharge_power,
        name=self.name("max_discharge_constraint"),
    )

name(what)

Return the model name of a variable or constraint of this entity type, such as ev_soc.

Source code in src/odys/optimization/formulations/storage.py
68
69
70
def name(self, what: str) -> str:
    """Return the model name of a variable or constraint of this entity type, such as `ev_soc`."""
    return f"{self.dimension}_{what}"

net_power_constraint(variables)

Net power is charging minus discharging power.

Source code in src/odys/optimization/formulations/storage.py
169
170
171
172
173
174
def net_power_constraint(self, variables: StorageVariables) -> ModelConstraint:
    """Net power is charging minus discharging power."""
    return ModelConstraint(
        constraint=(variables.net_power - variables.power_in + variables.power_out).to_constraint("=", 0),
        name=self.name("net_power_constraint"),
    )

power_injection(variables)

Return discharging minus charging power, summed over the batteries.

Source code in src/odys/optimization/formulations/storage.py
176
177
178
179
180
def power_injection(self, variables: StorageVariables) -> linopy.LinearExpression:
    """Return discharging minus charging power, summed over the batteries."""
    discharge = variables.power_out.sum(self.dimension)
    injection: linopy.LinearExpression = discharge - variables.power_in.sum(self.dimension)
    return injection

soc_dynamics_constraint(variables)

SOC follows self-discharge, charging and discharging (with efficiencies) from step to step.

Source code in src/odys/optimization/formulations/storage.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def soc_dynamics_constraint(self, variables: StorageVariables) -> ModelConstraint:
    """SOC follows self-discharge, charging and discharging (with efficiencies) from step to step."""
    dt = self.context.timestep_hours
    battery = self.battery
    soc = variables.soc
    expression = soc - (
        time_shifted(soc, 1) * (1 - battery.self_discharge_rate * dt)
        + battery.efficiency_charging * variables.power_in * dt / battery.capacity
        - 1 / battery.efficiency_discharging * variables.power_out * dt / battery.capacity
    )
    if self.soc_drop is not None:
        expression += self.soc_drop
    return ModelConstraint(
        constraint=expression.isel({TIME: slice(1, None)}).to_constraint("=", 0),
        name=self.name("soc_dynamics_constraint"),
    )

soc_end_constraint(variables)

The SOC at the last step equals soc_end, for the batteries that set one.

Source code in src/odys/optimization/formulations/storage.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def soc_end_constraint(self, variables: StorageVariables) -> list[ModelConstraint]:
    """The SOC at the last step equals `soc_end`, for the batteries that set one."""
    has_soc_end = self.states.soc_end.notnull()
    if not has_soc_end.any():
        return []
    last_step = {TIME: variables.soc.coords[TIME.value].values[-1]}
    with_soc_end = {self.dimension: has_soc_end}
    expression = variables.soc.sel(last_step).sel(with_soc_end) - self.states.soc_end.sel(with_soc_end)
    return [
        ModelConstraint(
            constraint=expression.to_constraint("=", 0),
            name=self.name("soc_end_constraint"),
        ),
    ]

soc_max_constraint(variables)

The SOC never exceeds soc_max.

Source code in src/odys/optimization/formulations/storage.py
155
156
157
158
159
160
def soc_max_constraint(self, variables: StorageVariables) -> ModelConstraint:
    """The SOC never exceeds `soc_max`."""
    return ModelConstraint(
        constraint=variables.soc <= self.battery.soc_max,
        name=self.name("soc_max_constraint"),
    )

soc_min_constraint(variables)

The SOC never falls below soc_min.

Source code in src/odys/optimization/formulations/storage.py
148
149
150
151
152
153
def soc_min_constraint(self, variables: StorageVariables) -> ModelConstraint:
    """The SOC never falls below `soc_min`."""
    return ModelConstraint(
        constraint=variables.soc >= self.battery.soc_min,
        name=self.name("soc_min_constraint"),
    )

soc_start_constraint(variables)

The SOC after the first step starts from soc_start, less self-discharge and the SOC drop at t=0.

Source code in src/odys/optimization/formulations/storage.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
def soc_start_constraint(self, variables: StorageVariables) -> ModelConstraint:
    """The SOC after the first step starts from `soc_start`, less self-discharge and the SOC drop at t=0."""
    dt = self.context.timestep_hours
    battery = self.battery
    first_step = {TIME: variables.soc.coords[TIME.value].values[0]}
    expression = (
        variables.soc.sel(first_step)
        - self.states.soc_start * (1 - battery.self_discharge_rate * dt)
        - battery.efficiency_charging * variables.power_in.sel(first_step) * dt / battery.capacity
        + 1 / battery.efficiency_discharging * variables.power_out.sel(first_step) * dt / battery.capacity
    )
    if self.soc_drop is not None:
        expression += self.soc_drop.isel({TIME: 0})
    return ModelConstraint(
        constraint=expression.to_constraint("=", 0),
        name=self.name("soc_start_constraint"),
    )

StorageVariables

Bases: BaseModel

Decision variables of a battery: charge and discharge power, net power, state of charge and mode.

Source code in src/odys/optimization/formulations/storage.py
18
19
20
21
22
23
24
25
26
27
class StorageVariables(BaseModel):
    """Decision variables of a battery: charge and discharge power, net power, state of charge and mode."""

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

    power_in: linopy.Variable
    net_power: linopy.Variable
    power_out: linopy.Variable
    soc: linopy.Variable
    charge_mode: linopy.Variable