Source code for spotgp.latitude

"""
latitude.py — Latitude distribution functions for starspot models.

LatitudeDistributionFunction defines the probability density p(phi) over
stellar latitude, controlling where spots are placed and how the kernel
is integrated over latitude.
"""
from __future__ import annotations

import numpy as np

__all__ = ["LatitudeDistributionFunction", "UniformDoubleHemisphereBand"]


[docs] class LatitudeDistributionFunction: """ Base class for starspot latitude distributions. Defines the probability density p(phi) over stellar latitude phi and the latitude range over which spots are placed. The default implementation is a uniform distribution over [-pi/2, pi/2]. To define a custom distribution, subclass this class and override ``__call__`` and optionally ``lat_range``. Examples -------- Equatorial band (spots confined to |phi| < 30 deg): >>> class EquatorialBand(LatitudeDistributionFunction): ... @property ... def lat_range(self): ... return (-np.pi / 6, np.pi / 6) ... def __call__(self, phi): ... return 1.0 Gaussian centred on the equator: >>> class GaussianLatitude(LatitudeDistributionFunction): ... def __init__(self, sigma=np.pi / 6): ... self.sigma = sigma ... def __call__(self, phi): ... return np.exp(-0.5 * (phi / self.sigma) ** 2) """ @property def param_dict(self) -> dict: """Free latitude parameters as ``{name: value}``. Default: none.""" return {} @property def param_keys(self) -> tuple: """Ordered parameter names for the theta vector.""" return tuple(self.param_dict.keys()) @property def lat_range(self) -> tuple: """(min, max) latitude in radians.""" return (-np.pi / 2, np.pi / 2) def __call__(self, phi: float) -> float: """ Unnormalized probability density at latitude phi. Normalization is handled internally by the kernel integrator. Parameters ---------- phi : float Stellar latitude [radians]. Returns ------- float Relative probability density at phi. """ return 1.0
[docs] def sympy_pdf(self): """ Return the sympy expression for the latitude PDF p(phi). Subclasses should override this to provide their analytic form. The base implementation returns 1 (uniform distribution). Returns ------- sympy.Expr or None Sympy expression for p(phi), or None if no analytic form exists. """ try: import sympy as sp except ImportError: raise ImportError( "sympy is required for get_sympy(). " "Install with: pip install sympy") return sp.Integer(1)
[docs] def get_sympy(self, display=True, status=None): """ Display the sympy expression for the latitude PDF p(phi). Requires sympy (``pip install sympy``). Parameters ---------- display : bool, optional If True (default), render equations as formatted LaTeX in a Jupyter notebook (via IPython.display) or print them as LaTeX strings in a plain terminal. status : str or None, optional If provided, appended to the class name header in brackets, e.g. ``"default"`` renders as ``LatitudeDistributionFunction [default]``. Returns ------- dict ``{"pdf": expr_or_None}`` """ try: import sympy as sp except ImportError: raise ImportError( "sympy is required for get_sympy(). " "Install with: pip install sympy") expr = self.sympy_pdf() exprs = {"pdf": expr} if display: rhs = r"\text{[numerical]}" if expr is None else sp.latex(expr) status_tag = r" \text{[" + status + r"]}" if status else "" header = r"\textbf{" + type(self).__name__ + r"}" + status_tag try: from IPython.display import display as ipy_display, Math ipy_display(Math(header)) ipy_display(Math(r"p(\phi) = " + rhs)) except ImportError: status_str = f" [{status}]" if status else "" print(f"{type(self).__name__}{status_str}") print(f" $p(\\phi) = {rhs}$") return exprs
def __repr__(self) -> str: return (f"{type(self).__name__}(" f"lat_range=[{self.lat_range[0]:.3f}, {self.lat_range[1]:.3f}])")
[docs] class UniformDoubleHemisphereBand(LatitudeDistributionFunction): """Uniform distribution confined to min_lat < |phi| < max_lat.""" def __init__(self, min_lat_deg: float = 0.0, max_lat_deg: float = 90.0): self._min_lat = float(np.deg2rad(min_lat_deg)) self._max_lat = float(np.deg2rad(max_lat_deg)) @property def param_dict(self) -> dict: return {"lat_min": self._min_lat, "lat_max": self._max_lat} @property def lat_range(self) -> tuple: return (self._min_lat, self._max_lat) def __call__(self, phi: float) -> float: return 1.0 if self._min_lat < np.abs(phi) < self._max_lat else 0.0
[docs] def sympy_pdf(self): import sympy as sp phi = sp.Symbol(r'\phi', real=True) lo = sp.Float(self._min_lat) hi = sp.Float(self._max_lat) return sp.Piecewise( (sp.Integer(1), (sp.Abs(phi) > lo) & (sp.Abs(phi) < hi)), (sp.Integer(0), True), )