Note

You can download this example as a Jupyter notebook or start it in interactive mode.

Optimization with Linopy#

In PyPSA v0.22, an additional optimization module was introduced to the package. It is built on Linopy and aims at

  • performance as we know it from the native PyPSA optimization (lopf with pyomo=False)

  • flexibility as we know from the Pyomo implementation

  • usability as we know from pandas/xarray

Linopy is a stand-alone package and works similar to Pyomo, but without the memory overhead and much faster. In the long-term, we are planning to slowly migrate towards the Linopy-based optimization only. In order to facilitate the transmission from the native PyPSA optimization (lopf with pyomo=False), the module pypsa.optimization.compat provides functions similar to pypsa.linopt. Have a look at our migration guide (next notebook).

If you don’t have any code to migrate, we recommend to directly use the Linopy functions instead.

For additional information on the Linopy package, have a look at the documentation.

Let’s get started#

Now, we demonstrate the behaviour of the optimization with linopy. The core functions for the optimization can be called via the Network.optimize accessor. The accessor is used for creating, solving, modifying the optimization problem. Further, it supports to run different optimization formulations and provides helper functions.

At first, we run the ordinary linearized optimal power flow (LOPF). We then extend the formulation by some additional constraints.

[1]:
import pypsa
ERROR 1: PROJ: proj_create_from_database: Open of /home/docs/checkouts/readthedocs.org/user_builds/pypsa/conda/latest/share/proj failed
[2]:
n = pypsa.examples.ac_dc_meshed(from_master=True)
WARNING:pypsa.io:Importing network from PyPSA version v0.17.1 while current version is v0.28.1. Read the release notes at https://pypsa.readthedocs.io/en/latest/release_notes.html to prepare your network for import.
INFO:pypsa.io:Imported network ac-dc-meshed.nc has buses, carriers, generators, global_constraints, lines, links, loads

In order to make the network a bit more interesting, we modify its data: We set gas generators to non-extendable,

[3]:
n.generators.loc[n.generators.carrier == "gas", "p_nom_extendable"] = False

… add ramp limits,

[4]:
n.generators.loc[n.generators.carrier == "gas", "ramp_limit_down"] = 0.2
n.generators.loc[n.generators.carrier == "gas", "ramp_limit_up"] = 0.2

… add additional storage units (cyclic and non-cyclic) and fix one state_of_charge,

[5]:
n.add(
    "StorageUnit",
    "su",
    bus="Manchester",
    marginal_cost=10,
    inflow=50,
    p_nom_extendable=True,
    capital_cost=10,
    p_nom=2000,
    efficiency_dispatch=0.5,
    cyclic_state_of_charge=True,
    state_of_charge_initial=1000,
)

n.add(
    "StorageUnit",
    "su2",
    bus="Manchester",
    marginal_cost=10,
    p_nom_extendable=True,
    capital_cost=50,
    p_nom=2000,
    efficiency_dispatch=0.5,
    carrier="gas",
    cyclic_state_of_charge=False,
    state_of_charge_initial=1000,
)

n.storage_units_t.state_of_charge_set.loc[n.snapshots[7], "su"] = 100

…and add an additional store.

[6]:
n.add("Bus", "storebus", carrier="hydro", x=-5, y=55)
n.madd(
    "Link",
    ["battery_power", "battery_discharge"],
    "",
    bus0=["Manchester", "storebus"],
    bus1=["storebus", "Manchester"],
    p_nom=100,
    efficiency=0.9,
    p_nom_extendable=True,
    p_nom_max=1000,
)
n.madd(
    "Store",
    ["store"],
    bus="storebus",
    e_nom=2000,
    e_nom_extendable=True,
    marginal_cost=10,
    capital_cost=10,
    e_nom_max=5000,
    e_initial=100,
    e_cyclic=True,
);

Ordinary Optimization#

Per default, the optimization based on linopy mimics the well-known n.lopf optimization. We run it by calling the optimize accessor.

[7]:
n.optimize()
WARNING:pypsa.consistency:The following stores have carriers which are not defined:
Index(['store'], dtype='object', name='Store')
WARNING:pypsa.consistency:The following links have carriers which are not defined:
Index(['Norwich Converter', 'Norway Converter', 'Bremen Converter', 'DC link',
       'battery_power', 'battery_discharge'],
      dtype='object', name='Link')
WARNING:pypsa.consistency:The following lines have carriers which are not defined:
Index(['0', '1', '2', '3', '4', '5', '6'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following lines have zero x, which could break the linear load flow:
Index(['2', '3', '4'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following lines have zero r, which could break the linear load flow:
Index(['0', '1', '5', '6'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following buses have carriers which are not defined:
Index(['London', 'Norwich', 'Norwich DC', 'Manchester', 'Bremen', 'Bremen DC',
       'Frankfurt', 'Norway', 'Norway DC', 'storebus'],
      dtype='object', name='Bus')
WARNING:pypsa.consistency:The following stores have carriers which are not defined:
Index(['store'], dtype='object', name='Store')
WARNING:pypsa.consistency:The following links have carriers which are not defined:
Index(['Norwich Converter', 'Norway Converter', 'Bremen Converter', 'DC link',
       'battery_power', 'battery_discharge'],
      dtype='object', name='Link')
WARNING:pypsa.consistency:The following lines have carriers which are not defined:
Index(['0', '1', '2', '3', '4', '5', '6'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following lines have zero x, which could break the linear load flow:
Index(['2', '3', '4'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following lines have zero r, which could break the linear load flow:
Index(['0', '1', '5', '6'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following buses have carriers which are not defined:
Index(['London', 'Norwich', 'Norwich DC', 'Manchester', 'Bremen', 'Bremen DC',
       'Frankfurt', 'Norway', 'Norway DC', 'storebus'],
      dtype='object', name='Bus')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.io: Writing time: 0.15s
INFO:linopy.solvers:Log file at /tmp/highs.log
INFO:linopy.constants: Optimization successful:
Status: ok
Termination condition: optimal
Solution: 300 primals, 748 duals
Objective: 1.41e+07
Solver model: available
Solver message: optimal

INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Generator-ext-p-lower, Generator-ext-p-upper, Generator-fix-p-ramp_limit_up, Generator-fix-p-ramp_limit_down, Line-ext-s-lower, Line-ext-s-upper, Link-ext-p-lower, Link-ext-p-upper, Store-ext-e-lower, Store-ext-e-upper, StorageUnit-ext-p_dispatch-lower, StorageUnit-ext-p_dispatch-upper, StorageUnit-ext-p_store-lower, StorageUnit-ext-p_store-upper, StorageUnit-ext-state_of_charge-lower, StorageUnit-ext-state_of_charge-upper, StorageUnit-state_of_charge_set, Kirchhoff-Voltage-Law, StorageUnit-energy_balance, Store-energy_balance were not assigned to the network.
Running HiGHS 1.7.1 (git hash: 0c240d8): Copyright (c) 2024 HiGHS under MIT licence terms
Coefficient ranges:
  Matrix [1e-02, 2e+00]
  Cost   [9e-03, 3e+03]
  Bound  [5e+01, 8e+05]
  RHS    [9e-01, 8e+04]
Presolving model
524 rows, 296 cols, 1324 nonzeros  0s
428 rows, 200 cols, 1410 nonzeros  0s
400 rows, 199 cols, 1366 nonzeros  0s
Presolve : Reductions: rows 400(-348); columns 199(-101); elements 1366(-205)
Solving the presolved LP
Using EKK dual simplex solver - serial
  Iteration        Objective     Infeasibilities num(sum)
          0    -1.3357027996e+05 Pr: 114(89908.3); Du: 0(2.99875e-11) 0s
        178     1.4121237693e+07 Pr: 0(0); Du: 0(7.10543e-14) 0s
Solving the original LP from the solution after postsolve
Model   status      : Optimal
Simplex   iterations: 178
Objective value     :  1.4121237693e+07
HiGHS run time      :          0.01
[7]:
('ok', 'optimal')

Compared to the native optimization, we now have a model instance attached to our network. It is a container of all variables, constraints and the objective function. You can modify this as much as you please, by directly adding or deleting variables or constraints etc.

[8]:
n.model
[8]:
Linopy LP model
===============

Variables:
----------
 * Generator-p_nom (Generator-ext)
 * Line-s_nom (Line-ext)
 * Link-p_nom (Link-ext)
 * Store-e_nom (Store-ext)
 * StorageUnit-p_nom (StorageUnit-ext)
 * Generator-p (snapshot, Generator)
 * Line-s (snapshot, Line)
 * Link-p (snapshot, Link)
 * Store-e (snapshot, Store)
 * StorageUnit-p_dispatch (snapshot, StorageUnit)
 * StorageUnit-p_store (snapshot, StorageUnit)
 * StorageUnit-state_of_charge (snapshot, StorageUnit)
 * StorageUnit-spill (snapshot, StorageUnit)
 * Store-p (snapshot, Store)
 * objective_constant

Constraints:
------------
 * Generator-ext-p_nom-lower (Generator-ext)
 * Generator-ext-p_nom-upper (Generator-ext)
 * Line-ext-s_nom-lower (Line-ext)
 * Line-ext-s_nom-upper (Line-ext)
 * Link-ext-p_nom-lower (Link-ext)
 * Link-ext-p_nom-upper (Link-ext)
 * Store-ext-e_nom-lower (Store-ext)
 * Store-ext-e_nom-upper (Store-ext)
 * StorageUnit-ext-p_nom-lower (StorageUnit-ext)
 * StorageUnit-ext-p_nom-upper (StorageUnit-ext)
 * Generator-fix-p-lower (snapshot, Generator-fix)
 * Generator-fix-p-upper (snapshot, Generator-fix)
 * Generator-ext-p-lower (snapshot, Generator-ext)
 * Generator-ext-p-upper (snapshot, Generator-ext)
 * Generator-fix-p-ramp_limit_up (snapshot, Generator-fix)
 * Generator-fix-p-ramp_limit_down (snapshot, Generator-fix)
 * Line-ext-s-lower (snapshot, Line-ext)
 * Line-ext-s-upper (snapshot, Line-ext)
 * Link-ext-p-lower (snapshot, Link-ext)
 * Link-ext-p-upper (snapshot, Link-ext)
 * Store-ext-e-lower (snapshot, Store-ext)
 * Store-ext-e-upper (snapshot, Store-ext)
 * StorageUnit-ext-p_dispatch-lower (snapshot, StorageUnit-ext)
 * StorageUnit-ext-p_dispatch-upper (snapshot, StorageUnit-ext)
 * StorageUnit-ext-p_store-lower (snapshot, StorageUnit-ext)
 * StorageUnit-ext-p_store-upper (snapshot, StorageUnit-ext)
 * StorageUnit-ext-state_of_charge-lower (snapshot, StorageUnit-ext)
 * StorageUnit-ext-state_of_charge-upper (snapshot, StorageUnit-ext)
 * StorageUnit-state_of_charge_set (snapshot, StorageUnit-state_of_charge_set_i)
 * Bus-nodal_balance (Bus, snapshot)
 * Kirchhoff-Voltage-Law (snapshot, cycles)
 * StorageUnit-energy_balance (snapshot, StorageUnit)
 * Store-energy_balance (snapshot, Store)
 * GlobalConstraint-co2_limit (snapshot)

Status:
-------
ok

Modify model, optimize and feed back to network#

When you have a fresh network and you just want to create the model instance, run

[9]:
n.optimize.create_model();
WARNING:pypsa.consistency:The following sub_networks have carriers which are not defined:
Index(['0', '1', '2'], dtype='object', name='SubNetwork')
WARNING:pypsa.consistency:The following stores have carriers which are not defined:
Index(['store'], dtype='object', name='Store')
WARNING:pypsa.consistency:The following links have carriers which are not defined:
Index(['Norwich Converter', 'Norway Converter', 'Bremen Converter', 'DC link',
       'battery_power', 'battery_discharge'],
      dtype='object', name='Link')
WARNING:pypsa.consistency:The following lines have carriers which are not defined:
Index(['0', '1', '2', '3', '4', '5', '6'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following lines have zero x, which could break the linear load flow:
Index(['2', '3', '4'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following lines have zero r, which could break the linear load flow:
Index(['0', '1', '5', '6'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following buses have carriers which are not defined:
Index(['London', 'Norwich', 'Norwich DC', 'Manchester', 'Bremen', 'Bremen DC',
       'Frankfurt', 'Norway', 'Norway DC', 'storebus'],
      dtype='object', name='Bus')

Through the model instance we gain a lot of flexibility. Let’s say for example we want to remove the Kirchhoff Voltage Law constraint, thus convert the model to a transport model. This can be done via

[10]:
n.model.constraints.remove("Kirchhoff-Voltage-Law")

Now, we want to optimize the altered model and feed to solution back to the network. Here again, we use the optimize accessor.

[11]:
n.optimize.solve_model()
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.io: Writing time: 0.14s
INFO:linopy.solvers:Log file at /tmp/highs.log
INFO:linopy.constants: Optimization successful:
Status: ok
Termination condition: optimal
Solution: 300 primals, 728 duals
Objective: 1.41e+07
Solver model: available
Solver message: optimal

INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Generator-ext-p-lower, Generator-ext-p-upper, Generator-fix-p-ramp_limit_up, Generator-fix-p-ramp_limit_down, Line-ext-s-lower, Line-ext-s-upper, Link-ext-p-lower, Link-ext-p-upper, Store-ext-e-lower, Store-ext-e-upper, StorageUnit-ext-p_dispatch-lower, StorageUnit-ext-p_dispatch-upper, StorageUnit-ext-p_store-lower, StorageUnit-ext-p_store-upper, StorageUnit-ext-state_of_charge-lower, StorageUnit-ext-state_of_charge-upper, StorageUnit-state_of_charge_set, StorageUnit-energy_balance, Store-energy_balance were not assigned to the network.
Running HiGHS 1.7.1 (git hash: 0c240d8): Copyright (c) 2024 HiGHS under MIT licence terms
Coefficient ranges:
  Matrix [1e-02, 2e+00]
  Cost   [9e-03, 3e+03]
  Bound  [5e+01, 8e+05]
  RHS    [9e-01, 8e+04]
Presolving model
504 rows, 296 cols, 1264 nonzeros  0s
426 rows, 218 cols, 1302 nonzeros  0s
399 rows, 218 cols, 1248 nonzeros  0s
Presolve : Reductions: rows 399(-329); columns 218(-82); elements 1248(-263)
Solving the presolved LP
Using EKK dual simplex solver - serial
  Iteration        Objective     Infeasibilities num(sum)
          0    -2.4998861248e+00 Ph1: 190(204422); Du: 2(2.49989) 0s
        236     1.4121074568e+07 Pr: 0(0) 0s
Solving the original LP from the solution after postsolve
Model   status      : Optimal
Simplex   iterations: 236
Objective value     :  1.4121074568e+07
HiGHS run time      :          0.01
[11]:
('ok', 'optimal')

Here, we followed the recommended way to run altered models:

  1. Create the model instance - n.optimize.create_model()

  2. Modify the model to your needs

  3. Solve and feed back - n.optimize.solve_model()

For compatibility reasons the optimize function, also allows passing a extra_funcionality argument, as we know it from the lopf function. The above behaviour with use of the extra functionality is obtained through

[12]:
def remove_kvl(n, sns):
    print("KVL removed!")
    n.model.constraints.remove("Kirchhoff-Voltage-Law")


n.optimize(extra_functionality=remove_kvl)
WARNING:pypsa.consistency:The following sub_networks have carriers which are not defined:
Index(['0', '1', '2'], dtype='object', name='SubNetwork')
WARNING:pypsa.consistency:The following stores have carriers which are not defined:
Index(['store'], dtype='object', name='Store')
WARNING:pypsa.consistency:The following links have carriers which are not defined:
Index(['Norwich Converter', 'Norway Converter', 'Bremen Converter', 'DC link',
       'battery_power', 'battery_discharge'],
      dtype='object', name='Link')
WARNING:pypsa.consistency:The following lines have carriers which are not defined:
Index(['0', '1', '2', '3', '4', '5', '6'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following lines have zero x, which could break the linear load flow:
Index(['2', '3', '4'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following lines have zero r, which could break the linear load flow:
Index(['0', '1', '5', '6'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following buses have carriers which are not defined:
Index(['London', 'Norwich', 'Norwich DC', 'Manchester', 'Bremen', 'Bremen DC',
       'Frankfurt', 'Norway', 'Norway DC', 'storebus'],
      dtype='object', name='Bus')
WARNING:pypsa.consistency:The following sub_networks have carriers which are not defined:
Index(['0', '1', '2'], dtype='object', name='SubNetwork')
WARNING:pypsa.consistency:The following stores have carriers which are not defined:
Index(['store'], dtype='object', name='Store')
WARNING:pypsa.consistency:The following links have carriers which are not defined:
Index(['Norwich Converter', 'Norway Converter', 'Bremen Converter', 'DC link',
       'battery_power', 'battery_discharge'],
      dtype='object', name='Link')
WARNING:pypsa.consistency:The following lines have carriers which are not defined:
Index(['0', '1', '2', '3', '4', '5', '6'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following lines have zero x, which could break the linear load flow:
Index(['2', '3', '4'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following lines have zero r, which could break the linear load flow:
Index(['0', '1', '5', '6'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following buses have carriers which are not defined:
Index(['London', 'Norwich', 'Norwich DC', 'Manchester', 'Bremen', 'Bremen DC',
       'Frankfurt', 'Norway', 'Norway DC', 'storebus'],
      dtype='object', name='Bus')
INFO:linopy.model: Solve problem using Highs solver
KVL removed!
INFO:linopy.io: Writing time: 0.14s
INFO:linopy.solvers:Log file at /tmp/highs.log
INFO:linopy.constants: Optimization successful:
Status: ok
Termination condition: optimal
Solution: 300 primals, 728 duals
Objective: 1.41e+07
Solver model: available
Solver message: optimal

INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Generator-ext-p-lower, Generator-ext-p-upper, Generator-fix-p-ramp_limit_up, Generator-fix-p-ramp_limit_down, Line-ext-s-lower, Line-ext-s-upper, Link-ext-p-lower, Link-ext-p-upper, Store-ext-e-lower, Store-ext-e-upper, StorageUnit-ext-p_dispatch-lower, StorageUnit-ext-p_dispatch-upper, StorageUnit-ext-p_store-lower, StorageUnit-ext-p_store-upper, StorageUnit-ext-state_of_charge-lower, StorageUnit-ext-state_of_charge-upper, StorageUnit-state_of_charge_set, StorageUnit-energy_balance, Store-energy_balance were not assigned to the network.
Running HiGHS 1.7.1 (git hash: 0c240d8): Copyright (c) 2024 HiGHS under MIT licence terms
Coefficient ranges:
  Matrix [1e-02, 2e+00]
  Cost   [9e-03, 3e+03]
  Bound  [5e+01, 8e+05]
  RHS    [9e-01, 8e+04]
Presolving model
504 rows, 296 cols, 1264 nonzeros  0s
426 rows, 218 cols, 1302 nonzeros  0s
399 rows, 218 cols, 1248 nonzeros  0s
Presolve : Reductions: rows 399(-329); columns 218(-82); elements 1248(-263)
Solving the presolved LP
Using EKK dual simplex solver - serial
  Iteration        Objective     Infeasibilities num(sum)
          0    -2.4998861248e+00 Ph1: 190(204422); Du: 2(2.49989) 0s
        236     1.4121074568e+07 Pr: 0(0) 0s
Solving the original LP from the solution after postsolve
Model   status      : Optimal
Simplex   iterations: 236
Objective value     :  1.4121074568e+07
HiGHS run time      :          0.01
[12]:
('ok', 'optimal')

Additional constraints#

In the following, we exemplarily present a set of additional constraints. Note, the dual values of the additional constraints won’t be stored in default data fields in the PyPSA network. But in any case, they are stored in the linopy.Model.

Again, we first build the optimization model, add our constraints and finally solve the network. For the first step, we use again our accessor optimize to access the function create_model. This returns the linopy model that we can modify.

[13]:
m = n.optimize.create_model()  # the return value is the model, let's use it directly!
WARNING:pypsa.consistency:The following sub_networks have carriers which are not defined:
Index(['0', '1', '2'], dtype='object', name='SubNetwork')
WARNING:pypsa.consistency:The following stores have carriers which are not defined:
Index(['store'], dtype='object', name='Store')
WARNING:pypsa.consistency:The following links have carriers which are not defined:
Index(['Norwich Converter', 'Norway Converter', 'Bremen Converter', 'DC link',
       'battery_power', 'battery_discharge'],
      dtype='object', name='Link')
WARNING:pypsa.consistency:The following lines have carriers which are not defined:
Index(['0', '1', '2', '3', '4', '5', '6'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following lines have zero x, which could break the linear load flow:
Index(['2', '3', '4'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following lines have zero r, which could break the linear load flow:
Index(['0', '1', '5', '6'], dtype='object', name='Line')
WARNING:pypsa.consistency:The following buses have carriers which are not defined:
Index(['London', 'Norwich', 'Norwich DC', 'Manchester', 'Bremen', 'Bremen DC',
       'Frankfurt', 'Norway', 'Norway DC', 'storebus'],
      dtype='object', name='Bus')
  1. Minimum for state of charge

Assume we want to set a minimum state of charge of 50 MWh in our storage unit. This is done by:

[14]:
sus = m.variables["StorageUnit-state_of_charge"]
m.add_constraints(sus >= 50, name="StorageUnit-minimum_soc")
[14]:
Constraint `StorageUnit-minimum_soc` (snapshot: 10, StorageUnit: 2):
--------------------------------------------------------------------
[2015-01-01 00:00:00, su]: +1 StorageUnit-state_of_charge[2015-01-01 00:00:00, su]   ≥ 50.0
[2015-01-01 00:00:00, su2]: +1 StorageUnit-state_of_charge[2015-01-01 00:00:00, su2] ≥ 50.0
[2015-01-01 01:00:00, su]: +1 StorageUnit-state_of_charge[2015-01-01 01:00:00, su]   ≥ 50.0
[2015-01-01 01:00:00, su2]: +1 StorageUnit-state_of_charge[2015-01-01 01:00:00, su2] ≥ 50.0
[2015-01-01 02:00:00, su]: +1 StorageUnit-state_of_charge[2015-01-01 02:00:00, su]   ≥ 50.0
[2015-01-01 02:00:00, su2]: +1 StorageUnit-state_of_charge[2015-01-01 02:00:00, su2] ≥ 50.0
[2015-01-01 03:00:00, su]: +1 StorageUnit-state_of_charge[2015-01-01 03:00:00, su]   ≥ 50.0
                ...
[2015-01-01 06:00:00, su2]: +1 StorageUnit-state_of_charge[2015-01-01 06:00:00, su2] ≥ 50.0
[2015-01-01 07:00:00, su]: +1 StorageUnit-state_of_charge[2015-01-01 07:00:00, su]   ≥ 50.0
[2015-01-01 07:00:00, su2]: +1 StorageUnit-state_of_charge[2015-01-01 07:00:00, su2] ≥ 50.0
[2015-01-01 08:00:00, su]: +1 StorageUnit-state_of_charge[2015-01-01 08:00:00, su]   ≥ 50.0
[2015-01-01 08:00:00, su2]: +1 StorageUnit-state_of_charge[2015-01-01 08:00:00, su2] ≥ 50.0
[2015-01-01 09:00:00, su]: +1 StorageUnit-state_of_charge[2015-01-01 09:00:00, su]   ≥ 50.0
[2015-01-01 09:00:00, su2]: +1 StorageUnit-state_of_charge[2015-01-01 09:00:00, su2] ≥ 50.0

The return value of the add_constraints function is a array with the labels of the constraints. You can access the constraint now through:

[15]:
m.constraints["StorageUnit-minimum_soc"]
[15]:
Constraint `StorageUnit-minimum_soc` (snapshot: 10, StorageUnit: 2):
--------------------------------------------------------------------
[2015-01-01 00:00:00, su]: +1 StorageUnit-state_of_charge[2015-01-01 00:00:00, su]   ≥ 50.0
[2015-01-01 00:00:00, su2]: +1 StorageUnit-state_of_charge[2015-01-01 00:00:00, su2] ≥ 50.0
[2015-01-01 01:00:00, su]: +1 StorageUnit-state_of_charge[2015-01-01 01:00:00, su]   ≥ 50.0
[2015-01-01 01:00:00, su2]: +1 StorageUnit-state_of_charge[2015-01-01 01:00:00, su2] ≥ 50.0
[2015-01-01 02:00:00, su]: +1 StorageUnit-state_of_charge[2015-01-01 02:00:00, su]   ≥ 50.0
[2015-01-01 02:00:00, su2]: +1 StorageUnit-state_of_charge[2015-01-01 02:00:00, su2] ≥ 50.0
[2015-01-01 03:00:00, su]: +1 StorageUnit-state_of_charge[2015-01-01 03:00:00, su]   ≥ 50.0
                ...
[2015-01-01 06:00:00, su2]: +1 StorageUnit-state_of_charge[2015-01-01 06:00:00, su2] ≥ 50.0
[2015-01-01 07:00:00, su]: +1 StorageUnit-state_of_charge[2015-01-01 07:00:00, su]   ≥ 50.0
[2015-01-01 07:00:00, su2]: +1 StorageUnit-state_of_charge[2015-01-01 07:00:00, su2] ≥ 50.0
[2015-01-01 08:00:00, su]: +1 StorageUnit-state_of_charge[2015-01-01 08:00:00, su]   ≥ 50.0
[2015-01-01 08:00:00, su2]: +1 StorageUnit-state_of_charge[2015-01-01 08:00:00, su2] ≥ 50.0
[2015-01-01 09:00:00, su]: +1 StorageUnit-state_of_charge[2015-01-01 09:00:00, su]   ≥ 50.0
[2015-01-01 09:00:00, su2]: +1 StorageUnit-state_of_charge[2015-01-01 09:00:00, su2] ≥ 50.0

and inspects its attributes like lhs, sign and rhs, e.g.

[16]:
m.constraints["StorageUnit-minimum_soc"].rhs
[16]:
<xarray.DataArray 'rhs' (snapshot: 10, StorageUnit: 2)> Size: 160B
array([[50., 50.],
       [50., 50.],
       [50., 50.],
       [50., 50.],
       [50., 50.],
       [50., 50.],
       [50., 50.],
       [50., 50.],
       [50., 50.],
       [50., 50.]])
Coordinates:
  * snapshot     (snapshot) datetime64[ns] 80B 2015-01-01 ... 2015-01-01T09:0...
  * StorageUnit  (StorageUnit) object 16B 'su' 'su2'
  1. Fix the ratio between ingoing and outgoing capacity of the Store

The battery in our system is modelled with two links and a store. We should make sure that its charging and discharging capacities, meaning their links, are somehow coupled.

[17]:
capacity = m.variables["Link-p_nom"]
eff = n.links.at["battery_power", "efficiency"]
lhs = capacity.loc["battery_power"] - eff * capacity.loc["battery_discharge"]
m.add_constraints(lhs == 0, name="Link-battery_fix_ratio")
[17]:
Constraint `Link-battery_fix_ratio`
-----------------------------------
+1 Link-p_nom[battery_power] - 0.9 Link-p_nom[battery_discharge] = -0.0
  1. Every bus must in total produce the 20% of the total demand

For this, we use the linopy function groupby_sum which follows the pattern from pandas/xarray’s groupby function.

[18]:
total_demand = n.loads_t.p_set.sum().sum()
buses = n.generators.bus.to_xarray()
prod_per_bus = m.variables["Generator-p"].groupby(buses).sum().sum("snapshot")
m.add_constraints(prod_per_bus >= total_demand / 5, name="Bus-minimum_production_share")
[18]:
Constraint `Bus-minimum_production_share` (bus: 3):
---------------------------------------------------
[Frankfurt]: +1 Generator-p[2015-01-01 00:00:00, Frankfurt Wind] + 1 Generator-p[2015-01-01 01:00:00, Frankfurt Wind] + 1 Generator-p[2015-01-01 02:00:00, Frankfurt Wind] ... +1 Generator-p[2015-01-01 07:00:00, Frankfurt Gas] + 1 Generator-p[2015-01-01 08:00:00, Frankfurt Gas] + 1 Generator-p[2015-01-01 09:00:00, Frankfurt Gas]        ≥ 6509.525616820881
[Manchester]: +1 Generator-p[2015-01-01 00:00:00, Manchester Wind] + 1 Generator-p[2015-01-01 01:00:00, Manchester Wind] + 1 Generator-p[2015-01-01 02:00:00, Manchester Wind] ... +1 Generator-p[2015-01-01 07:00:00, Manchester Gas] + 1 Generator-p[2015-01-01 08:00:00, Manchester Gas] + 1 Generator-p[2015-01-01 09:00:00, Manchester Gas] ≥ 6509.525616820881
[Norway]: +1 Generator-p[2015-01-01 00:00:00, Norway Wind] + 1 Generator-p[2015-01-01 01:00:00, Norway Wind] + 1 Generator-p[2015-01-01 02:00:00, Norway Wind] ... +1 Generator-p[2015-01-01 07:00:00, Norway Gas] + 1 Generator-p[2015-01-01 08:00:00, Norway Gas] + 1 Generator-p[2015-01-01 09:00:00, Norway Gas]                             ≥ 6509.525616820881
[19]:
con = prod_per_bus >= total_demand / 5
[20]:
con
[20]:
Constraint (unassigned) (bus: 3):
---------------------------------
[Frankfurt]: +1 Generator-p[2015-01-01 00:00:00, Frankfurt Wind] + 1 Generator-p[2015-01-01 01:00:00, Frankfurt Wind] + 1 Generator-p[2015-01-01 02:00:00, Frankfurt Wind] ... +1 Generator-p[2015-01-01 07:00:00, Frankfurt Gas] + 1 Generator-p[2015-01-01 08:00:00, Frankfurt Gas] + 1 Generator-p[2015-01-01 09:00:00, Frankfurt Gas]        ≥ 6509.525616820881
[Manchester]: +1 Generator-p[2015-01-01 00:00:00, Manchester Wind] + 1 Generator-p[2015-01-01 01:00:00, Manchester Wind] + 1 Generator-p[2015-01-01 02:00:00, Manchester Wind] ... +1 Generator-p[2015-01-01 07:00:00, Manchester Gas] + 1 Generator-p[2015-01-01 08:00:00, Manchester Gas] + 1 Generator-p[2015-01-01 09:00:00, Manchester Gas] ≥ 6509.525616820881
[Norway]: +1 Generator-p[2015-01-01 00:00:00, Norway Wind] + 1 Generator-p[2015-01-01 01:00:00, Norway Wind] + 1 Generator-p[2015-01-01 02:00:00, Norway Wind] ... +1 Generator-p[2015-01-01 07:00:00, Norway Gas] + 1 Generator-p[2015-01-01 08:00:00, Norway Gas] + 1 Generator-p[2015-01-01 09:00:00, Norway Gas]                             ≥ 6509.525616820881

… and now let’s solve the network again.

[21]:
n.optimize.solve_model()
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.io: Writing time: 0.16s
INFO:linopy.solvers:Log file at /tmp/highs.log
INFO:linopy.constants: Optimization successful:
Status: ok
Termination condition: optimal
Solution: 300 primals, 772 duals
Objective: 1.43e+07
Solver model: available
Solver message: optimal

INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Generator-ext-p-lower, Generator-ext-p-upper, Generator-fix-p-ramp_limit_up, Generator-fix-p-ramp_limit_down, Line-ext-s-lower, Line-ext-s-upper, Link-ext-p-lower, Link-ext-p-upper, Store-ext-e-lower, Store-ext-e-upper, StorageUnit-ext-p_dispatch-lower, StorageUnit-ext-p_dispatch-upper, StorageUnit-ext-p_store-lower, StorageUnit-ext-p_store-upper, StorageUnit-ext-state_of_charge-lower, StorageUnit-ext-state_of_charge-upper, StorageUnit-state_of_charge_set, Kirchhoff-Voltage-Law, StorageUnit-energy_balance, Store-energy_balance, StorageUnit-minimum_soc were not assigned to the network.
Running HiGHS 1.7.1 (git hash: 0c240d8): Copyright (c) 2024 HiGHS under MIT licence terms
Coefficient ranges:
  Matrix [1e-02, 2e+00]
  Cost   [9e-03, 3e+03]
  Bound  [5e+01, 8e+05]
  RHS    [9e-01, 8e+04]
Presolving model
527 rows, 296 cols, 1384 nonzeros  0s
431 rows, 200 cols, 1470 nonzeros  0s
403 rows, 199 cols, 1426 nonzeros  0s
Presolve : Reductions: rows 403(-369); columns 199(-101); elements 1426(-227)
Solving the presolved LP
Using EKK dual simplex solver - serial
  Iteration        Objective     Infeasibilities num(sum)
          0    -1.3357133799e+05 Pr: 126(125956); Du: 0(3.07779e-11) 0s
        172     1.4337112555e+07 Pr: 0(0) 0s
Solving the original LP from the solution after postsolve
Model   status      : Optimal
Simplex   iterations: 172
Objective value     :  1.4337112555e+07
HiGHS run time      :          0.01
[21]:
('ok', 'optimal')

Analysing the constraints#

Let’s see if the system got our own constraints. We look at n.constraints which combines summarises constraints going into the linear problem

[22]:
n.model.constraints
[22]:
linopy.model.Constraints
------------------------
 * Generator-ext-p_nom-lower (Generator-ext)
 * Generator-ext-p_nom-upper (Generator-ext)
 * Line-ext-s_nom-lower (Line-ext)
 * Line-ext-s_nom-upper (Line-ext)
 * Link-ext-p_nom-lower (Link-ext)
 * Link-ext-p_nom-upper (Link-ext)
 * Store-ext-e_nom-lower (Store-ext)
 * Store-ext-e_nom-upper (Store-ext)
 * StorageUnit-ext-p_nom-lower (StorageUnit-ext)
 * StorageUnit-ext-p_nom-upper (StorageUnit-ext)
 * Generator-fix-p-lower (snapshot, Generator-fix)
 * Generator-fix-p-upper (snapshot, Generator-fix)
 * Generator-ext-p-lower (snapshot, Generator-ext)
 * Generator-ext-p-upper (snapshot, Generator-ext)
 * Generator-fix-p-ramp_limit_up (snapshot, Generator-fix)
 * Generator-fix-p-ramp_limit_down (snapshot, Generator-fix)
 * Line-ext-s-lower (snapshot, Line-ext)
 * Line-ext-s-upper (snapshot, Line-ext)
 * Link-ext-p-lower (snapshot, Link-ext)
 * Link-ext-p-upper (snapshot, Link-ext)
 * Store-ext-e-lower (snapshot, Store-ext)
 * Store-ext-e-upper (snapshot, Store-ext)
 * StorageUnit-ext-p_dispatch-lower (snapshot, StorageUnit-ext)
 * StorageUnit-ext-p_dispatch-upper (snapshot, StorageUnit-ext)
 * StorageUnit-ext-p_store-lower (snapshot, StorageUnit-ext)
 * StorageUnit-ext-p_store-upper (snapshot, StorageUnit-ext)
 * StorageUnit-ext-state_of_charge-lower (snapshot, StorageUnit-ext)
 * StorageUnit-ext-state_of_charge-upper (snapshot, StorageUnit-ext)
 * StorageUnit-state_of_charge_set (snapshot, StorageUnit-state_of_charge_set_i)
 * Bus-nodal_balance (Bus, snapshot)
 * Kirchhoff-Voltage-Law (snapshot, cycles)
 * StorageUnit-energy_balance (snapshot, StorageUnit)
 * Store-energy_balance (snapshot, Store)
 * GlobalConstraint-co2_limit (snapshot)
 * StorageUnit-minimum_soc (snapshot, StorageUnit)
 * Link-battery_fix_ratio (Link-ext)
 * Bus-minimum_production_share (bus)

The last three entries show our constraints. Let’s check whether out two custom constraint are fulfilled:

[23]:
n.links.loc[["battery_power", "battery_discharge"], ["p_nom_opt"]]
[23]:
p_nom_opt
Link
battery_power 900.0
battery_discharge 1000.0
[24]:
n.storage_units_t.state_of_charge
[24]:
StorageUnit su su2
snapshot
2015-01-01 00:00:00 1835.745612 1000.000000
2015-01-01 01:00:00 1836.063354 490.091300
2015-01-01 02:00:00 1886.063354 490.091300
2015-01-01 03:00:00 1936.063354 490.091300
2015-01-01 04:00:00 1986.063354 1000.000000
2015-01-01 05:00:00 50.000000 50.000000
2015-01-01 06:00:00 50.000000 50.000000
2015-01-01 07:00:00 100.000000 156.725142
2015-01-01 08:00:00 150.000000 50.000000
2015-01-01 09:00:00 50.000000 50.000000
[25]:
n.generators_t.p.groupby(n.generators.bus, axis=1).sum().sum() / n.loads_t.p.sum().sum()
/tmp/ipykernel_4791/2985613193.py:1: FutureWarning: DataFrame.groupby with axis=1 is deprecated. Do `frame.T.groupby(...)` without axis instead.
  n.generators_t.p.groupby(n.generators.bus, axis=1).sum().sum() / n.loads_t.p.sum().sum()
[25]:
bus
Frankfurt     0.200000
Manchester    0.200000
Norway        0.637048
dtype: float64

Looks good! Now, let’s see which dual values were parsed. Therefore we have a look into n.model.dual

[26]:
n.model.dual
/home/docs/checkouts/readthedocs.org/user_builds/pypsa/conda/latest/lib/python3.12/site-packages/linopy/common.py:376: UserWarning: Coordinates across variables not equal. Perform outer join.
  warn(
[26]:
<xarray.Dataset> Size: 7kB
Dimensions:                                (Generator-ext: 3, Line-ext: 7,
                                            Link-ext: 6, Store-ext: 1,
                                            StorageUnit-ext: 2, snapshot: 10,
                                            Generator-fix: 3,
                                            StorageUnit-state_of_charge_set_i: 1,
                                            Bus: 10, cycles: 2, StorageUnit: 2,
                                            Store: 1, bus: 3)
Coordinates: (12/13)
  * Generator-ext                          (Generator-ext) object 24B 'Manche...
  * Line-ext                               (Line-ext) object 56B '0' '1' ... '6'
  * Link-ext                               (Link-ext) object 48B 'Norwich Con...
  * Store-ext                              (Store-ext) object 8B 'store'
  * StorageUnit-ext                        (StorageUnit-ext) object 16B 'su' ...
  * snapshot                               (snapshot) datetime64[ns] 80B 2015...
    ...                                     ...
  * StorageUnit-state_of_charge_set_i      (StorageUnit-state_of_charge_set_i) object 8B ...
  * Bus                                    (Bus) object 80B 'London' ... 'sto...
  * cycles                                 (cycles) int64 16B 0 1
  * StorageUnit                            (StorageUnit) object 16B 'su' 'su2'
  * Store                                  (Store) object 8B 'store'
  * bus                                    (bus) object 24B 'Frankfurt' ... '...
Data variables: (12/37)
    Generator-ext-p_nom-lower              (Generator-ext) float64 24B -0.0 ....
    Generator-ext-p_nom-upper              (Generator-ext) float64 24B nan .....
    Line-ext-s_nom-lower                   (Line-ext) float64 56B -0.0 ... -0.0
    Line-ext-s_nom-upper                   (Line-ext) float64 56B nan ... nan
    Link-ext-p_nom-lower                   (Link-ext) float64 48B -0.0 ... -0.0
    Link-ext-p_nom-upper                   (Link-ext) float64 48B nan ... -527.1
    ...                                     ...
    StorageUnit-energy_balance             (snapshot, StorageUnit) float64 160B ...
    Store-energy_balance                   (snapshot, Store) float64 80B 521....
    GlobalConstraint-co2_limit             float64 8B -950.5
    StorageUnit-minimum_soc                (snapshot, StorageUnit) float64 160B ...
    Link-battery_fix_ratio                 float64 8B -567.9
    Bus-minimum_production_share           (bus) float64 24B 35.2 45.42 -0.0
[27]:
n.model.dual["StorageUnit-minimum_soc"]
[27]:
<xarray.DataArray 'StorageUnit-minimum_soc' (snapshot: 10, StorageUnit: 2)> Size: 160B
array([[ -0.        ,  -0.        ],
       [ -0.        ,  -0.        ],
       [ -0.        ,  -0.        ],
       [ -0.        ,  -0.        ],
       [ -0.        ,  -0.        ],
       [ -0.        ,  -0.        ],
       [157.42265847,   4.44444444],
       [ -0.        ,  -0.        ],
       [ -0.        ,  -0.        ],
       [  5.55555556,  67.84841829]])
Coordinates:
  * snapshot     (snapshot) datetime64[ns] 80B 2015-01-01 ... 2015-01-01T09:0...
  * StorageUnit  (StorageUnit) object 16B 'su' 'su2'
[28]:
n.model.dual["Link-battery_fix_ratio"]
[28]:
<xarray.DataArray 'Link-battery_fix_ratio' ()> Size: 8B
array(-567.89006727)
[29]:
n.model.dual["Bus-minimum_production_share"]
[29]:
<xarray.DataArray 'Bus-minimum_production_share' (bus: 3)> Size: 24B
array([35.20056477, 45.41857493, -0.        ])
Coordinates:
  * bus      (bus) object 24B 'Frankfurt' 'Manchester' 'Norway'

These are the basic functionalities of the optimize accessor. There are many more functions like abstract optimziation formulations (security constraint optimization, iterative transmission expansion optimization, etc.) or helper functions (fixing optimized capacities, adding load shedding). Try them out if you want!

[30]:
print("\n".join([func for func in n.optimize.__dir__() if not func.startswith("_")]))
create_model
solve_model
assign_solution
assign_duals
post_processing
optimize_transmission_expansion_iteratively
optimize_security_constrained
optimize_with_rolling_horizon
optimize_mga
fix_optimal_capacities
fix_optimal_dispatch
add_load_shedding