diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..6faf446 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,61 @@ +name: Deploy Docs + +on: + push: + branches: + - main + paths: + - "cbfpy/**.py" + - "docs/**" + - "examples/**.py" + - "pyproject.toml" + - ".github/workflows/docs.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +env: + PYTHON_VERSION: "3.10" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ".[dev]" + + - name: Build docs + run: sphinx-build -W docs/source docs/build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/build + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 532feae..462cfdc 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -6,68 +6,75 @@ on: - main paths: - "cbfpy/**.py" + - "docs/**" - "examples/**.py" - "tests/**.py" - - "poetry.lock" + - "pyproject.toml" - ".github/workflows/pr-check.yml" env: - PYTHON_VERSION: 3.8 + PYTHON_VERSION: "3.10" jobs: test: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} - - name: Cache Poetry cache - uses: actions/cache@v3 - id: poetry_cache - with: - path: ~/.cache/pypoetry - key: poetry-cache-${{ env.PYTHON_VERSION }}-${{ hashFiles('**/poetry.lock') }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ".[test]" + + - name: Run tests + run: python -m pytest + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 - - name: Cache Packages - uses: actions/cache@v3 - id: package_cache + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 with: - path: ~/.local - key: poetry-${{ env.PYTHON_VERSION }}-${{ hashFiles('**/poetry.lock') }} + python-version: ${{ env.PYTHON_VERSION }} - - name: Install Poetry - run: curl -sSL https://install.python-poetry.org | python3 - + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ".[dev]" - - name: Install Dependencies - if: steps.poetry_cache.outputs.cache-hit != 'true' && steps.package_cache.outputs.cache-hit != 'true' - run: poetry install + - name: Ruff check + run: ruff check . - - name: Python Test - run: poetry run task test + - name: Ruff format check + run: ruff format --check . - lint: + - name: Mypy + run: mypy . + + docs: runs-on: ubuntu-latest - needs: [test] steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Cache Poetry cache - uses: actions/cache@v3 + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 with: - path: ~/.cache/pypoetry - key: poetry-cache-${{ env.PYTHON_VERSION }}-${{ hashFiles('**/poetry.lock') }} + python-version: ${{ env.PYTHON_VERSION }} - - name: Cache Packages - uses: actions/cache@v3 - with: - path: ~/.local - key: poetry-${{ env.PYTHON_VERSION }}-${{ hashFiles('**/poetry.lock') }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ".[dev]" - - name: Python Lint - run: poetry run task lint + - name: Build docs + run: sphinx-build -W docs/source docs/build diff --git a/.gitignore b/.gitignore index 21d7c85..314f1f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,21 @@ -*__pycache__ +__pycache__/ *.pyc -.mypy_cache -.pytest_cache -/docs/source/* -/docs/build/* -!/docs/source/conf.py +*.egg-info/ +dist/ +.venv/ + +# type checker / linter +.mypy_cache/ +.ruff_cache/ + +# test +.pytest_cache/ .coverage coverage.xml -cov_html -.ruff_cache -.venv -dist +cov_html/ + +# docs +/docs/build/ +/docs/source/_build/ +/docs/source/Makefile +/docs/source/make.bat diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 969e91d..0d72c0c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v5.0.0 hooks: - id: trailing-whitespace args: [--markdown-linebreak-ext=md] @@ -8,19 +8,12 @@ repos: - id: mixed-line-ending args: [--fix=lf] - id: check-added-large-files - args: ["--maxkb=10000"] # 10MB + args: ["--maxkb=10000"] - id: check-toml - id: check-yaml - - repo: https://github.com/pycqa/isort - rev: 5.11.4 - hooks: - - id: isort - - repo: https://github.com/psf/black - rev: 22.12.0 - hooks: - - id: black - language_version: python3 - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.0.221" + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.10 hooks: - id: ruff + args: [--fix] + - id: ruff-format diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 7e39a34..b799146 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,8 @@ { "recommendations": [ "ms-python.python", - "njpwerner.autodocstring", + "charliermarsh.ruff", + "ms-python.mypy-type-checker", "ryanluker.vscode-coverage-gutters" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index f33d736..6519246 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,13 @@ { - "python.linting.mypyArgs": [ - "--config-file=./pyproject.toml" - ], "python.testing.pytestEnabled": true, "python.testing.autoTestDiscoverOnSaveEnabled": true, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports.ruff": "explicit" + } + }, "coverage-gutters.showGutterCoverage": true, "coverage-gutters.showLineCoverage": true, "coverage-gutters.showRulerCoverage": true diff --git a/README.md b/README.md index 9bad4b1..5c91be8 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ # cbfpy Python package for using simple control barrier function. +[**Documentation**](https://toshi67026.github.io/cbfpy/) + ## Requirements -- poetry 1.3.1 +- Python >= 3.10 ## Installation -Create virtualenv and install dependencies defined for the project. ```sh -poetry install +pip install -e . ``` -Build and install locally with pip. +With dev/test dependencies: ```sh -poetry build -python -m pip install cbfpy --find-links=dist +pip install -e ".[dev,test]" ``` ## Examples @@ -24,49 +24,37 @@ python -m pip install cbfpy --find-links=dist ### Usage ```sh -poetry run python examples/example_{cbf name}.py +python examples/example_{cbf name}.py ``` -## Document -Generate document from docstring. -```sh -poetry run task docs -``` +## Documentation +Documentation is automatically published to [GitHub Pages](https://toshi67026.github.io/cbfpy/) on push to main. -Browse generated document by opening the html files in docs/build/ from your browser. - - -## Tools -### Format -- isort -- black +To build locally: ```sh -poetry run task fmt +pip install -e ".[dev]" +sphinx-build docs/source docs/build ``` +Then open `docs/build/index.html`. -### Lint -- black -- ruff -- mypy +## Tools +### Format & Lint +- [ruff](https://docs.astral.sh/ruff/) (format + lint + import sort) +- [mypy](https://mypy-lang.org/) (type check) ```sh -poetry run task lint +ruff format . +ruff check --fix . +mypy . ``` ### Test -- pytest -- pytest-cov ```sh -poetry run task test +pytest ``` The vscode extension [coverage-gutters](https://marketplace.visualstudio.com/items?itemName=ryanluker.vscode-coverage-gutters) can be used to display the test coverage. ### pre-commit Apply [config file](.pre-commit-config.yaml) for pre-commit. ```sh -poetry run pre-commit install -``` - -### Export requirements.txt -```sh -poetry export -f requirements.txt --output requirements.txt --without-hashes +pre-commit install ``` diff --git a/cbfpy/__init__.py b/cbfpy/__init__.py index e69de29..1dfb432 100755 --- a/cbfpy/__init__.py +++ b/cbfpy/__init__.py @@ -0,0 +1,28 @@ +from cbfpy.cbf import ( + CBFBase, + CircleCBF, + GeneralCBF, + LiDARCBF, + Pnorm2dCBF, + ScalarCBF, + ScalarRangeCBF, + UnicycleCircleCBF, + UnicyclePnorm2dCBF, + rotation_matrix_2d, +) +from cbfpy.cbf_qp_solver import CBFNomQPSolver, CBFQPSolver + +__all__ = [ + "CBFBase", + "CBFNomQPSolver", + "CBFQPSolver", + "CircleCBF", + "GeneralCBF", + "LiDARCBF", + "Pnorm2dCBF", + "ScalarCBF", + "ScalarRangeCBF", + "UnicycleCircleCBF", + "UnicyclePnorm2dCBF", + "rotation_matrix_2d", +] diff --git a/cbfpy/cbf.py b/cbfpy/cbf.py index 8d5ef4b..04c1175 100755 --- a/cbfpy/cbf.py +++ b/cbfpy/cbf.py @@ -1,87 +1,100 @@ #!/usr/bin/env python -from dataclasses import dataclass -from typing import Tuple, cast +from typing import cast import numpy as np from numpy.typing import NDArray from sympy import Matrix, Symbol, cos, lambdify, sin, sqrt, symbols -@dataclass +def rotation_matrix_2d(rad: float) -> NDArray: + """Create a 2D rotation matrix. + + Args: + rad: rotation angle in radians + + Returns: + 2x2 rotation matrix + """ + return np.array( + [ + [np.cos(rad), -np.sin(rad)], + [np.sin(rad), np.cos(rad)], + ] + ) + + class CBFBase: """CBF base class - Attributes: - G (NDArray): constraint matrix(=dh/dx) - h (float): constraint value(=h(x)) + The CBF optimization problem is formulated as: + minimize_{u} {objective function} + subject to G*u + alpha(h) >= 0 - Note: - The CBF optimization problem is formulated as: - minimize_{u} {objective function} - subject to G*u + alpha(h) >= 0 + Attributes: + G: constraint matrix (=dh/dx) + h: constraint value (=h(x)) """ G: NDArray h: float - def get_constraints(self) -> Tuple[NDArray, float]: + def get_constraints(self) -> tuple[NDArray, float]: """ Returns: - (NDArray): G - (NDArray): alpha(h) - - Note: - Call get_constraints individually to apply alpha function. + G: constraint matrix + alpha(h): value of alpha function applied to h """ return self.G, self._alpha(self.h) def _alpha(self, h: float) -> float: - """ + """Extended class-K function. Override to customize. + Args: - h (float): constraint value(=h(x)) + h: constraint value (=h(x)) Returns: - (float): h - - Note: - If you use specific alpha function, implement it with override. + alpha(h). Default implementation returns h itself. """ return h class GeneralCBF(CBFBase): - """ + """General-purpose CBF with manually specified constraints. + Attributes: - G (NDArray): constraint matrix(=dh/dx). shape=(1,) - h (float): constraint value(=h(x)) + G: constraint matrix (=dh/dx). shape=(N,) + h: constraint value (=h(x)) """ + def __init__(self, G: NDArray, h: float) -> None: + self.G = G + self.h = h + def calc_constraints(self, G: NDArray, h: float) -> None: self.G = G self.h = h class ScalarCBF(CBFBase): - """ + """CBF for scalar state variable. + + If keep_upper is True, the safety set is x - limit >= 0. + Attributes: - limit (float): limit for scalar state variable - keep_upper (bool): flag to prohibit going lower of the limit. Defaults to True. - x (Symbol): scalar state variable in symbolic form for cbf - sign (Symbol): symbolic variable for cbf - G (NDArray): constraint matrix(=dh/dx). shape=(1,) - h (NDArray): constraint value(=h(x)). shape=(1,) - - Note: - If keep_upper is True, the safety set is x - limit >= 0. + limit: limit for scalar state variable + keep_upper: flag to prohibit going lower of the limit + G: constraint matrix (=dh/dx). shape=(1,) + h: constraint value (=h(x)) """ - def __init__(self) -> None: - self.x = Symbol("x", real=True) # type: ignore - self.sign = Symbol("sign_", real=True) # type: ignore + def __init__(self, limit: float, keep_upper: bool = True) -> None: + self.x = Symbol("x", real=True) + self.sign = Symbol("sign_", real=True) + self.set_parameters(limit, keep_upper) def set_parameters(self, limit: float, keep_upper: bool = True) -> None: - """Set parameters and auxiliary functions for constraint calculation""" + """Set parameters and rebuild symbolic functions.""" self.limit = limit self.keep_upper = keep_upper @@ -89,7 +102,7 @@ def set_parameters(self, limit: float, keep_upper: bool = True) -> None: self._calc_dhdx = lambdify([self.x, self.sign], cbf.diff(self.x)) self._calc_h = lambdify([self.x, self.sign], cbf) - def get_parameters(self) -> Tuple[float, bool]: + def get_parameters(self) -> tuple[float, bool]: return self.limit, self.keep_upper def calc_constraints(self, curr_value: float) -> None: @@ -100,26 +113,25 @@ def calc_constraints(self, curr_value: float) -> None: class ScalarRangeCBF(CBFBase): - """ + """CBF for scalar state variable with range constraint. + + If keep_inside is True, the safety set is a <= x <= b. + Attributes: - a (float): lower limit for scalar state variable - b (float): upper limit for scalar state variable - keep_inside (bool): flag to prohibit going outside of the range. Defaults to True. - x (Symbol): scalar state variable in symbolic form for cbf - sign (Symbol): symbolic variable for cbf - G (NDArray): constraint matrix(=dh/dx). shape=(1,) - h (float): constraint value(=h(x)) - - Note: - If keep_inside is True, the safety set is a <= x <= b. + a: lower limit for scalar state variable + b: upper limit for scalar state variable + keep_inside: flag to prohibit going outside of the range + G: constraint matrix (=dh/dx). shape=(1,) + h: constraint value (=h(x)) """ - def __init__(self) -> None: - self.x = Symbol("x", real=True) # type: ignore - self.sign = Symbol("sign_", real=True) # type: ignore + def __init__(self, a: float, b: float, keep_inside: bool = True) -> None: + self.x = Symbol("x", real=True) + self.sign = Symbol("sign_", real=True) + self.set_parameters(a, b, keep_inside) def set_parameters(self, a: float, b: float, keep_inside: bool = True) -> None: - """Set parameters and auxiliary functions for constraint calculation""" + """Set parameters and rebuild symbolic functions.""" assert a < b self.a = a self.b = b @@ -129,7 +141,7 @@ def set_parameters(self, a: float, b: float, keep_inside: bool = True) -> None: self._calc_dhdx = lambdify([self.x, self.sign], cbf.diff(self.x)) self._calc_h = lambdify([self.x, self.sign], cbf) - def get_parameters(self) -> Tuple[float, float, bool]: + def get_parameters(self) -> tuple[float, float, bool]: return self.a, self.b, self.keep_inside def calc_constraints(self, curr_value: float) -> None: @@ -140,42 +152,42 @@ def calc_constraints(self, curr_value: float) -> None: class CircleCBF(CBFBase): - """ - Atrributes: - center (NDArray): center of circular area in world coordinate. shape=(2,) - radius (float): radius of the circular area. - keep_inside (bool): flag to prohibit going outside of the area. Defaults to True. - x (Matrix): state variables in symbolic form for cbf - sign (Symbol): symbolic variable for cbf - G (NDArray): constraint matrix(=dh/dx). shape=(2,) - h (float): constraint value(=h(x)) + """CBF for circular area constraint. + + Attributes: + center: center of circular area in world coordinate. shape=(2,) + radius: radius of the circular area + keep_inside: flag to prohibit going outside of the area + G: constraint matrix (=dh/dx). shape=(2,) + h: constraint value (=h(x)) """ - def __init__(self) -> None: - self.x = Matrix(symbols("x, y", real=True)) # type: ignore - self.sign = Symbol("sign_", real=True) # type: ignore + def __init__(self, center: NDArray, radius: float, keep_inside: bool = True) -> None: + self.x = Matrix(symbols("x, y", real=True)) + self.sign = Symbol("sign_", real=True) + self.set_parameters(center, radius, keep_inside) def set_parameters(self, center: NDArray, radius: float, keep_inside: bool = True) -> None: - """Set parameters and auxiliary functions for constraint calculation""" + """Set parameters and rebuild symbolic functions.""" self.center = center.flatten() assert radius > 0 self.radius = radius self.keep_inside = keep_inside - cbf = self.sign * (1.0 - self.x.norm(ord=2)) # type: ignore + cbf = self.sign * (1.0 - self.x.norm(ord=2)) self._calc_dhdx = lambdify([self.x, self.sign], cbf.diff(self.x)) self._calc_h = lambdify([self.x, self.sign], cbf) - def get_parameters(self) -> Tuple[NDArray, float, bool]: + def get_parameters(self) -> tuple[NDArray, float, bool]: return self.center, self.radius, self.keep_inside def calc_constraints(self, agent_position: NDArray) -> None: """ Args: - agent_position (NDArray): agent position in world coordinate. shape=(2,) + agent_position: agent position in world coordinate. shape=(2,) Note: - division by radius is a remnant of the transformation + Division by radius is a remnant of the transformation. """ agent_position_transformed = self._transform_agent_position(agent_position.flatten()) sign = 1 if self.keep_inside else -1 @@ -185,23 +197,35 @@ def calc_constraints(self, agent_position: NDArray) -> None: self.h = self._calc_h(agent_position_transformed, sign) def _transform_agent_position(self, agent_position: NDArray) -> NDArray: - """ + """Transform agent position from world coordinate to unit circle. + Args: - agent_position (NDArray): agent position in world coordinate. shape=(2,) + agent_position: agent position in world coordinate. shape=(2,) Returns: - (NDArray): transformed agent position within unit circle. shape=(2,) + Transformed agent position within unit circle. shape=(2,) """ return cast(NDArray, ((agent_position - self.center) / self.radius)) class UnicycleCircleCBF(CircleCBF): - def __init__(self) -> None: - self.x = Matrix(symbols("x, y, theta", real=True)) # type: ignore - self.sign = Symbol("sign_", real=True) # type: ignore + """CBF for circular area constraint with unicycle model. + + Attributes: + center: center of circular area in world coordinate. shape=(2,) + radius: radius of the circular area + keep_inside: flag to prohibit going outside of the area + G: constraint matrix (=dh/dx). shape=(2,) + h: constraint value (=h(x)) + """ + + def __init__(self, center: NDArray, radius: float, keep_inside: bool = True) -> None: + self.x = Matrix(symbols("x, y, theta", real=True)) + self.sign = Symbol("sign_", real=True) + self.set_parameters(center, radius, keep_inside) def set_parameters(self, center: NDArray, radius: float, keep_inside: bool = True) -> None: - """Set parameters and auxiliary functions for constraint calculation""" + """Set parameters and rebuild symbolic functions.""" self.center = center.flatten() assert radius > 0 self.radius = radius @@ -214,16 +238,15 @@ def set_parameters(self, center: NDArray, radius: float, keep_inside: bool = Tru def calc_constraints(self, agent_pose: NDArray) -> None: """ Args: - agent_position (NDArray): agent position in world coordinate. shape=(2,) + agent_pose: agent pose in world coordinate. shape=(3,) [x, y, theta] Note: - division by radius is a remnant of the transformation + Division by radius is a remnant of the transformation. """ sign = 1 if self.keep_inside else -1 agent_pose_transformed = self._transform_agent_pose(agent_pose.flatten()) theta = agent_pose[2] - # system matrix A = np.array( [ [np.cos(theta), 0], @@ -236,40 +259,43 @@ def calc_constraints(self, agent_pose: NDArray) -> None: self.h = self._calc_h(agent_pose_transformed, sign) def _transform_agent_pose(self, agent_pose: NDArray) -> NDArray: - """ + """Transform agent pose from world coordinate to unit circle. + Args: - agent_pose (NDArray): agent pose in world coordinate. shape=(3,) + agent_pose: agent pose in world coordinate. shape=(3,) Returns: - (NDArray): transformed agent pose within unit circle. shape=(3,) + Transformed agent pose within unit circle. shape=(3,) """ agent_position = agent_pose[0:2] - return cast(NDArray, np.append((agent_position - self.center) / self.radius, agent_pose[2])) + return np.append((agent_position - self.center) / self.radius, agent_pose[2]) class Pnorm2dCBF(CBFBase): - """ - Atrributes: - center (NDArray): center of area in world coordinate. shape=(2,) - width (NDArray): half length of the major and minor axis of ellipse + """CBF for p-norm shaped area constraint. + + Attributes: + center: center of area in world coordinate. shape=(2,) + width: half length of the major and minor axis of ellipse that match the x and y axis in the area coordinate. shape=(2,) - theta (float): rotation angle(rad) world to the area coordinate. - p (float): multiplier for p-norm. - keep_inside (bool): flag to prohibit going outside of the area. Defaults to True. - x (Matrix): state variables in symbolic form for cbf - sign (Symbol): symbolic variable for cbf - G (NDArray): constraint matrix(=dh/dx). shape=(2,) - h (float): constraint value(=h(x)) + theta: rotation angle (rad) from world to area coordinate + p: multiplier for p-norm + keep_inside: flag to prohibit going outside of the area + G: constraint matrix (=dh/dx). shape=(2,) + h: constraint value (=h(x)) """ - def __init__(self) -> None: - self.x = Matrix(symbols("x, y", real=True)) # type: ignore - self.sign = Symbol("sign_", real=True) # type: ignore + def __init__( + self, center: NDArray, width: NDArray, theta: float = 0.0, p: float = 2.0, keep_inside: bool = True + ) -> None: + self.x = Matrix(symbols("x, y", real=True)) + self.sign = Symbol("sign_", real=True) + self.set_parameters(center, width, theta, p, keep_inside) def set_parameters( self, center: NDArray, width: NDArray, theta: float = 0.0, p: float = 2.0, keep_inside: bool = True ) -> None: - """Set parameters and auxiliary functions for constraint calculation""" + """Set parameters and rebuild symbolic functions.""" self.center = center.flatten() self.width = width.flatten() self.theta = theta @@ -277,66 +303,69 @@ def set_parameters( self.p = p self.keep_inside = keep_inside - # applyfunc(lambda x: x**self.p): element-wise power - cbf = self.sign * (1.0 - sum(abs(self.x.applyfunc(lambda x: x**self.p))) ** (1 / self.p)) # type: ignore + cbf = self.sign * (1.0 - sum(abs(self.x.applyfunc(lambda x: x**self.p))) ** (1 / self.p)) self._calc_dhdx = lambdify([self.x, self.sign], cbf.diff(self.x)) self._calc_h = lambdify([self.x, self.sign], cbf) - def get_parameters(self) -> Tuple[NDArray, NDArray, float, float, bool]: - """ - Returns: - Tuple[NDArray, NDArray, float, float, bool]: parameters - """ + def get_parameters(self) -> tuple[NDArray, NDArray, float, float, bool]: return self.center, self.width, self.theta, self.p, self.keep_inside def calc_constraints(self, agent_position: NDArray) -> None: """ Args: - agent_position (NDArray): agent position in world coordinate. shape=(2,) + agent_position: agent position in world coordinate. shape=(2,) Note: - division by width is a remnant of the transformation + Division by width is a remnant of the transformation. """ agent_position_transformed = self._transform_agent_position(agent_position.flatten()) sign = 1 if self.keep_inside else -1 - rotation_matrix = self._get_rotation_matrix(self.theta) self.G = ( - rotation_matrix @ self._calc_dhdx(agent_position_transformed, sign) / self.width.reshape(2, 1) + rotation_matrix_2d(self.theta) + @ self._calc_dhdx(agent_position_transformed, sign) + / self.width.reshape(2, 1) ).flatten() assert self.G.shape == (2,) self.h = self._calc_h(agent_position_transformed, sign) def _transform_agent_position(self, agent_position: NDArray) -> NDArray: - """ + """Transform agent position from world coordinate to normalized area coordinate. + Args: - agent_position (NDArray): agent position in world coordinate. shape=(2,) + agent_position: agent position in world coordinate. shape=(2,) Returns: - (NDArray): transformed agent position within unit circle. shape=(2,) + Transformed agent position within unit shape. shape=(2,) """ - rotation_matrix = self._get_rotation_matrix(-self.theta) - return cast(NDArray, rotation_matrix @ (agent_position - self.center) / self.width) - - @staticmethod - def _get_rotation_matrix(rad: float) -> NDArray: - return np.array( - [ - [np.cos(rad), -np.sin(rad)], - [np.sin(rad), np.cos(rad)], - ] - ) + return cast(NDArray, rotation_matrix_2d(-self.theta) @ (agent_position - self.center) / self.width) class UnicyclePnorm2dCBF(Pnorm2dCBF): - def __init__(self) -> None: - self.x = Matrix(symbols("x, y, agent_theta", real=True)) # type: ignore - self.sign = Symbol("sign_", real=True) # type: ignore + """CBF for p-norm shaped area constraint with unicycle model. + + Attributes: + center: center of area in world coordinate. shape=(2,) + width: half length of the major and minor axis of ellipse + that match the x and y axis in the area coordinate. shape=(2,) + theta: rotation angle (rad) from world to area coordinate + p: multiplier for p-norm + keep_inside: flag to prohibit going outside of the area + G: constraint matrix (=dh/dx). shape=(2,) + h: constraint value (=h(x)) + """ + + def __init__( + self, center: NDArray, width: NDArray, theta: float = 0.0, p: float = 2.0, keep_inside: bool = True + ) -> None: + self.x = Matrix(symbols("x, y, agent_theta", real=True)) + self.sign = Symbol("sign_", real=True) + self.set_parameters(center, width, theta, p, keep_inside) def set_parameters( self, center: NDArray, width: NDArray, theta: float = 0.0, p: float = 2.0, keep_inside: bool = True ) -> None: - """Set parameters and auxiliary functions for constraint calculation""" + """Set parameters and rebuild symbolic functions.""" self.center = center.flatten() self.width = width.flatten() self.theta = theta @@ -344,7 +373,6 @@ def set_parameters( self.p = p self.keep_inside = keep_inside - # applyfunc(lambda x: x**self.p): element-wise power cbf = self.sign * ( 1.0 - sum(abs((np.hstack([np.eye(2), np.zeros([2, 1])]) @ self.x).applyfunc(lambda x: x**self.p))) @@ -356,17 +384,15 @@ def set_parameters( def calc_constraints(self, agent_pose: NDArray) -> None: """ Args: - agent_position (NDArray): agent position in world coordinate. shape=(2,) + agent_pose: agent pose in world coordinate. shape=(3,) [x, y, theta] Note: - division by radius is a remnant of the transformation + Division by width is a remnant of the transformation. """ agent_pose_transformed = self._transform_agent_pose(agent_pose.flatten()) sign = 1 if self.keep_inside else -1 - rotation_matrix = self._get_rotation_matrix(self.theta) agent_theta = agent_pose[2] - # system matrix A = np.array( [ [np.cos(agent_theta), 0], @@ -376,51 +402,48 @@ def calc_constraints(self, agent_pose: NDArray) -> None: ) dhdx = self._calc_dhdx(agent_pose_transformed, sign) assert dhdx.shape == (3, 1), dhdx - # assert False, f"{rotation_matrix},\n {dhdx},\n {dhdx[0:2]}" - # assert ( - # False - # ), f"{np.vstack([rotation_matrix @ dhdx[0:2] / self.width.reshape(2, 1), dhdx[2]]).reshape(1, -1) @ A}" self.G = ( - np.vstack([rotation_matrix @ dhdx[0:2] / self.width.reshape(2, 1), dhdx[2]]).reshape(1, -1) @ A + np.vstack([rotation_matrix_2d(self.theta) @ dhdx[0:2] / self.width.reshape(2, 1), dhdx[2]]).reshape(1, -1) + @ A ).flatten() assert self.G.shape == (2,) self.h = self._calc_h(agent_pose_transformed, sign) def _transform_agent_pose(self, agent_pose: NDArray) -> NDArray: - """ + """Transform agent pose from world coordinate to normalized area coordinate. + Args: - agent_pose (NDArray): agent pose in world coordinate. shape=(3,) + agent_pose: agent pose in world coordinate. shape=(3,) Returns: - (NDArray): transformed agent pose within unit circle. shape=(3,) + Transformed agent pose within unit shape. shape=(3,) """ - rotation_matrix = self._get_rotation_matrix(-self.theta) agent_position = agent_pose[0:2] - return cast(NDArray, np.append(rotation_matrix @ (agent_position - self.center) / self.width, agent_pose[2])) + return np.append(rotation_matrix_2d(-self.theta) @ (agent_position - self.center) / self.width, agent_pose[2]) class LiDARCBF(CBFBase): - """ + """CBF for LiDAR-based obstacle avoidance. + Attributes: - width (NDArray): half length of the major and minor axis of ellipse + width: half length of the major and minor axis of ellipse that match the x and y axis in the agent coordinate. shape=(2,) - keep_upper (bool): flag to prohibit going lower of the limit. Defaults to True. - r: state variable in symbolic form for cbf - theta: state variable in symbolic form for cbf - G (NDArray): constraint matrix(=dh/dx). shape=(2,) - h (float): constraint value(=h(x)) + keep_upper: flag to prohibit going lower of the limit + G: constraint matrix (=dh/dx). shape=(2,) + h: constraint value (=h(x)) """ - def __init__(self) -> None: - self.r = Symbol("r", real=True) # type: ignore - self.theta = Symbol("theta", real=True) # type: ignore + def __init__(self, width: NDArray, keep_upper: bool = True) -> None: + self.r = Symbol("r", real=True) + self.theta = Symbol("theta", real=True) + self.set_parameters(width, keep_upper) def set_parameters(self, width: NDArray, keep_upper: bool = True) -> None: - """Set parameters and auxiliary functions for constraint calculation""" + """Set parameters and rebuild symbolic functions.""" self.width = width.flatten() self.keep_upper = keep_upper - r_c = sqrt(sum((self.width * np.array([cos(self.theta), sin(self.theta)])) ** 2)) # type: ignore + r_c = sqrt(sum((self.width * np.array([cos(self.theta), sin(self.theta)])) ** 2)) cbf = self.r - r_c self._calc_dhdx = lambdify( [self.r, self.theta], @@ -428,7 +451,7 @@ def set_parameters(self, width: NDArray, keep_upper: bool = True) -> None: ) self._calc_h = lambdify([self.r, self.theta], cbf) - def get_parameters(self) -> Tuple[NDArray, bool]: + def get_parameters(self) -> tuple[NDArray, bool]: return self.width, self.keep_upper def calc_constraints(self, r: float, theta: float) -> None: diff --git a/cbfpy/cbf_qp_solver.py b/cbfpy/cbf_qp_solver.py index c338763..7d8b304 100755 --- a/cbfpy/cbf_qp_solver.py +++ b/cbfpy/cbf_qp_solver.py @@ -1,144 +1,115 @@ #!/usr/bin/env python -from typing import Any, List, Tuple -import cvxopt +from collections.abc import Sequence + import numpy as np +import quadprog from numpy.typing import NDArray -class QPSolver: - def __init__(self) -> None: - self._set_solver_options() +def _assemble_constraints(G_list: Sequence[NDArray]) -> NDArray: + """Assemble constraint matrix from a list of constraint vectors. - def _set_solver_options( - self, - show_progress: bool = False, - maxiters: int = 100, - abstol: float = 1e1, - reltol: float = 1e1, - feastol: float = 1e-7, - refinement: int = 0, - ) -> None: - """ - Args: - show_progress (bool, optional): Turns the output to the screen on or off. Defaults to False. - maxiters (int, optional): Maximum number of iterations. Defaults to 100. - abstol (float, optional): Absolute accuracy. Defaults to 1e1. - reltol (float, optional): Relative accuracy. Defaults to 1e1. - feastol (float, optional): Tolerance for feasibility conditions. Defaults to 1e-7. - refinement (int, optional): - Number of iterative refinement steps when solving KKT equations - (default: 0 if the problem has no second-order cone or matrix inequality constraints; 1 otherwise).. - Defaults to 0. - """ - cvxopt.solvers.options["show_progress"] = show_progress - cvxopt.solvers.options["maxiters"] = maxiters - cvxopt.solvers.options["abstol"] = abstol - cvxopt.solvers.options["reltol"] = reltol - cvxopt.solvers.options["feastol"] = feastol - cvxopt.solvers.options["refinement"] = refinement - - def _set_qp_solvers(self, P: NDArray, q: NDArray, G: NDArray, alpha_h: NDArray) -> Any: - """ - Args: - P (NDArray): optimization weight matrix. shape=(N, N) - q (NDArray): optimization weight vector. shape=(N, 1) - G (NDArray): constraint matrix. shape=(M, N) - alpha_h (NDArray): constraint vector. shape=(M,) + Args: + G_list: list of constraint vectors. [(N,), (N,), ...] - Returns: - Dict[str, List[Any]]: cvxopt.solvers.coneqp - """ - P_mat = cvxopt.matrix(P.astype("float")) - q_mat = cvxopt.matrix(q.astype("float")) - G_mat = cvxopt.matrix(G.astype("float")) - alpha_h_mat = cvxopt.matrix(alpha_h.astype("float")) + Returns: + Assembled constraint matrix. shape=(M, N) + """ + if len(G_list) > 1: + return np.array([g.flatten() for g in G_list]) + return G_list[0].reshape(1, -1) + + +def _solve_qp(P: NDArray, q: NDArray, G_ineq: NDArray, h_ineq: NDArray) -> NDArray: + """Solve a quadratic program. + + minimize_{x} (1/2) * x^T P x + q^T x + subject to G_ineq @ x <= h_ineq - try: - return cvxopt.solvers.coneqp(P_mat, q_mat, G_mat, alpha_h_mat) - except Exception as e: - raise e + Uses quadprog (Goldfarb/Idnani dual algorithm) internally. + Args: + P: positive definite weight matrix. shape=(N, N) + q: linear cost vector. shape=(N,) or (N, 1) + G_ineq: inequality constraint matrix. shape=(M, N) + h_ineq: inequality constraint bound. shape=(M,) + + Returns: + Optimal solution x. shape=(N,) + """ + # quadprog solves: min 1/2 x^T G x - a^T x, s.t. C^T x >= b + # Mapping: G=P, a=-q, C^T=-G_ineq (so C=-G_ineq^T), b=-h_ineq + x, *_ = quadprog.solve_qp( + P.astype(float), + -q.flatten().astype(float), + -G_ineq.T.astype(float), + -h_ineq.flatten().astype(float), + ) + return np.asarray(x) + + +class CBFQPSolver: + """CBF-QP solver for general quadratic cost.""" -class CBFQPSolver(QPSolver): def optimize( self, P: NDArray, q: NDArray, - G_list: List[NDArray], - alpha_h_list: List[float], - ) -> Tuple[str, NDArray]: - """ - Solve the following optimization problem + G_list: Sequence[NDArray], + alpha_h_list: list[float], + ) -> tuple[str, NDArray]: + """Solve the CBF-QP optimization problem. + minimize_{u} (1/2) * u^T*P*u + q^T*u - subject to G*u + alpha(h) >= 0 + subject to G*u <= alpha(h) Args: - P (NDArray): optimization weight matrix. shape=(N, N) - q (NDArray): optimization weight vector. shape=(N, 1) - G (List[NDArray]): constraint matrix list. [(N,), (N,), ...] - alpha_h (List[float]): constraint value list - - Notes: - cvxopt.matrix()にshapeが(N,)のNDArrayを渡すと,shapeが(N, 1)のmatrixを返す. - ソルバーに与えたいGの構造は(1, N)であるから,matrixにarrayを渡す際に(1, N)のshapeを持つようにしておく必要がある. - 制約が複数あり,ソルバーに渡すGが(M, N)の構造を持つ場合も考慮しないといけないので,以下の通りlistの長さをもとに場合分けして,様々な入力に対して適切なG_matを生成できるよう工夫している. + P: optimization weight matrix. shape=(N, N) + q: optimization weight vector. shape=(N,) or (N, 1) + G_list: constraint matrix list. [(N,), (N,), ...] + alpha_h_list: constraint value list Returns: - (str): status - (NDArray): optimal input. shape=(N,) + status: "optimal" on success + optimal_input: optimal input. shape=(N,) """ - assert isinstance(G_list, list) - assert isinstance(alpha_h_list, list) - - if len(G_list) > 1: - G = np.array(list(map(lambda x: x.flatten(), G_list))) - else: - G = G_list[0].reshape(1, -1) + G = _assemble_constraints(G_list) alpha_h = np.array(alpha_h_list) + x = _solve_qp(P, q, G, alpha_h) + return "optimal", x - try: - sol = self._set_qp_solvers(P, q, G, alpha_h) - return sol["status"], np.array(sol["x"]).flatten() - except Exception as e: - raise e +class CBFNomQPSolver: + """CBF-QP solver that tracks a nominal input.""" -class CBFNomQPSolver(QPSolver): def optimize( self, nominal_input: NDArray, P: NDArray, - G_list: List[NDArray], - alpha_h_list: List[float], - ) -> Tuple[str, NDArray]: - """ - Solve the following optimization problem + G_list: Sequence[NDArray], + alpha_h_list: list[float], + ) -> tuple[str, NDArray]: + """Solve the CBF-QP optimization problem with nominal input tracking. + minimize_{u} (1/2) * (u-nominal_input)^T*P*(u-nominal_input) subject to G*u + alpha(h) >= 0 Args: - nominal_input (NDArray): nominal_input. shape=(N,) - P (NDArray): optimization weight matrix. shape=(N, N) - G (List[NDArray]): constraint matrix list. [shape]=[(N,), (N,), ...] - alpha_h (List[float]): constraint value list + nominal_input: nominal input. shape=(N,) + P: optimization weight matrix. shape=(N, N) + G_list: constraint matrix list. [(N,), (N,), ...] + alpha_h_list: constraint value list Returns: - (str): status - (NDArray): optimal input. shape=(N,) + status: "optimal" on success + optimal_input: optimal input. shape=(N,) """ nominal_input = nominal_input.reshape(-1, 1) q = -P.T @ nominal_input - assert isinstance(G_list, list) - assert isinstance(alpha_h_list, list) - - if len(G_list) > 1: - G = -np.array(list(map(lambda x: x.flatten(), G_list))) - else: - G = -G_list[0].reshape(1, -1) + G = -_assemble_constraints(G_list) alpha_h = np.array(alpha_h_list) - - sol = self._set_qp_solvers(P, q, G, alpha_h) - return sol["status"], np.array(sol["x"]).flatten() + x = _solve_qp(P, q, G, alpha_h) + return "optimal", x diff --git a/docs/source/cbfpy.rst b/docs/source/cbfpy.rst new file mode 100644 index 0000000..e6271b2 --- /dev/null +++ b/docs/source/cbfpy.rst @@ -0,0 +1,16 @@ +API Reference +============= + +CBF Classes +----------- + +.. automodule:: cbfpy.cbf + :members: + :show-inheritance: + +QP Solvers +---------- + +.. automodule:: cbfpy.cbf_qp_solver + :members: + :show-inheritance: diff --git a/docs/source/examples.rst b/docs/source/examples.rst new file mode 100644 index 0000000..c6bbe47 --- /dev/null +++ b/docs/source/examples.rst @@ -0,0 +1,90 @@ +Examples +======== + +All examples can be run with: + +.. code-block:: bash + + python examples/example_{name}.py + +Each example creates a CBF optimizer wrapping a CBF and QP solver, +then runs a matplotlib animation showing the nominal input (black arrows) +vs. the CBF-filtered safe input (red arrows). + + +Scalar CBF +---------- + +Constrains a 1D state variable to stay above or below a limit. +The limit and direction change over time to demonstrate dynamic reconfiguration. + +.. literalinclude:: ../../examples/example_scalar_cbf.py + :language: python + :caption: examples/example_scalar_cbf.py + + +Scalar Range CBF +---------------- + +Constrains a 1D state variable to stay inside or outside a range ``[a, b]``. +Uses the barrier function :math:`h(x) = \left(\frac{b-a}{2}\right)^2 - \left(x - \frac{a+b}{2}\right)^2`. + +.. literalinclude:: ../../examples/example_scalar_range_cbf.py + :language: python + :caption: examples/example_scalar_range_cbf.py + + +Circle CBF +---------- + +Two agents move toward each other with a circular collision avoidance constraint. +Each agent treats the other's position as an obstacle with ``keep_inside=False``. + +.. literalinclude:: ../../examples/example_circle_cbf.py + :language: python + :caption: examples/example_circle_cbf.py + + +P-norm 2D CBF +------------- + +An agent avoids a p-norm (ellipsoidal) shaped area. +The p-norm shape generalizes circles (:math:`p=2`), diamonds (:math:`p=1`), and rectangles (:math:`p \to \infty`). + +.. literalinclude:: ../../examples/example_pnorm2d_cbf.py + :language: python + :caption: examples/example_pnorm2d_cbf.py + + +Unicycle Circle CBF +------------------- + +Circular area constraint for a unicycle model (input: linear velocity + angular velocity). +The unicycle kinematics are handled by an input transformation matrix. + +.. literalinclude:: ../../examples/example_unicycle_circle_cbf.py + :language: python + :caption: examples/example_unicycle_circle_cbf.py + + +Unicycle P-norm 2D CBF +---------------------- + +P-norm shaped area constraint for a unicycle model. +Combines the p-norm barrier function with unicycle input transformation. + +.. literalinclude:: ../../examples/example_unicycle_pnorm2d_cbf.py + :language: python + :caption: examples/example_unicycle_pnorm2d_cbf.py + + +LiDAR CBF +---------- + +LiDAR-based obstacle avoidance for a unicycle model. +Simulates a 2D LiDAR sensor and creates a CBF constraint for each ray, +enabling reactive navigation through environments with multiple obstacles. + +.. literalinclude:: ../../examples/example_lidar_cbf.py + :language: python + :caption: examples/example_lidar_cbf.py diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..08aaaa9 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,50 @@ +cbfpy documentation +=================== + +**cbfpy** is a Python package for using simple Control Barrier Functions (CBF). + +CBF provides a framework for safety-constrained control by solving the optimization problem: + +.. math:: + + \min_{u} \quad & \frac{1}{2} (u - u_{\text{nom}})^T P (u - u_{\text{nom}}) \\ + \text{s.t.} \quad & \frac{\partial h}{\partial x} u + \alpha(h(x)) \geq 0 + +where :math:`h(x)` is the barrier function defining the safe set :math:`\{x \mid h(x) \geq 0\}`, +and :math:`\alpha` is an extended class-K function. + +Features +-------- + +- Symbolic CBF construction using SymPy (automatic gradient computation) +- Multiple CBF types: scalar, circular, p-norm, unicycle, LiDAR-based +- Lightweight QP solver via `quadprog `_ (Goldfarb/Idnani algorithm) +- Runtime parameter reconfiguration via ``set_parameters()`` + +Quick Start +----------- + +.. code-block:: python + + import numpy as np + from cbfpy import CircleCBF, CBFNomQPSolver + + # Create a circular CBF (keep agent inside a circle) + cbf = CircleCBF(center=np.zeros(2), radius=2.0, keep_inside=True) + + # Compute constraints at the current agent position + cbf.calc_constraints(agent_position=np.array([1.5, 0.0])) + G, alpha_h = cbf.get_constraints() + + # Solve the CBF-QP to get the safe input + solver = CBFNomQPSolver() + nominal_input = np.array([1.0, 0.0]) # desired direction + status, safe_input = solver.optimize(nominal_input, np.eye(2), [G], [alpha_h]) + + +.. toctree:: + :maxdepth: 4 + :caption: Contents: + + cbfpy + examples diff --git a/examples/example_circle_cbf.py b/examples/example_circle_cbf.py index b0acdf7..a881039 100755 --- a/examples/example_circle_cbf.py +++ b/examples/example_circle_cbf.py @@ -1,6 +1,5 @@ #!/usr/bin/env python -from typing import List, Tuple import matplotlib.pyplot as plt import numpy as np @@ -13,50 +12,35 @@ class CBFOptimizer: - def __init__(self) -> None: + def __init__(self, center: NDArray, radius: float = 1.0, keep_inside: bool = True) -> None: self.qp_nom_solver = CBFNomQPSolver() self.P = np.eye(2) - - self.circle_cbf = CircleCBF() - - # initialize(must be overwritten) - self.set_parameters(np.zeros(2)) + self.circle_cbf = CircleCBF(center, radius, keep_inside) def set_parameters(self, center: NDArray, radius: float = 1.0, keep_inside: bool = True) -> None: self.circle_cbf.set_parameters(center, radius, keep_inside) - def get_parameters(self) -> Tuple[NDArray, float, bool]: + def get_parameters(self) -> tuple[NDArray, float, bool]: return self.circle_cbf.get_parameters() - def _calc_constraints(self, agent_position: NDArray) -> None: + def optimize(self, nominal_input: NDArray, agent_position: NDArray) -> tuple[str, NDArray]: self.circle_cbf.calc_constraints(agent_position) - - def _get_constraints(self) -> Tuple[List[NDArray], List[float]]: G, alpha_h = self.circle_cbf.get_constraints() - return [G], [alpha_h] - - def optimize(self, nominal_input: NDArray, agent_position: NDArray) -> Tuple[str, NDArray]: - self._calc_constraints(agent_position) - G_list, alpha_h_list = self._get_constraints() - - try: - return self.qp_nom_solver.optimize(nominal_input, self.P, G_list, alpha_h_list) - except Exception as e: - raise e + return self.qp_nom_solver.optimize(nominal_input, self.P, [G], [alpha_h]) def main() -> None: - optimizer_list = [CBFOptimizer(), CBFOptimizer()] + optimizer_list = [CBFOptimizer(np.zeros(2)), CBFOptimizer(np.zeros(2))] initial_position_array = np.array([[-2, -2.5], [2, 2]]) - agent_position_list: List[NDArray] = [initial_position_array] + agent_position_list: list[NDArray] = [initial_position_array] dt = 0.1 fig, ax = plt.subplots() def update( frame: int, - agent_position_list: List[NDArray], + agent_position_list: list[NDArray], ) -> None: ax.cla() @@ -107,7 +91,7 @@ def update( ax.set_xlim(lim) ax.set_ylim(lim) - ani = FuncAnimation( + ani = FuncAnimation( # noqa: F841 fig, update, frames=100, diff --git a/examples/example_lidar_cbf.py b/examples/example_lidar_cbf.py index 3b57593..7cbb689 100755 --- a/examples/example_lidar_cbf.py +++ b/examples/example_lidar_cbf.py @@ -1,8 +1,9 @@ #!/usr/bin/env python from abc import abstractmethod +from collections.abc import Sequence from dataclasses import dataclass -from typing import List, Sequence, Tuple, cast +from typing import cast import matplotlib.pyplot as plt import numpy as np @@ -10,57 +11,43 @@ from matplotlib.animation import FuncAnimation from numpy.typing import NDArray -from cbfpy.cbf import LiDARCBF +from cbfpy.cbf import LiDARCBF, rotation_matrix_2d from cbfpy.cbf_qp_solver import CBFNomQPSolver class CBFOptimizer: - def __init__(self, num_points: int) -> None: + def __init__(self, num_points: int, width: NDArray, keep_upper: bool = True) -> None: self.qp_nom_solver = CBFNomQPSolver() # Set weights so that angular velocity is more likely to occur. self.P = np.diag([10, 1]) - self.lidar_cbf_list = [LiDARCBF()] * num_points + self.lidar_cbf_list = [LiDARCBF(width, keep_upper) for _ in range(num_points)] def set_parameters(self, width: NDArray, keep_upper: bool = True) -> None: for lidar_cbf in self.lidar_cbf_list: lidar_cbf.set_parameters(width, keep_upper) - def get_parameters(self) -> List[Tuple[NDArray, bool]]: + def get_parameters(self) -> list[tuple[NDArray, bool]]: return [lidar_cbf.get_parameters() for lidar_cbf in self.lidar_cbf_list] - def calc_constraints(self, r: NDArray, theta: NDArray) -> None: + def optimize(self, nominal_input: NDArray, r: NDArray, theta: NDArray) -> tuple[str, NDArray]: + G_list: list[NDArray] = [] + alpha_h_list: list[float] = [] for i, lidar_cbf in enumerate(self.lidar_cbf_list): lidar_cbf.calc_constraints(r[i], theta[i]) - - def get_constraints(self) -> Tuple[List[NDArray], List[float]]: - G_list: List[NDArray] = [] - alpha_h_list: List[float] = [] - for lidar_cbf in self.lidar_cbf_list: G, alpha_h = lidar_cbf.get_constraints() G_list.append(G) alpha_h_list.append(alpha_h) - return G_list, alpha_h_list - - def optimize(self, nominal_input: NDArray, r: NDArray, theta: NDArray) -> Tuple[str, NDArray]: - self.calc_constraints(r, theta) - G_list, alpha_h_list = self.get_constraints() - - try: - return self.qp_nom_solver.optimize(nominal_input, self.P, G_list, alpha_h_list) - except Exception as e: - raise e + return self.qp_nom_solver.optimize(nominal_input, self.P, G_list, alpha_h_list) class Obstacle: @abstractmethod - def is_inner(self, r: float, theta: float, curr_pose: NDArray) -> bool: - ... + def is_inner(self, r: float, theta: float, curr_pose: NDArray) -> bool: ... @abstractmethod - def plot_func(self, color: str, alpha: float) -> patches: - ... + def plot_func(self, color: str, alpha: float) -> patches.Patch: ... @dataclass @@ -84,7 +71,7 @@ def is_inner(self, r: float, theta: float, curr_pose: NDArray) -> bool: <= self.radius, ) - def plot_func(self, color: str, alpha: float) -> patches: + def plot_func(self, color: str, alpha: float) -> patches.Patch: return patches.Circle(xy=self.center, radius=self.radius, color=color, alpha=alpha) @@ -95,11 +82,9 @@ class RectangleObstacle(Obstacle): theta: float def is_inner(self, r: float, theta: float, curr_pose: NDArray) -> bool: - rotation_matrix = self._get_rotation_matrix(-self.theta) - # standardization to unit square transformed_position: NDArray = ( - rotation_matrix + rotation_matrix_2d(-self.theta) @ ( np.array( [r * np.cos(theta + curr_pose[2]), r * np.sin(theta + curr_pose[2])] + np.array(curr_pose[0:2]) @@ -112,7 +97,7 @@ def is_inner(self, r: float, theta: float, curr_pose: NDArray) -> bool: # max norm return cast(bool, np.linalg.norm(transformed_position, ord=np.inf) <= 1) - def plot_func(self, color: str, alpha: float) -> patches: + def plot_func(self, color: str, alpha: float) -> patches.Patch: # matplotlib>=3.6 is required return patches.Rectangle( xy=self.center - self.width, @@ -124,15 +109,6 @@ def plot_func(self, color: str, alpha: float) -> patches: alpha=alpha, ) - @staticmethod - def _get_rotation_matrix(rad: float) -> NDArray: - return np.array( - [ - [np.cos(rad), -np.sin(rad)], - [np.sin(rad), np.cos(rad)], - ] - ) - class LiDARSimulator: def __init__( @@ -150,7 +126,7 @@ def __init__( self.range_step_num = range_step_num self.obstacle_list = obstacle_list - def sim(self, curr_pose: NDArray) -> Tuple[NDArray, NDArray]: + def sim(self, curr_pose: NDArray) -> tuple[NDArray, NDArray]: # in agent coordinate theta_array = np.array([2 * np.pi / self.num_points * i for i in range(self.num_points)]) range_step_array = np.array( @@ -166,17 +142,7 @@ def linear_search( curr_pose: NDArray, obstacle_list: Sequence[Obstacle], ) -> float: - """Linear search for measured distance - - Args: - range_step_array (NDArray): candidate sequence of measured distances - theta (float): laser angle in agent coordinate - curr_pose (NDArray): current pose(x,y,theta) - obstacle_list (Sequence[Obstacle]): obstacles - - Returns: - float: measured distance - """ + """Linear search for measured distance.""" for range_ in range_step_array: if any([obstacle.is_inner(range_, theta, curr_pose) for obstacle in obstacle_list]): return float(range_) @@ -195,11 +161,11 @@ def linear_search( def main() -> None: num_points = 20 - optimizer = CBFOptimizer(num_points) - optimizer.set_parameters(np.array([0.7, 0.5])) + width = np.array([0.7, 0.5]) + optimizer = CBFOptimizer(num_points, width) initial_pose = np.array([0, -3, -0.1]) - agent_pose_list: List[NDArray] = [initial_pose] + agent_pose_list: list[NDArray] = [initial_pose] dt = 0.1 # set obstacles @@ -216,7 +182,7 @@ def main() -> None: fig, ax = plt.subplots() - def update(frame: int, agent_pose_list: List[NDArray]) -> None: + def update(frame: int, agent_pose_list: list[NDArray]) -> None: ax.cla() curr_pose = agent_pose_list[-1] @@ -302,16 +268,16 @@ def update(frame: int, agent_pose_list: List[NDArray]) -> None: ax.plot([0], [0], linewidth=5, color="red", label="optimal_input") param_list = optimizer.get_parameters() - width = param_list[0][0] - r = patches.Ellipse( + cbf_width = param_list[0][0] + r_patch = patches.Ellipse( xy=curr_position, - width=width[0] * 2, - height=width[1] * 2, + width=cbf_width[0] * 2, + height=cbf_width[1] * 2, angle=curr_theta * 180 / np.pi, color="blue", alpha=0.5, ) - ax.add_patch(r) + ax.add_patch(r_patch) for obstacle in obstacle_list: ax.add_patch(obstacle.plot_func(color="green", alpha=0.5)) @@ -323,7 +289,7 @@ def update(frame: int, agent_pose_list: List[NDArray]) -> None: ax.set_ylim(lim) ax.legend(loc="upper left") - ani = FuncAnimation( + ani = FuncAnimation( # noqa: F841 fig, update, frames=500, diff --git a/examples/example_pnorm2d_cbf.py b/examples/example_pnorm2d_cbf.py index 2bb201f..45f627e 100755 --- a/examples/example_pnorm2d_cbf.py +++ b/examples/example_pnorm2d_cbf.py @@ -1,6 +1,5 @@ #!/usr/bin/env python -from typing import List, Tuple import matplotlib.pyplot as plt import numpy as np @@ -13,47 +12,28 @@ class CBFOptimizer: - def __init__(self) -> None: + def __init__( + self, center: NDArray, width: NDArray, theta: float = 0.0, p: float = 2.0, keep_inside: bool = True + ) -> None: self.qp_nom_solver = CBFNomQPSolver() self.P = np.eye(2) - - self.pnorm2d_cbf = Pnorm2dCBF() - - # initialize(must be overwritten) - self.set_parameters(np.zeros(2), np.ones(2)) + self.pnorm2d_cbf = Pnorm2dCBF(center, width, theta, p, keep_inside) def set_parameters( self, center: NDArray, width: NDArray, theta: float = 0.0, p: float = 2.0, keep_inside: bool = True ) -> None: self.pnorm2d_cbf.set_parameters(center, width, theta, p, keep_inside) - def get_parameters(self) -> Tuple[NDArray, NDArray, float, float, bool]: + def get_parameters(self) -> tuple[NDArray, NDArray, float, float, bool]: return self.pnorm2d_cbf.get_parameters() - def _calc_constraints(self, agent_position: NDArray) -> None: + def optimize(self, nominal_input: NDArray, agent_position: NDArray) -> tuple[str, NDArray]: self.pnorm2d_cbf.calc_constraints(agent_position) - - def _get_constraints(self) -> Tuple[List[NDArray], List[float]]: G, alpha_h = self.pnorm2d_cbf.get_constraints() - return [G], [alpha_h] - - def optimize(self, nominal_input: NDArray, agent_position: NDArray) -> Tuple[str, NDArray]: - self._calc_constraints(agent_position) - G_list, alpha_h_list = self._get_constraints() - - try: - return self.qp_nom_solver.optimize(nominal_input, self.P, G_list, alpha_h_list) - except Exception as e: - raise e + return self.qp_nom_solver.optimize(nominal_input, self.P, [G], [alpha_h]) def main() -> None: - optimizer = CBFOptimizer() - - initial_position = np.array([-3, -2.5]) - agent_position_list: List[NDArray] = [initial_position] - dt = 0.1 - # obstacle center = np.array([-0.5, 0.5]) width = np.array([3, 2]) @@ -61,14 +41,18 @@ def main() -> None: p = 2.0 keep_inside = False - optimizer.set_parameters(center, width, theta, p, keep_inside) + optimizer = CBFOptimizer(center, width, theta, p, keep_inside) + + initial_position = np.array([-3, -2.5]) + agent_position_list: list[NDArray] = [initial_position] + dt = 0.1 nominal_input = np.ones(2) fig, ax = plt.subplots() def update( frame: int, - agent_position_list: List[NDArray], + agent_position_list: list[NDArray], ) -> None: ax.cla() @@ -107,7 +91,7 @@ def update( ax.set_xlim(lim) ax.set_ylim(lim) - ani = FuncAnimation( + ani = FuncAnimation( # noqa: F841 fig, update, frames=100, diff --git a/examples/example_scalar_cbf.py b/examples/example_scalar_cbf.py index e77a32f..96adfd0 100755 --- a/examples/example_scalar_cbf.py +++ b/examples/example_scalar_cbf.py @@ -1,6 +1,5 @@ #!/usr/bin/env python -from typing import List, Tuple import matplotlib.pyplot as plt import numpy as np @@ -12,52 +11,37 @@ class CBFOptimizer: - def __init__(self) -> None: + def __init__(self, limit: float, keep_upper: bool = True) -> None: self.qp_nom_solver = CBFNomQPSolver() self.P = np.eye(1) - - self.scalar_cbf = ScalarCBF() - - # initialize (must be overwritten) - self.set_parameters(0.0) + self.scalar_cbf = ScalarCBF(limit, keep_upper) def set_parameters(self, limit: float, keep_upper: bool = True) -> None: self.scalar_cbf.set_parameters(limit, keep_upper) - def get_parameters(self) -> Tuple[float, bool]: + def get_parameters(self) -> tuple[float, bool]: return self.scalar_cbf.get_parameters() - def _calc_constraints(self, curr_value: float) -> None: + def optimize(self, nominal_input: float, curr_value: float) -> tuple[str, NDArray]: self.scalar_cbf.calc_constraints(curr_value) - - def _get_constraints(self) -> Tuple[List[NDArray], List[float]]: G, alpha_h = self.scalar_cbf.get_constraints() - return [G], [alpha_h] - - def optimize(self, nominal_input: float, curr_value: float) -> Tuple[str, NDArray]: - self._calc_constraints(curr_value) - G_list, alpha_h_list = self._get_constraints() - - try: - return self.qp_nom_solver.optimize(np.array(nominal_input), self.P, G_list, alpha_h_list) - except Exception as e: - raise e + return self.qp_nom_solver.optimize(np.array(nominal_input), self.P, [G], [alpha_h]) def main() -> None: - optimizer = CBFOptimizer() + optimizer = CBFOptimizer(limit=0.0) initial_value = 0.0 - value_list: List[float] = [initial_value] - time_list: List[float] = [0.0] + value_list: list[float] = [initial_value] + time_list: list[float] = [0.0] dt = 0.1 fig, ax = plt.subplots() def update( frame: int, - value_list: List[float], - time_list: List[float], + value_list: list[float], + time_list: list[float], ) -> None: ax.cla() @@ -109,7 +93,7 @@ def update( ax.set_ylim([-3, 3]) ax.legend(loc="upper right") - ani = FuncAnimation( + ani = FuncAnimation( # noqa: F841 fig, update, frames=100, diff --git a/examples/example_scalar_range_cbf.py b/examples/example_scalar_range_cbf.py index 7acae57..7622d0f 100755 --- a/examples/example_scalar_range_cbf.py +++ b/examples/example_scalar_range_cbf.py @@ -1,6 +1,5 @@ #!/usr/bin/env python -from typing import List, Tuple import matplotlib.pyplot as plt import numpy as np @@ -12,52 +11,37 @@ class CBFOptimizer: - def __init__(self) -> None: + def __init__(self, a: float, b: float, keep_inside: bool = True) -> None: self.qp_nom_solver = CBFNomQPSolver() self.P = np.eye(1) - - self.scalar_range_cbf = ScalarRangeCBF() - - # initialize (must be overwritten) - self.set_parameters(0.0, 1.0) + self.scalar_range_cbf = ScalarRangeCBF(a, b, keep_inside) def set_parameters(self, a: float, b: float, keep_inside: bool = True) -> None: self.scalar_range_cbf.set_parameters(a, b, keep_inside) - def get_parameters(self) -> Tuple[float, float, bool]: + def get_parameters(self) -> tuple[float, float, bool]: return self.scalar_range_cbf.get_parameters() - def _calc_constraints(self, curr_value: float) -> None: + def optimize(self, nominal_input: float, curr_value: float) -> tuple[str, NDArray]: self.scalar_range_cbf.calc_constraints(curr_value) - - def _get_constraints(self) -> Tuple[List[NDArray], List[float]]: G, alpha_h = self.scalar_range_cbf.get_constraints() - return [G], [alpha_h] - - def optimize(self, nominal_input: float, curr_value: float) -> Tuple[str, NDArray]: - self._calc_constraints(curr_value) - G_list, alpha_h_list = self._get_constraints() - - try: - return self.qp_nom_solver.optimize(np.array(nominal_input), self.P, G_list, alpha_h_list) - except Exception as e: - raise e + return self.qp_nom_solver.optimize(np.array(nominal_input), self.P, [G], [alpha_h]) def main() -> None: - optimizer = CBFOptimizer() + optimizer = CBFOptimizer(a=0.0, b=1.0) initial_value = 0.0 - value_list: List[float] = [initial_value] - time_list: List[float] = [0.0] + value_list: list[float] = [initial_value] + time_list: list[float] = [0.0] dt = 0.1 fig, ax = plt.subplots() def update( frame: int, - value_list: List[float], - time_list: List[float], + value_list: list[float], + time_list: list[float], ) -> None: ax.cla() @@ -114,7 +98,7 @@ def update( ax.set_ylim([-3, 3]) ax.legend(loc="upper right") - ani = FuncAnimation( + ani = FuncAnimation( # noqa: F841 fig, update, frames=100, diff --git a/examples/example_unicycle_circle_cbf.py b/examples/example_unicycle_circle_cbf.py index 2160575..bf1d84a 100755 --- a/examples/example_unicycle_circle_cbf.py +++ b/examples/example_unicycle_circle_cbf.py @@ -1,6 +1,5 @@ #!/usr/bin/env python -from typing import List, Tuple import matplotlib.pyplot as plt import numpy as np @@ -13,55 +12,39 @@ class CBFOptimizer: - def __init__(self) -> None: + def __init__(self, center: NDArray, radius: float = 1.0, keep_inside: bool = True) -> None: self.qp_nom_solver = CBFNomQPSolver() self.P = np.eye(2) - - self.circle_cbf = UnicycleCircleCBF() - - # initialize(must be overwritten) - self.set_parameters(np.zeros(2)) + self.circle_cbf = UnicycleCircleCBF(center, radius, keep_inside) def set_parameters(self, center: NDArray, radius: float = 1.0, keep_inside: bool = True) -> None: self.circle_cbf.set_parameters(center, radius, keep_inside) - def get_parameters(self) -> Tuple[NDArray, float, bool]: + def get_parameters(self) -> tuple[NDArray, float, bool]: return self.circle_cbf.get_parameters() - def _calc_constraints(self, agent_pose: NDArray) -> None: + def optimize(self, nominal_input: NDArray, agent_pose: NDArray) -> tuple[str, NDArray]: self.circle_cbf.calc_constraints(agent_pose) - - def _get_constraints(self) -> Tuple[List[NDArray], List[float]]: G, alpha_h = self.circle_cbf.get_constraints() - return [G], [alpha_h] - - def optimize(self, nominal_input: NDArray, agent_pose: NDArray) -> Tuple[str, NDArray]: - self._calc_constraints(agent_pose) - G_list, alpha_h_list = self._get_constraints() - - try: - return self.qp_nom_solver.optimize(nominal_input, self.P, G_list, alpha_h_list) - except Exception as e: - raise e + return self.qp_nom_solver.optimize(nominal_input, self.P, [G], [alpha_h]) def main() -> None: - optimizer = CBFOptimizer() - - initial_pose_array = np.array([0, 0.5, 0.0]) - agent_pose_list: List[NDArray] = [initial_pose_array] - dt = 0.1 center = np.array([-0.5, 0.5]) radius = 1.5 keep_inside = True - optimizer.set_parameters(center, radius, keep_inside) + optimizer = CBFOptimizer(center, radius, keep_inside) + + initial_pose_array = np.array([0, 0.5, 0.0]) + agent_pose_list: list[NDArray] = [initial_pose_array] + dt = 0.1 fig, ax = plt.subplots() def update( frame: int, - agent_pose_list: List[NDArray], + agent_pose_list: list[NDArray], ) -> None: ax.cla() @@ -145,7 +128,7 @@ def update( ax.set_xlim(lim) ax.set_ylim(lim) - ani = FuncAnimation( + ani = FuncAnimation( # noqa: F841 fig, update, frames=100, diff --git a/examples/example_unicycle_pnorm2d_cbf.py b/examples/example_unicycle_pnorm2d_cbf.py index b6fd1ca..fa02fcd 100755 --- a/examples/example_unicycle_pnorm2d_cbf.py +++ b/examples/example_unicycle_pnorm2d_cbf.py @@ -1,6 +1,5 @@ #!/usr/bin/env python -from typing import List, Tuple import matplotlib.pyplot as plt import numpy as np @@ -13,59 +12,45 @@ class CBFOptimizer: - def __init__(self) -> None: + def __init__( + self, center: NDArray, width: NDArray, theta: float = 0.0, p: float = 2.0, keep_inside: bool = True + ) -> None: self.qp_nom_solver = CBFNomQPSolver() self.P = np.diag([1, 10]) - - self.pnorm2d_cbf = UnicyclePnorm2dCBF() - - # initialize(must be overwritten) - self.set_parameters(np.zeros(2), np.ones(2)) + self.pnorm2d_cbf = UnicyclePnorm2dCBF(center, width, theta, p, keep_inside) def set_parameters( self, center: NDArray, width: NDArray, theta: float = 0.0, p: float = 2.0, keep_inside: bool = True ) -> None: self.pnorm2d_cbf.set_parameters(center, width, theta, p, keep_inside) - def get_parameters(self) -> Tuple[NDArray, NDArray, float, float, bool]: + def get_parameters(self) -> tuple[NDArray, NDArray, float, float, bool]: return self.pnorm2d_cbf.get_parameters() - def _calc_constraints(self, agent_pose: NDArray) -> None: + def optimize(self, nominal_input: NDArray, agent_pose: NDArray) -> tuple[str, NDArray]: self.pnorm2d_cbf.calc_constraints(agent_pose) - - def _get_constraints(self) -> Tuple[List[NDArray], List[float]]: G, alpha_h = self.pnorm2d_cbf.get_constraints() - return [G], [alpha_h] - - def optimize(self, nominal_input: NDArray, agent_pose: NDArray) -> Tuple[str, NDArray]: - self._calc_constraints(agent_pose) - G_list, alpha_h_list = self._get_constraints() - - try: - return self.qp_nom_solver.optimize(nominal_input, self.P, G_list, alpha_h_list) - except Exception as e: - raise e + return self.qp_nom_solver.optimize(nominal_input, self.P, [G], [alpha_h]) def main() -> None: - optimizer = CBFOptimizer() - - initial_pose_array = np.array([-1, -1, 0]) - agent_pose_list: List[NDArray] = [initial_pose_array] - dt = 0.1 center = np.array([-0.5, 0.5]) width = np.array([3, 2]) theta = -0.3 p = 2.0 keep_inside = True - optimizer.set_parameters(center, width, theta, p, keep_inside) + optimizer = CBFOptimizer(center, width, theta, p, keep_inside) + + initial_pose_array = np.array([-1, -1, 0]) + agent_pose_list: list[NDArray] = [initial_pose_array] + dt = 0.1 fig, ax = plt.subplots() def update( frame: int, - agent_pose_list: List[NDArray], + agent_pose_list: list[NDArray], ) -> None: ax.cla() @@ -151,7 +136,7 @@ def update( ax.set_xlim(lim) ax.set_ylim(lim) - ani = FuncAnimation( + ani = FuncAnimation( # noqa: F841 fig, update, frames=200, diff --git a/pyproject.toml b/pyproject.toml index d88f195..72fd475 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,108 +1,72 @@ -[tool.poetry] +[project] name = "cbfpy" version = "0.1.0" description = "Python package for using simple control barrier function" -authors = ["Toshiyuki Oshima "] -license = "Apache License 2.0" readme = "README.md" - -packages = [ - { include = "cbfpy" }, - { include = "examples" }, - { include = "tests" }, +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [{ name = "Toshiyuki Oshima", email = "toshiyuki67026@gmail.com" }] +dependencies = [ + "sympy>=1.12", + "quadprog>=0.1.12", + "matplotlib>=3.7", + "numpy>=1.24", ] -# duplicate definition with tool.poetry for sphinx -[project] -name = "cbfpy" -version = "0.1.0" -description = "Python package for using simple control barrier function" - -[[project.authors]] -name = "Toshiyuki Oshima" - -[tool.poetry.dependencies] -python = "^3.8.10" -sympy = "^1.11.1" -cvxopt = "^1.3.0" -matplotlib = "^3.6.2" - -[tool.poetry.group.dev.dependencies] -black = "^22.12.0" -isort = "^5.11.4" -mypy = "^0.991" -sphinx = "5.3.0" -sphinx-rtd-theme = "^1.1.1" -sphinx-pyproject = "^0.1.0" -taskipy = "^1.10.3" -pre-commit = "^2.21.0" -ruff = "^0.0.213" - -[tool.poetry.group.test.dependencies] -pytest = "^7.2.0" -pytest-cov = "^4.0.0" -coverage = "^7.0.3" - [build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" -[tool.black] -line-length = 119 -target-version = ['py38'] -include = '\.pyi?$' -# automatically ignore files in .gitignore -extend-exclude = ''' -( - \.git - | \.vscode - | assets -) -''' +[tool.hatch.build.targets.wheel] +packages = ["cbfpy"] -[tool.isort] -profile = "black" -src_paths = ["cbfpy", "examples", "tests"] -line_length = 119 -# ignore files in .gitignore -skip_gitignore = true +[project.optional-dependencies] +dev = [ + "ruff>=0.9", + "mypy>=1.14", + "sphinx>=7.4", + "sphinx-rtd-theme>=3.0", + "sphinx-pyproject>=0.3", + "pre-commit>=4.0", +] +test = [ + "pytest>=8.0", + "pytest-cov>=6.0", +] [tool.ruff] line-length = 119 -select = ["E", "F"] -ignore = ["E402", "F841"] -# automatically ignore files in .gitignore -exclude = [".git", ".vscode", "assets"] +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP"] +ignore = ["E402"] + +[tool.ruff.lint.isort] +known-first-party = ["cbfpy"] [tool.mypy] -python_version = 3.8 +python_version = "3.10" plugins = "numpy.typing.mypy_plugin" strict = true ignore_missing_imports = true disallow_any_generics = false +exclude = ["examples/"] [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "--cov=cbfpy --cov-branch --cov-report xml --cov-report html:cov_html" +addopts = "--cov=cbfpy --cov-branch --cov-report=xml --cov-report=html:cov_html" [tool.sphinx-pyproject] project = "cbfpy" -copyright = "2022, Toshiyuki Oshima" +copyright = "2026, Toshiyuki Oshima" language = "en" package_root = "cbfpy" html_theme = "sphinx_rtd_theme" todo_include_todos = true -templates_path = ["_templates"] -html_static_path = ["_static"] extensions = [ "sphinx.ext.autodoc", "sphinx.ext.viewcode", "sphinx.ext.todo", "sphinx.ext.napoleon", ] - -[tool.taskipy.tasks] -test = "pytest" -fmt = "isort . && black ." -lint = "black --check . && ruff . && mypy ." -docs = "sphinx-apidoc -F -o docs/source cbfpy && sphinx-build docs/source docs/build" diff --git a/tests/test_cbf.py b/tests/test_cbf.py index 96bd79d..67b7fa4 100755 --- a/tests/test_cbf.py +++ b/tests/test_cbf.py @@ -17,26 +17,26 @@ class TestCBFBase: - G = np.ones(2) - h = 1.0 - cbf_base = CBFBase(G=G, h=h) - def test_get_constraints(self) -> None: - G, alpha_h = self.cbf_base.get_constraints() - assert np.allclose(G, self.G) - assert np.allclose(alpha_h, self.cbf_base._alpha(self.h)) + G = np.ones(2) + h = 1.0 + cbf_base = CBFBase() + cbf_base.G = G + cbf_base.h = h + ret_G, alpha_h = cbf_base.get_constraints() + assert np.allclose(ret_G, G) + assert np.allclose(alpha_h, cbf_base._alpha(h)) class TestGeneralCBF: - general_cbf = GeneralCBF(G=np.ones(2), h=1.0) - def test_calc_constraints(self) -> None: + general_cbf = GeneralCBF(G=np.ones(2), h=1.0) G = 2 * np.ones(2) h = 2.0 - self.general_cbf.calc_constraints(G, h) - ret_G, ret_alpha_h = self.general_cbf.get_constraints() + general_cbf.calc_constraints(G, h) + ret_G, ret_alpha_h = general_cbf.get_constraints() assert np.allclose(ret_G, G) - assert np.allclose(ret_alpha_h, self.general_cbf._alpha(h)) + assert np.allclose(ret_alpha_h, general_cbf._alpha(h)) class TestScalarCBF: @@ -44,15 +44,13 @@ class TestScalarCBF: keep_upper = True def test_set_and_get_parameters(self) -> None: - scalar_cbf = ScalarCBF() - scalar_cbf.set_parameters(self.limit, self.keep_upper) + scalar_cbf = ScalarCBF(self.limit, self.keep_upper) limit, keep_upper = scalar_cbf.get_parameters() assert limit == pytest.approx(self.limit) assert keep_upper == self.keep_upper def test_calc_constraints(self) -> None: - scalar_cbf = ScalarCBF() - scalar_cbf.set_parameters(self.limit, self.keep_upper) + scalar_cbf = ScalarCBF(self.limit, self.keep_upper) scalar_cbf.calc_constraints(3.0) G, alpha_h = scalar_cbf.get_constraints() assert np.allclose(G, np.array(1.0)) @@ -65,16 +63,14 @@ class TestScalarRangeCBF: keep_inside = True def test_set_and_get_parameters(self) -> None: - scalar_range_cbf = ScalarRangeCBF() - scalar_range_cbf.set_parameters(self.a, self.b, self.keep_inside) + scalar_range_cbf = ScalarRangeCBF(self.a, self.b, self.keep_inside) a, b, keep_inside = scalar_range_cbf.get_parameters() assert a == pytest.approx(self.a) assert b == pytest.approx(self.b) assert keep_inside == self.keep_inside def test_calc_constraints(self) -> None: - scalar_range_cbf = ScalarRangeCBF() - scalar_range_cbf.set_parameters(self.a, self.b, self.keep_inside) + scalar_range_cbf = ScalarRangeCBF(self.a, self.b, self.keep_inside) scalar_range_cbf.calc_constraints(2.0) G, alpha_h = scalar_range_cbf.get_constraints() @@ -88,16 +84,14 @@ class TestCircleCBF: keep_inside = True def test_set_and_get_parameters(self) -> None: - circle_cbf = CircleCBF() - circle_cbf.set_parameters(self.center, self.radius, self.keep_inside) + circle_cbf = CircleCBF(self.center, self.radius, self.keep_inside) center, radius, keep_inside = circle_cbf.get_parameters() assert np.allclose(center, self.center) assert radius == pytest.approx(self.radius) assert keep_inside == self.keep_inside def test_calc_constraints(self) -> None: - circle_cbf = CircleCBF() - circle_cbf.set_parameters(self.center, self.radius, self.keep_inside) + circle_cbf = CircleCBF(self.center, self.radius, self.keep_inside) agent_position = 2 * np.ones(2) circle_cbf.calc_constraints(agent_position) @@ -112,16 +106,14 @@ class TestUnicycleCircleCBF: keep_inside = True def test_set_and_get_parameters(self) -> None: - circle_cbf = UnicycleCircleCBF() - circle_cbf.set_parameters(self.center, self.radius, self.keep_inside) + circle_cbf = UnicycleCircleCBF(self.center, self.radius, self.keep_inside) center, radius, keep_inside = circle_cbf.get_parameters() assert np.allclose(center, self.center) assert radius == pytest.approx(self.radius) assert keep_inside == self.keep_inside def test_calc_constraints(self) -> None: - circle_cbf = UnicycleCircleCBF() - circle_cbf.set_parameters(self.center, self.radius, self.keep_inside) + circle_cbf = UnicycleCircleCBF(self.center, self.radius, self.keep_inside) agent_pose = 2 * np.ones(3) circle_cbf.calc_constraints(agent_pose) @@ -138,8 +130,7 @@ class TestPnorm2dCBF: keep_inside = True def test_set_and_get_parameters(self) -> None: - pnorm2d_cbf = Pnorm2dCBF() - pnorm2d_cbf.set_parameters(self.center, self.width, self.theta, self.p, self.keep_inside) + pnorm2d_cbf = Pnorm2dCBF(self.center, self.width, self.theta, self.p, self.keep_inside) center, width, theta, p, keep_inside = pnorm2d_cbf.get_parameters() assert np.allclose(center, self.center) assert np.allclose(width, self.width) @@ -148,8 +139,7 @@ def test_set_and_get_parameters(self) -> None: assert keep_inside == self.keep_inside def test_calc_constraints(self) -> None: - pnorm2d_cbf = Pnorm2dCBF() - pnorm2d_cbf.set_parameters(self.center, self.width, self.theta, self.p, self.keep_inside) + pnorm2d_cbf = Pnorm2dCBF(self.center, self.width, self.theta, self.p, self.keep_inside) agent_position = 2 * np.ones(2) pnorm2d_cbf.calc_constraints(agent_position) @@ -166,8 +156,7 @@ class TestUnicyclePnorm2dCBF: keep_inside = True def test_set_and_get_parameters(self) -> None: - pnorm2d_cbf = UnicyclePnorm2dCBF() - pnorm2d_cbf.set_parameters(self.center, self.width, self.theta, self.p, self.keep_inside) + pnorm2d_cbf = UnicyclePnorm2dCBF(self.center, self.width, self.theta, self.p, self.keep_inside) center, width, theta, p, keep_inside = pnorm2d_cbf.get_parameters() assert np.allclose(center, self.center) assert np.allclose(width, self.width) @@ -176,8 +165,7 @@ def test_set_and_get_parameters(self) -> None: assert keep_inside == self.keep_inside def test_calc_constraints(self) -> None: - pnorm2d_cbf = UnicyclePnorm2dCBF() - pnorm2d_cbf.set_parameters(self.center, self.width, self.theta, self.p, self.keep_inside) + pnorm2d_cbf = UnicyclePnorm2dCBF(self.center, self.width, self.theta, self.p, self.keep_inside) agent_pose = 2 * np.ones(3) pnorm2d_cbf.calc_constraints(agent_pose) @@ -191,15 +179,13 @@ class TestLiDARCBF: keep_upper = True def test_set_and_get_parameters(self) -> None: - lidar_cbf = LiDARCBF() - lidar_cbf.set_parameters(self.width, self.keep_upper) + lidar_cbf = LiDARCBF(self.width, self.keep_upper) width, keep_upper = lidar_cbf.get_parameters() assert np.allclose(width, self.width) assert keep_upper == self.keep_upper def test_calc_constraints(self) -> None: - lidar_cbf = LiDARCBF() - lidar_cbf.set_parameters(self.width, self.keep_upper) + lidar_cbf = LiDARCBF(self.width, self.keep_upper) lidar_cbf.calc_constraints(3.0, 0.0) G, alpha_h = lidar_cbf.get_constraints() diff --git a/tests/test_cbf_qp_solver.py b/tests/test_cbf_qp_solver.py index 9fcba3a..8db35af 100755 --- a/tests/test_cbf_qp_solver.py +++ b/tests/test_cbf_qp_solver.py @@ -12,32 +12,33 @@ class TestCBFQPSolver: q = np.ones(2) def test_optimize_optimal1(self) -> None: - """one inequality constraint""" + """one inequality constraint (inactive)""" G_list = [np.ones(2)] alpha_h_list = [1.0] status, optimal_input = self.qp_solver.optimize(self.P, self.q, G_list, alpha_h_list) assert status == "optimal" - assert np.allclose(optimal_input, np.array([-1.00188955, -1.00188955])) + # Unconstrained minimum: x = -q = [-1, -1], constraint [1,1]x<=1 is inactive + assert np.allclose(optimal_input, np.array([-1.0, -1.0])) def test_optimize_optimal2(self) -> None: - """two inequality constraints""" + """two inequality constraints (one active)""" G_list = [np.ones(2), -np.ones(2)] alpha_h_list = [1.0, 1.0] status, optimal_input = self.qp_solver.optimize(self.P, self.q, G_list, alpha_h_list) assert status == "optimal" - assert np.allclose(optimal_input, np.array([-0.2, -0.2])) + # Active constraint: x1+x2 = -1, by symmetry x1=x2=-0.5 + assert np.allclose(optimal_input, np.array([-0.5, -0.5])) def test_optimize_except(self) -> None: - """exception""" + """dimension mismatch raises exception""" G_list = [np.ones(2), -np.ones(2)] alpha_h_list = [1.0] assert len(G_list) != len(alpha_h_list) - with pytest.raises(Exception) as e: + with pytest.raises(Exception): _ = self.qp_solver.optimize(self.P, self.q, G_list, alpha_h_list) - assert str(e.value) == "'G' must be a 'd' matrix of size (1, 2)" class TestCBFNomQPSolver: @@ -46,31 +47,32 @@ class TestCBFNomQPSolver: nominal_input = np.ones(2) def test_optimize_optimal1(self) -> None: - """one inequality constraint""" + """one inequality constraint (inactive)""" G_list = [np.ones(2)] alpha_h_list = [1.0] status, optimal_input = self.nom_qp_solver.optimize(self.nominal_input, self.P, G_list, alpha_h_list) assert status == "optimal" - assert np.allclose(optimal_input, np.array([1.00188955, 1.00188955])) + # Unconstrained minimum: u = nominal_input = [1, 1], constraint is inactive + assert np.allclose(optimal_input, np.array([1.0, 1.0])) def test_optimize_optimal2(self) -> None: - """two inequality constraints""" + """two inequality constraints (one active)""" G_list = [np.ones(2), -np.ones(2)] alpha_h_list = [1.0, 1.0] status, optimal_input = self.nom_qp_solver.optimize(self.nominal_input, self.P, G_list, alpha_h_list) assert status == "optimal" - assert np.allclose(optimal_input, np.array([0.2, 0.2])) + # Active constraint: u1+u2 = 1, by symmetry u1=u2=0.5 + assert np.allclose(optimal_input, np.array([0.5, 0.5])) def test_optimize_except(self) -> None: - """exception""" + """dimension mismatch raises exception""" G_list = [np.ones(2), -np.ones(2)] alpha_h_list = [1.0] assert len(G_list) != len(alpha_h_list) - with pytest.raises(Exception) as e: + with pytest.raises(Exception): _ = self.nom_qp_solver.optimize(self.nominal_input, self.P, G_list, alpha_h_list) - assert str(e.value) == "'G' must be a 'd' matrix of size (1, 2)"