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.
Files changed (58) hide show
  1. {exoiris-0.21.0 → exoiris-0.23.0}/CHANGELOG.md +25 -0
  2. {exoiris-0.21.0 → exoiris-0.23.0}/ExoIris.egg-info/PKG-INFO +2 -1
  3. {exoiris-0.21.0 → exoiris-0.23.0}/ExoIris.egg-info/requires.txt +1 -0
  4. {exoiris-0.21.0 → exoiris-0.23.0}/PKG-INFO +2 -1
  5. {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/exoiris.py +184 -59
  6. exoiris-0.23.0/exoiris/loglikelihood.py +139 -0
  7. {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/tsdata.py +46 -12
  8. {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/tslpf.py +118 -54
  9. {exoiris-0.21.0 → exoiris-0.23.0}/requirements.txt +2 -1
  10. exoiris-0.21.0/exoiris/loglikelihood.py +0 -144
  11. {exoiris-0.21.0 → exoiris-0.23.0}/.github/workflows/python-package.yml +0 -0
  12. {exoiris-0.21.0 → exoiris-0.23.0}/.gitignore +0 -0
  13. {exoiris-0.21.0 → exoiris-0.23.0}/.readthedocs.yaml +0 -0
  14. {exoiris-0.21.0 → exoiris-0.23.0}/CODE_OF_CONDUCT.md +0 -0
  15. {exoiris-0.21.0 → exoiris-0.23.0}/ExoIris.egg-info/SOURCES.txt +0 -0
  16. {exoiris-0.21.0 → exoiris-0.23.0}/ExoIris.egg-info/dependency_links.txt +0 -0
  17. {exoiris-0.21.0 → exoiris-0.23.0}/ExoIris.egg-info/top_level.txt +0 -0
  18. {exoiris-0.21.0 → exoiris-0.23.0}/LICENSE +0 -0
  19. {exoiris-0.21.0 → exoiris-0.23.0}/README.md +0 -0
  20. {exoiris-0.21.0 → exoiris-0.23.0}/doc/Makefile +0 -0
  21. {exoiris-0.21.0 → exoiris-0.23.0}/doc/make.bat +0 -0
  22. {exoiris-0.21.0 → exoiris-0.23.0}/doc/requirements.txt +0 -0
  23. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/_static/css/custom.css +0 -0
  24. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/api/binning.rst +0 -0
  25. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/api/exoiris.rst +0 -0
  26. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/api/tsdata.rst +0 -0
  27. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/conf.py +0 -0
  28. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/01a_not_so_short_intro.ipynb +0 -0
  29. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/01b_short_intro.ipynb +0 -0
  30. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/02_increasing_knot_resolution.ipynb +0 -0
  31. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/03_increasing_data_resolution.ipynb +0 -0
  32. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/04_gaussian_processes.ipynb +0 -0
  33. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/05a_ldtkldm.ipynb +0 -0
  34. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/A2_full_data_resolution.ipynb +0 -0
  35. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/appendix_1_data_preparation.ipynb +0 -0
  36. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/data/README.txt +0 -0
  37. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/data/nirHiss_order_1.h5 +0 -0
  38. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/data/nirHiss_order_2.h5 +0 -0
  39. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/example1.png +0 -0
  40. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/e01/plot_1.ipynb +0 -0
  41. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/figures.ipynb +0 -0
  42. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/friendly_introduction.ipynb +0 -0
  43. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/index.rst +0 -0
  44. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/k_knot_example.svg +0 -0
  45. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/examples/setup_multiprocessing.py +0 -0
  46. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/index.rst +0 -0
  47. {exoiris-0.21.0 → exoiris-0.23.0}/doc/source/install.rst +0 -0
  48. {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/__init__.py +0 -0
  49. {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/binning.py +0 -0
  50. {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/ephemeris.py +0 -0
  51. {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/ldtkld.py +0 -0
  52. {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/spotmodel.py +0 -0
  53. {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/tsmodel.py +0 -0
  54. {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/util.py +0 -0
  55. {exoiris-0.21.0 → exoiris-0.23.0}/exoiris/wlpf.py +0 -0
  56. {exoiris-0.21.0 → exoiris-0.23.0}/pyproject.toml +0 -0
  57. {exoiris-0.21.0 → exoiris-0.23.0}/setup.cfg +0 -0
  58. {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.21.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
@@ -11,3 +11,4 @@ xarray
11
11
  seaborn
12
12
  astropy
13
13
  uncertainties
14
+ scikit-learn
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ExoIris
3
- Version: 0.21.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
- a._tsa.ps = ParameterSet([pickle.loads(p) for p in priors])
140
- a._tsa.ps.freeze()
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['bspline', 'pchip', 'makima', 'nearest', 'linear'] = 'makima'):
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[self._tsa._sl_rratios])
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.iloc[:, self._tsa._sl_rratios])
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(self, result: Optional[str] = None, axs: Optional[tuple[Axes, Axes]] = None) -> None | Figure:
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 ('quadratic', 'quadratic-tri', 'power-2', 'power-2-pm'):
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='all', figsize=(13,4))
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 = 'mcmc' if self._tsa.sampler is not None else 'fit'
935
- if result not in ('fit', 'mcmc'):
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 == 'mcmc' and self._tsa.sampler is None:
938
- raise ValueError("Cannot plot posterior solution before running the MCMC sampler.")
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 == 'fit':
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], 'ok')
947
- axs[0].plot(wavelength[ix], ldc[:,0][ix])
948
- axs[1].plot(self._tsa.ld_knots, pv[self._tsa._sl_ld][1::2], 'ok')
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
- pvp = self._tsa._mc_chains.reshape([-1, self._tsa.ndim])
952
- ldc = pvp[:,self._tsa._sl_ld]
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[:,::2], 0)
955
- ld1e = ldc[:,::2].std(0)
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[:,:,0], [50, 16, 84], axis=0)
961
- ld2p = percentile(ldc[:,:,1], [50, 16, 84], axis=0)
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], 'k')
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], 'k')
992
+ axs[1].plot(wavelength[ix], ld2p[0][ix], "k")
967
993
 
968
- axs[0].errorbar(self._tsa.ld_knots, ld1m, ld1e, fmt='ok')
969
- axs[1].errorbar(self._tsa.ld_knots, ld2m, ld2e, fmt='ok')
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], ':', c='C0')
985
-
986
- setp(axs, xlim=(wavelength.min(), wavelength.max()), xlabel=r'Wavelength [$\mu$m]')
987
- setp(axs[0], ylabel='Limb darkening coefficient 1')
988
- setp(axs[1], ylabel='Limb darkening coefficient 2')
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 transmission_spectrum(self) -> Table:
1113
- """Get the posterior transmission spectrum as a Pandas DataFrame.
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[:, self._tsa._sl_rratios]), axis=1)
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 radius_ratio_spectrum(self, wavelengths: ArrayLike, knot_samples: ArrayLike | None = None) -> ndarray:
1134
- if knot_samples is None:
1135
- knot_samples = self.posterior_samples.iloc[:, self._tsa._sl_rratios].values
1136
- k_posteriors = zeros((knot_samples.shape[0], wavelengths.size))
1137
- for i, ks in enumerate(knot_samples):
1138
- k_posteriors[i, :] = self._tsa._ip(wavelengths, self._tsa.k_knots, ks)
1139
- return k_posteriors
1140
-
1141
- def area_ratio_spectrum(self, wavelengths: ArrayLike, knot_samples: ArrayLike | None = None) -> ndarray:
1142
- if knot_samples is None:
1143
- knot_samples = self.posterior_samples.iloc[:, self._tsa._sl_rratios].values
1144
- d_posteriors = zeros((knot_samples.shape[0], wavelengths.size))
1145
- for i, ks in enumerate(knot_samples):
1146
- d_posteriors[i, :] = self._tsa._ip(wavelengths, self._tsa.k_knots, ks) ** 2
1147
- return d_posteriors
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: ArrayLike, kind: Literal['radius_ratio', 'depth'] = 'depth') -> LogLikelihood:
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(self, wavelengths, kind)
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)