getframes 2.2.0__tar.gz → 2.3.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 (111) hide show
  1. {getframes-2.2.0 → getframes-2.3.0}/CHANGELOG.md +36 -1
  2. {getframes-2.2.0 → getframes-2.3.0}/PKG-INFO +3 -2
  3. {getframes-2.2.0 → getframes-2.3.0}/README.md +1 -1
  4. {getframes-2.2.0 → getframes-2.3.0}/pyproject.toml +1 -0
  5. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/__about__.py +1 -1
  6. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/frame.py +4 -2
  7. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/noise.py +1 -11
  8. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/scene/optics.py +3 -2
  9. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/scene/psf.py +47 -34
  10. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/scene/thermal.py +2 -5
  11. getframes-2.3.0/tests/test_conformance.py +83 -0
  12. {getframes-2.2.0 → getframes-2.3.0}/tests/test_scene.py +23 -0
  13. {getframes-2.2.0 → getframes-2.3.0}/.gitignore +0 -0
  14. {getframes-2.2.0 → getframes-2.3.0}/LICENSE +0 -0
  15. {getframes-2.2.0 → getframes-2.3.0}/benchmarks/__init__.py +0 -0
  16. {getframes-2.2.0 → getframes-2.3.0}/benchmarks/bench_detector_workspace.py +0 -0
  17. {getframes-2.2.0 → getframes-2.3.0}/benchmarks/bench_devices.py +0 -0
  18. {getframes-2.2.0 → getframes-2.3.0}/benchmarks/bench_fixed_map_dtype.py +0 -0
  19. {getframes-2.2.0 → getframes-2.3.0}/benchmarks/detector-workspace-results.json +0 -0
  20. {getframes-2.2.0 → getframes-2.3.0}/benchmarks/device-results.json +0 -0
  21. {getframes-2.2.0 → getframes-2.3.0}/benchmarks/device-results.md +0 -0
  22. {getframes-2.2.0 → getframes-2.3.0}/benchmarks/fixed-map-dtype-results.json +0 -0
  23. {getframes-2.2.0 → getframes-2.3.0}/benchmarks/render_device_table.py +0 -0
  24. {getframes-2.2.0 → getframes-2.3.0}/benchmarks/run.py +0 -0
  25. {getframes-2.2.0 → getframes-2.3.0}/examples/01_basic_dark_frame.py +0 -0
  26. {getframes-2.2.0 → getframes-2.3.0}/examples/02_custom_camera.py +0 -0
  27. {getframes-2.2.0 → getframes-2.3.0}/examples/03_master_dark.py +0 -0
  28. {getframes-2.2.0 → getframes-2.3.0}/examples/04_browse_presets.py +0 -0
  29. {getframes-2.2.0 → getframes-2.3.0}/examples/05_visualise.py +0 -0
  30. {getframes-2.2.0 → getframes-2.3.0}/examples/06_photon_transfer_curve.py +0 -0
  31. {getframes-2.2.0 → getframes-2.3.0}/examples/07_star_field_exposure.py +0 -0
  32. {getframes-2.2.0 → getframes-2.3.0}/examples/08_ao_limiting_magnitude.py +0 -0
  33. {getframes-2.2.0 → getframes-2.3.0}/examples/09_transit_photometry.py +0 -0
  34. {getframes-2.2.0 → getframes-2.3.0}/examples/10_detector_realism.py +0 -0
  35. {getframes-2.2.0 → getframes-2.3.0}/examples/11_radiometry_and_ir.py +0 -0
  36. {getframes-2.2.0 → getframes-2.3.0}/examples/12_ml_dataset.py +0 -0
  37. {getframes-2.2.0 → getframes-2.3.0}/examples/13_crowded_field.py +0 -0
  38. {getframes-2.2.0 → getframes-2.3.0}/examples/14_keck_lgs_ttf_trade_study.ipynb +0 -0
  39. {getframes-2.2.0 → getframes-2.3.0}/examples/15_detector_characterization.py +0 -0
  40. {getframes-2.2.0 → getframes-2.3.0}/examples/16_detector_showcase.py +0 -0
  41. {getframes-2.2.0 → getframes-2.3.0}/examples/README.md +0 -0
  42. {getframes-2.2.0 → getframes-2.3.0}/examples/_common.py +0 -0
  43. {getframes-2.2.0 → getframes-2.3.0}/examples/detector_showcase.webp +0 -0
  44. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/__init__.py +0 -0
  45. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/analysis/__init__.py +0 -0
  46. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/analysis/apertures.py +0 -0
  47. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/analysis/characterize.py +0 -0
  48. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/analysis/nondestructive.py +0 -0
  49. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/analysis/ptc.py +0 -0
  50. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/backend.py +0 -0
  51. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/calibrate.py +0 -0
  52. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/camera.py +0 -0
  53. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/cli.py +0 -0
  54. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/config.py +0 -0
  55. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/dataset.py +0 -0
  56. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/observation.py +0 -0
  57. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/__init__.py +0 -0
  58. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/__init__.py +0 -0
  59. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/andor_cb1_0_5mp.toml +0 -0
  60. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/andor_ikon_m934.toml +0 -0
  61. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/andor_ixon_ultra_888.toml +0 -0
  62. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/andor_marana_4_2b_11.toml +0 -0
  63. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/andor_ocam2k.toml +0 -0
  64. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/first_light_imaging_cred_one.toml +0 -0
  65. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/generic_ccd.toml +0 -0
  66. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/generic_cmos.toml +0 -0
  67. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/generic_eapd.toml +0 -0
  68. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/generic_emccd.toml +0 -0
  69. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/generic_scmos.toml +0 -0
  70. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/hamamatsu_orca_fusion.toml +0 -0
  71. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/hamamatsu_orca_quest_2.toml +0 -0
  72. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/leonardo_saphira.toml +0 -0
  73. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/nuvu_hnu_128_omega.toml +0 -0
  74. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/nuvu_hnu_240.toml +0 -0
  75. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/photometrics_prime_95b.toml +0 -0
  76. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/princeton_instruments_kuro_1200b.toml +0 -0
  77. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/qhy530_pro_ii.toml +0 -0
  78. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/scimeasure_little_joe_ccd39.toml +0 -0
  79. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/tucsen_aries_6504_pro.toml +0 -0
  80. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/presets/data/zwo_asi2600mm.toml +0 -0
  81. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/py.typed +0 -0
  82. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/scene/__init__.py +0 -0
  83. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/scene/photometry.py +0 -0
  84. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/scene/scene.py +0 -0
  85. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/scene/sources.py +0 -0
  86. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/scene/wcs.py +0 -0
  87. {getframes-2.2.0 → getframes-2.3.0}/src/getframes/spectral.py +0 -0
  88. {getframes-2.2.0 → getframes-2.3.0}/tests/test_analysis.py +0 -0
  89. {getframes-2.2.0 → getframes-2.3.0}/tests/test_benchmarks.py +0 -0
  90. {getframes-2.2.0 → getframes-2.3.0}/tests/test_calibrate.py +0 -0
  91. {getframes-2.2.0 → getframes-2.3.0}/tests/test_camera.py +0 -0
  92. {getframes-2.2.0 → getframes-2.3.0}/tests/test_characterize.py +0 -0
  93. {getframes-2.2.0 → getframes-2.3.0}/tests/test_cli.py +0 -0
  94. {getframes-2.2.0 → getframes-2.3.0}/tests/test_config.py +0 -0
  95. {getframes-2.2.0 → getframes-2.3.0}/tests/test_dataset.py +0 -0
  96. {getframes-2.2.0 → getframes-2.3.0}/tests/test_detector.py +0 -0
  97. {getframes-2.2.0 → getframes-2.3.0}/tests/test_frame.py +0 -0
  98. {getframes-2.2.0 → getframes-2.3.0}/tests/test_gain.py +0 -0
  99. {getframes-2.2.0 → getframes-2.3.0}/tests/test_gpu.py +0 -0
  100. {getframes-2.2.0 → getframes-2.3.0}/tests/test_noise.py +0 -0
  101. {getframes-2.2.0 → getframes-2.3.0}/tests/test_nondestructive_analysis.py +0 -0
  102. {getframes-2.2.0 → getframes-2.3.0}/tests/test_observation.py +0 -0
  103. {getframes-2.2.0 → getframes-2.3.0}/tests/test_presets.py +0 -0
  104. {getframes-2.2.0 → getframes-2.3.0}/tests/test_radiometry.py +0 -0
  105. {getframes-2.2.0 → getframes-2.3.0}/tests/test_realism.py +0 -0
  106. {getframes-2.2.0 → getframes-2.3.0}/tests/test_scale.py +0 -0
  107. {getframes-2.2.0 → getframes-2.3.0}/tests/test_scene_enrich.py +0 -0
  108. {getframes-2.2.0 → getframes-2.3.0}/tests/test_signal.py +0 -0
  109. {getframes-2.2.0 → getframes-2.3.0}/tests/test_spectral.py +0 -0
  110. {getframes-2.2.0 → getframes-2.3.0}/tests/test_validation.py +0 -0
  111. {getframes-2.2.0 → getframes-2.3.0}/tests/test_workspace.py +0 -0
@@ -6,6 +6,40 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.3.0] - 2026-10-07
10
+
11
+ ### Fixed
12
+
13
+ - **`MoffatPSF`, `EllipticalGaussianPSF` and `AiryPSF` no longer put light that
14
+ falls off the frame back onto it.** They normalised their stamp *after*
15
+ clipping it to the frame, so a source on the edge column deposited its full
16
+ flux instead of the part that lands on the detector (a Moffat star centred on
17
+ column 0 kept 100% of its light, where `GaussianPSF` correctly keeps about
18
+ half). The stamp is now normalised before clipping, which follows the AO stack
19
+ convention that light lost at a detector edge is lost, not renormalised
20
+ (aocore CONVENTIONS 3.3). Sources wholly inside the frame are unchanged.
21
+
22
+ ### Changed
23
+
24
+ - **Generic primitives now come from `aocore`.** getframes is part of an
25
+ adaptive-optics stack whose shared conventions and primitives live in
26
+ [`aocore`](https://github.com/jacotay7/aocore), now a core dependency
27
+ (`aocore>=0.1.2,<0.2`). `noise.block_sum` is `aocore.block_sum`, re-exported
28
+ so existing imports keep working; it accepts everything it did before and now
29
+ also takes a `(fy, fx)` factor and leading batch axes, and raises a clear
30
+ `ValueError` for a factor below 1 or a shape that does not divide (previously
31
+ a bare reshape error, or a `ZeroDivisionError` for a factor of 0).
32
+ `Frame.binned` sums through it. `AiryPSF` and `Thermal` take their radians per
33
+ arcsecond from `aocore.ARCSEC_TO_RAD`, and `Vignetting` its centred grid from
34
+ `aocore.coordinate_grid`. Every output is bit-for-bit unchanged.
35
+
36
+ ### Added
37
+
38
+ - **Conformance tests** (`tests/test_conformance.py`) run the
39
+ `aocore.conformance` checks that apply to a detector package: every PSF model
40
+ deposits unit flux, a source at `(n - 1) / 2` and the vignetting pattern are
41
+ centred on the optical axis for odd and even windows.
42
+
9
43
  ## [2.2.0] - 2026-08-24
10
44
 
11
45
  ### Added
@@ -558,7 +592,8 @@ together in 1.0.
558
592
  - Documentation, runnable examples, and CI (lint, type-check, test matrix, PyPI
559
593
  release via Trusted Publishing).
560
594
 
561
- [Unreleased]: https://github.com/jacotay7/getframes/compare/2.2.0...HEAD
595
+ [Unreleased]: https://github.com/jacotay7/getframes/compare/2.3.0...HEAD
596
+ [2.3.0]: https://github.com/jacotay7/getframes/compare/2.2.0...2.3.0
562
597
  [2.2.0]: https://github.com/jacotay7/getframes/compare/2.1.1...2.2.0
563
598
  [2.1.1]: https://github.com/jacotay7/getframes/compare/2.1.0...2.1.1
564
599
  [2.1.0]: https://github.com/jacotay7/getframes/compare/2.0.0...2.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: getframes
3
- Version: 2.2.0
3
+ Version: 2.3.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/
@@ -23,6 +23,7 @@ Classifier: Topic :: Scientific/Engineering :: Astronomy
23
23
  Classifier: Topic :: Scientific/Engineering :: Image Processing
24
24
  Classifier: Typing :: Typed
25
25
  Requires-Python: >=3.10
26
+ Requires-Dist: aocore<0.2,>=0.1.2
26
27
  Requires-Dist: astropy>=5.0
27
28
  Requires-Dist: numpy>=1.23
28
29
  Requires-Dist: scipy>=1.10
@@ -72,7 +73,7 @@ switches to CUDA (via CuPy) with a single argument.
72
73
  ## Install
73
74
 
74
75
  ```bash
75
- pip install getframes # CPU (NumPy + SciPy + astropy)
76
+ pip install getframes # CPU (NumPy + SciPy + astropy + aocore)
76
77
  pip install 'getframes[gpu]' # + CuPy for CUDA 12.x
77
78
  pip install -e '.[dev]' # from a clone, for development
78
79
  ```
@@ -27,7 +27,7 @@ switches to CUDA (via CuPy) with a single argument.
27
27
  ## Install
28
28
 
29
29
  ```bash
30
- pip install getframes # CPU (NumPy + SciPy + astropy)
30
+ pip install getframes # CPU (NumPy + SciPy + astropy + aocore)
31
31
  pip install 'getframes[gpu]' # + CuPy for CUDA 12.x
32
32
  pip install -e '.[dev]' # from a clone, for development
33
33
  ```
@@ -36,6 +36,7 @@ classifiers = [
36
36
  ]
37
37
  dynamic = ["version"]
38
38
  dependencies = [
39
+ "aocore>=0.1.2,<0.2",
39
40
  "numpy>=1.23",
40
41
  "scipy>=1.10",
41
42
  "astropy>=5.0",
@@ -1,4 +1,4 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  """Single source of truth for the package version."""
3
3
 
4
- __version__ = "2.2.0"
4
+ __version__ = "2.3.0"
@@ -7,6 +7,7 @@ from dataclasses import dataclass, field
7
7
  from typing import TYPE_CHECKING, Any
8
8
 
9
9
  import numpy as np
10
+ from aocore import block_sum
10
11
 
11
12
  from .backend import get_array_module, to_numpy
12
13
 
@@ -128,8 +129,9 @@ class Frame:
128
129
  if factor == 1:
129
130
  binned = data.copy()
130
131
  else:
131
- blocks = data.reshape(height // factor, factor, width // factor, factor)
132
- binned = blocks.sum(axis=(1, 3)) if method == "sum" else blocks.mean(axis=(1, 3))
132
+ binned = block_sum(data, factor)
133
+ if method == "mean":
134
+ binned = binned / (factor * factor)
133
135
  metadata = dict(self.metadata)
134
136
  metadata["binning"] = int(metadata.get("binning", 1)) * factor
135
137
  return Frame(data=binned, metadata=metadata, truth=None)
@@ -37,6 +37,7 @@ from contextlib import contextmanager
37
37
  from typing import TYPE_CHECKING, Any, NamedTuple
38
38
 
39
39
  import numpy as np
40
+ from aocore import block_sum
40
41
 
41
42
  from .backend import ArrayBackend, get_backend
42
43
 
@@ -1213,17 +1214,6 @@ def frame_electrons(
1213
1214
  return electrons
1214
1215
 
1215
1216
 
1216
- def block_sum(array: Any, factor: int) -> Any:
1217
- """Sum an array into ``factor x factor`` super-pixel blocks (both dims divisible)."""
1218
- if factor == 1:
1219
- return array
1220
- height, width = array.shape
1221
- binned: NDArray[Any] = array.reshape(height // factor, factor, width // factor, factor).sum(
1222
- axis=(1, 3)
1223
- )
1224
- return binned
1225
-
1226
-
1227
1217
  def simulate_frame(
1228
1218
  config: CameraConfig,
1229
1219
  photon_rate: PhotonRate,
@@ -9,6 +9,7 @@ from dataclasses import dataclass
9
9
  from typing import TYPE_CHECKING
10
10
 
11
11
  import numpy as np
12
+ from aocore import coordinate_grid
12
13
  from numpy.typing import NDArray
13
14
 
14
15
  from .photometry import Bandpass
@@ -41,8 +42,8 @@ class Vignetting:
41
42
  """Relative illumination in ``[0, 1]`` for a frame of ``(height, width)``."""
42
43
  height, width = shape
43
44
  cy, cx = (height - 1) / 2.0, (width - 1) / 2.0
44
- yy, xx = np.mgrid[0:height, 0:width]
45
- r = np.hypot(xx - cx, yy - cy)
45
+ yy, xx = coordinate_grid((height, width))
46
+ r = np.hypot(xx, yy)
46
47
  r_corner = math.hypot(max(cx, width - 1 - cx), max(cy, height - 1 - cy))
47
48
  if r_corner == 0:
48
49
  return np.ones(shape, dtype=np.float64)
@@ -11,9 +11,11 @@ sampled on a stamp and normalised.
11
11
  from __future__ import annotations
12
12
 
13
13
  import math
14
+ from collections.abc import Callable
14
15
  from dataclasses import dataclass
15
16
 
16
17
  import numpy as np
18
+ from aocore import ARCSEC_TO_RAD
17
19
  from numpy.typing import NDArray
18
20
  from scipy.ndimage import shift as _ndimage_shift
19
21
  from scipy.special import erf, j1
@@ -37,6 +39,33 @@ def _stamp_bounds(
37
39
  return x0, x1, y0, y1
38
40
 
39
41
 
42
+ def _deposit_sampled(
43
+ image: NDArray[np.float64],
44
+ x: float,
45
+ y: float,
46
+ radius: int,
47
+ flux: float,
48
+ profile_at: Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]],
49
+ ) -> None:
50
+ """Add a sampled, normalised stamp, losing whatever falls off the frame.
51
+
52
+ ``profile_at(dx, dy)`` evaluates the PSF at column/row offsets from the source.
53
+ The profile is normalised over the *whole* stamp before it is clipped to the
54
+ frame, so light off the edge is lost rather than renormalised back in.
55
+ """
56
+ x0, x1, y0, y1 = _stamp_bounds(x, y, radius, image.shape)
57
+ if x0 >= x1 or y0 >= y1:
58
+ return # source falls entirely off the frame
59
+ ix, iy = round(x), round(y)
60
+ offsets = np.arange(-radius, radius + 1, dtype=np.float64)
61
+ profile = profile_at(offsets[None, :] + (ix - x), offsets[:, None] + (iy - y))
62
+ total = profile.sum()
63
+ if total > 0:
64
+ sx, sy = x0 - (ix - radius), y0 - (iy - radius)
65
+ stamp = profile[sy : sy + (y1 - y0), sx : sx + (x1 - x0)]
66
+ image[y0:y1, x0:x1] += flux * stamp / total
67
+
68
+
40
69
  class PSF:
41
70
  """Base class for point-spread functions."""
42
71
 
@@ -193,17 +222,12 @@ class MoffatPSF(PSF):
193
222
  alpha = fwhm_pix / (2.0 * np.sqrt(2.0 ** (1.0 / self.beta) - 1.0))
194
223
 
195
224
  radius = int(np.ceil(6.0 * alpha)) + 1
196
- x0, x1, y0, y1 = _stamp_bounds(x, y, radius, image.shape)
197
- if x0 >= x1 or y0 >= y1:
198
- return
225
+ beta = self.beta
199
226
 
200
- xs = np.arange(x0, x1) - x
201
- ys = np.arange(y0, y1) - y
202
- rr = xs[None, :] ** 2 + ys[:, None] ** 2
203
- profile = (1.0 + rr / alpha**2) ** (-self.beta)
204
- total = profile.sum()
205
- if total > 0:
206
- image[y0:y1, x0:x1] += flux * profile / total
227
+ def profile(dx: NDArray[np.float64], dy: NDArray[np.float64]) -> NDArray[np.float64]:
228
+ return np.asarray((1.0 + (dx**2 + dy**2) / alpha**2) ** (-beta))
229
+
230
+ _deposit_sampled(image, x, y, radius, flux, profile)
207
231
 
208
232
 
209
233
  @dataclass(frozen=True)
@@ -238,20 +262,15 @@ class EllipticalGaussianPSF(PSF):
238
262
  raise ValueError("PSF FWHM and plate scale must be positive.")
239
263
 
240
264
  radius = int(np.ceil(5.0 * sigma_major)) + 1
241
- x0, x1, y0, y1 = _stamp_bounds(x, y, radius, image.shape)
242
- if x0 >= x1 or y0 >= y1:
243
- return
244
-
245
- xs = np.arange(x0, x1) - x
246
- ys = np.arange(y0, y1) - y
247
265
  theta = math.radians(self.position_angle_deg)
248
266
  cos_t, sin_t = math.cos(theta), math.sin(theta)
249
- u = xs[None, :] * cos_t + ys[:, None] * sin_t
250
- v = -xs[None, :] * sin_t + ys[:, None] * cos_t
251
- profile = np.exp(-0.5 * ((u / sigma_major) ** 2 + (v / sigma_minor) ** 2))
252
- total = profile.sum()
253
- if total > 0:
254
- image[y0:y1, x0:x1] += flux * profile / total
267
+
268
+ def profile(dx: NDArray[np.float64], dy: NDArray[np.float64]) -> NDArray[np.float64]:
269
+ u = dx * cos_t + dy * sin_t
270
+ v = -dx * sin_t + dy * cos_t
271
+ return np.exp(-0.5 * ((u / sigma_major) ** 2 + (v / sigma_minor) ** 2))
272
+
273
+ _deposit_sampled(image, x, y, radius, flux, profile)
255
274
 
256
275
 
257
276
  @dataclass(frozen=True)
@@ -293,23 +312,17 @@ class AiryPSF(PSF):
293
312
  raise ValueError("AiryPSF obstruction must be in [0, 1).")
294
313
 
295
314
  # Radians per pixel, then the argument scale x = pi D theta / lambda.
296
- rad_per_pixel = plate_scale_arcsec_per_pixel * (math.pi / 180.0 / 3600.0)
315
+ rad_per_pixel = plate_scale_arcsec_per_pixel * ARCSEC_TO_RAD
297
316
  arg_per_pixel = math.pi * self.aperture_diameter_m / self.wavelength_m * rad_per_pixel
298
317
  # First null at 1.22 lambda / D; size the stamp to a few Airy rings.
299
318
  first_null_pix = 1.22 / (arg_per_pixel / math.pi) if arg_per_pixel > 0 else 1.0
300
319
  radius = int(np.ceil(5.0 * first_null_pix)) + 1
301
- x0, x1, y0, y1 = _stamp_bounds(x, y, radius, image.shape)
302
- if x0 >= x1 or y0 >= y1:
303
- return
320
+ obstruction = self.obstruction
321
+
322
+ def profile(dx: NDArray[np.float64], dy: NDArray[np.float64]) -> NDArray[np.float64]:
323
+ return _airy_intensity(arg_per_pixel * np.hypot(dx, dy), obstruction)
304
324
 
305
- xs = np.arange(x0, x1) - x
306
- ys = np.arange(y0, y1) - y
307
- rr = np.sqrt(xs[None, :] ** 2 + ys[:, None] ** 2)
308
- arg = arg_per_pixel * rr
309
- profile = _airy_intensity(arg, self.obstruction)
310
- total = profile.sum()
311
- if total > 0:
312
- image[y0:y1, x0:x1] += flux * profile / total
325
+ _deposit_sampled(image, x, y, radius, flux, profile)
313
326
 
314
327
 
315
328
  def _airy_intensity(arg: NDArray[np.float64], obstruction: float) -> NDArray[np.float64]:
@@ -14,11 +14,11 @@ Pure NumPy, no randomness.
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- import math
18
17
  from dataclasses import dataclass
19
18
  from typing import TYPE_CHECKING
20
19
 
21
20
  import numpy as np
21
+ from aocore import ARCSEC_TO_RAD
22
22
  from numpy.typing import NDArray
23
23
 
24
24
  from ..spectral import SED
@@ -34,9 +34,6 @@ _H_PLANCK = 6.62607015e-34 # J s
34
34
  _C_LIGHT = 2.99792458e8 # m / s
35
35
  _K_BOLTZMANN = 1.380649e-23 # J / K
36
36
 
37
- # Arcseconds to radians, for the per-pixel solid angle.
38
- _ARCSEC_TO_RAD = math.pi / (180.0 * 3600.0)
39
-
40
37
 
41
38
  def _photon_radiance(
42
39
  wavelength_m: NDArray[np.float64], temperature_k: float
@@ -96,7 +93,7 @@ class Thermal:
96
93
  wl_m = resp.wavelength_nm * 1e-9
97
94
  integrand = _photon_radiance(wl_m, self.temperature_k) * resp.value
98
95
  radiance = float(_trapezoid(integrand, wl_m)) # photons/s/m^2/sr
99
- omega_sr = (optics.plate_scale_arcsec_per_pixel * _ARCSEC_TO_RAD) ** 2
96
+ omega_sr = (optics.plate_scale_arcsec_per_pixel * ARCSEC_TO_RAD) ** 2
100
97
  return self.emissivity * radiance * optics.collecting_area_m2 * omega_sr
101
98
 
102
99
  def photon_sed(
@@ -0,0 +1,83 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """The AO stack conventions (aocore ``CONVENTIONS.md``) that apply to getframes.
3
+
4
+ getframes owns detectors, not wavefronts: its PSFs are analytic profiles (or a
5
+ user kernel), not images of an OPD map. So the OPD-driven checks (tilt direction,
6
+ slope sign, wind motion, Zernike basis, RMS) do not apply. What does apply is the
7
+ flat-wavefront case: where the optical axis sits in the image plane (rule 1.3) and
8
+ that a normalized point-source image carries unit flux (rule 3.3). The callables
9
+ below take the checks' OPD argument for its shape only.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ import aocore
17
+ import numpy as np
18
+ import pytest
19
+ from aocore import conformance
20
+
21
+ from getframes.scene import (
22
+ AiryPSF,
23
+ ArrayPSF,
24
+ EllipticalGaussianPSF,
25
+ GaussianPSF,
26
+ MoffatPSF,
27
+ Vignetting,
28
+ )
29
+ from getframes.scene.psf import PSF
30
+
31
+ PLATE_SCALE_ARCSEC = 0.01
32
+
33
+
34
+ def _psfs() -> list[PSF]:
35
+ kernel = np.exp(-0.5 * ((np.arange(15.0) - 7.0) / 2.0) ** 2)
36
+ return [
37
+ GaussianPSF(fwhm_arcsec=0.04),
38
+ MoffatPSF(fwhm_arcsec=0.04, beta=3.0),
39
+ EllipticalGaussianPSF(fwhm_major_arcsec=0.05, fwhm_minor_arcsec=0.03),
40
+ # 8 m aperture at 1.6 um: lambda / D = 0.041 arcsec, about 4 pixels.
41
+ AiryPSF(aperture_diameter_m=8.0, wavelength_m=1.6e-6),
42
+ ArrayPSF(kernel=np.outer(kernel, kernel)),
43
+ ]
44
+
45
+
46
+ def _on_axis_image(psf: PSF, shape: tuple[int, ...]) -> np.ndarray[Any, Any]:
47
+ """A unit-flux point source on the optical axis ``(n - 1) / 2`` (rule 1.3)."""
48
+ height, width = shape
49
+ image = np.zeros((height, width))
50
+ psf.add_source(image, (width - 1) / 2.0, (height - 1) / 2.0, 1.0, PLATE_SCALE_ARCSEC)
51
+ return image
52
+
53
+
54
+ @pytest.mark.parametrize("psf", _psfs(), ids=lambda psf: type(psf).__name__)
55
+ def test_point_source_image_carries_unit_flux(psf: PSF) -> None:
56
+ # Rule 3.3: the window holds all of the light, so the image sums to the flux.
57
+ conformance.check_unit_flux(lambda opd: _on_axis_image(psf, opd.shape), pupil_shape=(96, 96))
58
+
59
+
60
+ @pytest.mark.parametrize("psf", _psfs(), ids=lambda psf: type(psf).__name__)
61
+ @pytest.mark.parametrize("shape", [(64, 64), (65, 65), (64, 65)])
62
+ def test_pixel_coordinates_put_the_axis_on_pixel_centres(psf: PSF, shape: tuple[int, int]) -> None:
63
+ # Rules 1.2-1.3: getframes positions are pixel-centre indices, so a source at
64
+ # ``(n - 1) / 2`` images to the window centre, between pixels for even n.
65
+ conformance.check_image_centring(lambda opd: _on_axis_image(psf, opd.shape), pupil_shape=shape)
66
+
67
+
68
+ @pytest.mark.parametrize("shape", [(64, 64), (65, 65), (64, 65)])
69
+ def test_vignetting_is_centred_on_the_optical_axis(shape: tuple[int, int]) -> None:
70
+ # Rule 1.3: the field-dependent illumination falls off about ``(n - 1) / 2``.
71
+ vignetting = Vignetting(strength=0.4, power=2.0)
72
+ conformance.check_image_centring(
73
+ lambda opd: vignetting.illumination_map(opd.shape), pupil_shape=shape
74
+ )
75
+
76
+
77
+ def test_arcsecond_constant_is_the_shared_one() -> None:
78
+ # Rule 2.3: AiryPSF and the thermal background take their radians per
79
+ # arcsecond from aocore rather than a local literal.
80
+ from getframes.scene import psf, thermal
81
+
82
+ assert psf.ARCSEC_TO_RAD is aocore.ARCSEC_TO_RAD
83
+ assert thermal.ARCSEC_TO_RAD is aocore.ARCSEC_TO_RAD
@@ -61,6 +61,29 @@ def test_psf_conserves_flux(psf, optics):
61
61
  assert img.sum() == pytest.approx(1000.0, rel=1e-3)
62
62
 
63
63
 
64
+ @pytest.mark.parametrize(
65
+ "psf",
66
+ [
67
+ gf.GaussianPSF(fwhm_arcsec=1.2),
68
+ gf.MoffatPSF(fwhm_arcsec=1.2),
69
+ gf.EllipticalGaussianPSF(fwhm_major_arcsec=1.6, fwhm_minor_arcsec=0.8),
70
+ # lambda / D = 2 arcsec, five pixels.
71
+ gf.AiryPSF(aperture_diameter_m=0.1, wavelength_m=1.0e-6),
72
+ ],
73
+ ids=lambda psf: type(psf).__name__,
74
+ )
75
+ def test_psf_loses_the_flux_that_falls_off_the_frame(psf):
76
+ # Light beyond the detector edge is lost, never renormalised back in
77
+ # (aocore CONVENTIONS 3.3). A source on column 0 of a frame must deposit
78
+ # exactly the part of a fully captured image that lies at x >= 0.
79
+ full = np.zeros((128, 128))
80
+ psf.add_source(full, x=64.3, y=64.0, flux=1000.0, plate_scale_arcsec_per_pixel=0.4)
81
+ edge = np.zeros((128, 64))
82
+ psf.add_source(edge, x=0.3, y=64.0, flux=1000.0, plate_scale_arcsec_per_pixel=0.4)
83
+ assert edge.sum() == pytest.approx(full[:, 64:].sum(), rel=1e-9)
84
+ assert edge.sum() < 0.75 * full.sum()
85
+
86
+
64
87
  def test_psf_peaks_at_source_position(optics):
65
88
  img = np.zeros((64, 64))
66
89
  gf.GaussianPSF(1.0).add_source(
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes