ExoIris 0.21.0__tar.gz → 0.23.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.
- {exoiris-0.21.0 → exoiris-0.23.0}/CHANGELOG.md +25 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/ExoIris.egg-info/PKG-INFO +2 -1
- {exoiris-0.21.0 → exoiris-0.23.0}/ExoIris.egg-info/requires.txt +1 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/PKG-INFO +2 -1
- {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/exoiris.py +184 -59
- exoiris-0.23.0/exoiris/loglikelihood.py +139 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/tsdata.py +46 -12
- {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/tslpf.py +118 -54
- {exoiris-0.21.0 → exoiris-0.23.0}/requirements.txt +2 -1
- exoiris-0.21.0/exoiris/loglikelihood.py +0 -144
- {exoiris-0.21.0 → exoiris-0.23.0}/.github/workflows/python-package.yml +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/.gitignore +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/.readthedocs.yaml +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/CODE_OF_CONDUCT.md +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/ExoIris.egg-info/SOURCES.txt +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/ExoIris.egg-info/dependency_links.txt +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/ExoIris.egg-info/top_level.txt +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/LICENSE +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/README.md +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/Makefile +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/make.bat +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/requirements.txt +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/_static/css/custom.css +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/api/binning.rst +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/api/exoiris.rst +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/api/tsdata.rst +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/conf.py +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/01a_not_so_short_intro.ipynb +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/01b_short_intro.ipynb +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/02_increasing_knot_resolution.ipynb +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/03_increasing_data_resolution.ipynb +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/04_gaussian_processes.ipynb +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/05a_ldtkldm.ipynb +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/A2_full_data_resolution.ipynb +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/appendix_1_data_preparation.ipynb +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/data/README.txt +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/data/nirHiss_order_1.h5 +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/data/nirHiss_order_2.h5 +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/example1.png +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/plot_1.ipynb +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/figures.ipynb +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/friendly_introduction.ipynb +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/index.rst +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/k_knot_example.svg +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/setup_multiprocessing.py +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/index.rst +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/install.rst +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/__init__.py +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/binning.py +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/ephemeris.py +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/ldtkld.py +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/spotmodel.py +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/tsmodel.py +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/util.py +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/wlpf.py +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/pyproject.toml +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/setup.cfg +0 -0
- {exoiris-0.21.0 → exoiris-0.23.0}/tests/test_binning.py +0 -0
|
@@ -5,6 +5,31 @@ All notable changes to ExoIris will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.23.0] - 2025-12-16
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Switched baseline modeling to a least-squares approach.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- Corrected prior loading for parameter sets.
|
|
15
|
+
- Validated `samples` before posterior spectrum calculation to prevent runtime errors.
|
|
16
|
+
|
|
17
|
+
## [0.22.0] - 2025-12-13
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- Added support for free-k knot configuration in spline models.
|
|
21
|
+
- Added SVD solver options in the `loglikelihood.LogLikelihood` class.
|
|
22
|
+
- Added `bspline-quadratic` interpolation option.
|
|
23
|
+
|
|
24
|
+
### Improved
|
|
25
|
+
- Improved transmission spectrum and log-likelihood methods for robustness and performance.
|
|
26
|
+
- Cleaned up and refactored the `LogLikelihood` class.
|
|
27
|
+
- Updated limb darkening parameter plotting.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- Added a safety check for uninitialized `white_gp_models` in white
|
|
31
|
+
light curve processing.
|
|
32
|
+
|
|
8
33
|
## [0.21.0] - 2025-11-24
|
|
9
34
|
|
|
10
35
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ExoIris
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.23.0
|
|
4
4
|
Summary: Easy and robust exoplanet transmission spectroscopy.
|
|
5
5
|
Author-email: Hannu Parviainen <hannu@iac.es>
|
|
6
6
|
License: GPLv3
|
|
@@ -29,6 +29,7 @@ Requires-Dist: xarray
|
|
|
29
29
|
Requires-Dist: seaborn
|
|
30
30
|
Requires-Dist: astropy
|
|
31
31
|
Requires-Dist: uncertainties
|
|
32
|
+
Requires-Dist: scikit-learn
|
|
32
33
|
Dynamic: license-file
|
|
33
34
|
|
|
34
35
|
# ExoIris: Fast and Flexible Transmission Spectroscopy in Python
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ExoIris
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.23.0
|
|
4
4
|
Summary: Easy and robust exoplanet transmission spectroscopy.
|
|
5
5
|
Author-email: Hannu Parviainen <hannu@iac.es>
|
|
6
6
|
License: GPLv3
|
|
@@ -29,6 +29,7 @@ Requires-Dist: xarray
|
|
|
29
29
|
Requires-Dist: seaborn
|
|
30
30
|
Requires-Dist: astropy
|
|
31
31
|
Requires-Dist: uncertainties
|
|
32
|
+
Requires-Dist: scikit-learn
|
|
32
33
|
Dynamic: license-file
|
|
33
34
|
|
|
34
35
|
# ExoIris: Fast and Flexible Transmission Spectroscopy in Python
|
|
@@ -32,7 +32,7 @@ from emcee import EnsembleSampler
|
|
|
32
32
|
from matplotlib.pyplot import subplots, setp, figure, Figure, Axes
|
|
33
33
|
from numpy import (any, where, sqrt, clip, percentile, median, squeeze, floor, ndarray, isfinite,
|
|
34
34
|
array, inf, arange, argsort, concatenate, full, nan, r_, nanpercentile, log10,
|
|
35
|
-
ceil, unique, zeros)
|
|
35
|
+
ceil, unique, zeros, cov)
|
|
36
36
|
from numpy.typing import ArrayLike
|
|
37
37
|
from numpy.random import normal
|
|
38
38
|
from pytransit import UniformPrior, NormalPrior
|
|
@@ -133,11 +133,20 @@ def load_model(fname: Path | str, name: str | None = None):
|
|
|
133
133
|
for i in range(hdr['NSPOTS']):
|
|
134
134
|
a.add_spot(hdr[f'SP{i+1:02d}_EG'])
|
|
135
135
|
|
|
136
|
+
# Read the free k knot indices if they exist.
|
|
137
|
+
# ==========================================
|
|
138
|
+
if 'N_FREE_K' in hdr and hdr['N_FREE_K'] > 0:
|
|
139
|
+
n_free_k = hdr['N_FREE_K']
|
|
140
|
+
a._tsa.set_free_k_knots([int(hdr[f'KK_IX_{i:03d}']) for i in range(n_free_k)])
|
|
141
|
+
|
|
136
142
|
# Read the priors.
|
|
137
143
|
# ================
|
|
138
144
|
priors = pickle.loads(codecs.decode(json.loads(hdul['PRIORS'].header['PRIORS']).encode(), "base64"))
|
|
139
|
-
|
|
140
|
-
|
|
145
|
+
for praw in priors:
|
|
146
|
+
p = pickle.loads(praw)
|
|
147
|
+
if p.name in a._tsa.ps.names:
|
|
148
|
+
a._tsa.set_prior(p.name, p.prior)
|
|
149
|
+
|
|
141
150
|
if 'DE' in hdul:
|
|
142
151
|
a._tsa._de_population = Table(hdul['DE'].data).to_pandas().values
|
|
143
152
|
a._tsa._de_imin = hdul['DE'].header['IMIN']
|
|
@@ -154,7 +163,7 @@ class ExoIris:
|
|
|
154
163
|
|
|
155
164
|
def __init__(self, name: str, ldmodel, data: TSDataGroup | TSData, nk: int = 50, nldc: int = 10, nthreads: int = 1,
|
|
156
165
|
tmpars: dict | None = None, noise_model: Literal["white", "fixed_gp", "free_gp"] = 'white',
|
|
157
|
-
interpolation: Literal['
|
|
166
|
+
interpolation: Literal['nearest', 'linear', 'pchip', 'makima', 'bspline', 'bspline-quadratic'] = 'makima'):
|
|
158
167
|
"""
|
|
159
168
|
Parameters
|
|
160
169
|
----------
|
|
@@ -211,6 +220,7 @@ class ExoIris:
|
|
|
211
220
|
self._white_fluxes: None | list[ndarray] = None
|
|
212
221
|
self._white_errors: None | list[ndarray] = None
|
|
213
222
|
self._white_models: None | list[ndarray] = None
|
|
223
|
+
self.white_gp_models: None | list[ndarray] = None
|
|
214
224
|
|
|
215
225
|
def lnposterior(self, pvp: ndarray) -> ndarray:
|
|
216
226
|
"""Calculate the log posterior probability for a single parameter vector or an array of parameter vectors.
|
|
@@ -274,9 +284,6 @@ class ExoIris:
|
|
|
274
284
|
if parameter == 'radius ratios':
|
|
275
285
|
for l in self._tsa.k_knots:
|
|
276
286
|
self.set_prior(f'k_{l:08.5f}', prior, *nargs)
|
|
277
|
-
elif parameter == 'baselines':
|
|
278
|
-
for par in self.ps[self._tsa._sl_baseline]:
|
|
279
|
-
self.set_prior(par.name, prior, *nargs)
|
|
280
287
|
elif parameter == 'wn multipliers':
|
|
281
288
|
for par in self.ps[self._tsa._sl_wnm]:
|
|
282
289
|
self.set_prior(par.name, prior, *nargs)
|
|
@@ -653,6 +660,9 @@ class ExoIris:
|
|
|
653
660
|
the data and the number of columns specified (ncol). If the provided axes array (axs)
|
|
654
661
|
does not accommodate all the subplots, the behavior is undefined.
|
|
655
662
|
"""
|
|
663
|
+
if self.white_gp_models is None:
|
|
664
|
+
raise ValueError("White light curve GP predictions are not available. Run 'optimize_gp_hyperparameters' first.")
|
|
665
|
+
|
|
656
666
|
ndata = self.data.size
|
|
657
667
|
|
|
658
668
|
if axs is None:
|
|
@@ -871,13 +881,13 @@ class ExoIris:
|
|
|
871
881
|
|
|
872
882
|
if result == 'fit':
|
|
873
883
|
pv = self._tsa._de_population[self._tsa._de_imin]
|
|
874
|
-
ks = self._tsa._eval_k(pv
|
|
884
|
+
ks = self._tsa._eval_k(pv)
|
|
875
885
|
ar = 1e2 * concatenate([squeeze(k) for k in ks]) ** 2
|
|
876
886
|
ax.plot(wavelength[ix], ar[ix], c='k')
|
|
877
887
|
ax.plot(self._tsa.k_knots, 1e2 * pv[self._tsa._sl_rratios] ** 2, 'k.')
|
|
878
888
|
else:
|
|
879
889
|
df = pd.DataFrame(self._tsa._mc_chains.reshape([-1, self._tsa.ndim]), columns=self._tsa.ps.names)
|
|
880
|
-
ks = self._tsa._eval_k(df.
|
|
890
|
+
ks = self._tsa._eval_k(df.values)
|
|
881
891
|
ar = 1e2 * concatenate(ks, axis=1) ** 2
|
|
882
892
|
ax.fill_between(wavelength[ix], *percentile(ar[:, ix], [16, 84], axis=0), alpha=0.25)
|
|
883
893
|
ax.plot(wavelength[ix], median(ar, 0)[ix], c='k')
|
|
@@ -893,7 +903,11 @@ class ExoIris:
|
|
|
893
903
|
ax.set_xticks(xticks, labels=xticks)
|
|
894
904
|
return ax.get_figure()
|
|
895
905
|
|
|
896
|
-
def plot_limb_darkening_parameters(
|
|
906
|
+
def plot_limb_darkening_parameters(
|
|
907
|
+
self,
|
|
908
|
+
result: None | Literal["fit", "mcmc"] = None,
|
|
909
|
+
axs: None | tuple[Axes, Axes] = None,
|
|
910
|
+
) -> None | Figure:
|
|
897
911
|
"""Plot the limb darkening parameters.
|
|
898
912
|
|
|
899
913
|
Parameters
|
|
@@ -922,56 +936,68 @@ class ExoIris:
|
|
|
922
936
|
This method plots the limb darkening parameters for two-parameter limb darkening models. It supports only
|
|
923
937
|
quadratic, quadratic-tri, power-2, and power-2-pm models.
|
|
924
938
|
"""
|
|
925
|
-
if not self._tsa.ldmodel in (
|
|
939
|
+
if not self._tsa.ldmodel in (
|
|
940
|
+
"quadratic",
|
|
941
|
+
"quadratic-tri",
|
|
942
|
+
"power-2",
|
|
943
|
+
"power-2-pm",
|
|
944
|
+
):
|
|
926
945
|
return None
|
|
927
946
|
|
|
928
947
|
if axs is None:
|
|
929
|
-
fig, axs = subplots(1, 2, sharey=
|
|
948
|
+
fig, axs = subplots(1, 2, sharey="all", figsize=(13, 4))
|
|
930
949
|
else:
|
|
931
950
|
fig = axs[0].get_figure()
|
|
932
951
|
|
|
933
952
|
if result is None:
|
|
934
|
-
result =
|
|
935
|
-
if result not in (
|
|
953
|
+
result = "mcmc" if self._tsa.sampler is not None else "fit"
|
|
954
|
+
if result not in ("fit", "mcmc"):
|
|
936
955
|
raise ValueError("Result must be either 'fit', 'mcmc', or None")
|
|
937
|
-
if result ==
|
|
938
|
-
|
|
956
|
+
if result == "mcmc" and not (
|
|
957
|
+
self._tsa.sampler is not None or self.mcmc_chains is not None
|
|
958
|
+
):
|
|
959
|
+
raise ValueError(
|
|
960
|
+
"Cannot plot posterior solution before running the MCMC sampler."
|
|
961
|
+
)
|
|
939
962
|
|
|
940
963
|
wavelength = concatenate(self.data.wavelengths)
|
|
941
964
|
ix = argsort(wavelength)
|
|
942
965
|
|
|
943
|
-
if result ==
|
|
966
|
+
if result == "fit":
|
|
944
967
|
pv = self._tsa._de_population[self._tsa._de_imin]
|
|
945
968
|
ldc = squeeze(concatenate(self._tsa._eval_ldc(pv), axis=1))
|
|
946
|
-
axs[0].plot(self._tsa.ld_knots, pv[self._tsa._sl_ld][0::2],
|
|
947
|
-
axs[0].plot(wavelength[ix], ldc[:,0][ix])
|
|
948
|
-
axs[1].plot(self._tsa.ld_knots, pv[self._tsa._sl_ld][1::2],
|
|
949
|
-
axs[1].plot(wavelength[ix], ldc[:,1][ix])
|
|
969
|
+
axs[0].plot(self._tsa.ld_knots, pv[self._tsa._sl_ld][0::2], "ok")
|
|
970
|
+
axs[0].plot(wavelength[ix], ldc[:, 0][ix])
|
|
971
|
+
axs[1].plot(self._tsa.ld_knots, pv[self._tsa._sl_ld][1::2], "ok")
|
|
972
|
+
axs[1].plot(wavelength[ix], ldc[:, 1][ix])
|
|
950
973
|
else:
|
|
951
|
-
|
|
952
|
-
|
|
974
|
+
if self._tsa.sampler is not None:
|
|
975
|
+
pvp = self._tsa._mc_chains.reshape([-1, self._tsa.ndim])
|
|
976
|
+
else:
|
|
977
|
+
pvp = self.mcmc_chains.reshape([-1, self._tsa.ndim])
|
|
978
|
+
ldc = pvp[:, self._tsa._sl_ld]
|
|
953
979
|
|
|
954
|
-
ld1m = median(ldc[
|
|
955
|
-
ld1e = ldc[
|
|
956
|
-
ld2m = median(ldc[:,1::2], 0)
|
|
957
|
-
ld2e = ldc[:,1::2].std(0)
|
|
980
|
+
ld1m = median(ldc[:, ::2], 0)
|
|
981
|
+
ld1e = ldc[:, ::2].std(0)
|
|
982
|
+
ld2m = median(ldc[:, 1::2], 0)
|
|
983
|
+
ld2e = ldc[:, 1::2].std(0)
|
|
958
984
|
|
|
959
985
|
ldc = concatenate(self._tsa._eval_ldc(pvp), axis=1)
|
|
960
|
-
ld1p = percentile(ldc[
|
|
961
|
-
ld2p = percentile(ldc[
|
|
986
|
+
ld1p = percentile(ldc[:, :, 0], [50, 16, 84], axis=0)
|
|
987
|
+
ld2p = percentile(ldc[:, :, 1], [50, 16, 84], axis=0)
|
|
962
988
|
|
|
963
989
|
axs[0].fill_between(wavelength[ix], ld1p[1, ix], ld1p[2, ix], alpha=0.5)
|
|
964
|
-
axs[0].plot(wavelength[ix], ld1p[0][ix],
|
|
990
|
+
axs[0].plot(wavelength[ix], ld1p[0][ix], "k")
|
|
965
991
|
axs[1].fill_between(wavelength[ix], ld2p[1, ix], ld2p[2, ix], alpha=0.5)
|
|
966
|
-
axs[1].plot(wavelength[ix], ld2p[0][ix],
|
|
992
|
+
axs[1].plot(wavelength[ix], ld2p[0][ix], "k")
|
|
967
993
|
|
|
968
|
-
axs[0].errorbar(self._tsa.ld_knots, ld1m, ld1e, fmt=
|
|
969
|
-
axs[1].errorbar(self._tsa.ld_knots, ld2m, ld2e, fmt=
|
|
994
|
+
axs[0].errorbar(self._tsa.ld_knots, ld1m, ld1e, fmt="ok")
|
|
995
|
+
axs[1].errorbar(self._tsa.ld_knots, ld2m, ld2e, fmt="ok")
|
|
970
996
|
|
|
971
997
|
ldp = full((self.nldp, 2, 2), nan)
|
|
972
998
|
for i in range(self.nldp):
|
|
973
999
|
for j in range(2):
|
|
974
|
-
p = self.ps[self._tsa._sl_ld][i*2+j].prior
|
|
1000
|
+
p = self.ps[self._tsa._sl_ld][i * 2 + j].prior
|
|
975
1001
|
if isinstance(p, UniformPrior):
|
|
976
1002
|
ldp[i, j, 0] = p.a
|
|
977
1003
|
ldp[i, j, 1] = p.b
|
|
@@ -981,11 +1007,15 @@ class ExoIris:
|
|
|
981
1007
|
|
|
982
1008
|
for i in range(2):
|
|
983
1009
|
for j in range(2):
|
|
984
|
-
axs[i].plot(self._tsa.ld_knots, ldp[:, i, j],
|
|
985
|
-
|
|
986
|
-
setp(
|
|
987
|
-
|
|
988
|
-
|
|
1010
|
+
axs[i].plot(self._tsa.ld_knots, ldp[:, i, j], ":", c="C0")
|
|
1011
|
+
|
|
1012
|
+
setp(
|
|
1013
|
+
axs,
|
|
1014
|
+
xlim=(wavelength.min(), wavelength.max()),
|
|
1015
|
+
xlabel=r"Wavelength [$\mu$m]",
|
|
1016
|
+
)
|
|
1017
|
+
setp(axs[0], ylabel="Limb darkening coefficient 1")
|
|
1018
|
+
setp(axs[1], ylabel="Limb darkening coefficient 2")
|
|
989
1019
|
return fig
|
|
990
1020
|
|
|
991
1021
|
def plot_residuals(self, result: Optional[str] = None, ax: None | Axes | Sequence[Axes] = None,
|
|
@@ -1109,8 +1139,8 @@ class ExoIris:
|
|
|
1109
1139
|
return fig
|
|
1110
1140
|
|
|
1111
1141
|
@property
|
|
1112
|
-
def
|
|
1113
|
-
"""Get the posterior transmission spectrum as
|
|
1142
|
+
def transmission_spectrum_table(self) -> Table:
|
|
1143
|
+
"""Get the posterior transmission spectrum as an Astropy Table.
|
|
1114
1144
|
|
|
1115
1145
|
Raises
|
|
1116
1146
|
------
|
|
@@ -1122,7 +1152,7 @@ class ExoIris:
|
|
|
1122
1152
|
|
|
1123
1153
|
pvp = self.posterior_samples
|
|
1124
1154
|
wls = concatenate(self.data.wavelengths)
|
|
1125
|
-
ks = concatenate(self._tsa._eval_k(pvp.values
|
|
1155
|
+
ks = concatenate(self._tsa._eval_k(pvp.values), axis=1)
|
|
1126
1156
|
ar = ks**2
|
|
1127
1157
|
ix = argsort(wls)
|
|
1128
1158
|
return Table(data=[wls[ix]*u.micrometer,
|
|
@@ -1130,21 +1160,103 @@ class ExoIris:
|
|
|
1130
1160
|
median(ar, 0)[ix], ar.std(0)[ix]],
|
|
1131
1161
|
names = ['wavelength', 'radius_ratio', 'radius_ratio_e', 'area_ratio', 'area_ratio_e'])
|
|
1132
1162
|
|
|
1133
|
-
def
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1163
|
+
def transmission_spectrum_samples(self, wavelengths: ndarray | None = None,
|
|
1164
|
+
kind: Literal['radius_ratio', 'depth'] = 'depth',
|
|
1165
|
+
samples: ndarray | None = None) -> tuple[ndarray, ndarray]:
|
|
1166
|
+
"""Calculate posterior transmission spectrum samples.
|
|
1167
|
+
|
|
1168
|
+
This method computes the posterior samples of the transmission spectrum,
|
|
1169
|
+
either as radius ratios or as transit depths, depending on the specified
|
|
1170
|
+
kind. It interpolates the data for given wavelengths or uses the
|
|
1171
|
+
instrumental wavelength grid if none is provided. Requires that MCMC
|
|
1172
|
+
sampling has been performed prior to calling this method.
|
|
1173
|
+
|
|
1174
|
+
Parameters
|
|
1175
|
+
----------
|
|
1176
|
+
wavelengths
|
|
1177
|
+
The array of wavelengths at which the spectrum should be sampled.
|
|
1178
|
+
If None, the default wavelength grid defined by the instrumental data
|
|
1179
|
+
will be used.
|
|
1180
|
+
kind
|
|
1181
|
+
Specifies the desired representation of the transmission spectrum.
|
|
1182
|
+
'radius_ratio' returns the spectrum in radius ratio units, while
|
|
1183
|
+
'depth' returns the spectrum in transit depth units. Default is 'depth'.
|
|
1184
|
+
samples
|
|
1185
|
+
Array of posterior samples to use for calculation. If None,
|
|
1186
|
+
the method will use previously stored posterior samples.
|
|
1187
|
+
|
|
1188
|
+
Returns
|
|
1189
|
+
-------
|
|
1190
|
+
ndarray
|
|
1191
|
+
Array containing the transmission spectrum samples for the specified
|
|
1192
|
+
wavelengths. The representation (radius ratio or depth) depends on the
|
|
1193
|
+
specified `kind`.
|
|
1194
|
+
"""
|
|
1195
|
+
if self.mcmc_chains is None and samples is None:
|
|
1196
|
+
raise ValueError("Cannot calculate posterior transmission spectrum before running the MCMC sampler.")
|
|
1197
|
+
|
|
1198
|
+
if kind not in ('radius_ratio', 'depth'):
|
|
1199
|
+
raise ValueError("Invalid value for `kind`. Must be either 'radius_ratio' or 'depth'.")
|
|
1200
|
+
|
|
1201
|
+
if samples is None:
|
|
1202
|
+
samples = self.posterior_samples.values
|
|
1203
|
+
|
|
1204
|
+
if wavelengths is None:
|
|
1205
|
+
wavelengths = concatenate(self.data.wavelengths)
|
|
1206
|
+
wavelengths.sort()
|
|
1207
|
+
|
|
1208
|
+
k_posteriors = zeros((samples.shape[0], wavelengths.size))
|
|
1209
|
+
k_knots = self._tsa.k_knots.copy()
|
|
1210
|
+
for i, pv in enumerate(samples):
|
|
1211
|
+
if self._tsa.free_k_knot_ids is not None:
|
|
1212
|
+
k_knots[self._tsa.free_k_knot_ids] = pv[self._tsa._sl_kloc]
|
|
1213
|
+
k_posteriors[i, :] = self._tsa._ip(wavelengths, k_knots, pv[self._tsa._sl_rratios])
|
|
1214
|
+
|
|
1215
|
+
if kind == 'radius_ratio':
|
|
1216
|
+
return wavelengths, k_posteriors
|
|
1217
|
+
else:
|
|
1218
|
+
return wavelengths, k_posteriors**2
|
|
1219
|
+
|
|
1220
|
+
def transmission_spectrum(self, wavelengths: ndarray | None = None, kind: Literal['radius_ratio', 'depth'] = 'depth', samples: ndarray | None = None, return_cov: bool = True) -> tuple[ndarray, ndarray]:
|
|
1221
|
+
"""Compute the transmission spectrum.
|
|
1222
|
+
|
|
1223
|
+
This method calculates the mean transmission spectrum values and the covariance matrix
|
|
1224
|
+
(or standard deviations) for the given parameter set. The mean represents the average
|
|
1225
|
+
transmission spectrum, and the covariance provides information on the uncertainties and
|
|
1226
|
+
correlations between wavelengths or samples.
|
|
1227
|
+
|
|
1228
|
+
Parameters
|
|
1229
|
+
----------
|
|
1230
|
+
wavelengths
|
|
1231
|
+
Array of wavelength values at which to calculate the transmission spectrum.
|
|
1232
|
+
If None, the default grid will be used.
|
|
1233
|
+
kind
|
|
1234
|
+
Specifies the method to represent the spectrum. 'radius_ratio' computes the
|
|
1235
|
+
spectrum in terms of the planet-to-star radius ratio, while 'depth' computes
|
|
1236
|
+
the spectrum in terms of transit depth.
|
|
1237
|
+
samples
|
|
1238
|
+
Array of samples used to compute the spectrum uncertainties. If None, previously
|
|
1239
|
+
stored samples will be utilized.
|
|
1240
|
+
|
|
1241
|
+
return_cov : bool, optional
|
|
1242
|
+
Indicates whether to return the covariance matrix of the computed transmission
|
|
1243
|
+
spectrum. If True, the covariance matrix is returned along with the mean spectrum.
|
|
1244
|
+
If False, the standard deviation of the spectrum is returned.
|
|
1245
|
+
|
|
1246
|
+
Returns
|
|
1247
|
+
-------
|
|
1248
|
+
tuple[ndarray, ndarray]
|
|
1249
|
+
A tuple containing two arrays:
|
|
1250
|
+
- The mean transmission spectrum.
|
|
1251
|
+
- The covariance matrix of the spectrum (if `return_cov` is True), or the
|
|
1252
|
+
standard deviation (if `return_cov` is False).
|
|
1253
|
+
"""
|
|
1254
|
+
sp_samples = self.transmission_spectrum_samples(wavelengths, kind, samples)[1]
|
|
1255
|
+
mean = sp_samples.mean(0)
|
|
1256
|
+
if return_cov:
|
|
1257
|
+
return mean, cov(sp_samples, rowvar=False)
|
|
1258
|
+
else:
|
|
1259
|
+
return mean, sp_samples.std(0)
|
|
1148
1260
|
|
|
1149
1261
|
def save(self, overwrite: bool = False) -> None:
|
|
1150
1262
|
"""Save the ExoIris analysis to a FITS file.
|
|
@@ -1163,6 +1275,13 @@ class ExoIris:
|
|
|
1163
1275
|
pri.header['interp'] = self._tsa.interpolation
|
|
1164
1276
|
pri.header['noise'] = self._tsa.noise_model
|
|
1165
1277
|
|
|
1278
|
+
if self._tsa.free_k_knot_ids is None:
|
|
1279
|
+
pri.header['n_free_k'] = 0
|
|
1280
|
+
else:
|
|
1281
|
+
pri.header['n_free_k'] = len(self._tsa.free_k_knot_ids)
|
|
1282
|
+
for i, ix in enumerate(self._tsa.free_k_knot_ids):
|
|
1283
|
+
pri.header[f'kk_ix_{i:03d}'] = ix
|
|
1284
|
+
|
|
1166
1285
|
# Priors
|
|
1167
1286
|
# ======
|
|
1168
1287
|
pr = pf.ImageHDU(name='priors')
|
|
@@ -1246,7 +1365,9 @@ class ExoIris:
|
|
|
1246
1365
|
|
|
1247
1366
|
hdul.writeto(f"{self.name}.fits", overwrite=True)
|
|
1248
1367
|
|
|
1249
|
-
def create_loglikelihood_function(self, wavelengths:
|
|
1368
|
+
def create_loglikelihood_function(self, wavelengths: ndarray, kind: Literal['radius_ratio', 'depth'] = 'depth',
|
|
1369
|
+
method: Literal['svd', 'randomized_svd', 'eigh'] = 'svd',
|
|
1370
|
+
n_max_samples: int = 10000) -> LogLikelihood:
|
|
1250
1371
|
"""Create a reduced-rank Gaussian log-likelihood function for retrieval.
|
|
1251
1372
|
|
|
1252
1373
|
Parameters
|
|
@@ -1265,7 +1386,11 @@ class ExoIris:
|
|
|
1265
1386
|
"""
|
|
1266
1387
|
if self.mcmc_chains is None:
|
|
1267
1388
|
raise ValueError("Cannot create log-likelihood function before running the MCMC sampler.")
|
|
1268
|
-
return LogLikelihood(
|
|
1389
|
+
return LogLikelihood(wavelengths,
|
|
1390
|
+
self.transmission_spectrum_samples(wavelengths, kind)[1],
|
|
1391
|
+
method=method,
|
|
1392
|
+
n_max_samples=n_max_samples,
|
|
1393
|
+
nk=self.nk)
|
|
1269
1394
|
|
|
1270
1395
|
def create_initial_population(self, n: int, source: str, add_noise: bool = True) -> ndarray:
|
|
1271
1396
|
"""Create an initial parameter vector population for the DE optimisation.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# ExoIris: fast, flexible, and easy exoplanet transmission spectroscopy in Python.
|
|
2
|
+
# Copyright (C) 2025 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
|
+
from typing import Literal
|
|
17
|
+
|
|
18
|
+
from numpy import full, cov, sum, ndarray, log, pi, asarray
|
|
19
|
+
from numpy.linalg import eigh, svd
|
|
20
|
+
from sklearn.utils.extmath import randomized_svd
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LogLikelihood:
|
|
24
|
+
def __init__(self, wavelength: ndarray, spectra: None | ndarray = None, spmean: None | ndarray = None,
|
|
25
|
+
spcov: None | ndarray = None, eps: float = 1e-10, method: Literal['svd', 'randomized_svd', 'eigh'] = 'svd',
|
|
26
|
+
n_max_samples: int = 10000, nk: int | None = None):
|
|
27
|
+
"""Reduced-rank Normal log-likelihood.
|
|
28
|
+
|
|
29
|
+
This class constructs a statistically robust log-likelihood function for
|
|
30
|
+
comparing a theoretical transmission spectrum to the posterior distribution
|
|
31
|
+
inferred by ExoIris.
|
|
32
|
+
|
|
33
|
+
Because the posterior samples are generated from a spline with $K$ knots
|
|
34
|
+
but evaluated on $M$ wavelengths ($M \gg K$), the empirical covariance
|
|
35
|
+
matrix is singular or strongly ill-conditioned. This class solves the
|
|
36
|
+
rank-deficiency problem by projecting the model into the principal
|
|
37
|
+
subspace of the posterior (Karhunen-Loève compression).
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
wavelength
|
|
42
|
+
The wavelength grid with a shape (M,) on which the posterior samples and theoretical
|
|
43
|
+
spectra are evaluated.
|
|
44
|
+
spectra
|
|
45
|
+
The posterior spectrum samples with shape (N_samples, M_wavelengths).
|
|
46
|
+
If provided, ``spmean`` and ``spcov`` are computed automatically.
|
|
47
|
+
Mutually exclusive with ``spmean`` and ``spcov``.
|
|
48
|
+
spmean
|
|
49
|
+
The pre-computed mean spectrum with shape (M,). Must be provided
|
|
50
|
+
along with ``spcov`` if ``spectra`` is None.
|
|
51
|
+
spcov
|
|
52
|
+
The pre-computed covariance matrix with shape (M, M). Must be provided
|
|
53
|
+
along with ``spmean`` if ``spectra`` is None.
|
|
54
|
+
eps
|
|
55
|
+
Relative tolerance factor used to determine which eigenvalues of
|
|
56
|
+
the covariance matrix are considered significant. Eigenvalues smaller
|
|
57
|
+
than ``eps * max_eigenvalue`` are discarded. Default is ``1e-10``.
|
|
58
|
+
|
|
59
|
+
Notes
|
|
60
|
+
-----
|
|
61
|
+
This implementation follows the "Signal-to-Noise Eigenmode" formalism
|
|
62
|
+
described by Tegmark et al. (1997) for analyzing rank-deficient
|
|
63
|
+
cosmological datasets.
|
|
64
|
+
|
|
65
|
+
The log-likelihood is evaluated as:
|
|
66
|
+
|
|
67
|
+
.. math:: \ln \mathcal{L} = -\frac{1}{2} \left[ \sum_{i=1}^{K} \frac{p_i^2}{\lambda_i} + \sum_{i=1}^{K} \ln(\lambda_i) + K \ln(2\pi) \right]
|
|
68
|
+
|
|
69
|
+
where $\lambda_i$ are the significant eigenvalues of the covariance
|
|
70
|
+
matrix, and $p_i$ are the projections of the model residuals onto the
|
|
71
|
+
corresponding eigenvectors (principal components).
|
|
72
|
+
|
|
73
|
+
References
|
|
74
|
+
----------
|
|
75
|
+
Tegmark, M., Taylor, A. N., & Heavens, A. F. (1997). Karhunen-Loève
|
|
76
|
+
eigenvalue problems in cosmology: how should we tackle large data sets?
|
|
77
|
+
*The Astrophysical Journal*, 480(1), 22.
|
|
78
|
+
"""
|
|
79
|
+
self.wavelength = wavelength
|
|
80
|
+
self.eps = eps
|
|
81
|
+
|
|
82
|
+
if spectra is not None and (spmean is not None or spcov is not None):
|
|
83
|
+
raise ValueError("Cannot specify both `spectra` and `spmean` and `spcov`.")
|
|
84
|
+
|
|
85
|
+
if spectra is None and (spmean is None or spcov is None):
|
|
86
|
+
raise ValueError("Must specify either `spectra` or both `spmean` and `spcov`.")
|
|
87
|
+
|
|
88
|
+
if spectra is not None:
|
|
89
|
+
spectra = spectra[:n_max_samples, :]
|
|
90
|
+
self.spmean = spectra.mean(axis=0)
|
|
91
|
+
|
|
92
|
+
if method == 'svd':
|
|
93
|
+
_, sigma, evecs = svd(spectra - spectra.mean(0), full_matrices=False)
|
|
94
|
+
evals = (sigma**2) / (spectra.shape[0] - 1)
|
|
95
|
+
evecs = evecs.T
|
|
96
|
+
elif method == 'randomized_svd':
|
|
97
|
+
if nk is None:
|
|
98
|
+
raise ValueError("Must specify `nk` when using `method='randomized_svd'`.")
|
|
99
|
+
_, sigma, evecs = randomized_svd(spectra - spectra.mean(0), n_components=nk, n_iter=5, random_state=0)
|
|
100
|
+
evals = (sigma ** 2) / (spectra.shape[0] - 1)
|
|
101
|
+
evecs = evecs.T
|
|
102
|
+
elif method == 'eigh' or (spmean is not None and spcov is not None):
|
|
103
|
+
if spectra is not None:
|
|
104
|
+
self.spcov = cov(spectra, rowvar=False)
|
|
105
|
+
else:
|
|
106
|
+
self.spmean = spmean
|
|
107
|
+
self.spcov = spcov
|
|
108
|
+
evals, evecs = eigh(self.spcov)
|
|
109
|
+
|
|
110
|
+
keep = evals > eps * evals.max()
|
|
111
|
+
self.eigenvalues, self.eigenvectors = evals[keep], evecs[:, keep]
|
|
112
|
+
self.log_det = sum(log(self.eigenvalues))
|
|
113
|
+
self.log_twopi = self.eigenvalues.size * log(2*pi)
|
|
114
|
+
|
|
115
|
+
def __call__(self, model: ndarray | float) -> ndarray:
|
|
116
|
+
"""Evaluate the log-likelihood of a model spectrum.
|
|
117
|
+
|
|
118
|
+
Parameters
|
|
119
|
+
----------
|
|
120
|
+
model : float or ndarray
|
|
121
|
+
The theoretical model spectrum. If a float is provided, it is
|
|
122
|
+
broadcast to a flat spectrum. If an array, it must match the
|
|
123
|
+
wavelength grid size used during initialization.
|
|
124
|
+
|
|
125
|
+
Returns
|
|
126
|
+
-------
|
|
127
|
+
float
|
|
128
|
+
The natural log-likelihood $\ln \mathcal{L}$.
|
|
129
|
+
"""
|
|
130
|
+
if isinstance(model, float):
|
|
131
|
+
model = full(self.wavelength.size, model)
|
|
132
|
+
else:
|
|
133
|
+
model = asarray(model)
|
|
134
|
+
|
|
135
|
+
# Project the residuals onto the eigenvectors (Basis Rotation)
|
|
136
|
+
# and Compute the Mahalanobis Distance (Chi-Squared in Subspace).
|
|
137
|
+
p = (self.spmean - model) @ self.eigenvectors
|
|
138
|
+
chisq = sum(p**2 / self.eigenvalues)
|
|
139
|
+
return -0.5 * (chisq + self.log_det + self.log_twopi)
|