Skip to content

Commit edaae02

Browse files
committed
Add tests for JSON encoder and optimize_experiments error handling in DoE module
1 parent fe72c91 commit edaae02

File tree

3 files changed

+211
-2
lines changed

3 files changed

+211
-2
lines changed

pyomo/contrib/doe/tests/test_doe_build.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
from pyomo.common.fileutils import this_file_dir
2424
import pyomo.common.unittest as unittest
25-
from pyomo.contrib.doe.doe import ObjectiveLib
25+
from pyomo.contrib.doe.doe import ObjectiveLib, _DoEResultsJSONEncoder
2626

2727
if not (numpy_available and scipy_available):
2828
raise unittest.SkipTest("Pyomo.DoE needs scipy and numpy to run tests")
@@ -881,6 +881,19 @@ def test_maximize_objective_set_contents(self):
881881
self.assertNotIn(ObjectiveLib.condition_number, maximize)
882882
self.assertNotIn(ObjectiveLib.zero, maximize)
883883

884+
def test_doe_results_json_encoder_handles_numpy_and_enum(self):
885+
payload = {
886+
"scalar": np.int64(7),
887+
"array": np.array([1.0, 2.0]),
888+
"objective": ObjectiveLib.trace,
889+
}
890+
encoded = json.dumps(payload, cls=_DoEResultsJSONEncoder)
891+
decoded = json.loads(encoded)
892+
893+
self.assertEqual(decoded["scalar"], 7)
894+
self.assertEqual(decoded["array"], [1.0, 2.0])
895+
self.assertEqual(decoded["objective"], str(ObjectiveLib.trace))
896+
884897
def test_symmetrize_lower_tri_helper(self):
885898
m = np.array([[1.0, 0.0, 0.0], [2.0, 3.0, 0.0], [4.0, 5.0, 6.0]])
886899
got = DesignOfExperiments._symmetrize_lower_tri(m)

pyomo/contrib/doe/tests/test_doe_errors.py

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# Solutions of Sandia, LLC, the U.S. Government retains certain rights in this
77
# software. This software is distributed under the 3-clause BSD License.
88
# ____________________________________________________________________________________
9+
import json
910
import warnings
1011
from pyomo.common.dependencies import (
1112
numpy as np,
@@ -24,7 +25,10 @@
2425

2526
if scipy_available:
2627
from pyomo.contrib.doe import DesignOfExperiments
27-
from pyomo.contrib.doe.doe import InitializationMethod
28+
from pyomo.contrib.doe.doe import (
29+
InitializationMethod,
30+
_DoEResultsJSONEncoder,
31+
)
2832
from pyomo.contrib.doe.tests.experiment_class_example_flags import (
2933
BadExperiment,
3034
RooneyBieglerExperimentFlag,
@@ -109,6 +113,16 @@ def test_experiment_none_error(self):
109113

110114
doe_obj = DesignOfExperiments(**DoE_args)
111115

116+
def test_experiment_list_empty_error(self):
117+
with self.assertRaisesRegex(
118+
ValueError, "The 'experiment_list' cannot be empty"
119+
):
120+
DesignOfExperiments(experiment_list=[], objective_option="pseudo_trace")
121+
122+
def test_doe_results_json_encoder_unsupported_object_raises(self):
123+
with self.assertRaises(TypeError):
124+
json.dumps({"x": object()}, cls=_DoEResultsJSONEncoder)
125+
112126
def test_reactor_check_no_get_labeled_model(self):
113127
fd_method = "central"
114128
obj_used = "pseudo_trace"
@@ -844,6 +858,102 @@ def test_optimize_experiments_invalid_init_n_samples_float(self):
844858
init_n_samples=2.5,
845859
)
846860

861+
def test_optimize_experiments_lhs_requires_template_mode(self):
862+
doe_obj = DesignOfExperiments(
863+
experiment_list=[
864+
RooneyBieglerMultiExperiment(hour=2.0),
865+
RooneyBieglerMultiExperiment(hour=3.0),
866+
],
867+
objective_option="pseudo_trace",
868+
)
869+
with self.assertRaisesRegex(
870+
ValueError,
871+
r"``initialization_method='lhs'`` is currently supported only in template mode",
872+
):
873+
doe_obj.optimize_experiments(initialization_method="lhs")
874+
875+
def test_optimize_experiments_lhs_requires_scipy(self):
876+
doe_obj = DesignOfExperiments(
877+
experiment_list=[RooneyBieglerMultiExperiment(hour=2.0)],
878+
objective_option="pseudo_trace",
879+
)
880+
with patch("pyomo.contrib.doe.doe.scipy_available", False):
881+
with self.assertRaisesRegex(
882+
ImportError, r"LHS initialization requires scipy"
883+
):
884+
doe_obj.optimize_experiments(initialization_method="lhs")
885+
886+
def test_optimize_experiments_init_parallel_requires_bool(self):
887+
doe_obj = DesignOfExperiments(
888+
experiment_list=[RooneyBieglerMultiExperiment(hour=2.0)],
889+
objective_option="pseudo_trace",
890+
)
891+
with self.assertRaisesRegex(
892+
ValueError, r"``init_parallel`` must be a bool, got 1."
893+
):
894+
doe_obj.optimize_experiments(initialization_method="lhs", init_parallel=1)
895+
896+
def test_optimize_experiments_init_combo_parallel_requires_bool(self):
897+
doe_obj = DesignOfExperiments(
898+
experiment_list=[RooneyBieglerMultiExperiment(hour=2.0)],
899+
objective_option="pseudo_trace",
900+
)
901+
with self.assertRaisesRegex(
902+
ValueError, r"``init_combo_parallel`` must be a bool"
903+
):
904+
doe_obj.optimize_experiments(
905+
initialization_method="lhs", init_combo_parallel="yes"
906+
)
907+
908+
def test_optimize_experiments_init_n_workers_must_be_positive_integer(self):
909+
doe_obj = DesignOfExperiments(
910+
experiment_list=[RooneyBieglerMultiExperiment(hour=2.0)],
911+
objective_option="pseudo_trace",
912+
)
913+
with self.assertRaisesRegex(
914+
ValueError, r"``init_n_workers`` must be None or a positive integer"
915+
):
916+
doe_obj.optimize_experiments(initialization_method="lhs", init_n_workers=0)
917+
918+
def test_optimize_experiments_init_combo_chunk_size_must_be_positive_integer(self):
919+
doe_obj = DesignOfExperiments(
920+
experiment_list=[RooneyBieglerMultiExperiment(hour=2.0)],
921+
objective_option="pseudo_trace",
922+
)
923+
with self.assertRaisesRegex(
924+
ValueError,
925+
r"``init_combo_chunk_size`` must be a positive integer",
926+
):
927+
doe_obj.optimize_experiments(
928+
initialization_method="lhs", init_combo_chunk_size=0
929+
)
930+
931+
def test_optimize_experiments_init_combo_parallel_threshold_positive_integer(self):
932+
doe_obj = DesignOfExperiments(
933+
experiment_list=[RooneyBieglerMultiExperiment(hour=2.0)],
934+
objective_option="pseudo_trace",
935+
)
936+
with self.assertRaisesRegex(
937+
ValueError,
938+
r"``init_combo_parallel_threshold`` must be a positive integer",
939+
):
940+
doe_obj.optimize_experiments(
941+
initialization_method="lhs", init_combo_parallel_threshold=0
942+
)
943+
944+
def test_optimize_experiments_init_max_wall_clock_time_must_be_positive(self):
945+
doe_obj = DesignOfExperiments(
946+
experiment_list=[RooneyBieglerMultiExperiment(hour=2.0)],
947+
objective_option="pseudo_trace",
948+
)
949+
with self.assertRaisesRegex(
950+
ValueError,
951+
r"``init_max_wall_clock_time`` must be None or a positive number",
952+
):
953+
doe_obj.optimize_experiments(
954+
initialization_method="lhs", init_max_wall_clock_time=0
955+
)
956+
847957
def test_optimize_experiments_n_exp_with_multi_list(self):
848958
doe_obj = DesignOfExperiments(
849959
experiment_list=[

pyomo/contrib/doe/tests/test_doe_solve.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1462,6 +1462,92 @@ def test_optimize_experiments_trace_expected_values(self):
14621462
self.assertStructuredAlmostEqual(got_hours, expected_hours, abstol=1e-3)
14631463
self.assertAlmostEqual(scenario["log10 A-opt"], -2.2347, places=3)
14641464

1465+
def test_optimize_experiments_termination_message_bytes(self):
1466+
doe = self._make_template_doe("pseudo_trace")
1467+
original_solve = doe.solver.solve
1468+
1469+
def _solve_with_bytes_message(*args, **kwargs):
1470+
res = original_solve(*args, **kwargs)
1471+
res.solver.message = b"byte-message"
1472+
return res
1473+
1474+
with patch.object(doe.solver, "solve", side_effect=_solve_with_bytes_message):
1475+
doe.optimize_experiments(n_exp=1)
1476+
1477+
self.assertEqual(doe.results["Termination Message"], "byte-message")
1478+
1479+
def test_optimize_experiments_termination_message_fallback_to_str(self):
1480+
doe = self._make_template_doe("pseudo_trace")
1481+
original_solve = doe.solver.solve
1482+
1483+
class _CustomMessage:
1484+
def __str__(self):
1485+
return "custom-message"
1486+
1487+
def _solve_with_custom_message(*args, **kwargs):
1488+
res = original_solve(*args, **kwargs)
1489+
res.solver.message = _CustomMessage()
1490+
return res
1491+
1492+
with patch.object(doe.solver, "solve", side_effect=_solve_with_custom_message):
1493+
doe.optimize_experiments(n_exp=1)
1494+
1495+
self.assertEqual(doe.results["Termination Message"], "custom-message")
1496+
1497+
def test_optimize_experiments_safe_metric_failure_sets_nan(self):
1498+
doe = self._make_template_doe("pseudo_trace")
1499+
with patch("pyomo.contrib.doe.doe.np.linalg.inv", side_effect=RuntimeError("boom")):
1500+
with self.assertLogs("pyomo.contrib.doe.doe", level="WARNING") as log_cm:
1501+
doe.optimize_experiments(n_exp=1)
1502+
1503+
scenario = doe.results["Scenarios"][0]
1504+
self.assertTrue(np.isnan(scenario["log10 A-opt"]))
1505+
self.assertTrue(
1506+
any("failed to compute log10 A-opt" in msg for msg in log_cm.output)
1507+
)
1508+
1509+
def test_optimize_experiments_non_cholesky_determinant_initialization(self):
1510+
exp = RooneyBieglerMultiExperiment(hour=2.0, y=10.0)
1511+
solver = SolverFactory("ipopt")
1512+
solver.options["linear_solver"] = "ma57"
1513+
solver.options["halt_on_ampl_error"] = "yes"
1514+
solver.options["max_iter"] = 3000
1515+
doe = DesignOfExperiments(
1516+
experiment_list=[exp],
1517+
objective_option="determinant",
1518+
step=1e-2,
1519+
solver=solver,
1520+
_Cholesky_option=False,
1521+
_only_compute_fim_lower=False,
1522+
)
1523+
original_solve = doe.solver.solve
1524+
solve_count = {"n": 0}
1525+
1526+
class _MockSolverInfo:
1527+
status = "ok"
1528+
termination_condition = "optimal"
1529+
message = "mock-solve"
1530+
1531+
class _MockResults:
1532+
solver = _MockSolverInfo()
1533+
1534+
def _solve_first_real_then_mock(*args, **kwargs):
1535+
solve_count["n"] += 1
1536+
if solve_count["n"] == 1:
1537+
return original_solve(*args, **kwargs)
1538+
return _MockResults()
1539+
1540+
with patch.object(doe.solver, "solve", side_effect=_solve_first_real_then_mock):
1541+
doe.optimize_experiments(n_exp=1)
1542+
1543+
scenario_block = doe.model.param_scenario_blocks[0]
1544+
self.assertTrue(hasattr(scenario_block.obj_cons, "determinant"))
1545+
total_fim = np.array(doe.results["Scenarios"][0]["Total FIM"])
1546+
expected_det = np.linalg.det(total_fim)
1547+
self.assertAlmostEqual(
1548+
pyo.value(scenario_block.obj_cons.determinant), expected_det, places=6
1549+
)
1550+
14651551

14661552
if __name__ == "__main__":
14671553
unittest.main()

0 commit comments

Comments
 (0)