import logging
import warnings
import jax
import jax.numpy as jnp
import numpy as np
from .params import resolve_hparam
from .spot_model import SpotEvolutionModel
from .animations import AnimationMixin
__all__ = ["LightcurveModel", "compute_sigmak"]
logger = logging.getLogger("spotgp")
def compute_sigmak(nspot_rate, alpha_max, fspot=0.0):
"""Compute the kernel amplitude prefactor sigma_k.
Thin wrapper around params.resolve_hparam for the physical_rate mode.
Parameters
----------
nspot_rate : float
Spot emergence rate [spots/day].
alpha_max : float
Peak spot angular radius [rad].
fspot : float, optional
Spot contrast fraction (default 0).
Returns
-------
sigma_k : float
sigma_k = sqrt(nspot_rate) * (1 - fspot) * alpha_max**2
"""
return np.sqrt(nspot_rate) * (1 - fspot) * alpha_max**2
# =====================================================================
# Spot projection helpers for animation
# =====================================================================
def _projected_spot_patch(lon, lat, alpha, inc, n_pts=60):
"""
Compute the 2D projected outline of a circular spot on a sphere.
Parameters
----------
lon : float
Spot longitude (radians).
lat : float
Spot latitude (radians).
alpha : float
Spot angular radius (radians).
inc : float
Stellar inclination (radians).
n_pts : int
Number of points in the outline polygon.
Returns
-------
front_x, front_y : ndarray or None
Visible portion outline.
back_x, back_y : ndarray or None
Hidden (far-side) portion outline.
"""
# Spot center direction in observer frame
cx = -np.sin(inc) * np.sin(lat) + np.cos(inc) * np.cos(lat) * np.cos(lon)
cy = np.cos(lat) * np.sin(lon)
cz = np.cos(inc) * np.sin(lat) + np.sin(inc) * np.cos(lat) * np.cos(lon)
c_vec = np.array([cx, cy, cz])
# Build orthonormal basis on the tangent plane at spot center
up = np.array([0, 0, 1.0]) if abs(cz) < 0.9 else np.array([1.0, 0, 0])
e1 = np.cross(c_vec, up)
e1 /= np.linalg.norm(e1)
e2 = np.cross(c_vec, e1)
e2 /= np.linalg.norm(e2)
# Trace the spot boundary on the unit sphere
phi = np.linspace(0, 2 * np.pi, n_pts)
pts = (np.cos(alpha) * c_vec[:, None]
+ np.sin(alpha) * (np.cos(phi) * e1[:, None]
+ np.sin(phi) * e2[:, None]))
proj_x = pts[1] # right on sky
proj_y = pts[0] # up on sky
visible = pts[2] > 0
if np.all(visible):
return proj_x, proj_y, None, None
elif not np.any(visible):
return None, None, proj_x, proj_y
else:
fx, fy = _extract_visible(proj_x, proj_y, pts, visible, n_pts)
bx, by = _extract_hidden(proj_x, proj_y, pts, visible, n_pts)
return fx, fy, bx, by
def _extract_visible(proj_x, proj_y, pts, visible, n_pts):
"""Extract visible portion of spot outline with limb interpolation."""
xs, ys = [], []
for i in range(n_pts):
if visible[i]:
xs.append(proj_x[i])
ys.append(proj_y[i])
else:
if i > 0 and visible[i - 1]:
t = pts[2, i - 1] / (pts[2, i - 1] - pts[2, i])
xs.append(proj_x[i - 1] + t * (proj_x[i] - proj_x[i - 1]))
ys.append(proj_y[i - 1] + t * (proj_y[i] - proj_y[i - 1]))
if i < n_pts - 1 and visible[i + 1]:
t = pts[2, i] / (pts[2, i] - pts[2, i + 1])
xs.append(proj_x[i] + t * (proj_x[i + 1] - proj_x[i]))
ys.append(proj_y[i] + t * (proj_y[i + 1] - proj_y[i]))
if len(xs) < 3:
return None, None
return np.array(xs), np.array(ys)
def _extract_hidden(proj_x, proj_y, pts, visible, n_pts):
"""Extract hidden portion of spot outline with limb interpolation."""
xs, ys = [], []
for i in range(n_pts):
if not visible[i]:
xs.append(proj_x[i])
ys.append(proj_y[i])
else:
if i > 0 and not visible[i - 1]:
t = pts[2, i - 1] / (pts[2, i - 1] - pts[2, i])
xs.append(proj_x[i - 1] + t * (proj_x[i] - proj_x[i - 1]))
ys.append(proj_y[i - 1] + t * (proj_y[i] - proj_y[i - 1]))
if i < n_pts - 1 and not visible[i + 1]:
t = pts[2, i] / (pts[2, i] - pts[2, i + 1])
xs.append(proj_x[i] + t * (proj_x[i + 1] - proj_x[i]))
ys.append(proj_y[i] + t * (proj_y[i + 1] - proj_y[i]))
if len(xs) < 3:
return None, None
return np.array(xs), np.array(ys)
@jax.jit
def _zeta(x):
"""Calculate zeta(x) for spot limb darkening."""
return (jnp.cos(x) * jnp.heaviside(x, 1.0) * jnp.heaviside(jnp.pi/2 - x, 1.0)
+ jnp.heaviside(-x, 1.0))
@jax.jit
def _alphak(teval, tmaxk, lspot, tem, tdec, alpha_max):
"""Compute spot angular size evolution (vectorized over time)."""
dt1 = teval - tmaxk + lspot/2 + tem
dt2 = teval - tmaxk + lspot/2
dt3 = teval - tmaxk - lspot/2
dt4 = teval - tmaxk - lspot/2 - tdec
alphak = (dt1 * jnp.heaviside(dt1, 1.0) - dt2 * jnp.heaviside(dt2, 1.0)) / tem
alphak += -(dt3 * jnp.heaviside(dt3, 1.0) - dt4 * jnp.heaviside(dt4, 1.0)) / tdec
alphak *= alpha_max
return alphak
@jax.jit
def _betak(teval, longk, latk, tmaxk, peq, kappa, inc):
"""Compute spot angle from disk center (vectorized over time)."""
longk_t = longk + 2*jnp.pi/peq * (1 - kappa * jnp.sin(latk)**2) * (teval - tmaxk)
cosb = jnp.cos(inc) * jnp.sin(latk)
cosb += jnp.sin(inc) * jnp.cos(latk) * jnp.cos(longk_t)
betak_t = jnp.arccos(jnp.clip(cosb, -1.0, 1.0))
return betak_t, longk_t
@jax.jit
def _dflux_single_spot(teval, longk, latk, tmaxk,
peq, kappa, inc, lspot, tem, tdec, alpha_max, fspot):
"""
Compute flux deficit for a single spot over all time steps.
Fully vectorized over time using JAX.
"""
betak_t, _ = _betak(teval, longk, latk, tmaxk, peq, kappa, inc)
alphak_t = _alphak(teval, tmaxk, lspot, tem, tdec, alpha_max)
cosa = jnp.cos(alphak_t)
sina = jnp.sin(alphak_t)
cosb = jnp.cos(betak_t)
sinb = jnp.sin(betak_t)
# Avoid division by zero with small epsilon
eps = 1e-30
cota = cosa / (sina + eps)
cscb = 1.0 / (sinb + eps)
cotb = cosb / (sinb + eps)
# Clamp argument for arccos to [-1, 1]
arg1 = jnp.clip(cosa * cscb, -1.0, 1.0)
arg2 = jnp.clip(-cota * cotb, -1.0, 1.0)
sqrt_arg = jnp.clip(1 - cosa**2 * cscb**2, 0.0, None)
Ak = jnp.arccos(arg1)
Ak += cosb * sina**2 * jnp.arccos(arg2)
Ak -= cosa * sinb * jnp.sqrt(sqrt_arg)
# Simple spot limb darkening factor (no limb darkening case)
factor = 1.0 - fspot
dspot = Ak / jnp.pi * factor
# Zero out contributions where spot has zero size
dspot = jnp.where(alphak_t > 1e-15, dspot, 0.0)
return dspot
# Vectorize over spots (batch the single-spot function over spot index)
_dflux_all_spots = jax.vmap(
_dflux_single_spot,
in_axes=(None, 0, 0, 0, # teval shared; longk, latk, tmaxk per-spot
None, None, None, None, None, None, None, None) # scalar params shared
)
@jax.jit
def _dflux_single_spot_fixed(teval, tmaxk, lspot, tem, tdec, alpha_max, fspot):
"""
Flux deficit for a spot fixed at disk center (no stellar rotation).
Equivalent to _dflux_single_spot with beta=0 at all times: only the
spot size envelope drives flux changes. With beta=0 the projected
area simplifies to A_k = pi * sin^2(alpha).
"""
alphak_t = _alphak(teval, tmaxk, lspot, tem, tdec, alpha_max)
sina = jnp.sin(alphak_t)
dspot = sina**2 * (1.0 - fspot)
dspot = jnp.where(alphak_t > 1e-15, dspot, 0.0)
return dspot
# Vectorize fixed-spot function over tmaxk only (no per-spot geometry)
_dflux_all_spots_fixed = jax.vmap(
_dflux_single_spot_fixed,
in_axes=(None, 0, None, None, None, None, None) # teval shared; tmaxk per-spot
)
@jax.jit
def _dflux_single_spot_constant(teval, longk, latk, tmaxk,
peq, kappa, inc, alpha_max, fspot):
"""
Flux deficit for a spot with constant angular size (no envelope evolution).
The spot is always at full size alpha_max; only stellar rotation via
_betak modulates the projected area.
"""
betak_t, _ = _betak(teval, longk, latk, tmaxk, peq, kappa, inc)
cosa = jnp.cos(alpha_max)
sina = jnp.sin(alpha_max)
cosb = jnp.cos(betak_t)
sinb = jnp.sin(betak_t)
eps = 1e-30
cota = cosa / (sina + eps)
cscb = 1.0 / (sinb + eps)
cotb = cosb / (sinb + eps)
arg1 = jnp.clip(cosa * cscb, -1.0, 1.0)
arg2 = jnp.clip(-cota * cotb, -1.0, 1.0)
sqrt_arg = jnp.clip(1 - cosa**2 * cscb**2, 0.0, None)
Ak = jnp.arccos(arg1)
Ak += cosb * sina**2 * jnp.arccos(arg2)
Ak -= cosa * sinb * jnp.sqrt(sqrt_arg)
return Ak / jnp.pi * (1.0 - fspot)
# Vectorize constant-size function over per-spot geometry
_dflux_all_spots_constant = jax.vmap(
_dflux_single_spot_constant,
in_axes=(None, 0, 0, 0, None, None, None, None, None)
)
[docs]
class LightcurveModel(AnimationMixin):
"""
JAX-accelerated star with spots and its lightcurve.
Same interface as the numpy version but uses JAX for vectorized
computation across all spots simultaneously.
Args:
peq (float): Equatorial period of the star.
kappa (float): Differential rotation shear.
inc (float): Inclination of the star.
nspot (int): Number of spots.
tau_spot (float, optional): Timescale for both emergence and decay of the spots. Defaults to None.
tem (float, optional): Emergence timescale of the spots. Defaults to 2.
tdec (float, optional): Decay timescale of the spots. Defaults to 2.
alpha_max (float, optional): Maximum angular area of the spots. Defaults to 0.1.
fspot (float, optional): Spot contrast fraction. Defaults to 0.
lspot (float, optional): Spot lifetime. Defaults to 5.
long (list, optional): Range of spot longitudes. Defaults to [0, 2*pi].
lat (list, optional): Range of spot latitudes. Defaults to [0, pi].
tsim (float, optional): End simulation time. Defaults to 28.
tsamp (float, optional): Sampling cadence. Defaults to 0.02.
limb_darkening (bool, optional): Flag to enable limb darkening. Defaults to False.
"""
def __init__(self, peq=4.0, kappa=0.0, inc=np.pi/2, nspot=None,
tau_spot=None, tem=2, tdec=2, alpha_max=0.1, fspot=0, lspot=5,
long=[0, 2*np.pi], lat=[-np.pi/2, np.pi/2],
tsim=28, tsamp=0.02, limb_darkening=False, tmax=None,
rotate=True, grow=True, nspot_rate=None):
# simulation parameters
self.tsim = tsim
self.tsamp = tsamp
self.t = np.arange(0, self.tsim, self.tsamp)
# star properties
self.peq = peq
self.kappa = kappa
self.inc = inc
self.inc_deg = inc * 180/np.pi
# resolve nspot from nspot_rate if needed
if nspot_rate is not None:
self.nspot_rate = float(nspot_rate)
self.nspot = max(1, int(nspot_rate * tsim))
elif nspot is not None:
self.nspot_rate = None
self.nspot = int(nspot)
else:
self.nspot_rate = None
self.nspot = 10
# spot properties (scalars)
if tau_spot is not None:
self.tem = tau_spot
self.tdec = tau_spot
else:
self.tem = tem
self.tdec = tdec
self.alpha_max = alpha_max
self.fspot = fspot
self.lspot = lspot
self.tlifetime = self.lspot + self.tem + self.tdec
self.long = self._assign_property(long)
self.lat = self._assign_property(lat)
if tmax is None:
self.tmax = np.random.uniform(-(self.lspot/2 + self.tdec),
self.tsim + self.lspot/2 + self.tem,
self.nspot)
elif isinstance(tmax, float):
self.tmax = np.full(self.nspot, tmax)
else:
self.tmax = np.asarray(tmax)
self.rotate = bool(rotate)
self.grow = bool(grow)
# limb darkening
self.limb_darkening = limb_darkening
self.limbc = np.array([0.3999, 0.4269, -0.0227, -0.0839])
self.limbd = self.limbc
# compute lightcurve using JAX
self.flux = self.Flux(self.t)
[docs]
@classmethod
def from_spot_model(cls, spot_model: "SpotEvolutionModel",
nspot: int = None, *, nspot_rate: float = None, **kwargs):
"""Construct a LightcurveModel from a SpotEvolutionModel.
Parameters
----------
spot_model : SpotEvolutionModel
Fully configured spot evolution model.
nspot : int, optional
Total number of spots to simulate.
nspot_rate : float, optional
Spot emergence rate [spots/day]. The actual number of spots is
``max(1, int(nspot_rate * tsim))``. Exactly one of ``nspot`` or
``nspot_rate`` must be provided.
**kwargs
Forwarded to LightcurveModel.__init__ (e.g. tsim, tsamp, lat, long).
Returns
-------
LightcurveModel
"""
if nspot is None and nspot_rate is None:
raise ValueError("Provide either nspot or nspot_rate.")
if nspot is not None and nspot_rate is not None:
raise ValueError("Provide either nspot or nspot_rate, not both.")
from .envelope import TrapezoidAsymmetricEnvelope
env = spot_model.envelope
if env is not None:
if isinstance(env, TrapezoidAsymmetricEnvelope):
tau_em = env.tau_em
tau_dec = env.tau_dec
else:
tau_em = env.tau_spot
tau_dec = env.tau_spot
lspot = spot_model.lspot
else:
tau_em = kwargs.pop("tem", kwargs.pop("tau_spot", 2.0))
tau_dec = kwargs.pop("tdec", tau_em)
lspot = kwargs.pop("lspot", 5.0)
alpha_max = spot_model.alpha_max if spot_model.alpha_max is not None \
else kwargs.pop("alpha_max", 0.1)
fspot = spot_model.fspot if spot_model.fspot else kwargs.pop("fspot", 0.0)
if "lat" not in kwargs:
kwargs["lat"] = list(spot_model.latitude_distribution.lat_range)
vis = spot_model.visibility
return cls(
peq=vis.peq if vis is not None else kwargs.pop("peq", 4.0),
kappa=vis.kappa if vis is not None else kwargs.pop("kappa", 0.0),
inc=vis.inc if vis is not None else kwargs.pop("inc", np.pi / 2),
nspot=nspot,
nspot_rate=nspot_rate,
tem=tau_em,
tdec=tau_dec,
alpha_max=alpha_max,
fspot=fspot,
lspot=lspot,
rotate=(vis is not None),
grow=(spot_model.envelope is not None),
**kwargs,
)
[docs]
@classmethod
def from_hparam(cls, hparam: dict, nspot: int = None, *,
nspot_rate: float = None, **kwargs):
"""Construct a LightcurveModel from a GPSolver-compatible hparam dict.
Accepts the same raw hparam dict that GPSolver/AnalyticKernel take,
including all amplitude modes (sigma_k, nspot_rate, or nspot), and
both symmetric (tau) and asymmetric (tau_em + tau_dec) envelopes.
This removes the need to manually decompose the dict in scripts.
Parameters
----------
hparam : dict
Raw hyperparameter dict. Must contain peq, kappa, inc, lspot,
tau_spot (or tau_em/tau_dec), and an amplitude specification.
nspot : int, optional
Total number of spots to simulate.
nspot_rate : float, optional
Spot emergence rate [spots/day]. Exactly one of ``nspot`` or
``nspot_rate`` must be provided.
**kwargs
Forwarded to LightcurveModel.__init__ (e.g. tsim, tsamp, lat, long).
Returns
-------
LightcurveModel
"""
if nspot is None and nspot_rate is None:
raise ValueError("Provide either nspot or nspot_rate.")
if nspot is not None and nspot_rate is not None:
raise ValueError("Provide either nspot or nspot_rate, not both.")
p = resolve_hparam(hparam)
tau_em = p.get("tau_em", p["tau_spot"])
tau_dec = p.get("tau_dec", p["tau_spot"])
alpha_max = p.get("alpha_max", kwargs.pop("alpha_max", 0.1))
fspot = p.get("fspot", kwargs.pop("fspot", 0.0))
return cls(
peq=p["peq"], kappa=p["kappa"], inc=p["inc"],
nspot=nspot, nspot_rate=nspot_rate,
tem=tau_em, tdec=tau_dec,
alpha_max=alpha_max, fspot=fspot, lspot=p["lspot"],
**kwargs,
)
def _assign_property(self, var):
if isinstance(var, float):
return np.full(self.nspot, var)
elif isinstance(var, (int, list, np.ndarray)):
return np.random.uniform(var[0], var[1], self.nspot)
else:
raise TypeError("Invalid datatype for model parameter. "
"Valid types: int, float, list, np.ndarray")
[docs]
def Flux(self, teval):
"""
Compute the full lightcurve using JAX vmap over all spots.
Instead of a Python loop over nspot, all spots are computed
in parallel via JAX's vmap.
"""
teval_jax = jnp.array(teval)
long_jax = jnp.array(np.atleast_1d(self.long))
lat_jax = jnp.array(np.atleast_1d(self.lat))
tmax_jax = jnp.array(self.tmax)
# Compute all spots in parallel via vmap
if self.rotate and self.grow:
dspots = _dflux_all_spots(
teval_jax, long_jax, lat_jax, tmax_jax,
self.peq, self.kappa, self.inc,
self.lspot, self.tem, self.tdec, self.alpha_max, self.fspot
)
elif self.rotate and not self.grow:
dspots = _dflux_all_spots_constant(
teval_jax, long_jax, lat_jax, tmax_jax,
self.peq, self.kappa, self.inc, self.alpha_max, self.fspot
)
elif not self.rotate and self.grow:
dspots = _dflux_all_spots_fixed(
teval_jax, tmax_jax,
self.lspot, self.tem, self.tdec, self.alpha_max, self.fspot
)
else: # not rotate, not grow
dspots = _dflux_all_spots_constant(
teval_jax, long_jax, lat_jax, tmax_jax,
self.peq, self.kappa, self.inc, self.alpha_max, self.fspot
)
# Convert back to numpy for storage
self.dspots = np.asarray(dspots)
# Stellar limb darkening
self.dlimb = self._stellar_limb()
# Total remaining flux
flux = 1 - self.dlimb - np.sum(self.dspots, axis=0)
return flux
def _stellar_limb(self):
if self.limb_darkening:
ncoeff = len(self.limbc)
return np.sum([n*self.limbc[n] / (n + ncoeff) for n in range(ncoeff)])
return 0.0
[docs]
def plot_lightcurve(self, show_spots=True, show_title=True):
"""Plot the lightcurve."""
import matplotlib.pyplot as plt
from matplotlib import rc
rc('font', **{'family': 'serif', 'serif': ['Computer Modern']})
rc('text', usetex=True)
rc('figure', facecolor='w')
rc('xtick', labelsize=20)
rc('ytick', labelsize=20)
flux = self.flux + self.dlimb
dflux_pct = (flux - 1) * 100
fig = plt.figure(figsize=[16, 6])
if show_spots:
for ii in range(self.nspot):
plt.plot(self.t, -self.dspots[ii] * 100, alpha=0.5)
plt.plot(self.t, dflux_pct, color="k")
if show_title:
title = r"$P_{{\rm eq}}$={:.1f} d, ".format(self.peq)
title += r"$\kappa$={:.2f}, ".format(self.kappa)
title += r"$i$={:.0f} deg, ".format(self.inc_deg)
title += r"nspot={:.0f}, ".format(self.nspot)
title += r"$\alpha_{{\rm max}}$={:.1f}, ".format(self.alpha_max)
title += r"$l_{{\rm spot}}$={:.2f}, ".format(self.lspot)
title += r"$\tau_{{\rm em}}$={:.2f}, ".format(self.tem)
title += r"$\tau_{{\rm dec}}$={:.2f}".format(self.tdec)
plt.title(title, fontsize=25)
plt.xlabel("Time [days]", fontsize=24)
plt.ylabel(r"$\Delta$ Flux [\%]", fontsize=24)
plt.ylim(min(dflux_pct) - 0.2, max(dflux_pct) + 0.2)
plt.xlim(self.t[0], self.t[-1])
plt.minorticks_on()
plt.ticklabel_format(axis='both', style='', useOffset=False)
plt.close()
return fig