Skip to content

Explore/fft backends - #373

Merged
alexlib merged 8 commits into
masterfrom
explore/fft-backends
Sep 5, 2026
Merged

Explore/fft backends#373
alexlib merged 8 commits into
masterfrom
explore/fft-backends

Conversation

@alexlib

@alexlib alexlib commented Sep 5, 2026

Copy link
Copy Markdown
Member

Summary by Sourcery

Add an optional Rust acceleration backend for OpenPIV processing while retaining SciPy fallback behavior and supporting cross-platform packaging, benchmarking, documentation, and CI validation.

New Features:

  • Add an optional Rust/PyO3 backend for FFT correlation, window extraction, subpixel displacement, signal-to-noise, and vector validation operations.
  • Expose backend selection through PIV processing settings and support Rust-backed multigrid and multiprocessing workflows.
  • Add cross-platform wheel build and optional PyPI publishing automation for the Rust extension.

Bug Fixes:

  • Improve multiprocessing resource cleanup and make validation tests deterministic.

Enhancements:

  • Add benchmark tooling and documentation comparing FFT implementations and documenting backend architecture and usage.
  • Expand backend parity, numerical accuracy, edge-case, and end-to-end PIV test coverage.

Build:

  • Add the Rust extension crate and its dependency lockfile, plus optional Rust dependencies and maturin development tooling.

CI:

  • Replace Poetry-based CI setup with uv, test SciPy and Rust backends separately across supported Python versions, and add dependency security auditing.
  • Remove the previous build workflow and add automated cross-platform wheel packaging checks.

Deployment:

  • Build and optionally publish Linux, Windows, and macOS binary wheels alongside Python source distributions.

Documentation:

  • Document FFT backend behavior, development guidance, and dependency security audit procedures.

alexlib and others added 4 commits August 23, 2026 00:50
….fft

Explores whether pyFFTW (FFTW bindings), rocket-fft (numba-jitted
numpy.fft), or a hand-rolled Cython FFT wrapper could beat the scipy.fft
backend merged in v0.25.5.

Result: none of them win by enough to justify a new dependency.
- pyFFTW (default ESTIMATE planning): a wash vs scipy.fft (0.88-1.05x).
- pyFFTW (FFTW_MEASURE, plan reused across repeated calls of the same
  shape): ~7% faster in steady state, but the ~1s one-time planning cost
  needs 250+ calls at a fixed shape to amortize -- not representative of a
  multipass PIV run using several window sizes per pass.
- rocket-fft (numba): consistently 2x slower than scipy.fft.
- Custom Cython FFT wrapper: not built. It would at best match pyFFTW (same
  underlying FFTW library and planning cost), which already falls short of
  scipy.fft's real-world advantage -- ruled out by the benchmark numbers
  before writing any Cython.

See benchmarks/fft_backends/RESULTS.md for full numbers and methodology.
Recommendation: keep scipy.fft as shipped in v0.25.5; do not add pyfftw/
numba/rocket-fft as dependencies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEo1qh1WnHYS4eAkq449yu
…arking suite

- Implement Rust backend (openpiv_rust) with Rayon-parallelized RealFFT circular and linear cross-correlation and subpixel peak position finding
- Fix linear cross-correlation power-of-2 centering formula and workers parameter for SciPy backend
- Integrate backend='rust' into pyprocess, multigrid_windef, and multiprocessing tools
- Add GitHub Actions workflows for multi-platform binary wheel builds (wheels.yml) and dual-backend testing (testing.yml)
- Add developer guide in documentation and benchmark suite (benchmarks/run_benchmarks.py)
…IV packages to PyPI

- Consolidate packaging into .github/workflows/wheels.yml
- Build openpiv_rust wheels for Linux (x86_64, aarch64), Windows (x64), and macOS (x86_64, aarch64) via PyO3/maturin-action
- Build openpiv pure Python sdist and wheel via Poetry
- Publish all distributions to PyPI using pypa/gh-action-pypi-publish on release tags or workflow_dispatch
- Use astral-sh/setup-uv@v5 across all GitHub Actions workflows with caching
- Replace poetry build with uv build in wheels.yml
- Replace poetry publish with uv publish in wheels.yml
- Replace poetry install with uv python install and uv pip in testing.yml
- Modernize tool.poetry.group.dev.dependencies in pyproject.toml
Copilot AI lite review requested due to automatic review settings September 5, 2026 09:39
@sourcery-ai

sourcery-ai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR introduces an optional Rust native backend for batched FFT correlation and subpixel displacement processing, wires backend selection through the PIV pipeline, adds multiprocessing and in-memory windef support, and supplies validation, benchmarks, documentation, and CI/wheel publishing for the new implementation.

Sequence diagram for backend-selected PIV correlation

sequenceDiagram
    participant PIV as PIV pipeline
    participant PyProcess as pyprocess.fft_correlate_images
    participant SciPy as scipy.fft
    participant Rust as openpiv_rust

    PIV->>PyProcess: fft_correlate_images(..., backend)
    alt backend is rust
        PyProcess->>Rust: fft_correlate_circular or fft_correlate_linear
        Rust->>Rust: batched FFT correlation
        Rust-->>PyProcess: correlation maps
    else backend is scipy
        PyProcess->>SciPy: rfft2 and irfft2
        SciPy-->>PyProcess: correlation maps
    end
    PyProcess-->>PIV: correlation maps
Loading

File-Level Changes

Change Details Files
Add an optional Rust/PyO3 FFT and subpixel-processing backend with Python dispatch and end-to-end PIV integration.
  • Implement batched circular and linear real-FFT correlation using rustfft/realfft and Rayon.
  • Expose Rust subpixel peak and displacement conversion functions, including validation and non-contiguous input handling.
  • Add backend settings and route correlation, first-pass, multipass, and windef operations through the selected backend.
  • Add Rust-vs-SciPy correctness, edge-case, PIV pipeline, and multiprocessing tests.
crates/openpiv_rust/Cargo.toml
crates/openpiv_rust/Cargo.lock
crates/openpiv_rust/src/lib.rs
openpiv/pyprocess.py
openpiv/settings.py
openpiv/windef.py
openpiv/test/test_rust_backend.py
openpiv/test/test_rust_subpixel.py
openpiv/test/test_rust_windef.py
openpiv/tools.py
pyproject.toml
uv.lock
Restructure batch PIV execution to support configurable multiprocessing and reusable image-pair workers.
  • Extract per-pair processing into a picklable worker and make n_cpus configurable through PIV settings.
  • Use a context-managed multiprocessing pool to ensure worker cleanup.
  • Add an in-memory multigrid windef entry point for backend comparisons.
openpiv/windef.py
openpiv/tools.py
openpiv/settings.py
Add benchmark tooling and documentation comparing FFT backends and demonstrating Rust PIV behavior.
  • Benchmark NumPy, SciPy, pyFFTW, rocket-fft, and Rust paths across representative batched workloads.
  • Document that SciPy remains the preferred dependency-free FFT choice while retaining Rust as an optional backend.
  • Add developer and FFT-backend documentation plus demo scripts for end-to-end timing and visualization.
benchmarks/fft_backends/RESULTS.md
benchmarks/fft_backends/bench_single_call.py
benchmarks/fft_backends/bench_repeated_calls.py
benchmarks/fft_backends/benchmark_windef.py
benchmarks/fft_backends/run_piv_quiver_demo.py
benchmarks/fft_backends/test_rust_fft.py
benchmarks/run_benchmarks.py
openpiv/docs/index.rst
openpiv/docs/src/developers.rst
openpiv/docs/src/fft_correlation_backends.rst
Revise CI and packaging workflows for optional native-backend builds and cross-platform wheel publication.
  • Replace Poetry-based setup with uv in setup and test workflows.
  • Split CI into SciPy and Rust backend test jobs across supported Python versions.
  • Build Linux, Windows, and macOS Rust wheels and publish distributions to PyPI on tags or explicit manual dispatch.
  • Remove the previous build workflow and update ignore/lock metadata.
.github/workflows/build.yml
.github/workflows/copilot-setup-steps.yml
.github/workflows/testing.yml
.github/workflows/wheels.yml
.gitignore
pyproject.toml
uv.lock

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 security issue, and 4 other issues

Security issues:

  • PyO3 has an Out-of-bounds Read in nth / nth_back for PyList and PyTuple iterators (link)
Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="crates/openpiv_rust/src/lib.rs" line_range="187-189" />
<code_context>
+        // 5. Normalization scale factor + 2D fftshift
+        let fft_norm = 1.0 / ((h * w) as f64);
+        let scale = if normalized_correlation {
+            fft_norm * fft_norm
+        } else {
+            fft_norm
+        };
+
</code_context>
<issue_to_address>
**issue (bug_risk):** The Rust backend applies an extra normalization factor for normalized correlations: circular mode uses `1/(H*W)^2` after the inputs have already been normalized, and linear mode divides the result by the output dimensions after the inverse FFT. Rust normalized results are therefore smaller than SciPy results by an additional image-size-dependent factor.

**Triggers:** When `backend='rust'` and `normalized_correlation=True`.

**Suggested fix:** Use the same inverse-FFT scaling as SciPy after `normalize_intensity`; remove the extra `fft_norm` factor in circular mode and the post-correlation division in linear mode.
</issue_to_address>

### Comment 2
<location path="openpiv/pyprocess.py" line_range="435-439" />
<code_context>
     if subpixel_method not in ("gaussian", "centroid", "parabolic"):
         raise ValueError(f"Method not implemented {subpixel_method}")

+    if HAS_RUST and corr.ndim == 2:
+        return openpiv_rust.find_subpixel_peak_position(
+            np.ascontiguousarray(corr, dtype=np.float64),
+            subpixel_method=subpixel_method,
+        )
+
     # the peak locations
</code_context>
<issue_to_address>
**issue (testing):** The Rust subpixel test does not compare Rust against the Python implementation because `pyprocess.find_subpixel_peak_position` dispatches to Rust whenever `openpiv_rust` is installed. Both sides of the assertion therefore execute the same Rust code, allowing a regression in the Rust implementation to pass unnoticed.

**Triggers:** When the Rust extension is installed, as required by the Rust test environment.

**Suggested fix:** Call the pre-dispatch Python implementation directly or disable the automatic Rust dispatch while computing the reference result.
</issue_to_address>

### Comment 3
<location path="openpiv/pyprocess.py" line_range="435-439" />
<code_context>
     if subpixel_method not in ("gaussian", "centroid", "parabolic"):
         raise ValueError(f"Method not implemented {subpixel_method}")

+    if HAS_RUST and corr.ndim == 2:
+        return openpiv_rust.find_subpixel_peak_position(
+            np.ascontiguousarray(corr, dtype=np.float64),
+            subpixel_method=subpixel_method,
+        )
+
     # the peak locations
</code_context>
<issue_to_address>
**issue (broader_impact):** Installing the optional Rust package silently changes the default SciPy/Python execution path for subpixel peak finding and displacement conversion, even when the caller selects the SciPy backend. The Rust implementation is selected solely from `HAS_RUST`, so users can get different numerical behavior merely by installing the optional extension.

**Triggers:** When `openpiv_rust` is installed and callers use the default SciPy backend.

**Suggested fix:** Gate Rust subpixel dispatch on an explicit backend setting and propagate that setting through `correlation_to_displacement` and related callers.
</issue_to_address>

### Comment 4
<location path="benchmarks/fft_backends/bench_single_call.py" line_range="4" />
<code_context>
+"""Benchmark FFT backends for openpiv's batched cross-correlation pattern.
+
+Not part of the package; a throwaway script for the explore/fft-backends
+branch. Run with: uv run python scripts_bench_fft.py
+"""
+import time
</code_context>
<issue_to_address>
**nitpick:** The benchmark module's usage documentation tells users to run `scripts_bench_fft.py`, but that file is not the added benchmark script. Following the documented command fails because the referenced entry point does not exist.

**Suggested fix:** Change the usage example to `python benchmarks/fft_backends/bench_single_call.py` or provide the referenced wrapper script.

```suggestion
branch. Run with: python benchmarks/fft_backends/bench_single_call.py
```
</issue_to_address>

### Comment 5
<location path="crates/openpiv_rust/Cargo.lock" line_range="196-212" />
<code_context>

</code_context>
<issue_to_address>
**security (GHSA-36hh-v3qg-5jq4):** PyO3 has an Out-of-bounds Read in `nth` / `nth_back` for `PyList` and `PyTuple` iterators

PyO3 0.24.0 added optimized implementations of `Iterator::nth` and `DoubleEndedIterator::nth_back` for the `BoundListIterator` and `BoundTupleIterator` types. These implementations computed the target index using unchecked `usize` addition (`index + n`) before bounds-checking against the sequence length, then read the element via `get_item_unchecked`.

In `nth` methods, a sufficiently large `n` (combined with a non-zero internal index) could cause the addition to overflow and wrap around, producing a small "target index" that passed the bounds check and enabling reads at the front of the `list` or `tuple` of elements previously yielded by the iterator.

In `nth_back` methods, a sufficiently large `n` could cause underflow in a similar fashion, however would instead allow reads of arbitrary memory past the end of the `list` or `tuple` storage.

*Source: trivy*
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 4 findings to address first, and the new workflow grants write and OIDC token permissions and can publish artifacts from a manually selected branch to PyPI, so a mistaken trigger or compromised dependency could create an externally distributed release that reverting the PR cannot retract. The Rust backend and CI changes also add substantial new build and dependency surface beyond the existing SciPy path.

Blocking findings: crates/openpiv_rust/src/lib.rs:189, openpiv/pyprocess.py:439, openpiv/pyprocess.py:439, crates/openpiv_rust/Cargo.lock:212


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment on lines +187 to +189
fft_norm * fft_norm
} else {
fft_norm

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The Rust backend applies an extra normalization factor for normalized correlations: circular mode uses 1/(H*W)^2 after the inputs have already been normalized, and linear mode divides the result by the output dimensions after the inverse FFT. Rust normalized results are therefore smaller than SciPy results by an additional image-size-dependent factor.

Triggers: When backend='rust' and normalized_correlation=True.

Suggested fix: Use the same inverse-FFT scaling as SciPy after normalize_intensity; remove the extra fft_norm factor in circular mode and the post-correlation division in linear mode.

Comment thread openpiv/pyprocess.py Outdated
Comment on lines +435 to +439
if HAS_RUST and corr.ndim == 2:
return openpiv_rust.find_subpixel_peak_position(
np.ascontiguousarray(corr, dtype=np.float64),
subpixel_method=subpixel_method,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (testing): The Rust subpixel test does not compare Rust against the Python implementation because pyprocess.find_subpixel_peak_position dispatches to Rust whenever openpiv_rust is installed. Both sides of the assertion therefore execute the same Rust code, allowing a regression in the Rust implementation to pass unnoticed.

Triggers: When the Rust extension is installed, as required by the Rust test environment.

Suggested fix: Call the pre-dispatch Python implementation directly or disable the automatic Rust dispatch while computing the reference result.

Comment thread openpiv/pyprocess.py Outdated
Comment on lines +435 to +439
if HAS_RUST and corr.ndim == 2:
return openpiv_rust.find_subpixel_peak_position(
np.ascontiguousarray(corr, dtype=np.float64),
subpixel_method=subpixel_method,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (broader_impact): Installing the optional Rust package silently changes the default SciPy/Python execution path for subpixel peak finding and displacement conversion, even when the caller selects the SciPy backend. The Rust implementation is selected solely from HAS_RUST, so users can get different numerical behavior merely by installing the optional extension.

Triggers: When openpiv_rust is installed and callers use the default SciPy backend.

Suggested fix: Gate Rust subpixel dispatch on an explicit backend setting and propagate that setting through correlation_to_displacement and related callers.

"""Benchmark FFT backends for openpiv's batched cross-correlation pattern.

Not part of the package; a throwaway script for the explore/fft-backends
branch. Run with: uv run python scripts_bench_fft.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: The benchmark module's usage documentation tells users to run scripts_bench_fft.py, but that file is not the added benchmark script. Following the documented command fails because the referenced entry point does not exist.

Suggested fix: Change the usage example to python benchmarks/fft_backends/bench_single_call.py or provide the referenced wrapper script.

Suggested change
branch. Run with: uv run python scripts_bench_fft.py
branch. Run with: python benchmarks/fft_backends/bench_single_call.py

Comment thread crates/openpiv_rust/Cargo.lock
- Set PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 and pin supported Python interpreters in maturin wheel builds
- Use pytest.importorskip for openpiv_rust in unit tests to allow pure Python CI without Rust
- Fix uv virtualenv usage and pass --system in copilot setup steps
- Use explicit pytest executable path from uv venv in testing.yml

Copilot AI commented Sep 5, 2026

Copy link
Copy Markdown

One or more custom setup steps configured for this repository failed during this Copilot code review run:

Install project dependencies

Setup steps run before each review. If the review above is missing context, or no review was posted at all, the failing step above may be the cause. See the workflow run for failure details, fix your setup steps configuration, and re-request a review.

Note

You can configure setup steps for Copilot code review separately from Copilot cloud agent with a copilot-code-review.yml file. Read the docs for details.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

CI and local SciPy-only test runs will fail due to unconditional Rust-test imports and an invalid .[dev] install target in the uv-based workflow.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR adds an optional Rust-accelerated FFT correlation backend (openpiv_rust) while keeping SciPy as the default, and wires backend selection + multiprocessing controls through PIVSettings and the windef pipeline. It also expands CI/build workflows (uv + maturin) and adds documentation/benchmarks for backend behavior and performance.

Changes:

  • Add Rust-backed FFT correlation + batched subpixel/displacement helpers, selectable via settings.backend, and propagate backend selection through windef/pyprocess.
  • Refactor windef batch processing for multiprocessing picklability and improve multiprocessing cleanup with a context-managed pool.
  • Add Rust backend tests, docs/benchmarks, and new wheel-building/publishing workflows.
File summaries
File Description
uv.lock Updates uv lock metadata (Python requirement).
pyproject.toml Adds optional openpiv-rust dependency, rust extra, and dev-group deps (incl. maturin).
openpiv/windef.py Refactors batch worker, adds backend passthrough to correlation, adds multigrid_windef.
openpiv/tools.py Uses context-managed multiprocessing.Pool to ensure cleanup.
openpiv/test/test_rust_windef.py Adds windef Rust-vs-SciPy parity + multiprocessing tests.
openpiv/test/test_rust_subpixel.py Adds Rust subpixel + batch displacement tests.
openpiv/test/test_rust_backend.py Adds Rust FFT correlation correctness/edge-case tests.
openpiv/settings.py Adds backend and n_cpus settings for backend selection and multiprocessing.
openpiv/pyprocess.py Adds Rust acceleration hooks, backend selection for FFT correlation, and workers plumbing for SciPy FFT.
openpiv/docs/src/fft_correlation_backends.rst New backend/performance study documentation.
openpiv/docs/src/developers.rst Adds Rust vs non-Rust development instructions.
openpiv/docs/index.rst Adds the new FFT backend doc to the Sphinx toctree.
crates/openpiv_rust/src/lib.rs Implements Rust FFT correlation + subpixel/displacement APIs via PyO3/Rayon/RealFFT.
crates/openpiv_rust/Cargo.toml Declares Rust crate metadata and dependencies.
crates/openpiv_rust/Cargo.lock Locks Rust crate dependency versions.
benchmarks/run_benchmarks.py Adds benchmark runner for correlation/subpixel/windef backends.
benchmarks/fft_backends/test_rust_fft.py Adds standalone Rust vs SciPy validation/benchmark script.
benchmarks/fft_backends/run_piv_quiver_demo.py Adds demo script for visual comparison of vector fields.
benchmarks/fft_backends/RESULTS.md Records backend exploration findings/results.
benchmarks/fft_backends/benchmark_windef.py Adds windef-focused benchmark harness.
benchmarks/fft_backends/bench_single_call.py Adds single-call FFT backend benchmark script.
benchmarks/fft_backends/bench_repeated_calls.py Adds repeated-call FFT backend benchmark script.
.gitignore Ignores Rust build outputs and wheel artifacts.
.github/workflows/wheels.yml New multi-OS wheel build + optional PyPI publishing workflow.
.github/workflows/testing.yml Migrates CI testing to uv and adds a Rust-backend job.
.github/workflows/copilot-setup-steps.yml Updates setup steps to uv and newer checkout action.
.github/workflows/build.yml Removes the previous Poetry-based publish workflow.
Review details

Suppressed comments (1)

.github/workflows/copilot-setup-steps.yml:1

  • This workflow file begins with a UTF-8 BOM before name:. Remove the BOM to prevent YAML parsing/tooling issues across environments.
name: "Copilot Setup Steps"
  • Files reviewed: 24/27 changed files
  • Comments generated: 8
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/testing.yml Outdated
- name: Install dependencies (pure Python / SciPy mode)
run: |
uv venv --python ${{ matrix.python-version }}
uv pip install ".[dev]" pytest
Comment thread openpiv/test/test_rust_backend.py Outdated
Comment thread openpiv/test/test_rust_subpixel.py Outdated
Comment on lines +1 to +4
import numpy as np
import pytest
import openpiv_rust
from openpiv import pyprocess
Comment on lines +4 to +6
import numpy as np
import pytest
from imageio.v3 import imwrite
Comment thread .github/workflows/testing.yml Outdated
Comment thread .github/workflows/wheels.yml Outdated
Comment thread benchmarks/run_benchmarks.py Outdated
Comment thread openpiv/docs/src/fft_correlation_backends.rst Outdated
@alexlib
alexlib merged commit 148a2b6 into master Sep 5, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants