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
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.

Please address the inline comments.

Comment thread pyomo/gdp/plugins/hull.py Outdated
local_vars[disj].add(var)
all_local_vars.add(var)
else:
vars_to_disaggregate[disjunct].add(var)
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 branch uses the stale outer-loop disjunct variable instead of the current disj. For a variable marked LocalVars while appearing in multiple disjuncts, this only adds the variable to vars_to_disaggregate for the last active disjunct, leaving other disjuncts without a substitute. A simple two-disjunct model with x in both disjuncts and LocalVars entries for both currently fails during transformation with ValueError: No value for uninitialized ScalarVar object x.

Please use the current disjunct and add regression coverage for the generalized-local-vars case. The fix may need to ensure every disjunct in disjuncts_var_appears_in[var] gets a disaggregated variable once the variable is treated as generalized local.

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. I think all that needs to be done here is to use the correct variable disj instead of disjunct (I really dislike how python keeps that variable in scope after its loop is finished...). I fixed that and added a test for generalized localvars to ensure it behaves as intended.

Comment thread pyomo/gdp/plugins/hull.py Outdated
f"{e.__class__.__name__} (was prepared for success or a ValueError)."
)
raise
# Second, try making it well-defined by editing only the regular vars
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: _get_well_defined_point() mutates original model variables before calling the heuristic solver, but restores values/fixed states only on the successful path. If the solver is unavailable, errors, or no point is found, the user model is left changed. For example, with a fixed initialized variable, a failed hull transform due to missing gurobipy left x changed from value=5, fixed=True to value=0, fixed=False.

Please wrap the solve/search section in try/finally and restore orig_values and orig_fixed on all exits, including solver exceptions and the GDP_Error path.

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.

Fixed -- now handled in a finally block, so the original model state is restored even when we exit by throwing

self.assertEqual(m_pts[m.disjunction][m.a], 0)
self.assertEqual(m_pts[m.disjunction][m.x], 0)

@unittest.skipUnless(gurobi_available, "Gurobi is not available")
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.

Nonblocking: Most new domain-restriction coverage is skipped when Gurobi is unavailable, including the documented escape hatch for users who pass well_defined_points directly. Please add at least one non-Gurobi test that supplies a ComponentMap manually, for example transforming log(m.x - 1) with {m.disjunction: ComponentMap([(m.x, 2)])}.

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 don't think this is a concern--we have Gurobi in our testing infrastructure.

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's no trouble so I went ahead and added one anyway

Comment thread pyomo/gdp/plugins/hull.py
""",
),
)
CONFIG.declare(
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.

Nonblocking: The class docstring says the transformation accepts keyword arguments but still lists only perspective_function, EPS, and targets. Since this PR adds public options, please document well_defined_points and well_defined_points_heuristic_solver there or point readers to the generated CONFIG documentation.

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.

Added those entries to the class doc

@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.

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