From 91bd5152b1e7a4f4889e739356671648e85c9659 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 20 Feb 2026 08:57:42 +0100 Subject: [PATCH 1/6] Update notebook as spec --- doc/coordinate-alignment.nblink | 3 + examples/coordinate-alignment.ipynb | 456 +++++++++++++++++++--------- 2 files changed, 314 insertions(+), 145 deletions(-) create mode 100644 doc/coordinate-alignment.nblink diff --git a/doc/coordinate-alignment.nblink b/doc/coordinate-alignment.nblink new file mode 100644 index 00000000..ef588b91 --- /dev/null +++ b/doc/coordinate-alignment.nblink @@ -0,0 +1,3 @@ +{ + "path": "../examples/coordinate-alignment.ipynb" +} diff --git a/examples/coordinate-alignment.ipynb b/examples/coordinate-alignment.ipynb index 1547bd9d..e440d467 100644 --- a/examples/coordinate-alignment.ipynb +++ b/examples/coordinate-alignment.ipynb @@ -6,13 +6,30 @@ "source": [ "# Coordinate Alignment\n", "\n", - "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." + "Since linopy builds on xarray, coordinate alignment matters when combining variables or expressions that live on different coordinates.\n", + "\n", + "linopy uses **strict, operation-dependent defaults** that prevent silent data loss and ambiguous fill behavior:\n", + "\n", + "| Operation | Default | On mismatch |\n", + "|-----------|---------|-------------|\n", + "| `+`, `-` | `\"exact\"` | `ValueError` — coordinates must match |\n", + "| `*`, `/` | `\"inner\"` | Intersection — natural filtering |\n", + "| `<=`, `>=`, `==` (DataArray RHS) | `\"exact\"` | `ValueError` — coordinates must match |\n", + "\n", + "When you need to combine operands with mismatched coordinates, use the named methods (`.add()`, `.sub()`, `.mul()`, `.div()`, `.le()`, `.ge()`, `.eq()`) with an explicit `join=` parameter.\n", + "\n", + "This convention is inspired by [pyoframe](https://github.com/Bravos-Power/pyoframe) and [xarray's planned direction](https://github.com/pydata/xarray/issues/8527) toward stricter alignment." ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.191791Z", + "start_time": "2026-02-20T07:55:08.189675Z" + } + }, "outputs": [], "source": [ "import numpy as np\n", @@ -26,173 +43,196 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Default Alignment Behavior\n", + "## Matching Coordinates — The Default Case\n", "\n", - "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)." + "When two operands share the same coordinates on every shared dimension, all operators work directly. No special handling is needed." ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.219582Z", + "start_time": "2026-02-20T07:55:08.205251Z" + } + }, "outputs": [], "source": [ "m = linopy.Model()\n", "\n", "time = pd.RangeIndex(5, name=\"time\")\n", "x = m.add_variables(lower=0, coords=[time], name=\"x\")\n", + "y = m.add_variables(lower=0, coords=[time], name=\"y\")\n", "\n", - "subset_time = pd.RangeIndex(3, name=\"time\")\n", - "y = m.add_variables(lower=0, coords=[subset_time], name=\"y\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "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." + "# Same coordinates — works fine\n", + "x + y" ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.229459Z", + "start_time": "2026-02-20T07:55:08.224390Z" + } + }, "outputs": [], "source": [ - "x + y" + "factor = xr.DataArray([2, 3, 4, 5, 6], dims=[\"time\"], coords={\"time\": time})\n", + "x * factor" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "The same applies when multiplying by a constant that covers only a subset of coordinates. Missing positions get a coefficient of zero:" + "## Broadcasting (Different Dimensions)\n", + "\n", + "Alignment only checks **shared** dimensions. If operands have different dimension names, they expand (broadcast) as in xarray — this is unaffected by the alignment convention." ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.240925Z", + "start_time": "2026-02-20T07:55:08.235115Z" + } + }, "outputs": [], "source": [ - "factor = xr.DataArray([2, 3, 4], dims=[\"time\"], coords={\"time\": [0, 1, 2]})\n", - "x * factor" + "techs = pd.Index([\"solar\", \"wind\", \"gas\"], name=\"tech\")\n", + "cost = xr.DataArray([1.0, 0.5, 3.0], dims=[\"tech\"], coords={\"tech\": techs})\n", + "\n", + "# x has dim \"time\", cost has dim \"tech\" — no shared dim, pure broadcast\n", + "x * cost # -> (time, tech)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Adding a constant subset also fills missing coordinates with zero:" + "## Addition / Subtraction: `\"exact\"` Default\n", + "\n", + "When operands have different coordinates on a shared dimension, `+` and `-` raise a `ValueError`. This prevents silent data loss or ambiguous fill behavior." ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.256128Z", + "start_time": "2026-02-20T07:55:08.246697Z" + } + }, "outputs": [], "source": [ - "x + factor" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Constraints with Subset RHS\n", + "subset_time = pd.RangeIndex(3, name=\"time\")\n", + "y_short = m.add_variables(lower=0, coords=[subset_time], name=\"y_short\")\n", "\n", - "For constraints, missing right-hand-side values are filled with ``NaN``, which tells linopy to **skip** the constraint at those positions:" + "# x has 5 time steps, y_short has 3 — coordinates don't match\n", + "try:\n", + " x + y_short\n", + "except ValueError as e:\n", + " print(\"ValueError:\", e)" ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.264875Z", + "start_time": "2026-02-20T07:55:08.259746Z" + } + }, "outputs": [], "source": [ - "rhs = xr.DataArray([10, 20, 30], dims=[\"time\"], coords={\"time\": [0, 1, 2]})\n", - "con = x <= rhs\n", - "con" + "# Same for adding a constant DataArray with mismatched coordinates\n", + "partial_const = xr.DataArray([10, 20, 30], dims=[\"time\"], coords={\"time\": [0, 1, 2]})\n", + "\n", + "try:\n", + " x + partial_const\n", + "except ValueError as e:\n", + " print(\"ValueError:\", e)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "The constraint only applies at time 0, 1, 2. At time 3 and 4 the RHS is ``NaN``, so no constraint is created." + "## Multiplication / Division: `\"inner\"` Default\n", + "\n", + "Multiplication by a parameter array is a natural filtering operation — like applying an availability factor to a subset of time steps. The result is restricted to the **intersection** of coordinates. No fill values are needed." ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": "### Same-Shape Operands: Positional Alignment\n\nWhen 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:" - }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.275571Z", + "start_time": "2026-02-20T07:55:08.270271Z" + } + }, "outputs": [], "source": [ - "offset_const = xr.DataArray(\n", - " [10, 20, 30, 40, 50], dims=[\"time\"], coords={\"time\": [5, 6, 7, 8, 9]}\n", - ")\n", - "x + offset_const" + "partial_factor = xr.DataArray([2, 3, 4], dims=[\"time\"], coords={\"time\": [0, 1, 2]})\n", + "\n", + "# x has time 0-4, partial_factor has time 0-2\n", + "# Inner join: result restricted to time 0, 1, 2\n", + "x * partial_factor" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": "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:" - }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.286941Z", + "start_time": "2026-02-20T07:55:08.280454Z" + } + }, "outputs": [], "source": [ + "# Disjoint coordinates: no intersection -> empty result\n", "z = m.add_variables(lower=0, coords=[pd.RangeIndex(5, 10, name=\"time\")], name=\"z\")\n", - "x + z" + "disjoint_factor = xr.DataArray(\n", + " [1, 2, 3, 4, 5], dims=[\"time\"], coords={\"time\": range(5)}\n", + ")\n", + "\n", + "z * disjoint_factor" ] }, { "cell_type": "markdown", "metadata": {}, - "source": "``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.\n\nTo force **label-based** alignment, pass an explicit ``join``:" - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], "source": [ - "x.add(z, join=\"outer\")" + "## Named Methods with `join=`\n", + "\n", + "When you intentionally want to combine operands with mismatched coordinates, use the named methods with an explicit `join=` parameter. This makes the alignment intent clear in the code." ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": "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." - }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## The ``join`` Parameter\n", - "\n", - "For explicit control over alignment, use the ``.add()``, ``.sub()``, ``.mul()``, and ``.div()`` methods with a ``join`` parameter. The supported values follow xarray conventions:\n", - "\n", - "- ``\"inner\"`` — intersection of coordinates\n", - "- ``\"outer\"`` — union of coordinates (with fill)\n", - "- ``\"left\"`` — keep left operand's coordinates\n", - "- ``\"right\"`` — keep right operand's coordinates\n", - "- ``\"override\"`` — positional alignment, ignore coordinate labels\n", - "- ``\"exact\"`` — coordinates must match exactly (raises on mismatch)" + "### Setup: Overlapping but Non-Identical Coordinates" ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.296398Z", + "start_time": "2026-02-20T07:55:08.289560Z" + } + }, "outputs": [], "source": [ "m2 = linopy.Model()\n", @@ -208,13 +248,20 @@ "cell_type": "markdown", "metadata": {}, "source": [ + "`a` has coordinates i=[0, 1, 2] and `b` has i=[1, 2, 3]. They overlap at i=1 and i=2 but are not identical, so `a + b` raises a `ValueError`.\n", + "\n", "**Inner join** — only shared coordinates (i=1, 2):" ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.309658Z", + "start_time": "2026-02-20T07:55:08.300849Z" + } + }, "outputs": [], "source": [ "a.add(b, join=\"inner\")" @@ -224,13 +271,18 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "**Outer join** — union of coordinates (i=0, 1, 2, 3):" + "**Outer join** — union of coordinates (i=0, 1, 2, 3). Where one operand is missing, it drops out of the sum (fill with zero):" ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.327236Z", + "start_time": "2026-02-20T07:55:08.318480Z" + } + }, "outputs": [], "source": [ "a.add(b, join=\"outer\")" @@ -246,7 +298,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.345463Z", + "start_time": "2026-02-20T07:55:08.337188Z" + } + }, "outputs": [], "source": [ "a.add(b, join=\"left\")" @@ -262,7 +319,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.363658Z", + "start_time": "2026-02-20T07:55:08.354475Z" + } + }, "outputs": [], "source": [ "a.add(b, join=\"right\")" @@ -271,12 +333,19 @@ { "cell_type": "markdown", "metadata": {}, - "source": "**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:" + "source": [ + "**Override** — positional alignment, ignore coordinate labels. The result uses the left operand's coordinates:" + ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.383357Z", + "start_time": "2026-02-20T07:55:08.372963Z" + } + }, "outputs": [], "source": [ "a.add(b, join=\"override\")" @@ -286,28 +355,40 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Multiplication with ``join``\n", + "### Multiplication with `join=`\n", "\n", - "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:" + "The same `join=` parameter works on `.mul()` and `.div()`. Since multiplication defaults to `\"inner\"`, you only need explicit `join=` when you want a different mode:" ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.396019Z", + "start_time": "2026-02-20T07:55:08.390987Z" + } + }, "outputs": [], "source": [ "const = xr.DataArray([2, 3, 4], dims=[\"i\"], coords={\"i\": [1, 2, 3]})\n", "\n", - "a.mul(const, join=\"inner\")" + "# Default inner join — intersection of i=[0,1,2] and i=[1,2,3]\n", + "a * const" ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.411255Z", + "start_time": "2026-02-20T07:55:08.404219Z" + } + }, "outputs": [], "source": [ + "# Left join — keep a's coordinates, fill missing factor with 0\n", "a.mul(const, join=\"left\")" ] }, @@ -315,121 +396,196 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Alignment in Constraints\n", + "## Constraints with DataArray RHS\n", "\n", - "The ``.le()``, ``.ge()``, and ``.eq()`` methods create constraints with explicit coordinate alignment. They accept the same ``join`` parameter:" + "Constraint operators (`<=`, `>=`, `==`) with a DataArray right-hand side also default to `\"exact\"` — coordinates must match. Use `.le()`, `.ge()`, `.eq()` with `join=` to control alignment.\n", + "\n", + "The RHS may have **fewer** dimensions than the expression (broadcasting), but **not more**. The expression defines the problem structure; the RHS provides bounds within that structure." ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.435280Z", + "start_time": "2026-02-20T07:55:08.419069Z" + } + }, "outputs": [], "source": [ - "rhs = xr.DataArray([10, 20], dims=[\"i\"], coords={\"i\": [0, 1]})\n", + "# RHS with fewer dimensions — broadcasts (works fine)\n", + "m3 = linopy.Model()\n", + "hours = pd.RangeIndex(24, name=\"hour\")\n", + "techs = pd.Index([\"solar\", \"wind\", \"gas\"], name=\"tech\")\n", + "gen = m3.add_variables(lower=0, coords=[hours, techs], name=\"gen\")\n", "\n", - "a.le(rhs, join=\"inner\")" + "capacity = xr.DataArray([100, 80, 50], dims=[\"tech\"], coords={\"tech\": techs})\n", + "m3.add_constraints(\n", + " gen <= capacity, name=\"capacity_limit\"\n", + ") # capacity broadcasts over hour" ] }, { - "cell_type": "markdown", - "metadata": {}, + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.449885Z", + "start_time": "2026-02-20T07:55:08.443051Z" + } + }, + "outputs": [], "source": [ - "With ``join=\"inner\"``, the constraint only exists at the intersection (i=0, 1). Compare with ``join=\"left\"``:" + "# RHS with matching coordinates — works fine\n", + "full_rhs = xr.DataArray(np.arange(5, dtype=float), dims=[\"time\"], coords={\"time\": time})\n", + "con = x <= full_rhs\n", + "con" ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.461738Z", + "start_time": "2026-02-20T07:55:08.456633Z" + } + }, "outputs": [], "source": [ - "a.le(rhs, join=\"left\")" + "# RHS with mismatched coordinates — raises ValueError\n", + "partial_rhs = xr.DataArray([10, 20, 30], dims=[\"time\"], coords={\"time\": [0, 1, 2]})\n", + "\n", + "try:\n", + " x <= partial_rhs\n", + "except ValueError as e:\n", + " print(\"ValueError:\", e)" ] }, { - "cell_type": "markdown", - "metadata": {}, + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.470943Z", + "start_time": "2026-02-20T07:55:08.464365Z" + } + }, + "outputs": [], "source": [ - "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.\n", - "\n", - "The same methods work on expressions:" + "# Use .le() with join=\"inner\" — constraint only at the intersection\n", + "x.to_linexpr().le(partial_rhs, join=\"inner\")" ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.485672Z", + "start_time": "2026-02-20T07:55:08.478220Z" + } + }, "outputs": [], "source": [ - "expr = 2 * a + 1\n", - "expr.eq(rhs, join=\"inner\")" + "# Use .le() with join=\"left\" — constraint at all of x's coordinates,\n", + "# NaN where RHS is missing (no constraint at those positions)\n", + "x.to_linexpr().le(partial_rhs, join=\"left\")" ] }, { "cell_type": "markdown", "metadata": {}, - "source": "## Practical Example\n\nConsider a generation dispatch model where solar availability follows a daily profile and a minimum demand constraint only applies during peak hours." + "source": [ + "## Practical Example\n", + "\n", + "Consider a generation dispatch model where solar availability is a partial factor and a minimum demand constraint only applies during peak hours." + ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.495969Z", + "start_time": "2026-02-20T07:55:08.492164Z" + } + }, "outputs": [], "source": [ - "m3 = linopy.Model()\n", + "m4 = linopy.Model()\n", "\n", "hours = pd.RangeIndex(24, name=\"hour\")\n", "techs = pd.Index([\"solar\", \"wind\", \"gas\"], name=\"tech\")\n", "\n", - "gen = m3.add_variables(lower=0, coords=[hours, techs], name=\"gen\")" + "gen = m4.add_variables(lower=0, coords=[hours, techs], name=\"gen\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Capacity limits apply to all hours and techs — standard broadcasting handles this:" + "Capacity limits apply to all hours and techs. The `capacity` DataArray has only the `tech` dimension — it broadcasts over `hour` (no shared dimension to conflict):" ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.511065Z", + "start_time": "2026-02-20T07:55:08.499152Z" + } + }, "outputs": [], "source": [ "capacity = xr.DataArray([100, 80, 50], dims=[\"tech\"], coords={\"tech\": techs})\n", - "m3.add_constraints(gen <= capacity, name=\"capacity_limit\")" + "m4.add_constraints(gen <= capacity, name=\"capacity_limit\")" ] }, { "cell_type": "markdown", "metadata": {}, - "source": "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:" + "source": [ + "Solar availability is a factor that covers all 24 hours. Since coordinates match exactly, multiplication with `*` works directly:" + ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.532326Z", + "start_time": "2026-02-20T07:55:08.519001Z" + } + }, "outputs": [], "source": [ "solar_avail = np.zeros(24)\n", - "solar_avail[6:19] = 100 * np.sin(np.linspace(0, np.pi, 13))\n", + "solar_avail[6:19] = np.sin(np.linspace(0, np.pi, 13))\n", "solar_availability = xr.DataArray(solar_avail, dims=[\"hour\"], coords={\"hour\": hours})\n", "\n", "solar_gen = gen.sel(tech=\"solar\")\n", - "m3.add_constraints(solar_gen <= solar_availability, name=\"solar_avail\")" + "m4.add_constraints(solar_gen <= 100 * solar_availability, name=\"solar_avail\")" ] }, { "cell_type": "markdown", "metadata": {}, - "source": "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:" + "source": [ + "Peak demand of 120 MW must be met only during hours 8-20. The demand array covers a subset of hours. Use `.ge()` with `join=\"inner\"` to restrict the constraint to just those hours:" + ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T07:55:08.550668Z", + "start_time": "2026-02-20T07:55:08.537649Z" + } + }, "outputs": [], "source": [ "peak_hours = pd.RangeIndex(8, 21, name=\"hour\")\n", @@ -438,49 +594,59 @@ ")\n", "\n", "total_gen = gen.sum(\"tech\")\n", - "m3.add_constraints(total_gen.ge(peak_demand, join=\"inner\"), name=\"peak_demand\")" + "\n", + "# Constraint only at peak hours (intersection)\n", + "m4.add_constraints(total_gen.ge(peak_demand, join=\"inner\"), name=\"peak_demand\")" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": "The demand constraint only applies during peak hours (8–20). Outside that range, no minimum generation is required." - }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Summary\n", "\n", - "| ``join`` | Coordinates | Fill behavior |\n", - "|----------|------------|---------------|\n", - "| ``None`` (default) | Auto-detect (keeps superset) | Zeros for arithmetic, NaN for constraint RHS |\n", - "| ``\"inner\"`` | Intersection only | No fill needed |\n", - "| ``\"outer\"`` | Union | Fill with operation identity (0 for add, 0 for mul) |\n", - "| ``\"left\"`` | Left operand's | Fill right with identity |\n", - "| ``\"right\"`` | Right operand's | Fill left with identity |\n", - "| ``\"override\"`` | Left operand's (positional) | Positional alignment, ignore labels |\n", - "| ``\"exact\"`` | Must match exactly | Raises error if different |" + "### Default Behavior\n", + "\n", + "| Context | Default `join` | Behavior |\n", + "|---------|---------------|----------|\n", + "| Arithmetic operators (`+`, `-`) | `\"exact\"` | Coordinates must match; raises `ValueError` on mismatch |\n", + "| Arithmetic operators (`*`, `/`) | `\"inner\"` | Intersection of coordinates; no fill needed |\n", + "| Constraint operators (`<=`, `>=`, `==`) with DataArray RHS | `\"exact\"` | Coordinates must match; raises `ValueError` on mismatch |\n", + "\n", + "### All Join Modes\n", + "\n", + "| `join` | Coordinates | Fill behavior |\n", + "|--------|------------|---------------|\n", + "| `\"exact\"` (default for `+`, `-`, constraints) | Must match exactly | Raises `ValueError` if different |\n", + "| `\"inner\"` (default for `*`, `/`) | Intersection only | No fill needed |\n", + "| `\"outer\"` | Union | Fill with zero (arithmetic) or `NaN` (constraint RHS) |\n", + "| `\"left\"` | Left operand's | Fill right with zero (arithmetic) or `NaN` (constraint RHS) |\n", + "| `\"right\"` | Right operand's | Fill left with zero |\n", + "| `\"override\"` | Left operand's (positional) | Positional alignment, ignores coordinate labels |\n", + "\n", + "### Quick Reference\n", + "\n", + "| Operation | Matching coords | Mismatched coords |\n", + "|-----------|----------------|-------------------|\n", + "| `x + y` | Works | `ValueError` |\n", + "| `x * factor` | Works | Intersection |\n", + "| `x.add(y, join=\"inner\")` | Works | Intersection |\n", + "| `x.add(y, join=\"outer\")` | Works | Union with fill |\n", + "| `x <= rhs` (DataArray) | Works | `ValueError` |\n", + "| `x.le(rhs, join=\"inner\")` | Works | Intersection |\n", + "| `x.le(rhs, join=\"left\")` | Works | Left coords, NaN fill |" ] } ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.3" + "version": "3.11.0" } }, "nbformat": 4, From 076f29ba0aea9dc3c2e6230b01830d04e07daab9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:59:17 +0100 Subject: [PATCH 2/6] Implement consistent coordinate alignment for linopy operations Use "exact" join for +/- (raises ValueError on mismatch), "inner" join for *// (intersection), and "exact" for constraint DataArray RHS. Named methods (.add(), .sub(), .mul(), .div(), .le(), .ge(), .eq()) accept explicit join= parameter as escape hatch. - Remove shape-dependent "override" heuristic from merge() and _align_constant() - Add join parameter support to to_constraint() for DataArray RHS - Forbid extra dimensions on constraint RHS - Update tests with structured raise-then-recover pattern - Update coordinate-alignment notebook with examples and migration guide Co-Authored-By: Claude Opus 4.6 --- examples/coordinate-alignment.ipynb | 464 +++++++++++++++++++++++----- linopy/expressions.py | 147 ++++++--- linopy/variables.py | 5 +- test/test_linear_expression.py | 451 ++++++++++++++------------- test/test_optimization.py | 4 +- 5 files changed, 732 insertions(+), 339 deletions(-) diff --git a/examples/coordinate-alignment.ipynb b/examples/coordinate-alignment.ipynb index e440d467..b92b084b 100644 --- a/examples/coordinate-alignment.ipynb +++ b/examples/coordinate-alignment.ipynb @@ -18,7 +18,7 @@ "\n", "When you need to combine operands with mismatched coordinates, use the named methods (`.add()`, `.sub()`, `.mul()`, `.div()`, `.le()`, `.ge()`, `.eq()`) with an explicit `join=` parameter.\n", "\n", - "This convention is inspired by [pyoframe](https://github.com/Bravos-Power/pyoframe) and [xarray's planned direction](https://github.com/pydata/xarray/issues/8527) toward stricter alignment." + "This convention is inspired by [pyoframe](https://github.com/Bravos-Power/pyoframe)." ] }, { @@ -26,8 +26,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.191791Z", - "start_time": "2026-02-20T07:55:08.189675Z" + "end_time": "2026-02-20T08:34:40.590547Z", + "start_time": "2026-02-20T08:34:40.580957Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:10.188745Z", + "iopub.status.busy": "2026-02-20T08:31:10.188638Z", + "iopub.status.idle": "2026-02-20T08:31:11.700268Z", + "shell.execute_reply": "2026-02-20T08:31:11.700023Z" } }, "outputs": [], @@ -53,8 +59,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.219582Z", - "start_time": "2026-02-20T07:55:08.205251Z" + "end_time": "2026-02-20T08:34:40.620850Z", + "start_time": "2026-02-20T08:34:40.599526Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.701873Z", + "iopub.status.busy": "2026-02-20T08:31:11.701711Z", + "iopub.status.idle": "2026-02-20T08:31:11.760554Z", + "shell.execute_reply": "2026-02-20T08:31:11.760331Z" } }, "outputs": [], @@ -74,8 +86,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.229459Z", - "start_time": "2026-02-20T07:55:08.224390Z" + "end_time": "2026-02-20T08:34:40.633254Z", + "start_time": "2026-02-20T08:34:40.626281Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.761623Z", + "iopub.status.busy": "2026-02-20T08:31:11.761542Z", + "iopub.status.idle": "2026-02-20T08:31:11.766540Z", + "shell.execute_reply": "2026-02-20T08:31:11.766356Z" } }, "outputs": [], @@ -90,7 +108,9 @@ "source": [ "## Broadcasting (Different Dimensions)\n", "\n", - "Alignment only checks **shared** dimensions. If operands have different dimension names, they expand (broadcast) as in xarray — this is unaffected by the alignment convention." + "Alignment only checks **shared** dimensions. If operands have different dimension names, they expand (broadcast) as in xarray — this is unaffected by the alignment convention.\n", + "\n", + "This works in both directions: a constant with extra dimensions expands the expression, and an expression with extra dimensions expands over the constant." ] }, { @@ -98,8 +118,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.240925Z", - "start_time": "2026-02-20T07:55:08.235115Z" + "end_time": "2026-02-20T08:34:40.650251Z", + "start_time": "2026-02-20T08:34:40.639851Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.767547Z", + "iopub.status.busy": "2026-02-20T08:31:11.767487Z", + "iopub.status.idle": "2026-02-20T08:31:11.773127Z", + "shell.execute_reply": "2026-02-20T08:31:11.772954Z" } }, "outputs": [], @@ -111,6 +137,36 @@ "x * cost # -> (time, tech)" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T08:34:40.667715Z", + "start_time": "2026-02-20T08:34:40.656983Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.774071Z", + "iopub.status.busy": "2026-02-20T08:31:11.773994Z", + "iopub.status.idle": "2026-02-20T08:31:11.780472Z", + "shell.execute_reply": "2026-02-20T08:31:11.780265Z" + } + }, + "outputs": [], + "source": [ + "# Constant with MORE dimensions than the expression — also broadcasts\n", + "w = m.add_variables(lower=0, coords=[techs], name=\"w\") # dims: (tech,)\n", + "time_profile = xr.DataArray(\n", + " [[1, 2], [3, 4], [5, 6]],\n", + " dims=[\"tech\", \"time\"],\n", + " coords={\"tech\": techs, \"time\": [0, 1]},\n", + ")\n", + "\n", + "# w has dim \"tech\", time_profile has dims (\"tech\", \"time\")\n", + "# \"time\" is extra — it expands the expression via broadcasting\n", + "w + time_profile # -> (tech, time)" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -125,8 +181,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.256128Z", - "start_time": "2026-02-20T07:55:08.246697Z" + "end_time": "2026-02-20T08:34:40.683657Z", + "start_time": "2026-02-20T08:34:40.673488Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.781625Z", + "iopub.status.busy": "2026-02-20T08:31:11.781557Z", + "iopub.status.idle": "2026-02-20T08:31:11.786621Z", + "shell.execute_reply": "2026-02-20T08:31:11.786442Z" } }, "outputs": [], @@ -146,8 +208,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.264875Z", - "start_time": "2026-02-20T07:55:08.259746Z" + "end_time": "2026-02-20T08:34:40.698177Z", + "start_time": "2026-02-20T08:34:40.691406Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.787505Z", + "iopub.status.busy": "2026-02-20T08:31:11.787448Z", + "iopub.status.idle": "2026-02-20T08:31:11.790477Z", + "shell.execute_reply": "2026-02-20T08:31:11.790298Z" } }, "outputs": [], @@ -175,8 +243,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.275571Z", - "start_time": "2026-02-20T07:55:08.270271Z" + "end_time": "2026-02-20T08:34:40.722403Z", + "start_time": "2026-02-20T08:34:40.704702Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.791396Z", + "iopub.status.busy": "2026-02-20T08:31:11.791334Z", + "iopub.status.idle": "2026-02-20T08:31:11.796458Z", + "shell.execute_reply": "2026-02-20T08:31:11.796262Z" } }, "outputs": [], @@ -193,8 +267,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.286941Z", - "start_time": "2026-02-20T07:55:08.280454Z" + "end_time": "2026-02-20T08:34:40.750336Z", + "start_time": "2026-02-20T08:34:40.739583Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.797412Z", + "iopub.status.busy": "2026-02-20T08:31:11.797355Z", + "iopub.status.idle": "2026-02-20T08:31:11.803105Z", + "shell.execute_reply": "2026-02-20T08:31:11.802861Z" } }, "outputs": [], @@ -229,8 +309,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.296398Z", - "start_time": "2026-02-20T07:55:08.289560Z" + "end_time": "2026-02-20T08:34:40.770327Z", + "start_time": "2026-02-20T08:34:40.762873Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.804161Z", + "iopub.status.busy": "2026-02-20T08:31:11.804100Z", + "iopub.status.idle": "2026-02-20T08:31:11.807917Z", + "shell.execute_reply": "2026-02-20T08:31:11.807731Z" } }, "outputs": [], @@ -258,8 +344,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.309658Z", - "start_time": "2026-02-20T07:55:08.300849Z" + "end_time": "2026-02-20T08:34:40.785505Z", + "start_time": "2026-02-20T08:34:40.775987Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.808856Z", + "iopub.status.busy": "2026-02-20T08:31:11.808774Z", + "iopub.status.idle": "2026-02-20T08:31:11.815876Z", + "shell.execute_reply": "2026-02-20T08:31:11.815678Z" } }, "outputs": [], @@ -279,8 +371,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.327236Z", - "start_time": "2026-02-20T07:55:08.318480Z" + "end_time": "2026-02-20T08:34:40.811388Z", + "start_time": "2026-02-20T08:34:40.797806Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.816893Z", + "iopub.status.busy": "2026-02-20T08:31:11.816817Z", + "iopub.status.idle": "2026-02-20T08:31:11.824433Z", + "shell.execute_reply": "2026-02-20T08:31:11.824155Z" } }, "outputs": [], @@ -300,8 +398,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.345463Z", - "start_time": "2026-02-20T07:55:08.337188Z" + "end_time": "2026-02-20T08:34:40.834416Z", + "start_time": "2026-02-20T08:34:40.823515Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.825622Z", + "iopub.status.busy": "2026-02-20T08:31:11.825544Z", + "iopub.status.idle": "2026-02-20T08:31:11.832608Z", + "shell.execute_reply": "2026-02-20T08:31:11.832423Z" } }, "outputs": [], @@ -321,8 +425,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.363658Z", - "start_time": "2026-02-20T07:55:08.354475Z" + "end_time": "2026-02-20T08:34:40.854542Z", + "start_time": "2026-02-20T08:34:40.841131Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.833545Z", + "iopub.status.busy": "2026-02-20T08:31:11.833490Z", + "iopub.status.idle": "2026-02-20T08:31:11.840073Z", + "shell.execute_reply": "2026-02-20T08:31:11.839884Z" } }, "outputs": [], @@ -342,8 +452,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.383357Z", - "start_time": "2026-02-20T07:55:08.372963Z" + "end_time": "2026-02-20T08:34:40.872890Z", + "start_time": "2026-02-20T08:34:40.862894Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.841049Z", + "iopub.status.busy": "2026-02-20T08:31:11.840991Z", + "iopub.status.idle": "2026-02-20T08:31:11.847135Z", + "shell.execute_reply": "2026-02-20T08:31:11.846968Z" } }, "outputs": [], @@ -365,8 +481,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.396019Z", - "start_time": "2026-02-20T07:55:08.390987Z" + "end_time": "2026-02-20T08:34:40.899679Z", + "start_time": "2026-02-20T08:34:40.889148Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.848157Z", + "iopub.status.busy": "2026-02-20T08:31:11.848101Z", + "iopub.status.idle": "2026-02-20T08:31:11.852887Z", + "shell.execute_reply": "2026-02-20T08:31:11.852713Z" } }, "outputs": [], @@ -378,29 +500,29 @@ ] }, { - "cell_type": "code", - "execution_count": null, + "cell_type": "markdown", "metadata": { "ExecuteTime": { "end_time": "2026-02-20T07:55:08.411255Z", "start_time": "2026-02-20T07:55:08.404219Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:23:53.301013Z", + "iopub.status.busy": "2026-02-20T08:23:53.300958Z", + "iopub.status.idle": "2026-02-20T08:23:53.305201Z", + "shell.execute_reply": "2026-02-20T08:23:53.305026Z" } }, - "outputs": [], - "source": [ - "# Left join — keep a's coordinates, fill missing factor with 0\n", - "a.mul(const, join=\"left\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, "source": [ "## Constraints with DataArray RHS\n", "\n", - "Constraint operators (`<=`, `>=`, `==`) with a DataArray right-hand side also default to `\"exact\"` — coordinates must match. Use `.le()`, `.ge()`, `.eq()` with `join=` to control alignment.\n", + "Constraint operators (`<=`, `>=`, `==`) with a DataArray right-hand side also default to `\"exact\"` — coordinates on shared dimensions must match. Use `.le()`, `.ge()`, `.eq()` with `join=` to control alignment.\n", "\n", - "The RHS may have **fewer** dimensions than the expression (broadcasting), but **not more**. The expression defines the problem structure; the RHS provides bounds within that structure." + "**Dimension rules for constraint RHS:**\n", + "- The RHS may have **fewer** dimensions than the expression — the bound broadcasts. This is the standard way to apply a per-tech capacity across all time steps.\n", + "- The RHS must **not** have **more** dimensions than the expression. An expression with `dims=(tech,)` defines one variable per tech; an RHS with `dims=(time, tech)` would create redundant constraints on the same variable, which is almost always a mistake.\n", + "\n", + "Note: this is different from arithmetic, where a constant with extra dims freely expands the expression. For constraints, the expression defines the problem structure." ] }, { @@ -408,8 +530,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.435280Z", - "start_time": "2026-02-20T07:55:08.419069Z" + "end_time": "2026-02-20T08:34:40.925963Z", + "start_time": "2026-02-20T08:34:40.907028Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.853850Z", + "iopub.status.busy": "2026-02-20T08:31:11.853793Z", + "iopub.status.idle": "2026-02-20T08:31:11.866027Z", + "shell.execute_reply": "2026-02-20T08:31:11.865850Z" } }, "outputs": [], @@ -431,8 +559,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.449885Z", - "start_time": "2026-02-20T07:55:08.443051Z" + "end_time": "2026-02-20T08:34:40.949905Z", + "start_time": "2026-02-20T08:34:40.939482Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.866958Z", + "iopub.status.busy": "2026-02-20T08:31:11.866899Z", + "iopub.status.idle": "2026-02-20T08:31:11.871115Z", + "shell.execute_reply": "2026-02-20T08:31:11.870937Z" } }, "outputs": [], @@ -448,8 +582,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.461738Z", - "start_time": "2026-02-20T07:55:08.456633Z" + "end_time": "2026-02-20T08:34:40.970277Z", + "start_time": "2026-02-20T08:34:40.961415Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.872062Z", + "iopub.status.busy": "2026-02-20T08:31:11.872004Z", + "iopub.status.idle": "2026-02-20T08:31:11.874934Z", + "shell.execute_reply": "2026-02-20T08:31:11.874765Z" } }, "outputs": [], @@ -468,8 +608,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.470943Z", - "start_time": "2026-02-20T07:55:08.464365Z" + "end_time": "2026-02-20T08:34:41.006270Z", + "start_time": "2026-02-20T08:34:40.997162Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.875820Z", + "iopub.status.busy": "2026-02-20T08:31:11.875763Z", + "iopub.status.idle": "2026-02-20T08:31:11.879949Z", + "shell.execute_reply": "2026-02-20T08:31:11.879781Z" } }, "outputs": [], @@ -483,8 +629,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.485672Z", - "start_time": "2026-02-20T07:55:08.478220Z" + "end_time": "2026-02-20T08:34:41.036419Z", + "start_time": "2026-02-20T08:34:41.022644Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.880853Z", + "iopub.status.busy": "2026-02-20T08:31:11.880793Z", + "iopub.status.idle": "2026-02-20T08:31:11.884663Z", + "shell.execute_reply": "2026-02-20T08:31:11.884503Z" } }, "outputs": [], @@ -494,6 +646,35 @@ "x.to_linexpr().le(partial_rhs, join=\"left\")" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T08:34:41.053285Z", + "start_time": "2026-02-20T08:34:41.043483Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.885511Z", + "iopub.status.busy": "2026-02-20T08:31:11.885453Z", + "iopub.status.idle": "2026-02-20T08:31:11.889326Z", + "shell.execute_reply": "2026-02-20T08:31:11.889148Z" + } + }, + "outputs": [], + "source": [ + "# RHS with MORE dimensions than expression — raises ValueError\n", + "y_tech = m.add_variables(lower=0, coords=[techs], name=\"y_tech\") # dims: (tech,)\n", + "rhs_extra_dims = xr.DataArray(\n", + " np.ones((5, 3)), dims=[\"time\", \"tech\"], coords={\"time\": time, \"tech\": techs}\n", + ")\n", + "\n", + "try:\n", + " y_tech <= rhs_extra_dims # \"time\" is not in the expression\n", + "except ValueError as e:\n", + " print(\"ValueError:\", e)" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -508,8 +689,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.495969Z", - "start_time": "2026-02-20T07:55:08.492164Z" + "end_time": "2026-02-20T08:34:41.067545Z", + "start_time": "2026-02-20T08:34:41.062532Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.890266Z", + "iopub.status.busy": "2026-02-20T08:31:11.890211Z", + "iopub.status.idle": "2026-02-20T08:31:11.893636Z", + "shell.execute_reply": "2026-02-20T08:31:11.893458Z" } }, "outputs": [], @@ -534,8 +721,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.511065Z", - "start_time": "2026-02-20T07:55:08.499152Z" + "end_time": "2026-02-20T08:34:41.084071Z", + "start_time": "2026-02-20T08:34:41.071733Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.894513Z", + "iopub.status.busy": "2026-02-20T08:31:11.894459Z", + "iopub.status.idle": "2026-02-20T08:31:11.904179Z", + "shell.execute_reply": "2026-02-20T08:31:11.904001Z" } }, "outputs": [], @@ -556,8 +749,14 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.532326Z", - "start_time": "2026-02-20T07:55:08.519001Z" + "end_time": "2026-02-20T08:34:41.104144Z", + "start_time": "2026-02-20T08:34:41.091479Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.905131Z", + "iopub.status.busy": "2026-02-20T08:31:11.905072Z", + "iopub.status.idle": "2026-02-20T08:31:11.914430Z", + "shell.execute_reply": "2026-02-20T08:31:11.914242Z" } }, "outputs": [], @@ -573,17 +772,21 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "Peak demand of 120 MW must be met only during hours 8-20. The demand array covers a subset of hours. Use `.ge()` with `join=\"inner\"` to restrict the constraint to just those hours:" - ] + "source": "Peak demand of 120 MW must be met only during hours 8-20. The demand array covers a subset of hours. Use `.ge()` with `join=\"inner\"` to restrict the constraint to just those hours:" }, { "cell_type": "code", "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.550668Z", - "start_time": "2026-02-20T07:55:08.537649Z" + "end_time": "2026-02-20T08:34:41.122157Z", + "start_time": "2026-02-20T08:34:41.107939Z" + }, + "execution": { + "iopub.execute_input": "2026-02-20T08:31:11.915441Z", + "iopub.status.busy": "2026-02-20T08:31:11.915362Z", + "iopub.status.idle": "2026-02-20T08:31:11.925960Z", + "shell.execute_reply": "2026-02-20T08:31:11.925775Z" } }, "outputs": [], @@ -599,6 +802,112 @@ "m4.add_constraints(total_gen.ge(peak_demand, join=\"inner\"), name=\"peak_demand\")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "Selecting the correct subset of the variable produces the same result, and is arguably more readable:" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-20T08:37:39.113259Z", + "start_time": "2026-02-20T08:37:39.065983Z" + } + }, + "outputs": [], + "source": [ + "# Constraint only at peak hours (intersection)\n", + "m4.add_constraints(\n", + " total_gen.sel(hour=peak_hours) >= peak_demand, name=\"peak_demand_sel\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Migrating from Previous Versions\n", + "\n", + "Previous versions of linopy used a **shape-dependent heuristic** for coordinate alignment. The behavior depended on whether operands happened to have the same shape, and was inconsistent between `Variable` and `LinearExpression`:\n", + "\n", + "| Condition | Old behavior | New behavior |\n", + "|-----------|-------------|-------------|\n", + "| Same shape, same coordinates | Works correctly | Works correctly (no change) |\n", + "| Same shape, **different** coordinates, `+`/`-` | `\"override\"` — positional alignment (**bug-prone**) | `\"exact\"` — raises `ValueError` |\n", + "| Same shape, **different** coordinates, `*`/`/` | Buggy (crashes or produces garbage) | `\"inner\"` — intersection |\n", + "| Different shape, expr + expr | `\"outer\"` — union of coordinates | `\"exact\"` — raises `ValueError` |\n", + "| Different shape, expr + constant | `\"left\"` — keeps expression coords, fills missing with 0 | `\"exact\"` — raises `ValueError` |\n", + "| Different shape, expr * constant | Buggy (crashes for `LinearExpression`, produces garbage for `Variable`) | `\"inner\"` — intersection |\n", + "| Constraint with mismatched DataArray RHS | Same-shape: `\"override\"` (positional); different-shape: `\"left\"` (fills missing RHS with 0) | `\"exact\"` — raises `ValueError` |\n", + "\n", + "### Why the change?\n", + "\n", + "The old heuristic caused several classes of bugs:\n", + "\n", + "1. **Silent positional alignment**: When two operands happened to have the same shape but entirely different coordinates (e.g., `x(time=[0,1,2]) + z(time=[5,6,7])`), they were matched by position — giving a wrong result with no warning.\n", + "\n", + "2. **Non-associative addition**: `(y + factor) + x` could give a different result than `y + (x + factor)` because `\"left\"` for expr+constant dropped the constant's extra coordinates before they could be recovered by a subsequent addition.\n", + "\n", + "3. **Broken multiplication**: Multiplying a `LinearExpression` by a DataArray with mismatched coordinates would crash with an `AssertionError`. Multiplying a `Variable` by such a DataArray produced a result with misaligned coefficients and variable references.\n", + "\n", + "### How to update your code\n", + "\n", + "If your code combines operands with **mismatched coordinates** and you relied on the old behavior, you'll now get a `ValueError` (for `+`/`-`) or a smaller result (for `*`/`/`). Here's how to migrate:" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Addition with mismatched coordinates** — expr+expr previously used `\"outer\"`, expr+constant used `\"left\"`. Both now raise `ValueError`:\n", + "\n", + "```python\n", + "# Old code (worked silently):\n", + "result = x + y_short # different-size expr+expr → was \"outer\"\n", + "result = x + partial_const # expr + constant → was \"left\"\n", + "\n", + "# New code — be explicit about the join:\n", + "result = x.add(y_short, join=\"outer\") # union of coordinates\n", + "result = x.add(partial_const, join=\"left\") # keep x's coordinates, fill 0\n", + "```\n", + "\n", + "**Same-shape but different coordinates** — previously matched by position (`\"override\"`) for addition. Now raises `ValueError` for `+`/`-`, gives intersection for `*`/`/`:\n", + "\n", + "```python\n", + "# Old code (silently matched positions — likely a bug!):\n", + "x_abc = m.add_variables(coords=[[\"a\", \"b\", \"c\"]], name=\"x_abc\")\n", + "y_def = m.add_variables(coords=[[\"d\", \"e\", \"f\"]], name=\"y_def\")\n", + "result = x_abc + y_def # Old: positional match → New: ValueError\n", + "\n", + "# If you really want positional matching (rare):\n", + "result = x_abc.add(y_def, join=\"override\")\n", + "```\n", + "\n", + "**Multiplication with mismatched coordinates** — previously broken (crash or garbage). Now uses `\"inner\"` (intersection):\n", + "\n", + "```python\n", + "# Old code — would crash (LinExpr) or produce garbage (Variable):\n", + "x * partial_factor # x has 5 coords, partial_factor has 3\n", + "\n", + "# New code — result has 3 entries (intersection). This now works correctly!\n", + "# If you need to keep all of x's coordinates (zero-fill missing):\n", + "x.mul(partial_factor, join=\"left\")\n", + "```\n", + "\n", + "**Constraints with mismatched DataArray RHS** — previously used positional alignment (same shape) or `\"left\"` with 0-fill (different shape). Now raises `ValueError`:\n", + "\n", + "```python\n", + "# Old code:\n", + "con = x <= partial_rhs # Old: \"left\" (fill 0) or \"override\" → New: ValueError\n", + "\n", + "# New code — be explicit:\n", + "con = x.to_linexpr().le(partial_rhs, join=\"left\") # keep x's coords, NaN fill\n", + "con = x.to_linexpr().le(partial_rhs, join=\"inner\") # intersection only\n", + "```" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -609,9 +918,16 @@ "\n", "| Context | Default `join` | Behavior |\n", "|---------|---------------|----------|\n", - "| Arithmetic operators (`+`, `-`) | `\"exact\"` | Coordinates must match; raises `ValueError` on mismatch |\n", - "| Arithmetic operators (`*`, `/`) | `\"inner\"` | Intersection of coordinates; no fill needed |\n", - "| Constraint operators (`<=`, `>=`, `==`) with DataArray RHS | `\"exact\"` | Coordinates must match; raises `ValueError` on mismatch |\n", + "| Arithmetic operators (`+`, `-`) | `\"exact\"` | Coordinates must match on shared dims; raises `ValueError` on mismatch |\n", + "| Arithmetic operators (`*`, `/`) | `\"inner\"` | Intersection of coordinates on shared dims; no fill needed |\n", + "| Constraint operators (`<=`, `>=`, `==`) with DataArray RHS | `\"exact\"` | Coordinates must match on shared dims; raises `ValueError` on mismatch |\n", + "\n", + "### Extra Dimensions (Broadcasting)\n", + "\n", + "| Context | Extra dims on constant/RHS | Extra dims on expression |\n", + "|---------|--------------------------|------------------------|\n", + "| Arithmetic (`+`, `-`, `*`, `/`) | Expands the expression (standard xarray broadcast) | Expands over the constant |\n", + "| Constraint RHS (`<=`, `>=`, `==`) | **Forbidden** — raises `ValueError` | RHS broadcasts over expression's extra dims |\n", "\n", "### All Join Modes\n", "\n", @@ -645,8 +961,16 @@ "name": "python3" }, "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", "name": "python", - "version": "3.11.0" + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.11" } }, "nbformat": 4, diff --git a/linopy/expressions.py b/linopy/expressions.py index e1fbe1a9..cd172a8a 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -48,7 +48,6 @@ LocIndexer, as_dataarray, assign_multiindex_safe, - check_common_keys_values, check_has_nulls, check_has_nulls_polars, fill_missing_coords, @@ -528,6 +527,7 @@ def _align_constant( other: DataArray, fill_value: float = 0, join: str | None = None, + default_join: str = "exact", ) -> tuple[DataArray, DataArray, bool]: """ Align a constant DataArray with self.const. @@ -539,7 +539,10 @@ def _align_constant( fill_value : float, default: 0 Fill value for missing coordinates. join : str, optional - Alignment method. If None, uses size-aware default behavior. + Alignment method. If None, uses default_join. + default_join : str, default: "exact" + Default join mode when join is None. Use "exact" for add/sub, + "inner" for mul/div. Returns ------- @@ -551,19 +554,32 @@ def _align_constant( Whether the expression's data needs reindexing. """ if join is None: - if other.sizes == self.const.sizes: - return self.const, other.assign_coords(coords=self.coords), False + join = default_join + + if join == "override": + return self.const, other.assign_coords(coords=self.coords), False + elif join == "left": return ( self.const, other.reindex_like(self.const, fill_value=fill_value), False, ) - elif join == "override": - return self.const, other.assign_coords(coords=self.coords), False else: - self_const, aligned = xr.align( - self.const, other, join=join, fill_value=fill_value - ) + try: + self_const, aligned = xr.align( + self.const, other, join=join, fill_value=fill_value + ) + except ValueError as e: + if "exact" in str(e): + raise ValueError( + f"{e}\n" + "Use .add()/.sub()/.mul()/.div() with an explicit join= parameter:\n" + ' .add(other, join="inner") # intersection of coordinates\n' + ' .add(other, join="outer") # union of coordinates (with fill)\n' + ' .add(other, join="left") # keep left operand\'s coordinates\n' + ' .add(other, join="override") # positional alignment' + ) from None + raise return self_const, aligned, True def _add_constant( @@ -573,7 +589,7 @@ def _add_constant( return self.assign(const=self.const + other) da = as_dataarray(other, coords=self.coords, dims=self.coord_dims) self_const, da, needs_data_reindex = self._align_constant( - da, fill_value=0, join=join + da, fill_value=0, join=join, default_join="exact" ) if needs_data_reindex: return self.__class__( @@ -593,7 +609,7 @@ def _apply_constant_op( ) -> GenericExpression: factor = as_dataarray(other, coords=self.coords, dims=self.coord_dims) self_const, factor, needs_data_reindex = self._align_constant( - factor, fill_value=fill_value, join=join + factor, fill_value=fill_value, join=join, default_join="inner" ) if needs_data_reindex: data = self.data.reindex_like(self_const, fill_value=self._fill_value) @@ -1082,7 +1098,40 @@ def to_constraint( f"RHS DataArray has dimensions {extra_dims} not present " f"in the expression. Cannot create constraint." ) - rhs = rhs.reindex_like(self.const, fill_value=np.nan) + effective_join = join if join is not None else "exact" + if effective_join == "override": + aligned_rhs = rhs.assign_coords(coords=self.const.coords) + expr_const = self.const + expr_data = self.data + elif effective_join == "left": + aligned_rhs = rhs.reindex_like(self.const, fill_value=np.nan) + expr_const = self.const + expr_data = self.data + else: + try: + expr_const_aligned, aligned_rhs = xr.align( + self.const, rhs, join=effective_join, fill_value=np.nan + ) + except ValueError as e: + if "exact" in str(e): + raise ValueError( + f"{e}\n" + "Use .le()/.ge()/.eq() with an explicit join= parameter:\n" + ' .le(rhs, join="inner") # intersection of coordinates\n' + ' .le(rhs, join="left") # keep expression coordinates (NaN fill)\n' + ' .le(rhs, join="override") # positional alignment' + ) from None + raise + expr_const = expr_const_aligned.fillna(0) + expr_data = self.data.reindex_like( + expr_const_aligned, fill_value=self._fill_value + ) + aligned_rhs = aligned_rhs + constraint_rhs = aligned_rhs - expr_const + data = assign_multiindex_safe( + expr_data[["coeffs", "vars"]], sign=sign, rhs=constraint_rhs + ) + return constraints.Constraint(data, model=self.model) elif isinstance(rhs, np.ndarray | pd.Series | pd.DataFrame) and rhs.ndim > len( self.coord_dims ): @@ -2320,16 +2369,6 @@ def merge( model = exprs[0].model - if join is not None: - override = join == "override" - elif cls in linopy_types and dim in HELPER_DIMS: - coord_dims = [ - {k: v for k, v in e.sizes.items() if k not in HELPER_DIMS} for e in exprs - ] - override = check_common_keys_values(coord_dims) # type: ignore - else: - override = False - data = [e.data if isinstance(e, linopy_types) else e for e in exprs] data = [fill_missing_coords(ds, fill_helper_dims=True) for ds in data] @@ -2345,23 +2384,55 @@ def merge( if join is not None: kwargs["join"] = join - elif override: - kwargs["join"] = "override" + elif dim == TERM_DIM: + kwargs["join"] = "exact" + elif dim == FACTOR_DIM: + kwargs["join"] = "inner" else: - kwargs.setdefault("join", "outer") - - if dim == TERM_DIM: - ds = xr.concat([d[["coeffs", "vars"]] for d in data], dim, **kwargs) - subkwargs = {**kwargs, "fill_value": 0} - const = xr.concat([d["const"] for d in data], dim, **subkwargs).sum(TERM_DIM) - ds = assign_multiindex_safe(ds, const=const) - elif dim == FACTOR_DIM: - ds = xr.concat([d[["vars"]] for d in data], dim, **kwargs) - coeffs = xr.concat([d["coeffs"] for d in data], dim, **kwargs).prod(FACTOR_DIM) - const = xr.concat([d["const"] for d in data], dim, **kwargs).prod(FACTOR_DIM) - ds = assign_multiindex_safe(ds, coeffs=coeffs, const=const) - else: - ds = xr.concat(data, dim, **kwargs) + kwargs["join"] = "outer" + + try: + if dim == TERM_DIM: + ds = xr.concat([d[["coeffs", "vars"]] for d in data], dim, **kwargs) + subkwargs = {**kwargs, "fill_value": 0} + const = xr.concat([d["const"] for d in data], dim, **subkwargs).sum( + TERM_DIM + ) + ds = assign_multiindex_safe(ds, const=const) + elif dim == FACTOR_DIM: + ds = xr.concat([d[["vars"]] for d in data], dim, **kwargs) + coeffs = xr.concat([d["coeffs"] for d in data], dim, **kwargs).prod( + FACTOR_DIM + ) + const = xr.concat([d["const"] for d in data], dim, **kwargs).prod( + FACTOR_DIM + ) + ds = assign_multiindex_safe(ds, coeffs=coeffs, const=const) + else: + # Pre-pad helper dims to same size before concat + fill = kwargs.get("fill_value", FILL_VALUE) + for helper_dim in HELPER_DIMS: + sizes = [d.sizes.get(helper_dim, 0) for d in data] + max_size = max(sizes) if sizes else 0 + if max_size > 0 and min(sizes) < max_size: + data = [ + d.reindex({helper_dim: range(max_size)}, fill_value=fill) + if d.sizes.get(helper_dim, 0) < max_size + else d + for d in data + ] + ds = xr.concat(data, dim, **kwargs) + except ValueError as e: + if "exact" in str(e): + raise ValueError( + f"{e}\n" + "Use .add()/.sub()/.mul()/.div() with an explicit join= parameter:\n" + ' .add(other, join="inner") # intersection of coordinates\n' + ' .add(other, join="outer") # union of coordinates (with fill)\n' + ' .add(other, join="left") # keep left operand\'s coordinates\n' + ' .add(other, join="override") # positional alignment' + ) from None + raise for d in set(HELPER_DIMS) & set(ds.coords): ds = ds.reset_index(d, drop=True) diff --git a/linopy/variables.py b/linopy/variables.py index 0eea6634..274344a1 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -400,8 +400,9 @@ def __mul__(self, other: SideLike) -> ExpressionLike: try: if isinstance(other, Variable | ScalarVariable): return self.to_linexpr() * other - - return self.to_linexpr(other) + if isinstance(other, expressions.LinearExpression): + return self.to_linexpr() * other + return self.to_linexpr()._multiply_by_constant(other) except TypeError: return NotImplemented diff --git a/test/test_linear_expression.py b/test/test_linear_expression.py index 2af1a8ea..d77bd00f 100644 --- a/test/test_linear_expression.py +++ b/test/test_linear_expression.py @@ -7,8 +7,6 @@ from __future__ import annotations -from typing import Any - import numpy as np import pandas as pd import polars as pl @@ -443,8 +441,12 @@ def test_linear_expression_sum( assert_linequal(expr.sum(["dim_0", TERM_DIM]), expr.sum("dim_0")) - # test special case otherride coords - expr = v.loc[:9] + v.loc[10:] + # disjoint coords now raise with exact default + with pytest.raises(ValueError, match="exact"): + v.loc[:9] + v.loc[10:] + + # explicit outer join gives union + expr = v.loc[:9].add(v.loc[10:], join="override") assert expr.nterm == 2 assert len(expr.coords["dim_2"]) == 10 @@ -467,8 +469,12 @@ def test_linear_expression_sum_with_const( assert_linequal(expr.sum(["dim_0", TERM_DIM]), expr.sum("dim_0")) - # test special case otherride coords - expr = v.loc[:9] + v.loc[10:] + # disjoint coords now raise with exact default + with pytest.raises(ValueError, match="exact"): + v.loc[:9] + v.loc[10:] + + # explicit outer join gives union + expr = v.loc[:9].add(v.loc[10:], join="override") assert expr.nterm == 2 assert len(expr.coords["dim_2"]) == 10 @@ -577,7 +583,18 @@ def test_linear_expression_multiplication_invalid( expr / x -class TestSubsetCoordinateAlignment: +class TestExactAlignmentDefault: + """ + Test the new alignment convention: exact for +/-, inner for *//. + + v has dim_2=[0..19] (20 entries). + subset has dim_2=[1, 3] (2 entries, subset of v's coords). + superset has dim_2=[0..24] (25 entries, superset of v's coords). + + Each test shows the operation, verifies the new behavior (raises or + intersection), then shows the explicit join= that recovers the old result. + """ + @pytest.fixture def subset(self) -> xr.DataArray: return xr.DataArray([10.0, 30.0], dims=["dim_2"], coords={"dim_2": [1, 3]}) @@ -588,298 +605,293 @@ def superset(self) -> xr.DataArray: np.arange(25, dtype=float), dims=["dim_2"], coords={"dim_2": range(25)} ) + @pytest.fixture + def matching(self) -> xr.DataArray: + return xr.DataArray( + np.arange(20, dtype=float), + dims=["dim_2"], + coords={"dim_2": range(20)}, + ) + @pytest.fixture def expected_fill(self) -> np.ndarray: + """Old expected result: 20-entry array with values at positions 1,3.""" arr = np.zeros(20) arr[1] = 10.0 arr[3] = 30.0 return arr - def test_var_mul_subset( - self, v: Variable, subset: xr.DataArray, expected_fill: np.ndarray - ) -> None: - result = v * subset - assert result.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(result.coeffs.values).any() - np.testing.assert_array_equal(result.coeffs.squeeze().values, expected_fill) - - def test_expr_mul_subset( - self, v: Variable, subset: xr.DataArray, expected_fill: np.ndarray - ) -> None: - expr = 1 * v - result = expr * subset - assert result.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(result.coeffs.values).any() - np.testing.assert_array_equal(result.coeffs.squeeze().values, expected_fill) - - @pytest.mark.parametrize( - "make_lhs,make_rhs", - [ - (lambda v, s: s * v, lambda v, s: v * s), - (lambda v, s: s * (1 * v), lambda v, s: (1 * v) * s), - (lambda v, s: s + v, lambda v, s: v + s), - (lambda v, s: s + (v + 5), lambda v, s: (v + 5) + s), - ], - ids=["subset*var", "subset*expr", "subset+var", "subset+expr"], - ) - def test_commutativity( - self, v: Variable, subset: xr.DataArray, make_lhs: Any, make_rhs: Any - ) -> None: - assert_linequal(make_lhs(v, subset), make_rhs(v, subset)) + # --- Addition / subtraction with subset constant --- def test_var_add_subset( self, v: Variable, subset: xr.DataArray, expected_fill: np.ndarray ) -> None: - result = v + subset - assert result.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(result.const.values).any() + # now raises + with pytest.raises(ValueError, match="exact"): + v + subset + + # explicit join="left" recovers old behavior: 20 entries, fill 0 + result = v.add(subset, join="left") + assert result.sizes["dim_2"] == 20 np.testing.assert_array_equal(result.const.values, expected_fill) def test_var_sub_subset( self, v: Variable, subset: xr.DataArray, expected_fill: np.ndarray ) -> None: - result = v - subset - assert result.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(result.const.values).any() - np.testing.assert_array_equal(result.const.values, -expected_fill) + with pytest.raises(ValueError, match="exact"): + v - subset - def test_subset_sub_var(self, v: Variable, subset: xr.DataArray) -> None: - assert_linequal(subset - v, -v + subset) + result = v.sub(subset, join="left") + assert result.sizes["dim_2"] == 20 + np.testing.assert_array_equal(result.const.values, -expected_fill) def test_expr_add_subset( self, v: Variable, subset: xr.DataArray, expected_fill: np.ndarray ) -> None: - expr = v + 5 - result = expr + subset - assert result.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(result.const.values).any() + with pytest.raises(ValueError, match="exact"): + (v + 5) + subset + + result = (v + 5).add(subset, join="left") + assert result.sizes["dim_2"] == 20 np.testing.assert_array_equal(result.const.values, expected_fill + 5) - def test_expr_sub_subset( + # --- Addition with superset constant --- + + def test_var_add_superset(self, v: Variable, superset: xr.DataArray) -> None: + with pytest.raises(ValueError, match="exact"): + v + superset + + result = v.add(superset, join="left") + assert result.sizes["dim_2"] == 20 + assert not np.isnan(result.const.values).any() + + # --- Addition / multiplication with disjoint coords --- + + def test_disjoint_add(self, v: Variable) -> None: + disjoint = xr.DataArray( + [100.0, 200.0], dims=["dim_2"], coords={"dim_2": [50, 60]} + ) + with pytest.raises(ValueError, match="exact"): + v + disjoint + + result = v.add(disjoint, join="outer") + assert result.sizes["dim_2"] == 22 # union of [0..19] and [50, 60] + + def test_disjoint_mul(self, v: Variable) -> None: + disjoint = xr.DataArray( + [10.0, 20.0], dims=["dim_2"], coords={"dim_2": [50, 60]} + ) + # inner join: no intersection → empty + result = v * disjoint + assert result.sizes["dim_2"] == 0 + + # explicit join="left": 20 entries, all zeros + result = v.mul(disjoint, join="left") + assert result.sizes["dim_2"] == 20 + np.testing.assert_array_equal(result.coeffs.squeeze().values, np.zeros(20)) + + def test_disjoint_div(self, v: Variable) -> None: + disjoint = xr.DataArray( + [10.0, 20.0], dims=["dim_2"], coords={"dim_2": [50, 60]} + ) + result = v / disjoint + assert result.sizes["dim_2"] == 0 + + # --- Multiplication / division with subset constant --- + + def test_var_mul_subset( self, v: Variable, subset: xr.DataArray, expected_fill: np.ndarray ) -> None: - expr = v + 5 - result = expr - subset - assert result.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(result.const.values).any() - np.testing.assert_array_equal(result.const.values, 5 - expected_fill) + # inner join: 2 entries (intersection) + result = v * subset + assert result.sizes["dim_2"] == 2 + assert result.coeffs.squeeze().sel(dim_2=1).item() == pytest.approx(10.0) + assert result.coeffs.squeeze().sel(dim_2=3).item() == pytest.approx(30.0) - def test_subset_sub_expr(self, v: Variable, subset: xr.DataArray) -> None: - expr = v + 5 - assert_linequal(subset - expr, -(expr - subset)) + # explicit join="left" recovers old behavior: 20 entries, fill 0 + result = v.mul(subset, join="left") + assert result.sizes["dim_2"] == 20 + np.testing.assert_array_equal(result.coeffs.squeeze().values, expected_fill) + + def test_expr_mul_subset(self, v: Variable, subset: xr.DataArray) -> None: + result = (1 * v) * subset + assert result.sizes["dim_2"] == 2 + assert result.coeffs.squeeze().sel(dim_2=1).item() == pytest.approx(10.0) + + def test_var_mul_superset(self, v: Variable, superset: xr.DataArray) -> None: + # inner join: intersection = v's 20 coords + result = v * superset + assert result.sizes["dim_2"] == 20 + assert not np.isnan(result.coeffs.values).any() def test_var_div_subset(self, v: Variable, subset: xr.DataArray) -> None: + # inner join: 2 entries result = v / subset - assert result.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(result.coeffs.values).any() + assert result.sizes["dim_2"] == 2 + assert result.coeffs.squeeze().sel(dim_2=1).item() == pytest.approx(0.1) + assert result.coeffs.squeeze().sel(dim_2=3).item() == pytest.approx(1.0 / 30) + + # explicit join="left": 20 entries, fill 1 + result = v.div(subset, join="left") + assert result.sizes["dim_2"] == 20 assert result.coeffs.squeeze().sel(dim_2=1).item() == pytest.approx(0.1) assert result.coeffs.squeeze().sel(dim_2=0).item() == pytest.approx(1.0) + # --- Constraints with subset RHS --- + def test_var_le_subset(self, v: Variable, subset: xr.DataArray) -> None: - con = v <= subset - assert con.sizes["dim_2"] == v.sizes["dim_2"] - assert con.rhs.sel(dim_2=1).item() == 10.0 - assert con.rhs.sel(dim_2=3).item() == 30.0 - assert np.isnan(con.rhs.sel(dim_2=0).item()) + with pytest.raises(ValueError, match="exact"): + v <= subset - @pytest.mark.parametrize("sign", ["<=", ">=", "=="]) - def test_var_comparison_subset( - self, v: Variable, subset: xr.DataArray, sign: str - ) -> None: - if sign == "<=": - con = v <= subset - elif sign == ">=": - con = v >= subset - else: - con = v == subset - assert con.sizes["dim_2"] == v.sizes["dim_2"] + # explicit join="left": 20 entries, NaN where RHS missing + con = v.to_linexpr().le(subset, join="left") + assert con.sizes["dim_2"] == 20 assert con.rhs.sel(dim_2=1).item() == 10.0 + assert con.rhs.sel(dim_2=3).item() == 30.0 assert np.isnan(con.rhs.sel(dim_2=0).item()) def test_expr_le_subset(self, v: Variable, subset: xr.DataArray) -> None: expr = v + 5 - con = expr <= subset - assert con.sizes["dim_2"] == v.sizes["dim_2"] + with pytest.raises(ValueError, match="exact"): + expr <= subset + + con = expr.le(subset, join="left") + assert con.sizes["dim_2"] == 20 assert con.rhs.sel(dim_2=1).item() == pytest.approx(5.0) assert con.rhs.sel(dim_2=3).item() == pytest.approx(25.0) assert np.isnan(con.rhs.sel(dim_2=0).item()) - def test_add_commutativity_full_coords(self, v: Variable) -> None: - full = xr.DataArray( - np.arange(20, dtype=float), - dims=["dim_2"], - coords={"dim_2": range(20)}, - ) - assert_linequal(v + full, full + v) - - def test_superset_addition_pins_to_lhs( - self, v: Variable, superset: xr.DataArray + @pytest.mark.parametrize("sign", ["<=", ">=", "=="]) + def test_var_comparison_subset( + self, v: Variable, subset: xr.DataArray, sign: str ) -> None: - result = v + superset - assert result.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(result.const.values).any() - - def test_superset_add_var(self, v: Variable, superset: xr.DataArray) -> None: - assert_linequal(superset + v, v + superset) - - def test_superset_sub_var(self, v: Variable, superset: xr.DataArray) -> None: - assert_linequal(superset - v, -v + superset) + with pytest.raises(ValueError, match="exact"): + if sign == "<=": + v <= subset + elif sign == ">=": + v >= subset + else: + v == subset + + def test_constraint_le_join_inner(self, v: Variable, subset: xr.DataArray) -> None: + con = v.to_linexpr().le(subset, join="inner") + assert con.sizes["dim_2"] == 2 + assert con.rhs.sel(dim_2=1).item() == 10.0 + assert con.rhs.sel(dim_2=3).item() == 30.0 - def test_superset_mul_var(self, v: Variable, superset: xr.DataArray) -> None: - assert_linequal(superset * v, v * superset) + # --- Matching coordinates: unchanged behavior --- - @pytest.mark.parametrize("sign", ["<=", ">="]) - def test_superset_comparison_var( - self, v: Variable, superset: xr.DataArray, sign: str - ) -> None: - if sign == "<=": - con = superset <= v - else: - con = superset >= v - assert con.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(con.lhs.coeffs.values).any() - assert not np.isnan(con.rhs.values).any() - - def test_disjoint_addition_pins_to_lhs(self, v: Variable) -> None: - disjoint = xr.DataArray( - [100.0, 200.0], dims=["dim_2"], coords={"dim_2": [50, 60]} - ) - result = v + disjoint - assert result.sizes["dim_2"] == v.sizes["dim_2"] + def test_add_matching_unchanged(self, v: Variable, matching: xr.DataArray) -> None: + result = v + matching + assert result.sizes["dim_2"] == 20 assert not np.isnan(result.const.values).any() - np.testing.assert_array_equal(result.const.values, np.zeros(20)) - def test_expr_div_subset(self, v: Variable, subset: xr.DataArray) -> None: - expr = 1 * v - result = expr / subset - assert result.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(result.coeffs.values).any() - assert result.coeffs.squeeze().sel(dim_2=1).item() == pytest.approx(0.1) - assert result.coeffs.squeeze().sel(dim_2=0).item() == pytest.approx(1.0) + def test_mul_matching_unchanged(self, v: Variable, matching: xr.DataArray) -> None: + result = v * matching + assert result.sizes["dim_2"] == 20 - def test_subset_add_var_coefficients( - self, v: Variable, subset: xr.DataArray - ) -> None: - result = subset + v - np.testing.assert_array_equal(result.coeffs.squeeze().values, np.ones(20)) + def test_le_matching_unchanged(self, v: Variable, matching: xr.DataArray) -> None: + con = v <= matching + assert con.sizes["dim_2"] == 20 - def test_subset_sub_var_coefficients( - self, v: Variable, subset: xr.DataArray + def test_add_commutativity_matching( + self, v: Variable, matching: xr.DataArray ) -> None: - result = subset - v - np.testing.assert_array_equal(result.coeffs.squeeze().values, -np.ones(20)) + assert_linequal(v + matching, matching + v) - @pytest.mark.parametrize("sign", ["<=", ">=", "=="]) - def test_subset_comparison_var( - self, v: Variable, subset: xr.DataArray, sign: str - ) -> None: - if sign == "<=": - con = subset <= v - elif sign == ">=": - con = subset >= v - else: - con = subset == v - assert con.sizes["dim_2"] == v.sizes["dim_2"] - assert np.isnan(con.rhs.sel(dim_2=0).item()) - assert con.rhs.sel(dim_2=1).item() == pytest.approx(10.0) + def test_mul_commutativity(self, v: Variable, subset: xr.DataArray) -> None: + assert_linequal(v * subset, subset * v) - def test_superset_mul_pins_to_lhs( - self, v: Variable, superset: xr.DataArray - ) -> None: - result = v * superset - assert result.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(result.coeffs.values).any() + # --- Explicit join modes --- - def test_superset_div_pins_to_lhs(self, v: Variable) -> None: - superset_nonzero = xr.DataArray( - np.arange(1, 26, dtype=float), - dims=["dim_2"], - coords={"dim_2": range(25)}, + def test_add_join_inner(self, v: Variable, subset: xr.DataArray) -> None: + result = v.add(subset, join="inner") + assert result.sizes["dim_2"] == 2 + assert result.const.sel(dim_2=1).item() == 10.0 + assert result.const.sel(dim_2=3).item() == 30.0 + + def test_add_join_outer(self, v: Variable, subset: xr.DataArray) -> None: + result = v.add(subset, join="outer") + assert result.sizes["dim_2"] == 20 + assert result.const.sel(dim_2=1).item() == 10.0 + assert result.const.sel(dim_2=0).item() == 0.0 + + def test_add_join_override(self, v: Variable) -> None: + disjoint = xr.DataArray( + np.ones(20), dims=["dim_2"], coords={"dim_2": range(50, 70)} ) - result = v / superset_nonzero - assert result.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(result.coeffs.values).any() + result = v.add(disjoint, join="override") + assert result.sizes["dim_2"] == 20 + assert list(result.coords["dim_2"].values) == list(range(20)) + + # --- Quadratic expressions --- def test_quadexpr_add_subset( self, v: Variable, subset: xr.DataArray, expected_fill: np.ndarray ) -> None: qexpr = v * v - result = qexpr + subset - assert isinstance(result, QuadraticExpression) - assert result.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(result.const.values).any() - np.testing.assert_array_equal(result.const.values, expected_fill) + with pytest.raises(ValueError, match="exact"): + qexpr + subset - def test_quadexpr_sub_subset( - self, v: Variable, subset: xr.DataArray, expected_fill: np.ndarray - ) -> None: - qexpr = v * v - result = qexpr - subset + result = qexpr.add(subset, join="left") assert isinstance(result, QuadraticExpression) - assert result.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(result.const.values).any() - np.testing.assert_array_equal(result.const.values, -expected_fill) + assert result.sizes["dim_2"] == 20 + np.testing.assert_array_equal(result.const.values, expected_fill) def test_quadexpr_mul_subset( self, v: Variable, subset: xr.DataArray, expected_fill: np.ndarray ) -> None: qexpr = v * v + # inner join: 2 entries result = qexpr * subset assert isinstance(result, QuadraticExpression) - assert result.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(result.coeffs.values).any() - np.testing.assert_array_equal(result.coeffs.squeeze().values, expected_fill) + assert result.sizes["dim_2"] == 2 - def test_subset_mul_quadexpr( - self, v: Variable, subset: xr.DataArray, expected_fill: np.ndarray - ) -> None: - qexpr = v * v - result = subset * qexpr + # explicit join="left": 20 entries + result = qexpr.mul(subset, join="left") assert isinstance(result, QuadraticExpression) - assert result.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(result.coeffs.values).any() + assert result.sizes["dim_2"] == 20 np.testing.assert_array_equal(result.coeffs.squeeze().values, expected_fill) - def test_subset_add_quadexpr(self, v: Variable, subset: xr.DataArray) -> None: - qexpr = v * v - assert_quadequal(subset + qexpr, qexpr + subset) + # --- Multi-dimensional --- def test_multidim_subset_mul(self, m: Model) -> None: coords_a = pd.RangeIndex(4, name="a") coords_b = pd.RangeIndex(5, name="b") w = m.add_variables(coords=[coords_a, coords_b], name="w") - subset_2d = xr.DataArray( [[2.0, 3.0], [4.0, 5.0]], dims=["a", "b"], coords={"a": [1, 3], "b": [0, 4]}, ) + + # inner join: 2x2 result = w * subset_2d + assert result.sizes["a"] == 2 + assert result.sizes["b"] == 2 + + # explicit join="left": 4x5, zeros at non-subset positions + result = w.mul(subset_2d, join="left") assert result.sizes["a"] == 4 assert result.sizes["b"] == 5 - assert not np.isnan(result.coeffs.values).any() assert result.coeffs.squeeze().sel(a=1, b=0).item() == pytest.approx(2.0) assert result.coeffs.squeeze().sel(a=3, b=4).item() == pytest.approx(5.0) assert result.coeffs.squeeze().sel(a=0, b=0).item() == pytest.approx(0.0) - assert result.coeffs.squeeze().sel(a=1, b=2).item() == pytest.approx(0.0) def test_multidim_subset_add(self, m: Model) -> None: coords_a = pd.RangeIndex(4, name="a") coords_b = pd.RangeIndex(5, name="b") w = m.add_variables(coords=[coords_a, coords_b], name="w") - subset_2d = xr.DataArray( [[2.0, 3.0], [4.0, 5.0]], dims=["a", "b"], coords={"a": [1, 3], "b": [0, 4]}, ) - result = w + subset_2d - assert result.sizes["a"] == 4 - assert result.sizes["b"] == 5 - assert not np.isnan(result.const.values).any() - assert result.const.sel(a=1, b=0).item() == pytest.approx(2.0) - assert result.const.sel(a=3, b=4).item() == pytest.approx(5.0) - assert result.const.sel(a=0, b=0).item() == pytest.approx(0.0) + + with pytest.raises(ValueError, match="exact"): + w + subset_2d + + # --- Edge cases --- def test_constraint_rhs_extra_dims_raises(self, v: Variable) -> None: rhs = xr.DataArray( @@ -893,24 +905,6 @@ def test_da_truediv_var_raises(self, v: Variable) -> None: with pytest.raises(TypeError): da / v # type: ignore[operator] - def test_disjoint_mul_produces_zeros(self, v: Variable) -> None: - disjoint = xr.DataArray( - [10.0, 20.0], dims=["dim_2"], coords={"dim_2": [50, 60]} - ) - result = v * disjoint - assert result.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(result.coeffs.values).any() - np.testing.assert_array_equal(result.coeffs.squeeze().values, np.zeros(20)) - - def test_disjoint_div_preserves_coeffs(self, v: Variable) -> None: - disjoint = xr.DataArray( - [10.0, 20.0], dims=["dim_2"], coords={"dim_2": [50, 60]} - ) - result = v / disjoint - assert result.sizes["dim_2"] == v.sizes["dim_2"] - assert not np.isnan(result.coeffs.values).any() - np.testing.assert_array_equal(result.coeffs.squeeze().values, np.ones(20)) - def test_da_eq_da_still_works(self) -> None: da1 = xr.DataArray([1, 2, 3]) da2 = xr.DataArray([1, 2, 3]) @@ -931,7 +925,8 @@ def test_subset_constraint_solve_integration(self) -> None: coords = pd.RangeIndex(5, name="i") x = m.add_variables(lower=0, upper=100, coords=[coords], name="x") subset_ub = xr.DataArray([10.0, 20.0], dims=["i"], coords={"i": [1, 3]}) - m.add_constraints(x <= subset_ub, name="subset_ub") + # exact default raises — use explicit join="left" (NaN = no constraint) + m.add_constraints(x.to_linexpr().le(subset_ub, join="left"), name="subset_ub") m.add_objective(x.sum(), sense="max") m.solve(solver_name=available_solvers[0]) sol = m.solution["x"] @@ -1789,10 +1784,12 @@ def b(self, m2: Model) -> Variable: def c(self, m2: Model) -> Variable: return m2.variables["c"] - def test_add_join_none_preserves_default(self, a: Variable, b: Variable) -> None: - result_default = a.to_linexpr() + b.to_linexpr() - result_none = a.to_linexpr().add(b.to_linexpr(), join=None) - assert_linequal(result_default, result_none) + def test_add_join_none_raises_on_mismatch(self, a: Variable, b: Variable) -> None: + # a has i=[0,1,2], b has i=[1,2,3] — exact default raises + with pytest.raises(ValueError, match="exact"): + a.to_linexpr() + b.to_linexpr() + with pytest.raises(ValueError, match="exact"): + a.to_linexpr().add(b.to_linexpr(), join=None) def test_add_expr_join_inner(self, a: Variable, b: Variable) -> None: result = a.to_linexpr().add(b.to_linexpr(), join="inner") @@ -2028,10 +2025,10 @@ def test_quadratic_add_constant_join_inner(self, a: Variable, b: Variable) -> No quad = a.to_linexpr() * b.to_linexpr() const = xr.DataArray([10, 20, 30], dims=["i"], coords={"i": [1, 2, 3]}) result = quad.add(const, join="inner") - assert list(result.data.indexes["i"]) == [1, 2, 3] + assert list(result.data.indexes["i"]) == [1, 2] - def test_quadratic_add_expr_join_inner(self, a: Variable) -> None: - quad = a.to_linexpr() * a.to_linexpr() + def test_quadratic_add_expr_join_inner(self, a: Variable, b: Variable) -> None: + quad = a.to_linexpr() * b.to_linexpr() const = xr.DataArray([10, 20], dims=["i"], coords={"i": [0, 1]}) result = quad.add(const, join="inner") assert list(result.data.indexes["i"]) == [0, 1] @@ -2040,7 +2037,7 @@ def test_quadratic_mul_constant_join_inner(self, a: Variable, b: Variable) -> No quad = a.to_linexpr() * b.to_linexpr() const = xr.DataArray([2, 3, 4], dims=["i"], coords={"i": [1, 2, 3]}) result = quad.mul(const, join="inner") - assert list(result.data.indexes["i"]) == [1, 2, 3] + assert list(result.data.indexes["i"]) == [1, 2] def test_merge_join_left(self, a: Variable, b: Variable) -> None: result: LinearExpression = merge([a.to_linexpr(), b.to_linexpr()], join="left") diff --git a/test/test_optimization.py b/test/test_optimization.py index 492d703a..6bcb1627 100644 --- a/test/test_optimization.py +++ b/test/test_optimization.py @@ -186,8 +186,8 @@ def model_with_non_aligned_variables() -> Model: lower = pd.Series(0, range(8)) y = m.add_variables(lower=lower, coords=[lower.index], name="y") - m.add_constraints(x + y, GREATER_EQUAL, 10.5) - m.objective = 1 * x + 0.5 * y + m.add_constraints(x.add(y, join="outer"), GREATER_EQUAL, 10.5) + m.objective = x.add(0.5 * y, join="outer") return m From 140021ec7eb0c5ad973bb5b78cb77794d3e79152 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:15:21 +0100 Subject: [PATCH 3/6] Added user warning for joins which result in size 0 expressions.py --- linopy/expressions.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/linopy/expressions.py b/linopy/expressions.py index cd172a8a..ad455a03 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -611,6 +611,14 @@ def _apply_constant_op( self_const, factor, needs_data_reindex = self._align_constant( factor, fill_value=fill_value, join=join, default_join="inner" ) + if self_const.size == 0 and self.const.size > 0: + warn( + "Multiplication/division resulted in an empty expression because " + "the operands have no overlapping coordinates (inner join). " + "This is likely a modeling error.", + UserWarning, + stacklevel=3, + ) if needs_data_reindex: data = self.data.reindex_like(self_const, fill_value=self._fill_value) return self.__class__( From abd3ac204963d7d8b497c133b4d9c773a3415240 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:35:17 +0100 Subject: [PATCH 4/6] Update convention and tests. Make notebooks mroe concise --- examples/coordinate-alignment.ipynb | 891 +++++++--------------------- linopy/expressions.py | 15 +- test/test_linear_expression.py | 22 +- test/test_typing.py | 7 +- 4 files changed, 237 insertions(+), 698 deletions(-) diff --git a/examples/coordinate-alignment.ipynb b/examples/coordinate-alignment.ipynb index b92b084b..0eed3e97 100644 --- a/examples/coordinate-alignment.ipynb +++ b/examples/coordinate-alignment.ipynb @@ -2,38 +2,40 @@ "cells": [ { "cell_type": "markdown", + "id": "7fb27b941602401d91542211134fc71a", "metadata": {}, "source": [ - "# Coordinate Alignment\n", + "# Coordinate Alignment in linopy\n", "\n", - "Since linopy builds on xarray, coordinate alignment matters when combining variables or expressions that live on different coordinates.\n", + "linopy enforces strict defaults for coordinate alignment so that mismatches never silently produce wrong results.\n", "\n", - "linopy uses **strict, operation-dependent defaults** that prevent silent data loss and ambiguous fill behavior:\n", + "| Operation | Shared-dim alignment | Extra dims on constant/RHS |\n", + "|-----------|---------------------|---------------------------|\n", + "| `+`, `-` | `\"exact\"` — must match | **Forbidden** |\n", + "| `*`, `/` | `\"inner\"` — intersection | Expands the expression |\n", + "| `<=`, `>=`, `==` | `\"exact\"` — must match | **Forbidden** |\n", "\n", - "| Operation | Default | On mismatch |\n", - "|-----------|---------|-------------|\n", - "| `+`, `-` | `\"exact\"` | `ValueError` — coordinates must match |\n", - "| `*`, `/` | `\"inner\"` | Intersection — natural filtering |\n", - "| `<=`, `>=`, `==` (DataArray RHS) | `\"exact\"` | `ValueError` — coordinates must match |\n", + "**Why?** Addition and constraint RHS only change constant terms — expanding into new dimensions would duplicate the same variable. Multiplication changes coefficients, so expanding is meaningful. The rules are consistent: `a*x + b <= 0` and `a*x <= -b` always behave identically.\n", "\n", - "When you need to combine operands with mismatched coordinates, use the named methods (`.add()`, `.sub()`, `.mul()`, `.div()`, `.le()`, `.ge()`, `.eq()`) with an explicit `join=` parameter.\n", + "When coordinates don't match, use the named methods (`.add()`, `.sub()`, `.mul()`, `.div()`, `.le()`, `.ge()`, `.eq()`) with an explicit `join=` parameter.\n", "\n", - "This convention is inspired by [pyoframe](https://github.com/Bravos-Power/pyoframe)." + "Inspired by [pyoframe](https://github.com/Bravos-Power/pyoframe)." ] }, { "cell_type": "code", "execution_count": null, + "id": "acae54e37e7d407bbb7b55eff062a284", "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.590547Z", - "start_time": "2026-02-20T08:34:40.580957Z" + "end_time": "2026-02-20T12:33:12.721284Z", + "start_time": "2026-02-20T12:33:11.943302Z" }, "execution": { - "iopub.execute_input": "2026-02-20T08:31:10.188745Z", - "iopub.status.busy": "2026-02-20T08:31:10.188638Z", - "iopub.status.idle": "2026-02-20T08:31:11.700268Z", - "shell.execute_reply": "2026-02-20T08:31:11.700023Z" + "iopub.execute_input": "2026-02-20T12:32:49.360029Z", + "iopub.status.busy": "2026-02-20T12:32:49.359903Z", + "iopub.status.idle": "2026-02-20T12:32:50.514585Z", + "shell.execute_reply": "2026-02-20T12:32:50.514115Z" } }, "outputs": [], @@ -47,26 +49,26 @@ }, { "cell_type": "markdown", + "id": "9a63283cbaf04dbcab1f6479b197f3a8", "metadata": {}, "source": [ - "## Matching Coordinates — The Default Case\n", - "\n", - "When two operands share the same coordinates on every shared dimension, all operators work directly. No special handling is needed." + "## What works by default" ] }, { "cell_type": "code", "execution_count": null, + "id": "8dd0d8092fe74a7c96281538738b07e2", "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.620850Z", - "start_time": "2026-02-20T08:34:40.599526Z" + "end_time": "2026-02-20T12:33:12.760105Z", + "start_time": "2026-02-20T12:33:12.724924Z" }, "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.701873Z", - "iopub.status.busy": "2026-02-20T08:31:11.701711Z", - "iopub.status.idle": "2026-02-20T08:31:11.760554Z", - "shell.execute_reply": "2026-02-20T08:31:11.760331Z" + "iopub.execute_input": "2026-02-20T12:32:50.516166Z", + "iopub.status.busy": "2026-02-20T12:32:50.515988Z", + "iopub.status.idle": "2026-02-20T12:32:50.558987Z", + "shell.execute_reply": "2026-02-20T12:32:50.558720Z" } }, "outputs": [], @@ -74,527 +76,238 @@ "m = linopy.Model()\n", "\n", "time = pd.RangeIndex(5, name=\"time\")\n", + "techs = pd.Index([\"solar\", \"wind\", \"gas\"], name=\"tech\")\n", + "\n", "x = m.add_variables(lower=0, coords=[time], name=\"x\")\n", "y = m.add_variables(lower=0, coords=[time], name=\"y\")\n", - "\n", - "# Same coordinates — works fine\n", - "x + y" + "gen = m.add_variables(lower=0, coords=[time, techs], name=\"gen\")" ] }, { "cell_type": "code", "execution_count": null, + "id": "72eea5119410473aa328ad9291626812", "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.633254Z", - "start_time": "2026-02-20T08:34:40.626281Z" + "end_time": "2026-02-20T12:33:12.772918Z", + "start_time": "2026-02-20T12:33:12.764952Z" }, "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.761623Z", - "iopub.status.busy": "2026-02-20T08:31:11.761542Z", - "iopub.status.idle": "2026-02-20T08:31:11.766540Z", - "shell.execute_reply": "2026-02-20T08:31:11.766356Z" + "iopub.execute_input": "2026-02-20T12:32:50.560447Z", + "iopub.status.busy": "2026-02-20T12:32:50.560319Z", + "iopub.status.idle": "2026-02-20T12:32:50.568613Z", + "shell.execute_reply": "2026-02-20T12:32:50.568245Z" } }, "outputs": [], "source": [ - "factor = xr.DataArray([2, 3, 4, 5, 6], dims=[\"time\"], coords={\"time\": time})\n", - "x * factor" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Broadcasting (Different Dimensions)\n", - "\n", - "Alignment only checks **shared** dimensions. If operands have different dimension names, they expand (broadcast) as in xarray — this is unaffected by the alignment convention.\n", - "\n", - "This works in both directions: a constant with extra dimensions expands the expression, and an expression with extra dimensions expands over the constant." + "# Addition/subtraction — matching coordinates\n", + "x + y" ] }, { "cell_type": "code", "execution_count": null, + "id": "8edb47106e1a46a883d545849b8ab81b", "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.650251Z", - "start_time": "2026-02-20T08:34:40.639851Z" + "end_time": "2026-02-20T12:33:12.783370Z", + "start_time": "2026-02-20T12:33:12.777304Z" }, "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.767547Z", - "iopub.status.busy": "2026-02-20T08:31:11.767487Z", - "iopub.status.idle": "2026-02-20T08:31:11.773127Z", - "shell.execute_reply": "2026-02-20T08:31:11.772954Z" + "iopub.execute_input": "2026-02-20T12:32:50.569890Z", + "iopub.status.busy": "2026-02-20T12:32:50.569796Z", + "iopub.status.idle": "2026-02-20T12:32:50.575044Z", + "shell.execute_reply": "2026-02-20T12:32:50.574842Z" } }, "outputs": [], "source": [ - "techs = pd.Index([\"solar\", \"wind\", \"gas\"], name=\"tech\")\n", - "cost = xr.DataArray([1.0, 0.5, 3.0], dims=[\"tech\"], coords={\"tech\": techs})\n", - "\n", - "# x has dim \"time\", cost has dim \"tech\" — no shared dim, pure broadcast\n", - "x * cost # -> (time, tech)" + "# Multiplication — matching coordinates\n", + "factor = xr.DataArray([2, 3, 4, 5, 6], dims=[\"time\"], coords={\"time\": time})\n", + "x * factor" ] }, { "cell_type": "code", "execution_count": null, + "id": "10185d26023b46108eb7d9f57d49d2b3", "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.667715Z", - "start_time": "2026-02-20T08:34:40.656983Z" + "end_time": "2026-02-20T12:33:12.795728Z", + "start_time": "2026-02-20T12:33:12.789249Z" }, "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.774071Z", - "iopub.status.busy": "2026-02-20T08:31:11.773994Z", - "iopub.status.idle": "2026-02-20T08:31:11.780472Z", - "shell.execute_reply": "2026-02-20T08:31:11.780265Z" + "iopub.execute_input": "2026-02-20T12:32:50.576125Z", + "iopub.status.busy": "2026-02-20T12:32:50.576061Z", + "iopub.status.idle": "2026-02-20T12:32:50.581269Z", + "shell.execute_reply": "2026-02-20T12:32:50.581041Z" } }, "outputs": [], "source": [ - "# Constant with MORE dimensions than the expression — also broadcasts\n", - "w = m.add_variables(lower=0, coords=[techs], name=\"w\") # dims: (tech,)\n", - "time_profile = xr.DataArray(\n", - " [[1, 2], [3, 4], [5, 6]],\n", - " dims=[\"tech\", \"time\"],\n", - " coords={\"tech\": techs, \"time\": [0, 1]},\n", - ")\n", - "\n", - "# w has dim \"tech\", time_profile has dims (\"tech\", \"time\")\n", - "# \"time\" is extra — it expands the expression via broadcasting\n", - "w + time_profile # -> (tech, time)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Addition / Subtraction: `\"exact\"` Default\n", - "\n", - "When operands have different coordinates on a shared dimension, `+` and `-` raise a `ValueError`. This prevents silent data loss or ambiguous fill behavior." + "# Multiplication — partial overlap gives intersection\n", + "partial = xr.DataArray([10, 20, 30], dims=[\"time\"], coords={\"time\": [0, 1, 2]})\n", + "x * partial # result: time 0, 1, 2 only" ] }, { "cell_type": "code", "execution_count": null, + "id": "8763a12b2bbd4a93a75aff182afb95dc", "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.683657Z", - "start_time": "2026-02-20T08:34:40.673488Z" + "end_time": "2026-02-20T12:33:12.805925Z", + "start_time": "2026-02-20T12:33:12.798771Z" }, "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.781625Z", - "iopub.status.busy": "2026-02-20T08:31:11.781557Z", - "iopub.status.idle": "2026-02-20T08:31:11.786621Z", - "shell.execute_reply": "2026-02-20T08:31:11.786442Z" + "iopub.execute_input": "2026-02-20T12:32:50.582245Z", + "iopub.status.busy": "2026-02-20T12:32:50.582185Z", + "iopub.status.idle": "2026-02-20T12:32:50.587327Z", + "shell.execute_reply": "2026-02-20T12:32:50.587163Z" } }, "outputs": [], "source": [ - "subset_time = pd.RangeIndex(3, name=\"time\")\n", - "y_short = m.add_variables(lower=0, coords=[subset_time], name=\"y_short\")\n", - "\n", - "# x has 5 time steps, y_short has 3 — coordinates don't match\n", - "try:\n", - " x + y_short\n", - "except ValueError as e:\n", - " print(\"ValueError:\", e)" + "# Multiplication — different dims broadcast (expands the expression)\n", + "cost = xr.DataArray([1.0, 0.5, 3.0], dims=[\"tech\"], coords={\"tech\": techs})\n", + "x * cost # result: (time, tech)" ] }, { "cell_type": "code", "execution_count": null, + "id": "7623eae2785240b9bd12b16a66d81610", "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.698177Z", - "start_time": "2026-02-20T08:34:40.691406Z" + "end_time": "2026-02-20T12:33:12.823606Z", + "start_time": "2026-02-20T12:33:12.811410Z" }, "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.787505Z", - "iopub.status.busy": "2026-02-20T08:31:11.787448Z", - "iopub.status.idle": "2026-02-20T08:31:11.790477Z", - "shell.execute_reply": "2026-02-20T08:31:11.790298Z" + "iopub.execute_input": "2026-02-20T12:32:50.588398Z", + "iopub.status.busy": "2026-02-20T12:32:50.588330Z", + "iopub.status.idle": "2026-02-20T12:32:50.598610Z", + "shell.execute_reply": "2026-02-20T12:32:50.598402Z" } }, "outputs": [], "source": [ - "# Same for adding a constant DataArray with mismatched coordinates\n", - "partial_const = xr.DataArray([10, 20, 30], dims=[\"time\"], coords={\"time\": [0, 1, 2]})\n", - "\n", - "try:\n", - " x + partial_const\n", - "except ValueError as e:\n", - " print(\"ValueError:\", e)" + "# Constraints — RHS with fewer dims broadcasts naturally\n", + "capacity = xr.DataArray([100, 80, 50], dims=[\"tech\"], coords={\"tech\": techs})\n", + "m.add_constraints(gen <= capacity, name=\"cap\") # capacity broadcasts over time" ] }, { "cell_type": "markdown", + "id": "7cdc8c89c7104fffa095e18ddfef8986", "metadata": {}, "source": [ - "## Multiplication / Division: `\"inner\"` Default\n", - "\n", - "Multiplication by a parameter array is a natural filtering operation — like applying an availability factor to a subset of time steps. The result is restricted to the **intersection** of coordinates. No fill values are needed." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.722403Z", - "start_time": "2026-02-20T08:34:40.704702Z" - }, - "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.791396Z", - "iopub.status.busy": "2026-02-20T08:31:11.791334Z", - "iopub.status.idle": "2026-02-20T08:31:11.796458Z", - "shell.execute_reply": "2026-02-20T08:31:11.796262Z" - } - }, - "outputs": [], - "source": [ - "partial_factor = xr.DataArray([2, 3, 4], dims=[\"time\"], coords={\"time\": [0, 1, 2]})\n", - "\n", - "# x has time 0-4, partial_factor has time 0-2\n", - "# Inner join: result restricted to time 0, 1, 2\n", - "x * partial_factor" + "## What raises an error" ] }, { "cell_type": "code", "execution_count": null, + "id": "b118ea5561624da68c537baed56e602f", "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.750336Z", - "start_time": "2026-02-20T08:34:40.739583Z" + "end_time": "2026-02-20T12:33:12.840568Z", + "start_time": "2026-02-20T12:33:12.834424Z" }, "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.797412Z", - "iopub.status.busy": "2026-02-20T08:31:11.797355Z", - "iopub.status.idle": "2026-02-20T08:31:11.803105Z", - "shell.execute_reply": "2026-02-20T08:31:11.802861Z" + "iopub.execute_input": "2026-02-20T12:32:50.599634Z", + "iopub.status.busy": "2026-02-20T12:32:50.599571Z", + "iopub.status.idle": "2026-02-20T12:32:50.605548Z", + "shell.execute_reply": "2026-02-20T12:32:50.605322Z" } }, "outputs": [], "source": [ - "# Disjoint coordinates: no intersection -> empty result\n", - "z = m.add_variables(lower=0, coords=[pd.RangeIndex(5, 10, name=\"time\")], name=\"z\")\n", - "disjoint_factor = xr.DataArray(\n", - " [1, 2, 3, 4, 5], dims=[\"time\"], coords={\"time\": range(5)}\n", + "# Addition with mismatched coordinates\n", + "y_short = m.add_variables(\n", + " lower=0, coords=[pd.RangeIndex(3, name=\"time\")], name=\"y_short\"\n", ")\n", "\n", - "z * disjoint_factor" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Named Methods with `join=`\n", - "\n", - "When you intentionally want to combine operands with mismatched coordinates, use the named methods with an explicit `join=` parameter. This makes the alignment intent clear in the code." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Setup: Overlapping but Non-Identical Coordinates" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.770327Z", - "start_time": "2026-02-20T08:34:40.762873Z" - }, - "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.804161Z", - "iopub.status.busy": "2026-02-20T08:31:11.804100Z", - "iopub.status.idle": "2026-02-20T08:31:11.807917Z", - "shell.execute_reply": "2026-02-20T08:31:11.807731Z" - } - }, - "outputs": [], - "source": [ - "m2 = linopy.Model()\n", - "\n", - "i_a = pd.Index([0, 1, 2], name=\"i\")\n", - "i_b = pd.Index([1, 2, 3], name=\"i\")\n", - "\n", - "a = m2.add_variables(coords=[i_a], name=\"a\")\n", - "b = m2.add_variables(coords=[i_b], name=\"b\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "`a` has coordinates i=[0, 1, 2] and `b` has i=[1, 2, 3]. They overlap at i=1 and i=2 but are not identical, so `a + b` raises a `ValueError`.\n", - "\n", - "**Inner join** — only shared coordinates (i=1, 2):" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.785505Z", - "start_time": "2026-02-20T08:34:40.775987Z" - }, - "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.808856Z", - "iopub.status.busy": "2026-02-20T08:31:11.808774Z", - "iopub.status.idle": "2026-02-20T08:31:11.815876Z", - "shell.execute_reply": "2026-02-20T08:31:11.815678Z" - } - }, - "outputs": [], - "source": [ - "a.add(b, join=\"inner\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Outer join** — union of coordinates (i=0, 1, 2, 3). Where one operand is missing, it drops out of the sum (fill with zero):" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.811388Z", - "start_time": "2026-02-20T08:34:40.797806Z" - }, - "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.816893Z", - "iopub.status.busy": "2026-02-20T08:31:11.816817Z", - "iopub.status.idle": "2026-02-20T08:31:11.824433Z", - "shell.execute_reply": "2026-02-20T08:31:11.824155Z" - } - }, - "outputs": [], - "source": [ - "a.add(b, join=\"outer\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Left join** — keep left operand's coordinates (i=0, 1, 2):" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.834416Z", - "start_time": "2026-02-20T08:34:40.823515Z" - }, - "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.825622Z", - "iopub.status.busy": "2026-02-20T08:31:11.825544Z", - "iopub.status.idle": "2026-02-20T08:31:11.832608Z", - "shell.execute_reply": "2026-02-20T08:31:11.832423Z" - } - }, - "outputs": [], - "source": [ - "a.add(b, join=\"left\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Right join** — keep right operand's coordinates (i=1, 2, 3):" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.854542Z", - "start_time": "2026-02-20T08:34:40.841131Z" - }, - "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.833545Z", - "iopub.status.busy": "2026-02-20T08:31:11.833490Z", - "iopub.status.idle": "2026-02-20T08:31:11.840073Z", - "shell.execute_reply": "2026-02-20T08:31:11.839884Z" - } - }, - "outputs": [], - "source": [ - "a.add(b, join=\"right\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Override** — positional alignment, ignore coordinate labels. The result uses the left operand's coordinates:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.872890Z", - "start_time": "2026-02-20T08:34:40.862894Z" - }, - "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.841049Z", - "iopub.status.busy": "2026-02-20T08:31:11.840991Z", - "iopub.status.idle": "2026-02-20T08:31:11.847135Z", - "shell.execute_reply": "2026-02-20T08:31:11.846968Z" - } - }, - "outputs": [], - "source": [ - "a.add(b, join=\"override\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Multiplication with `join=`\n", - "\n", - "The same `join=` parameter works on `.mul()` and `.div()`. Since multiplication defaults to `\"inner\"`, you only need explicit `join=` when you want a different mode:" + "try:\n", + " x + y_short # time coords don't match\n", + "except ValueError as e:\n", + " print(\"ValueError:\", e)" ] }, { "cell_type": "code", "execution_count": null, + "id": "938c804e27f84196a10c8828c723f798", "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.899679Z", - "start_time": "2026-02-20T08:34:40.889148Z" + "end_time": "2026-02-20T12:33:12.847760Z", + "start_time": "2026-02-20T12:33:12.843647Z" }, "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.848157Z", - "iopub.status.busy": "2026-02-20T08:31:11.848101Z", - "iopub.status.idle": "2026-02-20T08:31:11.852887Z", - "shell.execute_reply": "2026-02-20T08:31:11.852713Z" + "iopub.execute_input": "2026-02-20T12:32:50.606568Z", + "iopub.status.busy": "2026-02-20T12:32:50.606506Z", + "iopub.status.idle": "2026-02-20T12:32:50.609742Z", + "shell.execute_reply": "2026-02-20T12:32:50.609552Z" } }, "outputs": [], "source": [ - "const = xr.DataArray([2, 3, 4], dims=[\"i\"], coords={\"i\": [1, 2, 3]})\n", - "\n", - "# Default inner join — intersection of i=[0,1,2] and i=[1,2,3]\n", - "a * const" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T07:55:08.411255Z", - "start_time": "2026-02-20T07:55:08.404219Z" - }, - "execution": { - "iopub.execute_input": "2026-02-20T08:23:53.301013Z", - "iopub.status.busy": "2026-02-20T08:23:53.300958Z", - "iopub.status.idle": "2026-02-20T08:23:53.305201Z", - "shell.execute_reply": "2026-02-20T08:23:53.305026Z" - } - }, - "source": [ - "## Constraints with DataArray RHS\n", - "\n", - "Constraint operators (`<=`, `>=`, `==`) with a DataArray right-hand side also default to `\"exact\"` — coordinates on shared dimensions must match. Use `.le()`, `.ge()`, `.eq()` with `join=` to control alignment.\n", - "\n", - "**Dimension rules for constraint RHS:**\n", - "- The RHS may have **fewer** dimensions than the expression — the bound broadcasts. This is the standard way to apply a per-tech capacity across all time steps.\n", - "- The RHS must **not** have **more** dimensions than the expression. An expression with `dims=(tech,)` defines one variable per tech; an RHS with `dims=(time, tech)` would create redundant constraints on the same variable, which is almost always a mistake.\n", - "\n", - "Note: this is different from arithmetic, where a constant with extra dims freely expands the expression. For constraints, the expression defines the problem structure." + "# Addition with extra dimensions on the constant\n", + "profile = xr.DataArray(\n", + " np.ones((3, 5)), dims=[\"tech\", \"time\"], coords={\"tech\": techs, \"time\": time}\n", + ")\n", + "try:\n", + " x + profile # would duplicate x[t] across techs\n", + "except ValueError as e:\n", + " print(\"ValueError:\", e)" ] }, { "cell_type": "code", "execution_count": null, + "id": "504fb2a444614c0babb325280ed9130a", "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.925963Z", - "start_time": "2026-02-20T08:34:40.907028Z" + "end_time": "2026-02-20T12:33:12.855699Z", + "start_time": "2026-02-20T12:33:12.851877Z" }, "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.853850Z", - "iopub.status.busy": "2026-02-20T08:31:11.853793Z", - "iopub.status.idle": "2026-02-20T08:31:11.866027Z", - "shell.execute_reply": "2026-02-20T08:31:11.865850Z" + "iopub.execute_input": "2026-02-20T12:32:50.610738Z", + "iopub.status.busy": "2026-02-20T12:32:50.610665Z", + "iopub.status.idle": "2026-02-20T12:32:50.614493Z", + "shell.execute_reply": "2026-02-20T12:32:50.614335Z" } }, "outputs": [], "source": [ - "# RHS with fewer dimensions — broadcasts (works fine)\n", - "m3 = linopy.Model()\n", - "hours = pd.RangeIndex(24, name=\"hour\")\n", - "techs = pd.Index([\"solar\", \"wind\", \"gas\"], name=\"tech\")\n", - "gen = m3.add_variables(lower=0, coords=[hours, techs], name=\"gen\")\n", + "# Multiplication with zero overlap\n", + "z = m.add_variables(lower=0, coords=[pd.RangeIndex(5, 10, name=\"time\")], name=\"z\")\n", "\n", - "capacity = xr.DataArray([100, 80, 50], dims=[\"tech\"], coords={\"tech\": techs})\n", - "m3.add_constraints(\n", - " gen <= capacity, name=\"capacity_limit\"\n", - ") # capacity broadcasts over hour" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.949905Z", - "start_time": "2026-02-20T08:34:40.939482Z" - }, - "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.866958Z", - "iopub.status.busy": "2026-02-20T08:31:11.866899Z", - "iopub.status.idle": "2026-02-20T08:31:11.871115Z", - "shell.execute_reply": "2026-02-20T08:31:11.870937Z" - } - }, - "outputs": [], - "source": [ - "# RHS with matching coordinates — works fine\n", - "full_rhs = xr.DataArray(np.arange(5, dtype=float), dims=[\"time\"], coords={\"time\": time})\n", - "con = x <= full_rhs\n", - "con" + "try:\n", + " z * factor # z has time 5-9, factor has time 0-4 — no intersection\n", + "except ValueError as e:\n", + " print(\"ValueError:\", e)" ] }, { "cell_type": "code", "execution_count": null, + "id": "59bbdb311c014d738909a11f9e486628", "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T08:34:40.970277Z", - "start_time": "2026-02-20T08:34:40.961415Z" + "end_time": "2026-02-20T12:33:12.864666Z", + "start_time": "2026-02-20T12:33:12.860577Z" }, "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.872062Z", - "iopub.status.busy": "2026-02-20T08:31:11.872004Z", - "iopub.status.idle": "2026-02-20T08:31:11.874934Z", - "shell.execute_reply": "2026-02-20T08:31:11.874765Z" + "iopub.execute_input": "2026-02-20T12:32:50.615336Z", + "iopub.status.busy": "2026-02-20T12:32:50.615276Z", + "iopub.status.idle": "2026-02-20T12:32:50.618275Z", + "shell.execute_reply": "2026-02-20T12:32:50.618094Z" } }, "outputs": [], "source": [ - "# RHS with mismatched coordinates — raises ValueError\n", + "# Constraint RHS with mismatched coordinates\n", "partial_rhs = xr.DataArray([10, 20, 30], dims=[\"time\"], coords={\"time\": [0, 1, 2]})\n", "\n", "try:\n", @@ -606,351 +319,159 @@ { "cell_type": "code", "execution_count": null, + "id": "b43b363d81ae4b689946ece5c682cd59", "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T08:34:41.006270Z", - "start_time": "2026-02-20T08:34:40.997162Z" - }, - "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.875820Z", - "iopub.status.busy": "2026-02-20T08:31:11.875763Z", - "iopub.status.idle": "2026-02-20T08:31:11.879949Z", - "shell.execute_reply": "2026-02-20T08:31:11.879781Z" - } - }, - "outputs": [], - "source": [ - "# Use .le() with join=\"inner\" — constraint only at the intersection\n", - "x.to_linexpr().le(partial_rhs, join=\"inner\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T08:34:41.036419Z", - "start_time": "2026-02-20T08:34:41.022644Z" - }, - "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.880853Z", - "iopub.status.busy": "2026-02-20T08:31:11.880793Z", - "iopub.status.idle": "2026-02-20T08:31:11.884663Z", - "shell.execute_reply": "2026-02-20T08:31:11.884503Z" - } - }, - "outputs": [], - "source": [ - "# Use .le() with join=\"left\" — constraint at all of x's coordinates,\n", - "# NaN where RHS is missing (no constraint at those positions)\n", - "x.to_linexpr().le(partial_rhs, join=\"left\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T08:34:41.053285Z", - "start_time": "2026-02-20T08:34:41.043483Z" + "end_time": "2026-02-20T12:33:12.874035Z", + "start_time": "2026-02-20T12:33:12.869466Z" }, "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.885511Z", - "iopub.status.busy": "2026-02-20T08:31:11.885453Z", - "iopub.status.idle": "2026-02-20T08:31:11.889326Z", - "shell.execute_reply": "2026-02-20T08:31:11.889148Z" + "iopub.execute_input": "2026-02-20T12:32:50.619169Z", + "iopub.status.busy": "2026-02-20T12:32:50.619111Z", + "iopub.status.idle": "2026-02-20T12:32:50.622842Z", + "shell.execute_reply": "2026-02-20T12:32:50.622654Z" } }, "outputs": [], "source": [ - "# RHS with MORE dimensions than expression — raises ValueError\n", - "y_tech = m.add_variables(lower=0, coords=[techs], name=\"y_tech\") # dims: (tech,)\n", - "rhs_extra_dims = xr.DataArray(\n", + "# Constraint RHS with extra dimensions\n", + "w = m.add_variables(lower=0, coords=[techs], name=\"w\") # dims: (tech,)\n", + "rhs_2d = xr.DataArray(\n", " np.ones((5, 3)), dims=[\"time\", \"tech\"], coords={\"time\": time, \"tech\": techs}\n", ")\n", - "\n", "try:\n", - " y_tech <= rhs_extra_dims # \"time\" is not in the expression\n", + " w <= rhs_2d # would create redundant constraints on w[tech]\n", "except ValueError as e:\n", " print(\"ValueError:\", e)" ] }, { "cell_type": "markdown", + "id": "8a65eabff63a45729fe45fb5ade58bdc", "metadata": {}, "source": [ - "## Practical Example\n", + "## Positional alignment with `join=\"override\"`\n", "\n", - "Consider a generation dispatch model where solar availability is a partial factor and a minimum demand constraint only applies during peak hours." + "A common pattern: two arrays with the same shape but different (or no) coordinate labels. Use `join=\"override\"` to align by position, ignoring labels." ] }, { "cell_type": "code", "execution_count": null, + "id": "c3933fab20d04ec698c2621248eb3be0", "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T08:34:41.067545Z", - "start_time": "2026-02-20T08:34:41.062532Z" + "end_time": "2026-02-20T12:33:12.887673Z", + "start_time": "2026-02-20T12:33:12.877435Z" }, "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.890266Z", - "iopub.status.busy": "2026-02-20T08:31:11.890211Z", - "iopub.status.idle": "2026-02-20T08:31:11.893636Z", - "shell.execute_reply": "2026-02-20T08:31:11.893458Z" + "iopub.execute_input": "2026-02-20T12:32:50.623830Z", + "iopub.status.busy": "2026-02-20T12:32:50.623775Z", + "iopub.status.idle": "2026-02-20T12:32:50.632476Z", + "shell.execute_reply": "2026-02-20T12:32:50.632321Z" } }, "outputs": [], "source": [ - "m4 = linopy.Model()\n", + "m2 = linopy.Model()\n", "\n", - "hours = pd.RangeIndex(24, name=\"hour\")\n", - "techs = pd.Index([\"solar\", \"wind\", \"gas\"], name=\"tech\")\n", + "a = m2.add_variables(coords=[[\"x\", \"y\", \"z\"]], name=\"a\")\n", + "b = m2.add_variables(coords=[[\"p\", \"q\", \"r\"]], name=\"b\")\n", "\n", - "gen = m4.add_variables(lower=0, coords=[hours, techs], name=\"gen\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Capacity limits apply to all hours and techs. The `capacity` DataArray has only the `tech` dimension — it broadcasts over `hour` (no shared dimension to conflict):" + "# a + b fails because labels don't match\n", + "# join=\"override\" aligns by position and keeps left operand's labels\n", + "a.add(b, join=\"override\")" ] }, { "cell_type": "code", "execution_count": null, + "id": "4dd4641cc4064e0191573fe9c69df29b", "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T08:34:41.084071Z", - "start_time": "2026-02-20T08:34:41.071733Z" + "end_time": "2026-02-20T12:33:12.905207Z", + "start_time": "2026-02-20T12:33:12.899976Z" }, "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.894513Z", - "iopub.status.busy": "2026-02-20T08:31:11.894459Z", - "iopub.status.idle": "2026-02-20T08:31:11.904179Z", - "shell.execute_reply": "2026-02-20T08:31:11.904001Z" + "iopub.execute_input": "2026-02-20T12:32:50.633382Z", + "iopub.status.busy": "2026-02-20T12:32:50.633328Z", + "iopub.status.idle": "2026-02-20T12:32:50.637298Z", + "shell.execute_reply": "2026-02-20T12:32:50.637123Z" } }, "outputs": [], "source": [ - "capacity = xr.DataArray([100, 80, 50], dims=[\"tech\"], coords={\"tech\": techs})\n", - "m4.add_constraints(gen <= capacity, name=\"capacity_limit\")" + "# Same for constraints\n", + "rhs = xr.DataArray([1.0, 2.0, 3.0], dims=[\"dim_0\"], coords={\"dim_0\": [\"p\", \"q\", \"r\"]})\n", + "a.to_linexpr().le(rhs, join=\"override\")" ] }, { "cell_type": "markdown", + "id": "8309879909854d7188b41380fd92a7c3", "metadata": {}, "source": [ - "Solar availability is a factor that covers all 24 hours. Since coordinates match exactly, multiplication with `*` works directly:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T08:34:41.104144Z", - "start_time": "2026-02-20T08:34:41.091479Z" - }, - "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.905131Z", - "iopub.status.busy": "2026-02-20T08:31:11.905072Z", - "iopub.status.idle": "2026-02-20T08:31:11.914430Z", - "shell.execute_reply": "2026-02-20T08:31:11.914242Z" - } - }, - "outputs": [], - "source": [ - "solar_avail = np.zeros(24)\n", - "solar_avail[6:19] = np.sin(np.linspace(0, np.pi, 13))\n", - "solar_availability = xr.DataArray(solar_avail, dims=[\"hour\"], coords={\"hour\": hours})\n", + "## Other join modes\n", + "\n", + "All named methods (`.add()`, `.sub()`, `.mul()`, `.div()`, `.le()`, `.ge()`, `.eq()`) accept a `join=` parameter:\n", "\n", - "solar_gen = gen.sel(tech=\"solar\")\n", - "m4.add_constraints(solar_gen <= 100 * solar_availability, name=\"solar_avail\")" + "| `join` | Coordinates kept | Fill |\n", + "|--------|-----------------|------|\n", + "| `\"exact\"` | Must match | `ValueError` if different |\n", + "| `\"inner\"` | Intersection | — |\n", + "| `\"outer\"` | Union | Zero (arithmetic) / NaN (constraints) |\n", + "| `\"left\"` | Left operand's | Zero / NaN for missing right |\n", + "| `\"right\"` | Right operand's | Zero for missing left |\n", + "| `\"override\"` | Left operand's | Positional alignment |" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": "Peak demand of 120 MW must be met only during hours 8-20. The demand array covers a subset of hours. Use `.ge()` with `join=\"inner\"` to restrict the constraint to just those hours:" - }, { "cell_type": "code", "execution_count": null, + "id": "3ed186c9a28b402fb0bc4494df01f08d", "metadata": { "ExecuteTime": { - "end_time": "2026-02-20T08:34:41.122157Z", - "start_time": "2026-02-20T08:34:41.107939Z" + "end_time": "2026-02-20T12:33:12.934067Z", + "start_time": "2026-02-20T12:33:12.909515Z" }, "execution": { - "iopub.execute_input": "2026-02-20T08:31:11.915441Z", - "iopub.status.busy": "2026-02-20T08:31:11.915362Z", - "iopub.status.idle": "2026-02-20T08:31:11.925960Z", - "shell.execute_reply": "2026-02-20T08:31:11.925775Z" - } - }, - "outputs": [], - "source": [ - "peak_hours = pd.RangeIndex(8, 21, name=\"hour\")\n", - "peak_demand = xr.DataArray(\n", - " np.full(len(peak_hours), 120.0), dims=[\"hour\"], coords={\"hour\": peak_hours}\n", - ")\n", - "\n", - "total_gen = gen.sum(\"tech\")\n", - "\n", - "# Constraint only at peak hours (intersection)\n", - "m4.add_constraints(total_gen.ge(peak_demand, join=\"inner\"), name=\"peak_demand\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": "Selecting the correct subset of the variable produces the same result, and is arguably more readable:" - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T08:37:39.113259Z", - "start_time": "2026-02-20T08:37:39.065983Z" + "iopub.execute_input": "2026-02-20T12:32:50.638240Z", + "iopub.status.busy": "2026-02-20T12:32:50.638182Z", + "iopub.status.idle": "2026-02-20T12:32:50.659703Z", + "shell.execute_reply": "2026-02-20T12:32:50.659485Z" } }, "outputs": [], "source": [ - "# Constraint only at peak hours (intersection)\n", - "m4.add_constraints(\n", - " total_gen.sel(hour=peak_hours) >= peak_demand, name=\"peak_demand_sel\"\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Migrating from Previous Versions\n", - "\n", - "Previous versions of linopy used a **shape-dependent heuristic** for coordinate alignment. The behavior depended on whether operands happened to have the same shape, and was inconsistent between `Variable` and `LinearExpression`:\n", - "\n", - "| Condition | Old behavior | New behavior |\n", - "|-----------|-------------|-------------|\n", - "| Same shape, same coordinates | Works correctly | Works correctly (no change) |\n", - "| Same shape, **different** coordinates, `+`/`-` | `\"override\"` — positional alignment (**bug-prone**) | `\"exact\"` — raises `ValueError` |\n", - "| Same shape, **different** coordinates, `*`/`/` | Buggy (crashes or produces garbage) | `\"inner\"` — intersection |\n", - "| Different shape, expr + expr | `\"outer\"` — union of coordinates | `\"exact\"` — raises `ValueError` |\n", - "| Different shape, expr + constant | `\"left\"` — keeps expression coords, fills missing with 0 | `\"exact\"` — raises `ValueError` |\n", - "| Different shape, expr * constant | Buggy (crashes for `LinearExpression`, produces garbage for `Variable`) | `\"inner\"` — intersection |\n", - "| Constraint with mismatched DataArray RHS | Same-shape: `\"override\"` (positional); different-shape: `\"left\"` (fills missing RHS with 0) | `\"exact\"` — raises `ValueError` |\n", - "\n", - "### Why the change?\n", - "\n", - "The old heuristic caused several classes of bugs:\n", - "\n", - "1. **Silent positional alignment**: When two operands happened to have the same shape but entirely different coordinates (e.g., `x(time=[0,1,2]) + z(time=[5,6,7])`), they were matched by position — giving a wrong result with no warning.\n", - "\n", - "2. **Non-associative addition**: `(y + factor) + x` could give a different result than `y + (x + factor)` because `\"left\"` for expr+constant dropped the constant's extra coordinates before they could be recovered by a subsequent addition.\n", - "\n", - "3. **Broken multiplication**: Multiplying a `LinearExpression` by a DataArray with mismatched coordinates would crash with an `AssertionError`. Multiplying a `Variable` by such a DataArray produced a result with misaligned coefficients and variable references.\n", - "\n", - "### How to update your code\n", - "\n", - "If your code combines operands with **mismatched coordinates** and you relied on the old behavior, you'll now get a `ValueError` (for `+`/`-`) or a smaller result (for `*`/`/`). Here's how to migrate:" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Addition with mismatched coordinates** — expr+expr previously used `\"outer\"`, expr+constant used `\"left\"`. Both now raise `ValueError`:\n", - "\n", - "```python\n", - "# Old code (worked silently):\n", - "result = x + y_short # different-size expr+expr → was \"outer\"\n", - "result = x + partial_const # expr + constant → was \"left\"\n", - "\n", - "# New code — be explicit about the join:\n", - "result = x.add(y_short, join=\"outer\") # union of coordinates\n", - "result = x.add(partial_const, join=\"left\") # keep x's coordinates, fill 0\n", - "```\n", - "\n", - "**Same-shape but different coordinates** — previously matched by position (`\"override\"`) for addition. Now raises `ValueError` for `+`/`-`, gives intersection for `*`/`/`:\n", - "\n", - "```python\n", - "# Old code (silently matched positions — likely a bug!):\n", - "x_abc = m.add_variables(coords=[[\"a\", \"b\", \"c\"]], name=\"x_abc\")\n", - "y_def = m.add_variables(coords=[[\"d\", \"e\", \"f\"]], name=\"y_def\")\n", - "result = x_abc + y_def # Old: positional match → New: ValueError\n", - "\n", - "# If you really want positional matching (rare):\n", - "result = x_abc.add(y_def, join=\"override\")\n", - "```\n", - "\n", - "**Multiplication with mismatched coordinates** — previously broken (crash or garbage). Now uses `\"inner\"` (intersection):\n", - "\n", - "```python\n", - "# Old code — would crash (LinExpr) or produce garbage (Variable):\n", - "x * partial_factor # x has 5 coords, partial_factor has 3\n", - "\n", - "# New code — result has 3 entries (intersection). This now works correctly!\n", - "# If you need to keep all of x's coordinates (zero-fill missing):\n", - "x.mul(partial_factor, join=\"left\")\n", - "```\n", - "\n", - "**Constraints with mismatched DataArray RHS** — previously used positional alignment (same shape) or `\"left\"` with 0-fill (different shape). Now raises `ValueError`:\n", + "i_a = pd.Index([0, 1, 2], name=\"i\")\n", + "i_b = pd.Index([1, 2, 3], name=\"i\")\n", "\n", - "```python\n", - "# Old code:\n", - "con = x <= partial_rhs # Old: \"left\" (fill 0) or \"override\" → New: ValueError\n", + "a = m2.add_variables(coords=[i_a], name=\"a2\")\n", + "b = m2.add_variables(coords=[i_b], name=\"b2\")\n", "\n", - "# New code — be explicit:\n", - "con = x.to_linexpr().le(partial_rhs, join=\"left\") # keep x's coords, NaN fill\n", - "con = x.to_linexpr().le(partial_rhs, join=\"inner\") # intersection only\n", - "```" + "print(\"inner:\", list(a.add(b, join=\"inner\").coords[\"i\"].values)) # [1, 2]\n", + "print(\"outer:\", list(a.add(b, join=\"outer\").coords[\"i\"].values)) # [0, 1, 2, 3]\n", + "print(\"left: \", list(a.add(b, join=\"left\").coords[\"i\"].values)) # [0, 1, 2]\n", + "print(\"right:\", list(a.add(b, join=\"right\").coords[\"i\"].values)) # [1, 2, 3]" ] }, { "cell_type": "markdown", + "id": "cb1e1581032b452c9409d6c6813c49d1", "metadata": {}, "source": [ - "## Summary\n", - "\n", - "### Default Behavior\n", - "\n", - "| Context | Default `join` | Behavior |\n", - "|---------|---------------|----------|\n", - "| Arithmetic operators (`+`, `-`) | `\"exact\"` | Coordinates must match on shared dims; raises `ValueError` on mismatch |\n", - "| Arithmetic operators (`*`, `/`) | `\"inner\"` | Intersection of coordinates on shared dims; no fill needed |\n", - "| Constraint operators (`<=`, `>=`, `==`) with DataArray RHS | `\"exact\"` | Coordinates must match on shared dims; raises `ValueError` on mismatch |\n", - "\n", - "### Extra Dimensions (Broadcasting)\n", - "\n", - "| Context | Extra dims on constant/RHS | Extra dims on expression |\n", - "|---------|--------------------------|------------------------|\n", - "| Arithmetic (`+`, `-`, `*`, `/`) | Expands the expression (standard xarray broadcast) | Expands over the constant |\n", - "| Constraint RHS (`<=`, `>=`, `==`) | **Forbidden** — raises `ValueError` | RHS broadcasts over expression's extra dims |\n", - "\n", - "### All Join Modes\n", + "## Migrating from previous versions\n", "\n", - "| `join` | Coordinates | Fill behavior |\n", - "|--------|------------|---------------|\n", - "| `\"exact\"` (default for `+`, `-`, constraints) | Must match exactly | Raises `ValueError` if different |\n", - "| `\"inner\"` (default for `*`, `/`) | Intersection only | No fill needed |\n", - "| `\"outer\"` | Union | Fill with zero (arithmetic) or `NaN` (constraint RHS) |\n", - "| `\"left\"` | Left operand's | Fill right with zero (arithmetic) or `NaN` (constraint RHS) |\n", - "| `\"right\"` | Right operand's | Fill left with zero |\n", - "| `\"override\"` | Left operand's (positional) | Positional alignment, ignores coordinate labels |\n", + "Previous versions used a shape-dependent heuristic that caused silent bugs (positional alignment on same-shape operands, non-associative addition, broken multiplication). The new behavior:\n", "\n", - "### Quick Reference\n", + "| Condition | Old | New |\n", + "|-----------|-----|-----|\n", + "| Same shape, different coords, `+`/`-` | Positional match (silent bug) | `ValueError` |\n", + "| Different shape, `+`/`-` | `\"outer\"` or `\"left\"` (implicit) | `ValueError` |\n", + "| Mismatched coords, `*`/`/` | Crash or garbage | Intersection (or error if empty) |\n", + "| Constraint with mismatched RHS | `\"override\"` or `\"left\"` | `ValueError` |\n", "\n", - "| Operation | Matching coords | Mismatched coords |\n", - "|-----------|----------------|-------------------|\n", - "| `x + y` | Works | `ValueError` |\n", - "| `x * factor` | Works | Intersection |\n", - "| `x.add(y, join=\"inner\")` | Works | Intersection |\n", - "| `x.add(y, join=\"outer\")` | Works | Union with fill |\n", - "| `x <= rhs` (DataArray) | Works | `ValueError` |\n", - "| `x.le(rhs, join=\"inner\")` | Works | Intersection |\n", - "| `x.le(rhs, join=\"left\")` | Works | Left coords, NaN fill |" + "To migrate: replace `x + y` with `x.add(y, join=\"outer\")` (or whichever join matches your intent)." ] } ], @@ -974,5 +495,5 @@ } }, "nbformat": 4, - "nbformat_minor": 4 + "nbformat_minor": 5 } diff --git a/linopy/expressions.py b/linopy/expressions.py index ad455a03..3a150d0d 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -588,6 +588,14 @@ def _add_constant( if np.isscalar(other) and join is None: return self.assign(const=self.const + other) da = as_dataarray(other, coords=self.coords, dims=self.coord_dims) + extra_dims = set(da.dims) - set(self.coord_dims) + if extra_dims: + raise ValueError( + f"Constant has dimensions {extra_dims} not present in the " + f"expression. Addition/subtraction cannot introduce new " + f"dimensions — use multiplication to expand, or select/reindex " + f"the constant to match the expression's dimensions." + ) self_const, da, needs_data_reindex = self._align_constant( da, fill_value=0, join=join, default_join="exact" ) @@ -612,12 +620,9 @@ def _apply_constant_op( factor, fill_value=fill_value, join=join, default_join="inner" ) if self_const.size == 0 and self.const.size > 0: - warn( + raise ValueError( "Multiplication/division resulted in an empty expression because " - "the operands have no overlapping coordinates (inner join). " - "This is likely a modeling error.", - UserWarning, - stacklevel=3, + "the operands have no overlapping coordinates (inner join)." ) if needs_data_reindex: data = self.data.reindex_like(self_const, fill_value=self._fill_value) diff --git a/test/test_linear_expression.py b/test/test_linear_expression.py index d77bd00f..ea81bb2b 100644 --- a/test/test_linear_expression.py +++ b/test/test_linear_expression.py @@ -681,9 +681,9 @@ def test_disjoint_mul(self, v: Variable) -> None: disjoint = xr.DataArray( [10.0, 20.0], dims=["dim_2"], coords={"dim_2": [50, 60]} ) - # inner join: no intersection → empty - result = v * disjoint - assert result.sizes["dim_2"] == 0 + # inner join: no intersection → error + with pytest.raises(ValueError, match="no overlapping coordinates"): + v * disjoint # explicit join="left": 20 entries, all zeros result = v.mul(disjoint, join="left") @@ -694,8 +694,8 @@ def test_disjoint_div(self, v: Variable) -> None: disjoint = xr.DataArray( [10.0, 20.0], dims=["dim_2"], coords={"dim_2": [50, 60]} ) - result = v / disjoint - assert result.sizes["dim_2"] == 0 + with pytest.raises(ValueError, match="no overlapping coordinates"): + v / disjoint # --- Multiplication / division with subset constant --- @@ -900,6 +900,18 @@ def test_constraint_rhs_extra_dims_raises(self, v: Variable) -> None: with pytest.raises(ValueError, match="not present in the expression"): v <= rhs + def test_add_constant_extra_dims_raises(self, v: Variable) -> None: + da = xr.DataArray( + [[1.0, 2.0]], dims=["extra", "dim_2"], coords={"dim_2": [0, 1]} + ) + with pytest.raises(ValueError, match="not present in the expression"): + v + da + with pytest.raises(ValueError, match="not present in the expression"): + v - da + # multiplication still allows extra dims (broadcasts) + result = v * da + assert "extra" in result.dims + def test_da_truediv_var_raises(self, v: Variable) -> None: da = xr.DataArray(np.ones(20), dims=["dim_2"], coords={"dim_2": range(20)}) with pytest.raises(TypeError): diff --git a/test/test_typing.py b/test/test_typing.py index 99a27033..312f76c9 100644 --- a/test/test_typing.py +++ b/test/test_typing.py @@ -7,6 +7,7 @@ def test_operations_with_data_arrays_are_typed_correctly() -> None: m = linopy.Model() a: xr.DataArray = xr.DataArray([1, 2, 3]) + s: xr.DataArray = xr.DataArray(5.0) v: linopy.Variable = m.add_variables(lower=0.0, name="v") e: linopy.LinearExpression = v * 1.0 @@ -14,12 +15,12 @@ def test_operations_with_data_arrays_are_typed_correctly() -> None: _ = a * v _ = v * a - _ = v + a + _ = v + s _ = a * e _ = e * a - _ = e + a + _ = e + s _ = a * q _ = q * a - _ = q + a + _ = q + s From 1e18984320bb9ce6fbae8e585b03cc8b85b3064b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:38:27 +0100 Subject: [PATCH 5/6] show assign_coords pattern --- examples/coordinate-alignment.ipynb | 383 ++++++++++++++-------------- 1 file changed, 191 insertions(+), 192 deletions(-) diff --git a/examples/coordinate-alignment.ipynb b/examples/coordinate-alignment.ipynb index 0eed3e97..e1309e37 100644 --- a/examples/coordinate-alignment.ipynb +++ b/examples/coordinate-alignment.ipynb @@ -2,7 +2,6 @@ "cells": [ { "cell_type": "markdown", - "id": "7fb27b941602401d91542211134fc71a", "metadata": {}, "source": [ "# Coordinate Alignment in linopy\n", @@ -24,32 +23,30 @@ }, { "cell_type": "code", - "execution_count": null, - "id": "acae54e37e7d407bbb7b55eff062a284", "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T12:33:12.721284Z", - "start_time": "2026-02-20T12:33:11.943302Z" - }, "execution": { - "iopub.execute_input": "2026-02-20T12:32:49.360029Z", - "iopub.status.busy": "2026-02-20T12:32:49.359903Z", - "iopub.status.idle": "2026-02-20T12:32:50.514585Z", - "shell.execute_reply": "2026-02-20T12:32:50.514115Z" + "iopub.execute_input": "2026-02-20T12:35:53.150316Z", + "iopub.status.busy": "2026-02-20T12:35:53.150100Z", + "iopub.status.idle": "2026-02-20T12:35:54.105967Z", + "shell.execute_reply": "2026-02-20T12:35:54.105432Z" + }, + "ExecuteTime": { + "end_time": "2026-02-20T12:36:56.193551Z", + "start_time": "2026-02-20T12:36:56.190913Z" } }, - "outputs": [], "source": [ "import numpy as np\n", "import pandas as pd\n", "import xarray as xr\n", "\n", "import linopy" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", - "id": "9a63283cbaf04dbcab1f6479b197f3a8", "metadata": {}, "source": [ "## What works by default" @@ -57,21 +54,18 @@ }, { "cell_type": "code", - "execution_count": null, - "id": "8dd0d8092fe74a7c96281538738b07e2", "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T12:33:12.760105Z", - "start_time": "2026-02-20T12:33:12.724924Z" - }, "execution": { - "iopub.execute_input": "2026-02-20T12:32:50.516166Z", - "iopub.status.busy": "2026-02-20T12:32:50.515988Z", - "iopub.status.idle": "2026-02-20T12:32:50.558987Z", - "shell.execute_reply": "2026-02-20T12:32:50.558720Z" + "iopub.execute_input": "2026-02-20T12:35:54.110532Z", + "iopub.status.busy": "2026-02-20T12:35:54.109029Z", + "iopub.status.idle": "2026-02-20T12:35:54.164335Z", + "shell.execute_reply": "2026-02-20T12:35:54.163789Z" + }, + "ExecuteTime": { + "end_time": "2026-02-20T12:36:56.215580Z", + "start_time": "2026-02-20T12:36:56.207497Z" } }, - "outputs": [], "source": [ "m = linopy.Model()\n", "\n", @@ -81,125 +75,121 @@ "x = m.add_variables(lower=0, coords=[time], name=\"x\")\n", "y = m.add_variables(lower=0, coords=[time], name=\"y\")\n", "gen = m.add_variables(lower=0, coords=[time, techs], name=\"gen\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, - "id": "72eea5119410473aa328ad9291626812", "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T12:33:12.772918Z", - "start_time": "2026-02-20T12:33:12.764952Z" - }, "execution": { - "iopub.execute_input": "2026-02-20T12:32:50.560447Z", - "iopub.status.busy": "2026-02-20T12:32:50.560319Z", - "iopub.status.idle": "2026-02-20T12:32:50.568613Z", - "shell.execute_reply": "2026-02-20T12:32:50.568245Z" + "iopub.execute_input": "2026-02-20T12:35:54.166957Z", + "iopub.status.busy": "2026-02-20T12:35:54.166600Z", + "iopub.status.idle": "2026-02-20T12:35:54.185234Z", + "shell.execute_reply": "2026-02-20T12:35:54.184778Z" + }, + "ExecuteTime": { + "end_time": "2026-02-20T12:36:56.230513Z", + "start_time": "2026-02-20T12:36:56.222101Z" } }, - "outputs": [], "source": [ "# Addition/subtraction — matching coordinates\n", "x + y" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, - "id": "8edb47106e1a46a883d545849b8ab81b", "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T12:33:12.783370Z", - "start_time": "2026-02-20T12:33:12.777304Z" - }, "execution": { - "iopub.execute_input": "2026-02-20T12:32:50.569890Z", - "iopub.status.busy": "2026-02-20T12:32:50.569796Z", - "iopub.status.idle": "2026-02-20T12:32:50.575044Z", - "shell.execute_reply": "2026-02-20T12:32:50.574842Z" + "iopub.execute_input": "2026-02-20T12:35:54.187479Z", + "iopub.status.busy": "2026-02-20T12:35:54.187284Z", + "iopub.status.idle": "2026-02-20T12:35:54.197488Z", + "shell.execute_reply": "2026-02-20T12:35:54.197090Z" + }, + "ExecuteTime": { + "end_time": "2026-02-20T12:36:56.241644Z", + "start_time": "2026-02-20T12:36:56.235473Z" } }, - "outputs": [], "source": [ "# Multiplication — matching coordinates\n", "factor = xr.DataArray([2, 3, 4, 5, 6], dims=[\"time\"], coords={\"time\": time})\n", "x * factor" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, - "id": "10185d26023b46108eb7d9f57d49d2b3", "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T12:33:12.795728Z", - "start_time": "2026-02-20T12:33:12.789249Z" - }, "execution": { - "iopub.execute_input": "2026-02-20T12:32:50.576125Z", - "iopub.status.busy": "2026-02-20T12:32:50.576061Z", - "iopub.status.idle": "2026-02-20T12:32:50.581269Z", - "shell.execute_reply": "2026-02-20T12:32:50.581041Z" + "iopub.execute_input": "2026-02-20T12:35:54.199528Z", + "iopub.status.busy": "2026-02-20T12:35:54.199323Z", + "iopub.status.idle": "2026-02-20T12:35:54.210352Z", + "shell.execute_reply": "2026-02-20T12:35:54.209978Z" + }, + "ExecuteTime": { + "end_time": "2026-02-20T12:36:56.253971Z", + "start_time": "2026-02-20T12:36:56.246880Z" } }, - "outputs": [], "source": [ "# Multiplication — partial overlap gives intersection\n", "partial = xr.DataArray([10, 20, 30], dims=[\"time\"], coords={\"time\": [0, 1, 2]})\n", "x * partial # result: time 0, 1, 2 only" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, - "id": "8763a12b2bbd4a93a75aff182afb95dc", "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T12:33:12.805925Z", - "start_time": "2026-02-20T12:33:12.798771Z" - }, "execution": { - "iopub.execute_input": "2026-02-20T12:32:50.582245Z", - "iopub.status.busy": "2026-02-20T12:32:50.582185Z", - "iopub.status.idle": "2026-02-20T12:32:50.587327Z", - "shell.execute_reply": "2026-02-20T12:32:50.587163Z" + "iopub.execute_input": "2026-02-20T12:35:54.212115Z", + "iopub.status.busy": "2026-02-20T12:35:54.211953Z", + "iopub.status.idle": "2026-02-20T12:35:54.223732Z", + "shell.execute_reply": "2026-02-20T12:35:54.223319Z" + }, + "ExecuteTime": { + "end_time": "2026-02-20T12:36:56.267382Z", + "start_time": "2026-02-20T12:36:56.259835Z" } }, - "outputs": [], "source": [ "# Multiplication — different dims broadcast (expands the expression)\n", "cost = xr.DataArray([1.0, 0.5, 3.0], dims=[\"tech\"], coords={\"tech\": techs})\n", "x * cost # result: (time, tech)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, - "id": "7623eae2785240b9bd12b16a66d81610", "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T12:33:12.823606Z", - "start_time": "2026-02-20T12:33:12.811410Z" - }, "execution": { - "iopub.execute_input": "2026-02-20T12:32:50.588398Z", - "iopub.status.busy": "2026-02-20T12:32:50.588330Z", - "iopub.status.idle": "2026-02-20T12:32:50.598610Z", - "shell.execute_reply": "2026-02-20T12:32:50.598402Z" + "iopub.execute_input": "2026-02-20T12:35:54.225717Z", + "iopub.status.busy": "2026-02-20T12:35:54.225519Z", + "iopub.status.idle": "2026-02-20T12:35:54.247553Z", + "shell.execute_reply": "2026-02-20T12:35:54.247125Z" + }, + "ExecuteTime": { + "end_time": "2026-02-20T12:36:56.305476Z", + "start_time": "2026-02-20T12:36:56.292Z" } }, - "outputs": [], "source": [ "# Constraints — RHS with fewer dims broadcasts naturally\n", "capacity = xr.DataArray([100, 80, 50], dims=[\"tech\"], coords={\"tech\": techs})\n", "m.add_constraints(gen <= capacity, name=\"cap\") # capacity broadcasts over time" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", - "id": "7cdc8c89c7104fffa095e18ddfef8986", "metadata": {}, "source": [ "## What raises an error" @@ -207,21 +197,18 @@ }, { "cell_type": "code", - "execution_count": null, - "id": "b118ea5561624da68c537baed56e602f", "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T12:33:12.840568Z", - "start_time": "2026-02-20T12:33:12.834424Z" - }, "execution": { - "iopub.execute_input": "2026-02-20T12:32:50.599634Z", - "iopub.status.busy": "2026-02-20T12:32:50.599571Z", - "iopub.status.idle": "2026-02-20T12:32:50.605548Z", - "shell.execute_reply": "2026-02-20T12:32:50.605322Z" + "iopub.execute_input": "2026-02-20T12:35:54.249529Z", + "iopub.status.busy": "2026-02-20T12:35:54.249355Z", + "iopub.status.idle": "2026-02-20T12:35:54.260588Z", + "shell.execute_reply": "2026-02-20T12:35:54.259868Z" + }, + "ExecuteTime": { + "end_time": "2026-02-20T12:36:56.319773Z", + "start_time": "2026-02-20T12:36:56.312636Z" } }, - "outputs": [], "source": [ "# Addition with mismatched coordinates\n", "y_short = m.add_variables(\n", @@ -232,25 +219,24 @@ " x + y_short # time coords don't match\n", "except ValueError as e:\n", " print(\"ValueError:\", e)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, - "id": "938c804e27f84196a10c8828c723f798", "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T12:33:12.847760Z", - "start_time": "2026-02-20T12:33:12.843647Z" - }, "execution": { - "iopub.execute_input": "2026-02-20T12:32:50.606568Z", - "iopub.status.busy": "2026-02-20T12:32:50.606506Z", - "iopub.status.idle": "2026-02-20T12:32:50.609742Z", - "shell.execute_reply": "2026-02-20T12:32:50.609552Z" + "iopub.execute_input": "2026-02-20T12:35:54.262548Z", + "iopub.status.busy": "2026-02-20T12:35:54.262376Z", + "iopub.status.idle": "2026-02-20T12:35:54.268753Z", + "shell.execute_reply": "2026-02-20T12:35:54.268391Z" + }, + "ExecuteTime": { + "end_time": "2026-02-20T12:36:56.331386Z", + "start_time": "2026-02-20T12:36:56.326247Z" } }, - "outputs": [], "source": [ "# Addition with extra dimensions on the constant\n", "profile = xr.DataArray(\n", @@ -260,25 +246,24 @@ " x + profile # would duplicate x[t] across techs\n", "except ValueError as e:\n", " print(\"ValueError:\", e)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, - "id": "504fb2a444614c0babb325280ed9130a", "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T12:33:12.855699Z", - "start_time": "2026-02-20T12:33:12.851877Z" - }, "execution": { - "iopub.execute_input": "2026-02-20T12:32:50.610738Z", - "iopub.status.busy": "2026-02-20T12:32:50.610665Z", - "iopub.status.idle": "2026-02-20T12:32:50.614493Z", - "shell.execute_reply": "2026-02-20T12:32:50.614335Z" + "iopub.execute_input": "2026-02-20T12:35:54.270585Z", + "iopub.status.busy": "2026-02-20T12:35:54.270420Z", + "iopub.status.idle": "2026-02-20T12:35:54.277993Z", + "shell.execute_reply": "2026-02-20T12:35:54.276363Z" + }, + "ExecuteTime": { + "end_time": "2026-02-20T12:36:56.350503Z", + "start_time": "2026-02-20T12:36:56.343806Z" } }, - "outputs": [], "source": [ "# Multiplication with zero overlap\n", "z = m.add_variables(lower=0, coords=[pd.RangeIndex(5, 10, name=\"time\")], name=\"z\")\n", @@ -287,25 +272,24 @@ " z * factor # z has time 5-9, factor has time 0-4 — no intersection\n", "except ValueError as e:\n", " print(\"ValueError:\", e)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, - "id": "59bbdb311c014d738909a11f9e486628", "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T12:33:12.864666Z", - "start_time": "2026-02-20T12:33:12.860577Z" - }, "execution": { - "iopub.execute_input": "2026-02-20T12:32:50.615336Z", - "iopub.status.busy": "2026-02-20T12:32:50.615276Z", - "iopub.status.idle": "2026-02-20T12:32:50.618275Z", - "shell.execute_reply": "2026-02-20T12:32:50.618094Z" + "iopub.execute_input": "2026-02-20T12:35:54.281858Z", + "iopub.status.busy": "2026-02-20T12:35:54.281316Z", + "iopub.status.idle": "2026-02-20T12:35:54.287843Z", + "shell.execute_reply": "2026-02-20T12:35:54.287269Z" + }, + "ExecuteTime": { + "end_time": "2026-02-20T12:36:56.361211Z", + "start_time": "2026-02-20T12:36:56.356813Z" } }, - "outputs": [], "source": [ "# Constraint RHS with mismatched coordinates\n", "partial_rhs = xr.DataArray([10, 20, 30], dims=[\"time\"], coords={\"time\": [0, 1, 2]})\n", @@ -314,25 +298,24 @@ " x <= partial_rhs\n", "except ValueError as e:\n", " print(\"ValueError:\", e)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, - "id": "b43b363d81ae4b689946ece5c682cd59", "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T12:33:12.874035Z", - "start_time": "2026-02-20T12:33:12.869466Z" - }, "execution": { - "iopub.execute_input": "2026-02-20T12:32:50.619169Z", - "iopub.status.busy": "2026-02-20T12:32:50.619111Z", - "iopub.status.idle": "2026-02-20T12:32:50.622842Z", - "shell.execute_reply": "2026-02-20T12:32:50.622654Z" + "iopub.execute_input": "2026-02-20T12:35:54.290439Z", + "iopub.status.busy": "2026-02-20T12:35:54.290235Z", + "iopub.status.idle": "2026-02-20T12:35:54.302535Z", + "shell.execute_reply": "2026-02-20T12:35:54.302145Z" + }, + "ExecuteTime": { + "end_time": "2026-02-20T12:36:56.385743Z", + "start_time": "2026-02-20T12:36:56.380702Z" } }, - "outputs": [], "source": [ "# Constraint RHS with extra dimensions\n", "w = m.add_variables(lower=0, coords=[techs], name=\"w\") # dims: (tech,)\n", @@ -343,72 +326,90 @@ " w <= rhs_2d # would create redundant constraints on w[tech]\n", "except ValueError as e:\n", " print(\"ValueError:\", e)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", - "id": "8a65eabff63a45729fe45fb5ade58bdc", "metadata": {}, "source": [ - "## Positional alignment with `join=\"override\"`\n", + "## Positional alignment\n", "\n", - "A common pattern: two arrays with the same shape but different (or no) coordinate labels. Use `join=\"override\"` to align by position, ignoring labels." + "A common pattern: two arrays with the same shape but different (or no) coordinate labels. The cleanest fix is to relabel one operand with `.assign_coords()` so that coordinates match explicitly:" ] }, { "cell_type": "code", - "execution_count": null, - "id": "c3933fab20d04ec698c2621248eb3be0", "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T12:33:12.887673Z", - "start_time": "2026-02-20T12:33:12.877435Z" - }, "execution": { - "iopub.execute_input": "2026-02-20T12:32:50.623830Z", - "iopub.status.busy": "2026-02-20T12:32:50.623775Z", - "iopub.status.idle": "2026-02-20T12:32:50.632476Z", - "shell.execute_reply": "2026-02-20T12:32:50.632321Z" + "iopub.execute_input": "2026-02-20T12:35:54.304505Z", + "iopub.status.busy": "2026-02-20T12:35:54.304317Z", + "iopub.status.idle": "2026-02-20T12:35:54.322551Z", + "shell.execute_reply": "2026-02-20T12:35:54.322153Z" + }, + "ExecuteTime": { + "end_time": "2026-02-20T12:37:36.671817Z", + "start_time": "2026-02-20T12:37:36.662325Z" } }, - "outputs": [], "source": [ "m2 = linopy.Model()\n", "\n", "a = m2.add_variables(coords=[[\"x\", \"y\", \"z\"]], name=\"a\")\n", "b = m2.add_variables(coords=[[\"p\", \"q\", \"r\"]], name=\"b\")\n", "\n", - "# a + b fails because labels don't match\n", - "# join=\"override\" aligns by position and keeps left operand's labels\n", - "a.add(b, join=\"override\")" - ] + "# Relabel b's coordinates to match a, then add normally\n", + "a + b.assign_coords(dim_0=a.coords[\"dim_0\"])" + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, - "id": "4dd4641cc4064e0191573fe9c69df29b", "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T12:33:12.905207Z", - "start_time": "2026-02-20T12:33:12.899976Z" - }, "execution": { - "iopub.execute_input": "2026-02-20T12:32:50.633382Z", - "iopub.status.busy": "2026-02-20T12:32:50.633328Z", - "iopub.status.idle": "2026-02-20T12:32:50.637298Z", - "shell.execute_reply": "2026-02-20T12:32:50.637123Z" + "iopub.execute_input": "2026-02-20T12:35:54.324642Z", + "iopub.status.busy": "2026-02-20T12:35:54.324465Z", + "iopub.status.idle": "2026-02-20T12:35:54.332579Z", + "shell.execute_reply": "2026-02-20T12:35:54.332088Z" + }, + "ExecuteTime": { + "end_time": "2026-02-20T12:36:56.424015Z", + "start_time": "2026-02-20T12:36:56.418311Z" } }, - "outputs": [], "source": [ "# Same for constraints\n", "rhs = xr.DataArray([1.0, 2.0, 3.0], dims=[\"dim_0\"], coords={\"dim_0\": [\"p\", \"q\", \"r\"]})\n", - "a.to_linexpr().le(rhs, join=\"override\")" - ] + "a <= rhs.assign_coords(dim_0=a.coords[\"dim_0\"])" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-20T12:35:54.336196Z", + "iopub.status.busy": "2026-02-20T12:35:54.335947Z", + "iopub.status.idle": "2026-02-20T12:35:54.360683Z", + "shell.execute_reply": "2026-02-20T12:35:54.359622Z" + }, + "ExecuteTime": { + "end_time": "2026-02-20T12:36:56.441516Z", + "start_time": "2026-02-20T12:36:56.432774Z" + } + }, + "source": [ + "# Shorthand: join=\"override\" does the same (positional match, keeps left labels)\n", + "a.add(b, join=\"override\")" + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", - "id": "8309879909854d7188b41380fd92a7c3", "metadata": {}, "source": [ "## Other join modes\n", @@ -427,21 +428,18 @@ }, { "cell_type": "code", - "execution_count": null, - "id": "3ed186c9a28b402fb0bc4494df01f08d", "metadata": { - "ExecuteTime": { - "end_time": "2026-02-20T12:33:12.934067Z", - "start_time": "2026-02-20T12:33:12.909515Z" - }, "execution": { - "iopub.execute_input": "2026-02-20T12:32:50.638240Z", - "iopub.status.busy": "2026-02-20T12:32:50.638182Z", - "iopub.status.idle": "2026-02-20T12:32:50.659703Z", - "shell.execute_reply": "2026-02-20T12:32:50.659485Z" + "iopub.execute_input": "2026-02-20T12:35:54.363885Z", + "iopub.status.busy": "2026-02-20T12:35:54.363642Z", + "iopub.status.idle": "2026-02-20T12:35:54.404550Z", + "shell.execute_reply": "2026-02-20T12:35:54.403860Z" + }, + "ExecuteTime": { + "end_time": "2026-02-20T12:36:56.472328Z", + "start_time": "2026-02-20T12:36:56.446352Z" } }, - "outputs": [], "source": [ "i_a = pd.Index([0, 1, 2], name=\"i\")\n", "i_b = pd.Index([1, 2, 3], name=\"i\")\n", @@ -453,11 +451,12 @@ "print(\"outer:\", list(a.add(b, join=\"outer\").coords[\"i\"].values)) # [0, 1, 2, 3]\n", "print(\"left: \", list(a.add(b, join=\"left\").coords[\"i\"].values)) # [0, 1, 2]\n", "print(\"right:\", list(a.add(b, join=\"right\").coords[\"i\"].values)) # [1, 2, 3]" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", - "id": "cb1e1581032b452c9409d6c6813c49d1", "metadata": {}, "source": [ "## Migrating from previous versions\n", From e7ea997217e9e2ebc5c88af91996b482d933af20 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:51:38 +0100 Subject: [PATCH 6/6] All join="override" usages in tests have been replaced with assign_coords. Here's what changed: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_linear_expression_sum / test_linear_expression_sum_with_const: v.loc[:9].add(v.loc[10:], join="override") → v.loc[:9] + v.loc[10:].assign_coords(dim_2=v.loc[:9].coords["dim_2"]) - test_add_join_override → test_add_positional_assign_coords: uses v + disjoint.assign_coords(...) - test_add_constant_join_override → test_add_constant_positional: now uses different coords [5,6,7] + assign_coords to make the test meaningful - test_same_shape_add_join_override → test_same_shape_add_assign_coords: uses + c.to_linexpr().assign_coords(...) - test_add_constant_override_positional → test_add_constant_positional_different_coords: expr + other.assign_coords(...) - test_sub_constant_override → test_sub_constant_positional: expr - other.assign_coords(...) - test_mul_constant_override_positional → test_mul_constant_positional: expr * other.assign_coords(...) - test_div_constant_override_positional → test_div_constant_positional: expr / other.assign_coords(...) - test_variable_mul_override → test_variable_mul_positional: a * other.assign_coords(...) - test_variable_div_override → test_variable_div_positional: a / other.assign_coords(...) - test_add_same_coords_all_joins: removed "override" from loop, added assign_coords variant - test_add_scalar_with_explicit_join → test_add_scalar: simplified to expr + 10 --- test/test_linear_expression.py | 56 ++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/test/test_linear_expression.py b/test/test_linear_expression.py index ea81bb2b..2ced61a0 100644 --- a/test/test_linear_expression.py +++ b/test/test_linear_expression.py @@ -445,8 +445,8 @@ def test_linear_expression_sum( with pytest.raises(ValueError, match="exact"): v.loc[:9] + v.loc[10:] - # explicit outer join gives union - expr = v.loc[:9].add(v.loc[10:], join="override") + # positional alignment via assign_coords + expr = v.loc[:9] + v.loc[10:].assign_coords(dim_2=v.loc[:9].coords["dim_2"]) assert expr.nterm == 2 assert len(expr.coords["dim_2"]) == 10 @@ -473,8 +473,8 @@ def test_linear_expression_sum_with_const( with pytest.raises(ValueError, match="exact"): v.loc[:9] + v.loc[10:] - # explicit outer join gives union - expr = v.loc[:9].add(v.loc[10:], join="override") + # positional alignment via assign_coords + expr = v.loc[:9] + v.loc[10:].assign_coords(dim_2=v.loc[:9].coords["dim_2"]) assert expr.nterm == 2 assert len(expr.coords["dim_2"]) == 10 @@ -816,11 +816,11 @@ def test_add_join_outer(self, v: Variable, subset: xr.DataArray) -> None: assert result.const.sel(dim_2=1).item() == 10.0 assert result.const.sel(dim_2=0).item() == 0.0 - def test_add_join_override(self, v: Variable) -> None: + def test_add_positional_assign_coords(self, v: Variable) -> None: disjoint = xr.DataArray( np.ones(20), dims=["dim_2"], coords={"dim_2": range(50, 70)} ) - result = v.add(disjoint, join="override") + result = v + disjoint.assign_coords(dim_2=v.coords["dim_2"]) assert result.sizes["dim_2"] == 20 assert list(result.coords["dim_2"].values) == list(range(20)) @@ -1829,10 +1829,10 @@ def test_add_constant_join_outer(self, a: Variable) -> None: result = a.to_linexpr().add(const, join="outer") assert list(result.data.indexes["i"]) == [0, 1, 2, 3] - def test_add_constant_join_override(self, a: Variable, c: Variable) -> None: + def test_add_constant_positional(self, a: Variable) -> None: expr = a.to_linexpr() - const = xr.DataArray([10, 20, 30], dims=["i"], coords={"i": [0, 1, 2]}) - result = expr.add(const, join="override") + const = xr.DataArray([10, 20, 30], dims=["i"], coords={"i": [5, 6, 7]}) + result = expr + const.assign_coords(i=expr.coords["i"]) assert list(result.data.indexes["i"]) == [0, 1, 2] assert (result.const.values == const.values).all() @@ -1889,8 +1889,8 @@ def test_merge_join_parameter(self, a: Variable, b: Variable) -> None: result: LinearExpression = merge([a.to_linexpr(), b.to_linexpr()], join="inner") assert list(result.data.indexes["i"]) == [1, 2] - def test_same_shape_add_join_override(self, a: Variable, c: Variable) -> None: - result = a.to_linexpr().add(c.to_linexpr(), join="override") + def test_same_shape_add_assign_coords(self, a: Variable, c: Variable) -> None: + result = a.to_linexpr() + c.to_linexpr().assign_coords(i=a.coords["i"]) assert list(result.data.indexes["i"]) == [0, 1, 2] def test_add_expr_outer_const_values(self, a: Variable, b: Variable) -> None: @@ -1928,17 +1928,17 @@ def test_add_constant_inner_fill_values(self, a: Variable) -> None: assert list(result.coords["i"].values) == [1] assert result.const.sel(i=1).item() == 15 - def test_add_constant_override_positional(self, a: Variable) -> None: + def test_add_constant_positional_different_coords(self, a: Variable) -> None: expr = 1 * a + 5 other = xr.DataArray([10, 20, 30], dims=["i"], coords={"i": [5, 6, 7]}) - result = expr.add(other, join="override") + result = expr + other.assign_coords(i=expr.coords["i"]) assert list(result.coords["i"].values) == [0, 1, 2] np.testing.assert_array_equal(result.const.values, [15, 25, 35]) - def test_sub_constant_override(self, a: Variable) -> None: + def test_sub_constant_positional(self, a: Variable) -> None: expr = 1 * a + 5 other = xr.DataArray([10, 20, 30], dims=["i"], coords={"i": [5, 6, 7]}) - result = expr.sub(other, join="override") + result = expr - other.assign_coords(i=expr.coords["i"]) assert list(result.coords["i"].values) == [0, 1, 2] np.testing.assert_array_equal(result.const.values, [-5, -15, -25]) @@ -1952,10 +1952,10 @@ def test_sub_expr_outer_const_values(self, a: Variable, b: Variable) -> None: assert result.const.sel(i=2).item() == -5 assert result.const.sel(i=3).item() == -10 - def test_mul_constant_override_positional(self, a: Variable) -> None: + def test_mul_constant_positional(self, a: Variable) -> None: expr = 1 * a + 5 other = xr.DataArray([2, 3, 4], dims=["i"], coords={"i": [5, 6, 7]}) - result = expr.mul(other, join="override") + result = expr * other.assign_coords(i=expr.coords["i"]) assert list(result.coords["i"].values) == [0, 1, 2] np.testing.assert_array_equal(result.const.values, [10, 15, 20]) np.testing.assert_array_equal(result.coeffs.squeeze().values, [2, 3, 4]) @@ -1972,10 +1972,10 @@ def test_mul_constant_outer_fill_values(self, a: Variable) -> None: assert result.coeffs.squeeze().sel(i=1).item() == 2 assert result.coeffs.squeeze().sel(i=0).item() == 0 - def test_div_constant_override_positional(self, a: Variable) -> None: + def test_div_constant_positional(self, a: Variable) -> None: expr = 1 * a + 10 other = xr.DataArray([2.0, 5.0, 10.0], dims=["i"], coords={"i": [5, 6, 7]}) - result = expr.div(other, join="override") + result = expr / other.assign_coords(i=expr.coords["i"]) assert list(result.coords["i"].values) == [0, 1, 2] np.testing.assert_array_equal(result.const.values, [5.0, 2.0, 1.0]) @@ -1999,16 +1999,16 @@ def test_variable_add_outer_values(self, a: Variable, b: Variable) -> None: assert set(result.coords["i"].values) == {0, 1, 2, 3} assert result.nterm == 2 - def test_variable_mul_override(self, a: Variable) -> None: + def test_variable_mul_positional(self, a: Variable) -> None: other = xr.DataArray([2, 3, 4], dims=["i"], coords={"i": [5, 6, 7]}) - result = a.mul(other, join="override") + result = a * other.assign_coords(i=a.coords["i"]) assert isinstance(result, LinearExpression) assert list(result.coords["i"].values) == [0, 1, 2] np.testing.assert_array_equal(result.coeffs.squeeze().values, [2, 3, 4]) - def test_variable_div_override(self, a: Variable) -> None: + def test_variable_div_positional(self, a: Variable) -> None: other = xr.DataArray([2.0, 5.0, 10.0], dims=["i"], coords={"i": [5, 6, 7]}) - result = a.div(other, join="override") + result = a / other.assign_coords(i=a.coords["i"]) assert isinstance(result, LinearExpression) assert list(result.coords["i"].values) == [0, 1, 2] np.testing.assert_array_almost_equal( @@ -2022,14 +2022,18 @@ def test_merge_outer_join(self, a: Variable, b: Variable) -> None: def test_add_same_coords_all_joins(self, a: Variable, c: Variable) -> None: expr_a = 1 * a + 5 const = xr.DataArray([1, 2, 3], dims=["i"], coords={"i": [0, 1, 2]}) - for join in ["override", "outer", "inner"]: + for join in ["outer", "inner"]: result = expr_a.add(const, join=join) assert list(result.coords["i"].values) == [0, 1, 2] np.testing.assert_array_equal(result.const.values, [6, 7, 8]) + # assign_coords also works when coords already match + result = expr_a + const.assign_coords(i=expr_a.coords["i"]) + assert list(result.coords["i"].values) == [0, 1, 2] + np.testing.assert_array_equal(result.const.values, [6, 7, 8]) - def test_add_scalar_with_explicit_join(self, a: Variable) -> None: + def test_add_scalar(self, a: Variable) -> None: expr = 1 * a + 5 - result = expr.add(10, join="override") + result = expr + 10 np.testing.assert_array_equal(result.const.values, [15, 15, 15]) assert list(result.coords["i"].values) == [0, 1, 2]