makewfs 2.1.0__tar.gz → 2.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {makewfs-2.1.0 → makewfs-2.1.1}/AGENTS.md +8 -5
- {makewfs-2.1.0 → makewfs-2.1.1}/CHANGELOG.md +33 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/CITATION.cff +1 -1
- {makewfs-2.1.0 → makewfs-2.1.1}/PKG-INFO +1 -1
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/configuration.md +1 -1
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/__about__.py +1 -1
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/api.py +1 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/backend.py +78 -12
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/provenance.py +5 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/sensors/pyramid.py +7 -1
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_gpu_backend.py +52 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_numerics.py +30 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_pyramid.py +18 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/.gitignore +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/CONTRIBUTING.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/LICENSE +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/README.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/ROADMAP.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/__init__.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/benchmark_compiled_sh_executor.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/benchmark_sh_state_batching.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/check_regression.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/configs/pyramid_40_float32.toml +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/configs/pyramid_60_mod8_float32.toml +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/configs/pyramid_80_mod32_float64.toml +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/configs/shack_hartmann_20x20_float32.toml +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/configs/shack_hartmann_60x60_float64.toml +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/configs/shack_hartmann_quadrature_9sample.toml +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/device-results-neoverse-n1-rtx4060.json +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/device-results-neoverse-n1-rtxa400.json +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/device-results-neoverse-n1.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/device-results.json +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/device-results.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/haka-compiled-sh-executor-quadro-p620.json +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/haka-sh-state-batching-quadro-p620.json +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/profile_warm.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/reference-results.json +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/reference-table.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/render_device_table.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/render_table.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/run.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/adr/0001-units-coordinates.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/adr/0002-flux-normalization.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/adr/0003-public-api.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/adr/0004-backend-boundary.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/adr/index.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/api.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/concepts.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/contributing.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/detectors.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/examples.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/gallery/makewfs-gallery.json +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/gallery/makewfs-gallery.svg +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/gallery.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/guide-stars.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/index.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/interop.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/performance.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/pyramid.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/quickstart.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/release.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/shack-hartmann.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/stability.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/troubleshooting.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/units-and-coordinates.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/docs/validation.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/README.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/cds_readout.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/closed_loop_injection.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/compare_sensors.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/configs/angular_kernel.txt +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/configs/lgs_thin_beacon.toml +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/configs/precision_throughput.toml +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/configs/pyramid_minimal.toml +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/configs/qe_curve.txt +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/configs/shack_hartmann_extended_source.toml +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/configs/shack_hartmann_minimal.toml +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/configs/shack_hartmann_spectral_qe.toml +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/detector_choices.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/gallery.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/README.md +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/analyze_lut.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/benchmark.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/camera_modes.csv +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/camera_modes_empirical_floor_continuous.csv +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/compare_real.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/fit_secondary.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/haka_cpu_gpu_benchmark.json +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/haka_lut_snr.json +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/keck_haka.json +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/keck_haka.toml +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/mauna_kea_extinction.csv +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/mauna_kea_extinction_nir.csv +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/ocam_20260720/extract_ocam_images.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/ocam_20260720/make_ocam_video.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/real_vs_simulation.json +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/secondary_fit.json +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/simulate.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/lgs_elongation.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/lgs_thin_beacon.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/magnitude_series.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/makewfs_showcase.webp +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/moving_atmosphere.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/precision_throughput.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/pyramid_modulation.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/quickstart.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/realistic_broadband.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/sh_design_trade.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/showcase.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/examples/spectral_qe.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/pyproject.toml +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/__init__.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/cli.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/config.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/detector.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/pupil.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/py.typed +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/radiometry.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/sampling.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/sensors/__init__.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/sensors/_cuda_graph.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/sensors/_shack_hartmann_cuda.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/sensors/base.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/sensors/shack_hartmann.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/source.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/wavefront.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_backend_audit.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_benchmarks.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_cli.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_config.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_conformance.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_hcipy_validation.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_input_rms.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_interop.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_keck_haka_example.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_oopao_validation.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_optics_validation.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_provenance.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_public_api.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_radiometry.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_shack_hartmann.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_shack_hartmann_sampling.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_source.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_validation_report.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_wavefront.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/validation/__init__.py +0 -0
- {makewfs-2.1.0 → makewfs-2.1.1}/validation/run.py +0 -0
|
@@ -255,15 +255,18 @@ MPLBACKEND=Agg python examples/gallery.py
|
|
|
255
255
|
fixed shapes and never synchronize with the host (no `scalar`, `.item()`,
|
|
256
256
|
`to_host`, or data-dependent shapes). A failed capture silently falls back to
|
|
257
257
|
eager execution and records why in `engine._graph.failure`; check it after
|
|
258
|
-
changing that method.
|
|
258
|
+
changing that method. Anything the graph uses must outlive it: its FFTs run
|
|
259
|
+
on the engine's own cuFFT plans (`plans=` in `fft_axis`), never CuPy's global
|
|
260
|
+
plan cache, which evicts and frees plans the graph still references.
|
|
259
261
|
- The compiled SH kernel reads the OPD as float64; `_CompiledShackHartmannExecutor.render`
|
|
260
262
|
widens a float32 lenslet-grid resample (exact). Its static thread and
|
|
261
263
|
shared-memory checks cannot see register pressure, so construction also
|
|
262
264
|
checks the compiled kernel's `max_threads_per_block` and falls back.
|
|
263
|
-
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
- Grid sizes set the physics, so they must never depend on the backend.
|
|
266
|
+
`backend.next_fast_length` is one 7-smooth rule for CPU and GPU (it equals
|
|
267
|
+
CuPy's `next_fast_len`); do not call `scipy.fft.next_fast_len` or
|
|
268
|
+
`cupyx.scipy.fft.next_fast_len` for a size. SciPy's admits 11, which made the
|
|
269
|
+
pyramid pad differently on the two devices before 2.1.1.
|
|
267
270
|
|
|
268
271
|
## Documentation and examples
|
|
269
272
|
|
|
@@ -4,6 +4,39 @@ All notable changes to `makewfs` are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.1.1] - 2026-10-07
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **CPU and GPU pyramids used different propagation grids for some
|
|
12
|
+
geometries** ([#15](https://github.com/jacotay7/makewfs/issues/15)). The FFT
|
|
13
|
+
size came from `scipy.fft.next_fast_len` on the CPU and
|
|
14
|
+
`cupyx.scipy.fft.next_fast_len` on the GPU, and SciPy also admits a factor
|
|
15
|
+
of 11. Whenever it picked one (requests such as 33, 66 or 99 samples), the
|
|
16
|
+
CPU padded to 33/66/99 while the GPU padded to 35/70/100, and the photon
|
|
17
|
+
rates differed by up to 46% (24-pixel pupil, 9-pixel separation, 12-point
|
|
18
|
+
modulation at 3 lambda/D). Both backends now use one rule: the smallest
|
|
19
|
+
length with no prime factor above 7, which equals CuPy's choice, so **GPU
|
|
20
|
+
results are unchanged** and CPU and GPU now agree to rounding.
|
|
21
|
+
- **Behaviour change on the CPU** for every pyramid whose old SciPy size had a
|
|
22
|
+
factor of 11: the grid grows by at most 9.1% and the rate changes. At the
|
|
23
|
+
default `fft_oversampling = 2` this affects detector widths
|
|
24
|
+
(`pixels_across_pupil + pupil_separation_pixels + 2 * detector_margin_pixels`)
|
|
25
|
+
of 11, 22, 33, 38, 43, 44, 55, 65, 66, 76, 77, 82, 88, 99, 109, 110,
|
|
26
|
+
113-115, 121, 129-132, 136, 137, 148, 151-154, 163-165, 176, 181, 197, 198,
|
|
27
|
+
217-220, 226-231, 241, 242, 246, 247, 263-269, 271-275 and 295-297 pixels
|
|
28
|
+
(up to 300). Overall, 1,342 of the requests 1-4096 change size.
|
|
29
|
+
- **Unaffected:** the shipped example and benchmark pyramids (grids of 288,
|
|
30
|
+
108, 160 and 216 samples) and every Shack-Hartmann configuration, whose FFT
|
|
31
|
+
sizes follow from the configured sampling.
|
|
32
|
+
- Frames now record the grid as `wfs_pyramid_fft_size_px`.
|
|
33
|
+
- **The 2.1.0 GPU pyramid's CUDA graph could replay freed cuFFT memory.** It
|
|
34
|
+
used plans from CuPy's global plan cache, which evicts them once other
|
|
35
|
+
transforms fill it (for example many sensors or user FFTs in one process),
|
|
36
|
+
and replay then read freed memory (`cudaErrorIllegalAddress`). The pyramid
|
|
37
|
+
now owns its plans, built exactly as CuPy builds them, so results are
|
|
38
|
+
unchanged.
|
|
39
|
+
|
|
7
40
|
## [2.1.0] - 2026-10-07
|
|
8
41
|
|
|
9
42
|
### Performance
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: makewfs
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.1
|
|
4
4
|
Summary: Configuration-driven adaptive-optics wavefront sensor image simulation.
|
|
5
5
|
Project-URL: Homepage, https://github.com/jacotay7/makewfs
|
|
6
6
|
Project-URL: Documentation, https://jacotay7.github.io/makewfs/
|
|
@@ -116,7 +116,7 @@ kernel file hash and all normalized states are included in frame provenance.
|
|
|
116
116
|
| `detector.roi` | Optional full-detector ROI table with non-negative `left_px`/`top_px` and positive `width_px`/`height_px`. Its shape must match the optical mosaic. |
|
|
117
117
|
| `numerics.dtype` | Optical real precision, `"float32"` or `"float64"` (default `"float64"`). |
|
|
118
118
|
| `numerics.device` | Execution device, `"cpu"` (default) or `"gpu"`. GPU requires the `makewfs[gpu]` extra and a GPU-capable `getframes`; optical, truth, and ADU arrays stay device-resident. |
|
|
119
|
-
| `numerics.fft_oversampling` | Positive FFT integration oversampling (default `2`). Also scales the pyramid propagation grid so diffraction beyond the detector crop is discarded instead of wrapping onto the pupil rims. |
|
|
119
|
+
| `numerics.fft_oversampling` | Positive FFT integration oversampling (default `2`). Also scales the pyramid propagation grid so diffraction beyond the detector crop is discarded instead of wrapping onto the pupil rims. The pyramid grid side is the smallest length with no prime factor above 7 that is at least `fft_oversampling` times the detector width; the rule is the same on CPU and GPU, and frames record it as `wfs_pyramid_fft_size_px`. |
|
|
120
120
|
| `numerics.fft_workers` | Positive `scipy.fft` worker count (default `1`). |
|
|
121
121
|
| `numerics.pupil_samples_per_lenslet` | Optional integer ≥4: samples per lenslet pitch of the SH pupil grid that holds the amplitude mask, a custom mask, and lenslet illumination. Default `max(8, ceil(max(input.shape) / lenslets_across_pupil))`. Each lenslet field is then propagated on an integer refinement of this grid that is at least as fine as the widest detector window in lenslet `lambda/d` (see [Lenslet-field sampling](shack-hartmann.md#lenslet-field-sampling)); a value already that fine is used unchanged. |
|
|
122
122
|
| `numerics.pupil_supersampling` | Positive analytic pupil boundary sub-sampling factor (default `1`). |
|
|
@@ -58,6 +58,7 @@ class WavefrontSensor:
|
|
|
58
58
|
seed=None,
|
|
59
59
|
source_states=self.engine.source_states,
|
|
60
60
|
file_digests=self.engine.file_digests,
|
|
61
|
+
pyramid_fft_size=getattr(self.engine, "nfft", None),
|
|
61
62
|
)
|
|
62
63
|
self._rms_weights = pupil_weights(self._input_pupil_intensity(), backend=self.backend)
|
|
63
64
|
|
|
@@ -255,19 +255,53 @@ class ArrayBackend:
|
|
|
255
255
|
norm: str,
|
|
256
256
|
inverse: bool = False,
|
|
257
257
|
workers: int = 1,
|
|
258
|
+
plans: dict[Any, Any] | None = None,
|
|
258
259
|
) -> Any:
|
|
259
260
|
"""One-dimensional FFT along ``axis`` of an array the caller owns.
|
|
260
261
|
|
|
261
262
|
The pruned two-dimensional transforms are built from these passes. On
|
|
262
263
|
the CPU the input may be overwritten, so pass only a temporary.
|
|
264
|
+
|
|
265
|
+
On a GPU, ``plans`` is a caller-owned cache of cuFFT plans. CuPy's own
|
|
266
|
+
plan cache is global and evicts plans when other transforms run, which
|
|
267
|
+
frees memory a captured CUDA graph still uses; a graph's owner passes
|
|
268
|
+
its own dict so the plans live exactly as long as the graph. The plan
|
|
269
|
+
is the same ``Plan1d`` CuPy would build, so values are unchanged.
|
|
263
270
|
"""
|
|
264
271
|
if self.is_cpu:
|
|
265
272
|
from scipy import fft
|
|
266
273
|
|
|
267
274
|
transform = fft.ifft if inverse else fft.fft
|
|
268
275
|
return transform(array, axis=axis, norm=norm, workers=workers, overwrite_x=True)
|
|
276
|
+
return self._device_fft_axis( # pragma: no cover - optional CUDA execution
|
|
277
|
+
array, axis=axis, norm=norm, inverse=inverse, plans=plans
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def _device_fft_axis( # pragma: no cover - optional CUDA execution
|
|
281
|
+
self,
|
|
282
|
+
array: Any,
|
|
283
|
+
*,
|
|
284
|
+
axis: int,
|
|
285
|
+
norm: str,
|
|
286
|
+
inverse: bool,
|
|
287
|
+
plans: dict[Any, Any] | None,
|
|
288
|
+
) -> Any:
|
|
269
289
|
transform = self.xp.fft.ifft if inverse else self.xp.fft.fft
|
|
270
|
-
|
|
290
|
+
if plans is None:
|
|
291
|
+
return transform(array, axis=axis, norm=norm)
|
|
292
|
+
# CuPy transforms ``axis`` as the last axis of a contiguous copy, with a
|
|
293
|
+
# ``Plan1d`` keyed by length, type and batch; build that same plan.
|
|
294
|
+
length = int(array.shape[axis])
|
|
295
|
+
batch = int(array.size) // length
|
|
296
|
+
key = (length, array.dtype.str, batch)
|
|
297
|
+
plan = plans.get(key)
|
|
298
|
+
if plan is None:
|
|
299
|
+
from cupy.cuda import cufft
|
|
300
|
+
|
|
301
|
+
kind = cufft.CUFFT_C2C if array.dtype == np.complex64 else cufft.CUFFT_Z2Z
|
|
302
|
+
plan = plans[key] = cufft.Plan1d(length, kind, batch)
|
|
303
|
+
with plan:
|
|
304
|
+
return transform(array, axis=axis, norm=norm)
|
|
271
305
|
|
|
272
306
|
def pruned_fft2(
|
|
273
307
|
self,
|
|
@@ -280,6 +314,7 @@ class ArrayBackend:
|
|
|
280
314
|
inverse: bool = False,
|
|
281
315
|
axes: tuple[int, int] = (-2, -1),
|
|
282
316
|
workers: int = 1,
|
|
317
|
+
plans: dict[Any, Any] | None = None,
|
|
283
318
|
) -> Any:
|
|
284
319
|
"""Unitary square 2-D FFT that skips zero input and unwanted output lines.
|
|
285
320
|
|
|
@@ -307,7 +342,7 @@ class ArrayBackend:
|
|
|
307
342
|
Keep ``axes`` equal to the pass order of the transform being replaced,
|
|
308
343
|
because rounding depends on it: SciPy's ``fft2`` runs the listed axes in
|
|
309
344
|
order when ``overwrite_x=True`` but the last axis first when it
|
|
310
|
-
allocates its output.
|
|
345
|
+
allocates its output. ``plans`` is passed to :meth:`fft_axis`.
|
|
311
346
|
"""
|
|
312
347
|
if (output_start is None) != (output_length is None):
|
|
313
348
|
raise ValueError("output_start and output_length go together")
|
|
@@ -317,7 +352,12 @@ class ArrayBackend:
|
|
|
317
352
|
if input_start is not None:
|
|
318
353
|
spectrum = self._embed(spectrum, input_start, size=size, axis=first_axis)
|
|
319
354
|
spectrum = self.fft_axis(
|
|
320
|
-
spectrum,
|
|
355
|
+
spectrum,
|
|
356
|
+
axis=first_axis,
|
|
357
|
+
norm=first_norm,
|
|
358
|
+
inverse=inverse,
|
|
359
|
+
workers=workers,
|
|
360
|
+
plans=plans,
|
|
321
361
|
)
|
|
322
362
|
if output_start is not None:
|
|
323
363
|
assert output_length is not None
|
|
@@ -325,7 +365,12 @@ class ArrayBackend:
|
|
|
325
365
|
if input_start is not None:
|
|
326
366
|
spectrum = self._embed(spectrum, input_start, size=size, axis=second_axis)
|
|
327
367
|
spectrum = self.fft_axis(
|
|
328
|
-
spectrum,
|
|
368
|
+
spectrum,
|
|
369
|
+
axis=second_axis,
|
|
370
|
+
norm=second_norm,
|
|
371
|
+
inverse=inverse,
|
|
372
|
+
workers=workers,
|
|
373
|
+
plans=plans,
|
|
329
374
|
)
|
|
330
375
|
if output_start is not None:
|
|
331
376
|
assert output_length is not None
|
|
@@ -412,13 +457,12 @@ class ArrayBackend:
|
|
|
412
457
|
return gaussian_filter(array, sigma=sigma, mode="constant")
|
|
413
458
|
|
|
414
459
|
def next_fast_length(self, value: int) -> int:
|
|
415
|
-
"""Return
|
|
416
|
-
if self.is_cpu:
|
|
417
|
-
from scipy.fft import next_fast_len
|
|
418
|
-
else: # pragma: no cover - GPU optional
|
|
419
|
-
from cupyx.scipy.fft import next_fast_len
|
|
460
|
+
"""Return the shared FFT length for ``value`` (see :func:`next_fast_length`).
|
|
420
461
|
|
|
421
|
-
|
|
462
|
+
Deliberately independent of the backend: a grid size sets the physics
|
|
463
|
+
(padding, aliasing), so the CPU and the GPU must choose the same one.
|
|
464
|
+
"""
|
|
465
|
+
return next_fast_length(value)
|
|
422
466
|
|
|
423
467
|
def scalar(self, value: Any) -> float:
|
|
424
468
|
"""Extract one host scalar at an explicit metadata/geometry boundary."""
|
|
@@ -523,9 +567,31 @@ def centered_fft_intensity(
|
|
|
523
567
|
)
|
|
524
568
|
|
|
525
569
|
|
|
570
|
+
_FAST_FFT_FACTORS = (2, 3, 5, 7)
|
|
571
|
+
|
|
572
|
+
|
|
526
573
|
def next_fast_length(value: int) -> int:
|
|
527
|
-
"""Return
|
|
528
|
-
|
|
574
|
+
"""Return the smallest length ``>= value`` with no prime factor above 7.
|
|
575
|
+
|
|
576
|
+
One rule for every backend, so a configuration propagates on the same grid
|
|
577
|
+
on the CPU and the GPU. cuFFT has optimized kernels for radices 2, 3, 5
|
|
578
|
+
and 7, and SciPy's pocketfft handles them efficiently too. SciPy's own
|
|
579
|
+
``next_fast_len`` also admits 11, which CuPy's does not; before 2.1.1 the
|
|
580
|
+
pyramid therefore padded differently on the two devices whenever SciPy
|
|
581
|
+
picked a factor of 11 (for example 33, 66 or 99 against 35, 70 or 100).
|
|
582
|
+
This rule equals CuPy's ``next_fast_len``.
|
|
583
|
+
"""
|
|
584
|
+
if value < 1:
|
|
585
|
+
raise ValueError("FFT length must be positive")
|
|
586
|
+
length = int(value)
|
|
587
|
+
while True:
|
|
588
|
+
remainder = length
|
|
589
|
+
for factor in _FAST_FFT_FACTORS:
|
|
590
|
+
while remainder % factor == 0:
|
|
591
|
+
remainder //= factor
|
|
592
|
+
if remainder == 1:
|
|
593
|
+
return length
|
|
594
|
+
length += 1
|
|
529
595
|
|
|
530
596
|
|
|
531
597
|
__all__ = [
|
|
@@ -52,12 +52,15 @@ def metadata(
|
|
|
52
52
|
seed: int | None,
|
|
53
53
|
source_states: tuple[SourceState, ...] | None = None,
|
|
54
54
|
file_digests: dict[str, str] | None = None,
|
|
55
|
+
pyramid_fft_size: int | None = None,
|
|
55
56
|
) -> dict[str, Any]:
|
|
56
57
|
"""Build serializable metadata for an ideal or detector frame.
|
|
57
58
|
|
|
58
59
|
``opd_rms_m`` is the input OPD's pupil-weighted, piston-removed RMS and
|
|
59
60
|
``opd_rms_unweighted_m`` its unweighted whole-grid RMS with piston
|
|
60
61
|
included, both already reduced by the caller (aocore CONVENTIONS 4.1).
|
|
62
|
+
``pyramid_fft_size`` is the side of the pyramid's square propagation grid,
|
|
63
|
+
recorded as ``wfs_pyramid_fft_size_px`` because it sets the physics.
|
|
61
64
|
"""
|
|
62
65
|
states = iter_source_states(config) if source_states is None else source_states
|
|
63
66
|
result: dict[str, Any] = {
|
|
@@ -97,6 +100,8 @@ def metadata(
|
|
|
97
100
|
"lower_left",
|
|
98
101
|
"lower_right",
|
|
99
102
|
]
|
|
103
|
+
if pyramid_fft_size is not None:
|
|
104
|
+
result["wfs_pyramid_fft_size_px"] = int(pyramid_fft_size)
|
|
100
105
|
return result
|
|
101
106
|
|
|
102
107
|
|
|
@@ -50,7 +50,8 @@ class PyramidEngine(SensorEngine):
|
|
|
50
50
|
self.output_shape = (pixels + separation + 2 * margin, pixels + separation + 2 * margin)
|
|
51
51
|
# Oversample the propagation grid so the diffraction halo lands outside
|
|
52
52
|
# the detector crop instead of wrapping onto the pupil rims; cropped
|
|
53
|
-
# flux is reported as captured rate, never renormalized.
|
|
53
|
+
# flux is reported as captured rate, never renormalized. The length
|
|
54
|
+
# rule is shared by every backend, so CPU and GPU use the same grid.
|
|
54
55
|
self.nfft = self.backend.next_fast_length(
|
|
55
56
|
config.numerics.fft_oversampling * max(self.output_shape)
|
|
56
57
|
)
|
|
@@ -122,6 +123,9 @@ class PyramidEngine(SensorEngine):
|
|
|
122
123
|
self._graph = (
|
|
123
124
|
None if self.backend.is_cpu else _CudaGraphReplay(self.backend.xp, self._propagate)
|
|
124
125
|
)
|
|
126
|
+
# The graph replays cuFFT work, so its plans must outlive it rather than
|
|
127
|
+
# live in CuPy's global, evicting plan cache (see ``fft_axis``).
|
|
128
|
+
self._fft_plans: dict[Any, Any] = {}
|
|
125
129
|
|
|
126
130
|
def _make_pyramid_mask(self) -> NDArray[Any]:
|
|
127
131
|
"""Build four signed focal-plane ramps that separate the pupils."""
|
|
@@ -243,6 +247,7 @@ class PyramidEngine(SensorEngine):
|
|
|
243
247
|
size=self.nfft,
|
|
244
248
|
input_start=self._pupil_start,
|
|
245
249
|
workers=self.config.numerics.fft_workers,
|
|
250
|
+
plans=self._fft_plans,
|
|
246
251
|
)
|
|
247
252
|
focal *= self._unshifted_mask
|
|
248
253
|
exit_pupil = self.backend.pruned_fft2(
|
|
@@ -252,6 +257,7 @@ class PyramidEngine(SensorEngine):
|
|
|
252
257
|
output_length=self._base_output_shape[0],
|
|
253
258
|
inverse=True,
|
|
254
259
|
workers=self.config.numerics.fft_workers,
|
|
260
|
+
plans=self._fft_plans,
|
|
255
261
|
)
|
|
256
262
|
cropped = self.backend.abs(exit_pupil) ** 2
|
|
257
263
|
mosaic = self.backend.mean(cropped, axis=0)
|
|
@@ -492,3 +492,55 @@ def test_pyramid_cuda_graph_replay_equals_eager_and_owns_outputs(broadband: bool
|
|
|
492
492
|
assert float(second.captured_rate_per_s) == float(eager.captured_rate_per_s)
|
|
493
493
|
expected_cube = (3, *engine.output_shape) if broadband else (1, *engine.output_shape)
|
|
494
494
|
assert second.spectral_photon_rate.shape == expected_cube
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
@pytest.mark.gpu
|
|
498
|
+
def test_pyramid_geometry_with_former_factor_eleven_grid_matches_cpu() -> None:
|
|
499
|
+
# 24 + 9 pixels at 2x oversampling requests 66 samples: SciPy used to pick
|
|
500
|
+
# 66 (2 x 3 x 11) on the CPU and CuPy 70 on the GPU, which changed the
|
|
501
|
+
# rate by up to 46% for this modulated geometry. Both now use 70.
|
|
502
|
+
cupy = _cupy()
|
|
503
|
+
config = _config("pyramid_minimal.toml")
|
|
504
|
+
assert config.pyramid is not None
|
|
505
|
+
config = replace(
|
|
506
|
+
config,
|
|
507
|
+
numerics=replace(config.numerics, dtype="float64"),
|
|
508
|
+
pyramid=replace(
|
|
509
|
+
config.pyramid,
|
|
510
|
+
pixels_across_pupil=24,
|
|
511
|
+
pupil_separation_pixels=9,
|
|
512
|
+
modulation_radius_lambda_over_d=3.0,
|
|
513
|
+
modulation_samples=12,
|
|
514
|
+
),
|
|
515
|
+
)
|
|
516
|
+
cpu = WavefrontSensor(config)
|
|
517
|
+
gpu = WavefrontSensor(replace(config, numerics=replace(config.numerics, device="gpu")))
|
|
518
|
+
assert cpu.engine.nfft == gpu.engine.nfft == 70
|
|
519
|
+
opd = np.random.default_rng(7).normal(0.0, 5.0e-8, config.input.shape)
|
|
520
|
+
|
|
521
|
+
expected = cpu.photon_rate(opd)
|
|
522
|
+
actual = cupy.asnumpy(gpu.photon_rate(cupy.asarray(opd)))
|
|
523
|
+
np.testing.assert_allclose(actual, expected, rtol=1e-10, atol=1e-10 * expected.max())
|
|
524
|
+
cpu_size = cpu.expose(opd, seed=1).metadata["wfs_pyramid_fft_size_px"]
|
|
525
|
+
gpu_size = gpu.expose(cupy.asarray(opd), seed=1).metadata["wfs_pyramid_fft_size_px"]
|
|
526
|
+
assert cpu_size == gpu_size == 70
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
@pytest.mark.gpu
|
|
530
|
+
def test_pyramid_cuda_graph_survives_cupy_plan_cache_churn() -> None:
|
|
531
|
+
# CuPy's plan cache is global and evicts plans when other transforms run;
|
|
532
|
+
# the captured graph must not depend on it (2.1.0 replayed freed memory).
|
|
533
|
+
cupy = _cupy()
|
|
534
|
+
sensor = WavefrontSensor(_gpu_config("pyramid_minimal.toml"))
|
|
535
|
+
opd = cupy.asarray(np.random.default_rng(3).normal(0.0, 5.0e-8, sensor.config.input.shape))
|
|
536
|
+
first = sensor.photon_rate(opd).copy()
|
|
537
|
+
assert sensor.engine._graph._graph is not None
|
|
538
|
+
for length in range(20, 60):
|
|
539
|
+
cupy.fft.fft(cupy.ones((3, length), dtype=cupy.complex64), axis=-1)
|
|
540
|
+
cupy.fft.config.get_plan_cache().clear()
|
|
541
|
+
import gc
|
|
542
|
+
|
|
543
|
+
gc.collect()
|
|
544
|
+
again = sensor.photon_rate(opd)
|
|
545
|
+
cupy.cuda.Device().synchronize()
|
|
546
|
+
assert bool(cupy.array_equal(first, again))
|
|
@@ -7,11 +7,13 @@ import numpy as np
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
9
|
from makewfs.backend import (
|
|
10
|
+
ArrayBackend,
|
|
10
11
|
centered_fft2,
|
|
11
12
|
centered_fft_intensity,
|
|
12
13
|
centered_ifft2,
|
|
13
14
|
complex_dtype,
|
|
14
15
|
cpu_backend,
|
|
16
|
+
next_fast_length,
|
|
15
17
|
real_dtype,
|
|
16
18
|
)
|
|
17
19
|
from makewfs.config import SourceConfig, SpiderConfig, TelescopeConfig
|
|
@@ -372,3 +374,31 @@ def test_magnitude_rate_follows_pogson_scaling() -> None:
|
|
|
372
374
|
faint = replace(bright, magnitude=12.0)
|
|
373
375
|
ratio = source_rate_per_s(bright, telescope) / source_rate_per_s(faint, telescope)
|
|
374
376
|
assert np.isclose(ratio, 10.0**0.8, rtol=1e-12)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _seven_smooth(value: int) -> bool:
|
|
380
|
+
for factor in (2, 3, 5, 7):
|
|
381
|
+
while value % factor == 0:
|
|
382
|
+
value //= factor
|
|
383
|
+
return value == 1
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def test_fft_length_rule_is_minimal_seven_smooth_and_backend_independent() -> None:
|
|
387
|
+
gpu_named = ArrayBackend(np, name="cupy") # only the name differs from the CPU backend
|
|
388
|
+
for value in range(1, 3001):
|
|
389
|
+
length = next_fast_length(value)
|
|
390
|
+
assert length >= value
|
|
391
|
+
assert _seven_smooth(length)
|
|
392
|
+
assert not any(_seven_smooth(candidate) for candidate in range(value, length))
|
|
393
|
+
assert cpu_backend().next_fast_length(value) == length
|
|
394
|
+
assert gpu_named.next_fast_length(value) == length
|
|
395
|
+
# Lengths SciPy used to pick on the CPU while CuPy picked another.
|
|
396
|
+
assert [next_fast_length(value) for value in (33, 66, 99)] == [35, 70, 100]
|
|
397
|
+
with pytest.raises(ValueError, match="positive"):
|
|
398
|
+
next_fast_length(0)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def test_fft_length_rule_matches_cupy_next_fast_len() -> None:
|
|
402
|
+
cupy_fft = pytest.importorskip("cupyx.scipy.fft")
|
|
403
|
+
for value in range(1, 3001):
|
|
404
|
+
assert next_fast_length(value) == int(cupy_fft.next_fast_len(value))
|
|
@@ -271,3 +271,21 @@ def test_unshifted_pruned_propagation_matches_centered_reference(
|
|
|
271
271
|
np.testing.assert_allclose(
|
|
272
272
|
result.photon_rate, expected, rtol=tolerance, atol=tolerance * expected.max()
|
|
273
273
|
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@pytest.mark.parametrize(
|
|
277
|
+
("path", "size"),
|
|
278
|
+
[
|
|
279
|
+
(CONFIG, 288),
|
|
280
|
+
(CONFIG.parents[2] / "benchmarks" / "configs" / "pyramid_40_float32.toml", 108),
|
|
281
|
+
(CONFIG.parents[2] / "benchmarks" / "configs" / "pyramid_60_mod8_float32.toml", 160),
|
|
282
|
+
(CONFIG.parents[2] / "benchmarks" / "configs" / "pyramid_80_mod32_float64.toml", 216),
|
|
283
|
+
],
|
|
284
|
+
)
|
|
285
|
+
def test_pyramid_fft_size_is_recorded_and_unchanged_for_shipped_configs(
|
|
286
|
+
path: Path, size: int
|
|
287
|
+
) -> None:
|
|
288
|
+
sensor = WavefrontSensor.from_toml(path)
|
|
289
|
+
assert sensor.engine.nfft == size
|
|
290
|
+
frame = sensor.expose(np.zeros(sensor.config.input.shape), seed=1)
|
|
291
|
+
assert frame.metadata["wfs_pyramid_fft_size_px"] == size
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/camera_modes_empirical_floor_continuous.csv
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|