diff --git a/mpisppy/tests/test_config.py b/mpisppy/tests/test_config.py index 1ff7de4e9..97c197858 100644 --- a/mpisppy/tests/test_config.py +++ b/mpisppy/tests/test_config.py @@ -445,6 +445,64 @@ def test_rc_fixer_with_reduced_costs_does_not_raise(self): cfg = self._make_rho_cfg(rc_fixer=True, reduced_costs=True) cfg.checker() + def _add_log_dir_keys(self, cfg, hub_only=False, log_dir=None): + cfg.add_to_config("hub_only_solver_logs", description="x", + domain=bool, default=hub_only, argparse=False) + cfg.add_to_config("solver_log_dir", description="x", + domain=str, default=log_dir, argparse=False) + + def test_hub_only_solver_logs_without_log_dir_raises(self): + cfg = self._make_rho_cfg() + self._add_log_dir_keys(cfg, hub_only=True, log_dir=None) + with self.assertRaises(ValueError): + cfg.checker() + + def test_hub_only_solver_logs_with_log_dir_does_not_raise(self): + cfg = self._make_rho_cfg() + self._add_log_dir_keys(cfg, hub_only=True, log_dir="/tmp/logs") + cfg.checker() + + def test_hub_only_solver_logs_default_off_does_not_raise(self): + cfg = self._make_rho_cfg() + self._add_log_dir_keys(cfg, hub_only=False, log_dir=None) + cfg.checker() + + +class TestSharedOptionsLogDir(unittest.TestCase): + """Tests for solver_log_dir propagation through cfg_vanilla.shared_options.""" + + def _make_cfg(self, log_dir=None, hub_only=False): + cfg = Config() + cfg.popular_args() + cfg.solver_name = "gurobi" + cfg.solver_log_dir = log_dir + cfg.hub_only_solver_logs = hub_only + return cfg + + def test_log_dir_propagates_to_hub_and_spoke_by_default(self): + import mpisppy.utils.cfg_vanilla as vanilla + cfg = self._make_cfg(log_dir="/tmp/logs", hub_only=False) + self.assertEqual(vanilla.shared_options(cfg, is_hub=True)["solver_log_dir"], + "/tmp/logs") + self.assertEqual(vanilla.shared_options(cfg, is_hub=False)["solver_log_dir"], + "/tmp/logs") + + def test_hub_only_suppresses_spoke_but_keeps_hub(self): + import mpisppy.utils.cfg_vanilla as vanilla + cfg = self._make_cfg(log_dir="/tmp/logs", hub_only=True) + self.assertEqual(vanilla.shared_options(cfg, is_hub=True)["solver_log_dir"], + "/tmp/logs") + self.assertNotIn("solver_log_dir", + vanilla.shared_options(cfg, is_hub=False)) + + def test_no_log_dir_no_key_anywhere(self): + import mpisppy.utils.cfg_vanilla as vanilla + cfg = self._make_cfg(log_dir=None, hub_only=False) + self.assertNotIn("solver_log_dir", + vanilla.shared_options(cfg, is_hub=True)) + self.assertNotIn("solver_log_dir", + vanilla.shared_options(cfg, is_hub=False)) + class TestConfigFixerArgs(unittest.TestCase): """Tests for Config.fixer_args() and related extension args.""" diff --git a/mpisppy/utils/cfg_vanilla.py b/mpisppy/utils/cfg_vanilla.py index 96738fe9b..f316649e9 100644 --- a/mpisppy/utils/cfg_vanilla.py +++ b/mpisppy/utils/cfg_vanilla.py @@ -50,7 +50,7 @@ def _maybe_attach_jensens(spoke_dict, cfg, spoke_prefix, "scenario_creator_kwargs": scenario_creator_kwargs, } -def shared_options(cfg): +def shared_options(cfg, is_hub=False): shoptions = { "solver_name": cfg.solver_name, "defaultPHrho": cfg.default_rho, @@ -90,7 +90,8 @@ def shared_options(cfg): if _hasit(cfg, "reduced_costs"): shoptions["rc_bound_tol"] = cfg.rc_bound_tol if _hasit(cfg, "solver_log_dir"): - shoptions["solver_log_dir"] = cfg.solver_log_dir + if is_hub or not cfg.get("hub_only_solver_logs", False): + shoptions["solver_log_dir"] = cfg.solver_log_dir if _hasit(cfg, "obbt"): shoptions["presolve_options"] = { "obbt" : cfg.obbt, @@ -154,7 +155,7 @@ def ph_hub( ): from mpisppy.opt.ph import PH from mpisppy.cylinders.hub import PHHub - shoptions = shared_options(cfg) + shoptions = shared_options(cfg, is_hub=True) options = copy.deepcopy(shoptions) options["convthresh"] = cfg.intra_hub_conv_thresh @@ -272,7 +273,7 @@ def subgradient_hub(cfg, ): from mpisppy.opt.subgradient import Subgradient from mpisppy.cylinders.hub import SubgradientHub - shoptions = shared_options(cfg) + shoptions = shared_options(cfg, is_hub=True) options = copy.deepcopy(shoptions) options["convthresh"] = cfg.intra_hub_conv_thresh @@ -316,7 +317,7 @@ def fwph_hub(cfg, ): from mpisppy.opt.fwph import FWPH from mpisppy.cylinders.hub import FWPHHub - shoptions = shared_options(cfg) + shoptions = shared_options(cfg, is_hub=True) options = copy.deepcopy(shoptions) options["convthresh"] = cfg.intra_hub_conv_thresh diff --git a/mpisppy/utils/config.py b/mpisppy/utils/config.py index 94edd933c..1f9a9dfa6 100644 --- a/mpisppy/utils/config.py +++ b/mpisppy/utils/config.py @@ -165,6 +165,9 @@ def _bad_options(msg): if self.get("rc_fixer") and not self.get("reduced_costs"): _bad_options("--rc-fixer requires --reduced-costs") + if self.get("hub_only_solver_logs") and not self.get("solver_log_dir"): + _bad_options("--hub-only-solver-logs requires --solver-log-dir") + def add_solver_specs(self, prefix=""): sstr = f"{prefix}_solver" if prefix else "solver" if prefix: @@ -213,6 +216,12 @@ def popular_args(self): domain=str, default=None) + self.add_to_config("hub_only_solver_logs", + description="When set with --solver-log-dir, only the hub writes " + "solver logs; spokes do not. Requires --solver-log-dir.", + domain=bool, + default=False) + self.add_to_config("warmstart_subproblems", description="Warmstart subproblems from prior solution.", domain=bool,