diff --git a/README.md b/README.md index 5d02552..7623f43 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Features * **Specialized data structures**: For defining index-sets, parameters, and decision variables — enabling concise and high-performance algebraic modeling. * **Easy access to additional CPLEX functionality**: Like [tuning tool](https://www.ibm.com/docs/en/icos/latest?topic=programmingconsiderations-tuning-tool), [runseeds](https://www.ibm.com/docs/en/icos/latest?topic=cplex-evaluating-variability), [displaying problem statistics](https://www.ibm.com/docs/en/icos/latest?topic=problem-displaying-statistics) and [displaying solution quality statistics](https://www.ibm.com/docs/en/icos/latest?topic=cplex-evaluating-solution-quality) — not directly available in DOcplex. * **Type-complete interface**: Enables static type checking and intelligent auto-completion suggestions with modern IDEs — reducing type errors and improving development speed. -* **Robust codebase**: 100% coverage spanning 1700+ test cases and fully type-checked with mypy under [strict mode](https://mypy.readthedocs.io/en/stable/getting_started.html#strict-mode-and-configuration). +* **Robust codebase**: 100% coverage spanning 1800+ test cases and fully type-checked with mypy under [strict mode](https://mypy.readthedocs.io/en/stable/getting_started.html#strict-mode-and-configuration). Links ----- diff --git a/docs/source/api_reference/parameters.rst b/docs/source/api_reference/parameters.rst index e071b18..f5120dc 100644 --- a/docs/source/api_reference/parameters.rst +++ b/docs/source/api_reference/parameters.rst @@ -92,6 +92,13 @@ Mapping operations ParamDictND.popitem ParamDictND.setdefault +Efficient subset selection +-------------------------- +.. autosummary:: + + VarDictND.subset_keys + VarDictND.subset_values + Views ----- - ``ParamDictND.items()`` diff --git a/docs/source/api_reference/variables.rst b/docs/source/api_reference/variables.rst index fd42be3..d2a4240 100644 --- a/docs/source/api_reference/variables.rst +++ b/docs/source/api_reference/variables.rst @@ -31,6 +31,8 @@ Attributes ---------- .. autosummary:: + VarDict1D.model + VarDict1D.vartype VarDict1D.key_name VarDict1D.value_name @@ -69,6 +71,8 @@ Attributes ---------- .. autosummary:: + VarDictND.model + VarDictND.vartype VarDictND.key_names VarDictND.value_name @@ -85,6 +89,13 @@ Mapping operations VarDictND.get VarDictND.lookup +Efficient subset selection +-------------------------- +.. autosummary:: + + VarDictND.subset_keys + VarDictND.subset_values + Views ----- - ``VarDictND.items()`` diff --git a/pyproject.toml b/pyproject.toml index a921127..2260db0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -183,11 +183,11 @@ exclude_also = [ legacy_tox_ini = """ [tox] min_version = 4.0 - env_list = py{310,311,312,313}, mypy, cpx{2010,2210,2211}, pd{15,20,X} + env_list = py{310,311,312,313}, mypy, cpx{2010,2210,2211,2212}, pd{15,20,X} [gh] python = - 3.10 = py310, mypy, cpx2010, cpx2210, cpx2211, pd15, pd20, pdX + 3.10 = py310, mypy, cpx{2010,2210,2211,2212}, pd{15,20,X} 3.11 = py311 3.12 = py312 3.13 = py313 @@ -212,8 +212,8 @@ legacy_tox_ini = """ mypy src mypy tests/typing_tests - [testenv:py{312,313}] - # Since CPLEX runtime is not supported beyond python 3.11, we skip all runtime-based tests + [testenv:py313] + # Since CPLEX runtime is not supported beyond python 3.12, we skip all runtime-based tests description = Run doctests, unit tests ex. model functions, and typing tests extras = tests commands = @@ -249,6 +249,15 @@ legacy_tox_ini = """ pytest --basetemp="{env_tmp_dir}" --no-cov \ tests/unit_tests/model_funcs/ + [testenv:cpx2212] + description = Run unit tests for model functions against CPLEX 22.1.2 + basepython = 3.10 + deps = cplex==22.1.2.0 + extras = tests + commands = + pytest --basetemp="{env_tmp_dir}" --no-cov \ + tests/unit_tests/model_funcs/ + [testenv:pd15] description = Run unit tests and doctests for pandas accessors against pandas 1.5 basepython = 3.10 diff --git a/src/docplex_extensions/__init__.py b/src/docplex_extensions/__init__.py index e247543..770b393 100644 --- a/src/docplex_extensions/__init__.py +++ b/src/docplex_extensions/__init__.py @@ -9,7 +9,7 @@ modeling with DOcplex — IBM® Decision Optimization CPLEX® Modeling for Python. """ -__version__ = '1.2.0' +__version__ = '1.3.0' # Check required dependencies from importlib.util import find_spec as _find_spec diff --git a/src/docplex_extensions/_dict_mixins.py b/src/docplex_extensions/_dict_mixins.py index 71661a7..ea663c7 100644 --- a/src/docplex_extensions/_dict_mixins.py +++ b/src/docplex_extensions/_dict_mixins.py @@ -172,7 +172,7 @@ def _get_repr_header(self) -> str: else: return f'{self.__class__.__name__}:' - def _subset_keys(self, *pattern: Any) -> list[ElemNDT]: + def subset_keys(self, *pattern: Any) -> list[ElemNDT]: """Get a subset of the N-dim tuple keys of the Dict with a wildcard pattern. Parameters @@ -202,7 +202,7 @@ def _subset_keys(self, *pattern: Any) -> list[ElemNDT]: except Exception as exc: self._reraise_exc_from_indexset(exc) - def _get_matching_values(self, *pattern: Any) -> tuple[ValT, ...]: + def subset_values(self, *pattern: Any) -> list[ValT]: """Get Dict values for all keys that match the wildcard pattern. Parameters @@ -214,7 +214,7 @@ def _get_matching_values(self, *pattern: Any) -> tuple[ValT, ...]: Returns ------- - tuple + list Raises ------ @@ -227,13 +227,13 @@ def _get_matching_values(self, *pattern: Any) -> tuple[ValT, ...]: ValueError If the pattern has no wildcard or all wildcards. """ - keys = self._subset_keys(*pattern) + keys = self.subset_keys(*pattern) match len(keys): case 0: - res: tuple[ValT, ...] = tuple() + res: list[ValT] = [] case 1: - res = (self[keys[0]],) + res = [self[keys[0]]] case _: - res = itemgetter(*keys)(self) + res = list(itemgetter(*keys)(self)) return res diff --git a/src/docplex_extensions/_model_funcs.py b/src/docplex_extensions/_model_funcs.py index ccab8c9..b668329 100644 --- a/src/docplex_extensions/_model_funcs.py +++ b/src/docplex_extensions/_model_funcs.py @@ -149,15 +149,18 @@ def solve(model: Model, **kwargs: Any) -> SolveSolution | None: if to_reopen: stream = open(stream._target.name, 'a') - # Write solution quality statistics - log_footer = '\n' + ' Solution quality statistics '.center(div_len, '-') + '\n\n' - log_footer += str(cplex.solution.get_quality_metrics()) - log_footer += '\n' + '-' * div_len + '\n' - stream.write(log_footer) + # Write solution quality statistics if CPLEX finds a feasible solution + if solution is None: + stream.write('\n' + '-' * div_len + '\n') + else: + log_footer = '\n' + ' Solution quality statistics '.center(div_len, '-') + '\n\n' + log_footer += str(cplex.solution.get_quality_metrics()) + log_footer += '\n' + '-' * div_len + '\n' + stream.write(log_footer) + stream.flush() # When logging to stream objects, close them at the end if to_reopen: - stream.flush() stream.close() return solution diff --git a/src/docplex_extensions/_param_dicts.py b/src/docplex_extensions/_param_dicts.py index 7676db5..437d8a2 100644 --- a/src/docplex_extensions/_param_dicts.py +++ b/src/docplex_extensions/_param_dicts.py @@ -635,10 +635,10 @@ def _calc_stat(self, *pattern: Any, stat_func: str) -> int | float: """ self._check_for_calc_stat(stat_func) if stat_func == 'sum' and pattern: - res: int | float = sum(self._get_matching_values(*pattern)) + res: int | float = sum(self.subset_values(*pattern)) elif stat_func != 'sum' and pattern: try: - res = getattr(statistics, stat_func)(self._get_matching_values(*pattern)) + res = getattr(statistics, stat_func)(self.subset_values(*pattern)) except statistics.StatisticsError: res = 0 else: diff --git a/src/docplex_extensions/_var_dicts.py b/src/docplex_extensions/_var_dicts.py index f4aa03d..fe65036 100644 --- a/src/docplex_extensions/_var_dicts.py +++ b/src/docplex_extensions/_var_dicts.py @@ -12,6 +12,7 @@ from docplex.mp.dvar import Var from docplex.mp.linear import LinearExpr, ZeroExpr from docplex.mp.model import Model +from docplex.mp.vartype import VarType from ._dict_mixins import DefaultT, Dict1DMixin, DictBaseMixin, DictNDMixin from ._index_sets import Elem1DT, ElemNDT, ElemT, IndexSet1D, IndexSetBase, IndexSetND @@ -32,21 +33,27 @@ class VarDictBase(dict[ElemT, VarT], DictBaseMixin[ElemT, VarT]): Dictionary of variable objects from docplex. indexset : IndexSetBase Keys of dictionary encapsulated in an IndexSet. - model : docplex.mp.Model + model : docplex.mp.model.Model DOcplex model associated with the variable objects. + vartype : docplex.mp.vartype.VarType + DOcplex VarType corresponding to the variable objects. """ # Private attributes # ------------------ # _indexset : IndexSetBase # Index-set of keys. - # _model : docplex.mp.model.Model - # DOcplex model. - __slots__ = ('_indexset', '_model') + __slots__ = ('_indexset', '_model', '_vartype') def __init__( - self, docpx_var_dict: dict[ElemT, VarT], /, *, indexset: IndexSetBase[ElemT], model: Model + self, + docpx_var_dict: dict[ElemT, VarT], + /, + *, + indexset: IndexSetBase[ElemT], + model: Model, + vartype: VarType, ) -> None: self._validate_docpx_var_dict(docpx_var_dict) @@ -60,10 +67,30 @@ def __init__( """Index-set of keys.""" self._model = model - """DOcplex model.""" + self._vartype = vartype super().__init__(docpx_var_dict) + @property + def model(self) -> Model: + """DOcplex model associated with the variables. + + Returns + ------- + docplex.mp.model.Model + """ + return self._model + + @property + def vartype(self) -> VarType: + """DOcplex VarType corresponding to the variables. + + Returns + ------- + DOcplex variable type (subclass of docplex.mp.vartype.VarType) + """ + return self._vartype + @staticmethod def _validate_docpx_var_dict(docpx_var_dict: dict[ElemT, VarT]) -> None: """Validate that the input is a populated dict of DOcplex variables. @@ -147,8 +174,10 @@ class VarDict1D(VarDictBase[Elem1DT, VarT], Dict1DMixin[Elem1DT, VarT]): Dictionary of variable objects from docplex. indexset : IndexSet1D Keys of dictionary encapsulated in IndexSet1D. - model : docplex.mp.Model + model : docplex.mp.model.Model DOcplex model associated with the variable objects. + vartype : docplex.mp.vartype.VarType + DOcplex VarType corresponding to the variable objects. value_name : str, optional Name to refer to variables - not used internally, and solely for user reference. """ @@ -157,8 +186,6 @@ class VarDict1D(VarDictBase[Elem1DT, VarT], Dict1DMixin[Elem1DT, VarT]): # ------------------ # _indexset : IndexSet1D # Index-set of keys. - # _model : docplex.mp.model.Model - # DOcplex model. __slots__ = ('_key_name', '_value_name') @@ -169,12 +196,13 @@ def __init__( /, *, model: Model, + vartype: VarType, value_name: str | None = None, ) -> None: self.key_name = indexset.name self.value_name = value_name - super().__init__(docpx_var_dict, indexset=indexset, model=model) + super().__init__(docpx_var_dict, indexset=indexset, model=model, vartype=vartype) def __new__( cls, @@ -183,6 +211,7 @@ def __new__( /, *, model: Model, + vartype: VarType, value_name: str | None = None, ) -> VarDict1D[Elem1DT, VarT]: raise TypeError( @@ -198,11 +227,14 @@ def _create( /, *, model: Model, + vartype: VarType, value_name: str | None = None, ) -> VarDict1D[Elem1DT, VarT]: # Private method to construt VarDict1D instance = super().__new__(cls) - cls.__init__(instance, docpx_var_dict, indexset, model=model, value_name=value_name) + cls.__init__( + instance, docpx_var_dict, indexset, model=model, vartype=vartype, value_name=value_name + ) return instance def __repr__(self) -> str: @@ -282,7 +314,7 @@ def sum(self) -> LinearExpr | ZeroExpr: >>> node_select.sum() docplex.mp.LinearExpr(node-select_A+node-select_B+node-select_C) """ - return self._model.sum_vars_all_different(self.values()) + return self.model.sum_vars_all_different(self.values()) class VarDictND(VarDictBase[ElemNDT, VarT], DictNDMixin[ElemNDT, VarT]): @@ -294,8 +326,10 @@ class VarDictND(VarDictBase[ElemNDT, VarT], DictNDMixin[ElemNDT, VarT]): Dictionary of variable objects from docplex. indexset : IndexSetND Keys of dictionary encapsulated in IndexSetND. - model : docplex.mp.Model + model : docplex.mp.model.Model DOcplex model associated with the variable objects. + vartype : docplex.mp.vartype.VarType + DOcplex VarType corresponding to the variable objects. value_name : str, optional Name to refer to variables - not used internally, and solely for user reference. """ @@ -304,8 +338,6 @@ class VarDictND(VarDictBase[ElemNDT, VarT], DictNDMixin[ElemNDT, VarT]): # ------------------ # _indexset : IndexSetND # Index-set of keys. - # _model : docplex.mp.model.Model - # DOcplex model. __slots__ = ('_key_names', '_value_name') @@ -316,12 +348,13 @@ def __init__( /, *, model: Model, + vartype: VarType, value_name: str | None = None, ) -> None: self.key_names = indexset.names self.value_name = value_name - super().__init__(docpx_var_dict, indexset=indexset, model=model) + super().__init__(docpx_var_dict, indexset=indexset, model=model, vartype=vartype) def __new__( cls, @@ -330,6 +363,7 @@ def __new__( /, *, model: Model, + vartype: VarType, value_name: str | None = None, ) -> VarDictND[ElemNDT, VarT]: raise TypeError( @@ -345,11 +379,14 @@ def _create( /, *, model: Model, + vartype: VarType, value_name: str | None = None, ) -> VarDictND[ElemNDT, VarT]: # Private method to construt VarDictND instance = super().__new__(cls) - cls.__init__(instance, docpx_var_dict, indexset, model=model, value_name=value_name) + cls.__init__( + instance, docpx_var_dict, indexset, model=model, vartype=vartype, value_name=value_name + ) return instance def __repr__(self) -> str: @@ -460,5 +497,5 @@ def sum(self, *pattern: Any) -> LinearExpr | ZeroExpr: docplex.mp.ZeroExpr() """ if pattern: - return self._model.sum_vars_all_different(self._get_matching_values(*pattern)) - return self._model.sum_vars_all_different(self.values()) + return self.model.sum_vars_all_different(self.subset_values(*pattern)) + return self.model.sum_vars_all_different(self.values()) diff --git a/src/docplex_extensions/_var_funcs.py b/src/docplex_extensions/_var_funcs.py index e8016f6..61c5b7b 100644 --- a/src/docplex_extensions/_var_funcs.py +++ b/src/docplex_extensions/_var_funcs.py @@ -569,5 +569,9 @@ def add_variables( value_name = name if isinstance(name, str) else None if isinstance(indexset, IndexSet1D): - return VarDict1D._create(docpx_var_dict, indexset, model=model, value_name=value_name) - return VarDictND._create(docpx_var_dict, indexset, model=model, value_name=value_name) + return VarDict1D._create( + docpx_var_dict, indexset, model=model, vartype=vt, value_name=value_name + ) + return VarDictND._create( + docpx_var_dict, indexset, model=model, vartype=vt, value_name=value_name + ) diff --git a/tests/unit_tests/model_funcs/conftest.py b/tests/unit_tests/model_funcs/conftest.py index 92511ce..e015671 100644 --- a/tests/unit_tests/model_funcs/conftest.py +++ b/tests/unit_tests/model_funcs/conftest.py @@ -147,3 +147,32 @@ def rs_mdl(): yield mdl mdl.end() + + +@pytest.fixture() +def expected_infeas_log_end(): + text = '\n'.join( + [ + '-------------------------------------------------------------------------------------', + '', + ] + ) + return text + + +@pytest.fixture(scope='module') +def infeas_mdl(): + mdl = Model(name='dummy', checker='off', ignore_names=True) + desk = mdl.integer_var(name='desk') + cell = mdl.integer_var(name='cell') + mdl.add_constraints_( + [ + 0.2 * desk + 0.4 * cell <= 400, + 0.2 * desk + 0.4 * cell >= 500, + ] + ) + mdl.maximize(12 * desk + 20 * cell) + + yield mdl + + mdl.end() diff --git a/tests/unit_tests/model_funcs/model_funcs_test.py b/tests/unit_tests/model_funcs/model_funcs_test.py index d9267b3..1dda119 100644 --- a/tests/unit_tests/model_funcs/model_funcs_test.py +++ b/tests/unit_tests/model_funcs/model_funcs_test.py @@ -5,21 +5,24 @@ """Model functionality.""" import pytest -from docplex.mp.model import Model from docplex_extensions import print_problem_stats, print_solution_quality_stats, runseeds, solve from .conftest import regex_trim -def validate_logoutput(input, expected_log_start, expected_log_end): - input_pre = input.split('CPLEX optimizer log')[0] - expected_pre = expected_log_start.split('CPLEX optimizer log')[0] +def validate_logoutput(input, infeasible, expected_log_start, expected_log_end): + if not infeasible: + input_pre = input.split('CPLEX optimizer log')[0] + expected_pre = expected_log_start.split('CPLEX optimizer log')[0] + assert regex_trim(input_pre) == regex_trim(expected_pre) - input_post = input.split('Solution quality statistics')[1] - expected_post = expected_log_end.split('Solution quality statistics')[1] - - assert regex_trim(input_pre) == regex_trim(expected_pre) + if infeasible: + input_post = input.split('-' * 85)[1] + expected_post = expected_log_end.split('-' * 85)[1] + else: + input_post = input.split('Solution quality statistics')[1] + expected_post = expected_log_end.split('Solution quality statistics')[1] assert regex_trim(input_post) == regex_trim(expected_post) @@ -59,6 +62,16 @@ def test_print_solution_quality_stats_unsolved_pass(capsys, mdl): assert regex_trim(captured) == regex_trim(expected) +def test_print_solution_quality_stats_infeas_pass(capsys, infeas_mdl): + _ = infeas_mdl.solve() + print_solution_quality_stats(infeas_mdl) + + captured = capsys.readouterr().out + expected = f'Model `{infeas_mdl.name}` has no incumbent solution.\n' + + assert regex_trim(captured) == regex_trim(expected) + + def test_print_solution_quality_stats_solved_pass(capsys, mdl): _ = mdl.solve() print_solution_quality_stats(mdl) @@ -82,61 +95,104 @@ def test_print_solution_quality_stats_solved_pass(capsys, mdl): assert regex_trim(captured) == regex_trim(expected) -def test_solve_solution_pass(): - mdl = Model(name='telephone_production') - desk = mdl.continuous_var(name='desk') - cell = mdl.continuous_var(name='cell') - mdl.add_constraint_(desk >= 100) - mdl.add_constraint_(cell >= 100) - mdl.add_constraint_(0.2 * desk + 0.4 * cell <= 400) - mdl.add_constraint_(0.5 * desk + 0.4 * cell <= 490) - mdl.maximize(12 * desk + 20 * cell) - - sol1 = mdl.solve() - sol2 = solve(mdl, clean_before_solve=True) +def test_solve_solution_pass(rs_mdl): + sol1 = rs_mdl.solve(clean_before_solve=True) + sol2 = solve(rs_mdl, clean_before_solve=True) assert sol1.to_string() == sol2.to_string() -def test_solve_logoutput_context_pass(capsys, mdl, expected_log_start, expected_log_end): +def test_solve_infeas_solution_pass(infeas_mdl): + sol1 = infeas_mdl.solve(clean_before_solve=True) + sol2 = solve(infeas_mdl, clean_before_solve=True) + + assert sol1 == sol2 + + +@pytest.mark.parametrize( + 'infeasible, _mdl, _expected_log_end', + [(False, 'mdl', 'expected_log_end'), (True, 'infeas_mdl', 'expected_infeas_log_end')], +) +def test_solve_logoutput_context_pass( + capsys, request, infeasible, _mdl, _expected_log_end, expected_log_start +): + mdl = request.getfixturevalue(_mdl) + expected_log_end = request.getfixturevalue(_expected_log_end) + mdl.context.solver.log_output = True _ = solve(mdl) captured = capsys.readouterr().out - validate_logoutput(captured, expected_log_start % mdl.name, expected_log_end) + validate_logoutput(captured, infeasible, expected_log_start % mdl.name, expected_log_end) +@pytest.mark.parametrize( + 'infeasible, _mdl, _expected_log_end', + [(False, 'mdl', 'expected_log_end'), (True, 'infeas_mdl', 'expected_infeas_log_end')], +) @pytest.mark.parametrize('log_output', [True, 'stdout', 'sys.stdout', '1']) -def test_solve_logoutput_stdout_pass(capsys, mdl, log_output, expected_log_start, expected_log_end): +def test_solve_logoutput_stdout_pass( + capsys, request, infeasible, _mdl, _expected_log_end, log_output, expected_log_start +): + mdl = request.getfixturevalue(_mdl) + expected_log_end = request.getfixturevalue(_expected_log_end) + _ = solve(mdl, log_output=log_output) captured = capsys.readouterr().out - validate_logoutput(captured, expected_log_start % mdl.name, expected_log_end) + validate_logoutput(captured, infeasible, expected_log_start % mdl.name, expected_log_end) +@pytest.mark.parametrize( + 'infeasible, _mdl, _expected_log_end', + [(False, 'mdl', 'expected_log_end'), (True, 'infeas_mdl', 'expected_infeas_log_end')], +) @pytest.mark.parametrize('log_output', ['stderr', 'sys.stderr']) -def test_solve_logoutput_stderr_pass(capsys, mdl, log_output, expected_log_start, expected_log_end): +def test_solve_logoutput_stderr_pass( + capsys, request, infeasible, _mdl, _expected_log_end, log_output, expected_log_start +): + mdl = request.getfixturevalue(_mdl) + expected_log_end = request.getfixturevalue(_expected_log_end) + _ = solve(mdl, log_output=log_output) captured = capsys.readouterr().err - validate_logoutput(captured, expected_log_start % mdl.name, expected_log_end) + validate_logoutput(captured, infeasible, expected_log_start % mdl.name, expected_log_end) -def test_solve_logoutput_filepath_pass(tmp_path, mdl, expected_log_start, expected_log_end): +@pytest.mark.parametrize( + 'infeasible, _mdl, _expected_log_end', + [(False, 'mdl', 'expected_log_end'), (True, 'infeas_mdl', 'expected_infeas_log_end')], +) +def test_solve_logoutput_filepath_pass( + tmp_path, request, infeasible, _mdl, _expected_log_end, expected_log_start +): + mdl = request.getfixturevalue(_mdl) + expected_log_end = request.getfixturevalue(_expected_log_end) + file = tmp_path / 'file.log' _ = solve(mdl, log_output=str(file)) captured = file.read_text(encoding='utf-8') - validate_logoutput(captured, expected_log_start % mdl.name, expected_log_end) + validate_logoutput(captured, infeasible, expected_log_start % mdl.name, expected_log_end) + +@pytest.mark.parametrize( + 'infeasible, _mdl, _expected_log_end', + [(False, 'mdl', 'expected_log_end'), (True, 'infeas_mdl', 'expected_infeas_log_end')], +) +def test_solve_logoutput_fileobj_pass( + tmp_path, request, infeasible, _mdl, _expected_log_end, expected_log_start +): + mdl = request.getfixturevalue(_mdl) + expected_log_end = request.getfixturevalue(_expected_log_end) -def test_solve_logoutput_fileobj_pass(tmp_path, mdl, expected_log_start, expected_log_end): file = tmp_path / 'file.log' with file.open('w') as f: _ = solve(mdl, log_output=f) captured = file.read_text(encoding='utf-8') - validate_logoutput(captured, expected_log_start % mdl.name, expected_log_end) + validate_logoutput(captured, infeasible, expected_log_start % mdl.name, expected_log_end) @pytest.mark.parametrize('log_output', [False, '0', None]) @@ -149,6 +205,15 @@ def test_solve_logoutput_false_pass(capsys, mdl, log_output): assert captured.out == expected +def test_solve_logoutput_not_provided(capsys, mdl): + _ = solve(mdl) + + captured = capsys.readouterr() + expected = '' + + assert captured.out == expected + + @pytest.mark.parametrize('mdl', ['abc', 123, ('A', 'B')]) def test_solve_mdl_typerr(mdl): with pytest.raises(TypeError): diff --git a/tests/unit_tests/param_dicts/conftest.py b/tests/unit_tests/param_dicts/conftest.py index bd3cb8d..c6fbf51 100644 --- a/tests/unit_tests/param_dicts/conftest.py +++ b/tests/unit_tests/param_dicts/conftest.py @@ -6,7 +6,7 @@ import pytest -from docplex_extensions import ParamDict1D, ParamDictND +from docplex_extensions import IndexSetND, ParamDict1D, ParamDictND @pytest.fixture @@ -37,3 +37,13 @@ def paramdictNd_pop2(): @pytest.fixture def paramdictNd_pop3(): return ParamDictND({('A', 'B'): 0, ('C', 'D'): 1, ('E', 'F'): 2}) + + +@pytest.fixture +def paramdictNd_cmb2(): + return ParamDictND({j: i for i, j in enumerate(IndexSetND(range(2), range(2)))}) + + +@pytest.fixture +def paramdictNd_cmb3(): + return ParamDictND({j: i for i, j in enumerate(IndexSetND(range(2), range(2), range(2)))}) diff --git a/tests/unit_tests/param_dicts/paramdictND_subset_selection_test.py b/tests/unit_tests/param_dicts/paramdictND_subset_selection_test.py new file mode 100644 index 0000000..b374c4b --- /dev/null +++ b/tests/unit_tests/param_dicts/paramdictND_subset_selection_test.py @@ -0,0 +1,149 @@ +# Copyright 2024 Samarth Mistry +# This file is part of the `docplex-extensions` package, which is released under +# the Apache Licence, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0). + +"""Subset selection methods of ParamDictND.""" + +import pytest + +from docplex_extensions import ParamDictND + + +@pytest.mark.parametrize( + '_input, values, expected', + [ + ('paramdictNd_cmb2', ('*', 1), [(0, 1), (1, 1)]), + ('paramdictNd_cmb2', (0, '*'), [(0, 0), (0, 1)]), + ('paramdictNd_cmb2', (2, '*'), []), + ('paramdictNd_cmb2', ('*', 2), []), + ('paramdictNd_cmb3', (0, '*', '*'), [(0, 0, 0), (0, 0, 1), (0, 1, 0), (0, 1, 1)]), + ('paramdictNd_cmb3', ('*', 1, '*'), [(0, 1, 0), (0, 1, 1), (1, 1, 0), (1, 1, 1)]), + ('paramdictNd_cmb3', (0, 1, '*'), [(0, 1, 0), (0, 1, 1)]), + ('paramdictNd_cmb3', (0, '*', 1), [(0, 0, 1), (0, 1, 1)]), + ('paramdictNd_cmb3', (2, '*', '*'), []), + ('paramdictNd_cmb3', (0, '*', 2), []), + ], +) +def test_subset_keys_pass(request, _input, values, expected): + input = request.getfixturevalue(_input) + assert input.subset_keys(*values) == expected + + +def test_subset_keys_empty(): + ts = ParamDictND() + with pytest.raises(LookupError): + ts.subset_keys(0, '*') + + +@pytest.mark.parametrize('values', [(0, '*'), (0, '*', 2, '*')]) +def test_subset_keys_diff_tuplelen(paramdictNd_cmb3, values): + with pytest.raises(ValueError): + paramdictNd_cmb3.subset_keys(*values) + + +@pytest.mark.parametrize('values', [((0, 1), (0, 0)), [(0, 1)]]) +def test_subset_keys_nonscaler(paramdictNd_cmb2, values): + with pytest.raises(TypeError): + paramdictNd_cmb2.subset_keys(*values) + + +@pytest.mark.parametrize('values', [('*', '*'), (0, 1)]) +def test_subset_keys_invalid_values(paramdictNd_cmb2, values): + with pytest.raises(ValueError): + paramdictNd_cmb2.subset_keys(*values) + + +@pytest.mark.parametrize( + 'index, value, expected', + [[(0, 0), 9, [(0, 0), (0, 1)]], [(0, 9), 9, [(0, 0), (0, 1), (0, 9)]]], +) +def test_subset_keys_w_addl_elem_setitem(paramdictNd_cmb2, index, value, expected): + _ = paramdictNd_cmb2.subset_keys(0, '*') + paramdictNd_cmb2[index] = value + + assert paramdictNd_cmb2.subset_keys(0, '*') == expected + + +def test_subset_keys_w_rmvd_elem_delitem(paramdictNd_cmb2): + _ = paramdictNd_cmb2.subset_keys(0, '*') + del paramdictNd_cmb2[0, 0] + + assert paramdictNd_cmb2.subset_keys(0, '*') == [(0, 1)] + + +def test_subset_keys_after_clear(paramdictNd_cmb2): + _ = paramdictNd_cmb2.subset_keys(0, '*') + paramdictNd_cmb2.clear() + + with pytest.raises(LookupError): + paramdictNd_cmb2.subset_keys(0, '*') + + +@pytest.mark.parametrize( + '_input, values, expected', + [ + ('paramdictNd_cmb2', ('*', 1), [1, 3]), + ('paramdictNd_cmb2', (0, '*'), [0, 1]), + ('paramdictNd_cmb2', (2, '*'), []), + ('paramdictNd_cmb2', ('*', 2), []), + ('paramdictNd_cmb3', (0, '*', '*'), [0, 1, 2, 3]), + ('paramdictNd_cmb3', ('*', 1, '*'), [2, 3, 6, 7]), + ('paramdictNd_cmb3', (0, 1, '*'), [2, 3]), + ('paramdictNd_cmb3', (0, '*', 1), [1, 3]), + ('paramdictNd_cmb3', (2, '*', '*'), []), + ('paramdictNd_cmb3', (0, '*', 2), []), + ], +) +def test_subset_values_pass(request, _input, values, expected): + input = request.getfixturevalue(_input) + assert input.subset_values(*values) == expected + + +def test_subset_values_empty(): + ts = ParamDictND() + with pytest.raises(LookupError): + ts.subset_values(0, '*') + + +@pytest.mark.parametrize('values', [(0, '*'), (0, '*', 2, '*')]) +def test_subset_values_diff_tuplelen(paramdictNd_cmb3, values): + with pytest.raises(ValueError): + paramdictNd_cmb3.subset_values(*values) + + +@pytest.mark.parametrize('values', [((0, 1), (0, 0)), [(0, 1)]]) +def test_subset_values_nonscaler(paramdictNd_cmb2, values): + with pytest.raises(TypeError): + paramdictNd_cmb2.subset_values(*values) + + +@pytest.mark.parametrize('values', [('*', '*'), (0, 1)]) +def test_subset_values_invalid_values(paramdictNd_cmb2, values): + with pytest.raises(ValueError): + paramdictNd_cmb2.subset_values(*values) + + +@pytest.mark.parametrize( + 'index, value, expected', + [[(0, 0), 9, [9, 1]], [(0, 9), 9, [0, 1, 9]]], +) +def test_subset_values_w_addl_elem_setitem(paramdictNd_cmb2, index, value, expected): + _ = paramdictNd_cmb2.subset_values(0, '*') + paramdictNd_cmb2[index] = value + + assert paramdictNd_cmb2.subset_values(0, '*') == expected + + +def test_subset_values_w_rmvd_elem_delitem(paramdictNd_cmb2): + _ = paramdictNd_cmb2.subset_values(0, '*') + del paramdictNd_cmb2[0, 0] + + assert paramdictNd_cmb2.subset_values(0, '*') == [1] + + +def test_subset_values_after_clear(paramdictNd_cmb2): + _ = paramdictNd_cmb2.subset_values(0, '*') + paramdictNd_cmb2.clear() + + with pytest.raises(LookupError): + paramdictNd_cmb2.subset_values(0, '*') diff --git a/tests/unit_tests/var_dicts_funcs/conftest.py b/tests/unit_tests/var_dicts_funcs/conftest.py index ab95077..faf8de4 100644 --- a/tests/unit_tests/var_dicts_funcs/conftest.py +++ b/tests/unit_tests/var_dicts_funcs/conftest.py @@ -7,6 +7,8 @@ import pytest from docplex.mp.model import Model +from docplex_extensions import IndexSetND + @pytest.fixture(scope='module') def mdl_1(): @@ -20,3 +22,13 @@ def mdl_2(): mdl_2 = Model(ignore_names=True) yield mdl_2 mdl_2.end() + + +@pytest.fixture +def setNd_cmb2(): + return IndexSetND(range(2), range(2)) + + +@pytest.fixture +def setNd_cmb3(): + return IndexSetND(range(2), range(2), range(2)) diff --git a/tests/unit_tests/var_dicts_funcs/constructor_and_attr_test.py b/tests/unit_tests/var_dicts_funcs/constructor_and_attr_test.py index 517d679..4d931f1 100644 --- a/tests/unit_tests/var_dicts_funcs/constructor_and_attr_test.py +++ b/tests/unit_tests/var_dicts_funcs/constructor_and_attr_test.py @@ -21,7 +21,7 @@ def test_vardict_init_typerr0(mdl_1, cls, indexset): docpx_vars = mdl_1.continuous_var_dict(indexset) with pytest.raises(TypeError): - cls(docpx_vars, indexset, model=mdl_1) + cls(docpx_vars, indexset, model=mdl_1, vartype=mdl_1.continuous_vartype) @pytest.mark.parametrize( @@ -34,7 +34,7 @@ def test_vardict_init_typerr0(mdl_1, cls, indexset): @pytest.mark.parametrize('vardict', [0, int, [1, 2], 'A']) def test_vardict_init_typerr1(mdl_1, cls, indexset, vardict): with pytest.raises(TypeError): - cls._create(vardict, indexset, model=mdl_1) + cls._create(vardict, indexset, model=mdl_1, vartype=mdl_1.continuous_vartype) @pytest.mark.parametrize( @@ -46,7 +46,7 @@ def test_vardict_init_typerr1(mdl_1, cls, indexset, vardict): ) def test_vardict_init_valerr1(mdl_1, cls, indexset): with pytest.raises(ValueError): - cls._create({}, indexset, model=mdl_1) + cls._create({}, indexset, model=mdl_1, vartype=mdl_1.continuous_vartype) @pytest.mark.parametrize( @@ -59,7 +59,7 @@ def test_vardict_init_valerr1(mdl_1, cls, indexset): @pytest.mark.parametrize('vardict', [{'A': 0, 'B': 1}, {1: [1, 2], 2: [2, 3]}]) def test_vardict_init_typerr2(mdl_1, cls, indexset, vardict): with pytest.raises(TypeError): - cls._create(vardict, indexset, model=mdl_1) + cls._create(vardict, indexset, model=mdl_1, vartype=mdl_1.continuous_vartype) @pytest.mark.parametrize( @@ -72,7 +72,7 @@ def test_vardict_init_typerr2(mdl_1, cls, indexset, vardict): def test_vardict_init_valerr2(mdl_1, cls, indexset, incorrect): v = add_variables(mdl_1, indexset, 'C') with pytest.raises(ValueError): - cls._create(v, incorrect, model=mdl_1) + cls._create(v, incorrect, model=mdl_1, vartype=mdl_1.continuous_vartype) @pytest.mark.parametrize('name', [None, 'KEY']) @@ -153,6 +153,50 @@ def test_vardict_init_valname_update_typeerr(mdl_1, indexset, input): v.value_name = input +@pytest.mark.parametrize('indexset', [IndexSet1D(range(2)), IndexSetND(range(2), range(2))]) +def test_vardict_attr_model_pass(mdl_1, indexset): + v = add_variables(mdl_1, indexset, 'C') + assert v.model is mdl_1 + + +@pytest.mark.parametrize('indexset', [IndexSet1D(range(2)), IndexSetND(range(2), range(2))]) +def test_vardict_attr_model_attrerr(mdl_1, indexset): + v = add_variables(mdl_1, indexset, 'C') + with pytest.raises(AttributeError): + v.model = 1 + + +@pytest.mark.parametrize( + 'typ', + ['continuous', 'integer', 'binary', 'semicontinuous', 'semiinteger', 'C', 'I', 'B', 'SC', 'SI'], +) +@pytest.mark.parametrize('indexset', [IndexSet1D(range(2)), IndexSetND(range(2), range(2))]) +def test_vardict_attr_vartype_pass(mdl_1, indexset, typ): + v = add_variables(mdl_1, indexset, typ, lb=1) + match typ.lower(): + case 'continuous' | 'c': + assert v.vartype is mdl_1.continuous_vartype + case 'binary' | 'b': + assert v.vartype is mdl_1.binary_vartype + case 'integer' | 'i': + assert v.vartype is mdl_1.integer_vartype + case 'semicontinuous' | 'sc': + assert v.vartype is mdl_1.semicontinuous_vartype + case 'semiinteger' | 'si': + assert v.vartype is mdl_1.semiinteger_vartype + + +@pytest.mark.parametrize( + 'typ', + ['continuous', 'integer', 'binary', 'semicontinuous', 'semiinteger', 'C', 'I', 'B', 'SC', 'SI'], +) +@pytest.mark.parametrize('indexset', [IndexSet1D(range(2)), IndexSetND(range(2), range(2))]) +def test_vardict_attr_vartype_attrerr(mdl_1, indexset, typ): + v = add_variables(mdl_1, indexset, typ, lb=1) + with pytest.raises(AttributeError): + v.vartype = 1 + + @pytest.mark.parametrize('indexset', [IndexSet1D(['A', 'B', 'C']), IndexSetND(range(2), range(2))]) def test_vardict_isinstance_dict(mdl_1, indexset): v = add_variables(mdl_1, indexset, 'C', name='VAL') diff --git a/tests/unit_tests/var_dicts_funcs/vardictND_subset_selection_test.py b/tests/unit_tests/var_dicts_funcs/vardictND_subset_selection_test.py new file mode 100644 index 0000000..27921a7 --- /dev/null +++ b/tests/unit_tests/var_dicts_funcs/vardictND_subset_selection_test.py @@ -0,0 +1,95 @@ +# Copyright 2024 Samarth Mistry +# This file is part of the `docplex-extensions` package, which is released under +# the Apache Licence, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0). + +"""Subset selection methods of VarDictND.""" + +import pytest + +from docplex_extensions import add_variables + + +@pytest.mark.parametrize( + '_input, values, expected', + [ + ('setNd_cmb2', ('*', 1), [(0, 1), (1, 1)]), + ('setNd_cmb2', (0, '*'), [(0, 0), (0, 1)]), + ('setNd_cmb2', (2, '*'), []), + ('setNd_cmb2', ('*', 2), []), + ('setNd_cmb3', (0, '*', '*'), [(0, 0, 0), (0, 0, 1), (0, 1, 0), (0, 1, 1)]), + ('setNd_cmb3', ('*', 1, '*'), [(0, 1, 0), (0, 1, 1), (1, 1, 0), (1, 1, 1)]), + ('setNd_cmb3', (0, 1, '*'), [(0, 1, 0), (0, 1, 1)]), + ('setNd_cmb3', (0, '*', 1), [(0, 0, 1), (0, 1, 1)]), + ('setNd_cmb3', (2, '*', '*'), []), + ('setNd_cmb3', (0, '*', 2), []), + ], +) +def test_subset_keys_pass(mdl_1, request, _input, values, expected): + indexset = request.getfixturevalue(_input) + v = add_variables(mdl_1, indexset, 'C', name='VAL') + + assert v.subset_keys(*values) == expected + + +@pytest.mark.parametrize('values', [(0, '*'), (0, '*', 2, '*')]) +def test_subset_keys_diff_tuplelen(mdl_1, setNd_cmb3, values): + v = add_variables(mdl_1, setNd_cmb3, 'C', name='VAL') + with pytest.raises(ValueError): + v.subset_keys(*values) + + +@pytest.mark.parametrize('values', [((0, 1), (0, 0)), [(0, 1)]]) +def test_subset_keys_nonscaler(mdl_1, setNd_cmb2, values): + v = add_variables(mdl_1, setNd_cmb2, 'C', name='VAL') + with pytest.raises(TypeError): + v.subset_keys(*values) + + +@pytest.mark.parametrize('values', [('*', '*'), (0, 1)]) +def test_subset_keys_invalid_values(mdl_1, setNd_cmb2, values): + v = add_variables(mdl_1, setNd_cmb2, 'C', name='VAL') + with pytest.raises(ValueError): + v.subset_keys(*values) + + +@pytest.mark.parametrize( + '_input, values, expected', + [ + ('setNd_cmb2', ('*', 1), [1, 3]), + ('setNd_cmb2', (0, '*'), [0, 1]), + ('setNd_cmb2', (2, '*'), []), + ('setNd_cmb2', ('*', 2), []), + ('setNd_cmb3', (0, '*', '*'), [0, 1, 2, 3]), + ('setNd_cmb3', ('*', 1, '*'), [2, 3, 6, 7]), + ('setNd_cmb3', (0, 1, '*'), [2, 3]), + ('setNd_cmb3', (0, '*', 1), [1, 3]), + ('setNd_cmb3', (2, '*', '*'), []), + ('setNd_cmb3', (0, '*', 2), []), + ], +) +def test_subset_values_pass(mdl_1, request, _input, values, expected): + indexset = request.getfixturevalue(_input) + v = add_variables(mdl_1, indexset, 'C', name='VAL') + + assert v.subset_values(*values) == [v[x] for x in v.subset_keys(*values)] + + +@pytest.mark.parametrize('values', [(0, '*'), (0, '*', 2, '*')]) +def test_subset_values_diff_tuplelen(mdl_1, setNd_cmb3, values): + v = add_variables(mdl_1, setNd_cmb3, 'C', name='VAL') + with pytest.raises(ValueError): + v.subset_values(*values) + + +@pytest.mark.parametrize('values', [((0, 1), (0, 0)), [(0, 1)]]) +def test_subset_values_nonscaler(mdl_1, setNd_cmb2, values): + v = add_variables(mdl_1, setNd_cmb2, 'C', name='VAL') + with pytest.raises(TypeError): + v.subset_values(*values) + + +@pytest.mark.parametrize('values', [('*', '*'), (0, 1)]) +def test_subset_values_invalid_values(mdl_1, setNd_cmb2, values): + v = add_variables(mdl_1, setNd_cmb2, 'C', name='VAL') + with pytest.raises(ValueError): + v.subset_values(*values)