From 11774924c75571163deb797db6ee384c4c4783e6 Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Thu, 7 Aug 2025 14:03:55 -0400 Subject: [PATCH 01/29] Added new GAMS solver interface, writer, and solution loader. WIP: unittest and coverage test --- pyomo/contrib/solver/plugins.py | 4 + pyomo/contrib/solver/solvers/gams.py | 675 +++++++++++++++++ .../contrib/solver/solvers/gms_sol_reader.py | 132 ++++ pyomo/repn/plugins/gams_writer_v2.py | 698 ++++++++++++++++++ 4 files changed, 1509 insertions(+) create mode 100644 pyomo/contrib/solver/solvers/gams.py create mode 100644 pyomo/contrib/solver/solvers/gms_sol_reader.py create mode 100644 pyomo/repn/plugins/gams_writer_v2.py diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index 86c05f2bd70..7644f98e2d8 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -15,6 +15,7 @@ from .solvers.gurobi_persistent import GurobiPersistent from .solvers.gurobi_direct import GurobiDirect from .solvers.highs import Highs +from .solvers.gams import GAMS def load(): @@ -34,3 +35,6 @@ def load(): SolverFactory.register( name='highs', legacy_name='highs', doc='Persistent interface to HiGHS' )(Highs) + SolverFactory.register( + name='gams', legacy_name='gams_v2', doc='Interface to GAMS' + )(GAMS) \ No newline at end of file diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py new file mode 100644 index 00000000000..6a7184bb860 --- /dev/null +++ b/pyomo/contrib/solver/solvers/gams.py @@ -0,0 +1,675 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import logging +import os +import shutil +import subprocess +import datetime +from io import StringIO +from typing import Mapping, Optional, Sequence +from tempfile import mkdtemp +import sys + +from pyomo.common import Executable +from pyomo.common.dependencies import pathlib +from pyomo.common.config import ConfigValue, document_kwargs_from_configdict, ConfigDict +from pyomo.common.tempfiles import TempfileManager +from pyomo.common.timing import HierarchicalTimer +from pyomo.core.base import Constraint, Var, value, Objective +from pyomo.core.staleflag import StaleFlagManager +from pyomo.contrib.solver.common.base import SolverBase +from pyomo.contrib.solver.common.config import SolverConfig +from pyomo.opt.results import ( + SolverStatus, + TerminationCondition, +) +from pyomo.contrib.solver.common.results import ( + legacy_termination_condition_map, + Results, + SolutionStatus, +) +from pyomo.contrib.solver.solvers.gms_sol_reader import GMSSolutionLoader + +import pyomo.core.base.suffix +from pyomo.common.tee import TeeStream +from pyomo.core.expr.visitor import replace_expressions +from pyomo.core.expr.numvalue import value +from pyomo.core.base.suffix import Suffix + +from pyomo.repn.plugins.gams_writer_v2 import GAMSWriterInfo, GAMSWriter + +logger = logging.getLogger(__name__) + +from pyomo.common.dependencies import attempt_import +import struct + +def _gams_importer(): + try: + import gams.core.gdx as gdx + return gdx + except ImportError: + try: + # fall back to the pre-GAMS-45.0 API + import gdxcc + return gdxcc + except: + # suppress the error from the old API and reraise the current API import error + pass + raise + +gdxcc, gdxcc_available = attempt_import('gdxcc', importer=_gams_importer) + +class GAMSConfig(SolverConfig): + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + super().__init__( + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + self.executable : Executable = self.declare( + 'executable', + ConfigValue( + default=Executable('gams'), + description="Executable for gams. Defaults to searching the " + "``PATH`` for the first available ``gams``.", + ), + ) + self.logfile : ConfigDict = self.declare( + 'logfile', + ConfigValue( + default=None, + description="Filename to output GAMS log to a file.", + ), + ) + self.writer_config: ConfigDict = self.declare( + 'writer_config', GAMSWriter.CONFIG() + ) + +class GAMSResults(Results): + def __init__(self): + super().__init__() + self.return_code : ConfigDict = self.declare( + 'return_code', + ConfigValue( + default=None, + description="Return code from the GAMS solver.", + ), + ) + self.gams_termination_condition : ConfigDict = self.declare( + 'gams_termination_condition', + ConfigValue( + default=None, + description="Include additional TerminationCondition domain." + ), + ) + self.gams_solver_status : ConfigDict = self.declare( + 'gams_solver_status', + ConfigValue( + default=None, + description="Include additional SolverStatus domain." + ), + ) + +class GAMS(SolverBase): + CONFIG = GAMSConfig() + + def __init__(self, **kwds): + super().__init__(**kwds) + self._writer = GAMSWriter() + self._available_cache = None + self._version_cache = None + + def available(self, config=None, exception_flag=True): + if config is None: + config = self.config + + """True if the solver is available.""" + exe = config.executable + + if not exe.available(): + if not exception_flag: + return False + raise NameError( + "No 'gams' command found on system PATH - GAMS shell " + "solver functionality is not available." + ) + # New versions of GAMS require a license to run anything. + # Instead of parsing the output, we will try solving a trivial + # model. + avail = self._run_simple_model(config, 1) + if not avail and exception_flag: + raise NameError( + "'gams' command failed to solve a simple model - " + "GAMS solver functionality is not available." + ) + return avail + + def _run_simple_model(self, config, n): + solver_exec = config.executable.path() + if solver_exec is None: + return False + tmpdir = mkdtemp() + try: + test = os.path.join(tmpdir, 'test.gms') + with open(test, 'w') as FILE: + FILE.write(self._simple_model(n)) + result = subprocess.run( + [solver_exec, test, "curdir=" + tmpdir, 'lo=0'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return not result.returncode + finally: + shutil.rmtree(tmpdir) + return False + + def _simple_model(self, n): + return """ + option limrow = 0; + option limcol = 0; + option solprint = off; + set I / 1 * %s /; + variables ans; + positive variables x(I); + equations obj; + obj.. ans =g= sum(I, x(I)); + model test / all /; + solve test using lp minimizing ans; + """ % ( + n, + ) + + def version(self, config=None): + if config is None: + config = self.config + pth = config.executable.path() + if self._version_cache is None or self._version_cache[0] != pth: + if pth is None: + self._version_cache = (None, None) + else: + cmd = [pth, "audit", "lo=3"] + subprocess_results = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + ) + version = subprocess_results.stdout.splitlines()[0] + version = [char for char in version.split(' ') if len(char) > 0][1] + self._version_cache = (pth, version) + + return self._version_cache[1] + + def _rewrite_path_win8p3(self, path): + """ + Return the 8.3 short path on Windows; unchanged elsewhere. + + This change is in response to Pyomo/pyomo#3579 which reported + that GAMS (direct) fails on Windows if there is a space in + the path. This utility converts paths to their 8.3 short-path version + (which never have spaces). + """ + if not sys.platform.startswith("win"): + return str(path) + + import ctypes, ctypes.wintypes as wt + + GetShortPathNameW = ctypes.windll.kernel32.GetShortPathNameW + GetShortPathNameW.argtypes = [wt.LPCWSTR, wt.LPWSTR, wt.DWORD] + + # the file must exist, or Windows will not create a short name + pathlib.Path(path).parent.mkdir(parents=True, exist_ok=True) + pathlib.Path(path).touch(exist_ok=True) + + buf = ctypes.create_unicode_buffer(260) + if GetShortPathNameW(str(path), buf, 260): + return buf.value + return str(path) + + + @document_kwargs_from_configdict(CONFIG) + def solve(self, model, **kwds): + #################################################################### + # Presolve + #################################################################### + # Begin time tracking + start_timestamp = datetime.datetime.now(datetime.timezone.utc) + + # Update configuration options, based on keywords passed to solve + config: GAMSConfig = self.config(value=kwds) + + # Check if solver is available, unavailable solver error will be raised in available() + self.available(config) + if config.timer is None: + timer = HierarchicalTimer() + else: + timer = config.timer + StaleFlagManager.mark_all_as_stale() + + # Because GAMS changes the CWD when running the solver, we need + # to convert user-provided file names to absolute paths + # (relative to the current directory) + if config.logfile is not None: + config.logfile = os.path.abspath(config.logfile) + + config.writer_config.put_results_format = 'gdx' if gdxcc_available else 'dat' + + # local variable to hold the working directory name and flags + newdir = False + dname = None + lst = "output.lst" + 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 config.working_dir is None: + dname = tempfile.mkdtemp() + newdir = True + else: + dname = config.working_dir + newdir = True + 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) + with open( + output_filename, 'w', newline='\n', encoding='utf-8' + ) as gms_file: + timer.start(f'write_{output_filename}_file') + self._writer.config.set_value(config.writer_config) + gms_info = self._writer.write( + model, + gms_file, + symbolic_solver_labels=config.symbolic_solver_labels, + ) + # NOTE: omit InfeasibleConstraintException for now + timer.stop(f'write_{output_filename}_file') + if config.writer_config.put_results_format == 'gdx': + results_filename = os.path.join(dname, f"{model.name}_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] + + if config.tee and not config.logfile: + # default behaviour of gams is to print to console, for + # compatibility with windows and *nix we want to explicitly log to + # stdout (see https://www.gams.com/latest/docs/UG_GamsCall.html) + command.append("lo=3") + elif not config.tee and not config.logfile: + command.append("lo=0") + elif not config.tee and config.logfile: + command.append("lo=2") + elif config.tee and config.logfile: + command.append("lo=4") + if config.logfile: + command.append(f"lf={self._rewrite_path_win8p3(config.logfile)}") + try: + 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) + timer.stop('subprocess') + rc = subprocess_result.returncode + txt = ostreams[0].getvalue() + if config.working_dir: + print("\nGAMS WORKING DIRECTORY: %s\n" % config.working_dir) + + if rc == 1 or rc == 127: + raise IOError("Command 'gams' was not recognized") + elif rc != 0: + if rc == 3: + # Execution Error + # Run check_expr_evaluation, which errors if necessary + print('Error rc=3, to be determined later') + # If nothing was raised, or for all other cases, raise this + logger.error( + "GAMS encountered an error during solve. " + "Check listing file for details." + ) + logger.error(txt) + if os.path.exists(lst_filename): + with open(lst_filename, 'r') as FILE: + logger.error("GAMS Listing file:\n\n%s" % (FILE.read(),)) + raise RuntimeError( + "GAMS encountered an error during solve. " + "Check listing file for details." + ) + if config.writer_config.put_results_format == 'gdx': + timer.start('parse_gdx') + model_soln, stat_vars = self._parse_gdx_results( + config, results_filename, statresults_filename + ) + timer.stop('parse_gdx') + + else: + timer.start('parse_dat') + model_soln, stat_vars = self._parse_dat_results( + config, results_filename, statresults_filename + ) + timer.stop('parse_dat') + finally: + if not config.working_dir: + print('Cleaning up temporary directory is handled by `release` from pyomo.common.tempfiles') + + # NOTE: solve completion time + + #################################################################### + # Postsolve (WIP) + #################################################################### + + # Mapping between old and new contrib results + rev_legacy_termination_condition_map = {v: k for k, v in legacy_termination_condition_map.items()} + + model_suffixes = list( + name + for ( + name, + comp, + ) in pyomo.core.base.suffix.active_import_suffix_generator(model) + ) + extract_dual = 'dual' in model_suffixes + extract_rc = 'rc' in model_suffixes + results = GAMSResults() + results.solver_name = "GAMS " + results.solver_version = str(self.version()) + + solvestat = stat_vars["SOLVESTAT"] + if solvestat == 1: + results.gams_solver_status = SolverStatus.ok + elif solvestat == 2: + results.gams_solver_status = SolverStatus.ok + results.gams_termination_condition = TerminationCondition.maxIterations + elif solvestat == 3: + results.gams_solver_status = SolverStatus.ok + results.gams_termination_condition = TerminationCondition.maxTimeLimit + elif solvestat == 5: + results.gams_solver_status = SolverStatus.ok + results.gams_termination_condition = TerminationCondition.maxEvaluations + elif solvestat == 7: + results.gams_solver_status = SolverStatus.aborted + results.gams_termination_condition = ( + TerminationCondition.licensingProblems + ) + elif solvestat == 8: + results.gams_solver_status = SolverStatus.aborted + results.gams_termination_condition = TerminationCondition.userInterrupt + elif solvestat == 10: + results.gams_solver_status = SolverStatus.error + results.gams_termination_condition = TerminationCondition.solverFailure + elif solvestat == 11: + results.gams_solver_status = SolverStatus.error + results.gams_termination_condition = ( + TerminationCondition.internalSolverError + ) + elif solvestat == 4: + results.gams_solver_status = SolverStatus.warning + results.message = "Solver quit with a problem (see LST file)" + elif solvestat in (9, 12, 13): + results.gams_solver_status = SolverStatus.error + elif solvestat == 6: + results.gams_solver_status = SolverStatus.unknown + + modelstat = stat_vars["MODELSTAT"] + if modelstat == 1: + results.gams_termination_condition = TerminationCondition.optimal + results.solution_status = SolutionStatus.optimal + elif modelstat == 2: + results.gams_termination_condition = TerminationCondition.locallyOptimal + results.solution_status = SolutionStatus.feasible + elif modelstat in [3, 18]: + results.gams_termination_condition = TerminationCondition.unbounded + # results.solution_status = SolutionStatus.unbounded + results.solution_status = SolutionStatus.noSolution + + elif modelstat in [4, 5, 6, 10, 19]: + results.gams_termination_condition = TerminationCondition.infeasibleOrUnbounded + results.solution_status = SolutionStatus.infeasible + elif modelstat == 7: + results.gams_termination_condition = TerminationCondition.feasible + results.solution_status = SolutionStatus.feasible + elif modelstat == 8: + # 'Integer solution model found' + results.gams_termination_condition = TerminationCondition.optimal + results.solution_status = SolutionStatus.optimal + elif modelstat == 9: + results.gams_termination_condition = ( + TerminationCondition.intermediateNonInteger + ) + results.solution_status = SolutionStatus.noSolution + elif modelstat == 11: + # Should be handled above, if modelstat and solvestat both + # indicate a licensing problem + if results.gams_termination_condition is None: + results.gams_termination_condition = ( + TerminationCondition.licensingProblems + ) + results.solution_status = SolutionStatus.noSolution + # results.solution_status = SolutionStatus.error + + elif modelstat in [12, 13]: + if results.gams_termination_condition is None: + results.gams_termination_condition = TerminationCondition.error + results.solution_status = SolutionStatus.noSolution + # results.solution_status = SolutionStatus.error + + elif modelstat == 14: + if results.gams_termination_condition is None: + results.gams_termination_condition = TerminationCondition.noSolution + results.solution_status = SolutionStatus.noSolution + # results.solution_status = SolutionStatus.unknown + + elif modelstat in [15, 16, 17]: + # Having to do with CNS models, + # not sure what to make of status descriptions + results.gams_termination_condition = TerminationCondition.optimal + results.solution_status = SolutionStatus.noSolution + else: + # This is just a backup catch, all cases are handled above + results.solution_status = SolutionStatus.noSolution + + # ensure backward compatibility before feeding to contrib.solver + results.termination_condition = rev_legacy_termination_condition_map[results.gams_termination_condition] + obj = list(model.component_data_objects(Objective, active=True)) + assert len(obj) == 1, 'Only one objective is allowed.' + if ( + results.solution_status in {SolutionStatus.feasible, SolutionStatus.optimal} + ): + results.solution_loader = GMSSolutionLoader(gdx_data=model_soln, gms_info=gms_info) + + if config.load_solutions: + results.solution_loader.load_vars() + results.incumbent_objective = stat_vars["OBJVAL"] + if ( + hasattr(model, 'dual') + and isinstance(model.dual, Suffix) + and model.dual.import_enabled() + ): + model.dual.update(results.solution_loader.get_duals()) + if ( + hasattr(model, 'rc') + and isinstance(model.rc, Suffix) + and model.rc.import_enabled() + ): + model.rc.update(results.solution_loader.get_reduced_costs()) + + else: + results.incumbent_objective = value( + replace_expressions( + obj[0].expr, + substitution_map={ + id(v): val + for v, val in results.solution_loader.get_primals().items() + }, + descend_into_named_expressions=True, + remove_named_expressions=True, + ) + ) + end_timestamp = datetime.datetime.now(datetime.timezone.utc) + results.timing_info.start_timestamp = start_timestamp + results.timing_info.wall_time = ( + end_timestamp - start_timestamp + ).total_seconds() + results.timing_info.timer = timer + return results + + def _parse_gdx_results(self, config, results_filename, statresults_filename): + model_soln = dict() + stat_vars = dict.fromkeys( + [ + 'MODELSTAT', + 'SOLVESTAT', + 'OBJEST', + 'OBJVAL', + 'NUMVAR', + 'NUMEQU', + 'NUMDVAR', + 'NUMNZ', + 'ETSOLVE', + ] + ) + + pgdx = gdxcc.new_gdxHandle_tp() + ret = gdxcc.gdxCreateD(pgdx, os.path.dirname(config.executable.path()), 128) + if not ret[0]: + raise RuntimeError("GAMS GDX failure (gdxCreate): %s." % ret[1]) + if os.path.exists(statresults_filename): + ret = gdxcc.gdxOpenRead(pgdx, statresults_filename) + if not ret[0]: + raise RuntimeError("GAMS GDX failure (gdxOpenRead): %d." % ret[1]) + + specVals = gdxcc.doubleArray(gdxcc.GMS_SVIDX_MAX) + rc = gdxcc.gdxGetSpecialValues(pgdx, specVals) + + specVals[gdxcc.GMS_SVIDX_EPS] = sys.float_info.min + specVals[gdxcc.GMS_SVIDX_UNDEF] = float("nan") + specVals[gdxcc.GMS_SVIDX_PINF] = float("inf") + specVals[gdxcc.GMS_SVIDX_MINF] = float("-inf") + specVals[gdxcc.GMS_SVIDX_NA] = struct.unpack(">d", bytes.fromhex("fffffffffffffffe"))[0] + gdxcc.gdxSetSpecialValues(pgdx, specVals) + + i = 0 + while True: + i += 1 + ret = gdxcc.gdxDataReadRawStart(pgdx, i) + if not ret[0]: + break + + ret = gdxcc.gdxSymbolInfo(pgdx, i) + if not ret[0]: + break + if len(ret) < 2: + raise RuntimeError("GAMS GDX failure (gdxSymbolInfo).") + stat = ret[1] + if not stat in stat_vars: + continue + + ret = gdxcc.gdxDataReadRaw(pgdx) + if not ret[0] or len(ret[2]) == 0: + raise RuntimeError("GAMS GDX failure (gdxDataReadRaw).") + + if stat in ('OBJEST', 'OBJVAL', 'ETSOLVE'): + stat_vars[stat] = ret[2][0] + else: + stat_vars[stat] = int(ret[2][0]) + + gdxcc.gdxDataReadDone(pgdx) + gdxcc.gdxClose(pgdx) + + if os.path.exists(results_filename): + ret = gdxcc.gdxOpenRead(pgdx, results_filename) + if not ret[0]: + raise RuntimeError("GAMS GDX failure (gdxOpenRead): %d." % ret[1]) + + specVals = gdxcc.doubleArray(gdxcc.GMS_SVIDX_MAX) + rc = gdxcc.gdxGetSpecialValues(pgdx, specVals) + + specVals[gdxcc.GMS_SVIDX_EPS] = sys.float_info.min + specVals[gdxcc.GMS_SVIDX_UNDEF] = float("nan") + specVals[gdxcc.GMS_SVIDX_PINF] = float("inf") + specVals[gdxcc.GMS_SVIDX_MINF] = float("-inf") + specVals[gdxcc.GMS_SVIDX_NA] = struct.unpack(">d", bytes.fromhex("fffffffffffffffe"))[0] + gdxcc.gdxSetSpecialValues(pgdx, specVals) + + i = 0 + while True: + i += 1 + ret = gdxcc.gdxDataReadRawStart(pgdx, i) + if not ret[0]: + break + + ret = gdxcc.gdxDataReadRaw(pgdx) + if not ret[0] or len(ret[2]) < 2: + raise RuntimeError("GAMS GDX failure (gdxDataReadRaw).") + level = ret[2][0] + dual = ret[2][1] + + ret = gdxcc.gdxSymbolInfo(pgdx, i) + if not ret[0]: + break + if len(ret) < 2: + raise RuntimeError("GAMS GDX failure (gdxSymbolInfo).") + model_soln[ret[1]] = (level, dual) + + gdxcc.gdxDataReadDone(pgdx) + gdxcc.gdxClose(pgdx) + + gdxcc.gdxFree(pgdx) + gdxcc.gdxLibraryUnload() + return model_soln, stat_vars + + def _parse_dat_results(self, config, results_filename, statresults_filename): + with open(statresults_filename, 'r') as statresults_file: + statresults_text = statresults_file.read() + + stat_vars = dict() + # Skip first line of explanatory text + for line in statresults_text.splitlines()[1:]: + items = line.split() + try: + stat_vars[items[0]] = float(items[1]) + except ValueError: + # GAMS printed NA, just make it nan + stat_vars[items[0]] = float('nan') + + with open(results_filename, 'r') as results_file: + results_text = results_file.read() + + model_soln = dict() + # Skip first line of explanatory text + for line in results_text.splitlines()[1:]: + items = line.split() + model_soln[items[0]] = (float(items[1]), float(items[2])) + + return model_soln, stat_vars + \ No newline at end of file diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py new file mode 100644 index 00000000000..a670b8643d5 --- /dev/null +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -0,0 +1,132 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + + +from typing import Tuple, Dict, Any, List, Sequence, Optional, Mapping, NoReturn + +from pyomo.core.base import Constraint, Var, value, Objective +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.var import VarData +from pyomo.core.expr import value +from pyomo.common.collections import ComponentMap +from pyomo.core.staleflag import StaleFlagManager +from pyomo.repn.plugins.gams_writer_v2 import GAMSWriterInfo +from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase + +class GDXFileData: + """ + Defines the data types found within a .gdx file + """ + + def __init__(self) -> None: + self.primals: List[float] = [] + self.duals: List[float] = [] + self.var_suffixes: Dict[str, Dict[int, Any]] = {} + self.con_suffixes: Dict[str, Dict[Any]] = {} + self.obj_suffixes: Dict[str, Dict[int, Any]] = {} + self.problem_suffixes: Dict[str, List[Any]] = {} + self.other: List(str) = [] + + +class GMSSolutionLoader(SolutionLoaderBase): + """ + Loader for solvers that create .gms files (e.g., gams) + """ + def __init__(self, gdx_data: GDXFileData, gms_info: GAMSWriterInfo) -> None: + self._gdx_data = gdx_data + self._gms_info = gms_info + + def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> NoReturn: + if self._gms_info is None: + raise RuntimeError( + 'Solution loader does not currently have a valid solution. Please ' + 'check results.termination_condition and/or results.solution_status.' + ) + if self._gdx_data is None: + assert len(self._gms_info.var_symbol_map.bySymbol) == 0 + else: + for sym, obj in self._gms_info.var_symbol_map.bySymbol.items(): + level = self._gdx_data[sym][0] + if obj.parent_component().ctype is Var: + obj.set_value(level, skip_validation = True) + + StaleFlagManager.mark_all_as_stale(delayed=True) + + def get_primals( + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: + if self._gms_info is None: + raise RuntimeError( + 'Solution loader does not currently have a valid solution. Please ' + 'check results.termination_condition and/or results.solution_status.' + ) + val_map = {} + if self._gdx_data is None: + assert len(self._gms_info.var_symbol_map.bySymbol) == 0 + else: + for sym, obj in self._gms_info.var_symbol_map.bySymbol.items(): + val_map[id(obj)] = self._gdx_data[sym][0] + + res = ComponentMap() + if vars_to_load is None: + vars_to_load = self._gms_info.var_symbol_map.bySymbol.items() + + for sym, obj in vars_to_load: + res[obj] = val_map[id(obj)] + + return res + + def get_duals( + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Dict[ConstraintData, float]: + if self._gms_info is None: + raise RuntimeError( + 'Solution loader does not currently have a valid solution. Please ' + 'check results.termination_condition and/or results.solution_status.' + ) + if self._gdx_data is None: + raise RuntimeError( + 'Solution loader does not currently have a valid solution. Please ' + 'check results.termination_condition and/or results.solution_status.' + ) + res = {} + + if cons_to_load is None: + cons_to_load = set(self._gms_info.con_symbol_map.bySymbol.keys()) + else: + cons_to_load = set(cons_to_load) + for sym, con in self._gms_info.con_symbol_map.bySymbol.items(): + if sym in cons_to_load and con.parent_component().ctype is not Objective: + res[con] = self._gdx_data[sym][1] + return res + + def get_reduced_costs(self, vars_to_load = None): + if self._gms_info is None: + raise RuntimeError( + 'Solution loader does not currently have a valid solution. Please ' + 'check results.termination_condition and/or results.solution_status.' + ) + if self._gdx_data is None: + raise RuntimeError( + 'Solution loader does not currently have a valid solution. Please ' + 'check results.termination_condition and/or results.solution_status.' + ) + + res = {} + + if vars_to_load is None: + vars_to_load = set(self._gms_info.var_symbol_map.bySymbol.keys()) + else: + vars_to_load = set(vars_to_load) + for sym, var in self._gms_info.var_symbol_map.bySymbol.items(): + if sym in vars_to_load and var.parent_component().ctype is Var: + res[var.name] = self._gdx_data[sym][1] + return res \ No newline at end of file diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py new file mode 100644 index 00000000000..c3aec7a58f0 --- /dev/null +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -0,0 +1,698 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import logging +from io import StringIO +from operator import itemgetter, attrgetter + +from pyomo.common.config import ( + ConfigBlock, + ConfigValue, + InEnum, + document_kwargs_from_configdict, +) +from pyomo.common.gc_manager import PauseGC +from pyomo.common.timing import TicTocTimer + +from pyomo.core.base import ( + Block, + Objective, + Constraint, + Var, + Param, + Expression, + SOSConstraint, + SortComponents, + Suffix, + SymbolMap, + minimize, + ShortNameLabeler, +) +from pyomo.core.base.component import ActiveComponent +from pyomo.core.base.label import NumericLabeler +from pyomo.opt import WriterFactory +# from pyomo.repn.quadratic import QuadraticRepnVisitor +from pyomo.repn.linear import LinearRepnVisitor +from pyomo.repn.util import ( + FileDeterminism, + FileDeterminism_to_SortComponents, + OrderedVarRecorder, + categorize_valid_components, + initialize_var_map_from_column_order, + int_float, + ordered_active_constraints, +) + +### FIXME: Remove the following as soon as non-active components no +### longer report active==True +from pyomo.core.base import Set, RangeSet, ExternalFunction +from pyomo.network import Port + +logger = logging.getLogger(__name__) +inf = float('inf') +neg_inf = float('-inf') + + +class GAMSWriterInfo(object): + """Return type for GAMSWriter.write() + + Attributes + ---------- + symbol_map: SymbolMap + + The :py:class:`SymbolMap` bimap between row/column labels and + Pyomo components. + """ + + def __init__(self, var_symbol_map, con_symbol_map): + self.var_symbol_map = var_symbol_map + self.con_symbol_map = con_symbol_map + +@WriterFactory.register('gams_writer_v2', 'Generate the corresponding gms file (version 2).') +class GAMSWriter(object): + CONFIG = ConfigBlock('gamswriter') + + """ + Write a model in the GAMS modeling language format. + + Keyword Arguments + ----------------- + output_filename: str + Name of file to write GAMS model to. Optionally pass a file-like + stream and the model will be written to that instead. + io_options: str + - warmstart=True + Warmstart by initializing model's variables to their values. + - symbolic_solver_labels=False + Use full Pyomo component names rather than + shortened symbols (slower, but useful for debugging). + - labeler=None + Custom labeler. Incompatible with symbolic_solver_labels. + - solver=None + If None, GAMS will use default solver for model type. + - mtype=None + Model type. If None, will chose from lp, nlp, mip, and minlp. + - add_options=None + List of additional lines to write directly + into model file before the solve statement. + For model attributes, is GAMS_MODEL. + - skip_trivial_constraints=False + Skip writing constraints whose body section is fixed. + - output_fixed_variables=False + If True, output fixed variables as variables; otherwise, + output numeric value. + - file_determinism=1 + | How much effort do we want to put into ensuring the + | GAMS file is written deterministically for a Pyomo model: + - NONE (0) : None + - ORDERED (10): rely on underlying component ordering (default) + - SORT_INDICES (20) : sort keys of indexed components + - SORT_SYMBOLS (30) : sort keys AND sort names (not declaration order) + - put_results='results' + Filename for optionally writing solution values and + marginals. If put_results_format is 'gdx', then GAMS + will write solution values and marginals to + GAMS_MODEL_p.gdx and solver statuses to + {put_results}_s.gdx. If put_results_format is 'dat', + then solution values and marginals are written to + (put_results).dat, and solver statuses to (put_results + + 'stat').dat. + - put_results_format='gdx' + Format used for put_results, one of 'gdx', 'dat'. + """ + # old GAMS config + CONFIG.declare( + 'warmstart', + ConfigValue( + default=True, + domain=bool, + description="Warmstart by initializing model's variables to their values.", + ), + ) + CONFIG.declare( + 'symbolic_solver_labels', + ConfigValue( + default=False, + domain=bool, + description='Write variables/constraints using model names', + doc=""" + Export variables and constraints to the gms file using human-readable + text names derived from the corresponding Pyomo component names. + """, + ), + ) + CONFIG.declare( + 'labeler', + ConfigValue( + default=None, + description='Callable to use to generate symbol names in gms file', + ), + ) + CONFIG.declare( + 'solver', + ConfigValue( + default=None, + description='If None, GAMS will use default solver for model type.', + ), + ) + CONFIG.declare( + 'mtype', + ConfigValue( + default=None, + description='Model type. If None, will chose from lp, mip. nlp and minlp will be implemented in the future.', + ), + ) + CONFIG.declare( + 'add_options', + ConfigValue( + default=None, + doc=""" + List of additional lines to write directly + into model file before the solve statement. + For model attributes, is GAMS_MODEL. + """, + ), + ) + CONFIG.declare( + 'skip_trivial_constraints', + ConfigValue( + default=False, + domain=bool, + description='Skip writing constraints whose body is constant', + ), + ) + CONFIG.declare( + 'output_fixed_variables', + ConfigValue( + default=False, + domain=bool, + description='If True, output fixed variables as variables; otherwise,output numeric value', + ), + ) + CONFIG.declare( + 'file_determinism', + ConfigValue( + default=FileDeterminism.ORDERED, + domain=InEnum(FileDeterminism), + description='How much effort to ensure file is deterministic', + doc=""" + How much effort do we want to put into ensuring the + GAMS file is written deterministically for a Pyomo model: + + - NONE (0) : None + - ORDERED (10): rely on underlying component ordering (default) + - SORT_INDICES (20) : sort keys of indexed components + - SORT_SYMBOLS (30) : sort keys AND sort names (not declaration order) + + """, + ), + ) + CONFIG.declare( + 'put_results', + ConfigValue( + default='results', + domain=str, + doc =""" + Filename for optionally writing solution values and + marginals. If put_results_format is 'gdx', then GAMS + will write solution values and marginals to + GAMS_MODEL_p.gdx and solver statuses to + {put_results}_s.gdx. If put_results_format is 'dat', + then solution values and marginals are written to + (put_results).dat, and solver statuses to (put_results + + 'stat').dat. + """ + ), + ) + CONFIG.declare( + 'put_results_format', + ConfigValue( + default='gdx', + description="Format used for put_results, one of 'gdx', 'dat'", + ), + ) + # NOTE: Taken from the lp_writer + CONFIG.declare( + 'allow_quadratic_objective', + ConfigValue( + default=True, + domain=bool, + description='If True, allow quadratic terms in the model objective', + ), + ) + CONFIG.declare( + 'allow_quadratic_constraint', + ConfigValue( + default=True, + domain=bool, + description='If True, allow quadratic terms in the model constraints', + ), + ) + CONFIG.declare( + 'row_order', + ConfigValue( + default=None, + description='Preferred constraint ordering', + doc=""" + To use with ordered_active_constraints function.""", + ), + ) + + def __init__(self): + self.config = self.CONFIG() + + def __call__(self, model, filename, solver_capability, io_options): + if filename is None: + filename = model.name + ".gms" + + config = self.config(io_options) + + with open(filename, 'w', newline='') as FILE: + info = self.write(model, FILE, config=config) + + return filename, info.symbol_map + + @document_kwargs_from_configdict(CONFIG) + def write(self, model, ostream, **options) -> GAMSWriterInfo: + """Write a model in GMS format. + + Returns + ------- + GAMSWriterInfo + + Parameters + ------- + model: ConcreteModel + The concrete Pyomo model to write out. + + ostream: io.TextIOBase + The text output stream where the GMS "file" will be written. + Could be an opened file or a io.StringIO. + """ + config = self.config(options) + + # Pause the GC, as the walker that generates the compiled GMS + # representation generates (and disposes of) a large number of + # small objects. + + # NOTE: First pass write the model but needs variables/equations defition first + with PauseGC(): + return _GMSWriter_impl(ostream, config).write(model) + +class _GMSWriter_impl(object): + def __init__(self, ostream, config): + # taken from lp_writer.py + self.ostream = ostream + self.config = config + self.symbol_map = None + + # Taken from nl_writer.py + self.symbolic_solver_labels = config.symbolic_solver_labels + self.subexpression_cache = {} + self.subexpression_order = None # set to [] later + self.external_functions = {} + self.used_named_expressions = set() + self.var_map = {} + self.var_id_to_nl_map = {} + self.next_V_line_id = 0 + self.pause_gc = None + + def write(self, model): + timing_logger = logging.getLogger('pyomo.common.timing.writer') + timer = TicTocTimer(logger=timing_logger) + with_debug_timing = ( + timing_logger.isEnabledFor(logging.DEBUG) and timing_logger.hasHandlers() + ) + + # Caching some frequently-used objects into the locals() + symbolic_solver_labels = self.symbolic_solver_labels + ostream = self.ostream + config = self.config + labeler = config.labeler + var_labeler, con_labeler = None, None + + sorter = FileDeterminism_to_SortComponents(config.file_determinism) + + component_map, unknown = categorize_valid_components( + model, + active=True, + sort=sorter, + valid={ + Block, + Constraint, + Var, + Param, + Expression, + # FIXME: Non-active components should not report as Active + ExternalFunction, + Set, + RangeSet, + Port, + # TODO: Piecewise, Complementarity + }, + targets={Suffix, SOSConstraint, Objective}, + ) + if unknown: + raise ValueError( + "The model ('%s') contains the following active components " + "that the LP writer does not know how to process:\n\t%s" + % ( + model.name, + "\n\t".join( + "%s:\n\t\t%s" % (k, "\n\t\t".join(map(attrgetter('name'), v))) + for k, v in unknown.items() + ), + ) + ) + + if symbolic_solver_labels and (labeler is not None): + raise ValueError( + "GAMS writer: Using both the " + "'symbolic_solver_labels' and 'labeler' " + "I/O options is forbidden" + ) + + if symbolic_solver_labels: + # Note that the Var and Constraint labelers must use the + # same labeler, so that we can correctly detect name + # collisions (which can arise when we truncate the labels to + # the max allowable length. GAMS requires all identifiers + # to start with a letter. We will (randomly) choose "s_" + # (for 'shortened') + var_labeler = con_labeler = ShortNameLabeler( + 60, + prefix='s_', + suffix='_', + caseInsensitive=True, + legalRegex='^[a-zA-Z]', + ) + elif labeler is None: + var_labeler = NumericLabeler('x') + con_labeler = NumericLabeler('c') + else: + var_labeler = con_labeler = labeler + + self.var_symbol_map = SymbolMap(var_labeler) + self.con_symbol_map = SymbolMap(con_labeler) + self.var_order = {_id: i for i, _id in enumerate(self.var_map)} + self.var_recorder = OrderedVarRecorder(self.var_map, self.var_order, sorter) + + visitor = LinearRepnVisitor( + self.subexpression_cache, + var_recorder=self.var_recorder, + ) + + # + # Tabulate constraints + # + skip_trivial_constraints = self.config.skip_trivial_constraints + have_nontrivial = False + last_parent = None + con_list = {} # NOTE: Save the constraint representation and write it after variables/equations declare + for con in ordered_active_constraints(model, self.config): + if with_debug_timing and con.parent_component() is not last_parent: + timer.toc('Constraint %s', last_parent, level=logging.DEBUG) + last_parent = con.parent_component() + # Note: Constraint.to_bounded_expression(evaluate_bounds=True) + # guarantee a return value that is either a (finite) + # native_numeric_type, or None + lb, body, ub = con.to_bounded_expression(True) + + if lb is None and ub is None: + # Note: you *cannot* output trivial (unbounded) + # constraints in LP format. I suppose we could add a + # slack variable if skip_trivial_constraints is False, + # but that seems rather silly. + continue + repn = visitor.walk_expression(body) + if repn.nonlinear is not None: + raise ValueError( + f"Model constraint ({con.name}) contains nonlinear terms that is currently not supported in the new gams_writer" + ) + + # Pull out the constant: we will move it to the bounds + offset = repn.constant + repn.constant = 0 + + if repn.linear or getattr(repn, 'quadratic', None): + have_nontrivial = True + else: + if ( + skip_trivial_constraints + and (lb is None or lb <= offset) + and (ub is None or ub >= offset) + ): + continue + + con_symbol = con_labeler(con) + declaration, definition, bounds = None, None, None + + if lb is not None: + if ub is None: + label = f'{con_symbol}_lo' + self.con_symbol_map.addSymbol(con, label) + self.var_symbol_map.addSymbol(con, label) + declaration = f'\n{label}.. ' + definition = self.write_expression(ostream, repn) + bounds = f' =G= {(lb - offset)!s};' + con_list[label] = declaration + definition + bounds + elif lb == ub: + label = f'{con_symbol}' + self.con_symbol_map.addSymbol(con, label) + self.var_symbol_map.addSymbol(con, label) + declaration = f'\n{label}.. ' + definition = self.write_expression(ostream, repn) + bounds = f' =E= {(lb - offset)!s};' + con_list[label] = declaration + definition + bounds + else: + raise NotImplementedError( + "Bounded constraints within the same expression is not supported" + ) + + elif ub is not None: + label = f'{con_symbol}_hi' + self.con_symbol_map.addSymbol(con, label) + declaration = f'\n{label}.. ' + definition = self.write_expression(ostream, repn) + bounds = f' =L= {(lb - offset)!s};' + con_list[label] = declaration + definition + bounds + + # + # Process objective + # + if not component_map[Objective]: + objectives = [Objective(expr=1)] + objectives[0].construct() + else: + objectives = [] + for blk in component_map[Objective]: + objectives.extend( + blk.component_data_objects( + Objective, active=True, descend_into=False, sort=sorter + ) + ) + if len(objectives) > 1: + raise ValueError( + "More than one active objective defined for input model '%s'; " + "Cannot write legal LP file\nObjectives: %s" + % (model.name, ' '.join(obj.name for obj in objectives)) + ) + + obj = objectives[0] + repn = visitor.walk_expression(obj.expr) + if repn.nonlinear is not None: + raise ValueError( + f"Model objective ({obj.name}) contains nonlinear terms that " + "is currently not supported in this new GAMSWriter" + ) + + label = self.con_symbol_map.getSymbol(obj, con_labeler) + declaration = f'\n{label}.. -GAMS_OBJECTIVE ' + definition = self.write_expression(ostream, repn) + bounds = f' =E= {(-repn.constant)!s};\n\n' + con_list[label] = declaration + definition + bounds + self.var_symbol_map.addSymbol(obj, label) + + # + # Write out variable declaration + # + integer_vars = [] + binary_vars = [] + var_bounds = {} + getSymbolByObjectID = self.var_symbol_map.byObject.get + + ostream.write( + "VARIABLES \n" + ) + for vid, v in self.var_map.items(): + v_symbol = getSymbolByObjectID(vid, None) + if not v_symbol: + continue + if v.is_continuous(): + ostream.write( + f"\t{v_symbol} \n" + ) + lb, ub = v.bounds + var_bounds[v_symbol] = (lb, ub) + elif v.is_binary(): + binary_vars.append(v_symbol) + elif v.is_integer(): + lb, ub = v.bounds + var_bounds[v_symbol] = (lb, ub) + integer_vars.append(v_symbol) + + + ostream.write( + f"\tGAMS_OBJECTIVE;\n\n" + ) + + if integer_vars: + ostream.write("\nINTEGER VARIABLES\n\t") + ostream.write("\n\t".join(integer_vars)+ ';\n\n') + + if binary_vars: + ostream.write("\nBINARY VARIABLES\n\t") + ostream.write("\n\t".join(binary_vars) + ';\n\n') + + # + # Writing out the equations/constraints + # + ostream.write( + "EQUATIONS \n" + ) + for id, cid in enumerate(self.con_symbol_map.byObject.keys()): + c = self.con_symbol_map.byObject[cid] + if id != len(self.con_symbol_map.byObject.keys())-1: + ostream.write(f"\t{c}\n") + else: + ostream.write(f"\t{c};\n\n") + + for con_label, con in con_list.items(): + ostream.write(con) + + # + # Handling variable bounds + # + for v, (lb, ub) in var_bounds.items(): + ostream.write(f'{v}.lo = {lb};\n{v}.up = {ub};\n') + + ostream.write(f'\nModel {model.name} / all /;\n') + ostream.write(f'{model.name}.limrow = 0;\n') + ostream.write(f'{model.name}.limcol = 0;\n') + + # CHECK FOR mtype flag based on variable domains - reals, integer + if config.mtype is None: + if binary_vars or integer_vars: + config.mtype = 'mip' # expand this to nlp, minlp + else: + config.mtype = 'lp' + + if config.put_results_format == 'gdx': + ostream.write("option savepoint=1;\n") + + ostream.write( + "SOLVE %s USING %s %simizing GAMS_OBJECTIVE;\n" + % (model.name, config.mtype, 'min' if obj.sense == minimize else 'max') + ) + # Set variables to store certain statuses and attributes + stat_vars = [ + 'MODELSTAT', + 'SOLVESTAT', + 'OBJEST', + 'OBJVAL', + 'NUMVAR', + 'NUMEQU', + 'NUMDVAR', + 'NUMNZ', + 'ETSOLVE', + ] + ostream.write( + "\nScalars MODELSTAT 'model status', SOLVESTAT 'solve status';\n" + ) + ostream.write("MODELSTAT = %s.modelstat;\n" % model.name) + ostream.write("SOLVESTAT = %s.solvestat;\n\n" % model.name) + + ostream.write("Scalar OBJEST 'best objective', OBJVAL 'objective value';\n") + ostream.write("OBJEST = %s.objest;\n" % model.name) + ostream.write("OBJVAL = %s.objval;\n\n" % model.name) + + ostream.write("Scalar NUMVAR 'number of variables';\n") + ostream.write("NUMVAR = %s.numvar\n\n" % model.name) + + ostream.write("Scalar NUMEQU 'number of equations';\n") + ostream.write("NUMEQU = %s.numequ\n\n" % model.name) + + ostream.write("Scalar NUMDVAR 'number of discrete variables';\n") + ostream.write("NUMDVAR = %s.numdvar\n\n" % model.name) + + ostream.write("Scalar NUMNZ 'number of nonzeros';\n") + ostream.write("NUMNZ = %s.numnz\n\n" % model.name) + + ostream.write("Scalar ETSOLVE 'time to execute solve statement';\n") + ostream.write("ETSOLVE = %s.etsolve\n\n" % model.name) + + if config.put_results is not None: + if config.put_results_format == 'gdx': + ostream.write("\nexecute_unload '%s_s.gdx'" % config.put_results) + for stat in stat_vars: + ostream.write(", %s" % stat) + ostream.write(";\n") + else: + results = config.put_results + '.dat' + ostream.write("\nfile results /'%s'/;" % results) + ostream.write("\nresults.nd=15;") + ostream.write("\nresults.nw=21;") + ostream.write("\nput results;") + ostream.write("\nput 'SYMBOL : LEVEL : MARGINAL' /;") + for sym, var in self.var_symbol_map.bySymbol.items(): + if var.parent_component().ctype is Var: + ostream.write("\nput %s ' ' %s.l ' ' %s.m /;" % (sym, sym, sym)) + for con in self.con_symbol_map.bySymbol.keys(): + ostream.write("\nput %s ' ' %s.l ' ' %s.m /;" % (con, con, con)) + ostream.write( + "\nput GAMS_OBJECTIVE ' ' GAMS_OBJECTIVE.l " + "' ' GAMS_OBJECTIVE.m;\n" + ) + + statresults = config.put_results + 'stat.dat' + ostream.write("\nfile statresults /'%s'/;" % statresults) + ostream.write("\nstatresults.nd=15;") + ostream.write("\nstatresults.nw=21;") + ostream.write("\nput statresults;") + ostream.write("\nput 'SYMBOL : VALUE' /;") + for stat in stat_vars: + ostream.write("\nput '%s' ' ' %s /;\n" % (stat, stat)) + + + timer.toc("Finished writing .gsm file", level=logging.DEBUG) + + info = GAMSWriterInfo(self.var_symbol_map, self.con_symbol_map) + return info + + def write_expression(self, ostream, expr): + save_eq = [] + assert not expr.constant + getSymbol = self.var_symbol_map.getSymbol + getVarOrder = self.var_order.__getitem__ + getVar = self.var_map.__getitem__ + expr_str = '' + if expr.linear: + for vid, coef in sorted( + expr.linear.items(), key=lambda x: getVarOrder(x[0]) + ): + if coef < 0: + # ostream.write(f'{coef!s}*{getSymbol(getVar(vid))}') + expr_str += f'{coef!s}*{getSymbol(getVar(vid))} \n' + else: + # ostream.write(f'+{coef!s}*{getSymbol(getVar(vid))}') + expr_str += f'+ {coef!s} * {getSymbol(getVar(vid))} \n' + + return expr_str \ No newline at end of file From 2385654f529ee4e7677a17625374b43b7b768b57 Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Thu, 7 Aug 2025 14:14:56 -0400 Subject: [PATCH 02/29] Ran black -S -C on commited files --- pyomo/contrib/solver/plugins.py | 6 +- pyomo/contrib/solver/solvers/gams.py | 136 ++++++++++-------- .../contrib/solver/solvers/gms_sol_reader.py | 14 +- pyomo/repn/plugins/gams_writer_v2.py | 74 +++++----- 4 files changed, 119 insertions(+), 111 deletions(-) diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index 7644f98e2d8..7318f5d1d53 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -35,6 +35,6 @@ def load(): SolverFactory.register( name='highs', legacy_name='highs', doc='Persistent interface to HiGHS' )(Highs) - SolverFactory.register( - name='gams', legacy_name='gams_v2', doc='Interface to GAMS' - )(GAMS) \ No newline at end of file + SolverFactory.register(name='gams', legacy_name='gams_v2', doc='Interface to GAMS')( + GAMS + ) diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index 6a7184bb860..4b5dacf8c7d 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -17,7 +17,7 @@ from io import StringIO from typing import Mapping, Optional, Sequence from tempfile import mkdtemp -import sys +import sys from pyomo.common import Executable from pyomo.common.dependencies import pathlib @@ -28,15 +28,12 @@ from pyomo.core.staleflag import StaleFlagManager from pyomo.contrib.solver.common.base import SolverBase from pyomo.contrib.solver.common.config import SolverConfig -from pyomo.opt.results import ( - SolverStatus, - TerminationCondition, -) +from pyomo.opt.results import SolverStatus, TerminationCondition from pyomo.contrib.solver.common.results import ( legacy_termination_condition_map, Results, SolutionStatus, -) +) from pyomo.contrib.solver.solvers.gms_sol_reader import GMSSolutionLoader import pyomo.core.base.suffix @@ -52,22 +49,27 @@ from pyomo.common.dependencies import attempt_import import struct + def _gams_importer(): try: import gams.core.gdx as gdx + return gdx except ImportError: try: # fall back to the pre-GAMS-45.0 API import gdxcc + return gdxcc except: # suppress the error from the old API and reraise the current API import error pass raise + gdxcc, gdxcc_available = attempt_import('gdxcc', importer=_gams_importer) + class GAMSConfig(SolverConfig): def __init__( self, @@ -84,7 +86,7 @@ def __init__( implicit_domain=implicit_domain, visibility=visibility, ) - self.executable : Executable = self.declare( + self.executable: Executable = self.declare( 'executable', ConfigValue( default=Executable('gams'), @@ -92,41 +94,38 @@ def __init__( "``PATH`` for the first available ``gams``.", ), ) - self.logfile : ConfigDict = self.declare( + self.logfile: ConfigDict = self.declare( 'logfile', ConfigValue( - default=None, - description="Filename to output GAMS log to a file.", + default=None, description="Filename to output GAMS log to a file." ), ) self.writer_config: ConfigDict = self.declare( 'writer_config', GAMSWriter.CONFIG() ) + class GAMSResults(Results): def __init__(self): super().__init__() - self.return_code : ConfigDict = self.declare( + self.return_code: ConfigDict = self.declare( 'return_code', - ConfigValue( - default=None, - description="Return code from the GAMS solver.", - ), + ConfigValue(default=None, description="Return code from the GAMS solver."), ) - self.gams_termination_condition : ConfigDict = self.declare( + self.gams_termination_condition: ConfigDict = self.declare( 'gams_termination_condition', ConfigValue( default=None, - description="Include additional TerminationCondition domain." + description="Include additional TerminationCondition domain.", ), - ) - self.gams_solver_status : ConfigDict = self.declare( + ) + self.gams_solver_status: ConfigDict = self.declare( 'gams_solver_status', ConfigValue( - default=None, - description="Include additional SolverStatus domain." + default=None, description="Include additional SolverStatus domain." ), - ) + ) + class GAMS(SolverBase): CONFIG = GAMSConfig() @@ -215,7 +214,7 @@ def version(self, config=None): version = subprocess_results.stdout.splitlines()[0] version = [char for char in version.split(' ') if len(char) > 0][1] self._version_cache = (pth, version) - + return self._version_cache[1] def _rewrite_path_win8p3(self, path): @@ -244,7 +243,6 @@ def _rewrite_path_win8p3(self, path): return buf.value return str(path) - @document_kwargs_from_configdict(CONFIG) def solve(self, model, **kwds): #################################################################### @@ -263,7 +261,7 @@ def solve(self, model, **kwds): else: timer = config.timer StaleFlagManager.mark_all_as_stale() - + # Because GAMS changes the CWD when running the solver, we need # to convert user-provided file names to absolute paths # (relative to the current directory) @@ -274,7 +272,7 @@ def solve(self, model, **kwds): # local variable to hold the working directory name and flags newdir = False - dname = None + dname = None lst = "output.lst" output_filename = None with TempfileManager.new_context() as tempfile: @@ -294,24 +292,28 @@ def solve(self, model, **kwds): basename = os.path.join(dname, model.name) output_filename = basename + '.gms' lst_filename = os.path.join(dname, lst) - with open( - output_filename, 'w', newline='\n', encoding='utf-8' - ) as gms_file: + with open(output_filename, 'w', newline='\n', encoding='utf-8') as gms_file: timer.start(f'write_{output_filename}_file') self._writer.config.set_value(config.writer_config) gms_info = self._writer.write( model, gms_file, symbolic_solver_labels=config.symbolic_solver_labels, - ) + ) # NOTE: omit InfeasibleConstraintException for now timer.stop(f'write_{output_filename}_file') if config.writer_config.put_results_format == 'gdx': results_filename = os.path.join(dname, f"{model.name}_p.gdx") - statresults_filename = os.path.join(dname, "%s_s.gdx" % (config.writer_config.put_results,)) + 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,)) + 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 @@ -338,7 +340,9 @@ def solve(self, model, **kwds): ostreams.append(sys.stdout) with TeeStream(*ostreams) as t: timer.start('subprocess') - subprocess_result = subprocess.run(command, stdout=t.STDOUT, stderr=t.STDERR) + subprocess_result = subprocess.run( + command, stdout=t.STDOUT, stderr=t.STDERR + ) timer.stop('subprocess') rc = subprocess_result.returncode txt = ostreams[0].getvalue() @@ -380,24 +384,27 @@ def solve(self, model, **kwds): timer.stop('parse_dat') finally: if not config.working_dir: - print('Cleaning up temporary directory is handled by `release` from pyomo.common.tempfiles') + print( + 'Cleaning up temporary directory is handled by `release` from pyomo.common.tempfiles' + ) - # NOTE: solve completion time + # NOTE: solve completion time #################################################################### # Postsolve (WIP) #################################################################### - + # Mapping between old and new contrib results - rev_legacy_termination_condition_map = {v: k for k, v in legacy_termination_condition_map.items()} + rev_legacy_termination_condition_map = { + v: k for k, v in legacy_termination_condition_map.items() + } model_suffixes = list( - name - for ( - name, - comp, - ) in pyomo.core.base.suffix.active_import_suffix_generator(model) + name + for (name, comp) in pyomo.core.base.suffix.active_import_suffix_generator( + model ) + ) extract_dual = 'dual' in model_suffixes extract_rc = 'rc' in model_suffixes results = GAMSResults() @@ -418,9 +425,7 @@ def solve(self, model, **kwds): results.gams_termination_condition = TerminationCondition.maxEvaluations elif solvestat == 7: results.gams_solver_status = SolverStatus.aborted - results.gams_termination_condition = ( - TerminationCondition.licensingProblems - ) + results.gams_termination_condition = TerminationCondition.licensingProblems elif solvestat == 8: results.gams_solver_status = SolverStatus.aborted results.gams_termination_condition = TerminationCondition.userInterrupt @@ -439,7 +444,7 @@ def solve(self, model, **kwds): results.gams_solver_status = SolverStatus.error elif solvestat == 6: results.gams_solver_status = SolverStatus.unknown - + modelstat = stat_vars["MODELSTAT"] if modelstat == 1: results.gams_termination_condition = TerminationCondition.optimal @@ -453,7 +458,9 @@ def solve(self, model, **kwds): results.solution_status = SolutionStatus.noSolution elif modelstat in [4, 5, 6, 10, 19]: - results.gams_termination_condition = TerminationCondition.infeasibleOrUnbounded + results.gams_termination_condition = ( + TerminationCondition.infeasibleOrUnbounded + ) results.solution_status = SolutionStatus.infeasible elif modelstat == 7: results.gams_termination_condition = TerminationCondition.feasible @@ -499,14 +506,16 @@ def solve(self, model, **kwds): results.solution_status = SolutionStatus.noSolution # ensure backward compatibility before feeding to contrib.solver - results.termination_condition = rev_legacy_termination_condition_map[results.gams_termination_condition] + results.termination_condition = rev_legacy_termination_condition_map[ + results.gams_termination_condition + ] obj = list(model.component_data_objects(Objective, active=True)) assert len(obj) == 1, 'Only one objective is allowed.' - if ( - results.solution_status in {SolutionStatus.feasible, SolutionStatus.optimal} - ): - results.solution_loader = GMSSolutionLoader(gdx_data=model_soln, gms_info=gms_info) - + if results.solution_status in {SolutionStatus.feasible, SolutionStatus.optimal}: + results.solution_loader = GMSSolutionLoader( + gdx_data=model_soln, gms_info=gms_info + ) + if config.load_solutions: results.solution_loader.load_vars() results.incumbent_objective = stat_vars["OBJVAL"] @@ -522,7 +531,7 @@ def solve(self, model, **kwds): and model.rc.import_enabled() ): model.rc.update(results.solution_loader.get_reduced_costs()) - + else: results.incumbent_objective = value( replace_expressions( @@ -567,15 +576,17 @@ def _parse_gdx_results(self, config, results_filename, statresults_filename): ret = gdxcc.gdxOpenRead(pgdx, statresults_filename) if not ret[0]: raise RuntimeError("GAMS GDX failure (gdxOpenRead): %d." % ret[1]) - + specVals = gdxcc.doubleArray(gdxcc.GMS_SVIDX_MAX) rc = gdxcc.gdxGetSpecialValues(pgdx, specVals) - + specVals[gdxcc.GMS_SVIDX_EPS] = sys.float_info.min specVals[gdxcc.GMS_SVIDX_UNDEF] = float("nan") specVals[gdxcc.GMS_SVIDX_PINF] = float("inf") specVals[gdxcc.GMS_SVIDX_MINF] = float("-inf") - specVals[gdxcc.GMS_SVIDX_NA] = struct.unpack(">d", bytes.fromhex("fffffffffffffffe"))[0] + specVals[gdxcc.GMS_SVIDX_NA] = struct.unpack( + ">d", bytes.fromhex("fffffffffffffffe") + )[0] gdxcc.gdxSetSpecialValues(pgdx, specVals) i = 0 @@ -618,9 +629,11 @@ def _parse_gdx_results(self, config, results_filename, statresults_filename): specVals[gdxcc.GMS_SVIDX_UNDEF] = float("nan") specVals[gdxcc.GMS_SVIDX_PINF] = float("inf") specVals[gdxcc.GMS_SVIDX_MINF] = float("-inf") - specVals[gdxcc.GMS_SVIDX_NA] = struct.unpack(">d", bytes.fromhex("fffffffffffffffe"))[0] + specVals[gdxcc.GMS_SVIDX_NA] = struct.unpack( + ">d", bytes.fromhex("fffffffffffffffe") + )[0] gdxcc.gdxSetSpecialValues(pgdx, specVals) - + i = 0 while True: i += 1 @@ -647,7 +660,7 @@ def _parse_gdx_results(self, config, results_filename, statresults_filename): gdxcc.gdxFree(pgdx) gdxcc.gdxLibraryUnload() return model_soln, stat_vars - + def _parse_dat_results(self, config, results_filename, statresults_filename): with open(statresults_filename, 'r') as statresults_file: statresults_text = statresults_file.read() @@ -672,4 +685,3 @@ def _parse_dat_results(self, config, results_filename, statresults_filename): model_soln[items[0]] = (float(items[1]), float(items[2])) return model_soln, stat_vars - \ No newline at end of file diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py index a670b8643d5..b40ae0af7ba 100644 --- a/pyomo/contrib/solver/solvers/gms_sol_reader.py +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -21,6 +21,7 @@ from pyomo.repn.plugins.gams_writer_v2 import GAMSWriterInfo from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase + class GDXFileData: """ Defines the data types found within a .gdx file @@ -40,6 +41,7 @@ class GMSSolutionLoader(SolutionLoaderBase): """ Loader for solvers that create .gms files (e.g., gams) """ + def __init__(self, gdx_data: GDXFileData, gms_info: GAMSWriterInfo) -> None: self._gdx_data = gdx_data self._gms_info = gms_info @@ -56,7 +58,7 @@ def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> NoRetur for sym, obj in self._gms_info.var_symbol_map.bySymbol.items(): level = self._gdx_data[sym][0] if obj.parent_component().ctype is Var: - obj.set_value(level, skip_validation = True) + obj.set_value(level, skip_validation=True) StaleFlagManager.mark_all_as_stale(delayed=True) @@ -105,10 +107,10 @@ def get_duals( cons_to_load = set(cons_to_load) for sym, con in self._gms_info.con_symbol_map.bySymbol.items(): if sym in cons_to_load and con.parent_component().ctype is not Objective: - res[con] = self._gdx_data[sym][1] + res[con] = self._gdx_data[sym][1] return res - def get_reduced_costs(self, vars_to_load = None): + def get_reduced_costs(self, vars_to_load=None): if self._gms_info is None: raise RuntimeError( 'Solution loader does not currently have a valid solution. Please ' @@ -119,7 +121,7 @@ def get_reduced_costs(self, vars_to_load = None): 'Solution loader does not currently have a valid solution. Please ' 'check results.termination_condition and/or results.solution_status.' ) - + res = {} if vars_to_load is None: @@ -128,5 +130,5 @@ def get_reduced_costs(self, vars_to_load = None): vars_to_load = set(vars_to_load) for sym, var in self._gms_info.var_symbol_map.bySymbol.items(): if sym in vars_to_load and var.parent_component().ctype is Var: - res[var.name] = self._gdx_data[sym][1] - return res \ No newline at end of file + res[var.name] = self._gdx_data[sym][1] + return res diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index c3aec7a58f0..f2d3d1442eb 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -39,6 +39,7 @@ from pyomo.core.base.component import ActiveComponent from pyomo.core.base.label import NumericLabeler from pyomo.opt import WriterFactory + # from pyomo.repn.quadratic import QuadraticRepnVisitor from pyomo.repn.linear import LinearRepnVisitor from pyomo.repn.util import ( @@ -76,7 +77,10 @@ def __init__(self, var_symbol_map, con_symbol_map): self.var_symbol_map = var_symbol_map self.con_symbol_map = con_symbol_map -@WriterFactory.register('gams_writer_v2', 'Generate the corresponding gms file (version 2).') + +@WriterFactory.register( + 'gams_writer_v2', 'Generate the corresponding gms file (version 2).' +) class GAMSWriter(object): CONFIG = ConfigBlock('gamswriter') @@ -220,7 +224,7 @@ class GAMSWriter(object): ConfigValue( default='results', domain=str, - doc =""" + doc=""" Filename for optionally writing solution values and marginals. If put_results_format is 'gdx', then GAMS will write solution values and marginals to @@ -229,7 +233,7 @@ class GAMSWriter(object): then solution values and marginals are written to (put_results).dat, and solver statuses to (put_results + 'stat').dat. - """ + """, ), ) CONFIG.declare( @@ -239,7 +243,7 @@ class GAMSWriter(object): description="Format used for put_results, one of 'gdx', 'dat'", ), ) - # NOTE: Taken from the lp_writer + # NOTE: Taken from the lp_writer CONFIG.declare( 'allow_quadratic_objective', ConfigValue( @@ -269,7 +273,7 @@ class GAMSWriter(object): def __init__(self): self.config = self.CONFIG() - def __call__(self, model, filename, solver_capability, io_options): + def __call__(self, model, filename, solver_capability, io_options): if filename is None: filename = model.name + ".gms" @@ -277,9 +281,9 @@ def __call__(self, model, filename, solver_capability, io_options): with open(filename, 'w', newline='') as FILE: info = self.write(model, FILE, config=config) - + return filename, info.symbol_map - + @document_kwargs_from_configdict(CONFIG) def write(self, model, ostream, **options) -> GAMSWriterInfo: """Write a model in GMS format. @@ -307,6 +311,7 @@ def write(self, model, ostream, **options) -> GAMSWriterInfo: with PauseGC(): return _GMSWriter_impl(ostream, config).write(model) + class _GMSWriter_impl(object): def __init__(self, ostream, config): # taken from lp_writer.py @@ -379,7 +384,7 @@ def write(self, model): "'symbolic_solver_labels' and 'labeler' " "I/O options is forbidden" ) - + if symbolic_solver_labels: # Note that the Var and Constraint labelers must use the # same labeler, so that we can correctly detect name @@ -404,19 +409,20 @@ def write(self, model): self.con_symbol_map = SymbolMap(con_labeler) self.var_order = {_id: i for i, _id in enumerate(self.var_map)} self.var_recorder = OrderedVarRecorder(self.var_map, self.var_order, sorter) - + visitor = LinearRepnVisitor( - self.subexpression_cache, - var_recorder=self.var_recorder, + self.subexpression_cache, var_recorder=self.var_recorder ) - + # # Tabulate constraints # skip_trivial_constraints = self.config.skip_trivial_constraints have_nontrivial = False last_parent = None - con_list = {} # NOTE: Save the constraint representation and write it after variables/equations declare + con_list = ( + {} + ) # NOTE: Save the constraint representation and write it after variables/equations declare for con in ordered_active_constraints(model, self.config): if with_debug_timing and con.parent_component() is not last_parent: timer.toc('Constraint %s', last_parent, level=logging.DEBUG) @@ -437,7 +443,7 @@ def write(self, model): raise ValueError( f"Model constraint ({con.name}) contains nonlinear terms that is currently not supported in the new gams_writer" ) - + # Pull out the constant: we will move it to the bounds offset = repn.constant repn.constant = 0 @@ -476,7 +482,7 @@ def write(self, model): raise NotImplementedError( "Bounded constraints within the same expression is not supported" ) - + elif ub is not None: label = f'{con_symbol}_hi' self.con_symbol_map.addSymbol(con, label) @@ -513,7 +519,7 @@ def write(self, model): f"Model objective ({obj.name}) contains nonlinear terms that " "is currently not supported in this new GAMSWriter" ) - + label = self.con_symbol_map.getSymbol(obj, con_labeler) declaration = f'\n{label}.. -GAMS_OBJECTIVE ' definition = self.write_expression(ostream, repn) @@ -529,17 +535,13 @@ def write(self, model): var_bounds = {} getSymbolByObjectID = self.var_symbol_map.byObject.get - ostream.write( - "VARIABLES \n" - ) + ostream.write("VARIABLES \n") for vid, v in self.var_map.items(): v_symbol = getSymbolByObjectID(vid, None) if not v_symbol: - continue + continue if v.is_continuous(): - ostream.write( - f"\t{v_symbol} \n" - ) + ostream.write(f"\t{v_symbol} \n") lb, ub = v.bounds var_bounds[v_symbol] = (lb, ub) elif v.is_binary(): @@ -549,14 +551,11 @@ def write(self, model): var_bounds[v_symbol] = (lb, ub) integer_vars.append(v_symbol) + ostream.write(f"\tGAMS_OBJECTIVE;\n\n") - ostream.write( - f"\tGAMS_OBJECTIVE;\n\n" - ) - if integer_vars: ostream.write("\nINTEGER VARIABLES\n\t") - ostream.write("\n\t".join(integer_vars)+ ';\n\n') + ostream.write("\n\t".join(integer_vars) + ';\n\n') if binary_vars: ostream.write("\nBINARY VARIABLES\n\t") @@ -565,12 +564,10 @@ def write(self, model): # # Writing out the equations/constraints # - ostream.write( - "EQUATIONS \n" - ) + ostream.write("EQUATIONS \n") for id, cid in enumerate(self.con_symbol_map.byObject.keys()): c = self.con_symbol_map.byObject[cid] - if id != len(self.con_symbol_map.byObject.keys())-1: + if id != len(self.con_symbol_map.byObject.keys()) - 1: ostream.write(f"\t{c}\n") else: ostream.write(f"\t{c};\n\n") @@ -580,7 +577,7 @@ def write(self, model): # # Handling variable bounds - # + # for v, (lb, ub) in var_bounds.items(): ostream.write(f'{v}.lo = {lb};\n{v}.up = {ub};\n') @@ -591,7 +588,7 @@ def write(self, model): # CHECK FOR mtype flag based on variable domains - reals, integer if config.mtype is None: if binary_vars or integer_vars: - config.mtype = 'mip' # expand this to nlp, minlp + config.mtype = 'mip' # expand this to nlp, minlp else: config.mtype = 'lp' @@ -614,9 +611,7 @@ def write(self, model): 'NUMNZ', 'ETSOLVE', ] - ostream.write( - "\nScalars MODELSTAT 'model status', SOLVESTAT 'solve status';\n" - ) + ostream.write("\nScalars MODELSTAT 'model status', SOLVESTAT 'solve status';\n") ostream.write("MODELSTAT = %s.modelstat;\n" % model.name) ostream.write("SOLVESTAT = %s.solvestat;\n\n" % model.name) @@ -671,7 +666,6 @@ def write(self, model): for stat in stat_vars: ostream.write("\nput '%s' ' ' %s /;\n" % (stat, stat)) - timer.toc("Finished writing .gsm file", level=logging.DEBUG) info = GAMSWriterInfo(self.var_symbol_map, self.con_symbol_map) @@ -693,6 +687,6 @@ def write_expression(self, ostream, expr): expr_str += f'{coef!s}*{getSymbol(getVar(vid))} \n' else: # ostream.write(f'+{coef!s}*{getSymbol(getVar(vid))}') - expr_str += f'+ {coef!s} * {getSymbol(getVar(vid))} \n' + expr_str += f'+ {coef!s} * {getSymbol(getVar(vid))} \n' - return expr_str \ No newline at end of file + return expr_str From b36086f47532ebfe3240611ff301821cda451d33 Mon Sep 17 00:00:00 2001 From: Miranda Mundt <55767766+mrmundt@users.noreply.github.com> Date: Thu, 7 Aug 2025 12:20:17 -0600 Subject: [PATCH 03/29] Typo! --- pyomo/repn/plugins/gams_writer_v2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index f2d3d1442eb..0ba80f5b4e0 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -307,7 +307,7 @@ def write(self, model, ostream, **options) -> GAMSWriterInfo: # representation generates (and disposes of) a large number of # small objects. - # NOTE: First pass write the model but needs variables/equations defition first + # NOTE: First pass write the model but needs variables/equations definition first with PauseGC(): return _GMSWriter_impl(ostream, config).write(model) From e91e480d233f844365fa11923c11bdbbdbb284a9 Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Thu, 7 Aug 2025 16:57:44 -0400 Subject: [PATCH 04/29] Added ignore checking for constant in the objective function during writing process. Added WIP unittest for the new solver interface --- .../solver/tests/solvers/test_gams_v2.py | 203 ++++++++++++++++++ pyomo/repn/plugins/gams_writer_v2.py | 82 +++---- 2 files changed, 248 insertions(+), 37 deletions(-) create mode 100644 pyomo/contrib/solver/tests/solvers/test_gams_v2.py diff --git a/pyomo/contrib/solver/tests/solvers/test_gams_v2.py b/pyomo/contrib/solver/tests/solvers/test_gams_v2.py new file mode 100644 index 00000000000..fcd7e4f1772 --- /dev/null +++ b/pyomo/contrib/solver/tests/solvers/test_gams_v2.py @@ -0,0 +1,203 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import os +import subprocess + +from pyomo.core.base import SymbolMap +from pyomo.core.base.label import NumericLabeler +import pyomo.environ as pyo +from pyomo.common.fileutils import ExecutableData +from pyomo.common.config import ConfigDict +from pyomo.common.errors import DeveloperError +import pyomo.contrib.solver.solvers.gams as gams +from pyomo.contrib.solver.common.util import NoSolutionError +from pyomo.opt.base import SolverFactory +from pyomo.common import unittest, Executable +from pyomo.common.tempfiles import TempfileManager +from pyomo.repn.plugins.gams_writer_v2 import GAMSWriter +import pdb + +""" +Formatted after pyomo/pyomo/contrib/solver/test/solvers/test_ipopt.py +""" + + +gams_available = gams.GAMS().available() + + +@unittest.skipIf(not gams_available, "The 'gams' command is not available") +class TestGAMSSolverConfig(unittest.TestCase): + def test_default_instantiation(self): + config = gams.GAMSConfig() + # Should be inherited + self.assertIsNone(config._description) + self.assertEqual(config._visibility, 0) + self.assertFalse(config.tee) + self.assertTrue(config.load_solutions) + self.assertTrue(config.raise_exception_on_nonoptimal_result) + self.assertFalse(config.symbolic_solver_labels) + self.assertIsNone(config.timer) + self.assertIsNone(config.threads) + self.assertIsNone(config.time_limit) + # Unique to this object + self.assertIsInstance(config.executable, type(Executable('path'))) + self.assertIsInstance(config.writer_config, type(GAMSWriter.CONFIG())) + + def test_custom_instantiation(self): + config = gams.GAMSConfig(description="A description") + config.tee = True + self.assertTrue(config.tee) + self.assertEqual(config._description, "A description") + self.assertIsNone(config.time_limit) + # Default should be `gams` + self.assertIsNotNone(str(config.executable)) + self.assertIn('gams', str(config.executable)) + # Set to a totally bogus path + config.executable = Executable('/bogus/path') + self.assertIsNone(config.executable.executable) + self.assertFalse(config.executable.available()) + + +class TestGAMSSolutionLoader(unittest.TestCase): + def test_get_reduced_costs_error(self): + loader = gams.GMSSolutionLoader(None, None) + with self.assertRaises(RuntimeError): + loader.get_primals() + loader.get_duals() + loader.get_reduced_costs() + + # Set _gms_info to something completely bogus but is not None + # Set the var_symbol_map and con_symbol_map to empty SymbolMap object type + class GAMSInfo: + pass + + class GDXData: + pass + + loader._gms_info = GAMSInfo() + loader._gms_info.var_symbol_map = SymbolMap(NumericLabeler('x')) + loader._gms_info.con_symbol_map = SymbolMap(NumericLabeler('c')) + + # We are asserting if there is no solution, the SymbolMap for variable length must be 0 + loader.get_primals() + + # if the model is infeasible, no dual information is returned + with self.assertRaises(RuntimeError): + loader.get_duals() + + +@unittest.skipIf(not gams_available, "The 'gams' command is not available") +class TestGAMSInterface(unittest.TestCase): + def test_class_member_list(self): + opt = gams.GAMS() + expected_list = [ + 'CONFIG', + 'available', + 'config', + 'is_persistent', + 'name', + 'solve', + 'version', + ] + method_list = [method for method in dir(opt) if method.startswith('_') is False] + self.assertEqual(sorted(expected_list), sorted(method_list)) + + def test_default_instantiation(self): + opt = gams.GAMS() + self.assertFalse(opt.is_persistent()) + self.assertIsNotNone(opt.version()) + self.assertEqual(opt.name, 'gams') + self.assertEqual(opt.CONFIG, opt.config) + self.assertTrue(opt.available()) + + def test_context_manager(self): + with gams.GAMS() as opt: + self.assertFalse(opt.is_persistent()) + self.assertIsNotNone(opt.version()) + self.assertEqual(opt.name, 'gams') + self.assertEqual(opt.CONFIG, opt.config) + self.assertTrue(opt.available()) + + def test_available(self): + opt = gams.GAMS() + self.assertTrue(opt.available()) + # Now we will try with a custom config that has a fake path + config = gams.GAMSConfig() + config.executable = Executable('/a/bogus/path') + with self.assertRaises(NameError): + opt.available(config=config) + + # _run_simple_model will return False because of the invalid path + self.assertFalse(opt._run_simple_model(config, 1)) + + def test_version(self): + opt = gams.GAMS() + self.assertIsNotNone(opt.version()) + + def test_write_gms_file(self): + # We are creating a simple model with 1 variable to check for gams execution + opt = gams.GAMS() + config = gams.GAMSConfig() + result = opt._run_simple_model(config, 1) + self.assertTrue(result) + + # Pass it some options that ARE on the command line and create a .gms file + # Currently solver_options is not implemented in the new interface + solver_exec = config.executable.path() + opt = gams.GAMS(solver_options={'iterLim': 1}) + with TempfileManager.new_context() as temp: + dname = temp.mkdtemp() + if not os.path.exists(dname): + os.mkdir(dname) + filename = os.path.join(dname, 'test.gms') + with open(filename, 'w') as FILE: + FILE.write(opt._simple_model(1)) + result = subprocess.run( + [solver_exec, filename, "curdir=" + dname, 'lo=0'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + self.assertTrue(result.returncode==0) + self.assertTrue(os.path.isfile(filename)) + +class TestGAMS(unittest.TestCase): + def create_model(self): + model = pyo.ConcreteModel('TestModel') + model.x = pyo.Var(initialize=1.5, bounds = (-5, 5)) + model.y = pyo.Var(initialize=1.5, bounds = (-5, 5)) + + def dummy_equation(m): + return (1.0 - m.x) + 100.0 * (m.y - m.x) + + model.obj = pyo.Objective(rule=dummy_equation, sense=pyo.minimize) + return model + + def test_gams_config(self): + # Test default initialization + config = gams.GAMSConfig() + self.assertTrue(config.load_solutions) + self.assertIsInstance(config.solver_options, ConfigDict) + self.assertIsInstance(config.executable, ExecutableData) + + # Test custom initialization + solver = SolverFactory('gams_v2', executable='/path/to/exe') + self.assertFalse(solver.config.tee) + self.assertTrue(solver.config.executable.startswith('/path')) + + # Change value on a solve call + # config = gams.GAMSConfig() + with TempfileManager.new_context() as temp: + dname = temp.mkdtemp() + # if working_dir is not specified, the tmpdir is deleted before solve can happen + solver = SolverFactory('gams_v2', working_dir=dname) + model = self.create_model() + solver.solve(model, tee=False, load_solutions = False) diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index 0ba80f5b4e0..ab803b6aa56 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -39,7 +39,6 @@ from pyomo.core.base.component import ActiveComponent from pyomo.core.base.label import NumericLabeler from pyomo.opt import WriterFactory - # from pyomo.repn.quadratic import QuadraticRepnVisitor from pyomo.repn.linear import LinearRepnVisitor from pyomo.repn.util import ( @@ -77,10 +76,7 @@ def __init__(self, var_symbol_map, con_symbol_map): self.var_symbol_map = var_symbol_map self.con_symbol_map = con_symbol_map - -@WriterFactory.register( - 'gams_writer_v2', 'Generate the corresponding gms file (version 2).' -) +@WriterFactory.register('gams_writer_v2', 'Generate the corresponding gms file (version 2).') class GAMSWriter(object): CONFIG = ConfigBlock('gamswriter') @@ -224,7 +220,7 @@ class GAMSWriter(object): ConfigValue( default='results', domain=str, - doc=""" + doc =""" Filename for optionally writing solution values and marginals. If put_results_format is 'gdx', then GAMS will write solution values and marginals to @@ -233,7 +229,7 @@ class GAMSWriter(object): then solution values and marginals are written to (put_results).dat, and solver statuses to (put_results + 'stat').dat. - """, + """ ), ) CONFIG.declare( @@ -243,7 +239,7 @@ class GAMSWriter(object): description="Format used for put_results, one of 'gdx', 'dat'", ), ) - # NOTE: Taken from the lp_writer + # NOTE: Taken from the lp_writer CONFIG.declare( 'allow_quadratic_objective', ConfigValue( @@ -273,7 +269,7 @@ class GAMSWriter(object): def __init__(self): self.config = self.CONFIG() - def __call__(self, model, filename, solver_capability, io_options): + def __call__(self, model, filename, solver_capability, io_options): if filename is None: filename = model.name + ".gms" @@ -281,9 +277,9 @@ def __call__(self, model, filename, solver_capability, io_options): with open(filename, 'w', newline='') as FILE: info = self.write(model, FILE, config=config) - + return filename, info.symbol_map - + @document_kwargs_from_configdict(CONFIG) def write(self, model, ostream, **options) -> GAMSWriterInfo: """Write a model in GMS format. @@ -311,7 +307,6 @@ def write(self, model, ostream, **options) -> GAMSWriterInfo: with PauseGC(): return _GMSWriter_impl(ostream, config).write(model) - class _GMSWriter_impl(object): def __init__(self, ostream, config): # taken from lp_writer.py @@ -384,7 +379,7 @@ def write(self, model): "'symbolic_solver_labels' and 'labeler' " "I/O options is forbidden" ) - + if symbolic_solver_labels: # Note that the Var and Constraint labelers must use the # same labeler, so that we can correctly detect name @@ -409,20 +404,19 @@ def write(self, model): self.con_symbol_map = SymbolMap(con_labeler) self.var_order = {_id: i for i, _id in enumerate(self.var_map)} self.var_recorder = OrderedVarRecorder(self.var_map, self.var_order, sorter) - + visitor = LinearRepnVisitor( - self.subexpression_cache, var_recorder=self.var_recorder + self.subexpression_cache, + var_recorder=self.var_recorder, ) - + # # Tabulate constraints # skip_trivial_constraints = self.config.skip_trivial_constraints have_nontrivial = False last_parent = None - con_list = ( - {} - ) # NOTE: Save the constraint representation and write it after variables/equations declare + con_list = {} # NOTE: Save the constraint representation and write it after variables/equations declare for con in ordered_active_constraints(model, self.config): if with_debug_timing and con.parent_component() is not last_parent: timer.toc('Constraint %s', last_parent, level=logging.DEBUG) @@ -443,7 +437,7 @@ def write(self, model): raise ValueError( f"Model constraint ({con.name}) contains nonlinear terms that is currently not supported in the new gams_writer" ) - + # Pull out the constant: we will move it to the bounds offset = repn.constant repn.constant = 0 @@ -482,7 +476,7 @@ def write(self, model): raise NotImplementedError( "Bounded constraints within the same expression is not supported" ) - + elif ub is not None: label = f'{con_symbol}_hi' self.con_symbol_map.addSymbol(con, label) @@ -519,10 +513,10 @@ def write(self, model): f"Model objective ({obj.name}) contains nonlinear terms that " "is currently not supported in this new GAMSWriter" ) - + label = self.con_symbol_map.getSymbol(obj, con_labeler) declaration = f'\n{label}.. -GAMS_OBJECTIVE ' - definition = self.write_expression(ostream, repn) + definition = self.write_expression(ostream, repn, True) bounds = f' =E= {(-repn.constant)!s};\n\n' con_list[label] = declaration + definition + bounds self.var_symbol_map.addSymbol(obj, label) @@ -535,13 +529,17 @@ def write(self, model): var_bounds = {} getSymbolByObjectID = self.var_symbol_map.byObject.get - ostream.write("VARIABLES \n") + ostream.write( + "VARIABLES \n" + ) for vid, v in self.var_map.items(): v_symbol = getSymbolByObjectID(vid, None) if not v_symbol: - continue + continue if v.is_continuous(): - ostream.write(f"\t{v_symbol} \n") + ostream.write( + f"\t{v_symbol} \n" + ) lb, ub = v.bounds var_bounds[v_symbol] = (lb, ub) elif v.is_binary(): @@ -551,11 +549,14 @@ def write(self, model): var_bounds[v_symbol] = (lb, ub) integer_vars.append(v_symbol) - ostream.write(f"\tGAMS_OBJECTIVE;\n\n") + ostream.write( + f"\tGAMS_OBJECTIVE;\n\n" + ) + if integer_vars: ostream.write("\nINTEGER VARIABLES\n\t") - ostream.write("\n\t".join(integer_vars) + ';\n\n') + ostream.write("\n\t".join(integer_vars)+ ';\n\n') if binary_vars: ostream.write("\nBINARY VARIABLES\n\t") @@ -564,10 +565,12 @@ def write(self, model): # # Writing out the equations/constraints # - ostream.write("EQUATIONS \n") + ostream.write( + "EQUATIONS \n" + ) for id, cid in enumerate(self.con_symbol_map.byObject.keys()): c = self.con_symbol_map.byObject[cid] - if id != len(self.con_symbol_map.byObject.keys()) - 1: + if id != len(self.con_symbol_map.byObject.keys())-1: ostream.write(f"\t{c}\n") else: ostream.write(f"\t{c};\n\n") @@ -577,7 +580,7 @@ def write(self, model): # # Handling variable bounds - # + # for v, (lb, ub) in var_bounds.items(): ostream.write(f'{v}.lo = {lb};\n{v}.up = {ub};\n') @@ -588,7 +591,7 @@ def write(self, model): # CHECK FOR mtype flag based on variable domains - reals, integer if config.mtype is None: if binary_vars or integer_vars: - config.mtype = 'mip' # expand this to nlp, minlp + config.mtype = 'mip' # expand this to nlp, minlp else: config.mtype = 'lp' @@ -611,7 +614,9 @@ def write(self, model): 'NUMNZ', 'ETSOLVE', ] - ostream.write("\nScalars MODELSTAT 'model status', SOLVESTAT 'solve status';\n") + ostream.write( + "\nScalars MODELSTAT 'model status', SOLVESTAT 'solve status';\n" + ) ostream.write("MODELSTAT = %s.modelstat;\n" % model.name) ostream.write("SOLVESTAT = %s.solvestat;\n\n" % model.name) @@ -666,14 +671,17 @@ def write(self, model): for stat in stat_vars: ostream.write("\nput '%s' ' ' %s /;\n" % (stat, stat)) + timer.toc("Finished writing .gsm file", level=logging.DEBUG) info = GAMSWriterInfo(self.var_symbol_map, self.con_symbol_map) return info - def write_expression(self, ostream, expr): + def write_expression(self, ostream, expr, is_objective=False): save_eq = [] - assert not expr.constant + + if not is_objective: + assert not expr.constant getSymbol = self.var_symbol_map.getSymbol getVarOrder = self.var_order.__getitem__ getVar = self.var_map.__getitem__ @@ -687,6 +695,6 @@ def write_expression(self, ostream, expr): expr_str += f'{coef!s}*{getSymbol(getVar(vid))} \n' else: # ostream.write(f'+{coef!s}*{getSymbol(getVar(vid))}') - expr_str += f'+ {coef!s} * {getSymbol(getVar(vid))} \n' + expr_str += f'+ {coef!s} * {getSymbol(getVar(vid))} \n' - return expr_str + return expr_str \ No newline at end of file From b060096065355ebb629e3e246c95a7f7fd2def95 Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Fri, 8 Aug 2025 13:02:46 -0400 Subject: [PATCH 05/29] Added support for add_options for multiple solver_options --- pyomo/repn/plugins/gams_writer_v2.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index ab803b6aa56..cd78dc4a215 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -303,7 +303,7 @@ def write(self, model, ostream, **options) -> GAMSWriterInfo: # representation generates (and disposes of) a large number of # small objects. - # NOTE: First pass write the model but needs variables/equations definition first + # NOTE: First pass write the model but needs variables/equations defition first with PauseGC(): return _GMSWriter_impl(ostream, config).write(model) @@ -316,6 +316,7 @@ def __init__(self, ostream, config): # Taken from nl_writer.py self.symbolic_solver_labels = config.symbolic_solver_labels + self.add_options = config.add_options self.subexpression_cache = {} self.subexpression_order = None # set to [] later self.external_functions = {} @@ -334,6 +335,7 @@ def write(self, model): # Caching some frequently-used objects into the locals() symbolic_solver_labels = self.symbolic_solver_labels + add_options = self.add_options ostream = self.ostream config = self.config labeler = config.labeler @@ -598,6 +600,12 @@ def write(self, model): if config.put_results_format == 'gdx': ostream.write("option savepoint=1;\n") + if add_options is not None: + ostream.write("\n* START USER ADDITIONAL OPTIONS\n") + for line in add_options: + ostream.write('option ' + line + '\n') + ostream.write("\n\n* END USER ADDITIONAL OPTIONS\n\n") + ostream.write( "SOLVE %s USING %s %simizing GAMS_OBJECTIVE;\n" % (model.name, config.mtype, 'min' if obj.sense == minimize else 'max') From 96ba64695caf2d0a920830034c82b7c9d40add7c Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Fri, 8 Aug 2025 13:04:17 -0400 Subject: [PATCH 06/29] typo --- pyomo/repn/plugins/gams_writer_v2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index cd78dc4a215..4608dec80dc 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -303,7 +303,7 @@ def write(self, model, ostream, **options) -> GAMSWriterInfo: # representation generates (and disposes of) a large number of # small objects. - # NOTE: First pass write the model but needs variables/equations defition first + # NOTE: First pass write the model but needs variables/equations definition first with PauseGC(): return _GMSWriter_impl(ostream, config).write(model) From b0f16a30bbcfaae07b50ab8bdaae1e48600cc897 Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Fri, 8 Aug 2025 13:56:40 -0400 Subject: [PATCH 07/29] ran black formatter --- .../solver/tests/solvers/test_gams_v2.py | 11 +-- pyomo/repn/plugins/gams_writer_v2.py | 74 +++++++++---------- 2 files changed, 40 insertions(+), 45 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gams_v2.py b/pyomo/contrib/solver/tests/solvers/test_gams_v2.py index fcd7e4f1772..05a9ae0ca6d 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gams_v2.py +++ b/pyomo/contrib/solver/tests/solvers/test_gams_v2.py @@ -153,7 +153,7 @@ def test_write_gms_file(self): # Pass it some options that ARE on the command line and create a .gms file # Currently solver_options is not implemented in the new interface solver_exec = config.executable.path() - opt = gams.GAMS(solver_options={'iterLim': 1}) + opt = gams.GAMS(solver_options={'iterLim': 1}) with TempfileManager.new_context() as temp: dname = temp.mkdtemp() if not os.path.exists(dname): @@ -166,14 +166,15 @@ def test_write_gms_file(self): stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - self.assertTrue(result.returncode==0) + self.assertTrue(result.returncode == 0) self.assertTrue(os.path.isfile(filename)) + class TestGAMS(unittest.TestCase): def create_model(self): model = pyo.ConcreteModel('TestModel') - model.x = pyo.Var(initialize=1.5, bounds = (-5, 5)) - model.y = pyo.Var(initialize=1.5, bounds = (-5, 5)) + model.x = pyo.Var(initialize=1.5, bounds=(-5, 5)) + model.y = pyo.Var(initialize=1.5, bounds=(-5, 5)) def dummy_equation(m): return (1.0 - m.x) + 100.0 * (m.y - m.x) @@ -200,4 +201,4 @@ def test_gams_config(self): # if working_dir is not specified, the tmpdir is deleted before solve can happen solver = SolverFactory('gams_v2', working_dir=dname) model = self.create_model() - solver.solve(model, tee=False, load_solutions = False) + solver.solve(model, tee=False, load_solutions=False) diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index 4608dec80dc..3de30880117 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -39,6 +39,7 @@ from pyomo.core.base.component import ActiveComponent from pyomo.core.base.label import NumericLabeler from pyomo.opt import WriterFactory + # from pyomo.repn.quadratic import QuadraticRepnVisitor from pyomo.repn.linear import LinearRepnVisitor from pyomo.repn.util import ( @@ -76,7 +77,10 @@ def __init__(self, var_symbol_map, con_symbol_map): self.var_symbol_map = var_symbol_map self.con_symbol_map = con_symbol_map -@WriterFactory.register('gams_writer_v2', 'Generate the corresponding gms file (version 2).') + +@WriterFactory.register( + 'gams_writer_v2', 'Generate the corresponding gms file (version 2).' +) class GAMSWriter(object): CONFIG = ConfigBlock('gamswriter') @@ -220,7 +224,7 @@ class GAMSWriter(object): ConfigValue( default='results', domain=str, - doc =""" + doc=""" Filename for optionally writing solution values and marginals. If put_results_format is 'gdx', then GAMS will write solution values and marginals to @@ -229,7 +233,7 @@ class GAMSWriter(object): then solution values and marginals are written to (put_results).dat, and solver statuses to (put_results + 'stat').dat. - """ + """, ), ) CONFIG.declare( @@ -239,7 +243,7 @@ class GAMSWriter(object): description="Format used for put_results, one of 'gdx', 'dat'", ), ) - # NOTE: Taken from the lp_writer + # NOTE: Taken from the lp_writer CONFIG.declare( 'allow_quadratic_objective', ConfigValue( @@ -269,7 +273,7 @@ class GAMSWriter(object): def __init__(self): self.config = self.CONFIG() - def __call__(self, model, filename, solver_capability, io_options): + def __call__(self, model, filename, solver_capability, io_options): if filename is None: filename = model.name + ".gms" @@ -277,9 +281,9 @@ def __call__(self, model, filename, solver_capability, io_options): with open(filename, 'w', newline='') as FILE: info = self.write(model, FILE, config=config) - + return filename, info.symbol_map - + @document_kwargs_from_configdict(CONFIG) def write(self, model, ostream, **options) -> GAMSWriterInfo: """Write a model in GMS format. @@ -307,6 +311,7 @@ def write(self, model, ostream, **options) -> GAMSWriterInfo: with PauseGC(): return _GMSWriter_impl(ostream, config).write(model) + class _GMSWriter_impl(object): def __init__(self, ostream, config): # taken from lp_writer.py @@ -381,7 +386,7 @@ def write(self, model): "'symbolic_solver_labels' and 'labeler' " "I/O options is forbidden" ) - + if symbolic_solver_labels: # Note that the Var and Constraint labelers must use the # same labeler, so that we can correctly detect name @@ -406,19 +411,20 @@ def write(self, model): self.con_symbol_map = SymbolMap(con_labeler) self.var_order = {_id: i for i, _id in enumerate(self.var_map)} self.var_recorder = OrderedVarRecorder(self.var_map, self.var_order, sorter) - + visitor = LinearRepnVisitor( - self.subexpression_cache, - var_recorder=self.var_recorder, + self.subexpression_cache, var_recorder=self.var_recorder ) - + # # Tabulate constraints # skip_trivial_constraints = self.config.skip_trivial_constraints have_nontrivial = False last_parent = None - con_list = {} # NOTE: Save the constraint representation and write it after variables/equations declare + con_list = ( + {} + ) # NOTE: Save the constraint representation and write it after variables/equations declare for con in ordered_active_constraints(model, self.config): if with_debug_timing and con.parent_component() is not last_parent: timer.toc('Constraint %s', last_parent, level=logging.DEBUG) @@ -439,7 +445,7 @@ def write(self, model): raise ValueError( f"Model constraint ({con.name}) contains nonlinear terms that is currently not supported in the new gams_writer" ) - + # Pull out the constant: we will move it to the bounds offset = repn.constant repn.constant = 0 @@ -478,7 +484,7 @@ def write(self, model): raise NotImplementedError( "Bounded constraints within the same expression is not supported" ) - + elif ub is not None: label = f'{con_symbol}_hi' self.con_symbol_map.addSymbol(con, label) @@ -515,7 +521,7 @@ def write(self, model): f"Model objective ({obj.name}) contains nonlinear terms that " "is currently not supported in this new GAMSWriter" ) - + label = self.con_symbol_map.getSymbol(obj, con_labeler) declaration = f'\n{label}.. -GAMS_OBJECTIVE ' definition = self.write_expression(ostream, repn, True) @@ -531,17 +537,13 @@ def write(self, model): var_bounds = {} getSymbolByObjectID = self.var_symbol_map.byObject.get - ostream.write( - "VARIABLES \n" - ) + ostream.write("VARIABLES \n") for vid, v in self.var_map.items(): v_symbol = getSymbolByObjectID(vid, None) if not v_symbol: - continue + continue if v.is_continuous(): - ostream.write( - f"\t{v_symbol} \n" - ) + ostream.write(f"\t{v_symbol} \n") lb, ub = v.bounds var_bounds[v_symbol] = (lb, ub) elif v.is_binary(): @@ -551,14 +553,11 @@ def write(self, model): var_bounds[v_symbol] = (lb, ub) integer_vars.append(v_symbol) + ostream.write(f"\tGAMS_OBJECTIVE;\n\n") - ostream.write( - f"\tGAMS_OBJECTIVE;\n\n" - ) - if integer_vars: ostream.write("\nINTEGER VARIABLES\n\t") - ostream.write("\n\t".join(integer_vars)+ ';\n\n') + ostream.write("\n\t".join(integer_vars) + ';\n\n') if binary_vars: ostream.write("\nBINARY VARIABLES\n\t") @@ -567,12 +566,10 @@ def write(self, model): # # Writing out the equations/constraints # - ostream.write( - "EQUATIONS \n" - ) + ostream.write("EQUATIONS \n") for id, cid in enumerate(self.con_symbol_map.byObject.keys()): c = self.con_symbol_map.byObject[cid] - if id != len(self.con_symbol_map.byObject.keys())-1: + if id != len(self.con_symbol_map.byObject.keys()) - 1: ostream.write(f"\t{c}\n") else: ostream.write(f"\t{c};\n\n") @@ -582,7 +579,7 @@ def write(self, model): # # Handling variable bounds - # + # for v, (lb, ub) in var_bounds.items(): ostream.write(f'{v}.lo = {lb};\n{v}.up = {ub};\n') @@ -593,7 +590,7 @@ def write(self, model): # CHECK FOR mtype flag based on variable domains - reals, integer if config.mtype is None: if binary_vars or integer_vars: - config.mtype = 'mip' # expand this to nlp, minlp + config.mtype = 'mip' # expand this to nlp, minlp else: config.mtype = 'lp' @@ -622,9 +619,7 @@ def write(self, model): 'NUMNZ', 'ETSOLVE', ] - ostream.write( - "\nScalars MODELSTAT 'model status', SOLVESTAT 'solve status';\n" - ) + ostream.write("\nScalars MODELSTAT 'model status', SOLVESTAT 'solve status';\n") ostream.write("MODELSTAT = %s.modelstat;\n" % model.name) ostream.write("SOLVESTAT = %s.solvestat;\n\n" % model.name) @@ -679,7 +674,6 @@ def write(self, model): for stat in stat_vars: ostream.write("\nput '%s' ' ' %s /;\n" % (stat, stat)) - timer.toc("Finished writing .gsm file", level=logging.DEBUG) info = GAMSWriterInfo(self.var_symbol_map, self.con_symbol_map) @@ -703,6 +697,6 @@ def write_expression(self, ostream, expr, is_objective=False): expr_str += f'{coef!s}*{getSymbol(getVar(vid))} \n' else: # ostream.write(f'+{coef!s}*{getSymbol(getVar(vid))}') - expr_str += f'+ {coef!s} * {getSymbol(getVar(vid))} \n' + expr_str += f'+ {coef!s} * {getSymbol(getVar(vid))} \n' - return expr_str \ No newline at end of file + return expr_str From dde656c5677d96951041a466265df0570facbf2b Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Fri, 8 Aug 2025 14:20:46 -0400 Subject: [PATCH 08/29] added add_options in the solver_interface --- pyomo/contrib/solver/solvers/gams.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index 4b5dacf8c7d..44f3f316f83 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -103,6 +103,17 @@ def __init__( self.writer_config: ConfigDict = self.declare( 'writer_config', GAMSWriter.CONFIG() ) + self.add_options: ConfigDict = self.declare( + 'add_options', + ConfigValue( + default=None, + doc=""" + List of additional lines to write directly + into model file before the solve statement. + For model attributes, is GAMS_MODEL. + """, + ), + ) class GAMSResults(Results): @@ -161,6 +172,10 @@ def available(self, config=None, exception_flag=True): ) return avail + def license_is_valid(self): + # New versions of the community license can run LPs up to 5k + return self._run_simple_model(5001) + def _run_simple_model(self, config, n): solver_exec = config.executable.path() if solver_exec is None: @@ -271,7 +286,6 @@ def solve(self, model, **kwds): config.writer_config.put_results_format = 'gdx' if gdxcc_available else 'dat' # local variable to hold the working directory name and flags - newdir = False dname = None lst = "output.lst" output_filename = None @@ -283,10 +297,8 @@ def solve(self, model, **kwds): # worry about the rest of the contents of that directory being deleted. if config.working_dir is None: dname = tempfile.mkdtemp() - newdir = True else: dname = config.working_dir - newdir = True if not os.path.exists(dname): os.mkdir(dname) basename = os.path.join(dname, model.name) @@ -299,7 +311,9 @@ def solve(self, model, **kwds): model, gms_file, symbolic_solver_labels=config.symbolic_solver_labels, + add_options=config.add_options, ) + # NOTE: omit InfeasibleConstraintException for now timer.stop(f'write_{output_filename}_file') if config.writer_config.put_results_format == 'gdx': @@ -388,8 +402,6 @@ def solve(self, model, **kwds): 'Cleaning up temporary directory is handled by `release` from pyomo.common.tempfiles' ) - # NOTE: solve completion time - #################################################################### # Postsolve (WIP) #################################################################### From 3476f724ae56fa14f5bef2a578a795546cf5253e Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Fri, 8 Aug 2025 15:31:56 -0400 Subject: [PATCH 09/29] Fixed bug with unable to create tmpdir. Updated test_gams_v2.py --- pyomo/contrib/solver/solvers/gams.py | 382 +++++++++--------- .../solver/tests/solvers/test_gams_v2.py | 11 +- 2 files changed, 195 insertions(+), 198 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index 44f3f316f83..ef93976e837 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -316,39 +316,38 @@ def solve(self, model, **kwds): # NOTE: omit InfeasibleConstraintException for now timer.stop(f'write_{output_filename}_file') - if config.writer_config.put_results_format == 'gdx': - results_filename = os.path.join(dname, f"{model.name}_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, f"{model.name}_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] - - if config.tee and not config.logfile: - # default behaviour of gams is to print to console, for - # compatibility with windows and *nix we want to explicitly log to - # stdout (see https://www.gams.com/latest/docs/UG_GamsCall.html) - command.append("lo=3") - elif not config.tee and not config.logfile: - command.append("lo=0") - elif not config.tee and config.logfile: - command.append("lo=2") - elif config.tee and config.logfile: - command.append("lo=4") - if config.logfile: - command.append(f"lf={self._rewrite_path_win8p3(config.logfile)}") - try: + #################################################################### + # Apply solver + #################################################################### + exe_path = config.executable.path() + command = [exe_path, output_filename, "o=" + lst, "curdir=" + dname] + + if config.tee and not config.logfile: + # default behaviour of gams is to print to console, for + # compatibility with windows and *nix we want to explicitly log to + # stdout (see https://www.gams.com/latest/docs/UG_GamsCall.html) + command.append("lo=3") + elif not config.tee and not config.logfile: + command.append("lo=0") + elif not config.tee and config.logfile: + command.append("lo=2") + elif config.tee and config.logfile: + command.append("lo=4") + if config.logfile: + command.append(f"lf={self._rewrite_path_win8p3(config.logfile)}") ostreams = [StringIO()] if config.tee: ostreams.append(sys.stdout) @@ -396,173 +395,174 @@ def solve(self, model, **kwds): config, results_filename, statresults_filename ) timer.stop('parse_dat') - finally: - if not config.working_dir: - print( - 'Cleaning up temporary directory is handled by `release` from pyomo.common.tempfiles' - ) - #################################################################### - # Postsolve (WIP) - #################################################################### - - # Mapping between old and new contrib results - rev_legacy_termination_condition_map = { - v: k for k, v in legacy_termination_condition_map.items() - } - - model_suffixes = list( - name - for (name, comp) in pyomo.core.base.suffix.active_import_suffix_generator( - model - ) - ) - extract_dual = 'dual' in model_suffixes - extract_rc = 'rc' in model_suffixes - results = GAMSResults() - results.solver_name = "GAMS " - results.solver_version = str(self.version()) - - solvestat = stat_vars["SOLVESTAT"] - if solvestat == 1: - results.gams_solver_status = SolverStatus.ok - elif solvestat == 2: - results.gams_solver_status = SolverStatus.ok - results.gams_termination_condition = TerminationCondition.maxIterations - elif solvestat == 3: - results.gams_solver_status = SolverStatus.ok - results.gams_termination_condition = TerminationCondition.maxTimeLimit - elif solvestat == 5: - results.gams_solver_status = SolverStatus.ok - results.gams_termination_condition = TerminationCondition.maxEvaluations - elif solvestat == 7: - results.gams_solver_status = SolverStatus.aborted - results.gams_termination_condition = TerminationCondition.licensingProblems - elif solvestat == 8: - results.gams_solver_status = SolverStatus.aborted - results.gams_termination_condition = TerminationCondition.userInterrupt - elif solvestat == 10: - results.gams_solver_status = SolverStatus.error - results.gams_termination_condition = TerminationCondition.solverFailure - elif solvestat == 11: - results.gams_solver_status = SolverStatus.error - results.gams_termination_condition = ( - TerminationCondition.internalSolverError - ) - elif solvestat == 4: - results.gams_solver_status = SolverStatus.warning - results.message = "Solver quit with a problem (see LST file)" - elif solvestat in (9, 12, 13): - results.gams_solver_status = SolverStatus.error - elif solvestat == 6: - results.gams_solver_status = SolverStatus.unknown - - modelstat = stat_vars["MODELSTAT"] - if modelstat == 1: - results.gams_termination_condition = TerminationCondition.optimal - results.solution_status = SolutionStatus.optimal - elif modelstat == 2: - results.gams_termination_condition = TerminationCondition.locallyOptimal - results.solution_status = SolutionStatus.feasible - elif modelstat in [3, 18]: - results.gams_termination_condition = TerminationCondition.unbounded - # results.solution_status = SolutionStatus.unbounded - results.solution_status = SolutionStatus.noSolution - - elif modelstat in [4, 5, 6, 10, 19]: - results.gams_termination_condition = ( - TerminationCondition.infeasibleOrUnbounded - ) - results.solution_status = SolutionStatus.infeasible - elif modelstat == 7: - results.gams_termination_condition = TerminationCondition.feasible - results.solution_status = SolutionStatus.feasible - elif modelstat == 8: - # 'Integer solution model found' - results.gams_termination_condition = TerminationCondition.optimal - results.solution_status = SolutionStatus.optimal - elif modelstat == 9: - results.gams_termination_condition = ( - TerminationCondition.intermediateNonInteger + #################################################################### + # Postsolve (WIP) + #################################################################### + + # Mapping between old and new contrib results + rev_legacy_termination_condition_map = { + v: k for k, v in legacy_termination_condition_map.items() + } + + model_suffixes = list( + name + for ( + name, + comp, + ) in pyomo.core.base.suffix.active_import_suffix_generator(model) ) - results.solution_status = SolutionStatus.noSolution - elif modelstat == 11: - # Should be handled above, if modelstat and solvestat both - # indicate a licensing problem - if results.gams_termination_condition is None: + extract_dual = 'dual' in model_suffixes + extract_rc = 'rc' in model_suffixes + results = GAMSResults() + results.solver_name = "GAMS " + results.solver_version = str(self.version()) + + solvestat = stat_vars["SOLVESTAT"] + if solvestat == 1: + results.gams_solver_status = SolverStatus.ok + elif solvestat == 2: + results.gams_solver_status = SolverStatus.ok + results.gams_termination_condition = TerminationCondition.maxIterations + elif solvestat == 3: + results.gams_solver_status = SolverStatus.ok + results.gams_termination_condition = TerminationCondition.maxTimeLimit + elif solvestat == 5: + results.gams_solver_status = SolverStatus.ok + results.gams_termination_condition = TerminationCondition.maxEvaluations + elif solvestat == 7: + results.gams_solver_status = SolverStatus.aborted results.gams_termination_condition = ( TerminationCondition.licensingProblems ) - results.solution_status = SolutionStatus.noSolution - # results.solution_status = SolutionStatus.error - - elif modelstat in [12, 13]: - if results.gams_termination_condition is None: - results.gams_termination_condition = TerminationCondition.error - results.solution_status = SolutionStatus.noSolution - # results.solution_status = SolutionStatus.error - - elif modelstat == 14: - if results.gams_termination_condition is None: - results.gams_termination_condition = TerminationCondition.noSolution - results.solution_status = SolutionStatus.noSolution - # results.solution_status = SolutionStatus.unknown - - elif modelstat in [15, 16, 17]: - # Having to do with CNS models, - # not sure what to make of status descriptions - results.gams_termination_condition = TerminationCondition.optimal - results.solution_status = SolutionStatus.noSolution - else: - # This is just a backup catch, all cases are handled above - results.solution_status = SolutionStatus.noSolution - - # ensure backward compatibility before feeding to contrib.solver - results.termination_condition = rev_legacy_termination_condition_map[ - results.gams_termination_condition - ] - obj = list(model.component_data_objects(Objective, active=True)) - assert len(obj) == 1, 'Only one objective is allowed.' - if results.solution_status in {SolutionStatus.feasible, SolutionStatus.optimal}: - results.solution_loader = GMSSolutionLoader( - gdx_data=model_soln, gms_info=gms_info - ) + elif solvestat == 8: + results.gams_solver_status = SolverStatus.aborted + results.gams_termination_condition = TerminationCondition.userInterrupt + elif solvestat == 10: + results.gams_solver_status = SolverStatus.error + results.gams_termination_condition = TerminationCondition.solverFailure + elif solvestat == 11: + results.gams_solver_status = SolverStatus.error + results.gams_termination_condition = ( + TerminationCondition.internalSolverError + ) + elif solvestat == 4: + results.gams_solver_status = SolverStatus.warning + results.message = "Solver quit with a problem (see LST file)" + elif solvestat in (9, 12, 13): + results.gams_solver_status = SolverStatus.error + elif solvestat == 6: + results.gams_solver_status = SolverStatus.unknown + + modelstat = stat_vars["MODELSTAT"] + if modelstat == 1: + results.gams_termination_condition = TerminationCondition.optimal + results.solution_status = SolutionStatus.optimal + elif modelstat == 2: + results.gams_termination_condition = TerminationCondition.locallyOptimal + results.solution_status = SolutionStatus.feasible + elif modelstat in [3, 18]: + results.gams_termination_condition = TerminationCondition.unbounded + # results.solution_status = SolutionStatus.unbounded + results.solution_status = SolutionStatus.noSolution + + elif modelstat in [4, 5, 6, 10, 19]: + results.gams_termination_condition = ( + TerminationCondition.infeasibleOrUnbounded + ) + results.solution_status = SolutionStatus.infeasible + elif modelstat == 7: + results.gams_termination_condition = TerminationCondition.feasible + results.solution_status = SolutionStatus.feasible + elif modelstat == 8: + # 'Integer solution model found' + results.gams_termination_condition = TerminationCondition.optimal + results.solution_status = SolutionStatus.optimal + elif modelstat == 9: + results.gams_termination_condition = ( + TerminationCondition.intermediateNonInteger + ) + results.solution_status = SolutionStatus.noSolution + elif modelstat == 11: + # Should be handled above, if modelstat and solvestat both + # indicate a licensing problem + if results.gams_termination_condition is None: + results.gams_termination_condition = ( + TerminationCondition.licensingProblems + ) + results.solution_status = SolutionStatus.noSolution + # results.solution_status = SolutionStatus.error + + elif modelstat in [12, 13]: + if results.gams_termination_condition is None: + results.gams_termination_condition = TerminationCondition.error + results.solution_status = SolutionStatus.noSolution + # results.solution_status = SolutionStatus.error + + elif modelstat == 14: + if results.gams_termination_condition is None: + results.gams_termination_condition = TerminationCondition.noSolution + results.solution_status = SolutionStatus.noSolution + # results.solution_status = SolutionStatus.unknown + + elif modelstat in [15, 16, 17]: + # Having to do with CNS models, + # not sure what to make of status descriptions + results.gams_termination_condition = TerminationCondition.optimal + results.solution_status = SolutionStatus.noSolution + else: + # This is just a backup catch, all cases are handled above + results.solution_status = SolutionStatus.noSolution - if config.load_solutions: - results.solution_loader.load_vars() - results.incumbent_objective = stat_vars["OBJVAL"] - if ( - hasattr(model, 'dual') - and isinstance(model.dual, Suffix) - and model.dual.import_enabled() - ): - model.dual.update(results.solution_loader.get_duals()) - if ( - hasattr(model, 'rc') - and isinstance(model.rc, Suffix) - and model.rc.import_enabled() - ): - model.rc.update(results.solution_loader.get_reduced_costs()) + # ensure backward compatibility before feeding to contrib.solver + results.termination_condition = rev_legacy_termination_condition_map[ + results.gams_termination_condition + ] + obj = list(model.component_data_objects(Objective, active=True)) + assert len(obj) == 1, 'Only one objective is allowed.' + if results.solution_status in { + SolutionStatus.feasible, + SolutionStatus.optimal, + }: + results.solution_loader = GMSSolutionLoader( + gdx_data=model_soln, gms_info=gms_info + ) - else: - results.incumbent_objective = value( - replace_expressions( - obj[0].expr, - substitution_map={ - id(v): val - for v, val in results.solution_loader.get_primals().items() - }, - descend_into_named_expressions=True, - remove_named_expressions=True, + if config.load_solutions: + results.solution_loader.load_vars() + results.incumbent_objective = stat_vars["OBJVAL"] + if ( + hasattr(model, 'dual') + and isinstance(model.dual, Suffix) + and model.dual.import_enabled() + ): + model.dual.update(results.solution_loader.get_duals()) + if ( + hasattr(model, 'rc') + and isinstance(model.rc, Suffix) + and model.rc.import_enabled() + ): + model.rc.update(results.solution_loader.get_reduced_costs()) + + else: + results.incumbent_objective = value( + replace_expressions( + obj[0].expr, + substitution_map={ + id(v): val + for v, val in results.solution_loader.get_primals().items() + }, + descend_into_named_expressions=True, + remove_named_expressions=True, + ) ) - ) - end_timestamp = datetime.datetime.now(datetime.timezone.utc) - results.timing_info.start_timestamp = start_timestamp - results.timing_info.wall_time = ( - end_timestamp - start_timestamp - ).total_seconds() - results.timing_info.timer = timer - return results + end_timestamp = datetime.datetime.now(datetime.timezone.utc) + results.timing_info.start_timestamp = start_timestamp + results.timing_info.wall_time = ( + end_timestamp - start_timestamp + ).total_seconds() + results.timing_info.timer = timer + return results def _parse_gdx_results(self, config, results_filename, statresults_filename): model_soln = dict() diff --git a/pyomo/contrib/solver/tests/solvers/test_gams_v2.py b/pyomo/contrib/solver/tests/solvers/test_gams_v2.py index 05a9ae0ca6d..d2cfde5f2f8 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gams_v2.py +++ b/pyomo/contrib/solver/tests/solvers/test_gams_v2.py @@ -107,6 +107,7 @@ def test_class_member_list(self): 'name', 'solve', 'version', + 'license_is_valid', ] method_list = [method for method in dir(opt) if method.startswith('_') is False] self.assertEqual(sorted(expected_list), sorted(method_list)) @@ -195,10 +196,6 @@ def test_gams_config(self): self.assertTrue(solver.config.executable.startswith('/path')) # Change value on a solve call - # config = gams.GAMSConfig() - with TempfileManager.new_context() as temp: - dname = temp.mkdtemp() - # if working_dir is not specified, the tmpdir is deleted before solve can happen - solver = SolverFactory('gams_v2', working_dir=dname) - model = self.create_model() - solver.solve(model, tee=False, load_solutions=False) + solver = SolverFactory('gams_v2') + model = self.create_model() + solver.solve(model, tee=False, load_solutions=False) From eb0a3dbcc89f5986d91bd4713037a0987a6960cd Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Sun, 10 Aug 2025 17:22:12 -0400 Subject: [PATCH 10/29] Added handling single bounded variable case (None,#) or (#, None) --- pyomo/repn/plugins/gams_writer_v2.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index 3de30880117..f527046c9bc 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -39,6 +39,7 @@ from pyomo.core.base.component import ActiveComponent from pyomo.core.base.label import NumericLabeler from pyomo.opt import WriterFactory +from pyomo.repn.util import ftoa # from pyomo.repn.quadratic import QuadraticRepnVisitor from pyomo.repn.linear import LinearRepnVisitor @@ -345,6 +346,7 @@ def write(self, model): config = self.config labeler = config.labeler var_labeler, con_labeler = None, None + warmstart = config.warmstart sorter = FileDeterminism_to_SortComponents(config.file_determinism) @@ -420,7 +422,6 @@ def write(self, model): # Tabulate constraints # skip_trivial_constraints = self.config.skip_trivial_constraints - have_nontrivial = False last_parent = None con_list = ( {} @@ -435,10 +436,7 @@ def write(self, model): lb, body, ub = con.to_bounded_expression(True) if lb is None and ub is None: - # Note: you *cannot* output trivial (unbounded) - # constraints in LP format. I suppose we could add a - # slack variable if skip_trivial_constraints is False, - # but that seems rather silly. + # WIP: handling unbounded variable continue repn = visitor.walk_expression(body) if repn.nonlinear is not None: @@ -451,7 +449,7 @@ def write(self, model): repn.constant = 0 if repn.linear or getattr(repn, 'quadratic', None): - have_nontrivial = True + pass else: if ( skip_trivial_constraints @@ -581,8 +579,13 @@ def write(self, model): # Handling variable bounds # for v, (lb, ub) in var_bounds.items(): - ostream.write(f'{v}.lo = {lb};\n{v}.up = {ub};\n') - + pyomo_v = self.var_symbol_map.bySymbol[v] + if lb is not None: + ostream.write(f'{v}.lo = {lb};\n') + if ub is not None: + ostream.write(f'{v}.up = {ub};\n') + if warmstart and pyomo_v.value is not None: + ostream.write("%s.l = %s;\n" % (v, ftoa(pyomo_v.value, False))) ostream.write(f'\nModel {model.name} / all /;\n') ostream.write(f'{model.name}.limrow = 0;\n') ostream.write(f'{model.name}.limcol = 0;\n') @@ -680,8 +683,6 @@ def write(self, model): return info def write_expression(self, ostream, expr, is_objective=False): - save_eq = [] - if not is_objective: assert not expr.constant getSymbol = self.var_symbol_map.getSymbol From c6d2d014bd75947f5863fb3817bee05f76e04cdd Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Mon, 11 Aug 2025 12:15:27 -0400 Subject: [PATCH 11/29] Match solver interface and writer configs to prevent implicit definition error --- pyomo/contrib/solver/solvers/gams.py | 87 ++++++++++++++++++++++++++-- pyomo/repn/plugins/gams_writer_v2.py | 16 ----- 2 files changed, 81 insertions(+), 22 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index ef93976e837..baad25220df 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -103,6 +103,76 @@ def __init__( self.writer_config: ConfigDict = self.declare( 'writer_config', GAMSWriter.CONFIG() ) + # Share the same config as the writer, passes at the function call write + self.declare( + 'warmstart', + ConfigValue( + default=True, + domain=bool, + description="Warmstart by initializing model's variables to their values.", + ), + ) + self.declare( + 'labeler', + ConfigValue( + default=None, + description='Callable to use to generate symbol names in gms file', + ), + ) + self.declare( + 'solver', + ConfigValue( + default=None, + description='If None, GAMS will use default solver for model type.', + ), + ) + self.declare( + 'mtype', + ConfigValue( + default=None, + description='Model type. If None, will chose from lp, mip. nlp and minlp will be implemented in the future.', + ), + ) + self.declare( + 'skip_trivial_constraints', + ConfigValue( + default=False, + domain=bool, + description='Skip writing constraints whose body is constant', + ), + ) + self.declare( + 'output_fixed_variables', + ConfigValue( + default=False, + domain=bool, + description='If True, output fixed variables as variables; otherwise,output numeric value', + ), + ) + self.declare( + 'put_results', + ConfigValue( + default='results', + domain=str, + doc=""" + Filename for optionally writing solution values and + marginals. If put_results_format is 'gdx', then GAMS + will write solution values and marginals to + GAMS_MODEL_p.gdx and solver statuses to + {put_results}_s.gdx. If put_results_format is 'dat', + then solution values and marginals are written to + (put_results).dat, and solver statuses to (put_results + + 'stat').dat. + """, + ), + ) + self.declare( + 'put_results_format', + ConfigValue( + default='gdx', + description="Format used for put_results, one of 'gdx', 'dat'", + ), + ) self.add_options: ConfigDict = self.declare( 'add_options', ConfigValue( @@ -114,6 +184,16 @@ def __init__( """, ), ) + # NOTE: Taken from the lp_writer + self.declare( + 'row_order', + ConfigValue( + default=None, + description='Preferred constraint ordering', + doc=""" + To use with ordered_active_constraints function.""", + ), + ) class GAMSResults(Results): @@ -307,12 +387,7 @@ def solve(self, model, **kwds): with open(output_filename, 'w', newline='\n', encoding='utf-8') as gms_file: timer.start(f'write_{output_filename}_file') self._writer.config.set_value(config.writer_config) - gms_info = self._writer.write( - model, - gms_file, - symbolic_solver_labels=config.symbolic_solver_labels, - add_options=config.add_options, - ) + gms_info = self._writer.write(model, gms_file, **config.writer_config) # NOTE: omit InfeasibleConstraintException for now timer.stop(f'write_{output_filename}_file') diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index f527046c9bc..305c9c76b5c 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -245,22 +245,6 @@ class GAMSWriter(object): ), ) # NOTE: Taken from the lp_writer - CONFIG.declare( - 'allow_quadratic_objective', - ConfigValue( - default=True, - domain=bool, - description='If True, allow quadratic terms in the model objective', - ), - ) - CONFIG.declare( - 'allow_quadratic_constraint', - ConfigValue( - default=True, - domain=bool, - description='If True, allow quadratic terms in the model constraints', - ), - ) CONFIG.declare( 'row_order', ConfigValue( From 2a6dcb7439cf99c328a25baa08a6f3796ebf92fd Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Tue, 12 Aug 2025 15:24:50 -0400 Subject: [PATCH 12/29] Added new gams solver interface for test_all_solvers_list() --- pyomo/contrib/solver/tests/solvers/test_solvers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 5ab36554061..589deb726a1 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -33,6 +33,7 @@ from pyomo.contrib.solver.solvers.gurobi_persistent import GurobiPersistent from pyomo.contrib.solver.solvers.gurobi_direct import GurobiDirect from pyomo.contrib.solver.solvers.highs import Highs +from pyomo.contrib.solver.solvers.gams import GAMS from pyomo.core.expr.numeric_expr import LinearExpression from pyomo.core.expr.compare import assertExpressionsEqual @@ -51,6 +52,7 @@ ('gurobi_direct', GurobiDirect), ('ipopt', Ipopt), ('highs', Highs), + ('gams', GAMS), ] mip_solvers = [ ('gurobi_persistent', GurobiPersistent), From b6ead2713e5171867d79cf287aff3a54d7c0b7fc Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Tue, 12 Aug 2025 16:14:11 -0400 Subject: [PATCH 13/29] Fixed bug of handling valid ub when writing pyomo expression --- pyomo/repn/plugins/gams_writer_v2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index 305c9c76b5c..9f57a441e1a 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -472,7 +472,7 @@ def write(self, model): self.con_symbol_map.addSymbol(con, label) declaration = f'\n{label}.. ' definition = self.write_expression(ostream, repn) - bounds = f' =L= {(lb - offset)!s};' + bounds = f' =L= {(ub - offset)!s};' con_list[label] = declaration + definition + bounds # From 89f80e02246e75ab751d197668cedce306d5ac98 Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Wed, 13 Aug 2025 09:17:01 -0400 Subject: [PATCH 14/29] Fixed bug when using ComponentMap to extract duals in gms_sol_reader.py --- .../contrib/solver/solvers/gms_sol_reader.py | 21 +++++++++---------- pyomo/solvers/tests/solvers.py | 20 ++++++++++++++++++ pyomo/solvers/tests/testcases.py | 20 ++++++++++++++++++ 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py index b40ae0af7ba..8429b753797 100644 --- a/pyomo/contrib/solver/solvers/gms_sol_reader.py +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -99,15 +99,16 @@ def get_duals( 'Solution loader does not currently have a valid solution. Please ' 'check results.termination_condition and/or results.solution_status.' ) - res = {} + + res = ComponentMap() if cons_to_load is None: cons_to_load = set(self._gms_info.con_symbol_map.bySymbol.keys()) - else: - cons_to_load = set(cons_to_load) + for sym, con in self._gms_info.con_symbol_map.bySymbol.items(): if sym in cons_to_load and con.parent_component().ctype is not Objective: res[con] = self._gdx_data[sym][1] + return res def get_reduced_costs(self, vars_to_load=None): @@ -122,13 +123,11 @@ def get_reduced_costs(self, vars_to_load=None): 'check results.termination_condition and/or results.solution_status.' ) - res = {} - + res = ComponentMap() if vars_to_load is None: - vars_to_load = set(self._gms_info.var_symbol_map.bySymbol.keys()) - else: - vars_to_load = set(vars_to_load) - for sym, var in self._gms_info.var_symbol_map.bySymbol.items(): - if sym in vars_to_load and var.parent_component().ctype is Var: - res[var.name] = self._gdx_data[sym][1] + vars_to_load = self._gms_info.var_symbol_map.bySymbol.items() + + for sym, obj in vars_to_load: + res[obj] = self._gdx_data[sym][1] + return res diff --git a/pyomo/solvers/tests/solvers.py b/pyomo/solvers/tests/solvers.py index e5058e8894b..ebe40352c3f 100644 --- a/pyomo/solvers/tests/solvers.py +++ b/pyomo/solvers/tests/solvers.py @@ -192,6 +192,26 @@ def test_solver_cases(*args): import_suffixes=['dual', 'rc'], ) + # + # GAMS V2 + # + + _gams_v2_capabilities = set(['linear', 'integer']) + + _test_solver_cases['gams_v2', 'gms'] = initialize( + name='gams_v2', + io='gms', + capabilities=_gams_v2_capabilities, + import_suffixes=['dual', 'rc'], + ) + + _test_solver_cases['gams_v2', 'python'] = initialize( + name='gams_v2', + io='python', + capabilities=_gams_v2_capabilities, + import_suffixes=['dual', 'rc'], + ) + # # GUROBI # diff --git a/pyomo/solvers/tests/testcases.py b/pyomo/solvers/tests/testcases.py index 696936ddf05..cb39f404733 100644 --- a/pyomo/solvers/tests/testcases.py +++ b/pyomo/solvers/tests/testcases.py @@ -128,6 +128,26 @@ "the pyomo model, or try a different GAMS solver.", ) +# +# GAMS V2 +# + +ExpectedFailures['gams_v2', 'gms', 'MILP_unbounded'] = ( + lambda v: v <= _trunk_version, + "GAMS requires finite bounds for integer variables. 1.0E100 is as extreme" + "as GAMS will define, and should be enough to appear unbounded. If the" + "solver cannot handle this bound, explicitly set a smaller bound on" + "the pyomo model, or try a different GAMS solver.", +) + +ExpectedFailures['gams_v2', 'python', 'MILP_unbounded'] = ( + lambda v: v <= _trunk_version, + "GAMS requires finite bounds for integer variables. 1.0E100 is as extreme" + "as GAMS will define, and should be enough to appear unbounded. If the" + "solver cannot handle this bound, explicitly set a smaller bound on" + "the pyomo model, or try a different GAMS solver.", +) + # # GLPK # From b0c3450ee54e7f8b25e29ef08363f1846a2887cb Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Wed, 13 Aug 2025 21:54:28 -0400 Subject: [PATCH 15/29] Corrected handling of solution_loader. Implemented bounded constraint and writing to gms. --- pyomo/contrib/solver/solvers/gams.py | 13 ++++++- .../contrib/solver/solvers/gms_sol_reader.py | 39 ++++++++++++++----- pyomo/repn/plugins/gams_writer_v2.py | 38 ++++++++++-------- 3 files changed, 63 insertions(+), 27 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index baad25220df..a1e51a1ecdc 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -594,7 +594,13 @@ def solve(self, model, **kwds): results.gams_termination_condition ] obj = list(model.component_data_objects(Objective, active=True)) - assert len(obj) == 1, 'Only one objective is allowed.' + + # NOTE: How should gams handle when no objective is provided + # NOTE: pyomo/contrib/solver/tests/solvers/test_solvers.py::TestSolvers::test_no_objective + # NOTE: results.incumbent_objective = None + # NOTE: results.objective_bound = None + # assert len(obj) == 1, 'Only one objective is allowed.' + if results.solution_status in { SolutionStatus.feasible, SolutionStatus.optimal, @@ -605,7 +611,10 @@ def solve(self, model, **kwds): if config.load_solutions: results.solution_loader.load_vars() - results.incumbent_objective = stat_vars["OBJVAL"] + if len(obj) == 1: + results.incumbent_objective = stat_vars["OBJVAL"] + else: + results.incumbent_objective = None if ( hasattr(model, 'dual') and isinstance(model.dual, Suffix) diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py index 8429b753797..68367b617a3 100644 --- a/pyomo/contrib/solver/solvers/gms_sol_reader.py +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -81,8 +81,11 @@ def get_primals( if vars_to_load is None: vars_to_load = self._gms_info.var_symbol_map.bySymbol.items() - for sym, obj in vars_to_load: - res[obj] = val_map[id(obj)] + for sym, obj in vars_to_load: + res[obj] = val_map[id(obj)] + else: + for obj in vars_to_load: + res[obj] = val_map[id(obj)] return res @@ -100,14 +103,22 @@ def get_duals( 'check results.termination_condition and/or results.solution_status.' ) - res = ComponentMap() + con_map = {} + if self._gdx_data is None: + assert len(self._gms_info.con_symbol_map.bySymbol) == 0 + else: + for sym, obj in self._gms_info.con_symbol_map.bySymbol.items(): + con_map[id(obj)] = self._gdx_data[sym][1] + res = ComponentMap() if cons_to_load is None: - cons_to_load = set(self._gms_info.con_symbol_map.bySymbol.keys()) + cons_to_load = self._gms_info.con_symbol_map.bySymbol.items() - for sym, con in self._gms_info.con_symbol_map.bySymbol.items(): - if sym in cons_to_load and con.parent_component().ctype is not Objective: - res[con] = self._gdx_data[sym][1] + for sym, obj in cons_to_load: + res[obj] = con_map[id(obj)] + else: + for obj in cons_to_load: + res[obj] = con_map[id(obj)] return res @@ -123,11 +134,21 @@ def get_reduced_costs(self, vars_to_load=None): 'check results.termination_condition and/or results.solution_status.' ) + var_map = {} + if self._gdx_data is None: + assert len(self._gms_info.var_symbol_map.bySymbol) == 0 + else: + for sym, obj in self._gms_info.var_symbol_map.bySymbol.items(): + var_map[id(obj)] = self._gdx_data[sym][1] + res = ComponentMap() if vars_to_load is None: vars_to_load = self._gms_info.var_symbol_map.bySymbol.items() - for sym, obj in vars_to_load: - res[obj] = self._gdx_data[sym][1] + for sym, obj in vars_to_load: + res[obj] = var_map[id(obj)] + else: + for obj in vars_to_load: + res[obj] = var_map[id(obj)] return res diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index 9f57a441e1a..6d368c03701 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -30,26 +30,21 @@ Param, Expression, SOSConstraint, - SortComponents, Suffix, SymbolMap, minimize, ShortNameLabeler, ) -from pyomo.core.base.component import ActiveComponent from pyomo.core.base.label import NumericLabeler from pyomo.opt import WriterFactory from pyomo.repn.util import ftoa -# from pyomo.repn.quadratic import QuadraticRepnVisitor from pyomo.repn.linear import LinearRepnVisitor from pyomo.repn.util import ( FileDeterminism, FileDeterminism_to_SortComponents, OrderedVarRecorder, categorize_valid_components, - initialize_var_map_from_column_order, - int_float, ordered_active_constraints, ) @@ -444,7 +439,6 @@ def write(self, model): con_symbol = con_labeler(con) declaration, definition, bounds = None, None, None - if lb is not None: if ub is None: label = f'{con_symbol}_lo' @@ -463,10 +457,23 @@ def write(self, model): bounds = f' =E= {(lb - offset)!s};' con_list[label] = declaration + definition + bounds else: - raise NotImplementedError( - "Bounded constraints within the same expression is not supported" - ) - + # We will need the constraint body twice. + # Procedure is taken from lp_writer.py + label = f'{con_symbol}_lo' + self.con_symbol_map.addSymbol(con, label) + self.var_symbol_map.addSymbol(con, label) + declaration = f'\n{label}.. ' + definition = self.write_expression(ostream, repn) + bounds = f' =G= {(lb - offset)!s};' + con_list[label] = declaration + definition + bounds + # + label = f'{con_symbol}_hi' + self.con_symbol_map.alias(con, label) + self.var_symbol_map.alias(con, label) + declaration = f'\n{label}.. ' + definition = self.write_expression(ostream, repn) + bounds = f' =L= {(ub - offset)!s};' + con_list[label] = declaration + definition + bounds elif ub is not None: label = f'{con_symbol}_hi' self.con_symbol_map.addSymbol(con, label) @@ -549,14 +556,13 @@ def write(self, model): # Writing out the equations/constraints # ostream.write("EQUATIONS \n") - for id, cid in enumerate(self.con_symbol_map.byObject.keys()): - c = self.con_symbol_map.byObject[cid] - if id != len(self.con_symbol_map.byObject.keys()) - 1: - ostream.write(f"\t{c}\n") + for count, (sym, con) in enumerate(con_list.items()): + if count != len(con_list.keys()) - 1: + ostream.write(f"\t{sym}\n") else: - ostream.write(f"\t{c};\n\n") + ostream.write(f"\t{sym};\n\n") - for con_label, con in con_list.items(): + for _, con in con_list.items(): ostream.write(con) # From a9df1d7261478ecc4f5f55249268abadd03acc9a Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Wed, 13 Aug 2025 22:30:34 -0400 Subject: [PATCH 16/29] Added offdigit based on error log message suggestion --- pyomo/repn/plugins/gams_writer_v2.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index 6d368c03701..64424c0f9cb 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -518,6 +518,11 @@ def write(self, model): con_list[label] = declaration + definition + bounds self.var_symbol_map.addSymbol(obj, label) + # Write the GAMS model + ostream.write("$offlisting\n") + # $offdigit ignores extra precise digits instead of erroring + ostream.write("$offdigit\n\n") + # # Write out variable declaration # From 518758e953a7f8326eb9df5b96f324b3a57fd960 Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Mon, 18 Aug 2025 10:10:28 -0400 Subject: [PATCH 17/29] Fixed bug of extracting constraint dual when alias exits (for bounded constraint). Fixed bug of load_solution that contains constraint id in addition to variable id --- pyomo/contrib/solver/solvers/gms_sol_reader.py | 3 +++ pyomo/repn/plugins/gams_writer_v2.py | 5 ----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py index 68367b617a3..dd5528fa6e5 100644 --- a/pyomo/contrib/solver/solvers/gms_sol_reader.py +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -109,6 +109,9 @@ def get_duals( else: for sym, obj in self._gms_info.con_symbol_map.bySymbol.items(): con_map[id(obj)] = self._gdx_data[sym][1] + for sym, obj in self._gms_info.con_symbol_map.aliases.items(): + if self._gdx_data[sym][1] != 0: + con_map[id(obj)] = self._gdx_data[sym][1] res = ComponentMap() if cons_to_load is None: diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index 64424c0f9cb..324fa2b7185 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -443,7 +443,6 @@ def write(self, model): if ub is None: label = f'{con_symbol}_lo' self.con_symbol_map.addSymbol(con, label) - self.var_symbol_map.addSymbol(con, label) declaration = f'\n{label}.. ' definition = self.write_expression(ostream, repn) bounds = f' =G= {(lb - offset)!s};' @@ -451,7 +450,6 @@ def write(self, model): elif lb == ub: label = f'{con_symbol}' self.con_symbol_map.addSymbol(con, label) - self.var_symbol_map.addSymbol(con, label) declaration = f'\n{label}.. ' definition = self.write_expression(ostream, repn) bounds = f' =E= {(lb - offset)!s};' @@ -461,7 +459,6 @@ def write(self, model): # Procedure is taken from lp_writer.py label = f'{con_symbol}_lo' self.con_symbol_map.addSymbol(con, label) - self.var_symbol_map.addSymbol(con, label) declaration = f'\n{label}.. ' definition = self.write_expression(ostream, repn) bounds = f' =G= {(lb - offset)!s};' @@ -469,7 +466,6 @@ def write(self, model): # label = f'{con_symbol}_hi' self.con_symbol_map.alias(con, label) - self.var_symbol_map.alias(con, label) declaration = f'\n{label}.. ' definition = self.write_expression(ostream, repn) bounds = f' =L= {(ub - offset)!s};' @@ -516,7 +512,6 @@ def write(self, model): definition = self.write_expression(ostream, repn, True) bounds = f' =E= {(-repn.constant)!s};\n\n' con_list[label] = declaration + definition + bounds - self.var_symbol_map.addSymbol(obj, label) # Write the GAMS model ostream.write("$offlisting\n") From a2ef9cc8b7d9370726fb81531ee1ee96d762fead Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Mon, 18 Aug 2025 11:28:20 -0400 Subject: [PATCH 18/29] Added aliases as part of parse_dat_results to be consistent with parse_gdx_results --- pyomo/repn/plugins/gams_writer_v2.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index 324fa2b7185..908a5f4ea32 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -653,6 +653,8 @@ def write(self, model): ostream.write("\nput %s ' ' %s.l ' ' %s.m /;" % (sym, sym, sym)) for con in self.con_symbol_map.bySymbol.keys(): ostream.write("\nput %s ' ' %s.l ' ' %s.m /;" % (con, con, con)) + for con in self.con_symbol_map.aliases.keys(): + ostream.write("\nput %s ' ' %s.l ' ' %s.m /;" % (con, con, con)) ostream.write( "\nput GAMS_OBJECTIVE ' ' GAMS_OBJECTIVE.l " "' ' GAMS_OBJECTIVE.m;\n" From c33095db5e88cec15137308608911a93603c2e75 Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Mon, 18 Aug 2025 13:01:24 -0400 Subject: [PATCH 19/29] Removed code duplication between the solver interface and writer by parsing solver_options --- pyomo/contrib/solver/solvers/gams.py | 98 +++++----------------------- pyomo/repn/plugins/gams_writer_v2.py | 7 +- 2 files changed, 19 insertions(+), 86 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index a1e51a1ecdc..acbc55d00ed 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -103,87 +103,7 @@ def __init__( self.writer_config: ConfigDict = self.declare( 'writer_config', GAMSWriter.CONFIG() ) - # Share the same config as the writer, passes at the function call write - self.declare( - 'warmstart', - ConfigValue( - default=True, - domain=bool, - description="Warmstart by initializing model's variables to their values.", - ), - ) - self.declare( - 'labeler', - ConfigValue( - default=None, - description='Callable to use to generate symbol names in gms file', - ), - ) - self.declare( - 'solver', - ConfigValue( - default=None, - description='If None, GAMS will use default solver for model type.', - ), - ) - self.declare( - 'mtype', - ConfigValue( - default=None, - description='Model type. If None, will chose from lp, mip. nlp and minlp will be implemented in the future.', - ), - ) - self.declare( - 'skip_trivial_constraints', - ConfigValue( - default=False, - domain=bool, - description='Skip writing constraints whose body is constant', - ), - ) - self.declare( - 'output_fixed_variables', - ConfigValue( - default=False, - domain=bool, - description='If True, output fixed variables as variables; otherwise,output numeric value', - ), - ) - self.declare( - 'put_results', - ConfigValue( - default='results', - domain=str, - doc=""" - Filename for optionally writing solution values and - marginals. If put_results_format is 'gdx', then GAMS - will write solution values and marginals to - GAMS_MODEL_p.gdx and solver statuses to - {put_results}_s.gdx. If put_results_format is 'dat', - then solution values and marginals are written to - (put_results).dat, and solver statuses to (put_results + - 'stat').dat. - """, - ), - ) - self.declare( - 'put_results_format', - ConfigValue( - default='gdx', - description="Format used for put_results, one of 'gdx', 'dat'", - ), - ) - self.add_options: ConfigDict = self.declare( - 'add_options', - ConfigValue( - default=None, - doc=""" - List of additional lines to write directly - into model file before the solve statement. - For model attributes, is GAMS_MODEL. - """, - ), - ) + # NOTE: Taken from the lp_writer self.declare( 'row_order', @@ -347,7 +267,8 @@ def solve(self, model, **kwds): start_timestamp = datetime.datetime.now(datetime.timezone.utc) # Update configuration options, based on keywords passed to solve - config: GAMSConfig = self.config(value=kwds) + # preserve_implicit=True is required to extract solver_options ConfigDict + config: GAMSConfig = self.config(value=kwds, preserve_implicit=True) # Check if solver is available, unavailable solver error will be raised in available() self.available(config) @@ -387,7 +308,18 @@ def solve(self, model, **kwds): with open(output_filename, 'w', newline='\n', encoding='utf-8') as gms_file: timer.start(f'write_{output_filename}_file') self._writer.config.set_value(config.writer_config) - gms_info = self._writer.write(model, gms_file, **config.writer_config) + + # update the writer config if any of the overlapping keys exists in the solver_options + non_solver_config = {} + for key in config.solver_options.keys(): + if key in self._writer.config: + self._writer.config[key] = config.solver_options[key] + else: + non_solver_config[key] = config.solver_options[key] + + self._writer.config['add_options'] = non_solver_config + + gms_info = self._writer.write(model, gms_file, **self._writer.config) # NOTE: omit InfeasibleConstraintException for now timer.stop(f'write_{output_filename}_file') diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index 908a5f4ea32..e6e7d9548ff 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -592,9 +592,10 @@ def write(self, model): if add_options is not None: ostream.write("\n* START USER ADDITIONAL OPTIONS\n") - for line in add_options: - ostream.write('option ' + line + '\n') - ostream.write("\n\n* END USER ADDITIONAL OPTIONS\n\n") + for options, val in add_options.items(): + # ostream.write('option ' + line + '\n') + ostream.write(f'option {options}={val};\n') + ostream.write("* END USER ADDITIONAL OPTIONS\n\n") ostream.write( "SOLVE %s USING %s %simizing GAMS_OBJECTIVE;\n" From f609ca2a73c55ee2cb9c3ccae94a5b00250f8a77 Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Tue, 19 Aug 2025 14:31:27 -0400 Subject: [PATCH 20/29] Revised code based on John's review. WIP: Handling rehash within available() and version() --- pyomo/contrib/solver/solvers/gams.py | 150 ++++++++++++++------------- 1 file changed, 76 insertions(+), 74 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index acbc55d00ed..2b7efbc4161 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -15,18 +15,24 @@ import subprocess import datetime from io import StringIO -from typing import Mapping, Optional, Sequence -from tempfile import mkdtemp +from typing import Mapping, Optional, Sequence, Tuple import sys -from pyomo.common import Executable +from pyomo.common.fileutils import Executable, ExecutableData from pyomo.common.dependencies import pathlib -from pyomo.common.config import ConfigValue, document_kwargs_from_configdict, ConfigDict +from pyomo.common.config import ( + ConfigValue, + ConfigDict, + document_configdict, + Path, + document_class_CONFIG, +) +from pyomo.common.modeling import NOTSET from pyomo.common.tempfiles import TempfileManager from pyomo.common.timing import HierarchicalTimer from pyomo.core.base import Constraint, Var, value, Objective from pyomo.core.staleflag import StaleFlagManager -from pyomo.contrib.solver.common.base import SolverBase +from pyomo.contrib.solver.common.base import SolverBase, Availability from pyomo.contrib.solver.common.config import SolverConfig from pyomo.opt.results import SolverStatus, TerminationCondition from pyomo.contrib.solver.common.results import ( @@ -41,6 +47,11 @@ from pyomo.core.expr.visitor import replace_expressions from pyomo.core.expr.numvalue import value from pyomo.core.base.suffix import Suffix +from pyomo.common.errors import ( + ApplicationError, + DeveloperError, + InfeasibleConstraintException, +) from pyomo.repn.plugins.gams_writer_v2 import GAMSWriterInfo, GAMSWriter @@ -70,6 +81,7 @@ def _gams_importer(): gdxcc, gdxcc_available = attempt_import('gdxcc', importer=_gams_importer) +@document_configdict() class GAMSConfig(SolverConfig): def __init__( self, @@ -86,18 +98,21 @@ def __init__( implicit_domain=implicit_domain, visibility=visibility, ) - self.executable: Executable = self.declare( + self.executable: ExecutableData = self.declare( 'executable', ConfigValue( - default=Executable('gams'), + domain=Executable, + default='gams', description="Executable for gams. Defaults to searching the " "``PATH`` for the first available ``gams``.", ), ) - self.logfile: ConfigDict = self.declare( + self.logfile: str = self.declare( 'logfile', ConfigValue( - default=None, description="Filename to output GAMS log to a file." + domain=Path(), + default=None, + description="Filename to output GAMS log to a file.", ), ) self.writer_config: ConfigDict = self.declare( @@ -138,50 +153,35 @@ def __init__(self): ) +@document_class_CONFIG(methods=['solve']) class GAMS(SolverBase): CONFIG = GAMSConfig() def __init__(self, **kwds): super().__init__(**kwds) self._writer = GAMSWriter() - self._available_cache = None - self._version_cache = None - - def available(self, config=None, exception_flag=True): - if config is None: - config = self.config - - """True if the solver is available.""" - exe = config.executable - - if not exe.available(): - if not exception_flag: - return False - raise NameError( - "No 'gams' command found on system PATH - GAMS shell " - "solver functionality is not available." - ) - # New versions of GAMS require a license to run anything. - # Instead of parsing the output, we will try solving a trivial - # model. - avail = self._run_simple_model(config, 1) - if not avail and exception_flag: - raise NameError( - "'gams' command failed to solve a simple model - " - "GAMS solver functionality is not available." - ) - return avail + self._available_cache = NOTSET + self._version_cache = NOTSET - def license_is_valid(self): - # New versions of the community license can run LPs up to 5k - return self._run_simple_model(5001) + def available(self, rehash: bool = False) -> Availability: + pth = self.config.executable.path() + if pth is None: + self._available_cache = (None, Availability.NotFound) + else: + self._available_cache = (pth, Availability.FullLicense) + if self._available_cache is not NOTSET and rehash == False: + return self._available_cache[1] + else: + raise NotImplementedError('feature for rehash is WIP') + # Executable(pth).available() + # Executable(pth).rehash() def _run_simple_model(self, config, n): solver_exec = config.executable.path() if solver_exec is None: return False - tmpdir = mkdtemp() - try: + with TempfileManager.new_context() as tempfile: + tmpdir = tempfile.mkdtemp() test = os.path.join(tmpdir, 'test.gms') with open(test, 'w') as FILE: FILE.write(self._simple_model(n)) @@ -191,9 +191,6 @@ def _run_simple_model(self, config, n): stderr=subprocess.DEVNULL, ) return not result.returncode - finally: - shutil.rmtree(tmpdir) - return False def _simple_model(self, n): return """ @@ -211,26 +208,29 @@ def _simple_model(self, n): n, ) - def version(self, config=None): - if config is None: - config = self.config - pth = config.executable.path() - if self._version_cache is None or self._version_cache[0] != pth: - if pth is None: - self._version_cache = (None, None) - else: - cmd = [pth, "audit", "lo=3"] - subprocess_results = subprocess.run( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - universal_newlines=True, - ) - version = subprocess_results.stdout.splitlines()[0] - version = [char for char in version.split(' ') if len(char) > 0][1] - self._version_cache = (pth, version) + def version(self, rehash: bool = False) -> Optional[Tuple[int, int, int]]: + pth = self.config.executable.path() + if pth is None: + self._version_cache = (None, None) + else: + cmd = [pth, "audit", "lo=3"] + subprocess_results = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + check=False, + ) + version = subprocess_results.stdout.splitlines()[0] + version = [char for char in version.split(' ') if len(char) > 0][1] + version = tuple(int(i) for i in version.split('.')) + self._version_cache = (pth, version) - return self._version_cache[1] + if self._version_cache is not NOTSET and rehash == False: + return self._version_cache[1] + + else: + raise NotImplementedError('feature for rehash is WIP') def _rewrite_path_win8p3(self, path): """ @@ -258,7 +258,6 @@ def _rewrite_path_win8p3(self, path): return buf.value return str(path) - @document_kwargs_from_configdict(CONFIG) def solve(self, model, **kwds): #################################################################### # Presolve @@ -270,21 +269,23 @@ def solve(self, model, **kwds): # preserve_implicit=True is required to extract solver_options ConfigDict config: GAMSConfig = self.config(value=kwds, preserve_implicit=True) - # Check if solver is available, unavailable solver error will be raised in available() - self.available(config) + # Check if solver is available + avail = self.available() + + if not avail: + raise ApplicationError( + f'Solver {self.__class__} is not available ({avail}).' + ) + if config.timer is None: timer = HierarchicalTimer() else: timer = config.timer StaleFlagManager.mark_all_as_stale() - # Because GAMS changes the CWD when running the solver, we need - # to convert user-provided file names to absolute paths - # (relative to the current directory) - if config.logfile is not None: - config.logfile = os.path.abspath(config.logfile) - - config.writer_config.put_results_format = 'gdx' if gdxcc_available else 'dat' + config.writer_config.setdefault( + "put_results_format", 'gdx' if gdxcc_available else 'dat' + ) # local variable to hold the working directory name and flags dname = None @@ -423,7 +424,7 @@ def solve(self, model, **kwds): extract_rc = 'rc' in model_suffixes results = GAMSResults() results.solver_name = "GAMS " - results.solver_version = str(self.version()) + results.solver_version = self.version() solvestat = stat_vars["SOLVESTAT"] if solvestat == 1: @@ -478,6 +479,7 @@ def solve(self, model, **kwds): TerminationCondition.infeasibleOrUnbounded ) results.solution_status = SolutionStatus.infeasible + raise InfeasibleConstraintException('Solver status returns infeasible') elif modelstat == 7: results.gams_termination_condition = TerminationCondition.feasible results.solution_status = SolutionStatus.feasible From ec5aa6ae9e173c7f9b34ae83844852dbdc9b7379 Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Tue, 19 Aug 2025 16:01:57 -0400 Subject: [PATCH 21/29] Re-added config into available and version, and deprecated test that are no longer valid. --- pyomo/contrib/solver/solvers/gams.py | 20 +++++++-- .../solver/tests/solvers/test_gams_v2.py | 41 ++++++++++++++++--- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index 2b7efbc4161..7ff6e8162f9 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -163,8 +163,14 @@ def __init__(self, **kwds): self._available_cache = NOTSET self._version_cache = NOTSET - def available(self, rehash: bool = False) -> Availability: - pth = self.config.executable.path() + def available( + self, config: Optional[GAMSConfig] = None, rehash: bool = False + ) -> Availability: + if config is None: + config = self.config + + pth = config.executable.path() + if pth is None: self._available_cache = (None, Availability.NotFound) else: @@ -208,8 +214,14 @@ def _simple_model(self, n): n, ) - def version(self, rehash: bool = False) -> Optional[Tuple[int, int, int]]: - pth = self.config.executable.path() + def version( + self, config: Optional[GAMSConfig] = None, rehash: bool = False + ) -> Optional[Tuple[int, int, int]]: + + if config is None: + config = self.config + pth = config.executable.path() + if pth is None: self._version_cache = (None, None) else: diff --git a/pyomo/contrib/solver/tests/solvers/test_gams_v2.py b/pyomo/contrib/solver/tests/solvers/test_gams_v2.py index d2cfde5f2f8..47d9e43c186 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gams_v2.py +++ b/pyomo/contrib/solver/tests/solvers/test_gams_v2.py @@ -107,7 +107,7 @@ def test_class_member_list(self): 'name', 'solve', 'version', - 'license_is_valid', + # 'license_is_valid', # DEPRECATED ] method_list = [method for method in dir(opt) if method.startswith('_') is False] self.assertEqual(sorted(expected_list), sorted(method_list)) @@ -128,6 +128,9 @@ def test_context_manager(self): self.assertEqual(opt.CONFIG, opt.config) self.assertTrue(opt.available()) + @unittest.skip( + "deprecated: available() is deprecated. available_cache is intended to replace this" + ) def test_available(self): opt = gams.GAMS() self.assertTrue(opt.available()) @@ -140,6 +143,9 @@ def test_available(self): # _run_simple_model will return False because of the invalid path self.assertFalse(opt._run_simple_model(config, 1)) + @unittest.skip( + "deprecated: test_version() is deprecated. test_version_cache is intended to replace this" + ) def test_version(self): opt = gams.GAMS() self.assertIsNotNone(opt.version()) @@ -170,6 +176,30 @@ def test_write_gms_file(self): self.assertTrue(result.returncode == 0) self.assertTrue(os.path.isfile(filename)) + def test_available_cache(self): + opt = gams.GAMS() + opt.available() + self.assertTrue(opt._available_cache[1]) + self.assertIsNotNone(opt._available_cache[0]) + # Now we will try with a custom config that has a fake path + config = gams.GAMSConfig() + config.executable = Executable('/a/bogus/path') + opt.available(config=config) + self.assertFalse(opt._available_cache[1]) + self.assertIsNone(opt._available_cache[0]) + + def test_version_cache(self): + opt = gams.GAMS() + opt.version() + self.assertIsNotNone(opt._version_cache[0]) + self.assertIsNotNone(opt._version_cache[1]) + # Now we will try with a custom config that has a fake path + config = gams.GAMSConfig() + config.executable = Executable('/a/bogus/path') + opt.version(config=config) + self.assertIsNone(opt._version_cache[0]) + self.assertIsNone(opt._version_cache[1]) + class TestGAMS(unittest.TestCase): def create_model(self): @@ -193,9 +223,10 @@ def test_gams_config(self): # Test custom initialization solver = SolverFactory('gams_v2', executable='/path/to/exe') self.assertFalse(solver.config.tee) - self.assertTrue(solver.config.executable.startswith('/path')) + self.assertIsNone(solver.config.executable.path()) + self.assertTrue(solver.config.executable._registered_name.startswith('/path')) - # Change value on a solve call - solver = SolverFactory('gams_v2') + def test_gams_solve(self): + # Gut check - does it solve? model = self.create_model() - solver.solve(model, tee=False, load_solutions=False) + gams.GAMS().solve(model) From 7bb817b37aaf665c62e9e20114c9fe400c8d34eb Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Tue, 19 Aug 2025 21:54:12 -0400 Subject: [PATCH 22/29] Sanity check config.writer_config.put_results_format without setdefault --- pyomo/contrib/solver/solvers/gams.py | 8 +++++--- pyomo/contrib/solver/tests/solvers/test_gams_v2.py | 3 +++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index 7ff6e8162f9..d7b7e45d76b 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -295,9 +295,11 @@ def solve(self, model, **kwds): timer = config.timer StaleFlagManager.mark_all_as_stale() - config.writer_config.setdefault( - "put_results_format", 'gdx' if gdxcc_available else 'dat' - ) + # SANITY CHECK - If setdefault is the bug + # config.writer_config.setdefault( + # "put_results_format", 'gdx' if gdxcc_available else 'dat' + # ) + config.writer_config.put_results_format = 'gdx' if gdxcc_available else 'dat' # local variable to hold the working directory name and flags dname = None diff --git a/pyomo/contrib/solver/tests/solvers/test_gams_v2.py b/pyomo/contrib/solver/tests/solvers/test_gams_v2.py index 47d9e43c186..316d0d6577a 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gams_v2.py +++ b/pyomo/contrib/solver/tests/solvers/test_gams_v2.py @@ -103,6 +103,7 @@ def test_class_member_list(self): 'CONFIG', 'available', 'config', + 'api_version', 'is_persistent', 'name', 'solve', @@ -230,3 +231,5 @@ def test_gams_solve(self): # Gut check - does it solve? model = self.create_model() gams.GAMS().solve(model) + self.assertAlmostEqual(model.x.value, 5) + self.assertAlmostEqual(model.y.value, -5) From 72beeaff57578300d973d26980669a4f74384544 Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Tue, 19 Aug 2025 22:53:27 -0400 Subject: [PATCH 23/29] Allow user to modify writer config via solver_options. Fixed bug of setting default to gdx that caused failed test. Implemented raise infeasible --- pyomo/contrib/solver/solvers/gams.py | 38 +++++++++++++++------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index d7b7e45d76b..b1cb29963fa 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -47,12 +47,12 @@ from pyomo.core.expr.visitor import replace_expressions from pyomo.core.expr.numvalue import value from pyomo.core.base.suffix import Suffix -from pyomo.common.errors import ( - ApplicationError, - DeveloperError, - InfeasibleConstraintException, +from pyomo.common.errors import ApplicationError +from pyomo.contrib.solver.common.util import ( + NoFeasibleSolutionError, + NoOptimalSolutionError, + NoSolutionError, ) - from pyomo.repn.plugins.gams_writer_v2 import GAMSWriterInfo, GAMSWriter logger = logging.getLogger(__name__) @@ -295,12 +295,6 @@ def solve(self, model, **kwds): timer = config.timer StaleFlagManager.mark_all_as_stale() - # SANITY CHECK - If setdefault is the bug - # config.writer_config.setdefault( - # "put_results_format", 'gdx' if gdxcc_available else 'dat' - # ) - config.writer_config.put_results_format = 'gdx' if gdxcc_available else 'dat' - # local variable to hold the working directory name and flags dname = None lst = "output.lst" @@ -323,6 +317,9 @@ def solve(self, model, **kwds): with open(output_filename, 'w', newline='\n', encoding='utf-8') as gms_file: timer.start(f'write_{output_filename}_file') self._writer.config.set_value(config.writer_config) + self._writer.config.put_results_format = ( + 'gdx' if gdxcc_available else 'dat' + ) # update the writer config if any of the overlapping keys exists in the solver_options non_solver_config = {} @@ -338,17 +335,17 @@ def solve(self, model, **kwds): # NOTE: omit InfeasibleConstraintException for now timer.stop(f'write_{output_filename}_file') - if config.writer_config.put_results_format == 'gdx': + if self._writer.config.put_results_format == 'gdx': results_filename = os.path.join(dname, f"{model.name}_p.gdx") statresults_filename = os.path.join( - dname, "%s_s.gdx" % (config.writer_config.put_results,) + dname, "%s_s.gdx" % (self._writer.config.put_results,) ) else: results_filename = os.path.join( - dname, "%s.dat" % (config.writer_config.put_results,) + dname, "%s.dat" % (self._writer.config.put_results,) ) statresults_filename = os.path.join( - dname, "%sstat.dat" % (config.writer_config.put_results,) + dname, "%sstat.dat" % (self._writer.config.put_results,) ) #################################################################### @@ -404,7 +401,7 @@ def solve(self, model, **kwds): "GAMS encountered an error during solve. " "Check listing file for details." ) - if config.writer_config.put_results_format == 'gdx': + if self._writer.config.put_results_format == 'gdx': timer.start('parse_gdx') model_soln, stat_vars = self._parse_gdx_results( config, results_filename, statresults_filename @@ -493,7 +490,6 @@ def solve(self, model, **kwds): TerminationCondition.infeasibleOrUnbounded ) results.solution_status = SolutionStatus.infeasible - raise InfeasibleConstraintException('Solver status returns infeasible') elif modelstat == 7: results.gams_termination_condition = TerminationCondition.feasible results.solution_status = SolutionStatus.feasible @@ -541,6 +537,14 @@ def solve(self, model, **kwds): results.termination_condition = rev_legacy_termination_condition_map[ results.gams_termination_condition ] + + # Taken from ipopt.py + if ( + config.raise_exception_on_nonoptimal_result + and results.solution_status != SolutionStatus.optimal + ): + raise NoOptimalSolutionError() + obj = list(model.component_data_objects(Objective, active=True)) # NOTE: How should gams handle when no objective is provided From 5c87b755b5477e631a22eafb220685a1464fa9f9 Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Wed, 20 Aug 2025 09:27:54 -0400 Subject: [PATCH 24/29] Corrected exeception type raised when solver return infeasible. Added rehash handling with Executable.rehash() --- pyomo/contrib/solver/solvers/gams.py | 19 +++++++++++-------- .../contrib/solver/solvers/gms_sol_reader.py | 15 ++++++++++----- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index b1cb29963fa..584da037438 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -166,21 +166,22 @@ def __init__(self, **kwds): def available( self, config: Optional[GAMSConfig] = None, rehash: bool = False ) -> Availability: + if config is None: config = self.config pth = config.executable.path() + if rehash: + Executable(pth).rehash() + rehash = False + if pth is None: self._available_cache = (None, Availability.NotFound) else: self._available_cache = (pth, Availability.FullLicense) if self._available_cache is not NOTSET and rehash == False: return self._available_cache[1] - else: - raise NotImplementedError('feature for rehash is WIP') - # Executable(pth).available() - # Executable(pth).rehash() def _run_simple_model(self, config, n): solver_exec = config.executable.path() @@ -222,6 +223,10 @@ def version( config = self.config pth = config.executable.path() + if rehash: + Executable(pth).rehash() + rehash = False + if pth is None: self._version_cache = (None, None) else: @@ -241,9 +246,6 @@ def version( if self._version_cache is not NOTSET and rehash == False: return self._version_cache[1] - else: - raise NotImplementedError('feature for rehash is WIP') - def _rewrite_path_win8p3(self, path): """ Return the 8.3 short path on Windows; unchanged elsewhere. @@ -282,7 +284,7 @@ def solve(self, model, **kwds): config: GAMSConfig = self.config(value=kwds, preserve_implicit=True) # Check if solver is available - avail = self.available() + avail = self.available(config) if not avail: raise ApplicationError( @@ -490,6 +492,7 @@ def solve(self, model, **kwds): TerminationCondition.infeasibleOrUnbounded ) results.solution_status = SolutionStatus.infeasible + results.solution_loader = GMSSolutionLoader(None, None) elif modelstat == 7: results.gams_termination_condition = TerminationCondition.feasible results.solution_status = SolutionStatus.feasible diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py index dd5528fa6e5..6edccc4fafd 100644 --- a/pyomo/contrib/solver/solvers/gms_sol_reader.py +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -20,6 +20,11 @@ from pyomo.core.staleflag import StaleFlagManager from pyomo.repn.plugins.gams_writer_v2 import GAMSWriterInfo from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +from pyomo.contrib.solver.common.util import ( + NoDualsError, + NoSolutionError, + NoReducedCostsError, +) class GDXFileData: @@ -48,7 +53,7 @@ def __init__(self, gdx_data: GDXFileData, gms_info: GAMSWriterInfo) -> None: def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> NoReturn: if self._gms_info is None: - raise RuntimeError( + raise NoSolutionError( 'Solution loader does not currently have a valid solution. Please ' 'check results.termination_condition and/or results.solution_status.' ) @@ -93,12 +98,12 @@ def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None ) -> Dict[ConstraintData, float]: if self._gms_info is None: - raise RuntimeError( + raise NoDualsError( 'Solution loader does not currently have a valid solution. Please ' 'check results.termination_condition and/or results.solution_status.' ) if self._gdx_data is None: - raise RuntimeError( + raise NoDualsError( 'Solution loader does not currently have a valid solution. Please ' 'check results.termination_condition and/or results.solution_status.' ) @@ -127,12 +132,12 @@ def get_duals( def get_reduced_costs(self, vars_to_load=None): if self._gms_info is None: - raise RuntimeError( + raise NoReducedCostsError( 'Solution loader does not currently have a valid solution. Please ' 'check results.termination_condition and/or results.solution_status.' ) if self._gdx_data is None: - raise RuntimeError( + raise NoReducedCostsError( 'Solution loader does not currently have a valid solution. Please ' 'check results.termination_condition and/or results.solution_status.' ) From d6aa9ff2e5bd363389998cc4297e22435ac85f3c Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Wed, 20 Aug 2025 09:53:47 -0400 Subject: [PATCH 25/29] Corrected test_gams_v2.py to match error raised. Corrected error message for infeasible model when calling load_var, dual, reduced_cost --- pyomo/contrib/solver/solvers/gms_sol_reader.py | 8 ++++---- pyomo/contrib/solver/tests/solvers/test_gams_v2.py | 10 ++++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gms_sol_reader.py b/pyomo/contrib/solver/solvers/gms_sol_reader.py index 6edccc4fafd..a26fe2a3441 100644 --- a/pyomo/contrib/solver/solvers/gms_sol_reader.py +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -99,12 +99,12 @@ def get_duals( ) -> Dict[ConstraintData, float]: if self._gms_info is None: raise NoDualsError( - 'Solution loader does not currently have a valid solution. Please ' + 'Solution loader does not currently have valid duals. Please ' 'check results.termination_condition and/or results.solution_status.' ) if self._gdx_data is None: raise NoDualsError( - 'Solution loader does not currently have a valid solution. Please ' + 'Solution loader does not currently have valid duals. Please ' 'check results.termination_condition and/or results.solution_status.' ) @@ -133,12 +133,12 @@ def get_duals( def get_reduced_costs(self, vars_to_load=None): if self._gms_info is None: raise NoReducedCostsError( - 'Solution loader does not currently have a valid solution. Please ' + 'Solution loader does not currently have valid reduced costs. Please ' 'check results.termination_condition and/or results.solution_status.' ) if self._gdx_data is None: raise NoReducedCostsError( - 'Solution loader does not currently have a valid solution. Please ' + 'Solution loader does not currently have valid reduced costs. Please ' 'check results.termination_condition and/or results.solution_status.' ) diff --git a/pyomo/contrib/solver/tests/solvers/test_gams_v2.py b/pyomo/contrib/solver/tests/solvers/test_gams_v2.py index 316d0d6577a..6f887773c03 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gams_v2.py +++ b/pyomo/contrib/solver/tests/solvers/test_gams_v2.py @@ -19,7 +19,11 @@ from pyomo.common.config import ConfigDict from pyomo.common.errors import DeveloperError import pyomo.contrib.solver.solvers.gams as gams -from pyomo.contrib.solver.common.util import NoSolutionError +from pyomo.contrib.solver.common.util import ( + NoSolutionError, + NoDualsError, + NoReducedCostsError, +) from pyomo.opt.base import SolverFactory from pyomo.common import unittest, Executable from pyomo.common.tempfiles import TempfileManager @@ -72,7 +76,9 @@ def test_get_reduced_costs_error(self): loader = gams.GMSSolutionLoader(None, None) with self.assertRaises(RuntimeError): loader.get_primals() + with self.assertRaises(NoDualsError): loader.get_duals() + with self.assertRaises(NoReducedCostsError): loader.get_reduced_costs() # Set _gms_info to something completely bogus but is not None @@ -91,7 +97,7 @@ class GDXData: loader.get_primals() # if the model is infeasible, no dual information is returned - with self.assertRaises(RuntimeError): + with self.assertRaises(NoDualsError): loader.get_duals() From f85d217b0d7cce452c46dd91180b20d9fe2b1b20 Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Wed, 20 Aug 2025 10:56:02 -0400 Subject: [PATCH 26/29] Prevent rewrite of termination condition in modelstat if the value is not none --- pyomo/contrib/solver/solvers/gams.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index 584da037438..9cd8d2550ad 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -324,6 +324,8 @@ def solve(self, model, **kwds): ) # update the writer config if any of the overlapping keys exists in the solver_options + if config.time_limit is not None: + config.solver_options['resLim'] = config.time_limit non_solver_config = {} for key in config.solver_options.keys(): if key in self._writer.config: @@ -488,9 +490,10 @@ def solve(self, model, **kwds): results.solution_status = SolutionStatus.noSolution elif modelstat in [4, 5, 6, 10, 19]: - results.gams_termination_condition = ( - TerminationCondition.infeasibleOrUnbounded - ) + if results.gams_termination_condition is None: + results.gams_termination_condition = ( + TerminationCondition.infeasibleOrUnbounded + ) results.solution_status = SolutionStatus.infeasible results.solution_loader = GMSSolutionLoader(None, None) elif modelstat == 7: From 0e94742632549615a84bb56853a229d2ac7a9856 Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Wed, 20 Aug 2025 14:25:24 -0400 Subject: [PATCH 27/29] Added an gams_solver_options (handled via solver_option in solve). Added handling solvestat and modelstat when solver is interrupted --- pyomo/contrib/solver/solvers/gams.py | 130 ++++++++++++++------------- pyomo/repn/plugins/gams_writer_v2.py | 55 ++++++++---- 2 files changed, 103 insertions(+), 82 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index 9cd8d2550ad..a51d9907020 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -118,7 +118,6 @@ def __init__( self.writer_config: ConfigDict = self.declare( 'writer_config', GAMSWriter.CONFIG() ) - # NOTE: Taken from the lp_writer self.declare( 'row_order', @@ -300,6 +299,7 @@ def solve(self, model, **kwds): # local variable to hold the working directory name and flags dname = None lst = "output.lst" + model_name = "GAMS_MODEL" output_filename = None with TempfileManager.new_context() as tempfile: # IMPORTANT - only delete the whole tmpdir if the solver was the one @@ -313,7 +313,7 @@ def solve(self, model, **kwds): dname = config.working_dir if not os.path.exists(dname): os.mkdir(dname) - basename = os.path.join(dname, model.name) + basename = os.path.join(dname, model_name) output_filename = basename + '.gms' lst_filename = os.path.join(dname, lst) with open(output_filename, 'w', newline='\n', encoding='utf-8') as gms_file: @@ -326,6 +326,7 @@ def solve(self, model, **kwds): # update the writer config if any of the overlapping keys exists in the solver_options if config.time_limit is not None: config.solver_options['resLim'] = config.time_limit + non_solver_config = {} for key in config.solver_options.keys(): if key in self._writer.config: @@ -340,7 +341,7 @@ def solve(self, model, **kwds): # NOTE: omit InfeasibleConstraintException for now timer.stop(f'write_{output_filename}_file') if self._writer.config.put_results_format == 'gdx': - results_filename = os.path.join(dname, f"{model.name}_p.gdx") + results_filename = os.path.join(dname, f"{model_name}_p.gdx") statresults_filename = os.path.join( dname, "%s_s.gdx" % (self._writer.config.put_results,) ) @@ -421,6 +422,9 @@ def solve(self, model, **kwds): #################################################################### # Postsolve (WIP) + """ + If solver is interrupted either from user input or resources, skip checking the modelstat + """ #################################################################### # Mapping between old and new contrib results @@ -478,66 +482,70 @@ def solve(self, model, **kwds): results.gams_solver_status = SolverStatus.unknown modelstat = stat_vars["MODELSTAT"] - if modelstat == 1: - results.gams_termination_condition = TerminationCondition.optimal - results.solution_status = SolutionStatus.optimal - elif modelstat == 2: - results.gams_termination_condition = TerminationCondition.locallyOptimal - results.solution_status = SolutionStatus.feasible - elif modelstat in [3, 18]: - results.gams_termination_condition = TerminationCondition.unbounded - # results.solution_status = SolutionStatus.unbounded - results.solution_status = SolutionStatus.noSolution - - elif modelstat in [4, 5, 6, 10, 19]: - if results.gams_termination_condition is None: + if solvestat == 1: + if modelstat == 1: + results.gams_termination_condition = TerminationCondition.optimal + results.solution_status = SolutionStatus.optimal + elif modelstat == 2: + results.gams_termination_condition = ( + TerminationCondition.locallyOptimal + ) + results.solution_status = SolutionStatus.feasible + elif modelstat in [3, 18]: + results.gams_termination_condition = TerminationCondition.unbounded + # results.solution_status = SolutionStatus.unbounded + results.solution_status = SolutionStatus.noSolution + + elif modelstat in [4, 5, 6, 10, 19]: results.gams_termination_condition = ( TerminationCondition.infeasibleOrUnbounded ) - results.solution_status = SolutionStatus.infeasible - results.solution_loader = GMSSolutionLoader(None, None) - elif modelstat == 7: - results.gams_termination_condition = TerminationCondition.feasible - results.solution_status = SolutionStatus.feasible - elif modelstat == 8: - # 'Integer solution model found' - results.gams_termination_condition = TerminationCondition.optimal - results.solution_status = SolutionStatus.optimal - elif modelstat == 9: - results.gams_termination_condition = ( - TerminationCondition.intermediateNonInteger - ) - results.solution_status = SolutionStatus.noSolution - elif modelstat == 11: - # Should be handled above, if modelstat and solvestat both - # indicate a licensing problem - if results.gams_termination_condition is None: + results.solution_status = SolutionStatus.infeasible + results.solution_loader = GMSSolutionLoader(None, None) + elif modelstat == 7: + results.gams_termination_condition = TerminationCondition.feasible + results.solution_status = SolutionStatus.feasible + elif modelstat == 8: + # 'Integer solution model found' + results.gams_termination_condition = TerminationCondition.optimal + results.solution_status = SolutionStatus.optimal + elif modelstat == 9: results.gams_termination_condition = ( - TerminationCondition.licensingProblems + TerminationCondition.intermediateNonInteger ) - results.solution_status = SolutionStatus.noSolution - # results.solution_status = SolutionStatus.error - - elif modelstat in [12, 13]: - if results.gams_termination_condition is None: - results.gams_termination_condition = TerminationCondition.error - results.solution_status = SolutionStatus.noSolution - # results.solution_status = SolutionStatus.error - - elif modelstat == 14: - if results.gams_termination_condition is None: - results.gams_termination_condition = TerminationCondition.noSolution - results.solution_status = SolutionStatus.noSolution - # results.solution_status = SolutionStatus.unknown - - elif modelstat in [15, 16, 17]: - # Having to do with CNS models, - # not sure what to make of status descriptions - results.gams_termination_condition = TerminationCondition.optimal - results.solution_status = SolutionStatus.noSolution - else: - # This is just a backup catch, all cases are handled above - results.solution_status = SolutionStatus.noSolution + results.solution_status = SolutionStatus.noSolution + elif modelstat == 11: + # Should be handled above, if modelstat and solvestat both + # indicate a licensing problem + if results.gams_termination_condition is None: + results.gams_termination_condition = ( + TerminationCondition.licensingProblems + ) + results.solution_status = SolutionStatus.noSolution + # results.solution_status = SolutionStatus.error + + elif modelstat in [12, 13]: + if results.gams_termination_condition is None: + results.gams_termination_condition = TerminationCondition.error + results.solution_status = SolutionStatus.noSolution + # results.solution_status = SolutionStatus.error + + elif modelstat == 14: + if results.gams_termination_condition is None: + results.gams_termination_condition = ( + TerminationCondition.noSolution + ) + results.solution_status = SolutionStatus.noSolution + # results.solution_status = SolutionStatus.unknown + + elif modelstat in [15, 16, 17]: + # Having to do with CNS models, + # not sure what to make of status descriptions + results.gams_termination_condition = TerminationCondition.optimal + results.solution_status = SolutionStatus.noSolution + else: + # This is just a backup catch, all cases are handled above + results.solution_status = SolutionStatus.noSolution # ensure backward compatibility before feeding to contrib.solver results.termination_condition = rev_legacy_termination_condition_map[ @@ -553,12 +561,6 @@ def solve(self, model, **kwds): obj = list(model.component_data_objects(Objective, active=True)) - # NOTE: How should gams handle when no objective is provided - # NOTE: pyomo/contrib/solver/tests/solvers/test_solvers.py::TestSolvers::test_no_objective - # NOTE: results.incumbent_objective = None - # NOTE: results.objective_bound = None - # assert len(obj) == 1, 'Only one objective is allowed.' - if results.solution_status in { SolutionStatus.feasible, SolutionStatus.optimal, diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py index e6e7d9548ff..42a5ea60485 100644 --- a/pyomo/repn/plugins/gams_writer_v2.py +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -181,6 +181,17 @@ class GAMSWriter(object): """, ), ) + CONFIG.declare( + 'gams_solver_options', + ConfigValue( + default=None, + doc=""" + List of additional lines to write directly + into model file before the solve statement. + Specifically for solvers. + """, + ), + ) CONFIG.declare( 'skip_trivial_constraints', ConfigValue( @@ -255,7 +266,7 @@ def __init__(self): def __call__(self, model, filename, solver_capability, io_options): if filename is None: - filename = model.name + ".gms" + filename = 'GAMS_MODEL' + ".gms" config = self.config(io_options) @@ -302,6 +313,8 @@ def __init__(self, ostream, config): # Taken from nl_writer.py self.symbolic_solver_labels = config.symbolic_solver_labels self.add_options = config.add_options + self.gams_solver_options = config.gams_solver_options + self.subexpression_cache = {} self.subexpression_order = None # set to [] later self.external_functions = {} @@ -319,8 +332,10 @@ def write(self, model): ) # Caching some frequently-used objects into the locals() + model_name = "GAMS_MODEL" symbolic_solver_labels = self.symbolic_solver_labels add_options = self.add_options + gams_solver_options = self.gams_solver_options ostream = self.ostream config = self.config labeler = config.labeler @@ -351,7 +366,7 @@ def write(self, model): if unknown: raise ValueError( "The model ('%s') contains the following active components " - "that the LP writer does not know how to process:\n\t%s" + "that the gams writer does not know how to process:\n\t%s" % ( model.name, "\n\t".join( @@ -495,7 +510,7 @@ def write(self, model): if len(objectives) > 1: raise ValueError( "More than one active objective defined for input model '%s'; " - "Cannot write legal LP file\nObjectives: %s" + "Cannot write legal gms file\nObjectives: %s" % (model.name, ' '.join(obj.name for obj in objectives)) ) @@ -576,9 +591,9 @@ def write(self, model): ostream.write(f'{v}.up = {ub};\n') if warmstart and pyomo_v.value is not None: ostream.write("%s.l = %s;\n" % (v, ftoa(pyomo_v.value, False))) - ostream.write(f'\nModel {model.name} / all /;\n') - ostream.write(f'{model.name}.limrow = 0;\n') - ostream.write(f'{model.name}.limcol = 0;\n') + ostream.write(f'\nModel {model_name} / all /;\n') + ostream.write(f'{model_name}.limrow = 0;\n') + ostream.write(f'{model_name}.limcol = 0;\n') # CHECK FOR mtype flag based on variable domains - reals, integer if config.mtype is None: @@ -590,16 +605,20 @@ def write(self, model): if config.put_results_format == 'gdx': ostream.write("option savepoint=1;\n") + ostream.write("\n* START USER ADDITIONAL OPTIONS\n") if add_options is not None: - ostream.write("\n* START USER ADDITIONAL OPTIONS\n") for options, val in add_options.items(): # ostream.write('option ' + line + '\n') ostream.write(f'option {options}={val};\n') - ostream.write("* END USER ADDITIONAL OPTIONS\n\n") + + if gams_solver_options is not None: + for options in gams_solver_options: + ostream.write(f'{options}\n') + ostream.write("* END USER ADDITIONAL OPTIONS\n\n") ostream.write( "SOLVE %s USING %s %simizing GAMS_OBJECTIVE;\n" - % (model.name, config.mtype, 'min' if obj.sense == minimize else 'max') + % (model_name, config.mtype, 'min' if obj.sense == minimize else 'max') ) # Set variables to store certain statuses and attributes stat_vars = [ @@ -614,27 +633,27 @@ def write(self, model): 'ETSOLVE', ] ostream.write("\nScalars MODELSTAT 'model status', SOLVESTAT 'solve status';\n") - ostream.write("MODELSTAT = %s.modelstat;\n" % model.name) - ostream.write("SOLVESTAT = %s.solvestat;\n\n" % model.name) + ostream.write("MODELSTAT = %s.modelstat;\n" % model_name) + ostream.write("SOLVESTAT = %s.solvestat;\n\n" % model_name) ostream.write("Scalar OBJEST 'best objective', OBJVAL 'objective value';\n") - ostream.write("OBJEST = %s.objest;\n" % model.name) - ostream.write("OBJVAL = %s.objval;\n\n" % model.name) + ostream.write("OBJEST = %s.objest;\n" % model_name) + ostream.write("OBJVAL = %s.objval;\n\n" % model_name) ostream.write("Scalar NUMVAR 'number of variables';\n") - ostream.write("NUMVAR = %s.numvar\n\n" % model.name) + ostream.write("NUMVAR = %s.numvar\n\n" % model_name) ostream.write("Scalar NUMEQU 'number of equations';\n") - ostream.write("NUMEQU = %s.numequ\n\n" % model.name) + ostream.write("NUMEQU = %s.numequ\n\n" % model_name) ostream.write("Scalar NUMDVAR 'number of discrete variables';\n") - ostream.write("NUMDVAR = %s.numdvar\n\n" % model.name) + ostream.write("NUMDVAR = %s.numdvar\n\n" % model_name) ostream.write("Scalar NUMNZ 'number of nonzeros';\n") - ostream.write("NUMNZ = %s.numnz\n\n" % model.name) + ostream.write("NUMNZ = %s.numnz\n\n" % model_name) ostream.write("Scalar ETSOLVE 'time to execute solve statement';\n") - ostream.write("ETSOLVE = %s.etsolve\n\n" % model.name) + ostream.write("ETSOLVE = %s.etsolve\n\n" % model_name) if config.put_results is not None: if config.put_results_format == 'gdx': From 9d5758e548e1a453aee3e3b237fe281afbdc7100 Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Thu, 21 Aug 2025 09:52:25 -0400 Subject: [PATCH 28/29] Divided solver and model termination condition into separate config. --- pyomo/contrib/solver/solvers/gams.py | 173 ++++++++++++++++----------- 1 file changed, 100 insertions(+), 73 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py index a51d9907020..f4c4f9805d4 100644 --- a/pyomo/contrib/solver/solvers/gams.py +++ b/pyomo/contrib/solver/solvers/gams.py @@ -137,8 +137,16 @@ def __init__(self): 'return_code', ConfigValue(default=None, description="Return code from the GAMS solver."), ) - self.gams_termination_condition: ConfigDict = self.declare( - 'gams_termination_condition', + self.gams_solver_termination_condition: ConfigDict = self.declare( + 'gams_solver_termination_condition', + ConfigValue( + default=None, + description="Include additional TerminationCondition domain." + "Take precedence over model_termination_condition if interruption occur", + ), + ) + self.gams_model_termination_condition: ConfigDict = self.declare( + 'gams_model_termination_condition', ConfigValue( default=None, description="Include additional TerminationCondition domain.", @@ -423,7 +431,7 @@ def solve(self, model, **kwds): #################################################################### # Postsolve (WIP) """ - If solver is interrupted either from user input or resources, skip checking the modelstat + If solver is interrupted either from user input or resources, skip checking the modelstat termination condition """ #################################################################### @@ -450,27 +458,37 @@ def solve(self, model, **kwds): results.gams_solver_status = SolverStatus.ok elif solvestat == 2: results.gams_solver_status = SolverStatus.ok - results.gams_termination_condition = TerminationCondition.maxIterations + results.gams_solver_termination_condition = ( + TerminationCondition.maxIterations + ) elif solvestat == 3: results.gams_solver_status = SolverStatus.ok - results.gams_termination_condition = TerminationCondition.maxTimeLimit + results.gams_solver_termination_condition = ( + TerminationCondition.maxTimeLimit + ) elif solvestat == 5: results.gams_solver_status = SolverStatus.ok - results.gams_termination_condition = TerminationCondition.maxEvaluations + results.gams_solver_termination_condition = ( + TerminationCondition.maxEvaluations + ) elif solvestat == 7: results.gams_solver_status = SolverStatus.aborted - results.gams_termination_condition = ( + results.gams_solver_termination_condition = ( TerminationCondition.licensingProblems ) elif solvestat == 8: results.gams_solver_status = SolverStatus.aborted - results.gams_termination_condition = TerminationCondition.userInterrupt + results.gams_solver_termination_condition = ( + TerminationCondition.userInterrupt + ) elif solvestat == 10: results.gams_solver_status = SolverStatus.error - results.gams_termination_condition = TerminationCondition.solverFailure + results.gams_solver_termination_condition = ( + TerminationCondition.solverFailure + ) elif solvestat == 11: results.gams_solver_status = SolverStatus.error - results.gams_termination_condition = ( + results.gams_solver_termination_condition = ( TerminationCondition.internalSolverError ) elif solvestat == 4: @@ -482,74 +500,83 @@ def solve(self, model, **kwds): results.gams_solver_status = SolverStatus.unknown modelstat = stat_vars["MODELSTAT"] - if solvestat == 1: - if modelstat == 1: - results.gams_termination_condition = TerminationCondition.optimal - results.solution_status = SolutionStatus.optimal - elif modelstat == 2: - results.gams_termination_condition = ( - TerminationCondition.locallyOptimal - ) - results.solution_status = SolutionStatus.feasible - elif modelstat in [3, 18]: - results.gams_termination_condition = TerminationCondition.unbounded - # results.solution_status = SolutionStatus.unbounded - results.solution_status = SolutionStatus.noSolution - - elif modelstat in [4, 5, 6, 10, 19]: - results.gams_termination_condition = ( - TerminationCondition.infeasibleOrUnbounded + if modelstat == 1: + results.gams_model_termination_condition = TerminationCondition.optimal + results.solution_status = SolutionStatus.optimal + elif modelstat == 2: + results.gams_model_termination_condition = ( + TerminationCondition.locallyOptimal + ) + results.solution_status = SolutionStatus.feasible + elif modelstat in [3, 18]: + results.gams_model_termination_condition = ( + TerminationCondition.unbounded + ) + # results.solution_status = SolutionStatus.unbounded + results.solution_status = SolutionStatus.noSolution + + elif modelstat in [4, 5, 6, 10, 19]: + results.gams_model_termination_condition = ( + TerminationCondition.infeasibleOrUnbounded + ) + results.solution_status = SolutionStatus.infeasible + results.solution_loader = GMSSolutionLoader(None, None) + elif modelstat == 7: + results.gams_model_termination_condition = TerminationCondition.feasible + results.solution_status = SolutionStatus.feasible + elif modelstat == 8: + # 'Integer solution model found' + results.gams_model_termination_condition = TerminationCondition.optimal + results.solution_status = SolutionStatus.optimal + elif modelstat == 9: + results.gams_model_termination_condition = ( + TerminationCondition.intermediateNonInteger + ) + results.solution_status = SolutionStatus.noSolution + elif modelstat == 11: + # Should be handled above, if modelstat and solvestat both + # indicate a licensing problem + if results.gams_model_termination_condition is None: + results.gams_model_termination_condition = ( + TerminationCondition.licensingProblems ) - results.solution_status = SolutionStatus.infeasible - results.solution_loader = GMSSolutionLoader(None, None) - elif modelstat == 7: - results.gams_termination_condition = TerminationCondition.feasible - results.solution_status = SolutionStatus.feasible - elif modelstat == 8: - # 'Integer solution model found' - results.gams_termination_condition = TerminationCondition.optimal - results.solution_status = SolutionStatus.optimal - elif modelstat == 9: - results.gams_termination_condition = ( - TerminationCondition.intermediateNonInteger + results.solution_status = SolutionStatus.noSolution + # results.solution_status = SolutionStatus.error + + elif modelstat in [12, 13]: + if results.gams_model_termination_condition is None: + results.gams_model_termination_condition = ( + TerminationCondition.error ) - results.solution_status = SolutionStatus.noSolution - elif modelstat == 11: - # Should be handled above, if modelstat and solvestat both - # indicate a licensing problem - if results.gams_termination_condition is None: - results.gams_termination_condition = ( - TerminationCondition.licensingProblems - ) - results.solution_status = SolutionStatus.noSolution - # results.solution_status = SolutionStatus.error - - elif modelstat in [12, 13]: - if results.gams_termination_condition is None: - results.gams_termination_condition = TerminationCondition.error - results.solution_status = SolutionStatus.noSolution - # results.solution_status = SolutionStatus.error - - elif modelstat == 14: - if results.gams_termination_condition is None: - results.gams_termination_condition = ( - TerminationCondition.noSolution - ) - results.solution_status = SolutionStatus.noSolution - # results.solution_status = SolutionStatus.unknown - - elif modelstat in [15, 16, 17]: - # Having to do with CNS models, - # not sure what to make of status descriptions - results.gams_termination_condition = TerminationCondition.optimal - results.solution_status = SolutionStatus.noSolution - else: - # This is just a backup catch, all cases are handled above - results.solution_status = SolutionStatus.noSolution + results.solution_status = SolutionStatus.noSolution + # results.solution_status = SolutionStatus.error + + elif modelstat == 14: + results.gams_model_termination_condition = ( + TerminationCondition.noSolution + ) + results.solution_status = SolutionStatus.noSolution + # results.solution_status = SolutionStatus.unknown + + elif modelstat in [15, 16, 17]: + # Having to do with CNS models, + # not sure what to make of status descriptions + results.gams_model_termination_condition = TerminationCondition.optimal + results.solution_status = SolutionStatus.noSolution + else: + # This is just a backup catch, all cases are handled above + results.solution_status = SolutionStatus.noSolution + + # prioritize solver termination condition if interruption occur + termination_condition_key = ( + results.gams_solver_termination_condition + if solvestat != 1 + else results.gams_model_termination_condition + ) # ensure backward compatibility before feeding to contrib.solver results.termination_condition = rev_legacy_termination_condition_map[ - results.gams_termination_condition + termination_condition_key ] # Taken from ipopt.py From 2973d9ccafb98a4f2f6b9df9d5399be527455a00 Mon Sep 17 00:00:00 2001 From: Norman_Tran Date: Fri, 22 Aug 2025 13:29:10 -0400 Subject: [PATCH 29/29] Added unittest skipif for linux_3.9_slim and linux_3.12_numpy failed imports --- pyomo/contrib/solver/tests/solvers/test_gams_v2.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyomo/contrib/solver/tests/solvers/test_gams_v2.py b/pyomo/contrib/solver/tests/solvers/test_gams_v2.py index 6f887773c03..af23a6a43c3 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gams_v2.py +++ b/pyomo/contrib/solver/tests/solvers/test_gams_v2.py @@ -208,6 +208,7 @@ def test_version_cache(self): self.assertIsNone(opt._version_cache[1]) +@unittest.skipIf(not gams_available, "The 'gams' command is not available") class TestGAMS(unittest.TestCase): def create_model(self): model = pyo.ConcreteModel('TestModel')