getframes 2.4.0__tar.gz → 2.6.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 (121) hide show
  1. {getframes-2.4.0 → getframes-2.6.0}/CHANGELOG.md +108 -1
  2. {getframes-2.4.0 → getframes-2.6.0}/PKG-INFO +5 -1
  3. {getframes-2.4.0 → getframes-2.6.0}/README.md +4 -0
  4. {getframes-2.4.0 → getframes-2.6.0}/benchmarks/bench_devices.py +9 -0
  5. getframes-2.6.0/benchmarks/bench_small_frames.py +192 -0
  6. getframes-2.6.0/benchmarks/device-results-neoverse-n1-rtx4060.json +221 -0
  7. getframes-2.6.0/benchmarks/device-results-neoverse-n1-rtxa400.json +126 -0
  8. getframes-2.6.0/benchmarks/device-results-neoverse-n1.md +39 -0
  9. getframes-2.6.0/benchmarks/small-frame-results.json +2118 -0
  10. getframes-2.6.0/benchmarks/small-frame-results.md +85 -0
  11. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/__about__.py +1 -1
  12. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/__init__.py +2 -1
  13. getframes-2.6.0/src/getframes/_cuda.py +224 -0
  14. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/backend.py +45 -1
  15. getframes-2.6.0/src/getframes/calibrate.py +615 -0
  16. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/camera.py +90 -1
  17. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/noise.py +246 -85
  18. {getframes-2.4.0 → getframes-2.6.0}/tests/test_gpu.py +121 -0
  19. getframes-2.6.0/tests/test_wfs_calibration.py +354 -0
  20. getframes-2.4.0/src/getframes/calibrate.py +0 -182
  21. {getframes-2.4.0 → getframes-2.6.0}/.gitignore +0 -0
  22. {getframes-2.4.0 → getframes-2.6.0}/LICENSE +0 -0
  23. {getframes-2.4.0 → getframes-2.6.0}/benchmarks/__init__.py +0 -0
  24. {getframes-2.4.0 → getframes-2.6.0}/benchmarks/bench_detector_workspace.py +0 -0
  25. {getframes-2.4.0 → getframes-2.6.0}/benchmarks/bench_fixed_map_dtype.py +0 -0
  26. {getframes-2.4.0 → getframes-2.6.0}/benchmarks/detector-workspace-results.json +0 -0
  27. {getframes-2.4.0 → getframes-2.6.0}/benchmarks/device-results.json +0 -0
  28. {getframes-2.4.0 → getframes-2.6.0}/benchmarks/device-results.md +0 -0
  29. {getframes-2.4.0 → getframes-2.6.0}/benchmarks/fixed-map-dtype-results.json +0 -0
  30. {getframes-2.4.0 → getframes-2.6.0}/benchmarks/render_device_table.py +0 -0
  31. {getframes-2.4.0 → getframes-2.6.0}/benchmarks/run.py +0 -0
  32. {getframes-2.4.0 → getframes-2.6.0}/examples/01_basic_dark_frame.py +0 -0
  33. {getframes-2.4.0 → getframes-2.6.0}/examples/02_custom_camera.py +0 -0
  34. {getframes-2.4.0 → getframes-2.6.0}/examples/03_master_dark.py +0 -0
  35. {getframes-2.4.0 → getframes-2.6.0}/examples/04_browse_presets.py +0 -0
  36. {getframes-2.4.0 → getframes-2.6.0}/examples/05_visualise.py +0 -0
  37. {getframes-2.4.0 → getframes-2.6.0}/examples/06_photon_transfer_curve.py +0 -0
  38. {getframes-2.4.0 → getframes-2.6.0}/examples/07_star_field_exposure.py +0 -0
  39. {getframes-2.4.0 → getframes-2.6.0}/examples/08_ao_limiting_magnitude.py +0 -0
  40. {getframes-2.4.0 → getframes-2.6.0}/examples/09_transit_photometry.py +0 -0
  41. {getframes-2.4.0 → getframes-2.6.0}/examples/10_detector_realism.py +0 -0
  42. {getframes-2.4.0 → getframes-2.6.0}/examples/11_radiometry_and_ir.py +0 -0
  43. {getframes-2.4.0 → getframes-2.6.0}/examples/12_ml_dataset.py +0 -0
  44. {getframes-2.4.0 → getframes-2.6.0}/examples/13_crowded_field.py +0 -0
  45. {getframes-2.4.0 → getframes-2.6.0}/examples/14_keck_lgs_ttf_trade_study.ipynb +0 -0
  46. {getframes-2.4.0 → getframes-2.6.0}/examples/15_detector_characterization.py +0 -0
  47. {getframes-2.4.0 → getframes-2.6.0}/examples/16_detector_showcase.py +0 -0
  48. {getframes-2.4.0 → getframes-2.6.0}/examples/README.md +0 -0
  49. {getframes-2.4.0 → getframes-2.6.0}/examples/_common.py +0 -0
  50. {getframes-2.4.0 → getframes-2.6.0}/examples/detector_showcase.webp +0 -0
  51. {getframes-2.4.0 → getframes-2.6.0}/pyproject.toml +0 -0
  52. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/analysis/__init__.py +0 -0
  53. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/analysis/apertures.py +0 -0
  54. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/analysis/characterize.py +0 -0
  55. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/analysis/nondestructive.py +0 -0
  56. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/analysis/ptc.py +0 -0
  57. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/cli.py +0 -0
  58. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/config.py +0 -0
  59. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/dataset.py +0 -0
  60. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/frame.py +0 -0
  61. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/observation.py +0 -0
  62. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/__init__.py +0 -0
  63. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/__init__.py +0 -0
  64. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/andor_cb1_0_5mp.toml +0 -0
  65. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/andor_ikon_m934.toml +0 -0
  66. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/andor_ixon_ultra_888.toml +0 -0
  67. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/andor_marana_4_2b_11.toml +0 -0
  68. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/andor_ocam2k.toml +0 -0
  69. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/first_light_imaging_cred_one.toml +0 -0
  70. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/generic_ccd.toml +0 -0
  71. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/generic_cmos.toml +0 -0
  72. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/generic_eapd.toml +0 -0
  73. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/generic_emccd.toml +0 -0
  74. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/generic_scmos.toml +0 -0
  75. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/hamamatsu_orca_fusion.toml +0 -0
  76. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/hamamatsu_orca_quest_2.toml +0 -0
  77. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/leonardo_saphira.toml +0 -0
  78. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/nuvu_hnu_128_omega.toml +0 -0
  79. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/nuvu_hnu_240.toml +0 -0
  80. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/photometrics_prime_95b.toml +0 -0
  81. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/princeton_instruments_kuro_1200b.toml +0 -0
  82. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/qhy530_pro_ii.toml +0 -0
  83. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/scimeasure_little_joe_ccd39.toml +0 -0
  84. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/tucsen_aries_6504_pro.toml +0 -0
  85. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/presets/data/zwo_asi2600mm.toml +0 -0
  86. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/py.typed +0 -0
  87. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/scene/__init__.py +0 -0
  88. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/scene/optics.py +0 -0
  89. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/scene/photometry.py +0 -0
  90. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/scene/psf.py +0 -0
  91. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/scene/scene.py +0 -0
  92. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/scene/sources.py +0 -0
  93. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/scene/thermal.py +0 -0
  94. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/scene/wcs.py +0 -0
  95. {getframes-2.4.0 → getframes-2.6.0}/src/getframes/spectral.py +0 -0
  96. {getframes-2.4.0 → getframes-2.6.0}/tests/test_analysis.py +0 -0
  97. {getframes-2.4.0 → getframes-2.6.0}/tests/test_backend.py +0 -0
  98. {getframes-2.4.0 → getframes-2.6.0}/tests/test_benchmarks.py +0 -0
  99. {getframes-2.4.0 → getframes-2.6.0}/tests/test_calibrate.py +0 -0
  100. {getframes-2.4.0 → getframes-2.6.0}/tests/test_camera.py +0 -0
  101. {getframes-2.4.0 → getframes-2.6.0}/tests/test_characterize.py +0 -0
  102. {getframes-2.4.0 → getframes-2.6.0}/tests/test_cli.py +0 -0
  103. {getframes-2.4.0 → getframes-2.6.0}/tests/test_config.py +0 -0
  104. {getframes-2.4.0 → getframes-2.6.0}/tests/test_conformance.py +0 -0
  105. {getframes-2.4.0 → getframes-2.6.0}/tests/test_dataset.py +0 -0
  106. {getframes-2.4.0 → getframes-2.6.0}/tests/test_detector.py +0 -0
  107. {getframes-2.4.0 → getframes-2.6.0}/tests/test_frame.py +0 -0
  108. {getframes-2.4.0 → getframes-2.6.0}/tests/test_gain.py +0 -0
  109. {getframes-2.4.0 → getframes-2.6.0}/tests/test_noise.py +0 -0
  110. {getframes-2.4.0 → getframes-2.6.0}/tests/test_nondestructive_analysis.py +0 -0
  111. {getframes-2.4.0 → getframes-2.6.0}/tests/test_observation.py +0 -0
  112. {getframes-2.4.0 → getframes-2.6.0}/tests/test_presets.py +0 -0
  113. {getframes-2.4.0 → getframes-2.6.0}/tests/test_radiometry.py +0 -0
  114. {getframes-2.4.0 → getframes-2.6.0}/tests/test_realism.py +0 -0
  115. {getframes-2.4.0 → getframes-2.6.0}/tests/test_scale.py +0 -0
  116. {getframes-2.4.0 → getframes-2.6.0}/tests/test_scene.py +0 -0
  117. {getframes-2.4.0 → getframes-2.6.0}/tests/test_scene_enrich.py +0 -0
  118. {getframes-2.4.0 → getframes-2.6.0}/tests/test_signal.py +0 -0
  119. {getframes-2.4.0 → getframes-2.6.0}/tests/test_spectral.py +0 -0
  120. {getframes-2.4.0 → getframes-2.6.0}/tests/test_validation.py +0 -0
  121. {getframes-2.4.0 → getframes-2.6.0}/tests/test_workspace.py +0 -0
@@ -6,6 +6,112 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.6.0] - 2026-10-10
10
+
11
+ ### Added
12
+
13
+ - **`Calibration`: ADU to input-referred electrons and incident photons**
14
+ (`gf.Calibration`, `Camera.calibration`). `Calibration.from_camera(cam,
15
+ exposure, n_darks=64, seed=...)` averages exposure-matched dark frames into a
16
+ mean master dark on the camera's device and precision; `to_electrons` applies
17
+ `L^-1[(ADU - dark) * gain_e_per_adu / em_gain] / flat`, inverting the
18
+ configured nonlinearity (closed form or Newton) by default; `to_photons`
19
+ divides by the scalar QE `expose` applies, a `quantum_efficiency`, the
20
+ `qe_curve` at `wavelength_nm`, or its effective value over a `bandpass`;
21
+ `saturated` flags pixels whose charge reached the input well, the output
22
+ register or the ADC ceiling. It works on NumPy and CuPy frames, takes an
23
+ optional unit-mean flat, and handles binned frames. Tests check that the mean
24
+ calibrated flux matches the incident photons to 0.5% for EMCCD, qCMOS, sCMOS,
25
+ eAPD, CCD and CMOS presets (issue #10).
26
+ - **`Camera.effective_read_noise_e(binning=..., binning_mode=...)`**: the
27
+ per-frame read noise referred to the input (electrons collected in the
28
+ pixel): output read noise (the quadrature mean of a per-pixel map), kTC/reset
29
+ noise and avalanche-scaled noise, divided by the EM/avalanche gain. The
30
+ docstring and the new guide give the excess-noise-factor SNR.
31
+ - **Guide: [A WFS camera in a closed loop](docs/guides/wfs-closed-loop.md)**:
32
+ exposure vs. frame period, ADU calibration and its accuracy, input-referred
33
+ noise and the excess noise factor, recommended EMCCD/qCMOS/sCMOS/eAPD/CCD
34
+ presets, and keeping small frames cheap.
35
+ - `benchmarks/bench_small_frames.py`: seeded 256x256 WFS frames for one preset
36
+ per detector class, with before/after comparison and a SHA-256 digest of
37
+ seeded output for bit-identity checks; results in
38
+ `benchmarks/small-frame-results.md`.
39
+
40
+ ### Performance
41
+
42
+ - **Photon-starved EMCCD and eAPD frames are faster on the CPU, with
43
+ bit-identical seeded output.** NumPy returns `Gamma(shape=0)` as exactly zero
44
+ without consuming a draw, but still visits every such pixel; the gain stage
45
+ now samples only the pixels that hold charge when they are a minority (a
46
+ wavefront sensor's dark background), and skips the `* 1` shape scaling at
47
+ `F = sqrt(2)`. Same draws, same order, same generator state afterwards;
48
+ seeded frames of every preset, in both precisions, are bit-identical to 2.5.0.
49
+ On one Neoverse-N1 core (256x256 Shack-Hartmann spots, 1 ms, in-process A/B):
50
+ the Gamma draw 1.43 → 0.78 ms; whole frames `andor_ocam2k` 6.57 → 5.95 ms
51
+ (1.10x), `leonardo_saphira` 4.79 → 4.29 ms (1.12x), `first_light_imaging_cred_one`
52
+ 7.16 → 6.63 ms (1.08x); presets without a gain stage are unchanged. The CPU
53
+ chain is otherwise at its sampler floor (about 85% of a frame is NumPy's
54
+ Poisson, Gamma and Gaussian draws). An exact vectorised replica of NumPy's
55
+ small-rate Poisson for the CIC draw was tried and measured no faster, so it
56
+ was not kept. See `benchmarks/small-frame-results.md`.
57
+
58
+ ### Documented
59
+
60
+ - The 1--2% calibrated-flux deficit seen in the dithered Shack-Hartmann
61
+ prototype is not a deterministic offset of the signal chain: averaged over
62
+ 300 frames the prototype recovers 1.002 (OCAM2K), 0.999 (ORCA-Quest 2) and
63
+ 1.001 (generic sCMOS) of the incident flux, while a single 288x288 frame
64
+ scatters by 1.2%, 1.0% and 3.3%. The guide lists the deterministic offsets a
65
+ pipeline can introduce (median master dark, scalar vs. spectral QE,
66
+ nonlinearity, saturation, unflattened fixed patterns) and their remedies.
67
+ - The Nüvü HNü 240 / 128 presets cap the *amplified* charge at their 500 / 800
68
+ e- `full_well_e` (no `output_full_well_e`), so at EM gain 1000 a pixel holding
69
+ one electron saturates; the guide warns about it (#11).
70
+
71
+ ## [2.5.0] - 2026-10-07
72
+
73
+ ### Added
74
+
75
+ - **Arm benchmark data point** (`benchmarks/device-results-neoverse-n1.*`): the
76
+ device table on an Ampere Neoverse-N1 host (16 pinned cores) with an RTX
77
+ 4060 and an RTX A400.
78
+
79
+ ### Performance
80
+
81
+ - **Small GPU frames run up to 2.6x faster, with bit-identical seeded output.**
82
+ WFS-sized frames were launch-bound: the host spent longer issuing about twenty
83
+ small CuPy kernels per frame than the GPU spent running them. The GPU path now
84
+ computes the photo and total expectations in one fused kernel and the whole
85
+ readout (full-well clip, defects, reset/avalanche/read noise, gain, bias
86
+ pedestal and structure, common mode, rounding, ADC saturation, `uint32`
87
+ conversion) in another; keeps scalar inputs (`background`, `extra_electrons`,
88
+ the EM-gain scale, the CIC rate) on the host instead of uploading them every
89
+ frame; samples Poisson counts directly in the working precision; and reuses
90
+ the chain's `photo + dark + extra` sum as `FrameTruth.mean_electrons`. The
91
+ noise is drawn by the same calls in the same order and the fused kernels
92
+ repeat the same floating-point operations (FMA contraction off), so every
93
+ seeded GPU frame and truth array is unchanged; a new GPU test compares them
94
+ bit for bit against the separate operations. `bench_devices.py` on an Ampere
95
+ Neoverse-N1 host (12 pinned cores), frames/s, 2.4.0 → now, median of three
96
+ interleaved runs: RTX 4060 — Pyramid 80x80 2,422 → 6,418 (2.65x),
97
+ Shack-Hartmann 160x160 2,424 → 6,340 (2.62x), OCAM2K 240x240 1,650 → 2,733
98
+ (1.66x), SAPHIRA 256x320 1,809 → 2,072 (1.15x), 1024x1024 240 → 247 (1.03x);
99
+ RTX A400 — Pyramid 2,403 → 6,315 (2.63x), the larger frames 1.02–1.06x.
100
+ Larger frames are bound by CuPy's double-precision Poisson and Gamma
101
+ samplers. See the [GPU guide](docs/guides/gpu.md#small-frames-fused-kernels).
102
+ - The NumPy path skips whole-frame identity arithmetic (adding a zero offset,
103
+ dividing by a unit gain, multiplying by a unit avalanche-gain map) and reuses
104
+ the expectation sum as the truth: 1–5% faster (1024x1024: 13.4 → 14.1 frames/s
105
+ on the same host), with identical seeded output. It remains bound by NumPy's
106
+ Poisson sampler (about 80% of a 1024x1024 frame), whose single sequential
107
+ stream cannot be threaded without changing seeded frames.
108
+
109
+ ### Fixed
110
+
111
+ - `benchmarks/bench_devices.py` reported the CPU of Arm hosts as `aarch64`;
112
+ it now reads the model from `lscpu` (e.g. `Neoverse-N1`) when
113
+ `/proc/cpuinfo` has no model name.
114
+
9
115
  ## [2.4.0] - 2026-10-07
10
116
 
11
117
  ### Added
@@ -646,7 +752,8 @@ together in 1.0.
646
752
  - Documentation, runnable examples, and CI (lint, type-check, test matrix, PyPI
647
753
  release via Trusted Publishing).
648
754
 
649
- [Unreleased]: https://github.com/jacotay7/getframes/compare/2.4.0...HEAD
755
+ [Unreleased]: https://github.com/jacotay7/getframes/compare/2.5.0...HEAD
756
+ [2.5.0]: https://github.com/jacotay7/getframes/compare/2.4.0...2.5.0
650
757
  [2.4.0]: https://github.com/jacotay7/getframes/compare/2.3.0...2.4.0
651
758
  [2.3.0]: https://github.com/jacotay7/getframes/compare/2.2.0...2.3.0
652
759
  [2.2.0]: https://github.com/jacotay7/getframes/compare/2.1.1...2.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: getframes
3
- Version: 2.4.0
3
+ Version: 2.6.0
4
4
  Summary: Generate physically realistic synthetic camera frames (CCD/CMOS/EMCCD/eAPD/sCMOS) — dark, bias, flat, and rendered star fields — with auditable noise physics for scientific imaging pipelines.
5
5
  Project-URL: Homepage, https://github.com/jacotay7/getframes
6
6
  Project-URL: Documentation, https://jacotay7.github.io/getframes/
@@ -174,6 +174,10 @@ for the methodology.
174
174
  - **Calibration & ground truth** — master bias/dark/flat builders and a
175
175
  `calibrate` reduction that closes the
176
176
  **[raw → reduced → truth loop](https://jacotay7.github.io/getframes/guides/calibration/)**.
177
+ - **Wavefront-sensor cameras** — `Calibration` turns ADU back into
178
+ input-referred electrons and photons, `Camera.effective_read_noise_e` gives
179
+ the input-referred noise, and a guide covers
180
+ **[a WFS camera in a closed loop](https://jacotay7.github.io/getframes/guides/wfs-closed-loop/)**.
177
181
  - **Observations** — `Observation` drives time series with jitter, drift, dither
178
182
  and persistence, carrying per-frame
179
183
  **[truth](https://jacotay7.github.io/getframes/guides/time-series/)**.
@@ -127,6 +127,10 @@ for the methodology.
127
127
  - **Calibration & ground truth** — master bias/dark/flat builders and a
128
128
  `calibrate` reduction that closes the
129
129
  **[raw → reduced → truth loop](https://jacotay7.github.io/getframes/guides/calibration/)**.
130
+ - **Wavefront-sensor cameras** — `Calibration` turns ADU back into
131
+ input-referred electrons and photons, `Camera.effective_read_noise_e` gives
132
+ the input-referred noise, and a guide covers
133
+ **[a WFS camera in a closed loop](https://jacotay7.github.io/getframes/guides/wfs-closed-loop/)**.
130
134
  - **Observations** — `Observation` drives time series with jitter, drift, dither
131
135
  and persistence, carrying per-frame
132
136
  **[truth](https://jacotay7.github.io/getframes/guides/time-series/)**.
@@ -112,6 +112,15 @@ def _cpu_model() -> str:
112
112
  return line.split(":", 1)[1].strip()
113
113
  except OSError:
114
114
  pass
115
+ # Arm /proc/cpuinfo has no "model name"; lscpu decodes the part number
116
+ # (e.g. "Neoverse-N1").
117
+ try:
118
+ output = subprocess.run(["lscpu"], check=True, capture_output=True, text=True).stdout
119
+ except (OSError, subprocess.CalledProcessError):
120
+ output = ""
121
+ for line in output.splitlines():
122
+ if line.startswith("Model name:"):
123
+ return line.split(":", 1)[1].strip()
115
124
  return platform.processor() or "unknown CPU"
116
125
 
117
126
 
@@ -0,0 +1,192 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Benchmark small, frequent wavefront-sensor frames on the CPU.
3
+
4
+ A closed-loop wavefront-sensor study calls :meth:`getframes.Camera.expose` once
5
+ per loop step on a small frame, so per-frame latency, not throughput on large
6
+ detectors, is what limits it. This script times seeded ``expose`` calls on a
7
+ 256x256 Shack-Hartmann-like spot pattern (16x16 subapertures of 16x16 pixels,
8
+ one Gaussian spot each) for one preset of each WFS detector class, at a
9
+ photon-starved and a bright flux, in two call styles:
10
+
11
+ * ``default``: ``expose(rate, t, seed=s, include_truth=False)``;
12
+ * ``workspace``: the same with a reused :class:`getframes.DetectorWorkspace` and
13
+ a caller-owned ``out`` frame.
14
+
15
+ It also records a SHA-256 digest of a few seeded frames per configuration, so two
16
+ revisions can be compared for bit-identical output (``--compare``). Timings are
17
+ process CPU time per frame over interleaved, warmed blocks (the chain is
18
+ single-threaded); they are machine dependent and are not part of the test gate.
19
+ Each case reports the median and the best (minimum) block; on a shared host,
20
+ compare the best blocks, which other load can only inflate.
21
+
22
+ Run from the repository root::
23
+
24
+ python benchmarks/bench_small_frames.py --output small-frame-results.json
25
+ python benchmarks/bench_small_frames.py --compare before.json after.json
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import hashlib
32
+ import json
33
+ import platform
34
+ import statistics
35
+ import time
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ import numpy as np
40
+
41
+ import getframes as gf
42
+
43
+ SHAPE = (256, 256)
44
+ SUBAPERTURE_PX = 16
45
+ EXPOSURE_S = 1e-3
46
+ #: One preset per detector class a WFS is built from.
47
+ PRESETS = (
48
+ ("EMCCD", "andor_ocam2k"),
49
+ ("EMCCD", "andor_ixon_ultra_888"),
50
+ ("qCMOS", "hamamatsu_orca_quest_2"),
51
+ ("sCMOS", "generic_scmos"),
52
+ ("eAPD", "first_light_imaging_cred_one"),
53
+ )
54
+ #: Photons per subaperture per frame: photon-starved and bright.
55
+ FLUXES = (100.0, 2000.0)
56
+ DIGEST_SEEDS = (0, 1, 2)
57
+
58
+
59
+ def spot_pattern(photons_per_subaperture: float) -> np.ndarray[Any, np.dtype[np.float64]]:
60
+ """A 256x256 photon map of one Gaussian spot (sigma 1.5 px) per subaperture."""
61
+ coordinates = np.arange(SUBAPERTURE_PX) - (SUBAPERTURE_PX - 1) / 2
62
+ spot = np.exp(-(coordinates[:, None] ** 2 + coordinates[None, :] ** 2) / (2 * 1.5**2))
63
+ spot *= photons_per_subaperture / spot.sum()
64
+ reps = (SHAPE[0] // SUBAPERTURE_PX, SHAPE[1] // SUBAPERTURE_PX)
65
+ return np.tile(spot, reps)
66
+
67
+
68
+ def make_camera(preset: str) -> gf.Camera:
69
+ return gf.Camera.from_preset(preset).with_config(resolution=SHAPE, roi=None)
70
+
71
+
72
+ def _block_ms(function: Any, calls: int) -> float:
73
+ # Process CPU time, not wall time: the chain is single-threaded, and CPU time
74
+ # is far less sensitive to other load on a shared host.
75
+ start = time.process_time_ns()
76
+ for index in range(calls):
77
+ function(index)
78
+ return (time.process_time_ns() - start) / calls / 1e6
79
+
80
+
81
+ def bench_case(preset: str, flux: float, calls: int, repetitions: int) -> dict[str, Any]:
82
+ camera = make_camera(preset)
83
+ rate = spot_pattern(flux) / EXPOSURE_S
84
+ workspace = gf.DetectorWorkspace()
85
+ out = np.empty(SHAPE, dtype=np.uint32)
86
+
87
+ def default(seed: int) -> None:
88
+ camera.expose(rate, EXPOSURE_S, seed=seed, include_truth=False)
89
+
90
+ def reused(seed: int) -> None:
91
+ camera.expose(
92
+ rate, EXPOSURE_S, seed=seed, include_truth=False, workspace=workspace, out=out
93
+ )
94
+
95
+ for seed in range(10):
96
+ default(seed)
97
+ reused(seed)
98
+ samples: dict[str, list[float]] = {"default": [], "workspace": []}
99
+ variants = [("default", default), ("workspace", reused)]
100
+ for repetition in range(repetitions):
101
+ for name, function in variants if repetition % 2 == 0 else variants[::-1]:
102
+ samples[name].append(_block_ms(function, calls))
103
+
104
+ digest = hashlib.sha256()
105
+ for seed in DIGEST_SEEDS:
106
+ frame = camera.expose(rate, EXPOSURE_S, seed=seed)
107
+ digest.update(np.ascontiguousarray(frame.data).tobytes())
108
+ assert frame.truth is not None
109
+ digest.update(np.ascontiguousarray(frame.truth.mean_electrons).tobytes())
110
+ return {
111
+ "preset": preset,
112
+ "photons_per_subaperture": flux,
113
+ "default_ms": statistics.median(samples["default"]),
114
+ "workspace_ms": statistics.median(samples["workspace"]),
115
+ "default_best_ms": min(samples["default"]),
116
+ "workspace_best_ms": min(samples["workspace"]),
117
+ "default_samples_ms": samples["default"],
118
+ "workspace_samples_ms": samples["workspace"],
119
+ "seeded_digest": digest.hexdigest(),
120
+ }
121
+
122
+
123
+ def run(calls: int, repetitions: int) -> dict[str, Any]:
124
+ results = []
125
+ for sensor, preset in PRESETS:
126
+ for flux in FLUXES:
127
+ case = bench_case(preset, flux, calls, repetitions)
128
+ case["class"] = sensor
129
+ results.append(case)
130
+ print(
131
+ f"{sensor:6s} {preset:30s} {flux:7.0f} ph/subap "
132
+ f"default {case['default_ms']:6.2f} ms workspace {case['workspace_ms']:6.2f} ms",
133
+ flush=True,
134
+ )
135
+ return {
136
+ "schema_version": 1,
137
+ "getframes": gf.__version__,
138
+ "numpy": np.__version__,
139
+ "platform": platform.platform(),
140
+ "processor": platform.processor() or platform.machine(),
141
+ "shape": list(SHAPE),
142
+ "exposure_s": EXPOSURE_S,
143
+ "calls_per_block": calls,
144
+ "repetitions": repetitions,
145
+ "results": results,
146
+ }
147
+
148
+
149
+ def compare(before_path: Path, after_path: Path) -> str:
150
+ """A Markdown table of before/after best blocks and whether seeded output matches."""
151
+ before = json.loads(before_path.read_text(encoding="utf-8"))
152
+ after = json.loads(after_path.read_text(encoding="utf-8"))
153
+ lines = [
154
+ f"Before: getframes {before['getframes']}; after: getframes {after['getframes']} "
155
+ f"({after['processor']}, NumPy {after['numpy']}, {after['shape'][0]}x{after['shape'][1]}).",
156
+ "",
157
+ "| Class | Preset | Photons/subap | Before (ms, best) | After (ms, best) | Speed-up | "
158
+ "After, workspace+out (ms, best) | Seeded output |",
159
+ "| --- | --- | ---: | ---: | ---: | ---: | ---: | --- |",
160
+ ]
161
+ old = {(r["preset"], r["photons_per_subaperture"]): r for r in before["results"]}
162
+ for row in after["results"]:
163
+ ref = old.get((row["preset"], row["photons_per_subaperture"]))
164
+ if ref is None:
165
+ continue
166
+ same = "identical" if ref["seeded_digest"] == row["seeded_digest"] else "CHANGED"
167
+ lines.append(
168
+ f"| {row['class']} | {row['preset']} | {row['photons_per_subaperture']:.0f} | "
169
+ f"{ref['default_best_ms']:.2f} | {row['default_best_ms']:.2f} | "
170
+ f"{ref['default_best_ms'] / row['default_best_ms']:.2f}x | "
171
+ f"{row['workspace_best_ms']:.2f} | {same} |"
172
+ )
173
+ return "\n".join(lines) + "\n"
174
+
175
+
176
+ def main() -> None:
177
+ parser = argparse.ArgumentParser(description=__doc__)
178
+ parser.add_argument("--calls", type=int, default=40)
179
+ parser.add_argument("--repetitions", type=int, default=7)
180
+ parser.add_argument("--output", type=Path)
181
+ parser.add_argument("--compare", nargs=2, type=Path, metavar=("BEFORE", "AFTER"))
182
+ arguments = parser.parse_args()
183
+ if arguments.compare:
184
+ print(compare(*arguments.compare), end="")
185
+ return
186
+ payload = run(arguments.calls, arguments.repetitions)
187
+ if arguments.output is not None:
188
+ arguments.output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
189
+
190
+
191
+ if __name__ == "__main__":
192
+ main()
@@ -0,0 +1,221 @@
1
+ {
2
+ "schema_version": 1,
3
+ "generated_at_utc": "2026-10-07T07:14:14.461693+00:00",
4
+ "command": "/home/jtaylor/miniforge3/envs/aosim-bench/bin/python benchmarks/bench_devices.py --seconds 2 --warmup 10 --device both --output /home/jtaylor/aosim-bench-logs/artifacts/getframes-device-rtx4060.json",
5
+ "revision": "7b01daf765d432ab141176548f2b11cf0e7fc11c",
6
+ "source_dirty": true,
7
+ "python": "3.13.15 | packaged by conda-forge | (main, Sep 2 2026, 22:02:03) [GCC 15.3.0]",
8
+ "platform": "Linux-6.17.9-76061709-generic-aarch64-with-glibc2.39",
9
+ "processor": "aarch64",
10
+ "cpu": "Neoverse-N1",
11
+ "gpu": "NVIDIA GeForce RTX 4060",
12
+ "dependencies": {
13
+ "getframes": "2.4.0",
14
+ "numpy": "2.5.3",
15
+ "scipy": "1.18.1",
16
+ "cupy": "14.2.0"
17
+ },
18
+ "methodology": {
19
+ "seconds_per_cell": 2.0,
20
+ "warmup_frames": 10,
21
+ "persistent_camera": true,
22
+ "device_resident_rate_and_output": true,
23
+ "include_truth": true,
24
+ "rng": "one generator seeded at camera construction and advanced per frame",
25
+ "cuda_synchronization": "before and after each timed region",
26
+ "construction_included": false,
27
+ "host_transfers_included": false
28
+ },
29
+ "results": [
30
+ {
31
+ "workflow": "pyramid_cmos_80",
32
+ "label": "Pyramid WFS CMOS",
33
+ "preset": "generic_cmos",
34
+ "sensor": "CMOS",
35
+ "shape": [
36
+ 80,
37
+ 80
38
+ ],
39
+ "precision": "float32",
40
+ "exposure_s": 0.001,
41
+ "photon_rate_per_s": 2000000.0,
42
+ "device": "cpu",
43
+ "frames": 4295,
44
+ "elapsed_s": 2.0004646239976864,
45
+ "frame_s": 0.000465765919440672,
46
+ "frames_per_s": 2147.001225853703,
47
+ "megapixels_per_s": 13.740807845463701
48
+ },
49
+ {
50
+ "workflow": "pyramid_cmos_80",
51
+ "label": "Pyramid WFS CMOS",
52
+ "preset": "generic_cmos",
53
+ "sensor": "CMOS",
54
+ "shape": [
55
+ 80,
56
+ 80
57
+ ],
58
+ "precision": "float32",
59
+ "exposure_s": 0.001,
60
+ "photon_rate_per_s": 2000000.0,
61
+ "device": "gpu",
62
+ "frames": 5011,
63
+ "elapsed_s": 2.0001363799965475,
64
+ "frame_s": 0.0003991491478739867,
65
+ "frames_per_s": 2505.329161608795,
66
+ "megapixels_per_s": 16.034106634296286
67
+ },
68
+ {
69
+ "workflow": "shack_hartmann_cmos_160",
70
+ "label": "Shack-Hartmann WFS CMOS",
71
+ "preset": "generic_cmos",
72
+ "sensor": "CMOS",
73
+ "shape": [
74
+ 160,
75
+ 160
76
+ ],
77
+ "precision": "float32",
78
+ "exposure_s": 0.001,
79
+ "photon_rate_per_s": 2000000.0,
80
+ "device": "cpu",
81
+ "frames": 1149,
82
+ "elapsed_s": 2.001656759006437,
83
+ "frame_s": 0.001742085952137891,
84
+ "frames_per_s": 574.0244898782395,
85
+ "megapixels_per_s": 14.69502694088293
86
+ },
87
+ {
88
+ "workflow": "shack_hartmann_cmos_160",
89
+ "label": "Shack-Hartmann WFS CMOS",
90
+ "preset": "generic_cmos",
91
+ "sensor": "CMOS",
92
+ "shape": [
93
+ 160,
94
+ 160
95
+ ],
96
+ "precision": "float32",
97
+ "exposure_s": 0.001,
98
+ "photon_rate_per_s": 2000000.0,
99
+ "device": "gpu",
100
+ "frames": 5026,
101
+ "elapsed_s": 2.000239181012148,
102
+ "frame_s": 0.0003979783487887282,
103
+ "frames_per_s": 2512.699504994586,
104
+ "megapixels_per_s": 64.3251073278614
105
+ },
106
+ {
107
+ "workflow": "ocam2k_emccd_240",
108
+ "label": "OCAM2K EMCCD",
109
+ "preset": "andor_ocam2k",
110
+ "sensor": "EMCCD",
111
+ "shape": [
112
+ 240,
113
+ 240
114
+ ],
115
+ "precision": "float32",
116
+ "exposure_s": 0.001,
117
+ "photon_rate_per_s": 2000000.0,
118
+ "device": "cpu",
119
+ "frames": 288,
120
+ "elapsed_s": 2.003861945006065,
121
+ "frame_s": 0.006957853975715504,
122
+ "frames_per_s": 143.72247585106385,
123
+ "megapixels_per_s": 8.278414609021278
124
+ },
125
+ {
126
+ "workflow": "ocam2k_emccd_240",
127
+ "label": "OCAM2K EMCCD",
128
+ "preset": "andor_ocam2k",
129
+ "sensor": "EMCCD",
130
+ "shape": [
131
+ 240,
132
+ 240
133
+ ],
134
+ "precision": "float32",
135
+ "exposure_s": 0.001,
136
+ "photon_rate_per_s": 2000000.0,
137
+ "device": "gpu",
138
+ "frames": 3420,
139
+ "elapsed_s": 2.0004429029941093,
140
+ "frame_s": 0.000584924825436874,
141
+ "frames_per_s": 1709.621401781179,
142
+ "megapixels_per_s": 98.47419274259589
143
+ },
144
+ {
145
+ "workflow": "saphira_eapd_256x320",
146
+ "label": "SAPHIRA eAPD",
147
+ "preset": "leonardo_saphira",
148
+ "sensor": "EAPD",
149
+ "shape": [
150
+ 256,
151
+ 320
152
+ ],
153
+ "precision": "float32",
154
+ "exposure_s": 0.001,
155
+ "photon_rate_per_s": 2000000.0,
156
+ "device": "cpu",
157
+ "frames": 228,
158
+ "elapsed_s": 2.0011549520131666,
159
+ "frame_s": 0.00877699540356652,
160
+ "frames_per_s": 113.93420572986187,
161
+ "megapixels_per_s": 9.333490133390285
162
+ },
163
+ {
164
+ "workflow": "saphira_eapd_256x320",
165
+ "label": "SAPHIRA eAPD",
166
+ "preset": "leonardo_saphira",
167
+ "sensor": "EAPD",
168
+ "shape": [
169
+ 256,
170
+ 320
171
+ ],
172
+ "precision": "float32",
173
+ "exposure_s": 0.001,
174
+ "photon_rate_per_s": 2000000.0,
175
+ "device": "gpu",
176
+ "frames": 3786,
177
+ "elapsed_s": 2.000311462994432,
178
+ "frame_s": 0.000528344284995888,
179
+ "frames_per_s": 1892.7052461782241,
180
+ "megapixels_per_s": 155.05041376692012
181
+ },
182
+ {
183
+ "workflow": "science_cmos_1024",
184
+ "label": "Large science CMOS",
185
+ "preset": "generic_cmos",
186
+ "sensor": "CMOS",
187
+ "shape": [
188
+ 1024,
189
+ 1024
190
+ ],
191
+ "precision": "float32",
192
+ "exposure_s": 5.0,
193
+ "photon_rate_per_s": 200.0,
194
+ "device": "cpu",
195
+ "frames": 27,
196
+ "elapsed_s": 2.000652825983707,
197
+ "frame_s": 0.07409825281421137,
198
+ "frames_per_s": 13.495594862504088,
199
+ "megapixels_per_s": 14.151156878545088
200
+ },
201
+ {
202
+ "workflow": "science_cmos_1024",
203
+ "label": "Large science CMOS",
204
+ "preset": "generic_cmos",
205
+ "sensor": "CMOS",
206
+ "shape": [
207
+ 1024,
208
+ 1024
209
+ ],
210
+ "precision": "float32",
211
+ "exposure_s": 5.0,
212
+ "photon_rate_per_s": 200.0,
213
+ "device": "gpu",
214
+ "frames": 525,
215
+ "elapsed_s": 2.2030622210004367,
216
+ "frame_s": 0.0041963089923817845,
217
+ "frames_per_s": 238.30466293484497,
218
+ "megapixels_per_s": 249.880550241568
219
+ }
220
+ ]
221
+ }