Source code for lime.workflow

import logging

import numpy as np
import pandas as pd

from time import time
from pathlib import Path
from astropy.io import fits
from lmfit.models import PolynomialModel
from inspect import signature

from matplotlib import pyplot as plt

import lime
from lime.tools import ProgressBar, join_fits_files, extract_wcs_header, pd_get, au
from lime.rsrc_manager import lineDB
from lime.fitting.lines import LineFitting, signal_to_noise_rola, sigma_corrections, k_gFWHM, velocity_to_wavelength_band, profiles_computation, linear_continuum_computation
from lime.transitions import Line, lines_frame, _REDSHIFT_DICT, multi_origin_lines_frame
from lime.retrieve.line_bands import determine_line_groups, groupify_lines_df
from lime.io import check_file_dataframe, check_file_array_mask, log_to_HDU, results_to_log, load_frame, LiMe_Error, check_fit_conf, lime_cfg
from lime.fitting.redshift import RedshiftFitting
from lime.plotting.plots import spec_continuum_calculation
from scipy import stats

try:
    import aspect
    aspect_check = True
except ImportError:
    aspect_check = False


_logger = logging.getLogger('LiMe')


def review_bands(spec, line, min_line_pixels=3, min_cont_pixels=2, user_cont_source='central', user_err_from_bands=False):

    # Check if the line bands are provided
    if line.mask is None:
        _logger.warning(f"Line {line} was not found on the input bands database. It won't be measured")
        return None

    # Check if the line is within the w3, w4 limits
    limit_blue, limit_red = spec.wave.compressed()[0], spec.wave.compressed()[-1]
    if ((line.mask[2] * (1 + spec.redshift)) < limit_blue) or ((line.mask[3] * (1 + spec.redshift)) > limit_red):
        _logger.warning(f"Line {line} bands are outside spectrum wavelengh range: w3 < w_min_rest ({line.mask[2]} < {limit_blue}) or"
                                                                               f" w4 > w_max_rest ({line.mask[3]} > {limit_red})"
                                                                               f" it won't be measured")
        return None

    # Check the units are the same
    if line.units_wave != spec.units_wave:
        _logger.warning(f"The input {line} line units ({line.units_wave}) are different from the input spectrum units ({spec.units_wave})")

    # Check if the spectrum does not have the error arr but the user has requested it
    if spec.err_flux is None and user_err_from_bands is False:
        _logger.warning(f'The observation does not have an error spectrum but the fit command has requested not to use '
                        f'the adjacent bands to compute the uncertainty. Please set the "user_err_from_bands=True" to perform'
                        f' a measurement.')
        return None

    # Compute the line and adjacent continua indeces:
    idcsEmis, idcsCont = line.index_bands(spec.wave, spec.redshift)

    if user_cont_source == 'fit':
        if spec.cont is None:
            raise LiMe_Error(f"The continuum has not been fit. Please run 'Spectrum.fit.continuum' before measuring lines"
                             f"or change the 'user_cont_source'.")
            idcsCont = idcsEmis

    # Logic for the bands source
    cont_from_bands = True if user_cont_source == 'adjacent' else False

    # Check if all the flux entries are masked
    emis_flux, cont_flux = spec.flux[idcsEmis], spec.flux[idcsCont]
    if np.all(emis_flux.mask):
        _logger.warning(f"Line {line} flux is fully masked. It won't be measured")
        return None
    if np.all(cont_flux.mask) and cont_from_bands:
        _logger.warning(f"Line {line} adjacent continua flux is fully masked. It won't be measured")
        return None

    # Check if all the flux entries are zero
    if not np.any(emis_flux):
        _logger.warning(f"Line {line} flux entries are all 0. It won't be measured")
        return None
    if not np.any(cont_flux) and cont_from_bands:
        _logger.warning(f"Line {line} continuum flux entries are all 0. It won't be measured")
        return None

    # Check if the line selection is too narrow
    if np.sum(~emis_flux.mask) < min_line_pixels:
        _logger.warning(f"Line {line} has only {np.sum(~emis_flux.mask)} pixels. It won't be measured")
        return None

    # Check if the continua selection is too narrow
    if (np.sum(~cont_flux.mask) < min_cont_pixels) and cont_from_bands:
        _logger.warning(f"Line {line} continuum bands have only {np.sum(~cont_flux.mask)} pixels. It won't be measured")
        return None

    return idcsEmis, idcsCont


def import_line_kinematics(line, z_cor, log, fit_conf):

    # Check if imported kinematics come from blended component
    for idx_child, child_label in enumerate(line.list_comps):

        # Check for kinem order
        parent_label = fit_conf.get(f'{child_label}_kinem')
        if (parent_label is not None) and line.group == 'b':

            # Tied kinematics in blended profile
            if parent_label in line.list_comps:
                idx_parent = line.list_comps.index(parent_label)
                factor = f'{line.list_comps[idx_child].wavelength / line.list_comps[idx_parent].wavelength:0.8f}'
                fit_conf[f'{child_label}_center'] = {'expr': f'{factor}*{parent_label}_center'}
                fit_conf[f'{child_label}_sigma'] = {'expr': f'{factor}*{parent_label}_sigma'}

            # Import kinematics from previously measured
            elif parent_label in log.index:
                mu_parent = log.loc[parent_label, ['center', 'center_err']].to_numpy()
                sigma_parent = log.loc[parent_label, ['sigma', 'sigma_err']].to_numpy()
                wave_ratio = line.list_comps[idx_child].wavelength/log.loc[parent_label, 'wavelength']

                center_child_arr = wave_ratio * (mu_parent / z_cor)
                sigma_child_arr = wave_ratio * sigma_parent

                # Store the value on the dictionary
                fit_conf[f'{child_label}_center'] = {'value': center_child_arr[0], 'vary': False}
                fit_conf[f'{child_label}_sigma'] = {'value': sigma_child_arr[0], 'vary': False}

                # Error for the propagation
                fit_conf[f'{child_label}_center_err'] = center_child_arr[1]
                fit_conf[f'{child_label}_sigma_err'] = sigma_child_arr[1]

            # Line has not been measured before found
            else:
                _logger.info(f'\n{parent_label} has not been found on the input lines frame for {child_label} kinematics export.'
                             f'\n - Please check you are using the right line label and that the line has been measured prior '
                             f'prior to the current fitting.')

    return


def check_cube_bands(input_bands, mask_list, fit_cfg):

    if input_bands is None:

        # Recover the mask_configuration as a list
        for mask_name in mask_list:

            mask_fit_cfg = fit_cfg.get(f'{mask_name}_line_fitting')

            missing_mask = False
            if mask_fit_cfg is not None:
                if mask_fit_cfg.get('bands') is None:
                    missing_mask = True
            else:
                missing_mask = True

            if missing_mask:
                error_message = 'No input "bands" provided. In this case you need to include the \n' \
                                f'you need to specify an "bands=log_file_address" entry the ' \
                                f'"[{mask_name}_file]" of your fitting configuration file'
                raise LiMe_Error(error_message)

    return


def recover_level_conf(fit_cfg, mask_key, default_key):

    default_cfg = fit_cfg.get(f'{default_key}_line_fitting') if default_key is not None else None
    mask_cfg = fit_cfg.get(f'{mask_key}_line_fitting') if mask_key is not None else None

    # Case there are not leveled entries
    if (default_cfg is None) and (mask_cfg is None):
        output_conf = fit_cfg

    # Proceed to update the levels
    else:

        # Default configuration
        default_conf = {} if default_cfg is None else default_cfg
        default_detect = default_conf.get('line_detection')

        # Mask conf
        mask_conf = {} if mask_cfg is None else mask_cfg
        mask_detect = mask_conf.get('line_detection')

        # Update the levels
        output_conf = {**default_conf, **mask_conf}

        # If no line detection don't add it
        if mask_detect is not None:
            output_conf['line_detection'] = mask_detect
        elif default_detect is not None:
            output_conf['line_detection'] = default_detect
        else:
            pass

    return output_conf


def check_compound_line_exclusion(line, lines_df):

    # Confirm the dataframe includes the group of lines
    group_label = pd_get(lines_df, line, 'group_label', transform='none', nan_to_none=True)

    # Confirm if the line is in the group of lines
    if group_label is not None:
        comp_list = group_label.split('+')
        measure_check = False if line in comp_list else True
    else:
        measure_check = True

    return measure_check


def continuum_model_fit(x_array, y_array, idcs, degree):

    poly3Mod = PolynomialModel(prefix=f'poly_{degree}', degree=degree)
    poly3Params = poly3Mod.guess(y_array[idcs], x=x_array[idcs])

    try:
        poly3Out = poly3Mod.fit(y_array[idcs], poly3Params, x=x_array[idcs])
        cont_fit = poly3Out.eval(x=x_array)

    except TypeError:
        _logger.warning(f'- The continuum fitting polynomial has more degrees ({degree}) than data points')
        cont_fit = np.full(x_array.size, np.nan)

    return cont_fit


def res_power_approx(wavelength_arr):

    """
    Estimate the spectral resolving power R = λ / Δλ approximation for a wavelength array.

    The dispersion per pixel (Δλ/pixel) is computed from the finite differences
    of the wavelength array. The resolution element is assumed to be Nyquist-sampled
    by 2 pixels, so the FWHM resolution element is 2 * (Δλ/pixel), giving:

        R ≈ λ / (2 * Δλ_pixel)

    Note: This is an approximation. The true R depends on the slit width,
    detector sampling, and optical quality of the spectrograph. For precise
    instrumental broadening estimates, an empirical LSF from arc/sky lines
    is preferred.

    Parameters
    ----------
    wavelength_arr : np.ndarray
        1D array of wavelengths, assumed to be in a consistent unit (e.g. Å).
        Must be monotonically increasing and uniformly or smoothly sampled.

    Returns
    -------
    res_power : np.ndarray
        1D array of resolving power R at each pixel, same shape as wavelength_arr.
        Dimensionless.

    Notes
    -----
    - The last pixel is extrapolated by repeating the second-to-last dispersion
      value, since np.ediff1d produces N-1 differences for an N-element array.
    - If the wavelength array has non-uniform sampling (e.g. from a non-linear
      dispersion solution), R will vary across the array accordingly.
    - Assumes 2 pixels per resolution element (Nyquist sampling). If your
      spectrograph samples the LSF with a different number of pixels, replace
      the factor of 2 with the appropriate value.

    Examples
    --------
    >>> wave = np.linspace(4000, 7000, 3000)   # 1 Å/pixel
    >>> R = res_power_approx(wave)
    >>> print(R[0])   # expect ~2000 at 4000 Å with 1 Å/pixel dispersion
    2000.0

    """

    delta_lambda = np.ediff1d(wavelength_arr, to_end=0)
    delta_lambda[-1] = delta_lambda[-2]
    return wavelength_arr / (2 * delta_lambda)


def spectrum_resampling(disp_intvl, pixel_width, pixel_number, constant_pixel_width, wave_arr, flux_arr, err_arr, mask_arr):

    # Check the input disespersion interval
    if disp_intvl is not None:

        if disp_intvl[0] < wave_arr[0]:
            _logger.warning(f'The input lower dispersion value is below the spectral range: disp_intvl {disp_intvl[0]} < {wave_arr[0]}')
        if disp_intvl[-1] > wave_arr[-1]:
            _logger.warning(f'The input higher dispersion value is above the spectral range: disp_intvl {disp_intvl[-1]} > {wave_arr[-1]}')

        bin_width = np.nanmean(np.diff(disp_intvl))

    else:

        # Compute the wavelength range based on the pixel width
        if pixel_width is not None:
            disp_intvl = np.arange(np.round(wave_arr[0]), np.round(wave_arr[-1]), pixel_width)
            bin_width = pixel_width

        # Compute the wavelength range based on a number of pixels
        else:
            if pixel_number is not None and pixel_number >= 2:
                if not float(pixel_number).is_integer():
                    _logger.info(f'The input pixel number has been rounded from {pixel_number} to {round(pixel_number)}')
                pixel_number = round(pixel_number)
            else:
                raise ValueError(f'In the number of pixels rebinning the input value must be above 1.')

            if constant_pixel_width:
                bin_width = np.nanmean(np.diff(wave_arr)) * pixel_number
                disp_intvl = np.arange(wave_arr[0], wave_arr[-1], bin_width)
            else:
                disp_intvl = wave_arr[pixel_number::pixel_number]
                bin_width = np.nanmean(np.diff(disp_intvl))

    # Compute bin edges from centers — applies to all branches
    bin_edges = np.concatenate([[disp_intvl[0] - bin_width / 2], disp_intvl + bin_width / 2])

    # Make the binning calculation
    flux_binned, edges, binnumber = stats.binned_statistic(wave_arr, flux_arr, statistic='mean', bins=bin_edges)

    if err_arr is not None:
        err_sum, _, _ = stats.binned_statistic(wave_arr, err_arr ** 2, statistic='sum', bins=bin_edges)
        counts, _, _ = stats.binned_statistic(wave_arr, err_arr, statistic='count', bins=bin_edges)
        err_binned = np.sqrt(err_sum) / counts
    else:
        err_binned = None

        # nbins = flux_binned.size
        # bin_idx = binnumber - 1  # make 0-based
        # sum_sq = np.bincount(bin_idx, weights=err_arr ** 2, minlength=nbins)
        # counts = np.bincount(bin_idx, minlength=nbins)
        # err_binned = np.sqrt(sum_sq) / counts

        # # get unique bin numbers #
        # uni_bin = np.unique(binnumber)
        # err_binned_Svea = []
        #
        # for binnum in uni_bin[:-1]:
        #     index_bin = np.where(binnumber == binnum)
        #     errors_bin = err_arr[index_bin]
        #     err_bin = np.sqrt(np.sum(errors_bin ** 2)) / (len(errors_bin))
        #     err_binned_Svea.append(err_bin)
        # err_binned_Svea = np.array(err_binned_Svea)

        # err_arr = self._spec.err_flux.data
        # sum_sq_errors = np.bincount(binnumber, weights=err_arr ** 2)
        # bin_counts = np.bincount(binnumber)
        # N_bins = flux_binned.size
        # sum_sq_errors_filtered = sum_sq_errors[1: N_bins + 1]
        # bin_counts_filtered = bin_counts[1: N_bins + 1]
        # err_binned = np.sqrt(sum_sq_errors_filtered) / bin_counts_filtered


    # # Update the binned wavelength # TODO when do I change this...
    # if disp_intvl.size != flux_binned.size:
    #     disp_intvl = disp_intvl[:-1] + bin_width / 2

    return disp_intvl, flux_binned, err_binned



class SpecRetriever:

    def __init__(self, spectrum):

        self._spec = spectrum

        return

[docs] def lines_frame(self, band_vsigma=70, n_sigma=4, adjust_central_band=True, instrumental_correction=True, exclude_bands_masked=True, map_band_vsigma=None, grouped_lines=None, automatic_grouping=False, fit_cfg=None, default_cfg_prefix='default', obj_cfg_prefix=None, update_default=True, line_list=None, particle_list=None, sig_digits=4, ref_bands=None, vacuum_waves=False, update_labels=False, update_latex=False, rejected_lines=None, Rayleigh_threshold=2, lines_redshift=None, map_origin=None, components=None, save_group_label=False): """ Return a bands dataframe with the spectral lines within the spectrum wavelength range. If the user does not provide a ``ref_bands`` This method queries the `LiMe bands database <https://lime-stable.readthedocs.io/en/latest/inputs/n_inputs3_line_bands.html>`_ and returns a :class:`pandas.DataFrame` of transitions visible within the spectrum's observed wavelength interval, taking into account the spectrum redshift, units, and pixel mask. The central bands (``w3``–``w4``) are optionally adjusted to match the expected line width, accounting for the emitting and/or absorbing medium velocity dispersion (``band_vsigma``) and instrumental broadening (``instrumental_correction``). Lines whose central band falls entirely within a masked pixel region can be excluded via ``exclude_bands_masked``. If a fitting configuration (``fit_cfg``) is provided, blended or merged line groups are resolved and the bands table is updated accordingly. Parameters ---------- band_vsigma : float, optional Velocity sigma in km/s used to set the half-width of the central band (``w3``–``w4``). Default is ``70``. n_sigma : int, optional Number of sigma used to compute the band half-width from ``band_vsigma``. Default is ``4``. adjust_central_band : bool, optional If ``True`` (default), recompute ``w3`` and ``w4`` from ``band_vsigma``, ``n_sigma``, and the instrumental broadening. instrumental_correction : bool, optional If ``True`` (default), include the instrumental broadening (derived from ``res_power``) when adjusting the central band width. exclude_bands_masked : bool, optional If ``True`` (default), remove lines whose central band pixels are entirely masked. map_band_vsigma : dict, optional Per-line overrides for ``band_vsigma``, keyed by line label. Lines not present in the dict use the global ``band_vsigma`` value. grouped_lines : dict, optional Explicit line grouping definitions. If ``None``, grouping is read from ``fit_cfg`` if available. automatic_grouping : bool, optional If ``True``, automatically decide the blended or merged line groups which match the observation. Default is ``False``. fit_cfg : dict or str or pathlib.Path, optional Fitting configuration. Can be a dictionary or a path to a configuration file. When provided, grouped lines and rejected lines are read from this configuration unless explicitly overridden. default_cfg_prefix : str, optional Prefix for default parameter entries in ``fit_cfg``. Default is ``"default"``. obj_cfg_prefix : str, optional Prefix for object-specific parameter entries in ``fit_cfg``. Default is ``None``. update_default : bool, optional If ``True`` (default), object-specific configuration entries override default entries. line_list : list or numpy.ndarray, optional Restrict the output to these line labels. Must follow `LiMe notation <https://lime-stable.readthedocs.io/en/latest/inputs/n_inputs2_line_labels.html>`_. particle_list : list or numpy.ndarray, optional Restrict the output to transitions from these ionic species (e.g. ``["H1", "O3"]``). sig_digits : int, optional Number of decimal figures in the line labels. Default is ``4``. ref_bands : pandas.DataFrame, str, or pathlib.Path, optional Alternative reference bands database. Defaults to the internal LiMe database. vacuum_waves : bool, optional If ``True``, convert wavelengths and band limits to vacuum values. Default is ``False``. update_labels : bool, optional If ``True``, recompute line labels from the transition data. Default is ``False``. update_latex : bool, optional If ``True``, recompute the ``latex_label`` column. Default is ``False``. rejected_lines : list, optional Line labels to exclude from the output. If ``None``, falls back to the value in ``fit_cfg`` if present. Rayleigh_threshold : float, optional Minimum wavelength separation (in units of ``band_vsigma``) below which two lines are considered blended via Rayleigh's criterion. Default is ``2``. lines_redshift : float, optional Redshift applied to the transition wavelengths when adjusting the central band, if no per-line ``z_line`` column is present in the bands table. Falls back to the spectrum redshift if ``None``. map_origin : dict, optional Mapping of origin labels to redshifts for multi-origin line queries. components : list, optional List of spectral shape components (e.g. ``["emission", "absorption"]``) used to filter lines by the predicted profile type from the feature detection algorithm. Requires ``spec.infer.pred_arr`` to have been computed beforehand. save_group_label : bool, optional If ``True``, store the group label in the bands table for grouped lines. Default is ``False``. Returns ------- pandas.DataFrame Bands dataframe with one row per transition, indexed by line label, containing wavelength, band limits (``w1``–``w6``), and metadata columns. Notes ----- - If ``res_power`` is not set on the spectrum, an approximate resolving power is computed from the wavelength array when ``instrumental_correction=True`` or ``fit_cfg`` is provided. - ``components`` filtering requires the aspect package and a prior call to the component detection algorithm; a :exc:`LiMe_Error` is raised otherwise. - Per-line redshifts in a ``z_line`` column (added by ``map_origin``) take precedence over ``lines_redshift`` and the spectrum redshift when adjusting central bands. Examples -------- Get all lines in the spectrum wavelength range: >>> bands = spec.retrieve.lines_frame() Restrict to hydrogen and oxygen transitions with a wider velocity band: >>> bands = spec.retrieve.lines_frame(particle_list=["H1", "O3"], band_vsigma=120) Use a fitting configuration to resolve blended lines: >>> bands = spec.retrieve.lines_frame(fit_cfg="my_cfg.toml") """ # Remove the mask from the wavelength array if necessary wave_intvl = self._spec.wave.compressed() # Check configuration format in_cfg = check_fit_conf(fit_cfg, default_cfg_prefix, obj_cfg_prefix, update_default) if fit_cfg else None # Generate the table of single lines taking into account possible origins rejected_lines = rejected_lines if rejected_lines is not None else (in_cfg or {}).get('rejected_lines') bands = multi_origin_lines_frame(map_origin, line_list, self._spec.redshift, wave_intvl=wave_intvl, particle_list=particle_list, units_wave=self._spec.units_wave, sig_digits=sig_digits, ref_bands=ref_bands, vacuum_waves=vacuum_waves, update_labels=update_labels, update_latex=update_latex, rejected_lines=rejected_lines) # Compute the resolving power if necessary if self._spec.res_power is not None: res_power = self._spec.res_power else: res_power = res_power_approx(wave_intvl) if (instrumental_correction or fit_cfg is not None) else None # Adjust the middle bands to match the line width if adjust_central_band: # Input the origin redshift if 'z_line' in bands.columns: z_corr = 1 + np.nan_to_num(bands['z_line'].to_numpy(), nan=self._spec.redshift) # Use the function redshift elif lines_redshift is not None: z_corr = 1 + lines_redshift # Use the object redshift else: z_corr = 1 + self._spec.redshift # Expected transitions in the observed frame # TODO This could be read from the configuration file lambda_obs = bands.wavelength.to_numpy() * z_corr # Add correction for the instrumental broadening if instrumental_correction: # Indexes for the lines emission peak idcs = np.searchsorted(wave_intvl, lambda_obs) # Use the instrumental resolution if available delta_lambda_inst = lambda_obs / (res_power[idcs] * k_gFWHM) # Constant velocity width else: delta_lambda_inst = 0 # Use unique or specific velocity sigma for the bands map_band_vsigma = map_band_vsigma if map_band_vsigma else (in_cfg or {}).get('map_band_vsigma') if map_band_vsigma is not None: band_vsigma = np.full(lambda_obs.size, band_vsigma) for idx in bands.index.get_indexer(map_band_vsigma.keys()): if idx > -1: band_vsigma[idx] = map_band_vsigma[bands.index[idx]] # Convert to spectral width delta_lambda = velocity_to_wavelength_band(n_sigma, band_vsigma, lambda_obs, delta_lambda_inst) # Add new values to database in the rest frame bands['w3'] = (lambda_obs - delta_lambda) / z_corr bands['w4'] = (lambda_obs + delta_lambda) / z_corr # Remove from the output bands those which have all their pixels masked if exclude_bands_masked: idcs_w3_w4 = np.searchsorted(self._spec.wave.data/(1+self._spec.redshift), bands.loc[:, 'w3':'w4']) idcs_valid = [idx for idx, start, end in zip(bands.index, idcs_w3_w4[:, 0], idcs_w3_w4[:, 1]) if not np.all(self._spec.flux[start:end].mask)] bands = bands.loc[idcs_valid] # Combine the blended/merged lines in the bands table if in_cfg: grouped_lines = grouped_lines if grouped_lines else (in_cfg or {}).get('grouped_lines') groups_dict = determine_line_groups(self._spec, bands, in_cfg, grouped_lines, automatic_grouping, n_sigma, Rayleigh_threshold) groupify_lines_df(bands, in_cfg, groups_dict, self._spec, save_group_label) # Filter the table to match the line detections if components: if aspect_check: if self._spec.infer.pred_arr is not None: # Create masks for all intervals starts = bands.w3.to_numpy()[:, None] * (1 + self._spec.redshift) ends = bands.w4.to_numpy()[:, None] * (1 + self._spec.redshift) # Check if x values fall within each interval in_intervals = (self._spec.wave.data >= starts) & (self._spec.wave.data < ends) # Check where y equals the target category shape_indexes = [aspect.cfg['shape_number'][comp] for comp in components] is_target_category = np.isin(self._spec.infer.pred_arr, shape_indexes) # Combine the masks to count target_category occurrences in each interval counts = np.sum(in_intervals & is_target_category, axis=1) # Check which intervals satisfy the minimum count condition idcs = counts >= 4 bands = bands.loc[idcs] else: raise LiMe_Error(f'No components data. Please run the components detection algorithm') else: raise LiMe_Error(f'Aspect is not installed') return bands
def spectrum(self, redshift=None, norm_flux=None, crop_waves=None, crop_flux=None, pixel_mask=None, mask_intvls=None, obj_redshift=False): # Extract the spectrum data mask_arr = self._spec.flux.mask wave_arr = self._spec.wave.data flux_arr = self._spec.flux.data if self._spec.norm_flux is None else self._spec.flux.data * self._spec.norm_flux if self._spec.err_flux is not None: err_arr = self._spec.err_flux.data if self._spec.norm_flux is None else self._spec.err_flux.data * self._spec.norm_flux else: err_arr = None # Use the original pixel mask if none is provided else combine pixel_mask = mask_arr if pixel_mask is None else mask_arr | pixel_mask # Add the user masked regions if mask_intvls is not None: # LiMe lines frame if isinstance(mask_intvls, pd.DataFrame): if {'w3', 'w4'}.issubset(mask_intvls): mask_intvls = mask_intvls.loc[:, ['w3','w4']].to_numpy() else: raise LiMe_Error(f'In order to use a pandas dataframe as "mask_intvls" it must have the a "w3" and' f' "w4" column to specify the lines location. Alternatively, you may use a matrix ' f'array to define the masked intervals') # Array of intervals (check it has the rigth dimensions) else: # Check the input has the right format mask_intvls = np.asarray(mask_intvls, dtype=float) if mask_intvls.ndim != 2 or mask_intvls.shape[1] != 2: raise ValueError('The argument "exclude_intvls" must be a list of (low, high) pairs') # Add redshift correction if obj_redshift: mask_intvls = mask_intvls * (1 + self._spec.redshift) if redshift is None else mask_intvls * (1 + redshift) # Loop through the wavelength intervals and add them to the mask # exclude_intvls = exclude_intvls * z_corr i0 = np.searchsorted(wave_arr, mask_intvls[:, 0], side="right") i1 = np.searchsorted(wave_arr, mask_intvls[:, 1], side="left") for start, stop in zip(i0, i1): pixel_mask[start:stop] = True # Recreate the spectrum out_spec = lime.Spectrum(input_wave=wave_arr, input_flux=flux_arr, input_err=err_arr, redshift=self._spec.redshift if redshift is None else redshift, res_power=self._spec.res_power, units_wave=self._spec.units_wave, units_flux=self._spec.units_flux, norm_flux=norm_flux, crop_waves=crop_waves, crop_flux=crop_flux, pixel_mask=pixel_mask) return out_spec def rebinned(self, disp_intvl=None, pixel_width=None, pixel_number=None, const_pixel_width=True, rest_frame=False, return_spectrum=False): """ Rebin the spectrum onto a new dispersion grid and return binned flux (and errors). Exactly **one** of ``disp_intvl``, ``pixel_width``, or ``pixel_number`` must be provided to define the target bins: - ``disp_intvl``: explicit bin **edges** (1D array, strictly increasing). - ``pixel_width``: constant bin width (same units as wavelength); bins span the full observed range ``[wave[0], wave[-1])``. - ``pixel_number``: group pixels by this integer factor. If ``constant_pixel_width`` is ``True`` (default), uses an average native dispersion to build uniform-width bins; otherwise, takes every ``pixel_number``-th native wavelength as an edge (non-uniform bin widths). The user can choose to perform the rebining in the observation restframe (this will set redshift=0 on the return spectrum). The binned flux is the mean flux per bin. If an uncertainty array is available, per-bin errors are combined as the square-root of the sum of variances divided by the number of contributing samples. Parameters ---------- disp_intvl : array-like, optional Monotonic array of bin **edges** for the target dispersion grid. If provided, it overrides the other arguments. Values outside the native spectral range will trigger a warning. pixel_width : float, optional Desired constant bin width (in wavelength units). Used to generate uniform bin edges covering the full native wavelength range. pixel_number : int, optional Number of native pixels to aggregate per bin. Must be ``>= 2``. If a non-integer is given, it is rounded and a message is logged. constant_pixel_width : bool, optional When rebinned by ``pixel_number``: - If ``True`` (default), compute a uniform bin width as the average native spacing times ``pixel_number``. - If ``False``, construct edges by taking every ``pixel_number``-th native wavelength (non-uniform bins). rest_frame: bool, optional Perform the rebinning on the observation rest frame. return_spectrum : bool, optional If ``False`` (default), return the binned wavelength, flux and flux uncertainty arrays. If ``True``, return a new :class:`lime.Spectrum` instance containing the binned data. Returns ------- disp_centers : numpy.ndarray Binned wavelength array (same units as disp_intvl if provided). flux_binned : numpy.ndarray Binned flux array. err_binned : numpy.ndarray or None Binned flux uncertainty array. spectrum : lime.Spectrum, optional Returned only if ``return_spectrum=True``. A new spectrum object with the binned data. Raises ------ ValueError If none or more than one of ``disp_intvl``, ``pixel_width``, ``pixel_number`` are provided, or if ``pixel_number < 2``. Notes ----- - ``disp_intvl`` is interpreted as **edges** (length = ``nbins + 1``); the returned ``disp_centers`` are computed as edge midpoints. - Binning uses ``scipy.stats.binned_statistic(..., statistic='mean')`` for flux. - If the spectrum contains masked or NaN values, ensure they are appropriately handled upstream; NaNs within a bin will propagate to the mean. """ # Check only one approach is necessary provided = {"dispersion inteval": disp_intvl, "pixel width": pixel_width, "pixel number": pixel_number} active = [name for name, val in provided.items() if val is not None] # Enforce only one rebinning array if len(active) == 0: raise ValueError("No arguments were provided to compute the new spectral resolution") if len(active) > 1: raise ValueError(f"Arguments {active} are mutually exclusive. Please only one.") # Extract the spectrum data z_corr = 1 if rest_frame is False else (1 + self._spec.redshift) mask_arr = self._spec.wave.mask wave_arr = self._spec.wave.data / z_corr flux_arr = self._spec.flux.data * z_corr err_arr = self._spec.err_flux.data * z_corr if self._spec.err_flux is not None else None # Perform the rebinne disp_intvl, flux_binned, err_binned = spectrum_resampling(disp_intvl, pixel_width, pixel_number, const_pixel_width, wave_arr, flux_arr, err_arr, mask_arr) if np.any(mask_arr): if mask_arr.size != disp_intvl.size: _logger.warning('Recalculating input mask') mask_arr = None if not return_spectrum: return disp_intvl, flux_binned, err_binned else: return lime.Spectrum(input_wave=disp_intvl, input_flux=flux_binned * self._spec.norm_flux if self._spec.norm_flux else flux_binned, input_err=err_binned * self._spec.norm_flux if self._spec.norm_flux else err_binned, redshift=0 if rest_frame else self._spec.redshift , res_power=self._spec.res_power, units_wave=self._spec.units_wave, units_flux=self._spec.units_flux, norm_flux=self._spec.norm_flux) def normalization(self, return_spectrum=False, crop_waves=None, crop_flux=None, **kwargs): """ Normalize the spectrum by its continuum. If the continuum has not been previously computed (``self._spec.cont is None``), this method automatically fits it by calling ``self._spec.fit.continuum(**kwargs)`` before performing the normalization. The normalized flux is defined as ``flux / continuum``. If a flux uncertainty array is available, the normalized uncertainty is propagated assuming independent errors in the flux and continuum. Parameters ---------- return_spectrum : bool, optional If ``False`` (default), return the normalized flux and uncertainty arrays. If ``True``, return a new :class:`lime.Spectrum` instance containing the normalized data. **kwargs Additional keyword arguments passed directly to ``lime.Spectrum.fit.continuum`` when the continuum needs to be computed. Returns ------- flux_norm : astropy.units.Quantity Continuum-normalized flux array. err_norm : astropy.units.Quantity or bool Uncertainty of the normalized flux. If the original spectrum does not contain a flux uncertainty array, this returns ``False``. spectrum : lime.Spectrum, optional Returned only if ``return_spectrum=True``. A new spectrum object with dimensionless flux units and ``norm_flux=1``. Notes ----- - The uncertainty propagation follows: ``σ(F/C) = |F/C| * sqrt[(σ_F / F)^2 + (σ_C / C)^2]`` where ``F`` is the flux and ``C`` is the continuum. - The returned spectrum preserves the original wavelength grid, redshift, spectral resolution, and pixel mask. - The continuum is computed only once and cached in the parent spectrum. """ # Compute the object continuum if not provided if self._spec.cont is None: self._spec.fit.continuum(**kwargs) # Normalize the flux flux_norm = self._spec.flux / self._spec.cont # Normalize the flux uncertainty if self._spec.err_flux is not None: err_norm = np.abs(flux_norm) * np.sqrt((self._spec.err_flux / self._spec.flux) ** 2 + (self._spec.cont_std / self._spec.cont) ** 2) else: err_norm = False # Return the normalized flux and uncertainty array if not return_spectrum: return flux_norm, err_norm # Return a LiMe spectrum else: return lime.Spectrum(self._spec.wave.data, flux_norm.data, err_norm.data, redshift=self._spec.redshift, units_wave=self._spec.units_wave, units_flux=au.dimensionless_unscaled, norm_flux=1, res_power=self._spec.res_power, pixel_mask=flux_norm.mask, crop_waves=crop_waves, crop_flux=crop_flux) def upper_line_limit(self, line, bands=None, signal_to_noise=8, err_from_bands=False, continua_sigma=True): # Use frame if available bands = self._spec.frame if bands is None else check_file_dataframe(bands) # Check if line is avaible to calculations if (bands is None) or (line not in bands.index): raise LiMe_Error('Upper flux limit requires the line band. Please input the lines frame or include in the ' 'spec.lines_frame measurements' if bands is None else f'Line "{line}" not found in the input bands') line = Line.from_transition(line, data_frame=bands) idcs_central, idcs_continua = line.index_bands(self._spec.wave, self._spec.redshift) lambda_central = line.wavelength * (1 + self._spec.redshift) if err_from_bands: sigma_cont = np.mean(self._spec.err_flux[idcs_central]) * self._spec.norm_flux else: sigma_cont = line.measurements.cont_err print(line.measurements.intg_flux) print(line.measurements.profile_flux) delta_lambda = np.mean(np.diff(self._spec.wave[idcs_central])) R_line = np.mean(self._spec.res_power[idcs_central]) g_const = np.sqrt(np.pi/(2*np.log(2))) upper_flux = signal_to_noise * sigma_cont * np.sqrt(g_const * (lambda_central / R_line) * delta_lambda) print('lambda_central', lambda_central) print('delta_lambda',delta_lambda) print('sigma_cont',sigma_cont) print('R_line',R_line) print('g_const',g_const) print('upper_flux',upper_flux) return upper_flux class SpecTreatment(LineFitting, RedshiftFitting): def __init__(self, spectrum): # Instantiate the dependencies LineFitting.__init__(self) # Lime spectrum object with the scientific data self._spec = spectrum self.line = None self._i_line = 0 self._n_lines = 0 self.cov_linear = None
[docs] def bands(self, label, bands=None, fit_cfg=None, min_method='least_squares', profile=None, shape=None, cont_source='central', err_from_bands=None, temp=10000.0, default_cfg_prefix='default', obj_cfg_prefix=None, update_default=True): """ Fit a spectral line from defined bands (see :ref:`bands documentation <line-bands-doc>`.). This method performs a full line measurement and profile fitting. The line is query from the default line database if no ``bands`` dataframe is provided. The function will also query the input ``fit_cfg`` dictionary if provided for the configuration settings. Parameters ---------- label : str or float Line label in `LiMe notation <https://lime-stable.readthedocs.io/en/latest/inputs/n_inputs2_line_labels.html>`_, or a transition wavelength (in the same units as the spectrum). If a numeric wavelength is given, the corresponding transition is queried from the ``bands`` table (or falling back to the default database if not provided). bands : pandas.DataFrame, str, or pathlib.Path, optional Either: * A bands DataFrame or file path to one. * If ``None``, the default LiMe bands database is used. fit_cfg : dict, str, or pathlib.Path, optional A dictionary or a path to a .toml file. min_method : str, optional Minimization algorithm used by :mod:`lmfit`. Supported methods are listed in the `lmfit.minimizer.Minimizer.minimize <https://lmfit.github.io/lmfit-py/fitting.html#lmfit.minimizer.Minimizer.minimize>`_ documentation. Default is ``"least_squares"``. profile : str, optional Profile type for fitting (e.g., ``"g"`` for Gaussian, ``"l"`` Lorentz, ...). If none is provided, the algorithm will use the default profile from the lines' database. shape : str, optional Line shape for the fitted profiles: "emi" for emission or "abs" for absroption. If none is provided, the algorithm will use the default shape from the lines' database. cont_source : {'central', 'adjacent', 'fitted'}, optional Method used to estimate the continuum level for line fitting. - ``'central'`` — use the edges of the central line band (w₃–w₄). - ``'adjacent'`` — use the adjacent continuum bands (w₁–w₂ and w₅–w₆). - ``'fitted'`` — use a previously fitted continuum model from the spectrum. The default is ``'central'``. err_from_bands : bool or None, optional If ``True``, estimate the pixel uncertainty from the continuum bands. If ``None``, use the spectrum’s ``err_flux`` data or fall back to the continuum regions if not available (False). The default value is None. temp : float, optional Electron temperature in Kelvin used to compute the thermal broadening correction for the fitted lines. Default is 10,000 K. default_cfg_prefix : str, optional Section key prefix for the default configuration in ``fit_cfg``. Default is ``"default"``. obj_cfg_prefix : str, optional Section key prefix for the object-specific configuration in ``fit_cfg``. update_default : bool, optional If ``True`` (default), merge parameters from ``obj_cfg_prefix`` into ``default_cfg_prefix``. If ``False``, the object configuration is used falling back to the default if not available. Returns ------- None The results are stored in the spectrum’s internal ``frame`` attribute and in ``self.line.measurements``. Notes ----- - The method performs the following steps: 1. Parse or retrieve the line definition using :meth:`~lime.Line.from_transition`. 2. Select line and continuum regions based on the provided or default ``bands``. 3. Estimate the continuum level and its uncertainty. 4. Compute non-parametric line properties (e.g., integrated flux). 5. Perform optional profile fitting via :mod:`lmfit` using ``min_method``. 6. Apply instrumental and thermal corrections to measured line widths. 7. Recalculate the signal-to-noise ratio and store all results in the spectrum’s log frame. - Continuum and error estimations are controlled via the ``cont_from_bands`` and ``err_from_bands`` flags. - Thermal broadening corrections use the provided ``temp`` parameter. - All results are written to the current spectrum’s measurement log and accessible through ``Spectrum.frame``. Examples -------- Fit a Gaussian emission line using the default configuration: >>> spec.fit.bands("O3_5007A") Use a custom bands table and configuration dictionary: >>> spec.fit.bands("H1_4861A", bands="my_bands.xlsx", fit_cfg=my_fit_cfg) Change the minimization algorithm and temperature: >>> spec.fit.bands("O2_3726A", min_method="nelder", temp=12000) Fit a line providing the central wavelength directly: >>> spec.fit.bands(5007.0, bands=my_bands_df) """ # Reset attributes self.cov_linear = None # Make a copy of the fitting configuration input_conf = check_fit_conf(fit_cfg, default_cfg_prefix, obj_cfg_prefix, update_default) # User inputs override default behaviour for the pixel error and the continuum calculation err_from_bands = True if (err_from_bands is None) and (self._spec.err_flux is None) else err_from_bands # Interpret the input line if isinstance(label, str): self.line = Line.from_transition(label, input_conf, data_frame=lineDB.frame if bands is None else check_file_dataframe(bands, copy_input=False), def_shape=shape, def_profile=profile) else: self.line = label # Check the line selection is valid idcs_selection = review_bands(self._spec, self.line, user_cont_source=cont_source, user_err_from_bands=err_from_bands) if idcs_selection is not None: # Unpack the line selections idcs_line, idcs_continua = idcs_selection # Compute line continuum and the pixel error cont_arr, pixel_err_arr = self._cont_level_profile(idcs_line, idcs_continua, cont_source, err_from_bands) # Non-parametric measurements self.integrated_properties(self.line, self._spec.wave[idcs_line], self._spec.flux[idcs_line], pixel_err_arr[idcs_line], cont_arr[idcs_line]) # Import kinematics if requested import_line_kinematics(self.line, 1 + self._spec.redshift, self._spec.frame, input_conf) # Profile fitting measurements idcs_fitting = idcs_selection[0] if cont_source == 'central' else idcs_selection[0] + idcs_selection[1] self.profile_fitting(self.line, x_arr=self._spec.wave[idcs_fitting], y_arr=self._spec.flux[idcs_fitting], err_arr=pixel_err_arr[idcs_fitting], cont_arr=cont_arr[idcs_fitting] if cont_source == 'fit' else None, user_conf=input_conf, fit_method=min_method) # Instrumental and thermal corrections for the lines sigma_corrections(self.line, idcs_line, self._spec.wave[idcs_line], self._spec.res_power, temp) # Recalculate the SNR with the profile parameters self.line.measurements.snr_line = signal_to_noise_rola(self.line.measurements.amp, self.line.measurements.cont_err, self.line.measurements.n_pixels) # Save the line parameters results_to_log(self.line, self._spec.frame, self._spec.norm_flux) return
[docs] def frame(self, bands, fit_cfg=None, min_method='least_squares', profile=None, shape=None, cont_source='central', err_from_bands=None, temp=10000.0, line_list=None, default_cfg_prefix='default', obj_cfg_prefix=None, update_default=True, line_detection=False, plot_fit=False, progress_output='bar'): """ Measure multiple spectral lines from a bands dataframe (see :ref:`bands documentation <line-bands-doc>`). This method automates the fitting of the lines on the input lines frame. It iterates through all listed transitions, performing continuum estimation, line detection (optional), and profile fitting using the configuration provided in ``fit_cfg``. Parameters ---------- bands : pandas.DataFrame, str, or pathlib.Path Bands table defining the line labels and bands limits (w1 to w6) for each line. fit_cfg : dict, str, or pathlib.Path, optional Fitting configuration dictionary or a path to a TOML configuration file. See the `profile fitting documentation <https://lime-stable.readthedocs.io/en/latest/inputs/n_inputs4_fit_configuration.html>`_. min_method : str, optional Minimization algorithm used by :mod:`lmfit`. See the `lmfit.minimizer.Minimizer.minimize <https://lmfit.github.io/lmfit-py/fitting.html#lmfit.minimizer.Minimizer.minimize>`_ documentation for available options. Default is ``"least_squares"``. profile : str, optional Profile type for fitting (e.g., ``"g"`` for Gaussian, ``"l"`` for Lorentzian). Defaults to the line database entry if omitted. shape : str, optional Line shape keyword (``"emi"`` for emission or ``"abs"`` for absorption). Defaults to the line database entry if omitted. cont_source : {'central', 'adjacent', 'fitted'}, optional Method used to estimate the continuum level for line fitting. - ``'central'`` — use the edges of the central line band (w₃–w₄). - ``'adjacent'`` — use the adjacent continuum bands (w₁–w₂ and w₅–w₆). - ``'fitted'`` — use a previously fitted continuum model from the spectrum. The default is ``'central'``. err_from_bands : bool or None, optional If ``True``, estimate the pixel uncertainty from the continuum bands. If ``None``, use the spectrum’s ``err_flux`` data or fall back to the continuum regions if not available (False). The default value is None. temp : float, optional Electron temperature (K) used to compute the thermal broadening correction. Default is 10,000 K. line_list : list of str, optional Subset of line labels from the bands table to process. If ``None``, all entries in ``bands`` are measured. default_cfg_prefix : str, optional Section key prefix for the default configuration in ``fit_cfg``. Default is ``"default"``. obj_cfg_prefix : str, optional Section key prefix for the object-specific configuration in ``fit_cfg``. update_default : bool, optional If ``True`` (default), merge parameters from ``obj_cfg_prefix`` into ``default_cfg_prefix``. If ``False``, the object configuration is used falling back to the default if not available. line_detection : bool, optional If ``True``, run the continuum fitting and line threshodling to confirme the presence of lines before measurements. The functions parameters must be specified in the ``fit_cfg`` (e.g., entries under ``"peaks_troughs"``, ``"continuum"``). Default is ``False``. plot_fit : bool, optional If ``True``, display the profile fit after each iteration. progress_output : {"bar", "counter", None}, optional Controls progress display in the console. - ``"bar"`` (default): show a dynamic progress bar. - ``"counter"``: print current line number and label. - ``None``: suppress console output. Returns ------- None The resulting measurements are stored in the spectrum’s internal ``frame`` attribute and in ``self.line.measurements``. Notes ----- - This method performs the following sequence for each line: 1. Optionally apply continuum and line detection preprocessing steps if enabled via ``line_detection=True`` and the appropicate keys are found at ``fit_cfg``. 2. Retrieve the line list from the ``bands`` table or the default database. 3. Estimate the continuum level and its uncertainty. 4. Perform non-parametric measurements (e.g., flux, EW, FWHM). 5. Run profile fitting using :mod:`lmfit` according to ``min_method``. 6. Apply instrumental and thermal width corrections (via ``Spectrum.res_power`` and ``temp``). 7. Recalculate SNR and store results in the spectrum’s log frame. - Progress reporting is configurable through ``progress_output``. - Use ``line_detection=True`` to automatically threshold and select only detected lines before fitting. Examples -------- Measure all lines from a bands file: >>> spec.fit.frame("my_bands.xlsx", fit_cfg="my_fit_config.toml") Run the fit with a progress bar: >>> spec.fit.frame(bands_df, progress_output="bar") Limit to a subset of lines: >>> spec.fit.frame(bands_df, line_list=["O3_5007A", "H1_4861A"]) Enable automatic line detection: >>> spec.fit.frame(bands_df, line_detection=True) """ # Check if the lines log is a dataframe or a file address bands = check_file_dataframe(bands) if bands is not None: # Crop the analysis to the target lines if line_list is not None: idcs = bands.index.isin(line_list) bands = bands.loc[idcs] # Load configuration input_conf = check_fit_conf(fit_cfg, default_cfg_prefix, obj_cfg_prefix, update_default=update_default, line_detection=line_detection) # Line detection if requested if line_detection: # Review the configuration entries cont_fit_conf = input_conf.get('continuum', None) if cont_fit_conf: self._spec.fit.continuum(**cont_fit_conf) else: _logger.warning(f'No "continuum" entry in input configuration file. No continuum fitting will be applied') # Perform the line detection detect_conf = input_conf.get('peaks_troughs', {}) if detect_conf: bands = self._spec.infer.peaks_troughs(bands, **detect_conf) else: _logger.warning(f'No "peaks_troughs" entry in input configuration file. No line thresholding will be applied') else: cont_fit_conf, detect_conf = None, None # Define lines to treat through the lines label_list = bands.index.to_numpy() self._n_lines = label_list.size # Loop through the lines if self._n_lines > 0: # On screen progress bar pbar = ProgressBar(progress_output, f'{self._n_lines} lines') if progress_output is not None: print(f'\nLine fitting progress{" (continuum fitting)" if cont_fit_conf is not None else ""}' f'{" (line detection)" if detect_conf is not None else ""}:') for self._i_line in np.arange(self._n_lines): # Ignore line if part of a blended/merge group line = label_list[self._i_line] measure_check = check_compound_line_exclusion(line, bands) if measure_check: # Progress message pbar.output_message(self._i_line, self._n_lines, pre_text="", post_text=f'({line})') # Fit the lines self.bands(line, bands, input_conf, min_method, profile, shape, cont_source=cont_source, err_from_bands=err_from_bands, temp=temp, obj_cfg_prefix=None, default_cfg_prefix=None) if plot_fit: self._spec.plot.bands() else: msg = f'No lines were measured from the input dataframe:\n - line_list: {line_list}\n - line_detection: {line_detection}' _logger.debug(msg) else: _logger.info(f'Not input dataframe. Lines were not measured') return
[docs] def continuum(self, degree_list, emis_threshold, abs_threshold=None, smooth_scale=None, exclude_intvls=None, rest_intvls=False, plot_steps=False, **kwargs): """ Fit the spectrum continuum via polynomial clipping. This routine estimates the continuum by iteratively fitting polynomials and sigma-clipping outliers above (emission) and below (absorption) a flux threshold. At each iteration, points outside configurable residual thresholds are excluded and the polynomial is refitted on the remaining pixels. The user may optionally provide a list of wavelength intervals to be excluded from the continuum fitting. By default, these limits are assumed to be defined in the observed frame. Parameters ---------- degree_list : list of int Polynomial degree to use at each iteration. The number of iterations equals ``len(degree_list)``. emis_threshold : list of float Upper (emission-side) clipping factors, in units of number of residual standard deviation for each iteration. Must have the same length as ``degree_list``. abs_threshold : list of float, optional Lower (absorption-side) clipping factors, also in units of number of residual standard deviation. If ``None``, the values in ``emis_threshold`` are reused for the lower limit. Must match the length of ``degree_list`` when provided. smooth_scale : int, optional Window size (in pixels) for a moving-average smoothing applied to the input flux before fitting. If ``None``, no smoothing is applied. exclude_intvls : list of tuple(float, float), optional List of wavelength intervals (low, high) to exclude from the continuum fitting. By default, intervals are interpreted in the observed frame. rest_intvls : bool, optional If ``True``, the wavelength intervals in ``exclude_intvls`` are assumed to be defined in the rest frame and are converted to the observed frame using ``λ_obs = λ_rest × (1 + z)`` prior to mask computation. plot_steps : bool, optional If ``True``, display a diagnostic plot after each iteration showing the current fit, clipping limits, and kept/rejected pixels. **kwargs Additional keyword arguments forwarded to the plotting helper if ``plot_steps=True`` (e.g., figure size, axis, title customization). Returns ------- None The method updates the spectrum in place, setting: - ``self._spec.cont`` : masked array of the final continuum model - ``self._spec.cont_std`` : float, standard deviation of residuals on kept pixels Notes ----- - **Initialization:** The first iteration seeds the mask using the 16th–84th percentile flux range of unmasked pixels, then fits the initial polynomial. - **Clipping:** After each fit, residuals are computed and the standard deviation is measured over currently kept pixels. New keep/reject limits are: ``low = model - abs_threshold[i] * std`` and ``high = model + emis_threshold[i] * std``. - **Masking:** Existing pixel masks are honored; clipping only modifies the continuum-selection mask on top of the original flux mask. - **Smoothing:** When ``smooth_scale`` is provided, a boxcar (length ``smooth_scale``) is convolved with the flux prior to fitting; the continuum itself is always evaluated on the original wavelength grid. Examples -------- Fit a three-iteration continuum with increasingly restrictive clipping: >>> degrees = [1, 2, 2] >>> thr_hi = [5.0, 3.0, 2.0] # emission-side thresholds (σ) >>> thr_lo = [5.0, 3.0, 2.0] # absorption-side thresholds (σ) >>> spec.fit.continuum(degrees, thr_hi, abs_threshold=thr_lo, smooth_scale=11) Show diagnostic plots at each iteration: >>> spec.fit.continuum([2, 2], [3.0, 2.0], plot_steps=True, title="Continuum fit") Exclude known emission-line regions (defined in the rest frame) from the continuum fit: >>> spec.continuum(degree_list=[3, 2], emis_threshold=[3.0, 2.0], exclude_intvls=[(4861, 4875), (6548, 6584)], rest_intvls=True) """ # Create a pre-Mask based on the original mask if available input_wave = self._spec.wave.data input_flux = self._spec.flux.data input_mask = ~self._spec.flux.mask # Correction if intervals are in the rest frame z_corr = 1 + self._spec.redshift if rest_intvls else 1 # Adjust the mask to exclude wavelengt intervals if exclude_intvls is not None: exclude_intvls = np.asarray(exclude_intvls, dtype=float) # Check the input has the right format if exclude_intvls.ndim != 2 or exclude_intvls.shape[1] != 2: raise ValueError('The argument "exclude_intvls" must be a list of (low, high) pairs') # Loop through the wavelength intervals and add them to the mask exclude_intvls = exclude_intvls * z_corr i0 = np.searchsorted(input_wave, exclude_intvls[:, 0], side="right") i1 = np.searchsorted(input_wave, exclude_intvls[:, 1], side="left") for start, stop in zip(i0, i1): input_mask[start:stop] = False # Smooth the spectrum if smooth_scale is not None: smoothing_window = np.ones(smooth_scale) / smooth_scale input_flux = np.convolve(input_flux, smoothing_window, mode='same') # Loop through the fitting degree abs_threshold = emis_threshold if abs_threshold is None else abs_threshold for i, degree in enumerate(degree_list): # First iteration use percentile limits for an initial fit if i == 0: low_lim, high_lim = np.nanpercentile(input_flux[input_mask], (16, 84)) mask_cont_0 = input_mask & (input_flux >= low_lim) & (input_flux <= high_lim) cont_fit = continuum_model_fit(input_wave, input_flux, mask_cont_0, degree) # Establishing the flux limits std_flux = np.nanstd((input_flux - cont_fit)[input_mask]) low_lim, high_lim = cont_fit - abs_threshold[i] * std_flux, cont_fit + emis_threshold[i] * std_flux # Add new entries to the mask input_mask = input_mask & (input_flux >= low_lim) & (input_flux <= high_lim) # Fit continuum cont_fit = continuum_model_fit(input_wave, input_flux, input_mask, degree) # Compute the continuum and assign replace the value outside the bands the new continuum if plot_steps is True or (plot_steps == 'last' and (i == len(degree_list) - 1)): default_ax_cfg = {'title':f'Continuum fitting ({i+1}/{len(degree_list)}), polynomial degree = {degree}'} input_kwargs = kwargs.get('ax_cfg') input_kwargs = default_ax_cfg if input_kwargs is None else default_ax_cfg.update(input_kwargs) spec_continuum_calculation(self._spec, input_wave, input_flux, cont_fit, input_mask, low_lim, high_lim, smooth_scale, exclude_intvls, ax_cfg=input_kwargs) # Include the standard deviation of the spectrum for the unmasked pixels self._spec.cont = np.ma.masked_array(cont_fit, self._spec.flux.mask) self._spec.cont_std = np.std((input_flux - cont_fit)[input_mask]) return
class CubeTreatment(LineFitting): def __init__(self, cube): # Instantiate the dependencies LineFitting.__init__(self) # Lime spectrum object with the scientific data self._cube = cube self._spec = None def spatial_mask(self, mask_file, fname, bands=None, fit_cfg=None, mask_list=None, line_list=None, log_ext_suffix='_LINELOG', min_method='least_squares', profile=None, shape=None, cont_source='central', err_from_bands=False, temp=10000.0, default_cfg_prefix='default', update_default=True, line_detection=False, progress_output='bar', plot_fit=False, header=None, join_output_files=True): """ Measure lines across an IFS cube using one or more spatial masks. This routine iterates over spaxels selected by binary masks (from ``mask_file``), measures the requested lines for each spaxel, and writes the results to one or more multi-extension FITS logs. Each spaxel’s measurements are saved in a dedicated extension named ``"{j}-{i}{log_ext_suffix}"`` (e.g., ``"25-30_LINELOG"``). The bands to use for measurements can be supplied globally via ``bands`` or, per-mask, via entries in ``fit_cfg``. Line-fitting behavior is configured via ``fit_cfg`` with a multi-level override scheme (default → mask → spaxel). Parameters ---------- mask_file : str or pathlib.Path or dict or numpy.ndarray Source of spatial masks. Can be a FITS file produced by :meth:`~lime.Cube.spatial_masking`, a dictionary mapping mask names to boolean arrays, or a boolean array. fname : str or pathlib.Path Output path for the combined measurements log (or the base name, if generating one .fits file per mask). bands : pandas.DataFrame, str, or pathlib.Path, optional Bands table (or path) to use for all masks/spaxels. If ``None``, the method will look for a per-mask bands path in ``fit_cfg``. fit_cfg : dict or str or pathlib.Path, optional Fitting configuration (dict or path to a TOML file). Supports a three-level override hierarchy: 1) ``default_cfg_prefix`` section (global defaults), 2) mask-level section named after the mask (e.g., ``"MASK_A"``), 3) spaxel-level section named ``"{j}-{i}_line_fitting"``. mask_list : list of str, optional Subset of masks in ``mask_file`` to process. If ``None``, all masks are used. line_list : list of str, optional Subset of line labels to measure from the bands table. If ``None``, all lines present in the bands are measured. log_ext_suffix : str, optional Suffix appended to each FITS extension name with results. Default is ``"_LINELOG"``. min_method : str, optional Minimization algorithm used by :mod:`lmfit`. See `lmfit.minimizer.Minimizer.minimize <https://lmfit.github.io/lmfit-py/fitting.html#lmfit.minimizer.Minimizer.minimize>`_. Default is ``"least_squares"``. profile : str, optional Profile identifier for fitting (e.g., ``"g"`` for Gaussian). If ``None``, falls back to the line database defaults. shape : str, optional Line shape: ``"emi"`` for emission or ``"abs"`` for absorption. If ``None``, falls back to database defaults. cont_source : {'central', 'adjacent', 'fitted'}, optional Method used to estimate the continuum level for line fitting. - ``'central'`` — use the edges of the central line band (w₃–w₄). - ``'adjacent'`` — use the adjacent continuum bands (w₁–w₂ and w₅–w₆). - ``'fitted'`` — use a previously fitted continuum model from the spectrum. The default is ``'central'``. err_from_bands : bool or None, optional If ``True``, estimate the pixel uncertainty from the continuum bands. If ``None``, use the spectrum’s ``err_flux`` data or fall back to the continuum regions if not available (False). The default value is None. temp : float, optional Electron temperature (K) for thermal broadening corrections. Default is ``10000.0``. default_cfg_prefix : str, optional Section name in ``fit_cfg`` containing global defaults. Default is ``"default"``. update_default : bool, optional If ``True`` (default), apply higher-level overrides by updating lower-level dictionaries (shared keys only). line_detection : bool, optional If ``True``, run the continuum fitting and line threshodling to confirme the presence of lines before measurements. The functions parameters must be specified in the ``fit_cfg`` (e.g., entries under ``"peaks_troughs"``, ``"continuum"``). Default is ``False``. progress_output : {"bar", "counter", None}, optional Console progress reporting mode. Default is ``"bar"``. plot_fit : bool, optional If ``True``, render a plot of each spaxel’s fitted lines during processing. Default is ``False``. header : dict, optional Extra FITS header keywords to add per spaxel page in the output logs. If a key matching the extension name (e.g., ``"25-30_LINELOG"``) exists, that dict is used; otherwise ``header`` is treated as a global header. join_output_files : bool, optional If multiple masks are processed, merge the per-mask logs into a single FITS file named after ``fname``. When ``False``, keep one output file per mask (named ``"{stem}_MASK-{mask}.fits"``). Default is ``True``. Returns ------- None Results are written to disk as one or more FITS files. Each spaxel’s measurements are stored in a binary table extension. Notes ----- - **Configuration hierarchy:** Values are resolved in the order *default → mask → spaxel*. Higher levels **update** shared keys only if ``update_default=True``. Otherwise, the method applies a fallback protocol where only the parameters explicitly defined in each section are used. - **WCS headers:** Spatial WCS keywords are added to each extension header when available, using the parent cube’s WCS metadata. Examples -------- Use a single bands table for all masks and join outputs: >>> cube.obs.spatial_mask( ... mask_file="O3_masks.fits", ... fname="logs/o3_all_masks.fits", ... bands="my_bands.xlsx", ... fit_cfg="fit_config.toml", ... progress_output="bar", ... ) Provide bands per mask via the configuration and keep files separate: >>> cube.obs.spatial_mask( ... mask_file="O3_masks.fits", ... fname="logs/o3_base.fits", ... fit_cfg="fit_config.toml", ... join_output_files=False, ... ) Limit measured lines and enable line detection: >>> cube.obs.spatial_mask( ... mask_file="masks.fits", ... fname="logs/selected_lines.fits", ... line_list=["O3_5007A", "H1_4861A"], ... line_detection=True, ... ) """ if bands is not None: bands = check_file_dataframe(bands) # Check if the mask variable is a file or an array mask_maps = check_file_array_mask(mask_file, mask_list) mask_list = np.array(list(mask_maps.keys())) mask_data_list = list(mask_maps.values()) # Resolve the reference directory with respect to the config file if provided if fit_cfg is not None and not isinstance(fit_cfg, dict): cfg_dir = Path(fit_cfg).resolve().parent else: cfg_dir = Path.cwd() # Check the mask configuration is included if there are no masks input_masks = mask_list if bands is None else None input_conf = check_fit_conf(fit_cfg, default_key=None, obj_key=None, update_default=update_default, group_list=input_masks) # Check if the output log folder exists fname = Path(fname) address_dir = fname.parent if not address_dir.is_dir(): raise LiMe_Error(f'The folder of the output log file does not exist at {fname}') address_stem = fname.stem # Determine the spaxels to treat at each mask spax_counter, total_spaxels, spaxels_dict = 0, 0, {} for idx_mask, mask_data in enumerate(mask_data_list): spa_mask, hdr_mask = mask_data idcs_spaxels = np.argwhere(spa_mask) total_spaxels += len(idcs_spaxels) spaxels_dict[idx_mask] = idcs_spaxels # Header data hdr_coords = extract_wcs_header(self._cube.wcs, drop_axis='spectral') if self._cube.wcs is not None else None # Loop through the masks n_masks = len(mask_list) mask_log_files_list = [address_dir/f'{address_stem}_MASK-{mask_name}.fits' for mask_name in mask_list] for i in np.arange(n_masks): # HDU_container hdul_log = fits.HDUList([fits.PrimaryHDU()]) # Mask progress indexing mask_name = mask_list[i] mask_hdr = mask_data_list[i][1] idcs_spaxels = spaxels_dict[i] # Recover the fitting configuration mask_conf = check_fit_conf(input_conf, default_cfg_prefix, mask_name) # Load the mask log if provided if bands is None: # bands_file = Path(mask_conf['bands']).resolve() # Get the section bands file when resolving bands or other relative paths from the config: bands_file = (cfg_dir / mask_conf['bands']).resolve() if bands_file.exists(): bands_in = load_frame(bands_file) else: err_msg = (f'Bands file not found at: {bands_file}.' f'\n- Resolving from log section - entry: ' f'\n [{mask_name}_line_fitting]' f'\n bands = {mask_conf["bands"]}') raise LiMe_Error(err_msg) else: bands_in = bands # Loop through the spaxels n_spaxels = idcs_spaxels.shape[0] n_lines, start_time = 0, time() print(f'\nSpatial mask {i + 1}/{n_masks}) {mask_name} ({n_spaxels} spaxels)') pbar = ProgressBar(progress_output, f'mask') for j in np.arange(n_spaxels): idx_j, idx_i = idcs_spaxels[j] spaxel_label = f'{idx_j}-{idx_i}' # Get the spaxel fitting configuration spaxel_conf = input_conf.get(f'{spaxel_label}_line_fitting') spaxel_conf = mask_conf if spaxel_conf is None else {**mask_conf, **spaxel_conf} # Spaxel progress message pbar.output_message(j, n_spaxels, pre_text="", post_text=f'(spaxel coordinate. {idx_j}-{idx_i})') # Get spaxel data spaxel = self._cube.get_spectrum(idx_j, idx_i, spaxel_label) # Fit the lines spaxel.fit.frame(bands_in, spaxel_conf, line_list=line_list, min_method=min_method, line_detection=line_detection, profile=profile, shape=shape, cont_source=cont_source, err_from_bands=err_from_bands, temp=temp, progress_output=None, plot_fit=None, obj_cfg_prefix=None, default_cfg_prefix=None, update_default=update_default) # Count the number of measurements n_lines += spaxel.frame.index.size # Create page header with the default data hdr_i = fits.Header() # Add WCS information if hdr_coords is not None: hdr_i.update(hdr_coords) # Add user information if header is not None: page_hdr = header.get(f'{spaxel_label}{log_ext_suffix}', None) page_hdr = header if page_hdr is None else page_hdr hdr_i.update(page_hdr) # Save to a fits file linesHDU = log_to_HDU(spaxel.frame, ext_name=f'{spaxel_label}{log_ext_suffix}', header_dict=hdr_i) if linesHDU is not None: hdul_log.append(linesHDU) # Plot the fittings if requested: if plot_fit: spaxel.plot.spectrum(rest_frame=True) # Save the log at each new mask hdul_log.writeto(mask_log_files_list[i], overwrite=True, output_verify='ignore') hdul_log.close() # Computation time and message end_time = time() elapsed_time = end_time - start_time print(f'\n{n_lines} lines measured in {elapsed_time/60:0.2f} minutes.') if join_output_files: output_comb_file = f'{address_dir/address_stem}.fits' # In case of only one file just rename it if len(mask_list) == 1: mask_0_path = Path(mask_log_files_list[0]) mask_0_path.rename(Path(output_comb_file)) else: print(f'\nJoining spatial log files ({",".join(mask_list)}) -> {output_comb_file}') join_fits_files(mask_log_files_list, output_comb_file, delete_after_join=join_output_files) return