Source code for polynomial_limb_darkened_transit

import jax

jax.config.update("jax_enable_x64", True)
from functools import partial

import jax.numpy as jnp
import numpy as np

from squishyplanet.engine.kepler import kepler, skypos, t0_to_t_peri
from squishyplanet.engine.parametric_ellipse import (
    cartesian_intersection_to_parametric_angle,
    point_in_ellipse,
    poly_to_parametric,
)

# Fixed-grid quadrature for the planet solution-vector integrals, replacing the
# previous adaptive quadax.quadgk call. A single unrolled pass is faster to
# evaluate and differentiate than the old adaptive refinement loop. The integrand
# behaves differently in the two ways planet_solution_vec is called, so we use a
# different fixed rule for each:
#
#   * Partial-transit arcs (a, b are the parametric angles where the outline
#     crosses the stellar limb): the integrand has a square-root-type singularity
#     at BOTH endpoints, where 1 - x^2 - y^2 -> 0. We integrate on a cubic
#     "smoothstep" reparameterization of [a, b] with zero derivative at each
#     endpoint, which regularizes the sqrt singularity into a smooth integrand and
#     recovers spectral GL convergence (solution-vector value error ~1e-14 and
#     jacfwd relative error ~1e-10 worst case over LD orders 2-8 and a wide range
#     of geometries, vs the old adaptive scheme's ~1e-7 jacfwd error).
#   * Full transit (a=0, b=2*pi, planet fully inside the star): the integrand is
#     smooth and periodic with no endpoint singularity, but can develop a sharp
#     near-cusp mid-arc when the outline grazes the limb internally
#     (1 - x^2 - y^2 -> 0 away from the endpoints). The smoothstep clusters nodes
#     at the endpoints -- the wrong place -- and under-resolves that feature. Here
#     we use the periodic trapezoid rule, which is spectrally accurate for smooth
#     periodic integrands and resolves the interior near-cusp far more efficiently
#     than a plain GL rule.
#
# The two schemes meet at internal tangency (d_center = 1 - r, i.e. second/third
# contact), where the full-circle integrand is at its most grazing. Their
# independent errors there set the size of any flux discontinuity at the handoff.
# N_TRAP is chosen so that worst-case step is ~5e-12 -- below the genuine physical
# contact-point kink (~2e-11) that any transit model has -- so the scheme switch
# introduces no discontinuity beyond the unavoidable physics.
#
# GL nodes are open (never land on +/-1), so the smoothstep samples never hit the
# exact endpoints and we avoid sqrt(negative)/nan even if the quartic roots are
# slightly off the limb.
N_GL = 48  # smoothstep GL nodes for partial-transit arcs
N_TRAP = 256  # periodic trapezoid nodes for the full circle
_gl_nodes, _gl_weights = np.polynomial.legendre.leggauss(N_GL)
# smoothstep map phi(t) = (3 t - t^3) / 2 on [-1, 1] (phi(+/-1) = +/-1) and its
# derivative dphi(t) = (3 - 3 t^2) / 2 (dphi(+/-1) = 0), evaluated at the nodes.
_GL_PHI = jnp.asarray((3.0 * _gl_nodes - _gl_nodes**3) / 2.0)
_GL_WDPHI = jnp.asarray(_gl_weights * (3.0 - 3.0 * _gl_nodes**2) / 2.0)
# Equally-spaced nodes for the periodic trapezoid (full circle), offset by half a
# step to (k + 1/2) * 2*pi / N_TRAP. This is equally spectrally accurate for smooth
# periodic integrands but keeps nodes off the "nice" angles 0, pi/2, pi, ... so they
# don't sit exactly on the grazing tangent of an axis-aligned outline (where
# 1 - x^2 - y^2 = 0). The integrand is nan-safe there regardless; this just avoids
# sampling the cusp itself.
_TRAP_NODES = jnp.asarray((np.arange(N_TRAP) + 0.5) * (2.0 * np.pi / N_TRAP))
_TRAP_WEIGHT = 2.0 * jnp.pi / N_TRAP
# A full-circle call is detected by its span being (numerically) 2*pi.
_FULL_SPAN = 2.0 * jnp.pi - 1e-9


def _t4(
    rho_xx: jax.Array,
    rho_xy: jax.Array,
    rho_x0: jax.Array,
    rho_yy: jax.Array,
    rho_y0: jax.Array,
    rho_00: jax.Array,
) -> jax.Array:
    return -1 + rho_00 - rho_x0 + rho_xx


def _t3(
    rho_xx: jax.Array,
    rho_xy: jax.Array,
    rho_x0: jax.Array,
    rho_yy: jax.Array,
    rho_y0: jax.Array,
    rho_00: jax.Array,
) -> jax.Array:
    return -2 * rho_xy + 2 * rho_y0


def _t2(
    rho_xx: jax.Array,
    rho_xy: jax.Array,
    rho_x0: jax.Array,
    rho_yy: jax.Array,
    rho_y0: jax.Array,
    rho_00: jax.Array,
) -> jax.Array:
    return -2 + 2 * rho_00 - 2 * rho_xx + 4 * rho_yy


def _t1(
    rho_xx: jax.Array,
    rho_xy: jax.Array,
    rho_x0: jax.Array,
    rho_yy: jax.Array,
    rho_y0: jax.Array,
    rho_00: jax.Array,
) -> jax.Array:
    return 2 * rho_xy + 2 * rho_y0


def _t0(
    rho_xx: jax.Array,
    rho_xy: jax.Array,
    rho_x0: jax.Array,
    rho_yy: jax.Array,
    rho_y0: jax.Array,
    rho_00: jax.Array,
) -> jax.Array:
    return -1 + rho_00 + rho_x0 + rho_xx


@jax.jit
def _single_intersection_points(
    rho_xx: jax.Array,
    rho_xy: jax.Array,
    rho_x0: jax.Array,
    rho_yy: jax.Array,
    rho_y0: jax.Array,
    rho_00: jax.Array,
    **kwargs: jax.Array,
) -> tuple[jax.Array, jax.Array]:
    t4 = _t4(rho_xx, rho_xy, rho_x0, rho_yy, rho_y0, rho_00)
    t3 = _t3(rho_xx, rho_xy, rho_x0, rho_yy, rho_y0, rho_00)
    t2 = _t2(rho_xx, rho_xy, rho_x0, rho_yy, rho_y0, rho_00)
    t1 = _t1(rho_xx, rho_xy, rho_x0, rho_yy, rho_y0, rho_00)
    t0 = _t0(rho_xx, rho_xy, rho_x0, rho_yy, rho_y0, rho_00)

    polys = jnp.array([t4, t3, t2, t1, t0])
    roots = jnp.roots(polys, strip_zeros=False)  # strip_zeros must be False to jit

    ts = jnp.where(jnp.imag(roots) == 0, jnp.real(roots), 999)
    xs = jnp.where(ts != 999, (1 - ts**2) / (1 + ts**2), ts)
    ys = jnp.where(ts != 999, 2 * ts / (1 + ts**2), ts)
    return xs, ys


# @jax.jit
# def multiple_intersection_points(rho_xx, rho_xy, rho_x0, rho_yy, rho_y0, rho_00, **kwargs):
#     t4 = _t4(rho_xx, rho_xy, rho_x0, rho_yy, rho_y0, rho_00)
#     t3 = _t3(rho_xx, rho_xy, rho_x0, rho_yy, rho_y0, rho_00)
#     t2 = _t2(rho_xx, rho_xy, rho_x0, rho_yy, rho_y0, rho_00)
#     t1 = _t1(rho_xx, rho_xy, rho_x0, rho_yy, rho_y0, rho_00)
#     t0 = _t0(rho_xx, rho_xy, rho_x0, rho_yy, rho_y0, rho_00)

#     polys = jnp.array([t4, t3, t2, t1, t0]).T

#     roots = jax.vmap(lambda x: jnp.roots(x, strip_zeros=False))(polys)

#     ts = jnp.where(jnp.imag(roots) == 0, jnp.real(roots), 999)
#     xs = jnp.where(ts != 999, (1-ts**2)/(1+ts**2), ts)
#     ys = jnp.where(ts != 999, 2*ts/(1+ts**2), ts)
#     return xs, ys


[docs] @jax.jit def parameterize_2d_helper( projected_r: jax.Array, projected_f: jax.Array, projected_theta: jax.Array, xc: jax.Array, yc: jax.Array, ) -> tuple[dict, dict]: """Convert from the alternative sky-projected parameterization to the same format used by the 3D parameterization. A good chunk of the code assumes that the planet's center is determined by the orbital elements and that it's outline is derived from an equatorial radius ``r``, a z-flattening ``f1``, a y-flattening ``f2``, and two body-centered rotations ``obliq`` and ``prec``. This are useful to have when working with phase curves that are sensitive to the actual 3D orientation of the planet, but when dealing with transits only, this parameterization is overkill and allows a bunch of degeneracies. So, if only doing transits, it is more convenient to parameterize the planet by its projected radius in the x and y directions, and the angle of the projected ellipse. This function takes in those parameters and returns the same dictionaries you'd get if you fed a full 3D parameterization into :func:`planet_2d.planet_2d_coeffs`. Args: projected_r (float): The projected "x" radius of the planet. projected_f (float): The flattening of the projected ellipse. projected_theta (float): The angle of the projected ellipse. Returns: tuple: A tuple of two dictionaries. The first dictionary contains the coefficients of the quadratic equation that describes the projected ellipse. The second dictionary contains coefficients that describe the parametric form of that same ellipse. """ # projected_ projected_r2 = projected_r * (1 - projected_f) cos_t = jnp.cos(projected_theta) sin_t = jnp.sin(projected_theta) two = { "rho_xx": cos_t**2 / projected_r**2 + sin_t**2 / projected_r2**2, "rho_xy": (2 * cos_t * sin_t) / projected_r**2 - (2 * cos_t * sin_t) / projected_r2**2, "rho_x0": (-2 * cos_t**2 * xc) / projected_r**2 - (2 * cos_t * yc * sin_t) / projected_r**2 + (2 * cos_t * yc * sin_t) / projected_r2**2 - (2 * xc * sin_t**2) / projected_r2**2, "rho_yy": cos_t**2 / projected_r2**2 + sin_t**2 / projected_r**2, "rho_y0": (-2 * cos_t**2 * yc) / projected_r2**2 - (2 * cos_t * xc * sin_t) / projected_r**2 + (2 * cos_t * xc * sin_t) / projected_r2**2 - (2 * yc * sin_t**2) / projected_r**2, "rho_00": (cos_t**2 * xc**2) / projected_r**2 + (cos_t**2 * yc**2) / projected_r2**2 + (2 * cos_t * xc * yc * sin_t) / projected_r**2 - (2 * cos_t * xc * yc * sin_t) / projected_r2**2 + (yc**2 * sin_t**2) / projected_r**2 + (xc**2 * sin_t**2) / projected_r2**2, } # two alternative takes cooked up during the Fortran implementation- # check later for numerical implications # projected_r_sq = projected_r * projected_r # projected_r2_sq = projected_r2 * projected_r2 # cos_t_sq = cos_t * cos_t # sin_t_sq = sin_t * sin_t # xc_sq = xc * xc # yc_sq = yc * yc # two = { # "rho_xx": cos_t_sq / projected_r_sq + sin_t_sq / projected_r2_sq, # "rho_xy": (2 * cos_t * sin_t) / projected_r_sq # - (2 * cos_t * sin_t) / projected_r2_sq, # "rho_x0": ( # ((-2 * cos_t_sq * xc) - (2 * cos_t * yc * sin_t)) / projected_r_sq # + ((2 * cos_t * yc * sin_t) - (2 * xc * sin_t_sq)) / projected_r2_sq # ), # "rho_yy": cos_t_sq / projected_r2_sq + sin_t_sq / projected_r_sq, # "rho_y0": ( # (- (2 * cos_t * xc * sin_t) - (2 * yc * sin_t_sq)) / projected_r_sq + # ((-2 * cos_t_sq * yc) + (2 * cos_t * xc * sin_t)) / projected_r2_sq # ), # "rho_00": ( # ((cos_t_sq * xc_sq) + (2 * cos_t * xc * yc * sin_t) + (yc_sq * sin_t_sq)) / projected_r_sq + # ((cos_t_sq * yc_sq) - (2 * cos_t * xc * yc * sin_t) + (xc_sq * sin_t_sq)) / projected_r2_sq # ) # } # two = {} # two["rho_xx"] = cos_t_sq / projected_r_sq + sin_t_sq / projected_r2_sq # tmp1 = 2.0 * cos_t * sin_t # two["rho_xy"] = tmp1 / projected_r_sq - tmp1 / projected_r2_sq # tmp1 = 2.0 * cos_t * yc * sin_t # two["rho_x0"] = ((-2.0 * cos_t_sq * xc) - tmp1) / projected_r_sq + ( # tmp1 - (2.0 * xc * sin_t_sq) # ) / projected_r2_sq # two["rho_yy"] = cos_t_sq / projected_r2_sq + sin_t_sq / projected_r_sq # tmp1 = 2.0 * cos_t * xc * sin_t # two["rho_y0"] = ((-2.0 * cos_t_sq * yc) + (tmp1)) / projected_r2_sq - ( # (2.0 * cos_t * xc * sin_t) + (2.0 * yc * sin_t_sq) # ) / projected_r_sq # tmp1 = 2.0 * cos_t * xc * yc * sin_t # two["rho_00"] = ( # (cos_t_sq * xc_sq) + (tmp1) + (yc_sq * sin_t_sq) # ) / projected_r_sq + ( # (cos_t_sq * yc_sq) - (tmp1) + (xc_sq * sin_t_sq) # ) / projected_r2_sq para = poly_to_parametric(**two) return two, para
[docs] @jax.jit def planet_solution_vec( a: jax.Array, b: jax.Array, g_coeffs: jax.Array, c_x1: jax.Array, c_x2: jax.Array, c_x3: jax.Array, c_y1: jax.Array, c_y2: jax.Array, c_y3: jax.Array, ) -> jax.Array: """Compute the "solution vector" for a 1D path across the star that lies on the outline of the planet. This computes Eq. 21 of `Agol, Luger, and Foreman-Mackey 2020 <https://ui.adsabs.harvard.edu/abs/2020AJ....159..123A/abstract>`_. But, instead of doing it analytically, this numerically solves the required integrals with a fixed-grid Gauss-Legendre rule (see ``N_GL`` and the module-level notes). For terms s_2 and higher, this is straightforward to do based on the equations in the paper: we simply parameterize the outline of the planet by some angle :math:`\\alpha`, then numerically integrate the dot product of Eq. 62 with that parameterization between the two endpoints of the path. For the first two lower-order terms however, Agol et al. do not provide an equivalent of Eq. 62 and instead provide only the analytic solutions. We therefore use the following as the equivalents for Eq. 62 for these terms: .. math:: G_0 = \\{0, x\\} .. math:: G_1 = \\left\\{0, \\frac{1}{2} \\left(x \\sqrt{-x^2-y^2+1}-\\left(y^2-1\\right) \\tan ^{-1}\\left(\\frac{x}{\\sqrt{-x^2-y^2+1}}\\right)\\right)+\\frac{\\pi }{12} \\right\\} These expressions were derived by solving the required PDE in Eq. 14 with the boundary conditions from Eq. 27. Finally, the C coefficients here describe the parametric form of the planet's outline as seen by the observer, and they satisfy: .. math:: x = c_{x1} \\cos(\\alpha) + c_{x2} \\sin(\\alpha) + c_{x3} y = c_{y1} \\cos(\\alpha) + c_{y2} \\sin(\\alpha) + c_{y3} for some angle :math:`\\alpha \\in [0, 2\\pi)`. Args: a (float): The starting parameter for the path along the planet's outline, :math:`\\alpha_0`. b (float): The ending parameter for the path along the planet's outline, :math:`\\alpha_1`. g_coeffs (Array): The system-specific limb darkening coefficients in the Green's basis. Computed by multiplying the u coefficients with the change of basis matrix from :func:`greens_basis_transform.generate_change_of_basis_matrix`. c_x1 (float): The first coefficient of the parametric 2D outline of the planet. c_x2 (float): The second coefficient of the parametric 2D outline of the planet. c_x3 (float): The third coefficient of the parametric 2D outline of the planet. c_y1 (float): The fourth coefficient of the parametric 2D outline of the planet. c_y2 (float): The fifth coefficient of the parametric 2D outline of the planet. c_y3 (float): The sixth coefficient of the parametric 2D outline of the planet. Returns: Array: The solution vector for the path along the planet's outline. The shape will match that of the input ``g_coeffs``. """ # The s0, s1, and higher-order sn integrals are evaluated together as a single # vector-valued integrand sharing one fixed Gauss-Legendre node set (instead of # three independent integrations over the same interval). The lax.scan over # polynomial orders becomes a vectorized power. ns = jnp.arange(g_coeffs.shape[0])[2:] def vec_integrand(s: jax.Array) -> jax.Array: cs = jnp.cos(s) sn = jnp.sin(s) x = cs * c_x1 + sn * c_x2 + c_x3 y = cs * c_y1 + sn * c_y2 + c_y3 dy_da = -sn * c_y1 + cs * c_y2 # Guard the grazing tangent. When the outline just touches the stellar limb # (fully-inside case), 1 - x^2 - y^2 -> 0 at one angle, and a fixed quadrature # node can land on or just past it. Two failure modes follow: (1) rounding of # x^2 + y^2 can make 1 - x^2 - y^2 slightly negative (platform-dependent), # giving sqrt(neg)=nan and (neg)**(n/2)=nan; (2) at exactly 1 - x^2 - y^2 = 0, # sqrt and arctan(x/root) have infinite derivatives, giving a nan *gradient* # even when the value is fine. The true outline has 1 - x^2 - y^2 >= 0 here, so # we use a nan-safe formulation: clamp, a guarded sqrt, and arctan2 (which # equals arctan(x/root) for root > 0 but stays finite in value and gradient at # root == 0). This is a no-op wherever 1 - x^2 - y^2 > 0. om = 1.0 - x**2 - y**2 pos = om > 0.0 one_minus = jnp.where(pos, om, 0.0) root = jnp.where(pos, jnp.sqrt(jnp.where(pos, om, 1.0)), 0.0) v0 = x * dy_da v1 = ( dy_da * (jnp.pi + 6 * x * root - 6 * jnp.arctan2(x, root) * (-1 + y**2)) ) / 12.0 common = -( c_x3 * (sn * c_y1 - cs * c_y2) + c_x2 * (c_y1 + cs * c_y3) - c_x1 * (c_y2 + sn * c_y3) ) vn = one_minus ** (ns / 2.0) * common return jnp.concatenate((jnp.array([v0, v1]), vn)) # Full-circle calls use the periodic trapezoid; partial arcs use the smoothstep # reparameterization of [a, b] that regularizes the sqrt endpoint singularity: # s = mid + half * phi(t), integral = half * sum_k w_k dphi(t_k) f(s_k). def full_circle(_: None) -> jax.Array: vals = jax.vmap(vec_integrand)(_TRAP_NODES) return _TRAP_WEIGHT * jnp.sum(vals, axis=0) def partial_arc(_: None) -> jax.Array: half = 0.5 * (b - a) mid = 0.5 * (a + b) vals = jax.vmap(vec_integrand)(mid + half * _GL_PHI) return half * jnp.sum(_GL_WDPHI[:, None] * vals, axis=0) return jax.lax.cond((b - a) >= _FULL_SPAN, full_circle, partial_arc, None)
# Closed-form antiderivatives for the star-arc integrands (see star_solution_vec). _HALF_PI = jnp.pi / 2.0 _THREE_HALF_PI = 3.0 * jnp.pi / 2.0 _TWO_PI = 2.0 * jnp.pi def _F0(t: jax.Array) -> jax.Array: """Antiderivative of ``cos^2(t)``: ``t/2 + sin(2t)/4``.""" return t / 2.0 + jnp.sin(2.0 * t) / 4.0 def _F_outer(t: jax.Array) -> jax.Array: """Antiderivative of the ``s1`` outer piece, ``(pi/12)(4 sin t - sin^3 t)``.""" s = jnp.sin(t) return (jnp.pi / 12.0) * (4.0 * s - s**3) def _F_inner(t: jax.Array) -> jax.Array: """Antiderivative of the ``s1`` inner piece, ``(pi/12)(-2 sin t + sin^3 t)``.""" s = jnp.sin(t) return (jnp.pi / 12.0) * (-2.0 * s + s**3) def _s0_definite(lo: jax.Array, hi: jax.Array) -> jax.Array: """Closed-form ``integral_{lo}^{hi} cos^2(t) dt``.""" return _F0(hi) - _F0(lo) def _s1_definite(lo: jax.Array, hi: jax.Array) -> jax.Array: """Closed-form ``integral_{lo}^{hi} s1_integrand(t) dt`` for ``0 <= lo <= hi <= 2 pi``. Splits the interval at ``pi/2`` and ``3 pi/2`` by clamping the limits into each region; the outer antiderivative is used on ``[0, pi/2]`` and ``[3 pi/2, 2 pi]``, the inner antiderivative on ``[pi/2, 3 pi/2]``. """ a1 = jnp.clip(lo, 0.0, _HALF_PI) b1 = jnp.clip(hi, 0.0, _HALF_PI) c1 = _F_outer(b1) - _F_outer(a1) a2 = jnp.clip(lo, _HALF_PI, _THREE_HALF_PI) b2 = jnp.clip(hi, _HALF_PI, _THREE_HALF_PI) c2 = _F_inner(b2) - _F_inner(a2) a3 = jnp.clip(lo, _THREE_HALF_PI, _TWO_PI) b3 = jnp.clip(hi, _THREE_HALF_PI, _TWO_PI) c3 = _F_outer(b3) - _F_outer(a3) return c1 + c2 + c3
[docs] @jax.jit def star_arc_solution_vec( theta_lo: jax.Array, theta_hi: jax.Array, g_coeffs: jax.Array, ) -> jax.Array: """Compute the "solution vector" for a 1D path that lies on the edge of the star. This is equivalent to :func:`planet_solution_vec`, but instead of integrating over paths that lie on the planet's outline, we integrate over paths that lie on the edge of the star. As pointed out in the paragraph following Eq. 69 in `Agol, Luger, and Foreman-Mackey 2020 <https://ui.adsabs.harvard.edu/abs/2020AJ....159..123A/abstract>`_, the contribution of all terms higher than :math:`G_1` will be zero in this case since we have limited ourselves to :math:`z=0` by remaining on the star's boundary. This simplifies things somewhat: the dot product of the parametric form of the star's outline with the :math:`G_0` and :math:`G_1` terms written out in :func:`planet_solution_vec` reduces to elementary trigonometric polynomials, so we evaluate the required integrals in closed form via their exact antiderivatives rather than numerically. Unlike its predecessor ``star_solution_vec``, this function takes the polar angles of the arc endpoints on the star directly (not the parametric angles of another curve's outline), and it makes no assumption about which of the arcs bounded by two intersection points is wanted: the caller chooses by passing the endpoints in counterclockwise order. This matters once more than one curve can cut the stellar limb into several arcs. Args: theta_lo (float): The starting polar angle of the arc on the star, in ``[0, 2 pi]``. theta_hi (float): The ending polar angle of the arc on the star. Must satisfy ``theta_lo <= theta_hi <= 2 pi``; the arc is traversed counterclockwise from ``theta_lo`` to ``theta_hi``. Arcs crossing the ``0 = 2 pi`` seam should be passed as two separate sub-arcs. g_coeffs (Array): The system-specific limb darkening coefficients in the Green's basis. Computed by multiplying the u coefficients with the change of basis matrix from :func:`greens_basis_transform.generate_change_of_basis_matrix`. Returns: Array: The solution vector for the path along the star's edge. The shape will match that of the input ``g_coeffs``. """ solution_vec = jnp.zeros(g_coeffs.shape[0]) solution_vec = solution_vec.at[0].set(_s0_definite(theta_lo, theta_hi)) solution_vec = solution_vec.at[1].set(_s1_definite(theta_lo, theta_hi)) return solution_vec
def _rot(angle: jax.Array, axis: str) -> jax.Array: """Batched rotation matrix about ``axis`` ('x', 'y', or 'z'). Args: angle (Array): Rotation angle(s) [radian], any leading shape ``(...)``. axis (str): One of ``"x"``, ``"y"``, ``"z"``. Returns: Array: Rotation matrices of shape ``(..., 3, 3)``. """ c, s = jnp.cos(angle), jnp.sin(angle) o, z = jnp.ones_like(c), jnp.zeros_like(c) if axis == "x": rows = [[o, z, z], [z, c, -s], [z, s, c]] elif axis == "y": rows = [[c, z, s], [z, o, z], [-s, z, c]] elif axis == "z": rows = [[c, -s, z], [s, c, z], [z, z, o]] else: raise ValueError(f"unknown axis {axis!r}") return jnp.stack([jnp.stack(row, -1) for row in rows], -2)
[docs] def outline_prelude(state: dict) -> tuple[dict, dict]: """Direct construction of the planet's projected outline from orbital elements. Drop-in replacement for the ``planet_3d_coeffs -> planet_2d_coeffs -> poly_to_parametric`` chain (the full 3D-parameterization branch of :func:`lightcurve`). Instead of expanding the implicit polynomials, it builds the central 3D quadric ``M = R diag(d) R^T`` directly, eliminates ``z`` via a 2x2 Schur complement to get the projected conic, and uses the planet's sky position ``skypos(...)[:2]`` as the outline center exactly. This avoids the near-cancellation divisions of the old chain and is machine-precision accurate (vs the chain's ~1e-9 worst case) as well as slightly faster. Expects ``state["f"]`` to already hold the per-timestep true anomalies. Applies the tidally-locked precession override internally (``prec = where(tidally_locked, f, prec)``). Args: state (dict): An :func:`OblateSystem` ``state`` dictionary. Uses the keys ``a, e, f, Omega, i, omega, r, obliq, prec, f1, f2, tidally_locked``. Returns: tuple: A tuple of two dictionaries. The first contains the implicit-conic coefficients ``rho_xx, rho_xy, rho_x0, rho_yy, rho_y0, rho_00`` of the outline (matching :func:`planet_2d.planet_2d_coeffs`). The second contains the parametric coefficients ``c_x1, c_x2, c_x3, c_y1, c_y2, c_y3`` with ``x = c_x1 cos(alpha) + c_x2 sin(alpha) + c_x3`` (and similarly for y), matching :func:`parametric_ellipse.poly_to_parametric`. Every leaf is broadcast to the time axis. """ prec = jnp.where(state["tidally_locked"], state["f"], state["prec"]) # central 3D quadric M = R diag(d) R^T (depends only on orientation + shape) R = ( _rot(state["Omega"], "z")
[docs] @ _rot(state["i"], "x") @ _rot(state["omega"] + prec, "z") @ _rot(state["obliq"], "y") ) r = jnp.asarray(state["r"]).ravel()[0] f1 = jnp.asarray(state["f1"]).ravel()[0] f2 = jnp.asarray(state["f2"]).ravel()[0] d = jnp.stack( [1 / r**2, 1 / (r * (1 - f2)) ** 2, 1 / (r * (1 - f1)) ** 2], -1, ) M = jnp.einsum("...ij,...j,...kj->...ik", R, d, R) # 2x2 outline conic via Schur complement eliminating z -> centered conic # A x'^2 + B x'y' + C y'^2 = 1 m22 = M[..., 2, 2] col = M[..., :2, 2] row = M[..., 2, :2] Amat = ( M[..., :2, :2] - (col[..., :, None] * row[..., None, :]) / m22[..., None, None] ) sxx = Amat[..., 0, 0] # A sxy = 2 * Amat[..., 0, 1] # B syy = Amat[..., 1, 1] # C # ellipse center is exactly the projected sky position pos = skypos( state["a"], state["e"], state["f"], state["Omega"], state["i"], state["omega"] ) xc = pos[0] yc = pos[1] # parametric ellipse: identical convention to poly_to_parametric_helper theta = jnp.where( sxx - syy != 0.0, 0.5 * jnp.arctan2(sxy, (sxx - syy)) + jnp.pi / 2, 0.0, ) theta = jnp.where(theta < 0.0, theta + jnp.pi, theta) cosa = jnp.cos(theta) sina = jnp.sin(theta) aa = sxx * cosa**2 + sxy * cosa * sina + syy * sina**2 bb = sxx * sina**2 - sxy * cosa * sina + syy * cosa**2 r1 = 1 / jnp.sqrt(aa) r2 = 1 / jnp.sqrt(bb) # xc/yc always vary with the true anomaly (length n_times); the orientation-only # quantities are scalar when prec is fixed. Broadcast them to the time axis so every # leaf shares a leading dimension for the downstream lax.scan (this subsumes the # rho_xx/rho_xy/rho_yy broadcast block the old 3D branch needed below). ones = jnp.ones_like(xc) para = { "c_x1": ones * (r1 * cosa), "c_x2": ones * (-r2 * sina), "c_x3": xc, "c_y1": ones * (r1 * sina), "c_y2": ones * (r2 * cosa), "c_y3": yc, } two = { "rho_xx": ones * sxx, "rho_xy": ones * sxy, "rho_x0": -2 * sxx * xc - sxy * yc, "rho_yy": ones * syy, "rho_y0": -2 * syy * yc - sxy * xc, "rho_00": sxx * xc**2 + sxy * xc * yc + syy * yc**2, } return two, para
def ellipse_bound( c_x1: jax.Array, c_x2: jax.Array, c_x3: jax.Array, c_y1: jax.Array, c_y2: jax.Array, c_y3: jax.Array, ) -> jax.Array: """Decide whether a step straddles the star edge (and so needs the quartic solve). The outline center is ``(c_x3, c_y3)`` and a safe upper bound on the distance of any outline point from the center is ``bound = sqrt(c_x1^2 + c_x2^2 + c_y1^2 + c_y2^2)`` (it equals ``sqrt(r1^2 + r2^2) >= max(r1, r2)``). With center distance ``d = sqrt(c_x3^2 + c_y3^2)``: ``d + bound < 1`` is provably fully inside, ``d - bound > 1`` provably fully outside; otherwise the step straddles and the quartic must be solved. A conservative bound is safe -- a straddling-classified step with no real root is harmless (the center-inside test resolves it). Args: c_x1 (float): Parametric outline coefficient (x, cos term). c_x2 (float): Parametric outline coefficient (x, sin term). c_x3 (float): Parametric outline coefficient (x, center). c_y1 (float): Parametric outline coefficient (y, cos term). c_y2 (float): Parametric outline coefficient (y, sin term). c_y3 (float): Parametric outline coefficient (y, center). Returns: bool: ``True`` if straddling (solve the quartic), ``False`` if provably fully inside or fully outside. """ bound = jnp.sqrt(c_x1**2 + c_x2**2 + c_y1**2 + c_y2**2) d = jnp.sqrt(c_x3**2 + c_y3**2) return (d + bound >= 1.0) & (d - bound <= 1.0)
def _arcs_from_angles(angles: jax.Array) -> tuple[jax.Array, jax.Array, jax.Array]: """Split ``[0, 2 pi]`` into consecutive arcs at the given angles. This is the "bookend" scheme: the (sentinel-padded) angles are sorted and the fixed endpoints 0 and 2 pi are prepended/appended, so ``n`` input slots always produce ``n + 1`` arc slots of static shape. Callers should map sentinel entries (no intersection) to 2 pi beforehand; those slots become zero-length arcs ``[2 pi, 2 pi]`` that integrate to zero. A boundary crossing the parameterization seam is represented as two sub-arcs, ``[a_last, 2 pi]`` and ``[0, a_first]``, whose contributions add, so no arc ever wraps. With no genuine angles at all, the single arc ``[0, 2 pi]`` survives and its midpoint doubles as a containment test point for the whole curve. Args: angles (Array): Angles in ``[0, 2 pi]``, sentinel slots mapped to 2 pi. Returns: Tuple: Three arrays of length ``len(angles) + 1``: the arc lower bounds, upper bounds, and midpoints. """ angles = jnp.sort(angles) lo = jnp.concatenate((jnp.zeros(1), angles)) hi = jnp.concatenate((angles, jnp.full(1, 2 * jnp.pi))) mid = 0.5 * (lo + hi) return lo, hi, mid
[docs] def ellipse_star_term(para: dict, two: dict, g_coeffs: jax.Array) -> jax.Array: """The Green's-theorem boundary integral of the blocked flux over one ellipse. Computes :math:`F(E \\cap S)`, the flux blocked by the intersection of a single opaque ellipse :math:`E` (a planet outline or a ring edge) with the stellar disk :math:`S`, dotted with the Green's-basis limb-darkening coefficients. The caller is responsible for multiplying by the overall normalization constant. Rather than assuming a fixed number of ellipse-star intersections, this takes an "exploratory" approach that handles 0 through 4 crossings (and containment in either direction) with one code path: find all crossings, split both the ellipse outline and the stellar limb into arcs at those crossings, keep each arc if its midpoint lies inside the other region (its inside/outside status is constant along the arc, since arcs are split at every crossing), and sum the kept arcs' contributions. Because the boundary of the intersection of convex regions is exactly the set of arcs of each curve that lie inside the other, and both curves are traversed counterclockwise, Green's theorem makes the summation order irrelevant -- no case enumeration or boundary stitching is needed. Args: para (dict): The parametric coefficients ``c_x1 ... c_y3`` of the ellipse outline for a single timestep. Must trace the outline counterclockwise (true of everything produced by :func:`poly_to_parametric`, :func:`outline_prelude`, and :func:`rings.ring_para_coeffs`). two (dict): The implicit-conic coefficients ``rho_xx ... rho_00`` of the same ellipse. g_coeffs (Array): The system-specific limb darkening coefficients in the Green's basis. Returns: Array: Scalar: the blocked flux from this ellipse, before normalization. """ def straddling_case(X: tuple) -> jax.Array: para, two = X xs, ys = _single_intersection_points(**two) # arcs of the ellipse outline, split at the crossings; keep those inside the # star (zero-length sentinel arcs are skipped to save their quadrature calls) alphas = cartesian_intersection_to_parametric_angle(xs, ys, **para) alphas = jnp.where(xs != 999, alphas, 2 * jnp.pi) alphas = jnp.where(alphas < 0, alphas + 2 * jnp.pi, alphas) alphas = jnp.where(alphas > 2 * jnp.pi, alphas - 2 * jnp.pi, alphas) lo, hi, mid = _arcs_from_angles(alphas) mx = para["c_x1"] * jnp.cos(mid) + para["c_x2"] * jnp.sin(mid) + para["c_x3"] my = para["c_y1"] * jnp.cos(mid) + para["c_y2"] * jnp.sin(mid) + para["c_y3"] keep = (mx**2 + my**2 < 1.0) & (hi > lo) def ellipse_arc(carry: jax.Array, arc: tuple) -> tuple[jax.Array, None]: arc_lo, arc_hi, arc_keep = arc val = jax.lax.cond( arc_keep, lambda _: jnp.matmul( planet_solution_vec(arc_lo, arc_hi, g_coeffs, **para), g_coeffs ), lambda _: 0.0, None, ) return carry + val, None ellipse_contribution = jax.lax.scan(ellipse_arc, 0.0, (lo, hi, keep))[0] # arcs of the stellar limb, split at the same crossings (now in polar angle); # keep those inside the ellipse. These are closed-form and cheap, so no # cond-gating is needed thetas = jnp.where(xs != 999, jnp.arctan2(ys, xs), 2 * jnp.pi) thetas = jnp.where(thetas < 0, thetas + 2 * jnp.pi, thetas) star_lo, star_hi, star_mid = _arcs_from_angles(thetas) keep_star = point_in_ellipse(jnp.cos(star_mid), jnp.sin(star_mid), **para) & ( star_hi > star_lo ) s0 = _s0_definite(star_lo, star_hi) s1 = _s1_definite(star_lo, star_hi) star_contribution = jnp.sum( jnp.where(keep_star, g_coeffs[0] * s0 + g_coeffs[1] * s1, 0.0) ) return ellipse_contribution + star_contribution def not_straddling_case(X: tuple) -> jax.Array: para, _ = X def fully_transiting(para: dict) -> jax.Array: return jnp.matmul( planet_solution_vec(a=0.0, b=2 * jnp.pi, g_coeffs=g_coeffs, **para), g_coeffs, ) def not_transiting(para: dict) -> float: return 0.0 return jax.lax.cond( para["c_x3"] ** 2 + para["c_y3"] ** 2 <= 1, fully_transiting, not_transiting, para, ) # conservative bounding pre-test: only outlines that straddle the star edge need # the quartic solve. Note an ellipse that contains the whole star always classifies # as straddling, and is handled by the crossing-free containment logic above. return jax.lax.cond( ellipse_bound(**para), straddling_case, not_straddling_case, (para, two) )
def _lightcurve_setup(state: dict, parameterize_with_projected_ellipse: bool) -> dict: """Shared vectorized setup for the transit drivers. Everything in :func:`lightcurve` that happens before the per-timestep ``jax.lax.scan``: solving Kepler's equation for the true anomalies, converting the limb-darkening ``u`` coefficients to the Green's basis, and building the per-timestep implicit and parametric outline coefficients. Split out so that a ringed system can compute this once and reuse it across the planet and ring-edge integrations. Note that this also stores the true anomalies in ``state["f"]`` (and ``state["t_peri"]`` if ``t0`` was provided) as a side effect, matching the previous inline behavior. Args: state (dict): A dictionary containing all of the keys that are included in an :func:`OblateSystem` ``state`` attribute. parameterize_with_projected_ellipse (bool): See :func:`lightcurve`. Returns: dict: Keys: ``fluxes`` (all ones, shape of ``times``), ``g_coeffs``, ``normalization_constant``, ``positions`` (from :func:`kepler.skypos`), ``two`` and ``para`` (per-timestep outline coefficient dicts), and ``largest_r`` (a bound on the planet's projected radius for the possibly-in-transit mask). """ fluxes = jnp.ones_like(state["times"]) if state["t0"] is not None: state["t_peri"] = t0_to_t_peri(**state) time_deltas = state["times"] - state["t_peri"] mean_anomalies = 2 * jnp.pi * time_deltas / state["period"] true_anomalies = kepler(mean_anomalies, state["e"]) state["f"] = true_anomalies # convert the u coefficients to g coefficients u_coeffs = jnp.ones(state["ld_u_coeffs"].shape[0] + 1) * (-1) u_coeffs = u_coeffs.at[1:].set(state["ld_u_coeffs"]) g_coeffs = jnp.matmul(state["greens_basis_transform"], u_coeffs) # total flux from the star. 1/eq. 28 in Agol, Luger, and Foreman-Mackey 2020 # note, multiply, don't divide normalization_constant = 1 / (jnp.pi * (g_coeffs[0] + (2 / 3) * g_coeffs[1])) # cartesian position of the planet at each timestep positions = skypos(**state) if parameterize_with_projected_ellipse: area = jnp.pi * state["projected_effective_r"] ** 2 r1 = jnp.sqrt(area / ((1 - state["projected_f"]) * jnp.pi)) r2 = r1 * (1 - state["projected_f"]) two, para = parameterize_2d_helper( r1, state["projected_f"], state["projected_theta"], positions[0, :], positions[1, :], ) # force the shapes to match: if user inputs a scaler for one value but the # others are still (1,) there'd be a problem. all is fine if they're all # scalars or all arrays though r1 = jnp.ones_like(r2) * r1 largest_r = jnp.max(jnp.array([r1, r2])) # if prec isn't the same length as f, parameterize_2d_helper left the # orientation-only quadratic terms as scalars while the centers are length-n. # Broadcast them to match for the downstream lax.scan. if state["prec"].shape != true_anomalies.shape: two["rho_xx"] = jnp.ones_like(state["f"]) * two["rho_xx"] two["rho_xy"] = jnp.ones_like(state["f"]) * two["rho_xy"] two["rho_yy"] = jnp.ones_like(state["f"]) * two["rho_yy"] else: # direct orbital-elements -> outline construction (M = R D R^T + Schur), # replacing the planet_3d_coeffs -> planet_2d_coeffs -> poly_to_parametric # chain. It applies the tidally-locked prec override and broadcasts every leaf # to the time axis internally. two, para = outline_prelude(state) largest_r = state["r"] return { "fluxes": fluxes, "g_coeffs": g_coeffs, "normalization_constant": normalization_constant, "positions": positions, "two": two, "para": para, "largest_r": largest_r, }
[docs] @partial(jax.jit, static_argnames=("parameterize_with_projected_ellipse",)) def lightcurve(state: dict, parameterize_with_projected_ellipse: bool) -> jax.Array: """The main function for computing a transit light curve. This function will return a 1-D array representing the flux received from the star, where each entry corresponds to a time in the input `state` dictionary. It first transforms the `state` into the implicit 3D surface of the planet, the implicit 2D sky-projected outline of the planet, and a parametric form of that outline for each time step. These are vectorized operations that are computed simultaneously across all times. It then solves for the intersection points of the planet and star, and if the planet is either partially or fully transiting, numerically solves the required 1D integrals that leverage Green's Theorem to compute the blocked flux. The flux-blocking calculations are done sequentially for each timestep using ``jax.lax.scan``, which seemed to be more efficient than vectorizing again while switching between branches with something like ``jax.lax.cond``. Keep these different behaviors in mind when computing dense lightcurves with ~100s of thousands of time steps: the first part will require enough memory to compute and store ~30 values for each step, but then the actual 1D integrals will be computed sequentially. Args: state (dict): A dictionary containing all of the keys that are included in an :func:`OblateSystem` ``state`` attribute. parameterize_with_projected_ellipse (bool): If ``True``, the planet's outline will be parameterized by the projected ellipse as seen by the observer. If ``False``, the planet's outline will be set by the full 3D parameterization of the planet. When dealing with planets that are not tidally locked and/or far from their host star and/or very close to spherical, you won't be able to tell the difference between these two parameterizations since the projected area won't be changing. In that case, it's better to use the simpler 2D parameterization to avoid the degeneracies and extra computation that can arise from the 3D parameterization. This argument is static for the JIT-compiled function. Returns: Array: The flux received from the star at each time step for the times included as ``state["times"]``. """ setup = _lightcurve_setup(state, parameterize_with_projected_ellipse) g_coeffs = setup["g_coeffs"] normalization_constant = setup["normalization_constant"] positions = setup["positions"] possibly_in_transit = ( positions[0, :] ** 2 + positions[1, :] ** 2 <= (1.0 + setup["largest_r"] * 1.1) ** 2 ) * (positions[2, :] > 0) def transiting(X: tuple) -> jax.Array: indv_para, indv_two = X return ellipse_star_term(indv_para, indv_two, g_coeffs) * normalization_constant def not_transiting(X: tuple) -> float: return 0.0 def scan_func(carry: None, scan_over: tuple) -> tuple[None, jax.Array]: indv_para, indv_two, mask = scan_over return None, jax.lax.cond( mask, transiting, not_transiting, (indv_para, indv_two) ) transit_fluxes = jax.lax.scan( scan_func, None, (setup["para"], setup["two"], possibly_in_transit), None )[1] return setup["fluxes"] - transit_fluxes