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.
Files changed (147) hide show
  1. {makewfs-2.1.0 → makewfs-2.1.1}/AGENTS.md +8 -5
  2. {makewfs-2.1.0 → makewfs-2.1.1}/CHANGELOG.md +33 -0
  3. {makewfs-2.1.0 → makewfs-2.1.1}/CITATION.cff +1 -1
  4. {makewfs-2.1.0 → makewfs-2.1.1}/PKG-INFO +1 -1
  5. {makewfs-2.1.0 → makewfs-2.1.1}/docs/configuration.md +1 -1
  6. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/__about__.py +1 -1
  7. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/api.py +1 -0
  8. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/backend.py +78 -12
  9. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/provenance.py +5 -0
  10. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/sensors/pyramid.py +7 -1
  11. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_gpu_backend.py +52 -0
  12. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_numerics.py +30 -0
  13. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_pyramid.py +18 -0
  14. {makewfs-2.1.0 → makewfs-2.1.1}/.gitignore +0 -0
  15. {makewfs-2.1.0 → makewfs-2.1.1}/CONTRIBUTING.md +0 -0
  16. {makewfs-2.1.0 → makewfs-2.1.1}/LICENSE +0 -0
  17. {makewfs-2.1.0 → makewfs-2.1.1}/README.md +0 -0
  18. {makewfs-2.1.0 → makewfs-2.1.1}/ROADMAP.md +0 -0
  19. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/__init__.py +0 -0
  20. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/benchmark_compiled_sh_executor.py +0 -0
  21. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/benchmark_sh_state_batching.py +0 -0
  22. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/check_regression.py +0 -0
  23. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/configs/pyramid_40_float32.toml +0 -0
  24. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/configs/pyramid_60_mod8_float32.toml +0 -0
  25. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/configs/pyramid_80_mod32_float64.toml +0 -0
  26. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/configs/shack_hartmann_20x20_float32.toml +0 -0
  27. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/configs/shack_hartmann_60x60_float64.toml +0 -0
  28. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/configs/shack_hartmann_quadrature_9sample.toml +0 -0
  29. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/device-results-neoverse-n1-rtx4060.json +0 -0
  30. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/device-results-neoverse-n1-rtxa400.json +0 -0
  31. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/device-results-neoverse-n1.md +0 -0
  32. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/device-results.json +0 -0
  33. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/device-results.md +0 -0
  34. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/haka-compiled-sh-executor-quadro-p620.json +0 -0
  35. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/haka-sh-state-batching-quadro-p620.json +0 -0
  36. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/profile_warm.py +0 -0
  37. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/reference-results.json +0 -0
  38. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/reference-table.md +0 -0
  39. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/render_device_table.py +0 -0
  40. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/render_table.py +0 -0
  41. {makewfs-2.1.0 → makewfs-2.1.1}/benchmarks/run.py +0 -0
  42. {makewfs-2.1.0 → makewfs-2.1.1}/docs/adr/0001-units-coordinates.md +0 -0
  43. {makewfs-2.1.0 → makewfs-2.1.1}/docs/adr/0002-flux-normalization.md +0 -0
  44. {makewfs-2.1.0 → makewfs-2.1.1}/docs/adr/0003-public-api.md +0 -0
  45. {makewfs-2.1.0 → makewfs-2.1.1}/docs/adr/0004-backend-boundary.md +0 -0
  46. {makewfs-2.1.0 → makewfs-2.1.1}/docs/adr/index.md +0 -0
  47. {makewfs-2.1.0 → makewfs-2.1.1}/docs/api.md +0 -0
  48. {makewfs-2.1.0 → makewfs-2.1.1}/docs/concepts.md +0 -0
  49. {makewfs-2.1.0 → makewfs-2.1.1}/docs/contributing.md +0 -0
  50. {makewfs-2.1.0 → makewfs-2.1.1}/docs/detectors.md +0 -0
  51. {makewfs-2.1.0 → makewfs-2.1.1}/docs/examples.md +0 -0
  52. {makewfs-2.1.0 → makewfs-2.1.1}/docs/gallery/makewfs-gallery.json +0 -0
  53. {makewfs-2.1.0 → makewfs-2.1.1}/docs/gallery/makewfs-gallery.svg +0 -0
  54. {makewfs-2.1.0 → makewfs-2.1.1}/docs/gallery.md +0 -0
  55. {makewfs-2.1.0 → makewfs-2.1.1}/docs/guide-stars.md +0 -0
  56. {makewfs-2.1.0 → makewfs-2.1.1}/docs/index.md +0 -0
  57. {makewfs-2.1.0 → makewfs-2.1.1}/docs/interop.md +0 -0
  58. {makewfs-2.1.0 → makewfs-2.1.1}/docs/performance.md +0 -0
  59. {makewfs-2.1.0 → makewfs-2.1.1}/docs/pyramid.md +0 -0
  60. {makewfs-2.1.0 → makewfs-2.1.1}/docs/quickstart.md +0 -0
  61. {makewfs-2.1.0 → makewfs-2.1.1}/docs/release.md +0 -0
  62. {makewfs-2.1.0 → makewfs-2.1.1}/docs/shack-hartmann.md +0 -0
  63. {makewfs-2.1.0 → makewfs-2.1.1}/docs/stability.md +0 -0
  64. {makewfs-2.1.0 → makewfs-2.1.1}/docs/troubleshooting.md +0 -0
  65. {makewfs-2.1.0 → makewfs-2.1.1}/docs/units-and-coordinates.md +0 -0
  66. {makewfs-2.1.0 → makewfs-2.1.1}/docs/validation.md +0 -0
  67. {makewfs-2.1.0 → makewfs-2.1.1}/examples/README.md +0 -0
  68. {makewfs-2.1.0 → makewfs-2.1.1}/examples/cds_readout.py +0 -0
  69. {makewfs-2.1.0 → makewfs-2.1.1}/examples/closed_loop_injection.py +0 -0
  70. {makewfs-2.1.0 → makewfs-2.1.1}/examples/compare_sensors.py +0 -0
  71. {makewfs-2.1.0 → makewfs-2.1.1}/examples/configs/angular_kernel.txt +0 -0
  72. {makewfs-2.1.0 → makewfs-2.1.1}/examples/configs/lgs_thin_beacon.toml +0 -0
  73. {makewfs-2.1.0 → makewfs-2.1.1}/examples/configs/precision_throughput.toml +0 -0
  74. {makewfs-2.1.0 → makewfs-2.1.1}/examples/configs/pyramid_minimal.toml +0 -0
  75. {makewfs-2.1.0 → makewfs-2.1.1}/examples/configs/qe_curve.txt +0 -0
  76. {makewfs-2.1.0 → makewfs-2.1.1}/examples/configs/shack_hartmann_extended_source.toml +0 -0
  77. {makewfs-2.1.0 → makewfs-2.1.1}/examples/configs/shack_hartmann_minimal.toml +0 -0
  78. {makewfs-2.1.0 → makewfs-2.1.1}/examples/configs/shack_hartmann_spectral_qe.toml +0 -0
  79. {makewfs-2.1.0 → makewfs-2.1.1}/examples/detector_choices.py +0 -0
  80. {makewfs-2.1.0 → makewfs-2.1.1}/examples/gallery.py +0 -0
  81. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/README.md +0 -0
  82. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/analyze_lut.py +0 -0
  83. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/benchmark.py +0 -0
  84. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/camera_modes.csv +0 -0
  85. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/camera_modes_empirical_floor_continuous.csv +0 -0
  86. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/compare_real.py +0 -0
  87. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/fit_secondary.py +0 -0
  88. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/haka_cpu_gpu_benchmark.json +0 -0
  89. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/haka_lut_snr.json +0 -0
  90. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/keck_haka.json +0 -0
  91. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/keck_haka.toml +0 -0
  92. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/mauna_kea_extinction.csv +0 -0
  93. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/mauna_kea_extinction_nir.csv +0 -0
  94. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/ocam_20260720/extract_ocam_images.py +0 -0
  95. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/ocam_20260720/make_ocam_video.py +0 -0
  96. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/real_vs_simulation.json +0 -0
  97. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/secondary_fit.json +0 -0
  98. {makewfs-2.1.0 → makewfs-2.1.1}/examples/keck_haka/simulate.py +0 -0
  99. {makewfs-2.1.0 → makewfs-2.1.1}/examples/lgs_elongation.py +0 -0
  100. {makewfs-2.1.0 → makewfs-2.1.1}/examples/lgs_thin_beacon.py +0 -0
  101. {makewfs-2.1.0 → makewfs-2.1.1}/examples/magnitude_series.py +0 -0
  102. {makewfs-2.1.0 → makewfs-2.1.1}/examples/makewfs_showcase.webp +0 -0
  103. {makewfs-2.1.0 → makewfs-2.1.1}/examples/moving_atmosphere.py +0 -0
  104. {makewfs-2.1.0 → makewfs-2.1.1}/examples/precision_throughput.py +0 -0
  105. {makewfs-2.1.0 → makewfs-2.1.1}/examples/pyramid_modulation.py +0 -0
  106. {makewfs-2.1.0 → makewfs-2.1.1}/examples/quickstart.py +0 -0
  107. {makewfs-2.1.0 → makewfs-2.1.1}/examples/realistic_broadband.py +0 -0
  108. {makewfs-2.1.0 → makewfs-2.1.1}/examples/sh_design_trade.py +0 -0
  109. {makewfs-2.1.0 → makewfs-2.1.1}/examples/showcase.py +0 -0
  110. {makewfs-2.1.0 → makewfs-2.1.1}/examples/spectral_qe.py +0 -0
  111. {makewfs-2.1.0 → makewfs-2.1.1}/pyproject.toml +0 -0
  112. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/__init__.py +0 -0
  113. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/cli.py +0 -0
  114. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/config.py +0 -0
  115. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/detector.py +0 -0
  116. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/pupil.py +0 -0
  117. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/py.typed +0 -0
  118. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/radiometry.py +0 -0
  119. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/sampling.py +0 -0
  120. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/sensors/__init__.py +0 -0
  121. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/sensors/_cuda_graph.py +0 -0
  122. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/sensors/_shack_hartmann_cuda.py +0 -0
  123. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/sensors/base.py +0 -0
  124. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/sensors/shack_hartmann.py +0 -0
  125. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/source.py +0 -0
  126. {makewfs-2.1.0 → makewfs-2.1.1}/src/makewfs/wavefront.py +0 -0
  127. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_backend_audit.py +0 -0
  128. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_benchmarks.py +0 -0
  129. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_cli.py +0 -0
  130. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_config.py +0 -0
  131. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_conformance.py +0 -0
  132. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_hcipy_validation.py +0 -0
  133. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_input_rms.py +0 -0
  134. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_interop.py +0 -0
  135. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_keck_haka_example.py +0 -0
  136. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_oopao_validation.py +0 -0
  137. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_optics_validation.py +0 -0
  138. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_provenance.py +0 -0
  139. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_public_api.py +0 -0
  140. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_radiometry.py +0 -0
  141. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_shack_hartmann.py +0 -0
  142. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_shack_hartmann_sampling.py +0 -0
  143. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_source.py +0 -0
  144. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_validation_report.py +0 -0
  145. {makewfs-2.1.0 → makewfs-2.1.1}/tests/test_wavefront.py +0 -0
  146. {makewfs-2.1.0 → makewfs-2.1.1}/validation/__init__.py +0 -0
  147. {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
- - `ArrayBackend.next_fast_length` uses SciPy on the CPU and CuPy on the GPU,
264
- which disagree when SciPy picks a factor of 11 (for example 33, 66, 99). The
265
- pyramid's FFT size, and therefore its result, can then differ between
266
- devices; parity tests use geometries where they agree.
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
@@ -5,7 +5,7 @@ type: software
5
5
  authors:
6
6
  - family-names: Taylor
7
7
  given-names: Jacob
8
- version: 2.1.0
8
+ version: 2.1.1
9
9
  date-released: 2026-10-07
10
10
  license: MIT
11
11
  repository-code: "https://github.com/jacotay7/makewfs"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: makewfs
3
- Version: 2.1.0
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`). |
@@ -1,3 +1,3 @@
1
1
  """Package version metadata."""
2
2
 
3
- __version__ = "2.1.0"
3
+ __version__ = "2.1.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
- return transform(array, axis=axis, norm=norm)
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, axis=first_axis, norm=first_norm, inverse=inverse, workers=workers
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, axis=second_axis, norm=second_norm, inverse=inverse, workers=workers
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 an FFT-friendly length using this backend's implementation."""
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
- return int(next_fast_len(value))
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 a convenient CPU FFT length."""
528
- return cpu_backend().next_fast_length(value)
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