ExoIris 0.23.2__py3-none-any.whl → 1.1.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.
- exoiris/bin1d.py +97 -0
- exoiris/bin2d.py +218 -0
- exoiris/exoiris.py +30 -42
- exoiris/lmlikelihood.py +172 -0
- exoiris/prtretrieval.py +164 -0
- exoiris/spotmodel.py +4 -4
- exoiris/tsdata.py +85 -5
- exoiris/tslpf.py +103 -33
- exoiris/util.py +1 -69
- {exoiris-0.23.2.dist-info → exoiris-1.1.0.dist-info}/METADATA +1 -1
- exoiris-1.1.0.dist-info/RECORD +21 -0
- {exoiris-0.23.2.dist-info → exoiris-1.1.0.dist-info}/WHEEL +1 -1
- exoiris-0.23.2.dist-info/RECORD +0 -17
- {exoiris-0.23.2.dist-info → exoiris-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {exoiris-0.23.2.dist-info → exoiris-1.1.0.dist-info}/top_level.txt +0 -0
exoiris/bin1d.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# ExoIris: fast, flexible, and easy exoplanet transmission spectroscopy in Python.
|
|
2
|
+
# Copyright (C) 2026 Hannu Parviainen
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
from numba import njit
|
|
18
|
+
from numpy import zeros, isfinite, where, nan, sqrt, ndarray, sum
|
|
19
|
+
|
|
20
|
+
@njit
|
|
21
|
+
def bin1d(v, e, el, er, bins, estimate_errors: bool = False) -> tuple[ndarray, ndarray]:
|
|
22
|
+
"""Bin 2D spectrophotometry data with its uncertainties into predefined bins along the first axis.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
v : ndarray
|
|
27
|
+
A 2D spectrophotometry array.
|
|
28
|
+
e : ndarray
|
|
29
|
+
A 2D array of uncertainties associated with the spectrophotometry in `v`, matching the shape of `v`.
|
|
30
|
+
el : ndarray
|
|
31
|
+
A 1D array containing the left edges of the integration ranges for each spectral data point.
|
|
32
|
+
er : ndarray
|
|
33
|
+
A 1D array containing the right edges of the integration ranges for each spectral data point.
|
|
34
|
+
bins : ndarray
|
|
35
|
+
A 2D array containing the edges of the bins. These should be sorted in ascending order.
|
|
36
|
+
estimate_errors: bool, optional.
|
|
37
|
+
Should the uncertainties be estimated from the data? Default value is False.
|
|
38
|
+
|
|
39
|
+
Returns
|
|
40
|
+
-------
|
|
41
|
+
tuple of ndarrays
|
|
42
|
+
A tuple containing two 2D arrays:
|
|
43
|
+
- The first array (`bv`) contains the binned values of the transmission spectrum.
|
|
44
|
+
- The second array (`be`) contains the binned uncertainties.
|
|
45
|
+
"""
|
|
46
|
+
nbins = len(bins)
|
|
47
|
+
ndata = v.shape[0]
|
|
48
|
+
bv = zeros((nbins, v.shape[1]))
|
|
49
|
+
be = zeros((nbins, v.shape[1]))
|
|
50
|
+
nonfin_weights = isfinite(v).astype('d')
|
|
51
|
+
v = where(nonfin_weights, v, 0.0)
|
|
52
|
+
e2 = where(nonfin_weights, e**2, 0.0)
|
|
53
|
+
weights = zeros(v.shape)
|
|
54
|
+
npt = zeros(v.shape[1])
|
|
55
|
+
|
|
56
|
+
i = 0
|
|
57
|
+
for ibin in range(nbins):
|
|
58
|
+
npt[:] = 0
|
|
59
|
+
bel, ber = bins[ibin]
|
|
60
|
+
for i in range(i, ndata - 1):
|
|
61
|
+
if el[i + 1] > bel:
|
|
62
|
+
break
|
|
63
|
+
il = i
|
|
64
|
+
if er[i] > ber:
|
|
65
|
+
weights[i, :] = ber - bel
|
|
66
|
+
npt += 1
|
|
67
|
+
else:
|
|
68
|
+
weights[i, :] = er[i] - max(el[i], bel)
|
|
69
|
+
npt += 1
|
|
70
|
+
for i in range(i + 1, ndata):
|
|
71
|
+
if er[i] < ber:
|
|
72
|
+
weights[i, :] = er[i] - el[i]
|
|
73
|
+
npt += 1
|
|
74
|
+
|
|
75
|
+
else:
|
|
76
|
+
weights[i, :] = ber - el[i]
|
|
77
|
+
npt += 1
|
|
78
|
+
break
|
|
79
|
+
ir = i
|
|
80
|
+
|
|
81
|
+
weights[il:ir+1, :] *= nonfin_weights[il:ir+1, :]
|
|
82
|
+
weights[il:ir+1, :] /= weights[il:ir+1, :].sum(0)
|
|
83
|
+
npt += (nonfin_weights[il:ir+1, :]-1.0).sum(0)
|
|
84
|
+
ws = sum(weights[il:ir+1, :], 0)
|
|
85
|
+
ws2 = sum(weights[il:ir+1, :]**2, 0)
|
|
86
|
+
ws = where(ws > 0, ws, nan)
|
|
87
|
+
bv[ibin] = vmean = sum(weights[il:ir+1, :] * v[il:ir+1,:], 0) / ws
|
|
88
|
+
|
|
89
|
+
if estimate_errors:
|
|
90
|
+
var_sum = sum(weights[il:ir+1, :] * (v[il:ir+1, :] - vmean)**2, 0)
|
|
91
|
+
denominator = ws - ws2 / ws
|
|
92
|
+
sample_variance = var_sum / denominator
|
|
93
|
+
be[ibin, :] = where((npt > 1) & (ws**2 > ws2),
|
|
94
|
+
sqrt(sample_variance * ws2 / (ws * ws)), nan)
|
|
95
|
+
else:
|
|
96
|
+
be[ibin] = sqrt(sum(weights[il:ir+1, :]**2 * e2[il:ir+1,:], 0)) / ws
|
|
97
|
+
return bv, be
|
exoiris/bin2d.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# ExoIris: fast, flexible, and easy exoplanet transmission spectroscopy in Python.
|
|
2
|
+
# Copyright (C) 2026 Hannu Parviainen
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
from numba import njit
|
|
18
|
+
from numpy import zeros, isfinite, where, nan, sqrt, ndarray, int32, float64
|
|
19
|
+
|
|
20
|
+
@njit
|
|
21
|
+
def bin2d(v: ndarray, e: ndarray,
|
|
22
|
+
wl_l: ndarray, wl_r: ndarray,
|
|
23
|
+
tm_l: ndarray, tm_r: ndarray,
|
|
24
|
+
wl_bins: ndarray, tm_bins: ndarray,
|
|
25
|
+
estimate_errors: bool = False) -> tuple[ndarray, ndarray, ndarray]:
|
|
26
|
+
"""Bin 2D spectrophotometry data in both wavelength and time dimensions.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
v : ndarray
|
|
31
|
+
A 2D spectrophotometry array with shape (n_wavelength, n_exposure).
|
|
32
|
+
e : ndarray
|
|
33
|
+
A 2D array of uncertainties matching the shape of `v`.
|
|
34
|
+
wl_l : ndarray
|
|
35
|
+
A 1D array of left wavelength edges for each spectral data point.
|
|
36
|
+
wl_r : ndarray
|
|
37
|
+
A 1D array of right wavelength edges for each spectral data point.
|
|
38
|
+
tm_l : ndarray
|
|
39
|
+
A 1D array of left time edges for each exposure.
|
|
40
|
+
tm_r : ndarray
|
|
41
|
+
A 1D array of right time edges for each exposure.
|
|
42
|
+
wl_bins : ndarray
|
|
43
|
+
A 2D array (n_wl_bins, 2) of wavelength bin edges [left, right].
|
|
44
|
+
tm_bins : ndarray
|
|
45
|
+
A 2D array (n_tm_bins, 2) of time bin edges [left, right].
|
|
46
|
+
estimate_errors : bool, optional
|
|
47
|
+
If True, estimate uncertainties from data scatter. Default is False.
|
|
48
|
+
|
|
49
|
+
Returns
|
|
50
|
+
-------
|
|
51
|
+
tuple of ndarrays
|
|
52
|
+
- bv: Binned values with shape (n_wl_bins, n_tm_bins).
|
|
53
|
+
- be: Binned uncertainties with shape (n_wl_bins, n_tm_bins).
|
|
54
|
+
- bn: Number of original finite pixels in each bin with shape (n_wl_bins, n_tm_bins).
|
|
55
|
+
"""
|
|
56
|
+
n_wl_bins = len(wl_bins)
|
|
57
|
+
n_tm_bins = len(tm_bins)
|
|
58
|
+
n_wl = v.shape[0]
|
|
59
|
+
n_tm = v.shape[1]
|
|
60
|
+
|
|
61
|
+
bv = zeros((n_wl_bins, n_tm_bins))
|
|
62
|
+
be = zeros((n_wl_bins, n_tm_bins))
|
|
63
|
+
bn = zeros((n_wl_bins, n_tm_bins), dtype=int32)
|
|
64
|
+
|
|
65
|
+
# Pre-compute masks and cleaned data
|
|
66
|
+
nonfin_mask = isfinite(v)
|
|
67
|
+
v_clean = where(nonfin_mask, v, 0.0)
|
|
68
|
+
e2_clean = where(nonfin_mask, e**2, 0.0)
|
|
69
|
+
nonfin_weights = nonfin_mask.astype(float64)
|
|
70
|
+
|
|
71
|
+
# Pre-compute time bin indices and weights
|
|
72
|
+
tm_il_arr = zeros(n_tm_bins, dtype=int32)
|
|
73
|
+
tm_ir_arr = zeros(n_tm_bins, dtype=int32)
|
|
74
|
+
tm_weights_all = zeros((n_tm_bins, n_tm))
|
|
75
|
+
|
|
76
|
+
for itm_bin in range(n_tm_bins):
|
|
77
|
+
tm_bel, tm_ber = tm_bins[itm_bin]
|
|
78
|
+
|
|
79
|
+
# Find first time index
|
|
80
|
+
tm_il = 0
|
|
81
|
+
for j in range(n_tm - 1):
|
|
82
|
+
if tm_l[j + 1] > tm_bel:
|
|
83
|
+
tm_il = j
|
|
84
|
+
break
|
|
85
|
+
else:
|
|
86
|
+
tm_il = n_tm - 1
|
|
87
|
+
|
|
88
|
+
tm_il_arr[itm_bin] = tm_il
|
|
89
|
+
|
|
90
|
+
# Calculate time weights with ROBUST overlap logic
|
|
91
|
+
# Overlap = max(0, min(pixel_right, bin_right) - max(pixel_left, bin_left))
|
|
92
|
+
# We perform the loop starting from the found index until pixels no longer overlap
|
|
93
|
+
|
|
94
|
+
tm_idx = tm_il
|
|
95
|
+
# Iterate until pixel starts after bin ends or we run out of pixels
|
|
96
|
+
while tm_idx < n_tm:
|
|
97
|
+
# Optimization: If pixel starts after bin ends, stop.
|
|
98
|
+
if tm_l[tm_idx] >= tm_ber:
|
|
99
|
+
# But careful: tm_il search might land us on a pixel that starts after bin
|
|
100
|
+
# if the bin is in a gap. If so, we just want to record the index and break.
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
# Calculate overlap
|
|
104
|
+
# min(pixel_r, bin_r)
|
|
105
|
+
r_bound = tm_r[tm_idx] if tm_r[tm_idx] < tm_ber else tm_ber
|
|
106
|
+
# max(pixel_l, bin_l)
|
|
107
|
+
l_bound = tm_l[tm_idx] if tm_l[tm_idx] > tm_bel else tm_bel
|
|
108
|
+
|
|
109
|
+
if r_bound > l_bound:
|
|
110
|
+
tm_weights_all[itm_bin, tm_idx] = r_bound - l_bound
|
|
111
|
+
|
|
112
|
+
# If this pixel goes past the bin, we are done with this bin
|
|
113
|
+
if tm_r[tm_idx] >= tm_ber:
|
|
114
|
+
break
|
|
115
|
+
|
|
116
|
+
tm_idx += 1
|
|
117
|
+
|
|
118
|
+
tm_ir_arr[itm_bin] = min(tm_idx, n_tm - 1)
|
|
119
|
+
|
|
120
|
+
# Allocation moved outside loop
|
|
121
|
+
wl_weights = zeros(n_wl)
|
|
122
|
+
|
|
123
|
+
# Process wavelength bins
|
|
124
|
+
wl_start = 0
|
|
125
|
+
for iwl_bin in range(n_wl_bins):
|
|
126
|
+
wl_bel, wl_ber = wl_bins[iwl_bin]
|
|
127
|
+
|
|
128
|
+
# Reset weights for this iteration (faster than re-allocating)
|
|
129
|
+
# We only need to zero out the range we are about to use or used previously?
|
|
130
|
+
# Actually, since we calculate specific indices [wl_il, wl_ir],
|
|
131
|
+
# we can just zero them out at the end of the loop, or re-zero the whole array.
|
|
132
|
+
# Given n_wl is usually small (spectroscopy), zeroing whole array is fine.
|
|
133
|
+
wl_weights[:] = 0.0
|
|
134
|
+
|
|
135
|
+
# Find first wavelength index
|
|
136
|
+
wl_il = wl_start
|
|
137
|
+
for wl_il in range(wl_start, n_wl - 1):
|
|
138
|
+
if wl_l[wl_il + 1] > wl_bel:
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
# Calculate wavelength weights (Same robust logic as time)
|
|
142
|
+
wl_idx = wl_il
|
|
143
|
+
while wl_idx < n_wl:
|
|
144
|
+
if wl_l[wl_idx] >= wl_ber:
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
r_bound = wl_r[wl_idx] if wl_r[wl_idx] < wl_ber else wl_ber
|
|
148
|
+
l_bound = wl_l[wl_idx] if wl_l[wl_idx] > wl_bel else wl_bel
|
|
149
|
+
|
|
150
|
+
if r_bound > l_bound:
|
|
151
|
+
wl_weights[wl_idx] = r_bound - l_bound
|
|
152
|
+
|
|
153
|
+
if wl_r[wl_idx] >= wl_ber:
|
|
154
|
+
break
|
|
155
|
+
wl_idx += 1
|
|
156
|
+
|
|
157
|
+
wl_ir = min(wl_idx, n_wl - 1)
|
|
158
|
+
|
|
159
|
+
# Optimization for next bin search
|
|
160
|
+
wl_start = wl_il
|
|
161
|
+
|
|
162
|
+
# Process all time bins for this wavelength bin
|
|
163
|
+
for itm_bin in range(n_tm_bins):
|
|
164
|
+
tm_il = tm_il_arr[itm_bin]
|
|
165
|
+
tm_ir = tm_ir_arr[itm_bin]
|
|
166
|
+
|
|
167
|
+
total_weight = 0.0
|
|
168
|
+
sum_w2 = 0.0
|
|
169
|
+
weighted_sum = 0.0
|
|
170
|
+
weighted_e2_sum = 0.0
|
|
171
|
+
npt = 0
|
|
172
|
+
|
|
173
|
+
for i in range(wl_il, wl_ir + 1):
|
|
174
|
+
w_wl = wl_weights[i]
|
|
175
|
+
if w_wl <= 0: continue # Skip if no overlap
|
|
176
|
+
|
|
177
|
+
for j in range(tm_il, tm_ir + 1):
|
|
178
|
+
# Combine weights
|
|
179
|
+
w = w_wl * tm_weights_all[itm_bin, j] * nonfin_weights[i, j]
|
|
180
|
+
|
|
181
|
+
if w > 0:
|
|
182
|
+
total_weight += w
|
|
183
|
+
sum_w2 += w * w
|
|
184
|
+
weighted_sum += w * v_clean[i, j]
|
|
185
|
+
weighted_e2_sum += w * w * e2_clean[i, j]
|
|
186
|
+
npt += 1
|
|
187
|
+
|
|
188
|
+
bn[iwl_bin, itm_bin] = npt
|
|
189
|
+
|
|
190
|
+
if total_weight > 0:
|
|
191
|
+
bv[iwl_bin, itm_bin] = vmean = weighted_sum / total_weight
|
|
192
|
+
|
|
193
|
+
if estimate_errors:
|
|
194
|
+
# Need at least 2 points (or effective degrees of freedom) to estimate variance
|
|
195
|
+
if npt > 1 and total_weight**2 > sum_w2:
|
|
196
|
+
var_sum = 0.0
|
|
197
|
+
for i in range(wl_il, wl_ir + 1):
|
|
198
|
+
w_wl = wl_weights[i]
|
|
199
|
+
if w_wl <= 0: continue
|
|
200
|
+
|
|
201
|
+
for j in range(tm_il, tm_ir + 1):
|
|
202
|
+
w = w_wl * tm_weights_all[itm_bin, j] * nonfin_weights[i, j]
|
|
203
|
+
if w > 0:
|
|
204
|
+
var_sum += w * (v_clean[i, j] - vmean) ** 2
|
|
205
|
+
|
|
206
|
+
denominator = total_weight - (sum_w2 / total_weight)
|
|
207
|
+
sample_variance = var_sum / denominator
|
|
208
|
+
be[iwl_bin, itm_bin] = sqrt(sample_variance * (sum_w2 / (total_weight * total_weight)))
|
|
209
|
+
else:
|
|
210
|
+
be[iwl_bin, itm_bin] = nan
|
|
211
|
+
else:
|
|
212
|
+
# Propagate input errors: SE = sqrt(sum(w² * σ²)) / sum(w)
|
|
213
|
+
be[iwl_bin, itm_bin] = sqrt(weighted_e2_sum) / total_weight
|
|
214
|
+
else:
|
|
215
|
+
bv[iwl_bin, itm_bin] = nan
|
|
216
|
+
be[iwl_bin, itm_bin] = nan
|
|
217
|
+
|
|
218
|
+
return bv, be, bn
|
exoiris/exoiris.py
CHANGED
|
@@ -43,7 +43,7 @@ from uncertainties import UFloat
|
|
|
43
43
|
|
|
44
44
|
from .ldtkld import LDTkLD
|
|
45
45
|
from .tsdata import TSData, TSDataGroup
|
|
46
|
-
from .tslpf import TSLPF
|
|
46
|
+
from .tslpf import TSLPF, interpolators
|
|
47
47
|
from .wlpf import WhiteLPF
|
|
48
48
|
from .loglikelihood import LogLikelihood
|
|
49
49
|
|
|
@@ -88,7 +88,14 @@ def load_model(fname: Path | str, name: str | None = None):
|
|
|
88
88
|
try:
|
|
89
89
|
ip = hdr['INTERP']
|
|
90
90
|
except KeyError:
|
|
91
|
-
ip = '
|
|
91
|
+
ip = 'linear'
|
|
92
|
+
|
|
93
|
+
# Read the interpolation model.
|
|
94
|
+
# =============================
|
|
95
|
+
try:
|
|
96
|
+
ip_ld = hdr['INTERP_LD']
|
|
97
|
+
except KeyError:
|
|
98
|
+
ip_ld = 'bspline-quadratic'
|
|
92
99
|
|
|
93
100
|
# Read the noise model.
|
|
94
101
|
# =====================
|
|
@@ -100,6 +107,7 @@ def load_model(fname: Path | str, name: str | None = None):
|
|
|
100
107
|
# Setup the analysis.
|
|
101
108
|
# ===================
|
|
102
109
|
a = ExoIris(name or hdr['NAME'], ldmodel=ldm, data=data, noise_model=noise_model, interpolation=ip)
|
|
110
|
+
a.set_limb_darkening_interpolator(ip_ld)
|
|
103
111
|
a.set_radius_ratio_knots(hdul['K_KNOTS'].data.astype('d'))
|
|
104
112
|
a.set_limb_darkening_knots(hdul['LD_KNOTS'].data.astype('d'))
|
|
105
113
|
|
|
@@ -162,7 +170,7 @@ class ExoIris:
|
|
|
162
170
|
"""
|
|
163
171
|
|
|
164
172
|
def __init__(self, name: str, ldmodel, data: TSDataGroup | TSData, nk: int = 50, nldc: int = 10, nthreads: int = 1,
|
|
165
|
-
tmpars: dict | None = None, noise_model: Literal["
|
|
173
|
+
tmpars: dict | None = None, noise_model: Literal["white_profiled", "white_marginalized", "fixed_gp", "free_gp"] = 'white_profiled',
|
|
166
174
|
interpolation: Literal['nearest', 'linear', 'pchip', 'makima', 'bspline', 'bspline-quadratic', 'bspline-cubic'] = 'linear'):
|
|
167
175
|
"""
|
|
168
176
|
Parameters
|
|
@@ -263,15 +271,15 @@ class ExoIris:
|
|
|
263
271
|
data = TSDataGroup([data]) if isinstance(data, TSData) else data
|
|
264
272
|
self._tsa.set_data(data)
|
|
265
273
|
|
|
266
|
-
def set_prior(self, parameter: Literal['radius ratios', '
|
|
274
|
+
def set_prior(self, parameter: Literal['radius ratios', 'offsets', 'wn multipliers'] | str,
|
|
267
275
|
prior: str | Any, *nargs) -> None:
|
|
268
276
|
"""Set a prior on a model parameter.
|
|
269
277
|
|
|
270
278
|
Parameters
|
|
271
279
|
----------
|
|
272
280
|
parameter
|
|
273
|
-
The name of the parameter to set a prior for. Can also be 'radius ratios', '
|
|
274
|
-
to set identical priors on all the radius ratios,
|
|
281
|
+
The name of the parameter to set a prior for. Can also be 'radius ratios', 'offsets', or 'wn multipliers'
|
|
282
|
+
to set identical priors on all the radius ratios, offsets, or white noise multipliers.
|
|
275
283
|
|
|
276
284
|
prior
|
|
277
285
|
The prior distribution for the parameter. This can be "NP" for a normal prior, "UP" for a
|
|
@@ -287,6 +295,9 @@ class ExoIris:
|
|
|
287
295
|
elif parameter == 'wn multipliers':
|
|
288
296
|
for par in self.ps[self._tsa._sl_wnm]:
|
|
289
297
|
self.set_prior(par.name, prior, *nargs)
|
|
298
|
+
elif parameter == 'offsets':
|
|
299
|
+
for par in self.ps[self._tsa._sl_bias]:
|
|
300
|
+
self.set_prior(par.name, prior, *nargs)
|
|
290
301
|
else:
|
|
291
302
|
self._tsa.set_prior(parameter, prior, *nargs)
|
|
292
303
|
|
|
@@ -509,6 +520,12 @@ class ExoIris:
|
|
|
509
520
|
else:
|
|
510
521
|
return self._wa.std_errors
|
|
511
522
|
|
|
523
|
+
def set_radius_ratio_interpolator(self, interpolator: str) -> None:
|
|
524
|
+
"""Set the interpolator for the radius ratio (k) model."""
|
|
525
|
+
if interpolator not in interpolators.keys():
|
|
526
|
+
raise ValueError(f"Interpolator {interpolator} not recognized.")
|
|
527
|
+
self._tsa.set_k_interpolator(interpolator)
|
|
528
|
+
|
|
512
529
|
def add_radius_ratio_knots(self, knot_wavelengths: Sequence) -> None:
|
|
513
530
|
"""Add radius ratio (k) knots.
|
|
514
531
|
|
|
@@ -558,6 +575,12 @@ class ExoIris:
|
|
|
558
575
|
nk = nk[(nk >= wlmin) & (nk <= wlmax)]
|
|
559
576
|
self.set_radius_ratio_knots(r_[ck[ck < nk[0]], nk, ck[ck > nk[-1]]])
|
|
560
577
|
|
|
578
|
+
def set_limb_darkening_interpolator(self, interpolator: str) -> None:
|
|
579
|
+
"""Set the interpolator for the limb darkening model."""
|
|
580
|
+
if interpolator not in interpolators.keys():
|
|
581
|
+
raise ValueError(f"Interpolator {interpolator} not recognized.")
|
|
582
|
+
self._tsa.set_ld_interpolator(interpolator)
|
|
583
|
+
|
|
561
584
|
def add_limb_darkening_knots(self, knot_wavelengths: Sequence) -> None:
|
|
562
585
|
"""Add limb darkening knots.
|
|
563
586
|
|
|
@@ -1283,6 +1306,7 @@ class ExoIris:
|
|
|
1283
1306
|
pri.header['t14'] = self.transit_duration
|
|
1284
1307
|
pri.header['ndgroups'] = self.data.size
|
|
1285
1308
|
pri.header['interp'] = self._tsa.interpolation
|
|
1309
|
+
pri.header['interp_ld'] = self._tsa.ld_interpolation
|
|
1286
1310
|
pri.header['noise'] = self._tsa.noise_model
|
|
1287
1311
|
|
|
1288
1312
|
if self._tsa.free_k_knot_ids is None:
|
|
@@ -1426,42 +1450,6 @@ class ExoIris:
|
|
|
1426
1450
|
"""
|
|
1427
1451
|
return self._tsa.create_initial_population(n, source, add_noise)
|
|
1428
1452
|
|
|
1429
|
-
def add_noise_to_solution(self, result: str = 'fit') -> None:
|
|
1430
|
-
"""Add noise to the global optimization result or MCMC parameter posteriors.
|
|
1431
|
-
|
|
1432
|
-
Add noise to the global optimization result or MCMC parameter posteriors. You may want to do this if you
|
|
1433
|
-
create a new analysis from another one, for example, by adding radius ratio knots or changing the intrinsic
|
|
1434
|
-
data resolution.
|
|
1435
|
-
|
|
1436
|
-
Parameters
|
|
1437
|
-
----------
|
|
1438
|
-
result
|
|
1439
|
-
Determines which result to add noise to. Default is 'fit'.
|
|
1440
|
-
|
|
1441
|
-
Raises
|
|
1442
|
-
------
|
|
1443
|
-
ValueError
|
|
1444
|
-
If the 'result' argument is not 'fit' or 'mcmc'.
|
|
1445
|
-
"""
|
|
1446
|
-
if result == 'fit':
|
|
1447
|
-
pvp = self._tsa._de_population[:, :].copy()
|
|
1448
|
-
elif result == 'mcmc':
|
|
1449
|
-
pvp = self._tsa._mc_chains[:, -1, :].copy()
|
|
1450
|
-
else:
|
|
1451
|
-
raise ValueError("The 'result' argument must be either 'fit' or 'mcmc'")
|
|
1452
|
-
|
|
1453
|
-
npv = pvp.shape[0]
|
|
1454
|
-
pvp[:, 0] += normal(0, 0.005, size=npv)
|
|
1455
|
-
pvp[:, 1] += normal(0, 0.001, size=npv)
|
|
1456
|
-
pvp[:, 3] += normal(0, 0.005, size=npv)
|
|
1457
|
-
pvp[:, self._tsa._sl_rratios] += normal(0, 1, pvp[:, self._tsa._sl_rratios].shape) * 0.002 * pvp[:, self._tsa._sl_rratios]
|
|
1458
|
-
pvp[:, self._tsa._sl_ld] += normal(0, 1, pvp[:, self._tsa._sl_ld].shape) * 0.002 * pvp[:, self._tsa._sl_ld]
|
|
1459
|
-
|
|
1460
|
-
if result == 'fit':
|
|
1461
|
-
self._tsa._de_population[:, :] = pvp
|
|
1462
|
-
else:
|
|
1463
|
-
pvp = self._tsa._mc_chains[:, -1, :] = pvp
|
|
1464
|
-
|
|
1465
1453
|
def optimize_gp_hyperparameters(self,
|
|
1466
1454
|
log10_sigma_bounds: float | tuple[float, float] | None = None,
|
|
1467
1455
|
log10_rho_bounds: float | tuple[float, float] = (-5, 0),
|
exoiris/lmlikelihood.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# ExoIris: fast, flexible, and easy exoplanet transmission spectroscopy in Python.
|
|
2
|
+
# Copyright (C) 2026 Hannu Parviainen
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from numba import njit
|
|
19
|
+
|
|
20
|
+
@njit
|
|
21
|
+
def _chol_solve(l: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
22
|
+
"""Solve L L^T x = b given lower-triangular L."""
|
|
23
|
+
y = np.linalg.solve(l, b)
|
|
24
|
+
return np.linalg.solve(l.T, y)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@njit
|
|
28
|
+
def marginalized_loglike_mbl1d(
|
|
29
|
+
obs: np.ndarray,
|
|
30
|
+
mod: np.ndarray,
|
|
31
|
+
covs: np.ndarray,
|
|
32
|
+
sigma: np.ndarray,
|
|
33
|
+
tau: float = 1e6,
|
|
34
|
+
drop_constant: bool = False,
|
|
35
|
+
) -> float:
|
|
36
|
+
"""Compute the analytically marginalized log-likelihood for a multiplicative baseline model.
|
|
37
|
+
|
|
38
|
+
JIT-compiled implementation of the collapsed (marginalized) log-likelihood for a
|
|
39
|
+
model where systematic trends multiply the physical signal:
|
|
40
|
+
|
|
41
|
+
obs = mod(θ) · covs·a + ε
|
|
42
|
+
|
|
43
|
+
where ε ~ N(0, diag(σ²)) and a ~ N(0, Λ). The baseline coefficients a are
|
|
44
|
+
integrated out analytically, yielding a likelihood that depends only on the
|
|
45
|
+
physical model mod(θ).
|
|
46
|
+
|
|
47
|
+
This formulation is appropriate when the out-of-transit baseline level must be
|
|
48
|
+
estimated from the data. The design matrix `covs` should include a constant
|
|
49
|
+
column (ones) to capture the baseline flux level, with additional columns for
|
|
50
|
+
systematic trends.
|
|
51
|
+
|
|
52
|
+
The computation avoids explicit matrix inversion by exploiting the Woodbury
|
|
53
|
+
identity and matrix determinant lemma, working with k×k matrices rather than
|
|
54
|
+
n×n matrices. This makes the function efficient when k ≪ n.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
obs : ndarray of shape (n,), dtype float64
|
|
59
|
+
Observed flux values. Must be contiguous float64 array.
|
|
60
|
+
mod : ndarray of shape (n,), dtype float64
|
|
61
|
+
Physical transit model evaluated at the current parameters θ, normalized
|
|
62
|
+
such that the out-of-transit level is unity. Must be contiguous float64.
|
|
63
|
+
covs : ndarray of shape (n, k), dtype float64
|
|
64
|
+
Design matrix for the multiplicative baseline. Should include a constant
|
|
65
|
+
column (ones) as the first column to capture the baseline flux level.
|
|
66
|
+
Additional columns represent systematic trends (e.g., airmass, detector
|
|
67
|
+
position, PSF width). Trend columns should typically be mean-centered.
|
|
68
|
+
Must be contiguous float64 with C ordering.
|
|
69
|
+
sigma : ndarray of shape (n,), dtype float64
|
|
70
|
+
Per-observation measurement uncertainties (standard deviations). All
|
|
71
|
+
values must be strictly positive. Must be contiguous float64.
|
|
72
|
+
tau : float, default 1e6
|
|
73
|
+
Prior standard deviation for all baseline coefficients (Λ = τ²I).
|
|
74
|
+
drop_constant : bool, default False
|
|
75
|
+
If True, omit terms constant in θ (log|Σ| and n·log(2π)). Use this for
|
|
76
|
+
MCMC sampling over θ when σ is fixed, as these terms only shift the
|
|
77
|
+
log-posterior by a constant without affecting sampling.
|
|
78
|
+
|
|
79
|
+
Returns
|
|
80
|
+
-------
|
|
81
|
+
float
|
|
82
|
+
The marginalized log-likelihood value. If drop_constant is True, this
|
|
83
|
+
omits θ-independent normalization terms.
|
|
84
|
+
|
|
85
|
+
Raises
|
|
86
|
+
------
|
|
87
|
+
numpy.linalg.LinAlgError
|
|
88
|
+
If Lambda or the internal matrix K is not positive definite (Cholesky
|
|
89
|
+
decomposition fails). No other validation is performed.
|
|
90
|
+
|
|
91
|
+
Notes
|
|
92
|
+
-----
|
|
93
|
+
The marginalized likelihood is obtained by integrating over the baseline
|
|
94
|
+
coefficients:
|
|
95
|
+
|
|
96
|
+
L(θ) = ∫ p(obs | θ, a) p(a) da
|
|
97
|
+
|
|
98
|
+
Defining Φ = diag(mod)·covs, the marginal distribution of obs is:
|
|
99
|
+
|
|
100
|
+
obs ~ N(0, C) where C = Σ + τ²ΦΦᵀ
|
|
101
|
+
|
|
102
|
+
Rather than inverting the n×n matrix C directly, the implementation uses:
|
|
103
|
+
|
|
104
|
+
C⁻¹ = W − τ²W·Φ·K⁻¹·Φᵀ·W (Woodbury identity)
|
|
105
|
+
|C| = |Σ|·|K| (matrix determinant lemma)
|
|
106
|
+
|
|
107
|
+
where W = Σ⁻¹ = diag(1/σ²) and K = I + τ²ΦᵀWΦ is a k×k matrix.
|
|
108
|
+
|
|
109
|
+
The log-likelihood is:
|
|
110
|
+
|
|
111
|
+
log L = −½ [obsᵀC⁻¹obs + log|C| + n·log(2π)]
|
|
112
|
+
= −½ [(obsᵀWobs − τ²cᵀK⁻¹c) + log|Σ| + log|K| + n·log(2π)]
|
|
113
|
+
|
|
114
|
+
where c = ΦᵀW·obs.
|
|
115
|
+
|
|
116
|
+
With the isotropic prior Λ = τ²I, the Cholesky factorization of Λ is trivially
|
|
117
|
+
L_Λ = τI, eliminating one matrix decomposition compared to the general case.
|
|
118
|
+
All operations involve only k×k matrices, giving O(nk² + k³) complexity
|
|
119
|
+
rather than O(n³).
|
|
120
|
+
|
|
121
|
+
In the limit τ → ∞, the marginalized likelihood approaches the profile
|
|
122
|
+
likelihood with a at its maximum likelihood estimate (ordinary least squares).
|
|
123
|
+
Numerical stability requires finite τ; values around 10⁶ are effectively
|
|
124
|
+
uninformative for normalized flux data while maintaining well-conditioned K.
|
|
125
|
+
"""
|
|
126
|
+
n = obs.shape[0]
|
|
127
|
+
ncov = covs.shape[1]
|
|
128
|
+
tau2 = tau * tau
|
|
129
|
+
phi = mod[:, None] * covs
|
|
130
|
+
w = 1.0 / (sigma * sigma)
|
|
131
|
+
wphi = w[:, None] * phi
|
|
132
|
+
k = np.eye(ncov) + tau2 * (phi.T @ wphi)
|
|
133
|
+
c = phi.T @ (w * obs)
|
|
134
|
+
lk = np.linalg.cholesky(k)
|
|
135
|
+
obswobs = np.dot(w * obs, obs)
|
|
136
|
+
kinvc = _chol_solve(lk, c)
|
|
137
|
+
quad = obswobs - tau2 * np.dot(c, kinvc)
|
|
138
|
+
logdetk = 2.0 * np.sum(np.log(np.diag(lk)))
|
|
139
|
+
if drop_constant:
|
|
140
|
+
return -0.5 * (quad + logdetk)
|
|
141
|
+
logdetsigma = np.sum(np.log(sigma * sigma))
|
|
142
|
+
return -0.5 * (quad + logdetsigma + logdetk + n * np.log(2.0 * np.pi))
|
|
143
|
+
|
|
144
|
+
@njit
|
|
145
|
+
def marginalized_loglike_mbl2d(
|
|
146
|
+
obs: np.ndarray,
|
|
147
|
+
mod: np.ndarray,
|
|
148
|
+
err: np.ndarray,
|
|
149
|
+
covs: np.ndarray,
|
|
150
|
+
mask: np.ndarray,
|
|
151
|
+
tau: float = 1e6,
|
|
152
|
+
) -> float:
|
|
153
|
+
nwl = obs.shape[0]
|
|
154
|
+
ncov = covs.shape[1]
|
|
155
|
+
tau2 = tau * tau
|
|
156
|
+
w = 1.0 / (err * err)
|
|
157
|
+
|
|
158
|
+
lnlike = 0.0
|
|
159
|
+
for i in range(nwl):
|
|
160
|
+
m = mask[i]
|
|
161
|
+
phi = mod[i, m, None] * covs[m]
|
|
162
|
+
wphi = w[i, m, None] * phi
|
|
163
|
+
k = np.eye(ncov) + tau2 * (phi.T @ wphi)
|
|
164
|
+
c = phi.T @ (w[i, m] * obs[i, m])
|
|
165
|
+
lk = np.linalg.cholesky(k)
|
|
166
|
+
obswobs = np.dot(w[i, m] * obs[i, m], obs[i, m])
|
|
167
|
+
kinvc = _chol_solve(lk, c)
|
|
168
|
+
quad = obswobs - tau2 * np.dot(c, kinvc)
|
|
169
|
+
logdetk = 2.0 * np.sum(np.log(np.diag(lk)))
|
|
170
|
+
logdetsigma = np.sum(np.log(err[i, m] * err[i, m]))
|
|
171
|
+
lnlike += -0.5 * (quad + logdetsigma + logdetk + mask[i].sum() * np.log(2.0 * np.pi))
|
|
172
|
+
return lnlike
|