Source code for pyEDITH.utils

from scipy.interpolate import interp1d
import numpy as np
import astropy.units as u
from typing import Dict, Any
import logging
from .units import *

logger = logging.getLogger("pyEDITH")


[docs] def average_over_bandpass(params: dict, wavelength_range: list) -> dict: """ Calculate the average of array parameters within a specified wavelength range. This function takes a dictionary of parameters and computes the mean value of all numpy array parameters (except wavelength) within the specified wavelength boundaries. The wavelength array is expected to be stored under the key "lam" in the params dictionary. Out-of-domain behaviour ----------------------- Some curves (e.g. ``qe_vis`` / ``qe_nir``) are only physically defined over part of the wavelength axis. When a requested ``wavelength_range`` does not straddle any tabulated point for a given curve, that curve is interpolated at the *center* of ``wavelength_range``. If that center lies outside the curve's native domain the interpolation deliberately returns ``NaN`` (via ``bounds_error=False, fill_value=np.nan``) rather than raising, so the NaN can be used downstream to mark the region where a curve does not apply (this is how the vis/nir split is reconstructed). Parameters ---------- params : dict Dictionary containing parameters where numpy arrays represent wavelength-dependent quantities. Must include a "lam" key containing the wavelength array. These parameters come from EACy and follow that formatting. wavelength_range : list Two-element list containing the lower and upper wavelength boundaries for averaging, expected to have astropy units Returns ------- dict Modified parameters dictionary with array values replaced by their mean values within the specified wavelength range """ # take the average within the specified wavelength range numpy_array_variables = { key: value for key, value in params.items() if isinstance(value, np.ndarray) } lam_values = params["lam"].value mask = (lam_values >= wavelength_range[0].value) & ( lam_values <= wavelength_range[1].value ) # Center of the requested bandpass, used only for the empty-slice fallback. center_wavelength = 0.5 * (wavelength_range[0].value + wavelength_range[1].value) for key, value in numpy_array_variables.items(): if key != "lam": if mask.any(): params[key] = np.mean(params[key][mask]) else: # No tabulated sample points fall inside the requested # bandpass; interpolate the curve at the band center instead. # Points outside the curve's native domain return NaN (rather # than raising) so the NaN can flag where the curve does not # apply -- this is what lets the vis/nir curves be stitched # back together element-wise downstream. interp_func = interp1d( params["lam"], params[key], bounds_error=False, fill_value=np.nan, ) params[key] = interp_func(center_wavelength) return params
[docs] def interpolate_over_bandpass(params: dict, wavelengths: list) -> dict: """ Interpolate array parameters onto a new wavelength grid. This function takes a dictionary of parameters and interpolates all numpy array parameters (except the wavelength array itself) onto a new set of wavelength points using 1D linear interpolation. The original wavelength array is expected to be stored under the key "lam" in the params dictionary. Out-of-domain behaviour ----------------------- Interpolation points that fall outside a curve's native wavelength domain return ``NaN`` (via ``bounds_error=False, fill_value=np.nan``) rather than raising. This is intentional: curves such as ``qe_vis`` / ``qe_nir`` are only defined over part of the spectrum, and the resulting NaNs mark the region where each curve does not apply so the vis/nir arrays can be stitched together element-wise (e.g. ``qe_vis = [finite, finite, nan]`` and ``qe_nir = [nan, nan, finite]``). Parameters ---------- params : dict Dictionary containing parameters where numpy arrays represent wavelength-dependent quantities. Must include a "lam" key containing the original wavelength array. These parameters come from EACy and follow that formatting. wavelengths : list New wavelength points onto which to interpolate the parameter arrays Returns ------- dict Modified parameters dictionary with array values interpolated onto the new wavelength grid specified by the wavelengths parameter """ # take the average within the specified wavelength range numpy_array_variables = { key: value for key, value in params.items() if isinstance(value, np.ndarray) } for key, value in numpy_array_variables.items(): if key != "lam": interp_func = interp1d( params["lam"], params[key], bounds_error=False, fill_value=np.nan, ) ynew = interp_func( wavelengths ) # interpolates the CG throughput values onto native wl grid params[key] = ynew return params
[docs] def fill_parameters( class_obj: object, parameters: dict, default_parameters: dict, locked_keys: set = None, allow_override: set = None, ) -> None: """ Populate class object attributes with user parameters or default values. Parameters ---------- class_obj : object Class instance whose attributes will be set. parameters : dict Dictionary of user-provided parameter values. default_parameters : dict Dictionary of default (or, for YIP/EAC mode, model-loaded) parameter values. locked_keys : set, optional Keys that must NOT be overridden by the user by default. For these keys the value staged in ``default_parameters`` is always used (e.g. values loaded from a YIP or EAC YAML), regardless of what the user supplied, UNLESS the key is also present in ``allow_override``. allow_override : set, optional Keys that are normally locked but that the user has explicitly and intentionally requested to override (e.g. via parameters["overrides"]). Has no effect on keys that are not in ``locked_keys`` -- those are already user-editable. Any name in ``allow_override`` that does not correspond to a key in ``default_parameters`` raises a ValueError, to catch typos rather than silently ignoring them. """ if locked_keys is None: locked_keys = set() if allow_override is None: allow_override = set() unknown_overrides = allow_override - set(default_parameters.keys()) if unknown_overrides: raise ValueError( f"'overrides' contains unrecognized parameter name(s): " f"{unknown_overrides}" ) def _coerce(user_value, default_value): """Match user_value's type/units to default_value's, where applicable.""" if isinstance(default_value, u.Quantity): if isinstance(user_value, u.Quantity): return user_value.to(default_value.unit) return u.Quantity(user_value, default_value.unit) return user_value for key, default_value in default_parameters.items(): is_locked = key in locked_keys is_overridden = key in allow_override if key in parameters and is_locked and not is_overridden: # User tried to set a value that is owned by the model (e.g. YIP/EAC) # and did not explicitly request an override. logger.warning( f"Parameter '{key}' is locked in this mode and " f"cannot be user-overridden; using the model-provided value " f"instead of the supplied value {parameters[key]!r}." ) setattr(class_obj, key, default_value) elif key in parameters and is_locked and is_overridden: # User explicitly requested to override a normally-locked value. final_value = _coerce(parameters[key], default_value) setattr(class_obj, key, final_value) logger.warning( f"Parameter '{key}' is normally locked in this mode, but was " f"explicitly overridden per user request (via 'overrides'). " f"Model-provided value was {default_value!r}; using " f"user-supplied value: {final_value!r}." ) elif key in parameters and not is_locked: # User provided a value and it is allowed to be overridden. final_value = _coerce(parameters[key], default_value) setattr(class_obj, key, final_value) logger.debug(f"Parameter '{key}' set to user-provided value: {final_value}") else: # Use default / model-provided value (also the path for locked # keys the user didn't touch at all). setattr(class_obj, key, default_value) logger.debug(f"Parameter '{key}' set to default value: {default_value}")
[docs] def convert_to_numpy_array(class_obj: object, array_params: list) -> None: """ Convert specified class attributes to numpy arrays with proper dtype. This function converts class attributes to numpy arrays with float64 dtype, while preserving astropy units for Quantity objects. Non-Quantity attributes are converted to plain numpy arrays, while Quantity attributes maintain their units but have their values converted to numpy arrays. Parameters ---------- class_obj : object Class instance whose attributes will be converted array_params : list List of attribute names to convert to numpy arrays """ for param in array_params: attr_value = getattr(class_obj, param) if isinstance(attr_value, u.Quantity): # If it's already a Quantity, convert to numpy array while preserving units setattr( class_obj, param, u.Quantity( np.array(attr_value.value, dtype=np.float64), attr_value.unit ), ) else: # If it's not a Quantity, convert to numpy array without units setattr(class_obj, param, np.array(attr_value, dtype=np.float64))
[docs] def validate_attributes(obj: Any, expected_args: Dict[str, Any]) -> None: """ Validate attributes of an object against expected types and units. This function checks that an object has all the required attributes and that each attribute has the correct type or units. It supports validation of integer and float types as well as astropy Quantity objects with specific units. Parameters ---------- obj : object The object whose attributes are to be validated expected_args : dict A dictionary where keys are attribute names and values are expected types or units Raises ------ AttributeError If a required attribute is missing TypeError If an attribute has an incorrect type ValueError If a Quantity attribute has incorrect units or if there's an unexpected type specification """ class_name = obj.__class__.__name__ for arg, expected_type in expected_args.items(): if not hasattr(obj, arg): raise AttributeError(f"{class_name} is missing attribute: {arg}") value = getattr(obj, arg) if expected_type is int: if not isinstance(value, (int, np.integer)): raise TypeError(f"{class_name} attribute {arg} should be an integer") elif expected_type is float: if not isinstance(value, (float, np.floating)): raise TypeError(f"{class_name} attribute {arg} should be a float") elif isinstance( expected_type, (u.UnitBase, u.CompositeUnit, u.IrreducibleUnit) ): if not isinstance(value, u.Quantity): raise TypeError(f"{class_name} attribute {arg} should be a Quantity") if value.unit != expected_type: raise ValueError( f"{class_name} attribute {arg} has incorrect units. " f"Expected {expected_type}, got {value.unit}" ) else: raise ValueError(f"Unexpected type specification for {arg}")
[docs] def synthesize_observation( snr_arr: np.ndarray, scene: object, random_seed: int = None, set_below_zero: float = np.nan, ) -> tuple: """ Synthesize an observation using calculated SNRs for each wavelength bin. This function generates a synthetic observation by adding noise to the planet-to-star flux ratio based on the provided signal-to-noise ratio array. The noise is drawn from a normal distribution and scaled according to the SNR values. This function requires that the ETC has been run in SNR mode with a given exposure time first. Parameters ---------- snr_arr : np.ndarray 1D array containing SNR for each spectral bin scene : AstrophysicalScene Scene object containing astrophysical parameters including Fp_over_Fs random_seed : int, optional Random seed for reproducible noise generation. Default is None set_below_zero : float, optional Value to assign to measurements below zero. Default is np.nan Returns ------- tuple A tuple containing: obs : np.ndarray 1D array, spectrum with added noise noise : np.ndarray 1D array, noise for each spectral bin """ # set a random seed if desired if random_seed is not None: np.random.seed(random_seed) noise = scene.Fp_over_Fs / snr_arr obs = scene.Fp_over_Fs + noise * np.random.randn(len(noise)) obs[obs < 0] = ( set_below_zero # any observation that is below zero is set to whatever you want ) return obs, noise
[docs] def wavelength_grid_fixed_res(x_min: float, x_max: float, res: float = -1) -> tuple: """ LEGACY Generate a wavelength grid at a fixed spectral resolution. This function creates a wavelength grid with constant resolution across the specified wavelength range. The grid spacing increases logarithmically to maintain constant R = λ/Δλ. Parameters ---------- x_min : float Minimum wavelength x_max : float Maximum wavelength res : float, optional Spectral resolution R = λ/Δλ. Default is -1 Returns ------- tuple A tuple containing two 1D numpy arrays: wavelength : np.ndarray Wavelength grid delta_wavelength : np.ndarray Delta wavelength grid """ x = [x_min] fac = (1 + 2 * res) / (2 * res - 1) i = 0 while x[i] * fac < x_max: x = np.concatenate((x, [x[i] * fac])) i = i + 1 Dx = x / res return np.squeeze(x), np.squeeze(Dx)
[docs] def gen_wavelength_grid(x_min: list, x_max: list, res: list) -> tuple: """ LEGACY Generate a continuous wavelength grid for multiple spectral channels. This function creates wavelength grids at fixed resolution for each spectral channel, then concatenates them to form a continuous wavelength grid covering all channels. Parameters ---------- x_min : list Minimum wavelength for each spectral channel x_max : list Maximum wavelength for each spectral channel res : list Spectral resolution for each spectral channel Returns ------- tuple A tuple containing two 1D numpy arrays: wavelength_grid : np.ndarray Combined wavelength grid for all channels delta_wavelength_grid : np.ndarray Combined delta wavelength grid for all channels """ x, Dx = wavelength_grid_fixed_res(x_min[0], x_max[0], res=res[0]) if len(x_min) > 1: for i in range(1, len(x_min)): xi, Dxi = wavelength_grid_fixed_res(x_min[i], x_max[i], res=res[i]) x = np.concatenate((x, xi)) Dx = np.concatenate((Dx, Dxi)) Dx = [Dxs for _, Dxs in sorted(zip(x, Dx))] x = np.sort(x) return np.squeeze(x), np.squeeze(Dx)
[docs] def regrid_wavelengths( input_wls: np.ndarray, res: list, lam_low: list = None, lam_high: list = None ) -> tuple: """ LEGACY Create a new wavelength grid with specified resolution and channel boundaries. This function generates a new wavelength grid given the resolution and channel boundaries for each spectral channel. If no boundaries are provided, it uses the full range of the input wavelengths. Parameters ---------- input_wls : np.ndarray The wavelength grid supplied by the user res : list Array of desired resolutions for each channel. Should have length equal to the number of spectral channels. For example, for UV, VIS, and NIR channels: res = [R_UV, R_VIS, R_NIR], e.g. [7, 140, 40] lam_low : list, optional Array of the lower boundaries of spectral channels lam_high : list, optional Array of the upper boundaries of spectral channels Returns ------- tuple A tuple containing two 1D numpy arrays: wavelength_grid : np.ndarray New wavelength grid delta_wavelength_grid : np.ndarray New delta wavelength grid """ if lam_low is None and lam_high is None: lam_low = [np.min(input_wls[1:])] lam_high = [np.max(input_wls[:-1])] else: # lam_low is not None and lam_high is not None: assert len(res) == len(lam_low) == len(lam_high) if len(res) > 1: assert ( np.min(input_wls) < lam_low[0] ), "Your minimum input wavelength is greater than first channel lower boundary." assert ( np.max(input_wls) > lam_high[-1] ), f"Your maximum input wavelength is less than last channel upper boundary." lam, dlam = gen_wavelength_grid(lam_low, lam_high, res) else: # no channel boundaries lam, dlam = gen_wavelength_grid(lam_low, lam_high, res) return lam, dlam
[docs] def regrid_spec_gaussconv( input_wls: np.ndarray, input_spec: np.ndarray, new_lam: np.ndarray, new_dlam: np.ndarray, ) -> np.ndarray: """ Regrid a spectrum onto a new wavelength grid using Gaussian convolution. This function regrids a spectrum by convolving with Gaussian kernels to account for the spectral resolution at each wavelength point. The convolution is performed in log-wavelength space for accurate spectral line handling. Parameters ---------- input_wls : np.ndarray The wavelength grid supplied by the user input_spec : np.ndarray The spectrum supplied by the user new_lam : np.ndarray The new wavelength grid calculated for the ETC new_dlam : np.ndarray The new delta wavelength grid calculated for the ETC Returns ------- np.ndarray 1D array containing the regridded spectrum with original units preserved """ R_arr = new_lam / new_dlam # interpolate original spectrum onto a fine log-lambda grid loglam_old = np.log(input_wls) interp_flux = interp1d(loglam_old, input_spec, bounds_error=False, fill_value=0.0) # make fine log-lambda grid dloglam = 1e-5 loglam_grid = np.arange(loglam_old[0], loglam_old[-1], dloglam) lam_grid = np.exp(loglam_grid) flux_grid = interp_flux(loglam_grid) spec_regrid = np.zeros_like(new_lam) for i in range(len(new_lam)): lam = new_lam[i] R = R_arr[i] # get width of gaussian: sigma = FWHM / (2*np.sqrt(2*np.log(2))), where FWHM is dlam, but this is in logspace, so FWHM = 1/R sigma_loglam = 1.0 / (R * 2.0 * np.sqrt(2 * np.log(2))) # Gaussian kernel in log-space kernel_half_width = int(4 * sigma_loglam / dloglam) kernel_grid = np.arange(-kernel_half_width, kernel_half_width + 1) kernel = np.exp(-0.5 * (kernel_grid * dloglam / sigma_loglam) ** 2) kernel /= np.sum(kernel) # Find center index center_idx = np.searchsorted(lam_grid, lam) # Define convolution range safely i1 = max(center_idx - kernel_half_width, 0) i2 = min(center_idx + kernel_half_width + 1, len(flux_grid)) k1 = kernel_half_width - (center_idx - i1) k2 = kernel_half_width + (i2 - center_idx) # Perform local convolution flux_segment = flux_grid[i1:i2] kernel_segment = kernel[k1:k2] spec_regrid[i] = np.sum(flux_segment * kernel_segment) return spec_regrid
[docs] def regrid_spec_interp( input_wls: np.ndarray, input_spec: np.ndarray, new_lam: np.ndarray ) -> np.ndarray: """ (Legacy) Regrid a spectrum onto a new wavelength grid using 1D interpolation. This function regrids a spectrum using simple linear interpolation between the original and new wavelength grids. This method is faster than Gaussian convolution but does not account for spectral resolution effects. Parameters ---------- input_wls : np.ndarray The wavelength grid supplied by the user input_spec : np.ndarray The spectrum supplied by the user new_lam : np.ndarray The new wavelength grid calculated for the ETC Returns ------- np.ndarray 1D array containing the regridded spectrum with original units preserved """ interp_func = interp1d(input_wls, input_spec) spec_regrid = interp_func(new_lam) return spec_regrid
[docs] def regrid_to_grid( values, from_wavelength, to_wavelength, to_delta_wavelength=None, *, # Forces all subsequent parameters to be keyword-only (must be called as name="value") name: str = "parameter", interpolation: str = "1d", # 1d or Gaussian ): """ Fit an already-shaped array onto a resolved wavelength grid. The rule is centralized here so no consumer re-implements it: * length 1 -> broadcast the single value to the grid * length == len(to_wavelength) -> already on the grid, pass through * any other length -> regrid onto the grid Note that any length > 1 that is neither 1 nor a mismatch requiring regrid for *user-supplied* params has already been vetted by ``parse_parameters``; this helper additionally covers *defaults* or values that never passed through it. Parameters ---------- values : array-like The values to fit onto the grid (a plain array or Quantity value). from_wavelength : array-like The wavelength grid ``values`` currently live on. For consumers this should be ``parsed_params["input_wavelength"]`` (the pre-regrid grid). to_wavelength : array-like The target (resolved) wavelength grid, i.e. ``observation.wavelength``. to_delta_wavelength : array-like, optional Bin widths of the target grid, required only when a regrid is needed. name : str, optional Name used in log messages. Returns ------- np.ndarray A float64 array of length ``len(to_wavelength)``. """ if isinstance(values, u.Quantity): unit = values.unit values_plain = np.atleast_1d(np.asarray(values.value, dtype=np.float64)) else: unit = None values_plain = np.atleast_1d(np.asarray(values, dtype=np.float64)) to_wavelength = np.asarray(to_wavelength, dtype=np.float64) n_target = len(to_wavelength) # length 1 -> broadcast if len(values_plain) == 1 and n_target > 1: result = values_plain[0] * np.ones(n_target, dtype=np.float64) # already on the grid -> pass through elif len(values_plain) == n_target: result = values_plain # otherwise -> regrid else: logger.info( f"'{name}' has length {len(values_plain)} but the resolved wavelength grid " f"has length {n_target}. Rebinning..." ) if interpolation == "1d": result = regrid_spec_interp( np.asarray(from_wavelength, dtype=np.float64), values_plain, to_wavelength, ) elif interpolation == "Gaussian": if to_delta_wavelength is None: raise ValueError( f"'{name}' must be regridded onto the resolved grid, but " f"'to_delta_wavelength' was not provided." ) result = regrid_spec_gaussconv( np.asarray(from_wavelength, dtype=np.float64), values_plain, to_wavelength, to_delta_wavelength, ) else: raise ValueError( "Unknown interpolation type. Possible values are '1d' for 1D " "interpolation and 'Gaussian' for Gaussian kernel interpolation " "(recommended for spectral quantities)." ) # ------------------------------------------------------------------ # Unit reattachment: return a Quantity if the input was one. # ------------------------------------------------------------------ if unit is not None: return result * unit return result