import itertools
import numpy as np
from astropy import units as u
from astropy.table import Table
from pydantic import BaseModel
try:
from pytransit import RoadRunnerModel
except ImportError: # pragma: no cover
# pytransit is an optional dependency (the ``exoplanet`` extra). Importing
# this module must still succeed without it so the rest of stellarphot can
# be imported; instantiating TransitModelFit without pytransit raises a
# clear error (see __init__).
RoadRunnerModel = None
try:
import lmfit
except ImportError: # pragma: no cover
# lmfit is an optional dependency (the ``exoplanet`` extra), guarded the
# same way as pytransit.
lmfit = None
_PYTRANSIT_INSTALL_MESSAGE = (
"You must install pytransit to use TransitModelFit. Try:\n"
" conda install -c conda-forge pytransit\n"
"or\n"
" pip install pytransit"
)
_LMFIT_INSTALL_MESSAGE = (
"You must install lmfit to use TransitModelFit. Try:\n"
" conda install -c conda-forge lmfit\n"
"or\n"
" pip install lmfit"
)
__all__ = ["TransitModelOptions", "TransitModelFit"]
def _default_params():
"""
Default parameter set for a transit fit.
Trend fitting is opt-in: the trend coefficients start with
``vary=False`` and are only fit if the user (or
`~stellarphot.transit_fitting.TransitModelOptions`) turns them on.
"""
params = lmfit.Parameters()
params.add("t0", value=0.0, vary=True)
params.add("period", value=1.0, vary=False)
params.add("rp", value=0.1, vary=True, min=0.01, max=0.5)
params.add("a", value=10.0, vary=True, min=1.0)
params.add("inclination", value=90.0, vary=True, min=50, max=90)
params.add("limb_u1", value=0.3, vary=False)
params.add("limb_u2", value=0.3, vary=False)
params.add("airmass_trend", value=0.0, vary=False)
params.add("width_trend", value=0.0, vary=False)
params.add("spp_trend", value=0.0, vary=False)
return params
[docs]
class TransitModelOptions(BaseModel):
bin_size: float = 5.0
keep_transit_time_fixed: bool = True
transit_time_range: float = 60.0
keep_radius_planet_fixed: bool = False
keep_radius_orbit_fixed: bool = False
fit_airmass: bool = False
fit_width: bool = False
fit_spp: bool = False
[docs]
class TransitModelFit:
"""
Transit model fits to observed light curves.
The underlying transit light curve is computed with pytransit's
`RoadRunnerModel
<https://pytransit.readthedocs.io/en/latest/notebooks/models/roadrunner/roadrunner_model_example_1.html>`_
using a quadratic limb-darkening law and a circular orbit. The fit is
performed with `lmfit <https://lmfit.github.io/lmfit-py/>`_.
Attributes
----------
params : `lmfit.Parameters`
The transit model parameters: ``t0``, ``period``, ``rp`` (planet
radius in stellar radii), ``a`` (orbital radius in stellar radii),
``inclination`` (degrees), ``limb_u1``/``limb_u2`` (quadratic
limb-darkening coefficients) and the linear trend coefficients
``airmass_trend``, ``width_trend`` and ``spp_trend``. Read or set
values, bounds and whether a parameter is fit through the standard
lmfit attributes, e.g. ``mod.params["rp"].value``,
``mod.params["t0"].vary``, ``mod.params["a"].min`` and, after a
fit, ``mod.params["rp"].stderr``.
fit_result : `lmfit.minimizer.MinimizerResult` or None
Result of the most recent call to ``fit``, including fit statistics
like ``bic`` and ``nvarys``. ``None`` until ``fit`` has been run.
times, airmass, width, spp, data, weights : array-like or None
Independent variables and data for the fit; see the property
docstrings.
Notes
-----
The orbit is always circular; there is no eccentricity parameter.
Trend fitting is opt-in: the trend coefficients default to
``vary=False`` even when a covariate (airmass, width, sky per pixel) is
set. Enable them via `TransitModelOptions`, directly (e.g.
``mod.params["airmass_trend"].vary = True``) or with
``compare_detrend_options(apply_best=True)``.
Examples
--------
Fit a transit, detrending against airmass::
mod = TransitModelFit()
mod.setup_model(t0=2455001.5, depth=12.1, duration=0.15, period=3.5)
mod.times = times
mod.data = fluxes
mod.weights = 1 / flux_errors
mod.airmass = airmass
mod.params["airmass_trend"].vary = True
result = mod.fit()
print(mod.params["rp"].value, mod.params["rp"].stderr)
print(result.bic)
# Which detrending parameters does the BIC favor?
bic_table = mod.compare_detrend_options()
"""
def __init__(self):
if RoadRunnerModel is None:
raise ImportError(_PYTRANSIT_INSTALL_MESSAGE)
if lmfit is None:
raise ImportError(_LMFIT_INSTALL_MESSAGE)
# pytransit's RoadRunnerModel with the quadratic limb-darkening law is
# parameterized at evaluation time via ``evaluate(...)``, so no separate
# parameter container is needed. The time array is supplied later via
# ``set_data`` (see the ``times`` setter).
#
# ``klims`` bounds the precomputed radius-ratio interpolation table.
# Its upper end must stay strictly above ``rp``'s allowed maximum (0.5
# in ``_default_params``): RoadRunner's native evaluator reads one
# element past the table when ``k`` lands exactly on the upper limit
# (an off-by-one in pytransit's boundary handling), which crashes with
# a segfault/access violation on some platforms. Widening the upper
# limit keeps the whole fittable ``rp`` range safely inside the table.
self._transit_model = RoadRunnerModel("quadratic", klims=(0.005, 0.6))
self._times = None
self._airmass = None
self._spp = None
self._width = None
self._data = None
self.weights = None
self.params = _default_params()
self.fit_result = None
self._detrend_parameters = set()
self._all_detrend_params = ["airmass", "width", "spp"]
def _check_consistent_lengths(self, proposed_value):
"""
Check that the proposed value has a length consistent with the
other independent variables. Consistent means same length as others
or the others are ``None``.
"""
if proposed_value is None:
return True
new_length = len(proposed_value)
for independent_var in [self._times, self._airmass, self._spp, self._width]:
if independent_var is None:
continue
elif len(independent_var) != new_length:
return False
else:
# All the lengths were good
return True
@property
def times(self):
"""
times : array-like
Times at which the light curve is observed. Must be set before
fitting.
"""
return self._times
@times.setter
def times(self, value):
if not self._check_consistent_lengths(value):
raise ValueError(
"Length of times not consistent with other independent variables."
)
self._times = value
if value is not None:
self._transit_model.set_data(np.asarray(value, dtype=float))
@property
def airmass(self):
"""
airmass : array-like
Airmass at each time. Must be set before fitting.
"""
return self._airmass
@airmass.setter
def airmass(self, value):
if not self._check_consistent_lengths(value):
raise ValueError(
"Length of airmass not consistent with other independent variables."
)
self._airmass = value
if value is not None:
self._detrend_parameters.add("airmass")
else:
self._detrend_parameters.discard("airmass")
@property
def width(self):
"""
weights : array-like
Weights to use for fitting. If not provided, all weights are
set to 1.
"""
return self._width
@width.setter
def width(self, value):
if not self._check_consistent_lengths(value):
raise ValueError(
"Length of width not consistent with other independent variables."
)
self._width = value
if value is not None:
self._detrend_parameters.add("width")
else:
self._detrend_parameters.discard("width")
@property
def spp(self):
"""
spp : array-like
Sky per pixel at each time. Must be set before fitting.
"""
return self._spp
@spp.setter
def spp(self, value):
if not self._check_consistent_lengths(value):
raise ValueError(
"Length of spp not consistent with other independent variables."
)
self._spp = value
if value is not None:
self._detrend_parameters.add("spp")
else:
self._detrend_parameters.discard("spp")
@property
def data(self):
"""
data : array-like
Observed fluxes. Must be set before fitting.
"""
return self._data
@data.setter
def data(self, value):
if not self._check_consistent_lengths(value):
raise ValueError(
"Length of data not consistent with independent variables."
)
self._data = value
def _model_flux(self, params, airmass, width, spp):
"""
Evaluate the model flux (transit plus additive linear trends) for the
given parameters at the times currently set on the pytransit model.
"""
p = params.valuesdict()
flux = self._transit_model.evaluate(
k=p["rp"],
ldc=[p["limb_u1"], p["limb_u2"]],
t0=p["t0"],
p=p["period"],
a=p["a"],
i=np.radians(p["inclination"]),
)
flux += p["airmass_trend"] * airmass
flux += p["width_trend"] * width
flux += p["spp_trend"] * spp
return flux
def _residual(self, params, airmass, width, spp, weights):
return (self._model_flux(params, airmass, width, spp) - self.data) * weights
def _run_fit(self, params):
"""
Run an lmfit minimization with the given parameters, returning the
`lmfit.minimizer.MinimizerResult`.
The fit runs on a copy of ``params``, so neither the input nor any
instance state is modified. For each covariate that is not set, the
corresponding trend parameter is forced to zero (and not varied) on
that copy and an all-zero covariate is substituted.
"""
params = params.copy()
covariates = {}
for name in self._all_detrend_params:
value = getattr(self, name)
if value is None:
params[f"{name}_trend"].set(value=0.0, vary=False)
covariates[name] = np.zeros(len(self.times))
else:
covariates[name] = np.asarray(value, dtype=float)
weights = self.weights if self.weights is not None else 1.0
# method="least_squares" is scipy's Trust Region Reflective, which
# handles a starting value that sits exactly on a bound (the default
# inclination of 90 degrees is on its upper bound); MINPACK leastsq
# does not.
return lmfit.minimize(
self._residual,
params,
method="least_squares",
args=(
covariates["airmass"],
covariates["width"],
covariates["spp"],
weights,
),
)
[docs]
def setup_model(
self,
*,
binned_data=None,
t0=0,
depth=0,
duration=0,
period=1,
inclination=90,
airmass_trend=0.0,
width_trend=0.0,
spp_trend=0.0,
model_options=None,
):
"""
Configure a transit model for fitting. The ``duration`` and ``depth``
are used to estimate underlying fit parameters; they are not
themselves fit parameters. Any previous parameter settings are reset
to their defaults before the new values are applied.
All arguments are keyword-only.
Parameters
----------
binned_data : `astropy.timeseries.BinnedTimeSeries`, optional
Binned time series to load the times, data, weights and
covariates from.
t0 : float
Time of the center of the transit. Can be in any units
but should be consistent with the units for the ``period``
and for the times used for fitting.
depth : float
Depth of the transit in parts per thousand. If zero (the
default), the planet radius ``rp`` is left at its default
starting value instead of being estimated.
duration : float
Duration of the transit, in the same units as ``t0`` and
``period``. If zero (the default), the orbital radius ``a`` is
left at its default starting value instead of being estimated.
Must be smaller than the ``period``.
period : float
Period of the planet. Should be in the same units as ``t0``
and times used for fitting.
inclination : float
Inclination of the orbit, in degrees.
airmass_trend : float, optional
Coefficient for a linear trend in airmass.
width_trend : float
Coefficient for a linear trend in stellar width.
spp_trend : float
Coefficient for a linear trend in sky per pixel.
model_options : TransitModelOptions, optional
Options for the transit model fit, mapped onto the ``vary``
flags (and, for ``t0``, the bounds) of the parameters.
Returns
-------
None
Sets values for the model parameters.
Raises
------
ValueError
If ``duration`` is not smaller than ``period``.
"""
if duration >= period:
raise ValueError(
f"The transit duration ({duration}) must be smaller than "
f"the period ({period})."
)
if binned_data:
self.times = (
np.array(
(
binned_data["time_bin_start"] + binned_data["time_bin_size"] / 2
).value
)
- 2400000
)
self.data = binned_data["normalized_flux"].value
self.weights = 1 / (binned_data["normalized_flux_error"].value)
self.airmass = np.array(binned_data["airmass"])
self.width = np.array(binned_data["width"])
self.spp = np.array(binned_data["sky_per_pix_avg"])
self.params = _default_params()
# rp is related to depth in a straightforward way; a nonpositive
# depth means "no estimate," leaving rp at its default. Seeding rp
# at zero would in any case start the fit below rp's lower bound.
if depth > 0:
self.params["rp"].value = np.sqrt(depth / 1000)
# The estimate below assumes a circular orbit and inclination of
# 90 degrees (edge on). This should be fine as a starting point
# for the fit. A nonpositive duration means "no estimate," leaving
# a at its default.
#
# See Kipping, eq. 16 at
# https://doi.org/10.1111/j.1365-2966.2010.16894.x
if duration > 0:
self.params["a"].value = 1 / np.sin(duration * np.pi / period)
self.params["period"].value = period
self.params["inclination"].value = inclination
self.params["t0"].value = t0
self.params["airmass_trend"].value = airmass_trend
self.params["width_trend"].value = width_trend
self.params["spp_trend"].value = spp_trend
if model_options is not None:
half_range = (model_options.transit_time_range * u.min).to("day").value / 2
self.params["t0"].min = t0 - half_range
self.params["t0"].max = t0 + half_range
self.params["t0"].vary = not model_options.keep_transit_time_fixed
self.params["a"].vary = not model_options.keep_radius_orbit_fixed
self.params["rp"].vary = not model_options.keep_radius_planet_fixed
self.params["airmass_trend"].vary = model_options.fit_airmass
self.params["width_trend"].vary = model_options.fit_width
self.params["spp_trend"].vary = model_options.fit_spp
[docs]
def fit(self):
"""
Fit the model to the data and update ``params`` with the best-fit
values and uncertainties.
Returns
-------
`lmfit.minimizer.MinimizerResult`
The full fit result, also stored as ``fit_result``. Includes
fit statistics like ``bic``.
Raises
------
ValueError
If the times or the data have not been set. If the fit itself
raises, ``params`` and ``fit_result`` are left untouched.
"""
if self.times is None:
raise ValueError("The times must be set before trying to fit.")
if self.data is None:
raise ValueError("The data must be set before trying to fit.")
result = self._run_fit(self.params)
self.fit_result = result
# Copy the best-fit values and uncertainties back into the user's
# parameters without touching vary/min/max, so user choices (e.g. a
# trend enabled while its covariate is unset) survive the fit.
for name, param in result.params.items():
self.params[name].value = param.value
self.params[name].stderr = param.stderr
return result
[docs]
def compare_detrend_options(self, apply_best=False):
"""
Compare, via the Bayesian Information Criterion, fits with every
on/off combination of the trend parameters whose covariate is set.
Each candidate fit runs on a copy of ``params`` — with the trends in
the combination varying, and the others fixed at zero — so this
method does not change the state of the model.
Parameters
----------
apply_best : bool, optional
If True, apply the lowest-BIC combination to ``params`` (each
trend in the winning combination is varied, the others are
zeroed and fixed) and run ``fit``.
Returns
-------
`astropy.table.Table`
One row per combination with a bool column per available trend
(``airmass``, ``width``, ``spp``) and a ``BIC`` column, sorted
by ascending BIC (best first).
"""
if self.times is None:
raise ValueError("The times must be set before trying to fit.")
if self.data is None:
raise ValueError("The data must be set before trying to fit.")
available = [
name
for name in self._all_detrend_params
if name in self._detrend_parameters
]
rows = []
for combo in itertools.product([False, True], repeat=len(available)):
params = self.params.copy()
for name, enabled in zip(available, combo, strict=True):
if enabled:
params[f"{name}_trend"].vary = True
else:
params[f"{name}_trend"].set(value=0.0, vary=False)
result = self._run_fit(params)
row = dict(zip(available, combo, strict=True))
row["BIC"] = result.bic
rows.append(row)
comparison = Table(rows=rows)
comparison.sort("BIC")
if apply_best:
best = comparison[0]
for name in available:
if best[name]:
self.params[f"{name}_trend"].vary = True
else:
self.params[f"{name}_trend"].set(value=0.0, vary=False)
self.fit()
return comparison
def _detrend(self, model, detrend_by):
if detrend_by == "all":
detrend_by = [
p for p in self._all_detrend_params if p in self._detrend_parameters
]
elif isinstance(detrend_by, str):
detrend_by = [detrend_by]
detrended = model.copy()
for trend in detrend_by:
detrended = detrended - (
self.params[f"{trend}_trend"].value * getattr(self, trend)
)
return detrended
[docs]
def data_light_curve(self, data=None, detrend_by=None):
"""
Function to return data light curve, optionally detrended by one or
more parameters.
Parameters
----------
data : array-like, optional
Data to use for calculating the light curve. If not provided,
the data used for fitting will be used.
detrend_by : str or list of str
Parameter(s) to detrend by. If ``None``, no detrending is
done. If ``'all'``, all parameters that are set will be
used for detrending.
Returns
-------
data : array-like
Data light curve, detrended if requested.
"""
data = data if data is not None else self.data
if detrend_by is not None:
data = self._detrend(data, detrend_by)
return data
[docs]
def model_light_curve(self, at_times=None, detrend_by=None):
"""
Calculate the light curve corresponding to the model, optionally
detrended by one or more parameters.
Parameters
----------
at_times : array-like
Times at which to calculate the model. If not provided, the
times used for fitting will be used. Because the airmass/width/spp
trend covariates are reused as-is, ``at_times`` must have the same
length as the model's times; a mismatched length raises
``ValueError``.
detrend_by : str or list of str
Parameter(s) to detrend by. If ``None``, no detrending is
done. If ``'all'``, all parameters that are set will be
used for detrending.
Returns
-------
model : array-like
Model light curve.
"""
if self.times is None:
raise ValueError(
"The times must be set before computing a model light curve."
)
zeros = np.zeros(len(self.times))
airmass = self.airmass if self.airmass is not None else zeros
width = self.width if self.width is not None else zeros
spp = self.spp if self.spp is not None else zeros
if at_times is not None:
at_times = np.asarray(at_times, dtype=float)
# The trend covariates (airmass/width/spp) are the ones supplied for
# the model's own times, so at_times must line up with them. Check
# before repointing the pytransit model so a bad call cannot leave
# the instance in a corrupted state.
if len(at_times) != len(self.times):
raise ValueError(
"at_times must have the same length as the model times "
f"({len(self.times)}) so the airmass/width/spp trend "
f"covariates line up; got length {len(at_times)}."
)
# Temporarily point the transit model at the new times, restoring
# the original times even if evaluation fails.
self._transit_model.set_data(at_times)
try:
model = self._model_flux(self.params, airmass, width, spp)
finally:
self._transit_model.set_data(np.asarray(self.times, dtype=float))
else:
model = self._model_flux(self.params, airmass, width, spp)
if detrend_by is not None:
model = self._detrend(model, detrend_by)
return model