From 35d2226ece03761e73aef2772221cfe5e4d81c6d Mon Sep 17 00:00:00 2001 From: Dave Woodruff Date: Thu, 18 Jun 2026 13:50:00 -0700 Subject: [PATCH] Fix reduced costs dropped by legacy results when load_solutions=False Both legacy solver wrappers -- APPSI (pyomo/contrib/appsi/base.py) and the new interface (pyomo/contrib/solver/common/base.py) -- lost reduced costs when a model was solved with solve(..., load_solutions=False) and the solution was later applied via model.solutions.load_from(results). Rather than attaching each variable's reduced cost to that variable's entry in legacy_soln.variable (as is done for primal values, and analogous to the per-constraint dual/slack handling), the code assigned every reduced cost to a single literal 'Rc' key: legacy_soln.variable['Rc'] = val so load_from never routed the values to the model's rc Suffix and it was left empty. Primal values, duals, and slacks were unaffected; the bug is specific to reduced costs and is not solver-specific (it affects every solver used through these wrappers, e.g. highs and gurobi). Attach 'Rc' to each variable's solution entry, mirroring the existing slack handling. The legacy-interface test_load_solutions tests in both interfaces are extended to load and check a nonzero reduced cost. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyomo/contrib/appsi/base.py | 4 +++- .../appsi/solvers/tests/test_persistent_solvers.py | 8 +++++++- pyomo/contrib/solver/common/base.py | 4 +++- pyomo/contrib/solver/tests/solvers/test_solvers.py | 8 +++++++- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/appsi/base.py b/pyomo/contrib/appsi/base.py index 9928568f414..bf3a8a5affc 100644 --- a/pyomo/contrib/appsi/base.py +++ b/pyomo/contrib/appsi/base.py @@ -1626,7 +1626,9 @@ def solve( legacy_soln.constraint[symbol]['Slack'] = val if hasattr(model, 'rc') and model.rc.import_enabled(): for v, val in results.solution_loader.get_reduced_costs().items(): - legacy_soln.variable['Rc'] = val + symbol = symbol_map.getSymbol(v) + if symbol in legacy_soln.variable: + legacy_soln.variable[symbol]['Rc'] = val legacy_results.solution.insert(legacy_soln) legacy_results._smap = symbol_map diff --git a/pyomo/contrib/appsi/solvers/tests/test_persistent_solvers.py b/pyomo/contrib/appsi/solvers/tests/test_persistent_solvers.py index 9fc7b5f2e3d..4ecea98ac39 100644 --- a/pyomo/contrib/appsi/solvers/tests/test_persistent_solvers.py +++ b/pyomo/contrib/appsi/solvers/tests/test_persistent_solvers.py @@ -1410,16 +1410,22 @@ def test_load_solutions(self, name: str, opt_class: Type[PersistentSolver]): raise unittest.SkipTest m = pyo.ConcreteModel() m.x = pyo.Var() - m.obj = pyo.Objective(expr=m.x) + m.y = pyo.Var(bounds=(0, None)) + m.obj = pyo.Objective(expr=m.x + m.y) m.c = pyo.Constraint(expr=(-1, m.x, 1)) if opt_class != MAiNGO: m.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.rc = pyo.Suffix(direction=pyo.Suffix.IMPORT) res = opt.solve(m, load_solutions=False) pyo.assert_optimal_termination(res) self.assertIsNone(m.x.value) + self.assertIsNone(m.y.value) if opt_class != MAiNGO: self.assertNotIn(m.c, m.dual) + self.assertNotIn(m.y, m.rc) m.solutions.load_from(res) self.assertAlmostEqual(m.x.value, -1) + self.assertAlmostEqual(m.y.value, 0) if opt_class != MAiNGO: self.assertAlmostEqual(m.dual[m.c], 1) + self.assertAlmostEqual(m.rc[m.y], 1) diff --git a/pyomo/contrib/solver/common/base.py b/pyomo/contrib/solver/common/base.py index fd0d4f1bc18..e1ed7b62831 100644 --- a/pyomo/contrib/solver/common/base.py +++ b/pyomo/contrib/solver/common/base.py @@ -571,7 +571,9 @@ def _solution_handler( legacy_soln.constraint[symbol_map.getSymbol(con)] = {'Dual': val} if hasattr(model, 'rc') and model.rc.import_enabled(): for var, val in results.solution_loader.get_reduced_costs().items(): - legacy_soln.variable['Rc'] = val + symbol = symbol_map.getSymbol(var) + if symbol in legacy_soln.variable: + legacy_soln.variable[symbol]['Rc'] = val legacy_results.solution.insert(legacy_soln) legacy_results._smap_id = id(symbol_map) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 723aa39433c..0b30a6eb923 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -2497,16 +2497,22 @@ def test_load_solutions(self, name: str, opt_class: Type[SolverBase]): raise unittest.SkipTest(f'Solver {opt.name} not available.') m = pyo.ConcreteModel() m.x = pyo.Var() - m.obj = pyo.Objective(expr=m.x) + m.y = pyo.Var(bounds=(0, None)) + m.obj = pyo.Objective(expr=m.x + m.y) m.c = pyo.Constraint(expr=(-1, m.x, 1)) if (name, opt_class) in dual_solvers: m.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) + m.rc = pyo.Suffix(direction=pyo.Suffix.IMPORT) res = opt.solve(m, load_solutions=False) pyo.assert_optimal_termination(res) self.assertIsNone(m.x.value) + self.assertIsNone(m.y.value) if (name, opt_class) in dual_solvers: self.assertNotIn(m.c, m.dual) + self.assertNotIn(m.y, m.rc) m.solutions.load_from(res) self.assertAlmostEqual(m.x.value, -1) + self.assertAlmostEqual(m.y.value, 0) if (name, opt_class) in dual_solvers: self.assertAlmostEqual(m.dual[m.c], 1) + self.assertAlmostEqual(m.rc[m.y], 1)