Source code for ringed_system

import jax

jax.config.update("jax_enable_x64", True)
import pprint
from collections.abc import Callable
from functools import partial

import jax.numpy as jnp
import matplotlib.pyplot as plt
from matplotlib.patches import PathPatch
from matplotlib.path import Path

from squishyplanet.engine.kepler import kepler, t0_to_t_peri
from squishyplanet.engine.ringed_transit import ringed_lightcurve
from squishyplanet.engine.rings import ring_para_coeffs
from squishyplanet.oblate_system import OblateSystem


[docs] class RingedSystem(OblateSystem): """A :class:`OblateSystem` whose planet hosts an opaque, flat, circular ring. The ring is assumed to be perfectly flat, perfectly circular, perfectly opaque, concentric with the planet, and bounded by an inner and an outer radius. By default it lies in the planet's equatorial plane (its 3D orientation set by the planet's ``obliq`` and ``prec``, including the tidally-locked time dependence); passing ``ring_obliq`` and ``ring_prec`` explicitly decouples the ring's orientation from the planet's. All other arguments, defaults, and methods match :class:`OblateSystem`, except that rings require the full 3D parameterization (``parameterize_with_projected_ellipse`` must remain ``False``). Args: ring_inner_r (float, [Rstar], default=None): The inner radius of the ring. Must be larger than the planet's equatorial radius ``r``. A required parameter, will raise an error if not provided. ring_outer_r (float, [Rstar], default=None): The outer radius of the ring. Must be larger than ``ring_inner_r``; note that we ignore the physics of ring formation and stability, so it is not capped at anything like the Roche radius. A required parameter, will raise an error if not provided. ring_obliq (float, [Radian], default=None): The obliquity of the ring plane, defined identically to the planet's ``obliq``. Provide both ``ring_obliq`` and ``ring_prec`` or neither; if neither, the ring tracks the planet's equatorial plane. ring_prec (float, [Radian], default=None): The precession angle of the ring plane, defined identically to the planet's ``prec``. **kwargs: All :class:`OblateSystem` arguments. """ def __init__( self, ring_inner_r: float | None = None, ring_outer_r: float | None = None, ring_obliq: float | None = None, ring_prec: float | None = None, **kwargs: object, ) -> None: assert ring_inner_r is not None, "'ring_inner_r' is a required parameter" assert ring_outer_r is not None, "'ring_outer_r' is a required parameter" assert not kwargs.get("parameterize_with_projected_ellipse", False), ( "rings require the full 3D parameterization: " "parameterize_with_projected_ellipse must be False" ) assert (ring_obliq is None) == (ring_prec is None), ( "provide both 'ring_obliq' and 'ring_prec' to decouple the ring from the " "planet's equatorial plane, or neither to keep them aligned" ) super().__init__(**kwargs) assert ring_outer_r > ring_inner_r > 0, "need ring_outer_r > ring_inner_r > 0" assert ring_inner_r > float(jnp.asarray(self._state["r"]).ravel()[0]), ( "ring_inner_r must be larger than the planet's equatorial radius r " "(the largest of its three axes)" ) ring_tracks_planet = ring_obliq is None if ring_tracks_planet: # placeholders only: when tracking, the engine reads the planet's # obliq/prec (and the tidally-locked true anomaly) at call time ring_obliq = 0.0 ring_prec = 0.0 self._state["ring_inner_r"] = jnp.array([float(ring_inner_r)]) self._state["ring_outer_r"] = jnp.array([float(ring_outer_r)]) self._state["ring_obliq"] = jnp.array([float(ring_obliq)]) self._state["ring_prec"] = jnp.array([float(ring_prec)]) self._state["ring_tracks_planet"] = ring_tracks_planet # the parent's __init__ already built a jitted lightcurve, but its frozen # state snapshot predates the ring keys; rebuild it now that they exist self._lightcurve_fwd_grad_enforced = self._setup_lightcurve_func() def __repr__(self) -> str: s = pprint.pformat(self.state) return f"RingedSystem(\n{s}\n)" def _setup_lightcurve_func(self) -> Callable: constants = { "oversample": self._state["oversample"], "state": self._state, } frozen = jax.tree_util.Partial(_ringed_lightcurve, **constants) @jax.custom_vjp def lightcurve(params: dict) -> jax.Array: return frozen(params) def lightcurve_fwd(params: dict) -> tuple: output = frozen(params) jac = jax.jacfwd(frozen)(params) return output, (jac,) def lightcurve_bwd(res: tuple, g: jax.Array) -> tuple: jac = res val = jax.tree.map(lambda x: x.T @ g, jac) return val lightcurve.defvjp(lightcurve_fwd, lightcurve_bwd) lightcurve = jax.jit(lightcurve) return lightcurve def _ring_edge_outlines(self, true_anomalies: jax.Array) -> tuple: """Sky-plane outlines of both ring edges at the given true anomalies. Args: true_anomalies (Array): True anomalies at which to project the ring. Returns: Tuple: Arrays ``(outer_x, outer_y, inner_x, inner_y)``, each of shape ``(len(true_anomalies), 200)``. """ thetas = jnp.linspace(0, 2 * jnp.pi, 200) outer_x, outer_y, inner_x, inner_y = [], [], [], [] for f in true_anomalies: f = jnp.array([f]) if self._state["ring_tracks_planet"]: ring_obliq = self._state["obliq"] ring_prec = f if self._state["tidally_locked"] else self._state["prec"] else: ring_obliq = self._state["ring_obliq"] ring_prec = self._state["ring_prec"] shared = dict( a=self._state["a"], e=self._state["e"], f=f, Omega=self._state["Omega"], i=self._state["i"], omega=self._state["omega"], ring_obliq=ring_obliq, ring_prec=ring_prec, ) for rRing, xs, ys in ( (self._state["ring_outer_r"], outer_x, outer_y), (self._state["ring_inner_r"], inner_x, inner_y), ): para = ring_para_coeffs(rRing=rRing, **shared) xs.append( para["c_x1"] * jnp.cos(thetas) + para["c_x2"] * jnp.sin(thetas) + para["c_x3"] ) ys.append( para["c_y1"] * jnp.cos(thetas) + para["c_y2"] * jnp.sin(thetas) + para["c_y3"] ) return ( jnp.array(outer_x), jnp.array(outer_y), jnp.array(inner_x), jnp.array(inner_y), )
[docs] def illustrate( self, times: jax.Array | None = None, true_anomalies: jax.Array | None = None, ring_fill: bool = True, **kwargs: object, ) -> None: """Visualize the layout of the system, including the ring, at one or more times. Identical to :func:`OblateSystem.illustrate` but overlays the projected outlines of both ring edges and (optionally) fills the annulus between them. Note that the layering is cosmetic: the filled annulus is drawn on top of the planet even though half of the physical ring passes behind it. Args: times (array-like, [Days], default=None): See :func:`OblateSystem.illustrate`. true_anomalies (array-like, [Radian], default=None): See :func:`OblateSystem.illustrate`. ring_fill (bool, default=True): Whether to fill the annulus between the ring edges, in addition to drawing their outlines. **kwargs: All other :func:`OblateSystem.illustrate` arguments. Returns: None: This method is used for its side effects of displaying a plot, not for its return value. """ super().illustrate(times=times, true_anomalies=true_anomalies, **kwargs) ax = plt.gca() # resolve times/true anomalies the same way the parent helper does if (times is not None) & (true_anomalies is not None): raise ValueError("Provide either times or true anomalies but not both") if times is not None: t_peri = self._state.get("t_peri", None) if t_peri is None: t_peri = t0_to_t_peri( e=self._state["e"], i=self._state["i"], omega=self._state["omega"], period=self._state["period"], t0=self._state["t0"], ) time_deltas = times - t_peri mean_anomalies = 2 * jnp.pi * time_deltas / self._state["period"] true_anomalies = kepler(mean_anomalies, self._state["e"]) elif true_anomalies is None: true_anomalies = jnp.array([jnp.pi / 2]) if isinstance(true_anomalies, float | int): true_anomalies = jnp.array([true_anomalies]) outer_x, outer_y, inner_x, inner_y = self._ring_edge_outlines(true_anomalies) for i in range(len(true_anomalies)): ax.plot(outer_x[i], outer_y[i], color="black", lw=1, label="Ring") ax.plot(inner_x[i], inner_y[i], color="black", lw=1) if ring_fill: # a two-subpath (outer + reversed inner) closed path fills as an # annulus under matplotlib's fill rule verts = jnp.concatenate( ( jnp.stack((outer_x[i], outer_y[i]), axis=1), jnp.stack((inner_x[i], inner_y[i]), axis=1)[::-1], ) ) codes = ( [Path.MOVETO] + [Path.LINETO] * (outer_x[i].shape[0] - 1) + [Path.MOVETO] + [Path.LINETO] * (inner_x[i].shape[0] - 1) ) patch = PathPatch( Path(verts, codes), facecolor="gray", edgecolor="none", alpha=0.5 ) ax.add_patch(patch) return
@partial(jax.jit, static_argnums=(1,)) def _ringed_lightcurve(params: dict, oversample: int, state: dict) -> jax.Array: for key in params: state[key] = params[key] transit = ringed_lightcurve(state) if oversample > 1: c = (transit.reshape(-1, oversample) * state["stencil"][None, :]).sum(axis=1) else: c = transit return c