Skip to content

Add tests for trivial constraints in pyomo/contrib/solver#3703

Draft
michaelbynum wants to merge 19 commits intoPyomo:mainfrom
michaelbynum:trivial_constraints
Draft

Add tests for trivial constraints in pyomo/contrib/solver#3703
michaelbynum wants to merge 19 commits intoPyomo:mainfrom
michaelbynum:trivial_constraints

Conversation

@michaelbynum
Copy link
Copy Markdown
Contributor

@michaelbynum michaelbynum commented Aug 14, 2025

Summary/Motivation:

This PR adds a test for trivial constraints (both feasible and infeasible) to the new solver tests. It also fixes some related bugs.

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.

@codecov
Copy link
Copy Markdown

codecov Bot commented Aug 14, 2025

Codecov Report

❌ Patch coverage is 61.36364% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.72%. Comparing base (81ec316) to head (ae7d2d9).

Files with missing lines Patch % Lines
...ontrib/solver/solvers/gurobi/gurobi_direct_base.py 57.14% 9 Missing ⚠️
pyomo/contrib/solver/common/solution_loader.py 60.00% 8 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3703      +/-   ##
==========================================
- Coverage   89.96%   88.72%   -1.24%     
==========================================
  Files         903      903              
  Lines      106604   106646      +42     
==========================================
- Hits        95902    94620    -1282     
- Misses      10702    12026    +1324     
Flag Coverage Δ
builders 29.21% <36.36%> (+<0.01%) ⬆️
default 86.27% <61.36%> (?)
expensive 35.66% <36.36%> (?)
linux 61.81% <36.36%> (-27.65%) ⬇️
linux_other 61.81% <36.36%> (-25.61%) ⬇️
oldsolvers 28.14% <36.36%> (+<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.

return self.var_map[self.v2id]


class GurobiDirectQuadratic(GurobiDirectBase):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shouldn't this class be named GurobiConsistentQuadratic? In GurobiObserver this is the used name.

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'm confused. Can you point me to to the place in GurobiObserver you are referring to?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I was referring to the field opt of the class _GurobiObserver.

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.

This class does not use the observer. This one is not persistent. Maybe I am missing something?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry, maybe I wasn’t clear enough. From what I see, in _GurobiObserver you are requesting opt as a GurobiPersistantQuadratic, but this class doesn’t appear to be defined. However, GurobiPersistant inherits from GurobiDirectQuadratic and is what gets passed to _GurobiObserver. So either I’m missing something, or there’s a naming issue.

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.

Good catch! Thank you. The observer is expecting GurobiPersistent not GurobiPersistentQuadratic. I will fix that.

self._solver_model.setObjective(gurobi_expr + repn_constant, sense=sense)


class _GurobiObserver(Observer):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since this class is just calling the "same" functions on the opt field. Why not just inherits Observer in GurobiPersistantSolver?

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.

It has to be done this way in order for "manual mode" of the persistent solvers to work. If someone calls opt.add_constraint directly on the solver interface, we need the observer to be updated so things stay synchronized.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You’re probably right, and I might be missing something. However, I don’t see why adding Observer as a superclass of GurobiPersistent wouldn't mean the observer is always updated, since it’s essentially the same as the persistent object.

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.

The important thing is that the _change_detector methods get called. This makes my head hurt a little. Let me think on it.

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.

So if we did something like

class GurobiPersistent(GurobiDirectQuadratic, Observer):
    def add_constraints(self, cons):
        self._change_detector.add_constraints(cons)

Then there would not be a way to call _add_constraints. It would just be an infinte loop. If GurobiPersistent.add_constraints gets called, it would call the _change_detector, and the _change_detector would again call add_constraints on the observer, which is GurobiPersistent.add_constraints.

That is convoluted, but did it make any sense?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With the current design, this will always happen: the solver calls the detector, which calls the observer, which in turn calls the solver (Opt) again. Or am I missing something?

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.

If GurobiPersistent.add_constraints gets called, then that will call ModelChangeDetector.add_constraints, which will then call _GurobiObserver.add_constraints, which then calls GurobiPersistent._add_constraints and not GurobiPersistent.add_constraints. Very subtle but important difference there. However, this conversation is making me realize how convoluted this is. Maybe someone has a better idea?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Okay, understood! One possible fix would be to give all Observer functions a common prefix (e.g., on_*) and make GurobiPersistantSolver a subclass of Observer. We could then reimplement the functions accordingly.

raise NoSolutionError()

def load_vars(
self, vars_to_load: Sequence[VarData] | None = None, solution_id=None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shouldn't we use Optional here?

@blnicho blnicho removed this from Pyomo 6.10 Feb 4, 2026
@michaelbynum michaelbynum changed the title [Depends on #3701] Add tests for trivial constraints in pyomo/contrib/solver Add tests for trivial constraints in pyomo/contrib/solver Apr 27, 2026
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.

Some minor nits (we can debate those), and one significant one (we should really propagate information about the infeasible constraint out through the NoSolutionError).

Comment on lines +379 to +380
except InfeasibleConstraintException:
res = self._get_infeasible_results(config=config)
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 exception should have information about the infeasible constraint in its message. That should be preserved and added to the NoSolutionError / NoOptimalSolutionError / NoFeasibleSolutionError exceptions

def __init__(self) -> None:
pass

def get_solution_ids(self) -> 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.

This is redundant (covered by the base class)

def get_number_of_solutions(self) -> int:
return 0

def load_solution(self):
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 is redundant (the base class will call get_vars(), which will raise the exception)

def load_solution(self):
raise NoSolutionError()

def load_vars(self, vars_to_load: Sequence[VarData] | None = None) -> 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.

This is redundant (the base class will call get_vars(), which will raise the exception)

) -> Mapping[VarData, float]:
raise NoSolutionError()

def load_import_suffixes(self):
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.

Is this is redundant? If the model has dual / rc Suffixes, then the base class implementation will call get_duals() / get_reduced_costs(), which will raise the exception. If those suffixes aren't defined, then nothing will happen ... but that might be consistent with the behavior implied by the base class implementation?

from pyomo.common.errors import PyomoException
from pyomo.common.shutdown import python_is_shutting_down
from pyomo.common.timing import HierarchicalTimer
from pyomo.common.errors import InfeasibleConstraintException
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.

Why was this added? It appears to be unused.

}
return GurobiDirectBase._tc_map

def _get_infeasible_results(self, config):
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 be promoted to a standard method (in results / util) that is specialized here?

@blnicho blnicho marked this pull request as draft April 30, 2026 13:01
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