makewfs 1.2.0__tar.gz → 2.0.0__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 (144) hide show
  1. {makewfs-1.2.0 → makewfs-2.0.0}/AGENTS.md +17 -2
  2. {makewfs-1.2.0 → makewfs-2.0.0}/CHANGELOG.md +59 -0
  3. {makewfs-1.2.0 → makewfs-2.0.0}/CITATION.cff +1 -1
  4. {makewfs-1.2.0 → makewfs-2.0.0}/PKG-INFO +2 -2
  5. makewfs-2.0.0/docs/concepts.md +67 -0
  6. {makewfs-1.2.0 → makewfs-2.0.0}/docs/stability.md +17 -0
  7. {makewfs-1.2.0 → makewfs-2.0.0}/examples/closed_loop_injection.py +8 -5
  8. {makewfs-1.2.0 → makewfs-2.0.0}/examples/showcase.py +3 -2
  9. {makewfs-1.2.0 → makewfs-2.0.0}/pyproject.toml +1 -1
  10. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/__about__.py +1 -1
  11. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/api.py +59 -11
  12. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/backend.py +13 -0
  13. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/provenance.py +10 -16
  14. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/sampling.py +65 -20
  15. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/sensors/base.py +6 -0
  16. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/sensors/pyramid.py +1 -0
  17. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/sensors/shack_hartmann.py +2 -2
  18. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/wavefront.py +85 -6
  19. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_gpu_backend.py +37 -0
  20. makewfs-2.0.0/tests/test_input_rms.py +213 -0
  21. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_provenance.py +0 -30
  22. makewfs-1.2.0/docs/concepts.md +0 -38
  23. {makewfs-1.2.0 → makewfs-2.0.0}/.gitignore +0 -0
  24. {makewfs-1.2.0 → makewfs-2.0.0}/CONTRIBUTING.md +0 -0
  25. {makewfs-1.2.0 → makewfs-2.0.0}/LICENSE +0 -0
  26. {makewfs-1.2.0 → makewfs-2.0.0}/README.md +0 -0
  27. {makewfs-1.2.0 → makewfs-2.0.0}/ROADMAP.md +0 -0
  28. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/__init__.py +0 -0
  29. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/benchmark_compiled_sh_executor.py +0 -0
  30. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/benchmark_sh_state_batching.py +0 -0
  31. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/check_regression.py +0 -0
  32. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/configs/pyramid_40_float32.toml +0 -0
  33. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/configs/pyramid_60_mod8_float32.toml +0 -0
  34. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/configs/pyramid_80_mod32_float64.toml +0 -0
  35. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/configs/shack_hartmann_20x20_float32.toml +0 -0
  36. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/configs/shack_hartmann_60x60_float64.toml +0 -0
  37. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/configs/shack_hartmann_quadrature_9sample.toml +0 -0
  38. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/device-results.json +0 -0
  39. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/device-results.md +0 -0
  40. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/haka-compiled-sh-executor-quadro-p620.json +0 -0
  41. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/haka-sh-state-batching-quadro-p620.json +0 -0
  42. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/profile_warm.py +0 -0
  43. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/reference-results.json +0 -0
  44. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/reference-table.md +0 -0
  45. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/render_device_table.py +0 -0
  46. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/render_table.py +0 -0
  47. {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/run.py +0 -0
  48. {makewfs-1.2.0 → makewfs-2.0.0}/docs/adr/0001-units-coordinates.md +0 -0
  49. {makewfs-1.2.0 → makewfs-2.0.0}/docs/adr/0002-flux-normalization.md +0 -0
  50. {makewfs-1.2.0 → makewfs-2.0.0}/docs/adr/0003-public-api.md +0 -0
  51. {makewfs-1.2.0 → makewfs-2.0.0}/docs/adr/0004-backend-boundary.md +0 -0
  52. {makewfs-1.2.0 → makewfs-2.0.0}/docs/adr/index.md +0 -0
  53. {makewfs-1.2.0 → makewfs-2.0.0}/docs/api.md +0 -0
  54. {makewfs-1.2.0 → makewfs-2.0.0}/docs/configuration.md +0 -0
  55. {makewfs-1.2.0 → makewfs-2.0.0}/docs/contributing.md +0 -0
  56. {makewfs-1.2.0 → makewfs-2.0.0}/docs/detectors.md +0 -0
  57. {makewfs-1.2.0 → makewfs-2.0.0}/docs/examples.md +0 -0
  58. {makewfs-1.2.0 → makewfs-2.0.0}/docs/gallery/makewfs-gallery.json +0 -0
  59. {makewfs-1.2.0 → makewfs-2.0.0}/docs/gallery/makewfs-gallery.svg +0 -0
  60. {makewfs-1.2.0 → makewfs-2.0.0}/docs/gallery.md +0 -0
  61. {makewfs-1.2.0 → makewfs-2.0.0}/docs/guide-stars.md +0 -0
  62. {makewfs-1.2.0 → makewfs-2.0.0}/docs/index.md +0 -0
  63. {makewfs-1.2.0 → makewfs-2.0.0}/docs/interop.md +0 -0
  64. {makewfs-1.2.0 → makewfs-2.0.0}/docs/performance.md +0 -0
  65. {makewfs-1.2.0 → makewfs-2.0.0}/docs/pyramid.md +0 -0
  66. {makewfs-1.2.0 → makewfs-2.0.0}/docs/quickstart.md +0 -0
  67. {makewfs-1.2.0 → makewfs-2.0.0}/docs/release.md +0 -0
  68. {makewfs-1.2.0 → makewfs-2.0.0}/docs/shack-hartmann.md +0 -0
  69. {makewfs-1.2.0 → makewfs-2.0.0}/docs/troubleshooting.md +0 -0
  70. {makewfs-1.2.0 → makewfs-2.0.0}/docs/units-and-coordinates.md +0 -0
  71. {makewfs-1.2.0 → makewfs-2.0.0}/docs/validation.md +0 -0
  72. {makewfs-1.2.0 → makewfs-2.0.0}/examples/README.md +0 -0
  73. {makewfs-1.2.0 → makewfs-2.0.0}/examples/cds_readout.py +0 -0
  74. {makewfs-1.2.0 → makewfs-2.0.0}/examples/compare_sensors.py +0 -0
  75. {makewfs-1.2.0 → makewfs-2.0.0}/examples/configs/angular_kernel.txt +0 -0
  76. {makewfs-1.2.0 → makewfs-2.0.0}/examples/configs/lgs_thin_beacon.toml +0 -0
  77. {makewfs-1.2.0 → makewfs-2.0.0}/examples/configs/precision_throughput.toml +0 -0
  78. {makewfs-1.2.0 → makewfs-2.0.0}/examples/configs/pyramid_minimal.toml +0 -0
  79. {makewfs-1.2.0 → makewfs-2.0.0}/examples/configs/qe_curve.txt +0 -0
  80. {makewfs-1.2.0 → makewfs-2.0.0}/examples/configs/shack_hartmann_extended_source.toml +0 -0
  81. {makewfs-1.2.0 → makewfs-2.0.0}/examples/configs/shack_hartmann_minimal.toml +0 -0
  82. {makewfs-1.2.0 → makewfs-2.0.0}/examples/configs/shack_hartmann_spectral_qe.toml +0 -0
  83. {makewfs-1.2.0 → makewfs-2.0.0}/examples/detector_choices.py +0 -0
  84. {makewfs-1.2.0 → makewfs-2.0.0}/examples/gallery.py +0 -0
  85. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/README.md +0 -0
  86. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/analyze_lut.py +0 -0
  87. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/benchmark.py +0 -0
  88. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/camera_modes.csv +0 -0
  89. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/camera_modes_empirical_floor_continuous.csv +0 -0
  90. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/compare_real.py +0 -0
  91. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/fit_secondary.py +0 -0
  92. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/haka_cpu_gpu_benchmark.json +0 -0
  93. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/haka_lut_snr.json +0 -0
  94. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/keck_haka.json +0 -0
  95. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/keck_haka.toml +0 -0
  96. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/mauna_kea_extinction.csv +0 -0
  97. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/mauna_kea_extinction_nir.csv +0 -0
  98. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/ocam_20260720/extract_ocam_images.py +0 -0
  99. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/ocam_20260720/make_ocam_video.py +0 -0
  100. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/real_vs_simulation.json +0 -0
  101. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/secondary_fit.json +0 -0
  102. {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/simulate.py +0 -0
  103. {makewfs-1.2.0 → makewfs-2.0.0}/examples/lgs_elongation.py +0 -0
  104. {makewfs-1.2.0 → makewfs-2.0.0}/examples/lgs_thin_beacon.py +0 -0
  105. {makewfs-1.2.0 → makewfs-2.0.0}/examples/magnitude_series.py +0 -0
  106. {makewfs-1.2.0 → makewfs-2.0.0}/examples/makewfs_showcase.webp +0 -0
  107. {makewfs-1.2.0 → makewfs-2.0.0}/examples/moving_atmosphere.py +0 -0
  108. {makewfs-1.2.0 → makewfs-2.0.0}/examples/precision_throughput.py +0 -0
  109. {makewfs-1.2.0 → makewfs-2.0.0}/examples/pyramid_modulation.py +0 -0
  110. {makewfs-1.2.0 → makewfs-2.0.0}/examples/quickstart.py +0 -0
  111. {makewfs-1.2.0 → makewfs-2.0.0}/examples/realistic_broadband.py +0 -0
  112. {makewfs-1.2.0 → makewfs-2.0.0}/examples/sh_design_trade.py +0 -0
  113. {makewfs-1.2.0 → makewfs-2.0.0}/examples/spectral_qe.py +0 -0
  114. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/__init__.py +0 -0
  115. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/cli.py +0 -0
  116. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/config.py +0 -0
  117. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/detector.py +0 -0
  118. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/pupil.py +0 -0
  119. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/py.typed +0 -0
  120. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/radiometry.py +0 -0
  121. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/sensors/__init__.py +0 -0
  122. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/sensors/_shack_hartmann_cuda.py +0 -0
  123. {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/source.py +0 -0
  124. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_backend_audit.py +0 -0
  125. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_benchmarks.py +0 -0
  126. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_cli.py +0 -0
  127. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_config.py +0 -0
  128. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_conformance.py +0 -0
  129. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_hcipy_validation.py +0 -0
  130. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_interop.py +0 -0
  131. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_keck_haka_example.py +0 -0
  132. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_numerics.py +0 -0
  133. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_oopao_validation.py +0 -0
  134. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_optics_validation.py +0 -0
  135. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_public_api.py +0 -0
  136. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_pyramid.py +0 -0
  137. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_radiometry.py +0 -0
  138. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_shack_hartmann.py +0 -0
  139. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_shack_hartmann_sampling.py +0 -0
  140. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_source.py +0 -0
  141. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_validation_report.py +0 -0
  142. {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_wavefront.py +0 -0
  143. {makewfs-1.2.0 → makewfs-2.0.0}/validation/__init__.py +0 -0
  144. {makewfs-1.2.0 → makewfs-2.0.0}/validation/run.py +0 -0
@@ -87,6 +87,16 @@ write a failing integration test/design note, use the conditional gates in
87
87
  - Intensities, not fields, are summed over incoherent wavelengths, modulation
88
88
  points, finite-source samples, and sodium slices.
89
89
  - Cropping reports lost flux; it does not renormalize it away.
90
+ - Wavefront metrics follow aocore CONVENTIONS 4.1. Frame metadata
91
+ `wfs_input_opd_rms_m` is the pupil-intensity-weighted, piston-removed RMS of
92
+ the input OPD on the input grid (weights from `WavefrontSensor._rms_weights`:
93
+ the analytic pupil evaluated on `input.shape`, or a custom mask area-averaged
94
+ from the engine's `configured_pupil`); `wfs_input_opd_rms_unweighted_m` is the
95
+ whole-grid quadratic mean with piston kept. Both are reduced on the device and
96
+ cross in the one `backend.scalars` batch, so do not swap in `aocore.rms` or
97
+ `aocore.rms_unweighted` there: they return host floats and would add a
98
+ synchronization each. Any other RMS-like key must say its variant in its
99
+ name (`_unweighted`, `_tiptilt_removed`).
90
100
  - The intended top-level API is `load_config`, `WavefrontSensor`, and `simulate`.
91
101
  Keep other implementation objects out of `makewfs.__init__` unless an API review
92
102
  explicitly accepts them.
@@ -153,8 +163,13 @@ Follow the target layout in `ROADMAP.md`:
153
163
  rotation and rectangular grids on the selected backend, none of which
154
164
  `aocore.Pupil` models. `backend.ArrayBackend` takes a dtype per array, explicit
155
165
  FFT workers and `ndimage` helpers, which `aocore.Backend` does not, and its
156
- centred FFTs keep the `fftshift` convention. `sampling.block_sum` wraps
157
- `aocore.block_sum` but keeps its own factor-two fast path.
166
+ centred FFTs keep the `fftshift` convention; `ArrayBackend.centered_coordinates`
167
+ builds aocore's coordinates directly on the device. `sampling.block_sum` is a
168
+ thin wrapper over `aocore.block_sum` (no local fast path since aocore 0.1.3,
169
+ which measured at least as fast at the SH call sites).
170
+ `sampling.area_rebin` is exact-overlap area averaging between grids of the
171
+ same extent and any shape ratio, which `aocore.block_sum` (integer factors
172
+ only) does not cover.
158
173
  - `sensors/` contains deterministic ideal optical engines and no camera noise.
159
174
  `_shack_hartmann_cuda.py` is a private first-use-JIT execution plan for exact
160
175
  compatible CUDA geometries; `shack_hartmann.py` remains the readable physics
@@ -4,6 +4,65 @@ All notable changes to `makewfs` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.0.0] - 2026-10-07
8
+
9
+ ### Breaking
10
+
11
+ - **`wfs_input_opd_rms_m` is now the pupil-weighted, piston-removed RMS.**
12
+ It follows aocore CONVENTIONS.md 4.1: the RMS of the input OPD in metres,
13
+ weighted by the intensity of the pupil the optics use and with the
14
+ intensity-weighted mean (piston) removed. Before 2.0 it was the quadratic
15
+ mean over the whole input grid, with every pixel counted equally, pixels
16
+ outside the pupil included and piston kept. For the same wavefront the new
17
+ value is usually smaller; a pure piston now reports 0. The weights are the
18
+ configured pupil's intensity (the amplitude the engines use, squared) on the
19
+ input grid: an analytic pupil is evaluated on `input.shape` with the
20
+ configured `numerics.pupil_supersampling`, the same map
21
+ `WavefrontSensor.pupil_illumination()` returns; a custom mask, which exists
22
+ only on the engine's pupil grid, is area-averaged onto the input grid.
23
+ Phase input is converted to OPD first, and `expose_integrated` reports the
24
+ RMS of the mean OPD, as before. The value is still reduced on the device and
25
+ crosses to the host in the same single batched transfer as the captured
26
+ photon rate. A pupil with no transmission on the input grid is now rejected
27
+ when the sensor is built.
28
+ - **`makewfs.provenance.metadata`**, an internal helper, takes the two
29
+ reduced RMS values as required `opd_rms_m` and `opd_rms_unweighted_m`
30
+ arguments and no longer accepts `opd_m`.
31
+
32
+ See "Migrating to 2.0" in the stability guide.
33
+
34
+ ### Added
35
+
36
+ - **`wfs_input_opd_rms_unweighted_m`** frame metadata keeps the 1.x quantity,
37
+ the unweighted RMS over the whole input grid with piston included, so no
38
+ information is lost. Read it wherever the old number is still wanted.
39
+ - Both sensor engines expose `configured_pupil`, the pupil amplitude on their
40
+ own pupil grid, and `makewfs.sampling.area_rebin` area-averages a map onto
41
+ another grid of the same extent, for any shape ratio.
42
+
43
+ ### Changed
44
+
45
+ - The `closed_loop_injection.py` and `showcase.py` examples take the residual
46
+ RMS they plot from each frame's `wfs_input_opd_rms_m` instead of a
47
+ whole-grid `np.std`, so the numbers they print are pupil-weighted. The
48
+ checked-in `makewfs_showcase.webp` was not regenerated.
49
+ - **Requires `aocore>=0.1.3,<0.2`.** `makewfs.sampling.block_sum` drops its
50
+ own factor-two shortcut and delegates every factor to `aocore.block_sum`,
51
+ whose strided CPU adds and single CuPy kernel measured at least as fast on
52
+ the spot stacks the Shack-Hartmann actually bins (for example
53
+ `(400, 16, 16)` float32: CPU 112 vs 173 us, Quadro P620 65 vs 94 us;
54
+ `(3600, 12, 12)` float64: CPU 0.90 vs 1.27 ms, GPU 98 vs 173 us). The
55
+ additions happen in a different order, so Shack-Hartmann float32 images
56
+ differ from 1.2.0 by float32 rounding (at most about 1e-7 relative) and
57
+ float64 ones by about 2e-16; pyramid images are bit-for-bit unchanged.
58
+ Pixel-centre coordinates for the input, pupil and DFT detector grids are
59
+ now built on the selected device by `aocore.centered_coordinates`
60
+ (`ArrayBackend.centered_coordinates`), with identical values.
61
+ - The input-RMS tests check the unweighted key against
62
+ `aocore.rms_unweighted`. The per-frame path keeps its own device reduction,
63
+ because the aocore functions return host floats and would each add a
64
+ synchronization.
65
+
7
66
  ## [1.2.0] - 2026-10-07
8
67
 
9
68
  - **Fixed: phase input was reported as metres.** With
@@ -5,7 +5,7 @@ type: software
5
5
  authors:
6
6
  - family-names: Taylor
7
7
  given-names: Jacob
8
- version: 1.2.0
8
+ version: 2.0.0
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: 1.2.0
3
+ Version: 2.0.0
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/
@@ -22,7 +22,7 @@ Classifier: Programming Language :: Python :: 3.13
22
22
  Classifier: Topic :: Scientific/Engineering :: Astronomy
23
23
  Classifier: Typing :: Typed
24
24
  Requires-Python: >=3.10
25
- Requires-Dist: aocore<0.2,>=0.1.2
25
+ Requires-Dist: aocore<0.2,>=0.1.3
26
26
  Requires-Dist: getframes>=2.2.0
27
27
  Requires-Dist: numpy>=1.23
28
28
  Requires-Dist: scipy>=1.10
@@ -0,0 +1,67 @@
1
+ # Concepts and conventions
2
+
3
+ ## Wavefront units
4
+
5
+ OPD is the canonical internal quantity and is measured in metres. A phase input
6
+ must declare `quantity = "phase"`, `unit = "rad"`, and
7
+ `reference_wavelength_m`; it is converted to OPD before propagation. Units are
8
+ never inferred from array magnitude.
9
+
10
+ The input array uses `(y, x)` order. Its physical extent and shape come from the
11
+ `[input]` table. The pupil amplitude is configured separately, so a per-frame
12
+ input contains only the phase/OPD map.
13
+
14
+ ## Image domains
15
+
16
+ The optical engines are deterministic and return an incident photon-rate map in
17
+ photons/s/native detector pixel. `getframes.Camera.expose()` performs the
18
+ scalar photon-to-electron-to-ADU chain, while the optional
19
+ `Camera.expose_spectral()` path applies wavelength-dependent QE exactly once
20
+ and preserves the incident cube in detector truth. Optical intensities are
21
+ summed over incoherent wavelength, source, modulation, and sodium-range samples;
22
+ complex fields are never added across incoherent states.
23
+
24
+ ## Piston and sampling
25
+
26
+ A constant piston changes only the global complex phase and therefore cannot
27
+ change intensity. The numerical implementation removes the weighted global
28
+ piston before evaluating the complex exponential to keep this invariant stable
29
+ in single precision.
30
+
31
+ When an input grid does not divide into the configured lenslets, OPD is
32
+ resampled on physical coordinates. Wrapped phase is never interpolated.
33
+
34
+ ## Input wavefront RMS in frame metadata
35
+
36
+ Every frame records two RMS values of the input wavefront, both in OPD metres
37
+ (phase input is converted at `input.reference_wavelength_m` first). Both
38
+ describe the input array as given, without `input.static_opd_path`; for
39
+ `expose_integrated` they describe the mean of the temporal samples.
40
+
41
+ | Key | Definition |
42
+ | --- | --- |
43
+ | `wfs_input_opd_rms_m` | Pupil-weighted, piston-removed RMS, the `rms` of aocore CONVENTIONS 4.1: `sqrt(sum a^2 (opd - <opd>_a)^2 / sum a^2)`, where `<opd>_a` is the intensity-weighted mean. |
44
+ | `wfs_input_opd_rms_unweighted_m` | `sqrt(mean(opd^2))` over every pixel of the input grid, with piston included and pixels outside the pupil counted. This was `wfs_input_opd_rms_m` before 2.0. |
45
+
46
+ The weight `a^2` is the intensity of the pupil the optics use, on the input
47
+ grid. The engines use the configured pupil as a field amplitude, so the
48
+ intensity is its square. An analytic pupil (diameter, obscuration, spiders,
49
+ segment gaps, rotation) is evaluated on `input.shape` with the same
50
+ `numerics.pupil_supersampling`; this is the map
51
+ `WavefrontSensor.pupil_illumination()` returns, computed in float64. A
52
+ `telescope.custom_mask_path` mask exists only on the engine's own pupil grid
53
+ (`lenslets_across_pupil` times the pupil samples per lenslet for a
54
+ Shack-Hartmann, `pixels_across_pupil` for a pyramid), so its intensity is
55
+ area-averaged onto the input grid: each input
56
+ pixel takes the mean over the mask cells it overlaps, which is exact when the
57
+ two shapes match. A piston, or OPD outside the pupil, therefore changes only
58
+ the unweighted value.
59
+
60
+ Both values are reduced on the selected device and cross to the host together
61
+ with the captured photon rate in one transfer.
62
+
63
+ ## Closed-loop use
64
+
65
+ The package intentionally stops at the detector image. A downstream controller
66
+ may turn that image into slopes, a reconstruction, and a deformable-mirror
67
+ command, then feed the resulting residual OPD back into `expose()`.
@@ -25,3 +25,20 @@ The physical per-frame contract is a wavefront array plus configuration (and an
25
25
  optional detector seed). `out` controls storage lifetime only and cannot change
26
26
  the simulated result. Atmosphere, reconstruction, controllers, and detector
27
27
  physics remain outside the package boundary.
28
+
29
+ ## Migrating to 2.0
30
+
31
+ makewfs 2.0 changes the meaning of one frame-metadata key; the API and the
32
+ configuration schema are unchanged.
33
+
34
+ - `wfs_input_opd_rms_m` is now the pupil-weighted, piston-removed RMS of
35
+ aocore CONVENTIONS 4.1 (see
36
+ [Concepts](concepts.md#input-wavefront-rms-in-frame-metadata)). For the same
37
+ wavefront it is usually smaller than before: it no longer includes piston or
38
+ any OPD outside the pupil.
39
+ - The old quantity, the unweighted RMS over the whole input grid with piston
40
+ included, is still recorded as `wfs_input_opd_rms_unweighted_m`. Code that
41
+ needs the 1.x number reads that key instead.
42
+ - `makewfs.provenance.metadata`, an internal helper, now takes both RMS values
43
+ as required `opd_rms_m` and `opd_rms_unweighted_m` arguments and no longer
44
+ accepts `opd_m`.
@@ -5,7 +5,9 @@ external AO loop that measures the frame, estimates a correction, and hands the
5
5
  next *residual* wavefront back to ``WavefrontSensor.expose``. The residual is a
6
6
  low-order aberration (defocus + astigmatism) so the Shack-Hartmann spots shift
7
7
  differently across the pupil and the relaxation toward the flat reference is
8
- visible, and a convergence panel tracks the residual RMS.
8
+ visible, and a convergence panel tracks the residual RMS that each frame
9
+ records in its ``wfs_input_opd_rms_m`` metadata: pupil-weighted with piston
10
+ removed, as aocore CONVENTIONS 4.1 defines ``rms``.
9
11
  """
10
12
 
11
13
  from __future__ import annotations
@@ -19,7 +21,7 @@ import makewfs
19
21
 
20
22
 
21
23
  def _low_order_residual(shape: tuple[int, int], amplitude_m: float) -> np.ndarray:
22
- """Return a defocus + astigmatism OPD normalized to ``amplitude_m`` RMS."""
24
+ """Return a defocus + astigmatism OPD with ``amplitude_m`` RMS over the grid."""
23
25
  yy, xx = np.mgrid[: shape[0], : shape[1]]
24
26
  x = (xx - (shape[1] - 1) / 2) / (shape[1] / 2)
25
27
  y = (yy - (shape[0] - 1) / 2) / (shape[0] / 2)
@@ -56,8 +58,9 @@ def main() -> None:
56
58
  residual_rms_nm: list[float] = []
57
59
  for step in range(args.steps):
58
60
  residual = _low_order_residual(shape, amplitude_m=200e-9) * (0.6**step)
59
- residual_rms_nm.append(float(np.std(residual) * 1e9))
60
- frames.append(np.asarray(sensor.expose(residual, seed=0)).astype(np.float64))
61
+ frame = sensor.expose(residual, seed=0)
62
+ residual_rms_nm.append(frame.metadata["wfs_input_opd_rms_m"] * 1e9)
63
+ frames.append(np.asarray(frame).astype(np.float64))
61
64
 
62
65
  columns = min(4, args.steps)
63
66
  shown = sorted({0, 1, args.steps // 2, args.steps - 1})[:columns]
@@ -91,7 +94,7 @@ def main() -> None:
91
94
  convergence.plot(range(args.steps), residual_rms_nm, "o-")
92
95
  convergence.set_title("external loop convergence")
93
96
  convergence.set_xlabel("loop step")
94
- convergence.set_ylabel("residual OPD RMS (nm)")
97
+ convergence.set_ylabel("residual OPD RMS over the pupil (nm)")
95
98
  convergence.grid(True, alpha=0.3)
96
99
 
97
100
  frame_rms = [float(np.sqrt(np.mean(diff**2))) for diff in diffs]
@@ -263,7 +263,6 @@ def benchmark_panel(panel: Panel, sync: Callable[[], None]) -> float:
263
263
  def collect_frames(panel: Panel) -> np.ndarray:
264
264
  """``(N_FRAMES, *output_shape)`` detector ADU on the host, plus the OPD rms."""
265
265
  import getframes
266
- import pyturb
267
266
 
268
267
  sensor = panel.build_sensor()
269
268
  atmosphere = panel.build_atmosphere()
@@ -271,8 +270,10 @@ def collect_frames(panel: Panel) -> np.ndarray:
271
270
  rms = []
272
271
  for index in range(N_FRAMES):
273
272
  residual = panel.residual(atmosphere, index)
274
- rms.append(float(np.std(pyturb.to_numpy(residual))) * 1e9)
275
273
  frame = sensor.expose(residual, seed=index)
274
+ # Pupil-weighted, piston-removed (aocore CONVENTIONS 4.1), reduced on
275
+ # the sensor's device.
276
+ rms.append(frame.metadata["wfs_input_opd_rms_m"] * 1e9)
276
277
  out[index] = getframes.to_numpy(frame.data).astype(np.float32)
277
278
  panel.badge_extra = f"residual {np.mean(rms):.0f} nm rms"
278
279
  return np.clip(out - panel.bias_adu, 0.0, None)
@@ -25,7 +25,7 @@ classifiers = [
25
25
  "Typing :: Typed",
26
26
  ]
27
27
  dependencies = [
28
- "aocore>=0.1.2,<0.2",
28
+ "aocore>=0.1.3,<0.2",
29
29
  "numpy>=1.23",
30
30
  "scipy>=1.10",
31
31
  "getframes>=2.2.0",
@@ -1,3 +1,3 @@
1
1
  """Package version metadata."""
2
2
 
3
- __version__ = "1.2.0"
3
+ __version__ = "2.0.0"
@@ -16,10 +16,11 @@ from .config import WFSConfig, load_config
16
16
  from .detector import DetectorAdapter
17
17
  from .provenance import metadata as build_metadata
18
18
  from .pupil import make_pupil
19
+ from .sampling import area_rebin
19
20
  from .sensors.base import OpticalResult, SensorEngine
20
21
  from .sensors.pyramid import PyramidEngine
21
22
  from .sensors.shack_hartmann import ShackHartmannEngine
22
- from .wavefront import iter_phase_samples
23
+ from .wavefront import grid_rms, iter_phase_samples, pupil_rms, pupil_weights
23
24
 
24
25
 
25
26
  class WavefrontSensor:
@@ -53,10 +54,12 @@ class WavefrontSensor:
53
54
  launched_rate=0.0,
54
55
  captured_rate=0.0,
55
56
  opd_rms_m=0.0,
57
+ opd_rms_unweighted_m=0.0,
56
58
  seed=None,
57
59
  source_states=self.engine.source_states,
58
60
  file_digests=self.engine.file_digests,
59
61
  )
62
+ self._rms_weights = pupil_weights(self._input_pupil_intensity(), backend=self.backend)
60
63
 
61
64
  @classmethod
62
65
  def from_toml(cls, path: str | Path) -> WavefrontSensor:
@@ -66,9 +69,42 @@ class WavefrontSensor:
66
69
  def _render(self, wavefront: ArrayLike) -> OpticalResult:
67
70
  return self.engine.render(cast(NDArray[np.float64], wavefront))
68
71
 
69
- def _opd_rms(self, opd: Any) -> Any:
70
- """Reduce OPD RMS on-device before the batched metadata crossing."""
71
- return self.backend.sqrt(self.backend.mean(opd**2))
72
+ def _input_pupil_intensity(self) -> Any:
73
+ """Return the pupil intensity the optics use, on the input grid.
74
+
75
+ An analytic pupil is evaluated on ``input.shape`` from the same
76
+ telescope model and ``numerics.pupil_supersampling`` the engine uses on
77
+ its own grid, as :meth:`pupil_illumination` does; both grids span
78
+ ``input.grid_extent_m``. A custom mask exists only on the engine's
79
+ configured pupil grid, so its intensity is area-averaged from there
80
+ onto the input grid, which is exact when the two shapes match. The
81
+ engines use the pupil as a field amplitude, so the intensity is its
82
+ square.
83
+ """
84
+ if self.config.telescope.custom_mask_path is None:
85
+ amplitude = make_pupil(
86
+ self.config.telescope,
87
+ self.config.input.shape,
88
+ self.config.input.grid_extent_m,
89
+ supersampling=self.config.numerics.pupil_supersampling,
90
+ backend=self.backend,
91
+ dtype=np.float64,
92
+ )
93
+ return amplitude * amplitude
94
+ amplitude = self.backend.asarray(self.engine.configured_pupil, dtype=np.float64)
95
+ return area_rebin(amplitude * amplitude, self.config.input.shape, backend=self.backend)
96
+
97
+ def _opd_rms(self, opd: Any) -> tuple[Any, Any]:
98
+ """Reduce both input-OPD RMS values on the device.
99
+
100
+ Returns the pupil-weighted, piston-removed RMS and the unweighted
101
+ whole-grid RMS as device scalars, for the one batched metadata
102
+ crossing in ``backend.scalars``.
103
+ """
104
+ return (
105
+ pupil_rms(opd, self._rms_weights, backend=self.backend),
106
+ grid_rms(opd, backend=self.backend),
107
+ )
72
108
 
73
109
  def _frame_metadata(
74
110
  self,
@@ -76,6 +112,7 @@ class WavefrontSensor:
76
112
  launched_rate: float,
77
113
  captured_rate: float,
78
114
  opd_rms_m: float,
115
+ opd_rms_unweighted_m: float,
79
116
  seed: int | None,
80
117
  ) -> dict[str, Any]:
81
118
  """Copy cached static provenance and fill the per-frame values."""
@@ -85,6 +122,7 @@ class WavefrontSensor:
85
122
  "wfs_launched_photons_s": float(launched_rate),
86
123
  "wfs_captured_photons_s": float(captured_rate),
87
124
  "wfs_input_opd_rms_m": float(opd_rms_m),
125
+ "wfs_input_opd_rms_unweighted_m": float(opd_rms_unweighted_m),
88
126
  "wfs_seed": seed if seed is not None else "internal",
89
127
  }
90
128
  )
@@ -182,18 +220,26 @@ class WavefrontSensor:
182
220
  seed: int | None = None,
183
221
  out: Any | None = None,
184
222
  ) -> Any:
185
- """Render one wavefront into optional caller-owned detector storage."""
223
+ """Render one wavefront into optional caller-owned detector storage.
224
+
225
+ Besides provenance and timings, the frame metadata records the input
226
+ wavefront's ``wfs_input_opd_rms_m``, its RMS in OPD metres weighted by
227
+ the pupil intensity with piston removed (aocore CONVENTIONS 4.1), and
228
+ ``wfs_input_opd_rms_unweighted_m``, its RMS over the whole input grid
229
+ with piston kept.
230
+ """
186
231
  total_start = perf_counter()
187
232
  optical_start = total_start
188
233
  result = self._render(wavefront)
189
- captured_rate, opd_rms = self.backend.scalars(
190
- result.captured_rate_per_s, self._opd_rms(result.opd_m)
234
+ captured_rate, opd_rms, opd_rms_unweighted = self.backend.scalars(
235
+ result.captured_rate_per_s, *self._opd_rms(result.opd_m)
191
236
  )
192
237
  optical_elapsed = perf_counter() - optical_start
193
238
  frame_metadata = self._frame_metadata(
194
239
  launched_rate=result.launched_rate_per_s,
195
240
  captured_rate=captured_rate,
196
241
  opd_rms_m=opd_rms,
242
+ opd_rms_unweighted_m=opd_rms_unweighted,
197
243
  seed=seed,
198
244
  )
199
245
  detector_start = perf_counter()
@@ -244,13 +290,14 @@ class WavefrontSensor:
244
290
  if not samples:
245
291
  raise ValueError("phase_samples must contain at least one sample")
246
292
  result = batched(samples)
247
- captured_rate, opd_rms = self.backend.scalars(
248
- result.captured_rate_per_s, self._opd_rms(result.opd_m)
293
+ captured_rate, opd_rms, opd_rms_unweighted = self.backend.scalars(
294
+ result.captured_rate_per_s, *self._opd_rms(result.opd_m)
249
295
  )
250
296
  frame_metadata = self._frame_metadata(
251
297
  launched_rate=result.launched_rate_per_s,
252
298
  captured_rate=captured_rate,
253
299
  opd_rms_m=opd_rms,
300
+ opd_rms_unweighted_m=opd_rms_unweighted,
254
301
  seed=seed,
255
302
  )
256
303
  frame_metadata["wfs_temporal_samples"] = len(samples)
@@ -305,13 +352,14 @@ class WavefrontSensor:
305
352
  None if spectral_rate_sum is None else spectral_rate_sum / sample_count
306
353
  )
307
354
  average_opd = opd_sum / sample_count
308
- captured_rate, opd_rms = self.backend.scalars(
309
- captured / sample_count, self._opd_rms(average_opd)
355
+ captured_rate, opd_rms, opd_rms_unweighted = self.backend.scalars(
356
+ captured / sample_count, *self._opd_rms(average_opd)
310
357
  )
311
358
  frame_metadata = self._frame_metadata(
312
359
  launched_rate=launched,
313
360
  captured_rate=captured_rate,
314
361
  opd_rms_m=opd_rms,
362
+ opd_rms_unweighted_m=opd_rms_unweighted,
315
363
  seed=seed,
316
364
  )
317
365
  frame_metadata["wfs_temporal_samples"] = sample_count
@@ -21,6 +21,7 @@ from dataclasses import dataclass
21
21
  from typing import Any, cast
22
22
 
23
23
  import numpy as np
24
+ from aocore import centered_coordinates as _aocore_centered_coordinates
24
25
  from numpy.typing import NDArray
25
26
 
26
27
 
@@ -48,6 +49,18 @@ class ArrayBackend:
48
49
  """Convert a value using this backend's array namespace."""
49
50
  return self.xp.asarray(value, dtype=dtype)
50
51
 
52
+ def centered_coordinates(self, n: int, *, dtype: Any) -> Any:
53
+ """Unit-pitch pixel-centre coordinates (CONVENTIONS 1.2) on this device.
54
+
55
+ ``aocore.centered_coordinates`` builds them in float64 and casts to
56
+ ``dtype`` directly on the GPU, so no host array is copied over. The
57
+ CPU path still passes through :meth:`asarray`, which keeps an injected
58
+ namespace in the loop.
59
+ """
60
+ if self.is_cpu:
61
+ return self.asarray(_aocore_centered_coordinates(n, dtype=dtype), dtype=dtype)
62
+ return _aocore_centered_coordinates(n, dtype=dtype, backend="gpu")
63
+
51
64
  def zeros(self, shape: Any, *, dtype: Any) -> Any:
52
65
  """Allocate a zero-filled array on this backend."""
53
66
  return self.xp.zeros(shape, dtype=dtype)
@@ -7,9 +7,6 @@ import importlib.metadata
7
7
  from pathlib import Path
8
8
  from typing import Any
9
9
 
10
- import numpy as np
11
- from numpy.typing import NDArray
12
-
13
10
  from .config import WFSConfig
14
11
  from .source import SourceState, iter_source_states
15
12
 
@@ -44,28 +41,24 @@ def referenced_file_digests(config: WFSConfig) -> dict[str, str]:
44
41
  }
45
42
 
46
43
 
47
- def _opd_rms(opd_m: NDArray[Any] | None, opd_rms_m: float | None) -> float:
48
- """Resolve a host OPD RMS, accepting a backend-reduced value."""
49
- if opd_rms_m is not None:
50
- return float(opd_rms_m)
51
- if opd_m is None:
52
- raise ValueError("metadata requires opd_m or opd_rms_m")
53
- return float(np.sqrt(np.mean(np.asarray(opd_m) ** 2)))
54
-
55
-
56
44
  def metadata(
57
45
  config: WFSConfig,
58
46
  *,
59
47
  sensor_kind: str,
60
48
  launched_rate: float,
61
49
  captured_rate: float,
62
- opd_m: NDArray[Any] | None = None,
63
- opd_rms_m: float | None = None,
50
+ opd_rms_m: float,
51
+ opd_rms_unweighted_m: float,
64
52
  seed: int | None,
65
53
  source_states: tuple[SourceState, ...] | None = None,
66
54
  file_digests: dict[str, str] | None = None,
67
55
  ) -> dict[str, Any]:
68
- """Build serializable metadata for an ideal or detector frame."""
56
+ """Build serializable metadata for an ideal or detector frame.
57
+
58
+ ``opd_rms_m`` is the input OPD's pupil-weighted, piston-removed RMS and
59
+ ``opd_rms_unweighted_m`` its unweighted whole-grid RMS with piston
60
+ included, both already reduced by the caller (aocore CONVENTIONS 4.1).
61
+ """
69
62
  states = iter_source_states(config) if source_states is None else source_states
70
63
  result: dict[str, Any] = {
71
64
  "frame_type": "wfs",
@@ -74,7 +67,8 @@ def metadata(
74
67
  "wfs_wavelength_m": config.sensor.wavelength_m,
75
68
  "wfs_launched_photons_s": float(launched_rate),
76
69
  "wfs_captured_photons_s": float(captured_rate),
77
- "wfs_input_opd_rms_m": _opd_rms(opd_m, opd_rms_m),
70
+ "wfs_input_opd_rms_m": float(opd_rms_m),
71
+ "wfs_input_opd_rms_unweighted_m": float(opd_rms_unweighted_m),
78
72
  "wfs_seed": seed if seed is not None else "internal",
79
73
  "wfs_source_kind": config.source.kind,
80
74
  "wfs_source_state_count": len(states),
@@ -9,7 +9,6 @@ from typing import Any, cast
9
9
 
10
10
  import numpy as np
11
11
  from aocore import block_sum as _aocore_block_sum
12
- from aocore import centered_coordinates
13
12
  from numpy.typing import NDArray
14
13
 
15
14
  from .backend import ArrayBackend, centered_fft_intensity, cpu_backend
@@ -147,9 +146,11 @@ def block_sum(
147
146
  """Sum square pixel blocks while preserving total flux.
148
147
 
149
148
  A thin wrapper over ``aocore.block_sum``, which owns flux-conserving
150
- binning (CONVENTIONS 9). It keeps this module's error messages and a
151
- factor-two fast path. ``backend`` is accepted for compatibility; the
152
- reduction runs on the array's own namespace.
149
+ binning (CONVENTIONS 9); it keeps this module's error messages.
150
+ ``backend`` is accepted for compatibility; the reduction runs on the
151
+ array's own namespace. Since aocore 0.1.3 its strided CPU adds and
152
+ single-kernel CuPy path are at least as fast as the factor-two shortcut
153
+ this module used to keep, on the spot stacks the Shack-Hartmann bins.
153
154
  """
154
155
  if factor < 1:
155
156
  raise ValueError("factor must be positive")
@@ -158,20 +159,65 @@ def block_sum(
158
159
  height, width = array.shape[-2:]
159
160
  if height % factor or width % factor:
160
161
  raise ValueError(f"shape {array.shape[-2:]} is not divisible by factor {factor}")
161
- if factor == 2:
162
- # Two-times oversampling is the common SH path. Direct strided sums
163
- # avoid NumPy's disproportionately expensive multi-axis reduction over
164
- # thousands of tiny spot images and work unchanged with CuPy arrays.
165
- return cast(
166
- NDArray[Any],
167
- array[..., 0::2, 0::2]
168
- + array[..., 0::2, 1::2]
169
- + array[..., 1::2, 0::2]
170
- + array[..., 1::2, 1::2],
171
- )
172
162
  return cast(NDArray[Any], _aocore_block_sum(array, factor))
173
163
 
174
164
 
165
+ def _area_overlap(target: int, source: int, *, backend: ArrayBackend, dtype: Any) -> NDArray[Any]:
166
+ """Return the ``(target, source)`` fractions of each target cell's length.
167
+
168
+ Both grids tile the same interval. Edges are compared in integer units of
169
+ ``1 / (target * source)``, so the overlaps are exact and every row sums to
170
+ one.
171
+ """
172
+ rows = backend.arange(target)[:, None]
173
+ columns = backend.arange(source)[None, :]
174
+ upper = backend.where(
175
+ (rows + 1) * source < (columns + 1) * target,
176
+ (rows + 1) * source,
177
+ (columns + 1) * target,
178
+ )
179
+ lower = backend.where(rows * source > columns * target, rows * source, columns * target)
180
+ overlap = backend.where(upper > lower, upper - lower, 0)
181
+ return cast(NDArray[Any], backend.asarray(overlap, dtype=dtype) / source)
182
+
183
+
184
+ def area_rebin(
185
+ array: NDArray[Any],
186
+ shape: tuple[int, int],
187
+ *,
188
+ backend: ArrayBackend | None = None,
189
+ ) -> NDArray[Any]:
190
+ """Area-average a map onto another grid spanning the same extent.
191
+
192
+ Each target pixel takes the mean of the source map over its own area,
193
+ weighting every source pixel by the exact fraction it overlaps, so any
194
+ integer or non-integer ratio, up or down, is handled without
195
+ interpolation. A uniform map stays uniform and the area integral of the map
196
+ is preserved. Equal shapes return the map unchanged.
197
+
198
+ Parameters
199
+ ----------
200
+ array
201
+ Two-dimensional ``(y, x)`` map on the source grid.
202
+ shape
203
+ Target ``(height, width)``.
204
+ backend
205
+ Array backend holding ``array``.
206
+
207
+ Returns
208
+ -------
209
+ numpy.ndarray or cupy.ndarray
210
+ The area-averaged map on the target grid, in ``array``'s dtype.
211
+ """
212
+ resolved = backend or cpu_backend()
213
+ height, width = array.shape
214
+ if (height, width) == tuple(shape):
215
+ return array
216
+ rows = _area_overlap(shape[0], height, backend=resolved, dtype=array.dtype)
217
+ columns = _area_overlap(shape[1], width, backend=resolved, dtype=array.dtype)
218
+ return cast(NDArray[Any], resolved.matmul(resolved.matmul(rows, array), columns.T))
219
+
220
+
175
221
  @dataclass
176
222
  class _SpotPropagationPlan:
177
223
  """Cached backend-resident geometry for repeated spot propagation.
@@ -250,8 +296,8 @@ class _SpotPropagationPlan:
250
296
  coordinate = backend.arange(nfft, dtype=np.float64)
251
297
  plan.half_sample = backend.exp(-1j * math.pi * coordinate / nfft)
252
298
  else:
253
- detector_coordinate = backend.asarray(
254
- centered_coordinates(high_resolution_pixels), dtype=np.float64
299
+ detector_coordinate = backend.centered_coordinates(
300
+ high_resolution_pixels, dtype=np.float64
255
301
  ) / (sampling * oversampling)
256
302
  pupil_coordinate = backend.arange(samples_per_lenslet, dtype=np.float64)
257
303
  kernel = backend.exp(
@@ -263,9 +309,7 @@ class _SpotPropagationPlan:
263
309
  )
264
310
  plan.dft_kernel = backend.astype(kernel, plan.field_dtype)
265
311
  if field_stop_radius_lambda_over_d is not None:
266
- coordinates = backend.asarray(
267
- centered_coordinates(high_resolution_pixels), dtype=np.float64
268
- )
312
+ coordinates = backend.centered_coordinates(high_resolution_pixels, dtype=np.float64)
269
313
  y, x = backend.meshgrid(coordinates, coordinates, indexing="ij")
270
314
  radius_lambda_over_d = backend.hypot(x, y) / (oversampling * sampling)
271
315
  plan.field_stop_mask = radius_lambda_over_d <= field_stop_radius_lambda_over_d
@@ -439,6 +483,7 @@ def spot_intensity(
439
483
 
440
484
 
441
485
  __all__ = [
486
+ "area_rebin",
442
487
  "block_sum",
443
488
  "crop_center",
444
489
  "lenslet_field_upsampling",