Skip to content

Model.copy() silently drops the quadratic part of the objective #903

Description

@mal84emma

Note

This report was generated by an AI agent (supervised by a human). It is a
self-documenting bug report: the repro, output and code references below were
produced and verified by the agent against master at 09c34dd.

Version Checks

  • Bug exists on the latest release (v0.9.1 — the offending line is identical there)
  • Bug exists on the current master branch (09c34dd)

Issue Description

Model.copy() rebuilds the objective as a LinearExpression unconditionally, so a
QuadraticExpression objective is silently downgraded to a linear one. The copy's
Model.type flips from QP to LP, objective.is_quadratic becomes False, and the
quadratic term's second variable column is simply reinterpreted as if the row were linear.
The copy then solves happily — as a different model — with no warning or error. The
copy.copy / copy.deepcopy protocols route through the same function and are equally
affected.

Severity: silently wrong results. Nothing raises. Any workflow that copies a model
before solving (scenario loops, sensitivity sweeps, or building a reference/oracle model)
gets an answer to a problem it did not pose. In the repro below the copy returns
-14.0 where the true optimum is 0.75. We first hit this in a differential test of
quadratic-objective support, where the "expected" value came from a copied model and was
off by more than 10x — indistinguishable, from inside the test, from a solver bug.

A related secondary symptom: on the copy, model.matrices.Q is None and
model.matrices.c raises IndexError: boolean index did not match indexed array along axis 0 — the quadratic term's two-variable rows are still present in the flattened
coefficient array, but the accessor now takes the linear path.

Reproducible Example

import copy as _copy

from linopy import Model

# minimise x**2 + 2*x + y   s.t.  x + y >= 1,  -5 <= x <= 5,  0 <= y <= 10
m = Model()
x = m.add_variables(lower=-5, upper=5, name="x")
y = m.add_variables(lower=0, upper=10, name="y")
m.add_constraints(x + y >= 1, name="con")
m.add_objective(x * x + 2 * x + y)

m2 = m.copy()

print(m.type, m.objective.is_quadratic, type(m.objective.expression).__name__)
print(m2.type, m2.objective.is_quadratic, type(m2.objective.expression).__name__)

for label, mm in [("copy.copy", _copy.copy(m)), ("copy.deepcopy", _copy.deepcopy(m))]:
    print(label, mm.type, mm.objective.is_quadratic)

m.solve(solver_name="highs", output_flag=False)
m2.solve(solver_name="highs", output_flag=False)
print("original objective:", m.objective.value)   # 0.75  (correct)
print("copy objective    :", m2.objective.value)  # -14.0 (different model)

Key output:

QP True QuadraticExpression
LP False LinearExpression
copy.copy LP False
copy.deepcopy LP False
original objective: 0.75000000000001
copy objective    : -14.0

The analytic optimum of the QP is x = -0.5, y = 1.5, objective 0.75, which the
original model reproduces; the copy reports x = -5.0, y = 6.0, objective -14.0.

Full script output (including matrices accessor symptom and version info)
linopy.__file__    : .../linopy-master/linopy/__init__.py
linopy.__version__ : 0.9.1.post1.dev26 (worktree of master @ 09c34dd)
python             : 3.11.15
platform           : Linux-6.8.0-1044-azure-x86_64-with-glibc2.35

=== original ===
type            : QP
is_quadratic    : True
nterm           : 3
Objective:
----------
QuadraticExpression: +1 x x + 2 x + 1 y
Sense: min
Value: None

=== m.copy() ===
type            : LP
is_quadratic    : False
nterm           : 3
Objective:
----------
LinearExpression: +1 x x + 2 x + 1 y
Sense: min
Value: None

expression class original : QuadraticExpression
expression class copy     : LinearExpression

=== copy.copy / copy.deepcopy protocols ===
copy.copy        type=LP is_quadratic=False
copy.deepcopy    type=LP is_quadratic=False

=== matrices accessor on the copy ===
original matrices.Q is None : False
copy matrices.Q is None     : True
copy matrices.c raised      : IndexError boolean index did not match indexed array along axis 0; size of axis is 3 but size of corresponding boolean axis is 6

=== solve both with HiGHS ===
original : ('ok', 'optimal') objective = 0.75000000000001 x = -0.49999990000000993 y = 1.49999990000001
copy     : ('ok', 'optimal') objective = -14.0 x = -5.0 y = 6.0

Analytic QP optimum: x = -0.5, y = 1.5, objective = 0.75
MISMATCH

Expected Behavior

m.copy() should preserve the objective's expression type, so that for a QP model:

m2 = m.copy()
assert m2.type == m.type                      # "QP"
assert m2.objective.is_quadratic
assert type(m2.objective.expression) is type(m.objective.expression)
m.solve(solver_name="highs"); m2.solve(solver_name="highs")
assert m2.objective.value == pytest.approx(m.objective.value)

Root Cause

In linopy/io.py, copy() hard-codes the objective's expression class:

obj_expr = LinearExpression(m.objective.expression.data.copy(deep=deep), new_model)

obj_expr = LinearExpression(m.objective.expression.data.copy(deep=deep), new_model)

The Dataset is copied faithfully, but wrapping it in LinearExpression discards the
QuadraticExpression type, and Model.type / Objective.is_quadratic are derived from
that type rather than from the data — hence the silent downgrade.

Notably the same function already does the right thing a few lines above for named
expressions, in the _copy_expr helper:

linopy/linopy/io.py

Lines 1243 to 1249 in 09c34dd

def _copy_expr(
name: str, expr: LinearExpression | QuadraticExpression
) -> LinearExpression | QuadraticExpression:
# Expressions hold no solve artifacts, so include_solution is irrelevant.
new_expr = type(expr)(expr.data.copy(deep=deep), new_model)
new_expr.attrs["name"] = name # __init__ resets the name to None
return new_expr

new_expr = type(expr)(expr.data.copy(deep=deep), new_model)

So the fix looks like applying the same type(m.objective.expression)(...) dispatch at
line 1270. (Stated as the code supports it — we have not checked whether
QuadraticExpression.__init__ needs anything beyond the same (data, model) signature,
though _copy_expr suggests it does not.) A regression test in test/test_io.py
asserting m.copy().type == "QP" and equal objective values after solving would cover it.

The line was introduced with Model.copy in #623 and has not been touched since, so this
has been present for every release that has Model.copy().

Installed Versions

Details
linopy    master @ 09c34dd (also verified identical line in v0.9.1)
python    3.11.15
platform  Linux-6.8.0-1044-azure-x86_64-with-glibc2.35
xarray    2026.7.0
numpy     2.4.6
pandas    3.0.3
scipy     1.17.1
highspy   1.15.1

How this was found

During differential testing of quadratic-objective support, where a reference model built
via model.copy() produced an expectation that disagreed with the solver by ~12x. The
workaround is to construct the second model from scratch rather than copying it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions