"""
MHD Radiative Transfer Loader Module
====================================

This module provides functionality for computing single-fluid magnetohydrodynamics (MHD)
quantities related to radiative transfer synthesis and spectral line analysis.

Main Components
--------------
MhdRadiativeLoader : class
    Primary class for radiative transfer calculations in MHD simulations, including:
    - Emission measure (EM) with optional TRAC correction and opacity
    - Velocity-Differential Emission Measure (VDEM)
    - Spectral line synthesis and Doppler velocity analysis
    - G(T) contribution functions from ChiantiPy

Helper Functions
---------------
lambda_to_doppler : Convert wavelength to Doppler velocity units
doppler_to_lambda : Convert Doppler velocity to wavelength units
read_qloss : Read radiative loss tables from file
interp_masked : Array masking with interpolation for VDEM binning

External Dependencies
--------------------
- muse.instr.utils : MUSE instrument utilities for spectral synthesis
- ChiantiPy.core : Atomic database for spectral line calculations

Note: Some external dependencies may be optional and will fail gracefully
with ImportFailed if not available.
"""

# builtins
import os
import shutil
import datetime as datetime

# public/common external (and, part of PlasmaCalcs requirements already)
import xarray as xr
import numpy as np

# private/external (not part of PlasmaCalcs requirements)
from ..tools import ImportFailed  # <-- helps if import fails
try:
    from muse.instr import utils as muse_instr_utils  
except ImportError as err:
    muse_instr_utils = ImportFailed("muse.instr.utils", err=err, locals=locals(), abbrv='muse_instr_utils')

try:
    import ChiantiPy.core as ch
except ImportError as err:
    ch = ImportFailed("ChiantiPy.core", err=err, locals=locals(), abbrv='ch')

# internal
from .mhd_bases import MhdBasesLoader
from ..defaults import DEFAULTS
from ..tools import (
    alias, simple_property, format_docstring,
    pcAccessor,
    xr1d, _xarray_save_prep,
    xarray_result_size_check,
    xarray_multiply_then_sum_along,
    xarray_moments,
    xarray_update_call_history,
)
from ..errors import (
    InputError, InputConflictError, InputMissingError,
    ComponentValueError, DimensionalityError,
)


### --------------------- Helper functions --------------------- ###

def lambda_to_doppler(wave, wave0):
    """
    Convert wavelength to Doppler velocity units (km/s).

    Parameters
    ----------
    wave : array-like
        Observed wavelength(s) [same units as wave0, e.g., Angstroms].
    wave0 : float
        Rest wavelength.

    Returns
    -------
    array-like
        Doppler velocity in km/s.
    """
    cc = 2.99792458e10 / 1e5
    return (wave / wave0 - 1.0) * cc  # outputs in km/s


def doppler_to_lambda(doppler, wave0):
    """
    Convert wavelength to Doppler velocity units (km/s).

    Parameters
    ----------
    doppler : array-like
        Doppler velocity in km/s.
    wave0 : float
        Rest wavelength.

    Returns
    -------
    array-like
        Observed wavelength(s) [same units as wave0, e.g., Angstroms]
    """
    cc = 2.99792458e10 / 1e5
    return wave0 * (1.0 + doppler / cc)  # outputs in units of wave0


def read_qloss(file="Radloss_Chianti.dat"):
    """
    Read radiative loss table from file.

    Parameters
    ----------
    file : str
        Filename for the radiative loss table.

    Returns
    -------
    tg : ndarray
        Temperature grid.
    qloss : ndarray
        Radiative loss values.
    """
    f = open("Radloss_Chianti.dat", "r")
    first_line = f.readline()
    ntg = int(first_line.strip())
    tg = np.zeros(ntg)
    qloss = np.zeros(ntg)

    for ii, line in enumerate(f):
        tg[ii], qloss[ii] = np.float64(line.strip().split())

    return tg, qloss

@pcAccessor.register('interp_masked', totype='array')   # <-- would be fine, if desired!
def interp_masked(array, bins, dim, *, mode='correct', return_weights=False):
    """
    Array masked to bins, possibly with interpolation correction and normalization factors,
        for use in (Velocity) Differential Emission Measure ((V)DEM) binning.
    The result has `array` dims plus `bins` dim, with values from 0 to 1.

    Conceptually - basically, this just tells whether `array` overlaps with each bin.
        E.g. result.isel(x=i, y=j, bindim=k) tells whether the (i,j)th x,y point overlaps with bin k.
    Actually - it may include an interpolation correction factor,
        accounting for adjacent cell values, and any jumps larger than bin widths.
        E.g. result.isel(x=i, bindim=k) tells how much of bin k is covered from the ith to (i+1)th x.
    Additionally - if return_weights, this method also returns xn, which can be applied to a value,
        to properly account for interpolation between adjacent cells.

    See Rempel et al. (2017) for details.
    (Note - Rempel+2017 formulas are slightly different than those here,
        because those formulas solve a slightly different problem.
        Testing those formulas here led to more numerical artifacts.)

    If binning multiple quantities (e.g., temperature and velocity), the canonical pattern to use is:
        mask1, xn = self.interp_masked(quantity1, bins1, return_weights='exp')
        mask2 = self.interp_masked(quantity2, bins2)
        mask3 = self.interp_masked(quantity3, bins3)
        ...   # etc. i.e., only the first call computes interpolation weights.
        emiss_interped = emiss * (1.0 - xn) + emiss.roll({x:1}) * xn

    Note - the result's first value along dim may be unreliable (if non-periodic array),
        as it relies on array.roll({dim:1}).

    Parameters
    ----------
    array : xarray.DataArray
        variable to be interp-masked. E.g., array of log10(temperature) values.
    bins : xarray.DataArray
        1D array of (evenly-spaced) bin centers (internally, assumes bin width = (bins[1]-bins[0])/2).
    dim : str
        apply the correction along this dimension of array.
        For VDEMS, this is the dimension along which the LOS integration will be performed.
    mode: 'correct' or 'simple'
        tells how to compute the result.
        'correct' --> includes correction factor.
        'simple' --> exclude correction factor, just tells whether each point falls within each bin.
                    Equivalent to: result = (bins - delta <= array) & (array < bins + delta),
                        where delta = (bins[1] - bins[0]) / 2 == bin width.
                    This can be much faster than mode='correct', but produces noisier results.
                    (Also, only allowed if return_weights=False).
    return_weights : False, True, or 'exp'
        whether to also return interpolation weights xn for each bin.
        False --> returns masked
        True --> returns (masked, xn)
        'exp' --> returns (masked, xn) but compute xn using (10**value, 10**bins) not (value, bins).

    Returns
    -------
    masked : xarray.DataArray
        array of fn * overlaps, where overlaps = whether each cell overlaps with each bin,
        and fn = normalization factor for each bin, clipped to [0, 1].
        (Or, if mode=='simple', this is just (bins - delta <= array) & (array < bins + delta).)
    xn : xarray.DataArray
        Interpolation weight for each bin, with values clipped to [0, 1].
        (Only returned if return_weights.)
    """
    ### checks ###
    if not isinstance(bins, xr.DataArray):
        raise TypeError("interp_masked: bins must be an xarray.DataArray")
    if bins.ndim != 1:
        raise DimensionalityError(f'interp_masked: expected 1D bins; got bins.ndim={bins.ndim}')
    if isinstance(return_weights, str) and return_weights != 'exp':
        raise InputError(f'return_weights should be False, True, or "exp"; got {return_weights!r}')
    if mode not in ('correct', 'simple'):
        raise InputError(f'mode should be "correct" or "simple"; got {mode!r}')
    if mode == 'simple' and return_weights:
        errmsg = f'mode="simple" only allowed if return_weights=False; got return_weights={return_weights!r}.'
        raise InputConflictError(errmsg)

    ### initial setup ###
    xarray_result_size_check(array, bins)  # if result would be too huge, make crash instead!
    delta = (bins.values[1] - bins.values[0]) / 2
    x = dim
    value = array

    ### mode=='simple' case ###
    if mode == 'simple':
        inbins = (value >= bins - delta) & (value < bins + delta)
        return inbins

    ### mode=='correct' ###
    value1 = value.roll({x:1})
    minvalue = np.minimum(value, value1)
    maxvalue = np.maximum(value, value1)
    # overlaps = "whether any part of the segment (from minvalue to maxvalue) overlaps the bin."
    overlaps = (maxvalue >= bins - delta) & (minvalue < bins + delta)
    clipmin = minvalue.clip(min=bins - delta)
    clipmax = maxvalue.clip(max=bins + delta)
    fn = np.abs((clipmax - clipmin) / (value1 - value))  # correction coefficient
    fn = fn.fillna(0).clip(0, 1)
    masked = overlaps * fn

    if return_weights:
        ### interpolation weights ###
        if return_weights=='exp':
            xn = (10**value - 10**bins) / np.abs(10**value - 10**value1)
        else:
            xn = (value - bins) / np.abs(value - value1)
        xn = xn.fillna(0).clip(0, 1)
        return masked, xn
    else:  # no need to compute weights
        return masked


### --------------------- MhdRadiativeLoader --------------------- ###

class MhdRadiativeLoader(MhdBasesLoader):
    """
    Loader for computing single-fluid MHD quantities related to radiative transfer.

    This class extends MhdBasesLoader to provide specialized functionality for
    radiative transfer calculations in magnetohydrodynamics (MHD) simulations,
    including:

    - **Emission Measure (EM)**: Calculations with optional TRAC (Transition Region
      Adaptive Conduction) correction and opacity effects
    - **Velocity-Differential Emission Measure (VDEM)**: Temperature and velocity
      binning for spectral line analysis
    - **Spectral Line Synthesis**: Full and partial spectra generation with instrument
      profiles and multiple emission line support
    - **Doppler Shifts**: Line-of-sight velocity analysis via Doppler shift calculations
    - **Intensity Moments**: Computation of spectral line moments (intensity, mean
      velocity, width)

    Key Features
    ------------
    - Configurable emission measure modes (with/without TRAC, with/without opacity)
    - Flexible temperature and velocity binning for VDEM calculations
    - Multiple VDEM computation modes: interpolated, non-interpolated, and fully interpolated
    - Support for photosphere masking in calculations
    - Integration with MUSE instrument utilities and ChiantiPy for spectral synthesis
    - Efficient handling of large datasets through xarray integration

    Attributes
    ----------
    emiss_mode : str
        Emission measure calculation mode ('notrac_noopa', 'trac_noopa', 
        'notrac_opa', 'trac_opa')
    emiss_trac : bool
        Whether to include TRAC correction in emission measure calculations
    emiss_opa : bool
        Whether to include opacity effects in emission measure calculations
    emiss_norm : float
        Emission measure normalization factor (default: 1e27)
    vdem_mode : str
        VDEM calculation mode ('interp', 'nointerp', 'allinterp')
    rcoords_logT : array-like
        Log10(temperature [K]) values for binning
    rcoords_vdop_kms : array-like
        Velocity/Doppler shift values in km/s for binning
    vdem_logT_min : float
        Minimum log10(temperature [K]) threshold for VDEM calculations

    See Also
    --------
    MhdBasesLoader : Parent class providing base MHD functionality
    """

    # # # HELPER METHODS # # #
    def _radiative_assert_single_component(self):
        '''asserts self.current_n_component() == 1; raise ComponentValueError otherwise.
        The error message will be particularly helpful and give options for how to fix the error.
        '''
        # [TODO] probably shouldn't exist anymore; relevant methods should use _los and/or _loi patterns.
        if self.current_n_component() != 1:
            errmsg = ("Inside of a MhdRadiativeLoader method, requiring a single component, "
                      f"but got self.component={self.component}.\nConsider specifying component, "
                      "e.g. via self.component='x', or self(f'{var}_y'), or self(var, component='z')")
            raise ComponentValueError(errmsg)

    # # # VARIOUS SETTINGS # # #
    @property
    def _extra_kw_for_quantity_loader_call(self):
        '''extra kwargs which can be used to set attrs self during self.__call__.
        The implementation here returns ['emiss_mode', 'vdem_logT', 'vdem_loopdim'] + any values from super().
        '''
        return ['emiss_mode', 'vdem_logT', 'vdem_loopdim'] + super()._extra_kw_for_quantity_loader_call

    # # # EMISSION MEASURE # # #

    cls_behavior_attrs.register('emiss_trac', default=False)
    emiss_trac = simple_property('_emiss_trac', default=False,
        doc='''whether to include TRAC correction in emission measure (self('emiss')).''')

    cls_behavior_attrs.register('emiss_opa', default=False)
    emiss_opa = simple_property('_emiss_opa', default=False,
        doc='''whether to include opacity in emission measure (self('emiss')).''')

    @property
    def emiss_mode(self):
        '''string telling emission measure mode.
        Depends on (and can be used to set) emiss_trac and emiss_opa.

        'notrac_noopa' <--> emiss_trac=False, emiss_opa=False
        'trac_noopa' <--> emiss_trac=True, emiss_opa=False
        'notrac_opa' <--> emiss_trac=False, emiss_opa=True
        'trac_opa' <--> emiss_trac=True, emiss_opa=True

        Setting string sets emiss_trac and emiss_opa accordingly.
        Getting string returns result according to emiss_trac and emiss_opa.

        (Currently included for backwards compatibility, might be removed eventually.)
        '''
        trac = self.emiss_trac
        opa = self.emiss_opa
        trac_str = 'trac' if trac else 'notrac'
        opa_str = 'opa' if opa else 'noopa'
        return f'{trac_str}_{opa_str}'
    @emiss_mode.setter
    def emiss_mode(self, value):
        EMISS_MODE_OPTIONS = ('notrac_noopa', 'trac_noopa', 'notrac_opa', 'trac_opa')
        if value not in EMISS_MODE_OPTIONS:
            raise InputError(f"Invalid emiss_mode {value!r}; expected one of {list(EMISS_MODE_OPTIONS.keys())}.")
        trac_str, opa_str = value.split('_')
        self.emiss_trac = (trac_str == 'trac')
        self.emiss_opa = (opa_str == 'opa')


    @known_var(attr_deps=[('emiss_trac', {True: ['emiss_trac'], False: ['emiss_notrac']}),
                          ('emiss_opa', {True: ['tau'], False: []})])
    def get_emiss(self):
        """Emission measure (EM) based on the selected emission mode (self.emiss_mode).
        Depends on self.emiss_mode; see self.EMISS_MODE_OPTIONS for details.

        Result is always in cgs, regardless of self.units.
        """
        __tracebackhide__ = DEFAULTS.TRACEBACKHIDE
        with self.using(coords_units=self.coords_units_explicit, units='cgs'):
            if self.emiss_trac:
                emiss = self('emiss_trac')
            else:
                emiss = self('emiss_notrac')
            if self.emiss_opa:
                tau = self('tau')
                # [TODO] emiss = self.loi_interp(emiss)  in this case...
                xarray_result_size_check(emiss, tau)  # prevent wayyyy too big result
                result = emiss * np.exp(-tau)
            else:
                result = emiss
            return result

    @known_var(deps=['ne', 'r'])
    def get_emiss_notrac(self):
        '''Emission measure in cgs without TRAC correction.
        Result is always in [cgs], regardless of self.units.

        nh = r / (n_per_nh * mh)
        emiss_notrac = ne * nh / self.emiss_norm [1/cm^6]
        '''
        self.assert_single_fluid_mode('emiss_notrac')
        with self.using(coords_units=self.coords_units_explicit, units='cgs'):
            r_per_nH_tot = (self.elements.n_per_nH() * self.elements.m * self.u('amu')).sum()
            val = self('ne') * self('r') / (r_per_nH_tot * self.emiss_norm)
            return val

    cls_behavior_attrs.register('emiss_norm', default=1e27)
    emiss_norm = simple_property('_emiss_norm', default=1e27,
        doc='''Emission Measure normalization factor. Used in self('emiss')
        and related vars. emiss = unnormed_emiss / emiss_norm.''')

    cls_behavior_attrs.register('emiss_trac_coeff')
    emiss_trac_coeff = simple_property('_emiss_trac_coeff', default=3,
        doc='''TRAC coefficient for emiss_trac calculation.
         transition region using an adaptive conduction coefficient (Johnston et al. 2020).''')

    @known_var(deps=['emiss_notrac', 'B', 'T'], ignores_dims=['component'])
    def get_emiss_trac(self):
        '''Emission measure in cgs with TRAC correction.
        Result is always in [cgs], regardless of self.units.

        nh = r / (n_per_nh * mh)
        emiss_trac = ne * nh * trac_correction / normalization

        self.emiss_trac_coeff tells the TRAC coefficient (default 3) 
        '''
        self.assert_single_fluid_mode('emiss_trac')
        if not set(('x', 'y', 'z')) <= set(self.maindims):  # i.e., x, y, and z are not all in maindims:
            errmsg = f'emiss_trac requires 3D data (x, y, and z), but got self.maindims={self.maindims}.'
            raise DimensionalityError(errmsg)
        with self.using(coords_units=self.coords_units_explicit, units='cgs'):
            emis = self('emiss_notrac')
            bx = self("B", component='x', units = 'raw')  # only using relative values of Bx,By,Bz so 'raw' is cheaper.
            by = self("B", component='y', units = 'raw')  #    also, 'cgs' B would complain, by default.
            bz = self("B", component='z', units = 'raw')
            tg = self('T')
            dx = self('maindims_x_dslice')  # dx accounting for self.slices appropriately for integral.
            dy = self('maindims_y_dslice')  # dy ^  (e.g. if slice(None,None,4), want ~4 times larger dy)
            dz = self('maindims_z_dslice')  # dz ^^

            tg_dep, qloss_dep = read_qloss()
            Lt = xr.DataArray(qloss_dep, coords={"T": tg_dep}, dims="T").interp(T=tg)

            Q_Chianti = emis * (Lt * self.emiss_norm)
            modb = np.sqrt(bx**2 + by**2 + bz**2)
            kappa_spitzer = 1e-6 * tg**2.5 # 1e-6 comes from TRAC method (dimensions?)

            L_R = (
                self.emiss_trac_coeff
                * (np.abs(bx) * dx + np.abs(by) * dy + np.abs(bz) * dz)
                / modb
            )

            fmin = np.sqrt(kappa_spitzer * tg / Q_Chianti) / (2.0 * L_R)
            fmin = fmin.where(fmin <= 1, 1.0)

            emis *= fmin
            return emis


    # # # VDEM # # #
    # differential emission measure as a function of temperature and velocity.
    # might also be a function of electron number density (see rcoords_logD_cgs).

    # temperature and velocity bins determined by rcoords_logT and rcoords_vdop_kms:
    cls_behavior_attrs.register('rcoords_vdop_kms')
    rcoords_vdop_kms = simple_property('_rcoords_vdop_kms', default=np.arange(-100,100,10), 
            doc='''velocity [km/s] (or, doppler shift) values, used by some MhdRadiativeLoader vars.
            E.g., used in VDEMs and in getting spectra.
            Can be a scalar, a 1D list or numpy array, or 1D xr.DataArray with dimension 'vdop'.''')
    @known_var
    def get_rcoords_vdop(self):
        '''velocity (or, doppler shift) values used by some vars (e.g. vdems and spectra), in [self.units] system,
        along dimension 'vdop', with coords always in km/s regardless of self.units.
        Determined by self.rcoords_vdop_kms (which can be set directly), converted to [self.units].
        '''
        result = xr1d(self.rcoords_vdop_kms, name='vdop', lenient=True)  # [km/s]
        return result * (self.u("u", convert_from='si') * 1e3)  # [self.units]

    cls_behavior_attrs.register('rcoords_logT')
    rcoords_logT = simple_property('_rcoords_logT', default=np.arange(4.6,6.5,0.1), 
            doc='''log10(Temperature [K]) values used by some MhdRadiativeLoader vars.
            E.g., used in VDEMs and in G(T) lookup tables.
            Can be a scalar, a 1D list or numpy array, or 1D xr.DataArray with dimension 'logT'.
            Note: rcoords_logT minimum value can never be less than self.vdem_logT_min.
                (attempting to set bad value will cause crash & helpful error message.)''')
    @rcoords_logT.setter
    def rcoords_logT(self, value):
        value = xr1d(value, name='logT', lenient=True)
        minval = np.min(value).item()
        if minval < self.vdem_logT_min:
            errmsg = (f'cannot set self.rcoords_logT to something with minimum ({minval:.3g}) less than '
                      f'self.vdem_logT_min={self.vdem_logT_min:.3g}.\n'
                      f'To proceed, first set vdem_logT_min to a smaller value, then try again.\n'
                      f'(To set both simultaneously, use self.vdem_logT = (new_min, new_coords).)')
            raise InputConflictError(errmsg)
        self._rcoords_logT = value

    @known_var
    def get_rcoords_logT(self):
        '''log(Temperature) values used by some vars (e.g. vdems and G(T)), in [self.units] system,
        along dimension 'logT', with coords always telling log(T [K]) regardless of self.units.
        Determined by self.rcoords_logT (which can be set directly), converted to [self.units].
        '''
        result = xr1d(self.rcoords_logT, name='logT', lenient=True)  # log10([K])
        u = self.u("temperature", convert_from='si')
        if u != 1.0:  # [EFF] small efficiency improvement, added because u will very commonly be 1.
            result = result + np.log10(u)   # (this formula also works if u==1, but would just add 0.)
        return result  # log10([self.units])

    cls_behavior_attrs.register('vdem_logT_min', default=4)
    vdem_logT_min = simple_property('_vdem_logT_min', default=4,
            doc='''Minimum log10(temperature [K]) to include in VDEM calculations.
            some VDEMs ignore emiss contributions from very cold regions (T < self.vdem_logT_min).
            Note: vdem_logT_min can never be more than minimum value of self.rcoords_logT.
                (attempting to set bad value will cause crash & helpful error message.)''')
    @vdem_logT_min.setter
    def vdem_logT_min(self, value):
        minval = np.min(self.rcoords_logT).item()
        if value > minval:
            errmsg = (f'cannot set self.vdem_logT_min to something ({value:.3g}) more than '
                      f'the minimum of self.rcoords_logT ({minval:.3g}).\n'
                      f'To proceed, first set rcoords_logT to a larger value, then try again.\n'
                      f'(To set both simultaneously, use self.vdem_logT = (new_min, new_coords).)')
            raise InputConflictError(errmsg)
        self._vdem_logT_min = value

    @property
    def vdem_logT(self):
        '''(self.vdem_logT_min, self.rcoords_logT).
        Can set both at once via self.vdem_logT = (desired_min, desired_coords)
        '''
        return (self.vdem_logT_min, self.rcoords_logT)
    @vdem_logT.setter
    def vdem_logT(self, value):
        newmin, coords = value
        minval = np.min(coords).item()
        if newmin > minval:
            errmsg = (f'cannot set self.vdem_logT = (newmin, newcoords) when newmin > min(newcoords)!\n'
                      f'got newmin={newmin:.3g}, min(newcoords)={minval:.3g}.')
            raise InputConflictError(errmsg)
        # avoid ever having logT_min > min(rcoords_logT):
        oldmin = self.vdem_logT_min
        self.vdem_logT_min = min(oldmin, newmin)
        # actually set the values:
        self.rcoords_logT = coords
        self.vdem_logT_min = newmin  # <-- in case newmin > oldmin

    cls_behavior_attrs.register('rcoords_logD_cgs', default=None)
    rcoords_logD_cgs = simple_property('_rcoords_logD_cgs', default=None, #np.arange(8,11,0.5), 
            doc='''log10(electron number density [cgs units]) values, used by some MhdRadiativeLoader vars.
            E.g., used in vdems and used by G(T,ne) when self.gofnt_mode=='density'.
            Can be a scalar, a 1D list or numpy array, or 1D xr.DataArray with dimension 'logD'.''')
    @known_var
    def get_rcoords_logD(self):
        '''log(electron number density) values for VDEMs and G(T,ne), with ne in [self.units] system,
        along dimension 'logD', with coords always telling log10(ne [cgs]) regardless of self.units.
        Determined by self.rcoords_logD_cgs (which can be set directly), converted to [self.units].

        Getting self('rcoords_logD') will crash if self.rcoords_logD_cgs is None, with a helpful error message.
        '''
        if self.rcoords_logD_cgs is None:
            errmsg = ('Cannot get self("rcoords_logD") because self.rcoords_logD_cgs is None.\n'
                      'To fix, set self.rcoords_logD_cgs to a scalar, a 1D xr.DataArray with dimension "logD"\n'
                      'or a 1D list or numpy array, e.g., self.rcoords_logD_cgs=np.arange(8,11,0.5)')
            raise InputMissingError(errmsg)
        result = xr1d(self.rcoords_logD_cgs, name='logD', lenient=True)  # log10([cgs])
        return result + np.log10(self.u("number_density", convert_from='cgs'))   # log10([self.units])

    @property
    def vdem_varies_logD(self):
        '''tells whether vdems should also vary across logD (electron number density) dimension,
        in addition to temperature (logT) and velocity (vdop) dimensions.
        Currently, equivalent to: (self.rcoords_logD_cgs is not None).
            (Eventually, might allow to edit this property directly, instead.)
        '''
        return self.rcoords_logD_cgs is not None

    # other vdem settings:
    cls_behavior_attrs.register('vdem_ignores_photosphere', default='z=0')
    vdem_ignores_photosphere = simple_property('_vdem_ignores_photosphere', default='z=0',
            doc='''Specifies how the photosphere is treated in VDEM calculations.
            Options include:
                'z=0' --> account for photosphere by ignoring all values at z < 0.
                False --> do not worry about photosphere.''')

    @known_pattern(r'_vdem_ignores_photosphere_(.+)', deps=[0])
    def get__vdem_ignores_photosphere_var(self, var, *, _match=None):
        '''_vdem_ignore_photosphere_var --> self(var) but possibly setting values at z<0 to 0,
        depending on self.vdem_ignores_photosphere.
        '''
        here, = _match.groups()
        arr = self(here)
        if self.vdem_ignores_photosphere == 'z=0':
            arr = arr.where(arr['z'] >= 0, 0)
        return arr

    cls_behavior_attrs.register('vdem_mode', default='interp')
    VDEM_MODE_OPTIONS = {
        'interp': (
            "Interpolate the velocity-differential emission measure (VDEM) in temperature only."
            "This mode uses 'Matthias trick' for temperature binning, providing a balance between speed and accuracy."
        ),
        'nointerp': (
            "No interpolation: "
            "Computes the VDEM by direct binning in both temperature and velocity without any interpolation. "
        ),
        'allinterp': "Interpolate in both temperature and velocity.",
    }
    vdem_mode = simple_property('_vdem_mode', default='interp', validate_from='VDEM_MODE_OPTIONS',
            doc='''Selects the velocity-differential emission measure (VDEM) calculation mode.
            See self.VDEM_MODE_OPTIONS for options.''')

    _DEFAULT_VDEM_LOS = '-component'

    cls_behavior_attrs.register('vdem_los', default=None)
    vdem_los = simple_property('_vdem_los', default=None,
        doc='''tells which line of sight to integrate along for vdem calculations.
        if None, vdems will use self.los if provided, else self._DEFAULT_VDEM_LOS (default: '-component').
            (see self.LOS_OPTIONS for more details about the available shorthand options.
                Another option is to use an xr.DataArray with 'component' dimension.)
        The default ('-component') means the opposite direction implied by self.component,
            e.g. if self.component=='z', then vdem_los is '-z' (corresponding to paths
                which start at top of box and proceed downwards along z).
        Use self.vdem_los_explicit to get the actual value that will be used
            (filling in default or value from self.los as appropriate).''')

    vdem_los_explicit = alias('vdem_los',
        doc='''self.vdem_los, but if None convert to the explicit vdem_los to use.''')
    @vdem_los_explicit.getter
    def vdem_los_explicit(self):
        result = self.vdem_los
        if result is None:
            result = self.los
            if result is None:
                result = self._DEFAULT_VDEM_LOS
        return result

    # vdem vars:
    @known_var(deps=['emiss_for_vdem_loi', 'u_loi_los', 'T_loi', 'rcoords_vdop', 'rcoords_logT'])
    def get_vdem(self):
        '''VDEM (intergrated along an axis), as a function of temperature and velocity.
        If 

        Result depends on self.vdem_mode; see self.VDEM_MODE_OPTIONS for details.
        See also: self.interp_masked

        Regardless of self.vdem_mode, the following always apply:
            temperature and velocity are determined by self.rcoords_logT and self.rcoords_vdop_kms,
                which can be set directly.

            Result is always in [cgs] units, regardless of self.units.
            (Result's coords' units will still be self.units, or self.coords_units if not None.)

            Might ignore photosphere, depending on self.vdem_ignores_photosphere;
                see help(type(self).vdem_ignores_photosphere) for details.

            Result is along line of sight corresponding to self.component (must be a single value).
            Can set via, e.g., self.component='x', or self('vdem_y'), or self(var, component='z').

            Ignores emiss contributions from very cold regions (log10(T) < self.vdem_logT_min).
        '''
        vdem_mode = self.vdem_mode

        with self.using(coords_units=self.coords_units_explicit, units='cgs', los=self.vdem_los_explicit):
            x = self._loi_integration_dim()  # e.g. 'x' if 'component' mode and self.component=='x'.
            # (also, e.g., 'ray_s_dim' or 'ray_s' if in 'ray_draping' mode.)

            ### get relevant vals ###
            dloi = self('dloi')  # get dloi first <--> crashes sooner if crash is needed.
            T = self("T_loi")
            ulos = self('u_loi_los')
            emiss = self('emiss_for_vdem_loi', _T_loi=T)

            if self.vdem_varies_logD:
                ne = self('ne_loi')
                logD = self('rcoords_logD')

            ### compute masks ###
            logT = self('rcoords_logT')
            vdop = self('rcoords_vdop')
            if vdem_mode == 'nointerp':
                maskT = self.interp_masked(np.log10(T), bins=logT, dim=x, mode='simple')
                maskvel = self.interp_masked(ulos, bins=vdop, dim=x, mode='simple')
                arrays = [maskT, maskvel, dloi, emiss]
                if self.vdem_varies_logD:  # put maskD too!
                    maskD = self.interp_masked(np.log10(ne), bins=logD, dim=x, mode='simple')
                    arrays = [maskT, maskvel, maskD, dloi, emiss]
            else:  # nonzero interpolation:
                vel_interp_mode = 'correct' if vdem_mode == 'allinterp' else 'simple'
                maskT, xn = self.interp_masked(np.log10(T), bins=logT, dim=x, return_weights='exp')
                maskvel = self.interp_masked(ulos, bins=vdop, dim=x, mode=vel_interp_mode)

                # apply interpolation correction xn factors to emiss:
                emiss1 = emiss.roll({x:1})
                emiss_interped = emiss * (1.0 - xn) + emiss1 * xn
                arrays = [maskT, maskvel, dloi, emiss_interped]

                if self.vdem_varies_logD:  # put maskD too!
                    maskD = self.interp_masked(np.log10(ne), bins=logD, dim=x, mode=vel_interp_mode)
                    arrays = [maskT, maskvel, maskD, dloi, emiss_interped]

                # drop first point (roll() makes it untrustworthy):
                arrays = [a.isel({x: slice(1, None, None)}) if x in a.dims else a for a in arrays]

            ### compute vdem = (maskT * maskvel * dloi * emiss).sum(x) ###
            vdem = self._vdem_multiply_then_sum_along(*arrays, dim=x)
            return vdem

    @known_var(deps=['(_vdem_ignores_photosphere_emiss)_loi', 'T_loi'])
    def get_emiss_for_vdem_loi(self, *, _T_loi=None):
        '''emiss_for_vdem_loi = emiss_loi, but with some adjustments for vdems:
            - ignores emiss contributions from very cold regions (log10(T) < self.vdem_logT_min).
            - depending on self.vdem_ignores_photosphere, might set to 0 below z=0

        [EFF] for efficiency, can provide _T_loi if known.
        '''
        T = self('T_loi') if _T_loi is None else _T_loi
        emiss = self('(_vdem_ignores_photosphere_emiss)_loi', emiss_opa=False)
        # ^ need to do that before applying tau, because tau depends on loi
        if self.emiss_opa:
            tau = self('tau')  # implied: tau_loi
            emiss = emiss * np.exp(-tau)   # apply tau correction
        emiss = xr.where(T > 10**(self.vdem_logT_min), emiss, 0)
        return emiss

    @known_var(deps=['vdem'], aliases=['vdem_nointerp'])
    def get_vdem_no_interp(self):
        '''VDEM (intergrated along an axis), without interpolation.
        See self.help('vdem') or help(self.get_vdem) for more details about vdems.
        Equivalent: self('vdem', vdem_mode='nointerp').
        '''
        return self('vdem', vdem_mode='nointerp')

    @known_var(deps=['vdem'])
    def get_vdem_interp(self):
        '''VDEM (intergrated along an axis), with interpolation (Matthias trick along T)
        See self.help('vdem') or help(self.get_vdem) for more details about vdems.
        Equivalent: self('vdem', vdem_mode='interp').
        '''
        return self('vdem', vdem_mode='interp')

    @known_var(deps=['vdem'])
    def get_vdem_allinterp(self):
        '''VDEM (intergrated along an axis), with interpolation (Matthias trick along T and u)
        See self.help('vdem') or help(self.get_vdem) for more details about vdems.
        Equivalent: self('vdem', vdem_mode='allinterp').
        '''
        return self('vdem', vdem_mode='allinterp')
    
    # vdem looping details:
    # cls_behavior_attrs <-- intentionally not registered, because shouldn't affect results!
    #  it just affects internal behaviors and efficiency.
    #  instead, added to self._extra_kw_for_quantity_loader_call,
    #    so it can still be adjusted in the usual way, like self(var, vdem_loopdim=...)
    vdem_loopdim = simple_property('_vdem_loopdim', default=('int', 0.45, 'GB'),
        doc=f'''Tells which dimension to loop over during vdems.
        Passed as "loop" kwarg to xarray_multiply_then_sum_along. Docs for that parameter copied below:
        {xarray_multiply_then_sum_along._paramdocs_dict['loop']}''')

    _vdem_errmsg_if_too_big = \
        f'''{xarray_multiply_then_sum_along._errmsg_default}
        
        - for chunking, can adjust self.chunks to break up the problem along self.maindims.
            E.g., self(vdem_var, chunks=dict(x_size=5)) does 5 points at a time along x,
                and joins at the end. It also enables multiprocessing, e.g. self(..., ncpu=10).

        - can also adjust self.vdem_loopdim. E.g. self('vdem', vdem_loopdim='logT')
            Or, vdem_loopdim='int', to loop across the dimension being integrated.
            The default is pretty good too, though: vdem_loopdim=('int', 0.45, 'GB').'''

    def _vdem_multiply_then_sum_along(self, *arrays, dim):
        '''multiply arrays then sum along dim, without making big intermediate array.
        Like xarray_multiply_then_sum_along, but uses vdem-relevant defaults from self.
        '''
        return xarray_multiply_then_sum_along(*arrays, dim=dim, loop=self.vdem_loopdim,
                                              errmsg=self._vdem_errmsg_if_too_big)


    # # # MOMENTS # # #

    @known_pattern(r'(.+)_moments_(.+)', deps=[1])
    def get_moments(self, var, *, _match=None):
        """First three moments of a var, e.g., vdem or spectra. 
        I0 = (int(I du)) [erg/cm^2/s/sr]
        I1 = (int(I u du))/I0 [km/s]
        I2 = (int(I u^2 du))/I0-I1^2 [km/s]

        dim_moments_var --> self(var).pc.moments(dim=dim).
        e.g., vdop_moments_vdem --> self('vdem').pc.moments(dim='vdop')

        result is a Dataset with data_vars 'moment_0', 'moment_1', 'moment_2'.
        """
        dim, var, = _match.groups()
        arr = self(var)
        return xarray_moments(arr, dim=dim)


    # # # Thermal + Instrumental Velocity # # #

    cls_behavior_attrs.register('w_instrumental_si', default=0.0)
    w_instrumental_si = simple_property('_w_instrumental_si', default=0.0,
        doc='''instrumental broadening for thermal velocity, in SI units.
        vtherm_instr = sqrt((kB T / m) + self.w_instrumental_si^2)''')

    @known_var
    def get_w_instrumental(self):
        '''instrumental broadening for thermal velocity, in self.units system.
        w_instrumental = self.w_instrumental_si, converted to self.units system.

        See also: vtherm_instr
        '''
        return xr.DataArray(self.w_instrumental_si * self.u('u', convert_from='si'))

    @known_var(deps=['Tjoule', 'm', 'w_instrumental'], aliases=['vth_instr', 'vthermal_instr'])
    def get_vtherm_instr(self):
        '''thermal velocity including instrumental broadening.
        vtherm_instr = sqrt((kB T / m) + w_instrumental^2)

        w_instrumental can be set via self.w_instrumental_si = value.
        '''
        return (self('Tjoule')  / self('m') + self('w_instrumental')**2)**0.5


    # # # G(T), G(T,n) # # #
    # contribution function(?)
    # depends on electron pressure or number density.

    # electron pressure and number density values determined by rcoords_Pe_cgs and rcoords_logD_cgs.
    # (rcoords_logD_cgs defined above, since it can also be used in vdem.)
    cls_behavior_attrs.register('rcoords_Pe_cgs', default=1e15)
    rcoords_Pe_cgs = simple_property('_rcoords_Pe_cgs', default=1e15, 
            doc='''electron Pressure [cgs units] values, used by some MhdRadiativeLoader vars.
            E.g., used by G(T) when self.gofnt_mode=='pressure'.
            Can be a scalar, a 1D list or numpy array, or 1D xr.DataArray with dimension 'Pe'.''')
    @known_var
    def get_rcoords_Pe(self):
        '''electron Pressure values for G(T) for fixed pressure, in [self.units] system,
        along dimension 'P', with coords always in [cgs] units regardless of self.units.
        Determined by self.rcoords_Pe_cgs (which can be set directly), converted to [self.units].
        '''
        result = xr1d(self.rcoords_Pe_cgs, name='Pe', lenient=True)  # [cgs]
        return result * self.u("pressure", convert_from='cgs')  # [self.units]

    # other G(T) settings:
    cls_behavior_attrs.register('gofnt_mode', default='pressure')
    GOFNT_MODE_OPTIONS = {'pressure': 'Compute G(T) for a fixed electron pressure',
                          'density': 'Compute G(T,ne)'
                          }
    gofnt_mode = simple_property('_gofnt_mode', default='pressure', validate_from='GOFNT_MODE_OPTIONS',
            doc='''Selects the G(T) calculation mode. See self.GOFNT_MODE_OPTIONS for options.''')
    
    cls_behavior_attrs.register('gofnt_interp_mode', default='interp')
    GOFNT_INTERP_MODE_OPTIONS = {
        'interp': (
            "Interpolate the emission measure x G(T) in temperature."
            "This mode uses 'Matthias trick' for temperature binning, providing a balance between speed and accuracy."
        ),
        'nointerp': (
            "No interpolation: "
            "Computes the emission measure x G(T) direct binning in both temperature and velocity without any interpolation. "
        ),
    }
    gofnt_interp_mode = simple_property('_gofnt_interp_mode', default='interp',
            validate_from='GOFNT_INTERP_MODE_OPTIONS',
            doc='''Selects the emission measure x G(T) calculation mode.
            See self.GOFNT_INTERP_MODE_OPTIONS for options.''')

    cls_behavior_attrs.register('gofnt_abundance', default='sun_coronal_2021_chianti')
    gofnt_abundance = simple_property('_gofnt_abundance', default='sun_coronal_2021_chianti',
            doc='''Abundance(?) to use for G(T) calculations.
            Passed directly to muse.instr.utils.chianti_gofnt_linelist via "abundance" kwarg.''')

    cls_behavior_attrs.register('gofnt_wavelength_range', default=[171.07, 171.08])
    gofnt_wavelength_range = simple_property('_gofnt_wavelength_range', default=[171.07, 171.08],
        doc='''Wavelength range (as [min, max]) (in [Angstrom] units) to use for G(T) calculations.
        Passed directly to muse.instr.utils.chianti_gofnt_linelist via "wavelength_range" kwarg.''')

    cls_behavior_attrs.register('rcoords_wavelength_A', default=171.07)
    rcoords_wavelength_A = simple_property('_rcoords_wavelength_A', default=171.07,
        doc='''Wavelength (in [Angstrom] units) used by some MhdRadiativeLoader vars.
        E.g., used in get_spectra().
        [TODO] can it be a 1D list/array, or is it required to be a scalar?''')
    
    
    # G(T) vars
    @known_pattern(r'GofT_(.+)_ion', deps=['rcoords_logT'],
                   attr_deps=[('gofnt_mode', {'pressure': 'rcoords_Pe', 'density': 'rcoords_logD'})])
    def get_Gofnt(self, var, *, _match=None):
        '''G(T,ne) from ChiantiPy and muse library for a given ion. E.g., GofT_fe_9_ion
        Always in [cgs] (erg*cm^3/s/sr) regardless of self.units.

        ion string (like 'fe_9') is passed into muse.instr.utils.chianti_gofnt_linelist,
            via ionList = [ionstr]. See that function for more details about available ions.

        "returns G(T,ne) and spectral line properties, including wavelength and transition probabilities." 
        [TODO] what does that mean?

        CAUTION: directly adjusts the value of self.rcoords_wavelength_A, based on result.
        '''
        ionstr, = _match.groups()

        # finds in Chiantypy the line list with the properties listed above
        if self.gofnt_mode == "pressure":
            line_list = muse_instr_utils.chianti_gofnt_linelist(
                temperature = 10**self('rcoords_logT'),
                pressure = self('rcoords_Pe', units='cgs'),  # ChiantiPy wants cgs
                abundance = self.gofnt_abundance,
                wavelength_range = self.gofnt_wavelength_range,
                ionList = [ionstr],
                minimum_abundance = None,
            )

            # Selecting the strongest line
            line_list["gofnt_max"] = line_list.gofnt.sum(['logT']) 
        elif self.gofnt_mode == 'density':
            line_list = muse_instr_utils.chianti_gofnt_linelist(
                temperature = 10**self('rcoords_logT'),
                density = 10**self('rcoords_logD', units='cgs'),   # ChiantiPy wants cgs
                abundance = self.gofnt_abundance,
                wavelength_range = self.gofnt_wavelength_range,
                ionList = [ionstr],
                minimum_abundance = None,
            )

            # Selecting the strongest line
            line_list["gofnt_max"] = line_list.gofnt.sum(['logT']) 
        else:
            assert False, f"coding error if reached this line; invalid gofnt_mode: {self.gofnt_mode!r}"
        sort_index = np.argsort(-line_list.gofnt_max, 
                                axis=line_list.gofnt_max.get_axis_num('trans_index'))
        line_list_sort = line_list[dict(trans_index=sort_index)]
        line_list_sort_c = line_list_sort.isel(trans_index=0)
        # SE - it's weird for a known_var to alter a behavior_attr
        # JMS yes ... but it's convenient here.
        # [TODO] long-term, better to include directly in output somehow (e.g., array.attrs?)
        #    (SE - for now it is okay. left a note in docstring to let user know the attr will be adjusted.)
        self.rcoords_wavelength_A = line_list_sort_c.squeeze().wvl.values
        return line_list_sort_c

    @known_pattern(r'GofT_(.+)_ion_atmos', deps=['T', {0: 'GofT_{group0}_ion'}],
                   attr_deps=[('gofnt_mode', {'density': 'ne'})])
    def get_Gofnt_atmos(self, var, *, _match=None):
        '''Interpolated G(T) with temperature, for a given ion.
        Always in [cgs] (erg*cm^3/s/sr) regardless of self.units.
        For details about available ions, see muse.instr.utils.chianti_gofnt_linelist
        '''
        ionstr, = _match.groups()
        with self.using(coords_units=self.coords_units_explicit, units='cgs'):
            goftn = self(f'GofT_{ionstr}_ion').gofnt * self.emiss_norm
            tg = self('T')
            if self.gofnt_mode == "pressure":
                return goftn.interp(logT=np.log10(tg))
            elif self.gofnt_mode == "density": 
                nel = self('ne')
                return goftn.interp(logT=np.log10(tg),logD=np.log10(nel))
            else:
                assert False, f"coding error if reached this line; invalid gofnt_mode: {self.gofnt_mode!r}"

    @known_pattern(r'GofT_(.+)_ion_em',
                   attr_deps=[('gofnt_interp_mode', {'interp': [{0: 'GofT_{group0}_ion_em_interp'}],
                                                   'nointerp': [{0: 'GofT_{group0}_ion_em_no_interp'}]})])
    def get_Gofnt_em(self, var, *, _match=None):
        '''Emission * G(T) for a given ion. Results always in [cgs] (erg/cm^2/s/sr).
        For details about available ions, see muse.instr.utils.chianti_gofnt_linelist
        '''
        __tracebackhide__ = DEFAULTS.TRACEBACKHIDE
        ionstr, = _match.groups()

        if self.gofnt_interp_mode == "interp": 
            return self(f"GofT_{ionstr}_ion_em_interp")
        elif self.gofnt_interp_mode == "nointerp": 
            return self(f"GofT_{ionstr}_ion_em_no_interp")
        else:
            assert False, f"coding error if reached this line; invalid gofnt_interp_mode: {self.gofnt_interp_mode!r}"

    @known_pattern(r'GofT_(.+)_ion_em_no_interp', deps=['emiss', {0: 'GofT_{group0}_ion_atmos'}])
    def get_Gofnt_em_nointerp(self, var, *, _match=None):
        '''Emission * G(T) for a given ion. Results always in [cgs] (erg/cm^2/s/sr).
        For details about available ions, see muse.instr.utils.chianti_gofnt_linelist
        '''
        __tracebackhide__ = DEFAULTS.TRACEBACKHIDE
        ionstr, = _match.groups()
        with self.using(coords_units=self.coords_units_explicit, units='cgs'):      
            goftn = self(f'GofT_{ionstr}_ion_atmos')
            em = self('emiss')
            return goftn * em

    @known_pattern(r'GofT_(.+)_ion_em_interp', deps=['emiss', 'T', {0: 'GofT_{group0}_ion'}])
    def get_Gofnt_em_interp(self, var, *, _match=None): # uhm .... not sure why, but for not sharp temperature gradient has an artifact ... 
        '''Emission * G(T) for a given ion. Results always in [cgs] (erg/cm^2/s/sr).
        For details about available ions, see muse.instr.utils.chianti_gofnt_linelist
        '''
        self._radiative_assert_single_component()
        ionstr, = _match.groups()
        x = str(self.component)
        with self.using(coords_units=self.coords_units_explicit, units='cgs'):      
            goftn_atmos = self(f'GofT_{ionstr}_ion_atmos')
            emiss = self('emiss')
            emiss = emiss * (emiss["z"] > 0)  # [TODO] connect to "vdem_ignores_photosphere" option or something like it

            emiss1 = emiss.roll({x:1})
            # removing emission from first cell due to roll
            emiss1[{x:0}] = 0.0

            masktg, xn = self.interp_masked(np.log10(self('T')), bins=self('rcoords_logT'), dim=x, return_weights='exp')
            emiss_interped = emiss * (1.0 - xn) + emiss1 * xn  # emiss with interp correction weighting adjacent cells

            result = masktg * goftn_atmos * emiss_interped
            return result.sum(dim='logT')


    @known_var(deps=['ne', 'T', 'r', 'dloi'])
    def get_tau_500(self, wavelength=None):
        '''Computes the optical depth at 500 nm.
        '''

        with self.using(coords_units=self.coords_units_explicit, units='cgs', los=self.vdem_los_explicit):   

            with self.using(coords_units=self.coords_units_explicit, units='cgs'):      
                x = self._loi_integration_dim()  # e.g. 'x' if 'component' mode and self.component=='x'.
                # (also, e.g., 'ray_s_dim' or 'ray_s' if in 'ray_draping' mode.)

                ### get relevant vals ###
                dloi = self('dloi') 

                kb = DEFAULTS.PHYSICAL.CONSTANTS_SI['kB'] * 1e7  # cgs units
                grph = (self.elements.n_per_nH() * self.elements.m * self.u('amu')).sum() 
                const = (1.03526e-16 / grph) * 2.9256e-17
                ev_to_erg = 1.60218e-12
                tg = self('T_loi')
                xhmbf = const * self('ne_loi') / tg**1.5 * np.exp(0.754e0 * ev_to_erg / kb / tg) * self('r_loi')
                tau = self.loi_cumsum(xhmbf * dloi, do_interp=False)
                return tau
        
    @known_var
    def get_opac_h_he(self, wavelength=None):
        '''Opacities at wavelength=self.rcoords_wavelength_A.
        from hydrogen and helium bound free absorption (Anzer & Heinzel, 2005)
        '''
        rhe = 0.1
        opa1d = self('chianti_opa1d_table')
        arr = (opa1d.h) * self('opac_h') + rhe * ((1 - opa1d.hei - (1-opa1d.hei-opa1d.he)) *
                                                  self('opac_hei') + (opa1d.hei) * self('opac_heii'))
        arr[arr < 0] = 0
        return arr

    @known_var
    def get_chianti_opa1d_table(self, tabname='chianti'):
        '''Ionization state table from ChiantiPy.
        result is an xr.Dataset with data_vars 'h', 'he', 'hei',
            telling the H, HeI, and HeII ionization fractions, respectively.
        '''
        h = ch.Ioneq.ioneq(1)
        h.load(tabname)
        he = ch.Ioneq.ioneq(2)
        he.load(tabname)
        opa1d = xr.Dataset()
        opa1d['he'] = xr.DataArray(he.Ioneq[0, :], coords={"logT": np.log10(he.Temperature)}, dims="logT")
        opa1d['hei'] = xr.DataArray(he.Ioneq[1, :], coords={"logT": np.log10(he.Temperature)}, dims="logT")
        opa1d['h'] = xr.DataArray(h.Ioneq[0, :], coords={"logT": np.log10(h.Temperature)}, dims="logT")
        return opa1d

    @known_var
    def get_opac_h(self):
        '''H opacity at self.rcoords_wavelength_A.
        See Anzer & Heinzel, 2005 for details. 
        '''
        ghi = 0.99
        o0 = 7.91e-18  # cm^2
        ohi = 0
        if self.rcoords_wavelength_A <= 912: # Amstrong
            ohi = o0 * ghi * (self.rcoords_wavelength_A / 912.0)**3
        return ohi

    @known_var
    def get_opac_hei(self):
        '''HeI opacity at self.rcoords_wavelength_A.
        See Anzer & Heinzel, 2005 for details. 
        '''
        c = [-2.953607e1, 7.083061e0, 8.678646e-1,
             -1.221932e0, 4.052997e-2, 1.317109e-1,
             -3.265795e-2, 2.500933e-3]
        ohei = 0
        if self.rcoords_wavelength_A <= 504: # Amstrong
            for i, cf in enumerate(c):
                ohei += cf * (np.log10(self.rcoords_wavelength_A))**i
            ohei = 10.0**ohei
        return ohei

    @known_var
    def get_opac_heii(self):
        '''HeII opacity at self.rcoords_wavelength_A.
        See Anzer & Heinzel, 2005 for details. 
        '''
        gheii = 0.85
        o0 = 7.91e-18  # cm^2
        oheii = 0
        if self.rcoords_wavelength_A <= 228: # Amstrong
            oheii = 16 * o0 * gheii * (self.rcoords_wavelength_A / 912.0)**3
        return oheii

    @known_var(deps=['r', 'opac_h_he', 'T'])
    def get_absorption_h_he(self):
        '''Absorption coefficient at wavelength=self.rcoords_wavelength_A,
            considering only H, HeI, and HeII contributions.
        Result is always in [cgs] units, regardless of self.units.
        See Anzer & Heinzel, 2005 for details.

        absorption_h_he = (opacity from h and he) * (single-fluid mass density) / grph,
            where grph = mean molecular mass (same unit system as other terms in expression).
            Assuming photopsheric abundances, grph is roughly 1.3 to 1.5 amu, depending on
            exact abundances model (but constant in space & time; it just depends on self.elements).
        '''
        with self.using(coords_units=self.coords_units_explicit, units='cgs'):   
            rho = self("r")
            grph = (self.elements.n_per_nH() * self.elements.m * self.u('amu')).sum() 
            opac_h_he = self('opac_h_he')
            opac = opac_h_he.interp(logT=np.log10(self('T')))
            opac = opac.fillna(opac.max())
            return (opac * rho) / grph

    @known_var(deps=['absorption_h_he_loi', 'dloi'])
    def get_tau(self):
        '''Optical depth along line of integration (default: loi = line of sight),
        at wavelength=self.rcoords_wavelength_A, and considering only H, HeI, and HeII contributions.
        Result is always in [cgs] units, regardless of self.units.

        loi determined by self.loi (see help(type(self).loi) for details),
            when using self.los = self.vdem_los_explicit (see help(type(self).vdem_los) for details).

        tau(l) = integral(absorption * dl, from 0th l value to l); use self.loi instead of l.
            (in python terms, this is represented by a cumsum.)

        To reverse direction (i.e. "start from the other end"), edit self.loi or self.vdem_los.
            E.g. when self.loi='los', swapping from self.vdem_los='-x' to self.vdem_los='x' swaps direction.
        '''
        with self.using(coords_units=self.coords_units_explicit, units='cgs', los=self.vdem_los_explicit):   
            dloi = self('dloi')   # get dloi first <--> crash sooner if crash is needed.
            absorption = self('absorption_h_he_loi')
            result = self.loi_cumsum(absorption * dloi, do_interp=False)  # interp already handled above
            return result

    @known_pattern(r'spectra_(.+)_ion_prof',
                   deps=['vtherm_instr', 'u', 'rcoords_vdop', {0: 'GofT_{group0}_ion_em'}],
                   attr_deps=[('emiss_mode', {'notrac_opa': 'tau', 'trac_opa': 'tau'})])
    def get_spectra(self, var, *, _match=None): # this one could be break in two functions, one with and the other without abs. 
        '''Synthetic spectra for a given ion. Always in [cgs] units, regardless of self.units.
        Units erg/cm^2/s/sr/Angs

        Result depends on self.rcoords_vdop_kms and self.rcoords_wavelength_A, which can be set directly.
        Result varies along the 'vdop' dimension which indicates doppler velocity (in km/s).

        [TODO] formula/details
        '''
        # [TODO] handle loi properly. For now, might be okay with simple loi, but will crash if ray_draping.
        ionstr, = _match.groups()
        self._radiative_assert_single_component()
        with self.using(coords_units=self.coords_units_explicit, units='cgs', los=self.vdem_los_explicit):
            # force noopa emiss mode, when getting _ion_em
            emiss_mode = self.emiss_mode
            if "_opa" in emiss_mode:
                emiss_mode = emiss_mode.replace("_opa", "_noopa")
            em = self(f'GofT_{ionstr}_ion_em', emiss_mode=emiss_mode)
            sig = self('vtherm_instr')
            vlos = self('u_los')  # [TODO] u_loi_los
            vdop = self('rcoords_vdop')
            wvl = doppler_to_lambda(self.rcoords_vdop_kms, self.rcoords_wavelength_A)  # from km/s to Angstrom
            with self.maintaining('wavelength'):
                I_slice = []
                for i_v, v_arr in enumerate(vdop):  # looping because "tau" not is ready to handle 'vdop' dim.
                    if "_opa" in self.emiss_mode:
                        self.rcoords_wavelength_A = wvl[i_v]
                        tau = self('tau')
                        xarray_result_size_check(tau, em)  # prevent wayyy too big result.
                        em_opa = em * np.exp(-self("tau"))
                    else:
                        em_opa = em
                    phi = np.exp(-0.5 * ((v_arr - vlos) / sig)**2) / (np.sqrt(2 * np.pi) * sig)
                    dlos = self('dloi')
                    xarray_result_size_check(em_opa, phi, dlos)  # prevent wayyy too big result.
                    I_slice.append((em_opa * phi * dlos).sum(dim=los))
            int = xr.concat(I_slice,dim="vdop")
            int = int.assign_coords(wavelength=("vdop",wvl))
        return int


    # # # INTERPOLATION CORRECTIONS # # #

    @format_docstring(_static_helper_doc=interp_masked.__doc__, sub_ntab=2)
    def interp_masked(self, array_or_var, bins, dim=None, *, mode='correct', return_weights=False):
        """
        Compute interpolation masks and normalization factors for a variable (e.g., "T")
        for use in DEM/VDEM binning with or without logarithmic scaling.

        Works just like the "static" method (not attached to this class),
        but with a few extra convenient features here:
            (1) first arg can be array (as in static method), OR a var.
                if it is a var (i.e., a str), use array=self(var)
            (2) dim does not need to be provided;
                if not provided, use dim=str(self.component).

        Docstring from "static" method copied below, for convenience.
        -------------------------------------------------------------
            {_static_helper_doc}
        """
        if isinstance(array_or_var, str):
            var = array_or_var
            array = self(var)
        else:
            array = array_or_var
        if dim is None:
            dim = str(self.component)
        return interp_masked(array=array, bins=bins, dim=dim, mode=mode, return_weights=return_weights)


    # # # VDEM PIPELINE # # #

    def vdem_pipeline(self, *, 
                      los_dim='z', 
                      iz0 = 50, 
                      tg_percent = 0.1, 
                      dlogT = 0.1,
                      mintg_cut = 4.6, 
                      maxtg_cut = 7.7, 
                      tg_bins = 60,
                      ulos_bin = 60,
                      vel_percent = 0.1,
                      dvdop = 10,
                      minvel_cut = -3000,
                      maxvel_cut = 3000,
                      emiss_norm=1e27,
                      modelname = '',
                      zarr_version = 2,
                      chunks=256,
                      ncpu=12,
                      dst='pc_vdem_pipeline',
                      author,
                      ):
        """
        Execute a standard VDEM (Velocity-Differential Emission Measure) analysis pipeline.

        This pipeline performs a complete VDEM analysis including:
        1. Automatic determination of temperature and velocity ranges from data histograms
        2. Computation of VDEM with configurable binning
        3. Generation of moment plots (intensity, mean velocity, width)
        4. Photosphere region analysis with separate plots
        5. Saving results to Zarr format with complete metadata

        The pipeline automatically determines optimal temperature and velocity bins based on
        histogram analysis, applies user-specified cutoffs, computes VDEM along the specified
        line-of-sight dimension, and generates diagnostic plots.

        Parameters
        ----------
        los_dim : str, default='z'
            Line-of-sight dimension for VDEM integration ('x', 'y', or 'z').
        iz0 : int, default=50
            Index along z-dimension marking the photosphere boundary. Used to define
            integration limits and photosphere cutoff analysis.
        tg_percent : float, default=0.1
            Percentage threshold for automatic temperature range determination.
            Histogram bins with density > tg_percent% of maximum are included.
        dlogT : float, default=0.1
            Temperature bin width in log10(T [K]) units.
        mintg_cut : float or None, default=4.6
            Minimum log10(temperature [K]) cutoff. Overrides automatic determination
            if specified.
        maxtg_cut : float or None, default=7.7
            Maximum log10(temperature [K]) cutoff. Overrides automatic determination
            if specified.
        tg_bins : int, default=60
            Number of bins for temperature histogram analysis.
        ulos_bin : int, default=60
            Number of bins for velocity histogram analysis.
        vel_percent : float, default=0.1
            Percentage threshold for automatic velocity range determination.
            Histogram bins with density > vel_percent% of maximum are included.
        dvdop : float, default=10
            Velocity bin width in km/s.
        minvel_cut : float or None, default=-3000
            Minimum velocity cutoff in km/s. Overrides automatic determination if specified.
        maxvel_cut : float or None, default=3000
            Maximum velocity cutoff in km/s. Overrides automatic determination if specified.
        emiss_norm : float, default=1e27
            Emission measure normalization factor.
        modelname : str, default=''
            Model identifier string used in output filenames.
        zarr_version : int, default=2
            Zarr format version for output files (2 or 3).
        chunks : int, default=256
            Chunk size for parallel computation along spatial dimensions.
        ncpu : int, default=12
            Number of CPU cores to use for parallel computation.
        dst : str, default='pc_vdem_pipeline'
            Destination directory for output files.
        author : str, required
            Author name/identifier for output filenames and metadata.

        Returns
        -------
        vdem0 : xarray.Dataset
            Processed VDEM dataset with coordinates and attributes set for the full domain.
            Contains 'vdem' data variable with dimensions (logT, vdop, x, y).

        Notes
        -----
        The pipeline generates the following outputs:
        
        - Temperature and velocity histogram plot: 
          'T_ulos_hist_{code}_{modelname}_{author}_{date}_{snap}.png'
        - VDEM moments plot (full domain): 
          'vdem_moment_{code}_{modelname}_{author}_{date}_{snap}.png'
        - VDEM moments plot (photosphere): 
          'vdem_moment_ph_{code}_{modelname}_{author}_{date}_{snap}.png'
        - VDEM Zarr dataset: 
          'vdem_{code}_{modelname}_los{los_dim}_{author}_{date}_{snap}.zarr'

        The pipeline automatically:
        - Sets units to CGS
        - Determines integration slice from iz0 to top of domain
        - Computes histograms to find optimal binning
        - Applies both automatic and manual cutoffs for T and velocity ranges
        - Computes VDEM with multiprocessing support
        - Generates moment plots with logarithmic scaling for intensity
        - Performs separate photosphere analysis (iz0 to iz0+5)
        - Adds complete history and metadata to output

        Examples
        --------
        >>> # Standard VDEM pipeline with default settings
        >>> result = calc.vdem_pipeline(author='scientist_name')
        
        >>> # Custom temperature and velocity ranges
        >>> result = calc.vdem_pipeline(
        ...     mintg_cut=5.0,
        ...     maxtg_cut=7.0,
        ...     minvel_cut=-1000,
        ...     maxvel_cut=1000,
        ...     author='scientist_name'
        ... )
        
        >>> # High-resolution analysis with more CPU cores
        >>> result = calc.vdem_pipeline(
        ...     dlogT=0.05,
        ...     dvdop=5,
        ...     chunks=128,
        ...     ncpu=24,
        ...     author='scientist_name'
        ... )

        See Also
        --------
        limb_vdem_pipeline : Pipeline for limb observations with ray draping
        get_vdem : Core VDEM computation method
        """

        import matplotlib.pyplot as plt
        from matplotlib import colors
        from dateutil.tz import gettz
        try:
            import zarr
        except ImportError as err:
            errmsg = ("vdem_pipeline requires zarr; try again after installing, e.g. 'pip install zarr'.")
            raise ImportError(errmsg) from err

        WEST = gettz("US/Western")

        chosen_code = type(self).__name__.replace("Calculator","")
        
        self.units = 'cgs'
        self.emiss_norm = emiss_norm
        self.component = los_dim
        top = np.argmax(self.get_maindims_coords()['z'])
        if iz0 is None:
            iz0 = np.argmin(np.abs(self.get_maindims_coords()['z']))
        if top < iz0:
            sl = slice(top,iz0)
        else:
            sl = slice(iz0,top)

        self.slices = ({'z':sl})
        tg = self("T")
        ulos = self("u", units = 'si') / 1e3 # to km/s

        # The histogram allows to find the temperature and velocity limits
        fig, ax = plt.subplots(1, 2, figsize=(12, 6))
        hist_values = np.log10(tg).plot.hist(density=True, bins=tg_bins, ax=ax[0])

        maxtg = (
                np.ceil(
                    np.max(hist_values[1][np.where(hist_values[0] > np.max(hist_values[0]) * tg_percent / 100.0)])
                    / dlogT
                )
                * dlogT
            )
        mintg = (
                np.floor(
                    np.min(hist_values[1][np.where(hist_values[0] > np.max(hist_values[0]) * tg_percent / 100.0)])
                    / dlogT
                )
                * dlogT
            )
        if mintg_cut is not None:
            mintg = np.max([mintg, mintg_cut])
        if maxtg_cut is not None:
            maxtg = np.min([maxtg, maxtg_cut])

        hist_values = ulos.plot.hist(density=True, bins=ulos_bin, ax=ax[1])
        fig.tight_layout()
        saveto = f"T_ulos_hist_{chosen_code}{modelname}_{author}_{datetime.datetime.now(WEST).date()!s}_{self.snap}.png"
        os.makedirs(dst, exist_ok=True)
        fig.savefig(os.path.join(dst, saveto))
        
        maxvel = (
                np.ceil(
                    np.max(hist_values[1][np.where(hist_values[0] > np.max(hist_values[0]) * vel_percent / 100.0)])
                    / dvdop
                )
                * dvdop
            )
        minvel = (
                np.floor(
                    np.min(hist_values[1][np.where(hist_values[0] > np.max(hist_values[0]) * vel_percent / 100.0)])
                    / dvdop
                )
                * dvdop
            )
        if minvel_cut is not None:
            minvel = np.max([minvel, minvel_cut])
        if maxvel_cut is not None:
            maxvel = np.min([maxvel, maxvel_cut])
        
        self.rcoords_logT = np.arange(mintg, maxtg, dlogT)
        self.rcoords_vdop_kms = np.arange(minvel, maxvel, dvdop)
        self.tabin.extrapolate_kind = "constant"
        vdem = self('vdem', chunks=dict(x=chunks), ncpu=ncpu)
        if self.vdem_varies_logD:
            vdem_sumd = vdem.sum(dim='logD')
        else: 
            vdem_sumd = vdem
        mom = vdem_sumd.pc.moments(dim='vdop')
        fig, ax = plt.subplots(1, 3, figsize=(12, 6))
        mom.moment_0.sum(dim='logT').plot.imshow(norm=colors.LogNorm(vmin=1e-5,vmax=1e5), ax=ax[0])
        mom.moment_1.sum(dim='logT').plot.imshow(ax=ax[1], cmap="bwr")
        mom.moment_2.sum(dim='logT').plot.imshow(ax=ax[2])
        fig.tight_layout()
        saveto = f"vdem_moment_{chosen_code}{modelname}_{author}_{datetime.datetime.now(WEST).date()!s}_{self.snap}.png"
        fig.savefig(os.path.join(dst, saveto))

        vdem_cut = self('vdem', 
                        slices=dict(z=slice(iz0, iz0+5)),
                        emiss_mode = 'notrac_noopa',
                        vdem_logT = (0, np.arange(3.0,4.5,0.1)),
                        rcoords_vdop_kms=np.arange(-1.0e1,1.e1,1),
                        rcoords_logD_cgs = None, 
                        vdem_mode='nointerp',
                        vdem_ignores_photosphere=False,
                        vdem_loopdim=None,
                        chunks=dict(x=chunks),
                        ncpu=ncpu,
                        component='z',
                        )

        mom = vdem_cut.pc.moments(dim='vdop')
        fig, ax = plt.subplots(1, 2, figsize=(12, 6))
        mom.moment_0.sum(dim='logT').plot(ax=ax[0])
        mom.moment_1.sum(dim='logT').plot(vmin=-5,vmax=5,cmap='bwr', ax=ax[1])
        ax[1].set_title("Doppler velocity (positive must be downward)")
        fig.tight_layout()
        saveto = f"vdem_moment_ph_{chosen_code}{modelname}_{author}_{datetime.datetime.now(WEST).date()!s}_{self.snap}.png"
        fig.savefig(os.path.join(dst, saveto))

        xarray_update_call_history(vdem, locals(), self.vdem_pipeline)

        vdem0 = vdem.to_dataset(name='vdem')
        # _xarray_save_prep(vdem)[0] trashes PlasmaCalcs rich metadata like Snap and Component objects,
        #  and adds some helpful attrs like pc__version__, datetime, and maybe pc__commit_hash.
        vdem0 = _xarray_save_prep(vdem0, add_code_snapshot_info=dict(default='_undefined_'))[0]
        vdem0.x.attrs["long_name"] = "X"
        vdem0.y.attrs["long_name"] = "Y"
        vdem0.x.attrs["units"] = "cm"
        vdem0.y.attrs["units"] = "cm"
        vdem0.vdem.attrs["units"] = "1e27 / cm5"
        vdem0.vdem.attrs["description"] = "DEM(T,vel,x,y)"
        vdem0.vdop.attrs["long_name"] = r"v$_{Doppler}$"
        vdem0.vdop.attrs["units"] = "km/s"
        vdem0.logT.attrs["long_name"] = r"log$_{10}$(T)"
        vdem0.logT.attrs["units"] = r"log$_{10}$ (K)"
        saveto = f"vdem_{chosen_code}{modelname}_los{los_dim}_{author}_{datetime.datetime.now(WEST).date()!s}_{self.snap}.zarr",
        vdem0.to_zarr(os.path.join(dst, saveto), zarr_version=zarr_version)
        return vdem0

    def limb_vdem_pipeline(self, *, 
                      iz0 = 50,
                      snap = None,
                      tile_snaps = None,
                      fov_method = "standard",
                      Abox_x_NXEXT = 0,
                      Nx_times = 1,
                      Ny_times = 1,
                      Nz_times = 1,
                      Asph_theta=np.pi/2,
                      Asph_phi=0,
                      tg_percent = 0.1, 
                      dlogT = 0.1,
                      mintg_cut = 4.6, 
                      maxtg_cut = 7.7, 
                      tg_bins = 60,
                      ulos_bin = 60,
                      vel_percent = 0.1,
                      dvdop = 10,
                      minvel_cut = -3000,
                      maxvel_cut = 3000,
                      emiss_norm = 1e27,
                      modelname = '',
                      nslicer = 46,
                      zarr_version = 2,
                      dst = 'pc_limb_vdem_pipeline',
                      author,
                      ):
        """
        Execute a VDEM pipeline for limb observations using ray draping geometry.

        This specialized pipeline performs VDEM analysis for solar limb observations where
        the line-of-sight follows curved ray paths through the simulation domain. It uses
        ray draping to simulate realistic observing geometries and supports tiling multiple
        snapshots for extended spatial coverage.

        The pipeline handles large datasets by computing VDEM in slices, performs both
        full-disk and photosphere-cutoff analyses, and generates diagnostic plots with
        proper coordinate transformations for limb viewing geometry.

        Parameters
        ----------
        iz0 : int, default=50
            Index along z-dimension marking the photosphere boundary. Used for
            photosphere cutoff analysis.
        snap : int or None, optional
            Snapshot number to analyze. If None, uses current snapshot.
        tile_snaps : array-like or None, optional
            Array of snapshot indices to tile along x-dimension for extended coverage.
            E.g., [0, 1, 2, 'current'] tiles three consecutive snapshots, before the current snapshot.
            ('current' or 'here' must appear in tile_snaps exactly once.)
            Note that the full (tiled) extent will be repeated periodically as needed to cover rays.
        fov_method : str, default='standard'
            Field-of-view generation method. Options depend on ray draping implementation.
        Abox_x_NXEXT : float, default=0
            X-offset for ray draping box in units of simulation XEXT (domain extent).
            Allows shifting the observing geometry along x.
        Nx_times : float, default=1
            Multiplier for number of FOV pixels in x-direction relative to simulation Nx.
        Ny_times : float, default=1
            Multiplier for number of FOV pixels in y-direction relative to simulation Ny.
        Nz_times : float, default=1
            Multiplier for number of ray integration points relative to simulation Nz.
        Asph_theta : float, default=np.pi/2
            Spherical coordinate theta (colatitude) angle in radians for viewing geometry.
            pi/2 corresponds to equatorial view.
        Asph_phi : float, default=0
            Spherical coordinate phi (azimuthal) angle in radians for viewing geometry.
        tg_percent : float, default=0.1
            Percentage threshold for automatic temperature range determination from histogram.
        dlogT : float, default=0.1
            Temperature bin width in log10(T [K]) units.
        mintg_cut : float or None, default=4.6
            Minimum log10(temperature [K]) cutoff. Overrides automatic determination.
        maxtg_cut : float or None, default=7.7
            Maximum log10(temperature [K]) cutoff. Overrides automatic determination.
        tg_bins : int, default=60
            Number of bins for temperature histogram analysis.
        ulos_bin : int, default=60
            Number of bins for velocity histogram analysis.
        vel_percent : float, default=0.1
            Percentage threshold for automatic velocity range determination from histogram.
        dvdop : float, default=10
            Velocity bin width in km/s.
        minvel_cut : float or None, default=-3000
            Minimum velocity cutoff in km/s. Overrides automatic determination.
        maxvel_cut : float or None, default=3000
            Maximum velocity cutoff in km/s. Overrides automatic determination.
        emiss_norm : float, default=1e27
            Emission measure normalization factor.
        modelname : str, default=''
            Model identifier string used in output filenames.
        nslicer : int, default=46
            Number of slices for dividing FOV computation. Higher values reduce memory
            usage but increase I/O overhead.
        zarr_version : int, default=2
            Zarr format version for output files (2 or 3).
        dst : str, default='pc_limb_vdem_pipeline'
            Destination directory for output files.
        author : str, required
            Author name/identifier for output filenames and metadata.

        Returns
        -------
        vdem0 : xarray.Dataset
            Processed VDEM dataset with limb observation coordinates and complete metadata.
            Contains 'vdem' data variable with dimensions (logT, vdop, x, y) where x and y
            represent FOV coordinates.

        Notes
        -----
        **Temporary Directories**: This pipeline creates and DELETES temporary directories:
        - '_pc_temp_fov_lib_vdem/': For storing partial VDEM computations
        - '_pc_temp_fov_lib_vdem_ph/': For photosphere-cutoff VDEM computations
        
        These directories will be destroyed via shutil.rmtree() during pipeline execution.

        **Ray Draping Setup**: The pipeline configures ray draping with:
        - R0 = Solar radius (from DEFAULTS.PHYSICAL.CONSTANTS_SI)
        - T = 1 AU (observer distance)
        - Integration points scaled by Nz_times
        - FOV dimensions scaled by Nx_times
        - Spherical viewing angles (Asph_theta, Asph_phi)

        **Output Files**:
        - Temperature/velocity histograms: 
          'T_ulos_hist_{code}_{modelname}_{author}_{date}_{snap}.png'
        - VDEM moments (full): 
          'vdem_moment_{code}_{modelname}_{author}_{date}_{snap}.png'
        - VDEM moments (photosphere): 
          'vdem_moment_ph_{code}_{modelname}_{author}_{date}_{snap}.png'
        - VDEM Zarr dataset: 
          'vdem_{code}_{modelname}_limb_{author}_{date}_snaps_{tiles}.zarr'

        **Processing Steps**:
        1. Configure ray draping geometry with spherical coordinates
        2. Enable snapshot tiling if tile_snaps provided
        3. Compute histograms to determine optimal T and velocity ranges
        4. Compute VDEM in FOV slices to manage memory
        5. Merge slices and compute moments for full domain
        6. Repeat with photosphere cutoff (z < iz0-15)
        7. Generate diagnostic plots with proper scaling
        8. Save with complete metadata and coordinate attributes

        Examples
        --------
        >>> # Standard limb observation at solar equator
        >>> result = calc.limb_vdem_pipeline(
        ...     snap=100,
        ...     tile_snaps=[99, 'current', 101],
        ...     author='scientist_name'
        ... )
        
        >>> # High-resolution polar limb observation
        >>> result = calc.limb_vdem_pipeline(
        ...     Asph_phi=-np.pi/2,  # North pole view
        ...     Nx_times=2,    # Double resolution
        ...     dlogT=0.05,    # Finer temperature bins
        ...     nslicer=92,    # More slices for memory
        ...     author='scientist_name'
        ... )
        
        >>> # Limb observation with shifted viewpoint
        >>> result = calc.limb_vdem_pipeline(
        ...     Abox_x_NXEXT=0.5,  # Shift by half domain width
        ...     Asph_phi=np.pi/4,  # 45-degree azimuthal angle
        ...     author='scientist_name'
        ... )

        See Also
        --------
        vdem_pipeline : Standard VDEM pipeline without ray draping
        get_vdem : Core VDEM computation method
        ray_draper : Ray draping geometry handler
        """

        import matplotlib.pyplot as plt
        from matplotlib import colors
        from dateutil.tz import gettz
        from ..tools import (
            xarray_mergeload,
        )
        try:
            import zarr
        except ImportError as err:
            errmsg = ("limb_vdem_pipeline requires zarr; try again after installing, e.g. 'pip install zarr'.")
            raise ImportError(errmsg) from err

        WEST = gettz("US/Western")

        chosen_code = type(self).__name__.replace("Calculator","")
        
        self.snap = snap

        self.units = 'cgs'
        self.emiss_norm = emiss_norm
        self.tile_snaps = False

        coords = self.get_maindims_coords()
        XEXT = coords['x'].max() - coords['x'].min()  # <-- XEXT in un-tiled system, i.e. the original simulation XEXT
        Nx = coords['x'].size
        Ny = coords['y'].size
        Nz = coords['z'].size
        Rsun = DEFAULTS.PHYSICAL.CONSTANTS_SI['R_sun']*1e2  # [cm]
        AU = DEFAULTS.PHYSICAL.CONSTANTS_SI['AU']*1e2  # [cm]

        self.ray_draping = dict(R0=Rsun, T=AU, ray_s=Nz, Asph_theta=Asph_theta, Asph_phi=Asph_phi,
                                fov=(fov_method, 10, 10), Abox_x=int(Abox_x_NXEXT*XEXT))
        ax_sph, ax_box = self.ray_draper.plot('both', periodic=False, fov=False, aspect=(1, 1, 1))
        self.ray_draper.plot_post(-XEXT, 0, ax_sph, ax_box, color='cyan')
        self.ray_draper.plot_post(0,10,ax_sph, ax_box,color="purple")
        # Save the figure
        saveto = f'ray_draper__{chosen_code}{modelname}_{author}_{datetime.datetime.now(WEST).date()!s}_{self.snap}.png'
        os.makedirs(dst, exist_ok=True)
        plt.savefig(os.path.join(dst, saveto), dpi=300, bbox_inches='tight')


        self.ray_draping = dict(R0=Rsun, T=AU, ray_s=int(Nz*Nz_times), Asph_theta=Asph_theta, Asph_phi=Asph_phi,
                                fov=(fov_method, int(Nx*Nx_times), int(Ny*Ny_times)), Abox_x=int(Abox_x_NXEXT*XEXT))
        self.ray_draping['fov'] = self.ray_draper.fov

        self.tile_snaps=('x', tile_snaps)
        self.los = 'ray_draping'

        # Checking limits
        tg = self("T")
        ulos = self("u", units = 'si') / 1e3 # to km/s

        # The histogram allows to find the temperature and velocity limits
        fig, ax = plt.subplots(1, 2, figsize=(12, 6))
        hist_values = np.log10(tg).plot.hist(density=True, bins=tg_bins, ax=ax[0])

        maxtg = (
                np.ceil(
                    np.max(hist_values[1][np.where(hist_values[0] > np.max(hist_values[0]) * tg_percent / 100.0)])
                    / dlogT
                )
                * dlogT
            )
        mintg = (
                np.floor(
                    np.min(hist_values[1][np.where(hist_values[0] > np.max(hist_values[0]) * tg_percent / 100.0)])
                    / dlogT
                )
                * dlogT
            )
        if mintg_cut is not None:
            mintg = np.max([mintg, mintg_cut])
        if maxtg_cut is not None:
            maxtg = np.min([maxtg, maxtg_cut])

        hist_values = ulos.plot.hist(density=True, bins=ulos_bin, ax=ax[1])
        fig.tight_layout()
        saveto = f"T_ulos_hist_{chosen_code}{modelname}_{author}_{datetime.datetime.now(WEST).date()!s}_{self.snap}.png"
        fig.savefig(os.path.join(dst, saveto))
        
        maxvel = (
                np.ceil(
                    np.max(hist_values[1][np.where(hist_values[0] > np.max(hist_values[0]) * vel_percent / 100.0)])
                    / dvdop
                )
                * dvdop
            )
        minvel = (
                np.floor(
                    np.min(hist_values[1][np.where(hist_values[0] > np.max(hist_values[0]) * vel_percent / 100.0)])
                    / dvdop
                )
                * dvdop
            )
        if minvel_cut is not None:
            minvel = np.max([minvel, minvel_cut])
        if maxvel_cut is not None:
            maxvel = np.min([maxvel, maxvel_cut])
        
        self.rcoords_logT = np.arange(mintg, maxtg, dlogT)
        self.rcoords_vdop_kms = np.arange(minvel, maxvel, dvdop)

        slicers = [slice(i, None, nslicer) for i in range(nslicer)]

        # 
        self.ray_draping['fov'] = self.ray_draper.fov
        FOV = self.ray_draper.FOV
        for slicer in slicers:
            self.ray_draping['fov'] = FOV.isel(FOVx=slicer)
            vdem = self("vdem")
            if vdem.name is None:
                vdem.name = "vdem"
            vdem.pc.save(f'_pc_temp_fov_lib_vdem/any_filename_you_want__{slicer}')

        vdem = xarray_mergeload('_pc_temp_fov_lib_vdem', kw_merge=dict(join='outer', compat="no_conflicts"))

        if os.path.exists("_pc_temp_fov_lib_vdem"):
            shutil.rmtree("_pc_temp_fov_lib_vdem")

        if self.vdem_varies_logD:
            vdem_sumd = vdem.sum(dim='logD')
        else: 
            vdem_sumd = vdem
        mom = vdem_sumd.pc.moments(dim='vdop')
        fig, ax = plt.subplots(1, 3, figsize=(12, 6))
        mom.moment_0.sum(dim='logT').plot.imshow(norm=colors.LogNorm(vmin=1e-5,vmax=1e5), ax=ax[0])
        mom.moment_1.sum(dim='logT').plot.imshow(ax=ax[1], cmap="bwr")
        mom.moment_2.sum(dim='logT').plot.imshow(ax=ax[2])
        fig.tight_layout()
        saveto = f"vdem_moment_{chosen_code}{modelname}_{author}_{datetime.datetime.now(WEST).date()!s}_{self.snap}.png"
        fig.savefig(os.path.join(dst, saveto))

        if iz0 is None:
            iz0 = np.argmin(np.abs(self.get_maindims_coords()['z']))
        sl = slice(0,iz0-1)
        self.slices = ({'z':sl})

        nslicer_cut = int(nslicer/3)+1
        slicers = [slice(i, None, nslicer_cut) for i in range(nslicer_cut)]

        coords = self.get_maindims_coords()
        XEXT = coords['x'].max() - coords['x'].min()  # <-- XEXT in un-tiled system, i.e. the original simulation XEXT
        Nx = coords['x'].size
        Ny = coords['y'].size
        Nz = coords['z'].size

        self.ray_draping = dict(R0=Rsun, T=AU, ray_s=int(Nz*Nz_times), Asph_theta=0, Asph_phi=0,
                                fov=(fov_method, int(Nx*Nx_times), int(Ny*Ny_times)), Abox_x=int(Abox_x_NXEXT*XEXT))
        FOV_cut = self.ray_draper.FOV

        for slicer in slicers:
            self.ray_draping['fov'] = FOV_cut.isel(FOVx=slicer)
            vdem_cut = self("vdem", vdem_logT = (0, np.arange(3.,4.4,0.1)), 
                            rcoords_vdop_kms=np.arange(-1.0e1,1.e1,1), 
                            vdem_mode='nointerp', 
                            emiss_mode = 'notrac_noopa',
                            vdem_ignores_photosphere=False, 
                            vdem_loopdim=None)
            if vdem_cut.name is None:
                vdem_cut.name = "vdem"
            vdem_cut.pc.save(f'_pc_temp_fov_lib_vdem_ph/any_filename_you_want__{slicer}')

        vdem_cut = xarray_mergeload('_pc_temp_fov_lib_vdem_ph', kw_merge=dict(join='outer', compat="no_conflicts"))

        if os.path.exists("_pc_temp_fov_lib_vdem_ph"):
            shutil.rmtree("_pc_temp_fov_lib_vdem_ph")

        mom = vdem_cut.pc.moments(dim='vdop')
        fig, ax = plt.subplots(1, 2, figsize=(12, 6))
        mom.moment_0.sum(dim='logT').plot(ax=ax[0])
        mom.moment_1.sum(dim='logT').plot(vmin=-5,vmax=5,cmap='bwr', ax=ax[1])
        ax[1].set_title("Doppler velocity (positive must be downward)")
        fig.tight_layout()
        saveto = f"vdem_moment_ph_{chosen_code}{modelname}_{author}_{datetime.datetime.now(WEST).date()!s}_{self.snap}.png"
        fig.savefig(os.path.join(dst, saveto))

        self.ray_draping['fov'] = FOV

        xarray_update_call_history(vdem, locals(), self.limb_vdem_pipeline)

        vdem0 = vdem.to_dataset(name='vdem')
        # _xarray_save_prep(vdem)[0] trashes PlasmaCalcs rich metadata like Snap and Component objects,
        #  and adds some helpful attrs like pc__version__, datetime, and maybe pc__commit_hash.
        vdem0 = _xarray_save_prep(vdem0, add_code_snapshot_info=dict(default='_undefined_'))[0]
        vdem0 = vdem0.rename({"FOVx": "x", "FOVy": "y"})
        vdem0.x.attrs["long_name"] = "X"
        vdem0.y.attrs["long_name"] = "Y"
        vdem0.x.attrs["units"] = "cm"
        vdem0.y.attrs["units"] = "cm"
        vdem0.vdem.attrs["units"] = "1e27 / cm5"
        vdem0.vdem.attrs["description"] = "DEM(T,vel,x,y)"
        vdem0.vdop.attrs["long_name"] = r"v$_{Doppler}$"
        vdem0.vdop.attrs["units"] = "km/s"
        vdem0.logT.attrs["long_name"] = r"log$_{10}$(T)"
        vdem0.logT.attrs["units"] = r"log$_{10}$ (K)"
        vdem0.attrs["emiss_mode"] = self.emiss_mode
        vdem0.attrs["emiss_norm"] = self.emiss_norm
        vdem0.attrs["vdem_mode"] = self.vdem_mode
        vdem0.attrs["los"] = self.los
        vdem0.attrs["tile_snaps"] = tile_snaps
        vdem0.attrs["wavelength"] = self.rcoords_wavelength_A
        now = datetime.datetime.now(WEST).date()
        saveto = f"vdem_{chosen_code}{modelname}_limb_{author}_{now!s}_snaps_{'_'.join(map(str, tile_snaps))}.zarr"
        vdem0.to_zarr(os.path.join(dst, saveto), zarr_version=zarr_version)
        return vdem0
