Source code for brutus.core.grid_generation

#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
Grid generation utilities for pre-computed stellar model grids.

This module provides functionality to generate the HDF5 model grid files
used by StarGrid for fast photometric predictions. Grids store pre-computed
photometry at a reference distance (1 kpc) along with polynomial coefficients
for reddening corrections as a function of A_V and R_V.

The grid generation process:
1. Creates a 5D grid in (mini, eep, feh, afe, smf) parameter space
2. Computes base photometry at A_V=0, R_V=3.3, distance=1 kpc
3. Fits polynomial coefficients for reddening dependence
4. Saves in HDF5 format compatible with StarGrid and load_models()

Classes
-------
GridGenerator : Main grid generation class
    Generates pre-computed model grids from evolutionary tracks

Functions
---------
None (all functionality in GridGenerator class)

Examples
--------
Generate a grid from evolutionary tracks:

>>> from brutus.core.individual import EEPTracks
>>> from brutus.core.grid_generation import GridGenerator
>>>
>>> # Initialize tracks and generator
>>> tracks = EEPTracks()
>>> generator = GridGenerator(tracks, filters=['g', 'r', 'i', 'z'])
>>>
>>> # Generate grid with default spacing
>>> generator.make_grid(output_file='my_grid.h5')

Generate a custom sparse grid:

>>> import numpy as np
>>> # Define custom grids
>>> mini_grid = np.arange(0.7, 1.5, 0.1)
>>> eep_grid = np.arange(300, 500, 10)
>>> feh_grid = np.array([-0.5, 0.0, 0.5])
>>>
>>> generator.make_grid(
...     mini_grid=mini_grid,
...     eep_grid=eep_grid,
...     feh_grid=feh_grid,
...     output_file='custom_grid.h5',
...     verbose=True
... )

Notes
-----
**Grid Reference Distance**: All grids are generated at 1 kpc (1000 pc)
reference distance. This choice provides consistency with Gaia parallax
measurements (1 mas = 1 kpc) and is documented in the grid attributes.

**Reddening Parameterization**: Photometry is parameterized as:
    m(A_V, R_V) = m_0 + A_V · (a + b · (R_V - 3.3))

where m_0 is the unreddened magnitude, and (a, b) are fitted coefficients.
This allows fast computation of reddened photometry for arbitrary (A_V, R_V).

**Grid Format**: HDF5 files follow the format established in Speagle et al. (2025)
and contain three datasets:
- `mag_coeffs`: Structured array (Nmodel, Nfilter, 3) with coefficients
- `labels`: Structured array (Nmodel,) with input parameters
- `parameters`: Structured array (Nmodel,) with predicted stellar parameters

**Memory and Performance**: Large grids can require substantial memory
and computation time. A full grid with default spacing contains ~300k models
and takes ~2-4 hours to generate. Use sparse grids for testing or when
covering a limited parameter space.

References
----------
- Grid format follows the structure established in Speagle et al. (2025),
  optimized for StarGrid interpolation performance.
"""

import sys
import time
from datetime import datetime
from itertools import product
from pathlib import Path

import h5py
import numpy as np

# Import brutus components
from ..data.filters import FILTERS
from .individual import StarEvolTrack

__all__ = ["GridGenerator"]


[docs] class GridGenerator: """ Generate pre-computed stellar model grids with reddening coefficients. This class creates HDF5 grid files used by StarGrid for fast photometric predictions. Grids are computed at 1 kpc reference distance and include polynomial coefficients for A_V and R_V reddening corrections. The generator uses evolutionary tracks (EEPTracks or Isochrone) combined with neural network bolometric corrections to compute photometry across a multi-dimensional parameter grid, then fits reddening vectors to enable fast interpolation. Parameters ---------- tracks : EEPTracks or Isochrone Stellar evolutionary track model providing parameter predictions. Must have compatible interface (get_predictions method). filters : list of str, optional Names of photometric filters for which to generate models. If None, uses all available filters from FILTERS. nnfile : str or Path, optional Path to neural network file for bolometric corrections. If None, uses default neural network file. verbose : bool, optional Whether to print progress messages during initialization. Default is True. Attributes ---------- tracks : EEPTracks or Isochrone Evolutionary track model star_track : StarEvolTrack SED generator combining tracks with neural networks filters : numpy.ndarray Array of filter names predictor : FastNNPredictor Neural network predictor for photometry Examples -------- Create grid generator and generate default grid: >>> from brutus.core.individual import EEPTracks >>> from brutus.core.grid_generation import GridGenerator >>> >>> tracks = EEPTracks(verbose=False) >>> gen = GridGenerator(tracks) >>> gen.make_grid(output_file='grid.h5', verbose=True) Generate grid for specific filters only: >>> gen_gaia = GridGenerator(tracks, filters=['Gaia_G_MAW', 'Gaia_BP_MAWf', 'Gaia_RP_MAW']) >>> gen_gaia.make_grid(output_file='grid_gaia.h5') Notes ----- The GridGenerator uses dependency injection rather than inheritance, accepting an EEPTracks (or Isochrone) instance. This design: - Maintains consistency with StarEvolTrack architecture - Allows flexibility in track implementations - Enables easier testing with mock tracks - Separates grid generation from track interpolation logic See Also -------- brutus.core.individual.StarGrid : Uses grids generated by this class brutus.data.loader.load_models : Loads grids generated by this class """
[docs] def __init__(self, tracks, filters=None, nnfile=None, verbose=True): """Initialize grid generator with evolutionary tracks.""" # Store tracks self.tracks = tracks # Initialize filters if filters is None: filters = FILTERS self.filters = np.array(filters) if verbose: sys.stderr.write(f"Grid filters: {list(self.filters)}\n") # Create StarEvolTrack for SED generation self.star_track = StarEvolTrack( tracks=tracks, filters=filters, nnfile=nnfile, verbose=verbose ) # Store neural network predictor self.predictor = self.star_track.predictor
[docs] def make_grid( self, mini_grid=None, eep_grid=None, feh_grid=None, afe_grid=None, smf_grid=None, av_grid=None, av_wt=None, rv_grid=None, rv_wt=None, dist=1000.0, loga_max=10.14, eep_binary_max=480.0, mini_bound=0.5, apply_corr=True, corr_params=None, output_file=None, verbose=True, ): """ Generate model grid with reddening coefficients. Creates a grid of stellar models across the specified parameter space, computing photometry at 1 kpc reference distance and fitting polynomial coefficients for reddening corrections. Results are saved to HDF5 format compatible with StarGrid. Parameters ---------- mini_grid : numpy.ndarray, optional Grid of initial masses in solar masses. If None, defaults to np.arange(0.5, 2.0, 0.025) covering low to intermediate mass stars. eep_grid : numpy.ndarray, optional Grid of equivalent evolutionary points. If None, defaults to adaptive grid: resolution 6 from 202-454 (MS), resolution 2 from 454-808 (post-MS). feh_grid : numpy.ndarray, optional Grid of [Fe/H] metallicity values. If None, defaults to adaptive grid: resolution 0.1 from -3.0 to -2.0, resolution 0.05 from -2.0 to +0.5. afe_grid : numpy.ndarray, optional Grid of [α/Fe] alpha enhancement values. If None, defaults to np.arange(-0.2, 0.6, 0.2). smf_grid : numpy.ndarray, optional Grid of secondary mass fractions for binaries. If None, defaults to [0.] (single stars only). av_grid : numpy.ndarray, optional Grid of A_V extinction values used for fitting reddening vector. If None, defaults to np.arange(0., 1.5, 0.3). av_wt : numpy.ndarray, optional Weights for A_V grid points when fitting. If None, defaults to (1e-5 + av_grid)**-1 which forces fit through A_V=0. rv_grid : numpy.ndarray, optional Grid of R_V values used for fitting differential reddening. If None, defaults to np.arange(2.4, 4.2, 0.3). rv_wt : numpy.ndarray, optional Weights for R_V grid points when fitting. If None, defaults to exp(-abs(R_V - 3.3) / 0.5) favoring R_V=3.3. dist : float, optional Reference distance in parsecs. Default is 1000 (1 kpc). **This should not be changed** as it affects StarGrid calibration. loga_max : float, optional Maximum log10(age) in years. Models older than this are masked. Default is 10.14 (13.8 Gyr). eep_binary_max : float, optional Maximum EEP for binary models. Above this, binaries are not computed (typically giant phase). Default is 480. mini_bound : float, optional Minimum initial mass threshold. Models below this are masked. Default is 0.5 solar masses. apply_corr : bool, optional Whether to apply empirical corrections to Teff and radius. Default is True. corr_params : tuple, optional Parameters for empirical corrections (dtdm, drdm, msto_smooth, feh_scale). If None, uses default values from tracks. output_file : str or Path, optional Path to output HDF5 file. If None, results are stored as attributes but not saved to disk. verbose : bool, optional Whether to print progress messages. Default is True. Returns ------- None Results are saved to output_file if provided, and stored as class attributes: grid_labels, grid_seds, grid_params, grid_sel Notes ----- **Grid Size**: Default parameters create ~300,000 models. Computation time scales linearly with grid size and quadratically with number of (av_grid × rv_grid) points for reddening fits. **Memory Usage**: Full grid with 20 filters requires ~2 GB for storage. Peak memory during generation can be 3-4× larger. **Reddening Fits**: For each valid model, the code: 1. Computes SEDs across (av_grid × rv_grid) combinations 2. Fits linear dependence on A_V at each R_V value 3. Fits linear dependence of A_V slope on R_V 4. Stores [m_0, a, b] where m = m_0 + A_V·(a + b·(R_V-3.3)) Examples -------- Generate default grid: >>> gen.make_grid(output_file='grid_default.h5') Generate sparse testing grid: >>> gen.make_grid( ... mini_grid=np.linspace(0.8, 1.2, 5), ... eep_grid=np.linspace(300, 450, 10), ... feh_grid=np.array([0.0]), ... afe_grid=np.array([0.0]), ... smf_grid=np.array([0.0]), ... output_file='grid_test.h5', ... verbose=True ... ) """ # Initialize parameter grids with defaults if mini_grid is None: mini_grid = np.arange(0.5, 2.0 + 1e-5, 0.025) if eep_grid is None: eep_grid = np.concatenate( [np.arange(202.0, 454.0, 6.0), np.arange(454.0, 808.0 + 1e-5, 2.0)] ) if feh_grid is None: feh_grid = np.concatenate( [np.arange(-3.0, -2.0, 0.1), np.arange(-2.0, 0.5 + 1e-5, 0.05)] ) if afe_grid is None: afe_grid = np.arange(-0.2, 0.6 + 1e-5, 0.2) if smf_grid is None: smf_grid = np.array([0.0]) # Initialize reddening grids and weights if av_grid is None: av_grid = np.arange(0.0, 1.5 + 1e-5, 0.3) av_grid[-1] -= 1e-5 # Ensure we don't exceed 1.5 if av_wt is None: # Inverse weighting favoring A_V=0 av_wt = (1e-5 + av_grid) ** -1.0 if rv_grid is None: rv_grid = np.arange(2.4, 4.2 + 1e-5, 0.3) if rv_wt is None: # Gaussian weighting favoring R_V=3.3 rv_wt = np.exp(-np.abs(rv_grid - 3.3) / 0.5) # Create grid labels label_names = ["mini", "eep", "feh", "afe", "smf"] ltype = np.dtype([(n, "f8") for n in label_names]) self.grid_labels = np.array( list(product(mini_grid, eep_grid, feh_grid, afe_grid, smf_grid)), dtype=ltype, ) Ngrid = len(self.grid_labels) if verbose: sys.stderr.write(f"\nGenerating grid with {Ngrid:,} models\n") sys.stderr.write( f" mini: {len(mini_grid)} points " f"[{mini_grid.min():.2f}, {mini_grid.max():.2f}]\n" ) sys.stderr.write( f" eep: {len(eep_grid)} points " f"[{eep_grid.min():.0f}, {eep_grid.max():.0f}]\n" ) sys.stderr.write( f" feh: {len(feh_grid)} points " f"[{feh_grid.min():.2f}, {feh_grid.max():.2f}]\n" ) sys.stderr.write( f" afe: {len(afe_grid)} points " f"[{afe_grid.min():.2f}, {afe_grid.max():.2f}]\n" ) sys.stderr.write( f" smf: {len(smf_grid)} points " f"[{smf_grid.min():.2f}, {smf_grid.max():.2f}]\n" ) sys.stderr.write( f"\nReddening grid: {len(av_grid)} A_V × " f"{len(rv_grid)} R_V = " f"{len(av_grid)*len(rv_grid)} evaluations per model\n\n" ) # Initialize storage arrays param_names = self.tracks.predictions ptype = np.dtype([(n, "f8") for n in param_names]) stype = np.dtype([(filt, "f4", 3) for filt in self.filters]) self.grid_seds = np.full(Ngrid, np.nan, dtype=stype) self.grid_params = np.full(Ngrid, np.nan, dtype=ptype) self.grid_sel = np.ones(Ngrid, dtype=bool) # Generate models percentage = -99 ttot, t1 = 0.0, time.time() for i, (mini, eep, feh, afe, smf) in enumerate(self.grid_labels): # Compute model and parameters at base reddening (sed, params, params2, eep2) = self.star_track.get_seds( mini=mini, eep=eep, feh=feh, afe=afe, smf=smf, av=0.0, rv=3.3, dist=dist, loga_max=loga_max, eep_binary_max=eep_binary_max, mini_bound=mini_bound, apply_corr=apply_corr, corr_params=corr_params, return_dict=False, return_eep2=True, ) # Save parameters for primary self.grid_params[i] = tuple(params) # Check if SED is valid if np.any(np.isnan(sed)) or np.any(np.isnan(params)): # Flag as invalid and fill with NaNs self.grid_sel[i] = False self.grid_seds[i] = tuple(np.full((len(self.filters), 3), np.nan)) else: # Fit reddening coefficients coeffs = self._fit_reddening_coefficients( mini=mini, eep=eep, feh=feh, afe=afe, smf=smf, eep2=eep2, sed_base=sed, av_grid=av_grid, av_wt=av_wt, rv_grid=rv_grid, rv_wt=rv_wt, dist=dist, loga_max=loga_max, eep_binary_max=eep_binary_max, mini_bound=mini_bound, apply_corr=apply_corr, corr_params=corr_params, ) self.grid_seds[i] = tuple(coeffs) # Update timing t2 = time.time() dt = t2 - t1 ttot += dt tavg = ttot / (i + 1) test = tavg * (Ngrid - i - 1) t1 = t2 # Print progress new_percentage = int((i + 1) / Ngrid * 1e5) if verbose and new_percentage != percentage: percentage = new_percentage sys.stderr.write( f"\rConstructing grid {percentage/1e3:6.3f}% " f"({i+1:,}/{Ngrid:,}) " f"[mini={mini:6.3f}, eep={eep:6.1f}, feh={feh:6.2f}, " f"afe={afe:5.2f}, smf={smf:4.2f}] " f"(t/obj: {tavg*1e3:5.2f} ms, " f"est. remaining: {test:8.1f} s) " ) sys.stderr.flush() if verbose: sys.stderr.write("\n\n") valid_models = self.grid_sel.sum() sys.stderr.write( f"Grid generation complete: " f"{valid_models:,}/{Ngrid:,} valid models " f"({100*valid_models/Ngrid:.1f}%)\n" ) # Save to file if requested if output_file is not None: self._save_grid(output_file, dist, verbose=verbose)
def _fit_reddening_coefficients( self, mini, eep, feh, afe, smf, eep2, sed_base, av_grid, av_wt, rv_grid, rv_wt, dist, loga_max, eep_binary_max, mini_bound, apply_corr, corr_params, ): """ Fit polynomial coefficients for reddening dependence. Computes SEDs across a grid of (A_V, R_V) values and fits a bilinear model: m(A_V, R_V) = m_0 + A_V · (a + b · (R_V - 3.3)) Parameters ---------- [parameters same as make_grid where applicable] sed_base : numpy.ndarray Base SED at A_V=0, R_V=3.3 eep2 : float EEP of secondary component for binary systems Returns ------- coeffs : numpy.ndarray of shape (Nfilters, 3) Coefficients [m_0, a, b] for each filter """ # Compute SEDs across reddening grid seds = np.array( [ [ self.star_track.get_seds( mini=mini, eep=eep, feh=feh, afe=afe, smf=smf, eep2=eep2, av=av, rv=rv, dist=dist, loga_max=loga_max, eep_binary_max=eep_binary_max, mini_bound=mini_bound, apply_corr=apply_corr, corr_params=corr_params, return_dict=False, )[0] for av in av_grid ] for rv in rv_grid ] ) # Shape: (Nrv, Nav, Nfilt) # Fit A_V dependence at each R_V # For each (rv, filter): fit m vs A_V sfits = np.array( [np.polyfit(av_grid, s, 1, w=av_wt).T for s in seds] ) # Shape: (Nrv, Nfilt, 2) where [..., 0] = slope, [..., 1] = intercept # Fit R_V dependence of the A_V slope # For each filter: fit A_V_slope vs R_V sedr, seda = np.polyfit( rv_grid, sfits[:, :, 0], 1, w=rv_wt ) # sedr: Rv dependence, seda: Av vector at Rv=3.3 # Combine: [base_magnitude, av_vector, rv_vector] coeffs = np.c_[sed_base, seda, sedr] return coeffs def _save_grid(self, output_file, dist, verbose=True): """ Save grid to HDF5 file in StarGrid-compatible format. Parameters ---------- output_file : str or Path Path to output HDF5 file dist : float Reference distance in parsecs verbose : bool, optional Whether to print save confirmation """ output_path = Path(output_file) if verbose: sys.stderr.write(f"Saving grid to {output_path}...\n") with h5py.File(output_path, "w") as f: # Save magnitude coefficients f.create_dataset( "mag_coeffs", data=self.grid_seds, compression="gzip", compression_opts=4, ) # Save labels (inputs) f.create_dataset( "labels", data=self.grid_labels, compression="gzip", compression_opts=4 ) # Save parameters (predictions) f.create_dataset( "parameters", data=self.grid_params, compression="gzip", compression_opts=4, ) # Save metadata f.attrs["reference_distance_pc"] = dist f.attrs["reference_distance_note"] = ( "All magnitudes computed at this distance (default 1 kpc = 1000 pc)" ) try: import brutus f.attrs["brutus_version"] = brutus.__version__ except (ImportError, AttributeError): f.attrs["brutus_version"] = "unknown" f.attrs["creation_date"] = datetime.now().isoformat() f.attrs["n_models_total"] = len(self.grid_labels) f.attrs["n_models_valid"] = self.grid_sel.sum() # Convert filters to ASCII strings for HDF5 compatibility f.attrs["filters"] = [filt.encode("ascii") for filt in self.filters] if verbose: file_size_mb = output_path.stat().st_size / 1024**2 sys.stderr.write(f"Grid saved successfully " f"({file_size_mb:.1f} MB)\n")