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. Under the v1 convention, linopy aligns strictly by coordinate label: operands that share a dimension must carry the same labels on it, and a genuine mismatch raises rather than being silently patched over. This guide shows how that strict alignment works and how to resolve mismatches explicitly with the join parameter.
[1]:
import numpy as np
import pandas as pd
import xarray as xr
import linopy
linopy.options["semantics"] = "v1"
Alignment by label#
When two operands share a dimension, linopy requires them to carry the same coordinate labels on it. Labels align by name, never by position, and non-shared dimensions broadcast freely. If the shared labels differ — a different set, or even the same labels in a different order — the operation raises a ValueError; linopy never silently drops, reorders, or invents coordinates, because in an optimization model a dropped coordinate is a dropped term or constraint.
[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")
x spans time 0–4 and y spans 0–2. Because time is shared but the label sets differ, adding them directly raises:
[3]:
try:
x + y
except ValueError as e:
print(e)
Coordinate mismatch on shared dimension 'time': left=[0, 1, 2, 3, 4], right=[0, 1, 2]. Resolve with `.sel(...)` / `.reindex(...)` to align before combining, with `.assign_coords(...)` to relabel one side (positional alignment, made explicit), with `linopy.align(...)` to pre-align several operands at once, or by passing an explicit `join=` argument to `.add` / `.sub` / `.mul` / `.div` / `.le` / `.ge` / `.eq` (accepts inner / outer / left / right / override).
The same holds for a constant that covers only a subset of the coordinates — a DataArray on time 0–2 shares the dimension but not the full label set, so multiplying by it raises too:
[4]:
factor = xr.DataArray([2, 3, 4], dims=["time"], coords={"time": [0, 1, 2]})
try:
x * factor
except ValueError as e:
print(e)
Coordinate mismatch on shared dimension 'time': left=[0, 1, 2, 3, 4], right=[0, 1, 2]. Resolve with `.sel(...)` / `.reindex(...)` to align before combining, with `.assign_coords(...)` to relabel one side (positional alignment, made explicit), with `linopy.align(...)` to pre-align several operands at once, or by passing an explicit `join=` argument to `.add` / `.sub` / `.mul` / `.div` / `.le` / `.ge` / `.eq` (accepts inner / outer / left / right / override).
Resolving a mismatch#
To combine mismatched operands, tell linopy how. Either bring them onto shared coordinates first — .sel / .isel / .reindex — relabel one side with .assign_coords, or pass an explicit join= to the named .add / .mul / … methods (covered below). For instance, select x down to factor’s coordinates and multiply on the shared range:
[5]:
x.sel(time=[0, 1, 2]) * factor
[5]:
LinearExpression [time: 3]:
---------------------------
[0]: +2 x[0]
[1]: +3 x[1]
[2]: +4 x[2]
Constraints follow the same rule#
A comparison (<=, >=, ==) aligns its two sides exactly like + does. So a right-hand side that covers only a subset of the constraint’s coordinates is a shared-dimension mismatch, and raises:
[6]:
rhs = xr.DataArray([10, 20, 30], dims=["time"], coords={"time": [0, 1, 2]})
try:
x <= rhs
except ValueError as e:
print(e)
Coordinate mismatch on shared dimension 'time': left=[0, 1, 2, 3, 4], right=[0, 1, 2]. Resolve with `.sel(...)` / `.reindex(...)` to align before combining, with `.assign_coords(...)` to relabel one side (positional alignment, made explicit), with `linopy.align(...)` to pre-align several operands at once, or by passing an explicit `join=` argument to `.add` / `.sub` / `.mul` / `.div` / `.le` / `.ge` / `.eq` (accepts inner / outer / left / right / override).
Build the constraint only where the RHS is defined by aligning first — either select the shared range, or use an explicit join (below). Selecting x down to the RHS coordinates:
[7]:
x.sel(time=[0, 1, 2]) <= rhs
[7]:
Constraint (unassigned) [time: 3]:
----------------------------------
[0]: +1 x[0] ≤ 10.0
[1]: +1 x[1] ≤ 20.0
[2]: +1 x[2] ≤ 30.0
No positional shortcut#
Legacy linopy had a special case: two operands of the same shape on a shared dimension were paired by position, ignoring their labels. The v1 convention removes this — alignment is always by label, whatever the shapes. So an operand whose labels differ from x raises even when the shapes match:
[8]:
offset_const = xr.DataArray(
[10, 20, 30, 40, 50], dims=["time"], coords={"time": [5, 6, 7, 8, 9]}
)
try:
x + offset_const
except ValueError as e:
print(e)
Coordinate mismatch on shared dimension 'time': left=[0, 1, 2, 3, 4], right=[5, 6, 7, 8, 9]. Resolve with `.sel(...)` / `.reindex(...)` to align before combining, with `.assign_coords(...)` to relabel one side (positional alignment, made explicit), with `linopy.align(...)` to pre-align several operands at once, or by passing an explicit `join=` argument to `.add` / `.sub` / `.mul` / `.div` / `.le` / `.ge` / `.eq` (accepts inner / outer / left / right / override).
If you genuinely want positional pairing — ignoring that the labels differ — ask for it explicitly with join="override", which relabels the right operand onto the left’s coordinates. It still requires the shared dimension to match in size:
[9]:
z = m.add_variables(lower=0, coords=[pd.RangeIndex(5, 10, name="time")], name="z")
x.add(z, join="override")
[9]:
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 labels; override pairs them by position and keeps x’s labels. For label-based alignment over the union instead, use join="outer":
[10]:
x.add(z, join="outer")
[10]:
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); each non-overlapping position keeps whichever operand is present there, the missing side contributing its additive identity (0). The next section walks through every join value.
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)
[11]:
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):
[12]:
a.add(b, join="inner")
[12]:
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):
[13]:
a.add(b, join="outer")
[13]:
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):
[14]:
a.add(b, join="left")
[14]:
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):
[15]:
a.add(b, join="right")
[15]:
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:
[16]:
a.add(b, join="override")
[16]:
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:
[17]:
const = xr.DataArray([2, 3, 4], dims=["i"], coords={"i": [1, 2, 3]})
a.mul(const, join="inner")
[17]:
LinearExpression [i: 2]:
------------------------
[1]: +2 a[1]
[2]: +3 a[2]
[18]:
a.mul(const, join="left")
[18]:
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:
[19]:
rhs = xr.DataArray([10, 20], dims=["i"], coords={"i": [0, 1]})
a.le(rhs, join="inner")
[19]:
Constraint (unassigned) [i: 2]:
-------------------------------
[0]: +1 a[0] ≤ 10.0
[1]: +1 a[1] ≤ 20.0
With join="inner", the constraint only exists at the intersection (i=0, 1). Compare with join="left":
[20]:
a.le(rhs, join="left")
[20]:
Constraint (unassigned) [i: 3]:
-------------------------------
[0]: +1 a[0] ≤ 10.0
[1]: +1 a[1] ≤ 20.0
[2]: +1 a[2] ≤ -0.0
With join="left", the result covers all of a’s coordinates (i=0, 1, 2). At i=2, where the RHS has no value, it is filled with 0, so the row becomes a[2] ≤ 0.
The same methods work on expressions:
[21]:
expr = 2 * a + 1
expr.eq(rhs, join="inner")
[21]:
Constraint (unassigned) [i: 2]:
-------------------------------
[0]: +2 a[0] = 9.0
[1]: +2 a[1] = 19.0
Practical Example#
Consider a generation dispatch model where solar availability follows a daily profile and a minimum demand constraint only applies during peak hours.
[22]:
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:
[23]:
capacity = xr.DataArray([100, 80, 50], dims=["tech"], coords={"tech": techs})
m3.add_constraints(gen <= capacity, name="capacity_limit")
[23]:
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:
[24]:
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")
[24]:
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:
[25]:
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")
[25]:
Constraint `peak_demand` [hour: 13]:
------------------------------------
[8]: +1 gen[8, solar] + 1 gen[8, wind] + 1 gen[8, gas] ≥ 120.0
[9]: +1 gen[9, solar] + 1 gen[9, wind] + 1 gen[9, gas] ≥ 120.0
[10]: +1 gen[10, solar] + 1 gen[10, wind] + 1 gen[10, gas] ≥ 120.0
[11]: +1 gen[11, solar] + 1 gen[11, wind] + 1 gen[11, gas] ≥ 120.0
[12]: +1 gen[12, solar] + 1 gen[12, wind] + 1 gen[12, gas] ≥ 120.0
[13]: +1 gen[13, solar] + 1 gen[13, wind] + 1 gen[13, gas] ≥ 120.0
[14]: +1 gen[14, solar] + 1 gen[14, wind] + 1 gen[14, gas] ≥ 120.0
[15]: +1 gen[15, solar] + 1 gen[15, wind] + 1 gen[15, gas] ≥ 120.0
[16]: +1 gen[16, solar] + 1 gen[16, wind] + 1 gen[16, gas] ≥ 120.0
[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
The demand constraint only applies during peak hours (8–20). Outside that range, no minimum generation is required.
Summary#
|
Coordinates |
Non-overlapping positions |
|---|---|---|
|
Must match by label and order (v1 uses |
Raises on any mismatch |
|
Intersection only |
— (nothing to fill) |
|
Union |
Missing side filled with the operation’s identity |
|
Left operand’s |
Right’s extras dropped; right’s gaps filled with identity |
|
Right operand’s |
Left’s extras dropped; left’s gaps filled with identity |
|
Left operand’s (positional) |
Requires equal size on shared dims |
|
Must match by label and order |
Raises on mismatch |