Developer Guide
This guide collects the maintainer- and contributor-facing operational guidance for pyrtc.
Naming and Packaging
For the stable release line:
user-facing project name: pyrtc
PyPI distribution name: pyrtcao
Python import name: pyrtc
command-line prefix: pyrtc-*
This keeps the public-facing name simple while avoiding a PyPI naming conflict.
Local Setup
git clone https://github.com/jacotay7/pyRTC.git
cd pyRTC
pip install -r requirements-test.txt
pip install -e .
Optional extras:
pip install -e .[docs]
pip install -e .[viewer]
pip install -e .[gui]
pip install -e .[gpu]
pip install -e .[hcipy] # runs the HCIPy tests instead of skipping them
requirements-test.txt already installs the optional packages the suite
exercises (matplotlib, astropy, optuna, hcipy, aotpy).
The viewer and gui extras install qtpy and PySide6 (Qt6). Without
them, tests/test_qt_smoke.py skips; with them it builds the manager GUI and
the viewer on Qt’s offscreen platform, so it needs no display. To check
PyQt6 as well, install PyQt6 and run it with QT_API=pyqt6.
Day-to-Day Checks
Run the main validation commands before opening a pull request or preparing a release candidate:
pytest -q
ruff check pyrtc tests benchmarks
python -m build
python -m twine check dist/*
Validate the built wheel in a clean environment:
python -m pyrtc.scripts.validate_dist_install --dist-dir dist
If you want to keep the validation environment for inspection instead of using a temporary venv:
python -m pyrtc.scripts.validate_dist_install --dist-dir dist --venv-dir wheel-test-env
Continuous Integration
GitHub Actions runs on pull requests into dev and main and on pushes to
main:
python-install.yml:installruns the suite with coverage on Python 3.10-3.14, plus the wheel checks and the perf smoke gate;smoke-system-notebookruns the end-to-end system tests and notebooks;free-threadedruns the suite on 3.14t withPYTHON_GIL=0, withouttests/system;aarch64runs the whole suite, system tests included, on an ARM64 runner (ubuntu-24.04-arm), where memory ordering and pyshmem’s atomics differ from x86;docsruns the Sphinx build.
cross-platform-smoke.yml: macOS and Windows smoke tests.gui-smoke.yml(qt-offscreen): the Qt manager GUI and viewer on the offscreen platform.isio-bridge.yml: builds ImageStreamIO and runs the ISIO bridge tests.lint.yml:ruff checkandruff format --check.publish-package.yml: builds and publishes to TestPyPI/PyPI, on a release or by hand.
The simulator system tests are timing-sensitive on busy runners; see the
notes on loop delay in AGENTS.md before tightening their thresholds.
Documentation Workflow
Install docs dependencies:
pip install -e .[docs]
Build the docs:
cd docs/source
make html
Live preview:
cd docs/source
sphinx-autobuild . _build/html
Benchmark Workflow
The README benchmark section is generated from a reproducible report captured on a target machine.
Generate a benchmark report:
pyrtc-ao-loop-bench \
--output benchmarks/readme_benchmark_report.json \
--iterations 300 \
--warmup 30 \
--system-sizes 10 20 60 \
--log-dir logs
Generate markdown tables for the README:
python benchmarks/readme_benchmark_table.py \
--report benchmarks/readme_benchmark_report.json \
--output benchmarks/readme_benchmark_table.md
Compare the current host report against the committed baseline:
python benchmarks/check_perf_baseline.py \
--current benchmarks/readme_benchmark_report.json \
--baseline benchmarks/ao_loop_bench_baseline.json
Performance History
Every CI run uploads its micro-benchmark report (perf-smoke-report-py<version>)
and, from Python 3.12, an end-to-end pipeline-latency report of the running
synthetic system. benchmarks/perf_history.py collects those artifacts from
recent runs and prints each metric’s latest value against the median of
earlier runs, flagging regressions. CI adds this table to the 3.12 job’s
summary; it is informational, because shared runners are noisy.
export GH_TOKEN=... # any token that can read Actions artifacts
python -m benchmarks.perf_history --repo jacotay7/pyRTC --runs 20
python -m benchmarks.perf_history --repo jacotay7/pyRTC --artifact pipeline-latency
# a lab host: keep nightly perf_smoke reports in a directory
python -m benchmarks.perf_history --from-dir lab_reports/ --plot trends.png --max-ratio 1.3
It exits with status 1 when a metric’s latest value exceeds --max-ratio
times its history, so a scheduled lab job can gate on it.
Free-Threaded Python
pyrtc runs on free-threaded CPython (3.13t/3.14t), where the worker threads of
a soft-RTC system no longer share one GIL. Everything the core imports
supports it, numba 0.67 included, and the whole test suite passes with the
GIL forced off. The free-threaded CI job checks both on 3.14t. Keep it
that way:
don’t import extensions that lack free-threading support from core modules;
a single such import turns the GIL back on process-wide (astropy’s
erfadoes, which is one reasonfitsis an optional extra);tests/test_public_api.pychecks the core imports.
Measured end-to-end latency (benchmarks/pipeline_latency_bench.py, synthetic
SHWFS soft-RTC system, 1000 samples, median of two interleaved runs, 8-core
(16-thread) x86-64 host), in microseconds:
frame rate, consumers |
p50 3.14 |
p50 3.14t |
p99 3.14 |
p99 3.14t |
|---|---|---|---|---|
200 Hz, notify |
306 |
258 |
444 |
309 |
200 Hz, poll |
267 |
232 |
393 |
333 |
1 kHz, notify |
252 |
245 |
413 |
303 |
1 kHz, poll |
260 |
229 |
376 |
358 |
Without the GIL, median latency drops 3-15% and the p99 tail 5-30%. The gain
is modest on this host because the numba kernels already release the GIL
(nogil=True), so most of the remaining contention is in Python glue code.
On slower cores the gain is much larger, because the glue code takes longer while holding the GIL. On an 80-core aarch64 host (Neoverse-N1), measured the same way on 8 cores, p50 / p99 in microseconds:
frame rate, consumers |
3.14 |
3.14t |
|---|---|---|
200 Hz, notify |
1673 / 3337 |
966 / 1199 |
1 kHz, notify |
6987 / 16105 |
843 / 1078 |
There the GIL build cannot keep up with a 1 kHz soft-RTC pipeline (the median latency exceeds the frame period, so frames queue), and the free-threaded build keeps up easily.
An apparent free-threading problem turned out to be BLAS threads (#139). On a
16-thread x86 host with 14 busy processes, the free-threaded HCIPy system test
let the loop delay grow to 6-19 frames and diverge, while the GIL build stayed
at 1-2 frames. The cause was the simulator’s OpenBLAS pools, which occupied
nearly every core by themselves. With them capped at one thread, the same test
passed 4 of 4 times under the same load. It never reproduced on an aarch64
host. Cap BLAS threads (OPENBLAS_NUM_THREADS=1) when load-testing a
simulated system, and give a real RTC dedicated cores either way. To try
free-threading: uv python install 3.14t, make a venv with it, and install
pyrtc as usual.
Logging Workflow
The main scripts, benchmark entry points, and hardware launcher paths use the shared pyrtc logging helpers.
Default behavior:
console logging enabled
level INFO
color enabled when the terminal supports it
Useful controls:
export PYRTC_LOG_LEVEL=INFO
export PYRTC_LOG_DIR=./logs
python examples/synthetic_shwfs/synthetic_shwfs_soft_rtc_example.py --duration 15
Per-command overrides:
pyrtc-view wfs --log-level DEBUG
python -m benchmarks.perf_smoke --log-file perf.log
Prefer PYRTC_LOG_DIR for multiprocess runs so parent and child processes write separate files.
Error-Handling Policy
For 1.0.x, prefer explicit, conservative behavior in non-real-time paths.
Raise exceptions when:
required configuration is missing or invalid
file loads or saves fail for requested user-visible artifacts
startup or hardware-attachment steps fail and the component cannot provide its documented behavior
a requested optional feature cannot be enabled safely
Warn and continue when:
the code is falling back from GPU to CPU for a supported code path
a convenience feature cannot be enabled but the main component behavior still works
the software can continue safely with a documented default or degraded mode
Log and suppress only when:
cleanup or teardown is best-effort
a background diagnostic or optional observer path fails without affecting core control-plane behavior
repeated operator-facing noise would be less useful than a single earlier warning
Avoid adding per-iteration exception handling or routine logging inside the steady-state real-time loop. Put detailed logging around setup, calibration, file I/O, control-plane state changes, and error boundaries instead.
Contribution Expectations
Contributions are most useful when they improve one or more of the following:
core AO component reliability
documentation and onboarding
example quality
performance observability
broadly reusable hardware integration patterns
Before starting larger work:
open an issue for major interface or architecture changes
keep bug fixes focused and reproducible
avoid mixing unrelated refactors with functional changes
be explicit about platform and dependency assumptions
Component Descriptors
pyrtc now exposes machine-readable component descriptors for the built-in core components. These descriptors are intended to support:
config validation
future manager and GUI form generation
stream introspection
future plugin discovery
Useful entry points from Python are:
import pyrtc
catalog = pyrtc.build_descriptor_catalog()
loop_descriptor = pyrtc.get_component_descriptor("loop")
wfs_descriptor = pyrtc.wavefront_sensor.describe()
hardware_delay = loop_descriptor["hardware_delay"]
default_gain = loop_descriptor["gain"]["default"]
In the REPL, descriptors now render as a compact summary rather than a full dataclass dump, and they support dict-like field lookup by config key. This means calls such as pyrtc.loop.describe()[“hardware_delay”] and pyrtc.loop.describe()[“gain”][“default”] work naturally.
Each descriptor includes:
top-level config section name
component class path
required and optional config fields
worker functions intended for the functions list
input and output stream metadata
calibration artifact hints
When adding new built-in components, update pyrtc/component_descriptors.py and keep the descriptor aligned with the actual config and stream contract. Future third-party integrations can also register descriptors programmatically without changing manager-specific code:
pyrtc.register_component_descriptor(custom_descriptor)
Descriptor-driven validation is intentionally generic and should be paired with component-specific validation for domain rules that cannot be captured as simple field metadata.
Unknown config keys
pyrtc-validate-config and component construction warn (without failing) about config keys that the component class does not read, such as method: where the loop reads im_method.
The known keys are the common runtime keys (class_name, class_file, name, functions, affinity, realtime_priority, gpu_device, input_streams, output_streams, resource), the descriptor fields, and any EXTRA_CONFIG_KEYS declared along the class hierarchy. Keys starting with _ are private runtime keys and are never reported.
A subclass of a built-in component that reads keys of its own declares them, which also opts it into the check:
class MyCamera(pyrtc.WavefrontSensor):
EXTRA_CONFIG_KEYS = ("serial", "exposure")
A subclass that declares neither EXTRA_CONFIG_KEYS nor its own COMPONENT_DESCRIPTOR is not checked, because its extra keys are unknown.
When opening a pull request:
state the motivation clearly
explain the user-visible behavior change
list the validation commands you ran
call out compatibility or deployment risks
Writing Components
Components talk to streams only through handles they register:
Open each stream once and register it with
register_input_stream(name, shm)(streams the component reads) orregister_output_stream(name, shm)(streams it writes). A stream a component both reads and writes, such as the corrector’swfc, is registered as both.Use
read_stream/write_streamwith the registered name. Reading a registered input records itsframe_idandwrite_streamstamps it on the outputs, which is how frame identity reaches the DM. An unregistered name raisesKeyError; a<name>_shmattribute is not a registration.A registered handle belongs to the component:
close()closes it, and registering a different handle under the same name closes the replaced one.Source components (wavefront sensors) number their own frames;
WavefrontSensor.exposekeeps a private exposure counter, so a simulator that also readswfcdoes not disturb it.
Release anything else the component holds (device SDK handles, extra threads, ring buffers) by overriding close(): call super().close(*args, **kwargs) first, which stops the component and joins its workers, then release your own resources. Do not put teardown in __del__; the base __del__ calls close(), but it only runs once the worker threads are gone.
Tests that exercise a single method without running __init__ build the component with testsupport.bare_component(Cls, inputs={...}, outputs={...}), which runs the real stream-state initialization and registers the given streams. Tests that run a whole system use testsupport.private_synthetic_config for private stream names and close the manager (with RTCManager... as manager or manager.close() in finally).
Hardware Contributions
Hardware-facing code is valuable but environment-specific.
For hardware integrations:
isolate vendor SDK assumptions clearly
document OS and dependency constraints
avoid breaking generic component behavior
provide a minimal usage example when possible
prefer simulator-backed validation where practical
Support Posture
The most stable public surface for 1.0.x is:
installation as pyrtcao
runtime import as pyrtc
the core AO component model
the documented shared-memory and configuration concepts
Linux-based development and deployment workflows
Areas that still need target-environment validation before operational use:
vendor SDK integrations
GPU-specific execution paths beyond the documented synthetic benchmark coverage
multi-process deployment details
platform-specific driver and device behavior
Current platform stance for the 1.x releases:
Linux is the primary supported operating system.
macOS and Windows smoke jobs are useful compatibility signal, but they are not the primary release target.
Hardware adapters remain environment-specific integrations, not universal support guarantees.
Issue Reporting
Useful bug reports should include:
Python version
operating system
install method
whether GPU support was enabled
whether real hardware or simulation was used
the smallest reproducible script or config
Release Checklist
Before publishing a release candidate:
Update the changelog and confirm version metadata.
Verify the README matches the install, import, and support story.
Confirm docs contain no user-facing placeholders.
Run the full validation path:
pytest -q ruff check pyrtc tests benchmarks python -m build python -m twine check dist/* python -m pyrtc.scripts.validate_dist_install --dist-dir dist cd docs/source && make html
Upload to TestPyPI first.
Validate installation from TestPyPI in a clean environment.
Publish to PyPI only after the TestPyPI install passes.
Publishing Workflow
The repository includes .github/workflows/publish-package.yml.
Expected usage:
workflow_dispatch with repository=testpypi for pre-release uploads
a published GitHub release, or manual dispatch with repository=pypi, for production uploads
This workflow assumes trusted publishing has been configured on both TestPyPI and PyPI.