diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a16f9a2..2c22506 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,10 @@ -name: CI +name: CI workflow on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] jobs: build: @@ -13,27 +13,34 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11"] + 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 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 - - 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 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Install dependencies and build package using uv run: | - pytest + uv sync --extra dev + uv build + + - name: Run linting with ruff + run: uv run ruff check . + + - name: Run formatting check with ruff + run: uv run ruff format --check . + + - name: Run type checking with mypy + run: uv run mypy src + + - name: Run tests with coverage using pytest + run: uv run pytest 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..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 ddbf496..29768bc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +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/psf/black - rev: 22.10.0 - hooks: - - id: black -- 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 a52a4ec..afab364 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,27 +1,83 @@ [build-system] -requires = ["setuptools >= 61.0"] -build-backend = "setuptools.build_meta" +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" license = {file = "LICENSE"} -requires-python = ">=3.9,<3.12" +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", +] +dependencies = [ + "mip>=1.15.0,<2.0.0", + "numpy>=2.3.3,<3.0.0", + "pandas>=2.3.2,<3.0.0", ] -dynamic = ["dependencies"] - -[tool.setuptools.dynamic] -dependencies = {file = ["requirements.txt"]} [project.urls] Homepage = "https://github.com/joshivanhoe/halfspace" Issues = "https://github.com/joshivanhoe/halfspace/issues" + +[project.optional-dependencies] +dev = [ + "ruff", + "mypy", + "pre-commit", + "pytest", + "pytest-cov", +] + +[tool.ruff] +line-length = 100 +exclude = [ + "*.ipynb", +] + +[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.ruff.lint.per-file-ignores] +"tests/**" = ["D"] # no documentation checks required for tests + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.mypy] +ignore_missing_imports = true +install_types = true +non_interactive = true +check_untyped_defs = true + +[tool.coverage.run] +source = ["src/halfspace"] +omit = ["*/tests/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/halfspace"] 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/__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 20ec1df..34d501a 100644 --- a/src/halfspace/convex_term.py +++ b/src/halfspace/convex_term.py @@ -3,18 +3,19 @@ It provides a modular framework for generating cutting planes. """ -from typing import Union, Callable, Optional, Iterable +from typing import Callable, Iterable, Literal, overload import mip import numpy as np +from .utils import check_param -QueryPoint = dict[mip.Var, float] -Var = Union[mip.Var, Iterable[mip.Var], mip.LinExprTensor] -Input = Union[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]] +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: @@ -23,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. """ @@ -39,29 +40,44 @@ 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 = "", - ): + ) -> None: """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_param( + x=step_size, + name="step_size", + var_type=float, + lb=0, + include_boundaries=False, + ) self.var = var self.func = func self.grad = grad 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 - ) -> Union[float, tuple[float, Union[float, np.ndarray]]]: + ) -> float | tuple[float, float | np.ndarray]: """Evaluate the term and (optionally) its gradient. Args: @@ -69,7 +85,7 @@ def __call__( 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. """ @@ -83,36 +99,48 @@ def __call__( @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. """ - fun, 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)) + fun - return grad * (self.var - x) + fun + 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] - 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. + 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) @@ -120,8 +148,15 @@ 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]: - """Evaluate the gradient.""" + 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). + """ if not self.grad: return self._approximate_grad(x=x) if isinstance(self.var, (mip.Var, mip.LinExprTensor)): @@ -130,20 +165,25 @@ 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]: - """Approximate the gradient of the function at point using the central finite difference method.""" + 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). + """ 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) + e = np.eye(n_dim) + for i in range(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 2f85cbc..f90c6b4 100644 --- a/src/halfspace/model.py +++ b/src/halfspace/model.py @@ -4,33 +4,45 @@ """ import logging -from typing import Optional, Iterable, Union +from typing import Iterable import mip 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 -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__( @@ -40,21 +52,21 @@ 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, - ): - """Optimization model constructor. + smoothing: float | None = 0.5, + solver_name: str | None = "CBC", + log_freq: int | None = 1, + ) -> None: + """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, @@ -81,28 +97,28 @@ 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 | int = 0, + ub: float | int = mip.INF, 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) @@ -110,24 +126,24 @@ def add_var( def add_var_tensor( self, shape: tuple[int, ...], - lb: Optional[float] = None, - ub: Optional[float] = None, + lb: float | int = 0, + ub: float | int = mip.INF, var_type: str = mip.CONTINUOUS, name: str = "", ) -> mip.LinExprTensor: """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,34 +158,35 @@ 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) 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. + 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. @@ -187,24 +204,24 @@ 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. + """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. @@ -222,54 +239,52 @@ 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. + """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 - query_point = { - x: self._start.get(x) or (x.lb + x.ub) / 2 for x in self._model.vars - } + # 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 + 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, @@ -280,31 +295,37 @@ 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 + 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 ) - if self.minimize == (objective_value_new < self.objective_value) and 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 @@ -314,7 +335,7 @@ def optimize( bound.x, a_min=self.objective_value, a_max=self.best_bound ) - # Update log + # Log progress self._search_log.append( { "iteration": i, @@ -329,22 +350,21 @@ 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( - 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." + "Max iterations without improvement reached - terminating search early." ) self._status = mip.OptimizationStatus.FEASIBLE return self.status - logging.info(f"Max iterations reached - terminating search.") + # Reached maximum iterations + logging.info("Max iterations reached - terminating search.") if self.best_solution: self._status = mip.OptimizationStatus.FEASIBLE else: @@ -352,139 +372,200 @@ 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: Union[mip.Var, mip.LinExprTensor, str] - ) -> Union[float, np.ndarray]: - """Get the value one or more decision variables corresponding to the best solution. + def var_value(self, x: mip.Var | mip.LinExprTensor | str) -> float | np.ndarray: + """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: + Variable value(s) as float or numpy array. - Returns: float or np.ndarray - The value(s) of the variable(s). + Raises: + TypeError: If input type is not supported. """ if isinstance(x, str): x = self.var_by_name(name=x) 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.") @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[Union[mip.Var, mip.LinExpr]]) -> mip.LinExpr: - """Create a linear expression from a summation.""" - return mip.xsum(terms=terms) + 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.0, + lb=0, var_type=float, include_boundaries=False, ) - check_scalar( + check_param( x=self.max_gap_abs, name="max_gap_abs", - lb=0.0, + lb=0, var_type=float, include_boundaries=False, ) - check_scalar( + check_param( x=self.infeasibility_tol, - name="feasibility_tol", + name="infeasibility_tol", var_type=float, - lb=0.0, + lb=0, include_boundaries=False, ) if self.smoothing is not None: - check_scalar( + check_param( 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: - check_scalar( + check_param( x=self.log_freq, name="log_freq", var_type=int, @@ -493,11 +574,23 @@ 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]: + """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), @@ -505,7 +598,7 @@ def _validate_bounds(lb: float, ub: float, var_type: str) -> tuple[float, float] 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 b43779e..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 Union, Iterable, Optional, Any, Type +from typing import Any, Iterable def log_table_header(columns: Iterable[str], width: int = 15) -> None: @@ -18,41 +17,47 @@ 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))) logging.info(line) -def log_table_row(values: Iterable[Union[float, int]], 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`. 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))) + logging.info("|{}|".format("|".join(values_))) -def check_scalar( +def check_param( 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: float | int | None = None, + ub: float | int | None = None, include_boundaries: bool = True, ) -> None: """Check that a scalar satisfies certain conditions. @@ -62,34 +67,38 @@ 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})." + 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 14249eb..3c87ab7 100644 --- a/tests/test_convex_term.py +++ b/tests/test_convex_term.py @@ -1,11 +1,9 @@ -from typing import Union, Optional - import mip import numpy as np 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( @@ -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,14 +28,12 @@ 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, ): # 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) @@ -93,9 +89,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()}, ) @@ -133,9 +127,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()}, ) @@ -173,7 +165,39 @@ 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()}, + ) + + +@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") + 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, + 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 076ab36..c009900 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: @@ -74,9 +72,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, @@ -88,9 +84,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, @@ -103,9 +97,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( @@ -119,29 +111,18 @@ 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_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(): 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, @@ -154,14 +135,43 @@ 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) + + +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=None, - expected_solution=None, - expected_status=mip.OptimizationStatus.INFEASIBLE, + 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 1d965a1..c312799 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,9 +1,9 @@ from contextlib import nullcontext as does_not_raise -from typing import Union, Iterable, Any, Optional, Type +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"]]) @@ -15,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[Union[float, int]], width: int): +def test_log_table_row(values: Iterable[float | int], width: int): log_table_row(values=values, width=width) # TODO: add log checks @@ -28,24 +28,24 @@ def test_log_table_row(values: Iterable[Union[float, int]], 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( +def test_check_param( 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: 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 new file mode 100644 index 0000000..eed6916 --- /dev/null +++ b/uv.lock @@ -0,0 +1,413 @@ +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] +dev = [ + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { 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 == 'dev'" }, + { name = "pytest-cov", marker = "extra == 'dev'" }, + { name = "ruff", marker = "extra == 'dev'" }, +] +provides-extras = ["dev"] + +[[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 = "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" +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 = "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" +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 = "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" +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 = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +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/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]] +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" }, +]