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

    bug:silentA bug that goes unnoticed by the user. No warning/exception.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions