hbtsim 0.2.0__py3-none-any.whl

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 (163) hide show
  1. hbtsim/__init__.py +27 -0
  2. hbtsim/__main__.py +35 -0
  3. hbtsim/aperture.py +315 -0
  4. hbtsim/bispectrum.py +396 -0
  5. hbtsim/catalog/__init__.py +285 -0
  6. hbtsim/catalog/build.py +347 -0
  7. hbtsim/catalog/cli.py +90 -0
  8. hbtsim/catalog/registry.py +113 -0
  9. hbtsim/catalog/schema.py +389 -0
  10. hbtsim/chromatic.py +289 -0
  11. hbtsim/cli.py +44 -0
  12. hbtsim/cli_common.py +143 -0
  13. hbtsim/configs/arrays/c2pu_pair.json +21 -0
  14. hbtsim/configs/arrays/eonsii_pair_teide.json +23 -0
  15. hbtsim/configs/arrays/eonsii_triangle_paranal.json +24 -0
  16. hbtsim/configs/arrays/keck_pair.json +26 -0
  17. hbtsim/configs/arrays/lpqi_orm.json +57 -0
  18. hbtsim/configs/arrays/lpqi_pathfinder.json +29 -0
  19. hbtsim/configs/arrays/magic_lst1.json +39 -0
  20. hbtsim/configs/arrays/maunakea_subaru_keck.json +33 -0
  21. hbtsim/configs/arrays/veritas.json +43 -0
  22. hbtsim/configs/arrays/vlt_ut.json +44 -0
  23. hbtsim/configs/backends/eonsii_mcp.json +11 -0
  24. hbtsim/configs/backends/eonsii_mcp_pbs.json +11 -0
  25. hbtsim/configs/backends/eonsii_r7500_spad.json +11 -0
  26. hbtsim/configs/backends/eonsii_spad.json +11 -0
  27. hbtsim/configs/backends/eonsii_spad_pbs.json +11 -0
  28. hbtsim/configs/backends/kk_1000ch.json +11 -0
  29. hbtsim/configs/backends/lpqi_500.json +13 -0
  30. hbtsim/configs/backends/lpqi_550.json +13 -0
  31. hbtsim/configs/backends/lpqi_halpha.json +13 -0
  32. hbtsim/configs/backends/lpqi_hbeta.json +13 -0
  33. hbtsim/configs/backends/lpqi_nextgen_500.json +13 -0
  34. hbtsim/configs/backends/lpqi_nextgen_550.json +13 -0
  35. hbtsim/configs/backends/lpqi_nextgen_halpha.json +13 -0
  36. hbtsim/configs/backends/lpqi_nextgen_hbeta.json +13 -0
  37. hbtsim/configs/backends/magic_sii.json +20 -0
  38. hbtsim/configs/backends/r5000_correlator.json +11 -0
  39. hbtsim/configs/backends/r5000_correlator_pbs.json +11 -0
  40. hbtsim/configs/backends/spad320_correlator.json +11 -0
  41. hbtsim/configs/backends/spad320_timetag.json +11 -0
  42. hbtsim/configs/backends/veritas_sii.json +35 -0
  43. hbtsim/configs/bands/photometric.json +55 -0
  44. hbtsim/configs/campaigns/chromatic_c2pu_regression.json +40 -0
  45. hbtsim/configs/campaigns/chromatic_sirius_vega_eonsii.json +39 -0
  46. hbtsim/configs/campaigns/g2_binaries_lpqi_pathfinder.json +45 -0
  47. hbtsim/configs/campaigns/g2_c2pu.json +39 -0
  48. hbtsim/configs/campaigns/g2_eonsii.json +40 -0
  49. hbtsim/configs/campaigns/g2_keck.json +38 -0
  50. hbtsim/configs/campaigns/g3_algol_maunakea.json +38 -0
  51. hbtsim/configs/campaigns/g3_betaaur_maunakea.json +36 -0
  52. hbtsim/configs/campaigns/g3_deltavel_eonsii_paranal.json +54 -0
  53. hbtsim/configs/campaigns/g3_deltavel_vlt.json +36 -0
  54. hbtsim/configs/campaigns/g3_spica_vlt.json +36 -0
  55. hbtsim/configs/campaigns/mc_sirius_b_eonsii.json +56 -0
  56. hbtsim/configs/campaigns/mc_sirius_b_lpqi_pathfinder.json +42 -0
  57. hbtsim/configs/campaigns/redclump_ii_dwarf.json +56 -0
  58. hbtsim/configs/campaigns/redclump_ii_supergiant.json +56 -0
  59. hbtsim/configs/campaigns/suite_phase6.json +92 -0
  60. hbtsim/configs/detectors/eonsii_mcp_pmt.json +41 -0
  61. hbtsim/configs/detectors/eonsii_spad.json +43 -0
  62. hbtsim/configs/detectors/eonsii_spad_correlator.json +13 -0
  63. hbtsim/configs/detectors/kk_ideal.json +27 -0
  64. hbtsim/configs/detectors/lpqi_spad64_i2cass.json +39 -0
  65. hbtsim/configs/detectors/lpqi_spad_nextgen.json +73 -0
  66. hbtsim/configs/detectors/magic_pmt.json +27 -0
  67. hbtsim/configs/detectors/spad_lambda.json +75 -0
  68. hbtsim/configs/detectors/spad_lambda_ng.json +13 -0
  69. hbtsim/configs/detectors/veritas_pmt.json +27 -0
  70. hbtsim/configs/ld_tables/claret11_algol_a.json +45 -0
  71. hbtsim/configs/ld_tables/claret11_algol_b.json +45 -0
  72. hbtsim/configs/ld_tables/claret11_betaaur.json +35 -0
  73. hbtsim/configs/ld_tables/claret11_spica.json +39 -0
  74. hbtsim/configs/ld_tables/k_giant_rough.json +47 -0
  75. hbtsim/configs/resources/newera.json +20 -0
  76. hbtsim/configs/resources/newera_redclump.json +19 -0
  77. hbtsim/configs/sites/calern.json +15 -0
  78. hbtsim/configs/sites/flwo.json +11 -0
  79. hbtsim/configs/sites/maunakea.json +11 -0
  80. hbtsim/configs/sites/orm.json +11 -0
  81. hbtsim/configs/sites/orm_not.json +15 -0
  82. hbtsim/configs/sites/paranal.json +13 -0
  83. hbtsim/configs/sites/teide.json +13 -0
  84. hbtsim/configs/spectrographs/eonsii_1000ch.json +16 -0
  85. hbtsim/configs/spectrographs/eonsii_60ch.json +14 -0
  86. hbtsim/configs/spectrographs/eonsii_r7500.json +16 -0
  87. hbtsim/configs/spectrographs/filter_2mass_h.json +16 -0
  88. hbtsim/configs/spectrographs/filter_2mass_k.json +16 -0
  89. hbtsim/configs/spectrographs/filter_cousins_i.json +16 -0
  90. hbtsim/configs/spectrographs/filter_cousins_r.json +16 -0
  91. hbtsim/configs/spectrographs/filter_johnson_b.json +16 -0
  92. hbtsim/configs/spectrographs/filter_johnson_v.json +16 -0
  93. hbtsim/configs/spectrographs/filter_lpqi_500_1nm.json +21 -0
  94. hbtsim/configs/spectrographs/filter_lpqi_550_1nm.json +21 -0
  95. hbtsim/configs/spectrographs/filter_lpqi_halpha_1nm.json +21 -0
  96. hbtsim/configs/spectrographs/filter_lpqi_hbeta_1nm.json +21 -0
  97. hbtsim/configs/spectrographs/hbeta_window_r5000.json +14 -0
  98. hbtsim/configs/spectrographs/kk_1000ch_400_950.json +14 -0
  99. hbtsim/configs/spectrographs/r5000_400_950.json +14 -0
  100. hbtsim/configs/spectrographs/spad_lambda_320.json +16 -0
  101. hbtsim/configs/targets/40eri_b.json +34 -0
  102. hbtsim/configs/targets/algol.json +72 -0
  103. hbtsim/configs/targets/betaaur.json +72 -0
  104. hbtsim/configs/targets/deltavel.json +72 -0
  105. hbtsim/configs/targets/gammacas.json +46 -0
  106. hbtsim/configs/targets/hd17652.json +46 -0
  107. hbtsim/configs/targets/hd360.json +46 -0
  108. hbtsim/configs/targets/procyon_b.json +29 -0
  109. hbtsim/configs/targets/sirius_a.json +32 -0
  110. hbtsim/configs/targets/sirius_b.json +37 -0
  111. hbtsim/configs/targets/spica.json +71 -0
  112. hbtsim/configs/targets/vega.json +30 -0
  113. hbtsim/configs/telescopes/c2pu_1m.json +12 -0
  114. hbtsim/configs/telescopes/eonsii_4m.json +18 -0
  115. hbtsim/configs/telescopes/gtc_10p4m.json +19 -0
  116. hbtsim/configs/telescopes/int_2p54m.json +17 -0
  117. hbtsim/configs/telescopes/keck_10m.json +12 -0
  118. hbtsim/configs/telescopes/kk_4m.json +14 -0
  119. hbtsim/configs/telescopes/lst1_23m.json +17 -0
  120. hbtsim/configs/telescopes/magic_17m.json +13 -0
  121. hbtsim/configs/telescopes/not_2p56m.json +17 -0
  122. hbtsim/configs/telescopes/subaru_8p2m.json +12 -0
  123. hbtsim/configs/telescopes/tng_3p58m.json +17 -0
  124. hbtsim/configs/telescopes/veritas_12m.json +18 -0
  125. hbtsim/configs/telescopes/vlt_ut_8p2m.json +10 -0
  126. hbtsim/configs/telescopes/wht_4p2m.json +18 -0
  127. hbtsim/data.py +382 -0
  128. hbtsim/diameter.py +104 -0
  129. hbtsim/estimators.py +107 -0
  130. hbtsim/fftmap.py +105 -0
  131. hbtsim/g2spec.py +297 -0
  132. hbtsim/geometry.py +111 -0
  133. hbtsim/hbt.py +278 -0
  134. hbtsim/iact.py +210 -0
  135. hbtsim/limbdark.py +194 -0
  136. hbtsim/montecarlo.py +370 -0
  137. hbtsim/movie.py +272 -0
  138. hbtsim/orbit.py +162 -0
  139. hbtsim/params.py +450 -0
  140. hbtsim/photometry.py +102 -0
  141. hbtsim/render.py +168 -0
  142. hbtsim/run_campaign.py +233 -0
  143. hbtsim/runners/__init__.py +30 -0
  144. hbtsim/runners/chromatic.py +215 -0
  145. hbtsim/runners/g2.py +301 -0
  146. hbtsim/runners/g3.py +359 -0
  147. hbtsim/runners/g3_campaign.py +334 -0
  148. hbtsim/runners/montecarlo.py +195 -0
  149. hbtsim/runners/scale.py +253 -0
  150. hbtsim/runners/suite.py +70 -0
  151. hbtsim/sed.py +698 -0
  152. hbtsim/serialize.py +87 -0
  153. hbtsim/single.py +284 -0
  154. hbtsim/snr.py +629 -0
  155. hbtsim/snr3.py +805 -0
  156. hbtsim/snr_cli.py +191 -0
  157. hbtsim/spectral.py +148 -0
  158. hbtsim-0.2.0.dist-info/METADATA +635 -0
  159. hbtsim-0.2.0.dist-info/RECORD +163 -0
  160. hbtsim-0.2.0.dist-info/WHEEL +5 -0
  161. hbtsim-0.2.0.dist-info/entry_points.txt +8 -0
  162. hbtsim-0.2.0.dist-info/licenses/LICENSE +28 -0
  163. hbtsim-0.2.0.dist-info/top_level.txt +1 -0
hbtsim/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """hbtsim: Hanbury Brown-Twiss intensity-interferometry simulation of
2
+ binary and single stars.
3
+
4
+ Targets, telescopes, detectors, spectrographs, backends, sites, arrays and
5
+ campaigns are JSON definitions in hbtsim/configs, loaded through
6
+ hbtsim.catalog:
7
+
8
+ from hbtsim import load_target, load_array
9
+ spica = load_target("spica")
10
+ vlt = load_array("vlt_ut")
11
+ """
12
+
13
+ from importlib.metadata import PackageNotFoundError, version as _version
14
+
15
+ try:
16
+ __version__ = _version("hbtsim")
17
+ except PackageNotFoundError: # a source tree that was never installed
18
+ __version__ = "0+unknown"
19
+
20
+ from .catalog import (Catalog, load_array, load_backend, load_campaign, load_detector,
21
+ load_site, load_spectrograph, load_target, load_telescope, load_triangle)
22
+ from .params import BinarySystem, DiskTarget, GridConfig, MovieConfig, Star
23
+ from .spectral import spectral_vis2
24
+
25
+ __all__ = ["__version__", "Catalog", "load_array", "load_backend", "load_campaign", "load_detector",
26
+ "load_site", "load_spectrograph", "load_target", "load_telescope", "load_triangle",
27
+ "BinarySystem", "DiskTarget", "GridConfig", "MovieConfig", "Star", "spectral_vis2"]
hbtsim/__main__.py ADDED
@@ -0,0 +1,35 @@
1
+ """`python -m hbtsim` / `hbtsim`: movie | snr | g2spec | catalog | run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ COMMANDS = {
8
+ "movie": ("hbtsim.cli", "main", "render the three-panel orbit / lightcurve / g2(B) movie"),
9
+ "snr": ("hbtsim.snr_cli", "main", "photon budget and g2 SNR for a binary on a two-telescope instrument"),
10
+ "g2spec": ("hbtsim.g2spec", "main", "channelized g2 spectrum movie"),
11
+ "catalog": ("hbtsim.catalog.cli", "main", "list / show / validate / dump the JSON catalog"),
12
+ "run": ("hbtsim.run_campaign", "main", "run a catalog campaign (hbtsim/runners)"),
13
+ "data": ("hbtsim.data", "main", "fetch / list the NewEra model tables (user cache, NERSC portal)"),
14
+ }
15
+
16
+
17
+ def main(argv=None) -> int:
18
+ argv = sys.argv[1:] if argv is None else list(argv)
19
+ if not argv or argv[0] in ("-h", "--help"):
20
+ print("usage: hbtsim <command> [options]\n\ncommands:")
21
+ for k, (_, _, doc) in COMMANDS.items():
22
+ print(f" {k:10s} {doc}")
23
+ return 0 if argv else 2
24
+ cmd, rest = argv[0], argv[1:]
25
+ if cmd not in COMMANDS:
26
+ print(f"hbtsim: unknown command {cmd!r}; one of {list(COMMANDS)}", file=sys.stderr)
27
+ return 2
28
+ import importlib
29
+ mod, fn, _ = COMMANDS[cmd]
30
+ out = getattr(importlib.import_module(mod), fn)(rest)
31
+ return 0 if out is None else int(out)
32
+
33
+
34
+ if __name__ == "__main__":
35
+ sys.exit(main())
hbtsim/aperture.py ADDED
@@ -0,0 +1,315 @@
1
+ """Finite-aperture averaging of the intensity correlations.
2
+
3
+ A telescope of diameter D does not sample V at one point of the (u, v)
4
+ plane. The intensity it collects is the integral of |E|^2 over its
5
+ pupil, so the cross-correlation of two telescopes at vector separation B
6
+ is the average of |gamma|^2 over every pair of pupil points,
7
+
8
+ <|V|^2>(B) = int |V(B + s)|^2 W(s) d^2s / (A1 A2),
9
+
10
+ with W(s) the cross-correlation of the two pupil functions -- for two
11
+ circles, the overlap area of circles of diameters D1 and D2 whose
12
+ centres are |s| apart (the "Chinese hat", support |s| < (D1 + D2)/2).
13
+ For a binary this is what suppresses the fringe: a fringe of period
14
+ P = lambda/rho sampled with pupils D1, D2 keeps only the fraction
15
+ A(pi D1/P) A(pi D2/P) of its contrast, A(x) = 2 J1(x)/x (the Airy
16
+ amplitude), which is 0.66 for Keck (10 m) on Beta Aur at 400 nm
17
+ (P ~ 25 m) and 0.46 for the VLT UTs on delta Vel at maximum separation.
18
+
19
+ The triple correlation couples all three pupils,
20
+
21
+ <g12 g23 g31> = sum_{i,j,k} w_i w_j w_k
22
+ gamma(B12 + r_j - r_i) gamma(B23 + r_k - r_j)
23
+ gamma(B31 + r_i - r_k),
24
+
25
+ with r_i, r_j, r_k running over quadrature points of pupils 1, 2, 3.
26
+ Smearing each gamma separately with its pair kernel is NOT equivalent
27
+ (it would apply every pupil twice) and over-attenuates; the exact
28
+ three-pupil average needs 3 m^2 visibility samples and an m^3
29
+ contraction, which the exact DFT core makes cheap (m = 14 -> 588
30
+ points per channel). The pair-smeared |gamma_ij|^2 that sets the pair
31
+ "ridges" of the triple correlation falls out of the same samples.
32
+
33
+ Quadrature. Pair kernel: Gauss-Legendre in |s| on [0, (D1 + D2)/2]
34
+ weighted by W(s)|s|, times equally spaced angles (product rule; weights
35
+ renormalized to sum to one so a constant |V|^2 is smeared exactly).
36
+ Pupil points: Gauss-Legendre in (r/R)^2 (uniform in area) times
37
+ equally spaced angles, rings staggered. Accuracy against the closed
38
+ forms above is checked in tests/test_aperture.py: the pair rule (60
39
+ points) is good to 3e-4 and the three-pupil rule (14 points per pupil)
40
+ to 1e-5 out to D/P = 0.7-0.8. Beyond that the node counts are scaled
41
+ up in proportion to D/P (quadrature_order, pupil_pair_quadrature_for,
42
+ TripleQuadrature.for_fringe), with a warning, and refused above
43
+ D/P = 3 where the fringe is unmeasurable anyway. delta Vel at its
44
+ correct distance (25.1 pc, Merand et al. 2011) is the case that needs
45
+ this: fringe period 4.7 m at 400 nm against the 8.2 m VLT pupils,
46
+ D/P = 1.74, contrast retained ~0.02.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import warnings
52
+
53
+ from dataclasses import dataclass
54
+
55
+ import numpy as np
56
+ from scipy.special import j1
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Closed forms
61
+ # ---------------------------------------------------------------------------
62
+ def circle_overlap_area(r1: float, r2: float, d) -> np.ndarray:
63
+ """Lens area common to two circles of radii r1, r2 with centres d
64
+ apart (vectorized in d; 0 beyond contact, the smaller disk inside
65
+ the larger when d <= |r1 - r2|)."""
66
+ d = np.asarray(d, dtype=float)
67
+ rmin, rmax = min(r1, r2), max(r1, r2)
68
+ out = np.zeros_like(d)
69
+ inside = d <= rmax - rmin
70
+ out[inside] = np.pi * rmin**2
71
+ lens = (~inside) & (d < r1 + r2)
72
+ dl = d[lens]
73
+ a1 = r1**2 * np.arccos(np.clip((dl**2 + r1**2 - r2**2) / (2 * dl * r1), -1, 1))
74
+ a2 = r2**2 * np.arccos(np.clip((dl**2 + r2**2 - r1**2) / (2 * dl * r2), -1, 1))
75
+ a3 = 0.5 * np.sqrt(np.maximum(
76
+ (-dl + r1 + r2) * (dl + r1 - r2) * (dl - r1 + r2) * (dl + r1 + r2), 0.0))
77
+ out[lens] = a1 + a2 - a3
78
+ return out
79
+
80
+
81
+ def airy_amplitude(x) -> np.ndarray:
82
+ """2 J1(x) / x: the Fourier transform of a uniform circular pupil,
83
+ normalized to 1 at x = 0."""
84
+ x = np.asarray(x, dtype=float)
85
+ xs = np.where(x == 0.0, 1.0, x)
86
+ return np.where(x == 0.0, 1.0, 2.0 * j1(xs) / xs)
87
+
88
+
89
+ def fringe_smearing_factor(d1_m: float, d2_m: float, rho_rad: float,
90
+ wavelength_m: float) -> float:
91
+ """Contrast retained by a pure fringe of period lambda/rho when
92
+ sampled with pupils D1, D2: A(pi D1 rho/lambda) A(pi D2 rho/lambda)."""
93
+ x = np.pi * rho_rad / wavelength_m
94
+ return float(airy_amplitude(x * d1_m) * airy_amplitude(x * d2_m))
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Quadratures
99
+ # ---------------------------------------------------------------------------
100
+ @dataclass(frozen=True)
101
+ class PupilQuadrature:
102
+ """Offsets (M, 2) [m] and weights (M,) (sum 1) that turn point samples
103
+ |V(B + offsets)|^2 into the pupil-pair average."""
104
+ offsets_m: np.ndarray
105
+ weights: np.ndarray
106
+ diameters_m: tuple
107
+
108
+ @property
109
+ def n_points(self) -> int:
110
+ return self.weights.size
111
+
112
+ def points(self, bvecs_m) -> np.ndarray:
113
+ """(K, M, 2): every baseline vector plus every offset."""
114
+ b = np.atleast_2d(np.asarray(bvecs_m, dtype=float))
115
+ return b[:, None, :] + self.offsets_m[None, :, :]
116
+
117
+ def reduce(self, values):
118
+ """Weighted average over the offset axis (the last axis of
119
+ `values`, e.g. |V|^2 of shape (..., K, M) -> (..., K))."""
120
+ return np.tensordot(np.asarray(values), self.weights, axes=([-1], [0]))
121
+
122
+
123
+ def pupil_pair_quadrature(d1_m: float, d2_m: float, n_r: int = 5,
124
+ n_theta: int = 12) -> PupilQuadrature:
125
+ """Quadrature of the pupil cross-correlation W(s) of two circular
126
+ apertures (see the module docstring)."""
127
+ r1, r2 = d1_m / 2.0, d2_m / 2.0
128
+ s_max = r1 + r2
129
+ t, wt = np.polynomial.legendre.leggauss(n_r)
130
+ s = 0.5 * s_max * (t + 1.0)
131
+ ws = 0.5 * s_max * wt * circle_overlap_area(r1, r2, s) * s
132
+ th = 2.0 * np.pi * (np.arange(n_theta) + 0.5) / n_theta
133
+ # stagger successive rings by half a step
134
+ ang = th[None, :] + (np.arange(n_r)[:, None] % 2) * np.pi / n_theta
135
+ off = np.stack([s[:, None] * np.cos(ang), s[:, None] * np.sin(ang)],
136
+ axis=-1).reshape(-1, 2)
137
+ w = np.repeat(ws, n_theta)
138
+ return PupilQuadrature(offsets_m=off, weights=w / w.sum(),
139
+ diameters_m=(d1_m, d2_m))
140
+
141
+
142
+ VALIDATED_PAIR_D_OVER_P = 0.8 # test_pair_quadrature_vs_airy coverage
143
+ VALIDATED_TRIPLE_D_OVER_P = 0.7 # test_triple_quadrature_vs_closed_form
144
+ MAX_D_OVER_P = 3.0 # refuse beyond: fringe contrast < 1e-3
145
+
146
+
147
+ def quadrature_order(d_over_p: float, base=(5, 12),
148
+ validated: float = VALIDATED_PAIR_D_OVER_P) -> tuple:
149
+ """(n_r, n_theta) for a pupil-averaging rule at pupil-to-fringe-period
150
+ ratio D/P: the base rule up to the validated ratio, then both node
151
+ counts scaled in proportion (the integrand oscillates ~D/P times
152
+ across the pupil). Raises for D/P > MAX_D_OVER_P."""
153
+ d_over_p = float(d_over_p)
154
+ if not np.isfinite(d_over_p) or d_over_p < 0:
155
+ raise ValueError(f"D/P must be finite and >= 0, got {d_over_p}")
156
+ if d_over_p > MAX_D_OVER_P:
157
+ raise ValueError(f"pupil/fringe-period ratio D/P = {d_over_p:.2f} > "
158
+ f"{MAX_D_OVER_P}: the fringe is smeared below 1e-3 and "
159
+ f"the pupil quadrature is untested there")
160
+ f = max(1.0, d_over_p / validated)
161
+ return int(np.ceil(base[0] * f)), int(np.ceil(base[1] * f))
162
+
163
+
164
+ def pupil_pair_quadrature_for(d1_m: float, d2_m: float,
165
+ fringe_period_m: float | None) -> PupilQuadrature:
166
+ """pupil_pair_quadrature with the node counts chosen for the fringe
167
+ period P = lambda/rho the pupils will be averaging over (the base rule
168
+ when P is None). Warns when D/P exceeds the validated range."""
169
+ if fringe_period_m is None or not np.isfinite(fringe_period_m) or fringe_period_m <= 0:
170
+ return pupil_pair_quadrature(d1_m, d2_m)
171
+ d_over_p = max(d1_m, d2_m) / float(fringe_period_m)
172
+ n_r, n_theta = quadrature_order(d_over_p)
173
+ if d_over_p > VALIDATED_PAIR_D_OVER_P:
174
+ warnings.warn(f"pupil/fringe-period ratio D/P = {d_over_p:.2f} exceeds the "
175
+ f"validated {VALIDATED_PAIR_D_OVER_P}; pair quadrature order "
176
+ f"raised to ({n_r}, {n_theta}); fringe contrast retained "
177
+ f"~{airy_amplitude(np.pi * d_over_p)**2:.3f}", stacklevel=3)
178
+ return pupil_pair_quadrature(d1_m, d2_m, n_r, n_theta)
179
+
180
+
181
+ def point_quadrature() -> PupilQuadrature:
182
+ """The no-smearing limit: one offset of zero (point apertures)."""
183
+ return PupilQuadrature(offsets_m=np.zeros((1, 2)), weights=np.ones(1),
184
+ diameters_m=(0.0, 0.0))
185
+
186
+
187
+ def disk_quadrature(diameter_m: float, n_r: int = 2, n_theta: int = 7):
188
+ """Points (m, 2) [m] and weights (m,) (sum 1) integrating a uniform
189
+ circular pupil of the given diameter: Gauss-Legendre in (r/R)^2 times
190
+ equally spaced angles (m = n_r n_theta; the defaults, m = 14, hold
191
+ the three-pupil bispectrum to ~1e-6 at D/P = 0.7)."""
192
+ R = diameter_m / 2.0
193
+ t, wt = np.polynomial.legendre.leggauss(n_r)
194
+ q = 0.5 * (t + 1.0) # (r/R)^2 in (0, 1), uniform-area
195
+ r = R * np.sqrt(q)
196
+ wr = 0.5 * wt
197
+ th = 2.0 * np.pi * (np.arange(n_theta) + 0.5) / n_theta
198
+ ang = th[None, :] + (np.arange(n_r)[:, None] % 2) * np.pi / n_theta
199
+ pts = np.stack([r[:, None] * np.cos(ang), r[:, None] * np.sin(ang)],
200
+ axis=-1).reshape(-1, 2)
201
+ w = np.repeat(wr / n_theta, n_theta)
202
+ return pts, w / w.sum()
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class TripleQuadrature:
207
+ """Pupil points of three apertures for the exact three-pupil average
208
+ of the triple product (and the pair-smeared |gamma_ij|^2)."""
209
+ points_m: tuple # three (m_i, 2) arrays
210
+ weights: tuple # three (m_i,) arrays, each summing to 1
211
+ diameters_m: tuple
212
+
213
+ @classmethod
214
+ def from_diameters(cls, d1_m: float, d2_m: float, d3_m: float,
215
+ n_r: int = 2, n_theta: int = 7) -> "TripleQuadrature":
216
+ pw = [disk_quadrature(d, n_r, n_theta) for d in (d1_m, d2_m, d3_m)]
217
+ return cls(points_m=tuple(p for p, _ in pw),
218
+ weights=tuple(w for _, w in pw),
219
+ diameters_m=(d1_m, d2_m, d3_m))
220
+
221
+ @classmethod
222
+ def for_fringe(cls, d1_m: float, d2_m: float, d3_m: float,
223
+ fringe_period_m: float | None) -> "TripleQuadrature":
224
+ """from_diameters with the per-pupil node counts scaled for the
225
+ fringe period (base rule when P is None); warns beyond the
226
+ validated D/P."""
227
+ if fringe_period_m is None or not np.isfinite(fringe_period_m) or fringe_period_m <= 0:
228
+ return cls.from_diameters(d1_m, d2_m, d3_m)
229
+ d_over_p = max(d1_m, d2_m, d3_m) / float(fringe_period_m)
230
+ n_r, n_theta = quadrature_order(d_over_p, base=(2, 7),
231
+ validated=VALIDATED_TRIPLE_D_OVER_P)
232
+ if d_over_p > VALIDATED_TRIPLE_D_OVER_P:
233
+ warnings.warn(f"pupil/fringe-period ratio D/P = {d_over_p:.2f} exceeds the "
234
+ f"validated {VALIDATED_TRIPLE_D_OVER_P}; triple quadrature "
235
+ f"order raised to ({n_r}, {n_theta}) per pupil "
236
+ f"({3 * (n_r * n_theta)**2} samples per channel)", stacklevel=3)
237
+ return cls.from_diameters(d1_m, d2_m, d3_m, n_r, n_theta)
238
+
239
+ @property
240
+ def shape(self) -> tuple:
241
+ return tuple(w.size for w in self.weights)
242
+
243
+ def offsets(self, bvecs_m) -> list:
244
+ """For the closed cycle B12, B23, B31 (3, 2): the three sample
245
+ grids [B12 + r_j - r_i (m1, m2, 2), B23 + r_k - r_j (m2, m3, 2),
246
+ B31 + r_i - r_k (m3, m1, 2)]."""
247
+ b = np.asarray(bvecs_m, dtype=float)
248
+ p1, p2, p3 = self.points_m
249
+ return [b[0] + p2[None, :, :] - p1[:, None, :],
250
+ b[1] + p3[None, :, :] - p2[:, None, :],
251
+ b[2] + p1[None, :, :] - p3[:, None, :]]
252
+
253
+ def flat_points(self, bvecs_m) -> np.ndarray:
254
+ """All sample points as one (K, 2) array (K = m1 m2 + m2 m3 + m3 m1),
255
+ in the order split_values expects."""
256
+ return np.concatenate([g.reshape(-1, 2) for g in self.offsets(bvecs_m)])
257
+
258
+ def split_values(self, values):
259
+ """Inverse of flat_points on the last axis: (..., K) -> the three
260
+ (..., m_i, m_j) grids."""
261
+ v = np.asarray(values)
262
+ m1, m2, m3 = self.shape
263
+ a = v[..., :m1 * m2].reshape(*v.shape[:-1], m1, m2)
264
+ b = v[..., m1 * m2:m1 * m2 + m2 * m3].reshape(*v.shape[:-1], m2, m3)
265
+ c = v[..., m1 * m2 + m2 * m3:].reshape(*v.shape[:-1], m3, m1)
266
+ return a, b, c
267
+
268
+ def reduce(self, values):
269
+ """From point samples of gamma at flat_points (..., K): the
270
+ three-pupil-averaged bispectrum (...,) and the pair-smeared
271
+ |gamma_12|^2, |gamma_23|^2, |gamma_31|^2 (..., 3)."""
272
+ g12, g23, g31 = self.split_values(values)
273
+ w1, w2, w3 = self.weights
274
+ bis = np.einsum("i,j,k,...ij,...jk,...ki->...", w1, w2, w3, g12, g23, g31)
275
+ v2 = np.stack([np.einsum("i,j,...ij->...", w1, w2, np.abs(g12) ** 2),
276
+ np.einsum("j,k,...jk->...", w2, w3, np.abs(g23) ** 2),
277
+ np.einsum("k,i,...ki->...", w3, w1, np.abs(g31) ** 2)],
278
+ axis=-1)
279
+ return bis, v2
280
+
281
+
282
+ def triple_quadrature_for(triangle, n_r: int = 2, n_theta: int = 7,
283
+ fringe_period_m: float | None = None) -> TripleQuadrature:
284
+ """TripleQuadrature from a bispectrum.Triangle's telescope diameters
285
+ (order scaled for the fringe period when one is given)."""
286
+ d = tuple(s.telescope.diameter_m for s in triangle.stations)
287
+ if fringe_period_m is not None:
288
+ return TripleQuadrature.for_fringe(*d, fringe_period_m)
289
+ return TripleQuadrature.from_diameters(*d, n_r, n_theta)
290
+
291
+
292
+ def fringe_period_m(rho_mas, wavelength_nm) -> float:
293
+ """P = lambda/rho [m] of the binary fringe: the shortest period over
294
+ the given wavelengths (use the blue end of a spectrograph)."""
295
+ from .params import MAS
296
+ rho = float(np.max(np.atleast_1d(rho_mas))) * MAS
297
+ lam = float(np.min(np.atleast_1d(wavelength_nm))) * 1e-9
298
+ return np.inf if rho <= 0 else lam / rho
299
+
300
+
301
+ def resolve_pupils(pupils, diameters, fringe_period_m=None):
302
+ """Normalize the `pupils` argument of the sampling functions:
303
+ None/False -> point sampling; True -> the given diameters; a
304
+ (d1, d2) pair; or a ready PupilQuadrature. With fringe_period_m the
305
+ quadrature order is chosen for that fringe (pupil_pair_quadrature_for)."""
306
+ if pupils is None or pupils is False:
307
+ return None
308
+ if isinstance(pupils, PupilQuadrature):
309
+ return pupils
310
+ if pupils is True:
311
+ if diameters is None:
312
+ raise ValueError("pupils=True needs the telescope diameters")
313
+ pupils = diameters
314
+ d1, d2 = pupils
315
+ return pupil_pair_quadrature_for(float(d1), float(d2), fringe_period_m)