Note
You can download this example as a Jupyter notebook or start it in interactive mode.
Coordinate Alignment#
Since linopy builds on xarray, coordinate alignment matters when combining variables or expressions that live on different coordinates. By default, linopy aligns operands automatically and fills missing entries with sensible defaults. This guide shows how alignment works and how to control it with the join parameter.
[1]:
import numpy as np
import pandas as pd
import xarray as xr
import linopy
Default Alignment Behavior#
When two operands share a dimension but have different coordinates, linopy keeps the larger (superset) coordinate range and fills missing positions with zeros (for addition) or zero coefficients (for multiplication).
[2]:
m = linopy.Model()
time = pd.RangeIndex(5, name="time")
x = m.add_variables(lower=0, coords=[time], name="x")
subset_time = pd.RangeIndex(3, name="time")
y = m.add_variables(lower=0, coords=[subset_time], name="y")
Adding x (5 time steps) and y (3 time steps) gives an expression over all 5 time steps. Where y has no entry (time 3, 4), the coefficient is zero — i.e. y simply drops out of the sum at those positions.
[3]:
x + y
[3]:
LinearExpression [time: 5]:
---------------------------
[0]: +1 x[0] + 1 y[0]
[1]: +1 x[1] + 1 y[1]
[2]: +1 x[2] + 1 y[2]
[3]: +1 x[3]
[4]: +1 x[4]
The same applies when multiplying by a constant that covers only a subset of coordinates. Missing positions get a coefficient of zero:
[4]:
factor = xr.DataArray([2, 3, 4], dims=["time"], coords={"time": [0, 1, 2]})
x * factor
[4]:
LinearExpression [time: 5]:
---------------------------
[0]: +2 x[0]
[1]: +3 x[1]
[2]: +4 x[2]
[3]: +0 x[3]
[4]: +0 x[4]
Adding a constant subset also fills missing coordinates with zero:
[5]:
x + factor
[5]:
LinearExpression [time: 5]:
---------------------------
[0]: +1 x[0] + 2
[1]: +1 x[1] + 3
[2]: +1 x[2] + 4
[3]: +1 x[3]
[4]: +1 x[4]
Constraints with Subset RHS#
For constraints, missing right-hand-side values are filled with NaN, which tells linopy to skip the constraint at those positions:
[6]:
rhs = xr.DataArray([10, 20, 30], dims=["time"], coords={"time": [0, 1, 2]})
con = x <= rhs
con
[6]:
Constraint (unassigned) [time: 5]:
----------------------------------
[0]: +1 x[0] ≤ 10.0
[1]: +1 x[1] ≤ 20.0
[2]: +1 x[2] ≤ 30.0
[3]: +1 x[3] ≤ nan
[4]: +1 x[4] ≤ nan
The constraint only applies at time 0, 1, 2. At time 3 and 4 the RHS is NaN, so no constraint is created.
Same-Shape Operands: Positional Alignment#
When two operands have the same shape on a shared dimension, linopy uses positional alignment by default — coordinate labels are ignored and the left operand’s labels are kept. This is a performance optimization but can be surprising:
[7]:
offset_const = xr.DataArray(
[10, 20, 30, 40, 50], dims=["time"], coords={"time": [5, 6, 7, 8, 9]}
)
x + offset_const
[7]:
LinearExpression [time: 5]:
---------------------------
[0]: +1 x[0] + 10
[1]: +1 x[1] + 20
[2]: +1 x[2] + 30
[3]: +1 x[3] + 40
[4]: +1 x[4] + 50
Even though offset_const has coordinates [5, 6, 7, 8, 9] and x has [0, 1, 2, 3, 4], the result uses x’s labels. The values are aligned by position, not by label. The same applies when adding two variables or expressions of identical shape:
[8]:
z = m.add_variables(lower=0, coords=[pd.RangeIndex(5, 10, name="time")], name="z")
x + z
[8]:
LinearExpression [time: 5]:
---------------------------
[0]: +1 x[0] + 1 z[5]
[1]: +1 x[1] + 1 z[6]
[2]: +1 x[2] + 1 z[7]
[3]: +1 x[3] + 1 z[8]
[4]: +1 x[4] + 1 z[9]
x (time 0–4) and z (time 5–9) share no coordinate labels, yet the result has 5 entries under x’s coordinates — because they have the same shape, positions are matched directly.
To force label-based alignment, pass an explicit join:
[9]:
x.add(z, join="outer")
[9]:
LinearExpression [time: 10]:
----------------------------
[0]: +1 x[0]
[1]: +1 x[1]
[2]: +1 x[2]
[3]: +1 x[3]
[4]: +1 x[4]
[5]: +1 z[5]
[6]: +1 z[6]
[7]: +1 z[7]
[8]: +1 z[8]
[9]: +1 z[9]
With join="outer", the result spans all 10 time steps (union of 0–4 and 5–9), filling missing positions with zeros. This is the correct label-based alignment. The same-shape positional shortcut is equivalent to join="override" — see below.
The join Parameter#
For explicit control over alignment, use the .add(), .sub(), .mul(), and .div() methods with a join parameter. The supported values follow xarray conventions:
"inner"— intersection of coordinates"outer"— union of coordinates (with fill)"left"— keep left operand’s coordinates"right"— keep right operand’s coordinates"override"— positional alignment, ignore coordinate labels"exact"— coordinates must match exactly (raises on mismatch)
[10]:
m2 = linopy.Model()
i_a = pd.Index([0, 1, 2], name="i")
i_b = pd.Index([1, 2, 3], name="i")
a = m2.add_variables(coords=[i_a], name="a")
b = m2.add_variables(coords=[i_b], name="b")
Inner join — only shared coordinates (i=1, 2):
[11]:
a.add(b, join="inner")
[11]:
LinearExpression [i: 2]:
------------------------
[1]: +1 a[1] + 1 b[1]
[2]: +1 a[2] + 1 b[2]
Outer join — union of coordinates (i=0, 1, 2, 3):
[12]:
a.add(b, join="outer")
[12]:
LinearExpression [i: 4]:
------------------------
[0]: +1 a[0]
[1]: +1 a[1] + 1 b[1]
[2]: +1 a[2] + 1 b[2]
[3]: +1 b[3]
Left join — keep left operand’s coordinates (i=0, 1, 2):
[13]:
a.add(b, join="left")
[13]:
LinearExpression [i: 3]:
------------------------
[0]: +1 a[0]
[1]: +1 a[1] + 1 b[1]
[2]: +1 a[2] + 1 b[2]
Right join — keep right operand’s coordinates (i=1, 2, 3):
[14]:
a.add(b, join="right")
[14]:
LinearExpression [i: 3]:
------------------------
[1]: +1 a[1] + 1 b[1]
[2]: +1 a[2] + 1 b[2]
[3]: +1 b[3]
Override — positional alignment, ignore coordinate labels. The result uses the left operand’s coordinates. Here a has i=[0, 1, 2] and b has i=[1, 2, 3], so positions are matched as 0↔1, 1↔2, 2↔3:
[15]:
a.add(b, join="override")
[15]:
LinearExpression [i: 3]:
------------------------
[0]: +1 a[0] + 1 b[1]
[1]: +1 a[1] + 1 b[2]
[2]: +1 a[2] + 1 b[3]
Multiplication with join#
The same join parameter works on .mul() and .div(). When multiplying by a constant that covers a subset, join="inner" restricts the result to shared coordinates only, while join="left" fills missing values with zero:
[16]:
const = xr.DataArray([2, 3, 4], dims=["i"], coords={"i": [1, 2, 3]})
a.mul(const, join="inner")
[16]:
LinearExpression [i: 2]:
------------------------
[1]: +2 a[1]
[2]: +3 a[2]
[17]:
a.mul(const, join="left")
[17]:
LinearExpression [i: 3]:
------------------------
[0]: +0 a[0]
[1]: +2 a[1]
[2]: +3 a[2]
Alignment in Constraints#
The .le(), .ge(), and .eq() methods create constraints with explicit coordinate alignment. They accept the same join parameter:
[18]:
rhs = xr.DataArray([10, 20], dims=["i"], coords={"i": [0, 1]})
a.le(rhs, join="inner")
[18]:
Constraint (unassigned) [i: 3]:
-------------------------------
[0]: +1 a[0] ≤ 10.0
[1]: +1 a[1] ≤ 20.0
[2]: +1 a[2] ≤ nan
With join="inner", the constraint only exists at the intersection (i=0, 1). Compare with join="left":
[19]:
a.le(rhs, join="left")
[19]:
Constraint (unassigned) [i: 3]:
-------------------------------
[0]: +1 a[0] ≤ 10.0
[1]: +1 a[1] ≤ 20.0
[2]: +1 a[2] ≤ nan
With join="left", the result covers all of a’s coordinates (i=0, 1, 2). At i=2, where the RHS has no value, the RHS becomes NaN and the constraint is masked out.
The same methods work on expressions:
[20]:
expr = 2 * a + 1
expr.eq(rhs, join="inner")
[20]:
Constraint (unassigned) [i: 3]:
-------------------------------
[0]: +2 a[0] = 9.0
[1]: +2 a[1] = 19.0
[2]: +2 a[2] = nan
Practical Example#
Consider a generation dispatch model where solar availability follows a daily profile and a minimum demand constraint only applies during peak hours.
[21]:
m3 = linopy.Model()
hours = pd.RangeIndex(24, name="hour")
techs = pd.Index(["solar", "wind", "gas"], name="tech")
gen = m3.add_variables(lower=0, coords=[hours, techs], name="gen")
Capacity limits apply to all hours and techs — standard broadcasting handles this:
[22]:
capacity = xr.DataArray([100, 80, 50], dims=["tech"], coords={"tech": techs})
m3.add_constraints(gen <= capacity, name="capacity_limit")
[22]:
Constraint `capacity_limit` [hour: 24, tech: 3]:
------------------------------------------------
[0, solar]: +1 gen[0, solar] ≤ 100.0
[0, wind]: +1 gen[0, wind] ≤ 80.0
[0, gas]: +1 gen[0, gas] ≤ 50.0
[1, solar]: +1 gen[1, solar] ≤ 100.0
[1, wind]: +1 gen[1, wind] ≤ 80.0
[1, gas]: +1 gen[1, gas] ≤ 50.0
[2, solar]: +1 gen[2, solar] ≤ 100.0
...
[21, gas]: +1 gen[21, gas] ≤ 50.0
[22, solar]: +1 gen[22, solar] ≤ 100.0
[22, wind]: +1 gen[22, wind] ≤ 80.0
[22, gas]: +1 gen[22, gas] ≤ 50.0
[23, solar]: +1 gen[23, solar] ≤ 100.0
[23, wind]: +1 gen[23, wind] ≤ 80.0
[23, gas]: +1 gen[23, gas] ≤ 50.0
For solar, we build a full 24-hour availability profile — zero at night, sine-shaped during daylight (hours 6–18). Since this covers all hours, standard alignment works directly and solar is properly constrained to zero at night:
[23]:
solar_avail = np.zeros(24)
solar_avail[6:19] = 100 * np.sin(np.linspace(0, np.pi, 13))
solar_availability = xr.DataArray(solar_avail, dims=["hour"], coords={"hour": hours})
solar_gen = gen.sel(tech="solar")
m3.add_constraints(solar_gen <= solar_availability, name="solar_avail")
[23]:
Constraint `solar_avail` [hour: 24]:
------------------------------------
[0]: +1 gen[0, solar] ≤ -0.0
[1]: +1 gen[1, solar] ≤ -0.0
[2]: +1 gen[2, solar] ≤ -0.0
[3]: +1 gen[3, solar] ≤ -0.0
[4]: +1 gen[4, solar] ≤ -0.0
[5]: +1 gen[5, solar] ≤ -0.0
[6]: +1 gen[6, solar] ≤ -0.0
...
[17]: +1 gen[17, solar] ≤ 25.881904510252102
[18]: +1 gen[18, solar] ≤ 1.2246467991473532e-14
[19]: +1 gen[19, solar] ≤ -0.0
[20]: +1 gen[20, solar] ≤ -0.0
[21]: +1 gen[21, solar] ≤ -0.0
[22]: +1 gen[22, solar] ≤ -0.0
[23]: +1 gen[23, solar] ≤ -0.0
Now suppose a minimum demand of 120 MW must be met, but only during peak hours (8–20). The demand array covers a subset of hours, so we use join="inner" to restrict the constraint to just those hours:
[24]:
peak_hours = pd.RangeIndex(8, 21, name="hour")
peak_demand = xr.DataArray(
np.full(len(peak_hours), 120.0), dims=["hour"], coords={"hour": peak_hours}
)
total_gen = gen.sum("tech")
m3.add_constraints(total_gen.ge(peak_demand, join="inner"), name="peak_demand")
[24]:
Constraint `peak_demand` [hour: 24] - 11 masked entries:
--------------------------------------------------------
[0]: None
[1]: None
[2]: None
[3]: None
[4]: None
[5]: None
[6]: None
...
[17]: +1 gen[17, solar] + 1 gen[17, wind] + 1 gen[17, gas] ≥ 120.0
[18]: +1 gen[18, solar] + 1 gen[18, wind] + 1 gen[18, gas] ≥ 120.0
[19]: +1 gen[19, solar] + 1 gen[19, wind] + 1 gen[19, gas] ≥ 120.0
[20]: +1 gen[20, solar] + 1 gen[20, wind] + 1 gen[20, gas] ≥ 120.0
[21]: None
[22]: None
[23]: None
The demand constraint only applies during peak hours (8–20). Outside that range, no minimum generation is required.
Summary#
|
Coordinates |
Fill behavior |
|---|---|---|
|
Auto-detect (keeps superset) |
Zeros for arithmetic, NaN for constraint RHS |
|
Intersection only |
No fill needed |
|
Union |
Fill with operation identity (0 for add, 0 for mul) |
|
Left operand’s |
Fill right with identity |
|
Right operand’s |
Fill left with identity |
|
Left operand’s (positional) |
Positional alignment, ignore labels |
|
Must match exactly |
Raises error if different |