Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
37de1cb
Fix no results returned when no discrete variables are present in Min…
Toflamus Feb 19, 2026
4fb7eac
Integrate the test into existing tests
Toflamus Feb 20, 2026
9f79251
Black the format
Toflamus Feb 20, 2026
29a96be
Update pyomo/contrib/mindtpy/algorithm_base_class.py
Toflamus Feb 20, 2026
44703d1
Update pyomo/contrib/mindtpy/algorithm_base_class.py
Toflamus Feb 20, 2026
c4c241b
Update pyomo/contrib/mindtpy/algorithm_base_class.py
Toflamus Feb 20, 2026
601e089
update test_mindtpy_lp_nlp.py and delete test_mindtpy_no_discrete.py
Toflamus Feb 20, 2026
048a075
deleted: pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py
Toflamus Feb 20, 2026
6071149
Merge branch 'fix/mindtpy-fix' of https://github.com/Toflamus/pyomo i…
Toflamus Feb 20, 2026
9311f4e
Apply suggestion from @tarikLG
Toflamus Feb 20, 2026
58c0d75
Update pyomo/contrib/mindtpy/algorithm_base_class.py
Toflamus Feb 20, 2026
8fbfe80
Update mirror helper
Toflamus Feb 23, 2026
655e7f8
Delete the no_discrete test
Toflamus Feb 24, 2026
33cf9d8
Build a separate test for changes
Toflamus Feb 24, 2026
cc1d813
Add copyright and black format for the new test file
Toflamus Feb 24, 2026
8071acc
Update pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py
Toflamus Feb 24, 2026
a27827f
Update pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py
Toflamus Feb 24, 2026
a92cd27
Update pyomo/contrib/mindtpy/algorithm_base_class.py
Toflamus Feb 24, 2026
925d9a8
Update pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py
Toflamus Feb 24, 2026
ef3bcfc
Merge pull request #1 from Toflamus/fix/mindtpy-fix
bernalde Feb 24, 2026
e964ac6
Black the format of pyomo/pyomo/contrib/mindtpy/algorithm_base_class.py
Toflamus Feb 24, 2026
27a040a
Merge pull request #2 from Toflamus/fix/mindtpy-fix
bernalde Feb 24, 2026
fd70276
Merge branch 'main' into main
jsiirola Mar 6, 2026
14110bd
Fix bound inference to only set primal bound, not dual bound
Toflamus Mar 20, 2026
1a524b6
Merge pull request #4 from Toflamus/fix/mindtpy-fix
bernalde Mar 25, 2026
1e64f38
Merge branch 'main' into main
bernalde Mar 25, 2026
34608bc
Consolidate duplicate TerminationCondition imports in test_mindtpy_no…
bernalde Apr 1, 2026
afb8408
Apply black formatting to test_mindtpy_no_discrete.py
bernalde Apr 1, 2026
1abf71a
Merge branch 'main' into main
jsiirola Apr 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 100 additions & 6 deletions pyomo/contrib/mindtpy/algorithm_base_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,12 +246,46 @@ def create_utility_block(self, model, name):
self.add_cuts_components(model)

def model_is_valid(self):
"""Determines whether the model is solvable by MindtPy.
"""
Check if the model requires the MindtPy MINLP decomposition algorithm.

This method performs a structural check on the working model.
It determines if the problem is a true Mixed-Integer program.
If no discrete variables are present, it serves as a short-circuit.
In short-circuit cases, the problem is solved immediately as an LP or NLP.

Returns
-------
bool
True if model is solvable in MindtPy, False otherwise.
True if the model has discrete variables and requires MindtPy iteration.
False if the model is purely continuous (LP or NLP).

Notes
-----
The validity check follows a specific hierarchical logic:

1. Discrete Variable Presence
The method first inspects ``MindtPy.discrete_variable_list``.
If this list is not empty, the function implicitly returns True.
This indicates the model is a valid MINLP for decomposition.

2. Continuous Model Handling (The "False" cases)
If the discrete variable list is empty, the model is "invalid" for MINLP.
The method then differentiates between LP and NLP structures.

3. NLP Branch
The code checks the ``polynomial_degree`` of constraints and objectives.
If any degree is non-linear (not in ``mip_constraint_polynomial_degree``),
it is treated as a standard Nonlinear Program (NLP).
The ``config.nlp_solver`` is called to solve the original model directly.

4. LP Branch
If all components are linear, it is treated as a Linear Program (LP).
The ``config.mip_solver`` is utilized for the solution process.
Solutions are loaded directly back into the ``original_model``.

In both continuous cases, the method returns False to bypass the main loop.
This ensures MindtPy does not attempt decomposition on trivial continuous models.
"""
m = self.working_model
MindtPy = m.MindtPy_utils
Expand All @@ -261,6 +295,7 @@ def model_is_valid(self):
prob = self.results.problem
if len(MindtPy.discrete_variable_list) == 0:
config.logger.info('Problem has no discrete decisions.')

obj = next(m.component_data_objects(ctype=Objective, active=True))
if (
any(
Expand All @@ -278,11 +313,16 @@ def model_is_valid(self):
update_solver_timelimit(
self.nlp_opt, config.nlp_solver, self.timing, config
)
self.nlp_opt.solve(
results = self.nlp_opt.solve(
self.original_model,
tee=config.nlp_solver_tee,
load_solutions=self.nlp_load_solutions,
**config.nlp_solver_args,
)
if len(results.solution) > 0:
self.original_model.solutions.load_from(results)

self._mirror_direct_solve_results(results=results, obj=obj, prob=prob)
return False
else:
config.logger.info(
Expand All @@ -302,6 +342,8 @@ def model_is_valid(self):
)
if len(results.solution) > 0:
self.original_model.solutions.load_from(results)

self._mirror_direct_solve_results(results=results, obj=obj, prob=prob)
return False

# Set up dual value reporting
Expand All @@ -317,6 +359,56 @@ def model_is_valid(self):
# need to do some kind of transformation (Glover?) or throw an error message
return True

def _mirror_direct_solve_results(self, results, obj, prob):
"""Mirror a direct (LP/NLP) solve result into MindtPy's results object.

This is used by `model_is_valid()` when the instance is purely continuous
(no discrete variables) and MindtPy short-circuits to a direct LP/NLP
solve.

Parameters
----------
results : SolverResults
Results returned by the direct solver.
obj : ObjectiveData
The (active) objective on the model that was solved.
prob : ProblemInformation
The MindtPy `self.results.problem` object to populate.
"""
prob.sense = obj.sense

# Solver status / termination metadata
self.results.solver.status = getattr(results.solver, 'status', SolverStatus.ok)
self.results.solver.termination_condition = results.solver.termination_condition
self.results.solver.message = getattr(results.solver, 'message', None)

# Prefer bound info from the direct solver results if present
lb = getattr(results.problem, 'lower_bound', None)
ub = getattr(results.problem, 'upper_bound', None)
if lb is not None:
prob.lower_bound = lb
if ub is not None:
prob.upper_bound = ub

# Fallback: if the solver reports optimal termination but does not
# provide explicit bounds, infer the *primal* bound from the objective
# value. A feasible solution only proves one side of the bound:
# - minimization → upper bound (any feasible point is an UB)
# - maximization → lower bound (any feasible point is a LB)
# We cannot infer the dual bound without a guarantee of global
# optimality (e.g. convexity), which a local NLP solver does not give.
if (
lb is None or ub is None
) and self.results.solver.termination_condition == tc.optimal:
obj_val = value(obj.expr, exception=False)
if obj_val is not None:
if obj.sense == minimize:
if ub is None:
prob.upper_bound = obj_val
else:
if lb is None:
prob.lower_bound = obj_val
Comment on lines +393 to +410
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'm confused about this: Isn't the results object from the solver already reporting whatever bounds it got? Why can't you just pass those through? If it's local, it should only have the primal bound, and not the dual bound, which is exactly what you want.

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 code does pass through bounds from the solver first (lines 385-391). The fallback logic here only activates when the solver doesn't provide bounds at all. This happens with NLP solvers like IPOPT, which never populate lower_bound or upper_bound in their results objects (you can confirm this in sol.py - the sol reader sets constraint/variable/objective counts but never sets bounds). So the fallback is necessary to infer the primal bound from the objective value when the solver reports optimal but provides no bound information at all.

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.

Oh, sorry, I think I was getting mixed up between old interfaces and new! This makes sense.


def build_ordered_component_lists(self, model):
"""Define lists used for future data transfer.

Expand Down Expand Up @@ -2801,14 +2893,16 @@ def solve(self, model, **kwds):
self._log_solver_intro_message()
self.initialize_subsolvers()

# Initialize MindtPy results early so that direct LP/NLP short-circuit
# paths can still return a valid SolverResults object.
setup_results_object(self.results, self.original_model, config)

# Validate the model to ensure that MindtPy is able to solve it.
if not self.model_is_valid():
return
return self.results

MindtPy = self.working_model.MindtPy_utils

setup_results_object(self.results, self.original_model, config)

# Reformulate the objective function.
self.objective_reformulation()

Expand Down
Loading
Loading