Source code for fleck.core

import numpy as np
import astropy.units as u
from astropy.coordinates import (CartesianRepresentation,
                                 UnitSphericalRepresentation)
from astropy.coordinates.matrix_utilities import rotation_matrix
from scipy.spatial.distance import pdist, squareform
from scipy.integrate import quad
from shapely.geometry.point import Point
from shapely import affinity
import matplotlib.pyplot as plt


__all__ = ['Star', 'generate_spots']


def limb_darkening(u_ld, r):
    """
    Quadratic limb darkening function.

    Parameters
    ----------
    u_ld : list
        Quadratic limb-darkening parameters
    r : float or `~numpy.ndarray`
        Radius in units of stellar radii.

    Returns
    -------
    f : float or `~numpy.ndarray`
        Flux at ``r``.
    """
    u1, u2 = u_ld
    mu = np.sqrt(1 - r**2)
    return (1 - u1 * (1 - mu) - u2 * (1 - mu)**2) / (1 - u1/3 - u2/6) / np.pi


def limb_darkening_normed(u_ld, r):
    """
    Limb-darkened flux, normalized by the central flux.

    Parameters
    ----------
    u_ld : list
        Quadratic limb-darkening parameters
    r : float or `~numpy.ndarray`
        Radius in units of stellar radii.

    Returns
    -------
    f : float or `~numpy.ndarray`
        Normalized flux at ``r``
    """
    return limb_darkening(u_ld, r)/limb_darkening(u_ld, 0)


def total_flux(u_ld):
    """
    Compute the total flux of the limb-darkened star.

    Parameters
    ----------
    u_ld : list
        Quadratic limb-darkening parameters

    Returns
    -------
    f : float
        Total flux
    """
    return 2 * np.pi * quad(lambda r: r * limb_darkening_normed(u_ld, r),
                            0, 1)[0]


def ellipse(center, lengths, angle=0):
    """
    Create a shapely ellipse.

    Parameters
    ----------
    center : list
        [x, y] centroid of the ellipse
    lengths : list
        [a, b] semimajor and semiminor axes
    angle : float
        Angle in degrees to rotate the semimajor axis

    Returns
    -------
    ellipse : `~shapely.geometry.polygon.Polygon`
        Elliptical shapely object
    """
    ell = affinity.scale(Point(center).buffer(1),
                         xfact=lengths[0], yfact=lengths[1])
    ell_rotated = affinity.rotate(ell, angle=angle)
    return ell_rotated


def circle(center, radius):
    """
    Create a shapely ellipse.

    Parameters
    ----------
    center : list
        [x, y] centroid of the ellipse
    radius : float
        Radius of the circle

    Returns
    -------
    circle : `~shapely.geometry.polygon.Polygon`
        Circular shapely object
    """
    return affinity.scale(Point(center).buffer(1),
                          xfact=radius, yfact=radius)


def consecutive(data, step_size=1):
    """
    Identify groups of consecutive integers, split them into separate arrays.
    """
    return np.split(data, np.where(np.diff(data) != step_size)[0]+1)


def sort_plot_points(xy_coord, k0=0):
    """
    Iteratively identify a continuous path from the given points xy_coord,
    starting by the point indexed by k0
    """
    n = len(xy_coord)
    distance_matrix = squareform(pdist(xy_coord, metric='euclidean'))
    mask = np.ones(n, dtype='bool')
    sorted_order = np.zeros(n, dtype=int)
    indices = np.arange(n)

    i = 0
    k = k0
    while True:
        sorted_order[i] = k
        mask[k] = False

        dist_k = distance_matrix[k][mask]
        indices_k = indices[mask]

        if not len(indices_k):
            break

        # find next unused closest point
        k = indices_k[np.argmin(dist_k)]
        i += 1
    return sorted_order


[docs] class Star(object): """ Object describing properties of a (population of) star(s) """ def __init__(self, spot_contrast, u_ld, phases=None, n_phases=None, rotation_period=None): """ Parameters ---------- spot_contrast : float Contrast of spots (0=perfectly dark, 1=same as photosphere) u_ld : list Quadratic limb-darkening parameters n_phases : int, optional Number of rotation steps to iterate over phases : `~numpy.ndarray`, optional Rotational phases of the star rotation_period : `~astropy.units.Quantity`, optional Rotation period of the star """ self.spot_contrast = spot_contrast if phases is not None: n_phases = len(phases) self.n_phases = n_phases self.u_ld = u_ld if phases is None and self.n_phases is not None: phases = np.arange(0, 2 * np.pi, 2 * np.pi / self.n_phases) * u.rad self.phases = phases self.f0 = total_flux(u_ld) self.rotation_period = rotation_period
[docs] def light_curve(self, spot_lons, spot_lats, spot_radii, inc_stellar, planet=None, times=None, fast=False, time_ref=None, return_spots_occulted=False, transit_model_kwargs={}): """ Generate a(n ensemble of) light curve(s). Light curve output will have shape ``(n_phases, len(inc_stellar))`` or ``(len(times), len(inc_stellar))``. Parameters ---------- spot_lons : `~numpy.ndarray` Spot longitudes spot_lats : `~numpy.ndarray` Spot latitudes spot_radii : `~numpy.ndarray` Spot radii inc_stellar : `~numpy.ndarray` Stellar inclinations planet : `~batman.TransitParams`, optional Transiting planet parameters times : `~numpy.ndarray`, optional Times at which to compute the light curve fast : bool, optional When `True`, use approximation that spots are fixed on the star during a transit event. When `False`, account for motion of starspots on stellar surface due to rotation during transit event. Default is `False`. time_ref : float, optional Reference time used as the initial rotational phase of the star, such that the sub-observer point is at zero longitude at ``time_ref``. return_spots_occulted : bool, optional Return whether or not spots have been occulted. Returns ------- light_curves : `~numpy.ndarray` Stellar light curves of shape ``(n_phases, len(inc_stellar))`` or ``(len(times), len(inc_stellar))`` """ if time_ref is None: if times is not None: time_ref = 0 else: time_ref = self.phases[0] # Compute the spot positions in cartesian coordinates: tilted_spots = self.spherical_to_cartesian(spot_lons, spot_lats, inc_stellar, times=times, planet=planet, time_ref=time_ref) # Compute the distance of each spot from the stellar centroid, mask # any spots that are "behind" the star, in other words, x < 0 r = np.ma.masked_array(np.hypot(tilted_spots.y.value, tilted_spots.z.value), mask=tilted_spots.x.value < 0) ld = limb_darkening_normed(self.u_ld, r) # Compute the out-of-transit flux missing due to each spot f_spots = (np.pi * spot_radii**2 * (1 - self.spot_contrast) * ld * np.sqrt(1 - r**2)) if planet is None: # If there is no transiting planet, skip the transit routine: lambda_e = np.zeros((len(self.phases), 1)) else: if not inc_stellar.isscalar: raise ValueError('Transiting exoplanets are implemented for ' 'planets transiting single stars only, but ' '``inc_stellar`` has multiple values. ') # Compute a transit model from batman import TransitModel n_spots = len(spot_lons) m = TransitModel(planet, times, **transit_model_kwargs) lambda_e = 1 - m.light_curve(planet)[:, np.newaxis] # Compute the true anomaly of the planet at each time, f: f = m.get_true_anomaly() # Compute the position of the planet in cartesian coordinates using # Equations 53-55 of Murray & Correia (2010). Note that these # coordinates are different from the cartesian coordinates used for # the spot positions. In this system, the observer is at X-> -inf. I = np.radians(90 - planet.inc) # noqa Omega = np.radians(planet.w) # this is 90 deg by default omega = np.pi / 2 X = planet.a * (np.cos(Omega) * np.cos(omega + f) - np.sin(Omega) * np.sin(omega + f) * np.cos(I)) Y = planet.a * (np.sin(Omega) * np.cos(omega + f) + np.cos(Omega) * np.sin(omega + f) * np.cos(I)) Z = planet.a * np.sin(omega + f) * np.sin(I) # Create a shapely circle object for the planet's silhouette only # when the planet is in front of the star, otherwise append `None` planet_disk = [circle([-Y[i], -Z[i]], planet.rp) if (np.abs(Y[i]) < 1 + planet.rp) and (X[i] < 0) else None for i in range(len(f))] if fast: spots_occulted = self._planet_spot_overlap_fast(planet, planet_disk, tilted_spots, spot_radii, n_spots, X, Y, lambda_e) else: spots_occulted = self._planet_spot_overlap_slow(planet, planet_disk, tilted_spots, spot_radii, n_spots, X, Y, lambda_e) # Return the flux missing from the star at each time due to spots # (f_spots/self.f0) and due to the transit (lambda_e): if return_spots_occulted: return (1 - np.sum(f_spots.filled(0)/self.f0, axis=1) - lambda_e, spots_occulted) else: return 1 - np.sum(f_spots.filled(0)/self.f0, axis=1) - lambda_e
[docs] def spherical_to_cartesian(self, spot_lons, spot_lats, inc_stellar, times=None, planet=None, time_ref=None): """ Convert spot parameter matrices in the original stellar coordinates to rotated and tilted cartesian coordinates. Parameters ---------- spot_lons : `~astropy.units.Quantity` Spot longitudes spot_lats : `~astropy.units.Quantity` Spot latitudes inc_stellar : `~astropy.units.Quantity` Stellar inclination times : `~numpy.ndarray` Times at which evaluate the stellar rotation planet : `~batman.TransitParams` Planet parameters time_ref : float Reference time used as the initial rotational phase of the star, such that the sub-observer point is at zero longitude at ``time_ref``. Returns ------- tilted_spots : `~numpy.ndarray` Rotated and tilted spot positions in cartesian coordinates """ # Spots by default are given in unit spherical representation (lat, lon) usr = UnitSphericalRepresentation(spot_lons, spot_lats) # Represent those spots with cartesian coordinates (x, y, z) # In this coordinate system, the observer is at positive x->inf, # the star is at the origin, and (y, z) is the sky plane. cartesian = usr.represent_as(CartesianRepresentation) # Generate array of rotation matrices to rotate the spots about the # stellar rotation axis if times is None or (hasattr(times, '__len__') and times[0] is None): rotate = rotation_matrix(self.phases[:, np.newaxis, np.newaxis], axis='z') else: if time_ref is None: time_ref = 0 rotational_phase = 2 * np.pi * ((times - time_ref) / self.rotation_period) * u.rad rotate = rotation_matrix(rotational_phase[:, np.newaxis, np.newaxis], axis='z') rotated_spots = cartesian.transform(rotate) if planet is not None and hasattr(planet, 'lam'): lam = planet.lam * u.deg else: lam = 0 * u.deg # Generate array of rotation matrices to rotate the spots so that the # star is observed from the correct stellar inclination stellar_inclination = rotation_matrix(inc_stellar - 90*u.deg, axis='y') inclined_spots = rotated_spots.transform(stellar_inclination) # Generate array of rotation matrices to rotate the spots so that the # planet's orbit normal is tilted with respect to stellar spin tilt = rotation_matrix(-lam, axis='x') tilted_spots = inclined_spots.transform(tilt) return tilted_spots
def _planet_spot_overlap_fast(self, planet, planet_disk, tilted_spots, spot_radii, n_spots, X, Y, lambda_e): """ Compute the overlap between the planet and starspots using the fast, approximate method. The approximation used by the fast method is to assume that: (1) the mid-transit time is observed for each transit in the observations, and (2) starspots are fixed during the transit event. This approximation is suitable to use when the stellar rotation period is much longer than the transit duration, and only complete transits are observed. Parameters ---------- planet : `~batman.TransitParams` Planet parameters from the batman API planet_disk : list List of positions of the planet when the planet is in front of the star. tilted_spots : `~astropy.units.Quantity` Cartesian positions of the starspots in the planet's "observer oriented" coordinate frame. spot_radii : `~numpy.ndarray` Radii of the starspots n_spots : int Number of spots X : `~numpy.ndarray` Cartesian `X` position of the planet at all times Y : `~numpy.ndarray` Cartesian `Y` position of the planet at all times lambda_e : `~numpy.ndarray` Occulted flux fraction (Mandel & Agol 2002) """ spots_occulted = False # Find the approximate mid-transit time indices in the observations # by looking for the sign flip in Y (planet crosses the sub-observer # point) when also X < 0 (planet in front of star): t0_inds = np.argwhere((np.sign(Y[1:]) < np.sign(Y[:-1])) & (X[1:] < 0)) # Compute the indices where the planet is in front of the star # (X < 0) and the planet is near the star |Y| < 1 + p: transit_inds_all = np.argwhere((X < 0) & (np.abs(Y) < 1 + planet.rp))[:, 0] # Split these indices up into separate numpy arrays for each # contiguous group - this will generate a list of numpy arrays each # containing the indices during individual transit events. transit_inds_groups = consecutive(transit_inds_all) # For each transit in the observations: for k, t0_ind, transit_inds in zip(range(len(t0_inds)), t0_inds, transit_inds_groups): spots = [] spot_ld_factors = [] for i in range(n_spots): # If the spot is visible (x > 0): if tilted_spots.x.value[t0_ind, i] > 0: spot_y = tilted_spots.y.value[t0_ind, i] spot_z = tilted_spots.z.value[t0_ind, i] # Compute the spot position and ellipsoidal shape r_spot = np.hypot(spot_z, spot_y) angle = np.arctan2(spot_z, spot_y) ellipse_centroid = np.array([ np.squeeze(spot_y), np.squeeze(spot_z) ]) ellipse_axes = np.array([ np.squeeze(spot_radii[i, 0] * np.sqrt(1 - r_spot**2)), np.squeeze(spot_radii[i, 0]) ]) spot = ellipse(ellipse_centroid, ellipse_axes, np.degrees(angle)) # Add the spot to our spot list spots.append(spot) spot_ld_factors.append(limb_darkening_normed(self.u_ld, r_spot)) # If any spots are visible: if len(spots) > 0: intersections = np.zeros((transit_inds.ptp()+1, len(spots))) # For each time when the planet is nearly transiting: for i in range(len(transit_inds)): planet_disk_i = planet_disk[transit_inds[i]] if planet_disk_i is not None: for j in range(len(spots)): # Compute the overlap between each spot and the # planet using shapely's `intersection` method spot_planet_overlap = planet_disk_i.intersection(spots[j]).area intersections[i, j] = np.squeeze( (1 - self.spot_contrast) / spot_ld_factors[j] * spot_planet_overlap / np.pi ) # Subtract the spot occultation amplitudes from the spotless # transit model that we computed earlier lambda_e[transit_inds] -= intersections.max(axis=1)[:, np.newaxis] if not np.all(intersections == 0): spots_occulted = True return spots_occulted def _planet_spot_overlap_slow(self, planet, planet_disk, tilted_spots, spot_radii, n_spots, X, Y, lambda_e): """ Compute the overlap between the planet and starspots using the slow, precise method. This method accounts for the motion of the starspots during the transit event. Parameters ---------- planet : `~batman.TransitParams` Planet parameters from the batman API planet_disk : list List of positions of the planet when the planet is in front of the star. tilted_spots : `~astropy.units.Quantity` Cartesian positions of the starspots in the planet's "observer oriented" coordinate frame. spot_radii : `~numpy.ndarray` Radii of the starspots n_spots : int Number of spots X : `~numpy.ndarray` Cartesian `X` position of the planet at all times Y : `~numpy.ndarray` Cartesian `Y` position of the planet at all times lambda_e : `~numpy.ndarray` Occulted flux fraction (Mandel & Agol 2002) """ spots_occulted = False # For each time in the observations: for k, planet_disk_i in enumerate(planet_disk): if planet_disk_i is not None: spots = [] spot_ld_factors = [] for i in range(n_spots): # If the spot is visible (x > 0): if tilted_spots.x.value[k, i] > 0: spot_y = tilted_spots.y.value[k, i] spot_z = tilted_spots.z.value[k, i] # Compute the spot position and ellipsoidal shape r_spot = np.hypot(spot_z, spot_y) angle = np.arctan2(spot_z, spot_y) ellipse_centroid = np.array([ np.squeeze(spot_y), np.squeeze(spot_z) ]) ellipse_axes = np.array([ np.squeeze(spot_radii[i, 0] * np.sqrt(1 - r_spot ** 2)), np.squeeze(spot_radii[i, 0]) ]) spot = ellipse(ellipse_centroid, ellipse_axes, np.degrees(angle)) # Add the spot to our spot list spots.append(spot) spot_ld_factors.append(limb_darkening_normed(self.u_ld, r_spot)) # If any spots are visible: if len(spots) > 0: intersections = np.zeros(len(spots)) for j in range(len(spots)): # Compute the overlap between each spot and the # planet using shapely's `intersection` method spot_planet_overlap = planet_disk_i.intersection(spots[j]).area intersections[j] = ((1 - self.spot_contrast) / spot_ld_factors[j] * spot_planet_overlap / np.pi) # Subtract the spot occultation amplitudes from the spotless # transit model that we computed earlier lambda_e[k] -= intersections.max() if not np.all(intersections == 0): spots_occulted = True return spots_occulted
[docs] def plot(self, spot_lons, spot_lats, spot_radii, inc_stellar, time=None, planet=None, ax=None, time_ref=0): """ Generate a plot of the stellar surface at ``time``. Takes the same arguments as `~fleck.Star.light_curve` with the exception of the singular ``time`` rather than ``times``, plus ``ax`` for pre-defined matplotlib axes. Coordinate frame is the "observer oriented" view defined in Fabrycky & Winn (2009) Figure 1a. The planet transits from left to right across the image. The dashed gray lines represent the upper and lower bounds of the planet's transit chord. Parameters ---------- spot_lons : `~astropy.units.Quantity` Spot longitudes spot_lats : `~astropy.units.Quantity` Spot latitudes spot_radii : `~numpy.ndarray` Spot radii inc_stellar : `~astropy.units.Quantity` Stellar inclination time : float Time at which to evaluate the spot parameters planet : `~batman.TransitParams` or list Planet parameters, or list of planet parameters. ax : `~matplotlib.pyplot.Axes`, optional Predefined matplotlib axes time_ref : float Reference time used as the initial rotational phase of the star, such that the sub-observer point is at zero longitude at ``time_ref``. Returns ------- ax : `~matplotlib.pyplot.Axes` Axis object. """ tilted_spots = self.spherical_to_cartesian(spot_lons, spot_lats, inc_stellar, times=np.array([time]), planet=planet, time_ref=time_ref) spots = [] for i in range(len(spot_lons)): # If the spot is visible (x > 0): if tilted_spots.x.value[0, i] > 0: spot_y = tilted_spots.y.value[0, i] spot_z = tilted_spots.z.value[0, i] # Compute the spot position and ellipsoidal shape r_spot = np.hypot(spot_z, spot_y) angle = np.arctan2(spot_z, spot_y) ellipse_centroid = np.array([ np.squeeze(spot_y), np.squeeze(spot_z) ]) ellipse_axes = np.array([ np.squeeze(spot_radii[i, 0] * np.sqrt(1 - r_spot ** 2)), np.squeeze(spot_radii[i, 0]) ]) print('args', ellipse_centroid, ellipse_axes, angle) spot = ellipse(ellipse_centroid, ellipse_axes, np.degrees(angle)) # Add the spot to our spot list spots.append(spot) if ax is None: ax = plt.gca() if hasattr(planet, "__len__"): # If there are multiple planets, plot their transit chord boundaries for p, color in zip(planet, ["C{0:d}".format(i) for i in range(len(planet))]): # Calculate impact parameter b = (p.a * np.cos(np.radians(p.inc)) * (1 - p.ecc**2) / (1 + p.ecc * np.sin(np.radians(p.w)))) # Compute the upper and lower envelopes of the transit chord in # the "observer oriented" reference frame (Fabrycky & Winn 2009) planet_lower_extent = -b-p.rp planet_upper_extent = -b+p.rp ax.axhline(planet_lower_extent, color=color, ls='--') ax.axhline(planet_upper_extent, color=color, ls='--') elif hasattr(planet, 'a'): # Calculate impact parameter b = (planet.a * np.cos(np.radians(planet.inc)) * (1 - planet.ecc**2) / (1 + planet.ecc * np.sin(np.radians(planet.w)))) # Compute the upper and lower envelopes of the transit chord in the # "observer oriented" reference frame (Fabrycky & Winn 2009) planet_lower_extent = -b-planet.rp planet_upper_extent = -b+planet.rp ax.axhline(planet_lower_extent, color='gray', ls='--') ax.axhline(planet_upper_extent, color='gray', ls='--') # Compute the position of the rotational pole of the star pole_lat, pole_lon = np.array([90])*u.deg, np.array([0])*u.deg polar_spot = self.spherical_to_cartesian(pole_lon, pole_lat, inc_stellar, times=np.array([0]), planet=planet) equator_lon = np.linspace(0, 2*np.pi, 50) * u.rad equator_lat = np.zeros(len(equator_lon)) * u.rad equatorial_line = self.spherical_to_cartesian(equator_lon, equator_lat, inc_stellar, times=np.array([time]), planet=planet) # Draw the outline of the star: x = np.linspace(-1, 1, 1000) ax.plot(x, np.sqrt(1-x**2), color='k') ax.plot(x, -np.sqrt(1-x**2), color='k') # If pole is visible, mark it: if polar_spot.x > 0: ax.scatter(-polar_spot.y, polar_spot.z, color='k', marker='x') # Where equator is visible, mark it: equator_visible = equatorial_line.x > 0 equator_xy = np.vstack([-equatorial_line.y[equator_visible], equatorial_line.z[equator_visible]]).T sort_equator = sort_plot_points(equator_xy, k0=np.argmax(equator_xy[:, 1])) ax.plot(-equatorial_line.y[equator_visible][sort_equator], equatorial_line.z[equator_visible][sort_equator], ls=':', color='gray') ax.set(ylim=[-1.01, 1.01], xlim=[-1.01, 1.01], aspect=1) # Draw each starspot: for i in range(len(spots)): spot_x, spot_y = [np.array(j.tolist()) for j in spots[i].exterior.xy] ax.fill(spot_x, spot_y, alpha=1-self.spot_contrast, color='k') return ax
[docs] def generate_spots(min_latitude, max_latitude, spot_radius, n_spots, n_inclinations=None, inclinations=None): """ Generate matrices of spot parameters. Will generate ``n_spots`` spots on different stars observed at ``n_inclinations`` different inclinations. Parameters ---------- min_latitude : float Minimum spot latitude max_latitude : float Maximum spot latitude spot_radius : float or `~numpy.ndarray` Spot radii n_spots : int Number of spots to generate n_inclinations : int, optional Number of inclinations to generate inclinations : `~numpy.ndarray`, optional Inclinations (user defined). Default (`None`): randomly generate. Returns ------- lons : `~astropy.units.Quantity` Spot longitudes, shape ``(n_spots, n_inclinations)`` lats : `~astropy.units.Quantity` Spot latitudes, shape ``(n_spots, n_inclinations)`` radii : float or `~numpy.ndarray` Spot radii, shape ``(n_spots, n_inclinations)`` inc_stellar : `~astropy.units.Quantity` Stellar inclinations, shape ``(n_inclinations, )`` """ delta_latitude = max_latitude - min_latitude if n_inclinations is not None and inclinations is None: inc_stellar = np.arccos(np.random.rand(n_inclinations)) inc_stellar = inc_stellar * np.sign(np.random.uniform(-1, 1, n_inclinations)) * u.deg else: n_inclinations = len(inclinations) if not inclinations.isscalar else 1 inc_stellar = inclinations radii = spot_radius * np.ones((n_spots, n_inclinations)) lats = (delta_latitude*np.random.rand(n_spots, n_inclinations) + min_latitude) * u.deg lons = 360*np.random.rand(n_spots, n_inclinations) * u.deg return lons, lats, radii, inc_stellar