From b15ccfc4a0ce9ac0cffc4d87eadd422f37a1a6d1 Mon Sep 17 00:00:00 2001 From: kopecn Date: Mon, 29 Jun 2026 22:52:33 -0700 Subject: [PATCH 01/70] updating template module --- .bumpversion.cfg | 10 + .claude/settings.local.json | 9 - .editorconfig | 33 ++ .env | 23 +- .github/CODEOWNERS | 3 + .github/workflows/ci.yml | 43 +++ .github/workflows/publish.yml | 45 +++ .github/workflows/tag-on-prod.yml | 47 +++ .gitignore | 217 ++++++------ .pylintrc | 34 -- .python-version | 1 + CODE_OF_CONDUCT.md | 3 +- CONTRIBUTING.md | 71 ++-- HISTORY.md | 18 +- LICENSE | 2 +- Makefile | 543 ++++++++++++++++++++++++------ pyproject.toml | 69 +++- requirements.txt | 22 ++ 18 files changed, 902 insertions(+), 291 deletions(-) create mode 100644 .bumpversion.cfg delete mode 100644 .claude/settings.local.json create mode 100644 .editorconfig create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/tag-on-prod.yml delete mode 100644 .pylintrc create mode 100644 .python-version create mode 100644 requirements.txt diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 0000000..db08425 --- /dev/null +++ b/.bumpversion.cfg @@ -0,0 +1,10 @@ +[bumpversion] +current_version = 0.0.1 +commit = true +tag = false +tag_name = v{new_version} +message = Bump version: {current_version} → {new_version} + +[bumpversion:file:pyproject.toml] +search = version = "{current_version}" +replace = version = "{new_version}" \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index f315077..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(python:*)" - ], - "deny": [], - "ask": [] - } -} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4ff9be7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,33 @@ +# https://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{html,css,js,json,sh,yml,yaml}] +indent_size = 2 + +[LICENSE] +insert_final_newline = false + +[Makefile] +indent_style = tab +indent_size = unset + +# Ignore binary or generated files +[*.{png,jpg,gif,ico,woff,woff2,ttf,eot,svg,pdf}] +charset = unset +end_of_line = unset +indent_style = unset +indent_size = unset +trim_trailing_whitespace = unset +insert_final_newline = unset +max_line_length = unset + +[*.{diff,patch}] +trim_trailing_whitespace = false diff --git a/.env b/.env index 5567480..5bd627c 100644 --- a/.env +++ b/.env @@ -1,2 +1,23 @@ +# This file is parsed by GNU Make (`include .env` in the Makefile), not a shell. +# Values are Make variables expanded UNQUOTED into recipes — keep that in mind below. + +# Python interpreter for pip-based targets (single token — used as `$(PYTHON) -m pip`). PYTHON=python3 -VENV=/tmp/pipTest + +# Default version for single-version uv operations. MUST appear in PYTHONS below. +DEFAULT_PYTHON=3.13 + +# Versions to test against. SPACE-SEPARATED, UNQUOTED on purpose: +# Make expands `$(PYTHONS)` straight into `uv python install ...` as separate args. +# Do NOT quote or comma-separate — that collapses it into one bogus argument. +# Canonical set: default 3.13, support floor 3.10 (matches requires-python>=3.10). +PYTHONS=3.10 3.11 3.12 3.13 + +# Throwaway clean-room venv for `make testInEnv` (deleted on teardown). +# Kept DISTINCT from uv's `.venv` so the clean-room never nukes your dev env. +VENV=.cleanroom-venv + +# Quality-target source dir. A generated project uses a src/ layout (no hooks/), +# so override the Makefile's root default (PY_SRC=hooks) → src here. This is what +# `make uv-lint` / `uv-typecheck` scan; the Makefile's PY_SRC ?= is the fallback. +PY_SRC=src diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..fbada3a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Code owners — automatically requested for review on matching paths. +# https://docs.github.com/en/repositories/managing-your-repos-settings/about-code-owners +* @kopecn diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4e5f53c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: [dev, prod] + +jobs: + lint-typecheck: + name: Lint & Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + + - name: Install default Python + run: uv python install 3.13 + + - name: Sync dev dependencies + run: make uv-sync + + - name: Lint + run: make uv-lint + + - name: Typecheck + run: make uv-typecheck + + test-python: + name: Python Tests (3.10 / 3.11 / 3.12 / 3.13) + runs-on: ubuntu-latest + env: + PYTHONS: "3.10 3.11 3.12 3.13" + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + + - name: Install Python versions + run: uv python install 3.10 3.11 3.12 3.13 + + - name: Run tests across all Python versions + run: make uv-test-all PYTHONS="3.10 3.11 3.12 3.13" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ffdf03e --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,45 @@ +# Publish-on-tag workflow — SCAFFOLD ONLY. +# +# `tag-on-prod.yml` creates a `v` tag when pyproject.toml's version changes +# on `prod`. This workflow is the intended consumer: it builds and uploads that tagged +# artifact to PyPI. It is deliberately left UNIMPLEMENTED for you to finish. +# +# To enable publishing: +# 1. Choose an auth method and uncomment the matching block below: +# (a) Trusted Publishing (recommended) — configure a PyPI "pending publisher" +# for this repo/workflow; needs `permissions: id-token: write`, no secret. +# (b) API token — add a `PYPI_API_TOKEN` repository secret. +# 2. Remove the `exit 1` guard in the `not-configured` step. +# +# Until then this workflow no-ops loudly so a tag never silently fails to publish. + +name: Publish to PyPI + +on: + push: + tags: + - "v*" + +jobs: + publish: + name: Build & publish + runs-on: ubuntu-latest + # permissions: + # id-token: write # (a) Trusted Publishing + steps: + - name: Not configured + run: | + echo "::error::publish.yml is a scaffold — wire up PyPI auth before enabling." + echo "See the header comment in .github/workflows/publish.yml." + exit 1 + + # - uses: actions/checkout@v4 + # - uses: astral-sh/setup-uv@v5 + # - name: Build + # run: make build + # - name: Publish (Trusted Publishing) + # uses: pypa/gh-action-pypi-publish@release/v1 + # - name: Publish (API token) + # uses: pypa/gh-action-pypi-publish@release/v1 + # with: + # password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/tag-on-prod.yml b/.github/workflows/tag-on-prod.yml new file mode 100644 index 0000000..5a79cf9 --- /dev/null +++ b/.github/workflows/tag-on-prod.yml @@ -0,0 +1,47 @@ +name: Create Release Tag + +on: + push: + branches: + - prod + +permissions: + contents: write + +jobs: + tag: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect version change + id: changed + run: | + if git diff --quiet HEAD^ HEAD -- pyproject.toml; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Get version from pyproject.toml + if: steps.changed.outputs.changed == 'true' + id: version + run: | + VERSION=$(grep '^version =' pyproject.toml | cut -d'"' -f2) + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Create tag if missing + if: steps.changed.outputs.changed == 'true' + run: | + TAG="v${{ steps.version.outputs.version }}" + + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag already exists" + exit 0 + fi + + git tag "$TAG" + git push origin "$TAG" \ No newline at end of file diff --git a/.gitignore b/.gitignore index d609a52..a9f81c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,157 +1,148 @@ -# Byte-compiled / optimized / DLL files +# ============================== +# Python Build / Bytecode Artifacts +# ============================== + +# Compiled Python files (bytecode) __pycache__/ *.py[cod] *$py.class -# Cython and C extensions +# Compiled binary extensions *.c *.so *.pyd *.dll -# Distribution / packaging -.Python +# Directory-level compiled artifacts build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ +develop-eggs/ sdist/ +parts/ var/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST -# PyInstaller -# Usually contains a dist/ folder and build/ artifacts -*.manifest -*.spec -# PyBuilder -target/ +# ============================== +# Virtual Environments & Dependencies +# ============================== -# Virtual environments -.env/ +# General virtual environments (Modern and Classic) .venv/ -env/ +# Throwaway clean-room venv for `make testInEnv` (VENV in .env) +.cleanroom-venv/ venv/ -ENV/ -env.bak/ -venv.bak/ - -# pipenv -Pipfile.lock +env/ +.env/ -# poetry +# Specific package manager caches / lock files poetry.lock +Pipfile.lock +# uv.lock is intentionally NOT committed: this is a library, so we declare version +# ranges (requires-python) and stay on uv's `uv pip` interface, which never writes a +# lockfile (only `uv sync`/`uv lock` do). Ignored as belt-and-suspenders. +uv.lock +# NOTE: requirements.txt and requirements-release.txt are COMMITTED, not ignored. Per the +# dependency BKM pyproject.toml declares dependency NAMES ONLY; the requirements files carry +# the pins + git-based pointers and every install path leans on them. No `lock`/compile step. +# requirements-local.txt is the per-machine local editable sibling overlay (`-e ../sibling` +# lines) consumed by `make uv-sync-local`. Never committed. +requirements-local.txt +# Generic wheel/package info folders +*.egg-info/ -# hatch -*.hatch/ - -# pdm -__pypackages__/ - -# tox and nox +# PDK / Environment Specific Cache Folders .tox/ .nox/ - -# pytest -.cache/ -.pytest_cache/ - -# mypy .mypy_cache/ -.dmypy.json -dmypy.json -# coverage and test reports +# ============================== +# Tooling and Static Analysis Caches +# ============================== + +# Coverage / Testing Reports .coverage .coverage.* -nosetests.xml -coverage.xml -*.cover -.hypothesis/ - -# Pyre type checker -.pyre/ - -# CMake files -CMakeFiles/ -CMakeCache.txt -cmake_install.cmake +htmlcov/ +# Pytest cache directory +.pytest_cache/ -# Translations -*.mo -*.pot +# Mypy specific files +dmypy.json -# Django stuff -*.log -local_settings.py -db.sqlite3 +# Ruff (Linter) cache +.ruff_cache/ -# Flask stuff -instance/ -.webassets-cache +# Jupyter Notebook checkpoints +.ipynb_checkpoints/ -# Scrapy stuff -.scrapy -# IPython / Jupyter -.ipynb_checkpoints/ -profile +# ============================== +# IDEs, Editors, and OS System Files +# ============================== # macOS system files -*.DS_Store +.DS_Store .AppleDouble .LSOverride -# Thumbnails and metadata -._* - -# Files that might appear in root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt +# Standard VCS/Code Editor folders +# IntelliJ IDEA family +.idea/ +# Generic module file +*.iml +# VS Code workspace settings +.vscode/ -# Unit test / coverage reports -htmlcov/ +# Python boilerplate / Framework config +# Django specific local overrides +local_settings.py + + +# ============================== +# Packaging, Installation & Build Systems +# ============================== + +# General package distribution folders (PyInstaller/Build tools) +# PyInstaller spec files +*.spec +# Setuptools directory +.Python +# Man pages/manifests +MANIFEST + +# CMake build system files +CMakeFiles/ +CMakeCache.txt +cmake_install.cmake + + +# ============================== +# Documentation and Translations +# ============================== -# Sphinx documentation build +# Sphinx documentation builds docs/_build/ -# IDEs and editors -# IntelliJ IDEA family -.idea/ -*.iml -*.ipr -*.iws -.idea_modules/ +# Translation files (gettext) +*.mo +*.pot -# Visual Studio Code -.vscode/ -# Cookiecutter -output/ -python_boilerplate/ -cookiecutter-pypackage-env/ +# ============================== +# Local Runtime Data & Logs +# (DO NOT COMMIT THESE FILES!) +# ============================== + +# Databases and local state files +# SQLite database file +db.sqlite3 +# Flask application instance folder +instance/ +# General storage data folder (if applicable) +storage/* + +# Logs +# Generic log files +*.log +pip-log.txt diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index f32cab5..0000000 --- a/.pylintrc +++ /dev/null @@ -1,34 +0,0 @@ -# https://pylint.readthedocs.io/en/v2.12.2/user_guide/options.html - -[BASIC] -module-naming-style=any -const-naming-style=any -class-naming-style=any -function-naming-style=any -method-naming-style=any -attr-naming-style=any -argument-naming-style=any -variable-naming-style=any -class-attribute-naming-style=any -class-const-naming-style=any -inlinevar-naming-style=any - -# Custom regexes to allow both snake_case and camelCase -module-rgx=(?:(?P[a-z_][a-z0-9_]+)|(?P[a-z]+(?:[A-Z][a-z0-9]*)*))$ -const-rgx=(?:(?P[A-Z_][A-Z0-9_]*)|(?P[A-Z][a-zA-Z0-9]*))$ -class-rgx=(?:(?P[A-Z][a-zA-Z0-9]*)|(?P[a-z_][a-z0-9_]+))$ -function-rgx=(?:(?P[a-z_][a-z0-9_]*)|(?P[a-z]+(?:[A-Z][a-z0-9]*)*))$ -method-rgx=(?:(?P[a-z_][a-z0-9_]*)|(?P[a-z]+(?:[A-Z][a-z0-9]*)*))$ -attr-rgx=(?:(?P[a-z_][a-z0-9_]*)|(?P[a-z]+(?:[A-Z][a-z0-9]*)*))$ -argument-rgx=(?:(?P[a-z_][a-z0-9_]*)|(?P[a-z]+(?:[A-Z][a-z0-9]*)*))$ -variable-rgx=(?:(?P[a-z_][a-z0-9_]*)|(?P[a-z]+(?:[A-Z][a-z0-9]*)*))$ -class-attribute-rgx=(?:(?P[a-z_][a-z0-9_]*)|(?P[a-z]+(?:[A-Z][a-z0-9]*)*))$ -class-const-rgx=(?:(?P[A-Z_][A-Z0-9_]*)|(?P[A-Z][a-zA-Z0-9]*))$ -inlinevar-rgx=(?:(?P[a-z_][a-z0-9_]*)|(?P[a-z]+(?:[A-Z][a-z0-9]*)*))$ - -[design] -max-locals = 24 - -[MESSAGES CONTROL] -disable=W0621 -disable=W0102 diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 4455894..6ff40ea 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,4 +1,3 @@ - # Contributor Covenant Code of Conduct ## Our Pledge @@ -61,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -[INSERT CONTACT METHOD]. +nicholas.bergantz@gmail.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7182138..27082ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to py-mathTools +# Contributing to py-MathTools Thank you for considering contributing to this project — every bit helps, and all contributors are appreciated! @@ -10,7 +10,7 @@ You can contribute in several ways: ### Report Bugs -Open an issue at [GitHub Issues](https://github.com/kopecn/pyMathTools/issues) with: +Open an issue at [GitHub Issues](https://github.com/kopecn/py_math_tools/issues) with: - Your operating system and version - Any relevant local setup details @@ -40,23 +40,23 @@ For new features, please: ## Getting Started -Follow these steps to set up `pyMathTools` locally: +Follow these steps to set up `py_math_tools` locally: 1. **Fork** the repository: - [https://github.com/kopecn/pyMathTools/fork](https://github.com/kopecn/pyMathTools/fork) + [https://github.com/kopecn/py_math_tools/fork](https://github.com/kopecn/py_math_tools/fork) 2. **Clone** your fork: ```sh - git clone git@github.com:your_name_here/pyMathTools.git - cd pyMathTools + git clone git@github.com:your_name_here/py_math_tools.git + cd py_math_tools ``` -3. **Set up a virtual environment** and install the project locally: +3. **Set up the environment** and install the project with dev dependencies: ```sh - mkvirtualenv pyMathTools - python setup.py develop + make dev # uv: create .venv + install -e ".[dev]" from pyproject + # (or `make installDev` for the pip fallback) ``` 4. **Create a new branch**: @@ -65,11 +65,11 @@ Follow these steps to set up `pyMathTools` locally: git checkout -b your-feature-branch ``` -5. **Run linters and tests**: +5. **Run linters, type checks, and tests**: ```sh - make lint - make test-all + make uv-fullCheck # ruff + mypy + pytest + # or individually: make uv-lint · make uv-typecheck · make uv-test ``` 6. **Commit and push your changes**: @@ -84,13 +84,30 @@ Follow these steps to set up `pyMathTools` locally: --- +## Co-developing with sibling repositories + +When you need to develop this project against a **local, unreleased** checkout of a sibling +library (e.g. it lives at `../my-sibling-lib`), use a **`requirements-local.txt`** overlay. Local +and git/path resolution never goes in `pyproject.toml` — the manifest stays dependency **names +only** (putting path/git pointers there is a module-deployment hazard). + +```sh +echo "-e ../my-sibling-lib" >> requirements-local.txt +make uv-sync-local # creates the venv, installs -r requirements-local.txt, then -e ".[dev]" +``` + +`requirements-local.txt` is **gitignored** and never read by CI/release, so a solo checkout is +unaffected (just don't create the file). + +--- + ## PR Guidelines Before submitting a pull request, make sure: - [ ] Tests are included for new logic - [ ] Documentation is updated if needed -- [ ] The project supports Python 3.12 and 3.13 +- [ ] The project supports Python 3.10 through 3.13 - [ ] All tests pass (CI checks will run on PRs) --- @@ -100,29 +117,45 @@ Before submitting a pull request, make sure: To run a targeted test suite: ```sh -pytest tests/test_pyMathTools.py +pytest tests/test_py_math_tools.py ``` +## Changelog (`HISTORY.md`) + +This project keeps a human-readable changelog in `HISTORY.md`, following +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). The best-known method: + +1. **Every PR that changes behavior** adds a bullet under the `## [Unreleased]` + section, in the appropriate group: `Added`, `Changed`, `Deprecated`, `Removed`, + `Fixed`, or `Security`. No "internal-only" changes (refactors, CI) need an entry + unless they affect users. +2. **At release time** (maintainers), rename `## [Unreleased]` to the new version and + date, e.g. `## [1.2.0] - 2025-08-16`, then add a fresh empty `## [Unreleased]` + block above it and update the comparison links at the bottom. +3. Keep entries imperative and user-facing ("Add X", "Fix Y"), not commit-message dumps. + ## Deploying (Maintainers Only) -1. Confirm all changes are committed (including `HISTORY.md`) -2. Bump the version: +1. Update `HISTORY.md`: roll `[Unreleased]` into the new version + date (see above). +2. Confirm all changes are committed (including `HISTORY.md`). +3. Bump the version: ```sh bump2version patch # Use major/minor/patch as needed ``` -3. Push changes and tags: +4. Push changes and tags: ```sh git push git push --tags ``` -4. (Optional) Use [GitHub Actions](https://docs.github.com/en/actions/use-cases-and-examples/building-and-testing/building-and-testing-python#publishing-to-pypi) to auto-deploy to PyPI. +5. (Optional) Use [GitHub Actions](https://docs.github.com/en/actions/use-cases-and-examples/building-and-testing/building-and-testing-python#publishing-to-pypi) to auto-deploy to PyPI. ## Code of Conduct This project follows a [Contributor Code of Conduct](https://chatgpt.com/#:~:text=follows%20a%20Contributor-,Code,-of%20Conduct.%20By). By participating, you agree to uphold these standards. -Feel free to reach out or [open an issue](https://github.com/kopecn/pyMathTools/issues) with any questions. +Feel free to reach out or [open an issue](https://github.com/kopecn/py_math_tools/issues) with any questions. diff --git a/HISTORY.md b/HISTORY.md index fd26aed..a20ef2e 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,19 @@ # History -## 0.0.1 (2025-12-01) +All notable changes to this project are documented here. -* First release on PyPI. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- + +## [0.0.1] - 2026-06-29 + +### Added +- First release on PyPI. + +[Unreleased]: https://github.com/kopecn/py_math_tools/compare/v0.0.1...HEAD +[0.0.1]: https://github.com/kopecn/py_math_tools/releases/tag/v0.0.1 diff --git a/LICENSE b/LICENSE index 6484eac..3d596aa 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025, Nicholas Bergantz +Copyright (c) 2026, Nicholas Bergantz Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index 0d7edff..057c92e 100644 --- a/Makefile +++ b/Makefile @@ -1,123 +1,476 @@ -.PHONY: clean cleanTest cleanArtifacts cleanBuild docs help test testInEnv \ - testInEnvInstallFromSetup testInEnvRunPytest testInEnvCleanup \ - dist release install devInstall flushPip build version tag +# ============================================================================ +# CONFIG +# ============================================================================ +.PHONY: help version checkCleanGit open-github \ + clean clean-build clean-artifacts clean-test \ + bump-patch bump-minor bump-major \ + check-uv install-uv list-uv \ + uv-bootstrap-pythons uv-bootstrap uv-sync uv-sync-headless uv-sync-dev uv-sync-release uv-sync-local uv-editable uv-refresh \ + uv-lint uv-format uv-typecheck uv-fullCheck \ + uv-test uv-test-all uv-test-matrix \ + uv-flush-cache uv-flush-envs uv-flush-pythons uv-flush-everything uv-nuke \ + uv-lifecycle-test \ + dev setup \ + installDev e refresh \ + test testInEnvCleanup testInEnvInstallFromSetup testInEnvRunPytest testInEnv \ + build validateBuild release-test release \ + nuke list .DEFAULT_GOAL := help -# Load anything from the .env file if it exists +# Load .env file if it exists ifneq (,$(wildcard .env)) - include .env - export + include .env + export endif -VERSION=v$(shell grep -m 1 version pyproject.toml | tr -s ' ' | tr -d '"' | tr -d "'" | cut -d' ' -f3) +# Defaults (overridable via .env — the user-editable surface). Keep in sync with .env. +PYTHONS ?= 3.10 3.11 3.12 3.13 +DEFAULT_PYTHON ?= 3.13 +PYTHON ?= python3 +VENV ?= .cleanroom-venv + +# Quality-target paths. ROOT half has NO src/ — its Python lives in hooks/ + tests/ +# (see GAPS.md §6). The template half overrides these to src/. Overridable via .env. +PY_SRC ?= hooks +PY_TESTS ?= tests +PY_EXAMPLES ?= +PY_ALL ?= $(PY_SRC) $(PY_TESTS) $(PY_EXAMPLES) + + +# Derived +# Tool runner for uv- quality/test recipes. `--extra dev` ensures ruff/mypy/pytest are +# resolved (and installed if missing) from the "[dev]" extra even on a FRESH checkout — +# no reliance on a pre-existing .venv, rather than the ambient PATH. +UV := uv run --extra dev PIP := $(PYTHON) -m pip +BUMPVERSION := bumpversion --allow-dirty +REPO := $(notdir $(CURDIR)) +UNAME_S := $(shell uname -s) +HR := ======================================== + +# Guard: DEFAULT_PYTHON must be one of the versions we test against. +ifeq ($(filter $(DEFAULT_PYTHON),$(PYTHONS)),) + $(error DEFAULT_PYTHON ($(DEFAULT_PYTHON)) is not in PYTHONS ($(PYTHONS)) — fix .env) +endif -help: ## Show this help - @grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ - | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}' - -bumpPatch: - bump2version patch - -bumpMinor: - bump2version minor - -bumpMajor: - bump2version major - -clean: cleanBuild cleanArtifacts cleanTest ## Remove all build, Python, and test-related artifacts - -cleanBuild: ## Delete build-related directories and files +# ============================================================================ +# MARK: - Helpers · +# ============================================================================ + +define uninstall_package_list + @$(1) | while read pkg; do \ + [ -n "$$pkg" ] || continue; \ + $(PIP) uninstall -y "$$pkg" 2>&1 \ + || echo "SKIPPED (system-managed): $$pkg"; \ + done +endef + +define print_packages + @echo "========================================" + @echo "$(1)" + @echo "========================================" + @$(2) list 2>/dev/null || echo "No packages or pip not available" + @echo +endef + +# Roll HISTORY.md on a version bump: open a fresh dated section under +# [Unreleased] (folding the accumulated notes into the just-bumped version) and +# amend it into bump2version's commit so version + changelog move together. +# Keep-a-Changelog convention: the `## [Unreleased]` header is the anchor. +define roll_changelog + @ver=$$($(MAKE) -s version); day=$$(date +%F); \ + awk -v v="$$ver" -v d="$$day" '\ + { print } \ + /^## \[Unreleased\]/ && !seen { print ""; print "## [" v "] - " d; seen=1 }' \ + HISTORY.md > HISTORY.md.tmp && mv HISTORY.md.tmp HISTORY.md; \ + git add HISTORY.md; \ + case "$$(git log -1 --pretty=%s)" in \ + "Bump version:"*) git commit --amend --no-edit ;; \ + *) git commit -m "Roll HISTORY.md for v$$ver" ;; \ + esac +endef + +# ============================================================================ +# MARK: - HELP +# ============================================================================ +help: ## Show this help + @echo "$(REPO) — make targets (bare = pip · uv-… = uv path)" + @echo "config: DEFAULT_PYTHON=$(DEFAULT_PYTHON) PYTHONS=$(PYTHONS)" + @echo "" + @awk 'BEGIN {FS = ":.*?## "} \ + /^##@ / {printf "\n\033[1m%s\033[0m\n", substr($$0, 5); next} \ + /^[a-zA-Z0-9_%-]+:.*?## / {printf " \033[36m%-26s\033[0m %s\n", $$1, $$2}' \ + $(MAKEFILE_LIST) + + +# ============================================================================ +# MARK: - COMMON · VERSION & GIT +# ============================================================================ +##@ Common · Version & Git +version: ## Display the current project version + @$(PYTHON) -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])" 2>/dev/null \ + || grep -m1 '^version' pyproject.toml | cut -d'"' -f2 + +checkCleanGit: ## Guard: fail if the git working tree is dirty + @[ -z "$$(git status --porcelain)" ] || \ + (echo "Working tree is dirty. Commit or stash changes first."; exit 1) + +# Static pattern rule: all three documented parts share one recipe (`$*` = the +# part). Bump the version, then roll the changelog into the same commit. +bump-patch bump-minor bump-major: bump-%: ## Bump version (patch|minor|major) + roll HISTORY.md + $(BUMPVERSION) $* + $(call roll_changelog) + +open-github: ## Open the GitHub repository in the default browser (macOS/Linux) + @remote=$$(git remote | head -1); \ + [ -n "$$remote" ] || { echo "No git remote configured."; exit 1; }; \ + url=$$(git remote get-url "$$remote" | sed -e 's|git@github.com:|https://github.com/|' -e 's|\.git$$||'); \ + echo "Opening $$url"; \ + if [ "$(UNAME_S)" = "Darwin" ]; then open "$$url"; \ + elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$$url"; \ + else echo "No browser opener found; visit: $$url"; fi + +# ============================================================================ +# MARK: - COMMON · CLEAN +# Base cleanup targets used by install, test, and CI workflows. +# ============================================================================ +##@ Common · Clean + +clean: clean-build clean-artifacts clean-test ## Remove all build, cache, and test artifacts + +clean-build: ## Remove packaging and distribution artifacts rm -rf build/ dist/ .eggs/ - find . -name '*.egg-info' -exec rm -rf {} + - find . -name '*.egg' -exec rm -rf {} + - -cleanArtifacts: ## Remove Python bytecode and cache files - find . \( -name '*.pyc' -o -name '*.pyo' -o -name '*~' \) -exec rm -f {} + - find . -name '__pycache__' -exec rm -rf {} + - -cleanTest: ## Remove test outputs and coverage data + find . \( -name '*.egg-info' -o -name '*.egg' \) -exec rm -rf {} + + rm -f uv.lock + +clean-artifacts: ## Remove Python bytecode and cache files + find . \( \ + -name '*.pyc' -o \ + -name '*.pyo' -o \ + -name '*~' -o \ + -name '__pycache__' \ + \) -exec rm -rf {} + + +clean-test: ## Remove test, coverage, and lint caches rm -f .coverage - rm -rf htmlcov/ .pytest_cache - -lint: ## Run linters like pylint or ruff - pylint src/ - -format: ## Format code with black - black src/ - -typecheck: ## Type check with mypy - mypy src/ + rm -rf \ + htmlcov/ \ + .pytest_cache/ \ + .mypy_cache/ \ + .ruff_cache/ \ + .tox/ \ + .nox/ + +# ============================================================================ +# MARK: - UV · TOOLING +# ============================================================================ +##@ UV · Tooling +check-uv: ## Check if uv is installed (guard for all uv- targets) + @command -v uv >/dev/null 2>&1 || { \ + echo "ERROR: uv not found."; \ + echo " Install it with: make install-uv"; \ + echo " Or see: https://docs.astral.sh/uv/getting-started/installation/"; \ + exit 1; } + +install-uv: ## Install uv (brew on macOS, installer script on Linux) +ifeq ($(UNAME_S),Darwin) + @echo "Detected macOS - installing via Homebrew..." + @command -v brew >/dev/null 2>&1 || { echo "ERROR: Homebrew not found. Install from https://brew.sh"; exit 1; } + brew install uv +else ifeq ($(UNAME_S),Linux) + @echo "Detected Linux - installing via official installer..." + curl -LsSf https://astral.sh/uv/install.sh | sh + @echo "" + @echo "NOTE: You may need to add ~/.local/bin to your PATH:" + @echo ' export PATH="$$HOME/.local/bin:$$PATH"' +else + @echo "Unsupported OS: $(UNAME_S)" + @echo "Install manually: https://docs.astral.sh/uv/getting-started/installation/" + @exit 1 +endif + @echo "" + @echo "uv installed successfully:" + @uv --version + +list-uv: check-uv ## List uv envs, installed Pythons, packages, and cache info + @echo "$(HR)"; echo "UV VERSION"; echo "$(HR)" + @uv --version + @echo ""; echo "$(HR)"; echo "INSTALLED PYTHON VERSIONS"; echo "$(HR)" + @uv python list --only-installed + @echo ""; echo "$(HR)"; echo "PROJECT VIRTUAL ENVIRONMENTS"; echo "$(HR)" + @ls -d .venv 2>/dev/null || echo "No .venv found" + @ls -d .venvs/*/ 2>/dev/null || echo "No .venvs/ matrix environments found" + @echo ""; echo "$(HR)"; echo "INSTALLED PACKAGES (.venv)"; echo "$(HR)" + @uv pip list 2>/dev/null || echo "No packages or .venv not found" + @echo ""; echo "$(HR)"; echo "UV CACHE INFO"; echo "$(HR)" + @uv cache dir + @du -sh $$(uv cache dir) 2>/dev/null || echo "Cache empty or not accessible" + +# ============================================================================ +# MARK: - UV · BOOTSTRAP & SYNC +# ============================================================================ +##@ UV · Bootstrap & Sync +uv-bootstrap-pythons: check-uv ## Install all configured Python versions via uv + uv python install $(PYTHONS) + +# Dependency model (BKM; see GAPS §5 / spec §6): pyproject.toml declares dependency +# NAMES ONLY — never version-pinned (only the application layer pins; module-level pins +# cause conflicts). The requirements*.txt files carry pins and git-based pointers, and +# every install path — pip AND uv — leans on them: `-r requirements.txt` then the +# editable self-install. No `uv.lock`, no `lock`/compile target. + +uv-bootstrap: check-uv uv-bootstrap-pythons ## Full bootstrap: pythons + venv + deps + uv venv --python $(DEFAULT_PYTHON) + uv pip install -r requirements.txt + uv pip install -e ".[dev]" + @echo "" + @echo "Bootstrap complete. Run 'make uv-test-all' to validate." + +uv-sync: check-uv ## Sync all dependencies including dev (default dev workflow) + @[ -d ".venv" ] || uv venv --python $(DEFAULT_PYTHON) + uv pip install -r requirements.txt + uv pip install -e ".[dev]" + +uv-sync-headless: check-uv ## Sync dependencies without dev/UI extras (headless deploy) + @[ -d ".venv" ] || uv venv --python $(DEFAULT_PYTHON) + uv pip install -r requirements.txt + uv pip install -e "." + +uv-sync-dev: check-uv ## Sync dependencies with dev extras + @[ -d ".venv" ] || uv venv --python $(DEFAULT_PYTHON) + uv pip install -r requirements.txt + uv pip install -e ".[dev]" + +uv-sync-release: check-uv ## Sync using tag-pinned release requirements (requirements-release.txt) + @[ -d ".venv" ] || uv venv --python $(DEFAULT_PYTHON) + uv pip install -r requirements-release.txt + uv pip install -e "." + +uv-sync-local: check-uv ## Sync using local editable path overrides (requirements-local.txt) + @[ -d ".venv" ] || uv venv --python $(DEFAULT_PYTHON) + uv pip install -r requirements-local.txt + uv pip install -e ".[dev]" + +dev: uv-sync ## One-command dev setup entrypoint (alias → uv-sync) +setup: dev ## One-command dev setup entrypoint (alias → uv-sync) + +uv-editable: check-uv ## Install this package editable via uv (uv pip install -e .) + uv pip install -e . + +uv-refresh: check-uv ## Clean cache + reinstall from requirements + upgrade editable dev + uv cache clean + uv pip install -r requirements.txt + uv pip install --upgrade -e ".[dev]" + +# ============================================================================ +# MARK: - UV · QUALITY +# ============================================================================ +##@ UV · Quality +uv-lint: check-uv ## Run ruff linter (read-only; non-zero exit for CI) + $(UV) ruff check $(PY_ALL) + +uv-format: check-uv ## Format code with ruff + $(UV) ruff format $(PY_ALL) +# KEEP --unsafe-fixes: intentional, added after findings on 2026-06-25. +# Do NOT strip in cleanup/refactor passes — the unsafe autofixes here are relied +# upon deliberately. Removing it has regressed this target before. + $(UV) ruff check --fix --unsafe-fixes $(PY_ALL) + +uv-typecheck: check-uv ## Strict type check with mypy + $(UV) mypy $(PY_SRC) $(PY_TESTS) $(PY_EXAMPLES) + +# ty (Astral's preview type-checker) is intentionally OUT for now (decision D1): +# it's pre-release and not wired into uv-fullCheck. Revisit when it stabilizes. +uv-fullCheck: check-uv uv-lint uv-typecheck uv-test ## lint + typecheck + tests + +# ============================================================================ +# MARK: - UV · TEST +# ============================================================================ +##@ UV · Test +# Depends on uv-sync so a fresh checkout never tests an empty/stale .venv (no +# false-green no-op): the [dev] extra is installed from pyproject before pytest runs. +uv-test: check-uv uv-sync ## Run tests on DEFAULT_PYTHON (ensures a synced env first) + $(UV) pytest + +uv-test-all: check-uv ## Run tests across all configured Python versions (.venvs/) + @failed=""; \ + for py in $(PYTHONS); do \ + echo ""; \ + echo "========================================"; \ + echo "Testing Python $$py"; \ + echo "========================================"; \ + venv=".venvs/$$py"; \ + [ -d "$$venv" ] || uv venv --python $$py "$$venv"; \ + if ( . "$$venv/bin/activate" && \ + uv pip install -q -e ".[dev]" && \ + python -m pytest ); then \ + echo "PASS: Python $$py"; \ + else \ + echo "FAIL: Python $$py"; \ + failed="$$failed $$py"; \ + fi; \ + done; \ + echo ""; \ + echo "========================================"; \ + if [ -n "$$failed" ]; then \ + echo "FAILED VERSIONS:$$failed"; \ + echo "========================================"; \ + exit 1; \ + else \ + echo "ALL PYTHON VERSIONS PASSED"; \ + echo "========================================"; \ + fi + +uv-test-matrix: uv-bootstrap-pythons uv-test-all ## Ensure Pythons installed, then run all tests + +# ============================================================================ +# MARK: - UV · FLUSH / NUKE +# ============================================================================ +##@ UV · Flush / Nuke + +uv-flush-envs: ## Remove all virtual environments (.venv + .venvs/) + @echo ">> Removing virtual environments..." + rm -rf .venv + rm -rf .venvs + rm -rf .venv-py* + @echo "Virtual environments removed." + +uv-flush-cache: check-uv ## Clean uv cache + @echo ">> Cleaning uv cache..." + uv cache clean + @echo "uv cache cleaned." + +uv-flush-pythons: ## Remove uv-managed Python installs (NUCLEAR) + @echo "WARNING: This removes ALL uv-managed Python installations!" + @echo "Location: ~/.local/share/uv/python" + rm -rf ~/.local/share/uv/python + @echo "uv-managed Pythons removed." + +uv-flush-everything: clean uv-flush-envs uv-flush-cache ## Full cleanup (keeps pythons) + @echo "Environment flushed. Run 'make uv-flush-pythons' separately for global Pythons." + +uv-nuke: uv-flush-everything ## NUCLEAR: everything then prompt for Python removal + @echo "" + @echo ">> Running uv-nuke..." + @$(MAKE) uv-flush-pythons + @echo "" + @echo "Environment nuked. Run 'make uv-bootstrap' to rebuild from scratch." + +uv-lifecycle-test: uv-flush-everything uv-bootstrap uv-test-all ## flush -> bootstrap -> test-all + @echo ">> Lifecycle test complete" + +# ============================================================================ +# MARK: - PIP · INSTALL +# ============================================================================ +##@ PIP · Install +# Ambient-pip fallback (prefer the uv- path). Both pip and uv lean on the requirements +# file (BKM rule 4): install -r requirements.txt, then self-install the editable +# package. No --break-system-packages / --force-reinstall: use a venv (make uv-sync) +# rather than fighting an externally-managed interpreter. +installDev: clean ## Install dev dependencies with pip (-r requirements.txt + editable [dev]) + $(PIP) install -r requirements.txt + $(PIP) install -e ".[dev]" + +e: ## Install this package in editable mode (pip install -e .) + $(PIP) install -e . -fullCheck: lint typecheck test ## Run full quality and test checks +refresh: ## Refresh pip packages: reinstall from requirements + upgrade editable dev + $(PIP) install -r requirements.txt + $(PIP) install --upgrade -e ".[dev]" -validateTomlSetup: ## Check if pyproject.toml and setup are valid - $(PYTHON) -m build --sdist --wheel --outdir /tmp/test_build +# ============================================================================ +# MARK: - PIP · TEST +# ============================================================================ +##@ PIP · Test test: ## Run tests using the current Python environment pytest -testInEnv: clean testInEnvInstallFromSetup testInEnvRunPytest testInEnvCleanup ## Run tests in a temporary virtual environment +testInEnvCleanup: ## Delete the temporary venv ($(VENV)) + rm -rf $(VENV) || true -testInEnvInstallFromSetup: testInEnvCleanup ## Set up temporary venv and install dev dependencies - $(PYTHON) -m venv $(VENV) && \ +testInEnvInstallFromSetup: testInEnvCleanup ## Create temp venv + install dev deps + $(PYTHON) -m venv $(VENV) . $(VENV)/bin/activate && \ which python3 && \ - $(PYTHON) -m pip install ".[personal_repos,develop]" + $(VENV)/bin/pip install ".[dev]" @echo "Virtual env can be activated with 'source $(VENV)/bin/activate'" -testInEnvRunPytest: ## Run tests inside the temporary virtual environment +testInEnvRunPytest: ## Run pytest inside the temporary venv . $(VENV)/bin/activate && \ which $(PYTHON) && \ $(PYTHON) -m pytest -testInEnvCleanup: ## Delete the temporary virtual environment - rm -rf $(VENV) || true - -checkVenv: - @test "$$VIRTUAL_ENV" != "" || (echo "Not in a virtualenv!"; exit 1) - -dist: clean ## Create source and wheel distributions - $(PYTHON) -m build - ls -l dist +testInEnv: clean testInEnvInstallFromSetup testInEnvRunPytest testInEnvCleanup ## Full clean-room test + @echo ">> testInEnv completed" -build: ## Build project to check packaging without uploading - rm -rf build dist +# ============================================================================ +# MARK: - PIP · BUILD & RELEASE +# ============================================================================ +##@ PIP · Build & Release +build: clean-build ## Build sdist + wheel ($(PYTHON) -m build) + @echo "Building package..." $(PYTHON) -m build -version: ## Display the current project version - @echo "Current version is $(VERSION)" - -tag: checkCleanGit version ## Create and push a git tag - @echo "Tagging version $(VERSION)" - git tag -a $(VERSION) -m "Creating version $(VERSION)" - git push origin $(VERSION) - -checkCleanGit: - @git diff-index --quiet HEAD -- || (echo "Git working directory not clean" && exit 1) - -releaseTest: dist ## Upload to TestPyPI - twine upload --repository-url https://test.pypi.org/legacy/ dist/* - -release: dist ## Upload the distribution package to PyPI - set -euo pipefail && twine upload dist/* - -install: clean ## Install the package in editable mode (local dev install) - $(PIP) install -e . - -devInstall: clean ## Install development dependencies - $(PIP) install -e .[develop] - $(PIP) install pytest pylint black mypy bump2version build twine - -docs: ## Build HTML documentation using Sphinx - sphinx-build -b html docs/ docs/_build/html - @echo "Documentation built in docs/_build/html" - -flushpip: SHELL := /bin/bash -flushpip: ## Uninstall all packages from the current environment - $(PIP) uninstall -y -r <($(PIP) freeze) - -e: ## install this package into environment for development - $(PIP) install -e . - +validateBuild: build ## Validate build artifacts with twine + @echo "Validating dist/ with twine..." + $(PYTHON) -m twine check dist/* + +release-test: checkCleanGit validateBuild ## Dry-run publish to TestPyPI (clean tree only) + @echo "Uploading $(REPO) v$$($(MAKE) -s version) to TestPyPI..." + @$(PYTHON) -m twine upload --repository testpypi dist/* + +# PyPI publishing is owned by CI, not this Makefile. Per the ci-cd spec, the +# pipeline is the single authoritative path to production — no manual, out-of-band +# uploads. `.github/workflows/tag-on-prod.yml` tags v on push to `prod`; +# a publish-on-tag workflow promotes that artifact. `make release` therefore +# refuses to upload and prints the release procedure instead. +release: validateBuild ## Refuse local upload; print the CI-driven release procedure + @echo "Local PyPI upload is disabled — the pipeline is the authoritative publish path." + @echo "" + @echo "To release $(REPO) v$$($(MAKE) -s version):" + @echo " 1. Bump the version (make bump-patch|bump-minor|bump-major) and merge to prod." + @echo " 2. Push to prod → tag-on-prod.yml creates the v tag." + @echo " 3. The publish-on-tag workflow uploads to PyPI." + @echo "" + @echo "For a local pre-flight, use: make release-test (TestPyPI)." + @exit 1 + +# ============================================================================ +# MARK: - PIP · FLUSH / LIST +# ============================================================================ +##@ PIP · Flush / List +# `nuke` is the INFERIOR pip fallback (Lesson 2): it per-package-uninstalls from +# the AMBIENT interpreter ($(PIP)). Prefer `make uv-flush-envs` — deleting the +# venv dir is the reliable flush primitive. Use this only when you're stuck in a +# non-deletable (e.g. system) env. Non-editable URL/VCS installs are skipped. +nuke: ## Per-package uninstall from ambient env (inferior — prefer uv-flush-envs) + @echo "Uninstalling regular packages (skipping system-managed)..." + $(call uninstall_package_list,$(PIP) freeze --exclude-editable | grep -v ' @ ') + + @echo "Uninstalling editable packages by name..." + $(call uninstall_package_list,$(PIP) list --editable --format=freeze | cut -d= -f1) + + @echo "pip-nuke complete." + +list: ## List pip packages in available environments + $(call print_packages,SYSTEM PYTHON PACKAGES,$(PIP)) + + @if [ -d ".venv" ]; then \ + echo "$(HR)"; \ + echo "VENV PACKAGES (.venv)"; \ + echo "$(HR)"; \ + .venv/bin/pip list 2>/dev/null || echo "No packages or pip not available"; \ + echo; \ + fi + + @for venv in .venvs/*; do \ + [ -d "$$venv" ] || continue; \ + echo "$(HR)"; \ + echo "VENV PACKAGES ($$venv)"; \ + echo "$(HR)"; \ + $$venv/bin/pip list 2>/dev/null || echo "No packages or pip not available"; \ + echo; \ + done + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index afb03c6..db889aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,44 +4,57 @@ build-backend = "setuptools.build_meta" [project] -name = "pyMathTools" +name = "py_math_tools" version = "0.0.1" description = "Python Boilerplate contains all the boilerplate you need to create a Python package." readme = {file = "README.md", content-type = "text/markdown"} -requires-python = ">= 3.10" +requires-python = ">=3.10" authors = [ {name = "Nicholas Bergantz", email = "nicholas.bergantz@gmail.com"} ] -keywords = [ - -] +keywords = ["my_package", "python", "package"] classifiers = [ - + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", ] license = {text = "MIT"} +# Runtime dependencies — NAMES ONLY, never version-pinned (BKM: only the application +# layer pins; module-level pins cause dependency conflicts). Put any version pins, +# tag-pins, or git-based pointers in requirements*.txt — NEVER here (git URLs in +# pyproject are a module-deployment disaster). e.g.: +# dependencies = ["requests"] dependencies = [ - "pyFoundationTools @ git+https://github.com/kopecn/py-foundationTools.git@dev", + "pyFoundationTools", "matplotlib", "numpy-quaternion", ] [project.optional-dependencies] -develop = [ +dev = [ "pytest", - "black", + "pytest-asyncio", + "ruff", "build", "bump2version", - "pylint", "mypy", + "ty", "twine", ] -personal_repos = [] [project.urls] -bugs = "https://github.com/kopecn/pyMathTools/issues" -changelog = "https://github.com/kopecn/pyMathTools/blob/master/changelog.md" -homepage = "https://github.com/kopecn/pyMathTools" +bugs = "https://github.com/kopecn/py_math_tools/issues" +changelog = "https://github.com/kopecn/py_math_tools/blob/prod/HISTORY.md" +homepage = "https://github.com/kopecn/py_math_tools" [tool.setuptools] package-dir = {"" = "src"} @@ -52,4 +65,30 @@ package-dir = {"" = "src"} [tool.pytest.ini_options] testpaths = ["tests/*"] python_functions = ["test*"] -python_files = ["test*.py"] \ No newline at end of file +python_files = ["test*.py"] +# src/ layout: let pytest import the package without an install (so `make test` and a +# bare `pytest` work on a fresh checkout). +pythonpath = ["src"] + +# Path resolution: the Makefile scopes quality targets via PY_* vars (.env sets +# PY_SRC=src for this src/ layout). The src/files config below is the fallback that +# makes a bare `ruff check .` / `mypy .` (no path args) and IDE integrations resolve +# the same scope. The Makefile is byte-identical across halves. +[tool.ruff] +target-version = "py310" # support floor; default dev interpreter is 3.13 +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] +ignore = ["D301"] + +[tool.mypy] +python_version = "3.10" +strict = true +files = ["src", "tests"] + +# Multi-repo co-development: local editable sibling repos and any git-based pointers +# go in requirements-local.txt (NEVER in pyproject — git URLs / path overrides here +# are a module-deployment disaster). `make uv-sync-local` installs that overlay on top +# of the normal env; it is gitignored and never read by CI/release. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c017f1b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,22 @@ +# Runtime dependencies +# +# pyproject.toml is the source of truth for dependency declarations, version ranges, +# and optional dependency groups. +# +# This file is intentionally minimal and is used by installation workflows that +# expect a requirements file (e.g., `make installDev`, `make uv-sync`). +# +# Add runtime dependencies here only as needed and keep them aligned with +# pyproject.toml. + +# Examples +# +# Tag: some-package @ git+https://github.com/example-org/some-package.git@v1.4.2 +# Commit: some-package @ git+https://github.com/example-org/some-package.git@a1b2c3d4e5f6 +# Branch: some-package @ git+https://github.com/example-org/some-package.git@main +# Monorepo: some-package @ git+https://github.com/example-org/monorepo.git@v2.0.0#subdirectory=packages/some-package +# Extras: some-package[dev,test] @ git+https://github.com/example-org/some-package.git@v1.4.2 +# Editable: -e git+https://github.com/example-org/some-package.git@develop#egg=some-package + + +pyFoundationTools @ git+https://github.com/kopecn/py-foundationTools.git@dev \ No newline at end of file From 0162ccce1902261e29b74f6070311c27fb4a7e51 Mon Sep 17 00:00:00 2001 From: kopecn Date: Thu, 2 Jul 2026 15:33:27 -0700 Subject: [PATCH 02/70] cleanups --- Makefile | 2 +- requirements.txt | 2 +- src/pyMathTools/hints.py | 5 +- src/pyMathTools/spatial/Quaternion.py | 86 ++++----- src/pyMathTools/spherical/constructors.py | 3 +- .../spherical/sphericalGenerators.py | 23 ++- .../spherical/sphericalTransforms.py | 17 +- .../plotUnitSpherical.py | 169 ++++++++++-------- tests/test_quaternion.py | 21 +-- 9 files changed, 166 insertions(+), 162 deletions(-) diff --git a/Makefile b/Makefile index 057c92e..7de61cb 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,7 @@ PY_ALL ?= $(PY_SRC) $(PY_TESTS) $(PY_EXAMPLES) # Tool runner for uv- quality/test recipes. `--extra dev` ensures ruff/mypy/pytest are # resolved (and installed if missing) from the "[dev]" extra even on a FRESH checkout — # no reliance on a pre-existing .venv, rather than the ambient PATH. -UV := uv run --extra dev +UV := uv run --extra dev --no-project PIP := $(PYTHON) -m pip BUMPVERSION := bumpversion --allow-dirty REPO := $(notdir $(CURDIR)) diff --git a/requirements.txt b/requirements.txt index c017f1b..de5e654 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,4 +19,4 @@ # Editable: -e git+https://github.com/example-org/some-package.git@develop#egg=some-package -pyFoundationTools @ git+https://github.com/kopecn/py-foundationTools.git@dev \ No newline at end of file +pyFoundationTools @ git+https://github.com/kopecn/py-foundationTools.git@feat/switch-to-new-template \ No newline at end of file diff --git a/src/pyMathTools/hints.py b/src/pyMathTools/hints.py index 2305056..2e27fb8 100644 --- a/src/pyMathTools/hints.py +++ b/src/pyMathTools/hints.py @@ -5,11 +5,12 @@ enabling strict and clear typing in mathematical code throughout the package. """ -from typing import Union, TYPE_CHECKING +from typing import TYPE_CHECKING, Union + import numpy as np +import quaternion from numpy import float64 from numpy.typing import NDArray -import quaternion if TYPE_CHECKING: from pyMathTools.spatial.quaternion import Quaternion diff --git a/src/pyMathTools/spatial/Quaternion.py b/src/pyMathTools/spatial/Quaternion.py index 966ee3c..7f6c194 100644 --- a/src/pyMathTools/spatial/Quaternion.py +++ b/src/pyMathTools/spatial/Quaternion.py @@ -3,39 +3,41 @@ """ from __future__ import annotations + from dataclasses import dataclass -from typing import TypeVar, Type, Any -import numpy as np -from foundationTypes.mathTypes.QuaternionType import QuaternionType +from typing import Any, TypeVar -# Import all necessary functions from quaternion module +import numpy as np +from foundationTypes.mathTypes.MathTypes import UnitSphericalSmallCircleType +from foundationTypes.mathTypes.quaternionABC import QuaternionABC +from quaternion import allclose as quat_allclose from quaternion import ( + as_euler_angles, as_float_array, as_quat_array, - from_float_array, as_rotation_matrix, - from_rotation_matrix, as_rotation_vector, - from_rotation_vector, - as_euler_angles, - from_euler_angles, as_vector_part, + from_euler_angles, + from_float_array, + from_rotation_matrix, + from_rotation_vector, from_vector_part, rotate_vectors, +) +from quaternion import ( isclose as quat_isclose, - allclose as quat_allclose, ) -from quaternion.quaternion_time_series import slerp as quat_slerp +from quaternion import quaternion as np_quaternion +from quaternion.quaternion_time_series import slerp as quat_slerp # type: ignore[import-untyped] # Import custom type hints from pyMathTools.hints import ( FloatArray3, FloatArray4, - RotationMatrix, FloatOrQuaternion, + RotationMatrix, ) -from foundationTypes.mathTypes.UnitSphericalSmallCircle import UnitSphericalSmallCircle - T = TypeVar("T", bound="Quaternion") @@ -46,23 +48,21 @@ def from_float(x: Any) -> float: @dataclass -class Quaternion(QuaternionType): +class Quaternion(QuaternionABC): """ wrapper class for numpy-quaternion for allowing """ - __q: np.quaternion + __q: np_quaternion # MARK: - Constructors @staticmethod - def fromNumpyQuaternion(q: np.quaternion) -> "Quaternion": + def fromNumpyQuaternion(q: np_quaternion) -> Quaternion: return Quaternion(q) @classmethod - def from_components( - cls: Type[T], w: float = 1, x: float = 0, y: float = 0, z: float = 0 - ) -> T: + def from_components(cls: type[T], w: float = 1, x: float = 0, y: float = 0, z: float = 0) -> T: """Create a quaternion instance from individual w, x, y, z components. Args: @@ -74,10 +74,10 @@ def from_components( Returns: A concrete QuaternionType instance of the calling class type """ - return cls(np.quaternion(w, x, y, z)) + return cls(np_quaternion(w, x, y, z)) @classmethod - def from_float_array(cls: Type[T], array: FloatArray4) -> T: + def from_float_array(cls: type[T], array: FloatArray4) -> T: """Create a quaternion from a 4-element float array [w, x, y, z]. Args: @@ -141,7 +141,7 @@ def z(self) -> float: return self.__q.z @property - def q(self) -> np.quaternion: + def q(self) -> np_quaternion: return self.__q # MARK: - Arithmetic Operators @@ -152,7 +152,7 @@ def __add__(self, other: FloatOrQuaternion) -> Quaternion: return Quaternion(self.__q + other.q) elif isinstance(other, (float, int)): return Quaternion(self.__q + other) - elif isinstance(other, np.quaternion): + elif isinstance(other, np_quaternion): return Quaternion(self.__q + other) return NotImplemented @@ -166,7 +166,7 @@ def __sub__(self, other: FloatOrQuaternion) -> Quaternion: return Quaternion(self.__q - other.q) elif isinstance(other, (float, int)): return Quaternion(self.__q - other) - elif isinstance(other, np.quaternion): + elif isinstance(other, np_quaternion): return Quaternion(self.__q - other) return NotImplemented @@ -174,7 +174,7 @@ def __rsub__(self, other: FloatOrQuaternion) -> Quaternion: """Right subtraction.""" if isinstance(other, (float, int)): return Quaternion(other - self.__q) - elif isinstance(other, np.quaternion): + elif isinstance(other, np_quaternion): return Quaternion(other - self.__q) return NotImplemented @@ -184,7 +184,7 @@ def __mul__(self, other: FloatOrQuaternion) -> Quaternion: return Quaternion(self.__q * other.q) elif isinstance(other, (float, int)): return Quaternion(self.__q * other) - elif isinstance(other, np.quaternion): + elif isinstance(other, np_quaternion): return Quaternion(self.__q * other) return NotImplemented @@ -198,7 +198,7 @@ def __truediv__(self, other: FloatOrQuaternion) -> Quaternion: return Quaternion(self.__q / other.q) elif isinstance(other, (float, int)): return Quaternion(self.__q / other) - elif isinstance(other, np.quaternion): + elif isinstance(other, np_quaternion): return Quaternion(self.__q / other) return NotImplemented @@ -206,7 +206,7 @@ def __rtruediv__(self, other: FloatOrQuaternion) -> Quaternion: """Right division.""" if isinstance(other, (float, int)): return Quaternion(other / self.__q) - elif isinstance(other, np.quaternion): + elif isinstance(other, np_quaternion): return Quaternion(other / self.__q) return NotImplemented @@ -232,7 +232,7 @@ def __eq__(self, other: object) -> bool: """Check equality with another quaternion.""" if isinstance(other, Quaternion): return bool(self.__q == other.q) - elif isinstance(other, np.quaternion): + elif isinstance(other, np_quaternion): return bool(self.__q == other) return False @@ -391,7 +391,7 @@ def to_rotation_matrix(self) -> RotationMatrix: return as_rotation_matrix(self.__q) @classmethod - def from_rotation_matrix(cls: Type[T], matrix: RotationMatrix) -> T: + def from_rotation_matrix(cls: type[T], matrix: RotationMatrix) -> T: """Create a quaternion from a 3x3 rotation matrix. Args: @@ -422,7 +422,7 @@ def to_rotation_vector(self) -> FloatArray3: return as_rotation_vector(self.__q) @classmethod - def from_rotation_vector(cls: Type[T], rotation_vector: FloatArray3) -> T: + def from_rotation_vector(cls: type[T], rotation_vector: FloatArray3) -> T: """Create a quaternion from axis-angle representation. Args: @@ -454,7 +454,7 @@ def to_euler_angles(self) -> FloatArray3: @classmethod def from_euler_angles( - cls: Type[T], + cls: type[T], alpha: float, beta: float, gamma: float, @@ -481,7 +481,7 @@ def from_euler_angles( # MARK: - Vector Part Operations @classmethod - def from_vector_part(cls: Type[T], vector: FloatArray3) -> T: + def from_vector_part(cls: type[T], vector: FloatArray3) -> T: """Create a quaternion from a 3D vector (pure quaternion). This creates a quaternion with w=0 and vector part equal to the input. @@ -593,7 +593,7 @@ def allclose( # MARK: - Special Constructors @classmethod - def identity(cls: Type[T]) -> T: + def identity(cls: type[T]) -> T: """Create an identity quaternion (no rotation). Returns: @@ -602,7 +602,7 @@ def identity(cls: Type[T]) -> T: return cls.from_components(w=1.0, x=0.0, y=0.0, z=0.0) @classmethod - def from_axis_angle(cls: Type[T], axis: FloatArray3, angle: float) -> T: + def from_axis_angle(cls: type[T], axis: FloatArray3, angle: float) -> T: """Create a quaternion from a rotation axis and angle. Args: @@ -624,7 +624,7 @@ def from_axis_angle(cls: Type[T], axis: FloatArray3, angle: float) -> T: @classmethod def _from_unit_direction_to_vector( - cls: Type[T], + cls: type[T], start_direction: FloatArray3, target_vector: FloatArray3, perpendicular_axis: FloatArray3, @@ -673,7 +673,7 @@ def _from_unit_direction_to_vector( return cls.from_axis_angle(axis, angle) @classmethod - def from_unit_x_to_vector(cls: Type[T], target_vector: FloatArray3) -> T: + def from_unit_x_to_vector(cls: type[T], target_vector: FloatArray3) -> T: """Create a quaternion that rotates the +X axis to point toward target_vector. This constructor computes the rotation that transforms the unit X direction @@ -701,7 +701,7 @@ def from_unit_x_to_vector(cls: Type[T], target_vector: FloatArray3) -> T: return cls._from_unit_direction_to_vector(start, target_vector, perpendicular) @classmethod - def from_unit_y_to_vector(cls: Type[T], target_vector: FloatArray3) -> T: + def from_unit_y_to_vector(cls: type[T], target_vector: FloatArray3) -> T: """Create a quaternion that rotates the +Y axis to point toward target_vector. This constructor computes the rotation that transforms the unit Y direction @@ -729,7 +729,7 @@ def from_unit_y_to_vector(cls: Type[T], target_vector: FloatArray3) -> T: return cls._from_unit_direction_to_vector(start, target_vector, perpendicular) @classmethod - def from_unit_z_to_vector(cls: Type[T], target_vector: FloatArray3) -> T: + def from_unit_z_to_vector(cls: type[T], target_vector: FloatArray3) -> T: """Create a quaternion that rotates the +Z axis to point toward target_vector. This constructor computes the rotation that transforms the unit Z direction @@ -757,7 +757,7 @@ def from_unit_z_to_vector(cls: Type[T], target_vector: FloatArray3) -> T: return cls._from_unit_direction_to_vector(start, target_vector, perpendicular) @classmethod - def from_dict(cls, obj: Any) -> "Quaternion": + def from_dict(cls, obj: Any) -> Quaternion: """Create a quaternion instance from a dictionary representation. Args: @@ -776,7 +776,7 @@ def from_dict(cls, obj: Any) -> "Quaternion": def to_unitSphericalSmallCircle( q: Quaternion, radius_angle: float = np.pi / 4 - ) -> UnitSphericalSmallCircle: + ) -> UnitSphericalSmallCircleType: """ """ a, p = q.vector_spherical - return UnitSphericalSmallCircle(azimuth=a, polar=p, radius_angle=radius_angle) + return UnitSphericalSmallCircleType(azimuth=a, polar=p, radius_angle=radius_angle) diff --git a/src/pyMathTools/spherical/constructors.py b/src/pyMathTools/spherical/constructors.py index 2a28fe3..6e4b17c 100644 --- a/src/pyMathTools/spherical/constructors.py +++ b/src/pyMathTools/spherical/constructors.py @@ -6,9 +6,8 @@ """ import numpy as np -from numpy import atan2, acos, pi - from foundationTypes.mathTypes.UnitSphericalArc import UnitSphericalArc +from numpy import atan2 def arc_from_two_points( diff --git a/src/pyMathTools/spherical/sphericalGenerators.py b/src/pyMathTools/spherical/sphericalGenerators.py index 6310ba3..4dbd7e3 100644 --- a/src/pyMathTools/spherical/sphericalGenerators.py +++ b/src/pyMathTools/spherical/sphericalGenerators.py @@ -36,24 +36,23 @@ r = sqrt(x² + y² + z²) """ -from typing import Tuple +from foundationTypes.mathTypes.UnitSphericalArc import UnitSphericalArc +from foundationTypes.mathTypes.UnitSphericalSmallCircle import UnitSphericalSmallCircle from numpy import ( - clip, - pi, + arccos, arctan2, - cos, - sin, array, + clip, + cos, cross, + dot, linspace, - zeros, + pi, + sin, sqrt, - arccos, - dot, + zeros, ) from numpy.linalg import norm -from foundationTypes.mathTypes.UnitSphericalSmallCircle import UnitSphericalSmallCircle -from foundationTypes.mathTypes.UnitSphericalArc import UnitSphericalArc from pyMathTools.hints import FloatNDArray @@ -61,7 +60,7 @@ def generate_spherical_small_circle_points( circle: UnitSphericalSmallCircle, num_points: int = 120, -) -> Tuple[FloatNDArray, FloatNDArray]: +) -> tuple[FloatNDArray, FloatNDArray]: """ Generate points on a true small circle on a unit sphere. @@ -158,7 +157,7 @@ def generate_spherical_small_circle_points( def generate_spherical_arc_points( arc: UnitSphericalArc, num_points: int = 120, -) -> Tuple[FloatNDArray, FloatNDArray]: +) -> tuple[FloatNDArray, FloatNDArray]: """ Generate points along an arc on a unit sphere. diff --git a/src/pyMathTools/spherical/sphericalTransforms.py b/src/pyMathTools/spherical/sphericalTransforms.py index b202fbd..f751604 100644 --- a/src/pyMathTools/spherical/sphericalTransforms.py +++ b/src/pyMathTools/spherical/sphericalTransforms.py @@ -14,21 +14,20 @@ See pyMathTools.generators.sphericalGenerators module docstring for complete details. """ -from typing import Tuple -from pyMathTools.hints import FloatOrNDArray -from numpy import float64 -from numpy.typing import NDArray +from foundationTypes.mathTypes.UnitSphericalArc import UnitSphericalArc from numpy import ( - clip, - pi, + arccos, arctan2, + clip, cos, + float64, + pi, sin, - arccos, ) from numpy.linalg import norm +from numpy.typing import NDArray -from foundationTypes.mathTypes.UnitSphericalArc import UnitSphericalArc +from pyMathTools.hints import FloatOrNDArray def plate_carree_transform( @@ -131,7 +130,7 @@ def cartesian_to_spherical(point: NDArray[float64]) -> tuple: def compute_spherical_arc_endpoint( arc: UnitSphericalArc, -) -> Tuple[float, float]: +) -> tuple[float, float]: """ Compute the endpoint of a UnitSphericalArc on a unit sphere. diff --git a/src/pyMathToolsPlotHelpers/plotUnitSpherical.py b/src/pyMathToolsPlotHelpers/plotUnitSpherical.py index 8ac8fad..08f8c0d 100644 --- a/src/pyMathToolsPlotHelpers/plotUnitSpherical.py +++ b/src/pyMathToolsPlotHelpers/plotUnitSpherical.py @@ -1,18 +1,16 @@ -from typing import List, Tuple, Optional -import numpy as np import matplotlib.pyplot as plt -from matplotlib.figure import Figure -from matplotlib.axes import Axes - -from foundationTypes.mathTypes.UnitSphericalSmallCircle import UnitSphericalSmallCircle +import numpy as np from foundationTypes.mathTypes.UnitSphericalArc import UnitSphericalArc +from foundationTypes.mathTypes.UnitSphericalSmallCircle import UnitSphericalSmallCircle +from matplotlib.axes import Axes +from matplotlib.figure import Figure from pyMathTools.spatial.Quaternion import Quaternion -from pyMathTools.spherical.sphericalTransforms import plate_carree_transform from pyMathTools.spherical.sphericalGenerators import ( - generate_spherical_small_circle_points, generate_spherical_arc_points, + generate_spherical_small_circle_points, ) +from pyMathTools.spherical.sphericalTransforms import plate_carree_transform deg45 = np.deg2rad(45) deg90 = np.deg2rad(90) @@ -21,21 +19,21 @@ def plot_unit_spherical_advanced( - circles: List[UnitSphericalSmallCircle] | None = None, - arcs: List[UnitSphericalArc] | None = None, - quaternions: List[Quaternion] | None = None, + circles: list[UnitSphericalSmallCircle] | None = None, + arcs: list[UnitSphericalArc] | None = None, + quaternions: list[Quaternion] | None = None, num_points: int = 120, - figsize: Tuple[int, int] = (10, 6), + figsize: tuple[int, int] = (10, 6), title: str = "Small Circles on Sphere (Flattened)", - colors: Optional[List[str]] = None, - labels: Optional[List[str]] = None, + colors: list[str] | None = None, + labels: list[str] | None = None, show_centers: bool = True, show_plot: bool = False, - ax: Optional[Axes] = None, - arc_colors: Optional[List[str]] = ["red", "green", "blue"], - circle_colors: Optional[List[str]] = ["red", "green", "blue"], - quaternion_colors: Optional[List[str]] = ["red", "green", "blue"], -) -> Tuple[Figure, Axes]: + ax: Axes | None = None, + arc_colors: list[str] | None = None, + circle_colors: list[str] | None = None, + quaternion_colors: list[str] | None = None, +) -> tuple[Figure, Axes]: """ Advanced version with more customization options. @@ -82,7 +80,14 @@ def plot_unit_spherical_advanced( """ # Helper function to get color with fallback to matplotlib's color cycle - def get_color(color_list: Optional[List[str]], idx: int) -> str: + if quaternion_colors is None: + quaternion_colors = ["red", "green", "blue"] + if circle_colors is None: + circle_colors = ["red", "green", "blue"] + if arc_colors is None: + arc_colors = ["red", "green", "blue"] + + def get_color(color_list: list[str] | None, idx: int) -> str: """Get color from list or fallback to matplotlib's color cycle.""" if color_list is None or len(color_list) == 0: return f"C{idx}" @@ -100,7 +105,6 @@ def get_color(color_list: Optional[List[str]], idx: int) -> str: if circles is not None: for idx, circle in enumerate(circles): - # Generate true spherical small circle points azimuth_points, polar_points = generate_spherical_small_circle_points( circle, num_points @@ -128,9 +132,7 @@ def get_color(color_list: Optional[List[str]], idx: int) -> str: # Plot center point if requested if show_centers: - x_center, y_center = plate_carree_transform( - circle.azimuth, circle.polar - ) + x_center, y_center = plate_carree_transform(circle.azimuth, circle.polar) ax.scatter( np.degrees(x_center), np.degrees(y_center), @@ -143,11 +145,8 @@ def get_color(color_list: Optional[List[str]], idx: int) -> str: # Generate points for each arc if arcs is not None: for idx, arc in enumerate(arcs): - # Generate spherical arc points - azimuth_points, polar_points = generate_spherical_arc_points( - arc, num_points - ) + azimuth_points, polar_points = generate_spherical_arc_points(arc, num_points) # Apply Plate Carrée projection x_points, y_points = plate_carree_transform(azimuth_points, polar_points) @@ -238,19 +237,19 @@ def get_color(color_list: Optional[List[str]], idx: int) -> str: def plot_unit_spherical_polar( - circles: List[UnitSphericalSmallCircle] | None = None, - arcs: List[UnitSphericalArc] | None = None, - quaternions: List[Quaternion] | None = None, + circles: list[UnitSphericalSmallCircle] | None = None, + arcs: list[UnitSphericalArc] | None = None, + quaternions: list[Quaternion] | None = None, num_points: int = 120, - figsize: Tuple[int, int] = (8, 8), + figsize: tuple[int, int] = (8, 8), title: str = "Small Circles on Sphere (Polar View)", - labels: Optional[List[str]] = None, - ax: Optional[Axes] = None, + labels: list[str] | None = None, + ax: Axes | None = None, show_plot: bool = False, - arc_colors: Optional[List[str]] = ["red", "green", "blue"], - circle_colors: Optional[List[str]] = ["red", "green", "blue"], - quaternion_colors: Optional[List[str]] = ["red", "green", "blue"], -) -> Tuple[Figure, Axes]: + arc_colors: list[str] | None = None, + circle_colors: list[str] | None = None, + quaternion_colors: list[str] | None = None, +) -> tuple[Figure, Axes]: """ Plot small circles and arcs on a sphere using polar projection. Azimuth is shown as the angle, polar angle is mapped to radius. @@ -291,7 +290,14 @@ def plot_unit_spherical_polar( """ # Helper function to get color with fallback to matplotlib's color cycle - def get_color(color_list: Optional[List[str]], idx: int) -> str: + if quaternion_colors is None: + quaternion_colors = ["red", "green", "blue"] + if circle_colors is None: + circle_colors = ["red", "green", "blue"] + if arc_colors is None: + arc_colors = ["red", "green", "blue"] + + def get_color(color_list: list[str] | None, idx: int) -> str: """Get color from list or fallback to matplotlib's color cycle.""" if color_list is None or len(color_list) == 0: return f"C{idx}" @@ -309,7 +315,6 @@ def get_color(color_list: Optional[List[str]], idx: int) -> str: if circles is not None: for idx, circle in enumerate(circles): - # Generate true spherical small circle points azimuth_points, polar_points = generate_spherical_small_circle_points( circle, num_points @@ -351,7 +356,9 @@ def get_color(color_list: Optional[List[str]], idx: int) -> str: radius_deg = np.degrees(circle.radius_angle) azimuth_deg = np.degrees(circle.azimuth) polar_deg = np.degrees(circle.polar) - label = f"Circle {idx + 1}: A:{azimuth_deg:.1f}°, P:{polar_deg:.1f}°, R:{radius_deg:.1f}°" + label = ( + f"Circle {idx + 1}: A:{azimuth_deg:.1f}°, P:{polar_deg:.1f}°, R:{radius_deg:.1f}°" + ) # Get color for this circle color = get_color(circle_colors, idx) @@ -369,11 +376,8 @@ def get_color(color_list: Optional[List[str]], idx: int) -> str: # Generate points for each arc if arcs is not None: for idx, arc in enumerate(arcs): - # Generate spherical arc points - azimuth_points, polar_points = generate_spherical_arc_points( - arc, num_points - ) + azimuth_points, polar_points = generate_spherical_arc_points(arc, num_points) # Map polar angle to radius (0 to 1) for polar plot radius_points = (polar_points + np.pi / 2) / np.pi @@ -440,12 +444,12 @@ def get_color(color_list: Optional[List[str]], idx: int) -> str: # Plot endpoint azimuth_deg = np.degrees(azimuth) polar_deg = np.degrees(polar) - label = f"Quat {idx + 1}: w:{quat.w:.1f}, x:{quat.x:.1f}, y:{quat.y:.1f}, z:{quat.z:.1f}" - - ax.scatter( - azimuth, radius, s=150, marker="*", label=label, c=color, zorder=10 + label = ( + f"Quat {idx + 1}: w:{quat.w:.1f}, x:{quat.x:.1f}, y:{quat.y:.1f}, z:{quat.z:.1f}" ) + ax.scatter(azimuth, radius, s=150, marker="*", label=label, c=color, zorder=10) + ax.set_title(title, fontsize=14, pad=20) if labels is not None: @@ -463,20 +467,20 @@ def get_color(color_list: Optional[List[str]], idx: int) -> str: def plot_unit_spherical_3d( - circles: List[UnitSphericalSmallCircle] | None = None, - arcs: List[UnitSphericalArc] | None = None, - quaternions: List[Quaternion] | None = None, + circles: list[UnitSphericalSmallCircle] | None = None, + arcs: list[UnitSphericalArc] | None = None, + quaternions: list[Quaternion] | None = None, num_points: int = 120, - figsize: Tuple[int, int] = (10, 10), + figsize: tuple[int, int] = (10, 10), title: str = "Small Circles on Unit Sphere (3D)", show_sphere: bool = True, show_legend: bool = True, show_plot: bool = False, - ax: Optional[Axes] = None, - arc_colors: Optional[List[str]] = ["red", "green", "blue"], - circle_colors: Optional[List[str]] = ["red", "green", "blue"], - quaternion_colors: Optional[List[str]] = ["red", "green", "blue"], -) -> Tuple[Figure, Axes]: + ax: Axes | None = None, + arc_colors: list[str] | None = None, + circle_colors: list[str] | None = None, + quaternion_colors: list[str] | None = None, +) -> tuple[Figure, Axes]: """ Plot small circles and arcs on a 3D unit sphere. @@ -518,7 +522,14 @@ def plot_unit_spherical_3d( """ # Helper function to get color with fallback to matplotlib's color cycle - def get_color(color_list: Optional[List[str]], idx: int) -> str: + if quaternion_colors is None: + quaternion_colors = ["red", "green", "blue"] + if circle_colors is None: + circle_colors = ["red", "green", "blue"] + if arc_colors is None: + arc_colors = ["red", "green", "blue"] + + def get_color(color_list: list[str] | None, idx: int) -> str: """Get color from list or fallback to matplotlib's color cycle.""" if color_list is None or len(color_list) == 0: return f"C{idx}" @@ -547,7 +558,6 @@ def get_color(color_list: Optional[List[str]], idx: int) -> str: # Plot each circle if circles is not None: for idx, circle in enumerate(circles): - # Generate true spherical small circle points azimuth_points, polar_points = generate_spherical_small_circle_points( circle, num_points @@ -564,7 +574,9 @@ def get_color(color_list: Optional[List[str]], idx: int) -> str: radius_deg = np.degrees(circle.radius_angle) azimuth_deg = np.degrees(circle.azimuth) polar_deg = np.degrees(circle.polar) - label = f"Circle {idx + 1}: A:{azimuth_deg:.1f}°, P:{polar_deg:.1f}°, R:{radius_deg:.1f}°" + label = ( + f"Circle {idx + 1}: A:{azimuth_deg:.1f}°, P:{polar_deg:.1f}°, R:{radius_deg:.1f}°" + ) # Get color for this circle color = get_color(circle_colors, idx) @@ -575,11 +587,8 @@ def get_color(color_list: Optional[List[str]], idx: int) -> str: # Plot each arc if arcs is not None: for idx, arc in enumerate(arcs): - # Generate spherical arc points - azimuth_points, polar_points = generate_spherical_arc_points( - arc, num_points - ) + azimuth_points, polar_points = generate_spherical_arc_points(arc, num_points) # Convert to Cartesian coordinates (unit sphere, r=1, physics convention) x = np.sin(polar_points) * np.cos(azimuth_points) @@ -634,7 +643,9 @@ def get_color(color_list: Optional[List[str]], idx: int) -> str: ) # Plot endpoint - label = f"Quat {idx + 1}: w:{quat.w:.1f}, x:{quat.x:.1f}, y:{quat.y:.1f}, z:{quat.z:.1f}" + label = ( + f"Quat {idx + 1}: w:{quat.w:.1f}, x:{quat.x:.1f}, y:{quat.y:.1f}, z:{quat.z:.1f}" + ) ax.scatter(x, y, z, s=50, marker="*", label=label, c=color, zorder=10) @@ -664,16 +675,16 @@ def get_color(color_list: Optional[List[str]], idx: int) -> str: def plot_unit_spherical_multiplot( - circles: List[UnitSphericalSmallCircle] | None = None, - arcs: List[UnitSphericalArc] | None = None, - quaternions: List[Quaternion] | None = None, + circles: list[UnitSphericalSmallCircle] | None = None, + arcs: list[UnitSphericalArc] | None = None, + quaternions: list[Quaternion] | None = None, num_points: int = 120, - figsize: Tuple[int, int] = (24, 6), + figsize: tuple[int, int] = (24, 6), title: str = "Small Circles on Unit Sphere - Multiple Views", show_plot: bool = False, - arc_colors: Optional[List[str]] = ["red", "green", "blue"], - circle_colors: Optional[List[str]] = ["red", "green", "blue"], - quaternion_colors: Optional[List[str]] = ["red", "green", "blue"], + arc_colors: list[str] | None = None, + circle_colors: list[str] | None = None, + quaternion_colors: list[str] | None = None, ) -> Figure: """ Create a multiplot showing Plate Carrée, Top View, and 3D views of spherical small circles. @@ -709,6 +720,12 @@ def plot_unit_spherical_multiplot( fig : matplotlib figure object """ # Create figure with overall title + if quaternion_colors is None: + quaternion_colors = ["red", "green", "blue"] + if circle_colors is None: + circle_colors = ["red", "green", "blue"] + if arc_colors is None: + arc_colors = ["red", "green", "blue"] fig = plt.figure(figsize=figsize) fig.suptitle(title, fontsize=16) @@ -786,7 +803,7 @@ def demo() -> None: """Demonstration of multiplot view showing spherical small circles in three different projections.""" # Define 4 circles using UnitSphericalSmallCircle dataclass - circles: List[UnitSphericalSmallCircle] = [ + [ UnitSphericalSmallCircle( azimuth=0, polar=0, @@ -829,7 +846,7 @@ def demo() -> None: ), ] - arcs: List[UnitSphericalArc] = [ + [ UnitSphericalArc(orient=0, azimuth=0, polar=0, arc_length=deg22_5), UnitSphericalArc(orient=deg45, azimuth=0, polar=0, arc_length=deg22_5), UnitSphericalArc(orient=deg90, azimuth=0, polar=0, arc_length=deg22_5), @@ -847,7 +864,7 @@ def demo() -> None: UnitSphericalArc(orient=0, azimuth=deg90, polar=deg45, arc_length=-deg22_5), ] - quats: List[Quaternion] = [ + quats: list[Quaternion] = [ Quaternion.from_components(w=1, x=0, y=0, z=0), Quaternion.from_components(w=0, x=1, y=0, z=0), Quaternion.from_components(w=0, x=0, y=1, z=0), diff --git a/tests/test_quaternion.py b/tests/test_quaternion.py index 384379d..8864716 100644 --- a/tests/test_quaternion.py +++ b/tests/test_quaternion.py @@ -13,6 +13,7 @@ """ import unittest + import numpy as np from numpy.testing import assert_allclose, assert_array_almost_equal @@ -252,7 +253,7 @@ def test_divide_scalar(self): def test_power(self): """Test quaternion power operation.""" q1 = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi / 2) - q2 = q1 ** 2 + q2 = q1**2 # (90° rotation)^2 should be 180° rotation assert_allclose(q2.angle, np.pi, rtol=1e-10) @@ -408,11 +409,7 @@ def test_to_rotation_matrix_90z(self): matrix = q.to_rotation_matrix() # 90° Z rotation matrix - expected = np.array([ - [0, -1, 0], - [1, 0, 0], - [0, 0, 1] - ]) + expected = np.array([[0, -1, 0], [1, 0, 0], [0, 0, 1]]) assert_array_almost_equal(matrix, expected, decimal=10) def test_to_rotation_matrix_180x(self): @@ -421,11 +418,7 @@ def test_to_rotation_matrix_180x(self): matrix = q.to_rotation_matrix() # 180° X rotation matrix - expected = np.array([ - [1, 0, 0], - [0, -1, 0], - [0, 0, -1] - ]) + expected = np.array([[1, 0, 0], [0, -1, 0], [0, 0, -1]]) assert_array_almost_equal(matrix, expected, decimal=10) def test_from_rotation_matrix_identity(self): @@ -437,11 +430,7 @@ def test_from_rotation_matrix_identity(self): def test_from_rotation_matrix_90z(self): """Test creating quaternion from 90° Z rotation matrix.""" - matrix = np.array([ - [0, -1, 0], - [1, 0, 0], - [0, 0, 1] - ]) + matrix = np.array([[0, -1, 0], [1, 0, 0], [0, 0, 1]]) q = Quaternion.from_rotation_matrix(matrix) expected = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi / 2) From 02efdd758dca5a3c825e9b0004a5445b6a013005 Mon Sep 17 00:00:00 2001 From: kopecn Date: Thu, 2 Jul 2026 15:58:32 -0700 Subject: [PATCH 03/70] cont... --- Makefile | 2 +- pyproject.toml | 2 +- src/pyMathTools/hints.py | 16 +-- src/pyMathTools/spatial/Quaternion.py | 6 +- src/pyMathTools/spatial/py.typed | 0 src/pyMathTools/spherical/constructors.py | 7 +- src/pyMathTools/spherical/py.typed | 0 .../spherical/sphericalGenerators.py | 12 +- .../spherical/sphericalTransforms.py | 10 +- .../plotUnitSpherical.py | 110 +++++++++++------- tests/test_quaternion.py | 2 +- 11 files changed, 97 insertions(+), 70 deletions(-) create mode 100644 src/pyMathTools/spatial/py.typed create mode 100644 src/pyMathTools/spherical/py.typed diff --git a/Makefile b/Makefile index 7de61cb..f28011a 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,7 @@ PY_ALL ?= $(PY_SRC) $(PY_TESTS) $(PY_EXAMPLES) # Tool runner for uv- quality/test recipes. `--extra dev` ensures ruff/mypy/pytest are # resolved (and installed if missing) from the "[dev]" extra even on a FRESH checkout — # no reliance on a pre-existing .venv, rather than the ambient PATH. -UV := uv run --extra dev --no-project +UV := uv run --no-project PIP := $(PYTHON) -m pip BUMPVERSION := bumpversion --allow-dirty REPO := $(notdir $(CURDIR)) diff --git a/pyproject.toml b/pyproject.toml index db889aa..50a3f2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,7 @@ select = ["E", "F", "I", "UP", "B"] ignore = ["D301"] [tool.mypy] -python_version = "3.10" +python_version = "3.13" strict = true files = ["src", "tests"] diff --git a/src/pyMathTools/hints.py b/src/pyMathTools/hints.py index 2e27fb8..e7caba7 100644 --- a/src/pyMathTools/hints.py +++ b/src/pyMathTools/hints.py @@ -7,26 +7,26 @@ from typing import TYPE_CHECKING, Union -import numpy as np -import quaternion from numpy import float64 from numpy.typing import NDArray +from quaternion import one as q_one # type: ignore[import-untyped] +from quaternion import quaternion as np_quaternion if TYPE_CHECKING: - from pyMathTools.spatial.quaternion import Quaternion + from pyMathTools.spatial.Quaternion import Quaternion # Basic numeric types -FloatOrNDArray = Union[float, NDArray[float64]] +FloatOrNDArray = float | NDArray[float64] FloatNDArray = NDArray[float64] # Quaternion types -QuaternionLike = Union["Quaternion", np.quaternion, NDArray[np.quaternion]] -FloatOrQuaternion = Union["Quaternion", float, np.quaternion] +QuaternionLike = Union["Quaternion", np_quaternion, NDArray[np_quaternion]] +FloatOrQuaternion = Union["Quaternion", float, np_quaternion] FloatArray3 = NDArray[float64] # Shape (..., 3) FloatArray4 = NDArray[float64] # Shape (..., 4) RotationMatrix = NDArray[float64] # Shape (..., 3, 3) if __name__ == "__main__": - q = quaternion.one - assert isinstance(q, np.quaternion) + q = q_one + assert isinstance(q, np_quaternion) diff --git a/src/pyMathTools/spatial/Quaternion.py b/src/pyMathTools/spatial/Quaternion.py index 7f6c194..c972d2a 100644 --- a/src/pyMathTools/spatial/Quaternion.py +++ b/src/pyMathTools/spatial/Quaternion.py @@ -10,7 +10,7 @@ import numpy as np from foundationTypes.mathTypes.MathTypes import UnitSphericalSmallCircleType from foundationTypes.mathTypes.quaternionABC import QuaternionABC -from quaternion import allclose as quat_allclose +from quaternion import allclose as quat_allclose # type: ignore[import-untyped] from quaternion import ( as_euler_angles, as_float_array, @@ -299,7 +299,9 @@ def axis(self) -> FloatArray3: @property def vector_spherical(self) -> tuple[float, float]: - """Return the quaternion's pointing direction in spherical coordinates (ISO physics convention). + """ + Return the quaternion's pointing direction in spherical coordinates + (ISO physics convention). The quaternion is applied to a reference direction (+X axis) to get the pointing direction, which is then converted to spherical coordinates. diff --git a/src/pyMathTools/spatial/py.typed b/src/pyMathTools/spatial/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/pyMathTools/spherical/constructors.py b/src/pyMathTools/spherical/constructors.py index 6e4b17c..076e0a1 100644 --- a/src/pyMathTools/spherical/constructors.py +++ b/src/pyMathTools/spherical/constructors.py @@ -6,7 +6,8 @@ """ import numpy as np -from foundationTypes.mathTypes.UnitSphericalArc import UnitSphericalArc +from foundationTypes.mathTypes.MathTypes import UnitSphericalArcType +from foundationTypes.mathTypes.unitSphericalArcABC import UnitSphericalArcABC from numpy import atan2 @@ -16,7 +17,7 @@ def arc_from_two_points( azimuth2: float, polar2: float, isPositive: bool = True, -) -> UnitSphericalArc: +) -> UnitSphericalArcABC: """ Create a UnitSphericalArc connecting two points on a unit sphere. @@ -88,7 +89,7 @@ def arc_from_two_points( if not isPositive: arc_length = -arc_length - return UnitSphericalArc( + return UnitSphericalArcType( azimuth=azimuth1, polar=polar1, orient=orient, diff --git a/src/pyMathTools/spherical/py.typed b/src/pyMathTools/spherical/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/pyMathTools/spherical/sphericalGenerators.py b/src/pyMathTools/spherical/sphericalGenerators.py index 4dbd7e3..94cd6cc 100644 --- a/src/pyMathTools/spherical/sphericalGenerators.py +++ b/src/pyMathTools/spherical/sphericalGenerators.py @@ -36,8 +36,8 @@ r = sqrt(x² + y² + z²) """ -from foundationTypes.mathTypes.UnitSphericalArc import UnitSphericalArc -from foundationTypes.mathTypes.UnitSphericalSmallCircle import UnitSphericalSmallCircle +from foundationTypes.mathTypes.unitSphericalArcABC import UnitSphericalArcABC +from foundationTypes.mathTypes.unitSphericalSmallCircleABC import UnitSphericalSmallCircleABC from numpy import ( arccos, arctan2, @@ -58,7 +58,7 @@ def generate_spherical_small_circle_points( - circle: UnitSphericalSmallCircle, + circle: UnitSphericalSmallCircleABC, num_points: int = 120, ) -> tuple[FloatNDArray, FloatNDArray]: """ @@ -71,7 +71,7 @@ def generate_spherical_small_circle_points( Parameters: ----------- - circle : UnitSphericalSmallCircle + circle : UnitSphericalSmallCircleABC Small circle specification with: - azimuth (φ): angle in xy-plane from +x axis [0, 2π) - polar (θ): angle from +z axis (colatitude) [0, π] @@ -155,7 +155,7 @@ def generate_spherical_small_circle_points( def generate_spherical_arc_points( - arc: UnitSphericalArc, + arc: UnitSphericalArcABC, num_points: int = 120, ) -> tuple[FloatNDArray, FloatNDArray]: """ @@ -172,7 +172,7 @@ def generate_spherical_arc_points( Parameters: ----------- - arc : UnitSphericalArc + arc : UnitSphericalArcABC Arc specification with: - azimuth (φ): starting point angle in xy-plane from +x axis [0, 2π) - polar (θ): starting point angle from +z axis (colatitude) [0, π] diff --git a/src/pyMathTools/spherical/sphericalTransforms.py b/src/pyMathTools/spherical/sphericalTransforms.py index f751604..4cd642b 100644 --- a/src/pyMathTools/spherical/sphericalTransforms.py +++ b/src/pyMathTools/spherical/sphericalTransforms.py @@ -14,7 +14,7 @@ See pyMathTools.generators.sphericalGenerators module docstring for complete details. """ -from foundationTypes.mathTypes.UnitSphericalArc import UnitSphericalArc +from foundationTypes.mathTypes.unitSphericalArcABC import UnitSphericalArcABC from numpy import ( arccos, arctan2, @@ -129,14 +129,14 @@ def cartesian_to_spherical(point: NDArray[float64]) -> tuple: def compute_spherical_arc_endpoint( - arc: UnitSphericalArc, + arc: UnitSphericalArcABC, ) -> tuple[float, float]: """ - Compute the endpoint of a UnitSphericalArc on a unit sphere. + Compute the endpoint of a UnitSphericalArcABC on a unit sphere. Parameters ---------- - arc : UnitSphericalArc + arc : UnitSphericalArcABC The spherical arc containing: - azimuth: Starting azimuth angle in radians (0 to 2π) - polar: Starting polar angle in radians (colatitude/zenith angle) @@ -162,7 +162,7 @@ def compute_spherical_arc_endpoint( Examples -------- - >>> arc = UnitSphericalArc( + >>> arc = UnitSphericalArcABC( ... arc_length=deg2rad(45), ... azimuth=0.0, ... orient=0.0, diff --git a/src/pyMathToolsPlotHelpers/plotUnitSpherical.py b/src/pyMathToolsPlotHelpers/plotUnitSpherical.py index 08f8c0d..00b4dcb 100644 --- a/src/pyMathToolsPlotHelpers/plotUnitSpherical.py +++ b/src/pyMathToolsPlotHelpers/plotUnitSpherical.py @@ -1,7 +1,10 @@ +from collections.abc import Sequence + import matplotlib.pyplot as plt import numpy as np -from foundationTypes.mathTypes.UnitSphericalArc import UnitSphericalArc -from foundationTypes.mathTypes.UnitSphericalSmallCircle import UnitSphericalSmallCircle +from foundationTypes.mathTypes.MathTypes import UnitSphericalArcType, UnitSphericalSmallCircleType +from foundationTypes.mathTypes.unitSphericalArcABC import UnitSphericalArcABC +from foundationTypes.mathTypes.unitSphericalSmallCircleABC import UnitSphericalSmallCircleABC from matplotlib.axes import Axes from matplotlib.figure import Figure @@ -19,8 +22,8 @@ def plot_unit_spherical_advanced( - circles: list[UnitSphericalSmallCircle] | None = None, - arcs: list[UnitSphericalArc] | None = None, + circles: list[UnitSphericalSmallCircleABC] | None = None, + arcs: list[UnitSphericalArcABC] | None = None, quaternions: list[Quaternion] | None = None, num_points: int = 120, figsize: tuple[int, int] = (10, 6), @@ -184,7 +187,13 @@ def get_color(color_list: list[str] | None, idx: int) -> str: label = labels[idx] if labels is not None and idx < len(labels) else None if label is None: - label = f"Quat {idx + 1}: w:{quat.w:.1f}, x:{quat.x:.1f}, y:{quat.y:.1f}, z:{quat.z:.1f}" + label = ( + f"Quat {idx + 1}: " + f"w:{quat.w:.1f}, " + f"x:{quat.x:.1f}, " + f"y:{quat.y:.1f}, " + f"z:{quat.z:.1f}" + ) # Plot vector as an arrow from origin to the point ax.annotate( @@ -237,8 +246,8 @@ def get_color(color_list: list[str] | None, idx: int) -> str: def plot_unit_spherical_polar( - circles: list[UnitSphericalSmallCircle] | None = None, - arcs: list[UnitSphericalArc] | None = None, + circles: list[UnitSphericalSmallCircleABC] | None = None, + arcs: list[UnitSphericalArcABC] | None = None, quaternions: list[Quaternion] | None = None, num_points: int = 120, figsize: tuple[int, int] = (8, 8), @@ -404,7 +413,13 @@ def get_color(color_list: list[str] | None, idx: int) -> str: azimuth_deg = np.degrees(arc.azimuth) polar_deg = np.degrees(arc.polar) orient_deg = np.degrees(arc.orient) - label = f"Arc {idx + 1}: A:{azimuth_deg:.1f}°, P:{polar_deg:.1f}°, L:{arc_length_deg:.1f}°, O:{orient_deg:.1f}°" + label = ( + f"Arc {idx + 1}: " + f"A:{azimuth_deg:.1f}°, " + f"P:{polar_deg:.1f}°, " + f"L:{arc_length_deg:.1f}°, " + f"O:{orient_deg:.1f}°" + ) # Get color for this arc color = get_color(arc_colors, idx) @@ -467,8 +482,8 @@ def get_color(color_list: list[str] | None, idx: int) -> str: def plot_unit_spherical_3d( - circles: list[UnitSphericalSmallCircle] | None = None, - arcs: list[UnitSphericalArc] | None = None, + circles: list[UnitSphericalSmallCircleABC] | None = None, + arcs: list[UnitSphericalArcABC] | None = None, quaternions: list[Quaternion] | None = None, num_points: int = 120, figsize: tuple[int, int] = (10, 10), @@ -600,7 +615,13 @@ def get_color(color_list: list[str] | None, idx: int) -> str: azimuth_deg = np.degrees(arc.azimuth) polar_deg = np.degrees(arc.polar) orient_deg = np.degrees(arc.orient) - label = f"Arc {idx + 1}: A:{azimuth_deg:.1f}°, P:{polar_deg:.1f}°, L:{arc_length_deg:.1f}°, O:{orient_deg:.1f}°" + label = ( + f"Arc {idx + 1}: " + f"A:{azimuth_deg:.1f}°, " + f"P:{polar_deg:.1f}°, " + f"L:{arc_length_deg:.1f}°, " + f"O:{orient_deg:.1f}°" + ) # Get color for this arc color = get_color(arc_colors, idx) @@ -675,8 +696,8 @@ def get_color(color_list: list[str] | None, idx: int) -> str: def plot_unit_spherical_multiplot( - circles: list[UnitSphericalSmallCircle] | None = None, - arcs: list[UnitSphericalArc] | None = None, + circles: Sequence[UnitSphericalSmallCircleABC] | None = None, + arcs: Sequence[UnitSphericalArcABC] | None = None, quaternions: list[Quaternion] | None = None, num_points: int = 120, figsize: tuple[int, int] = (24, 6), @@ -691,8 +712,8 @@ def plot_unit_spherical_multiplot( Parameters: ----------- - circles : List[UnitSphericalSmallCircle] or array-like - List of UnitSphericalSmallCircle objects or array of [azimuth, polar, radius] lists + circles : List[UnitSphericalSmallCircleABC] or array-like + List of UnitSphericalSmallCircleABC objects or array of [azimuth, polar, radius] lists arcs : List[UnitSphericalArc], optional List of UnitSphericalArc objects quaternions : List[Quaternion], optional @@ -800,68 +821,71 @@ def plot_unit_spherical_multiplot( def demo() -> None: - """Demonstration of multiplot view showing spherical small circles in three different projections.""" + """ + Demonstration of multiplot view showing spherical small circles in three + different projections. + """ # Define 4 circles using UnitSphericalSmallCircle dataclass - [ - UnitSphericalSmallCircle( + circles = [ + UnitSphericalSmallCircleType( azimuth=0, polar=0, radius_angle=deg45, ), - UnitSphericalSmallCircle( + UnitSphericalSmallCircleType( azimuth=0, polar=np.pi / 2, radius_angle=deg45, ), - UnitSphericalSmallCircle( + UnitSphericalSmallCircleType( azimuth=0, polar=np.pi, radius_angle=deg45, ), - UnitSphericalSmallCircle( + UnitSphericalSmallCircleType( azimuth=0, polar=-np.pi / 2, radius_angle=deg45, ), - UnitSphericalSmallCircle( + UnitSphericalSmallCircleType( azimuth=-np.pi / 2, polar=np.pi / 2, radius_angle=deg45, ), - UnitSphericalSmallCircle( + UnitSphericalSmallCircleType( azimuth=np.pi / 2, polar=np.pi / 2, radius_angle=deg45, ), - UnitSphericalSmallCircle( + UnitSphericalSmallCircleType( azimuth=-deg45, polar=deg45, radius_angle=deg45, ), - UnitSphericalSmallCircle( + UnitSphericalSmallCircleType( azimuth=np.deg2rad(135), polar=deg45, radius_angle=deg45, ), ] - [ - UnitSphericalArc(orient=0, azimuth=0, polar=0, arc_length=deg22_5), - UnitSphericalArc(orient=deg45, azimuth=0, polar=0, arc_length=deg22_5), - UnitSphericalArc(orient=deg90, azimuth=0, polar=0, arc_length=deg22_5), - UnitSphericalArc(orient=0, azimuth=0, polar=deg45, arc_length=deg22_5), - UnitSphericalArc(orient=0, azimuth=deg45, polar=deg45, arc_length=deg22_5), - UnitSphericalArc(orient=0, azimuth=deg90, polar=deg45, arc_length=deg22_5), - UnitSphericalArc(orient=deg45, azimuth=0, polar=deg45, arc_length=deg22_5), - UnitSphericalArc(orient=deg45, azimuth=deg45, polar=deg45, arc_length=deg22_5), - UnitSphericalArc(orient=deg45, azimuth=deg90, polar=deg45, arc_length=deg22_5), - UnitSphericalArc(orient=deg90, azimuth=0, polar=deg45, arc_length=deg22_5), - UnitSphericalArc(orient=deg90, azimuth=deg45, polar=deg45, arc_length=deg22_5), - UnitSphericalArc(orient=deg90, azimuth=deg90, polar=deg45, arc_length=deg22_5), - UnitSphericalArc(orient=0, azimuth=0, polar=deg45, arc_length=-deg22_5), - UnitSphericalArc(orient=0, azimuth=deg45, polar=deg45, arc_length=-deg22_5), - UnitSphericalArc(orient=0, azimuth=deg90, polar=deg45, arc_length=-deg22_5), + arcs = [ + UnitSphericalArcType(orient=0, azimuth=0, polar=0, arc_length=deg22_5), + UnitSphericalArcType(orient=deg45, azimuth=0, polar=0, arc_length=deg22_5), + UnitSphericalArcType(orient=deg90, azimuth=0, polar=0, arc_length=deg22_5), + UnitSphericalArcType(orient=0, azimuth=0, polar=deg45, arc_length=deg22_5), + UnitSphericalArcType(orient=0, azimuth=deg45, polar=deg45, arc_length=deg22_5), + UnitSphericalArcType(orient=0, azimuth=deg90, polar=deg45, arc_length=deg22_5), + UnitSphericalArcType(orient=deg45, azimuth=0, polar=deg45, arc_length=deg22_5), + UnitSphericalArcType(orient=deg45, azimuth=deg45, polar=deg45, arc_length=deg22_5), + UnitSphericalArcType(orient=deg45, azimuth=deg90, polar=deg45, arc_length=deg22_5), + UnitSphericalArcType(orient=deg90, azimuth=0, polar=deg45, arc_length=deg22_5), + UnitSphericalArcType(orient=deg90, azimuth=deg45, polar=deg45, arc_length=deg22_5), + UnitSphericalArcType(orient=deg90, azimuth=deg90, polar=deg45, arc_length=deg22_5), + UnitSphericalArcType(orient=0, azimuth=0, polar=deg45, arc_length=-deg22_5), + UnitSphericalArcType(orient=0, azimuth=deg45, polar=deg45, arc_length=-deg22_5), + UnitSphericalArcType(orient=0, azimuth=deg90, polar=deg45, arc_length=-deg22_5), ] quats: list[Quaternion] = [ @@ -879,8 +903,8 @@ def demo() -> None: ] _ = plot_unit_spherical_multiplot( - # circles=circles, - # arcs=arcs, + circles=circles, + arcs=arcs, quaternions=quats, show_plot=True, ) diff --git a/tests/test_quaternion.py b/tests/test_quaternion.py index 8864716..f100eaa 100644 --- a/tests/test_quaternion.py +++ b/tests/test_quaternion.py @@ -806,7 +806,7 @@ def test_dict_modification_doesnt_affect_quaternion(self): def test_inheritance_from_quaternion_type(self): """Test that Quaternion properly inherits from QuaternionType.""" - from foundationTypes.mathTypes.QuaternionType import QuaternionType + from foundationTypes.mathTypes.MathTypes import QuaternionType q = Quaternion.from_components(1.0, 2.0, 3.0, 4.0) self.assertIsInstance(q, QuaternionType) From d339936c27aebd30ab6273b704ff3daaeae83c44 Mon Sep 17 00:00:00 2001 From: kopecn Date: Thu, 2 Jul 2026 16:06:21 -0700 Subject: [PATCH 04/70] cool... --- src/pyMathTools/spatial/Quaternion.py | 24 +-- .../spherical/sphericalTransforms.py | 2 +- .../plotUnitSpherical.py | 48 ++--- tests/test_quaternion.py | 178 +++++++++--------- 4 files changed, 127 insertions(+), 125 deletions(-) diff --git a/src/pyMathTools/spatial/Quaternion.py b/src/pyMathTools/spatial/Quaternion.py index c972d2a..db95c37 100644 --- a/src/pyMathTools/spatial/Quaternion.py +++ b/src/pyMathTools/spatial/Quaternion.py @@ -120,25 +120,25 @@ def as_quat_array(array: np.ndarray) -> np.ndarray: >>> Quaternion.as_quat_array([[1,0,0,0], [0.707,0,0,0.707]]) array([quaternion(1, 0, 0, 0), quaternion(0.707, 0, 0, 0.707)]) """ - return as_quat_array(array) + return np.asarray(as_quat_array(array)) # MARK: - Properties @property def w(self) -> float: - return self.__q.w + return float(self.__q.w) @property def x(self) -> float: - return self.__q.x + return float(self.__q.x) @property def y(self) -> float: - return self.__q.y + return float(self.__q.y) @property def z(self) -> float: - return self.__q.z + return float(self.__q.z) @property def q(self) -> np_quaternion: @@ -295,7 +295,7 @@ def axis(self) -> FloatArray3: norm = np.linalg.norm(vect) if norm < 1e-10: return np.array([0.0, 0.0, 1.0]) # Default axis for identity - return vect / norm + return np.asarray(vect / norm, dtype=np.float64) @property def vector_spherical(self) -> tuple[float, float]: @@ -369,7 +369,7 @@ def as_float_array(self) -> FloatArray4: This function is fast because no data is copied; the returned quantity is just a "view" of the original. """ - return as_float_array(self.__q) + return np.asarray(as_float_array(self.__q), dtype=np.float64) def to_components(self) -> tuple[float, float, float, float]: """Return quaternion components as a tuple (w, x, y, z).""" @@ -390,7 +390,7 @@ def to_rotation_matrix(self) -> RotationMatrix: Raises: ZeroDivisionError: If this quaternion has zero norm """ - return as_rotation_matrix(self.__q) + return np.asarray(as_rotation_matrix(self.__q), dtype=np.float64) @classmethod def from_rotation_matrix(cls: type[T], matrix: RotationMatrix) -> T: @@ -421,7 +421,7 @@ def to_rotation_vector(self) -> FloatArray3: Returns: 3-element array representing axis-angle rotation """ - return as_rotation_vector(self.__q) + return np.asarray(as_rotation_vector(self.__q), dtype=np.float64) @classmethod def from_rotation_vector(cls: type[T], rotation_vector: FloatArray3) -> T: @@ -452,7 +452,7 @@ def to_euler_angles(self) -> FloatArray3: Returns: Array of (alpha, beta, gamma) in radians """ - return as_euler_angles(self.__q) + return np.asarray(as_euler_angles(self.__q), dtype=np.float64) @classmethod def from_euler_angles( @@ -503,7 +503,7 @@ def to_vector_part(self) -> FloatArray3: Returns: 3-element array [x, y, z] """ - return as_vector_part(self.__q) + return np.asarray(as_vector_part(self.__q), dtype=np.float64) def rotate_vector(self, vector: FloatArray3) -> FloatArray3: """Rotate a 3D vector by this quaternion. @@ -517,7 +517,7 @@ def rotate_vector(self, vector: FloatArray3) -> FloatArray3: Returns: Rotated 3-element vector """ - return rotate_vectors(self.__q, vector) + return np.asarray(rotate_vectors(self.__q, vector), dtype=np.float64) # MARK: - Interpolation Methods diff --git a/src/pyMathTools/spherical/sphericalTransforms.py b/src/pyMathTools/spherical/sphericalTransforms.py index 4cd642b..fc42b8e 100644 --- a/src/pyMathTools/spherical/sphericalTransforms.py +++ b/src/pyMathTools/spherical/sphericalTransforms.py @@ -102,7 +102,7 @@ def spherical_to_cartesian(azimuth: float, polar: float) -> tuple[float, float, return (x, y, z) -def cartesian_to_spherical(point: NDArray[float64]) -> tuple: +def cartesian_to_spherical(point: NDArray[float64]) -> tuple[float, float]: """ Convert Cartesian coordinates to spherical coordinates. diff --git a/src/pyMathToolsPlotHelpers/plotUnitSpherical.py b/src/pyMathToolsPlotHelpers/plotUnitSpherical.py index 00b4dcb..932e567 100644 --- a/src/pyMathToolsPlotHelpers/plotUnitSpherical.py +++ b/src/pyMathToolsPlotHelpers/plotUnitSpherical.py @@ -1,4 +1,5 @@ from collections.abc import Sequence +from typing import cast import matplotlib.pyplot as plt import numpy as np @@ -7,6 +8,7 @@ from foundationTypes.mathTypes.unitSphericalSmallCircleABC import UnitSphericalSmallCircleABC from matplotlib.axes import Axes from matplotlib.figure import Figure +from mpl_toolkits.mplot3d import Axes3D # type: ignore[import-untyped] from pyMathTools.spatial.Quaternion import Quaternion from pyMathTools.spherical.sphericalGenerators import ( @@ -22,8 +24,8 @@ def plot_unit_spherical_advanced( - circles: list[UnitSphericalSmallCircleABC] | None = None, - arcs: list[UnitSphericalArcABC] | None = None, + circles: Sequence[UnitSphericalSmallCircleABC] | None = None, + arcs: Sequence[UnitSphericalArcABC] | None = None, quaternions: list[Quaternion] | None = None, num_points: int = 120, figsize: tuple[int, int] = (10, 6), @@ -104,7 +106,7 @@ def get_color(color_list: list[str] | None, idx: int) -> str: if ax is None: fig, ax = plt.subplots(figsize=figsize) else: - fig = ax.get_figure() + fig = cast(Figure, ax.get_figure()) if circles is not None: for idx, circle in enumerate(circles): @@ -246,8 +248,8 @@ def get_color(color_list: list[str] | None, idx: int) -> str: def plot_unit_spherical_polar( - circles: list[UnitSphericalSmallCircleABC] | None = None, - arcs: list[UnitSphericalArcABC] | None = None, + circles: Sequence[UnitSphericalSmallCircleABC] | None = None, + arcs: Sequence[UnitSphericalArcABC] | None = None, quaternions: list[Quaternion] | None = None, num_points: int = 120, figsize: tuple[int, int] = (8, 8), @@ -320,7 +322,7 @@ def get_color(color_list: list[str] | None, idx: int) -> str: if ax is None: fig, ax = plt.subplots(figsize=figsize, subplot_kw={"projection": "polar"}) else: - fig = ax.get_figure() + fig = cast(Figure, ax.get_figure()) if circles is not None: for idx, circle in enumerate(circles): @@ -358,8 +360,8 @@ def get_color(color_list: list[str] | None, idx: int) -> str: azimuth_plot.append(azimuth_points[0]) radius_plot.append(radius_points[0]) - azimuth_plot = np.array(azimuth_plot) - radius_plot = np.array(radius_plot) + azimuth_arr = np.array(azimuth_plot) + radius_arr = np.array(radius_plot) # Convert radius to degrees for legend radius_deg = np.degrees(circle.radius_angle) @@ -374,8 +376,8 @@ def get_color(color_list: list[str] | None, idx: int) -> str: # Plot in polar coordinates using plot instead of scatter for continuous lines ax.plot( - azimuth_plot, - radius_plot, + azimuth_arr, + radius_arr, linewidth=2, alpha=0.7, label=label, @@ -405,8 +407,8 @@ def get_color(color_list: list[str] | None, idx: int) -> str: azimuth_plot.append(azimuth_points[i]) radius_plot.append(radius_points[i]) - azimuth_plot = np.array(azimuth_plot) - radius_plot = np.array(radius_plot) + azimuth_arr = np.array(azimuth_plot) + radius_arr = np.array(radius_plot) # Convert to degrees for legend arc_length_deg = np.degrees(arc.arc_length) @@ -426,8 +428,8 @@ def get_color(color_list: list[str] | None, idx: int) -> str: # Plot the arc ax.plot( - azimuth_plot, - radius_plot, + azimuth_arr, + radius_arr, linewidth=2, alpha=0.8, label=label, @@ -482,8 +484,8 @@ def get_color(color_list: list[str] | None, idx: int) -> str: def plot_unit_spherical_3d( - circles: list[UnitSphericalSmallCircleABC] | None = None, - arcs: list[UnitSphericalArcABC] | None = None, + circles: Sequence[UnitSphericalSmallCircleABC] | None = None, + arcs: Sequence[UnitSphericalArcABC] | None = None, quaternions: list[Quaternion] | None = None, num_points: int = 120, figsize: tuple[int, int] = (10, 10), @@ -491,7 +493,7 @@ def plot_unit_spherical_3d( show_sphere: bool = True, show_legend: bool = True, show_plot: bool = False, - ax: Axes | None = None, + ax: Axes3D | None = None, arc_colors: list[str] | None = None, circle_colors: list[str] | None = None, quaternion_colors: list[str] | None = None, @@ -559,7 +561,7 @@ def get_color(color_list: list[str] | None, idx: int) -> str: fig = plt.figure(figsize=figsize) ax = fig.add_subplot(111, projection="3d") else: - fig = ax.get_figure() + fig = cast(Figure, ax.get_figure()) # Optionally plot the sphere surface (physics convention) if show_sphere: @@ -643,7 +645,7 @@ def get_color(color_list: list[str] | None, idx: int) -> str: if quaternions is not None: for idx, quat in enumerate(quaternions): # Convert quaternion to Cartesian coordinates - x, y, z = quat.vector_cartesian + qx, qy, qz = quat.vector_cartesian # Get color for this quaternion color = get_color(quaternion_colors, idx) @@ -653,9 +655,9 @@ def get_color(color_list: list[str] | None, idx: int) -> str: 0, 0, 0, - x, - y, - z, + qx, + qy, + qz, length=1.0, arrow_length_ratio=0.05, color=color, @@ -668,7 +670,7 @@ def get_color(color_list: list[str] | None, idx: int) -> str: f"Quat {idx + 1}: w:{quat.w:.1f}, x:{quat.x:.1f}, y:{quat.y:.1f}, z:{quat.z:.1f}" ) - ax.scatter(x, y, z, s=50, marker="*", label=label, c=color, zorder=10) + ax.scatter(qx, qy, qz, s=50, marker="*", label=label, c=color, zorder=10) ax.set_xlabel("X") ax.set_ylabel("Y") diff --git a/tests/test_quaternion.py b/tests/test_quaternion.py index f100eaa..35d0380 100644 --- a/tests/test_quaternion.py +++ b/tests/test_quaternion.py @@ -23,7 +23,7 @@ class TestQuaternionConstruction(unittest.TestCase): """Test various construction methods for Quaternion.""" - def test_from_components_identity(self): + def test_from_components_identity(self) -> None: """Test creating identity quaternion from components.""" q = Quaternion.from_components(1, 0, 0, 0) self.assertEqual(q.w, 1.0) @@ -31,7 +31,7 @@ def test_from_components_identity(self): self.assertEqual(q.y, 0.0) self.assertEqual(q.z, 0.0) - def test_from_components_general(self): + def test_from_components_general(self) -> None: """Test creating general quaternion from components.""" q = Quaternion.from_components(0.7071, 0.0, 0.0, 0.7071) assert_allclose(q.w, 0.7071, rtol=1e-4) @@ -39,7 +39,7 @@ def test_from_components_general(self): assert_allclose(q.y, 0.0, atol=1e-10) assert_allclose(q.z, 0.7071, rtol=1e-4) - def test_from_components_default(self): + def test_from_components_default(self) -> None: """Test default values in from_components.""" q = Quaternion.from_components() self.assertEqual(q.w, 1.0) @@ -47,7 +47,7 @@ def test_from_components_default(self): self.assertEqual(q.y, 0.0) self.assertEqual(q.z, 0.0) - def test_identity(self): + def test_identity(self) -> None: """Test identity quaternion constructor.""" q = Quaternion.identity() self.assertEqual(q.w, 1.0) @@ -56,7 +56,7 @@ def test_identity(self): self.assertEqual(q.z, 0.0) self.assertTrue(q.is_unit) - def test_from_axis_angle_z_90(self): + def test_from_axis_angle_z_90(self) -> None: """Test construction from axis-angle (90° around Z).""" axis = np.array([0, 0, 1]) angle = np.pi / 2 @@ -67,7 +67,7 @@ def test_from_axis_angle_z_90(self): assert_allclose(q.y, 0.0, atol=1e-10) assert_allclose(q.z, np.sin(np.pi / 4), rtol=1e-10) - def test_from_axis_angle_x_180(self): + def test_from_axis_angle_x_180(self) -> None: """Test construction from axis-angle (180° around X).""" axis = np.array([1, 0, 0]) angle = np.pi @@ -78,7 +78,7 @@ def test_from_axis_angle_x_180(self): assert_allclose(q.y, 0.0, atol=1e-10) assert_allclose(q.z, 0.0, atol=1e-10) - def test_from_axis_angle_unnormalized(self): + def test_from_axis_angle_unnormalized(self) -> None: """Test that axis gets normalized automatically.""" axis = np.array([2, 0, 0]) # Not normalized angle = np.pi / 2 @@ -87,7 +87,7 @@ def test_from_axis_angle_unnormalized(self): # Should be same as normalized axis self.assertTrue(q.is_unit) - def test_from_vector_part(self): + def test_from_vector_part(self) -> None: """Test creating pure quaternion from vector.""" vec = np.array([1.0, 2.0, 3.0]) q = Quaternion.from_vector_part(vec) @@ -101,61 +101,61 @@ def test_from_vector_part(self): class TestQuaternionProperties(unittest.TestCase): """Test quaternion properties and attributes.""" - def test_norm_identity(self): + def test_norm_identity(self) -> None: """Test norm of identity quaternion.""" q = Quaternion.identity() assert_allclose(q.norm, 1.0) - def test_norm_general(self): + def test_norm_general(self) -> None: """Test norm of general quaternion.""" q = Quaternion.from_components(1, 2, 3, 4) expected_norm = np.sqrt(1 + 4 + 9 + 16) assert_allclose(q.norm, expected_norm) - def test_is_unit_true(self): + def test_is_unit_true(self) -> None: """Test is_unit for unit quaternion.""" q = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi / 2) self.assertTrue(q.is_unit) - def test_is_unit_false(self): + def test_is_unit_false(self) -> None: """Test is_unit for non-unit quaternion.""" q = Quaternion.from_components(1, 1, 1, 1) self.assertFalse(q.is_unit) - def test_angle_identity(self): + def test_angle_identity(self) -> None: """Test angle of identity quaternion.""" q = Quaternion.identity() assert_allclose(q.angle, 0.0, atol=1e-10) - def test_angle_90_degrees(self): + def test_angle_90_degrees(self) -> None: """Test angle extraction for 90° rotation.""" q = Quaternion.from_axis_angle(np.array([1, 0, 0]), np.pi / 2) assert_allclose(q.angle, np.pi / 2, rtol=1e-10) - def test_angle_180_degrees(self): + def test_angle_180_degrees(self) -> None: """Test angle extraction for 180° rotation.""" q = Quaternion.from_axis_angle(np.array([0, 1, 0]), np.pi) assert_allclose(q.angle, np.pi, rtol=1e-10) - def test_axis_z(self): + def test_axis_z(self) -> None: """Test axis extraction for Z-axis rotation.""" axis = np.array([0, 0, 1]) q = Quaternion.from_axis_angle(axis, np.pi / 2) assert_array_almost_equal(q.axis, axis, decimal=10) - def test_axis_general(self): + def test_axis_general(self) -> None: """Test axis extraction for general rotation.""" axis = np.array([1, 1, 1]) / np.sqrt(3) q = Quaternion.from_axis_angle(axis, np.pi / 4) assert_array_almost_equal(q.axis, axis, decimal=10) - def test_axis_identity(self): + def test_axis_identity(self) -> None: """Test axis for identity quaternion (defaults to Z).""" q = Quaternion.identity() # Identity should return default axis self.assertEqual(q.axis.shape, (3,)) - def test_to_components(self): + def test_to_components(self) -> None: """Test components tuple extraction.""" q = Quaternion.from_components(1, 2, 3, 4) w, x, y, z = q.to_components() @@ -168,7 +168,7 @@ def test_to_components(self): class TestQuaternionArithmetic(unittest.TestCase): """Test arithmetic operations on quaternions.""" - def test_add_quaternions(self): + def test_add_quaternions(self) -> None: """Test quaternion addition.""" q1 = Quaternion.from_components(1, 2, 3, 4) q2 = Quaternion.from_components(5, 6, 7, 8) @@ -179,7 +179,7 @@ def test_add_quaternions(self): self.assertEqual(q3.y, 10.0) self.assertEqual(q3.z, 12.0) - def test_add_scalar(self): + def test_add_scalar(self) -> None: """Test adding scalar to quaternion.""" q1 = Quaternion.from_components(1, 2, 3, 4) q2 = q1 + 5 @@ -189,7 +189,7 @@ def test_add_scalar(self): self.assertEqual(q2.y, 3.0) self.assertEqual(q2.z, 4.0) - def test_radd_scalar(self): + def test_radd_scalar(self) -> None: """Test right addition with scalar.""" q1 = Quaternion.from_components(1, 2, 3, 4) q2 = 5 + q1 @@ -199,7 +199,7 @@ def test_radd_scalar(self): self.assertEqual(q2.y, 3.0) self.assertEqual(q2.z, 4.0) - def test_subtract_quaternions(self): + def test_subtract_quaternions(self) -> None: """Test quaternion subtraction.""" q1 = Quaternion.from_components(5, 6, 7, 8) q2 = Quaternion.from_components(1, 2, 3, 4) @@ -210,7 +210,7 @@ def test_subtract_quaternions(self): self.assertEqual(q3.y, 4.0) self.assertEqual(q3.z, 4.0) - def test_multiply_quaternions(self): + def test_multiply_quaternions(self) -> None: """Test quaternion multiplication (Hamilton product).""" # 90° rotation around Z followed by 90° rotation around X q1 = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi / 2) @@ -220,7 +220,7 @@ def test_multiply_quaternions(self): # Result should be a valid rotation self.assertTrue(q3.is_unit) - def test_multiply_scalar(self): + def test_multiply_scalar(self) -> None: """Test multiplying quaternion by scalar.""" q1 = Quaternion.from_components(1, 2, 3, 4) q2 = q1 * 2 @@ -230,7 +230,7 @@ def test_multiply_scalar(self): self.assertEqual(q2.y, 6.0) self.assertEqual(q2.z, 8.0) - def test_divide_quaternions(self): + def test_divide_quaternions(self) -> None: """Test quaternion division.""" q1 = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi / 2) q2 = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi / 4) @@ -240,7 +240,7 @@ def test_divide_quaternions(self): self.assertTrue(q3.is_unit) assert_allclose(q3.angle, np.pi / 4, rtol=1e-10) - def test_divide_scalar(self): + def test_divide_scalar(self) -> None: """Test dividing quaternion by scalar.""" q1 = Quaternion.from_components(2, 4, 6, 8) q2 = q1 / 2 @@ -250,7 +250,7 @@ def test_divide_scalar(self): self.assertEqual(q2.y, 3.0) self.assertEqual(q2.z, 4.0) - def test_power(self): + def test_power(self) -> None: """Test quaternion power operation.""" q1 = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi / 2) q2 = q1**2 @@ -258,7 +258,7 @@ def test_power(self): # (90° rotation)^2 should be 180° rotation assert_allclose(q2.angle, np.pi, rtol=1e-10) - def test_negate(self): + def test_negate(self) -> None: """Test quaternion negation.""" q1 = Quaternion.from_components(1, 2, 3, 4) q2 = -q1 @@ -268,7 +268,7 @@ def test_negate(self): self.assertEqual(q2.y, -3.0) self.assertEqual(q2.z, -4.0) - def test_positive(self): + def test_positive(self) -> None: """Test unary positive.""" q1 = Quaternion.from_components(1, 2, 3, 4) q2 = +q1 @@ -278,7 +278,7 @@ def test_positive(self): self.assertEqual(q2.y, 3.0) self.assertEqual(q2.z, 4.0) - def test_abs(self): + def test_abs(self) -> None: """Test absolute value (norm).""" q = Quaternion.from_components(1, 2, 3, 4) expected_norm = np.sqrt(30) @@ -288,43 +288,43 @@ def test_abs(self): class TestQuaternionComparison(unittest.TestCase): """Test comparison operations.""" - def test_equality_same(self): + def test_equality_same(self) -> None: """Test equality for identical quaternions.""" q1 = Quaternion.from_components(1, 2, 3, 4) q2 = Quaternion.from_components(1, 2, 3, 4) self.assertEqual(q1, q2) - def test_equality_different(self): + def test_equality_different(self) -> None: """Test equality for different quaternions.""" q1 = Quaternion.from_components(1, 2, 3, 4) q2 = Quaternion.from_components(1, 2, 3, 5) self.assertNotEqual(q1, q2) - def test_inequality(self): + def test_inequality(self) -> None: """Test inequality operator.""" q1 = Quaternion.from_components(1, 2, 3, 4) q2 = Quaternion.from_components(1, 2, 3, 5) self.assertNotEqual(q1, q2) - def test_isclose_true(self): + def test_isclose_true(self) -> None: """Test isclose for nearly equal quaternions.""" q1 = Quaternion.from_components(1.0, 2.0, 3.0, 4.0) q2 = Quaternion.from_components(1.0 + 1e-10, 2.0, 3.0, 4.0) self.assertTrue(q1.isclose(q2)) - def test_isclose_false(self): + def test_isclose_false(self) -> None: """Test isclose for different quaternions.""" q1 = Quaternion.from_components(1.0, 2.0, 3.0, 4.0) q2 = Quaternion.from_components(1.1, 2.0, 3.0, 4.0) self.assertFalse(q1.isclose(q2)) - def test_allclose_true(self): + def test_allclose_true(self) -> None: """Test static allclose method.""" q1 = Quaternion.from_components(1.0, 2.0, 3.0, 4.0) q2 = Quaternion.from_components(1.0 + 1e-10, 2.0, 3.0, 4.0) self.assertTrue(Quaternion.allclose(q1, q2)) - def test_repr(self): + def test_repr(self) -> None: """Test __repr__ output.""" q = Quaternion.from_components(1, 2, 3, 4) repr_str = repr(q) @@ -332,7 +332,7 @@ def test_repr(self): self.assertIn("w=1", repr_str) self.assertIn("x=2", repr_str) - def test_str(self): + def test_str(self) -> None: """Test __str__ output.""" q = Quaternion.from_components(1, 2, 3, 4) str_repr = str(q) @@ -342,7 +342,7 @@ def test_str(self): class TestQuaternionUnaryOperations(unittest.TestCase): """Test unary operations on quaternions.""" - def test_conjugate(self): + def test_conjugate(self) -> None: """Test quaternion conjugate.""" q = Quaternion.from_components(1, 2, 3, 4) q_conj = q.conjugate() @@ -352,13 +352,13 @@ def test_conjugate(self): self.assertEqual(q_conj.y, -3.0) self.assertEqual(q_conj.z, -4.0) - def test_conjugate_identity(self): + def test_conjugate_identity(self) -> None: """Test conjugate of identity.""" q = Quaternion.identity() q_conj = q.conjugate() self.assertEqual(q, q_conj) - def test_inverse_unit(self): + def test_inverse_unit(self) -> None: """Test inverse of unit quaternion.""" q = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi / 2) q_inv = q.inverse() @@ -366,7 +366,7 @@ def test_inverse_unit(self): # For unit quaternions, inverse equals conjugate self.assertTrue(q_inv.isclose(q.conjugate())) - def test_inverse_times_quaternion(self): + def test_inverse_times_quaternion(self) -> None: """Test that q * q^-1 = identity.""" q = Quaternion.from_components(1, 2, 3, 4) q_inv = q.inverse() @@ -376,7 +376,7 @@ def test_inverse_times_quaternion(self): identity = Quaternion.identity() self.assertTrue(result.isclose(identity, atol=1e-10)) - def test_normalized_general(self): + def test_normalized_general(self) -> None: """Test normalization of general quaternion.""" q = Quaternion.from_components(1, 2, 3, 4) q_norm = q.normalized() @@ -384,7 +384,7 @@ def test_normalized_general(self): self.assertTrue(q_norm.is_unit) assert_allclose(q_norm.norm, 1.0) - def test_normalized_already_unit(self): + def test_normalized_already_unit(self) -> None: """Test normalizing already unit quaternion.""" q = Quaternion.identity() q_norm = q.normalized() @@ -395,7 +395,7 @@ def test_normalized_already_unit(self): class TestRotationMatrixConversions(unittest.TestCase): """Test conversions to/from rotation matrices.""" - def test_to_rotation_matrix_identity(self): + def test_to_rotation_matrix_identity(self) -> None: """Test identity quaternion to rotation matrix.""" q = Quaternion.identity() matrix = q.to_rotation_matrix() @@ -403,7 +403,7 @@ def test_to_rotation_matrix_identity(self): expected = np.eye(3) assert_array_almost_equal(matrix, expected, decimal=10) - def test_to_rotation_matrix_90z(self): + def test_to_rotation_matrix_90z(self) -> None: """Test 90° Z rotation to matrix.""" q = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi / 2) matrix = q.to_rotation_matrix() @@ -412,7 +412,7 @@ def test_to_rotation_matrix_90z(self): expected = np.array([[0, -1, 0], [1, 0, 0], [0, 0, 1]]) assert_array_almost_equal(matrix, expected, decimal=10) - def test_to_rotation_matrix_180x(self): + def test_to_rotation_matrix_180x(self) -> None: """Test 180° X rotation to matrix.""" q = Quaternion.from_axis_angle(np.array([1, 0, 0]), np.pi) matrix = q.to_rotation_matrix() @@ -421,14 +421,14 @@ def test_to_rotation_matrix_180x(self): expected = np.array([[1, 0, 0], [0, -1, 0], [0, 0, -1]]) assert_array_almost_equal(matrix, expected, decimal=10) - def test_from_rotation_matrix_identity(self): + def test_from_rotation_matrix_identity(self) -> None: """Test creating quaternion from identity matrix.""" matrix = np.eye(3) q = Quaternion.from_rotation_matrix(matrix) self.assertTrue(q.isclose(Quaternion.identity())) - def test_from_rotation_matrix_90z(self): + def test_from_rotation_matrix_90z(self) -> None: """Test creating quaternion from 90° Z rotation matrix.""" matrix = np.array([[0, -1, 0], [1, 0, 0], [0, 0, 1]]) q = Quaternion.from_rotation_matrix(matrix) @@ -436,7 +436,7 @@ def test_from_rotation_matrix_90z(self): expected = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi / 2) self.assertTrue(q.isclose(expected)) - def test_roundtrip_matrix_conversion(self): + def test_roundtrip_matrix_conversion(self) -> None: """Test quaternion -> matrix -> quaternion roundtrip.""" q_original = Quaternion.from_axis_angle(np.array([1, 1, 1]) / np.sqrt(3), np.pi / 3) matrix = q_original.to_rotation_matrix() @@ -449,14 +449,14 @@ def test_roundtrip_matrix_conversion(self): class TestRotationVectorConversions(unittest.TestCase): """Test conversions to/from rotation vectors (axis-angle).""" - def test_to_rotation_vector_identity(self): + def test_to_rotation_vector_identity(self) -> None: """Test identity quaternion to rotation vector.""" q = Quaternion.identity() vec = q.to_rotation_vector() assert_array_almost_equal(vec, np.zeros(3), decimal=10) - def test_to_rotation_vector_90z(self): + def test_to_rotation_vector_90z(self) -> None: """Test 90° Z rotation to rotation vector.""" q = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi / 2) vec = q.to_rotation_vector() @@ -464,14 +464,14 @@ def test_to_rotation_vector_90z(self): expected = np.array([0, 0, np.pi / 2]) assert_array_almost_equal(vec, expected, decimal=10) - def test_from_rotation_vector_identity(self): + def test_from_rotation_vector_identity(self) -> None: """Test creating quaternion from zero rotation vector.""" vec = np.zeros(3) q = Quaternion.from_rotation_vector(vec) self.assertTrue(q.isclose(Quaternion.identity())) - def test_from_rotation_vector_general(self): + def test_from_rotation_vector_general(self) -> None: """Test creating quaternion from rotation vector.""" vec = np.array([0, np.pi / 2, 0]) # 90° around Y q = Quaternion.from_rotation_vector(vec) @@ -479,7 +479,7 @@ def test_from_rotation_vector_general(self): expected = Quaternion.from_axis_angle(np.array([0, 1, 0]), np.pi / 2) self.assertTrue(q.isclose(expected)) - def test_roundtrip_rotation_vector(self): + def test_roundtrip_rotation_vector(self) -> None: """Test quaternion -> rotation vector -> quaternion roundtrip.""" q_original = Quaternion.from_axis_angle(np.array([1, 0, 0]), np.pi / 4) vec = q_original.to_rotation_vector() @@ -491,7 +491,7 @@ def test_roundtrip_rotation_vector(self): class TestEulerAngleConversions(unittest.TestCase): """Test conversions to/from Euler angles.""" - def test_to_euler_angles_identity(self): + def test_to_euler_angles_identity(self) -> None: """Test identity quaternion to Euler angles.""" q = Quaternion.identity() euler = q.to_euler_angles() @@ -499,13 +499,13 @@ def test_to_euler_angles_identity(self): # Identity should give zero or small angles self.assertEqual(euler.shape, (3,)) - def test_from_euler_angles_zeros(self): + def test_from_euler_angles_zeros(self) -> None: """Test creating quaternion from zero Euler angles.""" q = Quaternion.from_euler_angles(0, 0, 0) self.assertTrue(q.isclose(Quaternion.identity())) - def test_roundtrip_euler_angles(self): + def test_roundtrip_euler_angles(self) -> None: """Test quaternion -> Euler -> quaternion roundtrip.""" q_original = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi / 2) euler = q_original.to_euler_angles() @@ -518,14 +518,14 @@ def test_roundtrip_euler_angles(self): class TestVectorOperations(unittest.TestCase): """Test vector-related operations.""" - def test_to_vector_part(self): + def test_to_vector_part(self) -> None: """Test extracting vector part.""" q = Quaternion.from_components(1, 2, 3, 4) vec = q.to_vector_part() assert_array_almost_equal(vec, np.array([2, 3, 4])) - def test_rotate_vector_identity(self): + def test_rotate_vector_identity(self) -> None: """Test rotating vector with identity quaternion.""" q = Quaternion.identity() vec = np.array([1, 2, 3]) @@ -533,7 +533,7 @@ def test_rotate_vector_identity(self): assert_array_almost_equal(rotated, vec, decimal=10) - def test_rotate_vector_90z(self): + def test_rotate_vector_90z(self) -> None: """Test rotating vector 90° around Z.""" q = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi / 2) vec = np.array([1, 0, 0]) @@ -542,7 +542,7 @@ def test_rotate_vector_90z(self): expected = np.array([0, 1, 0]) assert_array_almost_equal(rotated, expected, decimal=10) - def test_rotate_vector_180x(self): + def test_rotate_vector_180x(self) -> None: """Test rotating vector 180° around X.""" q = Quaternion.from_axis_angle(np.array([1, 0, 0]), np.pi) vec = np.array([0, 1, 0]) @@ -551,7 +551,7 @@ def test_rotate_vector_180x(self): expected = np.array([0, -1, 0]) assert_array_almost_equal(rotated, expected, decimal=10) - def test_rotate_vector_general(self): + def test_rotate_vector_general(self) -> None: """Test general vector rotation.""" q = Quaternion.from_axis_angle(np.array([1, 1, 1]) / np.sqrt(3), 2 * np.pi / 3) vec = np.array([1, 0, 0]) @@ -565,7 +565,7 @@ def test_rotate_vector_general(self): class TestSLERP(unittest.TestCase): """Test spherical linear interpolation.""" - def test_slerp_endpoints(self): + def test_slerp_endpoints(self) -> None: """Test SLERP at endpoints.""" q1 = Quaternion.identity() q2 = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi / 2) @@ -578,7 +578,7 @@ def test_slerp_endpoints(self): q_end = q1.slerp(q2, 1.0) self.assertTrue(q_end.isclose(q2)) - def test_slerp_midpoint(self): + def test_slerp_midpoint(self) -> None: """Test SLERP at midpoint.""" q1 = Quaternion.identity() q2 = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi / 2) @@ -589,7 +589,7 @@ def test_slerp_midpoint(self): expected = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi / 4) self.assertTrue(q_mid.isclose(expected)) - def test_slerp_unit_preservation(self): + def test_slerp_unit_preservation(self) -> None: """Test that SLERP preserves unit norm.""" q1 = Quaternion.from_axis_angle(np.array([1, 0, 0]), np.pi / 4) q2 = Quaternion.from_axis_angle(np.array([0, 1, 0]), np.pi / 3) @@ -598,7 +598,7 @@ def test_slerp_unit_preservation(self): q_interp = q1.slerp(q2, t) self.assertTrue(q_interp.is_unit) - def test_slerp_smooth_interpolation(self): + def test_slerp_smooth_interpolation(self) -> None: """Test that SLERP produces smooth interpolation.""" q1 = Quaternion.identity() q2 = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi) @@ -612,7 +612,7 @@ def test_slerp_smooth_interpolation(self): for i in range(len(angles) - 1): self.assertLessEqual(angles[i], angles[i + 1]) - def test_slerp_opposite_quaternions(self): + def test_slerp_opposite_quaternions(self) -> None: """Test SLERP between opposite quaternions.""" q1 = Quaternion.from_axis_angle(np.array([0, 0, 1]), 0) q2 = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi) @@ -626,7 +626,7 @@ def test_slerp_opposite_quaternions(self): class TestArrayConversions(unittest.TestCase): """Test array conversion methods.""" - def test_as_float_array(self): + def test_as_float_array(self) -> None: """Test conversion to float array.""" q = Quaternion.from_components(1, 2, 3, 4) arr = q.as_float_array() @@ -634,7 +634,7 @@ def test_as_float_array(self): expected = np.array([1, 2, 3, 4]) assert_array_almost_equal(arr, expected) - def test_as_float_array_shape(self): + def test_as_float_array_shape(self) -> None: """Test shape of float array.""" q = Quaternion.from_components(1, 2, 3, 4) arr = q.as_float_array() @@ -645,13 +645,13 @@ def test_as_float_array_shape(self): class TestEdgeCases(unittest.TestCase): """Test edge cases and special scenarios.""" - def test_very_small_rotation(self): + def test_very_small_rotation(self) -> None: """Test very small rotation angle.""" q = Quaternion.from_axis_angle(np.array([1, 0, 0]), 1e-10) self.assertTrue(q.isclose(Quaternion.identity())) - def test_very_large_angle(self): + def test_very_large_angle(self) -> None: """Test angle larger than 2π.""" # Should wrap around q = Quaternion.from_axis_angle(np.array([0, 0, 1]), 3 * np.pi) @@ -660,7 +660,7 @@ def test_very_large_angle(self): expected = Quaternion.from_axis_angle(np.array([0, 0, 1]), np.pi) self.assertTrue(q.isclose(expected) or q.isclose(-expected)) - def test_zero_norm_handling(self): + def test_zero_norm_handling(self) -> None: """Test that operations handle near-zero quaternions appropriately.""" # This would be an invalid quaternion, but we test defensive handling # Note: actual zero quaternion would cause division by zero in some operations @@ -674,7 +674,7 @@ def test_zero_norm_handling(self): class TestQuaternionSerialization(unittest.TestCase): """Test serialization/deserialization methods and DataModelHelper inheritance.""" - def test_to_dict_identity(self): + def test_to_dict_identity(self) -> None: """Test to_dict for identity quaternion.""" q = Quaternion.identity() d = q.to_dict() @@ -689,7 +689,7 @@ def test_to_dict_identity(self): self.assertEqual(d["y"], 0.0) self.assertEqual(d["z"], 0.0) - def test_to_dict_general(self): + def test_to_dict_general(self) -> None: """Test to_dict for general quaternion.""" q = Quaternion.from_components(1.0, 2.0, 3.0, 4.0) d = q.to_dict() @@ -699,7 +699,7 @@ def test_to_dict_general(self): self.assertEqual(d["y"], 3.0) self.assertEqual(d["z"], 4.0) - def test_to_dict_normalized(self): + def test_to_dict_normalized(self) -> None: """Test to_dict preserves values for normalized quaternion.""" q = Quaternion.from_axis_angle(np.array([1, 0, 0]), np.pi / 2) d = q.to_dict() @@ -716,7 +716,7 @@ def test_to_dict_normalized(self): self.assertAlmostEqual(d["y"], q.y) self.assertAlmostEqual(d["z"], q.z) - def test_from_dict_identity(self): + def test_from_dict_identity(self) -> None: """Test from_dict for identity quaternion.""" d = {"w": 1.0, "x": 0.0, "y": 0.0, "z": 0.0} q = Quaternion.from_dict(d) @@ -727,7 +727,7 @@ def test_from_dict_identity(self): self.assertEqual(q.y, 0.0) self.assertEqual(q.z, 0.0) - def test_from_dict_general(self): + def test_from_dict_general(self) -> None: """Test from_dict for general quaternion.""" d = {"w": 1.0, "x": 2.0, "y": 3.0, "z": 4.0} q = Quaternion.from_dict(d) @@ -737,7 +737,7 @@ def test_from_dict_general(self): self.assertEqual(q.y, 3.0) self.assertEqual(q.z, 4.0) - def test_from_dict_with_integers(self): + def test_from_dict_with_integers(self) -> None: """Test from_dict accepts integer values.""" d = {"w": 1, "x": 2, "y": 3, "z": 4} q = Quaternion.from_dict(d) @@ -747,7 +747,7 @@ def test_from_dict_with_integers(self): self.assertEqual(q.y, 3.0) self.assertEqual(q.z, 4.0) - def test_roundtrip_serialization_identity(self): + def test_roundtrip_serialization_identity(self) -> None: """Test roundtrip: quaternion -> dict -> quaternion for identity.""" q_original = Quaternion.identity() d = q_original.to_dict() @@ -755,7 +755,7 @@ def test_roundtrip_serialization_identity(self): self.assertTrue(q_original.isclose(q_recovered)) - def test_roundtrip_serialization_general(self): + def test_roundtrip_serialization_general(self) -> None: """Test roundtrip: quaternion -> dict -> quaternion for general quaternion.""" q_original = Quaternion.from_components(1.0, 2.0, 3.0, 4.0) d = q_original.to_dict() @@ -763,7 +763,7 @@ def test_roundtrip_serialization_general(self): self.assertTrue(q_original.isclose(q_recovered)) - def test_roundtrip_serialization_rotation(self): + def test_roundtrip_serialization_rotation(self) -> None: """Test roundtrip for rotation quaternion.""" q_original = Quaternion.from_axis_angle(np.array([1, 1, 1]) / np.sqrt(3), np.pi / 3) d = q_original.to_dict() @@ -771,7 +771,7 @@ def test_roundtrip_serialization_rotation(self): self.assertTrue(q_original.isclose(q_recovered)) - def test_roundtrip_with_negatives(self): + def test_roundtrip_with_negatives(self) -> None: """Test roundtrip with negative components.""" q_original = Quaternion.from_components(-0.5, -0.5, 0.5, 0.5) d = q_original.to_dict() @@ -782,7 +782,7 @@ def test_roundtrip_with_negatives(self): self.assertEqual(q_recovered.y, 0.5) self.assertEqual(q_recovered.z, 0.5) - def test_to_dict_returns_new_dict(self): + def test_to_dict_returns_new_dict(self) -> None: """Test that to_dict returns a new dictionary each time.""" q = Quaternion.from_components(1.0, 2.0, 3.0, 4.0) d1 = q.to_dict() @@ -791,7 +791,7 @@ def test_to_dict_returns_new_dict(self): self.assertIsNot(d1, d2) self.assertEqual(d1, d2) - def test_dict_modification_doesnt_affect_quaternion(self): + def test_dict_modification_doesnt_affect_quaternion(self) -> None: """Test that modifying the dict doesn't affect the quaternion.""" q = Quaternion.from_components(1.0, 2.0, 3.0, 4.0) d = q.to_dict() @@ -804,21 +804,21 @@ def test_dict_modification_doesnt_affect_quaternion(self): self.assertEqual(q.w, 1.0) self.assertEqual(q.x, 2.0) - def test_inheritance_from_quaternion_type(self): + def test_inheritance_from_quaternion_type(self) -> None: """Test that Quaternion properly inherits from QuaternionType.""" from foundationTypes.mathTypes.MathTypes import QuaternionType q = Quaternion.from_components(1.0, 2.0, 3.0, 4.0) self.assertIsInstance(q, QuaternionType) - def test_inheritance_from_data_model_helper(self): + def test_inheritance_from_data_model_helper(self) -> None: """Test that Quaternion inherits from DataModelHelper.""" from foundationTypes.dataModelHelper import DataModelHelper q = Quaternion.from_components(1.0, 2.0, 3.0, 4.0) self.assertIsInstance(q, DataModelHelper) - def test_has_serialization_methods(self): + def test_has_serialization_methods(self) -> None: """Test that Quaternion has the required serialization methods.""" q = Quaternion.identity() From 7872825f70de6a071536cd755cbfd41e91d68465 Mon Sep 17 00:00:00 2001 From: kopecn Date: Thu, 2 Jul 2026 16:08:29 -0700 Subject: [PATCH 05/70] cool... --- tests/test_quaternion.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_quaternion.py b/tests/test_quaternion.py index 35d0380..bc17b38 100644 --- a/tests/test_quaternion.py +++ b/tests/test_quaternion.py @@ -805,11 +805,11 @@ def test_dict_modification_doesnt_affect_quaternion(self) -> None: self.assertEqual(q.x, 2.0) def test_inheritance_from_quaternion_type(self) -> None: - """Test that Quaternion properly inherits from QuaternionType.""" - from foundationTypes.mathTypes.MathTypes import QuaternionType + """Test that Quaternion properly inherits from the shared QuaternionABC.""" + from foundationTypes.mathTypes.quaternionABC import QuaternionABC q = Quaternion.from_components(1.0, 2.0, 3.0, 4.0) - self.assertIsInstance(q, QuaternionType) + self.assertIsInstance(q, QuaternionABC) def test_inheritance_from_data_model_helper(self) -> None: """Test that Quaternion inherits from DataModelHelper.""" From 043def99da5f8a03be725a506af774c865947baa Mon Sep 17 00:00:00 2001 From: kopecn Date: Sat, 11 Jul 2026 19:10:32 -0500 Subject: [PATCH 06/70] scoping out updates and upgrades. --- .claude/action-plan/00-overview.md | 116 +++++++++++ .../01-template-rename-and-refresh.md | 75 +++++++ .claude/action-plan/02-errors-and-layering.md | 54 +++++ .../action-plan/03-governance-and-readme.md | 63 ++++++ .../action-plan/04-precision-time-interval.md | 62 ++++++ .claude/action-plan/05-precision-timestamp.md | 60 ++++++ .claude/action-plan/06-position.md | 60 ++++++ .../action-plan/07-quaternion-additions.md | 58 ++++++ .claude/action-plan/08-spatial-pose.md | 63 ++++++ .../action-plan/09-univariate-polynomial.md | 56 +++++ .claude/action-plan/10-roots-kernel.md | 65 ++++++ .claude/action-plan/11-waveform1d-core.md | 67 ++++++ .../action-plan/12-waveform1d-operators.md | 51 +++++ .../action-plan/13-waveform1d-generators.md | 57 +++++ .../14-dsp-support-and-protocol.md | 72 +++++++ .claude/action-plan/15-waveform-position.md | 57 +++++ .claude/action-plan/16-waveform-quaternion.md | 54 +++++ .../action-plan/17-waveform-spatial-pose.md | 57 +++++ .claude/action-plan/18-dsp-calc.md | 51 +++++ .claude/action-plan/19-dsp-correlation.md | 49 +++++ .claude/action-plan/20-dsp-envelope.md | 47 +++++ .claude/action-plan/21-dsp-spectral.md | 58 ++++++ .claude/action-plan/22-dsp-filtering.md | 63 ++++++ .claude/action-plan/23-dsp-peaks.md | 49 +++++ .claude/action-plan/24-dsp-phase.md | 48 +++++ .claude/action-plan/25-dsp-resampling.md | 51 +++++ .claude/action-plan/26-dsp-time-alignment.md | 56 +++++ .claude/action-plan/27-dsp-triggers.md | 49 +++++ .claude/action-plan/28-dsp-windowing.md | 45 ++++ .claude/action-plan/29-dsp-zero-crossings.md | 50 +++++ .claude/action-plan/30-dsp-compose.md | 54 +++++ .../action-plan/31-otg-enums-and-errors.md | 52 +++++ .claude/action-plan/32-otg-input-parameter.md | 56 +++++ .claude/action-plan/33-otg-profile.md | 60 ++++++ .../action-plan/34-otg-block-brake-bound.md | 55 +++++ .../35-otg-trajectory-and-output.md | 59 ++++++ .claude/action-plan/36-otg-velocity-steps.md | 57 +++++ .../37-otg-position-first-second-steps.md | 49 +++++ .../38-otg-position-third-step1.md | 53 +++++ .../39-otg-position-third-step2.md | 50 +++++ .../action-plan/40-otg-calculator-target.md | 56 +++++ .claude/action-plan/41-otg-driver.md | 56 +++++ .claude/action-plan/42-otg-oracle-suites.md | 68 ++++++ .claude/action-plan/43-public-surface.md | 50 +++++ .claude/specs/mathToolsArchitecture.md | 188 +++++++++++++++++ .claude/specs/otg.md | 181 ++++++++++++++++ .claude/specs/polynomials.md | 125 +++++++++++ .claude/specs/precisionTimeMath.md | 127 ++++++++++++ .claude/specs/spatialMath.md | 192 +++++++++++++++++ .claude/specs/templateConformance.md | 109 ++++++++++ .claude/specs/waveformCore.md | 196 ++++++++++++++++++ .claude/specs/waveformDsp.md | 148 +++++++++++++ 52 files changed, 3804 insertions(+) create mode 100644 .claude/action-plan/00-overview.md create mode 100644 .claude/action-plan/01-template-rename-and-refresh.md create mode 100644 .claude/action-plan/02-errors-and-layering.md create mode 100644 .claude/action-plan/03-governance-and-readme.md create mode 100644 .claude/action-plan/04-precision-time-interval.md create mode 100644 .claude/action-plan/05-precision-timestamp.md create mode 100644 .claude/action-plan/06-position.md create mode 100644 .claude/action-plan/07-quaternion-additions.md create mode 100644 .claude/action-plan/08-spatial-pose.md create mode 100644 .claude/action-plan/09-univariate-polynomial.md create mode 100644 .claude/action-plan/10-roots-kernel.md create mode 100644 .claude/action-plan/11-waveform1d-core.md create mode 100644 .claude/action-plan/12-waveform1d-operators.md create mode 100644 .claude/action-plan/13-waveform1d-generators.md create mode 100644 .claude/action-plan/14-dsp-support-and-protocol.md create mode 100644 .claude/action-plan/15-waveform-position.md create mode 100644 .claude/action-plan/16-waveform-quaternion.md create mode 100644 .claude/action-plan/17-waveform-spatial-pose.md create mode 100644 .claude/action-plan/18-dsp-calc.md create mode 100644 .claude/action-plan/19-dsp-correlation.md create mode 100644 .claude/action-plan/20-dsp-envelope.md create mode 100644 .claude/action-plan/21-dsp-spectral.md create mode 100644 .claude/action-plan/22-dsp-filtering.md create mode 100644 .claude/action-plan/23-dsp-peaks.md create mode 100644 .claude/action-plan/24-dsp-phase.md create mode 100644 .claude/action-plan/25-dsp-resampling.md create mode 100644 .claude/action-plan/26-dsp-time-alignment.md create mode 100644 .claude/action-plan/27-dsp-triggers.md create mode 100644 .claude/action-plan/28-dsp-windowing.md create mode 100644 .claude/action-plan/29-dsp-zero-crossings.md create mode 100644 .claude/action-plan/30-dsp-compose.md create mode 100644 .claude/action-plan/31-otg-enums-and-errors.md create mode 100644 .claude/action-plan/32-otg-input-parameter.md create mode 100644 .claude/action-plan/33-otg-profile.md create mode 100644 .claude/action-plan/34-otg-block-brake-bound.md create mode 100644 .claude/action-plan/35-otg-trajectory-and-output.md create mode 100644 .claude/action-plan/36-otg-velocity-steps.md create mode 100644 .claude/action-plan/37-otg-position-first-second-steps.md create mode 100644 .claude/action-plan/38-otg-position-third-step1.md create mode 100644 .claude/action-plan/39-otg-position-third-step2.md create mode 100644 .claude/action-plan/40-otg-calculator-target.md create mode 100644 .claude/action-plan/41-otg-driver.md create mode 100644 .claude/action-plan/42-otg-oracle-suites.md create mode 100644 .claude/action-plan/43-public-surface.md create mode 100644 .claude/specs/mathToolsArchitecture.md create mode 100644 .claude/specs/otg.md create mode 100644 .claude/specs/polynomials.md create mode 100644 .claude/specs/precisionTimeMath.md create mode 100644 .claude/specs/spatialMath.md create mode 100644 .claude/specs/templateConformance.md create mode 100644 .claude/specs/waveformCore.md create mode 100644 .claude/specs/waveformDsp.md diff --git a/.claude/action-plan/00-overview.md b/.claude/action-plan/00-overview.md new file mode 100644 index 0000000..2d256bf --- /dev/null +++ b/.claude/action-plan/00-overview.md @@ -0,0 +1,116 @@ +--- +plan: math-tools-port +status: pending +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# Action Plan — Template Migration + Swift Math Port + +**Goal:** bring py-MathTools onto the py-foundationTools template conventions +and port the Swift `FoundationMathTypes` capability set (spatial SE(3) types, +precision time, waveforms + DSP, polynomials/roots, OTG trajectory +generation) as the Tier-3 math layer, per the accepted specs in +[`../specs/`](../specs/mathToolsArchitecture.md). + +**Usability north star:** a robotics/DSP engineer in a notebook can build a +waveform or pose, do the obvious math, and reach scipy-grade analysis with +minimal ceremony — well-typed numpy-adjacent Python, not translated Swift. + +## Conventions every chunk inherits (do not restate per chunk) + +1. **Spec is authoritative.** Each chunk lists its governing spec section(s). + If implementation forces a contract change, update the spec in the same + chunk and bump its `semver`. +2. **TDD:** write the failing tests first, implement, then run the gate. +3. **Gate:** `make uv-fullCheck` (ruff lint + mypy strict + pytest) must pass + at the end of every chunk. Test layout mirrors the package + (`tests//test_.py`), `unittest.TestCase` style, `test_*` + methods. +4. **Stay in scope:** touch ONLY the files the chunk lists. Adjacent + problems get reported in the chunk's completion notes, not fixed. +5. **Frontmatter:** every chunk carries `status: pending` → set + `in_progress` / `done` as you work; bump `last_updated`. +6. **Swift reference roots** (read-only, for faithful-port chunks): + - `SWIFT_MATH` = `/Users/nbergantz/__Workspaces__/spmWorkspaces/spmMathTools/spm/Sources/spmMathTools/FoundationMathTypes` + - `SWIFT_TYPES` = `/Users/nbergantz/__Workspaces__/spmWorkspaces/spmFoundationTools/spm/Sources/FoundationTypes` + - `SWIFT_TESTS` = `/Users/nbergantz/__Workspaces__/spmWorkspaces/spmMathTools/spm/Tests/spmMathToolsTests` +7. **Shared API idioms** (umbrella spec "API idioms"): `normalized()` method + / `normalize()` in-place; `isclose(rtol, atol)`; `__array__`; + `__hash__ = None` on mutable numpy-backed classes; snake_case throughout. +8. Do not commit; the human reviews and commits per track. + +## Dependency graph + +``` +Track A (template) 01 ──► 02 ──► 03 + │ + ┌────────────────┴───────────────────────────────┐ +Track B │ 04 ─► 05 06 ─► 07 ─► 08 09 10 │ (02 before all B) + │ └──────┬──────────┘└───┬────┘ │ │ +Track C │ ▼ │ │ │ + │ 11 ─► {12, 13, 14} │ │ │ + │ │ │ ▼ │ │ + │ │ │ 15(◄06) 16(◄07) ─► 17(◄08,15,16)│ +Track D │ │ ▼ │ │ + │ │ 18..29 (one per mixin; 26 also ◄19) │ │ + │ │ └────────► 30 (compose) │ │ +Track E │ └──────────────────────────────────────────┘ │ + │ 31(◄10) ─► 32 │ + │ 31 ─► 33 ─► 34 ─► {35, 36, 37, 38, 39} │ + │ {32,35..39} ─► 40 ─► 41 ─► 42 │ + └────────────────────────────────────────────────┘ +``` + +Tracks B/C/D/E parallelize after 01–02; within a track, run in numeric +order unless the graph says otherwise. Chunk 43 (public surface) runs last, +after 09, 17, 30, and 42. + +## Chunk index + +| # | Chunk | Track | Depends on | Spec | +|---|---|---|---|---| +| 01 | [template-rename-and-refresh](01-template-rename-and-refresh.md) | A | — | templateConformance | +| 02 | [errors-and-layering](02-errors-and-layering.md) | A | 01 | umbrella, templateConformance §5 | +| 03 | [governance-and-readme](03-governance-and-readme.md) | A | 02 | templateConformance §3–4 | +| 04 | [precision-time-interval](04-precision-time-interval.md) | B | 02 | precisionTimeMath | +| 05 | [precision-timestamp](05-precision-timestamp.md) | B | 04 | precisionTimeMath | +| 06 | [position](06-position.md) | B | 02 | spatialMath | +| 07 | [quaternion-additions](07-quaternion-additions.md) | B | 06 | spatialMath | +| 08 | [spatial-pose](08-spatial-pose.md) | B | 07 | spatialMath | +| 09 | [univariate-polynomial](09-univariate-polynomial.md) | B | 02 | polynomials | +| 10 | [roots-kernel](10-roots-kernel.md) | B | 02 | polynomials | +| 11 | [waveform1d-core](11-waveform1d-core.md) | C | 05 | waveformCore | +| 12 | [waveform1d-operators](12-waveform1d-operators.md) | C | 11 | waveformCore | +| 13 | [waveform1d-generators](13-waveform1d-generators.md) | C | 11 | waveformCore | +| 14 | [dsp-support-and-protocol](14-dsp-support-and-protocol.md) | C | 11 | waveformDsp | +| 15 | [waveform-position](15-waveform-position.md) | C | 11, 06 | waveformCore | +| 16 | [waveform-quaternion](16-waveform-quaternion.md) | C | 11, 07 | waveformCore | +| 17 | [waveform-spatial-pose](17-waveform-spatial-pose.md) | C | 08, 15, 16 | waveformCore | +| 18 | [dsp-calc](18-dsp-calc.md) | D | 14 | waveformDsp | +| 19 | [dsp-correlation](19-dsp-correlation.md) | D | 14 | waveformDsp | +| 20 | [dsp-envelope](20-dsp-envelope.md) | D | 14 | waveformDsp | +| 21 | [dsp-spectral](21-dsp-spectral.md) | D | 14 | waveformDsp | +| 22 | [dsp-filtering](22-dsp-filtering.md) | D | 14 | waveformDsp | +| 23 | [dsp-peaks](23-dsp-peaks.md) | D | 14 | waveformDsp | +| 24 | [dsp-phase](24-dsp-phase.md) | D | 14 | waveformDsp | +| 25 | [dsp-resampling](25-dsp-resampling.md) | D | 14 | waveformDsp | +| 26 | [dsp-time-alignment](26-dsp-time-alignment.md) | D | 14, 19 | waveformDsp | +| 27 | [dsp-triggers](27-dsp-triggers.md) | D | 14 | waveformDsp | +| 28 | [dsp-windowing](28-dsp-windowing.md) | D | 14 | waveformDsp | +| 29 | [dsp-zero-crossings](29-dsp-zero-crossings.md) | D | 14 | waveformDsp | +| 30 | [dsp-compose](30-dsp-compose.md) | D | 18–29 | waveformDsp | +| 31 | [otg-enums-and-errors](31-otg-enums-and-errors.md) | E | 10 | otg | +| 32 | [otg-input-parameter](32-otg-input-parameter.md) | E | 31 | otg | +| 33 | [otg-profile](33-otg-profile.md) | E | 31 | otg | +| 34 | [otg-block-brake-bound](34-otg-block-brake-bound.md) | E | 33 | otg | +| 35 | [otg-trajectory-and-output](35-otg-trajectory-and-output.md) | E | 34 | otg | +| 36 | [otg-velocity-steps](36-otg-velocity-steps.md) | E | 34 | otg | +| 37 | [otg-position-first-second-steps](37-otg-position-first-second-steps.md) | E | 34 | otg | +| 38 | [otg-position-third-step1](38-otg-position-third-step1.md) | E | 34 | otg | +| 39 | [otg-position-third-step2](39-otg-position-third-step2.md) | E | 34 | otg | +| 40 | [otg-calculator-target](40-otg-calculator-target.md) | E | 32, 35–39 | otg | +| 41 | [otg-driver](41-otg-driver.md) | E | 40 | otg | +| 42 | [otg-oracle-suites](42-otg-oracle-suites.md) | E | 41 | otg | +| 43 | [public-surface](43-public-surface.md) | A | 30, 42, 09, 17 | umbrella | diff --git a/.claude/action-plan/01-template-rename-and-refresh.md b/.claude/action-plan/01-template-rename-and-refresh.md new file mode 100644 index 0000000..33558cf --- /dev/null +++ b/.claude/action-plan/01-template-rename-and-refresh.md @@ -0,0 +1,75 @@ +--- +chunk: 01-template-rename-and-refresh +track: A +status: pending +depends_on: [] +spec: ../specs/templateConformance.md §Gap 1, §Gap 2.4; ../specs/spatialMath.md §Modules (ABC re-parent) +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 01 — Package rename + environment refresh + +**Deliverable:** the repo builds and gates green under the new snake_case +package names against the pinned foundation branch. Mechanical migration — +no behavior changes beyond the required ABC re-parent. + +## Files + +- `git mv src/pyMathTools src/math_tools`; inside it: + `git mv src/math_tools/spatial/Quaternion.py src/math_tools/spatial/quaternion.py`, + `git mv src/math_tools/spherical/sphericalGenerators.py src/math_tools/spherical/spherical_generators.py`, + `git mv src/math_tools/spherical/sphericalTransforms.py src/math_tools/spherical/spherical_transforms.py` + (`constructors.py`, `hints.py` keep their names). +- `git mv src/pyMathToolsPlotHelpers src/math_plot_helpers`; + `git mv src/math_plot_helpers/plotUnitSpherical.py src/math_plot_helpers/plot_unit_spherical.py`. +- Edit: `src/math_tools/spatial/quaternion.py` (imports/base only, see below), + every `__init__.py` touched by moves, `tests/test_quaternion.py`, + `examples/sphericalPlotting/plotArcs.py`, + `examples/sphericalPlotting/plotQuatUnitCircles.py`, `Makefile` (mypy + package list if it names packages), `.env` if it names packages. +- Add: `py.typed` in `src/math_tools/` and `src/math_plot_helpers/` roots + (keep the existing ones in subpackages). + +## Design constraints + +1. **First action:** `make uv-refresh` so `.venv` matches the + `requirements.txt` branch pin (the stale install has the pre-template ABC + layout; nothing imports correctly until this runs). +2. **ABC re-parent (the one semantic edit):** in `quaternion.py`, replace + `from foundationTypes.mathTypes.quaternionABC import QuaternionABC` with + `from foundation_abc.math.spatialABCs import QuaternionABC`. The old ABC + carried `DataModelHelper`; the new one is ABC-only with a concrete + `to_dict`. In `tests/test_quaternion.py`, update the serialization tests' + base-class assertions (`DataModelHelper` inheritance assertion → the new + ABC) — assertions on `to_dict`/`from_dict` *values* stay untouched. If any + other import from the old layout exists (grep `foundationTypes.mathTypes.` + across `src/`), re-point to `foundation_abc.math.*` / + `foundationTypes.mathTypes.MathTypes` equivalents. +3. All other edits are import-path text substitutions + (`pyMathTools` → `math_tools`, `pyMathToolsPlotHelpers` → + `math_plot_helpers`, moved module filenames). +4. `pyproject.toml`: update `description` only if trivially co-located; the + deps list changes in chunk 03, not here. + +## TDD steps + +1. `make uv-refresh`; run `make uv-test` to record the pre-existing pass/fail + baseline (the ABC import may already be broken — note it). +2. Perform moves + edits. +3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] `grep -rn "pyMathTools" src/ tests/ examples/ Makefile .env pyproject.toml` → no hits +- [ ] `grep -rn "foundationTypes.mathTypes.quaternionABC" src/ tests/` → no hits +- [ ] `git log --follow --oneline src/math_tools/spatial/quaternion.py` shows history (moves were `git mv`) +- [ ] All ~90 quaternion tests pass; diff to `tests/test_quaternion.py` contains only import lines and base-class assertion lines +- [ ] `make uv-fullCheck` passes + +## Out of scope + +`errors.py`, layering test, README, `.claude/CLAUDE.md`, pyproject dependency +list, any new math code, any `__init__.py` re-export curation beyond fixing +broken imports. diff --git a/.claude/action-plan/02-errors-and-layering.md b/.claude/action-plan/02-errors-and-layering.md new file mode 100644 index 0000000..e86705c --- /dev/null +++ b/.claude/action-plan/02-errors-and-layering.md @@ -0,0 +1,54 @@ +--- +chunk: 02-errors-and-layering +track: A +status: pending +depends_on: [01] +spec: ../specs/mathToolsArchitecture.md §Error semantics; ../specs/templateConformance.md §Gap 5 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 02 — Exception hierarchy + package layering test + +**Deliverable:** `math_tools/errors.py` and the AST-based layering test. + +## Files + +- Create: `src/math_tools/errors.py` +- Create: `tests/test_package_layering.py` +- Create: `tests/test_errors.py` + +## Design constraints + +1. `errors.py` defines exactly (each with a one-line docstring): + `MathToolsError(Exception)`, `WaveformCompatibilityError(MathToolsError)`, + `TimestampComparisonError(MathToolsError)`, + `PolynomialSolveError(MathToolsError)`. +2. Layering test pattern: copy the approach of py-foundationTools + `tests/test_package_layering.py` (AST-walk every module under `src/`, + collect `import`/`from` roots). Assertions per templateConformance §Gap 5: + `math_tools` imports neither `math_plot_helpers` nor `matplotlib`; + only `math_plot_helpers` imports `matplotlib`. +3. Additionally assert `math_tools.otg` (once it exists) does not import + `numpy` — write the rule now, guarded to skip if the package dir is + absent, so OTG chunks inherit enforcement for free. + +## TDD steps + +1. Write `tests/test_errors.py` (hierarchy, catchability as `MathToolsError`) + and `tests/test_package_layering.py`; watch errors test fail. +2. Implement `errors.py`; layering test must pass against the current tree. +3. Temporarily add `import matplotlib` to a `math_tools` module and confirm + the layering test fails; revert. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] All four exception classes exist and subclass as specified +- [ ] Layering test fails on an injected `import matplotlib` in `math_tools` (verified then reverted) +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Any consumer of the exceptions; OTG's `OtgError` (lives in `otg/errors.py`, +chunk 31); README/CLAUDE.md. diff --git a/.claude/action-plan/03-governance-and-readme.md b/.claude/action-plan/03-governance-and-readme.md new file mode 100644 index 0000000..6a1c1a7 --- /dev/null +++ b/.claude/action-plan/03-governance-and-readme.md @@ -0,0 +1,63 @@ +--- +chunk: 03-governance-and-readme +track: A +status: pending +depends_on: [02] +spec: ../specs/templateConformance.md §Gap 2, §Gap 3, §Gap 4 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 03 — Governance docs, README, dependency names + +**Deliverable:** `.claude/CLAUDE.md`, real README, pyproject dependency +names, fixed examples. + +## Files + +- Create: `.claude/CLAUDE.md` +- Edit: `README.md`, `pyproject.toml` (deps + description only), + `examples/sphericalPlotting/plotArcs.py`, + `examples/sphericalPlotting/plotQuatUnitCircles.py` + +## Design constraints + +1. `.claude/CLAUDE.md` follows the shape of py-foundationTools + `.claude/CLAUDE.md` but stays short: repo role (Tier 3 of foundation's + `mathTypeTiers.md`), the two-package layering diagram, gate command, + Makefile-is-source-of-truth note, and a linked index of every spec in + `.claude/specs/`. Reference specs; never duplicate their content. +2. `pyproject.toml` `dependencies` = names only: + `pyFoundationTools`, `numpy`, `scipy`, `numpy-quaternion`, `matplotlib`. + Replace the boilerplate `description`. +3. README per templateConformance Gap 4: describe only what exists at + execution time (quaternion, spherical utilities, template workflows); + sections: title → Features → Installation → Quick Start → Development + Workflows → Requirements. Quick Start snippets must actually run. +4. `plotArcs.py`: fix the dead + `foundationTypes.mathTypes.UnitSphericalArc.UnitSphericalArc` import to + the real generated type in `foundationTypes.mathTypes.MathTypes` + (verify the class name by reading that module). + +## TDD steps + +1. Add a test `tests/test_governance.py`: `.claude/CLAUDE.md` exists and its + text links every `*.md` in `.claude/specs/`; `README.md` contains no + "Boilerplate". Watch it fail. +2. Write the docs; run both example scripts manually + (`uv run python examples/sphericalPlotting/plotArcs.py` with a + non-interactive matplotlib backend) to prove imports resolve. +3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] `tests/test_governance.py` passes +- [ ] `pyproject.toml` deps are exactly the five names, unpinned +- [ ] Both example scripts import-run without error +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Makefile/CI edits; requirements pin changes; any `src/` code beyond the +example imports. diff --git a/.claude/action-plan/04-precision-time-interval.md b/.claude/action-plan/04-precision-time-interval.md new file mode 100644 index 0000000..b7b76eb --- /dev/null +++ b/.claude/action-plan/04-precision-time-interval.md @@ -0,0 +1,62 @@ +--- +chunk: 04-precision-time-interval +track: B +status: pending +depends_on: [02] +spec: ../specs/precisionTimeMath.md §Representation, §PrecisionTimeInterval, §Compliance 1–6, 10 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 04 — `PrecisionTimeInterval` + +**Deliverable:** the immutable attosecond-exact interval type. + +## Files + +- Create: `src/math_tools/precision_time/__init__.py` (re-export + `PrecisionTimeInterval`; `PrecisionTimestamp` added by chunk 05), + `src/math_tools/precision_time/py.typed`, + `src/math_tools/precision_time/precision_time_interval.py` +- Create: `tests/precision_time/__init__.py`, + `tests/precision_time/test_precision_time_interval.py` + +## Design constraints + +1. Subclass `foundation_abc.math.precisionTimeABC.PrecisionTimeIntervalABC`; + enums from `foundation_abc.math.mathEnums`. Use the ABC's + `ATTOSECONDS_PER_SECOND`, never a new literal. +2. Storage: one private signed `int` of total attoseconds (spec + §Representation shows the derived accessors — implement exactly that). +3. Immutable + hashable: no public setters; implement `__hash__` from the + total; all arithmetic returns new instances; foreign operand types return + `NotImplemented`. +4. Full surface per spec: constructors (`__init__` ABC-shaped with + normalization/carry, `from_seconds`, `from_attoseconds`, `from_string`), + constants (`ZERO`, `ONE_SECOND`, `ONE_DECISECOND`, `ONE_MILLISECOND`, + `ONE_MICROSECOND`), accessors (`total_attoseconds`, `seconds_as_float`), + arithmetic (`+ - neg pos abs`, scalar `* /` rounding to nearest + attosecond, interval `/` interval → float), total ordering, `bool`, + `to_dict`/`from_dict` on the ABC wire shape, `repr`. +5. Swift reference for semantics questions: + `SWIFT_TYPES/PrecisionTime/PrecisionTimeInterval.swift` (+`+Arithmetic`) + — but NO saturation/wrapping ops (spec divergence). + +## TDD steps + +1. Failing tests covering spec compliance items 2–6 and 10 (interval half) + plus hashability and `NotImplemented` fallback (e.g. `interval + 1.0` + raises `TypeError`). +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Spec compliance items 2, 3, 4, 5, 6 each have a named test and pass +- [ ] Round-trip with `foundationTypes.mathTypes.MathTypes.PrecisionTimeIntervalType` wire dicts (compliance 1, interval half) +- [ ] `isinstance(x, PrecisionTimeIntervalABC)` and `hash(x)` both work +- [ ] `make uv-fullCheck` passes + +## Out of scope + +`PrecisionTimestamp` (chunk 05); waveform usage; any datetime interop. diff --git a/.claude/action-plan/05-precision-timestamp.md b/.claude/action-plan/05-precision-timestamp.md new file mode 100644 index 0000000..7b00667 --- /dev/null +++ b/.claude/action-plan/05-precision-timestamp.md @@ -0,0 +1,60 @@ +--- +chunk: 05-precision-timestamp +track: B +status: pending +depends_on: [04] +spec: ../specs/precisionTimeMath.md §PrecisionTimestamp, §Compliance 1, 7–10 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 05 — `PrecisionTimestamp` + +**Deliverable:** the attosecond-exact epoch-referenced timestamp with +metadata-validated comparison. + +## Files + +- Create: `src/math_tools/precision_time/precision_timestamp.py` +- Edit: `src/math_tools/precision_time/__init__.py` (add export) +- Create: `tests/precision_time/test_precision_timestamp.py` + +## Design constraints + +1. Subclass `PrecisionTimestampABC`; composes a `PrecisionTimeInterval` + offset plus `timescale`/`reference_frame`/`uncertainty` (all optional, + `None` default). Immutable + hashable (metadata included in hash/eq). +2. Full surface per spec: constructors (`__init__`, `from_interval`, + `from_datetime` — tz-aware required, `ValueError` on naive —, `now`, + `from_days`, `EPOCH`), accessors (`interval`, `days_since_epoch`, + `seconds_of_day`, `as_datetime` UTC lossy-to-µs), arithmetic + (`ts ± interval`, `interval + ts`, `ts - ts → interval`), ordering + (offsets only), `==` (includes metadata). +3. `can_compare` / `compare_validated` semantics EXACTLY per spec (verified + against `SWIFT_TYPES/PrecisionTime/Extensions/PrecisionTimestamp+Comparable.swift`): + `can_compare` = both-specified timescale/frame equality only; + `compare_validated` additionally raises `TimestampComparisonError` + (from `math_tools.errors`) on uncertainty overlap when BOTH carry + uncertainty and `|delta| <= u1 + u2` (boundary equality raises). +4. Metadata propagation on `ts ± interval`: carried from the timestamp. + +## TDD steps + +1. Failing tests: spec compliance 7 (cross-epoch subtraction exact), 8 (all + `compare_validated` branches incl. the `delta == combined` boundary and + the None-metadata compatibility rules), 9 (datetime round-trip incl. + pre-epoch), plus hash/eq-with-metadata. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Spec compliance items 7, 8, 9 each have named tests and pass +- [ ] Round-trip with `PrecisionTimestampType` wire dicts (camelCase optional keys) +- [ ] `isinstance` of the ABC; `EPOCH.is_epoch` is True +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Waveform usage; timezone conversion beyond UTC; leap-second/timescale math +(metadata is carried, never interpreted). diff --git a/.claude/action-plan/06-position.md b/.claude/action-plan/06-position.md new file mode 100644 index 0000000..a590702 --- /dev/null +++ b/.claude/action-plan/06-position.md @@ -0,0 +1,60 @@ +--- +chunk: 06-position +track: B +status: pending +depends_on: [02] +spec: ../specs/spatialMath.md §Cross-cutting conventions, §Position, §Compliance 1–3, 10 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 06 — `Position` + +**Deliverable:** the numpy-backed 3D position type. + +## Files + +- Create: `src/math_tools/spatial/position.py` +- Edit: `src/math_tools/spatial/__init__.py` (export `Position` alongside + the existing `Quaternion`) +- Create: `tests/spatial/__init__.py`, `tests/spatial/test_position.py` + +## Design constraints + +1. Subclass `foundation_abc.math.spatialABCs.PositionABC`. Storage: private + `(3,)` float64 ndarray; `x/y/z` are settable properties into it. +2. Full surface per spec §Position: constructors (`__init__(x, y, z)`, + `from_vector(ArrayLike)`, `from_components(x, y, z)` scalars, + `from_cylindrical`, `from_spherical`, `from_spherical_iso`, + `origin/unit_x/unit_y/unit_z`, `from_dict`), NamedTuple inverse + accessors (`cylindrical`, `spherical`, `spherical_iso`), operators + (`+ - * /` with scalar broadcast both orders, in-place, unary), + `dot`/`cross`/`distance`/`distance_squared`, + `magnitude`/`magnitude_squared`/`norm`/`__abs__`/`is_unit`, + `normalize()`/`normalized()` (`ValueError` on zero vector), + `==`/`isclose(rtol, atol)`/`repr`/`to_dict`, `__array__`, + `__hash__ = None`. +3. Coordinate conventions per spec §Coordinate conventions — the two + spherical variants are distinct; reuse the ISO docstring language from + `math_tools/spherical/spherical_generators.py` by reference, not copy. +4. Style-match the incumbent `spatial/quaternion.py` (dataclass-flavored, + docstrings, classmethod constructors). + +## TDD steps + +1. Failing tests: coordinate init/accessor inverse grids (compliance 2), + `unit_x × unit_y == unit_z` (compliance 3), operator algebra, zero-vector + normalize `ValueError`, `__hash__ is None`, `np.asarray(p)` shape. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Round-trip with `foundationTypes` `PositionType` wire dicts (compliance 1) +- [ ] Both spherical conventions inverse-tested over a radius/angle grid, atol 1e-12 +- [ ] `hash` raises `TypeError`; `np.asarray(p)` returns the `(3,)` vector +- [ ] `make uv-fullCheck` passes + +## Out of scope + +`SpatialPose` (08); Quaternion changes (07); any use in waveforms. diff --git a/.claude/action-plan/07-quaternion-additions.md b/.claude/action-plan/07-quaternion-additions.md new file mode 100644 index 0000000..004ed05 --- /dev/null +++ b/.claude/action-plan/07-quaternion-additions.md @@ -0,0 +1,58 @@ +--- +chunk: 07-quaternion-additions +track: B +status: pending +depends_on: [06] +spec: ../specs/spatialMath.md §Quaternion additions, §Cross-cutting conventions +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 07 — `Quaternion` additions + +**Deliverable:** the five spec'd additions to the existing class — nothing +else changes. + +## Files + +- Edit: `src/math_tools/spatial/quaternion.py` +- Create: `tests/spatial/test_quaternion_additions.py` (existing + `tests/test_quaternion.py` is untouched) + +## Design constraints + +Per spec §Quaternion additions, add exactly: + +1. `dot(other: Quaternion) -> float` — 4-component dot. +2. `rotation_matrix_elements` property → new frozen dataclass + `RotationMatrixElements` (fields `xx…zz`, float) defined in the same + module, derived by delegating to the existing `to_rotation_matrix()` + (single source — do not re-derive from components). +3. `rotate_position(p: Position) -> Position` — wraps the existing + `rotate_vector`; import `Position` from `.position`. +4. `__array__(dtype=None)` → `[w, x, y, z]` float64. +5. Aliases `from_numpy_quaternion` / `to_unit_spherical_small_circle` + delegating to the camelCase originals; originals get a "deprecated + spelling" docstring line, no removal, no warning machinery. + +Existing behavior is authoritative — if a change to an existing method seems +needed, STOP and report instead. + +## TDD steps + +1. Failing tests: `dot` (orthogonal → 0, self → norm²), + `rotation_matrix_elements` equals `to_rotation_matrix()` entries, + `rotate_position` matches `rotate_vector` on the same input, + `np.asarray(q)` order `[w,x,y,z]`, aliases are the same functions. +2. Implement. 3. `make uv-fullCheck` green (all ~90 legacy tests still pass). + +## Acceptance criteria + +- [ ] Diff to `quaternion.py` is additive only (no modified existing lines except imports) +- [ ] Legacy `tests/test_quaternion.py` passes unmodified in this chunk +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Renaming/refactoring existing members; `SpatialPose`; double-cover changes. diff --git a/.claude/action-plan/08-spatial-pose.md b/.claude/action-plan/08-spatial-pose.md new file mode 100644 index 0000000..c5bf8cf --- /dev/null +++ b/.claude/action-plan/08-spatial-pose.md @@ -0,0 +1,63 @@ +--- +chunk: 08-spatial-pose +track: B +status: pending +depends_on: [07] +spec: ../specs/spatialMath.md §SpatialPose, §Compliance 4–8 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 08 — `SpatialPose` + +**Deliverable:** the SE(3) pose type composing `Position` + `Quaternion`. + +## Files + +- Create: `src/math_tools/spatial/spatial_pose.py` +- Edit: `src/math_tools/spatial/__init__.py` (export) +- Create: `tests/spatial/test_spatial_pose.py` + +## Design constraints + +1. Subclass `foundation_abc.math.spatialABCs.SpatialTransformABC` — the + ABC's rotation accessor name is `orientation` (not `quaternion`). +2. Full surface per spec §SpatialPose: constructors (`__init__(position, + orientation)` with identity defaults, `from_components(x,y,z,qw,qx,qy,qz)` + w-first, `from_homogeneous((4,4))` with rigid-bottom-row `ValueError`, + `from_denavit_hartenberg(a, alpha, d, theta)`, `identity()`, `from_dict`), + passthrough properties, `homogeneous` (orientation normalized on export), + operators (`pose * pose` composition, `pose * position` = full SE(3) + apply = `transform(p)`), `translated(by)`, `inverse`, + `relative_pose(to)`, `interpolate(to, t)` (lerp + existing `slerp`, + unclamped t), `position_distance(_squared)`, `angular_distance` + (double-cover safe via `|dot|`), `normalize()`/`normalized()`, + `==`/`isclose`/`repr`/`to_dict`, `__hash__ = None`. +3. Composition recipe (pin exact form): + `(a * b).position == a.position + a.orientation.rotate_position(b.position)`, + `(a * b).orientation == a.orientation * b.orientation`. +4. DH reference: `SWIFT_MATH/Spatial/Constructors/SpatialPose+DenavitHartenberg.swift` + (standard DH matrix; the precomputed-cos/sin overload collapses into the + one Python signature). + +## TDD steps + +1. Failing tests: spec compliance 4 (two published DH sets — use a 2-link + planar arm: `a1=a2=1, alpha=d=0`, θ=(90°, 0°) → end effector (0, 2, 0) + via chained poses, plus one non-planar set with alpha≠0 computed by hand + in the test comment), 5 (composition algebra), 6 (interpolate endpoints + + half-angle midpoint), 7 (`angular_distance(q, -q) == 0`), 8 (homogeneous + round-trip incl. non-normalized input). +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Spec compliance items 4–8 each have named tests and pass +- [ ] Round-trip with `foundationTypes` `SpatialTransformType` wire dicts +- [ ] `pose * position` equals `transform(position)` exactly +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Waveform containers; velocity/twist (se(3)) math; ROS/URDF interop. diff --git a/.claude/action-plan/09-univariate-polynomial.md b/.claude/action-plan/09-univariate-polynomial.md new file mode 100644 index 0000000..cf5529d --- /dev/null +++ b/.claude/action-plan/09-univariate-polynomial.md @@ -0,0 +1,56 @@ +--- +chunk: 09-univariate-polynomial +track: B +status: pending +depends_on: [02] +spec: ../specs/polynomials.md §UnivariatePolynomial, §Compliance 1, 7 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 09 — `UnivariatePolynomial` + +**Deliverable:** the numpy-backed general polynomial class. + +## Files + +- Create: `src/math_tools/functional/__init__.py` (export), + `src/math_tools/functional/py.typed`, + `src/math_tools/functional/polynomial.py` +- Create: `tests/functional/__init__.py`, + `tests/functional/test_polynomial.py` + +## Design constraints + +1. One immutable class per spec: ascending-degree `coefficients` + (float64 ndarray, trailing zeros trimmed), `degree`, `coefficient_at`, + `__call__` (scalar and array via + `numpy.polynomial.polynomial.polyval`), `derivative`, + `integrate(constant=0.0)`, `real_roots(tolerance=...)` (companion-matrix, + imag-filtered, sorted ascending; zero polynomial → + `PolynomialSolveError` from `math_tools.errors`; nonzero constant → `[]`), + `==`, human-readable `repr`. +2. `real_roots` returns ALL real roots — the deliberate contrast with the + OTG kernel (chunk 10) is stated in both docstrings. +3. Tolerance default is `POLYNOMIAL_ZERO_THRESHOLD`; import it from + `math_tools.functional.roots` if chunk 10 has landed, else define the + module constant here and chunk 10 re-homes it (note which happened). + +## TDD steps + +1. Failing tests: spec compliance 1 (`[−1, 0, 1]` → roots `[−1, 1]`, + derivative/integrate round-trip, vector `__call__` vs Horner), trimming + (`degree([1, 0, 0]) == 0`), zero-poly raise, repr shape, immutability. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Compliance item 1 tests pass; multiplicity case (e.g. `(x−1)²`) returns the duplicated root +- [ ] Zero polynomial raises `PolynomialSolveError` +- [ ] `make uv-fullCheck` passes + +## Out of scope + +The analytic root kernel (chunk 10); polynomial arithmetic between +polynomials (not in spec); fitting. diff --git a/.claude/action-plan/10-roots-kernel.md b/.claude/action-plan/10-roots-kernel.md new file mode 100644 index 0000000..96dc70c --- /dev/null +++ b/.claude/action-plan/10-roots-kernel.md @@ -0,0 +1,65 @@ +--- +chunk: 10-roots-kernel +track: B +status: pending +depends_on: [02] +spec: ../specs/polynomials.md §functional/roots.py, §Compliance 2–6 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 10 — Analytic root kernel (faithful port) + +**Deliverable:** `functional/roots.py` — the OTG numeric substrate. This is +a FAITHFUL port of `SWIFT_MATH/Functional/Roots.swift` + `Utils.swift`: +translate the algorithms line-by-line; do not substitute numpy/closed-form +"improvements". + +## Files + +- Create: `src/math_tools/functional/roots.py` +- Edit: `src/math_tools/functional/__init__.py` (export the public names) +- Create: `tests/functional/test_roots.py` + +## Design constraints + +1. Exact surface per spec §functional/roots.py: constants + (`EPS16 = 16 * sys.float_info.epsilon` — NOT 1e-16 —, + `POLYNOMIAL_TOLERANCE = 1e-14`, `POLYNOMIAL_ZERO_THRESHOLD = 1e-9`), + `solve_cubic`, `solve_resolvent -> (roots, real_root_count)`, + `solve_quartic_monic(a, b, c, d)`, `evaluate_polynomial`, + `polynomial_derivative`, `polynomial_monic_derivative`, + `shrink_interval`, `integrate_jerk`. +2. **Non-negative-only filtering** (Swift `insertIfPositive`, `val >= 0`) + is load-bearing: preserve it, and document it in the module + function + docstrings ("OTG time-domain kernel; NOT a general root finder — see + UnivariatePolynomial.real_roots"). +3. In the quartic, consult `solve_resolvent`'s real-root count before + reading slots 1–2 (the 1-real-root case stores an imaginary part in + slot 2 — Swift `Roots.swift:234` guard). +4. Imports: `math` + `sys` + stdlib ONLY (numpy allowed in tests only). +5. If chunk 09 defined `POLYNOMIAL_ZERO_THRESHOLD` locally, re-home it here + and update 09's import. + +## TDD steps + +1. Failing tests per spec compliance 2a (≥15 literal hand-computed vectors — + include: 3 distinct positive roots; mixed-sign roots pinning the filter + drops negatives; repeated root; complex pair; `a≈0` cubic→quadratic + fallback; all-negative → `[]`), 2b (seeded random coefficients vs the + non-negative real subset of `np.roots`, atol 1e-8), 3 (`shrink_interval` + quintic bracket), 4 (`integrate_jerk` analytic), 5 (`EPS16` value pin). +2. Translate the Swift, function by function. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Compliance items 2a, 2b, 3, 4, 5 pass +- [ ] `grep -E "import (numpy|scipy)" src/math_tools/functional/roots.py` → no hits (compliance 6) +- [ ] `solve_resolvent` returns the count; quartic honors it (a test with a 1-real-root resolvent case) +- [ ] `make uv-fullCheck` passes + +## Out of scope + +OTG modules; `UnivariatePolynomial` changes beyond the constant re-home; +performance work. diff --git a/.claude/action-plan/11-waveform1d-core.md b/.claude/action-plan/11-waveform1d-core.md new file mode 100644 index 0000000..16ae5af --- /dev/null +++ b/.claude/action-plan/11-waveform1d-core.md @@ -0,0 +1,67 @@ +--- +chunk: 11-waveform1d-core +track: C +status: pending +depends_on: [05] +spec: ../specs/waveformCore.md §ABC accessor, §Instantiability, §Time axis, §Waveform1D (constructors/statistics/indexing/mutation), §Compliance 1, 3, 5–7, 11 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 11 — `Waveform1D` core container + +**Deliverable:** the scalar waveform container (no operators, no generators, +no DSP — those are chunks 12, 13, 18–30). + +## Files + +- Create: `src/math_tools/waveforms/__init__.py` (export `Waveform1D`), + `src/math_tools/waveforms/py.typed`, + `src/math_tools/waveforms/waveform1d.py` +- Create: `tests/waveforms/__init__.py`, + `tests/waveforms/test_waveform1d_core.py` + +## Design constraints + +1. `class Waveform1D(Waveform1dABC)` — base list is EXACTLY this (DSP mixins + are composed by chunk 30, not here). Implement the ABC accessor + `waveform -> Sequence[float]`; numpy bulk on `values: npt.NDArray` + (copy-in on construction; dtype preserved, float64 default). +2. Time axis per spec: `dt: PrecisionTimeInterval` (strictly positive), + `t0: PrecisionTimestamp = EPOCH`; `dt_seconds: float = 1.0` is the + default path so `Waveform1D(values)` is legal; `t0_seconds` convenience; + `TypeError` when both a precision and a seconds form are given. + Computed: `duration` (`dt * (n-1)`, ZERO when n ≤ 1), + `duration_seconds`, `sampling_frequency_hz`, `nyquist_frequency_hz`, + `sample_count`/`__len__`, `time_axis()`. +3. Statistics properties (None-on-empty per spec), indexing/slicing + (`w[i]`, `w[a:b]` with attosecond-exact t0 shift, step≠1 `ValueError`), + `subset_time`, `value_at_index`/`value_at_time` (linear interp, + `ValueError` out of range), mutation API (`append`, `append_values`, + `prepend`, `prepend_values` with t0 back-shift, `insert`, `replace`, + `replace_range`, `pop(i=-1)` → `IndexError` on empty, `clear()`), + `==` (samples exact + dt + t0), `__iter__`, `__array__`, `repr`, + `to_dict`/`from_dict`. +4. Pin the population-variance choice: implement `variance` as ddof=0 and + pin with a literal expected value (spec compliance 5). + +## TDD steps + +1. Failing tests for spec compliance 1 (`ScalarWaveformType` round-trip), + 3 (slice t0 attosecond-exact), 5 (stats literals), 6 (value_at_time + midpoint), 7 (mutation semantics), 11 (`Waveform1D(values)` constructs; + `__abstractmethods__` empty; `np.asarray`; iteration). +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Named tests for compliance 1, 3, 5, 6, 7, 11 pass +- [ ] `w[2:5].t0 - w.t0 == w.dt * 2` exactly (PrecisionTime equality, not float) +- [ ] Integer-dtype input preserves dtype; float input is float64 +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Arithmetic/comparison operators (12), generators (13), DSP (18–30), +aggregate containers (15–17). diff --git a/.claude/action-plan/12-waveform1d-operators.md b/.claude/action-plan/12-waveform1d-operators.md new file mode 100644 index 0000000..ab2cd6c --- /dev/null +++ b/.claude/action-plan/12-waveform1d-operators.md @@ -0,0 +1,51 @@ +--- +chunk: 12-waveform1d-operators +track: C +status: pending +depends_on: [11] +spec: ../specs/waveformCore.md §Waveform1D Operators, §Compliance 2 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 12 — `Waveform1D` operators & comparison + +**Deliverable:** the elementwise operator surface. + +## Files + +- Edit: `src/math_tools/waveforms/waveform1d.py` +- Create: `tests/waveforms/test_waveform1d_operators.py` + +## Design constraints + +1. Per spec §Operators: `+ - * / // %` (waveform⊕waveform requires equal + `dt` AND length else `WaveformCompatibilityError`; scalar both orders), + in-place variants (mutate), bitwise `& | ^ << >> ~` integer-dtype-only + (`TypeError` with a clear message otherwise), unary `- + abs`, + `elements_equal` / `elements_less_than` / `elements_greater_than` + (→ bool ndarray), `isclose_elementwise(other, rtol=1e-9, atol=0.0)`, + whole-object `isclose(other, rtol, atol)`. +2. Results carry `dt`/`t0` from the left operand; binary op result dtype + follows numpy promotion. +3. Foreign types → `NotImplemented` (so `np.float64 + w` behaves). + +## TDD steps + +1. Failing tests: dt-mismatch and length-mismatch raise + `WaveformCompatibilityError` (spec compliance 2); scalar both orders; + in-place mutates in place (identity check); bitwise on float raises + `TypeError`; int `%` and `<<`; `isclose_elementwise` tolerance behavior; + `isclose` False on differing `t0`. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Compliance item 2 named test passes +- [ ] `(w + 1.0).t0 == w.t0` and `is not w` +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Generators, DSP, aggregate operators (aggregates have none). diff --git a/.claude/action-plan/13-waveform1d-generators.md b/.claude/action-plan/13-waveform1d-generators.md new file mode 100644 index 0000000..53ab0b4 --- /dev/null +++ b/.claude/action-plan/13-waveform1d-generators.md @@ -0,0 +1,57 @@ +--- +chunk: 13-waveform1d-generators +track: C +status: pending +depends_on: [11] +spec: ../specs/waveformCore.md §Waveform1D Constructors (generators), §Compliance 4 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 13 — `Waveform1D` signal generators + +**Deliverable:** the 21 generator classmethods. + +## Files + +- Edit: `src/math_tools/waveforms/waveform1d.py` (or a + `waveforms/_generators.py` helper module the classmethods delegate to, if + the class file is getting long — your call, but the public surface is the + classmethods) +- Create: `tests/waveforms/test_waveform1d_generators.py` + +## Design constraints + +1. Classmethods per spec: `sine`, `cosine`, `square`, `triangle`, + `sawtooth`, `chirp`, `exponential_decay`, `exponential_growth`, + `polynomial(coefficients)`, `linear_ramp`, `logarithm`, `logarithm10`, + `square_root`, `heaviside`, `relu`, `sigmoid`, `white_noise(seed)`, + `constant`, `impulse`, `damped_sinusoid`, `counter`, `digital_square`. + Common signature prefix `(n: int, *, dt=None, dt_seconds=None, t0=None, + t0_seconds=None, ...)` + per-shape params (frequency_hz, amplitude, + phase, etc.). +2. Argument names/meanings follow the Swift reference + (`SWIFT_MATH/Waveform1D/Extensions/Waveform1D+Generators.swift`); each + body is a numpy one-liner over `time_axis()`. +3. `white_noise` uses `np.random.default_rng(seed)`; `square`/`sawtooth`/ + `triangle` may use `scipy.signal` waveforms. + +## TDD steps + +1. Failing tests (spec compliance 4): `sine` matches `np.sin(2π·f·t)` on the + time axis; `white_noise(seed=k)` reproducible and differs for k+1; + `impulse` sums to one amplitude; `counter` is `arange`; `chirp` + instantaneous frequency endpoints (coarse check via zero-crossing counts + in the first/last quarter). +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] All 21+ generators exist with `n`/time-axis prefix signature and pass their shape test +- [ ] `Waveform1D.sine(n=1000)` works with no time args (dt = 1 s) +- [ ] `make uv-fullCheck` passes + +## Out of scope + +DSP analysis; generator variants Swift doesn't have. diff --git a/.claude/action-plan/14-dsp-support-and-protocol.md b/.claude/action-plan/14-dsp-support-and-protocol.md new file mode 100644 index 0000000..b805a51 --- /dev/null +++ b/.claude/action-plan/14-dsp-support-and-protocol.md @@ -0,0 +1,72 @@ +--- +chunk: 14-dsp-support-and-protocol +track: C +status: pending +depends_on: [11] +spec: ../specs/waveformDsp.md §Support descriptor types, §Organization (protocol), §Compliance 3 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 14 — DSP support types + mixin protocol + +**Deliverable:** the shared substrate every DSP mixin chunk builds on. + +## Files + +- Create: `src/math_tools/waveforms/support.py`, + `src/math_tools/waveforms/dsp/__init__.py`, + `src/math_tools/waveforms/dsp/py.typed`, + `src/math_tools/waveforms/dsp/_protocol.py`, + `src/math_tools/waveforms/dsp/_common.py` +- Edit: `src/math_tools/waveforms/__init__.py` (export support types) +- Create: `tests/waveforms/test_support.py` + +## Design constraints + +1. `support.py`: ALL enums and descriptor dataclasses listed in the spec + §Support descriptor types — string-valued `Enum`s; + `@dataclass(frozen=True, slots=True)` with **`eq=False` on every + ndarray-bearing descriptor** (`WaveformSpectrum`, `WaveformSpectrogram`, + `WaveformMelSpectrogram`, `WaveformInstantaneousFrequency`, + `WaveformFilterCoefficients`); scalar-only descriptors keep default eq. +2. `_protocol.py`: + +```python +class WaveformProtocol(Protocol): + @property + def values(self) -> npt.NDArray[Any]: ... + @property + def dt(self) -> PrecisionTimeInterval: ... + @property + def t0(self) -> PrecisionTimestamp: ... + @property + def sampling_frequency_hz(self) -> float: ... + def _with_values(self, values: npt.NDArray[Any]) -> "Waveform1D": ... +``` + + `_with_values(values)` (same dt/t0, new samples) must be added to + `Waveform1D` here — the one edit to `waveform1d.py` this chunk makes. + Adjust member names ONLY to match what `Waveform1D` actually exposes. +3. `_common.py`: `float_seconds(interval) -> float` and any helper two or + more mixins would otherwise duplicate; starts minimal. + +## TDD steps + +1. Failing tests: every enum member exists; frozen mutation raises; + ndarray descriptors — construct two equal-content instances, `==` + evaluates without raising (compliance 3); `Waveform1D` structurally + satisfies `WaveformProtocol` (assign to a protocol-typed variable under + mypy + a runtime `isinstance` check via `runtime_checkable`). +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] All spec-listed enums/dataclasses exist; ndarray ones have `eq=False` +- [ ] `Waveform1D._with_values` returns a new instance sharing dt/t0 +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Any mixin; composing anything into `Waveform1D`. diff --git a/.claude/action-plan/15-waveform-position.md b/.claude/action-plan/15-waveform-position.md new file mode 100644 index 0000000..fce7a41 --- /dev/null +++ b/.claude/action-plan/15-waveform-position.md @@ -0,0 +1,57 @@ +--- +chunk: 15-waveform-position +track: C +status: pending +depends_on: [11, 06] +spec: ../specs/waveformCore.md §ABC accessor, §Aggregate containers, §Compliance 1, 2, 8, 10 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 15 — `WaveformPosition` + +**Deliverable:** the position time-series container. + +## Files + +- Create: `src/math_tools/waveforms/waveform_position.py` +- Edit: `src/math_tools/waveforms/__init__.py` (export) +- Create: `tests/waveforms/test_waveform_position.py` + +## Design constraints + +1. Subclass `PositionWaveformABC`. Storage `(n, 3)` float64 + `positions_array`; ABC accessor `positions -> Sequence[PositionABC]` + materializes `Position` objects on access. Time axis identical to + `Waveform1D` (same shared behavior — extract a private helper/mixin from + `waveform1d.py` ONLY if it avoids real duplication; copying the ~6 small + time properties is acceptable). +2. Per spec §Aggregate containers with `Element = Position`: constructors + (element list, `from_components(x, y, z)` from `Waveform1D`s with + count/dt `ValueError`), element access (`w[i]`, `w[a:b]`, `get(i)`, + `__iter__`), `component_waveforms -> NamedTuple(x, y, z)`, + `are_all_unit`, `normalize()`/`normalized()` (`ValueError` on + zero-magnitude element), mutation verbs with `Position` payloads, + `extend`/`concatenate` (`WaveformCompatibilityError` on dt mismatch), + `==`/`repr`/`to_dict`/`from_dict`. +3. Bulk paths (normalize, component split) are vectorized over + `positions_array` — never loop over materialized `Position`s + (spec compliance 10). + +## TDD steps + +1. Failing tests: `PositionWaveformType` wire round-trip (compliance 1); + dt-mismatch raises (2); `component_waveforms` → `from_components` + round-trip (8); slice materialization; normalize on a zero row raises. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Compliance 1, 2, 8 named tests pass +- [ ] `w[i]` returns a `Position` equal to the stored row +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Quaternion/pose containers (16, 17); DSP on components. diff --git a/.claude/action-plan/16-waveform-quaternion.md b/.claude/action-plan/16-waveform-quaternion.md new file mode 100644 index 0000000..6faa13d --- /dev/null +++ b/.claude/action-plan/16-waveform-quaternion.md @@ -0,0 +1,54 @@ +--- +chunk: 16-waveform-quaternion +track: C +status: pending +depends_on: [11, 07] +spec: ../specs/waveformCore.md §ABC accessor, §Aggregate containers, §Compliance 1, 2, 8 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 16 — `WaveformQuaternion` + +**Deliverable:** the quaternion time-series container. + +## Files + +- Create: `src/math_tools/waveforms/waveform_quaternion.py` +- Edit: `src/math_tools/waveforms/__init__.py` (export) +- Create: `tests/waveforms/test_waveform_quaternion.py` + +## Design constraints + +1. Subclass `QuaternionWaveformABC`. Storage `(n, 4)` float64 + `quaternions_array` in **`(w, x, y, z)` order** (pinned repo-wide); ABC + accessor `quaternions -> Sequence[QuaternionABC]` materializes + `Quaternion` objects. +2. Mirror chunk 15's structure with `Element = Quaternion`: + `from_components(w, x, y, z)` (w-first), + `component_waveforms -> NamedTuple(w, x, y, z)` (w-first — NOT the Swift + x-first order), `are_all_unit` (unit stem, not "normalized"), + `normalize()`/`normalized()`, element access/iteration, mutation verbs, + `extend`/`concatenate`, `==`/`repr`/`to_dict`/`from_dict`. +3. Vectorized normalization: row-wise norm over the array; `ValueError` on a + zero row. + +## TDD steps + +1. Failing tests: `QuaternionWaveformType` wire round-trip (compliance 1; + note wire dicts are per-element `{"w","x","y","z"}`); dt-mismatch (2); + `component_waveforms` → `from_components` exact round-trip with the + w-first order pinned by name access (compliance 8); `are_all_unit` flips + after appending a non-unit element. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Compliance 1, 2, 8 named tests pass; tuple field names are `(w, x, y, z)` +- [ ] `w[i]` returns a `Quaternion` equal to the stored row +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Pose container (17); slerp/orientation interpolation on series (not in spec). diff --git a/.claude/action-plan/17-waveform-spatial-pose.md b/.claude/action-plan/17-waveform-spatial-pose.md new file mode 100644 index 0000000..1a10a27 --- /dev/null +++ b/.claude/action-plan/17-waveform-spatial-pose.md @@ -0,0 +1,57 @@ +--- +chunk: 17-waveform-spatial-pose +track: C +status: pending +depends_on: [08, 15, 16] +spec: ../specs/waveformCore.md §Aggregate containers, §Compliance 1, 2, 8, 9 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 17 — `WaveformSpatialPose` + +**Deliverable:** the 6-DOF pose time-series container (parallel arrays). + +## Files + +- Create: `src/math_tools/waveforms/waveform_spatial_pose.py` +- Edit: `src/math_tools/waveforms/__init__.py` (export) +- Create: `tests/waveforms/test_waveform_spatial_pose.py` + +## Design constraints + +1. Subclass `WaveformSpatialABC`. Parallel `positions_array (n,3)` + + `quaternions_array (n,4)` (w-first); ABC accessors `positions` and + `quaternions` materialize. +2. Per spec: constructors (component arrays, `from_poses(list[SpatialPose], + dt/dt_seconds, t0)`, `from_waveforms(position_waveform, + quaternion_waveform)` with count/dt `ValueError`), element access + returns `SpatialPose`, `component_waveforms` (nested + position+quaternion NamedTuples), `position_waveform`, + `quaternion_waveform`, `are_all_positions_unit` / + `are_all_quaternions_unit`, `normalize()`/`normalized()`, mutation with + `SpatialPose` payloads, `extend`/`concatenate`, `==`/`repr`/ + `to_dict`/`from_dict`. +3. **Subtle Swift parity (spec compliance 9):** `is_valid` = equal array + lengths; `sample_count` = `min(len(positions), len(quaternions))`. Both + behaviors kept even though constructors validate — direct array + manipulation in tests constructs the unequal state. + +## TDD steps + +1. Failing tests: `SpatialTransformWaveformType` wire round-trip + (compliance 1); dt-mismatch (2); pose round-trip + `from_poses` → `w[i]` (8); unequal-arrays `is_valid`/`sample_count` + pinned (9); `position_waveform`/`quaternion_waveform` slices match. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Compliance 1, 2, 8, 9 named tests pass +- [ ] `from_waveforms(w.position_waveform, w.quaternion_waveform) == w` +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Trajectory math on pose series; interpolation between samples. diff --git a/.claude/action-plan/18-dsp-calc.md b/.claude/action-plan/18-dsp-calc.md new file mode 100644 index 0000000..059b1cb --- /dev/null +++ b/.claude/action-plan/18-dsp-calc.md @@ -0,0 +1,51 @@ +--- +chunk: 18-dsp-calc +track: D +status: pending +depends_on: [14] +spec: ../specs/waveformDsp.md §Family contracts (CalcMixin), §Numerical conventions, §Compliance 1–2 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 18 — `CalcMixin` (integrate / derivative) + +**Deliverable:** `math_tools/waveforms/dsp/_calc.py` + tests. Swift +reference: `SWIFT_MATH/Waveform1D/Extensions/Waveform1D+Calc.swift`. + +## Files + +Create `src/math_tools/waveforms/dsp/_calc.py`, +`tests/waveforms/dsp/__init__.py`, `tests/waveforms/dsp/test_calc.py`. +Do NOT touch `waveform1d.py` (mixins compose in chunk 30). Tests exercise +the mixin via a local test subclass `class _W(CalcMixin, Waveform1D): pass` +— this pattern applies to every DSP chunk. + +## Design constraints + +- `class CalcMixin(WaveformProtocol)`; methods per spec table: + `integrate(initial_value=0.0) -> Waveform1D` (cumulative trapezoid scaled + by `dt` seconds, first sample = initial_value), + `derivative() -> Waveform1D` (`np.gradient` over the time axis). +- Results built via `self._with_values(...)`; imports: scipy/numpy + + `._protocol`/`._common` only (no sibling mixin imports — repo layering + test extended here if not already covering `dsp/`). + +## TDD steps + +1. Failing tests (spec compliance 1): derivative of a linear ramp is + constant (atol 1e-9); `integrate` of a constant is a ramp; + `integrate().derivative()` recovers a smooth signal interior + (rtol 1e-6). +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] The three analytic tests pass +- [ ] `grep "from math_tools.waveforms.dsp._" src/math_tools/waveforms/dsp/_calc.py` shows only `_protocol`/`_common` +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Any other mixin; composing into `Waveform1D`. diff --git a/.claude/action-plan/19-dsp-correlation.md b/.claude/action-plan/19-dsp-correlation.md new file mode 100644 index 0000000..c0e0285 --- /dev/null +++ b/.claude/action-plan/19-dsp-correlation.md @@ -0,0 +1,49 @@ +--- +chunk: 19-dsp-correlation +track: D +status: pending +depends_on: [14] +spec: ../specs/waveformDsp.md §Family contracts (CorrelationMixin), §Compliance 1–2 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 19 — `CorrelationMixin` + +**Deliverable:** `dsp/_correlation.py` + tests. Swift reference: +`SWIFT_MATH/Waveform1D/Extensions/Waveform1D+Correlation.swift`; test ideas: +`SWIFT_TESTS/Waveform1DCorrelationTests.swift` (39 tests — port the +representative cases). + +## Files + +Create `src/math_tools/waveforms/dsp/_correlation.py`, +`tests/waveforms/dsp/test_correlation.py`. Test-subclass pattern per +chunk 18. + +## Design constraints + +- Methods per spec table: `auto_correlation(max_lag=None, normalized=True)`, + `cross_correlation(other, max_lag=None, normalized=True)` (dt mismatch → + `WaveformCompatibilityError`), `find_max_correlation(other, max_lag=None) + -> WaveformTimeLag`. Backing `scipy.signal.correlate`/`correlation_lags`. +- Normalized autocorrelation is 1.0 at lag 0; `WaveformTimeLag` carries + `lag_samples`, `lag_seconds` (= lag · dt seconds), `correlation`. + +## TDD steps + +1. Failing tests: autocorrelation of white noise ≈ δ (lag-0 dominates); + `find_max_correlation` of a signal vs itself shifted by k samples + returns lag k (exact) with correlation ≈ 1; dt mismatch raises. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Known-shift lag recovery exact for k ∈ {0, 3, 17} +- [ ] Import-fence grep per chunk 18 +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Time alignment (chunk 26 consumes this); phase coherence (24). diff --git a/.claude/action-plan/20-dsp-envelope.md b/.claude/action-plan/20-dsp-envelope.md new file mode 100644 index 0000000..50161d6 --- /dev/null +++ b/.claude/action-plan/20-dsp-envelope.md @@ -0,0 +1,47 @@ +--- +chunk: 20-dsp-envelope +track: D +status: pending +depends_on: [14] +spec: ../specs/waveformDsp.md §Family contracts (EnvelopeMixin), §Compliance 1–2 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 20 — `EnvelopeMixin` + +**Deliverable:** `dsp/_envelope.py` + tests. Swift reference: +`SWIFT_MATH/Waveform1D/Extensions/Waveform1D+Envelope.swift`. + +## Files + +Create `src/math_tools/waveforms/dsp/_envelope.py`, +`tests/waveforms/dsp/test_envelope.py`. Test-subclass pattern per chunk 18. + +## Design constraints + +- Methods per spec table: `amplitude_envelope()` (Hilbert magnitude), + `upper_lower_envelopes() -> tuple[Waveform1D, Waveform1D]` (peak/valley + interpolation), `instantaneous_amplitude(method: + WaveformInstantaneousMethod)` dispatching HILBERT / RMS (windowed) / + PEAK (interp). Backing `scipy.signal.hilbert`, `find_peaks`, `np.interp`. +- Minimum-length `ValueError` with requirement in message (spec + §Numerical conventions). + +## TDD steps + +1. Failing tests: envelope of `A·sin` is ≈ A on the interior (rtol 5e-2, + edges excluded); envelope of a damped sinusoid tracks `A·exp(−λt)` + (interior, rtol 0.1); upper ≥ lower everywhere; each + `WaveformInstantaneousMethod` returns the right length. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Analytic sine/damped tests pass; import-fence grep per chunk 18 +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Phase analysis (24); spectral features (21). diff --git a/.claude/action-plan/21-dsp-spectral.md b/.claude/action-plan/21-dsp-spectral.md new file mode 100644 index 0000000..290b850 --- /dev/null +++ b/.claude/action-plan/21-dsp-spectral.md @@ -0,0 +1,58 @@ +--- +chunk: 21-dsp-spectral +track: D +status: pending +depends_on: [14] +spec: ../specs/waveformDsp.md §Family contracts (SpectralMixin), §Compliance 1–2 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 21 — `SpectralMixin` (FFT / PSD / spectrogram / mel / features) + +**Deliverable:** `dsp/_spectral.py` + tests. Swift references: +`Waveform1D+FFT.swift`, `+Spectrogram.swift` under +`SWIFT_MATH/Waveform1D/Extensions/`. + +## Files + +Create `src/math_tools/waveforms/dsp/_spectral.py`, +`tests/waveforms/dsp/test_spectral.py`. Test-subclass pattern per chunk 18. + +## Design constraints + +- Methods per spec table: `fft() -> WaveformSpectrum` (rfft; frequencies in + Hz from `sampling_frequency_hz`), `power_spectral_density(window=..., + scaling: WaveformPSDScaling = DENSITY, nperseg=None) -> WaveformSpectrum` + (`scipy.signal.welch`), `spectrogram(window=..., nperseg=..., overlap=...) + -> WaveformSpectrogram` (`scipy.signal.ShortTimeFFT` or + `scipy.signal.spectrogram`), `mel_spectrogram(n_mels=..., ...) -> + WaveformMelSpectrogram` (hand-built triangular filterbank over the + spectrogram — numpy), `spectral_features() -> WaveformSpectralFeatures` + (centroid, spread, rolloff, flatness — match the Swift field set from + `extractSpectralFeatures`; read the Swift struct first and mirror its + fields exactly). +- Minimum-length `ValueError` per §Numerical conventions. + +## TDD steps + +1. Failing tests (spec compliance 1): `fft` of a pure 10 Hz sine + (fs = 1 kHz, n = 1000) peaks at the 10 Hz bin; Parseval sanity + (`sum(|X|²)` vs `sum(x²)`, rtol 1e-6); PSD of white noise is flat within + a loose band; spectrogram of a chirp has monotonically increasing + argmax-frequency per column; mel filterbank rows sum ≈ 1 where fully + inside the band; spectral centroid of the 10 Hz sine ≈ 10 Hz (rtol 5e-2). +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Peak-bin, chirp-monotone, and centroid tests pass +- [ ] All four descriptor types round out of the methods without eq/hash errors +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Filtering (22), phase (24), windows themselves (28 — take `window` +parameters as `WaveformWindowType` and map via `scipy.signal.get_window` +directly here; chunk 28's helpers are for user-facing window utilities). diff --git a/.claude/action-plan/22-dsp-filtering.md b/.claude/action-plan/22-dsp-filtering.md new file mode 100644 index 0000000..d3cf625 --- /dev/null +++ b/.claude/action-plan/22-dsp-filtering.md @@ -0,0 +1,63 @@ +--- +chunk: 22-dsp-filtering +track: D +status: pending +depends_on: [14] +spec: ../specs/waveformDsp.md §Family contracts (FilteringMixin), §Numerical conventions, §Compliance 1–2 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 22 — `FilteringMixin` + +**Deliverable:** `dsp/_filtering.py` + tests. Swift reference: +`SWIFT_MATH/Waveform1D/Extensions/Waveform1D+Filtering.swift`. + +## Files + +Create `src/math_tools/waveforms/dsp/_filtering.py`, +`tests/waveforms/dsp/test_filtering.py`. Test-subclass pattern per chunk 18. + +## Design constraints + +- Methods per spec table: `low_pass_filter(cutoff_hz, order=4)`, + `high_pass_filter(cutoff_hz, order=4)`, `band_pass_filter(low_hz, + high_hz, order=4)`, generic `filtered(filter_type: WaveformFilterType, + ...)` dispatching to the above (+ BAND_STOP), `moving_average_filter( + window_size)`, `exponential_filter(alpha)`, + `savitzky_golay_filter(window_length, polyorder, deriv=0)`, + `whittaker_henderson_filter(lam, order=2)` (sparse difference-matrix + solve via `scipy.sparse` + `spsolve`), `frequency_response(...) -> + WaveformSpectrum`. +- Butterworth designs zero-phase via `filtfilt` (spec: zero-phase unless a + `causal=` flag). Cutoffs validated against Nyquist (`ValueError`). + +## Whittaker–Henderson recipe (the one clever bit) + +```python +n = len(v); D = sparse.eye(n, format="csc") +for _ in range(order): D = D[1:] - D[:-1] # order-th difference matrix +z = spsolve((sparse.eye(n) + lam * D.T @ D).tocsc(), v) +``` + +## TDD steps + +1. Failing tests (spec compliance 1): 5 Hz + 200 Hz mix (fs = 2 kHz) → + `low_pass_filter(50)` attenuates the 200 Hz component ≥ 40 dB while the + 5 Hz amplitude survives (rtol 5e-2), measured via chunk-local rfft; + high-pass mirror; band-pass keeps only the in-band tone; moving average + of a constant is identity; savgol on a noiseless cubic reproduces it + (atol 1e-8); Whittaker–Henderson with `lam→0` ≈ identity and large `lam` + ≈ linear trend. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] The ≥ 40 dB attenuation test and savgol-cubic test pass +- [ ] Nyquist-violation cutoff raises `ValueError` +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Resampling (25); windowing utilities (28). diff --git a/.claude/action-plan/23-dsp-peaks.md b/.claude/action-plan/23-dsp-peaks.md new file mode 100644 index 0000000..b2170ec --- /dev/null +++ b/.claude/action-plan/23-dsp-peaks.md @@ -0,0 +1,49 @@ +--- +chunk: 23-dsp-peaks +track: D +status: pending +depends_on: [14] +spec: ../specs/waveformDsp.md §Family contracts (PeakMixin), §Compliance 1–2 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 23 — `PeakMixin` + +**Deliverable:** `dsp/_peaks.py` + tests. Swift references: +`SWIFT_MATH/Waveform1D/Extensions/Waveform1D+Peak.swift`; +`SWIFT_TESTS/Waveform1DPeakTests.swift` (27 tests — port representative +cases). + +## Files + +Create `src/math_tools/waveforms/dsp/_peaks.py`, +`tests/waveforms/dsp/test_peaks.py`. Test-subclass pattern per chunk 18. + +## Design constraints + +- Methods per spec table: `detect_peaks(min_height=None, min_distance=None, + min_prominence=None) -> list[WaveformPeak]`, `detect_valleys(...)` + (negate + detect_peaks), `find_most_prominent_peaks(count, + min_distance=None) -> list[WaveformPeakWithProminence]`. Backing + `scipy.signal.find_peaks` / `peak_prominences`. +- `WaveformPeak.time_seconds` = `index * dt` seconds; empty list on no-hit + (never raise for no peaks). + +## TDD steps + +1. Failing tests (spec compliance 1): `sine(n, f, fs)` yields exactly + `⌊n·f/fs⌋` peaks (choose n, f, fs so the count is unambiguous); + valleys mirror peaks under negation; prominence ordering on a two-tone + signal (big + small bumps) returns the big ones first; flat signal → []. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Sine peak-count and prominence-order tests pass +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Trigger detection (27); zero crossings (29). diff --git a/.claude/action-plan/24-dsp-phase.md b/.claude/action-plan/24-dsp-phase.md new file mode 100644 index 0000000..a7881d4 --- /dev/null +++ b/.claude/action-plan/24-dsp-phase.md @@ -0,0 +1,48 @@ +--- +chunk: 24-dsp-phase +track: D +status: pending +depends_on: [14] +spec: ../specs/waveformDsp.md §Family contracts (PhaseMixin), §Compliance 1–2 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 24 — `PhaseMixin` + +**Deliverable:** `dsp/_phase.py` + tests. Swift reference: +`SWIFT_MATH/Waveform1D/Extensions/Waveform1D+PhaseAnalysis.swift`. + +## Files + +Create `src/math_tools/waveforms/dsp/_phase.py`, +`tests/waveforms/dsp/test_phase.py`. Test-subclass pattern per chunk 18. + +## Design constraints + +- Methods per spec table: `instantaneous_phase(unwrapped=True)` (Hilbert + angle), `unwrap_phase(threshold=π)` (`np.unwrap` on already-phase data), + `instantaneous_frequency() -> WaveformInstantaneousFrequency` (phase + gradient / 2π), `phase_difference(other)`, `phase_coherence(other, + window=...)`, `phase_synchronization_index(other) -> float` (PLV, in + [0, 1]), `group_delay(...)`. dt mismatch on binary methods → + `WaveformCompatibilityError`. + +## TDD steps + +1. Failing tests (spec compliance 1): unwrapped phase of a chirp is + monotone increasing; instantaneous frequency of a pure f-Hz sine ≈ f on + the interior (rtol 1e-2); PLV of a signal with itself == 1.0 and with an + independent seeded-noise signal < 0.3; `phase_difference` of `sin` vs + `cos` ≈ π/2 interior. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Chirp-monotone and PLV tests pass +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Envelope (20); spectral (21). diff --git a/.claude/action-plan/25-dsp-resampling.md b/.claude/action-plan/25-dsp-resampling.md new file mode 100644 index 0000000..4a8e913 --- /dev/null +++ b/.claude/action-plan/25-dsp-resampling.md @@ -0,0 +1,51 @@ +--- +chunk: 25-dsp-resampling +track: D +status: pending +depends_on: [14] +spec: ../specs/waveformDsp.md §Family contracts (ResamplingMixin), §Compliance 1–2 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 25 — `ResamplingMixin` + +**Deliverable:** `dsp/_resampling.py` + tests. Swift reference: +`SWIFT_MATH/Waveform1D/Extensions/Waveform1D+Resampling.swift`. + +## Files + +Create `src/math_tools/waveforms/dsp/_resampling.py`, +`tests/waveforms/dsp/test_resampling.py`. Test-subclass pattern per +chunk 18. + +## Design constraints + +- Methods per spec table: `decimated(factor)` (`scipy.signal.decimate`), + `interpolated(factor, method: WaveformInterpolationMethod = LINEAR)`, + `resampled(target_frequency_hz)` (`scipy.signal.resample` / + `resample_poly`), `resampled_to_match(other)`, + `polyphase_resampled(up, down)` (`resample_poly`). +- **dt bookkeeping is the tricky bit:** integer factor paths compute the new + `dt` exactly in PrecisionTime (`dt * factor` / `dt / factor` rounding to + nearest attosecond); `resampled(hz)` derives dt from the achieved rate. + New `t0` is unchanged. +- `factor < 1` or non-int factor → `ValueError`. + +## TDD steps + +1. Failing tests (spec compliance 1): `interpolated(2).decimated(2)` + round-trips a smooth signal interior (rtol 1e-3); `decimated(4).dt == + dt * 4` exactly (PrecisionTime equality); `resampled_to_match` yields + matching `dt` and length within ±1; polyphase 3:2 length check. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Round-trip and exact-dt tests pass +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Time alignment (26); `value_at_time` (already in core). diff --git a/.claude/action-plan/26-dsp-time-alignment.md b/.claude/action-plan/26-dsp-time-alignment.md new file mode 100644 index 0000000..9a7e393 --- /dev/null +++ b/.claude/action-plan/26-dsp-time-alignment.md @@ -0,0 +1,56 @@ +--- +chunk: 26-dsp-time-alignment +track: D +status: pending +depends_on: [14, 19] +spec: ../specs/waveformDsp.md §Family contracts (TimeAlignmentMixin), §Compliance 1–2 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 26 — `TimeAlignmentMixin` + +**Deliverable:** `dsp/_time_alignment.py` + tests. Swift reference: +`SWIFT_MATH/Waveform1D/Extensions/Waveform1D+TimeAlignment.swift`. + +## Files + +Create `src/math_tools/waveforms/dsp/_time_alignment.py`, +`tests/waveforms/dsp/test_time_alignment.py`. Test-subclass note: this +mixin's CORRELATION method calls the correlation surface, so the test +subclass composes both: `class _W(TimeAlignmentMixin, CorrelationMixin, +Waveform1D)`. + +## Design constraints + +- Methods per spec table: `aligned(to, method: WaveformAlignmentMethod = + CORRELATION)` (shift by detected lag; START_TIME aligns `t0`s), + `time_lag(to, max_lag=None) -> WaveformTimeLag | None` (None when no + correlation peak qualifies), classmethod + `synchronize(waveforms) -> list[Waveform1D]` (common overlapping span), + `time_windows(window_duration, overlap=0.0) -> list[Waveform1D]`, + `time_segments(boundaries) -> list[Waveform1D]`. +- Exception to the no-sibling-imports rule (documented in the spec's mixin + rules as "via CorrelationMixin"): this mixin may TYPE against the + protocol + call `self.cross_correlation`/`find_max_correlation` — declare + those on a small local `Protocol` extension rather than importing + `_correlation`, keeping module-level imports clean. + +## TDD steps + +1. Failing tests: `aligned` recovers a k-sample shift (result lag 0 + afterward); `synchronize` of two offset waveforms returns equal-length + overlaps with matching `t0`; `time_windows(1.0, overlap=0.5)` count + formula pinned; segment boundaries respected. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Shift-recovery and synchronize tests pass +- [ ] No `import ... _correlation` at module level (grep) +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Resampling to a common rate (25's `resampled_to_match`). diff --git a/.claude/action-plan/27-dsp-triggers.md b/.claude/action-plan/27-dsp-triggers.md new file mode 100644 index 0000000..0bdb46b --- /dev/null +++ b/.claude/action-plan/27-dsp-triggers.md @@ -0,0 +1,49 @@ +--- +chunk: 27-dsp-triggers +track: D +status: pending +depends_on: [14] +spec: ../specs/waveformDsp.md §Family contracts (TriggerMixin), §Compliance 1–2 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 27 — `TriggerMixin` + +**Deliverable:** `dsp/_triggers.py` + tests. Swift reference: +`SWIFT_MATH/Waveform1D/Extensions/Waveform1D+TriggerDetection.swift`. + +## Files + +Create `src/math_tools/waveforms/dsp/_triggers.py`, +`tests/waveforms/dsp/test_triggers.py`. Test-subclass pattern per chunk 18. + +## Design constraints + +- Methods per spec table: `detect_edge_triggers(level, edge: + WaveformEdgeType)` (sign-change of `values - level`, RISING/FALLING/BOTH), + `detect_level_triggers(level, ...)`, `detect_window_triggers(low, high, + kind: WaveformWindowTriggerType)` (ENTER/EXIT of the band), + `detect_pattern_triggers(pattern, tolerance)` (sliding-window match), + generic `detect_triggers(trigger: WaveformTrigger)` dispatching on + `trigger.kind`, `with_event_markers(events) -> WaveformWithEvents`. +- All detectors return `list[WaveformTriggerEvent]`, empty on no-hit; + events carry index, `time_seconds`, value, kind. + +## TDD steps + +1. Failing tests: square wave — rising-edge count == cycle count, falling + likewise, BOTH is their sum; window ENTER/EXIT pair up on a sine + crossing a band; pattern trigger finds an embedded motif at the planted + indices; no-hit → []. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Square-wave edge counts exact; pattern indices exact +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Zero crossings (29 — level 0 lives there); peak detection (23). diff --git a/.claude/action-plan/28-dsp-windowing.md b/.claude/action-plan/28-dsp-windowing.md new file mode 100644 index 0000000..1261a69 --- /dev/null +++ b/.claude/action-plan/28-dsp-windowing.md @@ -0,0 +1,45 @@ +--- +chunk: 28-dsp-windowing +track: D +status: pending +depends_on: [14] +spec: ../specs/waveformDsp.md §Family contracts (WindowingMixin), §Compliance 1–2 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 28 — `WindowingMixin` + +**Deliverable:** `dsp/_windowing.py` + tests. Swift reference: +`SWIFT_MATH/Waveform1D/Extensions/Waveform1D+Windowing.swift`. + +## Files + +Create `src/math_tools/waveforms/dsp/_windowing.py`, +`tests/waveforms/dsp/test_windowing.py`. Test-subclass pattern per chunk 18. + +## Design constraints + +- Methods per spec table: `windowed(window: WaveformWindowType)` + (elementwise multiply), staticmethods `generate_window(window, length) + -> npt.NDArray` (`scipy.signal.get_window`; KAISER takes a beta default + matching the Swift parameterization — read the Swift enum first), + `window_coherent_gain(window) -> float` (mean of the window), + `window_processing_gain(window) -> float`. + +## TDD steps + +1. Failing tests: RECTANGULAR `windowed` is identity; HANN endpoints ≈ 0 and + midpoint ≈ 1; coherent gain of HANN ≈ 0.5 (rtol 1e-2 for finite length); + every `WaveformWindowType` member generates without error. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] All enum members covered; HANN gain test passes +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Spectral methods' internal window use (21 handles its own). diff --git a/.claude/action-plan/29-dsp-zero-crossings.md b/.claude/action-plan/29-dsp-zero-crossings.md new file mode 100644 index 0000000..020742c --- /dev/null +++ b/.claude/action-plan/29-dsp-zero-crossings.md @@ -0,0 +1,50 @@ +--- +chunk: 29-dsp-zero-crossings +track: D +status: pending +depends_on: [14] +spec: ../specs/waveformDsp.md §Family contracts (ZeroCrossingMixin), §Compliance 1–2 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 29 — `ZeroCrossingMixin` + +**Deliverable:** `dsp/_zero_crossings.py` + tests. Swift reference: +`SWIFT_MATH/Waveform1D/Extensions/Waveform1D+ZeroX.swift`. + +## Files + +Create `src/math_tools/waveforms/dsp/_zero_crossings.py`, +`tests/waveforms/dsp/test_zero_crossings.py`. Test-subclass pattern per +chunk 18. + +## Design constraints + +- Methods per spec table: `zero_crossings(direction: + WaveformZeroCrossingDirection = BOTH) -> list[WaveformZeroCrossing]` + (sign-change indexing + sub-sample linear interpolation for + `time_seconds`), `zero_crossing_count(direction=BOTH)`, + `zero_crossing_rate() -> float` (crossings per second), + `segments_between_zero_crossings() -> list[Waveform1D]` (each segment + carries a correctly shifted `t0`). +- Exact zeros count once; empty list on no-hit. + +## TDD steps + +1. Failing tests (spec compliance 1): f-Hz sine over an integer number of + periods → `zero_crossing_rate() == 2f` ± one crossing; POSITIVE + + NEGATIVE counts sum to BOTH; sub-sample interp: crossing of a line + `y = t − 0.5` lands at 0.5 s (atol dt/100); segments' t0s are + monotonically increasing and lengths sum to ≈ n. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Rate and sub-sample interpolation tests pass +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Trigger detection (27). diff --git a/.claude/action-plan/30-dsp-compose.md b/.claude/action-plan/30-dsp-compose.md new file mode 100644 index 0000000..49f4e57 --- /dev/null +++ b/.claude/action-plan/30-dsp-compose.md @@ -0,0 +1,54 @@ +--- +chunk: 30-dsp-compose +track: D +status: pending +depends_on: [18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29] +spec: ../specs/waveformDsp.md §Organization (composition phasing), §Compliance 2, 4 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 30 — Compose the DSP mixins into `Waveform1D` + +**Deliverable:** the one-time base-list edit plus the MRO/layering pins. +Runs ONLY after chunks 18–29 are all done. + +## Files + +- Edit: `src/math_tools/waveforms/waveform1d.py` (base list + imports only) +- Edit: `src/math_tools/waveforms/dsp/__init__.py` (export the 12 mixins) +- Create: `tests/waveforms/dsp/test_compose.py` +- Edit: `tests/waveforms/dsp/test_*.py` — remove the per-chunk local test + subclasses (`class _W(XMixin, Waveform1D)`) in favor of plain `Waveform1D` + (mechanical; assertions unchanged) + +## Design constraints + +1. Base list exactly as the spec's Organization block (12 mixins then + `Waveform1dABC`); no other change to the class body. +2. `test_compose.py` pins: each of the 12 mixins appears in + `Waveform1D.__mro__` exactly once; `Waveform1D.__abstractmethods__ == + frozenset()`; `Waveform1D([1.0, 2.0])` constructs; one smoke call per + family on a short sine (e.g. `.fft()`, `.detect_peaks()`, …) succeeds. +3. Layering pin (spec compliance 2): each `dsp/_*.py` module's imports are + scipy/numpy/stdlib/`_protocol`/`_common` only — extend + `tests/test_package_layering.py` with this rule if chunk 02's version + doesn't already cover `dsp/`. + +## TDD steps + +1. Write `test_compose.py` first (fails: mixins not composed). +2. Make the base-list edit; simplify the per-chunk test subclasses. +3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] MRO test passes; every DSP family callable directly on `Waveform1D` +- [ ] `grep -rn "class _W(" tests/waveforms/dsp/` → no hits +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Any mixin behavior change — if a compose-time conflict (name collision +between mixins) appears, STOP and report; resolution is a spec decision. diff --git a/.claude/action-plan/31-otg-enums-and-errors.md b/.claude/action-plan/31-otg-enums-and-errors.md new file mode 100644 index 0000000..b336b71 --- /dev/null +++ b/.claude/action-plan/31-otg-enums-and-errors.md @@ -0,0 +1,52 @@ +--- +chunk: 31-otg-enums-and-errors +track: E +status: pending +depends_on: [10] +spec: ../specs/otg.md §Public API (enums), §Error semantics; §Compliance 3, 4 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 31 — OTG enums + errors + +**Deliverable:** the OTG package skeleton with wire-stable enums. + +## Files + +- Create: `src/math_tools/otg/__init__.py` (exports grow per chunk; start + with the enums + `OtgError`), `src/math_tools/otg/py.typed`, + `src/math_tools/otg/enums.py`, `src/math_tools/otg/errors.py` +- Create: `tests/otg/__init__.py`, `tests/otg/test_enums.py` + +## Design constraints + +1. `enums.py` per spec: `Result(IntEnum)` with the exact nine values + (including the intentional gap at −103 — transcribe from the spec table, + verify against `SWIFT_MATH/OTG/enums/Result.swift`); `ControlInterface`, + `Synchronization`, `DurationDiscretization` (string enums); + internal `ControlSigns` (UDDU/UDUD), `Direction` (UP/DOWN), + `ReachedLimits` (8 members per the Swift enum) — internal ones may live + in `enums.py` but are excluded from `__all__`. +2. `errors.py`: `OtgError(MathToolsError)` — raised only for structural + misuse per spec §Error semantics. +3. Pure stdlib module (the layering test's no-numpy-in-otg rule starts + applying here). + +## TDD steps + +1. Failing tests: every `Result` member's integer value grep-matches the + spec table (write the values as literals in the test); `Result(-103)` + raises `ValueError` (the gap is real); `OtgError` is a `MathToolsError`. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Nine `Result` values pinned literally; the −103 gap pinned +- [ ] No numpy import anywhere under `src/math_tools/otg/` +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Every other OTG module. diff --git a/.claude/action-plan/32-otg-input-parameter.md b/.claude/action-plan/32-otg-input-parameter.md new file mode 100644 index 0000000..d740cfc --- /dev/null +++ b/.claude/action-plan/32-otg-input-parameter.md @@ -0,0 +1,56 @@ +--- +chunk: 32-otg-input-parameter +track: E +status: pending +depends_on: [31] +spec: ../specs/otg.md §Public API (InputParameter) +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 32 — `InputParameter` + +**Deliverable:** the per-cycle input struct with validation and wire codec. + +## Files + +- Create: `src/math_tools/otg/input_parameter.py` +- Edit: `src/math_tools/otg/__init__.py` (export) +- Create: `tests/otg/test_input_parameter.py` + +## Design constraints + +1. Field-for-field port of `SWIFT_MATH/OTG/InputParameter.swift` (read it + first; snake_case the names): state/target arrays, limits, optional + min/positional limits, `enabled`, per-DOF interface/sync overrides, + `intermediate_positions`, per-section arrays, `minimum_duration`, + `interrupt_calculation_duration`. Constructor `InputParameter(dofs)` + fills the Swift defaults (arrays sized to dofs). +2. `validate(check_current_state_within_limits=False, + check_target_state_within_limits=True) -> bool` — port the Swift checks + (NaN/inf, limit positivity, target within limits, enabled handling) + branch-for-branch. +3. `==` field equality; `to_dict`/`from_dict` with the camelCase keys from + `SWIFT_MATH/OTG/extensions/InputParameter+codable.swift` — these keys + MUST match the truth-table JSON corpus (chunk 42 loads it through this + codec). +4. `list[float]` fields, stdlib only. + +## TDD steps + +1. Failing tests: default construction shapes; a valid input validates; each + invalidity class (zero max jerk, target velocity over limit, NaN) flips + `validate()`; `from_dict` on one literal case copied verbatim out of + `successful_trajectories.json` (embed the dict in the test) round-trips + `to_dict`. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] The embedded truth-table case parses; `==` and codec round-trip pass +- [ ] `make uv-fullCheck` passes + +## Out of scope + +OutputParameter/Trajectory (35); any solver logic. diff --git a/.claude/action-plan/33-otg-profile.md b/.claude/action-plan/33-otg-profile.md new file mode 100644 index 0000000..99d7dc5 --- /dev/null +++ b/.claude/action-plan/33-otg-profile.md @@ -0,0 +1,60 @@ +--- +chunk: 33-otg-profile +track: E +status: pending +depends_on: [31] +spec: ../specs/otg.md §Internal fidelity 1, 4, 5 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 33 — `Profile` + +**Deliverable:** the per-DOF kinematic profile record — the hot data +structure every step solver fills. Faithful port of +`SWIFT_MATH/OTG/Profile.swift` (~715 lines; translate mechanically, +preserve method and branch structure). + +## Files + +- Create: `src/math_tools/otg/profile.py` +- Create: `tests/otg/test_profile.py` + +## Design constraints + +1. Fixed-length `list[float]` arrays `t[7]`, `t_sum[7]`, `j[7]`, `a[8]`, + `v[8]`, `p[8]` — never resized. Targets `pf/vf/af`; `limits: + ReachedLimits`, `direction: Direction`, `control_signs: ControlSigns`; + `brake`/`accel` BrakeProfile fields are typed as the placeholder until + chunk 34 lands — declare them `brake: "BrakeProfile"` with a deferred + import if 34 is not yet merged, or coordinate to land 34's dataclass + stub here (note which). +2. Kinematic stepping uses `math_tools.functional.roots.integrate_jerk` — + never a fresh implementation. +3. Port the full `check*` family (`check`, `check_for_velocity`, + `check_for_second_order`, `check_for_first_order`, `set_boundary`, + `*_with_timing` variants, `check_position_extremum`, + `check_step_for_position_extremum`) with the Swift epsilon constants + (`EPS16` etc. from `functional.roots`). +4. Stdlib `math` only. + +## TDD steps + +1. Failing tests: a hand-constructed 7-phase profile (constant jerk ±j) + integrates to the expected `p/v/a` boundary arrays (compute the expected + values with `integrate_jerk` in the test, independent of Profile's own + loop ordering); `check` accepts a known-valid profile and rejects a + perturbed one (`t[3] += 1e-3`); `t_sum` is the prefix sum of `t`. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Boundary-array integration test passes; perturbation rejected +- [ ] `grep -n "integrate_jerk" src/math_tools/otg/profile.py` ≥ 1; + no local jerk-integration reimplementation +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Brake trajectory computation (34); step solvers; trajectory sampling. diff --git a/.claude/action-plan/34-otg-block-brake-bound.md b/.claude/action-plan/34-otg-block-brake-bound.md new file mode 100644 index 0000000..97c60a4 --- /dev/null +++ b/.claude/action-plan/34-otg-block-brake-bound.md @@ -0,0 +1,55 @@ +--- +chunk: 34-otg-block-brake-bound +track: E +status: pending +depends_on: [33] +spec: ../specs/otg.md §Internal fidelity 3–5 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 34 — `Block`, `BrakeProfile`, `Bound`, `Interval` + +**Deliverable:** the block-interval synchronization support types and the +brake pre-trajectory. Faithful ports of `SWIFT_MATH/OTG/Block.swift`, +`Brake.swift`, `Bound.swift`. + +## Files + +- Create: `src/math_tools/otg/block.py` (Block + Interval), + `src/math_tools/otg/brake.py`, `src/math_tools/otg/bound.py` +- Edit: `src/math_tools/otg/profile.py` ONLY if chunk 33 left the + BrakeProfile fields as deferred placeholders (wire the real type in) +- Create: `tests/otg/test_block.py`, `tests/otg/test_brake.py` + +## Design constraints + +1. `bound.py`: `Bound` (min/max/t_min/t_max) — plain mutable dataclass. +2. `brake.py`: `BrakeProfile` — port `finalize`, `finalize_second_order`, + `acceleration_brake`, `velocity_brake`, + `get_position_brake_trajectory`, `get_velocity_brake_trajectory`, + `v_at_t`, `v_at_a_zero`, `==` branch-for-branch. +3. `block.py`: `Block` (`p_min`, `t_min`, optional intervals `a`/`b`, + `calculate_block`, `is_blocked`, `get_profile`) and `Interval` + (`left`/`right`/`profile`). Preserve the blocked-interval selection + logic exactly — it drives synchronization in chunk 40. +4. Stdlib `math` + `functional.roots` only. + +## TDD steps + +1. Failing tests: brake — a state exceeding max acceleration produces a + non-zero brake duration and `v_at_t(0)` equals the initial velocity; + a within-limits state produces an empty brake (t[0] == 0). Block — + `is_blocked` boundary behavior at interval edges; `get_profile` returns + the interval's profile for an in-interval t. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Brake zero/non-zero cases pass; interval edge cases pinned +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Step solvers; TargetCalculator. diff --git a/.claude/action-plan/35-otg-trajectory-and-output.md b/.claude/action-plan/35-otg-trajectory-and-output.md new file mode 100644 index 0000000..b29f584 --- /dev/null +++ b/.claude/action-plan/35-otg-trajectory-and-output.md @@ -0,0 +1,59 @@ +--- +chunk: 35-otg-trajectory-and-output +track: E +status: pending +depends_on: [34] +spec: ../specs/otg.md §Public API (Trajectory, OutputParameter) +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 35 — `Trajectory` + `OutputParameter` + +**Deliverable:** the trajectory container/sampler and the per-cycle output +struct. Faithful ports of `SWIFT_MATH/OTG/Trajectory.swift` and +`OutputParameter.swift`. + +## Files + +- Create: `src/math_tools/otg/trajectory.py`, + `src/math_tools/otg/output_parameter.py` +- Edit: `src/math_tools/otg/__init__.py` (export both) +- Create: `tests/otg/test_trajectory.py`, `tests/otg/test_output_parameter.py` + +## Design constraints + +1. `Trajectory`: `profiles: list[list[Profile]]`, `duration`, + `cumulative_times`, `independent_min_durations`, `degrees_of_freedom`; + `at_time(t) -> (positions, velocities, accelerations)` (lists of float; + clamp to `[0, duration]` per the Swift edge handling — read `atTime` + first and mirror its section lookup), `position_extrema() -> + list[Bound]`, internal `get_intermediate_durations`, + `get_first_time_at_position`. +2. `OutputParameter(dofs, max_number_of_waypoints=0)`: fields per spec + (`new_position/velocity/acceleration/jerk`, `time`, `new_section`, + `did_section_change`, `new_calculation`, `calculation_duration` in µs), + `pass_to_input(input)` (copies new state into the input's current + state — port the Swift field list exactly), `repr`. +3. Stdlib only; sampling loops over DOFs like the Swift. + +## TDD steps + +1. Failing tests: build a single-DOF Trajectory from a hand-constructed + Profile (chunk 33's test fixture): `at_time(0)` == initial state, + `at_time(duration)` == target state (atol 1e-9), midpoint consistent + with `integrate_jerk`; out-of-range `t` clamps; `pass_to_input` copies + every kinematic field (assert against a sentinel-filled InputParameter). +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Endpoint/midpoint sampling tests pass +- [ ] `pass_to_input` field coverage pinned (loop over dofs asserting equality) +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Extrema across brake sections beyond the direct port; the driver's time +stepping (41). diff --git a/.claude/action-plan/36-otg-velocity-steps.md b/.claude/action-plan/36-otg-velocity-steps.md new file mode 100644 index 0000000..cf242ac --- /dev/null +++ b/.claude/action-plan/36-otg-velocity-steps.md @@ -0,0 +1,57 @@ +--- +chunk: 36-otg-velocity-steps +track: E +status: pending +depends_on: [34] +spec: ../specs/otg.md §Internal fidelity 2, 4, 5 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 36 — Velocity-interface step solvers + +**Deliverable:** `steps/velocity_second_order.py` + +`steps/velocity_third_order.py`. Faithful ports of +`SWIFT_MATH/OTG/VelocitySecondOrderStep1.swift`, `...Step2.swift`, +`VelocityThirdOrderStep1.swift`, `...Step2.swift`. + +## Files + +- Create: `src/math_tools/otg/steps/__init__.py`, + `src/math_tools/otg/steps/velocity_second_order.py`, + `src/math_tools/otg/steps/velocity_third_order.py` +- Create: `tests/otg/steps/__init__.py`, + `tests/otg/steps/test_velocity_steps.py` + +## Design constraints + +1. One class per Swift class, same names (`VelocityThirdOrderStep1` etc.), + same method names snake_cased, same branch structure — translate + mechanically; the case analysis encodes the Ruckig profile taxonomy and + must stay diffable against the Swift. +2. Roots/epsilons exclusively from `math_tools.functional.roots`. +3. Step1 computes the time-optimal profile (fills a `Profile`); Step2 + computes a profile for a prescribed duration. Keep the + `check`-based validation flow (Profile methods from chunk 33). + +## TDD steps + +1. Failing tests (analytic, hand-derived in test comments): third-order + velocity change Δv under jerk limit j and accel limit a — when + `Δv ≥ a²/j` the optimal profile is trapezoidal-acceleration with + `t = Δv/a + a/j` total; when `Δv < a²/j` it is triangular with + `t = 2·sqrt(Δv/j)`. Pin both regimes and the profile's boundary arrays; + second-order (no jerk limit) `t = Δv/a`. Step2: prescribed duration + 1.5× optimal yields a valid profile hitting the target velocity. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Both analytic regimes pinned for third order; second order pinned +- [ ] Class/method names diff-mappable to the Swift files (reviewer spot-check) +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Position-interface steps (37–39); synchronization. diff --git a/.claude/action-plan/37-otg-position-first-second-steps.md b/.claude/action-plan/37-otg-position-first-second-steps.md new file mode 100644 index 0000000..b338b5b --- /dev/null +++ b/.claude/action-plan/37-otg-position-first-second-steps.md @@ -0,0 +1,49 @@ +--- +chunk: 37-otg-position-first-second-steps +track: E +status: pending +depends_on: [34] +spec: ../specs/otg.md §Internal fidelity 2, 4, 5 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 37 — Position first/second-order step solvers + +**Deliverable:** `steps/position_first_order.py` + +`steps/position_second_order.py`. Faithful ports of +`SWIFT_MATH/OTG/PositionFirstOrderStep1.swift`, `...Step2.swift`, +`PositionSecondOrderStep1.swift`, `...Step2.swift`. + +## Files + +- Create: `src/math_tools/otg/steps/position_first_order.py`, + `src/math_tools/otg/steps/position_second_order.py` +- Create: `tests/otg/steps/test_position_first_second.py` + +## Design constraints + +Same porting rules as chunk 36 (mechanical translation, roots/epsilons from +`functional.roots`, Profile from chunk 33). + +## TDD steps + +1. Failing tests (analytic): first order (velocity-limited only) — + `t = |Δp|/v_max`, profile position endpoints exact. Second order + (velocity+acceleration) — trapezoidal velocity when + `|Δp| ≥ v_max²/a_max` with + `t = |Δp|/v_max + v_max/a_max`, else triangular with + `t = 2·sqrt(|Δp|/a_max)`; pin both, both directions (Δp < 0 exercises + `Direction.DOWN`). Step2: prescribed duration 2× optimal reaches the + target position. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] Trapezoidal + triangular regimes pinned in both directions +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Third-order steps (38, 39). diff --git a/.claude/action-plan/38-otg-position-third-step1.md b/.claude/action-plan/38-otg-position-third-step1.md new file mode 100644 index 0000000..59909e3 --- /dev/null +++ b/.claude/action-plan/38-otg-position-third-step1.md @@ -0,0 +1,53 @@ +--- +chunk: 38-otg-position-third-step1 +track: E +status: pending +depends_on: [34] +spec: ../specs/otg.md §Internal fidelity 2, 4, 5; §Module layout (chunking hints) +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 38 — `PositionThirdOrderStep1` (time-optimal) + +**Deliverable:** `steps/position_third_order_step1.py` — faithful port of +`SWIFT_MATH/OTG/PositionThirdOrderStep1.swift` (~850 lines). This is the +time-optimal profile finder for the full jerk-limited position interface. + +## Files + +- Create: `src/math_tools/otg/steps/position_third_order_step1.py` +- Create: `tests/otg/steps/test_position_third_step1.py` + +## Design constraints + +1. Mechanical translation: one Python method per Swift method + (`time_acc0_acc1_vel`, `time_vel`, `time_acc0`, … — keep every profile- + family case), same guard order, same use of `solve_cubic`/ + `solve_quartic_monic`/`shrink_interval` from `functional.roots`. +2. Do NOT simplify algebra or merge branches; if a Swift branch looks dead, + port it anyway and note it. +3. The class's public entry mirrors the Swift `getProfile`-style API filling + candidate `Profile`s and selecting valid ones via `Profile.check`. + +## TDD steps + +1. Failing tests: three hand-derivable cases — (a) rest-to-rest long move + (all limits reached: ACC0_ACC1_VEL profile; total time + `t = Δp/v + v/a + a/j` +symmetric phases — derive in the test comment), + (b) rest-to-rest short move (jerk-dominated, no limit reached), (c) a + moving-start case with a0 ≠ 0. Assert total duration (atol 1e-9) AND the + 7 segment times of the selected profile (this is what chunk 42's numeric + oracle will lean on). +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] The three analytic cases pass with segment-time pinning +- [ ] Every Swift method name has a snake_case counterpart (reviewer diff spot-check) +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Step2 (39); synchronization; blocks. diff --git a/.claude/action-plan/39-otg-position-third-step2.md b/.claude/action-plan/39-otg-position-third-step2.md new file mode 100644 index 0000000..c4402ff --- /dev/null +++ b/.claude/action-plan/39-otg-position-third-step2.md @@ -0,0 +1,50 @@ +--- +chunk: 39-otg-position-third-step2 +track: E +status: pending +depends_on: [34] +spec: ../specs/otg.md §Internal fidelity 2, 4, 5; §Module layout (chunking hints) +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 39 — `PositionThirdOrderStep2` (prescribed-duration) + +**Deliverable:** `steps/position_third_order_step2.py` — faithful port of +`SWIFT_MATH/OTG/PositionThirdOrderStep2.swift` (~1,900 lines, the single +largest file in the port). Solves a profile hitting the target at an +EXACT prescribed duration (the synchronization workhorse). + +## Files + +- Create: `src/math_tools/otg/steps/position_third_order_step2.py` +- Create: `tests/otg/steps/test_position_third_step2.py` + +## Design constraints + +1. Same mechanical-translation rules as chunk 38. If a single session + cannot finish, split at Swift function boundaries and leave the + remaining functions raising `NotImplementedError` with the chunk noted + `in_progress` — never merge a silently wrong branch. +2. Every polynomial solve routes through `functional.roots`; epsilon + comparisons use the shared constants. + +## TDD steps + +1. Failing tests: take chunk 38's three analytic cases, compute their + optimal durations, then ask Step2 for 1.25×, 1.5×, and 3× that duration — + assert the returned profile (a) passes `Profile.check`, (b) reaches + `pf/vf/af` (atol 1e-8), (c) has `t_sum[-1]` equal to the prescribed + duration (atol 1e-9). Add one negative test: a duration BELOW optimal + yields no valid profile. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] All three scale factors × three cases pass; below-optimal rejected +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Step1 changes; TargetCalculator. diff --git a/.claude/action-plan/40-otg-calculator-target.md b/.claude/action-plan/40-otg-calculator-target.md new file mode 100644 index 0000000..c3ab24d --- /dev/null +++ b/.claude/action-plan/40-otg-calculator-target.md @@ -0,0 +1,56 @@ +--- +chunk: 40-otg-calculator-target +track: E +status: pending +depends_on: [32, 35, 36, 37, 38, 39] +spec: ../specs/otg.md §Internal fidelity 3 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 40 — `TargetCalculator` + +**Deliverable:** `calculator_target.py` — faithful port of +`SWIFT_MATH/OTG/CalculatorTarget.swift` (~780 lines): per-DOF Step1/Step2 +dispatch plus cross-DOF synchronization (Block/Interval logic, the +discrete-duration path, phase sync). + +## Files + +- Create: `src/math_tools/otg/calculator_target.py` +- Create: `tests/otg/test_calculator_target.py` + +## Design constraints + +1. Port `calculate(...)` and `synchronize(...)` branch-for-branch: per-DOF + control-interface dispatch to the step solver classes (36–39), Block + construction, blocked-interval resolution, `Synchronization` mode + handling (TIME / TIME_IF_NECESSARY / PHASE / NONE), minimum-duration and + discrete-duration paths. +2. Error paths return the spec'd `Result` codes + (`ERROR_EXECUTION_TIME_CALCULATION`, `ERROR_SYNCHRONIZATION_CALCULATION`) + — no exceptions. +3. Fills a `Trajectory` (chunk 35); consumes `InputParameter` (32). + +## TDD steps + +1. Failing tests: (a) 1-DOF position case reproduces chunk 38's analytic + duration through the full calculate path; (b) 3-DOF TIME sync — all DOFs + report identical trajectory duration equal to the slowest DOF's optimal + (compose from chunk 38's cases with different distances); (c) NONE sync — + per-DOF durations equal their independent optima; (d) an `enabled=False` + DOF stays at its current state; (e) an unsolvable synchronization input + returns `ERROR_SYNCHRONIZATION_CALCULATION` rather than raising. +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] TIME/NONE sync duration semantics pinned; disabled-DOF pinned +- [ ] No `raise` in the calculate path (grep for `raise` → only in + structural-misuse guards, if any) +- [ ] `make uv-fullCheck` passes + +## Out of scope + +The `Otg` driver loop (41); waypoints beyond what the Swift implements. diff --git a/.claude/action-plan/41-otg-driver.md b/.claude/action-plan/41-otg-driver.md new file mode 100644 index 0000000..edfa1f0 --- /dev/null +++ b/.claude/action-plan/41-otg-driver.md @@ -0,0 +1,56 @@ +--- +chunk: 41-otg-driver +track: E +status: pending +depends_on: [40] +spec: ../specs/otg.md §Public API (Otg), §Error semantics, §Compliance 5 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 41 — `Otg` driver + +**Deliverable:** the per-cycle driver class and the finished public surface. +Faithful port of `SWIFT_MATH/OTG/spmOTG.swift`. + +## Files + +- Create: `src/math_tools/otg/otg.py` +- Edit: `src/math_tools/otg/__init__.py` (final `__all__` per spec §Module + layout) +- Create: `tests/otg/test_otg_driver.py` + +## Design constraints + +1. `Otg(control_cycle, dofs, max_number_of_waypoints=0)`; `OtgError` for + structural misuse only (non-positive control_cycle, DOF mismatch between + constructor and parameters); `update(input, output) -> Result` (validate + → recalc-on-change → step `output.time` → sample → WORKING/FINISHED), + `calculate(input, output) -> Result`, `reset()`. Port the input-change + detection and `interrupt_calculation_duration` handling as the Swift + does. +2. `calculation_duration` measured with `time.perf_counter_ns()` → µs + (Swift used DispatchTime). + +## TDD steps + +1. Failing tests: a 1-DOF rest-to-rest move driven via repeated `update` + reaches `FINISHED` in ≤ `duration/control_cycle + 2` calls (spec + invariant); `output.new_position` at each cycle matches + `trajectory.at_time(output.time)`; invalid input → `ERROR_INVALID_INPUT` + (no exception); DOF mismatch raises `OtgError`; `pass_to_input` + + `update` chaining is stable (`new_calculation` False when input + unchanged). +2. Implement. 3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] FINISHED-within-bound and no-exception error paths pass +- [ ] `math_tools/otg/__init__.py` `__all__` == the spec's export list + (pinned by test — spec compliance 5) +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Oracle suites (42); performance tuning. diff --git a/.claude/action-plan/42-otg-oracle-suites.md b/.claude/action-plan/42-otg-oracle-suites.md new file mode 100644 index 0000000..35fc876 --- /dev/null +++ b/.claude/action-plan/42-otg-oracle-suites.md @@ -0,0 +1,68 @@ +--- +chunk: 42-otg-oracle-suites +track: E +status: pending +depends_on: [41] +spec: ../specs/otg.md §Oracle and test strategy, §Compliance 1–2 +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 42 — OTG oracle suites + +**Deliverable:** the classification corpus, the 32-case numeric truth +table, and the ported continuity/comprehensive/regression suites — the +proof the port is faithful. + +## Files + +- Create: `tests/otg/data/successful_trajectories.json`, + `tests/otg/data/failed_trajectories.json` (copied UNMODIFIED from + `SWIFT_TESTS/OTGTests/truthTables/`), + `tests/otg/data/otg_numeric_truth.json` (transcribed from the hardcoded + 32-case array in `SWIFT_TESTS/OTGTests/OTGTruthTableTests.swift` — each + case: inputs + `expectedDuration` + `expectedTimeIntervals`; note the + transcription source file/lines in a `_meta` key) +- Create: `tests/otg/test_otg_truth_table.py`, + `tests/otg/test_otg_continuity.py`, `tests/otg/test_otg_comprehensive.py`, + `tests/otg/test_otg_failure_fixes.py`, `tests/otg/test_otg_invariants.py` + +## Design constraints + +1. Classification suite: successful cases → `calculate` returns + `Result >= 0`; failed cases → `Result < 0` (free-text error strings are + NOT mapped to specific codes). Load through `InputParameter.from_dict`. +2. Numeric suite: per case, duration rtol 1e-6; the selected profile's + `t` segment times vs `expectedTimeIntervals` atol 1e-8. +3. Continuity: port `OTGContinuityTests.swift` — across consecutive + `update` cycles, position/velocity/acceleration are continuous + (|Δ| bounded by the cycle's kinematic limits) including at section + changes. +4. Comprehensive/failure-fix: port `OTGComprehensiveTests.swift` and + `OTGFailureFixTests.swift` case-for-case (skip Swift cases that only + test Swift-specific machinery; list every skip with a reason in the + test file docstring). +5. Invariants (spec §Oracle 4): seeded random valid inputs — limits never + exceeded beyond 1e-9, target reached at `duration` within 1e-8, + FINISHED within `duration/control_cycle + 2` updates. +6. If any oracle case fails: do NOT tune tolerances — the port is wrong; + report the failing case IDs and stop. + +## TDD steps + +Oracle-first by construction: land the data + all suites (failing where the +port is wrong), then fix ports via follow-up notes — this chunk itself only +adds tests/data and may not modify `src/`. + +## Acceptance criteria + +- [ ] 100% classification corpus pass; 32/32 numeric cases pass +- [ ] Continuity + comprehensive + failure-fix + invariant suites pass +- [ ] `git diff --stat` for this chunk touches only `tests/otg/` +- [ ] `make uv-fullCheck` passes (full repo) + +## Out of scope + +Any `src/` modification (failures are reported, fixed under the owning +chunk's spec); performance benchmarks. diff --git a/.claude/action-plan/43-public-surface.md b/.claude/action-plan/43-public-surface.md new file mode 100644 index 0000000..bb27600 --- /dev/null +++ b/.claude/action-plan/43-public-surface.md @@ -0,0 +1,50 @@ +--- +chunk: 43-public-surface +track: A +status: pending +depends_on: [30, 42, 09, 17] +spec: ../specs/mathToolsArchitecture.md §Shared conventions (Public surface) +last_updated: 2026-07-11 +semver: 0.0.1 +author: Nicholas Bergantz +--- + +# 43 — Curated top-level public surface (final chunk) + +**Deliverable:** the flat `import math_tools as mt` working set. Runs last — +after every exporting track is done. + +## Files + +- Edit: `src/math_tools/__init__.py` +- Create: `tests/test_public_surface.py` + +## Design constraints + +1. Root `__all__` is EXACTLY the umbrella spec's list: `Position`, + `Quaternion`, `SpatialPose`, `PrecisionTimeInterval`, + `PrecisionTimestamp`, `Waveform1D`, `WaveformPosition`, + `WaveformQuaternion`, `WaveformSpatialPose`, `UnivariatePolynomial`, + `Otg`, `InputParameter`, `OutputParameter`, `Trajectory`, `Result`, + `MathToolsError`, `WaveformCompatibilityError`, + `TimestampComparisonError`, `PolynomialSolveError`. Re-exports only — + no logic in `__init__.py`. +2. The test pins `set(math_tools.__all__)` to that literal list, imports + every name, and asserts each resolves to the subpackage-defined class + (identity check, e.g. `math_tools.Position is + math_tools.spatial.Position`). + +## TDD steps + +1. Write the failing `__all__` pin test. 2. Wire the re-exports. +3. `make uv-fullCheck` green. + +## Acceptance criteria + +- [ ] `import math_tools as mt; mt.Waveform1D.sine(n=8)` works in a fresh interpreter +- [ ] `__all__` pin test passes; no extra public names +- [ ] `make uv-fullCheck` passes + +## Out of scope + +Adding names beyond the spec list (that requires an umbrella spec bump). diff --git a/.claude/specs/mathToolsArchitecture.md b/.claude/specs/mathToolsArchitecture.md new file mode 100644 index 0000000..6bac934 --- /dev/null +++ b/.claude/specs/mathToolsArchitecture.md @@ -0,0 +1,188 @@ +--- +version: 1.0 +type: umbrella-architecture +name: mathToolsArchitecture +purpose: Layering, package layout, dependency policy, and shared conventions for py-MathTools +spec: MathToolsArchitecture +scope: project +status: accepted +applies_to: src/, tests/, pyproject.toml +last_updated: 2026-07-11 +semver: 0.0.2 +author: Nicholas Bergantz +--- + +# py-MathTools Architecture + +> Umbrella spec. Each module's behavioral contract lives in a sibling spec +> (index at the bottom); this spec owns the layering, the package layout, the +> dependency policy, and the conventions every sibling inherits. On conflict +> within this repo, this spec wins for structure; the sibling wins for its own +> module's behavior. + +## Role — Tier 3 of the foundation math tiers + +py-MathTools is the **math implementation tier** ("`Xxxx`") of the tier system +defined in py-foundationTools' `.claude/specs/mathTypeTiers.md`: + +- **Tier 1** (`XxxxType`, `foundationTypes.mathTypes.MathTypes`) — codegen data + carriers. Serialization/validation only. Owned by py-foundationTools. +- **Tier 2** (`XxxxABC`, `foundation_abc.math.*`) — stdlib-only accessor + + serialization contracts. Owned by py-foundationTools. +- **Tier 3** (this repo) — rich math classes that subclass the Tier-2 ABCs, + choose their own storage (numpy), and implement arithmetic, composition, + interpolation, DSP, and trajectory generation. + +Rules that follow: + +1. Every py-MathTools class whose concept has a Tier-2 ABC **MUST subclass that + ABC** and satisfy its accessor and `to_dict`/`from_dict` contract + (`PositionABC`, `QuaternionABC`, `SpatialTransformABC`, `Waveform1dABC`, + `PositionWaveformABC`, `QuaternionWaveformABC`, `WaveformSpatialABC`, + `PrecisionTimeIntervalABC`, `PrecisionTimestampABC`). +2. py-MathTools **never re-implements storage/data-carrier types** that + foundation already generates; it interoperates with them via + `to_dict`/`from_dict` (wire-format parity is the compatibility contract). +3. Dependency direction is one-way: py-MathTools → pyFoundationTools. Nothing + in foundation may import this package. The foundation-side `XxxxMathLike` + modules referenced by `mathTypeTiers.md` do not exist yet; when foundation + adds them, tightening our base classes is a follow-on task in that repo's + cadence — not assumed here. + +## Dependency policy (differs from foundation) + +Foundation is zero-dependency by policy; **this repo is not**. A mature pip +package that covers a scope SHALL be preferred over reimplementation: + +| Dependency | Used for | Rule | +|---|---|---| +| `pyFoundationTools` | ABCs, generated Types, enums | required, git/tag pin in `requirements.txt` | +| `numpy` | array storage, linear algebra, FFT | required | +| `scipy` | signal processing (filters, PSD, spectrogram, peaks, resampling) | required; every DSP method wraps scipy/numpy when an equivalent exists | +| `numpy-quaternion` | quaternion backend | required (already in use) | +| `matplotlib` | plotting | **only** importable from `math_plot_helpers` | + +Wrappers MUST present a typed, ABC-conformant surface — the pip package is an +implementation detail, never part of the public API (no scipy/numpy types leak +into signatures except `np.ndarray` / `npt.NDArray[...]` where arrays are the +natural currency). Names go in `pyproject.toml`, pins in `requirements*.txt`, +per the template BKM (see [templateConformance.md](templateConformance.md)). + +## Packages and layering + +Two top-level snake_case packages under `src/` (replacing `pyMathTools` and +`pyMathToolsPlotHelpers` — see the rename contract in +[templateConformance.md](templateConformance.md)): + +``` +math_plot_helpers → math_tools → pyFoundationTools → stdlib + (matplotlib) (numpy, scipy, numpy-quaternion) +``` + +No reverse imports. `matplotlib` never appears in `math_tools`. + +### `math_tools` module map + +``` +src/math_tools/ + __init__.py # curated public re-exports (see Public surface) + py.typed + hints.py # shared type aliases (migrated from pyMathTools/hints.py) + errors.py # exception hierarchy (see Error semantics) + precision_time/ # PrecisionTimeInterval, PrecisionTimestamp → precisionTimeMath.md + spatial/ # Position, Quaternion, SpatialPose → spatialMath.md + spherical/ # migrated spherical arc/circle utilities (behavior unchanged) + waveforms/ # Waveform1D + aggregates → waveformCore.md + dsp/ # DSP mixin per family → waveformDsp.md + support.py # DSP descriptor dataclasses/enums → waveformDsp.md + functional/ # polynomials + analytic root solvers → polynomials.md + otg/ # online trajectory generation (Ruckig port) → otg.md +``` + +`src/math_plot_helpers/` holds the migrated plotting module(s); rename-only, +no behavioral change in this effort. + +## Shared conventions (inherited by every sibling spec) + +- **Naming:** pythonic `snake_case` methods/functions; `PascalCase` classes. + Swift's camelCase API maps mechanically (`durationInSeconds` → + `duration_seconds`). The Swift name is never kept for its own sake. +- **Storage:** numpy-backed (`float64` default). Scalar-generic Swift + duplication (Float/Double/Int specializations) collapses to one + implementation; dtype is preserved where the input dtype is meaningful + (integer waveforms). +- **Public surface:** each package and subpackage `__init__.py` re-exports its + public API with `__all__`; consumers import from the subpackage + (`from math_tools.spatial import Position`), never deep module paths. Every + package ships `py.typed`. Additionally the **root** `math_tools/__init__.py` + curates a flat working set so `import math_tools as mt` is coherent: + `Position`, `Quaternion`, `SpatialPose`, `PrecisionTimeInterval`, + `PrecisionTimestamp`, `Waveform1D`, `WaveformPosition`, + `WaveformQuaternion`, `WaveformSpatialPose`, `UnivariatePolynomial`, + `Otg`, `InputParameter`, `OutputParameter`, `Trajectory`, `Result`, and + the `errors` exception types (pinned by an `__all__` test; grows only via + a spec update). +- **API idioms (repo-wide):** `normalized()` is a method returning a copy, + `normalize()` mutates; approximate comparison is + `isclose(..., rtol, atol)` (numpy vocabulary — never a bare `tolerance` + argument); array-like classes implement `__array__` so `np.asarray(x)` + works; mutable numpy-backed classes set `__hash__ = None` explicitly + (only the immutable precision-time types are hashable). +- **Typing:** mypy `strict` clean (the gate). Explicit signatures everywhere. +- **Immutability of results:** analysis/DSP/transform methods return new + objects; only the explicitly-named mutation APIs (`append`, `insert`, …) + mutate in place. +- **Error semantics:** see below. +- **Testing:** `unittest.TestCase` style run under pytest (repo convention, + `tests/test*.py`); tests mirror the package layout + (`tests/spatial/test_position.py`, …). TDD per chunk. Gate: + `make uv-fullCheck`. +- **Docstrings:** every public symbol; module docstrings state conventions + (e.g. the ISO spherical convention doc in the existing `spherical/` module + stays canonical). + +## Error semantics + +`math_tools/errors.py` defines: + +```python +class MathToolsError(Exception): ... +class WaveformCompatibilityError(MathToolsError): ... # dt/shape mismatch on waveform ops +class TimestampComparisonError(MathToolsError): ... # timescale/frame mismatch in validated compare +class PolynomialSolveError(MathToolsError): ... # unsolvable/ill-posed root requests +``` + +- Programmer errors (wrong type, wrong shape, invalid argument) raise stdlib + `TypeError`/`ValueError`. +- Domain failures raise the `MathToolsError` subtype above. +- **Exception:** the OTG control loop reports via its `Result` code enum and + does not raise per cycle (real-time contract, see [otg.md](otg.md)). + +## Explicit non-goals (decided divergences from the Swift source) + +1. **No `Complex` type.** Python's native `complex` / numpy complex dtypes + cover Swift's `Complex` + its arithmetic/analysis extensions entirely. +2. **No cached `_isNormalized` flag.** Swift caches normalization and + invalidates on mutation; here `is_unit` is recomputed on access + (correctness > performance; optimize later with evidence). +3. **No saturating/wrapping integer arithmetic** in precision time. Swift's + `UInt64` clamping (`&+`, `&-`) is a representation constraint, not domain + behavior; Python ints are unbounded and exact. +4. **Swift stubs are not ported:** `Waveform1D+Custom`, the empty + `SpatialWaveforms/Operators/*+Arithmetic` files, and the empty + `PrecisionTime/Extensions` files define no behavior. +5. **Serialization file I/O helpers** (Swift's CSV/JSON `load/save`) are not + part of this effort; wire interop is `to_dict`/`from_dict` (foundation's + `DataModelHelper` owns file I/O patterns). + +## Sibling spec index + +| Spec | Contract | +|---|---| +| [templateConformance.md](templateConformance.md) | template migration: packaging, Makefile/CI parity, rename, governance | +| [precisionTimeMath.md](precisionTimeMath.md) | `PrecisionTimeInterval`, `PrecisionTimestamp` | +| [spatialMath.md](spatialMath.md) | `Position`, `Quaternion`, `SpatialPose` | +| [waveformCore.md](waveformCore.md) | `Waveform1D` + aggregate waveform containers | +| [waveformDsp.md](waveformDsp.md) | DSP families, scipy mapping, support types | +| [polynomials.md](polynomials.md) | polynomial type + analytic root solvers | +| [otg.md](otg.md) | online trajectory generation (Ruckig port) | diff --git a/.claude/specs/otg.md b/.claude/specs/otg.md new file mode 100644 index 0000000..ba43838 --- /dev/null +++ b/.claude/specs/otg.md @@ -0,0 +1,181 @@ +--- +version: 1.0 +type: specification +name: otg +purpose: Behavioral contract for the online trajectory generation (OTG / Ruckig-port) subsystem +spec: OTG +scope: project +status: accepted +applies_to: src/math_tools/otg/, tests/otg/ +last_updated: 2026-07-11 +semver: 0.0.2 +author: Nicholas Bergantz +--- + +# Online Trajectory Generation (OTG) + +> Sibling of [mathToolsArchitecture.md](mathToolsArchitecture.md). A +> **faithful port** of the Swift OTG subsystem +> (`spmMathTools/FoundationMathTypes/OTG/`, itself a Ruckig port): time +> optimal, jerk-limited, multi-DOF trajectory generation. Fidelity ranking: +> (1) identical results on the truth-table oracles, (2) identical public +> semantics, (3) code structure mirroring the Swift files so the two ports +> stay diffable. Numeric kernel: `functional/roots.py` +> ([polynomials.md](polynomials.md)) — the OTG chunks MUST NOT reimplement +> root solving. + +## Module layout (mirrors the Swift files 1:1) + +``` +math_tools/otg/ + __init__.py # public re-exports: Otg, InputParameter, OutputParameter, + # Trajectory, Profile, Result, ControlInterface, + # Synchronization, DurationDiscretization, OtgError + otg.py # Otg driver class (spmOTG.swift) + input_parameter.py # InputParameter + validate (InputParameter.swift + codable ext) + output_parameter.py # OutputParameter (OutputParameter.swift) + trajectory.py # Trajectory (Trajectory.swift) + profile.py # Profile + ControlSigns/Direction/ReachedLimits (Profile.swift) + block.py # Block, Interval (Block.swift) + bound.py # Bound (Bound.swift) + brake.py # BrakeProfile (Brake.swift) + calculator_target.py # TargetCalculator (CalculatorTarget.swift) + enums.py # Result, ControlInterface, Synchronization, DurationDiscretization + errors.py # OtgError (Swift RuckigError/OTGErrors) + steps/ + position_first_order.py # PositionFirstOrderStep1/Step2 + position_second_order.py # PositionSecondOrderStep1/Step2 + position_third_order_step1.py # PositionThirdOrderStep1 (~850 Swift lines) + position_third_order_step2.py # PositionThirdOrderStep2 (~1900 Swift lines) + velocity_second_order.py # VelocitySecondOrderStep1/Step2 + velocity_third_order.py # VelocityThirdOrderStep1/Step2 +``` + +**Chunking hints (sized for one-session execution):** +`position_third_order_step1.py` and `position_third_order_step2.py` are +separate modules AND separate chunks (combined they are ~2,750 Swift lines — +never one chunk; split further along Swift function boundaries if needed, +preserving diffability). `profile.py` (~715 Swift lines), +`calculator_target.py` (~780), and the `Otg` driver each get their own +chunk. + +Only the `__init__.py` re-exports are public; `steps/`, `block/bound/brake`, +and `calculator_target` are private (`_`-free filenames but excluded from +`__all__`; they mirror Swift `internal`). + +## Public API + +**Enums** (`enums.py`): +- `Result(IntEnum)`: `WORKING = 0`, `FINISHED = 1`, `ERROR = -1`, + `ERROR_INVALID_INPUT = -100`, `ERROR_TRAJECTORY_DURATION = -101`, + `ERROR_POSITIONAL_LIMITS = -102`, `ERROR_ZERO_LIMITS = -104`, + `ERROR_EXECUTION_TIME_CALCULATION = -110`, + `ERROR_SYNCHRONIZATION_CALCULATION = -111` — values are wire-stable + (Ruckig parity). +- `ControlInterface`: `POSITION`, `VELOCITY`. +- `Synchronization`: `TIME`, `TIME_IF_NECESSARY`, `PHASE`, `NONE`. +- `DurationDiscretization`: `CONTINUOUS`, `DISCRETE`. + +**`InputParameter(dofs: int)`** — mutable per-cycle input; field-for-field +port of the Swift struct (current/target position/velocity/acceleration, +max/min velocity/acceleration, max jerk, optional position limits, +`enabled: list[bool]`, per-DOF control interface/synchronization overrides, +`intermediate_positions`, per-section limits/durations, `minimum_duration`, +`interrupt_calculation_duration`). `validate(check_current_state_within_limits=False, +check_target_state_within_limits=True) -> bool`. `==` field equality. +`to_dict`/`from_dict` (camelCase keys per the Swift Codable extension). + +**`OutputParameter(dofs: int, max_number_of_waypoints: int = 0)`** — +`trajectory: Trajectory`, `new_position/new_velocity/new_acceleration/ +new_jerk: list[float]`, `time: float`, `new_section: int`, +`did_section_change: bool`, `new_calculation: bool`, +`calculation_duration: float` (µs, Swift parity), +`pass_to_input(input: InputParameter) -> None`. + +**`Trajectory`** — `duration: float`, `degrees_of_freedom: int`, +`at_time(t: float) -> tuple[list[float], list[float], list[float]]` +(position, velocity, acceleration; clamped to `[0, duration]` ends per +Swift), `position_extrema() -> list[Bound]`, `independent_min_durations: +list[float]`, profile access for tests. + +**`Otg(control_cycle: float, dofs: int, max_number_of_waypoints: int = 0)`** +— the driver: +- `update(input: InputParameter, output: OutputParameter) -> Result` — one + control cycle: validates (→ `ERROR_INVALID_INPUT`), recalculates when the + input changed, steps `output.time` forward by `control_cycle`, samples the + trajectory into `output.new_*`, returns `WORKING`/`FINISHED`/error code. +- `calculate(input: InputParameter, output: OutputParameter) -> Result` — + full-trajectory calculation without time stepping. +- `reset() -> None`. + +**Error semantics** (umbrella exception carve-out): per-cycle failures are +`Result` codes, never exceptions. `OtgError` is raised only for structural +misuse (mismatched DOF counts between constructor and parameters, +non-positive `control_cycle`) — conditions that make every future cycle +invalid. + +## Internal fidelity requirements + +1. `Profile` is the hot per-DOF state record: arrays `t[7]`, `t_sum[7]`, + `j[7]`, `a[8]`, `v[8]`, `p[8]` as `list[float]` (fixed length, never + resized), plus `brake`/`accel: BrakeProfile`, targets `pf/vf/af`, + `limits: ReachedLimits`, `direction: Direction`, + `control_signs: ControlSigns`, and the `check*` method family with the + same names snake_cased. The kinematic stepping uses + `functional.roots.integrate_jerk`. +2. Step-solver classes port one Swift class each, same names snake_cased + (`PositionThirdOrderStep1.get_profile_...` etc.), same branch structure. + Translate mechanically; do not restructure the case analysis (it encodes + the Ruckig paper's profile taxonomy). +3. `TargetCalculator.synchronize` reproduces the Swift block-interval + synchronization (including `Block`/`Interval` blocked-interval logic and + the discrete-duration path). +4. Float64 throughout; comparisons use the Swift port's exact epsilon + constants (`EPS16`, `POLYNOMIAL_*` from `functional/roots.py` — never + fresh literals). +5. Pure Python + stdlib `math` in the per-cycle path (no numpy — scalar + loops over DOFs, matching Swift; performance is explicitly a non-goal + until measured). + +## Oracle and test strategy + +Two oracle tiers — the JSON corpus records **inputs only** (no durations, no +kinematics, no Result codes), so it can classify but not pin numbers; the +numeric golden lives in a hardcoded Swift test array. + +1. **Classification corpus:** copy `successful_trajectories.json` + (~1,680 cases) and `failed_trajectories.json` (~100 cases) from + `spmMathTools/spm/Tests/spmMathToolsTests/OTGTests/truthTables/` into + `tests/otg/data/` unmodified. `test_otg_truth_table.py` asserts: + successful cases → `calculate` returns a non-error `Result` + (`Result >= 0`); failed cases → an error `Result` (`Result < 0` — the + JSON's free-text error strings are NOT mapped to specific codes). +2. **Numeric oracle:** port the 32-case hardcoded truth table from + `OTGTruthTableTests.swift` (each case: input state/limits + + `expectedDuration` + `expectedTimeIntervals`, the 7 profile segment + times) into `tests/otg/data/otg_numeric_truth.json`, transcribed + verbatim from the Swift literals. Assertions per case: trajectory + duration rtol 1e-6; `Profile.t` segment times against + `expectedTimeIntervals` atol 1e-8 (this pins branch selection, not just + the coincidentally-summable duration). +3. **Ported suites:** the Swift `OTGComprehensiveTests`, `OTGContinuityTests` + (position/velocity/acceleration continuity across cycle boundaries and + section changes), and `OTGFailureFixTests` regression cases are ported + test-for-test. +4. **Invariant tests:** for randomized (seeded) valid inputs — output never + exceeds max velocity/acceleration/jerk beyond 1e-9; `at_time(duration)` + hits the target state within 1e-8; `FINISHED` is reached within + `duration/control_cycle + 2` update calls. + +## Compliance requirements (test-checkable) + +1. Classification corpus passes 100% of cases; numeric 32-case oracle passes + (duration rtol 1e-6, segment times atol 1e-8). +2. Continuity suite passes (no kinematic discontinuities at cycle/section + boundaries). +3. `Result` enum integer values grep-match the table above. +4. `math_tools/otg/` imports numpy nowhere (grep-pinned); imports the root + kernel only from `math_tools.functional.roots`. +5. Public surface = the `__init__.py` re-export list, nothing else + (`__all__` pinned by test). +6. mypy strict clean. diff --git a/.claude/specs/polynomials.md b/.claude/specs/polynomials.md new file mode 100644 index 0000000..da33d23 --- /dev/null +++ b/.claude/specs/polynomials.md @@ -0,0 +1,125 @@ +--- +version: 1.0 +type: specification +name: polynomials +purpose: Behavioral contract for the univariate polynomial type and the analytic root solvers +spec: Polynomials +scope: project +status: accepted +applies_to: src/math_tools/functional/, tests/functional/ +last_updated: 2026-07-11 +semver: 0.0.2 +author: Nicholas Bergantz +--- + +# Polynomials and Root Solvers + +> Sibling of [mathToolsArchitecture.md](mathToolsArchitecture.md). Two +> deliberately different fidelity levels: a **pythonic polynomial class** +> (numpy-backed, replaces the Swift subclass-per-degree hierarchy) and a +> **faithful port of the analytic root kernel** (`Roots.swift` / +> `Utils.swift`), because the OTG solver ([otg.md](otg.md)) depends on that +> kernel's exact semantics and tolerances. + +## `functional/polynomial.py` — `UnivariatePolynomial` + +One class; the Swift `PolynomialUnivariateOrder` enum + eleven subclasses +collapse (documented divergence — composition over inheritance; numpy's +companion-matrix roots supersede per-degree closed forms for the general +API). + +- Storage: `coefficients: npt.NDArray` float64, **ascending degree order** + (`c[0] + c[1]·x + …`, numpy convention; the Swift ordering is mapped at + construction — pin with a test). +- `UnivariatePolynomial(coefficients: Sequence[float])` — `ValueError` on + empty; trailing (highest-degree) zeros trimmed, so `degree` is exact. +- `degree: int`; `coefficient_at(degree: int) -> float` (0.0 beyond degree). +- `__call__(x: float | npt.NDArray) -> float | npt.NDArray` — Horner/ + `np.polynomial.polynomial.polyval`. +- `derivative -> UnivariatePolynomial`, + `integrate(constant: float = 0.0) -> UnivariatePolynomial`. +- `real_roots(tolerance: float = POLYNOMIAL_ZERO_THRESHOLD) -> list[float]` + — companion-matrix roots (`np.roots` equivalent), imaginary parts within + tolerance treated as real, sorted ascending. Degree-0: empty list + (constant ≠ 0) or `PolynomialSolveError` (zero polynomial — infinitely + many roots). +- `==` (trimmed-coefficient equality), `repr` (human-readable + `"3.0·x² − 1.0"` style), immutable (frozen dataclass or read-only + property). + +## `functional/roots.py` — analytic kernel (faithful port) + +Free functions and constants, semantics matching `Roots.swift` / +`Utils.swift` exactly (this module is the OTG's numeric substrate — do not +"improve" it). + +**This is NOT a general root finder.** Swift's `insertIfPositive` +(`Utils.swift:33`) means `solve_cubic` / `solve_quartic_monic` return only +**non-negative** real roots (they solve for time ≥ 0). This MUST be stated in +the module docstring and each function docstring; general root finding is +`UnivariatePolynomial.real_roots` above — the two contracts are deliberately +different. + +```python +import sys + +EPS16: float = 16 * sys.float_info.epsilon # Swift: 16 * .ulpOfOne ≈ 3.5527e-15 (NOT 1e-16) +POLYNOMIAL_TOLERANCE: float = 1e-14 +POLYNOMIAL_ZERO_THRESHOLD: float = 1e-9 + +def solve_cubic(a: float, b: float, c: float, d: float) -> list[float]: ... +def solve_resolvent(a: float, b: float, c: float) -> tuple[list[float], int]: + """Roots of the cubic resolvent plus the REAL-root count (Swift returns Int). + + In the 1-real-root case the third slot holds the imaginary part, not a + root — callers (solve_quartic_monic) must consult the count before + reading slots 1..2. + """ +def solve_quartic_monic(a: float, b: float, c: float, d: float) -> list[float]: + """Non-negative real roots of the monic quartic x⁴ + a·x³ + b·x² + c·x + d.""" +def evaluate_polynomial(coefficients: Sequence[float], x: float) -> float: ... +def polynomial_derivative(coefficients: Sequence[float]) -> list[float]: ... +def polynomial_monic_derivative(coefficients: Sequence[float]) -> list[float]: ... +def shrink_interval(coefficients: Sequence[float], left: float, right: float) -> float: ... + +def integrate_jerk(t: float, p0: float, v0: float, a0: float, j: float) -> tuple[float, float, float]: + """Constant-jerk kinematic step → (p, v, a) after t. Swift Utils.integrate.""" +``` + +Rules: + +1. Real-root **multiplicity, ordering, and the non-negative filter** match + the Swift implementation (translate the algorithm, then pin with test + vectors; where Swift returns duplicated roots for multiplicity, so does + this). +2. Coefficient argument ordering matches the Swift functions (document per + function in the docstring; it differs from `UnivariatePolynomial`'s + ascending convention). +3. Pure `math`-module scalar code — **no numpy** in this module (called + per-cycle by OTG; keep it allocation-light). +4. Degenerate leading coefficients degrade exactly as Swift does (cubic with + `a ≈ 0` within tolerance solves the quadratic, etc.). + +## Compliance requirements (test-checkable) + +1. `UnivariatePolynomial([−1, 0, 1]).real_roots() == [−1.0, 1.0]`; derivative + /integrate round-trip up to the constant; `__call__` matches Horner + evaluation on a vector input. +2. Kernel functions pinned two ways, entirely within this repo (no Swift + toolchain required): + a. **Literal vectors** — ≥ 15 hand-computed cases as test literals + spanning distinct roots, repeated roots, complex pairs, all-negative + real roots (→ empty result, pinning the non-negative filter), and + degenerate leading coefficient (`a ≈ 0` cubic → quadratic fallback). + Agreement: atol 1e-9 per root, identical root counts. + b. **Cross-check** — for randomized (seeded) coefficient sets, results + equal the non-negative real subset of `np.roots` (imaginary part < + `POLYNOMIAL_ZERO_THRESHOLD`), atol 1e-8; numpy is a test-only import. +3. `shrink_interval` converges on a bracketed root of a quintic to + `POLYNOMIAL_TOLERANCE`. +4. `integrate_jerk` pinned analytically: `t=1, p0=0, v0=0, a0=0, j=6` → + `(1.0, 3.0, 6.0)`. +5. `EPS16 == 16 * sys.float_info.epsilon` pinned by test (guards against the + 1e-16 transcription error). +6. `roots.py` imports nothing beyond `math`/`sys`/stdlib (grep-pinned). +7. mypy strict clean. diff --git a/.claude/specs/precisionTimeMath.md b/.claude/specs/precisionTimeMath.md new file mode 100644 index 0000000..4e00aab --- /dev/null +++ b/.claude/specs/precisionTimeMath.md @@ -0,0 +1,127 @@ +--- +version: 1.0 +type: specification +name: precisionTimeMath +purpose: Behavioral contract for the Tier-3 PrecisionTimeInterval and PrecisionTimestamp math types +spec: PrecisionTimeMath +scope: project +status: accepted +applies_to: src/math_tools/precision_time/, tests/precision_time/ +last_updated: 2026-07-11 +semver: 0.0.2 +author: Nicholas Bergantz +--- + +# Precision Time Math Types + +> Sibling of [mathToolsArchitecture.md](mathToolsArchitecture.md). Ports the +> behavior of Swift `PrecisionTimeInterval` / `PrecisionTimestamp` +> (spmFoundationTools `FoundationTypes/PrecisionTime/`, including +> `+Arithmetic`) as Tier-3 classes over the foundation ABCs. These are the +> time axis of every waveform type ([waveformCore.md](waveformCore.md)). + +## Base contracts + +- `PrecisionTimeInterval(PrecisionTimeIntervalABC)` — from + `foundation_abc.math.precisionTimeABC`. +- `PrecisionTimestamp(PrecisionTimestampABC)` — same module. +- Enums come from `foundation_abc.math.mathEnums` (`NumericSign`, + `Timescale`, `ReferenceFrame`) — never redefined here. + +## Representation + +Internal storage is a **single signed Python `int` of total attoseconds** +(exact, unbounded). The ABC's `(seconds, attoseconds, sign)` triple is derived +on access: + +```python +@property +def seconds(self) -> int: return abs(self._total_atto) // ATTOSECONDS_PER_SECOND +@property +def attoseconds(self) -> int: return abs(self._total_atto) % ATTOSECONDS_PER_SECOND +@property +def sign(self) -> NumericSign: # ZERO when _total_atto == 0, else POSITIVE/NEGATIVE +``` + +Divergence from Swift (per umbrella non-goal 3): no `UInt64` saturation, no +wrapping operators — arithmetic is exact. + +## `PrecisionTimeInterval` + +Immutable (instances are hashable; all operations return new instances). + +**Constructors** +- `PrecisionTimeInterval(seconds: int = 0, attoseconds: int = 0, sign: NumericSign = POSITIVE)` — ABC-shaped; normalizes (attosecond carry into seconds); `ValueError` on negative magnitude components. +- `from_seconds(seconds: float | int) -> PrecisionTimeInterval` — sign inferred; float path loses no more than float64 precision. +- `from_attoseconds(total: int) -> PrecisionTimeInterval` — signed total. +- `from_string(seconds: str, fractional: str) -> PrecisionTimeInterval` — lossless decimal-string constructor (fractional right-padded to 18 digits; `ValueError` if >18 digits or non-numeric). +- Class constants: `ZERO`, `ONE_SECOND`, `ONE_DECISECOND`, `ONE_MILLISECOND`, `ONE_MICROSECOND`. + +**Accessors** (beyond ABC): `total_attoseconds: int` (signed), +`seconds_as_float: float`. + +**Arithmetic / comparison** (all with `PrecisionTimeInterval` operands unless +noted; `NotImplemented` for foreign types so Python falls back correctly): +- `+`, `-`, unary `-`, unary `+`, `abs()` +- `*` and `/` by `int | float` scalar (both operand orders for `*`); scalar + ops round to nearest attosecond. +- `/` interval → `float` ratio (`ZeroDivisionError` on zero divisor). +- `==`, `<`, `<=`, `>`, `>=` (total ordering on signed total), `__hash__`. +- `bool(x)` is `not x.is_zero`. + +**Serialization**: `to_dict` inherited from the ABC; `from_dict` accepts the +ABC wire shape `{"seconds", "attoseconds", "sign"}` — exactly what +`foundationTypes` `PrecisionTimeIntervalType.to_dict()` emits (wire-format +interop is the Tier-1↔Tier-3 contract). + +## `PrecisionTimestamp` + +A point in time = signed attosecond offset from the Unix epoch, plus optional +metadata `timescale: Timescale | None`, `reference_frame: ReferenceFrame | None`, +`uncertainty: int | None` (attoseconds). Immutable, hashable. + +**Constructors** +- `PrecisionTimestamp(seconds=0, attoseconds=0, sign=POSITIVE, *, timescale=None, reference_frame=None, uncertainty=None)` +- `from_interval(interval, *, timescale=None, ...)` +- `from_datetime(dt: datetime.datetime, ...)` — pre-epoch supported; naive datetimes are `ValueError` (require tz-aware). +- `now(*, timescale=None, ...)` — classmethod. +- `from_days(days_since_epoch: int, attoseconds_of_day: int = 0, sign=POSITIVE, ...)` +- Class constant `EPOCH`. + +**Accessors**: ABC set plus `interval: PrecisionTimeInterval` (offset from +epoch), `days_since_epoch: int`, `seconds_of_day: int`, +`as_datetime: datetime.datetime` (UTC, lossy to microseconds). + +**Arithmetic / comparison** +- `timestamp ± interval → timestamp` (metadata carried from the timestamp); + `interval + timestamp → timestamp`. +- `timestamp - timestamp → PrecisionTimeInterval`. +- `==` includes metadata equality; `<`/`<=`/`>`/`>=` compare offsets only + (numeric ordering, Swift parity). +- `can_compare(other) -> bool` — False only when **both** sides specify a + `timescale` and they differ, or both specify a `reference_frame` and they + differ (a `None` on either side is compatible with anything). Uncertainty + is NOT part of `can_compare` (Swift parity: `canCompare(to:)`). +- `compare_validated(other) -> int` — returns -1/0/1; raises + `TimestampComparisonError` (from `math_tools.errors`, with a reason) when + (a) both specify differing timescales, (b) both specify differing reference + frames, or (c) both carry `uncertainty` and the absolute offset difference + is ≤ the sum of the two uncertainties (overlap). This is the Python + rendering of Swift's `Result` + in `PrecisionTimestamp+Comparable.swift`. + +**Serialization**: ABC `to_dict` (camelCase optional keys); `from_dict` +accepts the same wire shape (parity with `PrecisionTimestampType`). + +## Compliance requirements (test-checkable) + +1. Round-trip: `PrecisionTimeInterval.from_dict(PrecisionTimeIntervalType.from_dict(d).to_dict()).to_dict() == d` for representative payloads (and the timestamp equivalent). +2. `from_string("1", "5")` equals 1.5 s exactly; `from_string("0", "000000000000000001")` is 1 attosecond. +3. Attosecond exactness: `ONE_SECOND - PrecisionTimeInterval.from_attoseconds(1)` has `seconds == 0`, `attoseconds == 10**18 - 1`. +4. Carry normalization: constructing with `attoseconds >= 10**18` carries into seconds. +5. Sign algebra: negating flips `sign`; zero is `NumericSign.ZERO` and `bool` False. +6. Scalar mult/div round to nearest attosecond (pin one case each). +7. `timestamp - timestamp` across the epoch (one pre-1970 operand) is exact. +8. `compare_validated` raises `TimestampComparisonError` on differing timescale (both set), differing frame (both set), and overlapping uncertainty (both set, delta ≤ combined — pin the boundary case delta == combined as raising); returns -1/0/1 otherwise. `can_compare` is True when either side's metadata is `None` and ignores uncertainty. +9. `as_datetime`/`from_datetime` round-trips to microsecond precision, including a pre-epoch instant. +10. mypy strict clean; all classes pass `isinstance(x, )`. diff --git a/.claude/specs/spatialMath.md b/.claude/specs/spatialMath.md new file mode 100644 index 0000000..5467715 --- /dev/null +++ b/.claude/specs/spatialMath.md @@ -0,0 +1,192 @@ +--- +version: 1.0 +type: specification +name: spatialMath +purpose: Behavioral contract for the Position, Quaternion, and SpatialPose math types +spec: SpatialMath +scope: project +status: accepted +applies_to: src/math_tools/spatial/, tests/spatial/ +last_updated: 2026-07-11 +semver: 0.0.2 +author: Nicholas Bergantz +--- + +# Spatial Math Types + +> Sibling of [mathToolsArchitecture.md](mathToolsArchitecture.md). Ports the +> Swift `Position` / `Quaternion` / `SpatialPose` behavior (base types from +> spmFoundationTools plus the operator/constructor extensions in +> spmMathTools `FoundationMathTypes/Spatial/`) as Tier-3 SE(3) classes. + +## Modules and base contracts + +| Class | Module | Subclasses | Storage | +|---|---|---|---| +| `Position` | `spatial/position.py` | `PositionABC` | `np.ndarray` shape `(3,)` float64 | +| `Quaternion` | `spatial/quaternion.py` (migrated existing class) | `QuaternionABC` (already) | `numpy-quaternion` (already) | +| `SpatialPose` | `spatial/spatial_pose.py` | `SpatialTransformABC` | composed `Position` + `Quaternion` | + +`Quaternion` is the **existing, tested implementation** (~90 tests). Its +math behavior is authoritative and unchanged, but one structural edit is +REQUIRED: the pinned foundation branch deleted +`foundationTypes.mathTypes.quaternionABC` — the base class re-parents to +`foundation_abc.math.spatialABCs.QuaternionABC`. The old ABC also carried +`DataModelHelper`; the new one is `ABC`-only, so serialization tests that +assert `DataModelHelper` inheritance update their base-class assertions +(`to_dict`/`from_dict` behavior itself is preserved by the new ABC). +Beyond that, this spec only adds the members under "Quaternion additions". + +## Coordinate conventions (single source of truth) + +- **spherical** (`azimuth`, `elevation`): elevation measured from the xy-plane + (geographic). +- **spherical ISO** (`azimuth`, `polar`): polar measured from +z (ISO 80000-2 + colatitude) — the convention already documented in + `math_tools/spherical/spherical_generators.py`; both conventions preserved, + as in Swift. +- Angles in radians everywhere. + +## Cross-cutting API conventions (all three classes) + +- `normalized()` is a **method** returning a copy (matches the incumbent + `Quaternion.normalized()`); `normalize()` mutates in place. +- Approximate comparison is `isclose(other, rtol=1e-9, atol=0.0) -> bool` + (incumbent signature style; numpy `rtol`/`atol` vocabulary repo-wide). +- These classes are **mutable → unhashable**: each sets `__hash__ = None` + explicitly (pinned by test). Hashable time types are the immutable + exception ([precisionTimeMath.md](precisionTimeMath.md)). +- `__array__(dtype=None)` on `Position` (→ `(3,)` vector) and `Quaternion` + (→ `[w, x, y, z]`), so `np.asarray(x)` / `plt.plot(...)` work directly. + +## `Position` + +Mutable components (`p.x = 1.0` allowed), matching the existing `Quaternion` +dataclass idiom. + +**Constructors** +- `Position(x=0.0, y=0.0, z=0.0)` +- `from_vector(v: npt.ArrayLike) -> Position` (copies; `ValueError` unless it + coerces to shape `(3,)`) — covers lists/tuples/arrays; there is no + separate sequence constructor +- `from_components(x=0.0, y=0.0, z=0.0)` — scalar args, defaulted, matching + the incumbent `Quaternion.from_components(w, x, y, z)` shape +- `from_cylindrical(radius, angle, height)` +- `from_spherical(radius, azimuth, elevation)` +- `from_spherical_iso(radius, azimuth, polar)` +- classmethod constants: `origin()`, `unit_x()`, `unit_y()`, `unit_z()` +- `from_dict({"x","y","z"})` (ABC) + +**Properties** +- `x, y, z: float` (settable); `vector: npt.NDArray` (copy out); + `components: list[float]` +- Inverse coordinate accessors returning `NamedTuple`s: + `cylindrical -> (radius, angle, height)`, + `spherical -> (radius, azimuth, elevation)`, + `spherical_iso -> (radius, azimuth, polar)` +- `magnitude`, `magnitude_squared`, `is_unit` (recomputed, tolerance 1e-12); + `norm` as an alias of `magnitude` (parity with the incumbent + `Quaternion.norm`) and `__abs__` returning it + +**Operators / methods** +- `+`/`-` position±position and position±scalar (scalar broadcast, both orders); + `*`/`/` by scalar (both orders for `*`); in-place variants; unary `-`, `+` +- `dot(other) -> float`, `cross(other) -> Position` +- `distance(to)`, `distance_squared(to)` +- `normalize()` (in place; `ValueError` on zero vector), + `normalized() -> Position` +- `==` (exact), `isclose(other, rtol, atol)`, `repr`, `to_dict`/`from_dict` + +Swift's custom `•`/`×` operators map to the named methods `dot`/`cross` plus +`@` is NOT used (reserved; matrix semantics would mislead). + +## `Quaternion` additions + +Added to the existing class (plus the ABC re-parent above; no other change): + +1. `dot(other) -> float` — 4-component dot product. +2. `rotation_matrix_elements -> RotationMatrixElements` — frozen dataclass + with fields `xx, xy, xz, yx, yy, yz, zx, zy, zz` derived from + `to_rotation_matrix()` (single source: delegates to the existing method). +3. `rotate_position(p: Position) -> Position` — thin wrapper over the existing + `rotate_vector`, typed for `Position` (`q * p` NOT overloaded; explicit + method only, to avoid ambiguity with the existing quaternion `*`). +4. `__array__(dtype=None)` per the cross-cutting conventions. +5. snake_case aliases for the two camelCase slips on the public surface: + `from_numpy_quaternion` (= `fromNumpyQuaternion`) and + `to_unit_spherical_small_circle` (= `to_unitSphericalSmallCircle`); the + camelCase originals remain as deprecated aliases (docstring note, no + removal in this effort). + +## `SpatialPose` + +**Constructors** +- `SpatialPose(position: Position, orientation: Quaternion)` (defaults: + origin, identity) +- `from_components(x, y, z, qw, qx, qy, qz)` — quaternion order w-first, + matching the existing `Quaternion.from_components`. +- `from_homogeneous(matrix: npt.NDArray) -> SpatialPose` — `(4,4)`; rotation + extracted via the existing `Quaternion.from_rotation_matrix`; `ValueError` + on shape or non-rigid bottom row. +- `from_denavit_hartenberg(a: float, alpha: float, d: float, theta: float)` — + standard DH; the Swift precomputed-cos/sin overload collapses into this one. +- `identity()` classmethod. +- `from_dict({"position", "orientation"})` (ABC). + +**Properties** +- `position: Position`, `orientation: Quaternion` (ABC names; `orientation` + is the ABC's name for Swift's `quaternion` — ABC wins) +- passthroughs `x, y, z, qw, qx, qy, qz: float` +- `homogeneous -> npt.NDArray` `(4,4)` float64 (orientation normalized first, + Swift parity) +- `is_unit` — delegates to `orientation` + +**Operators / methods** (SE(3) semantics; `*` = "apply", the universal +robotics reading) +- `pose * pose -> SpatialPose` — composition: result rotation + `q1 * q2`, result position `p1 + q1.rotate(p2)`. +- `pose * position -> Position` — full SE(3) application, identical to + `transform(p)` (`q.rotate(p) + position`). +- `transform(p: Position) -> Position` — the named form of the above. +- `translated(by: Position) -> SpatialPose` — translation-only shift of the + pose. Swift's `pose + position` / `pose - position` operators are NOT + ported: a `+` that ignores orientation reads like "apply" and silently + drops rotation — the explicit method removes the footgun. +- `inverse -> SpatialPose` — `q⁻¹`, `-(q⁻¹.rotate(p))`. +- `relative_pose(to: SpatialPose) -> SpatialPose` — `self.inverse * to`. +- `interpolate(to, t: float) -> SpatialPose` — position lerp + quaternion + slerp (delegates to existing `Quaternion.slerp`); `t` unclamped, Swift + parity pinned by test. +- `position_distance(to)`, `position_distance_squared(to)`, + `angular_distance(to) -> float` (radians, double-cover safe: uses + `min(θ, 2π−θ)` via `|dot|`). +- `normalize()` / `normalized()` — normalize orientation only. +- `==` exact, `isclose(other, rtol, atol)` (double-cover aware via + `Quaternion.isclose`), `repr`, `to_dict`/`from_dict`. + +## Compliance requirements (test-checkable) + +1. All three classes satisfy `isinstance` of their ABC and round-trip + `to_dict`/`from_dict` against the matching `foundationTypes` generated + Type's wire output — exact target names on the pinned branch: + `PositionType`, `QuaternionType`, `SpatialTransformType` + (NOT the pre-template `PositionVectorType`/`SpatialPoseType`; refresh the + venv to the branch pin first, per + [templateConformance.md](templateConformance.md)). +2. Coordinate inits/accessors are mutual inverses (property-style tests over + a grid of radii/angles, both spherical conventions, atol 1e-12). +3. `cross` follows the right-hand rule (`unit_x × unit_y == unit_z` pinned). +4. DH: pinned against at least two published DH parameter sets (e.g. a 2-link + planar arm at known joint angles → known end-effector pose). +5. Composition algebra: `pose * pose.inverse` ≈ identity; + `(a * b).transform(p) == a.transform(b.transform(p))` (atol 1e-9). +6. `interpolate(t=0)` == self, `(t=1)` ≈ other (double-cover tolerant); + midpoint rotation angle is half the total angle for a pure rotation. +7. `angular_distance` between `q` and `-q` is 0. +8. `homogeneous` ∘ `from_homogeneous` round-trips (atol 1e-9), including a + non-normalized input quaternion (normalized on export). +9. Existing `Quaternion` test suite passes with import-path and base-class + assertion edits only — no assertion on math behavior changes. +10. `__hash__ is None` pinned for all three classes; `np.asarray(x)` returns + the documented array for `Position` and `Quaternion`. +11. mypy strict clean. diff --git a/.claude/specs/templateConformance.md b/.claude/specs/templateConformance.md new file mode 100644 index 0000000..1f0a630 --- /dev/null +++ b/.claude/specs/templateConformance.md @@ -0,0 +1,109 @@ +--- +version: 1.0 +type: specification +name: templateConformance +purpose: Bring py-MathTools into full conformance with the py-foundationTools template conventions +spec: TemplateConformance +scope: project +status: accepted +applies_to: pyproject.toml, requirements.txt, Makefile, .env, .github/, .claude/, src/, README.md +last_updated: 2026-07-11 +semver: 0.0.2 +author: Nicholas Bergantz +--- + +# Template Conformance + +> Sibling of [mathToolsArchitecture.md](mathToolsArchitecture.md). The +> authoritative template reference is py-foundationTools at branch +> `feat/switch-to-new-template` +> (`/Users/nbergantz/__Workspaces__/pythonWorkspaces/py-foundationTools`); +> this spec states what "conformant" means for this repo, not a copy of that +> repo's content. + +## Already conformant (verify, don't re-do) + +The current branch already carries: the uv Makefile (with `uv-fullCheck` +gate), `.env` (`PY_SRC=src`), `.bumpversion.cfg`, `ci.yml` / +`tag-on-prod.yml` / `publish.yml` scaffold, names-only `pyproject.toml` +policy, and pinned `requirements.txt`. Conformance work MUST NOT rewrite +these wholesale; only close the specific gaps below. + +## Gap 1 — package rename (breaking, approved) + +| Current | Target | +|---|---| +| `src/pyMathTools/` | `src/math_tools/` | +| `src/pyMathToolsPlotHelpers/` | `src/math_plot_helpers/` | + +Requirements: + +1. `git mv` the trees; module layout inside follows the umbrella's module map + (existing `spatial/`, `spherical/`, `hints.py` migrate; `Quaternion.py` → + `spatial/quaternion.py`; `plotUnitSpherical.py` → + `math_plot_helpers/plot_unit_spherical.py`). Module **filenames** become + snake_case; class names are unchanged. +2. Behavior of migrated code is unchanged in the rename chunk — imports and + paths only ([stay-in-scope]). Extensions to migrated classes come later + under their own sibling specs. +3. All imports updated: `tests/`, `examples/`, intra-package. +4. Every package dir (`math_tools`, each subpackage with public API, + `math_plot_helpers`) contains `py.typed`. +5. `pyproject.toml` needs no `packages` edit (`package-dir = {"" = "src"}` + auto-discovers), but the Makefile's mypy package list (`MYPY_PKGS` pattern + from the template) and any name references MUST resolve to the new names. +6. Distribution name stays `py_math_tools`; only import packages rename. + +## Gap 2 — dependencies + +1. `pyproject.toml` `dependencies`: names only — + `pyFoundationTools`, `numpy`, `scipy`, `numpy-quaternion`, `matplotlib`. +2. `requirements.txt`: keep the `pyFoundationTools @ git+...@feat/switch-to-new-template` + pin until foundation tags a release, then move to a tag pin (tracked as a + known follow-up, not part of this effort). +3. No `uv.lock` committed (repo BKM). +4. **Hard prerequisite for every other chunk:** the installed `.venv` may + hold a pre-template `pyfoundationtools` (old per-type ABC layout, + `foundationTypes.mathTypes.quaternionABC` etc.). The first chunk MUST + `make uv-refresh` (or equivalent) so the environment matches the branch + pin's layout (`foundation_abc.math.*`, `PositionType`, + `SpatialTransformType`, `ScalarWaveformType`); the gate is meaningless + against the stale install. + +## Gap 3 — `.claude/` governance + +1. `.claude/CLAUDE.md` authored for this repo: role (Tier 3 of foundation's + math tiers), package map, gate command, pointer to `.claude/specs/`. + It references specs — never duplicates their content. +2. `.claude/specs/` — this spec set. +3. `.claude/action-plan/` — the chunk set produced from these specs. + +## Gap 4 — README and metadata + +1. `README.md` replaces the "Python Boilerplate" stub, following the + template's section shape: title → Features → Installation → Quick Start → + Development Workflows → Requirements. Content MUST describe what actually + exists at the time the chunk runs (no aspirational feature lists). +2. `pyproject.toml` `description` updated from boilerplate text. +3. `examples/sphericalPlotting/*` imports fixed to real module paths (note: + `plotArcs.py` currently imports a nonexistent + `foundationTypes.mathTypes.UnitSphericalArc` path). + +## Gap 5 — layering test + +Port the template's layering-enforcement pattern +(py-foundationTools `tests/test_package_layering.py`) as +`tests/test_package_layering.py` asserting, via import/AST scan of `src/`: + +- `math_tools` never imports `math_plot_helpers` or `matplotlib`. +- `math_plot_helpers` may import `math_tools`. +- Only `math_plot_helpers` imports `matplotlib`. + +## Compliance checklist (mechanically verifiable) + +- [ ] `grep -r "pyMathTools" src/ tests/ examples/ Makefile pyproject.toml` → no hits +- [ ] `find src -name py.typed` covers every public package +- [ ] `make uv-fullCheck` passes after rename +- [ ] `.claude/CLAUDE.md` exists and links every spec in `.claude/specs/` +- [ ] `README.md` contains no "Boilerplate" text +- [ ] `tests/test_package_layering.py` passes and fails if `import matplotlib` is added to any `math_tools` module diff --git a/.claude/specs/waveformCore.md b/.claude/specs/waveformCore.md new file mode 100644 index 0000000..310b723 --- /dev/null +++ b/.claude/specs/waveformCore.md @@ -0,0 +1,196 @@ +--- +version: 1.0 +type: specification +name: waveformCore +purpose: Behavioral contract for Waveform1D and the aggregate spatial waveform containers +spec: WaveformCore +scope: project +status: accepted +applies_to: src/math_tools/waveforms/, tests/waveforms/ +last_updated: 2026-07-11 +semver: 0.0.2 +author: Nicholas Bergantz +--- + +# Waveform Core Types + +> Sibling of [mathToolsArchitecture.md](mathToolsArchitecture.md). Ports Swift +> `Waveform1D` / `WaveformPosition` / `WaveformQuaternion` / +> `WaveformSpatialPose` container behavior (storage, stats, operators, +> slicing, mutation, component decomposition). The DSP/analysis surface is a +> separate contract: [waveformDsp.md](waveformDsp.md). + +## Modules and base contracts + +| Class | Module | Subclasses | Sample storage | +|---|---|---|---| +| `Waveform1D` | `waveforms/waveform1d.py` | `Waveform1dABC` | `np.ndarray` 1-D (dtype preserved; float64 default) | +| `WaveformPosition` | `waveforms/waveform_position.py` | `PositionWaveformABC` | `np.ndarray` `(n, 3)` float64 | +| `WaveformQuaternion` | `waveforms/waveform_quaternion.py` | `QuaternionWaveformABC` | `np.ndarray` `(n, 4)` float64, order `(w, x, y, z)` | +| `WaveformSpatialPose` | `waveforms/waveform_spatial_pose.py` | `WaveformSpatialABC` | parallel `(n,3)` + `(n,4)` arrays | + +Element access materializes `Position` / `Quaternion` / `SpatialPose` +([spatialMath.md](spatialMath.md)) on demand; bulk storage stays numpy +(vectorized ops never round-trip through per-element objects). + +## ABC accessor vs numpy storage (load-bearing) + +The ABCs' abstract accessors have fixed names and types that MUST be +implemented or the classes stay abstract: + +| Class | ABC accessor implemented | Returns | numpy bulk accessor | +|---|---|---|---| +| `Waveform1D` | `waveform` | `Sequence[float]` (the samples) | `values: npt.NDArray` | +| `WaveformPosition` | `positions` | `Sequence[PositionABC]` (materialized `Position` list, built on access) | `positions_array: npt.NDArray (n,3)` | +| `WaveformQuaternion` | `quaternions` | `Sequence[QuaternionABC]` | `quaternions_array: npt.NDArray (n,4)` | +| `WaveformSpatialPose` | `positions` + `quaternions` | both of the above | `positions_array` + `quaternions_array` | + +The inherited ABC `to_dict` therefore emits the correct wire keys +(`"waveform"`, `"positions"`, `"quaternions"`) for free. Implementation code +and the DSP mixins use the numpy accessors; the ABC accessors exist for the +contract and serialization. + +## Instantiability / DSP phasing (load-bearing) + +The core chunk ships `class Waveform1D(Waveform1dABC)` with **no DSP mixin +bases**. The mixin base list is added in a single later "compose" chunk after +every mixin exists ([waveformDsp.md](waveformDsp.md)); until then the class +is complete and the gate stays green. A test pins that `Waveform1D` is +concrete (`Waveform1D.__abstractmethods__ == frozenset()` and a bare +construction succeeds). + +## Time axis (shared by all four) + +- `dt: PrecisionTimeInterval` — required, `ValueError` unless strictly + positive ([precisionTimeMath.md](precisionTimeMath.md)). +- `t0: PrecisionTimestamp` — **defaults to `PrecisionTimestamp.EPOCH`**. + Divergence from Swift's optional `t0`, forced by the ABC's non-optional + `t0` contract; a waveform with unspecified start simply starts at epoch. +- Shared computed properties: `duration: PrecisionTimeInterval` + (`dt * (n-1)`, ZERO when `n <= 1`), `duration_seconds: float`, + `sampling_frequency_hz: float`, `nyquist_frequency_hz: float`, + `sample_count: int` (also `__len__`), `time_axis() -> npt.NDArray` + (float64 seconds relative to `t0`). +- Convenience constructor parameters on every class: + `dt_seconds: float = 1.0` (**the default when `dt` is omitted** — so + `Waveform1D(values)` and `Waveform1D.sine(n=1000)` are legal and mean + one sample per second; `TypeError` if both `dt` and `dt_seconds` given) + and `t0_seconds: float` (offset from epoch; mutually exclusive with `t0`). + +## `Waveform1D` + +**Constructors**: `Waveform1D(values, dt=..., t0=...)` (`values`: any 1-D +array-like; copied), plus signal generator classmethods (all take +`n: int, dt/dt_seconds, t0` and shape parameters; seeded determinism where +random): `sine`, `cosine`, `square`, `triangle`, `sawtooth`, `chirp`, +`exponential_decay`, `exponential_growth`, `polynomial(coefficients)`, +`linear_ramp`, `logarithm`, `logarithm10`, `square_root`, `heaviside`, +`relu`, `sigmoid`, `white_noise(seed)`, `constant`, `impulse`, +`damped_sinusoid`, `counter`, `digital_square`. Each maps to a one-line +numpy expression; the Swift parameterization is the reference for argument +names/meaning. + +**Statistics** (properties; `None` on empty where Swift is optional): +`minimum`, `maximum`, `peak_to_peak`, `mean`, `rms`, `standard_deviation`, +`variance`, `sum`, `absolute_sum`. + +**Operators** (elementwise; operands `Waveform1D | scalar`; both scalar +orders; in-place variants mutate): +- `+ - * /` (and `//`, `%` — meaningful for integer dtype); waveform⊕waveform + requires equal `dt` and length else `WaveformCompatibilityError`. +- Bitwise `& | ^ << >> ~` — integer dtype only (`TypeError` otherwise, + numpy's natural behavior surfaced with a clear message). +- Unary `-`, `+`, `abs()`. +- Comparison producers: `elements_equal(other) -> npt.NDArray[np.bool_]`, + `elements_less_than`, `elements_greater_than`, + `isclose_elementwise(other, rtol=1e-9, atol=0.0)` (numpy `rtol`/`atol` + vocabulary, consistent with the spatial types — not Swift's single + `tolerance`). +- `isclose(other, rtol=1e-9, atol=0.0) -> bool` — whole-waveform: same `dt`, + same `t0`, all samples close. +- `==` — full equality: samples (exact), `dt`, `t0`. +- `__iter__` (yields scalars), `__array__(dtype=None)` (→ the samples), so + `for x in w`, `np.asarray(w)`, `np.mean(w)`, `plt.plot(w)` all work. + +**Indexing / slicing / sampling** +- `w[i] -> scalar`; `w[a:b] -> Waveform1D` (t0 advanced by `a * dt`; + step != 1 is `ValueError` — resampling is the explicit API). Index + slicing is the only index-range API (Swift's `subset(start:end:)` is + redundant with it and not ported). +- `subset_time(start_time, end_time)` (PrecisionTimestamp or float seconds + relative to t0) — half-open, `ValueError` on empty/invalid range. +- `value_at_index(i: float) -> float`, `value_at_time(t) -> float` — linear + interpolation; `ValueError` outside `[0, n-1]` / the time span. + +**Mutation API** (the only in-place surface besides in-place operators) — +Python list vocabulary, not Swift's: +`append(x)`, `append_values(iterable)`, `prepend(x)`, +`prepend_values(iterable)` (t0 shifts back by `k * dt`), `insert(i, x)`, +`replace(i, x)`, `replace_range(slice, values)`, +`pop(i=-1) -> float` (`IndexError` on empty/out-of-range — stdlib +semantics, not Swift's Optional), `clear()`. + +## Aggregate containers + +All three share (with `Element` = `Position` / `Quaternion` / `SpatialPose`): + +- **Constructors**: from element arrays/lists; `from_components(...)` from + per-component `Waveform1D`s (`None`-safe: `ValueError` unless counts and + `dt` all match — Swift returned nil); `WaveformSpatialPose` additionally + `from_poses(list[SpatialPose], dt/dt_seconds, t0)` and + `from_waveforms(position_waveform, quaternion_waveform)`. +- **Element access**: `w[i] -> Element`, `w[a:b] -> Self`, `get(i) -> + Element | None` (Swift's `subscript(safe:)`), `__iter__` yielding + materialized `Element`s. +- **Component decomposition**: `component_waveforms` returning a `NamedTuple` + of `Waveform1D`s — `(x, y, z)` for positions, **`(w, x, y, z)`** for + quaternions (w-first, matching storage, `from_components`, and the + incumbent `Quaternion.to_components()`; the Swift x-first tuple order is + NOT kept), nested position+quaternion tuple for poses; + `WaveformSpatialPose` also `position_waveform` and `quaternion_waveform`. +- **Normalization**: predicate stem is `unit` everywhere — + `WaveformPosition.are_all_unit`, `WaveformQuaternion.are_all_unit`, + `WaveformSpatialPose.are_all_positions_unit` / + `are_all_quaternions_unit`; `normalize()` in place, `normalized()` copy. + Zero-magnitude elements raise `ValueError` (parity with + `Position.normalize`). +- **Mutation**: same verbs as `Waveform1D` with `Element` payloads, plus + `extend(other: Self)` and `concatenate(other: Self) -> Self` — both raise + `WaveformCompatibilityError` on `dt` mismatch (Swift `throws` parity). +- `WaveformSpatialPose.is_valid` (parallel arrays equal length) and + `sample_count == min(len(positions), len(quaternions))` — both subtle Swift + behaviors, kept. +- `==`, `repr`, `to_dict`/`from_dict` per the ABC wire shapes. + +## Compliance requirements (test-checkable) + +1. All four classes: `isinstance` of their ABC; `to_dict` output loads via + the matching `foundationTypes` generated Type and round-trips equal — + exact target names on the pinned branch: `Waveform1D` ↔ + `ScalarWaveformType`, `WaveformPosition` ↔ `PositionWaveformType`, + `WaveformQuaternion` ↔ `QuaternionWaveformType`, `WaveformSpatialPose` ↔ + `SpatialTransformWaveformType`. +2. `dt` mismatch on any binary/extend/concat op raises + `WaveformCompatibilityError`; equal-`dt` path is exact. +3. Slicing adjusts `t0` by exactly `start * dt` (attosecond-exact via + PrecisionTime, not float). +4. Generators pinned analytically: `sine` matches `np.sin` on the time axis; + `white_noise(seed=k)` reproducible; `impulse` sums to 1 sample of + amplitude. +5. Stats match numpy references on a fixed vector (`rms` = + `sqrt(mean(x**2))`, `variance` population variance — pin Swift's ddof + choice with a literal expected value). +6. `value_at_time` midpoint between two samples returns their average. +7. Mutation verbs match stdlib list semantics (pin `prepend` t0 shift; + `pop()` on empty raises `IndexError`; `clear()` empties). +8. Aggregate `component_waveforms` → `from_components` round-trips exactly + (quaternion tuple order `(w, x, y, z)` pinned). +9. `WaveformSpatialPose` with unequal arrays: `is_valid` False, + `sample_count` = min — both pinned. +10. Bulk ops never construct per-element objects (pin with a 1e6-sample + smoke test completing under a generous bound, guarding the vectorized + design). +11. `Waveform1D(values)` with no time arguments constructs (dt = 1 s); + `Waveform1D.__abstractmethods__` is empty; `np.asarray(w)` equals the + samples; `for x in w` iterates them. +12. mypy strict clean. diff --git a/.claude/specs/waveformDsp.md b/.claude/specs/waveformDsp.md new file mode 100644 index 0000000..914e262 --- /dev/null +++ b/.claude/specs/waveformDsp.md @@ -0,0 +1,148 @@ +--- +version: 1.0 +type: specification +name: waveformDsp +purpose: Behavioral contract for the Waveform1D DSP/analysis surface (scipy-backed) +spec: WaveformDsp +scope: project +status: accepted +applies_to: src/math_tools/waveforms/dsp/, src/math_tools/waveforms/support.py, tests/waveforms/dsp/ +last_updated: 2026-07-11 +semver: 0.0.2 +author: Nicholas Bergantz +--- + +# Waveform DSP Surface + +> Sibling of [mathToolsArchitecture.md](mathToolsArchitecture.md); extends +> `Waveform1D` from [waveformCore.md](waveformCore.md). Target: **capability +> parity** with the Swift `Waveform1D/Extensions/` DSP families, implemented +> as thin scipy/numpy wrappers. Parity is judged per capability, not per +> Swift overload — where scipy's algorithm is the better-tested equivalent, +> scipy semantics win and the divergence is documented in the method +> docstring. + +## Organization — one mixin per family + +Each family is a mixin class in its own module under +`math_tools/waveforms/dsp/`; `Waveform1D` composes them: + +```python +# waveforms/waveform1d.py +class Waveform1D( + CalcMixin, CorrelationMixin, EnvelopeMixin, SpectralMixin, + FilteringMixin, PeakMixin, PhaseMixin, ResamplingMixin, + TimeAlignmentMixin, TriggerMixin, WindowingMixin, ZeroCrossingMixin, + Waveform1dABC, +): +``` + +Mixin rules: stateless; touch only the public `Waveform1D` surface +(`values`, `dt`, `t0`, constructors). Typing mechanism (load-bearing — +mypy strict rejects a bare mixin referencing `self.values`): each mixin +**inherits** `WaveformProtocol` (a `typing.Protocol` in `dsp/_protocol.py` +declaring `values: npt.NDArray`, `dt`, `t0`, and the replace-values factory +the mixins use to build results). That makes every mixin independently +mypy-strict-checkable and independently testable. Methods return new +objects or descriptor types — never mutate. + +**Composition phasing** (mirrors waveformCore's "Instantiability" section): +the core `Waveform1D` ships with base `Waveform1dABC` only. Each mixin chunk +adds a module without touching `waveform1d.py`. A single final **compose +chunk** edits the base list to the form above, adds the MRO test, and pins +that the composed class is still concrete (empty `__abstractmethods__`, +bare construction succeeds). Compliance item 4 applies only from that chunk +onward. + +**Chunking hint:** one action-plan chunk per mixin; `support.py`, +`dsp/_protocol.py`, and `dsp/_common.py` land first as a prerequisite chunk. + +## Family contracts + +For every family: the Swift extension file is the reference for the method +list and argument meanings; the table row states the required Python surface +and its backing implementation. Enum arguments use the support types below. + +| Mixin (module) | Required methods | Backing | +|---|---|---| +| `CalcMixin` (`_calc.py`) | `integrate(initial_value=0.0)`, `derivative()` | cumulative trapezoid (`scipy.integrate.cumulative_trapezoid`) · `np.gradient`; both scale by `dt` seconds | +| `CorrelationMixin` (`_correlation.py`) | `auto_correlation(max_lag=None, normalized=True)`, `cross_correlation(other, ...)`, `find_max_correlation(other, ...) -> WaveformTimeLag` | `scipy.signal.correlate` / `correlation_lags` | +| `EnvelopeMixin` (`_envelope.py`) | `amplitude_envelope(...)`, `upper_lower_envelopes(...) -> tuple[Waveform1D, Waveform1D]`, `instantaneous_amplitude(method: WaveformInstantaneousMethod)` | `scipy.signal.hilbert`; peak-interp path via `find_peaks` + interp | +| `SpectralMixin` (`_spectral.py`) | `fft() -> WaveformSpectrum`, `power_spectral_density(...) -> WaveformSpectrum`, `spectrogram(...) -> WaveformSpectrogram`, `mel_spectrogram(...) -> WaveformMelSpectrogram`, `spectral_features() -> WaveformSpectralFeatures` | `np.fft.rfft` / `scipy.signal.welch` / `scipy.signal.ShortTimeFFT`; mel filterbank hand-built (numpy) | +| `FilteringMixin` (`_filtering.py`) | `filtered(filter_type: WaveformFilterType, order=4)`, `low_pass_filter(cutoff_hz, ...)`, `high_pass_filter(...)`, `band_pass_filter(low_hz, high_hz, ...)`, `moving_average_filter(window_size)`, `exponential_filter(alpha)`, `savitzky_golay_filter(window_length, polyorder, deriv=0)`, `whittaker_henderson_filter(lam, order=2)`, `frequency_response(...) -> WaveformSpectrum` | `scipy.signal.butter`+`filtfilt`, `savgol_filter`, `np.convolve`; Whittaker–Henderson via `scipy.sparse` difference-matrix solve | +| `PeakMixin` (`_peaks.py`) | `detect_peaks(...) -> list[WaveformPeak]`, `detect_valleys(...)`, `find_most_prominent_peaks(count, min_distance=None) -> list[WaveformPeakWithProminence]` | `scipy.signal.find_peaks` / `peak_prominences` | +| `PhaseMixin` (`_phase.py`) | `instantaneous_phase(...)`, `unwrap_phase(...)`, `instantaneous_frequency(...) -> WaveformInstantaneousFrequency`, `phase_difference(other, ...)`, `phase_coherence(other, ...)`, `phase_synchronization_index(other) -> float`, `group_delay(...)` | `scipy.signal.hilbert`, `np.unwrap` | +| `ResamplingMixin` (`_resampling.py`) | `decimated(factor, ...)`, `interpolated(factor, method: WaveformInterpolationMethod = ...)`, `resampled(target_frequency_hz, ...)`, `resampled_to_match(other)`, `polyphase_resampled(up, down)` | `scipy.signal.decimate` / `resample` / `resample_poly`; results carry recomputed `dt` (attosecond-exact where the ratio is rational) | +| `TimeAlignmentMixin` (`_time_alignment.py`) | `aligned(to, method: WaveformAlignmentMethod = ...)`, `time_lag(to, max_lag=None) -> WaveformTimeLag \| None`, `synchronize(waveforms: Sequence[Waveform1D]) -> list[Waveform1D]` (classmethod), `time_windows(...)`, `time_segments(...)` | correlation-lag via `CorrelationMixin` | +| `TriggerMixin` (`_triggers.py`) | `detect_triggers(trigger: WaveformTrigger) -> list[WaveformTriggerEvent]`, `detect_edge_triggers(level, edge: WaveformEdgeType)`, `detect_level_triggers(...)`, `detect_window_triggers(low, high, kind: WaveformWindowTriggerType)`, `detect_pattern_triggers(pattern, tolerance)`, `with_event_markers(events) -> WaveformWithEvents` | numpy comparisons + sign-change indexing | +| `WindowingMixin` (`_windowing.py`) | `windowed(window: WaveformWindowType)`, `generate_window(window, length) -> npt.NDArray` (staticmethod), `window_coherent_gain(window) -> float`, `window_processing_gain(window) -> float` | `scipy.signal.get_window` | +| `ZeroCrossingMixin` (`_zero_crossings.py`) | `zero_crossings(direction: WaveformZeroCrossingDirection = BOTH) -> list[WaveformZeroCrossing]`, `zero_crossing_count(...)`, `zero_crossing_rate(...) -> float`, `segments_between_zero_crossings(...) -> list[Waveform1D]` | numpy sign-change indexing, sub-sample linear interp | + +## Support descriptor types — `waveforms/support.py` + +Port from Swift `Waveform1D/Support/` **only the types consumed by the +methods above** (YAGNI on the rest; add with the method that needs them). + +- **Enums** (`enum.Enum`, values = snake_case strings): + `WaveformFilterType` (LOW_PASS/HIGH_PASS/BAND_PASS/BAND_STOP), + `WaveformWindowType` (HANN/HAMMING/BLACKMAN/BARTLETT/KAISER/RECTANGULAR), + `WaveformInterpolationMethod` (LINEAR/CUBIC/NEAREST/FOURIER), + `WaveformInstantaneousMethod` (HILBERT/RMS/PEAK), + `WaveformEdgeType` (RISING/FALLING/BOTH), + `WaveformWindowTriggerType` (ENTER/EXIT), + `WaveformAlignmentMethod` (CORRELATION/START_TIME), + `WaveformZeroCrossingDirection` (POSITIVE/NEGATIVE/BOTH), + `WaveformPaddingStrategy` (ZERO/EDGE/REFLECT/WRAP), + `WaveformPSDScaling` (DENSITY/SPECTRUM), + `WaveformSpectrogramScaling` (LINEAR/DB/MEL). +- **Frozen dataclasses** (all `@dataclass(frozen=True, slots=True)`). + **ndarray hazard:** any descriptor holding numpy arrays + (`WaveformSpectrum`, `WaveformSpectrogram`, `WaveformMelSpectrogram`, + `WaveformInstantaneousFrequency`, `WaveformFilterCoefficients`, …) MUST + use `@dataclass(frozen=True, slots=True, eq=False)` — the generated + `__eq__`/`__hash__` raise/misbehave on arrays (`ValueError: ambiguous + truth value`). Scalar-only descriptors keep the default `eq=True`. + Descriptor types: + `WaveformSpectrum(frequencies, magnitudes, phases)` (numpy arrays; replaces + Swift's anonymous fft tuple), `WaveformSpectrogram(times, frequencies, + magnitudes)`, `WaveformMelSpectrogram(...)`, `WaveformSpectralFeatures` + (centroid, spread, rolloff, flatness, …, matching the Swift fields), + `WaveformPeak(index, time_seconds, value)`, + `WaveformPeakWithProminence(peak, prominence)`, + `WaveformTimeLag(lag_samples, lag_seconds, correlation)`, + `WaveformTrigger(kind: WaveformTriggerType, level, ...)`, + `WaveformTriggerEvent(index, time_seconds, value, kind)`, + `WaveformEventMarker(index, label)`, `WaveformWithEvents(waveform, + events)`, `WaveformZeroCrossing(index, time_seconds, direction)`, + `WaveformInstantaneousFrequency(frequencies_hz, times_seconds)`, + `WaveformFrequencyRange(low_hz, high_hz)`, + `WaveformFilterCoefficients(numerator, denominator)`. + +## Numerical conventions + +- Frequencies in Hz, times in float seconds (derived from the PrecisionTime + axis at the boundary), phases in radians. +- Filters are zero-phase (`filtfilt`) unless a method exposes a `causal=` + flag. +- Methods that need a minimum length raise `ValueError` with the requirement + in the message (never a silent empty result), except detectors + (`detect_*`, `zero_crossings`) which return empty lists on no-hit. + +## Compliance requirements (test-checkable) + +1. Every mixin method tested against an analytically known signal — e.g. + `fft` of a pure 10 Hz sine peaks at 10 Hz bin; `low_pass_filter` on a + 5 Hz + 200 Hz mix attenuates the 200 Hz component by ≥ 40 dB; + `derivative` of a linear ramp is constant to atol 1e-9; + `integrate`∘`derivative` recovers a smooth signal (interior points, + rtol 1e-6); `detect_peaks` on `sine` finds exactly `⌊n·f·dt⌋` peaks; + `zero_crossing_rate` of a sine equals `2f` ±1 count; `unwrap_phase` of a + chirp is monotone; `resampled` ×2 then ÷2 round-trips (rtol 1e-3, interior). +2. Each mixin module imports scipy/numpy only (no sibling mixin imports — + shared helpers live in `dsp/_common.py`); pinned by a layering test. +3. Descriptor types are frozen (mutation raises); ndarray-bearing ones have + `eq=False` (pin: constructing two equal-content instances and comparing + does not raise); all mypy strict clean. +4. From the compose chunk onward: `Waveform1D` MRO composes every mixin + exactly once (pinned by a test enumerating `Waveform1D.__mro__`), the + class remains concrete, and a bare `Waveform1D(values)` constructs. From f8935f617b3f871f32ea3798d1bffb9751bbaeec Mon Sep 17 00:00:00 2001 From: kopecn Date: Sat, 11 Jul 2026 19:46:29 -0500 Subject: [PATCH 07/70] 01 Template rename and refresh Rename pyMathTools/pyMathToolsPlotHelpers packages to snake_case (math_tools/math_plot_helpers) and re-parent QuaternionABC onto the new foundation_abc layout per the template conformance spec. --- .../01-template-rename-and-refresh.md | 60 +++++++++++++++++-- examples/sphericalPlotting/plotArcs.py | 6 +- .../sphericalPlotting/plotQuatUnitCircles.py | 4 +- .../__init__.py | 0 .../plot_unit_spherical.py} | 12 ++-- .../spatial => math_plot_helpers}/py.typed | 0 .../spatial => math_tools}/__init__.py | 0 src/{pyMathTools => math_tools}/hints.py | 2 +- .../spherical => math_tools}/py.typed | 0 .../spatial}/__init__.py | 0 .../spatial/py.typed} | 0 .../spatial/quaternion.py} | 4 +- src/math_tools/spherical/__init__.py | 0 .../spherical/constructors.py | 2 +- src/math_tools/spherical/py.typed | 0 .../spherical/spherical_generators.py} | 8 ++- .../spherical/spherical_transforms.py} | 6 +- tests/__init__.py | 2 +- tests/test_quaternion.py | 12 ++-- 19 files changed, 85 insertions(+), 33 deletions(-) rename src/{pyMathTools => math_plot_helpers}/__init__.py (100%) rename src/{pyMathToolsPlotHelpers/plotUnitSpherical.py => math_plot_helpers/plot_unit_spherical.py} (98%) rename src/{pyMathTools/spatial => math_plot_helpers}/py.typed (100%) rename src/{pyMathTools/spatial => math_tools}/__init__.py (100%) rename src/{pyMathTools => math_tools}/hints.py (94%) rename src/{pyMathTools/spherical => math_tools}/py.typed (100%) rename src/{pyMathTools/spherical => math_tools/spatial}/__init__.py (100%) rename src/{pyMathToolsPlotHelpers/__init__.py => math_tools/spatial/py.typed} (100%) rename src/{pyMathTools/spatial/Quaternion.py => math_tools/spatial/quaternion.py} (99%) create mode 100644 src/math_tools/spherical/__init__.py rename src/{pyMathTools => math_tools}/spherical/constructors.py (97%) create mode 100644 src/math_tools/spherical/py.typed rename src/{pyMathTools/spherical/sphericalGenerators.py => math_tools/spherical/spherical_generators.py} (97%) rename src/{pyMathTools/spherical/sphericalTransforms.py => math_tools/spherical/spherical_transforms.py} (96%) diff --git a/.claude/action-plan/01-template-rename-and-refresh.md b/.claude/action-plan/01-template-rename-and-refresh.md index 33558cf..2d09849 100644 --- a/.claude/action-plan/01-template-rename-and-refresh.md +++ b/.claude/action-plan/01-template-rename-and-refresh.md @@ -1,7 +1,7 @@ --- chunk: 01-template-rename-and-refresh track: A -status: pending +status: complete depends_on: [] spec: ../specs/templateConformance.md §Gap 1, §Gap 2.4; ../specs/spatialMath.md §Modules (ABC re-parent) last_updated: 2026-07-11 @@ -62,14 +62,62 @@ no behavior changes beyond the required ABC re-parent. ## Acceptance criteria -- [ ] `grep -rn "pyMathTools" src/ tests/ examples/ Makefile .env pyproject.toml` → no hits -- [ ] `grep -rn "foundationTypes.mathTypes.quaternionABC" src/ tests/` → no hits -- [ ] `git log --follow --oneline src/math_tools/spatial/quaternion.py` shows history (moves were `git mv`) -- [ ] All ~90 quaternion tests pass; diff to `tests/test_quaternion.py` contains only import lines and base-class assertion lines -- [ ] `make uv-fullCheck` passes +- [x] `grep -rn "pyMathTools" src/ tests/ examples/ Makefile .env pyproject.toml` → no hits +- [x] `grep -rn "foundationTypes.mathTypes.quaternionABC" src/ tests/` → no hits +- [x] `git log --follow --oneline src/math_tools/spatial/quaternion.py` shows history (moves were `git mv`) — verified via `git status`/`git diff --staged` showing `renamed: src/pyMathTools/spatial/Quaternion.py -> src/math_tools/spatial/quaternion.py`; `--follow` itself needs a commit to walk, which this chunk intentionally leaves to the supervising process +- [x] All ~90 quaternion tests pass (89 collected/passed); diff to `tests/test_quaternion.py` contains only import lines, base-class assertion lines, and their two adjacent docstring lines (see Resolution notes) +- [x] `make uv-fullCheck` passes ## Out of scope `errors.py`, layering test, README, `.claude/CLAUDE.md`, pyproject dependency list, any new math code, any `__init__.py` re-export curation beyond fixing broken imports. + +## Resolution notes + +- `make uv-refresh` pulled the pinned foundation branch fresh; pre-move + `make uv-test` baseline reproduced the expected pre-existing break + (`ModuleNotFoundError: foundationTypes.mathTypes.quaternionABC`), confirming + the venv was stale before this chunk and the gate is meaningful after. +- Moves done via `git mv` as specified. `git status`/`git diff --staged` + correctly report `quaternion.py` as a rename from `Quaternion.py`; git's + similarity heuristic cross-matched some of the (byte-identical, empty) + `__init__.py` files to different-but-equivalent old empty `__init__.py` + paths — cosmetic only, every file's on-disk destination was verified + directly with `find`, and it does not affect `--follow` on the files that + matter (confirmed for `quaternion.py`). +- The old-layout ABC repoint (design constraint 2) turned out to reach beyond + `quaternion.py`: `foundationTypes.mathTypes.unitSphericalArcABC` and + `unitSphericalSmallCircleABC` (imported by `spherical_generators.py`, + `spherical_transforms.py`, `constructors.py`, `plot_unit_spherical.py`) + were also deleted from the pinned foundation branch and now live at + `foundation_abc.math.sphericalABCs`. Re-pointed all of them per the chunk's + explicit "grep `foundationTypes.mathTypes.` across `src/`" instruction — + required for `make uv-fullCheck` (mypy strict) to pass, since mypy scans + all of `src/`, not just the quaternion module. `foundationTypes.mathTypes.MathTypes.*` + imports (the codegen `*Type` classes) were left untouched — that module + still exists unchanged in the new layout. + This is a scope note, not a spec deviation: constraint 2's own text + authorized exactly this action; the "Files" list section above just didn't + enumerate every file it touched. +- `examples/sphericalPlotting/plotArcs.py` and `plotQuatUnitCircles.py` got + only the mechanical package-name substitution, per constraint 3 and Gap 4's + explicit ownership of their pre-existing broken `foundationTypes.mathTypes.UnitSphericalArc` + / `UnitSphericalSmallCircle` imports (not part of this chunk's scope, and + not scanned by `make uv-typecheck` since `PY_EXAMPLES` is unset in `.env`). +- `tests/test_quaternion.py`: `test_inheritance_from_data_model_helper` + asserted `isinstance(q, DataModelHelper)`, which is no longer true (the new + `QuaternionABC` is ABC-only). Repointed the import and assertion to + `QuaternionABC` per constraint 2's directive, and updated that test's and + the class's docstrings by one line each so the docstrings don't contradict + the assertion right below them — the only lines in this diff beyond raw + import-path substitution. No `to_dict`/`from_dict` value assertions were + touched. +- Added `py.typed` at `src/math_tools/` and `src/math_plot_helpers/` package + roots (subpackage ones already existed). +- No `Makefile`/`.env` edits were needed — neither names packages explicitly + (both scope quality targets via path variables, not package names). +- `pyproject.toml` `description` left untouched — no trivially co-located + edit was applicable in this chunk; still boilerplate text, tracked as Gap 4 + (chunk 03). diff --git a/examples/sphericalPlotting/plotArcs.py b/examples/sphericalPlotting/plotArcs.py index 991acb2..185334e 100644 --- a/examples/sphericalPlotting/plotArcs.py +++ b/examples/sphericalPlotting/plotArcs.py @@ -2,7 +2,7 @@ from foundationTypes.mathTypes.UnitSphericalArc import UnitSphericalArc -from pyMathToolsPlotHelpers.plotUnitSpherical import ( +from math_plot_helpers.plot_unit_spherical import ( deg45, deg90, deg180, @@ -10,10 +10,10 @@ plot_unit_spherical_multiplot, ) -from pyMathTools.spherical.constructors import ( +from math_tools.spherical.constructors import ( arc_from_two_points, ) -from pyMathTools.spherical.sphericalTransforms import compute_spherical_arc_endpoint +from math_tools.spherical.spherical_transforms import compute_spherical_arc_endpoint def demo() -> None: diff --git a/examples/sphericalPlotting/plotQuatUnitCircles.py b/examples/sphericalPlotting/plotQuatUnitCircles.py index 9f12860..efdaf6d 100644 --- a/examples/sphericalPlotting/plotQuatUnitCircles.py +++ b/examples/sphericalPlotting/plotQuatUnitCircles.py @@ -30,9 +30,9 @@ from foundationTypes.mathTypes.UnitSphericalSmallCircle import UnitSphericalSmallCircle -from pyMathTools.spatial.Quaternion import Quaternion +from math_tools.spatial.quaternion import Quaternion -from pyMathToolsPlotHelpers.plotUnitSpherical import ( +from math_plot_helpers.plot_unit_spherical import ( plot_unit_spherical_multiplot, ) diff --git a/src/pyMathTools/__init__.py b/src/math_plot_helpers/__init__.py similarity index 100% rename from src/pyMathTools/__init__.py rename to src/math_plot_helpers/__init__.py diff --git a/src/pyMathToolsPlotHelpers/plotUnitSpherical.py b/src/math_plot_helpers/plot_unit_spherical.py similarity index 98% rename from src/pyMathToolsPlotHelpers/plotUnitSpherical.py rename to src/math_plot_helpers/plot_unit_spherical.py index 932e567..e5fa87a 100644 --- a/src/pyMathToolsPlotHelpers/plotUnitSpherical.py +++ b/src/math_plot_helpers/plot_unit_spherical.py @@ -3,19 +3,21 @@ import matplotlib.pyplot as plt import numpy as np +from foundation_abc.math.sphericalABCs import ( + UnitSphericalArcABC, + UnitSphericalSmallCircleABC, +) from foundationTypes.mathTypes.MathTypes import UnitSphericalArcType, UnitSphericalSmallCircleType -from foundationTypes.mathTypes.unitSphericalArcABC import UnitSphericalArcABC -from foundationTypes.mathTypes.unitSphericalSmallCircleABC import UnitSphericalSmallCircleABC from matplotlib.axes import Axes from matplotlib.figure import Figure from mpl_toolkits.mplot3d import Axes3D # type: ignore[import-untyped] -from pyMathTools.spatial.Quaternion import Quaternion -from pyMathTools.spherical.sphericalGenerators import ( +from math_tools.spatial.quaternion import Quaternion +from math_tools.spherical.spherical_generators import ( generate_spherical_arc_points, generate_spherical_small_circle_points, ) -from pyMathTools.spherical.sphericalTransforms import plate_carree_transform +from math_tools.spherical.spherical_transforms import plate_carree_transform deg45 = np.deg2rad(45) deg90 = np.deg2rad(90) diff --git a/src/pyMathTools/spatial/py.typed b/src/math_plot_helpers/py.typed similarity index 100% rename from src/pyMathTools/spatial/py.typed rename to src/math_plot_helpers/py.typed diff --git a/src/pyMathTools/spatial/__init__.py b/src/math_tools/__init__.py similarity index 100% rename from src/pyMathTools/spatial/__init__.py rename to src/math_tools/__init__.py diff --git a/src/pyMathTools/hints.py b/src/math_tools/hints.py similarity index 94% rename from src/pyMathTools/hints.py rename to src/math_tools/hints.py index e7caba7..e2ac167 100644 --- a/src/pyMathTools/hints.py +++ b/src/math_tools/hints.py @@ -13,7 +13,7 @@ from quaternion import quaternion as np_quaternion if TYPE_CHECKING: - from pyMathTools.spatial.Quaternion import Quaternion + from math_tools.spatial.quaternion import Quaternion # Basic numeric types FloatOrNDArray = float | NDArray[float64] diff --git a/src/pyMathTools/spherical/py.typed b/src/math_tools/py.typed similarity index 100% rename from src/pyMathTools/spherical/py.typed rename to src/math_tools/py.typed diff --git a/src/pyMathTools/spherical/__init__.py b/src/math_tools/spatial/__init__.py similarity index 100% rename from src/pyMathTools/spherical/__init__.py rename to src/math_tools/spatial/__init__.py diff --git a/src/pyMathToolsPlotHelpers/__init__.py b/src/math_tools/spatial/py.typed similarity index 100% rename from src/pyMathToolsPlotHelpers/__init__.py rename to src/math_tools/spatial/py.typed diff --git a/src/pyMathTools/spatial/Quaternion.py b/src/math_tools/spatial/quaternion.py similarity index 99% rename from src/pyMathTools/spatial/Quaternion.py rename to src/math_tools/spatial/quaternion.py index db95c37..50ef923 100644 --- a/src/pyMathTools/spatial/Quaternion.py +++ b/src/math_tools/spatial/quaternion.py @@ -8,8 +8,8 @@ from typing import Any, TypeVar import numpy as np +from foundation_abc.math.spatialABCs import QuaternionABC from foundationTypes.mathTypes.MathTypes import UnitSphericalSmallCircleType -from foundationTypes.mathTypes.quaternionABC import QuaternionABC from quaternion import allclose as quat_allclose # type: ignore[import-untyped] from quaternion import ( as_euler_angles, @@ -32,7 +32,7 @@ from quaternion.quaternion_time_series import slerp as quat_slerp # type: ignore[import-untyped] # Import custom type hints -from pyMathTools.hints import ( +from math_tools.hints import ( FloatArray3, FloatArray4, FloatOrQuaternion, diff --git a/src/math_tools/spherical/__init__.py b/src/math_tools/spherical/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pyMathTools/spherical/constructors.py b/src/math_tools/spherical/constructors.py similarity index 97% rename from src/pyMathTools/spherical/constructors.py rename to src/math_tools/spherical/constructors.py index 076e0a1..9326ec4 100644 --- a/src/pyMathTools/spherical/constructors.py +++ b/src/math_tools/spherical/constructors.py @@ -6,8 +6,8 @@ """ import numpy as np +from foundation_abc.math.sphericalABCs import UnitSphericalArcABC from foundationTypes.mathTypes.MathTypes import UnitSphericalArcType -from foundationTypes.mathTypes.unitSphericalArcABC import UnitSphericalArcABC from numpy import atan2 diff --git a/src/math_tools/spherical/py.typed b/src/math_tools/spherical/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/pyMathTools/spherical/sphericalGenerators.py b/src/math_tools/spherical/spherical_generators.py similarity index 97% rename from src/pyMathTools/spherical/sphericalGenerators.py rename to src/math_tools/spherical/spherical_generators.py index 94cd6cc..acc41e6 100644 --- a/src/pyMathTools/spherical/sphericalGenerators.py +++ b/src/math_tools/spherical/spherical_generators.py @@ -36,8 +36,10 @@ r = sqrt(x² + y² + z²) """ -from foundationTypes.mathTypes.unitSphericalArcABC import UnitSphericalArcABC -from foundationTypes.mathTypes.unitSphericalSmallCircleABC import UnitSphericalSmallCircleABC +from foundation_abc.math.sphericalABCs import ( + UnitSphericalArcABC, + UnitSphericalSmallCircleABC, +) from numpy import ( arccos, arctan2, @@ -54,7 +56,7 @@ ) from numpy.linalg import norm -from pyMathTools.hints import FloatNDArray +from math_tools.hints import FloatNDArray def generate_spherical_small_circle_points( diff --git a/src/pyMathTools/spherical/sphericalTransforms.py b/src/math_tools/spherical/spherical_transforms.py similarity index 96% rename from src/pyMathTools/spherical/sphericalTransforms.py rename to src/math_tools/spherical/spherical_transforms.py index fc42b8e..2abe5d5 100644 --- a/src/pyMathTools/spherical/sphericalTransforms.py +++ b/src/math_tools/spherical/spherical_transforms.py @@ -11,10 +11,10 @@ - θ (theta/polar): angle from +z axis (colatitude), range [0, π] - φ (phi/azimuth): angle in xy-plane from +x axis, range [0, 2π) -See pyMathTools.generators.sphericalGenerators module docstring for complete details. +See math_tools.spherical.spherical_generators module docstring for complete details. """ -from foundationTypes.mathTypes.unitSphericalArcABC import UnitSphericalArcABC +from foundation_abc.math.sphericalABCs import UnitSphericalArcABC from numpy import ( arccos, arctan2, @@ -27,7 +27,7 @@ from numpy.linalg import norm from numpy.typing import NDArray -from pyMathTools.hints import FloatOrNDArray +from math_tools.hints import FloatOrNDArray def plate_carree_transform( diff --git a/tests/__init__.py b/tests/__init__.py index 9469f0f..ef2f36a 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Unit test package for pyMathTools.""" +"""Unit test package for math_tools.""" diff --git a/tests/test_quaternion.py b/tests/test_quaternion.py index bc17b38..9fd97e8 100644 --- a/tests/test_quaternion.py +++ b/tests/test_quaternion.py @@ -17,7 +17,7 @@ import numpy as np from numpy.testing import assert_allclose, assert_array_almost_equal -from pyMathTools.spatial.Quaternion import Quaternion +from math_tools.spatial.quaternion import Quaternion class TestQuaternionConstruction(unittest.TestCase): @@ -672,7 +672,7 @@ def test_zero_norm_handling(self) -> None: class TestQuaternionSerialization(unittest.TestCase): - """Test serialization/deserialization methods and DataModelHelper inheritance.""" + """Test serialization/deserialization methods and QuaternionABC inheritance.""" def test_to_dict_identity(self) -> None: """Test to_dict for identity quaternion.""" @@ -806,17 +806,17 @@ def test_dict_modification_doesnt_affect_quaternion(self) -> None: def test_inheritance_from_quaternion_type(self) -> None: """Test that Quaternion properly inherits from the shared QuaternionABC.""" - from foundationTypes.mathTypes.quaternionABC import QuaternionABC + from foundation_abc.math.spatialABCs import QuaternionABC q = Quaternion.from_components(1.0, 2.0, 3.0, 4.0) self.assertIsInstance(q, QuaternionABC) def test_inheritance_from_data_model_helper(self) -> None: - """Test that Quaternion inherits from DataModelHelper.""" - from foundationTypes.dataModelHelper import DataModelHelper + """Test that Quaternion inherits from QuaternionABC (ABC-only, not DataModelHelper).""" + from foundation_abc.math.spatialABCs import QuaternionABC q = Quaternion.from_components(1.0, 2.0, 3.0, 4.0) - self.assertIsInstance(q, DataModelHelper) + self.assertIsInstance(q, QuaternionABC) def test_has_serialization_methods(self) -> None: """Test that Quaternion has the required serialization methods.""" From 7be1ffd208f5214a5da48aa18b1e3f819a79491c Mon Sep 17 00:00:00 2001 From: kopecn Date: Sat, 11 Jul 2026 19:48:43 -0500 Subject: [PATCH 08/70] 02 Errors and layering Add the math_tools exception hierarchy (MathToolsError and three domain subtypes) and an AST-based package layering test enforcing math_tools never imports math_plot_helpers/matplotlib, and math_tools.otg (once it exists) never imports numpy. --- .claude/action-plan/02-errors-and-layering.md | 28 ++++- src/math_tools/errors.py | 22 ++++ tests/test_errors.py | 46 ++++++++ tests/test_package_layering.py | 100 ++++++++++++++++++ 4 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 src/math_tools/errors.py create mode 100644 tests/test_errors.py create mode 100644 tests/test_package_layering.py diff --git a/.claude/action-plan/02-errors-and-layering.md b/.claude/action-plan/02-errors-and-layering.md index e86705c..78fdbfb 100644 --- a/.claude/action-plan/02-errors-and-layering.md +++ b/.claude/action-plan/02-errors-and-layering.md @@ -1,7 +1,7 @@ --- chunk: 02-errors-and-layering track: A -status: pending +status: complete depends_on: [01] spec: ../specs/mathToolsArchitecture.md §Error semantics; ../specs/templateConformance.md §Gap 5 last_updated: 2026-07-11 @@ -44,11 +44,31 @@ author: Nicholas Bergantz ## Acceptance criteria -- [ ] All four exception classes exist and subclass as specified -- [ ] Layering test fails on an injected `import matplotlib` in `math_tools` (verified then reverted) -- [ ] `make uv-fullCheck` passes +- [x] All four exception classes exist and subclass as specified +- [x] Layering test fails on an injected `import matplotlib` in `math_tools` (verified then reverted) +- [x] `make uv-fullCheck` passes ## Out of scope Any consumer of the exceptions; OTG's `OtgError` (lives in `otg/errors.py`, chunk 31); README/CLAUDE.md. + +## Resolution notes + +- `errors.py` implements exactly the four classes from the spec, each a + one-line docstring, no added behavior. +- `test_package_layering.py` follows the py-foundationTools AST-scan pattern + (`tests/test_package_layering.py` there) rather than executing imports, so + it can't be defeated by import side effects. Four checks: `math_tools` ↛ + `math_plot_helpers`, `math_tools` ↛ `matplotlib`, only `math_plot_helpers` + → `matplotlib` (scans all of `src/` excluding that package), and the + guarded `math_tools.otg` ↛ `numpy` rule (currently a no-op skip since + `otg/` doesn't exist yet — will activate automatically once chunk 31 lands). +- Verification step 3 (inject `import matplotlib` into `math_tools/errors.py`, + confirm two layering assertions fail, revert) was done live against the + actual gate, not simulated; file diffed back to the original after. +- One ruff fix needed: the injected-violation assertion message exceeded the + 100-char line limit in `test_only_math_plot_helpers_imports_matplotlib`'s + sibling test; wrapped the f-string across two lines. +- No spec changes were required — the chunk's design constraints matched the + umbrella spec's Error semantics section exactly. diff --git a/src/math_tools/errors.py b/src/math_tools/errors.py new file mode 100644 index 0000000..0e3c637 --- /dev/null +++ b/src/math_tools/errors.py @@ -0,0 +1,22 @@ +"""Exception hierarchy for domain failures raised by ``math_tools``. + +Programmer errors (wrong type, wrong shape, invalid argument) raise stdlib +``TypeError``/``ValueError``; domain failures raise a ``MathToolsError`` +subtype defined here (see mathToolsArchitecture.md §Error semantics). +""" + + +class MathToolsError(Exception): + """Base class for all domain-level errors raised by math_tools.""" + + +class WaveformCompatibilityError(MathToolsError): + """Raised when waveform operands are incompatible (dt/shape mismatch).""" + + +class TimestampComparisonError(MathToolsError): + """Raised when comparing timestamps with mismatched timescale/frame.""" + + +class PolynomialSolveError(MathToolsError): + """Raised when a polynomial root request is unsolvable or ill-posed.""" diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..a546ae6 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,46 @@ +"""Unit tests for the ``math_tools.errors`` exception hierarchy.""" + +import unittest + +from math_tools.errors import ( + MathToolsError, + PolynomialSolveError, + TimestampComparisonError, + WaveformCompatibilityError, +) + + +class TestMathToolsErrorHierarchy(unittest.TestCase): + def test_math_tools_error_is_an_exception(self) -> None: + self.assertTrue(issubclass(MathToolsError, Exception)) + + def test_waveform_compatibility_error_subclasses_math_tools_error(self) -> None: + self.assertTrue(issubclass(WaveformCompatibilityError, MathToolsError)) + + def test_timestamp_comparison_error_subclasses_math_tools_error(self) -> None: + self.assertTrue(issubclass(TimestampComparisonError, MathToolsError)) + + def test_polynomial_solve_error_subclasses_math_tools_error(self) -> None: + self.assertTrue(issubclass(PolynomialSolveError, MathToolsError)) + + def test_waveform_compatibility_error_is_catchable_as_math_tools_error(self) -> None: + with self.assertRaises(MathToolsError): + raise WaveformCompatibilityError("dt mismatch") + + def test_timestamp_comparison_error_is_catchable_as_math_tools_error(self) -> None: + with self.assertRaises(MathToolsError): + raise TimestampComparisonError("timescale mismatch") + + def test_polynomial_solve_error_is_catchable_as_math_tools_error(self) -> None: + with self.assertRaises(MathToolsError): + raise PolynomialSolveError("ill-posed request") + + def test_math_tools_error_message_is_preserved(self) -> None: + try: + raise WaveformCompatibilityError("boom") + except MathToolsError as exc: + self.assertEqual(str(exc), "boom") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_package_layering.py b/tests/test_package_layering.py new file mode 100644 index 0000000..8d1bc23 --- /dev/null +++ b/tests/test_package_layering.py @@ -0,0 +1,100 @@ +"""Dependency-direction tests for the ``math_tools`` / ``math_plot_helpers`` split. + +Per templateConformance.md §Gap 5, the layering contract is: + +- ``math_tools`` never imports ``math_plot_helpers`` or ``matplotlib``. +- ``math_plot_helpers`` may import ``math_tools``. +- Only ``math_plot_helpers`` imports ``matplotlib``. +- ``math_tools.otg`` (once it exists) never imports ``numpy`` (real-time + control-loop constraint; guarded to skip until the package exists so OTG + chunks inherit enforcement for free). + +Enforced via a static AST scan (no imports executed) of every module under +``src/``, mirroring py-foundationTools' ``tests/test_package_layering.py``. +""" + +import ast +from pathlib import Path + +SRC_ROOT = Path(__file__).resolve().parent.parent / "src" +MATH_TOOLS_ROOT = SRC_ROOT / "math_tools" +MATH_PLOT_HELPERS_ROOT = SRC_ROOT / "math_plot_helpers" +OTG_ROOT = MATH_TOOLS_ROOT / "otg" + + +def _imported_top_level_names(module_path: Path) -> set[str]: + """Return the top-level package name of every module imported by ``module_path``.""" + tree = ast.parse(module_path.read_text(encoding="utf-8"), filename=str(module_path)) + names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + names.add(alias.name.split(".")[0]) + elif isinstance(node, ast.ImportFrom): + # node.level > 0 is a relative import (`from . import x`); it can only + # resolve within the same package, so it is never forbidden. + if node.level == 0 and node.module: + names.add(node.module.split(".")[0]) + return names + + +def test_math_tools_does_not_import_math_plot_helpers() -> None: + violations: dict[str, set[str]] = {} + for module_path in MATH_TOOLS_ROOT.rglob("*.py"): + imported = _imported_top_level_names(module_path) & {"math_plot_helpers"} + if imported: + violations[str(module_path.relative_to(SRC_ROOT))] = imported + + assert not violations, ( + "math_tools must not import math_plot_helpers (one-way dependency: " + f"math_plot_helpers -> math_tools), but found: {violations}" + ) + + +def test_math_tools_does_not_import_matplotlib() -> None: + violations: dict[str, set[str]] = {} + for module_path in MATH_TOOLS_ROOT.rglob("*.py"): + imported = _imported_top_level_names(module_path) & {"matplotlib"} + if imported: + violations[str(module_path.relative_to(SRC_ROOT))] = imported + + assert not violations, ( + "math_tools must not import matplotlib (matplotlib is " + f"math_plot_helpers-only), but found: {violations}" + ) + + +def test_only_math_plot_helpers_imports_matplotlib() -> None: + violations: dict[str, set[str]] = {} + for module_path in SRC_ROOT.rglob("*.py"): + if MATH_PLOT_HELPERS_ROOT in module_path.parents: + continue + imported = _imported_top_level_names(module_path) & {"matplotlib"} + if imported: + violations[str(module_path.relative_to(SRC_ROOT))] = imported + + assert not violations, f"only math_plot_helpers may import matplotlib, but found: {violations}" + + +def test_otg_does_not_import_numpy() -> None: + """OTG is a real-time control loop; it must not depend on numpy. + + Guarded to skip if ``math_tools/otg`` does not exist yet so this + enforcement is inherited for free once the OTG chunks land. + """ + if not OTG_ROOT.is_dir(): + return + + violations: dict[str, set[str]] = {} + for module_path in OTG_ROOT.rglob("*.py"): + imported = _imported_top_level_names(module_path) & {"numpy"} + if imported: + violations[str(module_path.relative_to(SRC_ROOT))] = imported + + assert not violations, f"math_tools.otg must not import numpy, but found: {violations}" + + +def test_math_tools_is_scanned() -> None: + """Guard against the scan silently finding zero files (e.g. a bad glob root).""" + scanned = list(MATH_TOOLS_ROOT.rglob("*.py")) + assert len(scanned) >= 1, f"expected at least 1 module under math_tools/, found {scanned}" From d792bb1bd8b20ff5379973eb32a3d779ca6ee259 Mon Sep 17 00:00:00 2001 From: kopecn Date: Sat, 11 Jul 2026 19:54:20 -0500 Subject: [PATCH 09/70] 03 Governance and readme Add .claude/CLAUDE.md and a real README, correct pyproject dependency names (numpy/scipy were missing), and fix dead MathTypes import paths in the two spherical plotting examples so they import-run cleanly. --- .claude/CLAUDE.md | 72 ++++++++++++++ .../action-plan/03-governance-and-readme.md | 55 +++++++++-- README.md | 98 ++++++++++++++++++- examples/sphericalPlotting/plotArcs.py | 8 +- .../sphericalPlotting/plotQuatUnitCircles.py | 4 +- pyproject.toml | 6 +- tests/test_governance.py | 47 +++++++++ 7 files changed, 273 insertions(+), 17 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 tests/test_governance.py diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..9f36b30 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,72 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`py-MathTools` is the **Tier 3 math implementation layer** of the foundation +math tiers defined in py-foundationTools' `mathTypeTiers.md`: it subclasses +the Tier-2 `foundation_abc.math.*` ABCs, chooses numpy-backed storage, and +implements arithmetic, composition, interpolation, DSP, and trajectory +generation on top of the Tier-1 `foundationTypes.mathTypes` data carriers. +Unlike `pyFoundationTools` (zero-dependency by policy), this repo depends on +a curated set of mature numeric packages rather than reimplementing them. + +## Packages and layering + +Two top-level snake_case packages under `src/`, one-way dependency: + +``` +math_plot_helpers → math_tools → pyFoundationTools → stdlib + (matplotlib) (numpy, scipy, numpy-quaternion) +``` + +`math_tools` never imports `math_plot_helpers` or `matplotlib`; only +`math_plot_helpers` imports `matplotlib`. This is enforced by +`tests/test_package_layering.py` (static AST scan of `src/`). + +## Commands + +All workflows go through the Makefile (`make help` lists them); **the +Makefile is the source of truth for tooling**, not this file or the README. +The `uv-` prefixed targets are the primary path. Key ones: + +- `make uv-fullCheck` — CI gate: `uv-lint` + `uv-typecheck` + `uv-test`. Run + this before considering work done. +- `make uv-lint` — ruff check +- `make uv-format` — `ruff format` + `ruff check --fix --unsafe-fixes` +- `make uv-typecheck` — strict `mypy` over `src/` + `tests/` +- `make uv-test` — sync deps then run pytest +- `make uv-refresh` — clean cache + reinstall from `requirements.txt` + + upgrade editable dev install (required after a `pyFoundationTools` pin + changes; a stale `.venv` makes the gate meaningless) + +## Specs + +`.claude/specs/` is the authoritative contract for this repo's architecture +and every module's behavior. Consult the relevant spec before extending a +module; if implementation forces a contract change, the spec is updated in +the same change and its `semver` bumped. + +- [mathToolsArchitecture.md](specs/mathToolsArchitecture.md) — umbrella: + layering, package layout, dependency policy, shared conventions, error + semantics +- [templateConformance.md](specs/templateConformance.md) — template + migration: packaging, Makefile/CI parity, rename, governance docs +- [precisionTimeMath.md](specs/precisionTimeMath.md) — `PrecisionTimeInterval`, + `PrecisionTimestamp` +- [spatialMath.md](specs/spatialMath.md) — `Position`, `Quaternion`, + `SpatialPose` +- [waveformCore.md](specs/waveformCore.md) — `Waveform1D` + aggregate + waveform containers +- [waveformDsp.md](specs/waveformDsp.md) — DSP families, scipy mapping, + support types +- [polynomials.md](specs/polynomials.md) — polynomial type + analytic root + solvers +- [otg.md](specs/otg.md) — online trajectory generation (Ruckig port) + +## Tests + +Tests live in `tests/`, `unittest.TestCase` style run under pytest +(`test*.py` files, `test_*` methods), mirroring the package layout under +`src/` (e.g. `tests/spatial/test_position.py`). diff --git a/.claude/action-plan/03-governance-and-readme.md b/.claude/action-plan/03-governance-and-readme.md index 6a1c1a7..804cdab 100644 --- a/.claude/action-plan/03-governance-and-readme.md +++ b/.claude/action-plan/03-governance-and-readme.md @@ -1,11 +1,11 @@ --- chunk: 03-governance-and-readme track: A -status: pending +status: complete depends_on: [02] spec: ../specs/templateConformance.md §Gap 2, §Gap 3, §Gap 4 last_updated: 2026-07-11 -semver: 0.0.1 +semver: 0.0.2 author: Nicholas Bergantz --- @@ -52,12 +52,55 @@ names, fixed examples. ## Acceptance criteria -- [ ] `tests/test_governance.py` passes -- [ ] `pyproject.toml` deps are exactly the five names, unpinned -- [ ] Both example scripts import-run without error -- [ ] `make uv-fullCheck` passes +- [x] `tests/test_governance.py` passes +- [x] `pyproject.toml` deps are exactly the five names, unpinned +- [x] Both example scripts import-run without error +- [x] `make uv-fullCheck` passes ## Out of scope Makefile/CI edits; requirements pin changes; any `src/` code beyond the example imports. + +## Resolution notes + +- `tests/test_governance.py` added: asserts `.claude/CLAUDE.md` exists and + its text contains the filename of every `.claude/specs/*.md` file, and + that `README.md` contains no `"Boilerplate"` text. Confirmed it failed + before the docs existed, passed after. +- `.claude/CLAUDE.md` written to the shape of py-foundationTools' + `.claude/CLAUDE.md` (Project Overview → layering → Commands → Specs index + → Tests) but scoped to what exists in this repo today: Tier 3 role, the + two-package layering diagram, the gate command, a Makefile-is-source-of- + truth note, and a linked index of all 7 specs in `.claude/specs/`. +- `README.md` rewritten per Gap 4's section shape (title → Features → + Installation → Quick Start → Development Workflows → Requirements), + describing only what exists at execution time: `Quaternion`, the + spherical arc/small-circle utilities, `errors.py`, and the + `math_plot_helpers` plotting package. Both Quick Start snippets + (quaternion arithmetic/conversion, spherical arc construction + + endpoint) were executed directly to confirm they run as written. +- `pyproject.toml`: `dependencies` set to the five names + (`pyFoundationTools`, `numpy`, `scipy`, `numpy-quaternion`, `matplotlib`) + — the prior list was missing `numpy`/`scipy` and had an unrelated stray + order; `description` replaced with a one-line Tier-3 summary. +- `examples/sphericalPlotting/plotArcs.py`: fixed the dead + `foundationTypes.mathTypes.UnitSphericalArc.UnitSphericalArc` import to + `foundationTypes.mathTypes.MathTypes.UnitSphericalArcType` (verified the + real class name by reading the installed `MathTypes.py`) and updated the + two local usages/type hints accordingly. +- `examples/sphericalPlotting/plotQuatUnitCircles.py`: same dead-import + pattern existed for `UnitSphericalSmallCircle` (not called out by name in + the chunk's design constraint 4, but the file was listed for edit and the + acceptance criterion requires *both* scripts to import-run without + error) — fixed to `foundationTypes.mathTypes.MathTypes.UnitSphericalSmallCircleType`. +- Both example scripts verified to run end-to-end with + `MPLBACKEND=Agg PYTHONPATH=src .venv/bin/python examples/sphericalPlotting/