From 462d520ea2f323003c1584ba9ce312f8d569aee0 Mon Sep 17 00:00:00 2001 From: Joshua Ivanhoe Date: Tue, 18 Jun 2024 12:14:49 +0100 Subject: [PATCH 01/12] Update min Python version (WIP) --- pyproject.toml | 50 ++++++++++++++++++++++++------------ requirements.txt | 7 ----- src/halfspace/convex_term.py | 25 ++++++++---------- src/halfspace/model.py | 36 ++++++++++++-------------- src/halfspace/utils.py | 14 +++++----- tests/test_convex_term.py | 6 ++--- tests/test_model.py | 20 +++------------ tests/test_utils.py | 11 ++++---- 8 files changed, 81 insertions(+), 88 deletions(-) delete mode 100644 requirements.txt diff --git a/pyproject.toml b/pyproject.toml index a52a4ec..0c19f54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,27 +1,45 @@ -[build-system] -requires = ["setuptools >= 61.0"] -build-backend = "setuptools.build_meta" - -[project] +[tool.poetry] name = "halfspace-optimizer" -version = "0.1.1" +version = "0.1.2" +description = "Cutting-plane solver for mixed-integer convex optimization problems" +license ="MIT" authors = [ - { name="Joshua Ivanhoe", email="joshua.k.ivanhoe@gmail.com" }, + "Joshua Ivanhoe " ] -description = "Cutting-plane solver for mixed-integer convex optimization problems" readme = "README.md" -license = {file = "LICENSE"} -requires-python = ">=3.9,<3.12" +repository = "https://github.com/joshivanhoe/halfspace" +documentation = "https://joshivanhoe.github.io/halfspace/" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] -dynamic = ["dependencies"] +packages = [ + { include = "halfspace", from = "src" } +] -[tool.setuptools.dynamic] -dependencies = {file = ["requirements.txt"]} +[tool.poetry.dependencies] +python = ">=3.10,<3.12" +mip = ">=1.15.0" +numpy = ">=1.25.2" +pandas = ">=2.0.3" -[project.urls] -Homepage = "https://github.com/joshivanhoe/halfspace" -Issues = "https://github.com/joshivanhoe/halfspace/issues" +[tool.poetry.group.dev.dependencies] +black = "*" +flake8 = "*" +pre-commit = "*" +tomli = "*" + +[tool.poetry.group.test.dependencies] +mypy = "*" +pytest = "*" +pytest-cov = "*" + +[tool.poetry.group.docs] +optional = true +[tool.poetry.group.docs.dependencies] +sphinx = "*" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 00698ec..0000000 --- a/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -mip>=1.15.0 -numpy>=1.25.2 -pandas>=2.0.3 -pre-commit>=3.6.0 -pytest>=7.4.4 -pytest-cov>=4.1.0 -tomli>=2.0.1 diff --git a/src/halfspace/convex_term.py b/src/halfspace/convex_term.py index 20ec1df..1e139d0 100644 --- a/src/halfspace/convex_term.py +++ b/src/halfspace/convex_term.py @@ -3,18 +3,17 @@ It provides a modular framework for generating cutting planes. """ -from typing import Union, Callable, Optional, Iterable +from typing import Callable, Iterable import mip import numpy as np - QueryPoint = dict[mip.Var, float] -Var = Union[mip.Var, Iterable[mip.Var], mip.LinExprTensor] -Input = Union[float, Iterable[float], np.ndarray] +Var = mip.Var | Iterable[mip.Var] | mip.LinExprTensor +Input = float | Iterable[float] | np.ndarray Func = Callable[[Input], float] -FuncGrad = Callable[[Input], tuple[float, Union[float, np.ndarray]]] -Grad = Callable[[Input], Union[float, np.ndarray]] +FuncGrad = Callable[[Input], tuple[float, float | np.ndarray]] +Grad = Callable[[Input], float | np.ndarray] class ConvexTerm: @@ -39,8 +38,8 @@ class ConvexTerm: def __init__( self, var: Var, - func: Union[Func, FuncGrad], - grad: Optional[Union[Grad, bool]] = None, + func: Func | FuncGrad, + grad: Grad | bool | None = None, step_size: float = 1e-6, name: str = "", ): @@ -61,7 +60,7 @@ def __init__( def __call__( self, query_point: QueryPoint, return_grad: bool = False - ) -> Union[float, tuple[float, Union[float, np.ndarray]]]: + ) -> float | tuple[float, float | np.ndarray]: """Evaluate the term and (optionally) its gradient. Args: @@ -107,9 +106,7 @@ def _get_input(self, query_point: QueryPoint) -> Input: return np.array([query_point[var] for var in self.var]) return query_point[self.var] - def _evaluate_func( - self, x: Input - ) -> Union[float, tuple[float, Union[float, np.ndarray]]]: + def _evaluate_func(self, x: Input) -> float | tuple[float, float | np.ndarray]: """Evaluate the function value. If `grad=True`, then both the value of the function and it's gradient are returned. @@ -120,7 +117,7 @@ def _evaluate_func( return self.func(*x) raise TypeError(f"Input of type '{type(x)}' not supported.") - def _evaluate_grad(self, x: Input) -> Union[float, np.ndarray]: + def _evaluate_grad(self, x: Input) -> float | np.ndarray: """Evaluate the gradient.""" if not self.grad: return self._approximate_grad(x=x) @@ -130,7 +127,7 @@ def _evaluate_grad(self, x: Input) -> Union[float, np.ndarray]: return self.grad(*x) raise TypeError(f"Input of type '{type(x)}' not supported.") - def _approximate_grad(self, x: Input) -> Union[float, np.ndarray]: + def _approximate_grad(self, x: Input) -> float | np.ndarray: """Approximate the gradient of the function at point using the central finite difference method.""" if self.is_multivariable: indexes = np.arange(len(x)) diff --git a/src/halfspace/model.py b/src/halfspace/model.py index 2f85cbc..e236c16 100644 --- a/src/halfspace/model.py +++ b/src/halfspace/model.py @@ -4,7 +4,7 @@ """ import logging -from typing import Optional, Iterable, Union +from typing import Iterable import mip import numpy as np @@ -40,9 +40,9 @@ def __init__( max_gap_abs: float = 1e-4, infeasibility_tol: float = 1e-4, step_size: float = 1e-6, - smoothing: Optional[float] = 0.5, - solver_name: Optional[str] = "CBC", - log_freq: Optional[int] = 1, + smoothing: float | None = 0.5, + solver_name: str | None = "CBC", + log_freq: int | None = 1, ): """Optimization model constructor. @@ -81,13 +81,13 @@ def reset(self) -> None: self._best_solution: dict[mip.Var, float] = dict() self._objective_value: float = (1 if self.minimize else -1) * mip.INF self._best_bound: float = -self._objective_value - self._status: Optional[mip.OptimizationStatus] = None + self._status: mip.OptimizationStatus | None = None self._search_log: list[dict[str, float]] = list() def add_var( self, - lb: Optional[float] = None, - ub: Optional[float] = None, + lb: float | None = None, + ub: float | None = None, var_type: str = mip.CONTINUOUS, name: str = "", ) -> mip.Var: @@ -110,8 +110,8 @@ def add_var( def add_var_tensor( self, shape: tuple[int, ...], - lb: Optional[float] = None, - ub: Optional[float] = None, + lb: float | None = None, + ub: float | None = None, var_type: str = mip.CONTINUOUS, name: str = "", ) -> mip.LinExprTensor: @@ -152,8 +152,8 @@ def add_linear_constr(self, constraint: mip.LinExpr, name: str = "") -> mip.Cons def add_nonlinear_constr( self, var: Var, - func: Union[Func, FuncGrad], - grad: Optional[Union[Grad, bool]] = None, + func: Func | FuncGrad, + grad: Grad | bool | None = None, name: str = "", ) -> ConvexTerm: """Add a nonlinear constraint to the model. @@ -187,8 +187,8 @@ def add_nonlinear_constr( def add_objective_term( self, var: Var, - func: Union[Func, FuncGrad], - grad: Optional[Union[Grad, bool]] = None, + func: Func | FuncGrad, + grad: Grad | bool | None = None, name: str = "", ) -> ConvexTerm: """Add an objective term to the model. @@ -222,8 +222,8 @@ def add_objective_term( def optimize( self, max_iters: int = 100, - max_iters_no_improvement: Optional[int] = None, - max_seconds_per_iter: Optional[float] = None, + max_iters_no_improvement: int | None = None, + max_seconds_per_iter: float | None = None, ) -> mip.OptimizationStatus: """Optimize the model. @@ -355,9 +355,7 @@ def var_by_name(self, name: str) -> mip.Var: """Get a variable by name.""" return self._model.var_by_name(name=name) - def var_value( - self, x: Union[mip.Var, mip.LinExprTensor, str] - ) -> Union[float, np.ndarray]: + def var_value(self, x: mip.Var | mip.LinExprTensor | str) -> float | np.ndarray: """Get the value one or more decision variables corresponding to the best solution. Args: @@ -448,7 +446,7 @@ def search_log(self) -> pd.DataFrame: return pd.DataFrame(self._search_log).set_index("iteration") @staticmethod - def sum(terms: Iterable[Union[mip.Var, mip.LinExpr]]) -> mip.LinExpr: + def sum(terms: Iterable[mip.Var | mip.LinExpr]) -> mip.LinExpr: """Create a linear expression from a summation.""" return mip.xsum(terms=terms) diff --git a/src/halfspace/utils.py b/src/halfspace/utils.py index b43779e..e4da969 100644 --- a/src/halfspace/utils.py +++ b/src/halfspace/utils.py @@ -1,8 +1,8 @@ """Utility functions for the `halfspace` package.""" import logging - -from typing import Union, Iterable, Optional, Any, Type +from numbers import Real, Integral +from typing import Iterable, Any, Type def log_table_header(columns: Iterable[str], width: int = 15) -> None: @@ -25,7 +25,7 @@ def log_table_header(columns: Iterable[str], width: int = 15) -> None: logging.info(line) -def log_table_row(values: Iterable[Union[float, int]], width: int = 15) -> None: +def log_table_row(values: Iterable[Real], width: int = 15) -> None: """Log a table row. Logging level is set to `logging.INFO`. @@ -39,7 +39,7 @@ def log_table_row(values: Iterable[Union[float, int]], width: int = 15) -> None: Returns: None """ values = [ - (f"{{:{width}}}" if isinstance(value, int) else f"{{:{width}.3e}}").format( + (f"{{:{width}}}" if isinstance(value, Integral) else f"{{:{width}.3e}}").format( value ) for value in values @@ -50,9 +50,9 @@ def log_table_row(values: Iterable[Union[float, int]], width: int = 15) -> None: def check_scalar( x: Any, name: str, - var_type: Optional[Union[Type, tuple[Type, ...]]] = None, - lb: Optional[Union[float, int]] = None, - ub: Optional[Union[float, int]] = None, + var_type: Type | tuple[Type, ...] | None = None, + lb: Integral | None = None, + ub: Integral | None = None, include_boundaries: bool = True, ) -> None: """Check that a scalar satisfies certain conditions. diff --git a/tests/test_convex_term.py b/tests/test_convex_term.py index 14249eb..917bf59 100644 --- a/tests/test_convex_term.py +++ b/tests/test_convex_term.py @@ -1,5 +1,3 @@ -from typing import Union, Optional - import mip import numpy as np import pytest @@ -13,7 +11,7 @@ def _process_callbacks( grad: Grad, combine_grad: bool, approximate_grad: bool, -) -> tuple[Union[Func, FuncGrad], Optional[Union[Grad, bool]]]: +) -> tuple[Func | FuncGrad, Grad | bool | None]: if combine_grad and approximate_grad: raise ValueError if combine_grad: @@ -30,7 +28,7 @@ def func_with_grad(*args, **kwargs): def _check_convex_term( term: ConvexTerm, expected_value: float, - expected_grad: Union[float, np.ndarray], + expected_grad: float | np.ndarray, expected_is_multivariable: bool, query_point: QueryPoint, ): diff --git a/tests/test_model.py b/tests/test_model.py index 076ab36..1e67d0a 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,5 +1,3 @@ -from typing import Optional - import mip import numpy as np import pandas as pd @@ -13,8 +11,8 @@ def _check_solution( model: Model, - expected_objective_value: Optional[float], - expected_solution: Optional[dict[Var, float]], + expected_objective_value: float | None = None, + expected_solution: dict[Var, float] | None = None, expected_status: mip.OptimizationStatus = mip.OptimizationStatus.OPTIMAL, ): if expected_objective_value is not None: @@ -124,12 +122,7 @@ def test_multivariable_linear_constraint_infeasible(): ) model.add_linear_constr(x + y >= 3) model.optimize() - _check_solution( - model=model, - expected_objective_value=None, - expected_solution=None, - expected_status=mip.OptimizationStatus.INFEASIBLE, - ) + _check_solution(model=model, expected_status=mip.OptimizationStatus.INFEASIBLE) def test_multivariable_nonlinear_constraint(): @@ -159,9 +152,4 @@ def test_multivariable_nonlinear_constraint_infeasible(): ) model.add_nonlinear_constr(var=(x, y), func=lambda x, y: np.exp(x + y) + 1) model.optimize() - _check_solution( - model=model, - expected_objective_value=None, - expected_solution=None, - expected_status=mip.OptimizationStatus.INFEASIBLE, - ) + _check_solution(model=model, expected_status=mip.OptimizationStatus.INFEASIBLE) diff --git a/tests/test_utils.py b/tests/test_utils.py index 1d965a1..dde117b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,6 @@ from contextlib import nullcontext as does_not_raise -from typing import Union, Iterable, Any, Optional, Type +from numbers import Real +from typing import Iterable, Any, Type import pytest @@ -15,7 +16,7 @@ def test_log_table_header(columns: Iterable[str], width: int): @pytest.mark.parametrize("values", [[1, 1.0, 2e10, 3e-10]]) @pytest.mark.parametrize("width", [10, 15]) -def test_log_table_row(values: Iterable[Union[float, int]], width: int): +def test_log_table_row(values: Iterable[Real], width: int): log_table_row(values=values, width=width) # TODO: add log checks @@ -38,9 +39,9 @@ def test_log_table_row(values: Iterable[Union[float, int]], width: int): def test_check_scalar( x: Any, name: str, - var_type: Optional[Union[Type, tuple[Type, ...]]], - lb: Optional[Union[float, int]], - ub: Optional[Union[float, int]], + var_type: Type | tuple[Type, ...] | None, + lb: Real | None, + ub: Real | None, include_boundaries: bool, expectation, ): From 20fb2c28e4ffc78f642851a0e52b6ede2f14cf3b Mon Sep 17 00:00:00 2001 From: Joshua Ivanhoe Date: Tue, 18 Jun 2024 12:16:55 +0100 Subject: [PATCH 02/12] Update GitHub actions workflows (WIP) --- .github/workflows/ci.yml | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a16f9a2..b933b3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI +name: CI workflow on: push: @@ -13,27 +13,27 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} + + - name: Install poetry + run: python -m pip install poetry + - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + run: poetry install + + - name: Format with black + run: poetry run black ./src + - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - env: - PYTHONPATH: ${{ github.workspace }}/src - run: | - pytest + run: poetry run flake8 --max-line-length 120 + + - name: Run tests + run: poetry run pytest --cov=src From fc73162df69c500e0ab364c533dc6f195b82d9f8 Mon Sep 17 00:00:00 2001 From: Joshua Ivanhoe Date: Tue, 6 Aug 2024 17:37:03 +0100 Subject: [PATCH 03/12] Update Python typing syntax to 3.10+ and fix typing errors --- pyproject.toml | 4 +-- src/halfspace/convex_term.py | 55 ++++++++++++++++++++++-------------- src/halfspace/model.py | 24 ++++++++-------- src/halfspace/utils.py | 22 ++++++++++----- 4 files changed, 64 insertions(+), 41 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0c19f54..c627ad5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,19 +19,19 @@ packages = [ ] [tool.poetry.dependencies] -python = ">=3.10,<3.12" +python = ">=3.10,<=3.12" mip = ">=1.15.0" numpy = ">=1.25.2" pandas = ">=2.0.3" [tool.poetry.group.dev.dependencies] black = "*" +mypy = "*" flake8 = "*" pre-commit = "*" tomli = "*" [tool.poetry.group.test.dependencies] -mypy = "*" pytest = "*" pytest-cov = "*" diff --git a/src/halfspace/convex_term.py b/src/halfspace/convex_term.py index 1e139d0..65d39f8 100644 --- a/src/halfspace/convex_term.py +++ b/src/halfspace/convex_term.py @@ -4,16 +4,19 @@ """ from typing import Callable, Iterable +from typing import TypeAlias, Literal, overload import mip import numpy as np -QueryPoint = dict[mip.Var, float] -Var = mip.Var | Iterable[mip.Var] | mip.LinExprTensor -Input = float | Iterable[float] | np.ndarray -Func = Callable[[Input], float] -FuncGrad = Callable[[Input], tuple[float, float | np.ndarray]] -Grad = Callable[[Input], float | np.ndarray] +from .utils import standard_basis_vector + +QueryPoint: TypeAlias = dict[mip.Var, float] +Var: TypeAlias = mip.Var | Iterable[mip.Var] | mip.LinExprTensor +Input: TypeAlias = float | Iterable[float] | np.ndarray +Func: TypeAlias = Callable[[Input], float] +FuncGrad: TypeAlias = Callable[[Input], tuple[float, float | np.ndarray]] +Grad: TypeAlias = Callable[[Input], float | np.ndarray] class ConvexTerm: @@ -42,7 +45,7 @@ def __init__( grad: Grad | bool | None = None, step_size: float = 1e-6, name: str = "", - ): + ) -> None: """Convex term constructor. Args: @@ -58,6 +61,18 @@ def __init__( self.step_size = step_size self.name = name + @overload + def __call__( + self, query_point: QueryPoint, return_grad: Literal[False] = False + ) -> float: + ... + + @overload + def __call__( + self, query_point: QueryPoint, return_grad: Literal[True] = True + ) -> tuple[float, float | np.ndarray]: + ... + def __call__( self, query_point: QueryPoint, return_grad: bool = False ) -> float | tuple[float, float | np.ndarray]: @@ -95,11 +110,11 @@ def generate_cut(self, query_point: QueryPoint) -> mip.LinExpr: Returns: The linear constraint representing the cutting plane. """ - fun, grad = self(query_point=query_point, return_grad=True) + func, grad = self(query_point=query_point, return_grad=True) x = self._get_input(query_point=query_point) if self.is_multivariable: - return mip.xsum(grad * (np.array(self.var) - x)) + fun - return grad * (self.var - x) + fun + return mip.xsum(grad * (np.array(self.var) - x)) + func + return grad * (self.var - x) + func def _get_input(self, query_point: QueryPoint) -> Input: if self.is_multivariable: @@ -130,17 +145,15 @@ def _evaluate_grad(self, x: Input) -> float | np.ndarray: def _approximate_grad(self, x: Input) -> float | np.ndarray: """Approximate the gradient of the function at point using the central finite difference method.""" if self.is_multivariable: - indexes = np.arange(len(x)) - return np.array( - [ - ( - self._evaluate_func(x=x + self.step_size / 2 * (indexes == i)) - - self._evaluate_func(x=x - self.step_size / 2 * (indexes == i)) - ) - / self.step_size - for i in indexes - ] - ) + n_dim = len(x) + grad = np.zeros(n_dim) + for i in range(n_dim): + e_i = standard_basis_vector(i=i, n_dim=n_dim) + grad[i] = ( + self._evaluate_func(x=x + self.step_size / 2 * e_i) + - self._evaluate_func(x=x - self.step_size / 2 * e_i) + ) / self.step_size + return grad return ( self._evaluate_func(x=x + self.step_size / 2) - self._evaluate_func(x=x - self.step_size / 2) diff --git a/src/halfspace/model.py b/src/halfspace/model.py index e236c16..63240f4 100644 --- a/src/halfspace/model.py +++ b/src/halfspace/model.py @@ -43,7 +43,7 @@ def __init__( smoothing: float | None = 0.5, solver_name: str | None = "CBC", log_freq: int | None = 1, - ): + ) -> None: """Optimization model constructor. Args: @@ -86,8 +86,8 @@ def reset(self) -> None: def add_var( self, - lb: float | None = None, - ub: float | None = None, + lb: float | int = 0, + ub: float | int = mip.INF, var_type: str = mip.CONTINUOUS, name: str = "", ) -> mip.Var: @@ -110,8 +110,8 @@ def add_var( def add_var_tensor( self, shape: tuple[int, ...], - lb: float | None = None, - ub: float | None = None, + lb: float | int = 0, + ub: float | int = mip.INF, var_type: str = mip.CONTINUOUS, name: str = "", ) -> mip.LinExprTensor: @@ -454,14 +454,14 @@ def _validate_params(self) -> None: check_scalar( x=self.max_gap, name="max_gap", - lb=0.0, + lb=0, var_type=float, include_boundaries=False, ) check_scalar( x=self.max_gap_abs, name="max_gap_abs", - lb=0.0, + lb=0, var_type=float, include_boundaries=False, ) @@ -469,7 +469,7 @@ def _validate_params(self) -> None: x=self.infeasibility_tol, name="feasibility_tol", var_type=float, - lb=0.0, + lb=0, include_boundaries=False, ) if self.smoothing is not None: @@ -477,8 +477,8 @@ def _validate_params(self) -> None: x=self.smoothing, name="smoothing", var_type=float, - lb=0.0, - ub=1.0, + lb=0, + ub=1, include_boundaries=False, ) if self.log_freq is not None: @@ -491,7 +491,9 @@ def _validate_params(self) -> None: ) @staticmethod - def _validate_bounds(lb: float, ub: float, var_type: str) -> tuple[float, float]: + def _validate_bounds( + lb: float | int, ub: float | int, var_type: str + ) -> tuple[float | int, float | int]: if var_type == mip.BINARY: lb, ub = 0, 1 else: diff --git a/src/halfspace/utils.py b/src/halfspace/utils.py index e4da969..48ce9a3 100644 --- a/src/halfspace/utils.py +++ b/src/halfspace/utils.py @@ -1,9 +1,10 @@ """Utility functions for the `halfspace` package.""" import logging -from numbers import Real, Integral from typing import Iterable, Any, Type +import numpy as np + def log_table_header(columns: Iterable[str], width: int = 15) -> None: """Log a table header. @@ -25,7 +26,7 @@ def log_table_header(columns: Iterable[str], width: int = 15) -> None: logging.info(line) -def log_table_row(values: Iterable[Real], width: int = 15) -> None: +def log_table_row(values: Iterable[float | int], width: int = 15) -> None: """Log a table row. Logging level is set to `logging.INFO`. @@ -38,21 +39,21 @@ def log_table_row(values: Iterable[Real], width: int = 15) -> None: Returns: None """ - values = [ - (f"{{:{width}}}" if isinstance(value, Integral) else f"{{:{width}.3e}}").format( + values_ = [ + (f"{{:{width}}}" if isinstance(value, int) else f"{{:{width}.3e}}").format( value ) for value in values ] - logging.info("|{}|".format("|".join(values))) + logging.info("|{}|".format("|".join(values_))) def check_scalar( x: Any, name: str, var_type: Type | tuple[Type, ...] | None = None, - lb: Integral | None = None, - ub: Integral | None = None, + lb: float | int | None = None, + ub: float | int | None = None, include_boundaries: bool = True, ) -> None: """Check that a scalar satisfies certain conditions. @@ -93,3 +94,10 @@ def check_scalar( assert ( x < ub ), f"Variable '{name}' ({x}) is greater than or equal to lower bound ({ub})." + + +def standard_basis_vector(i: int, n_dim: int) -> np.ndarray: + """Return the ith standard basis vector in R^n.""" + x = np.zeros(n_dim) + x[i] = 1 + return x From 3ac63318bbe7074785f9016890db344e68a3b03a Mon Sep 17 00:00:00 2001 From: Joshua Ivanhoe Date: Wed, 7 Aug 2024 11:46:04 +0100 Subject: [PATCH 04/12] Update pre-commit hooks to use ruff and reformat files --- .pre-commit-config.yaml | 8 ++++-- src/halfspace/__init__.py | 3 ++ src/halfspace/convex_term.py | 15 +++------- src/halfspace/model.py | 54 ++++++++++-------------------------- src/halfspace/utils.py | 23 ++++----------- tests/test_convex_term.py | 28 +++++-------------- tests/test_model.py | 32 ++++++--------------- 7 files changed, 46 insertions(+), 117 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ddbf496..64318bb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,10 +5,12 @@ repos: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace -- repo: https://github.com/psf/black - rev: 22.10.0 +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.6 hooks: - - id: black + - id: ruff + - id: ruff-format + args: [--line-length=120] - repo: https://github.com/pycqa/pydocstyle rev: 6.3.0 hooks: diff --git a/src/halfspace/__init__.py b/src/halfspace/__init__.py index 2f4d902..64b0b79 100644 --- a/src/halfspace/__init__.py +++ b/src/halfspace/__init__.py @@ -1,2 +1,5 @@ """The `halfspace` module implements a modelling class for mixed-integer convex optimization problems.""" + from .model import Model + +__all__ = ["Model"] diff --git a/src/halfspace/convex_term.py b/src/halfspace/convex_term.py index 65d39f8..26da12b 100644 --- a/src/halfspace/convex_term.py +++ b/src/halfspace/convex_term.py @@ -62,20 +62,14 @@ def __init__( self.name = name @overload - def __call__( - self, query_point: QueryPoint, return_grad: Literal[False] = False - ) -> float: - ... + def __call__(self, query_point: QueryPoint, return_grad: Literal[False] = False) -> float: ... @overload def __call__( self, query_point: QueryPoint, return_grad: Literal[True] = True - ) -> tuple[float, float | np.ndarray]: - ... + ) -> tuple[float, float | np.ndarray]: ... - def __call__( - self, query_point: QueryPoint, return_grad: bool = False - ) -> float | tuple[float, float | np.ndarray]: + def __call__(self, query_point: QueryPoint, return_grad: bool = False) -> float | tuple[float, float | np.ndarray]: """Evaluate the term and (optionally) its gradient. Args: @@ -155,6 +149,5 @@ def _approximate_grad(self, x: Input) -> float | np.ndarray: ) / self.step_size return grad return ( - self._evaluate_func(x=x + self.step_size / 2) - - self._evaluate_func(x=x - self.step_size / 2) + self._evaluate_func(x=x + self.step_size / 2) - self._evaluate_func(x=x - self.step_size / 2) ) / self.step_size diff --git a/src/halfspace/model.py b/src/halfspace/model.py index 63240f4..0be9d1c 100644 --- a/src/halfspace/model.py +++ b/src/halfspace/model.py @@ -243,13 +243,10 @@ def optimize( self._model.objective = bound # Initialize search - query_point = { - x: self._start.get(x) or (x.lb + x.ub) / 2 for x in self._model.vars - } + query_point = {x: self._start.get(x) or (x.lb + x.ub) / 2 for x in self._model.vars} iters_no_improvement = 0 for i in range(max_iters): - # Add cuts for violated nonlinear constraints for constr in self.nonlinear_constrs: if constr(query_point=query_point) > self.infeasibility_tol: @@ -257,10 +254,7 @@ def optimize( self._model.add_constr(expr <= 0) # Add objective cut - expr = mip.xsum( - term.generate_cut(query_point=query_point) - for term in self.objective_terms - ) + expr = mip.xsum(term.generate_cut(query_point=query_point) for term in self.objective_terms) if self.minimize: self._model.add_constr(bound >= expr) else: @@ -274,20 +268,15 @@ def optimize( mip.OptimizationStatus.OPTIMAL, mip.OptimizationStatus.FEASIBLE, ): - logging.info( - f"Solve unsuccessful - exiting with optimization status: '{status.value}'." - ) + logging.info(f"Solve unsuccessful - exiting with optimization status: '{status.value}'.") self._status = status return self.status # Update best solution/objective value and query point solution = {var: var.x for var in self._model.vars} - objective_value_new = sum( - term(query_point=solution) for term in self.objective_terms - ) + objective_value_new = sum(term(query_point=solution) for term in self.objective_terms) if self.minimize == (objective_value_new < self.objective_value) and all( - constr(solution) <= self.infeasibility_tol - for constr in self.nonlinear_constrs + constr(solution) <= self.infeasibility_tol for constr in self.nonlinear_constrs ): iters_no_improvement = 0 self._objective_value = objective_value_new @@ -297,8 +286,7 @@ def optimize( iters_no_improvement += 1 if self.smoothing is not None: query_point = { - var: self.smoothing * query_point[var] - + (1 - self.smoothing) * solution[var] + var: self.smoothing * query_point[var] + (1 - self.smoothing) * solution[var] for var in self._model.vars } else: @@ -306,13 +294,9 @@ def optimize( # Update best bound (clip values to prevent numerical errors from affecting termination logic) if self.minimize: - self._best_bound = np.clip( - bound.x, a_min=self.best_bound, a_max=self.objective_value - ) + self._best_bound = np.clip(bound.x, a_min=self.best_bound, a_max=self.objective_value) else: - self._best_bound = np.clip( - bound.x, a_min=self.objective_value, a_max=self.best_bound - ) + self._best_bound = np.clip(bound.x, a_min=self.objective_value, a_max=self.best_bound) # Update log self._search_log.append( @@ -331,20 +315,16 @@ def optimize( # Check early termination conditions if self.gap <= self.max_gap or self.gap_abs <= self.max_gap_abs: - logging.info( - f"Optimality tolerance reached - terminating search early." - ) + logging.info("Optimality tolerance reached - terminating search early.") self._status = mip.OptimizationStatus.OPTIMAL return self.status if max_iters_no_improvement is not None: if iters_no_improvement >= max_iters_no_improvement: - logging.info( - f"Max iterations without improvement reached - terminating search early." - ) + logging.info("Max iterations without improvement reached - terminating search early.") self._status = mip.OptimizationStatus.FEASIBLE return self.status - logging.info(f"Max iterations reached - terminating search.") + logging.info("Max iterations reached - terminating search.") if self.best_solution: self._status = mip.OptimizationStatus.FEASIBLE else: @@ -371,9 +351,7 @@ def var_value(self, x: mip.Var | mip.LinExprTensor | str) -> float | np.ndarray: if isinstance(x, mip.Var): return self.best_solution[x] if isinstance(x, mip.LinExprTensor): - return np.array([self.best_solution[var] for var in x.flatten()]).reshape( - x.shape - ) + return np.array([self.best_solution[var] for var in x.flatten()]).reshape(x.shape) if isinstance(x, Iterable): return np.array([self.best_solution[var] for var in x]) raise TypeError(f"Input of type '{type(x)}' not supported.") @@ -426,9 +404,7 @@ def best_bound(self) -> float: @property def gap(self) -> float: """Get the (relative) optimality gap.""" - return self.gap_abs / max( - min(abs(self.objective_value), abs(self.best_bound)), 1e-10 - ) + return self.gap_abs / max(min(abs(self.objective_value), abs(self.best_bound)), 1e-10) @property def gap_abs(self) -> float: @@ -491,9 +467,7 @@ def _validate_params(self) -> None: ) @staticmethod - def _validate_bounds( - lb: float | int, ub: float | int, var_type: str - ) -> tuple[float | int, float | int]: + def _validate_bounds(lb: float | int, ub: float | int, var_type: str) -> tuple[float | int, float | int]: if var_type == mip.BINARY: lb, ub = 0, 1 else: diff --git a/src/halfspace/utils.py b/src/halfspace/utils.py index 48ce9a3..b2c6ba9 100644 --- a/src/halfspace/utils.py +++ b/src/halfspace/utils.py @@ -39,12 +39,7 @@ def log_table_row(values: Iterable[float | int], width: int = 15) -> None: Returns: None """ - values_ = [ - (f"{{:{width}}}" if isinstance(value, int) else f"{{:{width}.3e}}").format( - value - ) - for value in values - ] + values_ = [(f"{{:{width}}}" if isinstance(value, int) else f"{{:{width}.3e}}").format(value) for value in values] logging.info("|{}|".format("|".join(values_))) @@ -75,25 +70,17 @@ def check_scalar( Returns: None """ if var_type is not None: - assert isinstance( - x, var_type - ), f"Variable '{name}' ({type(x)}) is not expected type ({var_type})." + assert isinstance(x, var_type), f"Variable '{name}' ({type(x)}) is not expected type ({var_type})." if lb is not None: if include_boundaries: assert x >= lb, f"Variable '{name}' ({x}) is less than lower bound ({lb})." else: - assert ( - x > lb - ), f"Variable '{name}' ({x}) is less than or equal to lower bound ({lb})." + assert x > lb, f"Variable '{name}' ({x}) is less than or equal to lower bound ({lb})." if ub is not None: if include_boundaries: - assert ( - x <= ub - ), f"Variable '{name}' ({x}) is greater than lower bound ({ub})." + assert x <= ub, f"Variable '{name}' ({x}) is greater than lower bound ({ub})." else: - assert ( - x < ub - ), f"Variable '{name}' ({x}) is greater than or equal to lower bound ({ub})." + assert x < ub, f"Variable '{name}' ({x}) is greater than or equal to lower bound ({ub})." def standard_basis_vector(i: int, n_dim: int) -> np.ndarray: diff --git a/tests/test_convex_term.py b/tests/test_convex_term.py index 917bf59..d3953fc 100644 --- a/tests/test_convex_term.py +++ b/tests/test_convex_term.py @@ -33,9 +33,7 @@ def _check_convex_term( query_point: QueryPoint, ): # Check evaluation without gradient - assert term(query_point=query_point, return_grad=False) == pytest.approx( - expected_value - ) + assert term(query_point=query_point, return_grad=False) == pytest.approx(expected_value) # Check evaluation with gradient value, grad = term(query_point=query_point, return_grad=True) @@ -64,9 +62,7 @@ def model() -> Model: ({"x": 1}, 1, 2), ], ) -@pytest.mark.parametrize( - ["combine_grad", "approximate_grad"], [(True, False), (False, True), (False, False)] -) +@pytest.mark.parametrize(["combine_grad", "approximate_grad"], [(True, False), (False, True), (False, False)]) def test_single_variable_term( model: Model, query_point: dict[str, float], @@ -91,9 +87,7 @@ def test_single_variable_term( expected_value=expected_value, expected_grad=expected_grad, expected_is_multivariable=False, - query_point={ - model.var_by_name(name=name): value for name, value in query_point.items() - }, + query_point={model.var_by_name(name=name): value for name, value in query_point.items()}, ) @@ -104,9 +98,7 @@ def test_single_variable_term( ({"x": 1, "y": 2}, 5, np.array([2, 4])), ], ) -@pytest.mark.parametrize( - ["combine_grad", "approximate_grad"], [(True, False), (False, True), (False, False)] -) +@pytest.mark.parametrize(["combine_grad", "approximate_grad"], [(True, False), (False, True), (False, False)]) def test_multivariable_term( model: Model, query_point: dict[str, float], @@ -131,9 +123,7 @@ def test_multivariable_term( expected_value=expected_value, expected_grad=expected_grad, expected_is_multivariable=True, - query_point={ - model.var_by_name(name=name): value for name, value in query_point.items() - }, + query_point={model.var_by_name(name=name): value for name, value in query_point.items()}, ) @@ -144,9 +134,7 @@ def test_multivariable_term( ({"z_0": 1, "z_1": 2}, 5, np.array([2, 4])), ], ) -@pytest.mark.parametrize( - ["combine_grad", "approximate_grad"], [(True, False), (False, True), (False, False)] -) +@pytest.mark.parametrize(["combine_grad", "approximate_grad"], [(True, False), (False, True), (False, False)]) def test_var_tensor_term( model: Model, query_point: dict[str, float], @@ -171,7 +159,5 @@ def test_var_tensor_term( expected_value=expected_value, expected_grad=expected_grad, expected_is_multivariable=True, - query_point={ - model.var_by_name(name=name): value for name, value in query_point.items() - }, + query_point={model.var_by_name(name=name): value for name, value in query_point.items()}, ) diff --git a/tests/test_model.py b/tests/test_model.py index 1e67d0a..95a7c49 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -16,9 +16,7 @@ def _check_solution( expected_status: mip.OptimizationStatus = mip.OptimizationStatus.OPTIMAL, ): if expected_objective_value is not None: - assert model.objective_value == pytest.approx( - expected_objective_value, abs=model.max_gap_abs - ) + assert model.objective_value == pytest.approx(expected_objective_value, abs=model.max_gap_abs) if expected_solution is not None: for x, expected_value in expected_solution.items(): assert model.var_value(x=x) == pytest.approx(expected_value, abs=VAR_TOL) @@ -72,9 +70,7 @@ def test_multivariable_variable_no_constraints(): model = Model() x = model.add_var(lb=0, ub=1) y = model.add_var(lb=0, ub=1) - model.add_objective_term( - var=(x, y), func=lambda x, y: (x - 0.25) ** 2 + (y - 0.25) ** 2 + 1 - ) + model.add_objective_term(var=(x, y), func=lambda x, y: (x - 0.25) ** 2 + (y - 0.25) ** 2 + 1) model.optimize() _check_solution( model=model, @@ -86,9 +82,7 @@ def test_multivariable_variable_no_constraints(): def test_multivariable_variable_as_tensor_no_constraints(): model = Model() x = model.add_var_tensor(shape=(2,), lb=0, ub=1) - model.add_objective_term( - var=x, func=lambda x: (x[0] - 0.25) ** 2 + (x[1] - 0.25) ** 2 + 1 - ) + model.add_objective_term(var=x, func=lambda x: (x[0] - 0.25) ** 2 + (x[1] - 0.25) ** 2 + 1) model.optimize() _check_solution( model=model, @@ -101,9 +95,7 @@ def test_multivariable_linear_constraint(): model = Model() x = model.add_var(lb=0, ub=1) y = model.add_var(lb=0, ub=1) - model.add_objective_term( - var=(x, y), func=lambda x, y: (x - 0.25) ** 2 + (y - 0.25) ** 2 + 1 - ) + model.add_objective_term(var=(x, y), func=lambda x, y: (x - 0.25) ** 2 + (y - 0.25) ** 2 + 1) model.add_linear_constr(100 * x + y <= 0.25) model.optimize() _check_solution( @@ -117,9 +109,7 @@ def test_multivariable_linear_constraint_infeasible(): model = Model() x = model.add_var(lb=0, ub=1) y = model.add_var(lb=0, ub=1) - model.add_objective_term( - var=(x, y), func=lambda x, y: (x - 0.25) ** 2 + (y - 0.25) ** 2 + 1 - ) + model.add_objective_term(var=(x, y), func=lambda x, y: (x - 0.25) ** 2 + (y - 0.25) ** 2 + 1) model.add_linear_constr(x + y >= 3) model.optimize() _check_solution(model=model, expected_status=mip.OptimizationStatus.INFEASIBLE) @@ -129,12 +119,8 @@ def test_multivariable_nonlinear_constraint(): model = Model(max_gap_abs=1e-2) x = model.add_var(lb=0, ub=1) y = model.add_var(lb=0, ub=1) - model.add_objective_term( - var=(x, y), func=lambda x, y: (x - 0.25) ** 2 + (y - 0.25) ** 2 + 1 - ) - model.add_nonlinear_constr( - var=(x, y), func=lambda x, y: (80 * x) ** 2 + y**2 - 0.25**2 - ) + model.add_objective_term(var=(x, y), func=lambda x, y: (x - 0.25) ** 2 + (y - 0.25) ** 2 + 1) + model.add_nonlinear_constr(var=(x, y), func=lambda x, y: (80 * x) ** 2 + y**2 - 0.25**2) model.optimize() _check_solution( model=model, @@ -147,9 +133,7 @@ def test_multivariable_nonlinear_constraint_infeasible(): model = Model(max_gap_abs=1e-2) x = model.add_var(lb=0, ub=1) y = model.add_var(lb=0, ub=1) - model.add_objective_term( - var=(x, y), func=lambda x, y: (x - 0.25) ** 2 + (y - 0.25) ** 2 + 1 - ) + model.add_objective_term(var=(x, y), func=lambda x, y: (x - 0.25) ** 2 + (y - 0.25) ** 2 + 1) model.add_nonlinear_constr(var=(x, y), func=lambda x, y: np.exp(x + y) + 1) model.optimize() _check_solution(model=model, expected_status=mip.OptimizationStatus.INFEASIBLE) From a522c0d2225781c83ca5178dc74953169c202aec Mon Sep 17 00:00:00 2001 From: Joshua Ivanhoe Date: Thu, 8 Aug 2024 14:51:07 +0100 Subject: [PATCH 05/12] Update GitHub actions --- .github/workflows/ci.yml | 17 +++++++++++------ .github/workflows/docs.yml | 2 +- .github/workflows/release.yml | 2 +- pyproject.toml | 23 ++++++++++++++++------- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b933b3a..b436928 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI workflow on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] jobs: build: @@ -29,11 +29,16 @@ jobs: - name: Install dependencies run: poetry install - - name: Format with black - run: poetry run black ./src + - name: Lint and format with ruff + run: | + ruff check --fix + ruff format + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: "Lint and format with ruff" - - name: Lint with flake8 - run: poetry run flake8 --max-line-length 120 + - name: Perform type checking with mypy + run: mypy src - name: Run tests run: poetry run pytest --cov=src diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2194228..16066e8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,7 +1,7 @@ name: Generate API documentation on: push: - branches: [ "main" ] + branches: ["main"] jobs: deploy: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c0b6e0..cd4b376 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v3 with: - python-version: '3.11' + python-version: "3.11" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/pyproject.toml b/pyproject.toml index c627ad5..ba32cb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,20 +25,29 @@ numpy = ">=1.25.2" pandas = ">=2.0.3" [tool.poetry.group.dev.dependencies] -black = "*" mypy = "*" -flake8 = "*" pre-commit = "*" -tomli = "*" +ruff = "*" [tool.poetry.group.test.dependencies] pytest = "*" pytest-cov = "*" -[tool.poetry.group.docs] -optional = true -[tool.poetry.group.docs.dependencies] -sphinx = "*" +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +extend-select = ["D"] # pydocstyle + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["D"] # Ignore all directories named `tests` for pydocstyle + +[tool.mypy] +ignore_missing_imports = true +exclude = ["convex_term.py"] [build-system] requires = ["poetry-core"] From c3c1df049258c9b1e000d8c387493d3b54849744 Mon Sep 17 00:00:00 2001 From: Joshua Ivanhoe Date: Thu, 8 Aug 2024 14:55:39 +0100 Subject: [PATCH 06/12] Update GitHub actions --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b436928..c20d4c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,8 @@ jobs: run: | ruff check --fix ruff format + + - name: Auto-commit ruff changes uses: stefanzweifel/git-auto-commit-action@v4 with: commit_message: "Lint and format with ruff" From 0d9713eac514a8f5dd6984dd1ab7e7e77cb60633 Mon Sep 17 00:00:00 2001 From: Joshua Ivanhoe Date: Thu, 8 Aug 2024 15:25:25 +0100 Subject: [PATCH 07/12] Update GitHub actions --- .github/workflows/ci.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c20d4c4..a8919da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,9 +30,8 @@ jobs: run: poetry install - name: Lint and format with ruff - run: | - ruff check --fix - ruff format + run: ruff check --fix + #ruff format - name: Auto-commit ruff changes uses: stefanzweifel/git-auto-commit-action@v4 From 47b471a5166b56db70ab6b2c254d5e067144dc6d Mon Sep 17 00:00:00 2001 From: Joshua Ivanhoe Date: Thu, 8 Aug 2024 15:42:50 +0100 Subject: [PATCH 08/12] Update GitHub actions --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ba32cb8..7ee4b35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ packages = [ ] [tool.poetry.dependencies] -python = ">=3.10,<=3.12" +python = ">=3.10,<3.13" mip = ">=1.15.0" numpy = ">=1.25.2" pandas = ">=2.0.3" From 55ddba889d591b12b628ce28497fd2466fe4e131 Mon Sep 17 00:00:00 2001 From: Joshua Ivanhoe Date: Thu, 8 Aug 2024 16:40:24 +0100 Subject: [PATCH 09/12] Update GitHub actions --- .github/workflows/ci.yml | 5 +++-- pyproject.toml | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8919da..c20d4c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,9 @@ jobs: run: poetry install - name: Lint and format with ruff - run: ruff check --fix - #ruff format + run: | + ruff check --fix + ruff format - name: Auto-commit ruff changes uses: stefanzweifel/git-auto-commit-action@v4 diff --git a/pyproject.toml b/pyproject.toml index 7ee4b35..496d64f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ python = ">=3.10,<3.13" mip = ">=1.15.0" numpy = ">=1.25.2" pandas = ">=2.0.3" +ruff = "*" [tool.poetry.group.dev.dependencies] mypy = "*" From e9f3cbe6ad612d20e2974f993642ba3fe5562589 Mon Sep 17 00:00:00 2001 From: Joshua Ivanhoe Date: Thu, 8 Aug 2024 16:59:47 +0100 Subject: [PATCH 10/12] Update GitHub actions --- .github/workflows/ci.yml | 4 ++-- pyproject.toml | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c20d4c4..ed7288a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,8 +31,8 @@ jobs: - name: Lint and format with ruff run: | - ruff check --fix - ruff format + python -m ruff check --fix + python -m ruff format - name: Auto-commit ruff changes uses: stefanzweifel/git-auto-commit-action@v4 diff --git a/pyproject.toml b/pyproject.toml index 496d64f..7ee4b35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,6 @@ python = ">=3.10,<3.13" mip = ">=1.15.0" numpy = ">=1.25.2" pandas = ">=2.0.3" -ruff = "*" [tool.poetry.group.dev.dependencies] mypy = "*" From c1683dab482ad4cc8b326108f995c099d3786993 Mon Sep 17 00:00:00 2001 From: Joshua Ivanhoe Date: Fri, 12 Sep 2025 10:36:29 +0100 Subject: [PATCH 11/12] feat: Various improvements (WIP) - Better docs - Additional tests - Minor bug fixes --- .github/workflows/ci.yml | 16 +- pyproject.toml | 99 ++++++---- src/halfspace/convex_term.py | 96 ++++++--- src/halfspace/model.py | 324 ++++++++++++++++++++----------- src/halfspace/utils.py | 52 ++--- tests/test_convex_term.py | 32 +++ tests/test_model.py | 36 ++++ tests/test_utils.py | 14 +- uv.lock | 366 +++++++++++++++++++++++++++++++++++ 9 files changed, 814 insertions(+), 221 deletions(-) create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed7288a..ea9f6af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.12"] steps: - uses: actions/checkout@v3 @@ -24,20 +24,10 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install poetry - run: python -m pip install poetry + run: python -m pip install uv - name: Install dependencies - run: poetry install - - - name: Lint and format with ruff - run: | - python -m ruff check --fix - python -m ruff format - - - name: Auto-commit ruff changes - uses: stefanzweifel/git-auto-commit-action@v4 - with: - commit_message: "Lint and format with ruff" + run: uv sync --extra all - name: Perform type checking with mypy run: mypy src diff --git a/pyproject.toml b/pyproject.toml index 7ee4b35..4df8f39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,54 +1,77 @@ -[tool.poetry] +[project] name = "halfspace-optimizer" -version = "0.1.2" -description = "Cutting-plane solver for mixed-integer convex optimization problems" -license ="MIT" +version = "0.1.1" authors = [ - "Joshua Ivanhoe " + { name="Joshua Ivanhoe", email="joshua.k.ivanhoe@gmail.com" }, ] +description = "Cutting-plane solver for mixed-integer convex optimization problems" readme = "README.md" -repository = "https://github.com/joshivanhoe/halfspace" -documentation = "https://joshivanhoe.github.io/halfspace/" +license = {file = "LICENSE"} +requires-python = ">=3.12,<3.13" +keywords = ["optimization", "mixed-integer", "convex", "cutting-plane", "solver"] classifiers = [ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering :: Mathematics", ] -packages = [ - { include = "halfspace", from = "src" } +dependencies = [ + "mip>=1.15.0,<2.0.0", + "numpy>=2.3.3,<3.0.0", + "pandas>=2.3.2,<3.0.0", ] -[tool.poetry.dependencies] -python = ">=3.10,<3.13" -mip = ">=1.15.0" -numpy = ">=1.25.2" -pandas = ">=2.0.3" - -[tool.poetry.group.dev.dependencies] -mypy = "*" -pre-commit = "*" -ruff = "*" +[project.urls] +Homepage = "https://github.com/joshivanhoe/halfspace" +Issues = "https://github.com/joshivanhoe/halfspace/issues" -[tool.poetry.group.test.dependencies] -pytest = "*" -pytest-cov = "*" +[project.optional-dependencies] +dev = [ + "ruff", + "pre-commit", + "tomli", +] +test = [ + "pytest", + "pytest-cov", +] +all = [ + "halfspace-optimizer[dev,test]", +] -[tool.ruff] -line-length = 100 +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" -[tool.ruff.lint] -extend-select = ["D"] # pydocstyle +[tool.uv] +index-url = "https://pypi.org/simple" +extra-index-url = [] -[tool.ruff.lint.pydocstyle] -convention = "google" +[tool.hatch.build.targets.wheel] +packages = ["src/halfspace"] -[tool.ruff.lint.per-file-ignores] -"tests/**" = ["D"] # Ignore all directories named `tests` for pydocstyle +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "--strict-markers", + "--strict-config", + "--cov=src/halfspace", + "--cov-report=term-missing", + "--cov-report=html", +] -[tool.mypy] -ignore_missing_imports = true -exclude = ["convex_term.py"] +[tool.coverage.run] +source = ["src/halfspace"] +omit = ["*/tests/*"] -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", +] diff --git a/src/halfspace/convex_term.py b/src/halfspace/convex_term.py index 26da12b..fad11ac 100644 --- a/src/halfspace/convex_term.py +++ b/src/halfspace/convex_term.py @@ -3,20 +3,19 @@ It provides a modular framework for generating cutting planes. """ -from typing import Callable, Iterable -from typing import TypeAlias, Literal, overload +from typing import Callable, Iterable, Literal, overload import mip import numpy as np -from .utils import standard_basis_vector +from .utils import check_scalar -QueryPoint: TypeAlias = dict[mip.Var, float] -Var: TypeAlias = mip.Var | Iterable[mip.Var] | mip.LinExprTensor -Input: TypeAlias = float | Iterable[float] | np.ndarray -Func: TypeAlias = Callable[[Input], float] -FuncGrad: TypeAlias = Callable[[Input], tuple[float, float | np.ndarray]] -Grad: TypeAlias = Callable[[Input], float | np.ndarray] +type QueryPoint = dict[mip.Var, float] +type Var = mip.Var | Iterable[mip.Var] | mip.LinExprTensor +type Input = float | Iterable[float] | np.ndarray +type Func = Callable[[Input], float] +type FuncGrad = Callable[[Input], tuple[float, float | np.ndarray]] +type Grad = Callable[[Input], float | np.ndarray] class ConvexTerm: @@ -25,15 +24,15 @@ class ConvexTerm: Attributes: var: The variable(s) included in the term. This can be provided in the form of a single variable, an iterable of multiple variables or a variable tensor. - func: A function for computing the term's value. This function should except one argument for each + func: A function for computing the term's value. This function should accept one argument for each variable in `var`. If `var` is a variable tensor, then the function should accept a single array. - grad: A function for computing the term's gradient. This function should except one argument for each + grad: A function for computing the term's gradient. This function should accept one argument for each variable in `var`. If `var` is a variable tensor, then the function should accept a single array. If `None`, then the gradient is approximated numerically using the central finite difference method. If `grad` is instead a Boolean and is `True`, then `func` is assumed to return a tuple where the first element is the function value and the second element is the gradient. This is useful when the gradient is expensive to compute. - step_size: The step size used for numerical gradient approximation. If `grad` is provided, then this argument + step_size: The step size used for numerical gradient approximation. Must be positive. If `grad` is provided, then this argument is ignored. name: The name for the term. """ @@ -49,12 +48,19 @@ def __init__( """Convex term constructor. Args: - var: The value of the `var` attribute. - func: The value of the `func` attribute. - grad: The value of the `grad` attribute. - step_size: The value of the `step_size` attribute. Must be positive. - name: The value of the `name` attribute. + var: The variable(s) included in the term. Can be a single variable, an iterable of variables, or a variable tensor. + func: The function for computing the term's value. + grad: The function for computing the term's gradient, or None for numerical approximation, or True if func returns (value, grad). + step_size: The step size for numerical gradient approximation. Must be positive. + name: The name for the term. """ + check_scalar( + x=step_size, + name="step_size", + var_type=float, + lb=0, + include_boundaries=False, + ) self.var = var self.func = func self.grad = grad @@ -77,7 +83,7 @@ def __call__(self, query_point: QueryPoint, return_grad: bool = False) -> float return_grad: Whether to return the term's gradient. Returns: - If `return_grad=False`, then only the value of the term is returned. Conversely, if `return_grad=True`, + If `return_grad=False`, then only the value of the term is returned. If `return_grad=True`, then a tuple is returned where the first element is the term's value and the second element is the term's gradient. """ @@ -91,26 +97,40 @@ def __call__(self, query_point: QueryPoint, return_grad: bool = False) -> float @property def is_multivariable(self) -> bool: - """Check whether the term is multivariable.""" + """Check whether the term is multivariable. + + Returns: + True if the term involves multiple variables, False otherwise. + """ return not isinstance(self.var, mip.Var) def generate_cut(self, query_point: QueryPoint) -> mip.LinExpr: """Generate a cutting plane for the term. + The cutting plane is a linear approximation of the convex term at the given query point, + valid for all feasible points due to convexity. + Args: - query_point: dict mapping mip.Var to float - The query point for which the cutting plane is generated. + query_point: The query point for which the cutting plane is generated. Returns: - The linear constraint representing the cutting plane. + A linear expression representing the cutting plane constraint. """ - func, grad = self(query_point=query_point, return_grad=True) + value, grad = self(query_point=query_point, return_grad=True) x = self._get_input(query_point=query_point) if self.is_multivariable: - return mip.xsum(grad * (np.array(self.var) - x)) + func - return grad * (self.var - x) + func + return mip.xsum(grad * (np.array(self.var) - x)) + value + return grad * (self.var - x) + value def _get_input(self, query_point: QueryPoint) -> Input: + """Extract input values from query point based on variable type. + + Args: + query_point: The query point containing variable values. + + Returns: + Input values in the format expected by the function. + """ if self.is_multivariable: return np.array([query_point[var] for var in self.var]) return query_point[self.var] @@ -118,7 +138,7 @@ def _get_input(self, query_point: QueryPoint) -> Input: def _evaluate_func(self, x: Input) -> float | tuple[float, float | np.ndarray]: """Evaluate the function value. - If `grad=True`, then both the value of the function and it's gradient are returned. + If `grad=True`, then both the value of the function and its gradient are returned. """ if isinstance(self.var, (mip.Var, mip.LinExprTensor)): return self.func(x) @@ -127,7 +147,14 @@ def _evaluate_func(self, x: Input) -> float | tuple[float, float | np.ndarray]: raise TypeError(f"Input of type '{type(x)}' not supported.") def _evaluate_grad(self, x: Input) -> float | np.ndarray: - """Evaluate the gradient.""" + """Evaluate the gradient. + + Args: + x: The input values at which to evaluate the gradient. + + Returns: + The gradient value(s). + """ if not self.grad: return self._approximate_grad(x=x) if isinstance(self.var, (mip.Var, mip.LinExprTensor)): @@ -137,15 +164,22 @@ def _evaluate_grad(self, x: Input) -> float | np.ndarray: raise TypeError(f"Input of type '{type(x)}' not supported.") def _approximate_grad(self, x: Input) -> float | np.ndarray: - """Approximate the gradient of the function at point using the central finite difference method.""" + """Approximate the gradient using central finite differences. + + Args: + x: The input values at which to approximate the gradient. + + Returns: + The approximated gradient value(s). + """ if self.is_multivariable: n_dim = len(x) grad = np.zeros(n_dim) + e = np.eye(n_dim) for i in range(n_dim): - e_i = standard_basis_vector(i=i, n_dim=n_dim) grad[i] = ( - self._evaluate_func(x=x + self.step_size / 2 * e_i) - - self._evaluate_func(x=x - self.step_size / 2 * e_i) + self._evaluate_func(x=x + self.step_size / 2 * e[i]) + - self._evaluate_func(x=x - self.step_size / 2 * e[i]) ) / self.step_size return grad return ( diff --git a/src/halfspace/model.py b/src/halfspace/model.py index 0be9d1c..6f8444b 100644 --- a/src/halfspace/model.py +++ b/src/halfspace/model.py @@ -13,24 +13,36 @@ from .convex_term import ConvexTerm, Var, Func, FuncGrad, Grad from .utils import check_scalar, log_table_header, log_table_row -Start = list[tuple[mip.Var, float]] +type Start = list[tuple[mip.Var, float]] class Model: - """Mixed-integer convex optimization model. + """Mixed-integer convex optimization model using outer approximation. + + This class implements an outer approximation algorithm for solving mixed-integer convex + optimization problems. The algorithm iteratively adds linear cuts to approximate nonlinear + constraints and objective functions, solving a sequence of mixed-integer linear programs. + + The model supports both continuous and discrete variables, linear and nonlinear constraints, + and can handle both minimization and maximization problems (with concave objectives for + maximization). Attributes: - minimize: Whether the objective should be minimized. If `False`, the objective will be maximized - note that in - this case the objective must concave, not convex. + minimize: Whether the objective should be minimized. If `False`, the objective will be + maximized - note that in this case the objective must be concave, not convex. max_gap: The maximum relative optimality gap allowed before the search is terminated. max_gap_abs: The maximum absolute optimality gap allowed before the search is terminated. - infeasibility_tol: The maximum allowed constraint violation permitted for a solution to be considered feasible. - step_size: The step size used to numerically evaluate gradients using the central finite difference method. Only - used when a function for analytically computing the gradient is not provided. - smoothing: The smoothing parameter used to update the query point. If `None`, the query point will not be - updated. - solver_name: The MIP solver to use. Valid options are 'CBC' and 'GUROBI'. Note that 'GUROBI' requires a license. - log_freq: The frequency with which logs are + infeasibility_tol: The maximum allowed constraint violation permitted for a solution to + be considered feasible. + step_size: The step size used to numerically evaluate gradients using the central finite + difference method. Only used when a function for analytically computing the gradient + is not provided. + smoothing: The smoothing parameter used to update the query point. If `None`, the query + point will be updated to the incumbent solution at each iteration. + solver_name: The MIP solver to use. Valid options are 'CBC' and 'GRB' (Gurobi). Note that + 'GRB' requires a license. + log_freq: The frequency with which progress logs are printed during optimization. + If `None`, no progress logs are printed. """ def __init__( @@ -44,17 +56,17 @@ def __init__( solver_name: str | None = "CBC", log_freq: int | None = 1, ) -> None: - """Optimization model constructor. + """Initialize the optimization model. Args: - minimize: Value for the `minimize` attribute. - max_gap: Value for the `max_gap` attribute. Must be positive. - max_gap_abs: Value for the `max_gap_abs` attribute. Must be positive. - infeasibility_tol: Value for the `infeasibility_tol` attribute. Must be positive. - step_size: Value for the `step_size` attribute. Must be positive. - smoothing: Value for the `smoothing` attribute. If provided, must be in the range (0, 1). - solver_name: Value for the `solver_name` attribute. - log_freq: Value for the `log_freq` attribute. + minimize: Whether to minimize the objective. If `False`, maximizes (requires concave objective). + max_gap: Maximum relative optimality gap for early termination. Must be positive. + max_gap_abs: Maximum absolute optimality gap for early termination. Must be positive. + infeasibility_tol: Maximum constraint violation for feasible solutions. Must be positive. + step_size: Step size for numerical gradient approximation. Must be positive. + smoothing: Query point smoothing parameter in (0, 1). If `None`, uses incumbent solution. + solver_name: MIP solver name ('CBC' or 'GRB'). 'GRB' requires a license. + log_freq: Progress logging frequency. If `None`, no progress logs are printed. """ self.minimize = minimize self.max_gap = max_gap @@ -68,7 +80,11 @@ def __init__( self.reset() def reset(self) -> None: - """Reset the model.""" + """Reset the model to its initial state. + + Clears all variables, constraints, and solution data, returning the model to + the state it was in immediately after construction. + """ self._model: mip.Model = mip.Model( solver_name=self.solver_name, sense=mip.MINIMIZE if self.minimize else mip.MAXIMIZE, @@ -91,18 +107,18 @@ def add_var( var_type: str = mip.CONTINUOUS, name: str = "", ) -> mip.Var: - """Add a decision variable to the model. + """Add a single decision variable to the model. Args: - lb: The lower bound for the decision variable. Must be finite and less than the upper bound. Cannot be - `None` if `var_type` is 'C' or 'I'. - ub: The upper bound for the decision variable. Must be finite and greater than the lower bound. Cannot be - `None` if `var_type` is 'C' or 'I'. - var_type: The variable type. Valid options are 'C' (continuous), 'I' (integer) and 'B' (binary). - name: The name of the decision variable. + lb: Lower bound for the variable. Must be finite and less than upper bound. + Cannot be `None` if `var_type` is 'C' or 'I'. + ub: Upper bound for the variable. Must be finite and greater than lower bound. + Cannot be `None` if `var_type` is 'C' or 'I'. + var_type: Variable type. Valid options are 'C' (continuous), 'I' (integer), and 'B' (binary). + name: Optional name for the variable. Returns: - The decision variable. + The created decision variable. """ lb, ub = self._validate_bounds(lb=lb, ub=ub, var_type=var_type) return self._model.add_var(lb=lb, ub=ub, var_type=var_type, name=name) @@ -118,16 +134,16 @@ def add_var_tensor( """Add a tensor of decision variables to the model. Args: - shape: The shape of the tensor. - lb: The lower bound for the decision variables. Must be finite and less than the upper bound. Cannot be - `None` if `var_type` is 'C' or 'I'. - ub: The upper bound for the decision variables. Must be finite and greater than the lower bound. Cannot be - `None` if `var_type` is 'C' or 'I'. - var_type: The variable type. Valid options are 'C' (continuous), 'I' (integer) and 'B' (binary). - name: The name of the decision variable. + shape: Shape of the variable tensor. + lb: Lower bound for all variables. Must be finite and less than upper bound. + Cannot be `None` if `var_type` is 'C' or 'I'. + ub: Upper bound for all variables. Must be finite and greater than lower bound. + Cannot be `None` if `var_type` is 'C' or 'I'. + var_type: Variable type for all variables. Valid options are 'C' (continuous), 'I' (integer), and 'B' (binary). + name: Base name for the variables (indices will be appended). Returns: - The tensor of decision variables. + The created variable tensor. """ lb, ub = self._validate_bounds(lb=lb, ub=ub, var_type=var_type) return self._model.add_var_tensor( @@ -142,10 +158,11 @@ def add_linear_constr(self, constraint: mip.LinExpr, name: str = "") -> mip.Cons """Add a linear constraint to the model. Args: - constraint: The linear constraint. - name: The name of the constraint. + constraint: Linear constraint expression (e.g., x + y <= 1). + name: Optional name for the constraint. - Returns: The constraint expression. + Returns: + The created constraint object. """ return self._model.add_constr(lin_expr=constraint, name=name) @@ -158,18 +175,18 @@ def add_nonlinear_constr( ) -> ConvexTerm: """Add a nonlinear constraint to the model. + The constraint is enforced as func(var) <= 0. The function must be convex for + minimization problems or concave for maximization problems. + Args: - var: The variable(s) included in the term. This can be provided in the form of a single variable, an - iterable of multiple variables or a variable tensor. - func: A function for computing the term's value. This function should except one argument for each - variable in `var`. If `var` is a variable tensor, then the function should accept a single array. - grad: A function for computing the term's gradient. This function should except one argument for each - variable in `var`. If `var` is a variable tensor, then the function should accept a single array. If - `None`, then the gradient is approximated numerically using the central finite difference method. If - `grad` is instead a Boolean and is `True`, then `func` is assumed to return a tuple where the first - element is the function value and the second element is the gradient. This is useful when the gradient - is expensive to compute. - name: The name of the constraint. + var: Variable(s) in the constraint. Can be a single variable, iterable of variables, + or variable tensor. + func: Function computing the constraint value. Should accept one argument for each + variable in `var`. If `var` is a tensor, function should accept a single array. + grad: Function computing the gradient. Should accept same arguments as `func`. + If `None`, gradient is approximated numerically. If `True`, `func` should return + (value, gradient) tuple for efficiency. + name: Optional name for the constraint. Returns: The convex term representing the constraint. @@ -191,20 +208,20 @@ def add_objective_term( grad: Grad | bool | None = None, name: str = "", ) -> ConvexTerm: - """Add an objective term to the model. + """Add a term to the objective function. + + The function must be convex for minimization problems or concave for maximization + problems. Multiple terms can be added to build up a complex objective. Args: - var: The variable(s) included in the term. This can be provided in the form of a single variable, an - iterable of multiple variables or a variable tensor. - func: A function for computing the term's value. This function should except one argument for each - variable in `var`. If `var` is a variable tensor, then the function should accept a single array. - grad: A function for computing the term's gradient. This function should except one argument for each - variable in `var`. If `var` is a variable tensor, then the function should accept a single array. If - `None`, then the gradient is approximated numerically using the central finite difference method. If - `grad` is instead a Boolean and is `True`, then `func` is assumed to return a tuple where the first - element is the function value and the second element is the gradient. This is useful when the gradient - is expensive to compute. - name: The name of the term. + var: Variable(s) in the objective term. Can be a single variable, iterable of + variables, or variable tensor. + func: Function computing the objective value. Should accept one argument for each + variable in `var`. If `var` is a tensor, function should accept a single array. + grad: Function computing the gradient. Should accept same arguments as `func`. + If `None`, gradient is approximated numerically. If `True`, `func` should return + (value, gradient) tuple for efficiency. + name: Optional name for the objective term. Returns: The objective term. @@ -225,45 +242,47 @@ def optimize( max_iters_no_improvement: int | None = None, max_seconds_per_iter: float | None = None, ) -> mip.OptimizationStatus: - """Optimize the model. + """Solve the optimization problem using outer approximation. + + The algorithm iteratively adds linear cuts to approximate nonlinear constraints + and objective functions, solving a sequence of mixed-integer linear programs. Args: - max_iters: The maximum number of iterations to run the search for. - max_iters_no_improvement: The maximum number of iterations to continue the search without improvement in - the objective value, once a feasible solution has been found. If `None`, then the search will continue - until `max_iters` regardless of lack of improvement in the objective value. - max_seconds_per_iter: The maximum number of seconds allow the MIP solver to run for each iteration. If - `None`, then the MIP solver will run until its convergence criteria are met. + max_iters: Maximum number of outer approximation iterations. + max_iters_no_improvement: Maximum iterations without objective improvement after + finding a feasible solution. If `None`, continues until `max_iters`. + max_seconds_per_iter: Maximum seconds for the MIP solver per iteration. + If `None`, solver runs until convergence. Returns: - The status of the search. + Optimization status indicating success or failure. """ - # Define objective in epigraph form + # Set up epigraph formulation: minimize/maximize t subject to t >=/<= objective bound = self._model.add_var(lb=-mip.INF, ub=mip.INF) self._model.objective = bound - # Initialize search + # Initialize search with starting point or variable bounds midpoint query_point = {x: self._start.get(x) or (x.lb + x.ub) / 2 for x in self._model.vars} iters_no_improvement = 0 for i in range(max_iters): - # Add cuts for violated nonlinear constraints + # Add linear cuts for violated nonlinear constraints for constr in self.nonlinear_constrs: if constr(query_point=query_point) > self.infeasibility_tol: expr = constr.generate_cut(query_point=query_point) self._model.add_constr(expr <= 0) - # Add objective cut + # Add linear cut for objective function expr = mip.xsum(term.generate_cut(query_point=query_point) for term in self.objective_terms) if self.minimize: - self._model.add_constr(bound >= expr) + self._model.add_constr(bound >= expr) # t >= objective else: - self._model.add_constr(bound <= expr) + self._model.add_constr(bound <= expr) # t <= objective - # Re-optimize LP/MIP model + # Solve the current mixed-integer linear program status = self._model.optimize(max_seconds=max_seconds_per_iter or mip.INF) - # If no solution is found, exit solve and return status + # Check if solver found a feasible solution if status not in ( mip.OptimizationStatus.OPTIMAL, mip.OptimizationStatus.FEASIBLE, @@ -272,33 +291,40 @@ def optimize( self._status = status return self.status - # Update best solution/objective value and query point + # Extract solution and evaluate true objective value solution = {var: var.x for var in self._model.vars} objective_value_new = sum(term(query_point=solution) for term in self.objective_terms) - if self.minimize == (objective_value_new < self.objective_value) and all( - constr(solution) <= self.infeasibility_tol for constr in self.nonlinear_constrs - ): + + # Check if this is a better feasible solution + is_improvement = self.minimize == (objective_value_new < self.objective_value) + is_feasible = all(constr(solution) <= self.infeasibility_tol for constr in self.nonlinear_constrs) + + if is_improvement and is_feasible: iters_no_improvement = 0 self._objective_value = objective_value_new self._best_solution = solution else: if np.isfinite(self.objective_value): iters_no_improvement += 1 + + # Update query point for next iteration if self.smoothing is not None: + # Smooth between current query point and new solution query_point = { var: self.smoothing * query_point[var] + (1 - self.smoothing) * solution[var] for var in self._model.vars } else: - query_point = query_point + # Use incumbent solution as next query point + query_point = solution - # Update best bound (clip values to prevent numerical errors from affecting termination logic) + # Update best bound with monotonicity to prevent numerical issues if self.minimize: self._best_bound = np.clip(bound.x, a_min=self.best_bound, a_max=self.objective_value) else: self._best_bound = np.clip(bound.x, a_min=self.objective_value, a_max=self.best_bound) - # Update log + # Log progress self._search_log.append( { "iteration": i, @@ -313,7 +339,7 @@ def optimize( if not i % self.log_freq: log_table_row(values=self._search_log[-1].values()) - # Check early termination conditions + # Check convergence criteria if self.gap <= self.max_gap or self.gap_abs <= self.max_gap_abs: logging.info("Optimality tolerance reached - terminating search early.") self._status = mip.OptimizationStatus.OPTIMAL @@ -324,6 +350,7 @@ def optimize( self._status = mip.OptimizationStatus.FEASIBLE return self.status + # Reached maximum iterations logging.info("Max iterations reached - terminating search.") if self.best_solution: self._status = mip.OptimizationStatus.FEASIBLE @@ -332,19 +359,31 @@ def optimize( return self.status def var_by_name(self, name: str) -> mip.Var: - """Get a variable by name.""" + """Get a variable by its name. + + Args: + name: The name of the variable to retrieve. + + Returns: + The variable with the specified name. + + Raises: + KeyError: If no variable with the given name exists. + """ return self._model.var_by_name(name=name) def var_value(self, x: mip.Var | mip.LinExprTensor | str) -> float | np.ndarray: - """Get the value one or more decision variables corresponding to the best solution. + """Get the value of one or more variables from the best solution. Args: - x: mip.Var or mip.LinExprTensor or str - The variable(s) to get the value of. This can be provided as a single variable, a tensor of variables - or the name of a variable. + x: Variable(s) to get values for. Can be a single variable, variable tensor, + variable name (string), or iterable of variables. - Returns: float or np.ndarray - The value(s) of the variable(s). + Returns: + Variable value(s) as float or numpy array. + + Raises: + TypeError: If input type is not supported. """ if isinstance(x, str): x = self.var_by_name(name=x) @@ -358,73 +397,128 @@ def var_value(self, x: mip.Var | mip.LinExprTensor | str) -> float | np.ndarray: @property def objective_terms(self) -> list[ConvexTerm]: - """Get the objective terms of the model.""" + """Get the objective terms of the model. + + Returns: + List of convex terms that make up the objective function. + """ return self._objective_terms @property def linear_constrs(self) -> mip.ConstrList: """Get the linear constraints of the model. - After the model is optimized, this will include the cuts added to the model. + After optimization, this includes both original linear constraints and + the linear cuts added during the outer approximation process. + + Returns: + List of all linear constraints in the model. """ return self._model.constrs @property def nonlinear_constrs(self) -> list[ConvexTerm]: - """Get the nonlinear constraints of the model.""" + """Get the nonlinear constraints of the model. + + Returns: + List of convex terms representing nonlinear constraints. + """ return self._nonlinear_constrs @property def start(self) -> Start: - """Get the starting solution or partial solution provided.""" + """Get the starting solution or partial solution. + + Returns: + List of (variable, value) pairs defining the starting point. + """ return [(key, value) for key, value in self._start.items()] @start.setter def start(self, value: Start) -> None: - """Set the starting solution or partial solution, provided as tuple of (variable, value) pairs.""" + """Set the starting solution or partial solution. + + Args: + value: List of (variable, value) pairs defining the starting point. + """ # TODO add validation checks here self._start = {var: x for var, x in value} self._model.start = value @property def best_solution(self) -> dict[mip.Var, float]: - """Get the best solution (all variables).""" + """Get the best feasible solution found. + + Returns: + Dictionary mapping variables to their values in the best solution. + """ return self._best_solution @property def objective_value(self) -> float: - """Get the objective value of the best solution.""" + """Get the objective value of the best solution. + + Returns: + Objective function value at the best feasible solution. + """ return self._objective_value @property def best_bound(self) -> float: - """Get the best bound.""" + """Get the best bound on the optimal objective value. + + Returns: + Best known bound (lower bound for minimization, upper bound for maximization). + """ return self._best_bound @property def gap(self) -> float: - """Get the (relative) optimality gap.""" - return self.gap_abs / max(min(abs(self.objective_value), abs(self.best_bound)), 1e-10) + """Get the relative optimality gap. + + Returns: + Relative gap as |objective_value - best_bound| / max(|objective_value|, |best_bound|). + """ + return self.gap_abs / max(abs(self.objective_value), abs(self.best_bound), 1.0) @property def gap_abs(self) -> float: - """Get the absolute optimality gap.""" + """Get the absolute optimality gap. + + Returns: + Absolute gap as |objective_value - best_bound|. + """ return abs(self.objective_value - self.best_bound) @property def status(self) -> mip.OptimizationStatus: - """Get the status of the model.""" + """Get the optimization status. + + Returns: + Status indicating whether optimization was successful and why it terminated. + """ return self._status @property def search_log(self) -> pd.DataFrame: - """Get the search log.""" + """Get the search progress log. + + Returns: + DataFrame with columns: iteration, objective_value, best_bound, gap. + """ return pd.DataFrame(self._search_log).set_index("iteration") @staticmethod def sum(terms: Iterable[mip.Var | mip.LinExpr]) -> mip.LinExpr: - """Create a linear expression from a summation.""" - return mip.xsum(terms=terms) + """Create a linear expression from a summation. + + Args: + terms: Iterable of variables or linear expressions to sum. + + Returns: + Linear expression representing the sum of all terms. + """ + return mip.xsum(terms) def _validate_params(self) -> None: check_scalar( @@ -443,7 +537,7 @@ def _validate_params(self) -> None: ) check_scalar( x=self.infeasibility_tol, - name="feasibility_tol", + name="infeasibility_tol", var_type=float, lb=0, include_boundaries=False, @@ -468,6 +562,16 @@ def _validate_params(self) -> None: @staticmethod def _validate_bounds(lb: float | int, ub: float | int, var_type: str) -> tuple[float | int, float | int]: + """Validate and normalize variable bounds. + + Args: + lb: Lower bound value. + ub: Upper bound value. + var_type: Variable type ('C', 'I', or 'B'). + + Returns: + Tuple of (validated_lb, validated_ub). + """ if var_type == mip.BINARY: lb, ub = 0, 1 else: diff --git a/src/halfspace/utils.py b/src/halfspace/utils.py index b2c6ba9..32c311c 100644 --- a/src/halfspace/utils.py +++ b/src/halfspace/utils.py @@ -1,9 +1,8 @@ """Utility functions for the `halfspace` package.""" import logging -from typing import Iterable, Any, Type +from typing import Iterable, Any -import numpy as np def log_table_header(columns: Iterable[str], width: int = 15) -> None: @@ -19,7 +18,11 @@ def log_table_header(columns: Iterable[str], width: int = 15) -> None: Returns: None """ - columns = [f"{{:{width}}}".format(col) for col in columns] + columns_list = list(columns) + if not columns_list: + return + + columns = [f"{{:{width}}}".format(col) for col in columns_list] line = "-{}-".format("-".join("-" * len(col) for col in columns)) logging.info(line) logging.info("|{}|".format("|".join(columns))) @@ -32,21 +35,25 @@ def log_table_row(values: Iterable[float | int], width: int = 15) -> None: Logging level is set to `logging.INFO`. Args: - values: iterable of str + values: iterable of float or int The values of the row. width: int, default=15 The width of each column. Returns: None """ - values_ = [(f"{{:{width}}}" if isinstance(value, int) else f"{{:{width}.3e}}").format(value) for value in values] + values_list = list(values) + if not values_list: + return + + values_ = [(f"{{:{width}}}" if isinstance(value, int) else f"{{:{width}.3e}}").format(value) for value in values_list] logging.info("|{}|".format("|".join(values_))) def check_scalar( x: Any, name: str, - var_type: Type | tuple[Type, ...] | None = None, + var_type: type | tuple[type, ...] | None = None, lb: float | int | None = None, ub: float | int | None = None, include_boundaries: bool = True, @@ -58,33 +65,34 @@ def check_scalar( The scalar to check. name: str, The name of the scalar. Used for error messages. - var_type: type or tuple of types, default=`None` + var_type: type or tuple of types, default=None The expected type(s) of the scalar. If `None`, then no type checking is performed. - lb: float or int, default=`None` + lb: float or int, default=None The lower bound of the scalar. If `None`, then no lower bound checking is performed. - ub: float or int, default=`None` + ub: float or int, default=None The upper bound of the scalar. If `None`, then no upper bound checking is performed. - include_boundaries: bool, default=`True` + include_boundaries: bool, default=True Whether to include the boundaries in the bound checking. + Raises: + ValueError: If the scalar does not meet the specified conditions. + Returns: None """ if var_type is not None: - assert isinstance(x, var_type), f"Variable '{name}' ({type(x)}) is not expected type ({var_type})." + if not isinstance(x, var_type): + raise ValueError(f"Variable '{name}' ({type(x)}) is not expected type ({var_type}).") if lb is not None: if include_boundaries: - assert x >= lb, f"Variable '{name}' ({x}) is less than lower bound ({lb})." + if x < lb: + raise ValueError(f"Variable '{name}' ({x}) is less than lower bound ({lb}).") else: - assert x > lb, f"Variable '{name}' ({x}) is less than or equal to lower bound ({lb})." + if x <= lb: + raise ValueError(f"Variable '{name}' ({x}) is less than or equal to lower bound ({lb}).") if ub is not None: if include_boundaries: - assert x <= ub, f"Variable '{name}' ({x}) is greater than lower bound ({ub})." + if x > ub: + raise ValueError(f"Variable '{name}' ({x}) is greater than upper bound ({ub}).") else: - assert x < ub, f"Variable '{name}' ({x}) is greater than or equal to lower bound ({ub})." - - -def standard_basis_vector(i: int, n_dim: int) -> np.ndarray: - """Return the ith standard basis vector in R^n.""" - x = np.zeros(n_dim) - x[i] = 1 - return x + if x >= ub: + raise ValueError(f"Variable '{name}' ({x}) is greater than or equal to upper bound ({ub}).") diff --git a/tests/test_convex_term.py b/tests/test_convex_term.py index d3953fc..75e4010 100644 --- a/tests/test_convex_term.py +++ b/tests/test_convex_term.py @@ -161,3 +161,35 @@ def test_var_tensor_term( expected_is_multivariable=True, query_point={model.var_by_name(name=name): value for name, value in query_point.items()}, ) + + +@pytest.mark.parametrize("step_size", [0.0, -1.0]) +def test_invalid_step_size_raises(model: Model, step_size: float): + x = model.var_by_name("x") + with pytest.raises(ValueError, match="step_size must be positive"): + ConvexTerm(var=x, func=lambda x: x**2, grad=lambda x: 2 * x, step_size=step_size) + + +def test_var_tensor_2d_term(model: Model): + z = model.add_var_tensor(shape=(2, 2), lb=-10, ub=10, name="z") + func = lambda w: (w**2).sum() + grad = lambda w: 2 * w + term = ConvexTerm(var=z, func=func, grad=grad) + + qp = { + model.var_by_name("z_0_0"): 1.0, + model.var_by_name("z_0_1"): 2.0, + model.var_by_name("z_1_0"): -1.0, + model.var_by_name("z_1_1"): 0.5, + } + expected_value = 1.0**2 + 2.0**2 + (-1.0) ** 2 + 0.5**2 + expected_grad = np.array([[2 * 1.0, 2 * 2.0], [2 * -1.0, 2 * 0.5]]) + + # Value and gradient sanity via helper + _check_convex_term( + term=term, + expected_value=expected_value, + expected_grad=expected_grad, + expected_is_multivariable=True, + query_point=qp, + ) diff --git a/tests/test_model.py b/tests/test_model.py index 95a7c49..7e8e1ad 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -137,3 +137,39 @@ def test_multivariable_nonlinear_constraint_infeasible(): model.add_nonlinear_constr(var=(x, y), func=lambda x, y: np.exp(x + y) + 1) model.optimize() _check_solution(model=model, expected_status=mip.OptimizationStatus.INFEASIBLE) + + +def test_maximize_concave_objective(): + model = Model(minimize=False) + x = model.add_var(lb=0, ub=1) + model.add_objective_term(var=x, func=lambda x: -(x - 0.75) ** 2 + 1) + model.optimize() + _check_solution( + model=model, + expected_objective_value=1.0, + expected_solution={x: 0.75}, + ) + + +def test_smoothing_none_runs(): + model = Model(smoothing=None) + x = model.add_var(lb=0, ub=1) + model.add_objective_term(var=x, func=lambda x: (x - 0.25) ** 2) + status = model.optimize(max_iters=3) + assert status in (mip.OptimizationStatus.OPTIMAL, mip.OptimizationStatus.FEASIBLE) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"max_gap": 0.0}, + {"max_gap_abs": 0.0}, + {"infeasibility_tol": 0.0}, + {"log_freq": 0}, + {"smoothing": -1e-3}, + {"smoothing": 1.0}, + ], +) +def test_parameter_validation_raises(kwargs): + with pytest.raises(ValueError): + Model(**kwargs) diff --git a/tests/test_utils.py b/tests/test_utils.py index dde117b..5c99a31 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,6 @@ from contextlib import nullcontext as does_not_raise from numbers import Real -from typing import Iterable, Any, Type +from typing import Iterable, Any import pytest @@ -29,17 +29,17 @@ def test_log_table_row(values: Iterable[Real], width: int): (1, "x", int, None, 2, True, does_not_raise()), (1, "x", int, 0, None, True, does_not_raise()), (1, "x", int, 0, 2, False, does_not_raise()), - (1, "x", float, 0, 2, True, pytest.raises(AssertionError)), - (1, "x", int, 2, 3, True, pytest.raises(AssertionError)), - (1, "x", int, 1, 2, False, pytest.raises(AssertionError)), - (1, "x", int, -1, 0, True, pytest.raises(AssertionError)), - (1, "x", int, 0, 1, False, pytest.raises(AssertionError)), + (1, "x", float, 0, 2, True, pytest.raises(ValueError)), + (1, "x", int, 2, 3, True, pytest.raises(ValueError)), + (1, "x", int, 1, 2, False, pytest.raises(ValueError)), + (1, "x", int, -1, 0, True, pytest.raises(ValueError)), + (1, "x", int, 0, 1, False, pytest.raises(ValueError)), ], ) def test_check_scalar( x: Any, name: str, - var_type: Type | tuple[Type, ...] | None, + var_type: type | tuple[type, ...] | None, lb: Real | None, ub: Real | None, include_boundaries: bool, diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..d649d0f --- /dev/null +++ b/uv.lock @@ -0,0 +1,366 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "cffi" +version = "1.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/a8/050ab4f0c3d4c1b8aaa805f70e26e84d0e27004907c5b8ecc1d31815f92a/cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9", size = 508501, upload-time = "2022-06-30T18:18:32.799Z" } + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.10.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/70/025b179c993f019105b79575ac6edb5e084fb0f0e63f15cdebef4e454fb5/coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90", size = 823736, upload-time = "2025-08-29T15:35:16.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/06/263f3305c97ad78aab066d116b52250dd316e74fcc20c197b61e07eb391a/coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea", size = 217324, upload-time = "2025-08-29T15:33:29.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/60/1e1ded9a4fe80d843d7d53b3e395c1db3ff32d6c301e501f393b2e6c1c1f/coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634", size = 217560, upload-time = "2025-08-29T15:33:30.748Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/52136173c14e26dfed8b106ed725811bb53c30b896d04d28d74cb64318b3/coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6", size = 249053, upload-time = "2025-08-29T15:33:32.041Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1d/ae25a7dc58fcce8b172d42ffe5313fc267afe61c97fa872b80ee72d9515a/coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9", size = 251802, upload-time = "2025-08-29T15:33:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/1f561d47743710fe996957ed7c124b421320f150f1d38523d8d9102d3e2a/coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c", size = 252935, upload-time = "2025-08-29T15:33:34.909Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ad/8b97cd5d28aecdfde792dcbf646bac141167a5cacae2cd775998b45fabb5/coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a", size = 250855, upload-time = "2025-08-29T15:33:36.922Z" }, + { url = "https://files.pythonhosted.org/packages/33/6a/95c32b558d9a61858ff9d79580d3877df3eb5bc9eed0941b1f187c89e143/coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5", size = 248974, upload-time = "2025-08-29T15:33:38.175Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/8ce95dee640a38e760d5b747c10913e7a06554704d60b41e73fdea6a1ffd/coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972", size = 250409, upload-time = "2025-08-29T15:33:39.447Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/7a55b0bdde78a98e2eb2356771fd2dcddb96579e8342bb52aa5bc52e96f0/coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d", size = 219724, upload-time = "2025-08-29T15:33:41.172Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/32b185b8b8e327802c9efce3d3108d2fe2d9d31f153a0f7ecfd59c773705/coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629", size = 220536, upload-time = "2025-08-29T15:33:42.524Z" }, + { url = "https://files.pythonhosted.org/packages/08/3a/d5d8dc703e4998038c3099eaf77adddb00536a3cec08c8dcd556a36a3eb4/coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80", size = 219171, upload-time = "2025-08-29T15:33:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/50db5379b615854b5cf89146f8f5bd1d5a9693d7f3a987e269693521c404/coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", size = 208986, upload-time = "2025-08-29T15:35:14.506Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, +] + +[[package]] +name = "halfspace-optimizer" +version = "0.1.1" +source = { editable = "." } +dependencies = [ + { name = "mip" }, + { name = "numpy" }, + { name = "pandas" }, +] + +[package.optional-dependencies] +all = [ + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "tomli" }, +] +dev = [ + { name = "pre-commit" }, + { name = "tomli" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "halfspace-optimizer", extras = ["dev", "test"], marker = "extra == 'all'" }, + { name = "mip", specifier = ">=1.15.0,<2.0.0" }, + { name = "numpy", specifier = ">=2.3.3,<3.0.0" }, + { name = "pandas", specifier = ">=2.3.2,<3.0.0" }, + { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pytest", marker = "extra == 'test'" }, + { name = "pytest-cov", marker = "extra == 'test'" }, + { name = "tomli", marker = "extra == 'dev'" }, +] +provides-extras = ["dev", "test", "all"] + +[[package]] +name = "identify" +version = "2.6.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/c4/62963f25a678f6a050fb0505a65e9e726996171e6dbe1547f79619eefb15/identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a", size = 99283, upload-time = "2025-09-06T19:30:52.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ae/2ad30f4652712c82f1c23423d79136fbce338932ad166d70c1efb86a5998/identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e", size = 99172, upload-time = "2025-09-06T19:30:51.759Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "mip" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/84/41ebb2db20fbc199768c317ee8238d50b55fd6ffaa4c17e2cb94f0d03152/mip-1.15.0.tar.gz", hash = "sha256:7f6f0381cfe2c52c1b8640203da2cb56974b26e23950ddfb1a76b37d916f197e", size = 24569566, upload-time = "2023-01-04T13:51:46.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/c9/d4ba71d5d73cf57596b8637ab20eda547c6cde296860f6b6192568809e70/mip-1.15.0-py3-none-any.whl", hash = "sha256:9a48c993ddc9a48591a59e4a1221b400eb1e35fc087052085774360330bb9226", size = 15269834, upload-time = "2023-01-04T13:51:34.596Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "numpy" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/19/95b3d357407220ed24c139018d2518fab0a61a948e68286a25f1a4d049ff/numpy-2.3.3.tar.gz", hash = "sha256:ddc7c39727ba62b80dfdbedf400d1c10ddfa8eefbd7ec8dcb118be8b56d31029", size = 20576648, upload-time = "2025-09-09T16:54:12.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/5d/bb7fc075b762c96329147799e1bcc9176ab07ca6375ea976c475482ad5b3/numpy-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cfdd09f9c84a1a934cde1eec2267f0a43a7cd44b2cca4ff95b7c0d14d144b0bf", size = 20957014, upload-time = "2025-09-09T15:56:29.966Z" }, + { url = "https://files.pythonhosted.org/packages/6b/0e/c6211bb92af26517acd52125a237a92afe9c3124c6a68d3b9f81b62a0568/numpy-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb32e3cf0f762aee47ad1ddc6672988f7f27045b0783c887190545baba73aa25", size = 14185220, upload-time = "2025-09-09T15:56:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f2/07bb754eb2ede9073f4054f7c0286b0d9d2e23982e090a80d478b26d35ca/numpy-2.3.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:396b254daeb0a57b1fe0ecb5e3cff6fa79a380fa97c8f7781a6d08cd429418fe", size = 5113918, upload-time = "2025-09-09T15:56:34.175Z" }, + { url = "https://files.pythonhosted.org/packages/81/0a/afa51697e9fb74642f231ea36aca80fa17c8fb89f7a82abd5174023c3960/numpy-2.3.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:067e3d7159a5d8f8a0b46ee11148fc35ca9b21f61e3c49fbd0a027450e65a33b", size = 6647922, upload-time = "2025-09-09T15:56:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f5/122d9cdb3f51c520d150fef6e87df9279e33d19a9611a87c0d2cf78a89f4/numpy-2.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c02d0629d25d426585fb2e45a66154081b9fa677bc92a881ff1d216bc9919a8", size = 14281991, upload-time = "2025-09-09T15:56:40.548Z" }, + { url = "https://files.pythonhosted.org/packages/51/64/7de3c91e821a2debf77c92962ea3fe6ac2bc45d0778c1cbe15d4fce2fd94/numpy-2.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9192da52b9745f7f0766531dcfa978b7763916f158bb63bdb8a1eca0068ab20", size = 16641643, upload-time = "2025-09-09T15:56:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/30/e4/961a5fa681502cd0d68907818b69f67542695b74e3ceaa513918103b7e80/numpy-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cd7de500a5b66319db419dc3c345244404a164beae0d0937283b907d8152e6ea", size = 16056787, upload-time = "2025-09-09T15:56:46.141Z" }, + { url = "https://files.pythonhosted.org/packages/99/26/92c912b966e47fbbdf2ad556cb17e3a3088e2e1292b9833be1dfa5361a1a/numpy-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:93d4962d8f82af58f0b2eb85daaf1b3ca23fe0a85d0be8f1f2b7bb46034e56d7", size = 18579598, upload-time = "2025-09-09T15:56:49.844Z" }, + { url = "https://files.pythonhosted.org/packages/17/b6/fc8f82cb3520768718834f310c37d96380d9dc61bfdaf05fe5c0b7653e01/numpy-2.3.3-cp312-cp312-win32.whl", hash = "sha256:5534ed6b92f9b7dca6c0a19d6df12d41c68b991cef051d108f6dbff3babc4ebf", size = 6320800, upload-time = "2025-09-09T15:56:52.499Z" }, + { url = "https://files.pythonhosted.org/packages/32/ee/de999f2625b80d043d6d2d628c07d0d5555a677a3cf78fdf868d409b8766/numpy-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:497d7cad08e7092dba36e3d296fe4c97708c93daf26643a1ae4b03f6294d30eb", size = 12786615, upload-time = "2025-09-09T15:56:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/49/6e/b479032f8a43559c383acb20816644f5f91c88f633d9271ee84f3b3a996c/numpy-2.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:ca0309a18d4dfea6fc6262a66d06c26cfe4640c3926ceec90e57791a82b6eee5", size = 10195936, upload-time = "2025-09-09T15:56:56.541Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/8e/0e90233ac205ad182bd6b422532695d2b9414944a280488105d598c70023/pandas-2.3.2.tar.gz", hash = "sha256:ab7b58f8f82706890924ccdfb5f48002b83d2b5a3845976a9fb705d36c34dcdb", size = 4488684, upload-time = "2025-08-21T10:28:29.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/db/614c20fb7a85a14828edd23f1c02db58a30abf3ce76f38806155d160313c/pandas-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fbb977f802156e7a3f829e9d1d5398f6192375a3e2d1a9ee0803e35fe70a2b9", size = 11587652, upload-time = "2025-08-21T10:27:15.888Z" }, + { url = "https://files.pythonhosted.org/packages/99/b0/756e52f6582cade5e746f19bad0517ff27ba9c73404607c0306585c201b3/pandas-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b9b52693123dd234b7c985c68b709b0b009f4521000d0525f2b95c22f15944b", size = 10717686, upload-time = "2025-08-21T10:27:18.486Z" }, + { url = "https://files.pythonhosted.org/packages/37/4c/dd5ccc1e357abfeee8353123282de17997f90ff67855f86154e5a13b81e5/pandas-2.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bd281310d4f412733f319a5bc552f86d62cddc5f51d2e392c8787335c994175", size = 11278722, upload-time = "2025-08-21T10:27:21.149Z" }, + { url = "https://files.pythonhosted.org/packages/d3/a4/f7edcfa47e0a88cda0be8b068a5bae710bf264f867edfdf7b71584ace362/pandas-2.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d31a6b4354e3b9b8a2c848af75d31da390657e3ac6f30c05c82068b9ed79b9", size = 11987803, upload-time = "2025-08-21T10:27:23.767Z" }, + { url = "https://files.pythonhosted.org/packages/f6/61/1bce4129f93ab66f1c68b7ed1c12bac6a70b1b56c5dab359c6bbcd480b52/pandas-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:df4df0b9d02bb873a106971bb85d448378ef14b86ba96f035f50bbd3688456b4", size = 12766345, upload-time = "2025-08-21T10:27:26.6Z" }, + { url = "https://files.pythonhosted.org/packages/8e/46/80d53de70fee835531da3a1dae827a1e76e77a43ad22a8cd0f8142b61587/pandas-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:213a5adf93d020b74327cb2c1b842884dbdd37f895f42dcc2f09d451d949f811", size = 13439314, upload-time = "2025-08-21T10:27:29.213Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/8114832daff7489f179971dbc1d854109b7f4365a546e3ea75b6516cea95/pandas-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c13b81a9347eb8c7548f53fd9a4f08d4dfe996836543f805c987bafa03317ae", size = 10983326, upload-time = "2025-08-21T10:27:31.901Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, +] From 441b02889879cc0501f4d3d8bbbe4d273b36369e Mon Sep 17 00:00:00 2001 From: Joshua Ivanhoe Date: Wed, 12 Nov 2025 10:51:31 +0000 Subject: [PATCH 12/12] feat: Migrate to uv for package management (WIP) --- .github/workflows/ci.yml | 30 ++++++---- .github/workflows/release.yml | 46 +++++++-------- .pre-commit-config.yaml | 51 +++++++++++------ README.md | 41 ++++++++------ pyproject.toml | 58 ++++++++++--------- src/halfspace/convex_term.py | 25 +++++---- src/halfspace/model.py | 101 +++++++++++++++++++-------------- src/halfspace/utils.py | 22 +++++--- tests/test_convex_term.py | 22 +++++--- tests/test_model.py | 6 +- tests/test_utils.py | 15 +++-- uv.lock | 103 +++++++++++++++++++++++++--------- 12 files changed, 322 insertions(+), 198 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea9f6af..2c22506 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,21 +16,31 @@ jobs: python-version: ["3.12"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install poetry - run: python -m pip install uv + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Install dependencies and build package using uv + run: | + uv sync --extra dev + uv build + + - name: Run linting with ruff + run: uv run ruff check . - - name: Install dependencies - run: uv sync --extra all + - name: Run formatting check with ruff + run: uv run ruff format --check . - - name: Perform type checking with mypy - run: mypy src + - name: Run type checking with mypy + run: uv run mypy src - - name: Run tests - run: poetry run pytest --cov=src + - name: Run tests with coverage using pytest + run: uv run pytest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd4b376..8e1b3e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,31 +1,33 @@ -name: Upload Python Package +name: Publish to PyPI on: release: types: [published] -permissions: - contents: read - jobs: - deploy: - + publish: + name: Build and Publish to PyPI runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v3 - with: - python-version: "3.11" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish package - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Set up Python + run: uv python install + + - name: Install build dependencies + run: uv sync --all-extras + + - name: Build the project + run: uv build + + - name: Publish to PyPI + run: uv publish --token ${{ secrets.PYPI_API_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 64318bb..29768bc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,18 +1,35 @@ repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.3.0 - hooks: - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace -- repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.6 - hooks: - - id: ruff - - id: ruff-format - args: [--line-length=120] -- repo: https://github.com/pycqa/pydocstyle - rev: 6.3.0 - hooks: - - id: pydocstyle - args: [--convention=google, --add-ignore=D100,D104] + # General hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: detect-private-key # Basic security check for secrets + + # Python formatting and linting with Ruff + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff-format + - id: ruff + args: [--fix] + + # Type checking with mypy +# - repo: https://github.com/pre-commit/mirrors-mypy +# rev: v1.11.2 +# hooks: +# - id: mypy + + # Documentation linting with pydocstyle + - repo: https://github.com/pycqa/pydocstyle + rev: 6.3.0 + hooks: + - id: pydocstyle + + # Enforce Conventional Commits in commit messages + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v3.3.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] diff --git a/README.md b/README.md index a5f0b91..63edef2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # ✨halfspace✨ -`halfspace` is an open-source, light-weight Python module for modelling and solving mixed-integer convex optimization problems of the form: +`halfspace` is an open-source, lightweight Python module for modeling and solving mixed-integer convex optimization problems of the form: $$ \begin{align} @@ -21,11 +21,15 @@ It is built on top of the high-performance Python `mip` module and uses a cuttin This implementation is based on the approach outlined in [Boyd & Vandenberghe (2008)](https://see.stanford.edu/materials/lsocoee364b/05-localization_methods_notes.pdf) - see Chapter 6. -## Quick start +## Installation -You can install `halfspace` using `pip` as follows: +`halfspace` requires Python 3.12: ```bash +# Using uv (recommended) +uv add halfspace-optimizer + +# Using pip pip install halfspace-optimizer ``` @@ -107,29 +111,34 @@ import logging logging.getLogger().setLevel(logging.INFO) ``` -The logging frequency can be adjusted as desiredusing the model's `log_freq` attribute. +The logging frequency can be adjusted as desired using the model's `log_freq` attribute. ## Development -Clone the repository using `git`: - ```bash +# Clone and setup git clone https://github.com/joshivanhoe/halfspace -```` +cd halfspace +uv venv .venv --python 3.12 +source .venv/bin/activate +uv sync --extra dev -Create a fresh virtual environment using `venv` or `conda`. -Activate the environment and navigate to the cloned `halfspace` directory. -Install a locally editable version of the package using `pip`: +# Setup pre-commit hooks +uv run pre-commit install +uv run pre-commit install --hook-type commit-msg -```bash -pip install -e . +# Run tests +uv run pytest ``` -To check the installation has worked, you can run the tests (with coverage metrics) using `pytest` as follows: +### Pre-commit Hooks + +This project uses pre-commit hooks for code quality (Ruff, mypy, pydocstyle, conventional commits). Hooks run automatically on commit, or manually with: ```bash -pytest --cov=halfspace tests/ +uv run pre-commit run --all-files ``` -Contributions are welcome! To see our development priorities, refer to the [open issues](https://github.com/joshivanhoe/halfspace/issues). -Please submit a pull request with a clear description of the changes you've made. +### Contributing + +Contributions welcome! See [open issues](https://github.com/joshivanhoe/halfspace/issues) for priorities. Ensure pre-commit hooks pass before submitting PRs. diff --git a/pyproject.toml b/pyproject.toml index 4df8f39..afab364 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,13 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + + [project] name = "halfspace-optimizer" version = "0.1.1" authors = [ - { name="Joshua Ivanhoe", email="joshua.k.ivanhoe@gmail.com" }, + {name="Joshua Ivanhoe", email="joshua.k.ivanhoe@gmail.com"}, ] description = "Cutting-plane solver for mixed-integer convex optimization problems" readme = "README.md" @@ -29,40 +34,38 @@ Issues = "https://github.com/joshivanhoe/halfspace/issues" [project.optional-dependencies] dev = [ "ruff", + "mypy", "pre-commit", - "tomli", -] -test = [ "pytest", "pytest-cov", ] -all = [ - "halfspace-optimizer[dev,test]", + +[tool.ruff] +line-length = 100 +exclude = [ + "*.ipynb", ] -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +[tool.ruff.lint] +extend-select = ["D", "I"] # pydocstyle +fixable = ["ALL"] +extend-ignore = [ + "D100", # Missing docstring in public module + "D104", # Missing docstring in public package + "D105", # Missing docstring in magic method +] -[tool.uv] -index-url = "https://pypi.org/simple" -extra-index-url = [] +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["D"] # no documentation checks required for tests -[tool.hatch.build.targets.wheel] -packages = ["src/halfspace"] +[tool.ruff.lint.pydocstyle] +convention = "google" -[tool.pytest.ini_options] -testpaths = ["tests"] -python_files = ["test_*.py"] -python_classes = ["Test*"] -python_functions = ["test_*"] -addopts = [ - "--strict-markers", - "--strict-config", - "--cov=src/halfspace", - "--cov-report=term-missing", - "--cov-report=html", -] +[tool.mypy] +ignore_missing_imports = true +install_types = true +non_interactive = true +check_untyped_defs = true [tool.coverage.run] source = ["src/halfspace"] @@ -75,3 +78,6 @@ exclude_lines = [ "raise AssertionError", "raise NotImplementedError", ] + +[tool.hatch.build.targets.wheel] +packages = ["src/halfspace"] diff --git a/src/halfspace/convex_term.py b/src/halfspace/convex_term.py index fad11ac..34d501a 100644 --- a/src/halfspace/convex_term.py +++ b/src/halfspace/convex_term.py @@ -8,7 +8,7 @@ import mip import numpy as np -from .utils import check_scalar +from .utils import check_param type QueryPoint = dict[mip.Var, float] type Var = mip.Var | Iterable[mip.Var] | mip.LinExprTensor @@ -54,7 +54,7 @@ def __init__( step_size: The step size for numerical gradient approximation. Must be positive. name: The name for the term. """ - check_scalar( + check_param( x=step_size, name="step_size", var_type=float, @@ -75,7 +75,9 @@ def __call__( self, query_point: QueryPoint, return_grad: Literal[True] = True ) -> tuple[float, float | np.ndarray]: ... - def __call__(self, query_point: QueryPoint, return_grad: bool = False) -> float | tuple[float, float | np.ndarray]: + def __call__( + self, query_point: QueryPoint, return_grad: bool = False + ) -> float | tuple[float, float | np.ndarray]: """Evaluate the term and (optionally) its gradient. Args: @@ -98,7 +100,7 @@ def __call__(self, query_point: QueryPoint, return_grad: bool = False) -> float @property def is_multivariable(self) -> bool: """Check whether the term is multivariable. - + Returns: True if the term involves multiple variables, False otherwise. """ @@ -124,10 +126,10 @@ def generate_cut(self, query_point: QueryPoint) -> mip.LinExpr: def _get_input(self, query_point: QueryPoint) -> Input: """Extract input values from query point based on variable type. - + Args: query_point: The query point containing variable values. - + Returns: Input values in the format expected by the function. """ @@ -148,10 +150,10 @@ def _evaluate_func(self, x: Input) -> float | tuple[float, float | np.ndarray]: def _evaluate_grad(self, x: Input) -> float | np.ndarray: """Evaluate the gradient. - + Args: x: The input values at which to evaluate the gradient. - + Returns: The gradient value(s). """ @@ -165,10 +167,10 @@ def _evaluate_grad(self, x: Input) -> float | np.ndarray: def _approximate_grad(self, x: Input) -> float | np.ndarray: """Approximate the gradient using central finite differences. - + Args: x: The input values at which to approximate the gradient. - + Returns: The approximated gradient value(s). """ @@ -183,5 +185,6 @@ def _approximate_grad(self, x: Input) -> float | np.ndarray: ) / self.step_size return grad return ( - self._evaluate_func(x=x + self.step_size / 2) - self._evaluate_func(x=x - self.step_size / 2) + self._evaluate_func(x=x + self.step_size / 2) + - self._evaluate_func(x=x - self.step_size / 2) ) / self.step_size diff --git a/src/halfspace/model.py b/src/halfspace/model.py index 6f8444b..f90c6b4 100644 --- a/src/halfspace/model.py +++ b/src/halfspace/model.py @@ -10,19 +10,19 @@ import numpy as np import pandas as pd -from .convex_term import ConvexTerm, Var, Func, FuncGrad, Grad -from .utils import check_scalar, log_table_header, log_table_row +from .convex_term import ConvexTerm, Func, FuncGrad, Grad, Var +from .utils import check_param, log_table_header, log_table_row type Start = list[tuple[mip.Var, float]] class Model: """Mixed-integer convex optimization model using outer approximation. - + This class implements an outer approximation algorithm for solving mixed-integer convex optimization problems. The algorithm iteratively adds linear cuts to approximate nonlinear constraints and objective functions, solving a sequence of mixed-integer linear programs. - + The model supports both continuous and discrete variables, linear and nonlinear constraints, and can handle both minimization and maximization problems (with concave objectives for maximization). @@ -81,7 +81,7 @@ def __init__( def reset(self) -> None: """Reset the model to its initial state. - + Clears all variables, constraints, and solution data, returning the model to the state it was in immediately after construction. """ @@ -273,7 +273,9 @@ def optimize( self._model.add_constr(expr <= 0) # Add linear cut for objective function - expr = mip.xsum(term.generate_cut(query_point=query_point) for term in self.objective_terms) + expr = mip.xsum( + term.generate_cut(query_point=query_point) for term in self.objective_terms + ) if self.minimize: self._model.add_constr(bound >= expr) # t >= objective else: @@ -287,18 +289,22 @@ def optimize( mip.OptimizationStatus.OPTIMAL, mip.OptimizationStatus.FEASIBLE, ): - logging.info(f"Solve unsuccessful - exiting with optimization status: '{status.value}'.") + logging.info( + f"Solve unsuccessful - exiting with optimization status: '{status.value}'." + ) self._status = status return self.status # Extract solution and evaluate true objective value solution = {var: var.x for var in self._model.vars} objective_value_new = sum(term(query_point=solution) for term in self.objective_terms) - + # Check if this is a better feasible solution is_improvement = self.minimize == (objective_value_new < self.objective_value) - is_feasible = all(constr(solution) <= self.infeasibility_tol for constr in self.nonlinear_constrs) - + is_feasible = all( + constr(solution) <= self.infeasibility_tol for constr in self.nonlinear_constrs + ) + if is_improvement and is_feasible: iters_no_improvement = 0 self._objective_value = objective_value_new @@ -306,12 +312,13 @@ def optimize( else: if np.isfinite(self.objective_value): iters_no_improvement += 1 - + # Update query point for next iteration if self.smoothing is not None: # Smooth between current query point and new solution query_point = { - var: self.smoothing * query_point[var] + (1 - self.smoothing) * solution[var] + var: self.smoothing * query_point[var] + + (1 - self.smoothing) * solution[var] for var in self._model.vars } else: @@ -320,9 +327,13 @@ def optimize( # Update best bound with monotonicity to prevent numerical issues if self.minimize: - self._best_bound = np.clip(bound.x, a_min=self.best_bound, a_max=self.objective_value) + self._best_bound = np.clip( + bound.x, a_min=self.best_bound, a_max=self.objective_value + ) else: - self._best_bound = np.clip(bound.x, a_min=self.objective_value, a_max=self.best_bound) + self._best_bound = np.clip( + bound.x, a_min=self.objective_value, a_max=self.best_bound + ) # Log progress self._search_log.append( @@ -346,7 +357,9 @@ def optimize( return self.status if max_iters_no_improvement is not None: if iters_no_improvement >= max_iters_no_improvement: - logging.info("Max iterations without improvement reached - terminating search early.") + logging.info( + "Max iterations without improvement reached - terminating search early." + ) self._status = mip.OptimizationStatus.FEASIBLE return self.status @@ -360,13 +373,13 @@ def optimize( def var_by_name(self, name: str) -> mip.Var: """Get a variable by its name. - + Args: name: The name of the variable to retrieve. - + Returns: The variable with the specified name. - + Raises: KeyError: If no variable with the given name exists. """ @@ -381,7 +394,7 @@ def var_value(self, x: mip.Var | mip.LinExprTensor | str) -> float | np.ndarray: Returns: Variable value(s) as float or numpy array. - + Raises: TypeError: If input type is not supported. """ @@ -398,7 +411,7 @@ def var_value(self, x: mip.Var | mip.LinExprTensor | str) -> float | np.ndarray: @property def objective_terms(self) -> list[ConvexTerm]: """Get the objective terms of the model. - + Returns: List of convex terms that make up the objective function. """ @@ -410,7 +423,7 @@ def linear_constrs(self) -> mip.ConstrList: After optimization, this includes both original linear constraints and the linear cuts added during the outer approximation process. - + Returns: List of all linear constraints in the model. """ @@ -419,7 +432,7 @@ def linear_constrs(self) -> mip.ConstrList: @property def nonlinear_constrs(self) -> list[ConvexTerm]: """Get the nonlinear constraints of the model. - + Returns: List of convex terms representing nonlinear constraints. """ @@ -428,7 +441,7 @@ def nonlinear_constrs(self) -> list[ConvexTerm]: @property def start(self) -> Start: """Get the starting solution or partial solution. - + Returns: List of (variable, value) pairs defining the starting point. """ @@ -437,7 +450,7 @@ def start(self) -> Start: @start.setter def start(self, value: Start) -> None: """Set the starting solution or partial solution. - + Args: value: List of (variable, value) pairs defining the starting point. """ @@ -448,7 +461,7 @@ def start(self, value: Start) -> None: @property def best_solution(self) -> dict[mip.Var, float]: """Get the best feasible solution found. - + Returns: Dictionary mapping variables to their values in the best solution. """ @@ -457,7 +470,7 @@ def best_solution(self) -> dict[mip.Var, float]: @property def objective_value(self) -> float: """Get the objective value of the best solution. - + Returns: Objective function value at the best feasible solution. """ @@ -466,7 +479,7 @@ def objective_value(self) -> float: @property def best_bound(self) -> float: """Get the best bound on the optimal objective value. - + Returns: Best known bound (lower bound for minimization, upper bound for maximization). """ @@ -475,7 +488,7 @@ def best_bound(self) -> float: @property def gap(self) -> float: """Get the relative optimality gap. - + Returns: Relative gap as |objective_value - best_bound| / max(|objective_value|, |best_bound|). """ @@ -484,7 +497,7 @@ def gap(self) -> float: @property def gap_abs(self) -> float: """Get the absolute optimality gap. - + Returns: Absolute gap as |objective_value - best_bound|. """ @@ -493,7 +506,7 @@ def gap_abs(self) -> float: @property def status(self) -> mip.OptimizationStatus: """Get the optimization status. - + Returns: Status indicating whether optimization was successful and why it terminated. """ @@ -502,7 +515,7 @@ def status(self) -> mip.OptimizationStatus: @property def search_log(self) -> pd.DataFrame: """Get the search progress log. - + Returns: DataFrame with columns: iteration, objective_value, best_bound, gap. """ @@ -511,31 +524,31 @@ def search_log(self) -> pd.DataFrame: @staticmethod def sum(terms: Iterable[mip.Var | mip.LinExpr]) -> mip.LinExpr: """Create a linear expression from a summation. - + Args: terms: Iterable of variables or linear expressions to sum. - + Returns: Linear expression representing the sum of all terms. """ return mip.xsum(terms) def _validate_params(self) -> None: - check_scalar( + check_param( x=self.max_gap, name="max_gap", lb=0, var_type=float, include_boundaries=False, ) - check_scalar( + check_param( x=self.max_gap_abs, name="max_gap_abs", lb=0, var_type=float, include_boundaries=False, ) - check_scalar( + check_param( x=self.infeasibility_tol, name="infeasibility_tol", var_type=float, @@ -543,7 +556,7 @@ def _validate_params(self) -> None: include_boundaries=False, ) if self.smoothing is not None: - check_scalar( + check_param( x=self.smoothing, name="smoothing", var_type=float, @@ -552,7 +565,7 @@ def _validate_params(self) -> None: include_boundaries=False, ) if self.log_freq is not None: - check_scalar( + check_param( x=self.log_freq, name="log_freq", var_type=int, @@ -561,21 +574,23 @@ def _validate_params(self) -> None: ) @staticmethod - def _validate_bounds(lb: float | int, ub: float | int, var_type: str) -> tuple[float | int, float | int]: + def _validate_bounds( + lb: float | int, ub: float | int, var_type: str + ) -> tuple[float | int, float | int]: """Validate and normalize variable bounds. - + Args: lb: Lower bound value. ub: Upper bound value. var_type: Variable type ('C', 'I', or 'B'). - + Returns: Tuple of (validated_lb, validated_ub). """ if var_type == mip.BINARY: lb, ub = 0, 1 else: - check_scalar( + check_param( x=lb, name="lb", var_type=(float, int), @@ -583,7 +598,7 @@ def _validate_bounds(lb: float | int, ub: float | int, var_type: str) -> tuple[f lb=-mip.INF, include_boundaries=False, ) - check_scalar( + check_param( x=ub, name="ub", var_type=(float, int), diff --git a/src/halfspace/utils.py b/src/halfspace/utils.py index 32c311c..e3a0630 100644 --- a/src/halfspace/utils.py +++ b/src/halfspace/utils.py @@ -1,8 +1,7 @@ """Utility functions for the `halfspace` package.""" import logging -from typing import Iterable, Any - +from typing import Any, Iterable def log_table_header(columns: Iterable[str], width: int = 15) -> None: @@ -21,7 +20,7 @@ def log_table_header(columns: Iterable[str], width: int = 15) -> None: columns_list = list(columns) if not columns_list: return - + columns = [f"{{:{width}}}".format(col) for col in columns_list] line = "-{}-".format("-".join("-" * len(col) for col in columns)) logging.info(line) @@ -45,12 +44,15 @@ def log_table_row(values: Iterable[float | int], width: int = 15) -> None: values_list = list(values) if not values_list: return - - values_ = [(f"{{:{width}}}" if isinstance(value, int) else f"{{:{width}.3e}}").format(value) for value in values_list] + + values_ = [ + (f"{{:{width}}}" if isinstance(value, int) else f"{{:{width}.3e}}").format(value) + for value in values_list + ] logging.info("|{}|".format("|".join(values_))) -def check_scalar( +def check_param( x: Any, name: str, var_type: type | tuple[type, ...] | None = None, @@ -88,11 +90,15 @@ def check_scalar( raise ValueError(f"Variable '{name}' ({x}) is less than lower bound ({lb}).") else: if x <= lb: - raise ValueError(f"Variable '{name}' ({x}) is less than or equal to lower bound ({lb}).") + raise ValueError( + f"Variable '{name}' ({x}) is less than or equal to lower bound ({lb})." + ) if ub is not None: if include_boundaries: if x > ub: raise ValueError(f"Variable '{name}' ({x}) is greater than upper bound ({ub}).") else: if x >= ub: - raise ValueError(f"Variable '{name}' ({x}) is greater than or equal to upper bound ({ub}).") + raise ValueError( + f"Variable '{name}' ({x}) is greater than or equal to upper bound ({ub})." + ) diff --git a/tests/test_convex_term.py b/tests/test_convex_term.py index 75e4010..3c87ab7 100644 --- a/tests/test_convex_term.py +++ b/tests/test_convex_term.py @@ -3,7 +3,7 @@ import pytest from halfspace import Model -from halfspace.convex_term import Func, FuncGrad, Grad, ConvexTerm, QueryPoint +from halfspace.convex_term import ConvexTerm, Func, FuncGrad, Grad, QueryPoint def _process_callbacks( @@ -62,7 +62,9 @@ def model() -> Model: ({"x": 1}, 1, 2), ], ) -@pytest.mark.parametrize(["combine_grad", "approximate_grad"], [(True, False), (False, True), (False, False)]) +@pytest.mark.parametrize( + ["combine_grad", "approximate_grad"], [(True, False), (False, True), (False, False)] +) def test_single_variable_term( model: Model, query_point: dict[str, float], @@ -98,7 +100,9 @@ def test_single_variable_term( ({"x": 1, "y": 2}, 5, np.array([2, 4])), ], ) -@pytest.mark.parametrize(["combine_grad", "approximate_grad"], [(True, False), (False, True), (False, False)]) +@pytest.mark.parametrize( + ["combine_grad", "approximate_grad"], [(True, False), (False, True), (False, False)] +) def test_multivariable_term( model: Model, query_point: dict[str, float], @@ -134,7 +138,9 @@ def test_multivariable_term( ({"z_0": 1, "z_1": 2}, 5, np.array([2, 4])), ], ) -@pytest.mark.parametrize(["combine_grad", "approximate_grad"], [(True, False), (False, True), (False, False)]) +@pytest.mark.parametrize( + ["combine_grad", "approximate_grad"], [(True, False), (False, True), (False, False)] +) def test_var_tensor_term( model: Model, query_point: dict[str, float], @@ -172,9 +178,11 @@ def test_invalid_step_size_raises(model: Model, step_size: float): def test_var_tensor_2d_term(model: Model): z = model.add_var_tensor(shape=(2, 2), lb=-10, ub=10, name="z") - func = lambda w: (w**2).sum() - grad = lambda w: 2 * w - term = ConvexTerm(var=z, func=func, grad=grad) + term = ConvexTerm( + var=z, + func=lambda w: (w**2).sum(), + grad=lambda w: 2 * w, + ) qp = { model.var_by_name("z_0_0"): 1.0, diff --git a/tests/test_model.py b/tests/test_model.py index 7e8e1ad..c009900 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -16,7 +16,9 @@ def _check_solution( expected_status: mip.OptimizationStatus = mip.OptimizationStatus.OPTIMAL, ): if expected_objective_value is not None: - assert model.objective_value == pytest.approx(expected_objective_value, abs=model.max_gap_abs) + assert model.objective_value == pytest.approx( + expected_objective_value, abs=model.max_gap_abs + ) if expected_solution is not None: for x, expected_value in expected_solution.items(): assert model.var_value(x=x) == pytest.approx(expected_value, abs=VAR_TOL) @@ -142,7 +144,7 @@ def test_multivariable_nonlinear_constraint_infeasible(): def test_maximize_concave_objective(): model = Model(minimize=False) x = model.add_var(lb=0, ub=1) - model.add_objective_term(var=x, func=lambda x: -(x - 0.75) ** 2 + 1) + model.add_objective_term(var=x, func=lambda x: -((x - 0.75) ** 2) + 1) model.optimize() _check_solution( model=model, diff --git a/tests/test_utils.py b/tests/test_utils.py index 5c99a31..c312799 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,10 +1,9 @@ from contextlib import nullcontext as does_not_raise -from numbers import Real -from typing import Iterable, Any +from typing import Any, Iterable import pytest -from halfspace.utils import log_table_header, log_table_row, check_scalar +from halfspace.utils import check_param, log_table_header, log_table_row @pytest.mark.parametrize("columns", [["a", "b", "c", "d"]]) @@ -16,7 +15,7 @@ def test_log_table_header(columns: Iterable[str], width: int): @pytest.mark.parametrize("values", [[1, 1.0, 2e10, 3e-10]]) @pytest.mark.parametrize("width", [10, 15]) -def test_log_table_row(values: Iterable[Real], width: int): +def test_log_table_row(values: Iterable[float | int], width: int): log_table_row(values=values, width=width) # TODO: add log checks @@ -36,17 +35,17 @@ def test_log_table_row(values: Iterable[Real], width: int): (1, "x", int, 0, 1, False, pytest.raises(ValueError)), ], ) -def test_check_scalar( +def test_check_param( x: Any, name: str, var_type: type | tuple[type, ...] | None, - lb: Real | None, - ub: Real | None, + lb: float | int | None, + ub: float | int | None, include_boundaries: bool, expectation, ): with expectation: - check_scalar( + check_param( x=x, name=name, var_type=var_type, diff --git a/uv.lock b/uv.lock index d649d0f..eed6916 100644 --- a/uv.lock +++ b/uv.lock @@ -78,33 +78,26 @@ dependencies = [ ] [package.optional-dependencies] -all = [ - { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "tomli" }, -] dev = [ + { name = "mypy" }, { name = "pre-commit" }, - { name = "tomli" }, -] -test = [ { name = "pytest" }, { name = "pytest-cov" }, + { name = "ruff" }, ] [package.metadata] requires-dist = [ - { name = "halfspace-optimizer", extras = ["dev", "test"], marker = "extra == 'all'" }, { name = "mip", specifier = ">=1.15.0,<2.0.0" }, + { name = "mypy", marker = "extra == 'dev'" }, { name = "numpy", specifier = ">=2.3.3,<3.0.0" }, { name = "pandas", specifier = ">=2.3.2,<3.0.0" }, { name = "pre-commit", marker = "extra == 'dev'" }, - { name = "pytest", marker = "extra == 'test'" }, - { name = "pytest-cov", marker = "extra == 'test'" }, - { name = "tomli", marker = "extra == 'dev'" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-cov", marker = "extra == 'dev'" }, + { name = "ruff", marker = "extra == 'dev'" }, ] -provides-extras = ["dev", "test", "all"] +provides-extras = ["dev"] [[package]] name = "identify" @@ -136,6 +129,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/c9/d4ba71d5d73cf57596b8637ab20eda547c6cde296860f6b6192568809e70/mip-1.15.0-py3-none-any.whl", hash = "sha256:9a48c993ddc9a48591a59e4a1221b400eb1e35fc087052085774360330bb9226", size = 15269834, upload-time = "2023-01-04T13:51:34.596Z" }, ] +[[package]] +name = "mypy" +version = "1.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/a3/931e09fc02d7ba96da65266884da4e4a8806adcdb8a57faaacc6edf1d538/mypy-1.18.1.tar.gz", hash = "sha256:9e988c64ad3ac5987f43f5154f884747faf62141b7f842e87465b45299eea5a9", size = 3448447, upload-time = "2025-09-11T23:00:47.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/14/1c3f54d606cb88a55d1567153ef3a8bc7b74702f2ff5eb64d0994f9e49cb/mypy-1.18.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:502cde8896be8e638588b90fdcb4c5d5b8c1b004dfc63fd5604a973547367bb9", size = 12911082, upload-time = "2025-09-11T23:00:41.465Z" }, + { url = "https://files.pythonhosted.org/packages/90/83/235606c8b6d50a8eba99773add907ce1d41c068edb523f81eb0d01603a83/mypy-1.18.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7509549b5e41be279afc1228242d0e397f1af2919a8f2877ad542b199dc4083e", size = 11919107, upload-time = "2025-09-11T22:58:40.903Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/4e2ce00f8d15b99d0c68a2536ad63e9eac033f723439ef80290ec32c1ff5/mypy-1.18.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5956ecaabb3a245e3f34100172abca1507be687377fe20e24d6a7557e07080e2", size = 12472551, upload-time = "2025-09-11T22:58:37.272Z" }, + { url = "https://files.pythonhosted.org/packages/32/bb/92642a9350fc339dd9dcefcf6862d171b52294af107d521dce075f32f298/mypy-1.18.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8750ceb014a96c9890421c83f0db53b0f3b8633e2864c6f9bc0a8e93951ed18d", size = 13340554, upload-time = "2025-09-11T22:59:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ee/38d01db91c198fb6350025d28f9719ecf3c8f2c55a0094bfbf3ef478cc9a/mypy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fb89ea08ff41adf59476b235293679a6eb53a7b9400f6256272fb6029bec3ce5", size = 13530933, upload-time = "2025-09-11T22:59:20.228Z" }, + { url = "https://files.pythonhosted.org/packages/da/8d/6d991ae631f80d58edbf9d7066e3f2a96e479dca955d9a968cd6e90850a3/mypy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:2657654d82fcd2a87e02a33e0d23001789a554059bbf34702d623dafe353eabf", size = 9828426, upload-time = "2025-09-11T23:00:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/e0/1d/4b97d3089b48ef3d904c9ca69fab044475bd03245d878f5f0b3ea1daf7ce/mypy-1.18.1-py3-none-any.whl", hash = "sha256:b76a4de66a0ac01da1be14ecc8ae88ddea33b8380284a9e3eae39d57ebcbe26e", size = 2352212, upload-time = "2025-09-11T22:59:26.576Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -194,6 +216,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/30/8114832daff7489f179971dbc1d854109b7f4365a546e3ea75b6516cea95/pandas-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c13b81a9347eb8c7548f53fd9a4f08d4dfe996836543f805c987bafa03317ae", size = 10983326, upload-time = "2025-08-21T10:27:31.901Z" }, ] +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + [[package]] name = "platformdirs" version = "4.4.0" @@ -314,6 +345,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, ] +[[package]] +name = "ruff" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/1a/1f4b722862840295bcaba8c9e5261572347509548faaa99b2d57ee7bfe6a/ruff-0.13.0.tar.gz", hash = "sha256:5b4b1ee7eb35afae128ab94459b13b2baaed282b1fb0f472a73c82c996c8ae60", size = 5372863, upload-time = "2025-09-10T16:25:37.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/fe/6f87b419dbe166fd30a991390221f14c5b68946f389ea07913e1719741e0/ruff-0.13.0-py3-none-linux_armv6l.whl", hash = "sha256:137f3d65d58ee828ae136a12d1dc33d992773d8f7644bc6b82714570f31b2004", size = 12187826, upload-time = "2025-09-10T16:24:39.5Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/c92296b1fc36d2499e12b74a3fdb230f77af7bdf048fad7b0a62e94ed56a/ruff-0.13.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21ae48151b66e71fd111b7d79f9ad358814ed58c339631450c66a4be33cc28b9", size = 12933428, upload-time = "2025-09-10T16:24:43.866Z" }, + { url = "https://files.pythonhosted.org/packages/44/cf/40bc7221a949470307d9c35b4ef5810c294e6cfa3caafb57d882731a9f42/ruff-0.13.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64de45f4ca5441209e41742d527944635a05a6e7c05798904f39c85bafa819e3", size = 12095543, upload-time = "2025-09-10T16:24:46.638Z" }, + { url = "https://files.pythonhosted.org/packages/f1/03/8b5ff2a211efb68c63a1d03d157e924997ada87d01bebffbd13a0f3fcdeb/ruff-0.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2c653ae9b9d46e0ef62fc6fbf5b979bda20a0b1d2b22f8f7eb0cde9f4963b8", size = 12312489, upload-time = "2025-09-10T16:24:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/37/fc/2336ef6d5e9c8d8ea8305c5f91e767d795cd4fc171a6d97ef38a5302dadc/ruff-0.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cec632534332062bc9eb5884a267b689085a1afea9801bf94e3ba7498a2d207", size = 11991631, upload-time = "2025-09-10T16:24:53.439Z" }, + { url = "https://files.pythonhosted.org/packages/39/7f/f6d574d100fca83d32637d7f5541bea2f5e473c40020bbc7fc4a4d5b7294/ruff-0.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd628101d9f7d122e120ac7c17e0a0f468b19bc925501dbe03c1cb7f5415b24", size = 13720602, upload-time = "2025-09-10T16:24:56.392Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c8/a8a5b81d8729b5d1f663348d11e2a9d65a7a9bd3c399763b1a51c72be1ce/ruff-0.13.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afe37db8e1466acb173bb2a39ca92df00570e0fd7c94c72d87b51b21bb63efea", size = 14697751, upload-time = "2025-09-10T16:24:59.89Z" }, + { url = "https://files.pythonhosted.org/packages/57/f5/183ec292272ce7ec5e882aea74937f7288e88ecb500198b832c24debc6d3/ruff-0.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f96a8d90bb258d7d3358b372905fe7333aaacf6c39e2408b9f8ba181f4b6ef2", size = 14095317, upload-time = "2025-09-10T16:25:03.025Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8d/7f9771c971724701af7926c14dab31754e7b303d127b0d3f01116faef456/ruff-0.13.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b5e3d883e4f924c5298e3f2ee0f3085819c14f68d1e5b6715597681433f153", size = 13144418, upload-time = "2025-09-10T16:25:06.272Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a6/7985ad1778e60922d4bef546688cd8a25822c58873e9ff30189cfe5dc4ab/ruff-0.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03447f3d18479df3d24917a92d768a89f873a7181a064858ea90a804a7538991", size = 13370843, upload-time = "2025-09-10T16:25:09.965Z" }, + { url = "https://files.pythonhosted.org/packages/64/1c/bafdd5a7a05a50cc51d9f5711da704942d8dd62df3d8c70c311e98ce9f8a/ruff-0.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fbc6b1934eb1c0033da427c805e27d164bb713f8e273a024a7e86176d7f462cf", size = 13321891, upload-time = "2025-09-10T16:25:12.969Z" }, + { url = "https://files.pythonhosted.org/packages/bc/3e/7817f989cb9725ef7e8d2cee74186bf90555279e119de50c750c4b7a72fe/ruff-0.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8ab6a3e03665d39d4a25ee199d207a488724f022db0e1fe4002968abdb8001b", size = 12119119, upload-time = "2025-09-10T16:25:16.621Z" }, + { url = "https://files.pythonhosted.org/packages/58/07/9df080742e8d1080e60c426dce6e96a8faf9a371e2ce22eef662e3839c95/ruff-0.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2a5c62f8ccc6dd2fe259917482de7275cecc86141ee10432727c4816235bc41", size = 11961594, upload-time = "2025-09-10T16:25:19.49Z" }, + { url = "https://files.pythonhosted.org/packages/6a/f4/ae1185349197d26a2316840cb4d6c3fba61d4ac36ed728bf0228b222d71f/ruff-0.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b7b85ca27aeeb1ab421bc787009831cffe6048faae08ad80867edab9f2760945", size = 12933377, upload-time = "2025-09-10T16:25:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/b6/39/e776c10a3b349fc8209a905bfb327831d7516f6058339a613a8d2aaecacd/ruff-0.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:79ea0c44a3032af768cabfd9616e44c24303af49d633b43e3a5096e009ebe823", size = 13418555, upload-time = "2025-09-10T16:25:25.681Z" }, + { url = "https://files.pythonhosted.org/packages/46/09/dca8df3d48e8b3f4202bf20b1658898e74b6442ac835bfe2c1816d926697/ruff-0.13.0-py3-none-win32.whl", hash = "sha256:4e473e8f0e6a04e4113f2e1de12a5039579892329ecc49958424e5568ef4f768", size = 12141613, upload-time = "2025-09-10T16:25:28.664Z" }, + { url = "https://files.pythonhosted.org/packages/61/21/0647eb71ed99b888ad50e44d8ec65d7148babc0e242d531a499a0bbcda5f/ruff-0.13.0-py3-none-win_amd64.whl", hash = "sha256:48e5c25c7a3713eea9ce755995767f4dcd1b0b9599b638b12946e892123d1efb", size = 13258250, upload-time = "2025-09-10T16:25:31.773Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a3/03216a6a86c706df54422612981fb0f9041dbb452c3401501d4a22b942c9/ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e", size = 12312357, upload-time = "2025-09-10T16:25:35.595Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -324,22 +381,12 @@ wheels = [ ] [[package]] -name = "tomli" -version = "2.2.1" +name = "typing-extensions" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, - { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, - { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, - { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, - { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, - { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, - { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, - { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]]