Development Guide¶
This guide summarizes the repository layout, formatting and testing workflow, and the main conventions contributors should follow.
Repository Layout¶
include/affine_mpc/- public C++ API headerssrc/- library implementationpython/affine_mpc/- Python package sourcespython/bindings/- pybind11 binding implementationtest/affine_mpc/- unit teststest/bindings/- Python binding teststest/python_npz/- Python/NumPy tests for NPZ fixtures and archive compatibilityexamples/- example simulation and plotting scriptsdocs/- user-facing documentation and Zensical sourcecmake/- dependency and package configuration helpers
Prerequisites¶
Some build, test, stub-generation, and docs workflows rely on Python tooling. It is recommended to use a virtual environment for Python dependencies, but you can also install them globally if you prefer.
python -m venv .venv
source .venv/bin/activate
.venv\Scripts\activate.bat # for cmd.exe
.venv\Scripts\Activate.ps1 # for PowerShell
Note
The rest of this guide assumes you have activated a Python virtual environment.
With a venv activated, pip should install packages to the venv rather than globally.
In some cases, you may need to use python -m pip instead of pip to ensure the correct Python environment is used.
Install the normal build-time Python dependencies with:
pip install -r requirements/build.txt
Optional additions:
- for docs builds:
pip install -r requirements/docs.txt - for Python package and stub development:
pip install ".[dev]"
Build Commands¶
Configure with tests, examples, and bindings (all default to OFF):
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DAFFINE_MPC_BUILD_TESTS=ON \
-DAFFINE_MPC_BUILD_EXAMPLES=ON \
-DAFFINE_MPC_BUILD_BINDINGS=ON
Tip
Ensure the Python venv where you installed the build dependencies is activated when you configure CMake.
Build:
cmake --build build --parallel
For a debug build, set -DCMAKE_BUILD_TYPE=Debug during configuration.
If you use a multi-config generator such as Visual Studio, select the configuration at build or test time with --config Release or --config Debug.
Test Commands¶
If you configured with -DAFFINE_MPC_BUILD_TESTS=ON, run all registered tests with:
ctest --test-dir build --output-on-failure
This covers:
- discovered C++ GoogleTest cases
python_npz_testspython_bindings_testsif bindings were also enabled
Run the Python test groups directly when you want faster iteration on those suites:
pytest test/bindings
pytest test/python_npz
Notes:
test/bindings/usestest/bindings/conftest.pyto locate a repo-local package underinstall/,build/python/, orout/Debug/python/test/bindings/requires that the bindings have already been built or installedtest/python_npz/usestest/python_npz/conftest.pyto locatenp_writer_fixtureunderbuild/orout/Debug/test/python_npz/does not import theaffine_mpcPython package, but it does require that tests have been built so the fixture executable exists
Run one discovered CTest by regex:
ctest --test-dir build -R "MPCLoggerTest\\." --output-on-failure
Run one test case:
./build/mpc_logger_tests --gtest_filter=MPCLoggerTest.ConvenienceLogStepWorks
CMake Configuration¶
This project defines the following CMake options (specified with -D):
| Option | Description |
|---|---|
CMAKE_BUILD_TYPE |
Select Release (default) or Debug for single-config generators |
AFFINE_MPC_BUILD_EXAMPLES |
Build example executables |
AFFINE_MPC_BUILD_TESTS |
Build unit tests and register test entry points |
AFFINE_MPC_BUILD_BINDINGS |
Build the Python bindings |
AFFINE_MPC_PY_ADD_TESTS |
Register Python binding tests in the Python subproject |
AFFINE_MPC_WITH_DAQP |
Build the DAQP solver for the condensed formulation |
AFFINE_MPC_WARNINGS_AS_ERRORS |
Treat compiler warnings as errors (enabled in CI) |
AFFINE_MPC_PY_ADD_TESTS is mainly set internally by the top-level build when both bindings and tests are enabled.
GCC and Clang builds always enable -Wall -Wextra for the targets in this repository, but not for dependencies.
CI also sets AFFINE_MPC_WARNINGS_AS_ERRORS=ON, so new warnings fail the build.
It is off by default so that other compilers or newer compiler versions do not break user and packaging builds.
Dependencies¶
Eigen is the only public dependency: the public headers use it, and the package config looks it up. OSQP and DAQP are private dependencies, which no installed header includes.
affine_mpc::OSQPSettings(include/affine_mpc/osqp_settings.hpp) mirrors OSQP's settings struct, andtoOSQP()insrc/osqp_solver.cppconverts it.- Everything that uses OSQP's types is in
OSQPSolver::Impl(src/osqp_solver_impl.hpp), which tests may include; the tests that do link OSQP's own target (seeCMakeLists.txt). - A fetched OSQP is built static, position-independent, and with hidden symbols: a shared
affine_mpcabsorbs it, and a static one installs it asaffine_mpc_osqp(without OSQP's headers). - A system OSQP (only looked up for shared builds) is linked privately.
test/affine_mpc/public_headers_tests.cppfails to compile if a public header includes an OSQP header, and theinstall-consumerCI job builds a project against an installedaffine_mpcwithout OSQP.
Updating OSQP¶
- Change the version in
cmake/extern_osqp.cmakeand update its row inlicenses/README.md. - Compare OSQP's
OSQPSettingsstruct (osqp_api_types.h) withaffine_mpc::OSQPSettings: add or remove fields in the struct,toOSQP(),operator<<, the Python binding (python/bindings/py_osqp_settings.cpp), andexpectSameOSQPSettings()intest/affine_mpc/osqp_solver_tests.cpp. - Run
OSQPSettingsTester.givenDefaultSettings_MatchOSQPDefaults, which compares the defaults withosqp_set_default_settings()field by field, and update the default member initializers if OSQP's defaults changed. - Run the full test suite, including a shared build with the system OSQP if one is installed.
Stub Generation¶
If you change the Python binding surface, regenerate python/affine_mpc/_bindings.pyi and commit the updated file.
From a bindings-enabled build tree:
cmake --build build --target python_stubgen
You can also run the helper script directly:
python python/custom_stubgen.py --pkg-dir build/python
Without --pkg-dir, the helper imports the installed affine_mpc package, as the check-stubs CI job does after pip install ".[dev]".
--pkg-dir=install generates stubs from an installed repo-local package.
Generate stubs in an environment with the dev extra: it pins pybind11-stubgen 3 (older versions reorder the whole stub) and includes SciPy, without which the stub loses the scipy.sparse types of QuadraticProgram.
Formatting¶
The project uses .clang-format with an LLVM-based configuration.
Format edited files with:
clang-format -i src/mpc.cpp
Use // clang-format off only when it clearly improves readability for matrix literals or similarly structured data.
For prose-heavy Markdown documentation, prefer one sentence per line. This makes diffs and review comments more localized without affecting rendered output.
Coding Conventions¶
- C++17 for the library and bindings; tests and examples build as C++20 (they use designated initializers)
- 2-space indentation in the existing codebase
PascalCasefor classes and structscamelCasefor methods and functionssnake_casefor variables and parameterssnake_case_for member variablesEigen::Reffor Eigen inputs and writable outputs where appropriateEigen::Mapfor zero-copy views into external memory
Error Handling Philosophy¶
- invalid dimensions or bad configuration arguments: throw exceptions
- runtime solve results: return
SolveStatus - many precondition checks use
assert() - avoid introducing unnecessary dynamic allocation in hot paths
Testing Conventions¶
- add or update tests for solver logic, indexing, references, constraints, and API behavior
- use
test/affine_mpc/utils.hpphelpers such asexpectEigenNear(...) - prefer the public QP getters (
getQP(),assembleQP(),getQPVariableBlocks(),getQPConstraintBlocks()) in tests, and derive fromMPConly for internal state they do not expose - NPZ output compatibility is validated from Python with
numpy.load(...)andzipfile, not from a C++ archive reader
Documentation Expectations¶
Build the rendered docs locally with:
pip install -r requirements/docs.txt
zensical build
Or locally serve the docs with:
zensical serve
When changing user-facing behavior:
- update the public header comments if API semantics changed
- update Python docstrings or stubs if the binding surface changed
- update
README.mdif the quick-start path changed - update the appropriate file under
docs/
Contribution Workflow¶
Before opening a PR:
- Format edited C++ files with clang-format and Python files with black.
- For code changes, build with tests, examples, and bindings enabled to make sure the full configuration still works.
- For docs-only changes, building the code is optional.
- Run the relevant tests, and run the full test suite when changes affect shared infrastructure, bindings, packaging, or public APIs.
- Update docs for public behavior changes.
- Ensure consistency in docs/behavior between C++ and Python (including stubs!).
- Keep changes focused and easy to review.
See CONTRIBUTING.md for the broader contribution process.
Making a Release¶
Changes go into main, and a release is a tag on main: a release PR bumps the versions, and publishing the GitHub release uploads the wheels and sdist to PyPI.
Every PR into main that changes the code or packaging runs the full wheel build, so packaging problems show up before anything is published.
The docs site deploys from main whenever docs/ changes, so it can describe features shortly before they are released.
Choosing the Version¶
Versions follow semantic versioning with the usual convention before 1.0:
0.MINOR.0for breaking changes: anything that makes users change their code (C++ or Python)0.MINOR.PATCHfor fixes and backward-compatible features
A change that only breaks the C++ ABI (e.g. a new field in SolveInfo or a new virtual function in QPSolver) can go in a patch release, as long as the release notes say that C++ code must be rebuilt.
The Python wheels contain the library, so ABI changes do not affect Python users.
Steps¶
- Make sure every change for the release is on
mainand CI passes there. - On a branch from
main, update the version inCMakeLists.txt(project(... VERSION X.Y.Z)), inCITATION.cff(versionanddate-released), and in the FetchContentGIT_TAGindocs/getting-started/cpp.md. The Python package's version comes from the git tag (setuptools_scm), so nothing in Python changes. - If a bundled dependency's version changed (
cmake/extern_*.cmake), update its row inlicenses/README.mdand replace its license file if the text changed. - If the binding surface changed, make sure the stubs are regenerated (see Stub Generation).
- Open a PR into
maintitled exactlyRelease X.Y.Z, with the release notes as its body (see below). The title matters: CI checks it against the versions inCMakeLists.txt,CITATION.cff, and the docsGIT_TAG, and editing the title later does not rerun that check (push a commit to rerun it). - Wait for CI: the build and test jobs, the stub check, and the wheel build (
Build and publish to PyPI, which builds and tests every wheel and the sdist on PRs without uploading them). - Merge the PR.
- Create a GitHub release with the tag
vX.Y.Zonmain, titledRelease X.Y.Z, with the same notes, and publish it. Publishing creates the tag and runs the wheel workflow again, which checks the tag against the versions and uploads to PyPI. - Check that the new version is on PyPI and installs (
pip install affine_mpc==X.Y.Z).
Patch Releases With Unreleased Work on main¶
If main already has changes that should wait for a later release, make the patch release from the previous release's tag instead:
- Create a branch from the tag (
git switch -c release/X.Y vX.Y.Z), commit the fix and the version bump on it, and push it. - Run the wheel build on that branch by hand (Actions,
Build and publish to PyPI, Run workflow), since it runs automatically only on PRs intomain. - Create the GitHub release with the new tag and that branch as its target, and publish it.
- Bring the fix into
mainwith a PR (without the version bump ifmainis already ahead).
Release Notes¶
Start with one paragraph that summarizes the release, then use the sections that apply, in this order:
- Breaking changes, with a migration for each
- Fixes and changes that can change results
- New features
- Performance, with measured speedups
- Packaging, Logging and printing, and Documentation
Say in a note or warning at the top whether the API is source compatible and whether the C++ library is ABI compatible with the previous release.