Source code for jesterTOV.eos.crust

r"""Neutron star crust models and data loading."""

import os
import jax.numpy as jnp
from jaxtyping import Array, Float

# Get the path to the crust directory (relative to this module's parent)
DEFAULT_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)))
CRUST_DIR = f"{DEFAULT_DIR}/crust_files"


[docs] class Crust: r""" Neutron star crust equation of state data handler. This class provides validated loading, preprocessing, and access to crust EOS data with automatic zero-pressure filtering and density range masking. It eliminates code duplication across EOS models by centralizing all crust preprocessing logic. The crust data files contain tabulated values of number density :math:`n`, pressure :math:`p`, and energy density :math:`\varepsilon` for the low-density outer and inner crust regions of neutron stars. Parameters ---------- name : str Name of the crust model to load (e.g., 'BPS', 'DH', 'SLy') or a filename with .npz extension for custom files. min_density : float, optional Minimum density cutoff [:math:`\mathrm{fm}^{-3}`]. Points with density below this value are excluded. Default is None (no minimum cutoff). max_density : float, optional Maximum density cutoff [:math:`\mathrm{fm}^{-3}`]. Points with density above this value are excluded. Default is None (no maximum cutoff). filter_zero_pressure : bool, optional If True, remove points with zero or negative pressure to avoid numerical issues in logarithmic calculations. Default is True. Raises ------ ValueError If the specified crust model is not found in the crust directory. Attributes ---------- n : Float[Array, "n_points"] Number densities [:math:`\mathrm{fm}^{-3}`] after filtering and masking p : Float[Array, "n_points"] Pressures [:math:`\mathrm{MeV} \, \mathrm{fm}^{-3}`] after filtering and masking e : Float[Array, "n_points"] Energy densities [:math:`\mathrm{MeV} \, \mathrm{fm}^{-3}`] after filtering and masking mu_lowest : Float Chemical potential at lowest density point: :math:`(\varepsilon_0 + p_0) / n_0` cs2 : Float[Array, "n_points"] Speed of sound squared :math:`c_s^2 = dp/d\varepsilon` max_density : Float Maximum density in filtered crust [:math:`\mathrm{fm}^{-3}`] min_density : Float Minimum density in filtered crust [:math:`\mathrm{fm}^{-3}`] Examples -------- Load a crust model with default settings: >>> from jesterTOV.eos.crust import Crust >>> crust = Crust("DH") >>> print(f"Loaded {len(crust)} crust points") >>> n, p, e = crust.get_data() Load with density range masking: >>> crust = Crust("DH", min_density=0.001, max_density=0.1) >>> print(f"Density range: {crust.min_density:.4f} - {crust.max_density:.4f} fm^-3") Check available crusts before loading: >>> available = Crust.list_available() >>> if Crust.validate("BPS"): ... crust = Crust("BPS") Access derived quantities: >>> crust = Crust("DH") >>> print(f"Chemical potential: {crust.mu_lowest:.2f} MeV") >>> print(f"Sound speed squared: {crust.cs2[0]:.4f}") See Also -------- SpectralDecomposition_EOS_model : Spectral EOS using crust stitching MetaModel_EOS_model : Meta-model EOS using crust stitching Notes ----- Available built-in crust models can be found in the crust_files directory (accessible via the CRUST_DIR constant or the get_crust_dir() class method). The class automatically handles: - Zero-pressure point filtering (avoids log(0) in calculations) - Density range masking (for crust-core matching) - Monotonicity validation """
[docs] def __init__( self, name: str, min_density: float | None = None, max_density: float | None = None, filter_zero_pressure: bool = True, ) -> None: r""" Initialize and load crust EOS data with optional preprocessing. The initialization process: 1. Validates crust existence 2. Loads raw data from .npz file 3. Applies zero-pressure filtering (if enabled) 4. Applies density range masking (if specified) 5. Validates data quality (monotonicity, positivity) 6. Stores filtered arrays for property access Parameters ---------- name : str Crust model name or path to .npz file min_density : float, optional Minimum density cutoff [:math:`\\mathrm{fm}^{-3}`] max_density : float, optional Maximum density cutoff [:math:`\\mathrm{fm}^{-3}`] filter_zero_pressure : bool, optional Remove zero/negative pressure points (default: True) """ self._name: str = name self._file_path: str = self._resolve_file_path(name) # Load raw data crust_data = jnp.load(self._file_path) n_raw = crust_data["n"] p_raw = crust_data["p"] e_raw = crust_data["e"] # Apply preprocessing pipeline n_filtered, p_filtered, e_filtered = self._preprocess( n_raw, p_raw, e_raw, min_density, max_density, filter_zero_pressure ) # Store filtered data self._n: Float[Array, "n_points"] = n_filtered self._p: Float[Array, "n_points"] = p_filtered self._e: Float[Array, "n_points"] = e_filtered
[docs] @classmethod def list_available(cls) -> list[str]: """ List all available crust model names. Returns ------- list[str] List of available crust model names (without .npz extension) Examples -------- >>> available = Crust.list_available() >>> print(f"Available crusts: {', '.join(available)}") Available crusts: BPS, DH, SLy """ crust_files = [f for f in os.listdir(CRUST_DIR) if f.endswith(".npz")] return [f.split(".")[0] for f in crust_files]
[docs] @classmethod def validate(cls, name: str) -> bool: """ Check if a crust model exists without loading it. This is useful for validation before instantiation, allowing fail-fast behavior in configuration parsing or user input validation. Parameters ---------- name : str Crust model name to validate Returns ------- bool True if crust exists, False otherwise Examples -------- >>> if Crust.validate("DH"): ... crust = Crust("DH") >>> else: ... print("Crust not found") """ try: cls._resolve_file_path_static(name) return True except ValueError: return False
[docs] @classmethod def get_crust_dir(cls) -> str: """ Return the path to the crust data directory. Returns ------- str Absolute path to the crust directory Examples -------- >>> crust_dir = Crust.get_crust_dir() >>> print(f"Crust data stored in: {crust_dir}") """ return CRUST_DIR
@property def n(self) -> Float[Array, "n_points"]: r""" Number density [:math:`\mathrm{fm}^{-3}`] after filtering and masking. Returns ------- Float[Array, "n_points"] Array of number densities """ return self._n @property def p(self) -> Float[Array, "n_points"]: r""" Pressure [:math:`\mathrm{MeV} \, \mathrm{fm}^{-3}`] after filtering and masking. Returns ------- Float[Array, "n_points"] Array of pressures """ return self._p @property def e(self) -> Float[Array, "n_points"]: r""" Energy density [:math:`\mathrm{MeV} \, \mathrm{fm}^{-3}`] after filtering and masking. Returns ------- Float[Array, "n_points"] Array of energy densities """ return self._e @property def mu_lowest(self) -> Float: r""" Chemical potential at the lowest density point. Computed as :math:`\mu_0 = (\varepsilon_0 + p_0) / n_0` where subscript 0 denotes the first (lowest density) point in the crust. This value is used as the starting point for integrating thermodynamic quantities in the meta-model EOS construction. Returns ------- Float Chemical potential [:math:`\mathrm{MeV}`] Examples -------- >>> crust = Crust("DH") >>> mu = crust.mu_lowest >>> print(f"Lowest chemical potential: {mu:.2f} MeV") """ mu_lowest_val = (self._e[0] + self._p[0]) / self._n[0] return mu_lowest_val @property def cs2(self) -> Float[Array, "n_points"]: r""" Speed of sound squared :math:`c_s^2 = dp/d\varepsilon`. Computed using numerical differentiation via gradient. Returns ------- Float[Array, "n_points"] Array of sound speed squared values (dimensionless) Examples -------- >>> crust = Crust("DH") >>> cs2 = crust.cs2 >>> print(f"Sound speed range: {cs2.min():.4f} - {cs2.max():.4f}") Notes ----- The speed of sound should satisfy :math:`0 < c_s^2 \leq 1` (in units where :math:`c = 1`) for physical crust models. """ # jnp.gradient with two 1D arrays returns a single Array # but type checker sees it as Array | list[Array] # Cast to satisfy type checker - we know it's an Array in this case cs2_vals = jnp.asarray(jnp.gradient(self._p, self._e)) return cs2_vals @property def max_density(self) -> Float: r""" Maximum density in filtered crust [:math:`\mathrm{fm}^{-3}`]. Returns ------- Float Maximum number density value """ return self._n[-1] @property def min_density(self) -> Float: r""" Minimum density in filtered crust [:math:`\mathrm{fm}^{-3}`]. Returns ------- Float Minimum number density value """ return self._n[0]
[docs] def get_data( self, ) -> tuple[ Float[Array, "n_points"], Float[Array, "n_points"], Float[Array, "n_points"] ]: """ Return (n, p, e) tuple for convenient unpacking. This method provides a convenient way to get all three arrays at once, useful for code patterns that expect tuple unpacking. Returns ------- tuple[Float[Array, "n_points"], Float[Array, "n_points"], Float[Array, "n_points"]] Tuple of (number density, pressure, energy density) arrays Examples -------- >>> crust = Crust("DH") >>> n, p, e = crust.get_data() >>> assert jnp.array_equal(n, crust.n) """ return self._n, self._p, self._e
def __len__(self) -> int: """ Return the number of crust points after filtering. Returns ------- int Number of points in filtered crust arrays Examples -------- >>> crust = Crust("DH") >>> print(f"Crust has {len(crust)} points") """ return len(self._n) def __repr__(self) -> str: """ String representation of Crust instance. Returns ------- str Human-readable string describing the crust Examples -------- >>> crust = Crust("DH", max_density=0.1) >>> print(crust) Crust(name='DH', n_points=42, density_range=[0.0001, 0.1000] fm^-3) """ return ( f"Crust(name='{self._name}', n_points={len(self)}, " f"density_range=[{self.min_density:.4f}, {self.max_density:.4f}] fm^-3)" ) # Private helper methods def _resolve_file_path(self, name: str) -> str: """ Resolve crust name to file path. Parameters ---------- name : str Crust model name or path Returns ------- str Absolute path to .npz file Raises ------ ValueError If crust not found """ return self._resolve_file_path_static(name) @staticmethod def _resolve_file_path_static(name: str) -> str: """ Static version of file path resolution for class methods. Parameters ---------- name : str Crust model name or path Returns ------- str Absolute path to .npz file Raises ------ ValueError If crust not found """ # Get available crust names available_crust_names = [ f.split(".")[0] for f in os.listdir(CRUST_DIR) if f.endswith(".npz") ] # If name doesn't end with .npz, try to find it in crust directory if not name.endswith(".npz"): if name in available_crust_names: return os.path.join(CRUST_DIR, f"{name}.npz") else: raise ValueError( f"Crust '{name}' not found in {CRUST_DIR}. " f"Available crusts: {available_crust_names}" ) else: # Name includes .npz extension - treat as file path # Expand user/home directory and convert to absolute path expanded_path = os.path.expanduser(name) absolute_path = os.path.abspath(expanded_path) if os.path.exists(absolute_path): return absolute_path else: raise ValueError(f"Crust file not found: {name}") def _preprocess( self, n: Float[Array, "n_raw"], p: Float[Array, "n_raw"], e: Float[Array, "n_raw"], min_density: float | None, max_density: float | None, filter_zero_pressure: bool, ) -> tuple[ Float[Array, "n_filtered"], Float[Array, "n_filtered"], Float[Array, "n_filtered"], ]: """ Apply preprocessing pipeline to raw crust data. The pipeline applies filters in this order: 1. Zero-pressure filtering (if enabled) 2. Density range masking (if specified) Parameters ---------- n, p, e : Float[Array, "n_raw"] Raw crust data arrays min_density, max_density : float, optional Density range bounds filter_zero_pressure : bool Whether to filter zero/negative pressures Returns ------- tuple[Float[Array, "n_filtered"], Float[Array, "n_filtered"], Float[Array, "n_filtered"]] Filtered (n, p, e) arrays """ # Start with all points mask = jnp.ones(len(n), dtype=bool) # Apply zero-pressure filter if filter_zero_pressure: mask = mask & (p > 0) # Apply density range filters if min_density is not None: mask = mask & (n >= min_density) if max_density is not None: mask = mask & (n <= max_density) # Apply mask n_filtered = n[mask] p_filtered = p[mask] e_filtered = e[mask] # Check if we masked out all points, which might happen if we mess up units if n_filtered.size == 0: raise ValueError( f"No crust points remain after filtering. Please check density units and range of the crust file: {self._file_path}" ) # Check monotonicity if not jnp.all(jnp.diff(n_filtered) > 0): raise ValueError( "Crust density is not monotonically increasing after filtering" ) return n_filtered, p_filtered, e_filtered