From 2a3b5731b6b73f8c1ba3b7831c1cdfc9c0a988f2 Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Wed, 14 Feb 2024 12:34:58 -0700 Subject: [PATCH] Unit tests for config, results, and util complete --- pyomo/contrib/solver/config.py | 6 +- pyomo/contrib/solver/results.py | 103 ++++++++++++------ pyomo/contrib/solver/sol_reader.py | 8 +- pyomo/contrib/solver/solution.py | 6 +- .../contrib/solver/tests/unit/test_config.py | 61 ++++++++++- .../contrib/solver/tests/unit/test_results.py | 6 +- .../solver/tests/unit/test_solution.py | 18 ++- pyomo/contrib/solver/tests/unit/test_util.py | 32 +++++- 8 files changed, 187 insertions(+), 53 deletions(-) diff --git a/pyomo/contrib/solver/config.py b/pyomo/contrib/solver/config.py index d5921c526b0..2a1a129d1ac 100644 --- a/pyomo/contrib/solver/config.py +++ b/pyomo/contrib/solver/config.py @@ -63,7 +63,8 @@ def __init__( ConfigValue( domain=str, default=None, - description="The directory in which generated files should be saved. This replaced the `keepfiles` option.", + description="The directory in which generated files should be saved. " + "This replaced the `keepfiles` option.", ), ) self.load_solutions: bool = self.declare( @@ -79,7 +80,8 @@ def __init__( ConfigValue( domain=bool, default=True, - description="If False, the `solve` method will continue processing even if the returned result is nonoptimal.", + description="If False, the `solve` method will continue processing " + "even if the returned result is nonoptimal.", ), ) self.symbolic_solver_labels: bool = self.declare( diff --git a/pyomo/contrib/solver/results.py b/pyomo/contrib/solver/results.py index 1fa9d653d01..5ed6de44430 100644 --- a/pyomo/contrib/solver/results.py +++ b/pyomo/contrib/solver/results.py @@ -22,18 +22,12 @@ NonNegativeFloat, ADVANCED_OPTION, ) -from pyomo.common.errors import PyomoException from pyomo.opt.results.solution import SolutionStatus as LegacySolutionStatus from pyomo.opt.results.solver import ( TerminationCondition as LegacyTerminationCondition, SolverStatus as LegacySolverStatus, ) - - -class SolverResultsError(PyomoException): - """ - General exception to catch solver system errors - """ +from pyomo.common.timing import HierarchicalTimer class TerminationCondition(enum.Enum): @@ -167,12 +161,16 @@ class Results(ConfigDict): iteration_count: int The total number of iterations. timing_info: ConfigDict - A ConfigDict containing three pieces of information: - start_time: UTC timestamp of when run was initiated + A ConfigDict containing two pieces of information: + start_timestamp: UTC timestamp of when run was initiated wall_time: elapsed wall clock time for entire process - solver_wall_time: elapsed wall clock time for solve call + timer: a HierarchicalTimer object containing timing data about the solve extra_info: ConfigDict A ConfigDict to store extra information such as solver messages. + solver_configuration: ConfigDict + A copy of the SolverConfig ConfigDict, for later inspection/reproducibility. + solver_log: str + (ADVANCED OPTION) Any solver log messages. """ def __init__( @@ -191,41 +189,85 @@ def __init__( visibility=visibility, ) - self.solution_loader = self.declare('solution_loader', ConfigValue()) + self.solution_loader = self.declare( + 'solution_loader', + ConfigValue( + description="Object for loading the solution back into the model." + ), + ) self.termination_condition: TerminationCondition = self.declare( 'termination_condition', ConfigValue( - domain=In(TerminationCondition), default=TerminationCondition.unknown + domain=In(TerminationCondition), + default=TerminationCondition.unknown, + description="The reason the solver exited. This is a member of the " + "TerminationCondition enum.", ), ) self.solution_status: SolutionStatus = self.declare( 'solution_status', - ConfigValue(domain=In(SolutionStatus), default=SolutionStatus.noSolution), + ConfigValue( + domain=In(SolutionStatus), + default=SolutionStatus.noSolution, + description="The result of the solve call. This is a member of " + "the SolutionStatus enum.", + ), ) self.incumbent_objective: Optional[float] = self.declare( - 'incumbent_objective', ConfigValue(domain=float, default=None) + 'incumbent_objective', + ConfigValue( + domain=float, + default=None, + description="If a feasible solution was found, this is the objective " + "value of the best solution found. If no feasible solution was found, this is None.", + ), ) self.objective_bound: Optional[float] = self.declare( - 'objective_bound', ConfigValue(domain=float, default=None) + 'objective_bound', + ConfigValue( + domain=float, + default=None, + description="The best objective bound found. For minimization problems, " + "this is the lower bound. For maximization problems, this is the " + "upper bound. For solvers that do not provide an objective bound, " + "this should be -inf (minimization) or inf (maximization)", + ), ) self.solver_name: Optional[str] = self.declare( - 'solver_name', ConfigValue(domain=str) + 'solver_name', + ConfigValue(domain=str, description="The name of the solver in use."), ) self.solver_version: Optional[Tuple[int, ...]] = self.declare( - 'solver_version', ConfigValue(domain=tuple) + 'solver_version', + ConfigValue( + domain=tuple, + description="A tuple representing the version of the solver in use.", + ), ) self.iteration_count: Optional[int] = self.declare( - 'iteration_count', ConfigValue(domain=NonNegativeInt, default=None) + 'iteration_count', + ConfigValue( + domain=NonNegativeInt, + default=None, + description="The total number of iterations.", + ), ) self.timing_info: ConfigDict = self.declare( 'timing_info', ConfigDict(implicit=True) ) self.timing_info.start_timestamp: datetime = self.timing_info.declare( - 'start_timestamp', ConfigValue(domain=Datetime) + 'start_timestamp', + ConfigValue( + domain=Datetime, description="UTC timestamp of when run was initiated." + ), ) self.timing_info.wall_time: Optional[float] = self.timing_info.declare( - 'wall_time', ConfigValue(domain=NonNegativeFloat) + 'wall_time', + ConfigValue( + domain=NonNegativeFloat, + description="Elapsed wall clock time for entire process.", + ), ) self.extra_info: ConfigDict = self.declare( 'extra_info', ConfigDict(implicit=True) @@ -233,13 +275,18 @@ def __init__( self.solver_configuration: ConfigDict = self.declare( 'solver_configuration', ConfigValue( - description="A copy of the config object used in the solve", + description="A copy of the config object used in the solve call.", visibility=ADVANCED_OPTION, ), ) self.solver_log: str = self.declare( 'solver_log', - ConfigValue(domain=str, default=None, visibility=ADVANCED_OPTION), + ConfigValue( + domain=str, + default=None, + visibility=ADVANCED_OPTION, + description="Any solver log messages.", + ), ) def display( @@ -248,18 +295,6 @@ def display( return super().display(content_filter, indent_spacing, ostream, visibility) -class ResultsReader: - pass - - -def parse_yaml(): - pass - - -def parse_json(): - pass - - # Everything below here preserves backwards compatibility legacy_termination_condition_map = { diff --git a/pyomo/contrib/solver/sol_reader.py b/pyomo/contrib/solver/sol_reader.py index 68654a4e9d7..3af30e1826b 100644 --- a/pyomo/contrib/solver/sol_reader.py +++ b/pyomo/contrib/solver/sol_reader.py @@ -15,7 +15,7 @@ from pyomo.common.errors import DeveloperError from pyomo.repn.plugins.nl_writer import NLWriterInfo -from .results import Results, SolverResultsError, SolutionStatus, TerminationCondition +from .results import Results, SolutionStatus, TerminationCondition class SolFileData: @@ -69,7 +69,7 @@ def parse_sol_file( line = sol_file.readline() model_objects.append(int(line)) else: - raise SolverResultsError("ERROR READING `sol` FILE. No 'Options' line found.") + raise Exception("ERROR READING `sol` FILE. No 'Options' line found.") # Identify the total number of variables and constraints number_of_cons = model_objects[number_of_options + 1] number_of_vars = model_objects[number_of_options + 3] @@ -85,12 +85,12 @@ def parse_sol_file( if line and ('objno' in line): exit_code_line = line.split() if len(exit_code_line) != 3: - raise SolverResultsError( + raise Exception( f"ERROR READING `sol` FILE. Expected two numbers in `objno` line; received {line}." ) exit_code = [int(exit_code_line[1]), int(exit_code_line[2])] else: - raise SolverResultsError( + raise Exception( f"ERROR READING `sol` FILE. Expected `objno`; received {line}." ) result.extra_info.solver_message = message.strip().replace('\n', '; ') diff --git a/pyomo/contrib/solver/solution.py b/pyomo/contrib/solver/solution.py index 33a3b1c939c..beb53cf979a 100644 --- a/pyomo/contrib/solver/solution.py +++ b/pyomo/contrib/solver/solution.py @@ -23,6 +23,11 @@ class SolutionLoaderBase(abc.ABC): + """ + Base class for all future SolutionLoader classes. + + Intent of this class and its children is to load the solution back into the model. + """ def load_vars( self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None ) -> NoReturn: @@ -58,7 +63,6 @@ def get_primals( primals: ComponentMap Maps variables to solution values """ - pass def get_duals( self, cons_to_load: Optional[Sequence[_GeneralConstraintData]] = None diff --git a/pyomo/contrib/solver/tests/unit/test_config.py b/pyomo/contrib/solver/tests/unit/test_config.py index 4a7cc250623..f28dd5fcedf 100644 --- a/pyomo/contrib/solver/tests/unit/test_config.py +++ b/pyomo/contrib/solver/tests/unit/test_config.py @@ -10,7 +10,12 @@ # ___________________________________________________________________________ from pyomo.common import unittest -from pyomo.contrib.solver.config import SolverConfig, BranchAndBoundConfig +from pyomo.contrib.solver.config import ( + SolverConfig, + BranchAndBoundConfig, + AutoUpdateConfig, + PersistentSolverConfig, +) class TestSolverConfig(unittest.TestCase): @@ -59,3 +64,57 @@ def test_interface_custom_instantiation(self): self.assertIsInstance(config.time_limit, float) config.rel_gap = 2.5 self.assertEqual(config.rel_gap, 2.5) + + +class TestAutoUpdateConfig(unittest.TestCase): + def test_interface_default_instantiation(self): + config = AutoUpdateConfig() + self.assertTrue(config.check_for_new_or_removed_constraints) + self.assertTrue(config.check_for_new_or_removed_vars) + self.assertTrue(config.check_for_new_or_removed_params) + self.assertTrue(config.check_for_new_objective) + self.assertTrue(config.update_constraints) + self.assertTrue(config.update_vars) + self.assertTrue(config.update_named_expressions) + self.assertTrue(config.update_objective) + self.assertTrue(config.update_objective) + self.assertTrue(config.treat_fixed_vars_as_params) + + def test_interface_custom_instantiation(self): + config = AutoUpdateConfig(description="A description") + config.check_for_new_objective = False + self.assertEqual(config._description, "A description") + self.assertTrue(config.check_for_new_or_removed_constraints) + self.assertFalse(config.check_for_new_objective) + + +class TestPersistentSolverConfig(unittest.TestCase): + def test_interface_default_instantiation(self): + config = PersistentSolverConfig() + 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) + self.assertTrue(config.auto_updates.check_for_new_or_removed_constraints) + self.assertTrue(config.auto_updates.check_for_new_or_removed_vars) + self.assertTrue(config.auto_updates.check_for_new_or_removed_params) + self.assertTrue(config.auto_updates.check_for_new_objective) + self.assertTrue(config.auto_updates.update_constraints) + self.assertTrue(config.auto_updates.update_vars) + self.assertTrue(config.auto_updates.update_named_expressions) + self.assertTrue(config.auto_updates.update_objective) + self.assertTrue(config.auto_updates.update_objective) + self.assertTrue(config.auto_updates.treat_fixed_vars_as_params) + + def test_interface_custom_instantiation(self): + config = PersistentSolverConfig(description="A description") + config.tee = True + config.auto_updates.check_for_new_objective = False + self.assertTrue(config.tee) + self.assertEqual(config._description, "A description") + self.assertFalse(config.auto_updates.check_for_new_objective) diff --git a/pyomo/contrib/solver/tests/unit/test_results.py b/pyomo/contrib/solver/tests/unit/test_results.py index 23c2c32f819..caef82129ec 100644 --- a/pyomo/contrib/solver/tests/unit/test_results.py +++ b/pyomo/contrib/solver/tests/unit/test_results.py @@ -69,7 +69,7 @@ def test_codes(self): class TestResults(unittest.TestCase): - def test_declared_items(self): + def test_member_list(self): res = results.Results() expected_declared = { 'extra_info', @@ -88,7 +88,7 @@ def test_declared_items(self): actual_declared = res._declared self.assertEqual(expected_declared, actual_declared) - def test_uninitialized(self): + def test_default_initialization(self): res = results.Results() self.assertIsNone(res.incumbent_objective) self.assertIsNone(res.objective_bound) @@ -118,7 +118,7 @@ def test_uninitialized(self): ): res.solution_loader.get_reduced_costs() - def test_results(self): + def test_generated_results(self): m = pyo.ConcreteModel() m.x = ScalarVar() m.y = ScalarVar() diff --git a/pyomo/contrib/solver/tests/unit/test_solution.py b/pyomo/contrib/solver/tests/unit/test_solution.py index 1ecba45b32a..67ce2556317 100644 --- a/pyomo/contrib/solver/tests/unit/test_solution.py +++ b/pyomo/contrib/solver/tests/unit/test_solution.py @@ -10,22 +10,30 @@ # ___________________________________________________________________________ from pyomo.common import unittest -from pyomo.contrib.solver import solution +from pyomo.contrib.solver.solution import SolutionLoaderBase, PersistentSolutionLoader -class TestPersistentSolverBase(unittest.TestCase): +class TestSolutionLoaderBase(unittest.TestCase): def test_abstract_member_list(self): expected_list = ['get_primals'] - member_list = list(solution.SolutionLoaderBase.__abstractmethods__) + member_list = list(SolutionLoaderBase.__abstractmethods__) self.assertEqual(sorted(expected_list), sorted(member_list)) @unittest.mock.patch.multiple( - solution.SolutionLoaderBase, __abstractmethods__=set() + SolutionLoaderBase, __abstractmethods__=set() ) def test_solution_loader_base(self): - self.instance = solution.SolutionLoaderBase() + self.instance = SolutionLoaderBase() self.assertEqual(self.instance.get_primals(), None) with self.assertRaises(NotImplementedError): self.instance.get_duals() with self.assertRaises(NotImplementedError): self.instance.get_reduced_costs() + + +class TestPersistentSolutionLoader(unittest.TestCase): + def test_abstract_member_list(self): + # We expect no abstract members at this point because it's a real-life + # instantiation of SolutionLoaderBase + member_list = list(PersistentSolutionLoader('ipopt').__abstractmethods__) + self.assertEqual(member_list, []) diff --git a/pyomo/contrib/solver/tests/unit/test_util.py b/pyomo/contrib/solver/tests/unit/test_util.py index 8a8a0221362..ab8a778067f 100644 --- a/pyomo/contrib/solver/tests/unit/test_util.py +++ b/pyomo/contrib/solver/tests/unit/test_util.py @@ -102,15 +102,41 @@ def test_check_optimal_termination_condition_legacy_interface(self): results = SolverResults() results.solver.status = SolverStatus.ok results.solver.termination_condition = LegacyTerminationCondition.optimal + # Both items satisfied self.assertTrue(check_optimal_termination(results)) + # Termination condition not satisfied results.solver.termination_condition = LegacyTerminationCondition.unknown self.assertFalse(check_optimal_termination(results)) + # Both not satisfied results.solver.termination_condition = SolverStatus.aborted self.assertFalse(check_optimal_termination(results)) - # TODO: Left off here; need to make these tests def test_assert_optimal_termination_new_interface(self): - pass + results = Results() + results.solution_status = SolutionStatus.optimal + results.termination_condition = ( + TerminationCondition.convergenceCriteriaSatisfied + ) + assert_optimal_termination(results) + # Termination condition not satisfied + results.termination_condition = TerminationCondition.iterationLimit + with self.assertRaises(RuntimeError): + assert_optimal_termination(results) + # Both not satisfied + results.solution_status = SolutionStatus.noSolution + with self.assertRaises(RuntimeError): + assert_optimal_termination(results) def test_assert_optimal_termination_legacy_interface(self): - pass + results = SolverResults() + results.solver.status = SolverStatus.ok + results.solver.termination_condition = LegacyTerminationCondition.optimal + assert_optimal_termination(results) + # Termination condition not satisfied + results.solver.termination_condition = LegacyTerminationCondition.unknown + with self.assertRaises(RuntimeError): + assert_optimal_termination(results) + # Both not satisfied + results.solver.termination_condition = SolverStatus.aborted + with self.assertRaises(RuntimeError): + assert_optimal_termination(results)