diff --git a/cpmpy/solvers/utils.py b/cpmpy/solvers/utils.py index e4ea011f2..8c6158234 100644 --- a/cpmpy/solvers/utils.py +++ b/cpmpy/solvers/utils.py @@ -160,7 +160,7 @@ def lookup(cls, name=None): if basename == solvername: # found the right solver return CPM_slv - raise ValueError(f"Unknown solver '{name}', chose from {cls.solvernames()}") + raise ValueError(f"Unknown solver '{name}', choose from {cls.solvernames()}") # using `builtin_solvers` is DEPRECATED, use `SolverLookup` object instead diff --git a/examples/advanced/counterfactual_explain.py b/examples/advanced/counterfactual_explain.py index 898c5c873..8aa9609a5 100644 --- a/examples/advanced/counterfactual_explain.py +++ b/examples/advanced/counterfactual_explain.py @@ -210,7 +210,11 @@ def inverse_optimize(values, weights, capacity, x_d, foil_idx): if sum(d_star * x_d) >= sum(d_star * x_0.value()): return d_star else: - master_model += [sum(d * x_d) >= sum(d * x_0.value())] + # Convert boolean arrays to integer arrays + x_d_int = x_d.astype(int) + x_0_val_int = x_0.value().astype(int) + # Add constraint using integer coefficients + master_model += [sum(d * x_d_int) >= sum(d * x_0_val_int)] i += 1 raise ValueError("Master model is UNSAT!") diff --git a/examples/advanced/ocus_explanations.py b/examples/advanced/ocus_explanations.py index 67b393dcb..633dcaaad 100644 --- a/examples/advanced/ocus_explanations.py +++ b/examples/advanced/ocus_explanations.py @@ -55,10 +55,9 @@ def explain_ocus(soft, soft_weights=None, hard=[], solver="ortools", verbose=0) # compute all derivable literals full_sol = solution_intersection(Model(hard + soft), solver, verbose) - # prep soft constraint formulation with a literal for each soft constraint # (so we can iteratively use an assumption solver on softliterals) - soft_lit = BoolVar(shape=len(soft), name="ind") + soft_lit = boolvar(shape=len(soft), name="ind") reified_soft = [] for i,bv in enumerate(soft_lit): reified_soft += [bv.implies(soft[i])] @@ -196,7 +195,7 @@ def explain_one_step_ocus(hard, soft_lit, cost, remaining_sol_to_explain, solver ## ----- SAT solver model ---- SAT = SolverLookup.lookup(solver)(Model(hard)) - while(True): + while True: hittingset_solver.solve() # Get hitting set @@ -209,7 +208,7 @@ def explain_one_step_ocus(hard, soft_lit, cost, remaining_sol_to_explain, solver print("\n\t hs =", hs, S) # SAT check and computation of model - if not SAT.solve(assumptions=S): + if not SAT.solve(assumptions=list(S)): if verbose > 1: print("\n\t ===> OCUS =", S) @@ -242,7 +241,7 @@ def solution_intersection(model, solver="ortools", verbose=False): assert SAT.solve(), "Propagation of soft constraints only possible if model is SAT." sat_model = set(bv if bv.value() else ~bv for bv in sat_vars) - while(SAT.solve()): + while SAT.solve(): # negate the values of the model sat_model &= set(bv if bv.value() else ~bv for bv in sat_vars) blocking_clause = ~all(sat_model) @@ -265,11 +264,10 @@ def cost_func(soft, soft_weights): ''' def cost_lit(cons): - # return soft weight if constraint is a soft constraint - if len(set({cons}) & set(soft)) > 0: + # return soft weight if the constraint is a soft constraint + if cons in set(soft): return soft_weights[soft.index(cons)] - else: - return 1 + return 1 return cost_lit diff --git a/examples/csplib/prob001_convert_data.py b/examples/csplib/prob001_convert_data.py index 130f72bf7..5b02a81f5 100644 --- a/examples/csplib/prob001_convert_data.py +++ b/examples/csplib/prob001_convert_data.py @@ -3,6 +3,8 @@ See `prob001_car_sequence.py` for the actual model that uses the JSON data file """ import json +import os + import numpy as np import re import sys @@ -139,11 +141,15 @@ def parse_data(fname): if len(sys.argv) > 2: out = sys.argv[2] - problems = list(parse_data(fname)) - + # if fname file does not exist, end with a warning + if not os.path.exists(fname): + print(f"File {fname} does not exist. No data to convert.") + else: + problems = list(parse_data(fname)) + # outstring = pprint.pformat(problems, indent=4, sort_dicts=False) + # outstring = outstring.replace("'",'"') - # outstring = pprint.pformat(problems, indent=4, sort_dicts=False) - # outstring = outstring.replace("'",'"') + with open(out, "w") as outfile: + json.dump(problems, outfile, cls=CompactJSONEncoder, indent=4) - with open(out, "w") as outfile: - json.dump(problems, outfile, cls=CompactJSONEncoder, indent=4) + print(f"Converted {len(problems)} problems to {out}") diff --git a/examples/csplib/prob007_all_interval.py b/examples/csplib/prob007_all_interval.py index 04a8062e9..1a23b7cd5 100644 --- a/examples/csplib/prob007_all_interval.py +++ b/examples/csplib/prob007_all_interval.py @@ -60,14 +60,14 @@ def print_solution(x, diffs): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("-length", type=int,help="Length of array, 12 by default", default=12) - parser.add_argument("--solution_limit", type=int, help="Number of solutions to find, find all by default", default=0) + parser.add_argument("--solution_limit", type=int, help="Number of solutions to find, find all by default", default=10) args = parser.parse_args() model, (x, diffs) = all_interval(args.length) found_n = model.solveAll(solution_limit=args.solution_limit, display=lambda: print_solution(x, diffs)) - if found_n == 0: - print(f"Fund {found_n} solutions") + if found_n > 0: + print(f"Found {found_n} solutions") else: raise ValueError("Problem is unsatisfiable") \ No newline at end of file diff --git a/examples/csplib/prob011_basketball_schedule.py b/examples/csplib/prob011_basketball_schedule.py index 9ba2f29bd..62ce2c131 100644 --- a/examples/csplib/prob011_basketball_schedule.py +++ b/examples/csplib/prob011_basketball_schedule.py @@ -123,13 +123,12 @@ def basketball_schedule(): for d in days[:-3]: if t not in [UNC, DUKE, WAKE]: # No team plays in three consecutive dates against UNC, Duke and Wake (independent of home/away). - model += ~((config[d,t] == UNC) & (config[d+1,t] == DUKE) & (config[d+2] == WAKE)) - model += ~((config[d,t] == UNC) & (config[d+1,t] == WAKE) & (config[d+2] == DUKE)) - model += ~((config[d,t] == DUKE) & (config[d+1,t] == UNC) & (config[d+2] == WAKE)) - model += ~((config[d,t] == DUKE) & (config[d+1,t] == WAKE) & (config[d+2] == UNC)) - model += ~((config[d,t] == WAKE) & (config[d+1,t] == UNC) & (config[d+2] == DUKE)) - model += ~((config[d,t] == WAKE) & (config[d+1,t] == DUKE) & (config[d+2] == UNC)) - + model += ~((config[d,t] == UNC) & (config[d+1,t] == DUKE) & (config[d+2,t] == WAKE)) + model += ~((config[d,t] == UNC) & (config[d+1,t] == WAKE) & (config[d+2,t] == DUKE)) + model += ~((config[d,t] == DUKE) & (config[d+1,t] == UNC) & (config[d+2,t] == WAKE)) + model += ~((config[d,t] == DUKE) & (config[d+1,t] == WAKE) & (config[d+2,t] == UNC)) + model += ~((config[d,t] == WAKE) & (config[d+1,t] == UNC) & (config[d+2,t] == DUKE)) + model += ~((config[d,t] == WAKE) & (config[d+1,t] == DUKE) & (config[d+2,t] == UNC)) # 9. Other constraints # UNC plays its rival Duke in the last date and in date 11 diff --git a/examples/csplib/prob013_progressive_party.py b/examples/csplib/prob013_progressive_party.py index 48ae51acb..a33f902e2 100644 --- a/examples/csplib/prob013_progressive_party.py +++ b/examples/csplib/prob013_progressive_party.py @@ -23,39 +23,37 @@ def progressive_party(n_boats, n_periods, capacity, crew_size, **kwargs): - is_host = boolvar(shape=n_boats, name="is_host") - visits = intvar(lb=0, ub=n_boats-1, shape=(n_periods,n_boats), name="visits") + visits = intvar(0, n_boats - 1, shape=(n_periods, n_boats), name="visits") model = Model() - # crews of host boats stay on boat + # Crews of host boats stay on boat for boat in range(n_boats): - model += (is_host[boat]).implies(all(visits[:,boat] == boat)) + model += (is_host[boat]).implies(all(visits[:, boat] == boat)) - # number of visitors can never exceed capacity of boat + # The total number of people aboard a boat, including the host crew and guest crews, must not exceed the capacity. for slot in range(n_periods): for boat in range(n_boats): - model += sum((visits[slot] == boat) * crew_size) <= capacity[boat] + # Sum of crew sizes of visiting boats + crew size of host boat + model += sum((visits[slot] == boat) * crew_size) + crew_size[boat] * is_host[boat] <= capacity[boat] - # guests cannot visit a boat twice + # Guests cannot visit a boat twice for boat in range(n_boats): - # Alldiff must be decomposed in v0.9.8, see issue #105 on github - model += (~is_host[boat]).implies(all((AllDifferent(visits[:,boat]).decompose()))) + model += (~is_host[boat]).implies(AllDifferent(visits[:, boat])) - # non-host boats cannot be visited + # Non-host boats cannot be visited for boat in range(n_boats): model += (~is_host[boat]).implies(all(visits != boat)) - # crews cannot meet more than once + # Crews cannot meet more than once for c1, c2 in all_pairs(range(n_boats)): - model += sum(visits[:,c1] == visits[:,c2]) <= 1 + model += sum(visits[:, c1] == visits[:, c2]) <= 1 - # minimize number of hosts needed + # Minimize number of hosts needed model.minimize(sum(is_host)) - return model, (visits,is_host) - + return model, (visits, is_host) # Helper functions @@ -79,7 +77,7 @@ def _print_instances(data): # argument parsing url = "https://raw.githubusercontent.com/CPMpy/cpmpy/csplib/examples/csplib/prob013_progressive_party.json" parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument('-instance', nargs='?', default="csplib_example", help="Name of the problem instance found in file 'filename'") + parser.add_argument('-instance', nargs='?', default="lan01", help="Name of the problem instance found in file 'filename'") parser.add_argument('-filename', nargs='?', default=url, help="File containing problem instances, can be local file or url") parser.add_argument('--list-instances', help='List all problem instances', action='store_true') diff --git a/examples/csplib/prob024_langford.py b/examples/csplib/prob024_langford.py index 1a222e5a5..d0460e509 100644 --- a/examples/csplib/prob024_langford.py +++ b/examples/csplib/prob024_langford.py @@ -62,7 +62,7 @@ def print_solution(position, solution): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("-k", type=int, default=8, help="Number of integers") - parser.add_argument("--solution_limit", type=int, default=0, help="Number of solutions to search for, find all by default") + parser.add_argument("--solution_limit", type=int, default=10, help="Number of solutions to search for, find all by default") args = parser.parse_args() diff --git a/examples/csplib/prob026_sport_scheduling.py b/examples/csplib/prob026_sport_scheduling.py index 59a205af2..14ceaa4b4 100644 --- a/examples/csplib/prob026_sport_scheduling.py +++ b/examples/csplib/prob026_sport_scheduling.py @@ -46,7 +46,7 @@ def sport_scheduling(n_teams): import argparse parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("-n_teams", type=int, default=8, help="Number of teams to schedule") + parser.add_argument("-n_teams", type=int, default=6, help="Number of teams to schedule") args = parser.parse_args() diff --git a/examples/csplib/prob028_bibd.py b/examples/csplib/prob028_bibd.py index 5dfbdced9..dd51ed7cd 100644 --- a/examples/csplib/prob028_bibd.py +++ b/examples/csplib/prob028_bibd.py @@ -58,7 +58,7 @@ def bibd(v, b, r, k, l): import argparse parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--solution_limit", type=int, default=0, help="Number of solutions to find, find all by default") + parser.add_argument("--solution_limit", type=int, default=10, help="Number of solutions to find, find all by default") args = parser.parse_args() @@ -73,4 +73,4 @@ def bibd(v, b, r, k, l): if num_solutions == 0: raise ValueError("Model is unsatisfiable") else: - print(f"Found {num_solutions} solutions") \ No newline at end of file + print(f"Found {num_solutions} solutions") diff --git a/examples/csplib/prob033_word_design.py b/examples/csplib/prob033_word_design.py index bb7d6e2a0..37befec99 100644 --- a/examples/csplib/prob033_word_design.py +++ b/examples/csplib/prob033_word_design.py @@ -56,7 +56,7 @@ def word_design(n=2): import argparse parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("-n_words", type=int, default=24, help="Number of words to find") + parser.add_argument("-n_words", type=int, default=8, help="Number of words to find") n = parser.parse_args().n_words diff --git a/examples/csplib/prob044_steiner.py b/examples/csplib/prob044_steiner.py index 34d973c26..57a21c186 100644 --- a/examples/csplib/prob044_steiner.py +++ b/examples/csplib/prob044_steiner.py @@ -55,7 +55,7 @@ def print_sol(sets): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("-num_sets", type=int, default=15, help="Number of sets") - parser.add_argument("--solution_limit", type=int, default=0, help="Number of solutions to find, find all by default") + parser.add_argument("--solution_limit", type=int, default=10, help="Number of solutions to find, find all by default") args = parser.parse_args() @@ -67,4 +67,4 @@ def print_sol(sets): if n_sol == 0: raise ValueError("Model is unsatisfiable") else: - print(f"Found {n_sol} solutions") \ No newline at end of file + print(f"Found {n_sol} solutions") diff --git a/examples/csplib/prob050_diamond_free.py b/examples/csplib/prob050_diamond_free.py index 3db641ccb..8b42e6006 100644 --- a/examples/csplib/prob050_diamond_free.py +++ b/examples/csplib/prob050_diamond_free.py @@ -84,7 +84,7 @@ def print_sol(matrix): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("-n", type=int, default=10, help="Size of diamond") - parser.add_argument("--solution-limit", type=int, default=0, help="Number of solutions to find, find all by default") + parser.add_argument("--solution-limit", type=int, default=10, help="Number of solutions to find, find all by default") args = parser.parse_args() diff --git a/examples/csplib/prob054_n_queens.py b/examples/csplib/prob054_n_queens.py index 1da4342e9..3b960b55b 100644 --- a/examples/csplib/prob054_n_queens.py +++ b/examples/csplib/prob054_n_queens.py @@ -47,7 +47,7 @@ def print_sol(queens): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("-n", type=int, default=16, help="Number of queens") - parser.add_argument("--solution_limit", type=int, default=0, help="Number of solutions, find all by default") + parser.add_argument("--solution_limit", type=int, default=10, help="Number of solutions, find all by default") args = parser.parse_args() diff --git a/examples/csplib/prob076_costas_arrays.py b/examples/csplib/prob076_costas_arrays.py index 6a2b59193..85e4233bc 100644 --- a/examples/csplib/prob076_costas_arrays.py +++ b/examples/csplib/prob076_costas_arrays.py @@ -82,7 +82,7 @@ def print_sol(costas, differences): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("-size", type=int, default=6, help="Size of array") - parser.add_argument("--solution_limit", type=int, default=0, help="Number of solutions, find all by default") + parser.add_argument("--solution_limit", type=int, default=10, help="Number of solutions, find all by default") args = parser.parse_args() diff --git a/examples/flexible_jobshop.py b/examples/flexible_jobshop.py index 54c669f9f..00174c4db 100644 --- a/examples/flexible_jobshop.py +++ b/examples/flexible_jobshop.py @@ -66,10 +66,17 @@ print("No solution found.") +def compare_solvers(model): + """ + Compare the runtime of all installed solvers on the given model. + """ + print("Solving with all installed solvers...") + for solvername in cp.SolverLookup.solvernames(): + try: + model.solve(solver=solvername, time_limit=10) # max 10 seconds + print(f"{solvername}: {model.status()}") + except Exception as e: + print(f"{solvername}: Not run -- {str(e)}") + # --- bonus: compare the runtime of all installed solvers --- -for solvername in cp.SolverLookup.solvernames(): - try: - model.solve(solver=solvername, time_limit=10) # max 10 seconds - print(f"{solvername}: {model.status()}") - except Exception as e: - print(f"{solvername}: Not run -- {str(e)}") +# compare_solvers(model) diff --git a/setup.py b/setup.py index 98b9f0748..ec6a5fe25 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ def get_version(rel_path): # Tools # "xcsp3": ["pycsp3"], <- for when xcsp3 is merged # Other - "test": ["pytest"], + "test": ["pytest", "pytest-timeout"], "docs": ["sphinx>=5.3.0", "sphinx_rtd_theme>=2.0.0", "myst_parser", "sphinx-automodapi", "readthedocs-sphinx-search>=0.3.2"], }, classifiers=[ diff --git a/tests/test_examples.py b/tests/test_examples.py index 33409af5e..1025ab497 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -9,66 +9,82 @@ from glob import glob from os.path import join from os import getcwd -import types -import importlib.machinery +import sys + +import runpy import pytest -from cpmpy import * +from cpmpy import SolverLookup +from cpmpy.exceptions import NotSupportedError, TransformationNotImplementedError +import itertools + +prefix = '.' if 'y' in getcwd()[-2:] else '..' + +EXAMPLES = glob(join(prefix, "examples", "*.py")) + \ + glob(join(prefix, "examples", "csplib", "*.py")) -cwd = getcwd() -if 'y' in cwd[-2:]: - EXAMPLES = glob(join(".", "examples", "*.py")) + \ - glob(join(".", "examples", "advanced", "*.py")) + \ - glob(join(".", "examples", "csplib", "*.py")) -else: - EXAMPLES = glob(join("..", "examples", "*.py")) + \ - glob(join("..", "examples", "advanced", "*.py")) + \ - glob(join("..", "examples", "csplib", "*.py")) +ADVANCED_EXAMPLES = glob(join(prefix, "examples", "advanced", "*.py")) -@pytest.mark.parametrize("example", EXAMPLES) -def test_examples(example): - """Loads example files and executes with default solver +# SOLVERS = SolverLookup.supported() +SOLVERS = [ + "ortools", + "gurobi", + "minizinc", +] -class TestExamples(unittest.TestCase): + +# run the test for each combination of solver and example +@pytest.mark.parametrize(("solver", "example"), itertools.product(SOLVERS, EXAMPLES)) +@pytest.mark.timeout(60) # 60-second timeout for each test +def test_example(solver, example): + """Loads the example file and executes its __main__ block with the given solver being set as default. Args: + solver ([string]): Loaded with parametrized solver name example ([string]): Loaded with parametrized example filename """ - # do not run, dependency local to that folder - if example.endswith('explain_satisfaction.py'): - return + if solver in ('gurobi', 'minizinc') and any(x in example for x in + ["npuzzle.py", "tst_likevrp.py", 'sudoku_', 'pareto_optimal.py', + 'prob009_perfect_squares.py', 'blocks_world.py', + 'flexible_jobshop.py']): + return pytest.skip(reason=f"exclude {example} for {solver}, too slow or solver-specific") - # catch ModuleNotFoundError if example imports stuff that may not be installed + base_solvers = SolverLookup.base_solvers try: - loader = importlib.machinery.SourceFileLoader("example", example) - mod = types.ModuleType(loader.name) - loader.exec_module(mod) # this runs the scripts + solver_class = SolverLookup.lookup(solver) + if not solver_class.supported(): + # check this here, as unsupported solvers can fail the example for various reasons + return pytest.skip(reason=f"solver {solver} not supported") + + # Overwrite SolverLookup.base_solvers to set the target solver first, making it the default + SolverLookup.base_solvers = lambda: sorted(base_solvers(), key=lambda s: s[0] == solver, reverse=True) + sys.argv = [example] # avoid pytest arguments being passed the executed module + runpy.run_path(example, run_name="__main__") # many examples won't do anything `__name__ != "__main__"` + except (NotSupportedError, TransformationNotImplementedError) as e: + if solver == 'ortools': # `from` augments exception trace + raise Exception( + "Example not supported by ortools, which is currently able to run all models, but raised") from e + pytest.skip( + reason=f"Skipped, solver or its transformation does not support model, raised {type(e).__name__}: {e}") + except ValueError as e: + if "Unknown solver" in str(e): + pytest.skip(reason=f"Skipped, example uses specific solver, raised: {e}") + else: # still fail for other reasons + raise e except ModuleNotFoundError as e: - pytest.skip('skipped, module {} is required'.format(str(e).split()[-1])) # returns + pytest.skip('Skipped, module {} is required'.format(str(e).split()[-1])) + finally: + SolverLookup.base_solvers = base_solvers - # run again with gurobi, if installed on system - if any(x in example for x in ["npuzzle","tst_likevrp", "ortools_presolve_propagate", 'sudoku_ratrun1.py']): - # exclude those, too slow or solver specific - return - gbi_slv = SolverLookup.lookup("gurobi") - if gbi_slv.supported(): - # temporarily brute-force overwrite SolverLookup.base_solvers so our solver is default - f = SolverLookup.base_solvers - try: - SolverLookup.base_solvers = lambda: [('gurobi', gbi_slv)]+f() - loader.exec_module(mod) - finally: - SolverLookup.base_solvers = f - # run again with minizinc, if installed on system - if example in ['./examples/npuzzle.py', './examples/tsp_likevrp.py', './examples/sudoku_ratrun1.py', './examples/sudoku_chockablock.py']: - # except for these too slow ones - return - mzn_slv = SolverLookup.lookup('minizinc') - if mzn_slv.supported(): - # temporarily brute-force overwrite SolverLookup.base_solvers so our solver is default - f = SolverLookup.base_solvers - try: - SolverLookup.base_solvers = lambda: [('minizinc', mzn_slv)]+f() - loader.exec_module(mod) - finally: - SolverLookup.base_solvers = f +@pytest.mark.parametrize("example", ADVANCED_EXAMPLES) +@pytest.mark.timeout(30) +def test_advanced_example(example): + """Loads the advanced example file and executes its __main__ block with no default solver set.""" + try: + sys.argv = [example] + runpy.run_path(example, run_name="__main__") + except Exception as e: + if "CPM_exact".lower() in str(e).lower(): + pytest.skip(reason=f"Skipped, example uses Exact but is not installed, raised: {e}") + else: + raise e