Skip to content

GDP Hull transform: add handling for cases of constraint functions not well-defined at the origin#3880

Open
sadavis1 wants to merge 46 commits into
Pyomo:mainfrom
sadavis1:hull-fix
Open

GDP Hull transform: add handling for cases of constraint functions not well-defined at the origin#3880
sadavis1 wants to merge 46 commits into
Pyomo:mainfrom
sadavis1:hull-fix

Conversation

@sadavis1
Copy link
Copy Markdown
Contributor

@sadavis1 sadavis1 commented Mar 23, 2026

Fixes # n/a

Summary/Motivation:

When applying the gdp.hull transformation to a model containing a constraint function whose evaluation is not well-defined when every variable is fixed at zero, the perspective functions used by the hull transformation are not well-defined either and lead to errors, especially when the mode is set to FurmanSawayaGrossmann (which is the useful one). This PR slightly alters the mathematical formulation to permit a nonzero base point which can be used in place of the origin, and adds a heuristic to try to magically find one by calling gurobi. When the zero point works, it uses that so Gurobi is never invoked.

Note: this is a minor merge conflict with #3874, if that is merged first I can rebase this

Changes proposed in this PR:

  • Alter mathematical formulation for gdp.hull transformation to include a base point
  • Add a heuristic to get a well-defined point. This is done at the pyomo level by solving a subproblem using a nonlinear solver, even though solvers usually have a way to do this sort of thing directly, because doing it directly requires setting many solver-specific options and in some cases triggers bugs in solvers (this happened for BARON during testing). This is done using a walker to generate constraints corresponding to each potentially ill-defined function evaluation.
  • Change the way LocalVars works to permit a category of variables that still need to be disaggregated now, but do not need to worry about global constraints and also do not need to be re-disaggregated in any parent disjunct if the GDP is nested ("generalized local vars"). This category is naturally occupied by local variables that have an offset (since the offset handling happens during disaggregation) but other variables may be placed in this category by the user by marking them LocalVars even though they appear on multiple disjuncts. Ordinary LocalVars occupying only one disjunct behave the same way as before.

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 Mar 24, 2026

Codecov Report

❌ Patch coverage is 91.84783% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.12%. Comparing base (dccdbdd) to head (f5b7cb7).

Files with missing lines Patch % Lines
pyomo/gdp/plugins/hull.py 91.84% 15 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main    #3880    +/-   ##
========================================
  Coverage   90.11%   90.12%            
========================================
  Files         905      905            
  Lines      107502   107644   +142     
========================================
+ Hits        96878    97016   +138     
- Misses      10624    10628     +4     
Flag Coverage Δ
builders 29.11% <17.93%> (-0.02%) ⬇️
default 86.46% <91.84%> (?)
expensive 35.50% <17.93%> (?)
linux 87.61% <91.84%> (-2.00%) ⬇️
linux_other 87.61% <91.84%> (+<0.01%) ⬆️
oldsolvers 28.06% <17.93%> (-0.01%) ⬇️
osx 83.00% <91.84%> (+<0.01%) ⬆️
win 85.54% <91.84%> (+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.

@blnicho blnicho requested a review from emma58 March 24, 2026 18:48
Copy link
Copy Markdown
Contributor

@emma58 emma58 left a comment

Choose a reason for hiding this comment

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

Thank you so much for this @sadavis1, some of this is quite unpleasant to think through! :P A lot of comments and questions, but overall this is looking good. @jsiirola, I left a couple questions for you in the comments too.

Comment thread pyomo/gdp/plugins/hull.py Outdated
Comment thread pyomo/gdp/plugins/hull.py Outdated
Comment thread pyomo/gdp/plugins/hull.py Outdated
Comment thread pyomo/gdp/plugins/hull.py Outdated
Comment thread pyomo/gdp/plugins/hull.py Outdated
Comment thread pyomo/gdp/plugins/hull.py Outdated
Comment thread pyomo/gdp/plugins/hull.py Outdated
Comment thread pyomo/gdp/plugins/hull.py Outdated
Comment thread pyomo/gdp/tests/test_hull.py Outdated
Comment thread pyomo/gdp/tests/test_hull.py Outdated
@emma58 emma58 requested a review from jsiirola March 24, 2026 22:14
@sadavis1
Copy link
Copy Markdown
Contributor Author

sadavis1 commented May 4, 2026

Merged in the latest changes from main, fixing conflicts, and addressed review comments - thank you David for the review.

@blnicho blnicho requested a review from bernalde May 4, 2026 23:00
@sadavis1
Copy link
Copy Markdown
Contributor Author

sadavis1 commented May 5, 2026

(1 character fix; test was throwing the right exception but not for the exact reason I wanted)

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 bunch of minor questions. I am comfortable deferring all except for what looks like a bug in the division domain handler.

Comment thread pyomo/gdp/plugins/hull.py Outdated
orig_values = ComponentMap()
orig_fixed = ComponentMap()
for x in itertools.chain(regular_vars, fallback_vars):
x0_map[x] = 0 # ZeroConstant?
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 think we have (slowly) moving away from using NumericConstant (including ZeroConstant) anywhere in Pyomo.

Comment thread pyomo/gdp/plugins/hull.py Outdated
orig_fixed = ComponentMap()
for x in itertools.chain(regular_vars, fallback_vars):
x0_map[x] = 0 # ZeroConstant?
orig_values[x] = value(x, exception=False)
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.

If these are all going to be Vars, any reason not to just use x.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.

Changed to using .value throughout when dealing with Vars

Comment thread pyomo/gdp/plugins/hull.py Outdated
Comment on lines +513 to +516
except ValueError: # ('math domain error')
pass
except ZeroDivisionError:
pass
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 could be

Suggested change
except ValueError: # ('math domain error')
pass
except ZeroDivisionError:
pass
except (ValueError, ZeroDivisionError): # ('math domain error')
pass

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.

Made that change

Comment thread pyomo/gdp/plugins/hull.py
Comment on lines +466 to +473
results = self._config.well_defined_points_heuristic_solver.solve(
test_model, load_solutions=False
)
if results.solver.termination_condition is TerminationCondition.infeasible:
return False
if results.solver.status is not SolverStatus.ok:
raise GDP_Error(f"Unexpected solver status {results.solver.status}.")
test_model.solutions.load_from(results)
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 wonder if we should just extend the SolverFactory so that it could be used as a domain validator? It already handles the str -> solver mapping. We would just need to augment it to pass through any solver instances that are instances of registered classes (that way the LegacySolverFactory would admit legacy solvers but not (unwrapped) contrib.solver solvers).

Comment thread pyomo/gdp/plugins/hull.py
Comment on lines +575 to +578
if results.solver.termination_condition is TerminationCondition.infeasible:
return False
if results.solver.status is not SolverStatus.ok:
raise GDP_Error(f"Unexpected solver status {results.solver.status}.")
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.

Would it be more consistent / future-proof to just check_optimal_termination? Or maybe not, because we don't care about optimality -- but then, should we make a peer utility for check_feasible_termination that we can use here?

Comment thread pyomo/gdp/plugins/hull.py
Comment on lines +1937 to +1938
# Same LP vs MIP problem as before
return (arg >= EPS_HEURISTIC,)
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.

What if the arg is allowed to be negative? (or by bounds is strictly negative?)

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 think this is a problem if arg is strictly negative. Since it's a heuristic, it's okay so long as it can be positive, but I think this would actually go infeasible if it was non-positive?

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 think the best change here it to just relax the bounds while solving, although it does not eliminate every failure case -- see the discussion below

Comment thread pyomo/gdp/plugins/hull.py Outdated
if (
arg.__class__ in EXPR.native_types
or not arg.is_potentially_variable()
or node.name not in _unary_function_handlers
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 wuold generally prefer "positive inaction" - that is, maintain a list (set) of non-restrictive unary functions, and a map of unary functions that imply domain restrictions, and then raise an exception if the node is not in either set.

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.

Changed - we now check against a set for all UnaryFunctionExpression. Note that this doesn't cover AbsExpression as it's a subclass, but that's currently the only one

Copy link
Copy Markdown
Contributor

@bernalde bernalde left a comment

Choose a reason for hiding this comment

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

Blocking issues:

  • The generated domain constraints for nonzero expressions still force the argument to be positive. This makes valid strictly negative denominators and negative-integer-power bases infeasible during the well-defined-point search.

Nonblocking issues:

  • There is one stale comment around the numeric zero used for the origin substitution.

Questions: none.

Tests run:

  • python -m pytest -q pyomo/gdp/tests/test_hull.py::DomainRestrictionTest pyomo/gdp/tests/test_hull.py::WellDefinedConstraintWalkerTest pyomo/gdp/tests/test_hull.py::TestGeneralizedLocalVars -> 4 passed, 6 skipped.
  • python -m pytest -q pyomo/gdp/tests/test_hull.py -> 151 passed, 10 skipped.
  • python -m black --check --diff pyomo/gdp/plugins/hull.py pyomo/gdp/tests/test_hull.py -> 2 files would be left unchanged.
  • Direct walker reproduction: _WellDefinedConstraintGenerator emits 0.0001 <= x for both 1 / x and x**-1 when x has bounds (-2, -1).

Gurobi is not available in my local environment, so the Gurobi-dependent heuristic tests were skipped locally. Hosted CI is currently green.

I would not merge this until the blocking issues above are addressed.

Comment thread pyomo/gdp/plugins/hull.py
return ()
else:
# return base != 0
return (base >= EPS_HEURISTIC,)
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.

Blocking: This treats every negative-integer power as if the only acceptable nonzero base is positive. A model with x bounded in [-2, -1] and x**-1 is well-defined, but the helper model gets x >= EPS_HEURISTIC and becomes infeasible before the transformation can find a base point. Please use a shared nonzero-domain helper that chooses base <= -EPS_HEURISTIC when expression bounds rule out the positive side, and add a regression test for a strictly negative base.

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.

See the discussion below

Comment thread pyomo/gdp/plugins/hull.py
Comment thread pyomo/gdp/plugins/hull.py Outdated
@bernalde
Copy link
Copy Markdown
Contributor

Prepared fixes for the remaining actionable review comments, but I could not push them to the PR head branch.

Commits pushed:

  • 9d9c0800d Address hull domain review comments on bernalde:review/pr-3880-comment-fixes

Push status:

  • Pushing to sadavis1:hull-fix failed with remote: Permission to sadavis1/pyomo.git denied to bernalde / HTTP 403.
  • Because the PR head was not updated, I am not replying to inline threads or marking anything resolved yet.

Main changes prepared:

  • Added a shared nonzero-domain helper for the well-defined-point heuristic. It uses expression bounds when available to choose arg <= -EPS_HEURISTIC for strictly negative domains, covering both division denominators and negative-integer power bases.
  • Removed the stale ZeroConstant comment, switched saved Var values to x.value, and combined the expected ValueError / ZeroDivisionError handling.
  • Added positive-inaction handling for known unrestricted unary functions and an explicit error for unhandled unary functions.
  • Added regression coverage for strictly negative nonzero domains and unary-function handling.

Tests run:

  • conda run -n pyomo-local-test python -m pytest -q pyomo/gdp/tests/test_hull.py::DomainRestrictionTest::test_nonzero_domain_strictly_negative pyomo/gdp/tests/test_hull.py::WellDefinedConstraintWalkerTest -> 5 passed.
  • conda run -n pyomo-local-test python -m pytest -q pyomo/gdp/tests/test_hull.py::DomainRestrictionTest pyomo/gdp/tests/test_hull.py::WellDefinedConstraintWalkerTest pyomo/gdp/tests/test_hull.py::TestGeneralizedLocalVars -> 14 passed.
  • conda run -n pyomo-local-test python -m pytest -q pyomo/gdp/tests/test_hull.py -> 143 passed, 22 skipped.
  • conda run -n pyomo-local-test python -m black --check --diff pyomo/gdp/plugins/hull.py pyomo/gdp/tests/test_hull.py -> 2 files would be left unchanged.
  • Manual strict-negative reproduction for 1 / x and x**-1 with x bounded (-2, -1) now transforms successfully with x0 = -2.0.

Comments intentionally not changed in this commit:

  • Solver validator centralization / SolverFactory extension: out of scope for this PR; the current option documentation already notes the V1 solver API expectation.
  • New feasible-termination utility: out of scope for this PR; this would be a broader solver-results API addition.
  • Threads already addressed by earlier commits, such as deterministic ComponentSet ordering, manual well_defined_points coverage, class option docs, generalized LocalVars, and restoration on error, were left unchanged.

Remaining risk:

  • The prepared commit still needs to be applied to sadavis1:hull-fix before the PR reflects these fixes.

@sadavis1
Copy link
Copy Markdown
Contributor Author

This business with positive versus nonzero values is a very good point. Keeping bounds is preferable for a nonlinear solver, and I originally thought it was fine to keep them since it was unlikely to break feasibility of the subproblem (though there are somewhat contrived counterexamples). But since the constraint associated with certain functions was changed from x != 0 to x > 0, it becomes much easier to break feasibility here just by bounding the variable on the wrong side of the number line. There are a few different ways to resolve this, each with some benefits and downsides.

  • I don't want to have x != 0 because that implies a big-M constraint and a MIP solve, and I don't want to do something potentially expensive in a simple heuristic unless the user is really asking for it.
  • It's not really possible for me to choose ahead of time whether to strengthen to x < 0 or x > 0, since answering the question of which of those is feasible is as hard as the original problem in the general case. Of course I could use fbbt to switch to negative when the expression is strictly negative by its bounds, but that is really ad hoc
  • I could extend the bounds out symmetrically around zero (e.g. (1, 3) is converted to (-3, 3)) which eliminates some failure cases such as 1/x for -2 <= x <= 1.
  • I can just remove the bounds entirely which of course eliminates any failure caused by a bound, including these. But it won't remove every failure case (e.g. 1/(-x^2) still fails here). Since I have no evidence of harm caused by unbounded variables (gurobi and baron pass the tests at least) I think this is probably the best option for now - remember that it isn't the end of the world if we fail here sometimes, since a dedicated user can still pick their base point manually.

@bernalde
Copy link
Copy Markdown
Contributor

I think that at least keeping track of these comments as an issue would be the right way to not have it be lost in this PR. I would commend you to try whether the problem is in fact resolved for instances that suffer from this in GDPLib (kaibel and gdp_col are among them). I have PRs in that repo that avoid the issue altogether by a hand-made transformation, but maybe with this work, it is unnecessary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants