sah.classes

Description

This module contains common classes used to load and manipulate spectral data. Any class can be instantiated directly as sah.Class().

Index

Spectra Used to load and process spectral data
Plotting Stores plotting options, used in Spectra.plotting
Material Used to store and calculate material parameters, such as molar masses and neutron cross sections

Examples

To load two INS spectra CSV files with cm$^{-1}$ as input units, converting them to meV units, and finally plotting them:

import sah
ins = sah.Spectra(
    type     = 'ins',
    files    = ['example_1.csv', 'example_2.csv'],
    units_in = 'cm-1',
    units    = 'meV',
    )
sah.plot(ins)

Check more use examples in the examples/ folder.


  1"""
  2# Description
  3
  4This module contains common classes used to load and manipulate spectral data.
  5Any class can be instantiated directly as `sah.Class()`.
  6
  7
  8# Index
  9
 10| | |
 11| --- | --- |
 12| `Spectra`  | Used to load and process spectral data |
 13| `Plotting` | Stores plotting options, used in `Spectra.plotting` |
 14| `Material` | Used to store and calculate material parameters, such as molar masses and neutron cross sections |
 15
 16
 17# Examples
 18
 19To load two INS spectra CSV files with cm$^{-1}$ as input units,
 20converting them to meV units, and finally plotting them:
 21```python
 22import sah
 23ins = sah.Spectra(
 24    type     = 'ins',
 25    files    = ['example_1.csv', 'example_2.csv'],
 26    units_in = 'cm-1',
 27    units    = 'meV',
 28    )
 29sah.plot(ins)
 30```
 31
 32Check more use examples in the [`examples/`](https://github.com/pablogila/sah/tree/main/examples) folder.
 33
 34---
 35"""
 36
 37
 38import numpy as np
 39import pandas as pd
 40from copy import deepcopy
 41import os
 42import aton
 43import periodictable
 44import scipy
 45
 46
 47# Common conversion factors
 48cm1_to_meV = (scipy.constants.h * scipy.constants.c * 100 / scipy.constants.e) * 1000
 49meV_to_cm1 = 1 / cm1_to_meV
 50
 51
 52class Plotting:
 53    """Stores plotting options, read by `sah.plot`"""
 54    def __init__(
 55            self,
 56            title:str=None,
 57            xlim=None,
 58            ylim=None,
 59            margins:list=[0,0],
 60            offset=True,
 61            scaling:float=1.0,
 62            vline:list=None,
 63            vline_error:list=None,
 64            figsize:tuple=None,
 65            log_xscale:bool=False,
 66            show_yticks:bool=False,
 67            xlabel:str=None,
 68            ylabel:str=None,
 69            legend=None,
 70            legend_title:str=None,
 71            legend_size='medium',
 72            legend_loc='best',
 73            viridis:bool=False,
 74            save_as:str=None,
 75        ):
 76        """Default values can be overwritten when initializing the Plotting object."""
 77        self.title = title
 78        """Title of the plot. Set it to an empty string to remove the title."""
 79        self.xlim = self._set_limits(xlim)
 80        """List with the x-limits of the plot, as in `[xlim_low, xlim_top]`."""
 81        self.ylim = self._set_limits(ylim)
 82        """List with the y-limits of the plot, as in `[ylim_low, ylim_top]`."""
 83        self.margins = self._set_limits(margins)
 84        """List with additional margins at the bottom and top of the plot, as in `[low_margin, top_margin]`."""
 85        self.offset = offset
 86        """If `True`, the plots will be separated automatically.
 87
 88        It can be set to a float, to equally offset the plots by a given value.
 89        """
 90        self.scaling = scaling
 91        "Scaling factor"
 92        if vline is not None and not isinstance(vline, list):
 93            vline = [vline]
 94        self.vline = vline
 95        """Vertical line/s to plot. Can be an int or float with the x-position, or a list with several ones."""
 96        if vline_error is not None and not isinstance(vline_error, list):
 97            vline_error = [vline_error]
 98        self.vline_error = vline_error
 99        """Plot a shaded area of the specified width around the vertical lines specified at `vline`.
100
101        It can be an array of the same length as `vline`, or a single value to be applied to all.
102        """
103        self.figsize = figsize
104        """Tuple with the figure size, as in matplotlib."""
105        self.log_xscale = log_xscale
106        """If true, plot the x-axis in logarithmic scale."""
107        self.show_yticks = show_yticks
108        """Show or not the yticks on the plot."""
109        self.xlabel = xlabel
110        """Custom label of the x-axis.
111
112        If `None`, the default label will be used.
113        Set to `''` to remove the label of the horizontal axis.
114        """
115        self.ylabel = ylabel
116        """Custom label of the y-axis.
117        
118        If `None`, the default label will be used.
119        Set to `''` to remove the label of the vertical axis.
120        """
121        if not isinstance(legend, list) and legend is not None and legend != False:
122            legend = [legend]
123        self.legend = legend
124        """Legend of the plot.
125
126        If `None`, the filenames will be used as legend.
127        Can be a bool to show or hide the plot legend.
128        It can also be an array containing the strings to display;
129        in that case, elements set to `False` will not be displayed.
130        """
131        self.legend_title = legend_title
132        """Title of the legend, defaults to `None`."""
133        self.legend_size = legend_size
134        """Size of the legend, as in matplotlib. Defaults to `'medium'`."""
135        self.legend_loc = legend_loc
136        """Location of the legend, as in matplotlib. Defaults to `'best'`."""
137        self.viridis: bool = viridis
138        """Use the Viridis colormap for the plot. Defaults to `False`."""
139        self.save_as = save_as
140        """Filename to save the plot. None by default."""
141
142    def _set_limits(self, limits) -> list:
143        """Set the x and y limits of the plot."""
144        if limits is None:
145            return [None, None]
146        if isinstance(limits, tuple):
147            limits = list(limits)
148        if isinstance(limits, list):
149            if len(limits) == 0:
150                return [None, None]
151            if len(limits) == 1:
152                return [None, limits[0]]
153            if len(limits) == 2:
154                return limits
155            else:
156                return limits[:2]
157        if isinstance(limits, int) or isinstance(limits, float):
158            return [None, limits]
159        else:
160            raise ValueError(f"Unknown plotting limits: Must be specified as a list of two elements, as [low_limit, high_limit]. Got: {limits}")
161
162
163class Spectra:
164    """Spectra object. Used to load and process spectral data.
165
166    Most functions in the `sah` module receive this object as input.
167    """
168    def __init__(
169            self,
170            type:str=None,
171            comment:str=None,
172            files=None,
173            dfs=None,
174            units=None,
175            units_in=None,
176            plotting:Plotting=Plotting(),
177        ):
178        """All values can be set when initializing the Spectra object."""
179        self.type = None
180        """Type of the spectra: `'INS'`, `'ATR'`, or `'RAMAN'`."""
181        self.comment = comment
182        """Custom comment. If `Plotting.title` is None,  it will be the title of the plot."""
183        self.files = None
184        """List containing the files with the spectral data.
185
186        Loaded automatically to `dfs` with Pandas at initialization.
187        In order for Pandas to read the files properly, note that the column lines must start by `#`.
188        Any additional line that is not data must be removed or commented with `#`.
189        CSV files must be formatted with the first column as the energy or energy transfer,
190        and the second column with the intensity or absorbance, depending on the case.
191        An additional third `'Error'` column can be used.
192        """
193        self.dfs = None
194        """List containing the pandas dataframes with the spectral data.
195        
196        Loaded automatically from `files` at initialization.
197        """
198        self.units = None
199        """Target units of the spectral data.
200        
201        Can be `'meV'` or `'cm-1'`."""
202        self.units_in = None
203        """Input units of the spectral data, used in the input CSV files.
204        
205        Can be `'meV'` or `'cm-1'`.
206        If the input CSV files have different units,
207        it can also be set as a list of the same length of the number of input files,
208        eg. `['meV', 'cm-1', 'cm-1']`.
209        """
210        self.plotting = plotting
211        """`Plotting` object, used to set the plotting options."""
212
213        self = self._set_type(type)
214        self = self._set_dataframes(files, dfs)
215        self = self.set_units(units, units_in)
216
217    def _set_type(self, type):
218        """Set and normalize the type of the spectra: `INS`, `ATR`, or `RAMAN`."""
219        if type.lower() in aton.alias.experiments['ins']:
220            self.type = 'ins'
221        elif type.lower() in aton.alias.experiments['atr']:
222            self.type = 'atr'
223        elif type.lower() in aton.alias.experiments['raman']:
224            self.type = 'raman'
225        else:
226            self.type = type.lower()
227        return self
228
229    def _set_dataframes(self, files, dfs):
230        '''Set the dfs list of dataframes, from the given files or dfs.'''
231        if isinstance(files, list):
232            self.files = files
233        elif isinstance(files, str):
234            self.files = [files]
235        else:
236            self.files = []
237
238        if isinstance(dfs, pd.DataFrame):
239            self.dfs = [dfs]
240        elif isinstance(dfs, list) and isinstance(dfs[0], pd.DataFrame):
241            self.dfs = dfs
242        else:
243            self.dfs = [self._read_dataframe(filename) for filename in self.files]
244        return self
245
246    def _read_dataframe(self, filename):
247        """Read a dataframe from a file."""
248        root = os.getcwd()
249        file_path = os.path.join(root, filename)
250        df = pd.read_csv(file_path, comment='#', sep=r',|;|\s+', engine='python', header=None)
251        # Remove any empty columns
252        df = df.dropna(axis=1, how='all')
253        df = df.sort_values(by=df.columns[0]) # Sort the data by energy
254
255        print(f'\nNew dataframe from {filename}')
256        print(df.head(),'\n')
257        return df
258
259    def set_units(
260            self,
261            units,
262            units_in=None,
263            default_unit='cm-1',
264            ):
265        """Method to change between spectral units. ALWAYS use this method to do that.
266
267        For example, to change to meV from cm-1:
268        ```python
269        Spectra.set_units('meV', 'cm-1')
270        ```
271        """
272        mev = 'meV'
273        cm = 'cm-1'
274        unit_format={
275                mev: aton.alias.units['meV'],
276                cm: aton.alias.units['cm1'] + aton.alias.units['cm'],
277            }
278        if self.units is not None:
279            units_in = deepcopy(self.units)
280            self.units = units
281        elif units is not None:
282            units_in = units_in
283            self.units = deepcopy(units)
284        elif units is None and units_in is None:
285            units_in = None
286            self.units = default_unit
287        elif units is None and units_in is not None:
288            units_in = None
289            self.units = deepcopy(units_in)
290        if isinstance(units_in, list):
291            for i, unit_in in enumerate(units_in):
292                for key, value in unit_format.items():
293                    if unit_in in value:
294                        units_in[i] = key
295                        break
296            if len(units_in) == 1:
297                units_in = units_in * len(self.files)
298            elif len(units_in) != len(self.files):
299                raise ValueError("units_in must be a list of the same length as files.")
300        if isinstance(units_in, str):
301            for key, value in unit_format.items():
302                if units_in in value:
303                    units_in = key
304                    break
305            units_in = [units_in] * len(self.files)
306        if isinstance(self.units, list):
307            for i, unit in enumerate(self.units):
308                for key, value in unit_format.items():
309                    if unit in value:
310                        self.units[i] = key
311                        break
312            if len(self.units) == 1:
313                self.units = self.units * len(self.files)
314            elif len(self.units) != len(self.files):
315                raise ValueError("units_in must be a list of the same length as files.")
316        if isinstance(self.units, str):
317            for key, value in unit_format.items():
318                if self.units in value:
319                    self.units = key
320                    break
321            self.units = [self.units] * len(self.files)
322        if units_in is None:
323            return self
324        # Otherwise, convert the dfs
325        if len(self.units) != len(units_in):
326            raise ValueError("Units len mismatching.")
327        for i, unit in enumerate(self.units):
328            if unit == units_in[i]:
329                continue
330            if unit == mev and units_in[i] == cm: 
331                self.dfs[i][self.dfs[i].columns[0]] = self.dfs[i][self.dfs[i].columns[0]] * cm1_to_meV
332        for i, df in enumerate(self.dfs):
333            if self.units[i] == mev:
334                E_units = 'meV'
335            elif self.units[i] == cm:
336                E_units = 'cm-1'
337            else:
338                E_units = self.units[i]
339            if self.type == 'ins':
340                if self.dfs[i].shape[1] == 3:
341                    self.dfs[i].columns = [f'Energy transfer / {E_units}', 'S(Q,E)', 'Error']
342                elif self.dfs[i].shape[1] == 2:
343                    self.dfs[i].columns = [f'Energy transfer / {E_units}', 'S(Q,E)']
344            elif self.type == 'atr':
345                if self.dfs[i].shape[1] == 3:
346                    self.dfs[i].columns = [f'Wavenumber / {E_units}', 'Absorbance', 'Error']
347                elif self.dfs[i].shape[1] == 2:
348                    self.dfs[i].columns = [f'Wavenumber / {E_units}', 'Absorbance']
349            elif self.type == 'raman':
350                if self.dfs[i].shape[1] == 3:
351                    self.dfs[i].columns = [f'Raman shift / {E_units}', 'Counts', 'Error']
352                elif self.dfs[i].shape[1] == 2:
353                    self.dfs[i].columns = [f'Raman shift / {E_units}', 'Counts']
354        return self
355
356
357class Material:
358    """Material class.
359
360    Used to calculate molar masses and cross sections,
361    and to pass data to different analysis functions
362    such as `sah.deuterium.impulse_approx().`
363    """
364    def __init__(
365            self,
366            elements:dict,
367            name:str=None,
368            grams:float=None,
369            grams_error:float=None,
370            mols:float=None,
371            mols_error:float=None,
372            molar_mass:float=None,
373            cross_section:float=None,
374            peaks:dict=None,
375        ):
376        """
377        All values can be set when initializing the Material object.
378        However, it is recommended to only set the elements and the grams,
379        and optionally the name, and calculate the rest with `Material.set()`.
380        """
381        self.elements = elements
382        """Dict of atoms in the material, as in `{'C':1, 'N':1, 'H': 3, 'D':3}`.
383
384        Isotopes can be expressed as 'H2', 'He4', etc. with the atom symbol + isotope mass number.
385        """
386        self.name = name
387        """String with the name of the material."""
388        self.grams = grams
389        """Mass, in grams."""
390        self.grams_error = grams_error
391        """Error of the measured mass in grams.
392
393        Set automatically with `Material.set()`.
394        """
395        self.mols = mols
396        """Number of moles.
397
398        Set automatically with `Material.set()`.
399        """
400        self.mols_error = mols_error
401        """Error of the number of moles.
402
403        Set automatically with `Material.set()`.
404        """
405        self.molar_mass = molar_mass
406        """Molar mass of the material, in mol/g.
407
408        Calculated automatically with `Material.set()`.
409        """
410        self.cross_section = cross_section
411        """Neutron total bound scattering cross section, in barns.
412
413        Calculated automatically with `Material.set()`.
414        """
415        self.peaks = peaks
416        """Dict with interesting peaks that you might want to store for later use."""
417
418    def _set_grams_error(self):
419        """Set the error in grams, based on the number of decimal places."""
420        if self.grams is None:
421            return
422        decimal_accuracy = len(str(self.grams).split('.')[1])
423        # Calculate the error in grams
424        self.grams_error = 10**(-decimal_accuracy)
425
426    def _set_mass(self):
427        """Set the molar mass of the material.
428
429        If `Material.grams` is provided, the number of moles will be
430        calculated and overwritten. Isotopes can be used as 'element + A',
431        eg. `'He4'`. This gets splitted with `aton.txt.extract.isotope()`.
432        """
433        material_grams_per_mol = 0.0
434        for key in self.elements:
435            try:
436                material_grams_per_mol += self.elements[key] * periodictable.elements.symbol(key).mass
437            except KeyError:  # Split the atomic flag as H2, etc
438                element, isotope = aton.txt.extract.isotope(key)
439                isotope_name = isotope+'-'+element  # Periodictable format
440                material_grams_per_mol += self.elements[key] * periodictable.elements.isotope(isotope_name).mass
441        self.molar_mass = material_grams_per_mol
442        if self.grams is not None:
443            self._set_grams_error()
444            self.mols = self.grams / material_grams_per_mol
445            self.mols_error = self.mols * np.sqrt((self.grams_error / self.grams)**2)
446    
447    def _set_cross_section(self):
448        """Set the cross section of the material, based on the `elements` dict.
449
450        If an isotope is used, eg. `'He4'`, it splits the name with `aton.txt.extract.isotope()`.
451        """
452        total_cross_section = 0.0
453        for key in self.elements:
454            try:
455                total_cross_section += self.elements[key] * periodictable.elements.symbol(key).neutron.total
456            except KeyError: # Split the atomic flag as H2, etc
457                element, isotope_index = aton.txt.extract.isotope(key)
458                isotope_name = isotope+'-'+element  # Periodictable format
459                total_cross_section += self.elements[key] * periodictable.elements.isotope(isotope_name).neutron.total
460        self.cross_section = total_cross_section
461
462    def set(self):
463        """Set the molar mass, cross section and errors of the material."""
464        self._set_mass()
465        self._set_cross_section()
466
467    def print(self):
468        """Print a summary with the material information."""
469        print('\nMATERIAL')
470        if self.name is not None:
471            print(f'Name: {self.name}')
472        if self.grams is not None and self.grams_error is not None:
473            print(f'Grams: {self.grams} +- {self.grams_error} g')
474        elif self.grams is not None:
475            print(f'Grams: {self.grams} g')
476        if self.mols is not None and self.mols_error is not None:
477            print(f'Moles: {self.mols} +- {self.mols_error} mol')
478        elif self.mols is not None:
479            print(f'Moles: {self.mols} mol')
480        if self.molar_mass is not None:
481            print(f'Molar mass: {self.molar_mass} g/mol')
482        if self.cross_section is not None:
483            print(f'Cross section: {self.cross_section} barns')
484        if self.elements is not None:
485            print(f'Elements: {self.elements}')
486        print('')
cm1_to_meV = 0.12398419843320026
meV_to_cm1 = 8.065543937349211
class Plotting:
 53class Plotting:
 54    """Stores plotting options, read by `sah.plot`"""
 55    def __init__(
 56            self,
 57            title:str=None,
 58            xlim=None,
 59            ylim=None,
 60            margins:list=[0,0],
 61            offset=True,
 62            scaling:float=1.0,
 63            vline:list=None,
 64            vline_error:list=None,
 65            figsize:tuple=None,
 66            log_xscale:bool=False,
 67            show_yticks:bool=False,
 68            xlabel:str=None,
 69            ylabel:str=None,
 70            legend=None,
 71            legend_title:str=None,
 72            legend_size='medium',
 73            legend_loc='best',
 74            viridis:bool=False,
 75            save_as:str=None,
 76        ):
 77        """Default values can be overwritten when initializing the Plotting object."""
 78        self.title = title
 79        """Title of the plot. Set it to an empty string to remove the title."""
 80        self.xlim = self._set_limits(xlim)
 81        """List with the x-limits of the plot, as in `[xlim_low, xlim_top]`."""
 82        self.ylim = self._set_limits(ylim)
 83        """List with the y-limits of the plot, as in `[ylim_low, ylim_top]`."""
 84        self.margins = self._set_limits(margins)
 85        """List with additional margins at the bottom and top of the plot, as in `[low_margin, top_margin]`."""
 86        self.offset = offset
 87        """If `True`, the plots will be separated automatically.
 88
 89        It can be set to a float, to equally offset the plots by a given value.
 90        """
 91        self.scaling = scaling
 92        "Scaling factor"
 93        if vline is not None and not isinstance(vline, list):
 94            vline = [vline]
 95        self.vline = vline
 96        """Vertical line/s to plot. Can be an int or float with the x-position, or a list with several ones."""
 97        if vline_error is not None and not isinstance(vline_error, list):
 98            vline_error = [vline_error]
 99        self.vline_error = vline_error
100        """Plot a shaded area of the specified width around the vertical lines specified at `vline`.
101
102        It can be an array of the same length as `vline`, or a single value to be applied to all.
103        """
104        self.figsize = figsize
105        """Tuple with the figure size, as in matplotlib."""
106        self.log_xscale = log_xscale
107        """If true, plot the x-axis in logarithmic scale."""
108        self.show_yticks = show_yticks
109        """Show or not the yticks on the plot."""
110        self.xlabel = xlabel
111        """Custom label of the x-axis.
112
113        If `None`, the default label will be used.
114        Set to `''` to remove the label of the horizontal axis.
115        """
116        self.ylabel = ylabel
117        """Custom label of the y-axis.
118        
119        If `None`, the default label will be used.
120        Set to `''` to remove the label of the vertical axis.
121        """
122        if not isinstance(legend, list) and legend is not None and legend != False:
123            legend = [legend]
124        self.legend = legend
125        """Legend of the plot.
126
127        If `None`, the filenames will be used as legend.
128        Can be a bool to show or hide the plot legend.
129        It can also be an array containing the strings to display;
130        in that case, elements set to `False` will not be displayed.
131        """
132        self.legend_title = legend_title
133        """Title of the legend, defaults to `None`."""
134        self.legend_size = legend_size
135        """Size of the legend, as in matplotlib. Defaults to `'medium'`."""
136        self.legend_loc = legend_loc
137        """Location of the legend, as in matplotlib. Defaults to `'best'`."""
138        self.viridis: bool = viridis
139        """Use the Viridis colormap for the plot. Defaults to `False`."""
140        self.save_as = save_as
141        """Filename to save the plot. None by default."""
142
143    def _set_limits(self, limits) -> list:
144        """Set the x and y limits of the plot."""
145        if limits is None:
146            return [None, None]
147        if isinstance(limits, tuple):
148            limits = list(limits)
149        if isinstance(limits, list):
150            if len(limits) == 0:
151                return [None, None]
152            if len(limits) == 1:
153                return [None, limits[0]]
154            if len(limits) == 2:
155                return limits
156            else:
157                return limits[:2]
158        if isinstance(limits, int) or isinstance(limits, float):
159            return [None, limits]
160        else:
161            raise ValueError(f"Unknown plotting limits: Must be specified as a list of two elements, as [low_limit, high_limit]. Got: {limits}")

Stores plotting options, read by sah.plot

Plotting( title: str = None, xlim=None, ylim=None, margins: list = [0, 0], offset=True, scaling: float = 1.0, vline: list = None, vline_error: list = None, figsize: tuple = None, log_xscale: bool = False, show_yticks: bool = False, xlabel: str = None, ylabel: str = None, legend=None, legend_title: str = None, legend_size='medium', legend_loc='best', viridis: bool = False, save_as: str = None)
 55    def __init__(
 56            self,
 57            title:str=None,
 58            xlim=None,
 59            ylim=None,
 60            margins:list=[0,0],
 61            offset=True,
 62            scaling:float=1.0,
 63            vline:list=None,
 64            vline_error:list=None,
 65            figsize:tuple=None,
 66            log_xscale:bool=False,
 67            show_yticks:bool=False,
 68            xlabel:str=None,
 69            ylabel:str=None,
 70            legend=None,
 71            legend_title:str=None,
 72            legend_size='medium',
 73            legend_loc='best',
 74            viridis:bool=False,
 75            save_as:str=None,
 76        ):
 77        """Default values can be overwritten when initializing the Plotting object."""
 78        self.title = title
 79        """Title of the plot. Set it to an empty string to remove the title."""
 80        self.xlim = self._set_limits(xlim)
 81        """List with the x-limits of the plot, as in `[xlim_low, xlim_top]`."""
 82        self.ylim = self._set_limits(ylim)
 83        """List with the y-limits of the plot, as in `[ylim_low, ylim_top]`."""
 84        self.margins = self._set_limits(margins)
 85        """List with additional margins at the bottom and top of the plot, as in `[low_margin, top_margin]`."""
 86        self.offset = offset
 87        """If `True`, the plots will be separated automatically.
 88
 89        It can be set to a float, to equally offset the plots by a given value.
 90        """
 91        self.scaling = scaling
 92        "Scaling factor"
 93        if vline is not None and not isinstance(vline, list):
 94            vline = [vline]
 95        self.vline = vline
 96        """Vertical line/s to plot. Can be an int or float with the x-position, or a list with several ones."""
 97        if vline_error is not None and not isinstance(vline_error, list):
 98            vline_error = [vline_error]
 99        self.vline_error = vline_error
100        """Plot a shaded area of the specified width around the vertical lines specified at `vline`.
101
102        It can be an array of the same length as `vline`, or a single value to be applied to all.
103        """
104        self.figsize = figsize
105        """Tuple with the figure size, as in matplotlib."""
106        self.log_xscale = log_xscale
107        """If true, plot the x-axis in logarithmic scale."""
108        self.show_yticks = show_yticks
109        """Show or not the yticks on the plot."""
110        self.xlabel = xlabel
111        """Custom label of the x-axis.
112
113        If `None`, the default label will be used.
114        Set to `''` to remove the label of the horizontal axis.
115        """
116        self.ylabel = ylabel
117        """Custom label of the y-axis.
118        
119        If `None`, the default label will be used.
120        Set to `''` to remove the label of the vertical axis.
121        """
122        if not isinstance(legend, list) and legend is not None and legend != False:
123            legend = [legend]
124        self.legend = legend
125        """Legend of the plot.
126
127        If `None`, the filenames will be used as legend.
128        Can be a bool to show or hide the plot legend.
129        It can also be an array containing the strings to display;
130        in that case, elements set to `False` will not be displayed.
131        """
132        self.legend_title = legend_title
133        """Title of the legend, defaults to `None`."""
134        self.legend_size = legend_size
135        """Size of the legend, as in matplotlib. Defaults to `'medium'`."""
136        self.legend_loc = legend_loc
137        """Location of the legend, as in matplotlib. Defaults to `'best'`."""
138        self.viridis: bool = viridis
139        """Use the Viridis colormap for the plot. Defaults to `False`."""
140        self.save_as = save_as
141        """Filename to save the plot. None by default."""

Default values can be overwritten when initializing the Plotting object.

title

Title of the plot. Set it to an empty string to remove the title.

xlim

List with the x-limits of the plot, as in [xlim_low, xlim_top].

ylim

List with the y-limits of the plot, as in [ylim_low, ylim_top].

margins

List with additional margins at the bottom and top of the plot, as in [low_margin, top_margin].

offset

If True, the plots will be separated automatically.

It can be set to a float, to equally offset the plots by a given value.

scaling

Scaling factor

vline

Vertical line/s to plot. Can be an int or float with the x-position, or a list with several ones.

vline_error

Plot a shaded area of the specified width around the vertical lines specified at vline.

It can be an array of the same length as vline, or a single value to be applied to all.

figsize

Tuple with the figure size, as in matplotlib.

log_xscale

If true, plot the x-axis in logarithmic scale.

show_yticks

Show or not the yticks on the plot.

xlabel

Custom label of the x-axis.

If None, the default label will be used. Set to '' to remove the label of the horizontal axis.

ylabel

Custom label of the y-axis.

If None, the default label will be used. Set to '' to remove the label of the vertical axis.

legend

Legend of the plot.

If None, the filenames will be used as legend. Can be a bool to show or hide the plot legend. It can also be an array containing the strings to display; in that case, elements set to False will not be displayed.

legend_title

Title of the legend, defaults to None.

legend_size

Size of the legend, as in matplotlib. Defaults to 'medium'.

legend_loc

Location of the legend, as in matplotlib. Defaults to 'best'.

viridis: bool

Use the Viridis colormap for the plot. Defaults to False.

save_as

Filename to save the plot. None by default.

class Spectra:
164class Spectra:
165    """Spectra object. Used to load and process spectral data.
166
167    Most functions in the `sah` module receive this object as input.
168    """
169    def __init__(
170            self,
171            type:str=None,
172            comment:str=None,
173            files=None,
174            dfs=None,
175            units=None,
176            units_in=None,
177            plotting:Plotting=Plotting(),
178        ):
179        """All values can be set when initializing the Spectra object."""
180        self.type = None
181        """Type of the spectra: `'INS'`, `'ATR'`, or `'RAMAN'`."""
182        self.comment = comment
183        """Custom comment. If `Plotting.title` is None,  it will be the title of the plot."""
184        self.files = None
185        """List containing the files with the spectral data.
186
187        Loaded automatically to `dfs` with Pandas at initialization.
188        In order for Pandas to read the files properly, note that the column lines must start by `#`.
189        Any additional line that is not data must be removed or commented with `#`.
190        CSV files must be formatted with the first column as the energy or energy transfer,
191        and the second column with the intensity or absorbance, depending on the case.
192        An additional third `'Error'` column can be used.
193        """
194        self.dfs = None
195        """List containing the pandas dataframes with the spectral data.
196        
197        Loaded automatically from `files` at initialization.
198        """
199        self.units = None
200        """Target units of the spectral data.
201        
202        Can be `'meV'` or `'cm-1'`."""
203        self.units_in = None
204        """Input units of the spectral data, used in the input CSV files.
205        
206        Can be `'meV'` or `'cm-1'`.
207        If the input CSV files have different units,
208        it can also be set as a list of the same length of the number of input files,
209        eg. `['meV', 'cm-1', 'cm-1']`.
210        """
211        self.plotting = plotting
212        """`Plotting` object, used to set the plotting options."""
213
214        self = self._set_type(type)
215        self = self._set_dataframes(files, dfs)
216        self = self.set_units(units, units_in)
217
218    def _set_type(self, type):
219        """Set and normalize the type of the spectra: `INS`, `ATR`, or `RAMAN`."""
220        if type.lower() in aton.alias.experiments['ins']:
221            self.type = 'ins'
222        elif type.lower() in aton.alias.experiments['atr']:
223            self.type = 'atr'
224        elif type.lower() in aton.alias.experiments['raman']:
225            self.type = 'raman'
226        else:
227            self.type = type.lower()
228        return self
229
230    def _set_dataframes(self, files, dfs):
231        '''Set the dfs list of dataframes, from the given files or dfs.'''
232        if isinstance(files, list):
233            self.files = files
234        elif isinstance(files, str):
235            self.files = [files]
236        else:
237            self.files = []
238
239        if isinstance(dfs, pd.DataFrame):
240            self.dfs = [dfs]
241        elif isinstance(dfs, list) and isinstance(dfs[0], pd.DataFrame):
242            self.dfs = dfs
243        else:
244            self.dfs = [self._read_dataframe(filename) for filename in self.files]
245        return self
246
247    def _read_dataframe(self, filename):
248        """Read a dataframe from a file."""
249        root = os.getcwd()
250        file_path = os.path.join(root, filename)
251        df = pd.read_csv(file_path, comment='#', sep=r',|;|\s+', engine='python', header=None)
252        # Remove any empty columns
253        df = df.dropna(axis=1, how='all')
254        df = df.sort_values(by=df.columns[0]) # Sort the data by energy
255
256        print(f'\nNew dataframe from {filename}')
257        print(df.head(),'\n')
258        return df
259
260    def set_units(
261            self,
262            units,
263            units_in=None,
264            default_unit='cm-1',
265            ):
266        """Method to change between spectral units. ALWAYS use this method to do that.
267
268        For example, to change to meV from cm-1:
269        ```python
270        Spectra.set_units('meV', 'cm-1')
271        ```
272        """
273        mev = 'meV'
274        cm = 'cm-1'
275        unit_format={
276                mev: aton.alias.units['meV'],
277                cm: aton.alias.units['cm1'] + aton.alias.units['cm'],
278            }
279        if self.units is not None:
280            units_in = deepcopy(self.units)
281            self.units = units
282        elif units is not None:
283            units_in = units_in
284            self.units = deepcopy(units)
285        elif units is None and units_in is None:
286            units_in = None
287            self.units = default_unit
288        elif units is None and units_in is not None:
289            units_in = None
290            self.units = deepcopy(units_in)
291        if isinstance(units_in, list):
292            for i, unit_in in enumerate(units_in):
293                for key, value in unit_format.items():
294                    if unit_in in value:
295                        units_in[i] = key
296                        break
297            if len(units_in) == 1:
298                units_in = units_in * len(self.files)
299            elif len(units_in) != len(self.files):
300                raise ValueError("units_in must be a list of the same length as files.")
301        if isinstance(units_in, str):
302            for key, value in unit_format.items():
303                if units_in in value:
304                    units_in = key
305                    break
306            units_in = [units_in] * len(self.files)
307        if isinstance(self.units, list):
308            for i, unit in enumerate(self.units):
309                for key, value in unit_format.items():
310                    if unit in value:
311                        self.units[i] = key
312                        break
313            if len(self.units) == 1:
314                self.units = self.units * len(self.files)
315            elif len(self.units) != len(self.files):
316                raise ValueError("units_in must be a list of the same length as files.")
317        if isinstance(self.units, str):
318            for key, value in unit_format.items():
319                if self.units in value:
320                    self.units = key
321                    break
322            self.units = [self.units] * len(self.files)
323        if units_in is None:
324            return self
325        # Otherwise, convert the dfs
326        if len(self.units) != len(units_in):
327            raise ValueError("Units len mismatching.")
328        for i, unit in enumerate(self.units):
329            if unit == units_in[i]:
330                continue
331            if unit == mev and units_in[i] == cm: 
332                self.dfs[i][self.dfs[i].columns[0]] = self.dfs[i][self.dfs[i].columns[0]] * cm1_to_meV
333        for i, df in enumerate(self.dfs):
334            if self.units[i] == mev:
335                E_units = 'meV'
336            elif self.units[i] == cm:
337                E_units = 'cm-1'
338            else:
339                E_units = self.units[i]
340            if self.type == 'ins':
341                if self.dfs[i].shape[1] == 3:
342                    self.dfs[i].columns = [f'Energy transfer / {E_units}', 'S(Q,E)', 'Error']
343                elif self.dfs[i].shape[1] == 2:
344                    self.dfs[i].columns = [f'Energy transfer / {E_units}', 'S(Q,E)']
345            elif self.type == 'atr':
346                if self.dfs[i].shape[1] == 3:
347                    self.dfs[i].columns = [f'Wavenumber / {E_units}', 'Absorbance', 'Error']
348                elif self.dfs[i].shape[1] == 2:
349                    self.dfs[i].columns = [f'Wavenumber / {E_units}', 'Absorbance']
350            elif self.type == 'raman':
351                if self.dfs[i].shape[1] == 3:
352                    self.dfs[i].columns = [f'Raman shift / {E_units}', 'Counts', 'Error']
353                elif self.dfs[i].shape[1] == 2:
354                    self.dfs[i].columns = [f'Raman shift / {E_units}', 'Counts']
355        return self

Spectra object. Used to load and process spectral data.

Most functions in the sah module receive this object as input.

Spectra( type: str = None, comment: str = None, files=None, dfs=None, units=None, units_in=None, plotting: Plotting = <Plotting object>)
169    def __init__(
170            self,
171            type:str=None,
172            comment:str=None,
173            files=None,
174            dfs=None,
175            units=None,
176            units_in=None,
177            plotting:Plotting=Plotting(),
178        ):
179        """All values can be set when initializing the Spectra object."""
180        self.type = None
181        """Type of the spectra: `'INS'`, `'ATR'`, or `'RAMAN'`."""
182        self.comment = comment
183        """Custom comment. If `Plotting.title` is None,  it will be the title of the plot."""
184        self.files = None
185        """List containing the files with the spectral data.
186
187        Loaded automatically to `dfs` with Pandas at initialization.
188        In order for Pandas to read the files properly, note that the column lines must start by `#`.
189        Any additional line that is not data must be removed or commented with `#`.
190        CSV files must be formatted with the first column as the energy or energy transfer,
191        and the second column with the intensity or absorbance, depending on the case.
192        An additional third `'Error'` column can be used.
193        """
194        self.dfs = None
195        """List containing the pandas dataframes with the spectral data.
196        
197        Loaded automatically from `files` at initialization.
198        """
199        self.units = None
200        """Target units of the spectral data.
201        
202        Can be `'meV'` or `'cm-1'`."""
203        self.units_in = None
204        """Input units of the spectral data, used in the input CSV files.
205        
206        Can be `'meV'` or `'cm-1'`.
207        If the input CSV files have different units,
208        it can also be set as a list of the same length of the number of input files,
209        eg. `['meV', 'cm-1', 'cm-1']`.
210        """
211        self.plotting = plotting
212        """`Plotting` object, used to set the plotting options."""
213
214        self = self._set_type(type)
215        self = self._set_dataframes(files, dfs)
216        self = self.set_units(units, units_in)

All values can be set when initializing the Spectra object.

type

Type of the spectra: 'INS', 'ATR', or 'RAMAN'.

comment

Custom comment. If Plotting.title is None, it will be the title of the plot.

files

List containing the files with the spectral data.

Loaded automatically to dfs with Pandas at initialization. In order for Pandas to read the files properly, note that the column lines must start by #. Any additional line that is not data must be removed or commented with #. CSV files must be formatted with the first column as the energy or energy transfer, and the second column with the intensity or absorbance, depending on the case. An additional third 'Error' column can be used.

dfs

List containing the pandas dataframes with the spectral data.

Loaded automatically from files at initialization.

units

Target units of the spectral data.

Can be 'meV' or 'cm-1'.

units_in

Input units of the spectral data, used in the input CSV files.

Can be 'meV' or 'cm-1'. If the input CSV files have different units, it can also be set as a list of the same length of the number of input files, eg. ['meV', 'cm-1', 'cm-1'].

plotting

Plotting object, used to set the plotting options.

def set_units(self, units, units_in=None, default_unit='cm-1'):
260    def set_units(
261            self,
262            units,
263            units_in=None,
264            default_unit='cm-1',
265            ):
266        """Method to change between spectral units. ALWAYS use this method to do that.
267
268        For example, to change to meV from cm-1:
269        ```python
270        Spectra.set_units('meV', 'cm-1')
271        ```
272        """
273        mev = 'meV'
274        cm = 'cm-1'
275        unit_format={
276                mev: aton.alias.units['meV'],
277                cm: aton.alias.units['cm1'] + aton.alias.units['cm'],
278            }
279        if self.units is not None:
280            units_in = deepcopy(self.units)
281            self.units = units
282        elif units is not None:
283            units_in = units_in
284            self.units = deepcopy(units)
285        elif units is None and units_in is None:
286            units_in = None
287            self.units = default_unit
288        elif units is None and units_in is not None:
289            units_in = None
290            self.units = deepcopy(units_in)
291        if isinstance(units_in, list):
292            for i, unit_in in enumerate(units_in):
293                for key, value in unit_format.items():
294                    if unit_in in value:
295                        units_in[i] = key
296                        break
297            if len(units_in) == 1:
298                units_in = units_in * len(self.files)
299            elif len(units_in) != len(self.files):
300                raise ValueError("units_in must be a list of the same length as files.")
301        if isinstance(units_in, str):
302            for key, value in unit_format.items():
303                if units_in in value:
304                    units_in = key
305                    break
306            units_in = [units_in] * len(self.files)
307        if isinstance(self.units, list):
308            for i, unit in enumerate(self.units):
309                for key, value in unit_format.items():
310                    if unit in value:
311                        self.units[i] = key
312                        break
313            if len(self.units) == 1:
314                self.units = self.units * len(self.files)
315            elif len(self.units) != len(self.files):
316                raise ValueError("units_in must be a list of the same length as files.")
317        if isinstance(self.units, str):
318            for key, value in unit_format.items():
319                if self.units in value:
320                    self.units = key
321                    break
322            self.units = [self.units] * len(self.files)
323        if units_in is None:
324            return self
325        # Otherwise, convert the dfs
326        if len(self.units) != len(units_in):
327            raise ValueError("Units len mismatching.")
328        for i, unit in enumerate(self.units):
329            if unit == units_in[i]:
330                continue
331            if unit == mev and units_in[i] == cm: 
332                self.dfs[i][self.dfs[i].columns[0]] = self.dfs[i][self.dfs[i].columns[0]] * cm1_to_meV
333        for i, df in enumerate(self.dfs):
334            if self.units[i] == mev:
335                E_units = 'meV'
336            elif self.units[i] == cm:
337                E_units = 'cm-1'
338            else:
339                E_units = self.units[i]
340            if self.type == 'ins':
341                if self.dfs[i].shape[1] == 3:
342                    self.dfs[i].columns = [f'Energy transfer / {E_units}', 'S(Q,E)', 'Error']
343                elif self.dfs[i].shape[1] == 2:
344                    self.dfs[i].columns = [f'Energy transfer / {E_units}', 'S(Q,E)']
345            elif self.type == 'atr':
346                if self.dfs[i].shape[1] == 3:
347                    self.dfs[i].columns = [f'Wavenumber / {E_units}', 'Absorbance', 'Error']
348                elif self.dfs[i].shape[1] == 2:
349                    self.dfs[i].columns = [f'Wavenumber / {E_units}', 'Absorbance']
350            elif self.type == 'raman':
351                if self.dfs[i].shape[1] == 3:
352                    self.dfs[i].columns = [f'Raman shift / {E_units}', 'Counts', 'Error']
353                elif self.dfs[i].shape[1] == 2:
354                    self.dfs[i].columns = [f'Raman shift / {E_units}', 'Counts']
355        return self

Method to change between spectral units. ALWAYS use this method to do that.

For example, to change to meV from cm-1:

Spectra.set_units('meV', 'cm-1')
class Material:
358class Material:
359    """Material class.
360
361    Used to calculate molar masses and cross sections,
362    and to pass data to different analysis functions
363    such as `sah.deuterium.impulse_approx().`
364    """
365    def __init__(
366            self,
367            elements:dict,
368            name:str=None,
369            grams:float=None,
370            grams_error:float=None,
371            mols:float=None,
372            mols_error:float=None,
373            molar_mass:float=None,
374            cross_section:float=None,
375            peaks:dict=None,
376        ):
377        """
378        All values can be set when initializing the Material object.
379        However, it is recommended to only set the elements and the grams,
380        and optionally the name, and calculate the rest with `Material.set()`.
381        """
382        self.elements = elements
383        """Dict of atoms in the material, as in `{'C':1, 'N':1, 'H': 3, 'D':3}`.
384
385        Isotopes can be expressed as 'H2', 'He4', etc. with the atom symbol + isotope mass number.
386        """
387        self.name = name
388        """String with the name of the material."""
389        self.grams = grams
390        """Mass, in grams."""
391        self.grams_error = grams_error
392        """Error of the measured mass in grams.
393
394        Set automatically with `Material.set()`.
395        """
396        self.mols = mols
397        """Number of moles.
398
399        Set automatically with `Material.set()`.
400        """
401        self.mols_error = mols_error
402        """Error of the number of moles.
403
404        Set automatically with `Material.set()`.
405        """
406        self.molar_mass = molar_mass
407        """Molar mass of the material, in mol/g.
408
409        Calculated automatically with `Material.set()`.
410        """
411        self.cross_section = cross_section
412        """Neutron total bound scattering cross section, in barns.
413
414        Calculated automatically with `Material.set()`.
415        """
416        self.peaks = peaks
417        """Dict with interesting peaks that you might want to store for later use."""
418
419    def _set_grams_error(self):
420        """Set the error in grams, based on the number of decimal places."""
421        if self.grams is None:
422            return
423        decimal_accuracy = len(str(self.grams).split('.')[1])
424        # Calculate the error in grams
425        self.grams_error = 10**(-decimal_accuracy)
426
427    def _set_mass(self):
428        """Set the molar mass of the material.
429
430        If `Material.grams` is provided, the number of moles will be
431        calculated and overwritten. Isotopes can be used as 'element + A',
432        eg. `'He4'`. This gets splitted with `aton.txt.extract.isotope()`.
433        """
434        material_grams_per_mol = 0.0
435        for key in self.elements:
436            try:
437                material_grams_per_mol += self.elements[key] * periodictable.elements.symbol(key).mass
438            except KeyError:  # Split the atomic flag as H2, etc
439                element, isotope = aton.txt.extract.isotope(key)
440                isotope_name = isotope+'-'+element  # Periodictable format
441                material_grams_per_mol += self.elements[key] * periodictable.elements.isotope(isotope_name).mass
442        self.molar_mass = material_grams_per_mol
443        if self.grams is not None:
444            self._set_grams_error()
445            self.mols = self.grams / material_grams_per_mol
446            self.mols_error = self.mols * np.sqrt((self.grams_error / self.grams)**2)
447    
448    def _set_cross_section(self):
449        """Set the cross section of the material, based on the `elements` dict.
450
451        If an isotope is used, eg. `'He4'`, it splits the name with `aton.txt.extract.isotope()`.
452        """
453        total_cross_section = 0.0
454        for key in self.elements:
455            try:
456                total_cross_section += self.elements[key] * periodictable.elements.symbol(key).neutron.total
457            except KeyError: # Split the atomic flag as H2, etc
458                element, isotope_index = aton.txt.extract.isotope(key)
459                isotope_name = isotope+'-'+element  # Periodictable format
460                total_cross_section += self.elements[key] * periodictable.elements.isotope(isotope_name).neutron.total
461        self.cross_section = total_cross_section
462
463    def set(self):
464        """Set the molar mass, cross section and errors of the material."""
465        self._set_mass()
466        self._set_cross_section()
467
468    def print(self):
469        """Print a summary with the material information."""
470        print('\nMATERIAL')
471        if self.name is not None:
472            print(f'Name: {self.name}')
473        if self.grams is not None and self.grams_error is not None:
474            print(f'Grams: {self.grams} +- {self.grams_error} g')
475        elif self.grams is not None:
476            print(f'Grams: {self.grams} g')
477        if self.mols is not None and self.mols_error is not None:
478            print(f'Moles: {self.mols} +- {self.mols_error} mol')
479        elif self.mols is not None:
480            print(f'Moles: {self.mols} mol')
481        if self.molar_mass is not None:
482            print(f'Molar mass: {self.molar_mass} g/mol')
483        if self.cross_section is not None:
484            print(f'Cross section: {self.cross_section} barns')
485        if self.elements is not None:
486            print(f'Elements: {self.elements}')
487        print('')

Material class.

Used to calculate molar masses and cross sections, and to pass data to different analysis functions such as sah.deuterium.impulse_approx().

Material( elements: dict, name: str = None, grams: float = None, grams_error: float = None, mols: float = None, mols_error: float = None, molar_mass: float = None, cross_section: float = None, peaks: dict = None)
365    def __init__(
366            self,
367            elements:dict,
368            name:str=None,
369            grams:float=None,
370            grams_error:float=None,
371            mols:float=None,
372            mols_error:float=None,
373            molar_mass:float=None,
374            cross_section:float=None,
375            peaks:dict=None,
376        ):
377        """
378        All values can be set when initializing the Material object.
379        However, it is recommended to only set the elements and the grams,
380        and optionally the name, and calculate the rest with `Material.set()`.
381        """
382        self.elements = elements
383        """Dict of atoms in the material, as in `{'C':1, 'N':1, 'H': 3, 'D':3}`.
384
385        Isotopes can be expressed as 'H2', 'He4', etc. with the atom symbol + isotope mass number.
386        """
387        self.name = name
388        """String with the name of the material."""
389        self.grams = grams
390        """Mass, in grams."""
391        self.grams_error = grams_error
392        """Error of the measured mass in grams.
393
394        Set automatically with `Material.set()`.
395        """
396        self.mols = mols
397        """Number of moles.
398
399        Set automatically with `Material.set()`.
400        """
401        self.mols_error = mols_error
402        """Error of the number of moles.
403
404        Set automatically with `Material.set()`.
405        """
406        self.molar_mass = molar_mass
407        """Molar mass of the material, in mol/g.
408
409        Calculated automatically with `Material.set()`.
410        """
411        self.cross_section = cross_section
412        """Neutron total bound scattering cross section, in barns.
413
414        Calculated automatically with `Material.set()`.
415        """
416        self.peaks = peaks
417        """Dict with interesting peaks that you might want to store for later use."""

All values can be set when initializing the Material object. However, it is recommended to only set the elements and the grams, and optionally the name, and calculate the rest with Material.set().

elements

Dict of atoms in the material, as in {'C':1, 'N':1, 'H': 3, 'D':3}.

Isotopes can be expressed as 'H2', 'He4', etc. with the atom symbol + isotope mass number.

name

String with the name of the material.

grams

Mass, in grams.

grams_error

Error of the measured mass in grams.

Set automatically with Material.set().

mols

Number of moles.

Set automatically with Material.set().

mols_error

Error of the number of moles.

Set automatically with Material.set().

molar_mass

Molar mass of the material, in mol/g.

Calculated automatically with Material.set().

cross_section

Neutron total bound scattering cross section, in barns.

Calculated automatically with Material.set().

peaks

Dict with interesting peaks that you might want to store for later use.

def set(self):
463    def set(self):
464        """Set the molar mass, cross section and errors of the material."""
465        self._set_mass()
466        self._set_cross_section()

Set the molar mass, cross section and errors of the material.

def print(self):
468    def print(self):
469        """Print a summary with the material information."""
470        print('\nMATERIAL')
471        if self.name is not None:
472            print(f'Name: {self.name}')
473        if self.grams is not None and self.grams_error is not None:
474            print(f'Grams: {self.grams} +- {self.grams_error} g')
475        elif self.grams is not None:
476            print(f'Grams: {self.grams} g')
477        if self.mols is not None and self.mols_error is not None:
478            print(f'Moles: {self.mols} +- {self.mols_error} mol')
479        elif self.mols is not None:
480            print(f'Moles: {self.mols} mol')
481        if self.molar_mass is not None:
482            print(f'Molar mass: {self.molar_mass} g/mol')
483        if self.cross_section is not None:
484            print(f'Cross section: {self.cross_section} barns')
485        if self.elements is not None:
486            print(f'Elements: {self.elements}')
487        print('')

Print a summary with the material information.