Skip to content
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d16bee5
adding tests for trivial constraints and fixing bugs
michaelbynum Aug 14, 2025
2001d15
Merge branch 'solver_api' into trivial_constraints
michaelbynum Aug 14, 2025
d25e721
run black
michaelbynum Aug 14, 2025
02f383d
Merge branch 'solver_api' into trivial_constraints
michaelbynum Aug 16, 2025
3d302a1
Merge branch 'solver_api' into trivial_constraints
michaelbynum Aug 16, 2025
ecd602d
Merge branch 'solver_api' into trivial_constraints
michaelbynum Aug 18, 2025
92e77ba
merge solver_api into trivial_constraints
michaelbynum Oct 5, 2025
045f537
merge main
michaelbynum Dec 12, 2025
253bdd9
Merge branch 'solver_api' into trivial_constraints
michaelbynum Dec 18, 2025
c3ad9b5
Merge branch 'solver_api' into trivial_constraints
michaelbynum Jan 29, 2026
ef6dcf6
Merge branch 'solver_api' into trivial_constraints
michaelbynum Feb 13, 2026
fb38368
Merge branch 'solver_api' into trivial_constraints
michaelbynum Feb 13, 2026
0365ae5
merge solver_api into trivial_constraints
michaelbynum Mar 23, 2026
7293c29
Merge branch 'solver_api' into trivial_constraints
michaelbynum Mar 23, 2026
75fee85
Merge branch 'solver_api' into trivial_constraints
michaelbynum Mar 26, 2026
29a72f7
Merge branch 'solver_api' into trivial_constraints
michaelbynum Apr 9, 2026
c216e19
Merge branch 'solver_api' into trivial_constraints
michaelbynum Apr 15, 2026
b754a29
merge main
michaelbynum Apr 27, 2026
ae7d2d9
run black
michaelbynum Apr 27, 2026
87a0f75
Merge remote-tracking branch 'origin/main' into trivial_constraints
michaelbynum May 1, 2026
d0c0515
remove redundant code and propagate error messages
michaelbynum May 1, 2026
bf6f6fd
fix highs interface
michaelbynum May 1, 2026
a1ed8e0
better handling of trivially feasible and infeasible constraints
michaelbynum May 3, 2026
29b9bf3
run black
michaelbynum May 3, 2026
eb7abb6
better handling of trivially infeasible constraints
michaelbynum May 3, 2026
c987a9f
run black
michaelbynum May 3, 2026
fe00fdd
better handling of trivially infeasible constraints
michaelbynum May 3, 2026
0c54635
remove unused line
michaelbynum May 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
20 changes: 20 additions & 0 deletions pyomo/contrib/solver/common/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
ADVANCED_OPTION,
DEVELOPER_OPTION,
)
from pyomo.contrib.solver.common.util import NoOptimalSolutionError, NoSolutionError
from pyomo.contrib.solver.common.solution_loader import NoSolutionSolutionLoader
from pyomo.opt.results.solution import SolutionStatus as LegacySolutionStatus
from pyomo.opt.results.solver import (
TerminationCondition as LegacyTerminationCondition,
Expand Down Expand Up @@ -242,6 +244,24 @@ def display(
return super().display(content_filter, indent_spacing, ostream, visibility)


def get_infeasible_results(config, err_msg, solver_name, solver_version):
res = Results()
res.solution_loader = NoSolutionSolutionLoader(err_msg)
res.solution_status = SolutionStatus.noSolution
res.termination_condition = TerminationCondition.provenInfeasible
res.incumbent_objective = None
res.objective_bound = None
res.timing_info.gurobi_time = None
res.solver_config = config
res.solver_name = solver_name
res.solver_version = solver_version
if config.raise_exception_on_nonoptimal_result:
raise NoOptimalSolutionError(err_msg)
if config.load_solutions:
raise NoSolutionError(err_msg)
return res


# Everything below here preserves backwards compatibility

legacy_termination_condition_map = {
Expand Down
24 changes: 24 additions & 0 deletions pyomo/contrib/solver/common/solution_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from pyomo.core.base.var import VarData
from pyomo.core.staleflag import StaleFlagManager
from pyomo.core.base.suffix import Suffix
from .util import NoSolutionError


class SolutionLoader:
Expand Down Expand Up @@ -331,6 +332,29 @@ def load_import_suffixes(self):
return self._loader.load_import_suffixes()


class NoSolutionSolutionLoader(SolutionLoader):
def __init__(self, err_msg: str) -> None:
self.err_msg = err_msg

def get_number_of_solutions(self) -> int:
return 0

def get_vars(
self, vars_to_load: Sequence[VarData] | None = None
) -> Mapping[VarData, float]:
raise NoSolutionError(self.err_msg)

def get_duals(
self, cons_to_load: Sequence[ConstraintData] | None = None
) -> dict[ConstraintData, float]:
raise NoSolutionError(self.err_msg)

def get_reduced_costs(
self, vars_to_load: Sequence[VarData] | None = None
) -> Mapping[VarData, float]:
raise NoSolutionError(self.err_msg)


class PersistentSolutionLoader(SolutionLoader):
"""
Loader for persistent solvers
Expand Down
206 changes: 110 additions & 96 deletions pyomo/contrib/solver/solvers/gams.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import pathlib

from pyomo.common.dependencies import attempt_import
from pyomo.common.errors import InfeasibleConstraintException
from pyomo.common.fileutils import Executable, ExecutableData
from pyomo.common.config import (
ConfigValue,
Expand All @@ -37,6 +38,7 @@
Results,
SolutionStatus,
TerminationCondition,
get_infeasible_results,
)
from pyomo.contrib.solver.solvers.gms_sol_reader import GMSSolutionLoader

Expand Down Expand Up @@ -292,110 +294,122 @@ def solve(self, model, **kwds):

output_filename = None
with TempfileManager.new_context() as tempfile:
# IMPORTANT - only delete the whole tmpdir if the solver was the one
# that made the directory. Otherwise, just delete the files the solver
# made, if not keepfiles. That way the user can select a directory
# they already have, like the current directory, without having to
# worry about the rest of the contents of that directory being deleted.
if not config.working_dir:
dname = tempfile.mkdtemp()
else:
dname = config.working_dir
if not os.path.exists(dname):
os.mkdir(dname)
basename = os.path.join(dname, model_name)
output_filename = basename + '.gms'
lst_filename = os.path.join(dname, lst)

timer.start(f'write_gms_file')
with open(output_filename, 'w', newline='\n', encoding='utf-8') as gms_file:
gms_info = GAMSWriter().write(
model, gms_file, config=config.writer_config
)
# NOTE: omit InfeasibleConstraintException for now
timer.stop(f'write_gms_file')
try:
# IMPORTANT - only delete the whole tmpdir if the solver was the one
# that made the directory. Otherwise, just delete the files the solver
# made, if not keepfiles. That way the user can select a directory
# they already have, like the current directory, without having to
# worry about the rest of the contents of that directory being deleted.
if not config.working_dir:
dname = tempfile.mkdtemp()
else:
dname = config.working_dir
if not os.path.exists(dname):
os.mkdir(dname)
basename = os.path.join(dname, model_name)
output_filename = basename + '.gms'
lst_filename = os.path.join(dname, lst)

timer.start(f'write_gms_file')
with open(
output_filename, 'w', newline='\n', encoding='utf-8'
) as gms_file:
gms_info = GAMSWriter().write(
model, gms_file, config=config.writer_config
)
timer.stop(f'write_gms_file')

if config.writer_config.put_results_format == 'gdx':
results_filename = os.path.join(dname, "GAMS_MODEL_p.gdx")
statresults_filename = os.path.join(
dname, "%s_s.gdx" % (config.writer_config.put_results,)
)
else:
results_filename = os.path.join(
dname, "%s.dat" % (config.writer_config.put_results,)
)
statresults_filename = os.path.join(
dname, "%sstat.dat" % (config.writer_config.put_results,)
)
if config.writer_config.put_results_format == 'gdx':
results_filename = os.path.join(dname, "GAMS_MODEL_p.gdx")
statresults_filename = os.path.join(
dname, "%s_s.gdx" % (config.writer_config.put_results,)
)
else:
results_filename = os.path.join(
dname, "%s.dat" % (config.writer_config.put_results,)
)
statresults_filename = os.path.join(
dname, "%sstat.dat" % (config.writer_config.put_results,)
)

####################################################################
# Apply solver
####################################################################
exe_path = config.executable.path()
command = [exe_path, output_filename, "o=" + lst, "curdir=" + dname]
####################################################################
# Apply solver
####################################################################
exe_path = config.executable.path()
command = [exe_path, output_filename, "o=" + lst, "curdir=" + dname]

# handled tee and logfile based on the length of list and
# string respectively
command.append(self._log_levels[(bool(config.tee), bool(config.logfile))])
# handled tee and logfile based on the length of list and
# string respectively
command.append(
self._log_levels[(bool(config.tee), bool(config.logfile))]
)

ostreams = [StringIO()]
if config.tee:
ostreams.append(sys.stdout)
ostreams = [StringIO()]
if config.tee:
ostreams.append(sys.stdout)

with TeeStream(*ostreams) as t:
timer.start('subprocess')
subprocess_result = subprocess.run(
command, stdout=t.STDOUT, stderr=t.STDERR, cwd=dname
)
timer.stop('subprocess')
rc = subprocess_result.returncode
txt = ostreams[0].getvalue()
if config.working_dir:
logger.info("\nGAMS WORKING DIRECTORY: %s\n" % config.working_dir)

if rc:
# If nothing was raised, or for all other cases, raise this
error_message = f"GAMS process encountered an error (returncode={rc})."
if rc == 3:
# Execution Error
# Run check_expr_evaluation, which errors if necessary
error_message += (
"\nError rc=3 (GAMS execution error), to be determined later."
with TeeStream(*ostreams) as t:
timer.start('subprocess')
subprocess_result = subprocess.run(
command, stdout=t.STDOUT, stderr=t.STDERR, cwd=dname
)
error_message += "\nCheck listing file for details.\n"
logger.error(error_message)
logger.error(txt.strip())
if os.path.exists(lst_filename):
with open(lst_filename, 'r') as FILE:
logger.error(
"\nGAMS Listing file:\n\n%s" % (FILE.read().strip(),)
)
raise RuntimeError(error_message)

timer.start('parse_results')
if config.writer_config.put_results_format == 'gdx':
model_soln, stat_vars = self._parse_gdx_results(
config, results_filename, statresults_filename
)
else:
model_soln, stat_vars = self._parse_dat_results(
config, results_filename, statresults_filename
)
timer.stop('parse_results')

####################################################################
# Postsolve (WIP)
results = self._postsolve(
model, timer, config, model_soln, stat_vars, gms_info
)
timer.stop('subprocess')
rc = subprocess_result.returncode
txt = ostreams[0].getvalue()
if config.working_dir:
logger.info("\nGAMS WORKING DIRECTORY: %s\n" % config.working_dir)

if rc:
# If nothing was raised, or for all other cases, raise this
error_message = (
f"GAMS process encountered an error (returncode={rc})."
)
if rc == 3:
# Execution Error
# Run check_expr_evaluation, which errors if necessary
error_message += "\nError rc=3 (GAMS execution error), to be determined later."
error_message += "\nCheck listing file for details.\n"
logger.error(error_message)
logger.error(txt.strip())
if os.path.exists(lst_filename):
with open(lst_filename, 'r') as FILE:
logger.error(
"\nGAMS Listing file:\n\n%s" % (FILE.read().strip(),)
)
raise RuntimeError(error_message)

timer.start('parse_results')
if config.writer_config.put_results_format == 'gdx':
model_soln, stat_vars = self._parse_gdx_results(
config, results_filename, statresults_filename
)
else:
model_soln, stat_vars = self._parse_dat_results(
config, results_filename, statresults_filename
)
timer.stop('parse_results')

results.solver_config = config
results.solver_log = ostreams[0].getvalue()
####################################################################
# Postsolve (WIP)
results = self._postsolve(
model, timer, config, model_soln, stat_vars, gms_info
)

tock = time.perf_counter()
results.timing_info.start_timestamp = start_timestamp
results.timing_info.wall_time = tock - tick
results.timing_info.timer = timer
results.solver_config = config
results.solver_log = ostreams[0].getvalue()

tock = time.perf_counter()
results.timing_info.start_timestamp = start_timestamp
results.timing_info.wall_time = tock - tick
results.timing_info.timer = timer
except InfeasibleConstraintException as err:
err_msg = f'Solution loader does not currently have a valid solution because the problem was proven to be infeasible ({str(err)}). Please check results.termination_condition and/or results.solution_status.'
results = get_infeasible_results(
config=config,
err_msg=err_msg,
solver_name=self.name,
solver_version=self.version(),
)
return results

def _postsolve(self, model, timer, config, model_soln, stat_vars, gms_info):
Expand Down
12 changes: 11 additions & 1 deletion pyomo/contrib/solver/solvers/gurobi/gurobi_direct_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from pyomo.common.config import ConfigValue
from pyomo.common.dependencies import attempt_import
from pyomo.common.enums import ObjectiveSense
from pyomo.common.errors import ApplicationError
from pyomo.common.errors import ApplicationError, InfeasibleConstraintException
from pyomo.common.shutdown import python_is_shutting_down
from pyomo.common.tee import capture_output, TeeStream
from pyomo.common.timing import HierarchicalTimer
Expand All @@ -34,10 +34,12 @@
NoReducedCostsError,
NoSolutionError,
)
from pyomo.contrib.solver.common.solution_loader import NoSolutionSolutionLoader
from pyomo.contrib.solver.common.results import (
Results,
SolutionStatus,
TerminationCondition,
get_infeasible_results,
)
from pyomo.contrib.solver.common.solution_loader import SolutionLoader
import time
Expand Down Expand Up @@ -375,6 +377,14 @@ def solve(self, model, **kwds) -> Results:
has_obj=has_obj,
config=config,
)
except InfeasibleConstraintException as err:
err_msg = f'Solution loader does not currently have a valid solution because the problem was proven to be infeasible ({str(err)}). Please check results.termination_condition and/or results.solution_status.'
res = get_infeasible_results(
config=config,
err_msg=err_msg,
solver_name=self.name,
solver_version=self.version(),
)
finally:
os.chdir(orig_cwd)

Expand Down
10 changes: 8 additions & 2 deletions pyomo/contrib/solver/solvers/gurobi/gurobi_persistent.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,13 @@ def _get_expr_from_pyomo_repn(self, repn):
vlist = [self._pyomo_var_to_solver_var_map[v] for v in repn.linear_vars]
new_expr = gurobipy.LinExpr(coef_list, vlist)
else:
new_expr = 0.0
# this still needs to be an expression object so that
# we don't generate a bool if both the body and
# the bounds are floats (i.e., a trivially feasible or
# trivially infeasible constraint). We don't want a bool
# because the constraint could swap between feasible and
# infeasible if the bounds are mutable.
new_expr = gurobipy.LinExpr()

if len(repn.quadratic_vars) > 0:
for coef, (x, y) in zip(repn.quadratic_coefs, repn.quadratic_vars):
Expand Down Expand Up @@ -535,7 +541,7 @@ def _add_constraints(self, cons: list[ConstraintData]):
)
elif ub is None:
rhs_expr = lb - repn.constant
gurobi_expr_list.append(float(value(rhs_expr)) <= gurobi_expr)
gurobi_expr_list.append(gurobi_expr >= float(value(rhs_expr)))
if not is_constant(rhs_expr):
mutable_constant = _MutableConstant(
rhs_expr, con, self._pyomo_con_to_solver_con_map
Expand Down
11 changes: 8 additions & 3 deletions pyomo/contrib/solver/solvers/highs.py
Original file line number Diff line number Diff line change
Expand Up @@ -686,7 +686,9 @@ def _postsolve(self, stream: io.StringIO):
results.timing_info.highs_time = highs.getRunTime()

self._sol = highs.getSolution()
has_feasible_solution = self._sol.value_valid
info = highs.getInfo()
# 0: None, 1: Infeasible, 2: Feasible
has_feasible_solution = info.primal_solution_status == 2
if status == highspy.HighsModelStatus.kOptimal:
results.solution_status = SolutionStatus.optimal
elif has_feasible_solution:
Expand Down Expand Up @@ -744,12 +746,15 @@ def _postsolve(self, stream: io.StringIO):

results.incumbent_objective = None
results.objective_bound = None
info = highs.getInfo()
if self._objective is not None:
if has_feasible_solution:
results.incumbent_objective = info.objective_function_value
if info.mip_node_count == -1:
if has_feasible_solution:
if (
has_feasible_solution
and results.termination_condition
== TerminationCondition.convergenceCriteriaSatisfied
):
results.objective_bound = info.objective_function_value
else:
results.objective_bound = None
Expand Down
Loading
Loading