diff --git a/README.md b/README.md index 55196dbe4..ea3747c00 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ config = TemoaConfig( time_sequencing="seasonal_timeslices", input_database="tutorial_database.sqlite", output_database="tutorial_database.sqlite", - solver_name="appsi_highs", + solver="appsi_highs", output_path=output_path, silent=False, ) @@ -238,7 +238,7 @@ scenario = "tutorial" scenario_mode = "perfect_foresight" input_database = "tutorial_database.sqlite" output_database = "tutorial_database.sqlite" -solver_name = "appsi_highs" +solver = "appsi_highs" ``` ### Configuration Options diff --git a/temoa/_internal/run_actions.py b/temoa/_internal/run_actions.py index dcdc01bd5..d586dfa9a 100644 --- a/temoa/_internal/run_actions.py +++ b/temoa/_internal/run_actions.py @@ -3,12 +3,13 @@ """ import sqlite3 -from collections.abc import Generator, Iterable +from collections.abc import Generator, Iterable, Mapping from contextlib import contextmanager from logging import getLogger from pathlib import Path from sys import version_info from time import perf_counter +from typing import Any from pyomo.environ import ( Constraint, @@ -25,6 +26,7 @@ from temoa._internal.table_writer import TableWriter from temoa.core.config import TemoaConfig from temoa.core.model import TemoaModel +from temoa.core.solver_spec import DEFAULT_SOLVER_OPTIONS from temoa.data_processing.db_to_excel import make_excel logger = getLogger(__name__) @@ -174,6 +176,7 @@ def solve_instance( solver_name: str, silent: bool = False, solver_suffixes: Iterable[str] | None = None, + solver_options: Mapping[str, Any] | None = None, ) -> tuple[TemoaModel, SolverResults]: """ Solve the instance and return a loaded instance @@ -181,6 +184,8 @@ def solve_instance( 'duals' is supported in the Temoa Framework. Some solvers may not support duals. :param silent: Run silently :param solver_name: The name of the solver to request from the SolverFactory + :param solver_options: options to set on the solver (see resolve_solver_options). If None, + the Temoa defaults for the solver are used :param instance: the instance to solve :return: loaded instance """ @@ -202,28 +207,11 @@ def solve_instance( if solver_name == 'neos': raise NotImplementedError('Neos based solve is not currently supported') - # Solver Configuration - if solver_name == 'cbc': - pass - - elif solver_name == 'cplex': - # Note: these parameter values match mip-dev / PyPSA - # (see: https://pypsa-eur.readthedocs.io/en/latest/configuration.html) - optimizer.options['lpmethod'] = 4 # barrier - optimizer.options['solutiontype'] = 2 # non basic solution, ie no crossover - optimizer.options['barrier convergetol'] = 1.0e-3 - optimizer.options['feasopt tolerance'] = 1.0e-4 - - elif solver_name == 'gurobi': - # Note: these parameter values match mip-dev / PyPSA (see: https://pypsa-eur.readthedocs.io/en/latest/configuration.html) - optimizer.options['Method'] = 2 # barrier - optimizer.options['Crossover'] = 0 # non basic solution, ie no crossover - optimizer.options['BarConvTol'] = 1.0e-3 - optimizer.options['FeasibilityTol'] = 1.0e-4 - optimizer.options['BarOrder'] = -1 # auto ordering; 2-4x faster than AMD on large models - - elif solver_name == 'appsi_highs': - pass + # Solver Configuration (defaults live in temoa.core.solver_spec.DEFAULT_SOLVER_OPTIONS) + if solver_options is None: + solver_options = DEFAULT_SOLVER_OPTIONS.get(solver_name, {}) + for option, option_value in solver_options.items(): + optimizer.options[option] = option_value # Suffix Handling solver_suffixes_list: list[str] = [] diff --git a/temoa/_internal/temoa_sequencer.py b/temoa/_internal/temoa_sequencer.py index bd44044e3..d781aad1b 100644 --- a/temoa/_internal/temoa_sequencer.py +++ b/temoa/_internal/temoa_sequencer.py @@ -27,6 +27,7 @@ from temoa.core.config import TemoaConfig from temoa.core.model import TemoaModel from temoa.core.modes import TemoaMode +from temoa.core.solver_spec import resolve_solver_options from temoa.data_io.hybrid_loader import HybridLoader from temoa.extensions.method_of_morris.morris_sequencer import MorrisSequencer from temoa.extensions.modeling_to_generate_alternatives.mga_sequencer import MgaSequencer @@ -254,6 +255,7 @@ def _run_perfect_foresight(self) -> None: self.config.solver_name, silent=self.config.silent, solver_suffixes=suffixes, + solver_options=resolve_solver_options(self.config.solver), ) good_solve, msg = check_solve_status(self.pf_results) if not good_solve: diff --git a/temoa/core/config.py b/temoa/core/config.py index 3e68c9225..173b296b7 100644 --- a/temoa/core/config.py +++ b/temoa/core/config.py @@ -1,10 +1,14 @@ import shutil import sys import tomllib +import warnings +from collections.abc import Mapping from logging import getLogger from pathlib import Path +from typing import Any from temoa.core.modes import TemoaMode +from temoa.core.solver_spec import SolverSpec, redact_solver_options from temoa.extensions.framework import normalize_extension_ids, resolve_extension_specs logger = getLogger(__name__) @@ -42,7 +46,7 @@ def __init__( input_database: Path, output_database: Path, output_path: Path, - solver_name: str, + solver_name: str | None = None, neos: bool = False, save_excel: bool = False, save_duals: bool = False, @@ -73,6 +77,7 @@ def __init__( output_threshold_cost: float | None = None, sqlite: dict[str, object] | None = None, extensions: list[str] | tuple[str, ...] | None = None, + solver: str | Mapping[str, Any] | SolverSpec | None = None, ): if '-' in scenario: raise ValueError( @@ -129,7 +134,20 @@ def __init__( self.neos = neos if self.neos: raise NotImplementedError('Neos is currently not supported.') - self.solver_name = solver_name + + # Validate solver input + if solver_name is not None: + if solver is not None: + raise ValueError("Specify either 'solver' or 'solver_name', not both") + warnings.warn( + "The 'solver_name' argument is deprecated, use 'solver' instead", + DeprecationWarning, + stacklevel=2, + ) + solver = solver_name + if solver is None: + raise SolverNotAvailableError('No solver specified in the configuration.') + self.solver = SolverSpec.parse(solver) self.save_excel = save_excel self.save_duals = save_duals @@ -230,6 +248,17 @@ def __init__( if not self.silent: sys.stderr.write('Warning: ' + msg) + @property + def solver_name(self) -> str: + """The name of the selected solver (shorthand for self.solver.name)""" + return self.solver.name + + @solver_name.setter + def solver_name(self, value: str) -> None: + # retained for backward compatibility. Changing solvers drops any configured options, + # as they are specific to the previous solver + self.solver = SolverSpec.parse(value) + @staticmethod def _check_solver_availability(solver_name: str) -> tuple[bool, str | None]: """ @@ -282,27 +311,41 @@ def build_config(config_file: Path, output_path: Path, silent: bool = False) -> data = tomllib.load(f) if 'solver_name' in data: - is_available, location = TemoaConfig._check_solver_availability(data['solver_name']) - if not is_available: - error_message = ( - f"The specified solver '{data['solver_name']}' was not found.\n" - 'Please ensure the solver is installed and accessible.\n' + if 'solver' in data: + raise ValueError( + "Config specifies both 'solver' and 'solver_name'. Use only 'solver' " + "('solver_name' is deprecated)." ) - if data['solver_name'].lower() in SOLVER_DOC_LINKS: - link = SOLVER_DOC_LINKS[data['solver_name'].lower()] - error_message += f'For installation instructions, refer to: {link}\n' - else: - error_message += ( - "Refer to the solver's official documentation for " - 'installation instructions.' - ) - raise SolverNotAvailableError(error_message) + logger.warning( + "The 'solver_name' config key is deprecated and will be removed in a future " + 'release. Replace it with: solver = "%s"', + data['solver_name'], + ) + data['solver'] = data.pop('solver_name') + + if 'solver' not in data: + raise SolverNotAvailableError('No solver name specified in the configuration.') + solver = SolverSpec.parse(data['solver']) + data['solver'] = solver + + is_available, location = TemoaConfig._check_solver_availability(solver.name) + if not is_available: + error_message = ( + f"The specified solver '{solver.name}' was not found.\n" + 'Please ensure the solver is installed and accessible.\n' + ) + if solver.name.lower() in SOLVER_DOC_LINKS: + link = SOLVER_DOC_LINKS[solver.name.lower()] + error_message += f'For installation instructions, refer to: {link}\n' else: - logger.info('Using solver: %s (%s)', data['solver_name'], location) + error_message += ( + "Refer to the solver's official documentation for installation instructions." + ) + raise SolverNotAvailableError(error_message) else: - raise SolverNotAvailableError('No solver name specified in the configuration.') + logger.info('Using solver: %s (%s)', solver.name, location) - if data.get('solver_name') == 'appsi_highs' and data.get('save_duals', False): + if solver.name == 'appsi_highs' and data.get('save_duals', False): raise ValueError( 'save_duals is not supported with appsi_highs (it does not expose duals via the ' 'APPSI interface). Disable save_duals or choose a different solver.' @@ -354,6 +397,9 @@ def __repr__(self) -> str: msg += spacer msg += '{:>{}s}: {}\n'.format('Selected solver', width, self.solver_name) + msg += '{:>{}s}: {}\n'.format( + 'Solver options', width, redact_solver_options(self.solver.options) + ) msg += '{:>{}s}: {}\n'.format('NEOS status', width, self.neos) msg += spacer diff --git a/temoa/core/solver_spec.py b/temoa/core/solver_spec.py new file mode 100644 index 000000000..800810228 --- /dev/null +++ b/temoa/core/solver_spec.py @@ -0,0 +1,107 @@ +""" +Solver selection and option resolution. + +The config accepts ``solver`` as either a plain solver name or a table with a ``name`` and a +passthrough ``options`` table. Options that reach the solver are layered as: + + DEFAULT_SOLVER_OPTIONS < [solver.options] < extension-specific options (MGA, MC, ...) + +Any solver name known to pyomo's SolverFactory is accepted. Solvers without an entry in +DEFAULT_SOLVER_OPTIONS simply get no Temoa defaults. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +# Note: these parameter values match mip-dev / PyPSA +# (see: https://pypsa-eur.readthedocs.io/en/latest/configuration.html) +DEFAULT_SOLVER_OPTIONS: dict[str, dict[str, Any]] = { + 'cplex': { + 'lpmethod': 4, # barrier + 'solutiontype': 2, # non basic solution, ie no crossover + 'barrier convergetol': 1.0e-3, + 'feasopt tolerance': 1.0e-4, + }, + 'gurobi': { + 'Method': 2, # barrier + 'Crossover': 0, # non basic solution, ie no crossover + 'BarConvTol': 1.0e-3, + 'FeasibilityTol': 1.0e-4, + 'BarOrder': -1, # auto ordering; 2-4x faster than AMD on large models + }, +} + + +@dataclass(frozen=True, slots=True) +class SolverSpec: + """A solver name plus the user-supplied options from the config (defaults excluded).""" + + name: str + options: Mapping[str, Any] = field(default_factory=dict) + + @classmethod + def parse(cls, raw: str | Mapping[str, Any] | SolverSpec) -> SolverSpec: + """ + Build a SolverSpec from a solver name, a {'name': ..., 'options': {...}} mapping, or an + existing SolverSpec + """ + if isinstance(raw, SolverSpec): + return raw + if isinstance(raw, str): + if not raw: + raise ValueError('Solver name must not be empty') + return cls(raw) + if isinstance(raw, Mapping): + unknown = set(raw) - {'name', 'options'} + if unknown: + raise ValueError( + f'Unrecognized key(s) in solver table: {sorted(unknown)}. Expected "name" ' + 'and (optionally) "options". Solver parameters belong under [solver.options]' + ) + name = raw.get('name') + if not isinstance(name, str) or not name: + raise ValueError('The solver table requires a non-empty "name" entry') + options = raw.get('options', {}) + if not isinstance(options, Mapping): + raise ValueError('The "options" entry of the solver table must be a table/dict') + return cls(name, dict(options)) + raise TypeError(f'solver must be a str or a table/dict, got: {type(raw).__name__}') + + +# substrings (lowercase) of option names whose values are credentials, e.g. gurobi's WLSSecret, +# CloudSecretKey, CSAPIAccessID, ServerPassword, LicenseID +_SENSITIVE_OPTION_MARKERS = ('secret', 'password', 'accessid', 'licenseid', 'key', 'token') + + +def redact_solver_options(options: Mapping[str, Any]) -> dict[str, Any]: + """ + Return a copy of the options that is safe to log or print, with credential values masked + """ + return { + option: '***' + if any(marker in option.lower() for marker in _SENSITIVE_OPTION_MARKERS) + else option_value + for option, option_value in options.items() + } + + +def resolve_solver_options( + spec: SolverSpec, + extension_options: Mapping[str, Any] | None = None, + *, + include_defaults: bool = True, +) -> dict[str, Any]: + """ + Merge the option layers that are passed to the solver + :param spec: the solver spec from the config + :param extension_options: options from an extension's own source (MGA/MC/Morris toml, + stochastic config), which take precedence over everything else + :param include_defaults: include DEFAULT_SOLVER_OPTIONS as the bottom layer. Solve paths that + historically ran without Temoa defaults (MGA base solve, stochastic) turn this off. + :return: a new dict of solver options + """ + defaults = DEFAULT_SOLVER_OPTIONS.get(spec.name, {}) if include_defaults else {} + return {**defaults, **spec.options, **(extension_options or {})} diff --git a/temoa/extensions/method_of_morris/morris.py b/temoa/extensions/method_of_morris/morris.py index 4ae1f308f..19dc114e9 100644 --- a/temoa/extensions/method_of_morris/morris.py +++ b/temoa/extensions/method_of_morris/morris.py @@ -16,6 +16,7 @@ from temoa._internal import run_actions from temoa._internal.table_writer import TableWriter from temoa.core.config import TemoaConfig +from temoa.core.solver_spec import resolve_solver_options from temoa.data_io.hybrid_loader import HybridLoader seed = 42 @@ -40,7 +41,11 @@ def evaluate( dp = DataPortal(data_dict={None: data}) instance = run_actions.build_instance(loaded_portal=dp, extensions=config.extensions) - mdl, res = run_actions.solve_instance(instance=instance, solver_name=config.solver_name) + mdl, res = run_actions.solve_instance( + instance=instance, + solver_name=config.solver_name, + solver_options=resolve_solver_options(config.solver), + ) status = run_actions.check_solve_status(res) if not status: raise RuntimeError('Bad solve during Method of Morris') diff --git a/temoa/extensions/method_of_morris/morris_evaluate.py b/temoa/extensions/method_of_morris/morris_evaluate.py index 9695b1406..daa3e8064 100644 --- a/temoa/extensions/method_of_morris/morris_evaluate.py +++ b/temoa/extensions/method_of_morris/morris_evaluate.py @@ -43,6 +43,7 @@ def evaluate( config: TemoaConfig, log_queue: Any, log_level: int, + solver_options: dict[str, Any] | None = None, ) -> list[float]: """ Run model for params provided and return objective value and emission value @@ -54,6 +55,7 @@ def evaluate( :param data: Data used to build the Data Portal :param i: indexing number :param config: The config file to pull run data from + :param solver_options: resolved solver options. If None, the Temoa defaults are used :return: list of objective value and CO2 emission value """ # get the logger configured... @@ -80,7 +82,10 @@ def evaluate( extensions=config.extensions, ) mdl, res = run_actions.solve_instance( - instance=instance, solver_name=config.solver_name, silent=True + instance=instance, + solver_name=config.solver_name, + silent=True, + solver_options=solver_options, ) status = run_actions.check_solve_status(res) if not status: diff --git a/temoa/extensions/method_of_morris/morris_sequencer.py b/temoa/extensions/method_of_morris/morris_sequencer.py index 6c3d1bc76..3a5baf194 100644 --- a/temoa/extensions/method_of_morris/morris_sequencer.py +++ b/temoa/extensions/method_of_morris/morris_sequencer.py @@ -22,6 +22,7 @@ from SALib.util import compute_groups_matrix, read_param_file # type: ignore[import-untyped] from temoa._internal.table_writer import TableWriter +from temoa.core.solver_spec import redact_solver_options, resolve_solver_options from temoa.data_io.hybrid_loader import HybridLoader from temoa.extensions.method_of_morris.morris_evaluate import evaluate @@ -70,11 +71,12 @@ def __init__(self, config: TemoaConfig): with open(path, 'rb') as f: all_options = tomllib.load(f) s_options = all_options.get(self.config.solver_name, {}) - logger.info('Using solver options: %s', s_options) except FileNotFoundError: logger.warning('Unable to find solver options toml file. Using default options.') s_options = {} + self.solver_options = resolve_solver_options(self.config.solver, s_options) + logger.info('Using solver options: %s', redact_solver_options(self.solver_options)) # output handling self.verbose = False # for troubleshooting @@ -189,7 +191,14 @@ def start(self) -> Any: sys.stdout.flush() morris_results = Parallel(n_jobs=self.num_cores)( delayed(evaluate)( - param_names, mm_samples[i, :], data, i, self.config, log_queue, log_level + param_names, + mm_samples[i, :], + data, + i, + self.config, + log_queue, + log_level, + solver_options=self.solver_options, ) for i in range(0, len(mm_samples)) ) diff --git a/temoa/extensions/modeling_to_generate_alternatives/MGA_solver_options.toml b/temoa/extensions/modeling_to_generate_alternatives/MGA_solver_options.toml index 869a53384..2b57d00c6 100644 --- a/temoa/extensions/modeling_to_generate_alternatives/MGA_solver_options.toml +++ b/temoa/extensions/modeling_to_generate_alternatives/MGA_solver_options.toml @@ -1,5 +1,7 @@ # A container for solver options # the top level solver name in brackets should align with the solver name in the config.toml +# These are layered on top of Temoa's solver defaults and the config's [solver.options], and take +# precedence over both. (see temoa/core/solver_spec.py) num_workers = 6 @@ -11,6 +13,7 @@ BarConvTol = 0.01 # Relative Barrier Tolerance primal-dual FeasibilityTol= 1e-2 # pretty loose Crossover= 0 # Disabled TimeLimit= 18000 # 5 hrs +BarOrder = -1 # auto ordering (gurobi default) # regarding BarConvTol: https://www.gurobi.com/documentation/current/refman/barrier_logging.html # note that ref above seems to imply that FeasibilyTol is NOT used when using barrier only...? @@ -19,6 +22,13 @@ TimeLimit= 18000 # 5 hrs # 'LogFile': './my_gurobi_log.log', # 'LPWarmStart': 2, # pass basis +[cplex] +# CPLEX's own defaults, restating them overrides the Temoa cplex defaults for MGA workers +lpmethod = 0 # automatic +solutiontype = 0 # automatic +'barrier convergetol' = 1.0e-8 +'feasopt tolerance' = 1.0e-6 + [cbc] # tbd diff --git a/temoa/extensions/modeling_to_generate_alternatives/mga_sequencer.py b/temoa/extensions/modeling_to_generate_alternatives/mga_sequencer.py index a261ba458..ebdfae220 100644 --- a/temoa/extensions/modeling_to_generate_alternatives/mga_sequencer.py +++ b/temoa/extensions/modeling_to_generate_alternatives/mga_sequencer.py @@ -33,6 +33,7 @@ from temoa._internal.run_actions import build_instance from temoa._internal.table_writer import TableWriter from temoa.components.costs import total_cost_rule +from temoa.core.solver_spec import redact_solver_options, resolve_solver_options from temoa.data_io.hybrid_loader import HybridLoader from temoa.extensions.modeling_to_generate_alternatives.manager_factory import get_manager from temoa.extensions.modeling_to_generate_alternatives.mga_constants import MgaAxis, MgaWeighting @@ -93,16 +94,23 @@ def __init__(self, config: TemoaConfig): with open(path, 'rb') as f: all_options = tomllib.load(f) s_options = all_options.get(self.config.solver_name, {}) - logger.info('Using solver options: %s', s_options) except FileNotFoundError: logger.warning('Unable to find solver options toml file. Using default options.') s_options = {} all_options = {} - # get handle on solver instance + # get handle on solver instance. The base solve only receives the user's + # [solver.options] (no Temoa defaults, no worker options) to get a more precise base cost self.opt = pyo.SolverFactory(self.config.solver_name) - self.worker_solver_options = s_options + for option, option_value in resolve_solver_options( + self.config.solver, include_defaults=False + ).items(): + self.opt.options[option] = option_value + self.worker_solver_options = resolve_solver_options(self.config.solver, s_options) + logger.info( + 'Using worker solver options: %s', redact_solver_options(self.worker_solver_options) + ) # some defaults, etc. self.internal_stop = False diff --git a/temoa/extensions/monte_carlo/MC_solver_options.toml b/temoa/extensions/monte_carlo/MC_solver_options.toml index da91cd56e..7a7471d61 100644 --- a/temoa/extensions/monte_carlo/MC_solver_options.toml +++ b/temoa/extensions/monte_carlo/MC_solver_options.toml @@ -1,5 +1,7 @@ # A container for solver options # the top level solver name in brackets should align with the solver name in the config.toml +# These are layered on top of Temoa's solver defaults and the config's [solver.options], and take +# precedence over both. (see temoa/core/solver_spec.py) num_workers = 11 @@ -11,6 +13,7 @@ BarConvTol = 1.0e-2 # Relative Barrier Tolerance primal-dual FeasibilityTol= 1.0e-2 # pretty loose Crossover= 0 # Disabled TimeLimit= 18000 # 5 hrs +BarOrder = -1 # auto ordering (gurobi default) # regarding BarConvTol: https://www.gurobi.com/documentation/current/refman/barrier_logging.html # note that ref above seems to imply that FeasibilyTol is NOT used when using barrier only...? @@ -19,6 +22,13 @@ TimeLimit= 18000 # 5 hrs # 'LogFile': './my_gurobi_log.log', # 'LPWarmStart': 2, # pass basis +[cplex] +# CPLEX's own defaults, restating them overrides the Temoa cplex defaults for MC workers +lpmethod = 0 # automatic +solutiontype = 0 # automatic +'barrier convergetol' = 1.0e-8 +'feasopt tolerance' = 1.0e-6 + [cbc] primalT = 1e-3 dualT = 1e-3 diff --git a/temoa/extensions/monte_carlo/mc_sequencer.py b/temoa/extensions/monte_carlo/mc_sequencer.py index eb99da89a..c5318e23f 100644 --- a/temoa/extensions/monte_carlo/mc_sequencer.py +++ b/temoa/extensions/monte_carlo/mc_sequencer.py @@ -18,6 +18,7 @@ from typing import TYPE_CHECKING, Any, cast from temoa._internal.table_writer import TableWriter +from temoa.core.solver_spec import redact_solver_options, resolve_solver_options from temoa.data_io.hybrid_loader import HybridLoader from temoa.extensions.monte_carlo.mc_run import MCRun, MCRunFactory from temoa.extensions.monte_carlo.mc_worker import MCWorker @@ -83,7 +84,6 @@ def __init__(self, config: TemoaConfig): with open(path, 'rb') as f: all_options = tomllib.load(f) s_options = all_options.get(self.config.solver_name, {}) - logger.info('Using solver options: %s', s_options) except FileNotFoundError: if options_file_path: @@ -95,7 +95,8 @@ def __init__(self, config: TemoaConfig): # worker options pulled from file self.num_workers = all_options.get('num_workers', 1) - self.worker_solver_options = s_options + self.worker_solver_options = resolve_solver_options(self.config.solver, s_options) + logger.info('Using solver options: %s', redact_solver_options(self.worker_solver_options)) # internal records self.solve_count = 0 diff --git a/temoa/extensions/myopic/myopic_sequencer.py b/temoa/extensions/myopic/myopic_sequencer.py index e32ed347f..8d01c0c04 100644 --- a/temoa/extensions/myopic/myopic_sequencer.py +++ b/temoa/extensions/myopic/myopic_sequencer.py @@ -16,6 +16,7 @@ from temoa._internal.table_writer import TableWriter from temoa.core.config import TemoaConfig from temoa.core.model import TemoaModel +from temoa.core.solver_spec import resolve_solver_options from temoa.data_io.hybrid_loader import HybridLoader from temoa.data_processing.db_to_excel import make_excel from temoa.extensions.myopic.myopic_index import MyopicIndex @@ -268,7 +269,10 @@ def start(self) -> None: if not self.config.silent and self.progress_mapper and idx: self.progress_mapper.report(idx, 'solve') model, results = run_actions.solve_instance( - instance=instance, solver_name=self.config.solver_name, silent=True + instance=instance, + solver_name=self.config.solver_name, + silent=True, + solver_options=resolve_solver_options(self.config.solver), ) optimal, status = run_actions.check_solve_status(results) diff --git a/temoa/extensions/single_vector_mga/sv_mga_sequencer.py b/temoa/extensions/single_vector_mga/sv_mga_sequencer.py index 543af6678..d0266c317 100644 --- a/temoa/extensions/single_vector_mga/sv_mga_sequencer.py +++ b/temoa/extensions/single_vector_mga/sv_mga_sequencer.py @@ -19,6 +19,7 @@ from temoa.components.costs import total_cost_rule from temoa.core.config import TemoaConfig from temoa.core.model import TemoaModel +from temoa.core.solver_spec import resolve_solver_options from temoa.data_io.hybrid_loader import HybridLoader from temoa.extensions.single_vector_mga.output_summary import summarize from temoa.model_checking.pricing_check import price_checker @@ -100,6 +101,7 @@ def start(self) -> None: solver_name=self.config.solver_name, silent=self.config.silent, solver_suffixes=suffixes, + solver_options=resolve_solver_options(self.config.solver), ) status = res.solver.termination_condition logger.debug('Termination condition: %s', status.name) @@ -163,6 +165,7 @@ def start(self) -> None: solver_name=self.config.solver_name, silent=self.config.silent, solver_suffixes=suffixes, + solver_options=resolve_solver_options(self.config.solver), ) status = res.solver.termination_condition logger.debug('Termination condition: %s', status.name) diff --git a/temoa/extensions/stochastics/stochastic_sequencer.py b/temoa/extensions/stochastics/stochastic_sequencer.py index 5c904ce30..195cf691a 100644 --- a/temoa/extensions/stochastics/stochastic_sequencer.py +++ b/temoa/extensions/stochastics/stochastic_sequencer.py @@ -5,6 +5,7 @@ import pyomo.environ as pyo +from temoa.core.solver_spec import resolve_solver_options from temoa.extensions.stochastics.stochastic_config import StochasticConfig if TYPE_CHECKING: @@ -50,8 +51,13 @@ def start(self) -> None: from temoa.extensions.stochastics.scenario_creator import scenario_creator - # Merge solver options from stoch_config - solver_options = self.stoch_config.solver_options.get(self.config.solver_name, {}) + # Merge solver options: [solver.options] < stoch_config. Temoa defaults are excluded to + # preserve the behavior of stochastic runs prior to the [solver] table + solver_options = resolve_solver_options( + self.config.solver, + self.stoch_config.solver_options.get(self.config.solver_name, {}), + include_defaults=False, + ) options = { 'solver': self.config.solver_name, diff --git a/temoa/tutorial_assets/config_sample.toml b/temoa/tutorial_assets/config_sample.toml index f903f330d..b262cf598 100644 --- a/temoa/tutorial_assets/config_sample.toml +++ b/temoa/tutorial_assets/config_sample.toml @@ -75,8 +75,15 @@ neos = false # solver (Mandatory) # Depending on what client machine has installed. -# [appsi_highs, cbc, gurobi, cplex, ...] -solver_name = "appsi_highs" +# [appsi_highs, cbc, gurobi, cplex, ...] (any solver available through pyomo's SolverFactory) +# Either a solver name, which uses Temoa's default options for that solver: +solver = "appsi_highs" +# or a name plus options passed through to the solver, which are merged over Temoa's defaults +# (use an inline table here, as a [solver] table header would capture the keys that follow it): +# solver = { name = "gurobi", options = { Method = 2, Crossover = 0, BarConvTol = 1.0e-3, FeasibilityTol = 1.0e-4, BarOrder = -1 } } +# Keep license credentials (e.g. gurobi WLS keys) in the solver's license file (gurobi.lic), not +# in these options. +# Note: 'solver_name' is deprecated but still accepted in place of 'solver' # ------------------------------------ # OUTPUTS