diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index 19fc9b2b2a1..7575337d4a2 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 + ) diff --git a/pyomo/contrib/solver/solvers/gams.py b/pyomo/contrib/solver/solvers/gams.py new file mode 100644 index 00000000000..f4c4f9805d4 --- /dev/null +++ b/pyomo/contrib/solver/solvers/gams.py @@ -0,0 +1,770 @@ +# ___________________________________________________________________________ +# +# 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, Tuple +import sys + +from pyomo.common.fileutils import Executable, ExecutableData +from pyomo.common.dependencies import pathlib +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, Availability +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.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__) + +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) + + +@document_configdict() +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: ExecutableData = self.declare( + 'executable', + ConfigValue( + domain=Executable, + default='gams', + description="Executable for gams. Defaults to searching the " + "``PATH`` for the first available ``gams``.", + ), + ) + self.logfile: str = self.declare( + 'logfile', + ConfigValue( + domain=Path(), + default=None, + description="Filename to output GAMS log to a file.", + ), + ) + self.writer_config: ConfigDict = self.declare( + 'writer_config', GAMSWriter.CONFIG() + ) + # 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): + 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_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.", + ), + ) + self.gams_solver_status: ConfigDict = self.declare( + 'gams_solver_status', + ConfigValue( + default=None, description="Include additional SolverStatus domain." + ), + ) + + +@document_class_CONFIG(methods=['solve']) +class GAMS(SolverBase): + CONFIG = GAMSConfig() + + def __init__(self, **kwds): + super().__init__(**kwds) + self._writer = GAMSWriter() + self._available_cache = NOTSET + self._version_cache = NOTSET + + 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] + + def _run_simple_model(self, config, n): + solver_exec = config.executable.path() + if solver_exec is None: + return False + 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)) + result = subprocess.run( + [solver_exec, test, "curdir=" + tmpdir, 'lo=0'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return not result.returncode + + 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: Optional[GAMSConfig] = None, rehash: bool = False + ) -> Optional[Tuple[int, int, int]]: + + if config is None: + config = self.config + pth = config.executable.path() + + if rehash: + Executable(pth).rehash() + rehash = False + + 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) + + if self._version_cache is not NOTSET and rehash == False: + 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) + + 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 + # preserve_implicit=True is required to extract solver_options ConfigDict + config: GAMSConfig = self.config(value=kwds, preserve_implicit=True) + + # Check if solver is available + avail = self.available(config) + + 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() + + # 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 + # 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() + else: + dname = config.working_dir + if not os.path.exists(dname): + os.mkdir(dname) + basename = os.path.join(dname, model_name) + output_filename = basename + '.gms' + lst_filename = os.path.join(dname, lst) + 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 + 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: + 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') + 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" % (self._writer.config.put_results,) + ) + else: + results_filename = os.path.join( + dname, "%s.dat" % (self._writer.config.put_results,) + ) + statresults_filename = os.path.join( + dname, "%sstat.dat" % (self._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)}") + 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 self._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') + + #################################################################### + # Postsolve (WIP) + """ + If solver is interrupted either from user input or resources, skip checking the modelstat termination condition + """ + #################################################################### + + # 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 = 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_solver_termination_condition = ( + TerminationCondition.maxIterations + ) + elif solvestat == 3: + results.gams_solver_status = SolverStatus.ok + results.gams_solver_termination_condition = ( + TerminationCondition.maxTimeLimit + ) + elif solvestat == 5: + results.gams_solver_status = SolverStatus.ok + results.gams_solver_termination_condition = ( + TerminationCondition.maxEvaluations + ) + elif solvestat == 7: + results.gams_solver_status = SolverStatus.aborted + results.gams_solver_termination_condition = ( + TerminationCondition.licensingProblems + ) + elif solvestat == 8: + results.gams_solver_status = SolverStatus.aborted + results.gams_solver_termination_condition = ( + TerminationCondition.userInterrupt + ) + elif solvestat == 10: + results.gams_solver_status = SolverStatus.error + results.gams_solver_termination_condition = ( + TerminationCondition.solverFailure + ) + elif solvestat == 11: + results.gams_solver_status = SolverStatus.error + results.gams_solver_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_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.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 + # 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[ + termination_condition_key + ] + + # 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)) + + 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() + if len(obj) == 1: + results.incumbent_objective = stat_vars["OBJVAL"] + else: + results.incumbent_objective = None + 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 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..a26fe2a3441 --- /dev/null +++ b/pyomo/contrib/solver/solvers/gms_sol_reader.py @@ -0,0 +1,162 @@ +# ___________________________________________________________________________ +# +# 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 +from pyomo.contrib.solver.common.util import ( + NoDualsError, + NoSolutionError, + NoReducedCostsError, +) + + +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 NoSolutionError( + '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)] + else: + for 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 NoDualsError( + '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 valid duals. Please ' + 'check results.termination_condition and/or results.solution_status.' + ) + + 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] + 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: + cons_to_load = self._gms_info.con_symbol_map.bySymbol.items() + + 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 + + def get_reduced_costs(self, vars_to_load=None): + if self._gms_info is None: + raise NoReducedCostsError( + '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 valid reduced costs. Please ' + '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] = var_map[id(obj)] + else: + for obj in vars_to_load: + res[obj] = var_map[id(obj)] + + return res 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..af23a6a43c3 --- /dev/null +++ b/pyomo/contrib/solver/tests/solvers/test_gams_v2.py @@ -0,0 +1,242 @@ +# ___________________________________________________________________________ +# +# 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, + NoDualsError, + NoReducedCostsError, +) +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() + 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 + # 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(NoDualsError): + 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', + 'api_version', + 'is_persistent', + 'name', + 'solve', + 'version', + # '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)) + + 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()) + + @unittest.skip( + "deprecated: available() is deprecated. available_cache is intended to replace this" + ) + 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)) + + @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()) + + 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)) + + 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]) + + +@unittest.skipIf(not gams_available, "The 'gams' command is not available") +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.assertIsNone(solver.config.executable.path()) + self.assertTrue(solver.config.executable._registered_name.startswith('/path')) + + 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) 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), diff --git a/pyomo/repn/plugins/gams_writer_v2.py b/pyomo/repn/plugins/gams_writer_v2.py new file mode 100644 index 00000000000..42a5ea60485 --- /dev/null +++ b/pyomo/repn/plugins/gams_writer_v2.py @@ -0,0 +1,715 @@ +# ___________________________________________________________________________ +# +# 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, + Suffix, + SymbolMap, + minimize, + ShortNameLabeler, +) +from pyomo.core.base.label import NumericLabeler +from pyomo.opt import WriterFactory +from pyomo.repn.util import ftoa + +from pyomo.repn.linear import LinearRepnVisitor +from pyomo.repn.util import ( + FileDeterminism, + FileDeterminism_to_SortComponents, + OrderedVarRecorder, + categorize_valid_components, + 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( + '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( + 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( + '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 = 'GAMS_MODEL' + ".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 definition 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.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 = {} + 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() + 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 + var_labeler, con_labeler = None, None + warmstart = config.warmstart + + 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 gams 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 + 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: + # WIP: handling unbounded variable + 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): + pass + 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) + 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) + declaration = f'\n{label}.. ' + definition = self.write_expression(ostream, repn) + bounds = f' =E= {(lb - offset)!s};' + con_list[label] = declaration + definition + bounds + else: + # 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) + 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) + 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) + declaration = f'\n{label}.. ' + definition = self.write_expression(ostream, repn) + bounds = f' =L= {(ub - 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 gms 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, True) + bounds = f' =E= {(-repn.constant)!s};\n\n' + con_list[label] = declaration + definition + bounds + + # 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 + # + 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 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{sym};\n\n") + + for _, con in con_list.items(): + ostream.write(con) + + # + # Handling variable bounds + # + for v, (lb, ub) in var_bounds.items(): + 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') + + # 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("\n* START USER ADDITIONAL OPTIONS\n") + if add_options is not None: + for options, val in add_options.items(): + # ostream.write('option ' + line + '\n') + ostream.write(f'option {options}={val};\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') + ) + # 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)) + 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" + ) + + 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, is_objective=False): + if not is_objective: + 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 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 #