Skip to content

Development

Setup

git clone git@github.com:QAaAT/felcu.git
cd felcu
pip install -e ".[dev]"

Use an editable install (-e). A regular pip install . copies the package into site-packages, and scripts run from elsewhere (such as examples/) will keep importing that copy after you change the source.

Tests

python -m pytest tests/ -v

To see which lines the tests miss, add --cov=felcu --cov-report=term-missing.

Every test belongs to exactly one category, one file each, from the lowest layer up:

Category File What it checks
unit test_unit.py One helper or internal component in isolation: name aliases, grids, series evaluation, the default eta and r formulas, the linear solver
interface test_interface.py, test_io.py The public contract: input validation, defaults and options, result fields, candidate selection, saving and loading
convergence test_convergence.py Mathematical correctness: targets met, the paper's closed-form coefficients, convergence rates and subnormalisation bounds
regression test_regression.py Output unchanged from the saved references (below)

The category is the file's pytestmark, and tests/conftest.py stops collection if any test has no category or more than one. A failure low down usually explains the ones above it: a broken helper also breaks convergence and regression, so start with the lowest failing category. Run one category with -m:

python -m pytest tests/ -m unit

Convergence and regression tests can look alike, but answer different questions. Convergence tests check that the output is right; regression tests check that it has not changed. Regenerating the references after a change that breaks a method would pass the regression tests, so the convergence tests are what catch it.

Helpers shared between test files live in tests/helpers.py.

Regression tests

tests/test_regression.py solves a fixed set of cases, defined in tests/regression_cases.py, and compares the results with references saved as JSON in tests/data/regression/. Rounding differs between BLAS/LAPACK builds and NumPy versions, so the comparison has three tiers:

  • discrete fields (m, basis_used, converged, ...) must match exactly, and fields computed in closed form from the inputs (eta, tau, sobolev_r, ...) to a relative 1e-12;
  • the series must match as a function on the fitted domain to 1e-3 * target_error. The individual coefficients are not compared: the fit is ill-conditioned, so they can move by more than the target error without changing the series;
  • alpha must match to a relative 1e-6, and lambda_used and the achieved errors, which come from a bisection on a flat error curve, to 1e-4.

When a change is meant to alter the coefficients, regenerate the references and commit them with the change:

python tests/regression_cases.py

The script refuses to run unless felcu is imported from the checkout (an editable install). To add a case, add it to CASES and regenerate. Pass a directory (python tests/regression_cases.py OUT_DIR) to write the results there instead of over the references.

Continuous integration

.github/workflows/ci.yml runs on every push to main and every pull request. A new push to a branch cancels its still-running checks.

Job What it does
lint ruff check and ruff format --check
test Each test category as its own step, with a combined coverage report, on Ubuntu, macOS and Windows with every supported Python
test-min-deps Each test category as its own step, on the oldest supported Python with the oldest supported NumPy
install The whole suite against a regular (not editable) install, run from outside the checkout, so a package that builds incomplete fails
examples Every script in examples/, including the plots (headless, from a scratch directory)

The jobs run in stages: lint first, then test, test-min-deps and install in parallel, then examples. A job only starts once the stage before it has passed, so a lint failure skips the tests and a test failure skips the examples.

Within test and test-min-deps, the categories run lowest first, and each step runs even when an earlier one failed, so one failure cannot hide another and the failing step's name says which layer broke. Every category runs on every platform: convergence and regression in particular depend on rounding, which differs between BLAS/LAPACK builds and NumPy versions. When they fail, the job uploads that platform's results as an artifact, regression-results-<os>-py<version> (or regression-results-min-deps), laid out like tests/data/regression/, so you can diff them against the references directly.

Dependabot (.github/dependabot.yml) opens monthly pull requests to update the GitHub Actions versions and the ruff pin. It leaves the supported Python and dependency versions alone; those follow SPEC 0 (below).

Supported versions

felcu follows SPEC 0: each Python version is supported for 3 years after its release, and each version of NumPy and matplotlib (the plots extra) for 2 years. The minimum of each is the oldest release still inside its window; the current minimums are in pyproject.toml, and SPEC 0's table lists when each drops out.

.github/workflows/spec0.yml keeps this up to date. On the first of every month (or when run by hand from the Actions tab) it runs .github/scripts/spec0_check.py --apply. When a floor is due to rise, or a released Python version is missing from the test matrix or the classifiers, the script updates:

  • requires-python, the classifiers, dependencies, the plots extra and ruff's target-version in pyproject.toml;
  • the test matrix, the NumPy pin of test-min-deps, and the Python of every job that pins one, in the workflows;
  • the requirements in docs/getting-started.md.

The workflow commits that to the spec0-update branch, opens a pull request, "Raise the supported versions to SPEC 0", and runs CI on it. A higher ruff target or a new Python can need code or dependency changes too, so review CI before merging, and push any fixes to the same branch: while the pull request is open, later updates are committed on top of it, so the fixes are kept. Two repository settings are needed: Issues and pull requests enabled, and Settings → Actions → General → Allow GitHub Actions to create and approve pull requests turned on.

Run the check locally with python .github/scripts/spec0_check.py; pass --today YYYY-MM-DD to see what a later date will ask for, and --apply to make the changes.

Add each new Python version to the test matrix by hand when its first release candidate is out; allow-prereleases lets CI install it before the final release, and the update keeps it there.

Linting and formatting

Code is linted and formatted with ruff, configured in pyproject.toml (line length 120; pycodestyle, pyflakes, import order, bugbear, pyupgrade and NumPy-deprecation rules). Before pushing:

ruff check --fix .
ruff format .

CI runs ruff check . and ruff format --check . and fails if either reports anything. The ruff version is pinned once, in the dev extra, and CI installs it from there, so local and CI formatting agree. Markdown files are not formatted, so code blocks in docs/ are left as written.

Documentation

The docs are built with MkDocs and the Material theme; the API reference is generated from docstrings by mkdocstrings.

pip install -e ".[docs]"
mkdocs serve        # live preview at http://127.0.0.1:8000
mkdocs build --strict

The plot images on the Examples page are committed under docs/images/plots/, so building the docs does not need matplotlib. After changing a plotting example, regenerate them with:

pip install -e ".[plots]"
for f in examples/plots/0*.py; do MPLBACKEND=Agg python "$f" docs/images/plots; done

Pushes to main that change the site's sources (docs/, mkdocs.yml, felcu/ for the API reference, examples/ for the Examples page, pyproject.toml or the workflow itself) publish the site to GitHub Pages (.github/workflows/docs.yml); pull requests that change them build it without publishing. To publish anyway, run the workflow by hand from the Actions tab. In the repository settings, under Pages → Build and deployment, the source must be set to GitHub Actions.

Package layout

felcu/
├── __init__.py        # Public API
├── types.py           # FourierExtensionResult dataclass
├── _io.py             # Internal: JSON serialisation of results
├── utils.py           # Public utility functions
├── spectral.py        # Spectral data extraction & problem-spec builder
├── solve.py           # Public solver (solve_fe_coefficients, compute_fe_coefficients)
├── _solver.py         # Internal: Fourier-extension fitting & regularisation search
├── _explicit.py       # Internal: reflected-extension and arcsine-Taylor coefficients
├── _coefficients.py   # Internal: basis matrices, coefficient packing
├── _linalg.py         # Internal: least squares, quadrature, penalties
└── _logging.py        # Internal: package logger