diff --git a/README.md b/README.md index 66bf5051f..5621a032a 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,10 @@ Documentation Source Code Issues -Python 3.8 +Python 3.8 | 3.9 | 3.10 C++14 +PyPI +Apache License

--- @@ -48,29 +50,21 @@ SymForce is developed and maintained by [Skydio](https://skydio.com/). It is use SymForce was published to [RSS 2022](https://roboticsconference.org/). Please cite it as follows: ``` -@inproceedings{Martiros-RSS-22, - author = {Hayk Martiros AND Aaron Miller AND Nathan Bucki AND Bradley Solliday AND Ryan Kennedy AND Jack Zhu AND Tung Dang AND Dominic Pattison AND Harrison Zheng AND Teo Tomic AND Peter Henry AND Gareth Cross AND Josiah VanderMey AND Alvin Sun AND Samuel Wang AND Kristen Holtz}, - title = {{SymForce: Symbolic Computation and Code Generation for Robotics}}, - booktitle = {Proceedings of Robotics: Science and Systems}, - year = {2022}, - doi = {10.15607/RSS.2022.XVIII.041} -} +@inproceedings{Martiros-RSS-22, + author = {Hayk Martiros AND Aaron Miller AND Nathan Bucki AND Bradley Solliday AND Ryan Kennedy AND Jack Zhu AND Tung Dang AND Dominic Pattison AND Harrison Zheng AND Teo Tomic AND Peter Henry AND Gareth Cross AND Josiah VanderMey AND Alvin Sun AND Samuel Wang AND Kristen Holtz}, + title = {{SymForce: Symbolic Computation and Code Generation for Robotics}}, + booktitle = {Proceedings of Robotics: Science and Systems}, + year = {2022}, + doi = {10.15607/RSS.2022.XVIII.041} +} ``` -# Build from pip +# Install -SymForce requires Python 3.8 or later. We suggest creating a virtual python environment. +Install with pip: -Install the `gmp` package with one of: -``` -apt install libgmp-dev # Linux -brew install gmp # Mac -conda install -c conda-forge gmp # Conda -``` - -Install SymForce -``` -pip install -e . +```bash +pip install symforce ``` Verify the installation in Python: @@ -78,58 +72,8 @@ Verify the installation in Python: >>> from symforce import geo >>> geo.Rot3() ``` -TODO: Create wheels for pip install symforce - -# Build CMake yourself (TODO deconflict) - -SymForce requires Python 3.8 or later. We suggest creating a virtual python environment. - -Install packages: -``` -# Linux -apt install doxygen libgmp-dev pandoc - -# Mac -brew install doxygen gmp pandoc - -# Conda -conda install -c conda-forge doxygen gmp pandoc -``` - -Install python requirements: -``` -pip install -r requirements.txt -``` -Install CMake if you don't already have a recent version: -``` -pip install "cmake>=3.19" -``` - -Build SymForce (requires C++14 or later): -``` -mkdir build -cd build -cmake .. -make -j 7 -``` -If you have build errors, try updating CMake. - -Install built Python packages: -``` -cd .. -pip install -e build/lcmtypes/python2.7 -pip install -e gen/python -pip install -e . -``` - -Verify the installation in Python: -```python ->>> from symforce import geo ->>> geo.Rot3() -``` - -TODO: Create wheels for pip install symforce +This installs pre-compiled C++ components of SymForce on Linux and Mac using pip wheels, but does not include C++ headers. If you want to compile against C++ SymForce types (like `sym::Optimizer`), you currently need to [build from source](#build-from-source). # Tutorial @@ -226,7 +170,7 @@ geo.V3.symbolic("x").norm(epsilon=sm.epsilon) -TODO: Link to a detailed epsilon tutorial once created. +See the [Epsilon Tutorial](https://symforce.org/notebooks/epsilon_tutorial.html) in the SymForce Docs for more information. ## Build an optimization problem @@ -574,6 +518,52 @@ $ --> To learn more, visit the SymForce tutorials [here](https://symforce.org/#guides). +# Build from Source + +SymForce requires Python 3.8 or later. We strongly suggest creating a virtual python environment. + +Install the `gmp` package with one of: +```bash +apt install libgmp-dev # Ubuntu +brew install gmp # Mac +conda install -c conda-forge gmp # Conda +``` + +SymForce contains both C++ and Python code. The C++ code is built using CMake. You can build the package either by calling pip, or by calling CMake directly. If building with `pip`, this will call CMake under the hood, and run the same CMake build for the C++ components. + +If you encounter build issues, please file an [issue](https://github.com/symforce-org/symforce/issues). + +## Build with pip + +The recommended way to build and install SymForce if you only plan on making Python changes is with pip. From the symforce directory: +```bash +pip install -e . +``` + +This will build the C++ components of SymForce, but you won't be able to run `pip install -e .` repeatedly if you need to rebuild C++ code. If you're changing C++ code and rebuilding, you should build with CMake directly as described [below](#build-with-cmake). + +`pip install .` will not install pinned versions of SymForce's dependencies, it'll install any compatible versions. It also won't install all packages required to run all of the SymForce tests and build all of the targets (e.g. building the docs or running the linters). If you want all packages required for that, you should `pip install .[dev]` instead (or one of the other groups of extra requirements in our `setup.py`). If you additionally want pinned versions of our dependencies, which are the exact versions guaranteed by CI to pass all of our tests, you can install them from `pip install -r dev_requirements.txt`. + +## Build with CMake + +If you'll be modifying the C++ parts of SymForce, you should build with CMake directly instead - this method will not install +SymForce into your Python environment, so you'll need to add it to your PYTHONPATH separately. + +Install python requirements: +```bash +pip install -r dev_requirements.txt +``` + +Build SymForce (requires C++14 or later): +```bash +mkdir build +cd build +cmake .. +make -j $(nproc) +``` + +You'll then need to add SymForce (along with `gen/python` and `third_party/skymarshal` within symforce) to your PYTHONPATH in order to use them. + # License SymForce is released under the [Apache 2.0](https://spdx.org/licenses/Apache-2.0.html) license. diff --git a/requirements.txt b/dev_requirements.txt similarity index 97% rename from requirements.txt rename to dev_requirements.txt index 9259cb042..42b530023 100644 --- a/requirements.txt +++ b/dev_requirements.txt @@ -65,7 +65,6 @@ docutils==0.17.1 # myst-parser # nbsphinx # sphinx - # sphinx-rtd-theme entrypoints==0.4 # via # jupyter-client @@ -177,15 +176,19 @@ nest-asyncio==1.5.5 numpy==1.22.3 # via # matplotlib + # pandas # scipy # skymarshal # symforce (setup.py) + # symforce-sym packaging==21.3 # via # ipykernel # matplotlib # nbconvert # sphinx +pandas==1.4.2 + # via symforce (setup.py) pandocfilters==1.5.0 # via nbconvert parso==0.8.3 @@ -206,6 +209,8 @@ platformdirs==2.5.1 # via # black # pylint +plotly==5.8.0 + # via symforce (setup.py) ply==3.11 # via skymarshal prompt-toolkit==3.0.29 @@ -235,8 +240,11 @@ python-dateutil==2.8.2 # via # jupyter-client # matplotlib + # pandas pytz==2022.1 - # via babel + # via + # babel + # pandas pyyaml==6.0 # via myst-parser pyzmq==22.3.0 @@ -263,12 +271,9 @@ sphinx==4.5.0 # myst-parser # nbsphinx # sphinx-autodoc-typehints - # sphinx-rtd-theme # symforce (setup.py) sphinx-autodoc-typehints==1.14.1 # via symforce (setup.py) -sphinx-rtd-theme==1.0.0 - # via symforce (setup.py) sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 @@ -287,6 +292,8 @@ file:./gen/python # via symforce (setup.py) sympy==1.10.1 # via symforce (setup.py) +tenacity==8.0.1 + # via plotly tinycss2==1.1.1 # via nbconvert tokenize-rt==4.2.1 diff --git a/docs/conf.py b/docs/conf.py index 64a2c1684..444068090 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -114,27 +114,34 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = "sphinx_rtd_theme" +html_theme = "alabaster" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = { + "description": "Fast symbolic computation, code generation, and nonlinear optimization for robotics", + "fixed_sidebar": False, + "page_width": "1200px", + "github_button": True, + "github_user": "symforce-org", + "github_repo": "symforce", + "github_type": "star", # 'canonical_url': '', # 'analytics_id': 'UA-XXXXXXX-1', # Provided by Google in your dashboard # 'logo_only': False, - "display_version": True, + # "display_version": True, # 'prev_next_buttons_location': 'bottom', # 'style_external_links': False, # 'vcs_pageview_mode': '', # 'style_nav_header_background': 'white', # Toc options - "collapse_navigation": False, - "sticky_navigation": True, - "navigation_depth": -1, - "includehidden": True, - "titles_only": True, + # "collapse_navigation": False, + # "sticky_navigation": True, + # "navigation_depth": -1, + "sidebar_includehidden": True, + # "titles_only": True, } # Add any paths that contain custom static files (such as style sheets) here, diff --git a/docs/development.rst b/docs/development.rst index e5b05bab5..0f76b03bb 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -15,12 +15,19 @@ See the :ref:`module reference ` for the core package structure. ************************************************* Build ************************************************* -SymForce is primarily written in Python, aimed to be 3.8+ compatible. It has a top level Makefile to execute high level commands: +SymForce is primarily written in Python and C++, and is Python 3.8+ and C++14 compatible. The build +system is CMake for the C++ components, and optionally pip / setuptools on top for Python packaging. +See the Build section on the [Homepage](/index.html#build-from-source) for build instructions. + + +************************************************* +Additional useful commands +************************************************* +SymForce also has a top level Makefile which is not used by the build, but provides some high +level commands for development: +----------------------------------------------+--------------------------+ -| Install requirements | ``make all_reqs`` | -+----------------------------------------------+--------------------------+ -| Run tests | ``make test`` | +| Run Python tests | ``make test`` | +----------------------------------------------+--------------------------+ | Run tests which update (most) generated code | ``make test_update`` | +----------------------------------------------+--------------------------+ @@ -32,18 +39,12 @@ SymForce is primarily written in Python, aimed to be 3.8+ compatible. It has a t +----------------------------------------------+--------------------------+ | Build docs + open in browser | ``make docs_open`` | +----------------------------------------------+--------------------------+ -| Launch Jupyter server | ``make notebook`` | -+----------------------------------------------+--------------------------+ -| Launch Jupyter server + browser | ``make notebook_open`` | -+----------------------------------------------+--------------------------+ | Run the code formatter (black, clang-format) | ``make format`` | +----------------------------------------------+--------------------------+ | Check types with mypy | ``make check_types`` | +----------------------------------------------+--------------------------+ | Check formatting and types | ``make lint`` | +----------------------------------------------+--------------------------+ -| Clean all build products | ``make clean`` | -+----------------------------------------------+--------------------------+ ************************************************* Documentation diff --git a/docs/index.rst b/docs/index.rst index d7041ec79..cf8fc4985 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,18 +1,20 @@ -SymForce Docs +SymForce Home ============= .. include:: ../README.md :parser: myst_parser.sphinx_ -Guides -====== - .. toctree:: :caption: Pages :hidden: self development + +.. toctree:: + :caption: Tutorials + :hidden: + notebooks/sympy_tutorial notebooks/geometry_tutorial notebooks/ops_tutorial @@ -20,6 +22,10 @@ Guides notebooks/values_tutorial notebooks/codegen_tutorial notebooks/optimization_tutorial + notebooks/epsilon_tutorial + +Guides +====== :doc:`development` How to build, configure, and develop @@ -31,7 +37,7 @@ Guides Introductory guide to doing math and geometry :doc:`notebooks/ops_tutorial` - Introductory guide to using Concepts in symforce + Introductory guide to using Ops in symforce :doc:`notebooks/cameras_tutorial` Introductory guide to using camera models @@ -45,6 +51,9 @@ Guides :doc:`notebooks/optimization_tutorial` Basic example of using generated code to do optimization +:doc:`notebooks/epsilon_tutorial` + Guide to how Epsilon is used to prevent singularities + .. _api-reference: .. toctree:: :hidden: diff --git a/docs/notebooks/epsilon_tutorial.ipynb b/docs/notebooks/epsilon_tutorial.ipynb new file mode 120000 index 000000000..ce6cf835e --- /dev/null +++ b/docs/notebooks/epsilon_tutorial.ipynb @@ -0,0 +1 @@ +../../notebooks/epsilon_tutorial.ipynb \ No newline at end of file diff --git a/docs/static/css/custom.css b/docs/static/css/custom.css index c8adef37a..316252ccb 100644 --- a/docs/static/css/custom.css +++ b/docs/static/css/custom.css @@ -1,3 +1,12 @@ [href$="#gh-dark-mode-only"], [src$="#gh-dark-mode-only"] { display: none; } + +section#symforce-home h1 { + display: none; +} + +/* Hide "Navigation" header on sidebar */ +div.sphinxsidebarwrapper h3:first-of-type { + display: none; +} diff --git a/gen/python/setup.py b/gen/python/setup.py index ea42bb947..b6251988c 100644 --- a/gen/python/setup.py +++ b/gen/python/setup.py @@ -7,13 +7,21 @@ from setuptools import setup, find_packages setup( - name="sym", - version="0.3.0", + name="symforce-sym", + version="0.4.0", description="generated numerical python package (installed by SymForce)", + license_file="LICENSE", long_description="generated numerical python package (installed by SymForce)", author="Skydio, Inc", author_email="hayk@skydio.com", + install_requires=["numpy"], license="Apache 2.0", packages=find_packages(), + python_requires=">=2.7", + project_urls={ + "Bug Tracker": "https://github.com/symforce-org/symforce/issues", + "Source": "https://github.com/symforce-org/symforce/tree/main/gen/python", + }, + url="https://github.com/symforce-org/symforce", zip_safe=False, ) diff --git a/notebooks/cameras_tutorial.ipynb b/notebooks/cameras_tutorial.ipynb index 347c9459b..ab0b89732 100644 --- a/notebooks/cameras_tutorial.ipynb +++ b/notebooks/cameras_tutorial.ipynb @@ -11,7 +11,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "This is an introductory walkthrough of the symbolic [cameras package](../api/symforce.cameras.html) in symforce." + "This is an introductory walkthrough of the symbolic [cameras package](../api/symforce.cam.html) in symforce." ] }, { @@ -73,7 +73,9 @@ "metadata": {}, "outputs": [], "source": [ - "camera_point_reprojected, _ = linear_camera_cal.pixel_from_camera_point(camera_ray)\n", + "camera_point_reprojected, _ = linear_camera_cal.pixel_from_camera_point(\n", + " camera_ray,\n", + ")\n", "display(camera_point_reprojected)" ] }, @@ -91,7 +93,10 @@ "outputs": [], "source": [ "linear_camera = cam.Camera(\n", - " calibration=cam.LinearCameraCal(focal_length=(440, 400), principal_point=(320, 240)),\n", + " calibration=cam.LinearCameraCal(\n", + " focal_length=(440, 400),\n", + " principal_point=(320, 240),\n", + " ),\n", " image_size=(640, 480),\n", ")\n", "display(linear_camera)" @@ -115,7 +120,11 @@ "for point in (point_in_FOV, point_outside_FOV):\n", " pixel, is_valid = linear_camera.pixel_from_camera_point(point)\n", " print(\n", - " \"point={} -> pixel={}, is_valid={}\".format(point.to_storage(), pixel.to_storage(), is_valid)\n", + " \"point={} -> pixel={}, is_valid={}\".format(\n", + " point.to_storage(),\n", + " pixel.to_storage(),\n", + " is_valid,\n", + " )\n", " )" ] }, @@ -134,7 +143,8 @@ "source": [ "linear_posed_camera = cam.PosedCamera(\n", " pose=geo.Pose3(\n", - " R=geo.Rot3.from_yaw_pitch_roll(0, sm.pi, 0), # camera is spun 180 degrees about y-axis\n", + " # camera is spun 180 degrees about y-axis\n", + " R=geo.Rot3.from_yaw_pitch_roll(0, sm.pi, 0),\n", " t=geo.V3(),\n", " ),\n", " calibration=linear_camera.calibration,\n", @@ -159,7 +169,8 @@ "global_point = geo.V3(0, 0, -1)\n", "print(\n", " \"point in global coordinates={} (in camera coordinates={})\".format(\n", - " global_point.to_storage(), (linear_posed_camera.pose * global_point).to_storage()\n", + " global_point.to_storage(),\n", + " (linear_posed_camera.pose * global_point).to_storage(),\n", " )\n", ")\n", "\n", diff --git a/notebooks/codegen_tutorial.ipynb b/notebooks/codegen_tutorial.ipynb index 03319667e..038a36a61 100644 --- a/notebooks/codegen_tutorial.ipynb +++ b/notebooks/codegen_tutorial.ipynb @@ -97,7 +97,10 @@ "metadata": {}, "outputs": [], "source": [ - "az_el_codegen = codegen.Codegen.function(func=az_el_from_point, config=codegen.CppConfig())\n", + "az_el_codegen = codegen.Codegen.function(\n", + " func=az_el_from_point,\n", + " config=codegen.CppConfig(),\n", + ")\n", "az_el_codegen_data = az_el_codegen.generate_function()\n", "\n", "print(\"Files generated in {}:\\n\".format(az_el_codegen_data.output_dir))\n", @@ -311,14 +314,20 @@ " config=codegen.CppConfig(),\n", " name=\"double_pendulum\",\n", ")\n", - "double_pendulum_values_data = double_pendulum_values.generate_function(namespace=namespace)\n", + "double_pendulum_values_data = double_pendulum_values.generate_function(\n", + " namespace=namespace,\n", + ")\n", "\n", - "# Print what we generated. Note the nested structs that were automatically generated.\n", + "# Print what we generated. Note the nested structs that were automatically\n", + "# generated.\n", "print(\"Files generated in {}:\\n\".format(double_pendulum_values_data.output_dir))\n", "for f in double_pendulum_values_data.generated_files:\n", " print(\" |- {}\".format(os.path.relpath(f, double_pendulum_values_data.output_dir)))\n", "\n", - "display_code_file(double_pendulum_values_data.function_dir / \"double_pendulum.h\", \"C++\")" + "display_code_file(\n", + " double_pendulum_values_data.function_dir / \"double_pendulum.h\",\n", + " \"C++\",\n", + ")" ] }, { @@ -342,13 +351,18 @@ " name=\"double_pendulum\",\n", " return_key=\"ddang\",\n", ")\n", - "double_pendulum_python_data = double_pendulum_python.generate_function(namespace=namespace)\n", + "double_pendulum_python_data = double_pendulum_python.generate_function(\n", + " namespace=namespace,\n", + ")\n", "\n", "print(\"Files generated in {}:\\n\".format(double_pendulum_python_data.output_dir))\n", "for f in double_pendulum_python_data.generated_files:\n", " print(\" |- {}\".format(os.path.relpath(f, double_pendulum_python_data.output_dir)))\n", "\n", - "display_code_file(double_pendulum_python_data.function_dir / \"double_pendulum.py\", \"python\")" + "display_code_file(\n", + " double_pendulum_python_data.function_dir / \"double_pendulum.py\",\n", + " \"python\",\n", + ")" ] }, { diff --git a/notebooks/epsilon_sandbox.ipynb b/notebooks/epsilon_tutorial.ipynb similarity index 57% rename from notebooks/epsilon_sandbox.ipynb rename to notebooks/epsilon_tutorial.ipynb index c27e47270..9188c91a1 100644 --- a/notebooks/epsilon_sandbox.ipynb +++ b/notebooks/epsilon_tutorial.ipynb @@ -4,7 +4,38 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# The use of epsilon to prevent singularities" + "# Epsilon Tutorial\n", + "\n", + "This notebook describes the epsilon mechanism by which numerical singularities are handled in SymForce. The [paper](https://arxiv.org/abs/2204.07889) addresses the theory in Section VI, and this tutorial demonstrates the idea through examples.\n", + "\n", + "The basic concept is that it is common to have functions in robotics that are smooth but have singularities at given points. Handwritten functions tend to handle them by adding an if statement at the singularity with some kind of approximation or alternate formulation. This is harder to do with symbolic expressions, and also is not kind to branch prediction. SymForce addresses this with a different method - shifting the input to the function away from the singular point with an infinitesimal variable $\\epsilon$ (epsilon). This approach is simple and fast for a useful class of removable singularities, with negligible effect to output values for sufficiently small epsilon.\n", + "\n", + "All functions in SymForce that have singularities take epsilon as an argument. In a numerical context, a very small floating point number should be passed in. In the symbolic context, an epsilon symbol should be passed in. Epsilon arguments are currently optional with zero defaults. This is convenient so that playing with expressions in a notebook doesn't require passing epsilons around. However, this is dangerous and it is extremely important to pass epsilons to get robust behavior or when generating code. Because of this, there are active efforts to make a more intelligent mechanism for the default epsilon to make it less of a footgun to accidentally forget an epsilon and end up with a NaN." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Goal\n", + "\n", + "We have a function `f(x)`.\n", + "\n", + "In the simplest case, we're trying to fix a removable singularity at `x=0`, i.e. `f(x).subs(x, 0) == sm.S.NaN`\n", + "\n", + "Libraries often do this by checking whether the value of `x` is close to 0, and using a different method for evaluation there, such as a Taylor expansion. In symbolic expressions, branching like this is messy and expensive.\n", + "\n", + "The idea is to instead make a function `f(x, eps)` so the value is not NaN, when `eps` is a small positive number: \n", + "`f(x, eps).subs(x, 0) != sm.S.NaN`\n", + "\n", + "We usually also want that the derivative is not NaN: \n", + "`f(x, eps).diff(x).subs(x, 0) != NaN`\n", + "\n", + "For value continuity we want to match the limit: \n", + "`f(x, eps).subs(x, 0).limit(eps, 0) == f(x).limit(x, 0)`\n", + "\n", + "For derivative continuity we want to match the limit: \n", + "`f(x, eps).diff(x).subs(x, 0).limit(eps, 0) == f(x).diff(x).limit(x, 0)`" ] }, { @@ -13,29 +44,168 @@ "metadata": {}, "outputs": [], "source": [ - "import sympy as sm" + "import numpy as np\n", + "import plotly.express as px\n", + "import sympy as sm\n", + "\n", + "x = sm.Symbol(\"x\")\n", + "eps = sm.Symbol(\"epsilon\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "We have a function `f(x)`.\n", + "## An example: sin(x) / x\n", "\n", - "In the simplest case, we're trying to fix a singularity at `x=0`: \n", - "`f(x).subs(x, 0) == sm.S.NaN`\n", + "For the whole section below, let's pretend x is positive so $x = -\\epsilon$ is not a fear. We'll address that later." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# The function `sin(x) / x` looks like this:\n", + "def f(x):\n", + " return sm.sin(x) / x\n", "\n", - "We need to make an epsilon version `f(x, eps)` so the value is not NaN: \n", - "`f(x, eps).subs(x, 0) != sm.S.NaN`\n", "\n", - "And also the derivative is not NaN: \n", - "`f(x, eps).diff(x).subs(x, 0) != NaN`\n", + "f(x)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# And its graph:\n", + "x_numerical = np.linspace(-5, 5)\n", + "px.line(x=x_numerical, y=np.vectorize(f, otypes=[float])(x_numerical))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# It has a removable singularity at 0, of the form 0/0, which gives NaN:\n", + "f(x).subs(x, 0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# The derivative has the same issue:\n", + "f(x).diff(x).subs(x, 0)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "One thought to fix this might be to just push the denominator away from 0. This does resolve the NaN, but it produces the wrong value! (Remember, the value at `x=0` should be 1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def f(x, eps):\n", + " return sm.sin(x) / (x + eps)\n", "\n", - "For value continuity it would be nice to match the limit: \n", - "`f(x, eps).subs(x, 0).limit(eps, 0) == f(x).limit(x, 0)`\n", "\n", - "For derivative continuity it would be nice to match the limit: \n", - "`f(x, eps).diff(x).subs(x, 0).limit(eps, 0) == f(x).diff(x).limit(x, 0)`" + "f(x, eps).subs(x, 0)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Similarly, the derivative is wrong, and actually diverges (it should be 0):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "f(x, eps).diff(x).subs(x, 0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "f(x, eps).diff(x).subs(x, 0).limit(eps, 0)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Instead, what we want to do is _perturb the input away from the singularity_. Effectively, we're shifting the graph of the function to the left. For removable singularities in well-behaved functions, the error introduced by this is proportional to epsilon. That looks like this:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def f(x, eps):\n", + " x_safe = x + eps\n", + " return sm.sin(x_safe) / x_safe\n", + "\n", + "\n", + "f(x, eps).subs(x, 0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "f(x, eps).subs(x, 0).limit(eps, 0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "f(x, eps).diff(x).subs(x, 0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "f(x, eps).diff(x).subs(x, 0).limit(eps, 0)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Automating Verification\n", + "\n", + "We can also write a function that automatically checks that you've used epsilon correctly, using SymPy's ability to take derivatives and limits as we've been doing above. That looks something like this - similar functions are available in SymForce in [symforce.test_util.epsilon_handling](../api/symforce.test_util.epsilon_handling.html)." ] }, { @@ -44,20 +214,19 @@ "metadata": {}, "outputs": [], "source": [ - "def is_epsilon_correct(func, singularity=0, limit_direction=\"+\", display_func=display):\n", - " # type: (T.Callable) -> bool\n", + "def is_epsilon_correct(func, singularity=0, limit_direction=\"+\"):\n", " \"\"\"\n", - " Check epsilon handling for a function that accepts a single value and an epsilon.\n", + " Check epsilon handling for a function that accepts a single value and an\n", + " epsilon.\n", "\n", " For epsilon to be handled correctly, the function must:\n", " 1) evaluate to a non-singularity at x=singularity given epsilon\n", - " 2) linear approximation of the original must match that taken with epsilon then substituted to zero\n", + " 2) linear approximation of the original must match that taken with\n", + " epsilon then substituted to zero\n", " \"\"\"\n", " # Create symbols\n", " x = sm.Symbol(\"x\", real=True)\n", " epsilon = sm.Symbol(\"epsilon\", positive=True)\n", - " EPS = 1e-8\n", - " TOL = sm.sqrt(EPS)\n", "\n", " is_correct = True\n", "\n", @@ -65,15 +234,14 @@ " expr_eps = func(x, epsilon)\n", " expr_raw = expr_eps.subs(epsilon, 0)\n", "\n", - " if display_func:\n", - " display_func(\"Expressions (raw / eps):\")\n", - " display_func(expr_raw)\n", - " display_func(expr_eps)\n", + " display(\"Expressions (raw / eps):\")\n", + " display(expr_raw)\n", + " display(expr_eps)\n", "\n", " # Sub in zero\n", " expr_eps_at_x_zero = expr_eps.subs(x, singularity)\n", " if expr_eps_at_x_zero == sm.S.NaN:\n", - " display_func(\"[ERROR] Epsilon handling failed, expression at 0 is NaN.\")\n", + " display(\"[ERROR] Epsilon handling failed, expression at 0 is NaN.\")\n", " is_correct = False\n", "\n", " # Take constant approximation at singularity and check equivalence\n", @@ -81,33 +249,27 @@ " value_x0_eps = expr_eps.subs(x, singularity)\n", " value_x0_eps_sub2 = sm.simplify(value_x0_eps.limit(epsilon, 0))\n", " if value_x0_eps_sub2 != value_x0_raw:\n", - " if display_func:\n", - " display_func(\n", - " \"[ERROR] Values at x={} not match (raw / eps / eps.limit):\".format(singularity)\n", - " )\n", - " display_func(value_x0_raw)\n", - " display_func(value_x0_eps)\n", - " display_func(value_x0_eps_sub2)\n", + " display(\n", + " f\"[ERROR] Values at x={singularity} do not match (raw / eps / eps.limit):\",\n", + " )\n", + " display(value_x0_raw)\n", + " display(value_x0_eps)\n", + " display(value_x0_eps_sub2)\n", " is_correct = False\n", "\n", - " # NOTE(hayk): Perhaps it's useful to be less strict and plug in small numerical values for x and eps\n", - " # value_x0_eps_sub = value_x0_eps.subs(epsilon, EPS)\n", - " # if abs(value_x0_raw.evalf() - value_x0_eps_sub.evalf()) > TOL:]\n", - " # derivative_x0_eps_sub = derivative_x0_eps.subs(epsilon, EPS)\n", - " # if abs(derivative_x0_raw.evalf() - derivative_x0_eps_sub.evalf()) > TOL:\n", - "\n", " # Take linear approximation at singularity and check equivalence\n", - " derivative_x0_raw = sm.simplify(expr_raw.diff(x).limit(x, singularity, limit_direction))\n", + " derivative_x0_raw = sm.simplify(\n", + " expr_raw.diff(x).limit(x, singularity, limit_direction),\n", + " )\n", " derivative_x0_eps = expr_eps.diff(x).subs(x, singularity)\n", " derivative_x0_eps_sub2 = sm.simplify(derivative_x0_eps.limit(epsilon, 0))\n", " if derivative_x0_eps_sub2 != derivative_x0_raw:\n", - " if display_func:\n", - " display_func(\n", - " \"[ERROR] Derivatives at x={} not match (raw / eps / eps.limit):\".format(singularity)\n", - " )\n", - " display_func(derivative_x0_raw)\n", - " display_func(derivative_x0_eps)\n", - " display_func(derivative_x0_eps_sub2)\n", + " display(\n", + " f\"[ERROR] Derivatives at x={singularity} do not match (raw / eps / eps.limit):\",\n", + " )\n", + " display(derivative_x0_raw)\n", + " display(derivative_x0_eps)\n", + " display(derivative_x0_eps_sub2)\n", " is_correct = False\n", "\n", " return is_correct" @@ -117,9 +279,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Test sin(x) / x\n", - "\n", - "For the whole section below, let's pretend x is positive so $x = -\\epsilon$ is not a fear. We'll address that later." + "### Test sin(x) / x" ] }, { @@ -166,7 +326,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Test (1 - cos(x)) / x" + "### Test (1 - cos(x)) / x" ] }, { @@ -185,7 +345,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Value passes if we just replace the denominator, because this one is ~ x**2 / x unlike the above which is ~ x / x\n", + "# Value passes if we just replace the denominator, because this one is\n", + "# ~ x**2 / x unlike the above which is ~ x / x\n", "assert is_epsilon_correct(lambda x, eps: (1 - sm.cos(x)) / (x + eps)) == False" ] }, @@ -203,7 +364,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Test x / sqrt(x**2)" + "### Test x / sqrt(x**2)" ] }, { @@ -233,7 +394,12 @@ "outputs": [], "source": [ "# Broken fix #2\n", - "assert is_epsilon_correct(lambda x, eps: (x + eps) / sm.sqrt(x ** 2 + eps ** 2)) == False" + "assert (\n", + " is_epsilon_correct(\n", + " lambda x, eps: (x + eps) / sm.sqrt(x ** 2 + eps ** 2),\n", + " )\n", + " == False\n", + ")" ] }, { @@ -243,7 +409,12 @@ "outputs": [], "source": [ "# Broken fix #3, ugh\n", - "assert is_epsilon_correct(lambda x, eps: (x + eps) / (eps + sm.sqrt(x ** 2 + eps ** 2))) == False" + "assert (\n", + " is_epsilon_correct(\n", + " lambda x, eps: (x + eps) / (eps + sm.sqrt(x ** 2 + eps ** 2)),\n", + " )\n", + " == False\n", + ")" ] }, { @@ -253,14 +424,19 @@ "outputs": [], "source": [ "# Working if you again replace all x with x + eps\n", - "assert is_epsilon_correct(lambda x, eps: (x + eps) / sm.sqrt((x + eps) ** 2)) == True" + "assert (\n", + " is_epsilon_correct(\n", + " lambda x, eps: (x + eps) / sm.sqrt((x + eps) ** 2),\n", + " )\n", + " == True\n", + ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "# Test acos(x) / sqrt(1 - x^2) at 1" + "### Test acos(x) / sqrt(1 - x^2) at 1" ] }, { @@ -299,7 +475,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Test atan2(0, x)" + "### Test atan2(0, x)" ] }, { @@ -326,7 +502,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Handling negative x" + "## Handling negative x" ] }, { @@ -335,7 +511,7 @@ "source": [ "If we consider an example from above like $sin(x + \\epsilon) / (x + \\epsilon)$, this can easily be singular for a negative $x$, specifically where $x = -\\epsilon$. If $x$ were always negative, we could do $sin(x - \\epsilon) / (x - \\epsilon)$.\n", "\n", - "So to handle both cases we can use the sign function as: $sin(x + sign(x) * \\epsilon) / (x + sign(x) * \\epsilon)$. This gives us the behavior that it always pushes $x$ away from zero because $sign(+) = 1$ and $sign(-) = -1$. However, $sign(0) = 0$ which breaks the original zero point. To resolve this we can make a \"no zero\" version that arbitrarily picks the positive direction when exactly at zero.\n", + "So to handle both cases we can use the sign function as: $sin(x + sign(x) * \\epsilon) / (x + sign(x) * \\epsilon)$. This gives us the behavior that it always pushes $x$ away from zero because $sign(+) = 1$ and $sign(-) = -1$. However, $sign(0) = 0$ (at least, by SymPy's definition) which breaks the original zero point. To resolve this we can make a \"no zero\" version that arbitrarily picks the positive direction when exactly at zero. We'll call this `sign_no_zero(x)`, or $snz(x)$\n", "\n", "We can implement this cleverly with the help of a min function:" ] @@ -349,7 +525,8 @@ "def sign_no_zero(x):\n", " # type: (T.Scalar) -> T.Scalar\n", " \"\"\"\n", - " Returns -1 if x is negative, 1 if x is positive, and 1 if x is zero (given a positive epsilon).\n", + " Returns -1 if x is negative, 1 if x is positive, and 1 if x is zero (given\n", + " a positive epsilon).\n", " \"\"\"\n", " return 2 * sm.Min(sm.sign(x), 0) + 1" ] @@ -376,7 +553,12 @@ "outputs": [], "source": [ "# Test for x / sqrt(x**2)\n", - "assert is_epsilon_correct(lambda x, eps: (x + eps) / sm.sqrt((x + eps) ** 2)) == True" + "assert (\n", + " is_epsilon_correct(\n", + " lambda x, eps: (x + eps) / sm.sqrt((x + eps) ** 2),\n", + " )\n", + " == True\n", + ")" ] }, { @@ -386,21 +568,26 @@ "outputs": [], "source": [ "# Test for atan2(0, x)\n", - "assert is_epsilon_correct(lambda x, eps: sm.atan2(0, x + eps * sign_no_zero(x))) == True" + "assert (\n", + " is_epsilon_correct(\n", + " lambda x, eps: sm.atan2(0, x + eps * sign_no_zero(x)),\n", + " )\n", + " == True\n", + ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "# Generalization" + "## Generalization" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "So far it seems like for a function $f(x)$ that is singular at $x=0$, the expression $f(x + \\epsilon * snc(x))$ for a small positive $\\epsilon$ will be non-singular and retain the same linear approximiation as $f(x)$. $snc$ is `sign_no_zero`. So we can easily write a function that does this substitution:" + "So far it seems like for a function $f(x)$ that is singular at $x=0$, the expression $f(x + \\epsilon * snz(x))$ for a small positive $\\epsilon$ will be non-singular and retain the same linear approximiation as $f(x)$. So we can easily write a function that does this substitution:" ] }, { @@ -410,12 +597,7 @@ "outputs": [], "source": [ "def add_epsilon_sign(expr, var, eps):\n", - " return expr.subs(var, var + eps * sign_no_zero(var))\n", - "\n", - "\n", - "# Alternative using Max\n", - "def add_epsilon_max(expr, var, eps):\n", - " return expr.subs(var, sign_no_zero(var) * sm.Max(eps, sm.Abs(var)))" + " return expr.subs(var, var + eps * sign_no_zero(var))" ] }, { @@ -425,7 +607,12 @@ "outputs": [], "source": [ "# Check known example\n", - "assert is_epsilon_correct(lambda x, eps: add_epsilon_sign(sm.sin(x) / x, x, eps)) == True" + "assert (\n", + " is_epsilon_correct(\n", + " lambda x, eps: add_epsilon_sign(sm.sin(x) / x, x, eps),\n", + " )\n", + " == True\n", + ")" ] }, { @@ -434,8 +621,24 @@ "metadata": {}, "outputs": [], "source": [ - "# With Max\n", - "assert is_epsilon_correct(lambda x, eps: add_epsilon_max(sm.sin(x) / x, x, eps)) == True" + "# Try some more complicated thing nobody wants to epsilon by hand\n", + "assert (\n", + " is_epsilon_correct(\n", + " lambda x, eps: add_epsilon_sign(\n", + " (x + sm.sin(x) ** 2) / (x * (1 - 1 / x)),\n", + " x,\n", + " eps,\n", + " )\n", + " )\n", + " == True\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Another common case is a function $f(x)$ that is singular at $|x| = 1$, where we expect $x$ to be between $-1$ and $1$. In this case we can use a similar idea, using $f(x - \\epsilon * snz(x))$. A function to do this would be:" ] }, { @@ -444,10 +647,26 @@ "metadata": {}, "outputs": [], "source": [ - "# Try some more complicated thing nobody wants to epsilon by hand\n", + "def add_epsilon_near_1_sign(expr, var, eps):\n", + " return expr.subs(var, var - eps * sign_no_zero(var))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Check known example\n", "assert (\n", " is_epsilon_correct(\n", - " lambda x, eps: add_epsilon_sign((x + sm.sin(x) ** 2) / (x * (1 - 1 / x)), x, eps)\n", + " lambda x, eps: add_epsilon_near_1_sign(\n", + " sm.cos(x) / sm.sqrt(1 - x ** 2),\n", + " x,\n", + " eps,\n", + " ),\n", + " singularity=1,\n", + " limit_direction=\"-\",\n", " )\n", " == True\n", ")" @@ -457,7 +676,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Another common case is a function $f(x)$ that is singular at $|x| = 1$, where we expect $x$ to be between $-1$ and $1$. In this case we can use a similar idea, using $f(x - \\epsilon * snc(x))$. Functions to do this would be:" + "### Clamping\n", + "\n", + "We could also imagine clamping away from the singularity, instead of shifting with addition. That would look like this:" ] }, { @@ -466,27 +687,30 @@ "metadata": {}, "outputs": [], "source": [ - "def add_epsilon_near_1_sign(expr, var, eps):\n", - " return expr.subs(var, var - eps * sign_no_zero(var))\n", + "def add_epsilon_max(expr, var, eps):\n", + " return expr.subs(var, sign_no_zero(var) * sm.Max(eps, sm.Abs(var)))\n", "\n", "\n", - "# Alternative using Max/Min\n", "def add_epsilon_near_1_clamp(expr, var, eps):\n", " return expr.subs(var, sm.Max(-1 + eps, sm.Min(1 - eps, var)))" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "However, this will always give a derivative of 0 at the singularity - since the derivative of `sign_no_zero(var) * sm.Max(eps, sm.Abs(var))` with respect to `var` is 0 there. We can see this with an example function, whose derivative at the singularity should be 1. `add_epsilon_sign` handles this correctly:" + ] + }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ - "# Check known example\n", "assert (\n", " is_epsilon_correct(\n", - " lambda x, eps: add_epsilon_near_1_sign(sm.cos(x) / sm.sqrt(1 - x ** 2), x, eps),\n", - " singularity=1,\n", - " limit_direction=\"-\",\n", + " lambda x, eps: add_epsilon_sign((sm.sin(x) + x ** 2) / x, x, eps),\n", " )\n", " == True\n", ")" @@ -496,23 +720,48 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "So in a sense we could just not write epsilons by hand and automatically add them in. However, all of this is assuming the only singularity is at $x=0$. It's easy to construct an arbitrary singularity, like $1/(1-x)$ for $x=1$. It's also easy to construct singularities where this method works symbolically, but relies on values like $\\epsilon^2$ which are too small in actual floating point implementations (see the `Pose2.to_tangent` section below). And we could have a composite of multiple functions, so it's hard to have global visibility into what all your singular points are. Sometimes parameter have a fixed range, sometimes things are normalized, etc.\n", + "`add_epsilon_max` gives the correct value, but not the correct derivative:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "assert (\n", + " is_epsilon_correct(\n", + " lambda x, eps: add_epsilon_max((sm.sin(x) + x ** 2) / x, x, eps),\n", + " )\n", + " == False\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Caveats and Limitations\n", "\n", - "So the question is how well can we generalize this single variable example with a single known singularity to multiple variables and only locally known singularities like at the places we add a division, square root, or atan2 operatiion?" + "So far we've assumed the only singularity is at $x=0$, but everything above works just fine for singularities at other locations, for instance with the function $1/(1-x)$ for $x=1$. What isn't handled well is functions that have multiple singularities, like $1/((x-1)(x-2)(x-3))$. You can _compose_ functions using the singularity handling method above just fine, and everything will be safe. This is almost always all that you need, but if you do encounter a function with multiple singularities that can't be rewritten as a composition of functions with fewer singularities, that will require something more complex.\n", + "\n", + "It's also easy to construct singularities where this method works symbolically, but relies on values like $\\epsilon^2$ which are too small in actual floating point implementations (see the `Pose2_SE2.to_tangent` section below). And we could have a composite of multiple functions, so it's hard to have global visibility into what all your singular points are. Sometimes parameters have a fixed range, sometimes things are normalized, etc.\n", + "\n", + "So a remaining open question is: how well can we generalize this single variable example with a single known singularity to multiple variables and only locally known singularities like at the places we add a division, square root, or atan2 operation?" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "# Case Studies" + "## Case Studies" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Pose2_SE2.from_tangent\n", + "### Pose2_SE2.from_tangent\n", "\n", "`Pose2_SE2.from_tangent` before epsilon handling looks like:\n", "\n", @@ -542,7 +791,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Pose2_SE2.to_tangent\n", + "### Pose2_SE2.to_tangent\n", "\n", "`Pose2_SE2.to_tangent` before epsilon handling looks like:\n", "\n", @@ -567,7 +816,9 @@ "outputs": [], "source": [ "assert (\n", - " is_epsilon_correct(lambda theta, eps: (0.5 * theta * sm.sin(theta)) / (1 - sm.cos(theta)))\n", + " is_epsilon_correct(\n", + " lambda theta, eps: (0.5 * theta * sm.sin(theta)) / (1 - sm.cos(theta)),\n", + " )\n", " == False\n", ")" ] @@ -602,15 +853,13 @@ "But in practice, the denominator's taylor series is $(1 - (1 - 0.5 \\epsilon^2))$, and if $\\epsilon$ is near machine precision, then the denominator will end up as exactly `1 - 1 == 0`. This might be solved by adding $sqrt(\\epsilon)$ instead, but that would introduce a large amount of error. Instead, we do some algebra:\n", "\n", "$$\n", + "\\begin{align*}\n", "\\frac{0.5 \\theta \\sin(\\theta)}{1 - \\cos(\\theta)}\n", - "= \n", - "\\frac{0.5 \\theta \\sin(\\theta)}{1 - \\cos(\\theta)} \\frac{1 + \\cos(\\theta)}{1 + \\cos(\\theta)}\n", - "= \n", - "\\frac{0.5 \\theta \\sin(\\theta) (1 + \\cos(\\theta))}{1 - \\cos^2(\\theta)}\n", - "= \n", - "\\frac{0.5 \\theta \\sin(\\theta) (1 + \\cos(\\theta))}{\\sin^2(\\theta)}\n", - "= \n", - "\\frac{0.5 \\theta (1 + \\cos(\\theta))}{\\sin(\\theta)}\n", + "&= \\frac{0.5 \\theta \\sin(\\theta)}{1 - \\cos(\\theta)} \\frac{1 + \\cos(\\theta)}{1 + \\cos(\\theta)} \\\\\n", + "&= \\frac{0.5 \\theta \\sin(\\theta) (1 + \\cos(\\theta))}{1 - \\cos^2(\\theta)} \\\\\n", + "&= \\frac{0.5 \\theta \\sin(\\theta) (1 + \\cos(\\theta))}{\\sin^2(\\theta)} \\\\\n", + "&= \\frac{0.5 \\theta (1 + \\cos(\\theta))}{\\sin(\\theta)}\n", + "\\end{align*}\n", "$$\n", "\n", "Then, the only singularity is at $\\sin(\\theta) = 0$, which we can fix with:" @@ -634,7 +883,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Rot3.to_tangent\n", + "### Rot3.to_tangent\n", "\n", "`Rot3.to_tangent` before epsilon handling looks like:\n", "\n", @@ -657,7 +906,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Multivariate functions" + "## Multivariate functions" ] }, { @@ -698,7 +947,9 @@ "metadata": {}, "outputs": [], "source": [ - "is_epsilon_correct(lambda x, eps: (x + eps) / sm.sqrt((x + eps) ** 2 + (0 + eps) ** 2))" + "is_epsilon_correct(\n", + " lambda x, eps: (x + eps) / sm.sqrt((x + eps) ** 2 + (0 + eps) ** 2),\n", + ")" ] }, { @@ -718,59 +969,11 @@ "source": [ "is_epsilon_correct(lambda x, eps: (x + eps) / sm.sqrt(eps ** 2 + (x + eps) ** 2))" ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Sign simplification rules\n", - "Not directly related to epsilon, but why in tarnation don't these simplify? Seem like pretty simple rules to add." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Expect this to be x\n", - "sm.simplify(sm.sign(x) * sm.Abs(x))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Expect this to be |x|\n", - "sm.simplify(sm.sign(x) * x)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Expect this to be 1\n", - "sm.simplify(sm.sign(x) * sm.sign(x))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Expect this to be sign(x)\n", - "sm.simplify(x / sm.Abs(x))" - ] } ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -784,7 +987,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.2" + "version": "3.8.11" } }, "nbformat": 4, diff --git a/notebooks/geometry_tutorial.ipynb b/notebooks/geometry_tutorial.ipynb index c996f7d68..e4145ee85 100644 --- a/notebooks/geometry_tutorial.ipynb +++ b/notebooks/geometry_tutorial.ipynb @@ -81,7 +81,11 @@ "# Rotate about x-axis\n", "theta = sm.Symbol(\"theta\")\n", "R_mat = geo.Matrix(\n", - " [[1, 0, 0], [0, sm.cos(theta), -sm.sin(theta)], [0, sm.sin(theta), sm.cos(theta)]]\n", + " [\n", + " [1, 0, 0],\n", + " [0, sm.cos(theta), -sm.sin(theta)],\n", + " [0, sm.sin(theta), sm.cos(theta)],\n", + " ]\n", ")\n", "R = geo.Rot3.from_rotation_matrix(R_mat)\n", "\n", @@ -131,9 +135,15 @@ "metadata": {}, "outputs": [], "source": [ - "world_R_body = geo.Rot3.symbolic(\"R\") # Rotation defining orientation of body frame wrt world frame\n", - "body_t_point = geo.Vector3.symbolic(\"p\") # Point written in body frame\n", - "world_t_point = world_R_body * body_t_point # Point written in world frame\n", + "# Rotation defining orientation of body frame wrt world frame\n", + "world_R_body = geo.Rot3.symbolic(\"R\")\n", + "\n", + "# Point written in body frame\n", + "body_t_point = geo.Vector3.symbolic(\"p\")\n", + "\n", + "# Point written in world frame\n", + "world_t_point = world_R_body * body_t_point\n", + "\n", "display(world_t_point)" ] }, @@ -237,8 +247,10 @@ "world_T_cam = world_T_body * body_T_cam\n", "\n", "# Compose pose with a point\n", - "body_t_point = geo.Vector3.symbolic(\"p\") # Position relative to body frame written in body frame\n", - "display(world_T_body * body_t_point) # Equivalent to: world_R_body * body_t_point + world_t_body" + "body_t_point = geo.Vector3.symbolic(\"p\") # Position in body frame\n", + "# Equivalent to: world_R_body * body_t_point + world_t_body\n", + "world_t_point = world_T_body * body_t_point\n", + "display(world_t_point)" ] }, { @@ -257,14 +269,14 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Vectors and matricies" + "## Vectors and Matrices" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Vectors and matrices are all represented using subclasses of geo.Matrix class, and can be constrcuted in several different ways as shown below." + "Vectors and matrices are all represented using subclasses of geo.Matrix class, and can be constructed in several different ways as shown below." ] }, { @@ -281,7 +293,8 @@ "# Construction using specified size + data\n", "m2 = geo.Matrix(2, 3, [1, 2, 3, 4, 5, 6])\n", "\n", - "# geo.MatrixNM creates a matrix with shape NxM (defined by default 6x6 matrices and smaller)\n", + "# geo.MatrixNM creates a matrix with shape NxM (defined by default for 6x6\n", + "# matrices and smaller)\n", "m3 = geo.Matrix23(1, 2, 3, 4, 5, 6)\n", "m4 = geo.Matrix23([1, 2, 3, 4, 5, 6])\n", "\n", @@ -354,9 +367,12 @@ "metadata": {}, "outputs": [], "source": [ - "zero_matrix = geo.Matrix33.zero() # We could also write ops.GroupOps.identity(geo.Matrix33)\n", + "zero_matrix = geo.Matrix33.zero()\n", "identity_matrix = geo.Matrix33.eye()\n", "\n", + "# We could also write:\n", + "zero_matrix = ops.GroupOps.identity(geo.Matrix33)\n", + "\n", "display(zero_matrix)\n", "display(identity_matrix)" ] @@ -429,7 +445,8 @@ "outputs": [], "source": [ "jacobian = residual.jacobian(R1)\n", - "# The jacobian is quite a complex symbolic expression, so we don't display it for convenience\n", + "# The jacobian is quite a complex symbolic expression, so we don't display it for\n", + "# convenience.\n", "# The shape is equal to (dimension of residual) x (dimension of tangent space)\n", "display(jacobian.shape)" ] @@ -495,7 +512,7 @@ "\n", "display(rot_sym)\n", "display(rot_num)\n", - "display(rot_num.simplify()) # Simplify interal symbolic expressions\n", + "display(rot_num.simplify()) # Simplify internal symbolic expressions\n", "display(rot_num.evalf()) # Numerical evaluation" ] }, @@ -621,7 +638,8 @@ "# Perturb R1 by the given vector in the tangent space around R1\n", "R2 = R1.retract([0.1, 2.3, -0.5])\n", "\n", - "# Compute the tangent vector pointing from R1 to R2, in the tangent space around R1\n", + "# Compute the tangent vector pointing from R1 to R2, in the tangent space\n", + "# around R1\n", "recovered_tangent_vec = R1.local_coordinates(R2)\n", "\n", "display(recovered_tangent_vec)" @@ -635,9 +653,10 @@ "source": [ "# Jacobian of storage w.r.t tangent space perturbation\n", "\n", - "# We chain storage_D_tangent together with jacobians of larger symbolic expressions taken\n", - "# with respect to the symbolic elements of the object (e.g. a quaternion for rotations) to compute\n", - "# the jacobian wrt the tanget space about the element.\n", + "# We chain storage_D_tangent together with jacobians of larger symbolic\n", + "# expressions taken with respect to the symbolic elements of the object (e.g. a\n", + "# quaternion for rotations) to compute the jacobian wrt the tanget space about\n", + "# the element.\n", "# I.e. residual_D_tangent = residual_D_storage * storage_D_tangent\n", "\n", "jacobian = R1.storage_D_tangent()\n", @@ -648,7 +667,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "For more details on Storage/Group/LieGroup operations, see the [Concept tutorial](../notebooks/ops_tutorial.html)." + "For more details on Storage/Group/LieGroup operations, see the [Ops tutorial](../notebooks/ops_tutorial.html)." ] }, { diff --git a/notebooks/ops_tutorial.ipynb b/notebooks/ops_tutorial.ipynb index 29ee36900..fcb36d1fc 100644 --- a/notebooks/ops_tutorial.ipynb +++ b/notebooks/ops_tutorial.ipynb @@ -4,14 +4,14 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Concepts Tutorial" + "# Ops Tutorial" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "SymForce uses [concepts](https://en.wikipedia.org/wiki/Concept_(generic_programming%29)) as an underlying mechanism. A concept is a specification of supported operations, including syntax and semantics, but does not require a subtype relationship. This means that a set of heterogenous types can be operated on in a homogenous way, i.e. types that are external and don't share a base class, like Python floats treated as scalars.\n", + "SymForce uses [concepts](https://en.wikipedia.org/wiki/Concept_(generic_programming)) as an underlying mechanism. A concept is a specification of supported operations, including syntax and semantics, but does not require a subtype relationship. This means that a set of heterogenous types can be operated on in a homogenous way, i.e. types that are external and don't share a base class, like Python floats treated as scalars.\n", "\n", "There are three core concepts, each of which is a superset of the previous. The core routines use these ops interfaces rather than calling methods on types directly. The [ops package](../api/symforce.ops.html) docs provide much more detail and each op is tested on each type, but examples are given here.\n", "\n", @@ -71,7 +71,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Because we are using concepts, we can operate on types that aren't subtypes of symforce\n", + "# Because we are using concepts, we can operate on types that aren't subtypes\n", + "# of symforce\n", "display(StorageOps.storage_dim(float))" ] }, @@ -288,7 +289,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Retract perturbs the given element in the tangent space and returns the updated element\n", + "# Retract perturbs the given element in the tangent space and returns the\n", + "# updated element\n", "rot2_perturbed = LieGroupOps.retract(rot2, [sm.Symbol(\"delta\")])\n", "display(rot2_perturbed.to_rotation_matrix())" ] @@ -299,7 +301,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Local coordinates compute the tangent space perturbation between one element and another\n", + "# Local coordinates compute the tangent space perturbation between one element\n", + "# and another\n", "display(StorageOps.simplify(LieGroupOps.local_coordinates(rot2, rot2_perturbed)))" ] }, @@ -309,11 +312,12 @@ "metadata": {}, "outputs": [], "source": [ - "# storage_D_tangent computes the jacobian of the storage space of an object with respect to\n", - "# the tangent space around the element.\n", + "# storage_D_tangent computes the jacobian of the storage space of an object with\n", + "# respect to the tangent space around the element.\n", "\n", - "# A 2D rotation is represented by a complex number, so storage_D_tangent represents how\n", - "# that complex number will change given an infinitesimal perturbation in the tangent space\n", + "# A 2D rotation is represented by a complex number, so storage_D_tangent\n", + "# represents how that complex number will change given an infinitesimal\n", + "# perturbation in the tangent space\n", "display(LieGroupOps.storage_D_tangent(rot2))" ] }, diff --git a/notebooks/optimization_tutorial.ipynb b/notebooks/optimization_tutorial.ipynb index 12ae88605..41b1c3c1b 100644 --- a/notebooks/optimization_tutorial.ipynb +++ b/notebooks/optimization_tutorial.ipynb @@ -4,156 +4,17 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Optimization Tutorial" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Disclaimer\n", - "Currently symforce supports generating arbitrary functions from symbolic expressions, and has a C++ optimizer for these functions, but no optimizer that's usable from a notebook. So the below example is **not** how you should do optimization with SymForce; instead, take a look at `symforce/examples/bundle_adjustment`, until Aaron updates this tutorial with Cling or a Python optimizer.\n", - "\n", - "-------------------------------\n", - "\n", - "Anyway, the idea in this tutorial is that users can generate expressions needed to do optimization (e.g. jacobians, hessions, etc.), and then use the resulting functions with existing optimization software. In this example we generate a function that computes an update to an optimization variable using gradient descent." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Here we demonstrate a simple gradient descent example to show how the basic idea works. In this problem we will use on-manifold gradient descent to minimize the error between two rotations R0 and R1. We assume R0 is constant, and that an initial guess for R1 is given." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "import numpy as np\n", - "\n", - "from symforce import codegen\n", - "from symforce import geo\n", - "from symforce import sympy as sm\n", - "from symforce.values import Values\n", - "\n", - "from symforce.notebook_util import display_code_file" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Create symbolic rotations (values will be filled at runtime)\n", - "R0 = geo.Rot3.symbolic(\"R0\")\n", - "R1 = geo.Rot3.symbolic(\"R1\")\n", - "epsilon = sm.Symbol(\"epsilon\") # Small number to prevent numerical errors\n", - "alpha = sm.Symbol(\"alpha\") # Gradient descent step size" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Compute the error between the two rotations in the tangent space of R1\n", - "error = geo.Matrix(R1.local_coordinates(R0, epsilon)) # Vector in tangent space\n", + "# Optimization Tutorial\n", "\n", - "# To match the traditional gradient descent formulation, we use a scalar error term + gradient\n", - "scalar_error = geo.Matrix([error.squared_norm()])\n", - "gradient = scalar_error.jacobian(R1) # Compute the gradient wrt the tangent space of R1\n", - "\n", - "# Here we compute the update to R1 by performing a gradient descent step in the tangent space of R1\n", - "current_state = geo.Matrix(R1.local_coordinates(R0, epsilon))\n", - "updated_state = current_state - alpha * gradient.T\n", - "updated_R1 = R1.retract(updated_state, epsilon)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Generate the update function\n", + "The best current example for how to use SymForce for optimization from Python is the tutorial example in `README.md`.\n", "\n", - "# Set up inputs and outputs\n", - "inputs = Values()\n", - "inputs[\"R0\"] = R0\n", - "inputs[\"R1\"] = R1\n", - "inputs[\"epsilon\"] = epsilon\n", - "inputs[\"alpha\"] = alpha\n", - "\n", - "outputs = Values()\n", - "outputs[\"R1_out\"] = updated_R1\n", - "\n", - "namespace = \"update_function\"\n", - "# Create the output function\n", - "update_function = codegen.Codegen(\n", - " name=\"update_function\",\n", - " inputs=inputs,\n", - " outputs=outputs,\n", - " config=codegen.PythonConfig(),\n", - " return_key=\"R1_out\",\n", - ")\n", - "# Output the code\n", - "update_function_data = update_function.generate_function(namespace=namespace)\n", - "display_code_file(update_function_data.function_dir / \"update_function.py\", \"python\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Next we set up the problem and solve" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from sym import Rot3\n", - "\n", - "# Import the generated function\n", - "gen_module = codegen.codegen_util.load_generated_package(\n", - " namespace, update_function_data.function_dir\n", - ")\n", - "\n", - "R0 = Rot3.from_tangent([-1.7, 0.5, 0.3])\n", - "R1 = Rot3.from_tangent([1.5, 0.2, -0.4])\n", - "epsilon = 1e-9\n", - "alpha = 0.1\n", - "\n", - "print(f\"Desired rotation: {R0}\")\n", - "print(f\"Initial rotation: {R1}\")\n", - "print(f\"Initial error: {np.linalg.norm(R1.local_coordinates(R0))}\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Run 10 steps of gradient descent\n", - "for i in range(10):\n", - " R1 = gen_module.update_function(R0, R1, epsilon, alpha)\n", - " print(f\"New error: {np.linalg.norm(R1.local_coordinates(R0))}\")\n", - "print(f\"Optimized rotation: {R1}\")" + "For examples of how to run optimization from C++, see `symforce/examples/bundle_adjustment_in_the_large` and `symforce/examples/bundle_adjustment`." ] } ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -167,7 +28,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.9" + "version": "3.8.11" } }, "nbformat": 4, diff --git a/setup.py b/setup.py index af933498c..616e69fc8 100644 --- a/setup.py +++ b/setup.py @@ -189,15 +189,32 @@ def symforce_version() -> str: "myst-parser", "nbsphinx", "nbstripout", + "pandas", + "plotly", "Sphinx", # sphinx-autodoc-typehints >=1.15 contains a bug causing it to crash parsing our typing.py "sphinx-autodoc-typehints<1.15", - "sphinx-rtd-theme", "breathe", ] cmdclass: T.Dict[str, T.Any] = dict(build_ext=CMakeBuild, install=InstallWithExtras) + +def symforce_data_files() -> T.List[str]: + # package_data doesn't support recursive globs until this merges: + # https://github.com/pypa/setuptools/pull/3309 + # So, we do the globbing ourselves + SYMFORCE_PKG_DIR = SOURCE_DIR / "symforce" + files_with_pattern = lambda pattern: [ + str(p.relative_to(SYMFORCE_PKG_DIR)) for p in SYMFORCE_PKG_DIR.rglob(pattern) + ] + return ( + files_with_pattern("*.jinja") + + files_with_pattern("*.mtx") + + ["test_util/random_expressions/README"] + ) + + setup( name="symforce", version=symforce_version(), @@ -224,10 +241,15 @@ def symforce_version() -> str: "Topic :: Scientific/Engineering :: Mathematics", "Topic :: Software Development :: Code Generators", "Topic :: Software Development :: Embedded Systems", + "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Education :: Computer Aided Instruction (CAI)", - "Programming Language :: Python :: 3", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: C++", - "License :: OSI Approved :: Apache-2.0 License", + "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", ], # ------------------------------------------------------------------------- @@ -237,6 +259,9 @@ def symforce_version() -> str: python_requires=">=3.8", # Find all packages in the directory packages=find_packages(), + package_data={ + "symforce": symforce_data_files(), + }, # Override the extension builder with our cmake class cmdclass=cmdclass, # Build C++ extension module @@ -248,9 +273,10 @@ def symforce_version() -> str: "graphviz", "jinja2", "numpy", + "scipy", f"skymarshal @ file://localhost/{SOURCE_DIR}/third_party/skymarshal", "sympy~=1.10.0", - f"sym @ file://localhost/{SOURCE_DIR}/gen/python", + f"symforce-sym @ file://localhost/{SOURCE_DIR}/gen/python", ], setup_requires=setup_requirements, extras_require={ @@ -266,7 +292,6 @@ def symforce_version() -> str: "pip-tools", "pybind11-stubgen", "pylint", - "scipy", "types-jinja2", "types-pygments", "types-requests", diff --git a/symforce/_version.py b/symforce/_version.py index 09b66d2eb..a6249560b 100644 --- a/symforce/_version.py +++ b/symforce/_version.py @@ -3,4 +3,4 @@ # This source code is under the Apache 2.0 license found in the LICENSE file. # ---------------------------------------------------------------------------- -version = "0.3.0" +version = "0.4.0" diff --git a/symforce/codegen/python_templates/setup.py.jinja b/symforce/codegen/python_templates/setup.py.jinja index 19c6b3f22..1bc32f1da 100644 --- a/symforce/codegen/python_templates/setup.py.jinja +++ b/symforce/codegen/python_templates/setup.py.jinja @@ -8,10 +8,18 @@ setup( name='{{ package_name }}', version='{{ version }}', description='{{ description }} (installed by SymForce)', + license_file="LICENSE", long_description='{{ description }} (installed by SymForce)', author='Skydio, Inc', author_email='hayk@skydio.com', + install_requires=["numpy"], license='Apache 2.0', packages=find_packages(), + python_requires=">=2.7", + project_urls={ + "Bug Tracker": "https://github.com/symforce-org/symforce/issues", + "Source": "https://github.com/symforce-org/symforce/tree/main/gen/python", + }, + url="https://github.com/symforce-org/symforce", zip_safe=False, ) diff --git a/test/symforce_gen_codegen_test.py b/test/symforce_gen_codegen_test.py index 476a47bc5..06f21822b 100644 --- a/test/symforce_gen_codegen_test.py +++ b/test/symforce_gen_codegen_test.py @@ -92,7 +92,7 @@ def test_gen_package_codegen_python(self) -> None: template_path=os.path.join(template_util.PYTHON_TEMPLATE_DIR, "setup.py.jinja"), output_path=os.path.join(output_dir, "setup.py"), data=dict( - package_name="sym", + package_name="symforce-sym", version=symforce.__version__, description="generated numerical python package", long_description="generated numerical python package", diff --git a/test/symforce_requirements_test.py b/test/symforce_requirements_test.py index c3d1ff1b7..74a7398f3 100644 --- a/test/symforce_requirements_test.py +++ b/test/symforce_requirements_test.py @@ -10,7 +10,7 @@ from symforce.test_util import TestCase from symforce import python_util -SYMFORCE_DIR = Path(__file__).parent.parent +SYMFORCE_DIR = Path(__file__).resolve().parent.parent class SymforceRequirementsTest(TestCase): @@ -21,12 +21,12 @@ class SymforceRequirementsTest(TestCase): def test_requirements(self) -> None: output_dir = Path(self.make_output_dir("sf_requirements_test_")) - output_requirements_file = output_dir / "requirements.txt" - symforce_requirements_file = SYMFORCE_DIR / "requirements.txt" + output_requirements_file = output_dir / "dev_requirements.txt" + symforce_requirements_file = SYMFORCE_DIR / "dev_requirements.txt" local_requirements_map = { f"skymarshal @ file://localhost/{SYMFORCE_DIR}/third_party/skymarshal": "file:./third_party/skymarshal", - f"sym @ file://localhost/{SYMFORCE_DIR}/gen/python": "file:./gen/python", + f"symforce-sym @ file://localhost/{SYMFORCE_DIR}/gen/python": "file:./gen/python", } # Copy the symforce requirements file into the temp directory @@ -58,7 +58,7 @@ def test_requirements(self) -> None: cwd=SYMFORCE_DIR, env=dict( os.environ, - # Compile command to put in the header of requirements.txt + # Compile command to put in the header of dev_requirements.txt CUSTOM_COMPILE_COMMAND="python test/symforce_requirements_test.py --update", ), ) diff --git a/third_party/skymarshal/README.md b/third_party/skymarshal/README.md new file mode 100644 index 000000000..e61250688 --- /dev/null +++ b/third_party/skymarshal/README.md @@ -0,0 +1,10 @@ +SkyMarshal +========== + +Simple cross-language message definitions with marshalling/unmarshalling code generation. + +An easy-to-maintain pure-Python implementation of `lcm-gen` of the +[LCM Project](https://github.com/lcm-proj/lcm), with wire format compatibility. Skymarshal adds additional features, including more supported languages and additional features for existing +languages. + +Built by [Skydio](https://skydio.com), used by [SymForce](https://symforce.org). diff --git a/third_party/skymarshal/cmake/skymarshal.cmake b/third_party/skymarshal/cmake/skymarshal.cmake index 254bc25f0..812ce24c7 100644 --- a/third_party/skymarshal/cmake/skymarshal.cmake +++ b/third_party/skymarshal/cmake/skymarshal.cmake @@ -91,6 +91,7 @@ function(add_skymarshal_bindings target_name bindings_dir lcmtypes_dir) execute_process( COMMAND ${SKYMARSHAL_PYTHON} ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/parse_types.py ${lcmtypes_dir} OUTPUT_VARIABLE TYPES_TO_GENERATE + COMMAND_ERROR_IS_FATAL ANY ) string(REPLACE "\n" ";" TYPES_TO_GENERATE_LIST ${TYPES_TO_GENERATE}) diff --git a/third_party/skymarshal/setup.cfg b/third_party/skymarshal/setup.cfg index e40356c91..eb9881db8 100644 --- a/third_party/skymarshal/setup.cfg +++ b/third_party/skymarshal/setup.cfg @@ -1,12 +1,27 @@ [metadata] name = skymarshal -version = 0.2 -description = "Python implementation of marshalling for LCM messages" +version = 0.4 +description = Python implementation of marshalling for LCM messages +long_description = file: README.md author = "Skydio, Inc" -license = "LGPL-2.1" +classifiers = + Intended Audience :: Developers + Development Status :: 5 - Beta + License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv2+) + Programming Language :: Python :: 4 + Topic :: Software Development :: Code Generators + Topic :: Software Development :: Libraries :: Python Modules + Operating System :: OS Independent +license = LGPL-2.1-or-later +license_file = LICENSE +url = https://github.com/symforce-org/symforce/tree/main/third_party/skymarshal +project_urls = + Bug Tracker = https://github.com/symforce-org/symforce/issues + Source = https://github.com/symforce-org/symforce/tree/main/third_party/skymarshal [options] packages = find: +python_requires = >= 3.8 include_package_data = True install_requires = argh