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.
- hbtsim/__init__.py +27 -0
- hbtsim/__main__.py +35 -0
- hbtsim/aperture.py +315 -0
- hbtsim/bispectrum.py +396 -0
- hbtsim/catalog/__init__.py +285 -0
- hbtsim/catalog/build.py +347 -0
- hbtsim/catalog/cli.py +90 -0
- hbtsim/catalog/registry.py +113 -0
- hbtsim/catalog/schema.py +389 -0
- hbtsim/chromatic.py +289 -0
- hbtsim/cli.py +44 -0
- hbtsim/cli_common.py +143 -0
- hbtsim/configs/arrays/c2pu_pair.json +21 -0
- hbtsim/configs/arrays/eonsii_pair_teide.json +23 -0
- hbtsim/configs/arrays/eonsii_triangle_paranal.json +24 -0
- hbtsim/configs/arrays/keck_pair.json +26 -0
- hbtsim/configs/arrays/lpqi_orm.json +57 -0
- hbtsim/configs/arrays/lpqi_pathfinder.json +29 -0
- hbtsim/configs/arrays/magic_lst1.json +39 -0
- hbtsim/configs/arrays/maunakea_subaru_keck.json +33 -0
- hbtsim/configs/arrays/veritas.json +43 -0
- hbtsim/configs/arrays/vlt_ut.json +44 -0
- hbtsim/configs/backends/eonsii_mcp.json +11 -0
- hbtsim/configs/backends/eonsii_mcp_pbs.json +11 -0
- hbtsim/configs/backends/eonsii_r7500_spad.json +11 -0
- hbtsim/configs/backends/eonsii_spad.json +11 -0
- hbtsim/configs/backends/eonsii_spad_pbs.json +11 -0
- hbtsim/configs/backends/kk_1000ch.json +11 -0
- hbtsim/configs/backends/lpqi_500.json +13 -0
- hbtsim/configs/backends/lpqi_550.json +13 -0
- hbtsim/configs/backends/lpqi_halpha.json +13 -0
- hbtsim/configs/backends/lpqi_hbeta.json +13 -0
- hbtsim/configs/backends/lpqi_nextgen_500.json +13 -0
- hbtsim/configs/backends/lpqi_nextgen_550.json +13 -0
- hbtsim/configs/backends/lpqi_nextgen_halpha.json +13 -0
- hbtsim/configs/backends/lpqi_nextgen_hbeta.json +13 -0
- hbtsim/configs/backends/magic_sii.json +20 -0
- hbtsim/configs/backends/r5000_correlator.json +11 -0
- hbtsim/configs/backends/r5000_correlator_pbs.json +11 -0
- hbtsim/configs/backends/spad320_correlator.json +11 -0
- hbtsim/configs/backends/spad320_timetag.json +11 -0
- hbtsim/configs/backends/veritas_sii.json +35 -0
- hbtsim/configs/bands/photometric.json +55 -0
- hbtsim/configs/campaigns/chromatic_c2pu_regression.json +40 -0
- hbtsim/configs/campaigns/chromatic_sirius_vega_eonsii.json +39 -0
- hbtsim/configs/campaigns/g2_binaries_lpqi_pathfinder.json +45 -0
- hbtsim/configs/campaigns/g2_c2pu.json +39 -0
- hbtsim/configs/campaigns/g2_eonsii.json +40 -0
- hbtsim/configs/campaigns/g2_keck.json +38 -0
- hbtsim/configs/campaigns/g3_algol_maunakea.json +38 -0
- hbtsim/configs/campaigns/g3_betaaur_maunakea.json +36 -0
- hbtsim/configs/campaigns/g3_deltavel_eonsii_paranal.json +54 -0
- hbtsim/configs/campaigns/g3_deltavel_vlt.json +36 -0
- hbtsim/configs/campaigns/g3_spica_vlt.json +36 -0
- hbtsim/configs/campaigns/mc_sirius_b_eonsii.json +56 -0
- hbtsim/configs/campaigns/mc_sirius_b_lpqi_pathfinder.json +42 -0
- hbtsim/configs/campaigns/redclump_ii_dwarf.json +56 -0
- hbtsim/configs/campaigns/redclump_ii_supergiant.json +56 -0
- hbtsim/configs/campaigns/suite_phase6.json +92 -0
- hbtsim/configs/detectors/eonsii_mcp_pmt.json +41 -0
- hbtsim/configs/detectors/eonsii_spad.json +43 -0
- hbtsim/configs/detectors/eonsii_spad_correlator.json +13 -0
- hbtsim/configs/detectors/kk_ideal.json +27 -0
- hbtsim/configs/detectors/lpqi_spad64_i2cass.json +39 -0
- hbtsim/configs/detectors/lpqi_spad_nextgen.json +73 -0
- hbtsim/configs/detectors/magic_pmt.json +27 -0
- hbtsim/configs/detectors/spad_lambda.json +75 -0
- hbtsim/configs/detectors/spad_lambda_ng.json +13 -0
- hbtsim/configs/detectors/veritas_pmt.json +27 -0
- hbtsim/configs/ld_tables/claret11_algol_a.json +45 -0
- hbtsim/configs/ld_tables/claret11_algol_b.json +45 -0
- hbtsim/configs/ld_tables/claret11_betaaur.json +35 -0
- hbtsim/configs/ld_tables/claret11_spica.json +39 -0
- hbtsim/configs/ld_tables/k_giant_rough.json +47 -0
- hbtsim/configs/resources/newera.json +20 -0
- hbtsim/configs/resources/newera_redclump.json +19 -0
- hbtsim/configs/sites/calern.json +15 -0
- hbtsim/configs/sites/flwo.json +11 -0
- hbtsim/configs/sites/maunakea.json +11 -0
- hbtsim/configs/sites/orm.json +11 -0
- hbtsim/configs/sites/orm_not.json +15 -0
- hbtsim/configs/sites/paranal.json +13 -0
- hbtsim/configs/sites/teide.json +13 -0
- hbtsim/configs/spectrographs/eonsii_1000ch.json +16 -0
- hbtsim/configs/spectrographs/eonsii_60ch.json +14 -0
- hbtsim/configs/spectrographs/eonsii_r7500.json +16 -0
- hbtsim/configs/spectrographs/filter_2mass_h.json +16 -0
- hbtsim/configs/spectrographs/filter_2mass_k.json +16 -0
- hbtsim/configs/spectrographs/filter_cousins_i.json +16 -0
- hbtsim/configs/spectrographs/filter_cousins_r.json +16 -0
- hbtsim/configs/spectrographs/filter_johnson_b.json +16 -0
- hbtsim/configs/spectrographs/filter_johnson_v.json +16 -0
- hbtsim/configs/spectrographs/filter_lpqi_500_1nm.json +21 -0
- hbtsim/configs/spectrographs/filter_lpqi_550_1nm.json +21 -0
- hbtsim/configs/spectrographs/filter_lpqi_halpha_1nm.json +21 -0
- hbtsim/configs/spectrographs/filter_lpqi_hbeta_1nm.json +21 -0
- hbtsim/configs/spectrographs/hbeta_window_r5000.json +14 -0
- hbtsim/configs/spectrographs/kk_1000ch_400_950.json +14 -0
- hbtsim/configs/spectrographs/r5000_400_950.json +14 -0
- hbtsim/configs/spectrographs/spad_lambda_320.json +16 -0
- hbtsim/configs/targets/40eri_b.json +34 -0
- hbtsim/configs/targets/algol.json +72 -0
- hbtsim/configs/targets/betaaur.json +72 -0
- hbtsim/configs/targets/deltavel.json +72 -0
- hbtsim/configs/targets/gammacas.json +46 -0
- hbtsim/configs/targets/hd17652.json +46 -0
- hbtsim/configs/targets/hd360.json +46 -0
- hbtsim/configs/targets/procyon_b.json +29 -0
- hbtsim/configs/targets/sirius_a.json +32 -0
- hbtsim/configs/targets/sirius_b.json +37 -0
- hbtsim/configs/targets/spica.json +71 -0
- hbtsim/configs/targets/vega.json +30 -0
- hbtsim/configs/telescopes/c2pu_1m.json +12 -0
- hbtsim/configs/telescopes/eonsii_4m.json +18 -0
- hbtsim/configs/telescopes/gtc_10p4m.json +19 -0
- hbtsim/configs/telescopes/int_2p54m.json +17 -0
- hbtsim/configs/telescopes/keck_10m.json +12 -0
- hbtsim/configs/telescopes/kk_4m.json +14 -0
- hbtsim/configs/telescopes/lst1_23m.json +17 -0
- hbtsim/configs/telescopes/magic_17m.json +13 -0
- hbtsim/configs/telescopes/not_2p56m.json +17 -0
- hbtsim/configs/telescopes/subaru_8p2m.json +12 -0
- hbtsim/configs/telescopes/tng_3p58m.json +17 -0
- hbtsim/configs/telescopes/veritas_12m.json +18 -0
- hbtsim/configs/telescopes/vlt_ut_8p2m.json +10 -0
- hbtsim/configs/telescopes/wht_4p2m.json +18 -0
- hbtsim/data.py +382 -0
- hbtsim/diameter.py +104 -0
- hbtsim/estimators.py +107 -0
- hbtsim/fftmap.py +105 -0
- hbtsim/g2spec.py +297 -0
- hbtsim/geometry.py +111 -0
- hbtsim/hbt.py +278 -0
- hbtsim/iact.py +210 -0
- hbtsim/limbdark.py +194 -0
- hbtsim/montecarlo.py +370 -0
- hbtsim/movie.py +272 -0
- hbtsim/orbit.py +162 -0
- hbtsim/params.py +450 -0
- hbtsim/photometry.py +102 -0
- hbtsim/render.py +168 -0
- hbtsim/run_campaign.py +233 -0
- hbtsim/runners/__init__.py +30 -0
- hbtsim/runners/chromatic.py +215 -0
- hbtsim/runners/g2.py +301 -0
- hbtsim/runners/g3.py +359 -0
- hbtsim/runners/g3_campaign.py +334 -0
- hbtsim/runners/montecarlo.py +195 -0
- hbtsim/runners/scale.py +253 -0
- hbtsim/runners/suite.py +70 -0
- hbtsim/sed.py +698 -0
- hbtsim/serialize.py +87 -0
- hbtsim/single.py +284 -0
- hbtsim/snr.py +629 -0
- hbtsim/snr3.py +805 -0
- hbtsim/snr_cli.py +191 -0
- hbtsim/spectral.py +148 -0
- hbtsim-0.2.0.dist-info/METADATA +635 -0
- hbtsim-0.2.0.dist-info/RECORD +163 -0
- hbtsim-0.2.0.dist-info/WHEEL +5 -0
- hbtsim-0.2.0.dist-info/entry_points.txt +8 -0
- hbtsim-0.2.0.dist-info/licenses/LICENSE +28 -0
- 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)
|