Skip to content

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

Open
michaelbynum wants to merge 27 commits intoPyomo:mainfrom
michaelbynum:trivial_constraints
Open

Add tests for trivial constraints in pyomo/contrib/solver#3703
michaelbynum wants to merge 27 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 82.69231% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.99%. Comparing base (c1ce36d) to head (fe00fdd).

Files with missing lines Patch % Lines
pyomo/contrib/solver/solvers/gams.py 77.96% 13 Missing ⚠️
pyomo/contrib/solver/common/solution_loader.py 66.66% 4 Missing ⚠️
pyomo/contrib/solver/common/results.py 94.44% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #3703   +/-   ##
=======================================
  Coverage   89.98%   89.99%           
=======================================
  Files         904      904           
  Lines      106891   106934   +43     
=======================================
+ Hits        96191    96231   +40     
- Misses      10700    10703    +3     
Flag Coverage Δ
builders 29.16% <14.42%> (+<0.01%) ⬆️
default 86.32% <82.69%> (?)
expensive 35.59% <14.42%> (?)
linux 87.46% <82.69%> (-2.03%) ⬇️
linux_other 87.46% <82.69%> (+<0.01%) ⬆️
oldsolvers 28.09% <14.42%> (+<0.01%) ⬆️
osx 82.82% <37.50%> (-0.01%) ⬇️
win 85.89% <82.69%> (-0.01%) ⬇️
win_other 85.89% <82.69%> (-0.01%) ⬇️

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

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 implemented this. Let me know what you think.

Comment thread pyomo/contrib/solver/common/solution_loader.py Outdated
Comment thread pyomo/contrib/solver/common/solution_loader.py Outdated
Comment thread pyomo/contrib/solver/common/solution_loader.py Outdated
) -> 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?

Comment thread pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py Outdated
}
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?

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

@blnicho blnicho marked this pull request as draft April 30, 2026 13:01
@michaelbynum
Copy link
Copy Markdown
Contributor Author

  • I still have one comment left to address.
  • There were several failing tests that I had to fix. I think there is still one more. These were real failures. There was a spot in the highs interface where I was using the wrong attribute to determine if there was a feasible solution or not. It works in most cases, but not all. I think I have that fixed. There was also an edge case in the gurobi persistent interface where a trivially infeasible constraint did not get caught if it started as a trivially feasible constraint and changed because of a mutable parameter or fixed variable value.

@michaelbynum
Copy link
Copy Markdown
Contributor Author

Tests are not running. I'm going to mark this as ready and see if that helps.

@michaelbynum michaelbynum marked this pull request as ready for review May 3, 2026 18:53
Comment on lines -390 to +397
skip_trivial_constraints
and (lb is None or lb <= offset)
# skip_trivial_constraints
# and (lb is None or lb <= offset)
(lb is None or lb <= offset)
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.

Not skipping trivially feasible constraints does not seem to work. When I create a trivially feasible constraint and do not skip them, I get exceptions (https://github.com/Pyomo/pyomo/actions/runs/25291155719/job/74143137773). I checked codecov, and this bit of code is not covered on the main branch. I think we should just always skip trivially feasible constraints in the gams writer. Thoughts? If everyone agrees, I can deprecate the option.

@michaelbynum
Copy link
Copy Markdown
Contributor Author

The 13 uncovered lines in gams.py are not related to this PR. I just indented that code inside of a try/except block.

@blnicho blnicho requested a review from jsiirola May 4, 2026 19:49
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