Skip to content

Rework SolutionLoader#3701

Open
michaelbynum wants to merge 43 commits intoPyomo:mainfrom
michaelbynum:solver_api
Open

Rework SolutionLoader#3701
michaelbynum wants to merge 43 commits intoPyomo:mainfrom
michaelbynum:solver_api

Conversation

@michaelbynum
Copy link
Copy Markdown
Contributor

Summary/Motivation:

This PR updates the solution loader in pyomo/contrib/solver based on recent design discussions.

Changes proposed in this PR:

  • add get_number_of_solutions method
  • add get_solution_ids method
  • add load_solution_method
  • add load_import_suffixes method
  • add solution_id as an argument to methods

Legal Acknowledgement

By contributing to this software project, I have read the contribution guide and agree to the following terms and conditions for my contribution:

  1. I agree my contributions are submitted under the BSD license.
  2. I represent I am authorized to make the contributions and grant the license. If my employer has rights to intellectual property that includes these contributions, I represent that I have received permission to make contributions and grant the required license on behalf of that employer.

@michaelbynum michaelbynum changed the title Rework SolutionLoader [Depends on #3698] Rework SolutionLoader Aug 13, 2025
@codecov
Copy link
Copy Markdown

codecov bot commented Aug 14, 2025

Codecov Report

❌ Patch coverage is 83.83838% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.76%. Comparing base (34a3877) to head (2fb8126).

Files with missing lines Patch % Lines
pyomo/contrib/solver/solvers/asl_sol_reader.py 86.79% 7 Missing ⚠️
pyomo/contrib/solver/solvers/highs.py 72.00% 7 Missing ⚠️
pyomo/contrib/solver/common/solution_loader.py 86.66% 6 Missing ⚠️
pyomo/contrib/solver/solvers/gms_sol_reader.py 75.00% 6 Missing ⚠️
...ontrib/solver/solvers/gurobi/gurobi_direct_base.py 76.47% 4 Missing ⚠️
pyomo/contrib/solver/solvers/ipopt.py 83.33% 1 Missing ⚠️
pyomo/contrib/solver/solvers/knitro/solution.py 80.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3701      +/-   ##
==========================================
- Coverage   89.94%   86.76%   -3.18%     
==========================================
  Files         902      902              
  Lines      106457   106595     +138     
==========================================
- Hits        95748    92492    -3256     
- Misses      10709    14103    +3394     
Flag Coverage Δ
builders 29.17% <29.79%> (-0.02%) ⬇️
default 86.23% <83.83%> (?)
expensive 35.62% <29.79%> (?)
linux ?
linux_other ?
oldsolvers 28.11% <29.79%> (-0.01%) ⬇️
osx ?
win ?
win_other ?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown
Member

@jsiirola jsiirola left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple minor questions that don't need to hold up the PR and one significant one that probably does (it look like the ASL solution loader is not loading all import suffixes).

Comment thread pyomo/contrib/solver/common/solution_loader.py Outdated
vars_to_load: list
A list of the variables whose solution value should be retrieved. If vars_to_load
is None, then the values for all variables will be retrieved.
solution_id: Optional[Any]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of Optional[Type], I believe the current "preferred Python" annotation is Type | None. In this case, since Any includes None, I think you could just do Any (although Any | None is a little more explicit).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment on lines +76 to +77
def load_import_suffixes(self, solution_id=None):
load_import_suffixes(self._pyomo_model, self, solution_id=solution_id)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only loads duals and rc ... the ASL supports arbitrary inpirt suffixes, which I think need to be supported here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you give me an example of what ASLSolFileData would look like if it had other suffixes. I see that it has attributes like var_suffixes. I assume the keys are the names of the suffixes, and the values map the variable indices to the suffix values? Any suggestions on how to test this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes: Suffix data comes back from the ASL flagged for "variables", "constraints", "objectives", and the "problem". We blindly parse that into the corresponding attributes (var_suffixes, etc).

  • For var, con, and obj, the dict maps suffix name to a dict that maps the 0-based integer index of the relevant variable / constraint / objective in the corresponding list in the NLWriterInfo returned by the writer tot he suffix value.
  • For the problem, since there is only one problem, the problem_suffixes dict just maps suffix name to value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - kind of. I just raise an error if any variables are eliminated or any scaling is done.

@blnicho blnicho requested a review from jsiirola March 17, 2026 19:27
@michaelbynum
Copy link
Copy Markdown
Contributor Author

michaelbynum commented Mar 17, 2026

@AnhTran01 , @boxblox - could you please take a look at the changes to the gams solution loader?

@boxblox
Copy link
Copy Markdown
Contributor

boxblox commented Mar 19, 2026

@AnhTran01 , @boxblox - could you please take a look at the changes to the gams solution loader?

yep, is there anything in particular you need me to focus in on? on my first pass it looks like a lot of internal organization. Not sure I'd have much to add on that, but can look for an eye for clarity, if that's helpful.

@michaelbynum
Copy link
Copy Markdown
Contributor Author

@AnhTran01 , @boxblox - could you please take a look at the changes to the gams solution loader?

yep, is there anything in particular you need me to focus in on? on my first pass it looks like a lot of internal organization. Not sure I'd have much to add on that, but can look for an eye for clarity, if that's helpful.

I mostly wanted to make sure I understood when a solution is available for the purposes of get_number_of_solutions. If _gms_info is not None, then it should be safe to assume that there is a solution, correct? That's what it looked like from load_vars.

@michaelbynum
Copy link
Copy Markdown
Contributor Author

@jsiirola - this should be ready for a follow-up review. One thing that is probably worth further discussion: I added a method to the SolutionLoader to get the number of solutions and the solution ids, but this does not include a way to indicate the status (optimal, feasible, etc.) of each of those solutions. Thoughts?

@michaelbynum
Copy link
Copy Markdown
Contributor Author

The Jenkins failures seem to be unrelated to this PR. They look like timeout error during calls to available. We probably want to increase the default timeout for those. I can create a separate PR for that.

Comment on lines +10 to +12
from __future__ import annotations

from typing import Sequence, Dict, Optional, Mapping, List, Any
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know what you need from __future__ here?

Also, as we are Python>-3.10, you don't need List or Dict (and can just use list and dict)


from collections.abc import Mapping, Sequence
from typing import Protocol
from typing import Any, List, Protocol
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, you don't need to import List and can just use list

from pyomo.core.base.suffix import Suffix


def load_import_suffixes(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a couple problems with this implementation:

  • this doesn't clear any import suffixes other than dual and rc
  • if there are multiple dual suffixes, only the last one found is cleared (and that is usually the deeper one in the tree)

See the next comment for a proposed solution

If there are multiple solutions, this specifies which solution
should be loaded. If None, the default solution will be used.
"""
return NotImplemented
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I propose the following default implementation:

Suggested change
return NotImplemented
def load_import_suffixes(self, solution_id=None):
"""Clear import suffixes on the model and load data returned by the solver.
Parameters
----------
solution_id: Optional[Any]
If there are multiple solutions, this specifies which solution
should be loaded. If None, the default solution will be used.
"""
suffixes = self._clear_import_suffixes()
if 'dual' in known_suffixes:
suffixes['dual'].update(solution_loader.get_duals(solution_id=solution_id))
if 'rc' in known_suffixes:
suffixes['rc'].update(
solution_loader.get_reduced_costs(solution_id=solution_id)
)
def _clear_import_suffixes(self):
"""Clear and return all import suffixes on the model
This walks the Pyomo model and clears all :class:`Suffix`
components that are flagged to import values from the solver
(this includes :attr:`Suffix.IMPORT` and
:attr:`Suffix.IMPORT_EXPORT`). It returns a :class:`dict`
mapping the :attr:`Suffix.local_name` to the :class:`Suffix`
closest to the root block.
Returns
-------
known_suffixes : dict[str, Suffix]
"""
known_suffixes = {}
for suffix in self.pyomo_model.component_objects(
Suffix,
active=True,
descend_into=True,
descent_order=TraversalStrategy.BreadthFirstSearch,
):
if not suffix.import_enabled():
continue
suffix.clear()
known_suffixes.setdefault(suffix.local_name, suffix)
return known_suffixes

This resolves issues with not clearing all import suffixes and handling multiple Suffix components with the same name. It also makes this a proper method of the loader (the "public utility function" seemed a bit strange).

This does imply a more significant change: it requires all loaders to keep an explicit reference to the Pyomo model. It is an open question if pyomo_model should just be a public attribute or a property... Either way, I think that is actually a positive change, but it is a bigger one that we should at least acknowledge/discuss.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An alternate implementation could be to force the solver to pre-collect the import Suffixes and just record that list on the SolutionLoader, but that seems more clunky and repetitive (each solver would have to remember to implement it)?

"""
return NotImplemented

def load_solution(self, solution_id=None):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a general question about this design: do we know the "cost" of switching the "active solution"? As implemented, load_solution will set and clear the active solution up to 3 times (for vars, duals, and reduced costs). Do we know if that is a problem.

An alternate solution could be to implement a context manager, so users could do things like:

results = solver.solve(m)
# ...
solution_ids = results.get_solution_ids()
with results.solution(solution_ids[2]):
    results.load_solution()
    # or
    vars = results.get_vars()
    duals = results.get_duals()

I am not proposing a change (yet) ... just interested in your thoughts.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One advantage of the context manager approach is we would only need to validate the solution_id in one place (instead of repeating it in every load / get method).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, the only interfaces that actually support solution pools are the gurobi interfaces. With those, I don't think there is really any cost. That being said, I still like your suggestion - particularly the bit about not checking the solution id in every method...

Comment on lines +72 to +73
def get_solution_ids(self) -> List[Any]:
return [None]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this return [] if self._nl_info is None?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, good catch. I need to add a test for this, too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did modify this. There are some lingering issues, but I would prefer to address them after #3703 is merged. I added an issue to track this (#3917). Right now, I don't think it is possible to hit the case where _nl_info is None. What happens is that results.solution_loader ends up being None instead. I personally would find that unexpected...

Comment thread pyomo/contrib/solver/solvers/asl_sol_reader.py
Comment thread pyomo/contrib/solver/solvers/highs.py Outdated
Comment thread pyomo/contrib/solver/solvers/asl_sol_reader.py Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants