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.
- {makewfs-1.2.0 → makewfs-2.0.0}/AGENTS.md +17 -2
- {makewfs-1.2.0 → makewfs-2.0.0}/CHANGELOG.md +59 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/CITATION.cff +1 -1
- {makewfs-1.2.0 → makewfs-2.0.0}/PKG-INFO +2 -2
- makewfs-2.0.0/docs/concepts.md +67 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/stability.md +17 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/closed_loop_injection.py +8 -5
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/showcase.py +3 -2
- {makewfs-1.2.0 → makewfs-2.0.0}/pyproject.toml +1 -1
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/__about__.py +1 -1
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/api.py +59 -11
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/backend.py +13 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/provenance.py +10 -16
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/sampling.py +65 -20
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/sensors/base.py +6 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/sensors/pyramid.py +1 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/sensors/shack_hartmann.py +2 -2
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/wavefront.py +85 -6
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_gpu_backend.py +37 -0
- makewfs-2.0.0/tests/test_input_rms.py +213 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_provenance.py +0 -30
- makewfs-1.2.0/docs/concepts.md +0 -38
- {makewfs-1.2.0 → makewfs-2.0.0}/.gitignore +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/CONTRIBUTING.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/LICENSE +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/README.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/ROADMAP.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/__init__.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/benchmark_compiled_sh_executor.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/benchmark_sh_state_batching.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/check_regression.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/configs/pyramid_40_float32.toml +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/configs/pyramid_60_mod8_float32.toml +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/configs/pyramid_80_mod32_float64.toml +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/configs/shack_hartmann_20x20_float32.toml +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/configs/shack_hartmann_60x60_float64.toml +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/configs/shack_hartmann_quadrature_9sample.toml +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/device-results.json +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/device-results.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/haka-compiled-sh-executor-quadro-p620.json +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/haka-sh-state-batching-quadro-p620.json +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/profile_warm.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/reference-results.json +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/reference-table.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/render_device_table.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/render_table.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/benchmarks/run.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/adr/0001-units-coordinates.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/adr/0002-flux-normalization.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/adr/0003-public-api.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/adr/0004-backend-boundary.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/adr/index.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/api.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/configuration.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/contributing.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/detectors.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/examples.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/gallery/makewfs-gallery.json +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/gallery/makewfs-gallery.svg +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/gallery.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/guide-stars.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/index.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/interop.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/performance.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/pyramid.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/quickstart.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/release.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/shack-hartmann.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/troubleshooting.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/units-and-coordinates.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/docs/validation.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/README.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/cds_readout.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/compare_sensors.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/configs/angular_kernel.txt +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/configs/lgs_thin_beacon.toml +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/configs/precision_throughput.toml +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/configs/pyramid_minimal.toml +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/configs/qe_curve.txt +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/configs/shack_hartmann_extended_source.toml +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/configs/shack_hartmann_minimal.toml +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/configs/shack_hartmann_spectral_qe.toml +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/detector_choices.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/gallery.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/README.md +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/analyze_lut.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/benchmark.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/camera_modes.csv +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/camera_modes_empirical_floor_continuous.csv +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/compare_real.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/fit_secondary.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/haka_cpu_gpu_benchmark.json +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/haka_lut_snr.json +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/keck_haka.json +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/keck_haka.toml +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/mauna_kea_extinction.csv +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/mauna_kea_extinction_nir.csv +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/ocam_20260720/extract_ocam_images.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/ocam_20260720/make_ocam_video.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/real_vs_simulation.json +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/secondary_fit.json +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/keck_haka/simulate.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/lgs_elongation.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/lgs_thin_beacon.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/magnitude_series.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/makewfs_showcase.webp +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/moving_atmosphere.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/precision_throughput.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/pyramid_modulation.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/quickstart.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/realistic_broadband.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/sh_design_trade.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/examples/spectral_qe.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/__init__.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/cli.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/config.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/detector.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/pupil.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/py.typed +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/radiometry.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/sensors/__init__.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/sensors/_shack_hartmann_cuda.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/src/makewfs/source.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_backend_audit.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_benchmarks.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_cli.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_config.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_conformance.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_hcipy_validation.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_interop.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_keck_haka_example.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_numerics.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_oopao_validation.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_optics_validation.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_public_api.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_pyramid.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_radiometry.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_shack_hartmann.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_shack_hartmann_sampling.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_source.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_validation_report.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/tests/test_wavefront.py +0 -0
- {makewfs-1.2.0 → makewfs-2.0.0}/validation/__init__.py +0 -0
- {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
|
|
157
|
-
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: makewfs
|
|
3
|
-
Version:
|
|
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.
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
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)
|
|
@@ -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
|
|
70
|
-
"""
|
|
71
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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":
|
|
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)
|
|
151
|
-
|
|
152
|
-
|
|
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.
|
|
254
|
-
|
|
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.
|
|
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",
|