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#

join

Coordinates

Non-overlapping positions

None (default)

Must match by label and order (v1 uses exact)

Raises on any mismatch

"inner"

Intersection only

— (nothing to fill)

"outer"

Union

Missing side filled with the operation’s identity

"left"

Left operand’s

Right’s extras dropped; right’s gaps filled with identity

"right"

Right operand’s

Left’s extras dropped; left’s gaps filled with identity

"override"

Left operand’s (positional)

Requires equal size on shared dims

"exact"

Must match by label and order

Raises on mismatch