<p><strong>This is answer to my own question, same as I posted to StackOverflow. See original question for the link</strong></p>
<p>I completely forgot to post this answer here once I got the convolution method to work. Thanks to a friend of mine for reminding me!</p>
<p><strong>Recap</strong></p>
<p>First, a quick recap: As mentioned in the original question, I have N-body simulation data of a simulated galaxy. I want to generate mock spectra using two different stellar templates from the MILES library (see the original question for details). Each particle is weighted to contribute to each of the two templates, with the total weight being unity (<code>W_1 + W_2 = 1.0</code>).</p>
<h2>Background: Gauss-Hermite function/parameters</h2>
<p>Figure 1 below shows the velocity distribution of the particles within a single Voronoi bin of the galaxy:</p>
<p><a href="https://i.sstatic.net/pzVHRGvf.png" rel="nofollow noreferrer"><img src="https://i.sstatic.net/pzVHRGvf.png" alt="Velocity distribution of particles within Voronoi Bin 296" /></a></p>
<ul>
<li>The grey area represent the total histogram.</li>
<li>The magenta area represent particles weighted to be part of Template 1.</li>
<li>The blue area represent particles weighted to be part of Template 2.</li>
<li>Solid lines represent the Gauss-Hermite fit to the data (see below), while dashed lines represent a pure Gaussian distribution.</li>
<li>Gauss-Hermite (GH) parameters are also visible.</li>
</ul>
<p>The fitted GH parameters in this case are:</p>
<ol>
<li>Mean velocity <code>V_0</code></li>
<li>Velocity dispersion <code>\sigma</code></li>
<li>Gauss-Hermite parameters <code>h_3</code> and <code>h_4</code>, which can be calculated using the equations provided in various references</li>
</ol>
<p>One can calculate Gauss-Hermite parameter values using existing Python libraries (e.g., NumPy), but one can also find the equations on Wikipedia. Below are a few Python functions representing how I have implemented the Gauss-Hermite function:</p>
<pre><code>def fhn(x,n):
    &quot;&quot;&quot;
    Physicists Hermite polynomials
    
    Parameters:
    x : array-like, the input values.
    n : int, the order of the Hermite polynomial.
    
    Returns:
    array-like, the values of the Hermite polynomial.
    Source:
        - Michele Cappellari: MNRAS 466, 798–811 (2017) Eq. 14. 
        - https://en.wikipedia.org/wiki/Hermite_polynomials
        - B. Neureiter : arXiv:2212.06173
        - K. Mehrgan : The mass composition of massive early-type galaxies
        - 
    &quot;&quot;&quot;
    n_fak = math.factorial(n)
    norm = n_fak * 2**n
    k_up = int(n/2)
    fhn = 0
    for k in range(0,k_up+1):
        beta_kn = n-2*k
        k_fak = math.factorial(k)
        n_2k_fak = math.factorial(n-2*k)
        vz = (-1) ** k
        a_kn = vz * n_fak/k_fak/n_2k_fak * 2**beta_kn
        fhn = fhn + a_kn*x**beta_kn
    fhn = fhn/np.sqrt(norm)
    return fhn

def LOSVD_gauss_hermite(v, amp, v_mean, sigma, *coeff):
    &quot;&quot;&quot;
    Compute the Gauss-Hermite series for given parameters.
    
    Parameters:
    v : array-like, velocity values.
    amp : float, amplitude.
    v_mean : float, mean velocity.
    sigma : float, velocity dispersion.
    coeff : tuple, Gauss-Hermite coefficients.
    
    Returns:
    array-like, the values of the Gauss-Hermite series.
    &quot;&quot;&quot;
    y = (v - v_mean) / sigma
    gauss = amp/np.sqrt(2*np.pi) * np.exp(-y**2 /2)
    len_h_coeff = len(coeff)
    sum = 1
    for i in range(len_h_coeff):
        sum += coeff[i] * fhn(y, i+3)
    L = gauss/sigma * sum 
    return L 


</code></pre>
<p>These functions can be fitted to the data using the <code>scipy.optimize.curve_fit</code> function. The reason for introducing the Gauss-Hermite parameters is that they provide a way to double-check that the results are correct.</p>
<h2>Creating spectra</h2>
<p><strong>Method 1:</strong>
I explained this method in the original question, but briefly:
We iterate through each particle, shift the stellar templates according to the line-of-sight velocity using the Doppler shift, and then sum the shifted spectra together. As a side note, the code for doing this has improved since the example given, and I can share that if needed.</p>
<p><strong>Method 2:</strong>
This is the main part of the answer: <strong>Convolution method</strong></p>
<p>I used the idea from Michele Cappellari's 2016 paper, &quot;Improving the Full Spectrum Fitting Method: Accurate Convolution with Gauss-Hermite Functions,&quot; specifically chapters 2.1 - 2.3 and 3.2.</p>
<p>This is the function I came up with. It is quite lengthy, as it includes a few additional features that are handy for me, but let me explain the important steps verbally:</p>
<ul>
<li><p><strong>Step 1 &amp; 2</strong> : Steps 1 &amp; 2: From my understanding, it is necessary to have linear spacing in the logarithmic scale, as that will correspond to a fixed velocity bin value (see, for example, M. Cappellari 2016). Then, use this constant velocity value as the <code>bin_width</code> for the convolution part.</p>
</li>
<li><p><strong>Step 5</strong> : The velocity range for the LOSVD (Line-Of-Sight Velocity Distribution) should be defined to cover <em>most</em> of the velocity distribution. I have chosen the velocity range to be [-s4, s4], where s4 represents 4 times the sigma value.</p>
</li>
<li><p><strong>Step 6</strong> : The most important part.</p>
</li>
</ul>
<ol>
<li><p><strong>Step 6.1</strong> : Compute the GH parameter values for the distribution. This is then used to 'functionalize' the LOSVD of the particles (one could use, for example, an instrumental broadening function instead of this).</p>
</li>
<li><p><strong>Step 6.2</strong> : Here, we compute the LOSVD of the particles and use it as the convolution kernel.</p>
</li>
<li><p><strong>Step 6.3</strong> : The key step: convolution using <code>scipy.signal.convolve()</code>. The convolution is done using the fluxes of the stellar templates and the computed LOSVD kernel.</p>
</li>
</ol>
<p>That’s pretty much it; all the other steps are for bookkeeping and solving other values. Here is the function:</p>
<pre><code>def compute_spectra_convolution_method(LOSv, weight_list, wavelengths_list, fluxes_list,
                                       silence=False,
                                       return_GH=False, len_h_coeff=2, bin_width='auto',
                                       interpolate_to_linear=False):
    &quot;&quot;&quot;
    USER COMMENT: This also assumes that each stellar template uses same wavelenght 'spacing' and 'range'.
                 - Usually case if one uses i.e MILES templates.
    
    Computes the convolved spectra for multiple templates using LOSVD modeled by Gauss-Hermite polynomials.

    Parameters:
    - LOSv : array-like, Line-Of-Sight velocities. (of particles/stars)
    - weight_list : list of arrays, weights for each template's contribution.
        -- Each element in the list corresponds to a template and contains weights that indicate how much each particle 
           contributes to that template.

    - wavelengths_list : list of arrays, The wavelengths corresponding to each template's spectrum.
    - fluxes_list : list of arrays, The flux (intensity) values for each template at the given wavelengths.
         
    - bin_width : float or 'auto'.  default='auto'
        The bin width for the velocity histogram and the LOSVD calculation. If 'auto', it is calculated from the 
        wavelength difference assuming a linear wavelength scale.

    - return_GH : bool, optional, default=False
        If True, the function also returns the computed Gauss-Hermite parameters along with their errors.
    
    - len_h_coeff : int, optional, default=2
        The number of Gauss-Hermite coefficients to consider beyond the Gaussian (default=2 corresponds to h3 and h4).

    - interpolate_to_linear : bool, optional, default=False
        If True, the computed spectra are interpolated onto a linear wavelenght scale. This is useful to keep scales constant in my case.
        NOTE: If interpolation to linear scale is desired, the first wavelengths_list should be
            in logarithmic (ln) form and the second should be in linear form which is used to interpolation.
    
    - Silence : bool, optional, default=False
        If True, the function prints information about the number of particles and templates.
            
    Returns:
    - df_tot : DataFrame
        A DataFrame containing the wavelengths ('lambda'), the total convolved flux ('Flux_tot'), and the convolved flux 
        for each template.

    - (Optionally) GH_params : DataFrame or dictionary
        If return_GH is True, this also returns a DataFrame containing the Gauss-Hermite parameters and their errors for each template.
   &quot;&quot;&quot;
    # Step 1: Verify and prepare the wavelengths for correct velocity calculation.
    # Ensure the first wavelenghts_list is ln for correct velocity calculation.
    wavelengths = np.exp(wavelengths_list[0])
    differences = np.diff(wavelengths_list[0])
    if not np.allclose(differences, differences[0]):
        print('Warning: Wavelengths are not equally spaced in ln scale.')

    # Step 2: Calculate the bin width for the velocity histogram.
    # This step is crucial for accurate LOSVD computation and convolution.
    if bin_width == 'auto':
        bin_width = np.diff(wavelengths_list[0])[0] * 299792.458 # Converting to km/s ; (51.5 km/s) ; M. Capellari 2016 eq. 7 &amp; 8
        print('Auto bin width:', bin_width)
        
    # Step 3: Print basic information if not silenced.
    # Determining the number of templates and particles for printing info
    NN_templates = len(fluxes_list)
    NN_particles = len(LOSv)
    if not silence: print(f'CONVOLUTION METHOD : Number of particles: {NN_particles}, NN_templates: {NN_templates}')
    
    # Step 4: Compute the Gauss-Hermite parameters, and save to dataframe if return_GH is True.
    if return_GH == True:
        GH_params, GH_error = compute_gauss_hermite_parameters(LOSv=LOSv,
                                            len_h_coeff=len_h_coeff, density=True,
                                            bin_width=bin_width)
        GH_params_data = []
        GH_params_data.append(['Total', *GH_params, *GH_error])
    
    # Step 5: Define the velocity range for the LOSVD kernel and prepare the output DataFrame.
    # The velocity range determines the extent of the convolution and should cover most of the velocity distribution.
    s4 = LOSv.std()*4 # 4-sigma range, should be more than enough 
    vel_range = np.arange(-s4, s4+bin_width, bin_width)  
    df_tot = pd.DataFrame({'lambda': wavelengths, 'Flux_tot': np.zeros_like(wavelengths_list[0])})

    # Step 6: Loop through each template to compute and convolve their spectra.
    # This is the core of the function, where each template's contribution is individually convolved with the LOSVD.
    for i_template in range(NN_templates):
        df_tot[f'Flux_{i_template+1}'] = np.zeros_like(wavelengths_list[0])

        # Step 6.1 : Compute GH parameters for each template, using the LOSVD and weights.
        GH_params, GH_error = compute_gauss_hermite_parameters(LOSv=LOSv,
                                                 len_h_coeff=len_h_coeff,
                                                 density=True,
                                                 bin_width=bin_width,
                                                 weights=weight_list[i_template] )

        # Step 6.2 :  Create the LOSVD kernel for convolution using the fitted velocity distribution.
        LOSVD_kernel = LOSVD_gauss_hermite(vel_range, *GH_params)
        LOSVD_kernel /= np.sum(LOSVD_kernel) # Normalize the kernel
        
        template_weight = np.sum(weight_list[i_template])

        # Step 6.3 : Convolute the template flux with the LOSVD kernel and add contributions to dataframe
        convoluted_flux = convolve(fluxes_list[i_template], LOSVD_kernel, mode='same', method='auto')*template_weight
        df_tot[f'Flux_{i_template+1}'] = convoluted_flux
        df_tot['Flux_tot'] += convoluted_flux

        # Store GH paramters if return_GH is True
        if return_GH:
            GH_params_data.append([f'T{i_template+1}', *GH_params, *GH_error])

    # Step 7: (Optional) Return Gauss-Hermite parameters in a DataFrame.
    if return_GH:
        columns = ['Label','amp', 'v0', 'sigma']
        error_columns = ['error_amp','error_v0', 'error_sigma']
        h_columns = [f'h{i}' for i in range(3, 3+len_h_coeff)]
        h_columns_error = [f'error_h{i}' for i in range(3, 3+len_h_coeff)]
        all_columns = columns + h_columns + error_columns + h_columns_error
        
        GH_params_df = pd.DataFrame(GH_params_data, columns=all_columns)
        
    # Step 8: (Optional) Interpolate to a linear wavelength scale if needed.
    # This is useful for ensuring that the output spectra are on a consistent wavelength grid/spacing.
    if interpolate_to_linear==True:
        df_tot = df_tot.rename(columns={'lambda':'lambda_ln'})
        df_tot.rename(columns=lambda x: x + '_ln' if 'Flux' in x else x, inplace=True)
        wavelengths_linear = wavelengths_list[1]
        
        for col in df_tot.columns[1:]:
            interpolated = np.interp(wavelengths_linear, df_tot['lambda_ln'], df_tot[col].values   )
            new_col_name = col[:-3]
            df_tot[new_col_name] = interpolated
        df_tot['lambda'] = wavelengths_linear

        # Just ordering the columns to nicer order
        column_order = ['lambda']
        non_ln_cols = column_order + [col for col in df_tot.columns if not col.endswith('_ln') and 'Flux' in col ]
        ln_cols = [col + '_ln' for col in non_ln_cols]
        column_order = non_ln_cols + ln_cols
        df_tot = df_tot[column_order]

    # Step 9: Return the final DataFrame, optionally including the GH parameters.
    if return_GH:
        return df_tot, GH_params_df
    else:
        return df_tot
</code></pre>
<hr />
<h2>Results</h2>
<p>Below, in Figure 2, I show the spectra generated using both methods, interpolation and convolution. In terms of time (N_particles= 11,850, N_templates= 2):</p>
<ul>
<li>Interpolation : 3.7360 seconds</li>
<li>convolution   : 0.0072 seconds
So, the convolution method is several orders of magnitude faster.</li>
</ul>
<p><a href="https://i.sstatic.net/vq8qyzo7.png" rel="nofollow noreferrer"><img src="https://i.sstatic.net/vq8qyzo7.png" alt="Comparison between interpolation and convolution spectras" /></a></p>
<p>As can be seen, the results are remarkably similar (the largest difference I observed was on the order of 1%).
<strong>However</strong>, as a disclaimer, the convolution method is not perfect.
After producing the spectra with both methods and then giving them as input files into programs that solve LOSVD fittings (Gauss-Hermite), both methods return similar sigma and Hermite parameter values (h3, h4, etc.) but the line-of-sight velocity might vary. Based on my tests, I have noticed that the width of the convolution kernel plays a crucial role, as small changes in that are visible in the obtained line-of-sight velocities. This is still a mystery for me regarding how to accurately solve and improve this.</p>

CC BY-SA 4.0
2024-08-09T09:57:35.653Z
2024-08-09T09:57:35.653Z
mamark
<p>Presumably the particles inside the pixel have a line-of-sight velocity profile. I would bin that profile in bins of width that are sufficient to sample the velocity resolution of the spectrum you want to generate (e.g. 2 bins per velocity resoluton FWHM).</p>
<p>Then it is simply a case of adding up the shifted and scaled (according to the frequency) spectrum for each bin.</p>
<p>For greatest efficiency I would bin your template spectrum in equal log wavelength intervals, of size equivalent to the velocity bins in your histograms. That way, the velocity shift translates into a simple integer number of log wavelength bins.
See for example <a href="https://astronomy.stackexchange.com/questions/16266/how-do-i-apply-a-velocity-shift-to-a-wavelength-array-with-uniform-logarithmic-s">How do I apply a velocity shift to a wavelength array with uniform logarithmic spacing?</a></p>
<p>If you really do want to represent your velocity profile with some fited function and then do a convolution, then you should still be applying that to the template spectrum binned in steps of equal log wavelength increment. After that you can either do the convolution integral numerically, step by step, or use a FFT algorithm, multiply the FTs of your velocity profile and template spectrum and then do the inverse FFT to get the result.</p>

CC BY-SA 4.0
2024-03-15T13:47:42.970Z
2024-03-15T13:47:42.970Z
ProfRob
<p>(I asked this same question also in StackOverflow, due to being both astronomy/coding problem, here link : <a href="https://stackoverflow.com/questions/78166419/efficient-method-for-generating-combined-spectra-from-n-body-simulation-data-usi">https://stackoverflow.com/questions/78166419/efficient-method-for-generating-combined-spectra-from-n-body-simulation-data-usi</a> )</p>
<p>I'm working with some N-body simulation data, and attempting to generate a combined spectrum from particles within a specific region (i.e. inside one 'pixel' or voronoi bin). Each particle has associated line-of-sight (LOS) velocities. My current approach involves Doppler shifting a stellar template spectrum (see top panel of figure below) for each particle and interpolating it back to original wavelenght points to sum up the fluxes. This method works, but it is a bit slow when applied to the full dataset of about 8 million particles.</p>
<p>Example of stellar template (obtained from <a href="http://research.iac.es/proyecto/miles/pages/webtools/tune-ssp-models.php" rel="nofollow noreferrer">Miles</a>  ). The template includes 4300 wavelenght points. The lower panel represent the combined spectrum of 112,866 particles.</p>
<p><a href="https://i.sstatic.net/ChcpJ.jpg" rel="nofollow noreferrer">Example stellar template and combined spectrum</a></p>
<p><strong>Current method :</strong></p>
<pre><code>
# Variables used in this method
template_wavelength_ln # Wavelengths of the template spectra with even intervals in ln scale
template_flux_ln # Fluxes of this template spectra

LOSv  # Line-Of-Sight velocities of particles corresponding to certain 'pixel'
# --------------
# The coding method itself


wavelengths_shift = DPF.doppler_shift(np.e**template_wavelength_ln, LOSv.reshape(-1,1)) 
# This calculates the doppler shift of the template wavelengths for each particle. 
# Returns 2d array. Due to high particle number, I have used some chunking method here but I took it off here as its not relevant to the problem itself. (i think)

df_tot = pd.DataFrame({'lambda': np.e**template_wavelength_ln, 'Flux_tot':np.zeros_like(template_flux_ln), 'Flux_1':np.zeros_like(template_flux_ln),
'Flux_2':np.zeros_like(template_flux_ln)})

# The above is just data frame which would include the spectrum infos. Currently I'm doing this for two different stellar templates, thus Flux_1 and Flux_2, but for the sake of example we use only one template.

for wave in wavelenght_shift:
    flux_1 = np.interp(df_tot['lambda'], wave, template_flux_ln) #interpolate the shifted wavelenghts back to the original positions
    df_tot['Flux_1'] += flux_1
    # df_tot['Flux_tot'] += (flux_1 + flux_2)

# -----------------
</code></pre>
<p>And this generated the nice combined spectrum of the particles. However, its quite slow when the doing 8 million particles, mainly due to np.interp() I guess.</p>
<p>where DPF.doppler_shift is</p>
<pre><code>def doppler_shift(wavelenghts, velocity, c=299792.458):
    ...
    lambda_shifted = wavelenghts * (1 + velocity/c)
    return lambda_shifted
</code></pre>
<p><strong>Method 2</strong>
This method would use the advantage of convolution. Here is a link to a paper which describes how one could generate the spectrum of galaxy using a template and LOSVD (line of sight velocity distribution) <a href="https://arxiv.org/pdf/1607.08538.pdf" rel="nofollow noreferrer">Improving the full spectrum fitting method: accurate convolution
with Gauss-Hermite functions by Cappellari</a> equation 1.</p>
<p>So, what I have done so far :
Obtained LOSVD of the particles in form of a Gauss-Hermite function. First I do a histogram of the LOS velocity particles, and then fit the Gauss-Hermite function to that histogram.</p>
<pre><code>from scipy.optimize import curve_fit
# Same initial variables are used here as in the code above

# Initial guess for the Gauss-hermite parameters 
initial_guess = [LOSv.mean(), LOSv.std(), 0, 0] # V_mean, sigma, h3, h4

# Histogram binning
bin_width = 10 # km/s
bin_edges = np.arange(LOSv.min() , LOSv.max() + bin_width, bin_width)
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2

hist_density = np.histogram(LOSv, bins=bin_edges, density=True)[0]

params_fit_total, error_fit_total = curve_fit(DPF.gauss_hermite, bin_centers, hist_density, p0=initial_guess, maxfev=10000)

# ---------------
# where DPF.gauss_hermite is 
from scipy.special import hermite
def gauss_hermite(v, v_mean, sigma, h3, h4):
   ...
   w = (v - v_mean) / sigma
   H3 = hermite(3, monic=True)(w)
   H4 = hermite(4, monic=True)(w)
   # One could also use these, I think
   # H3 = (2*np.sqrt(2)*w**3 - 3*np.sqrt(2)*w)/np.sqrt(6)
   # H4 = (4*w**4 - 12*w**2+3)/np.sqrt(24)
   L = 1/(sigma*np.sqrt(2*np.pi)) * np.exp(-0.5*w**2) * (1+h3*H4 + h4*H4)
   return L

# and then the LOSVD function i.e.

LOSVD_func = lambda v : DPF.gauss_hermite(v, *params_fit_total)
</code></pre>
<p>Now this params_fit_total includes the v0, sigma, h3 and h4 parameters of the LOSVD. Below is plot of the histogram and the LOSVD, including the v0 sigma h3 and h4 values in the legend.</p>
<p><a href="https://i.sstatic.net/MGSuI.jpg" rel="nofollow noreferrer">LOSVD histogram and Gauss-Hermite function</a></p>
<p>The challenge now is to generate the 'total' spectrum using the initial stellar template and this LOSVD function via convolution. However I have some difficulties on understanding how to do this, as I have not used convolution very much. I believe that the units of 'parameters' within the convolution factors have to match, so one might need to convert everything to either wavelengths or velocities. I remember that I have heard/read somewhere that
&quot;uniform sampling in the logarithm of the wavelength corresponds to a uniform sampling in velocity&quot;, which suggests a method for converting the units, but I'm not entirely sure how to implement this in practice.</p>
<p>Here are some equation algebra how I tried to justify the statement:
Doppler shift</p>
<p><span class="math-container">$$\lambda_0 = \lambda_e (1+\frac{v}{c})$$</span></p>
<p><span class="math-container">$$\rightarrow \lambda_0 - \lambda_e = \lambda_e \frac{v}{c}$$</span></p>
<p><span class="math-container">$$\rightarrow \frac{\lambda_0-\lambda_e}{\lambda_e} = \frac{\Delta \lambda}{\lambda_e} = \frac{v}{c}$$</span></p>
<p>which for small changes in <span class="math-container">$\lambda$</span> , thus small change in <span class="math-container">$v$</span>  yields into</p>
<p><span class="math-container">$$\Delta \ln(\lambda) = \frac{v}{c}$$</span></p>
<p>meaning that small and uniform change in the ln scale indeed relates to uniform sampling in velocity.</p>
<p>Yet... i'm not completely sure how to implementate this information to the convolution. I feel like this is close to the answer, but I have not got the convolution working...</p>
<p>I have tried something like</p>
<pre><code>from scipy.signal import fftconvolve

step_h_log = wave_log[1] - wave_log[0]
c = 299792.458 # km/s

# step_h_log = 0.0001717861122...
# so this would mean velocity bin of 51.5 km/s (step_h_log * c)

vel_space = np.arange( np.min(LOSv.min()), np.min(LOSv.max())+ step_h_log*c, step_h_log*c  )

test_convo = fftconvolve( DPF.doppler_shift(np.e**wave_log, vel_space), LOSVD_func(vel_space), mode='same' )
</code></pre>
<p>but indeed this gives error as the shapes differs... I have tried different ways but none gave the correct output.</p>
<p>If one is interested where to obtain the stellar templates, one can get them i.e. from here
<a href="http://research.iac.es/proyecto/miles/pages/webtools/tune-ssp-models.php" rel="nofollow noreferrer">http://research.iac.es/proyecto/miles/pages/webtools/tune-ssp-models.php</a> .
I am not sure if I could paste the .txt files here which contain the wavelengths and fluxes</p>
<p>Any ideas? I would greatly appreciate the help for both coding aspect and perhaps understanding how the convolution actually works in this context (unit conversion).</p>

<galaxy><spectra><python>
Efficient method for generating combined spectra from N-body simulation data using Python (convolution)
mamark
# Efficient method for generating combined spectra from N-body simulation data using Python (convolution)

(Asked by: username_0 on 2024-03-15)

(I asked this same question also in StackOverflow, due to being both astronomy/coding problem, here link : https://stackoverflow.com/questions/78166419/efficient-method-for-generating-combined-spectra-from-n-body-simulation-data-usi )
I'm working with some N-body simulation data, and attempting to generate a combined spectrum from particles within a specific region (i.e. inside one 'pixel' or voronoi bin). Each particle has associated line-of-sight (LOS) velocities. My current approach involves Doppler shifting a stellar template spectrum (see top panel of figure below) for each particle and interpolating it back to original wavelenght points to sum up the fluxes. This method works, but it is a bit slow when applied to the full dataset of about 8 million particles.
Example of stellar template (obtained from Miles  ). The template includes 4300 wavelenght points. The lower panel represent the combined spectrum of 112,866 particles.
Example stellar template and combined spectrum
Current method :

# Variables used in this method
template_wavelength_ln # Wavelengths of the template spectra with even intervals in ln scale
template_flux_ln # Fluxes of this template spectra

LOSv  # Line-Of-Sight velocities of particles corresponding to certain 'pixel'
# --------------
# The coding method itself


wavelengths_shift = DPF.doppler_shift(np.e**template_wavelength_ln, LOSv.reshape(-1,1)) 
# This calculates the doppler shift of the template wavelengths for each particle. 
# Returns 2d array. Due to high particle number, I have used some chunking method here but I took it off here as its not relevant to the problem itself. (i think)

df_tot = pd.DataFrame({'lambda': np.e**template_wavelength_ln, 'Flux_tot':np.zeros_like(template_flux_ln), 'Flux_1':np.zeros_like(template_flux_ln),
'Flux_2':np.zeros_like(template_flux_ln)})

# The above is just data frame which would include the spectrum infos. Currently I'm doing this for two different stellar templates, thus Flux_1 and Flux_2, but for the sake of example we use only one template.

for wave in wavelenght_shift:
    flux_1 = np.interp(df_tot['lambda'], wave, template_flux_ln) #interpolate the shifted wavelenghts back to the original positions
    df_tot['Flux_1'] += flux_1
    # df_tot['Flux_tot'] += (flux_1 + flux_2)

# -----------------

And this generated the nice combined spectrum of the particles. However, its quite slow when the doing 8 million particles, mainly due to np.interp() I guess.
where DPF.doppler_shift is
def doppler_shift(wavelenghts, velocity, c=299792.458):
    ...
    lambda_shifted = wavelenghts * (1 + velocity/c)
    return lambda_shifted

Method 2
This method would use the advantage of convolution. Here is a link to a paper which describes how one could generate the spectrum of galaxy using a template and LOSVD (line of sight velocity distribution) Improving the full spectrum fitting method: accurate convolution
with Gauss-Hermite functions by Cappellari equation 1.
So, what I have done so far :
Obtained LOSVD of the particles in form of a Gauss-Hermite function. First I do a histogram of the LOS velocity particles, and then fit the Gauss-Hermite function to that histogram.
from scipy.optimize import curve_fit
# Same initial variables are used here as in the code above

# Initial guess for the Gauss-hermite parameters 
initial_guess = [LOSv.mean(), LOSv.std(), 0, 0] # V_mean, sigma, h3, h4

# Histogram binning
bin_width = 10 # km/s
bin_edges = np.arange(LOSv.min() , LOSv.max() + bin_width, bin_width)
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2

hist_density = np.histogram(LOSv, bins=bin_edges, density=True)[0]

params_fit_total, error_fit_total = curve_fit(DPF.gauss_hermite, bin_centers, hist_density, p0=initial_guess, maxfev=10000)

# ---------------
# where DPF.gauss_hermite is 
from scipy.special import hermite
def gauss_hermite(v, v_mean, sigma, h3, h4):
   ...
   w = (v - v_mean) / sigma
   H3 = hermite(3, monic=True)(w)
   H4 = hermite(4, monic=True)(w)
   # One could also use these, I think
   # H3 = (2*np.sqrt(2)*w**3 - 3*np.sqrt(2)*w)/np.sqrt(6)
   # H4 = (4*w**4 - 12*w**2+3)/np.sqrt(24)
   L = 1/(sigma*np.sqrt(2*np.pi)) * np.exp(-0.5*w**2) * (1+h3*H4 + h4*H4)
   return L

# and then the LOSVD function i.e.

LOSVD_func = lambda v : DPF.gauss_hermite(v, *params_fit_total)

Now this params_fit_total includes the v0, sigma, h3 and h4 parameters of the LOSVD. Below is plot of the histogram and the LOSVD, including the v0 sigma h3 and h4 values in the legend.
LOSVD histogram and Gauss-Hermite function
The challenge now is to generate the 'total' spectrum using the initial stellar template and this LOSVD function via convolution. However I have some difficulties on understanding how to do this, as I have not used convolution very much. I believe that the units of 'parameters' within the convolution factors have to match, so one might need to convert everything to either wavelengths or velocities. I remember that I have heard/read somewhere that
"uniform sampling in the logarithm of the wavelength corresponds to a uniform sampling in velocity", which suggests a method for converting the units, but I'm not entirely sure how to implement this in practice.
Here are some equation algebra how I tried to justify the statement:
Doppler shift
$$\lambda_0 = \lambda_e (1+\frac{v}{c})$$
$$\rightarrow \lambda_0 - \lambda_e = \lambda_e \frac{v}{c}$$
$$\rightarrow \frac{\lambda_0-\lambda_e}{\lambda_e} = \frac{\Delta \lambda}{\lambda_e} = \frac{v}{c}$$
which for small changes in $\lambda$ , thus small change in $v$  yields into
$$\Delta \ln(\lambda) = \frac{v}{c}$$
meaning that small and uniform change in the ln scale indeed relates to uniform sampling in velocity.
Yet... i'm not completely sure how to implementate this information to the convolution. I feel like this is close to the answer, but I have not got the convolution working...
I have tried something like
from scipy.signal import fftconvolve

step_h_log = wave_log[1] - wave_log[0]
c = 299792.458 # km/s

# step_h_log = 0.0001717861122...
# so this would mean velocity bin of 51.5 km/s (step_h_log * c)

vel_space = np.arange( np.min(LOSv.min()), np.min(LOSv.max())+ step_h_log*c, step_h_log*c  )

test_convo = fftconvolve( DPF.doppler_shift(np.e**wave_log, vel_space), LOSVD_func(vel_space), mode='same' )

but indeed this gives error as the shapes differs... I have tried different ways but none gave the correct output.
If one is interested where to obtain the stellar templates, one can get them i.e. from here
http://research.iac.es/proyecto/miles/pages/webtools/tune-ssp-models.php .
I am not sure if I could paste the .txt files here which contain the wavelengths and fluxes
Any ideas? I would greatly appreciate the help for both coding aspect and perhaps understanding how the convolution actually works in this context (unit conversion).

**Tags:** galaxy, spectra, python


---

## 2 answers

### Answer by username_0 on 2024-08-09. Score: 0 | Accepted answer

This is answer to my own question, same as I posted to StackOverflow. See original question for the link
I completely forgot to post this answer here once I got the convolution method to work. Thanks to a friend of mine for reminding me!
Recap
First, a quick recap: As mentioned in the original question, I have N-body simulation data of a simulated galaxy. I want to generate mock spectra using two different stellar templates from the MILES library (see the original question for details). Each particle is weighted to contribute to each of the two templates, with the total weight being unity (W_1 + W_2 = 1.0).
Background: Gauss-Hermite function/parameters
Figure 1 below shows the velocity distribution of the particles within a single Voronoi bin of the galaxy:


The grey area represent the total histogram.
The magenta area represent particles weighted to be part of Template 1.
The blue area represent particles weighted to be part of Template 2.
Solid lines represent the Gauss-Hermite fit to the data (see below), while dashed lines represent a pure Gaussian distribution.
Gauss-Hermite (GH) parameters are also visible.

The fitted GH parameters in this case are:

Mean velocity V_0
Velocity dispersion \sigma
Gauss-Hermite parameters h_3 and h_4, which can be calculated using the equations provided in various references

One can calculate Gauss-Hermite parameter values using existing Python libraries (e.g., NumPy), but one can also find the equations on Wikipedia. Below are a few Python functions representing how I have implemented the Gauss-Hermite function:
def fhn(x,n):
    """
    Physicists Hermite polynomials
    
    Parameters:
    x : array-like, the input values.
    n : int, the order of the Hermite polynomial.
    
    Returns:
    array-like, the values of the Hermite polynomial.
    Source:
        - Michele Cappellari: MNRAS 466, 798–811 (2017) Eq. 14. 
        - https://en.wikipedia.org/wiki/Hermite_polynomials
        - B. Neureiter : arXiv:2212.06173
        - K. Mehrgan : The mass composition of massive early-type galaxies
        - 
    """
    n_fak = math.factorial(n)
    norm = n_fak * 2**n
    k_up = int(n/2)
    fhn = 0
    for k in range(0,k_up+1):
        beta_kn = n-2*k
        k_fak = math.factorial(k)
        n_2k_fak = math.factorial(n-2*k)
        vz = (-1) ** k
        a_kn = vz * n_fak/k_fak/n_2k_fak * 2**beta_kn
        fhn = fhn + a_kn*x**beta_kn
    fhn = fhn/np.sqrt(norm)
    return fhn

def LOSVD_gauss_hermite(v, amp, v_mean, sigma, *coeff):
    """
    Compute the Gauss-Hermite series for given parameters.
    
    Parameters:
    v : array-like, velocity values.
    amp : float, amplitude.
    v_mean : float, mean velocity.
    sigma : float, velocity dispersion.
    coeff : tuple, Gauss-Hermite coefficients.
    
    Returns:
    array-like, the values of the Gauss-Hermite series.
    """
    y = (v - v_mean) / sigma
    gauss = amp/np.sqrt(2*np.pi) * np.exp(-y**2 /2)
    len_h_coeff = len(coeff)
    sum = 1
    for i in range(len_h_coeff):
        sum += coeff[i] * fhn(y, i+3)
    L = gauss/sigma * sum 
    return L 



These functions can be fitted to the data using the scipy.optimize.curve_fit function. The reason for introducing the Gauss-Hermite parameters is that they provide a way to double-check that the results are correct.
Creating spectra
Method 1:
I explained this method in the original question, but briefly:
We iterate through each particle, shift the stellar templates according to the line-of-sight velocity using the Doppler shift, and then sum the shifted spectra together. As a side note, the code for doing this has improved since the example given, and I can share that if needed.
Method 2:
This is the main part of the answer: Convolution method
I used the idea from Michele Cappellari's 2016 paper, "Improving the Full Spectrum Fitting Method: Accurate Convolution with Gauss-Hermite Functions," specifically chapters 2.1 - 2.3 and 3.2.
This is the function I came up with. It is quite lengthy, as it includes a few additional features that are handy for me, but let me explain the important steps verbally:

Step 1 & 2 : Steps 1 & 2: From my understanding, it is necessary to have linear spacing in the logarithmic scale, as that will correspond to a fixed velocity bin value (see, for example, M. Cappellari 2016). Then, use this constant velocity value as the bin_width for the convolution part.

Step 5 : The velocity range for the LOSVD (Line-Of-Sight Velocity Distribution) should be defined to cover most of the velocity distribution. I have chosen the velocity range to be [-s4, s4], where s4 represents 4 times the sigma value.

Step 6 : The most important part.



Step 6.1 : Compute the GH parameter values for the distribution. This is then used to 'functionalize' the LOSVD of the particles (one could use, for example, an instrumental broadening function instead of this).

Step 6.2 : Here, we compute the LOSVD of the particles and use it as the convolution kernel.

Step 6.3 : The key step: convolution using scipy.signal.convolve(). The convolution is done using the fluxes of the stellar templates and the computed LOSVD kernel.


That’s pretty much it; all the other steps are for bookkeeping and solving other values. Here is the function:
def compute_spectra_convolution_method(LOSv, weight_list, wavelengths_list, fluxes_list,
                                       silence=False,
                                       return_GH=False, len_h_coeff=2, bin_width='auto',
                                       interpolate_to_linear=False):
    """
    USER COMMENT: This also assumes that each stellar template uses same wavelenght 'spacing' and 'range'.
                 - Usually case if one uses i.e MILES templates.
    
    Computes the convolved spectra for multiple templates using LOSVD modeled by Gauss-Hermite polynomials.

    Parameters:
    - LOSv : array-like, Line-Of-Sight velocities. (of particles/stars)
    - weight_list : list of arrays, weights for each template's contribution.
        -- Each element in the list corresponds to a template and contains weights that indicate how much each particle 
           contributes to that template.

    - wavelengths_list : list of arrays, The wavelengths corresponding to each template's spectrum.
    - fluxes_list : list of arrays, The flux (intensity) values for each template at the given wavelengths.
         
    - bin_width : float or 'auto'.  default='auto'
        The bin width for the velocity histogram and the LOSVD calculation. If 'auto', it is calculated from the 
        wavelength difference assuming a linear wavelength scale.

    - return_GH : bool, optional, default=False
        If True, the function also returns the computed Gauss-Hermite parameters along with their errors.
    
    - len_h_coeff : int, optional, default=2
        The number of Gauss-Hermite coefficients to consider beyond the Gaussian (default=2 corresponds to h3 and h4).

    - interpolate_to_linear : bool, optional, default=False
        If True, the computed spectra are interpolated onto a linear wavelenght scale. This is useful to keep scales constant in my case.
        NOTE: If interpolation to linear scale is desired, the first wavelengths_list should be
            in logarithmic (ln) form and the second should be in linear form which is used to interpolation.
    
    - Silence : bool, optional, default=False
        If True, the function prints information about the number of particles and templates.
            
    Returns:
    - df_tot : DataFrame
        A DataFrame containing the wavelengths ('lambda'), the total convolved flux ('Flux_tot'), and the convolved flux 
        for each template.

    - (Optionally) GH_params : DataFrame or dictionary
        If return_GH is True, this also returns a DataFrame containing the Gauss-Hermite parameters and their errors for each template.
   """
    # Step 1: Verify and prepare the wavelengths for correct velocity calculation.
    # Ensure the first wavelenghts_list is ln for correct velocity calculation.
    wavelengths = np.exp(wavelengths_list[0])
    differences = np.diff(wavelengths_list[0])
    if not np.allclose(differences, differences[0]):
        print('Warning: Wavelengths are not equally spaced in ln scale.')

    # Step 2: Calculate the bin width for the velocity histogram.
    # This step is crucial for accurate LOSVD computation and convolution.
    if bin_width == 'auto':
        bin_width = np.diff(wavelengths_list[0])[0] * 299792.458 # Converting to km/s ; (51.5 km/s) ; M. Capellari 2016 eq. 7 & 8
        print('Auto bin width:', bin_width)
        
    # Step 3: Print basic information if not silenced.
    # Determining the number of templates and particles for printing info
    NN_templates = len(fluxes_list)
    NN_particles = len(LOSv)
    if not silence: print(f'CONVOLUTION METHOD : Number of particles: {NN_particles}, NN_templates: {NN_templates}')
    
    # Step 4: Compute the Gauss-Hermite parameters, and save to dataframe if return_GH is True.
    if return_GH == True:
        GH_params, GH_error = compute_gauss_hermite_parameters(LOSv=LOSv,
                                            len_h_coeff=len_h_coeff, density=True,
                                            bin_width=bin_width)
        GH_params_data = []
        GH_params_data.append(['Total', *GH_params, *GH_error])
    
    # Step 5: Define the velocity range for the LOSVD kernel and prepare the output DataFrame.
    # The velocity range determines the extent of the convolution and should cover most of the velocity distribution.
    s4 = LOSv.std()*4 # 4-sigma range, should be more than enough 
    vel_range = np.arange(-s4, s4+bin_width, bin_width)  
    df_tot = pd.DataFrame({'lambda': wavelengths, 'Flux_tot': np.zeros_like(wavelengths_list[0])})

    # Step 6: Loop through each template to compute and convolve their spectra.
    # This is the core of the function, where each template's contribution is individually convolved with the LOSVD.
    for i_template in range(NN_templates):
        df_tot[f'Flux_{i_template+1}'] = np.zeros_like(wavelengths_list[0])

        # Step 6.1 : Compute GH parameters for each template, using the LOSVD and weights.
        GH_params, GH_error = compute_gauss_hermite_parameters(LOSv=LOSv,
                                                 len_h_coeff=len_h_coeff,
                                                 density=True,
                                                 bin_width=bin_width,
                                                 weights=weight_list[i_template] )

        # Step 6.2 :  Create the LOSVD kernel for convolution using the fitted velocity distribution.
        LOSVD_kernel = LOSVD_gauss_hermite(vel_range, *GH_params)
        LOSVD_kernel /= np.sum(LOSVD_kernel) # Normalize the kernel
        
        template_weight = np.sum(weight_list[i_template])

        # Step 6.3 : Convolute the template flux with the LOSVD kernel and add contributions to dataframe
        convoluted_flux = convolve(fluxes_list[i_template], LOSVD_kernel, mode='same', method='auto')*template_weight
        df_tot[f'Flux_{i_template+1}'] = convoluted_flux
        df_tot['Flux_tot'] += convoluted_flux

        # Store GH paramters if return_GH is True
        if return_GH:
            GH_params_data.append([f'T{i_template+1}', *GH_params, *GH_error])

    # Step 7: (Optional) Return Gauss-Hermite parameters in a DataFrame.
    if return_GH:
        columns = ['Label','amp', 'v0', 'sigma']
        error_columns = ['error_amp','error_v0', 'error_sigma']
        h_columns = [f'h{i}' for i in range(3, 3+len_h_coeff)]
        h_columns_error = [f'error_h{i}' for i in range(3, 3+len_h_coeff)]
        all_columns = columns + h_columns + error_columns + h_columns_error
        
        GH_params_df = pd.DataFrame(GH_params_data, columns=all_columns)
        
    # Step 8: (Optional) Interpolate to a linear wavelength scale if needed.
    # This is useful for ensuring that the output spectra are on a consistent wavelength grid/spacing.
    if interpolate_to_linear==True:
        df_tot = df_tot.rename(columns={'lambda':'lambda_ln'})
        df_tot.rename(columns=lambda x: x + '_ln' if 'Flux' in x else x, inplace=True)
        wavelengths_linear = wavelengths_list[1]
        
        for col in df_tot.columns[1:]:
            interpolated = np.interp(wavelengths_linear, df_tot['lambda_ln'], df_tot[col].values   )
            new_col_name = col[:-3]
            df_tot[new_col_name] = interpolated
        df_tot['lambda'] = wavelengths_linear

        # Just ordering the columns to nicer order
        column_order = ['lambda']
        non_ln_cols = column_order + [col for col in df_tot.columns if not col.endswith('_ln') and 'Flux' in col ]
        ln_cols = [col + '_ln' for col in non_ln_cols]
        column_order = non_ln_cols + ln_cols
        df_tot = df_tot[column_order]

    # Step 9: Return the final DataFrame, optionally including the GH parameters.
    if return_GH:
        return df_tot, GH_params_df
    else:
        return df_tot


Results
Below, in Figure 2, I show the spectra generated using both methods, interpolation and convolution. In terms of time (N_particles= 11,850, N_templates= 2):

Interpolation : 3.7360 seconds
convolution   : 0.0072 seconds
So, the convolution method is several orders of magnitude faster.


As can be seen, the results are remarkably similar (the largest difference I observed was on the order of 1%).
However, as a disclaimer, the convolution method is not perfect.
After producing the spectra with both methods and then giving them as input files into programs that solve LOSVD fittings (Gauss-Hermite), both methods return similar sigma and Hermite parameter values (h3, h4, etc.) but the line-of-sight velocity might vary. Based on my tests, I have noticed that the width of the convolution kernel plays a crucial role, as small changes in that are visible in the obtained line-of-sight velocities. This is still a mystery for me regarding how to accurately solve and improve this.


---

### Answer by username_1 on 2024-03-15. Score: 3

Presumably the particles inside the pixel have a line-of-sight velocity profile. I would bin that profile in bins of width that are sufficient to sample the velocity resolution of the spectrum you want to generate (e.g. 2 bins per velocity resoluton FWHM).

Then it is simply a case of adding up the shifted and scaled (according to the frequency) spectrum for each bin.

For greatest efficiency I would bin your template spectrum in equal log wavelength intervals, of size equivalent to the velocity bins in your histograms. That way, the velocity shift translates into a simple integer number of log wavelength bins.
See for example [How do I apply a velocity shift to a wavelength array with uniform logarithmic spacing?](https://astronomy.stackexchange.com/questions/16266/how-do-i-apply-a-velocity-shift-to-a-wavelength-array-with-uniform-logarithmic-s)

If you really do want to represent your velocity profile with some fited function and then do a convolution, then you should still be applying that to the template spectrum binned in steps of equal log wavelength increment. After that you can either do the convolution integral numerically, step by step, or use a FFT algorithm, multiply the FTs of your velocity profile and template spectrum and then do the inverse FFT to get the result.


---

