Repository: radis/radis (language: python)

Issue:
Fully-Sparse wavenumber arrays
<!-- Please be sure to check out our contributing guidelines,
https://github.com/radis/radis/blob/develop/CONTRIBUTING.md . -->

> Edit 16/08/24 @erwanp  : compared to #386, this is a memory-efficient implementation of the Sparse wavenumber grid implementation. #386 calculated the convolution on a masked array, accelerating greatly the convolution step (CPU time); however the boolean arrays were still full in memory. For large objects; for instance 20,000 cm-1 spectrum with 0.001 cm-1 resolution yields 20M points i.e. likely ~300 Mb arrays   . Note: we might have been able to leverage Scipy Sparse arrays; it wasn't tried at the time.   cf @minouHub @erwanp for questions  


### Todo

- [x] implement fast merging of ranges (avoid computing all booleans arrays 4x times per element of the DIT grid) --> DONE, but slower
- [x] if not enough to improve performances, finish benchmarks to see when to switch on/off the sparse implementation --> DONE, with "auto" mode 
- [ ] keep the reduce/expand optimization of #386 even in non-sparse mode ?
 
### Description
<!-- Provide a general description of what your pull request does. -->

Following a partial sparse implementation of DLM in #386 , which used a reduce/expand step of the wavenumber arrays before/after the convolution step; here I introduce a fully-sparse architecture for the DLM arrays. 
Instead of storing wavenumber arrays, we only store coordinates of the future non-zero ranges.

- The hard part is that the mapping of the lines on the grid (former add-at step) has to be done at the same time as the construction of the sparse ranges. 
- An additional difficulty is that each line contributes to 2x2 sparse-ranges in the LDM grid, which are likely different from one another. Here we use boolean arrays and combine them.

The broadening step of the DLM can be summarized as : 

1. (same as before) find indices of all lines in the (w, wG, wL) grid 
2. (new) grow the wavenumber grids as we add the lines on it. This uses a Pandas DataFrame to first [group lines by lineshape](https://github.com/radis/radis/pull/388/files#diff-85a035414f3d48ffc0db8079be5cac5553de6dd376e4844da9917ffa289dfb64R1949) ( It is the current performance bottleneck, and may be replaced by a custom groupy function. ),  and then a [sparse_add_at](https://github.com/radis/radis/pull/388/files#diff-fd7b5829ec3e91a3bb58217dbf7acde5e9e72391a1b47718b01431faaa9bbe2dR699) function  (which works quite efficiently)
3. (same as #386) convolve only the reduced array


PS : as already mentioned on the Slack, this implementation allows to have a different truncation for each point of the DLM grid and therefore makes it very possible to deploy multiple-wavenumber-grids (soon !)


### NO Test

So far, the implementation is slightly slower than #386. The Distribution (step 2 above) is quite slow, because of Panda's Groupby(). 

```python

import astropy.units as u
from radis import SpectrumFactory, config, plot_diff

print("Without Sparse Range Optimization")
print("=================================")
print("\n"*2)

truncation = 1.5  # cm-1

config["SPARSE_WAVERANGE"] = False
sf = SpectrumFactory(1, 3000,  # 26777,         # cm-1
                  molecule='NO',
                  isotope='1,2,3',
                  truncation=truncation,      #   ~
                  pressure=760 * u.torr,
                  verbose=False,
                  wstep=0.01,
                  mole_fraction=1)

sf.fetch_databank('hitemp')
s = sf.eq_spectrum(700)
s.print_perf_profile()


print("With Sparse Range Optimization")
print("==============================")
print("\n"*2)

config["SPARSE_WAVERANGE"] = True
s2 = sf.eq_spectrum(700)
s2.print_perf_profile()

assert 2*sf.df1.hwhm_voigt.max()*10 < truncation  # HAPI conditions

#%%
s.name = f'0.10.3 ({s.conditions["calculation_time"]:.0f}s)'
s2.name = f'Sparse optim ({s2.conditions["calculation_time"]:.0f}s)'
_, [ax0, ax1] = plot_diff(s, s2)
ax0.set_yscale('log')
```


Output:
```
Without Sparse Range Optimization
=================================


NOspectrum3088769491008 profiler :
    spectrum_calculation      13.645s ████████████████
        check_line_databank             0.000s 
        reinitialize                    0.011s 
            copy_database                   0.008s 
            memory_usage_warning            0.003s 
            reset_population                0.000s 
        scaled_eq_linestrength          0.032s 
        calc_lineshift                  0.002s 
        calc_hwhm                       0.058s 
        generate_wavenumber_arrays      0.002s 
        calc_line_broadening            13.496s ███████████████
            precompute_DLM_lineshapes       0.058s 
            DLM_Initialized_vectors         0.000s 
            DLM_closest_matching_line       0.037s 
            DLM_Distribute_lines            0.513s 
            DLM_convolve                    12.754s ██████████████
            others                          0.134s 
        calc_other_spectral_quan        0.040s 
        generate_spectrum_obj           0.004s 
        others                          0.135s 
With Sparse Range Optimization
==============================



NOspectrum3088769491392 profiler :
    spectrum_calculation      9.275s ████████████████
        check_line_databank             0.000s 
        reinitialize                    0.013s 
            copy_database                   0.012s 
            memory_usage_warning            0.001s 
            reset_population                0.000s 
        scaled_eq_linestrength          0.032s 
        calc_lineshift                  0.003s 
        calc_hwhm                       0.060s 
        generate_wavenumber_arrays      0.002s 
        calc_line_broadening            9.125s ███████████████
            precompute_DLM_lineshapes       0.061s 
            DLM_Initialized_vectors         0.000s 
            DLM_closest_matching_line       0.043s 
            DLM_Distribute_lines            2.758s ████                  # ⬅⬅ this should be improved 
            DLM_convolve                    6.177s ██████████     # ⬅ was ~3.4  in #386 only ? 
            others                          0.086s 
        calc_other_spectral_quan        0.034s 
        generate_spectrum_obj           0.007s 
        others                          0.086s 

```

Match is excellent : 

![image](https://user-images.githubusercontent.com/16088743/139602832-fb2c3d0e-0501-48e0-9420-17980c4aab86.png)

### CO2-Full-Range Test


So performances are not as good as in #386 yet (_EDIT : Fixed, see comments below_) , however; the Memory efficiency is much better as the wavenumbers are stored sparse. This allowed me to compute full-range NO spectra and full-range CO2 spectra ( 9.9 M lines, 2.7M spectral-points )  on a 16 GB RAM laptop without any problem .
Ex : 

```python

import astropy.units as u
from radis import calc_spectrum
s = calc_spectrum(1, 26777,         # cm-1           # <<< full spectral range!
                  molecule='CO2',
                  isotope='1,2,3',
                  pressure=760 * u.torr,
                  Tgas=700,           # K
                  mole_fraction=1,
                  wstep=0.01,
                  databank='hitemp',
                  verbose=3,
                  cutoff=0,
                  
                  )
s.print_perf_profile()
s.name = f"CO2 ({s.conditions['lines_calculated']:.0e}M lines, {s.conditions['calculation_time']:.1f}s)"
s.plot(yscale='log')
import matplotlib.pyplot as plt
plt.legend()
```

```
CO2spectrum3089228638272 profiler :
    spectrum_calculation      101.857s ████████████████
        check_line_databank             0.014s 
        reinitialize                    0.014s 
            copy_database                   0.000s 
            memory_usage_warning            0.013s 
            reset_population                0.001s 
        scaled_eq_linestrength          0.955s 
        calc_lineshift                  0.072s 
        calc_hwhm                       1.580s 
        generate_wavenumber_arrays      0.027s 
        calc_line_broadening            98.719s ███████████████
            precompute_DLM_lineshapes       0.820s 
            DLM_Initialized_vectors         0.001s 
            DLM_closest_matching_line       5.340s 
            DLM_Distribute_lines            38.251s ██████
            DLM_convolve                    53.500s ████████
            others                          0.807s 
        calc_other_spectral_quan        0.406s 
        generate_spectrum_obj           0.041s 
        others                          0.837s 
```
![image](https://user-images.githubusercontent.com/16088743/139603142-99625ee9-4306-4274-b47d-f2aaeee2736d.png)

(So ~ 2.5e11 points*lines / s ; which is what we achieved in the DIT paper ... but we could never scale to these large spectral ranges until now )

Updated convolve_with_slit with warnings if instrumental function has large near-zero wings
updated warnings on convolve_with slit and the documentaiton of the function, modified doc of spectrum to point to the doc of convolve_with_wlit

<!-- Please be sure to check out our contributing guidelines,
https://github.com/radis/radis/blob/develop/CONTRIBUTING.md . -->

### Description
<!-- Provide a general description of what your pull request does. -->

PR in order to update the documentation and the errors of radis.tools.slit.py convolve_with_slit.

Convolution intervene in mainly 2 places:
- convolution with physical broadening of spectral features during simulation
- convolution with instrumental function to fit simulation to experimental spectra

I did my best to explain to the user what to expect out of the convolution product in the case of intrumental functions.

It is important to tell users how convolution could affect data outside of the know spectral range. 

I modified apply_slit to point to the Notes I wrote in convolve_with_slit.

I transformed the AssertError of convolve with slit with several warnings, that look both at the spectral range and the FWHM of the input experimental slit. IMO, it will better guide users to what they should do in order to improve the output of the convolution.

<!-- If the pull request closes any open issues you can add this.
If you replace <Issue Number> with a number, GitHub will automatically link it.
If this pull request is unrelated to any issues, please remove
the following line. -->
Partition function calculation stuck in Spyder with "tabulation" mode (vaex)
### 🐛 Describe the bug
<!--- A clear and concise description of the bug (or unexpected behavior) -->

As already seen in #316 , the just-in-time tabulation of partition functions with `parsum_mode='tabulation'` is stuck if ran within a Spyder iPython console.


### 🖥 Steps To Reproduce
<!--- Steps or code snippet to reproduce the behavior.-->
<!--- Best : use test files so that everyone can run your example : https://radis.readthedocs.io/en/latest/dev/developer.html#test-files -->

Run the following snippet in a Spyder console : 

```python
import radis
radis.DEBUG_MODE = True
from radis import SpectrumFactory
sf = SpectrumFactory(wmin=2283.7,
                      wmax=2285,
                      isotope='1,2,3',
                      verbose=3,    # more for more details
                      parsum_mode="tabulation"     # 👈 comment to run in normal mode
                      )
sf.load_databank('HITEMP-CO2-TEST')        # predefined in ~/radis.json
s = sf.non_eq_spectrum(Tvib=3000, Trot=2000, path_length=1)
s.print_perf_profile()

```

The stuck happens when computing a mean with vaex.

Even more minimal error : 
```python
import numpy as np
import vaex
df = vaex.from_dict({"E":np.random.rand(1000)})
print(df)
print("before EQ mean")
E_bins = df.mean("E", binby="E", shape=10)
print("after EQ mean")       # 👈 I never get to here
```

Script works well if 
- ran from an Ipython ( 7.22.0) console in the terminal 
- ran from Python in the terminal
- ran from VS Code
- ran from Jupyter Lab (3.0.14)
- ran in Jupyter QtConsole



As pointed out by @minouHub , opening a new Spyder IPython console unlocks the process. If console has been "unlocked", I can even re-run the script another time before it gets stuck again. (?)

Last thing : although no graphics are plot anywhere in this example, this problem appears when my iPython console has **Qt5** output graphics. It does not appear with **Inline** graphics (tried both consistently 3 times)



> spyder                    5.0.5            py38haa95532_2
> spyder-kernels            2.0.5            py38haa95532_0
> ipython                   7.22.0           py38hd4e2768_0

on Windows.

Bug of get_baseline() function of Spectrum class
### 🐛 Describe the bug
1) radis.spectrum.operations.get_baseline() function has a `peakutils` module dependency which is not included in RADIS dependencies.  

2) There is a missing parameter in radis.spectrum.operations.get_baseline() function call. There also should be a wunit parameter for `Spectrum.from_array()` correct work. 

### 💡 Possible solutions
1) The first can be solved  by: `conda install -c conda-forge peakutils`, suggested by @erwanp

2) The second can be solved by adding wunit parameter to the line 1049 and 1076 of 
```
1049: def get_baseline(s, var="radiance", wunit="nm", Iunit=None):
1076:         w1, baseline, var, wunit=wunit, unit=Iunit, name=s.get_name() + "_baseline"
```


Discussion / hints:

# [Codecov](https://codecov.io/gh/radis/radis/pull/283?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=radis) Report
> Merging [#283](https://codecov.io/gh/radis/radis/pull/283?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=radis) (7ef1d48) into [develop](https://codecov.io/gh/radis/radis/commit/658e629de991586e585b220309349df229f4c86c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=radis) (658e629) will **increase** coverage by `0.00%`.
> The diff coverage is `77.77%`.


```diff
@@           Coverage Diff            @@
##           develop     #283   +/-   ##
========================================
  Coverage    75.24%   75.25%           
========================================
  Files          119      119           
  Lines        14273    14284   +11     
========================================
+ Hits         10740    10749    +9     
- Misses        3533     3535    +2     
```

Reported to the Spyder team https://github.com/spyder-ide/spyder/issues/16183 
Hello,
with enabled `parsum_mode="tabulation"` in plot_multi_temperature_fit.py example my spyder 5.0.3 crashes without explanation and running from terminal gives an error with the following traceback:

```
ERROR:MainThread:vaex.execution:error in task, flush task queue
Traceback (most recent call last):
  File "C:\Users\Blekhshtein\anaconda3\envs\radis-env\lib\site-packages\vaex\execution.py", line 219, in execute_async task._result = parts[0].get_result()
  File "C:\Users\Blekhshtein\anaconda3\envs\radis-env\lib\site-packages\vaex\cpu.py", line 532, in get_result result = result.copy()

numpy.core._exceptions._ArrayMemoryError: Unable to allocate 206. MiB for an array with shape (300, 300, 300) and data type int64

Traceback (most recent call last):
  File "examples/plot_multi_temperature_fit.py", line 70, in <module> s_best, best = sf.fit_spectrum(
  File "c:\users\blekhshtein\radis\radis\lbl\factory.py", line 1860, in fit_spectrum return fit_spectrum(
  File "c:\users\blekhshtein\radis\radis\tools\fitting.py", line 217, in fit_spectrum compute_los_model(
  File "c:\users\blekhshtein\radis\radis\tools\fitting.py", line 211, in <lambda> compute_los_model = lambda fit_parameters: model(
  File "c:\users\blekhshtein\radis\radis\tools\fitting.py", line 131, in Tvib12Tvib3Trot_NonLTEModel s = factory.non_eq_spectrum(**kwargs)
  File "c:\users\blekhshtein\radis\radis\lbl\factory.py", line 1328, in non_eq_spectrum self._calc_populations_noneq_multiTvib(
  File "c:\users\blekhshtein\radis\radis\lbl\base.py", line 2742, in _calc_populations_noneq_multiTvib / Q(Tvib, Trot)
  File "c:\users\blekhshtein\radis\radis\lbl\base.py", line 2655, in Q Q_dict[iso] = parsum.at_noneq_3Tvib(
  File "c:\users\blekhshtein\radis\radis\levels\partfunc.py", line 809, in at_noneq_3Tvib return self._noneq_3Tvib_tabulation_eval(
  File "c:\users\blekhshtein\radis\radis\levels\partfunc.py", line 962, in _noneq_3Tvib_tabulation_eval self._noneq_3Tvib_tabulation_setup(
  File "c:\users\blekhshtein\radis\radis\levels\partfunc.py", line 897, in _noneq_3Tvib_tabulation_setup g_bins_neq = df.sum(
  File "C:\Users\Blekhshtein\anaconda3\envs\radis-env\lib\site-packages\vaex\dataframe.py", line 943, in sum return self._compute_agg('sum', expression, binby, limits, shape, selection, delay, edges, progress, array_type=array_type)
  File "C:\Users\Blekhshtein\anaconda3\envs\radis-env\lib\site-packages\vaex\dataframe.py", line 773, in _compute_agg return self._delay(delay, var)
  File "C:\Users\Blekhshtein\anaconda3\envs\radis-env\lib\site-packages\vaex\dataframe.py", line 1537, in _delay self.execute()
  File "C:\Users\Blekhshtein\anaconda3\envs\radis-env\lib\site-packages\vaex\dataframe.py", line 375, in execute just_run(self.execute_async())
  File "C:\Users\Blekhshtein\anaconda3\envs\radis-env\lib\site-packages\vaex\asyncio.py", line 35, in just_run return loop.run_until_complete(coro)
  File "C:\Users\Blekhshtein\anaconda3\envs\radis-env\lib\site-packages\nest_asyncio.py", line 70, in run_until_complete return f.result()
  File "C:\Users\Blekhshtein\anaconda3\envs\radis-env\lib\asyncio\futures.py", line 178, in result raise self._exception
  File "C:\Users\Blekhshtein\anaconda3\envs\radis-env\lib\asyncio\tasks.py", line 280, in __step result = coro.send(None)
  File "C:\Users\Blekhshtein\anaconda3\envs\radis-env\lib\site-packages\vaex\dataframe.py", line 380, in execute_async await self.executor.execute_async()
  File "C:\Users\Blekhshtein\anaconda3\envs\radis-env\lib\site-packages\vaex\execution.py", line 219, in execute_async task._result = parts[0].get_result()
  File "C:\Users\Blekhshtein\anaconda3\envs\radis-env\lib\site-packages\vaex\cpu.py", line 532, in get_result result = result.copy()
numpy.core._exceptions.MemoryError: Unable to allocate 206. MiB for an array with shape (300, 300, 300) and data type int64
```
This error is not related to the vaex-stuck problem above. 
Here are some insights : 

-----

The table used to compute the  partition functions is too large for your system. 

You can adjust it by reducing the [N_bins](https://github.com/radis/radis/blob/74c9883d0063060a3bca799a0753eb03b2312b3a/radis/levels/partfunc.py#L228) parameter. 
It can be done directly in the source code, or  at runtime by changing `sf.parsum_calc[molecule][isotope][electronic_state].N_bins` . 

It will cost you a bit of accuracy. Below, it turns out that reducing from the default 300 to 100 has no visible impact on a typical spectrum (adjust with your typical conditions). But this makes the tabulation grid 3^3 = 27 times smaller. Should fix your memory problems. 

: 
```
from radis import SpectrumFactory
sf = SpectrumFactory(
    2284.2,
    2284.6,
    wstep=0.001,  # cm-1
    pressure=20 * 1e-3,  # bar
    db_use_cached=True,
    lvl_use_cached=True,
    cutoff=1e-25,
    isotope="1,2",
    path_length=10,  # cm-1
    mole_fraction=0.1 * 28.97 / 44.07,
    broadening_max_width=1,  # cm-1
    medium="vacuum",
    export_populations=None,  # 'vib',
    parsum_mode="tabulation"
)
sf.load_databank("HITEMP-CO2-TEST")

Tvib = (1600, 1600, 2500)
Trot = 1200

sf.load_databank('HITEMP-CO2-TEST')        # predefined in ~/radis.
s0 = sf.non_eq_spectrum(Tvib, Trot)

# now, adjust number of bins in partitoin functino calculator : 
for iso in sf._get_isotope_list():
    sf.parsum_calc['CO2'][iso]["X"].N_bins = 100    #👈 reduce this further if you want
s = sf.non_eq_spectrum(Tvib, Trot)

from radis import plot_diff
plot_diff(s0, s)
```
![image](https://user-images.githubusercontent.com/16088743/128718729-4d5c5f1c-287e-456a-959f-98792018a0d0.png)

Thank you for the information.
I thought it is related to the topic, sorry for misposting

All good, this lead to an improvement. We can coarsen the grid for higher dimensions with no impact on accuracy. Proposed in #339 


Fix patch:
diff --git a/README.rst b/README.rst
index 3258de52a..984cc2741 100644
--- a/README.rst
+++ b/README.rst
@@ -6,9 +6,9 @@
 *****************************************
 
 RADIS is a fast line-by-line code for high resolution infrared molecular spectra (emission / absorption,
-equilibrium / nonequilibrium).
+equilibrium / non-LTE) based on HITRAN/HITEMP/ExoMol.
 
-Includes post-processing tools to compare experimental spectra and spectra calculated
+It includes post-processing tools to compare experimental spectra and spectra calculated
 with RADIS or other spectral codes.
 
 User guide, install procedure and examples are available on the `RADIS Website <http://radis.readthedocs.io/>`__:
diff --git a/docs/lbl/lbl.rst b/docs/lbl/lbl.rst
index daddfcb70..28d9fffd2 100644
--- a/docs/lbl/lbl.rst
+++ b/docs/lbl/lbl.rst
@@ -364,11 +364,15 @@ and :py:mod:`~astropy.units` ::
                          cutoff=1e-25,              # cm/molecule
                          broadening_max_width=10,   # cm-1
                          )
-    sf.load_databank('HITRAN-CO2-TEST')        # this database must be defined in ~/radis.json
+    sf.load_databank('HITRAN-CO2-TEST', load_columns='noneq')        # this database must be defined in ~/radis.json
     s1 = sf.eq_spectrum(Tgas=300 * u.K)
     s2 = sf.eq_spectrum(Tgas=2000 * u.K)
     s3 = sf.non_eq_spectrum(Tvib=2000 * u.K, Trot=300 * u.K)
 
+Note that for non-LTE calculations, specific columns must be loaded. This is done by using the
+``load_columns='noneq'`` parameter. See :py:meth:`~radis.lbl.loader.DatabankLoader.load_databank`
+for more information.
+
 
 .. _label_lbl_config_file:
 
@@ -737,6 +741,21 @@ points for each linewidth.
     but is still experimental in 0.9.30. Feedback welcome!
 
 
+Sparse wavenumber grid
+----------------------
+
+To compute large band spectra with a small number of lines, RADIS includes
+a sparse wavenumber implementation of the DIT algorithm, which is
+activated based on a scarcity criterion (``Nlines/Ngrid_points > 1``).
+
+The sparse version can be forced to be activated or deactivated. This behavior
+is done by setting the `SPARSE_WAVENUMBER` key of the :py:attr:`radis.config`
+dictionary, or of the ~/radis.json user file.
+
+See the :ref:`HITRAN full-range example <example_hitran_full_range>` for an
+example.
+
+
 Database loading
 ----------------
 
diff --git a/docs/spectrum/spectrum.rst b/docs/spectrum/spectrum.rst
index 959cdfbd7..8ba0b4873 100644
--- a/docs/spectrum/spectrum.rst
+++ b/docs/spectrum/spectrum.rst
@@ -68,6 +68,7 @@ be used to batch-generate multiple spectra using the same line database::
 
     from radis import SpectrumFactory
     sf = SpectrumFactory(...)
+    sf.fetch_databank("hitemp", load_columns='noneq')  # or 'hitran', 'exomol', etc.
     s = sf.eq_spectrum(...)
     s2 = sf.non_eq_spectrum(...)
 
@@ -103,8 +104,14 @@ convenience function::
     s = Spectrum.from_array(w, T, 'transmittance_noslit',
                                wunit='nm', unit='') # adimensioned
 
+Dimensionned arrays can also be used directly ::
 
-Convenience functions have been added to handle the usual cases:
+    import astropy.units as u
+    w = np.linspace(200, 300) * u.nm
+    I = np.random.rand(len(w)) * u.mW/u.cm**2/u.sr/u.nm
+    s = Spectrum.from_array(w, I, 'radiance_noslit')
+
+Other convenience functions have been added to handle the usual cases:
 :func:`~radis.spectrum.models.calculated_spectrum`,
 :func:`~radis.spectrum.models.transmittance_spectrum` and
 :func:`~radis.spectrum.models.experimental_spectrum`::
@@ -116,6 +123,16 @@ Convenience functions have been added to handle the usual cases:
     s3 = experimental_spectrum(w, I, wunit='nm', Iunit='W/cm2/sr/nm')   # creates 'radiance'
 
 
+Initialize from Specutils
+-------------------------
+
+Use :py:meth:`~radis.spectrum.spectrum.Spectrum.from_specutils` to convert
+from a ``specutils`` :py:class:`specutils.spectra.spectrum1d.Spectrum1D` object ::
+
+    from radis import Spectrum
+    Spectrum.from_specutils(spectrum)
+
+
 Initialize from a text file
 ---------------------------
 
@@ -156,9 +173,21 @@ Spectrum conditions and get Spectrum that suits specific parameters
 .. minigallery:: radis.load_spec
 
 
-Want a quick spectrum
+Load from a HDF5 file
 ---------------------
 
+This is the fastest way to read a Spectrum object from disk. It keeps metadata
+and units, and you can also load only a part of a very large spectrum.
+Use :py:meth:`~radis.spectrum.spectrum.Spectrum.from_hdf5`  ::
+
+    Spectrum.from_hdf5("rad_hdf.h5", wmin=2100, wmax=2200, columns=['abscoeff', 'emisscoeff'])
+
+
+
+
+Calculate a test spectrum
+-------------------------
+
 You need a spectrum immediatly, to run some tests ? Use :py:func:`~radis.test.utils.test_spectrum` ::
 
     s = radis.test_spectrum()
@@ -446,6 +475,14 @@ automatically retrieves a spectrum from a database if you calculated it already,
 or calculates it and stores it if you didn't. Very useful for spectra that
 requires long computational times!
 
+Export to hdf5
+--------------
+
+This is the fastest way to dump a Spectrum object on disk (and also, it keeps metadata
+and therefore units !). Use :py:meth:`~radis.spectrum.spectrum.Spectrum.to_hdf5`  ::
+
+    s.to_hdf5('spec01.h5')
+
 
 Export to txt
 -------------
@@ -460,6 +497,28 @@ will export a given spectral arrays::
 
     s.savetxt('radiance_W_cm2_sr_um.csv', 'radiance_noslit', wunit='nm', Iunit='W/cm2/sr/µm')
 
+Export to Pandas
+----------------
+
+Use :py:meth:`~radis.spectrum.spectrum.Spectrum.to_pandas` ::
+
+    s.to_pandas()
+
+This will return a DataFrame with all spectral arrays as columns.
+
+
+Export to Specutils
+-------------------
+
+Use :py:meth:`~radis.spectrum.spectrum.Spectrum.to_specutils` to convert
+to a to ``specutils`` :py:class:`specutils.spectra.spectrum1d.Spectrum1D` object ::
+
+    s.to_specutils()
+
+
+.. minigallery:: radis.spectrum.spectrum.Spectrum.to_specutils
+
+
 
 How to modify a Spectrum object?
 ================================
@@ -605,13 +664,15 @@ For instance, remove a constant baseline in a given unit::
 
     s -= 0.1 * u.Unit('W/cm2/sr/nm')
 
-Here, se normalize a spectrum manually, assuming the spectrum units is in "mW/cm2/sr/nm" ::
+The :py:meth:`~radis.spectrum.spectrum.Spectrum.max` function returns a dimensionned
+value, therefore it can be used to normalize a spectrum directly :  ::
 
-    s /= s.max() * u.Unit("mW/cm2/sr/nm")
+    s /= s.max()
 
-Or below, we calibrate a Spectrum, assuming the spectrum units is in "count" ::
+Or below, we calibrate a Spectrum, assuming the spectrum units is in "count",
+and that our calibration show we have 94 :math:`mW/cm2/sr/nm` per count.  ::
 
-    s *= 100 * u.Unit("mW/cm2/sr/nm/count")
+    s *= 94 * u.Unit("mW/cm2/sr/nm") / u.Unit("count")
 
 
 .. _label_spectrum_offset_crop:
@@ -666,7 +727,7 @@ Or::
 If you want to create a new spectrum, don't forget to set ``inplace=False``
 for the first command that allows it. i.e ::
 
-    s2 = s.crop(4120, 4220, 'nm', inplace=False).apply_slit(3, 'nm').take('radiance')
+    s2 = s.crop(4120, 4220, 'nm', inplace=False).apply_slit(3, 'nm').offset(1.5, 'nm')
 
 
 .. _label_spectrum_remove_baseline:
@@ -684,6 +745,9 @@ Or remove a linear baseline with:
 - :py:func:`~radis.spectrum.operations.get_baseline`
 - :py:func:`~radis.spectrum.operations.sub_baseline`
 
+You could also use the functions available in :py:mod:`pyspecutils`,
+see :py:meth:`~radis.spectrum.spectrum.Spectrum.to_specutils`.
+
 
 Calculate transmittance from radiance with Kirchoff's law
 ---------------------------------------------------------
diff --git a/environment.yml b/environment.yml
index 5ae88f1da..927be0c9d 100644
--- a/environment.yml
+++ b/environment.yml
@@ -6,35 +6,35 @@ channels:
 - cantera
 dependencies:
 - python=3.8
-- numpy
-- scipy>=1.4.0
-- matplotlib
-- seaborn   # other matplotlib themes
-- cantera   # for chemical equilibrium computations
-- cython
-- pandas>=1.0.5
-- pytables  # for pandas to HDF5 export
-- plotly    # used in line-survey
 - astropy  # Unit aware calculations
-- plotly>=2.5.1  # for line survey HTML output
-- termcolor  # terminal colors
-- configparser
 - astroquery>=0.3.9  # to fetch HITRAN databases
-- pytest  # to run test suite
+- cantera   # for chemical equilibrium computations
+- configparser
+- cython
+- h5py   # load HDF5
 - joblib  # for parallel loading of SpecDatabase
+- matplotlib
+- numpy
 - numba  # just-in-time compiler
+- pandas>=1.0.5
+- plotly>=2.5.1  # for line survey HTML output
 - psutil # for getting user RAM
-- h5py   # load HDF5
+- scipy>=1.4.0
+- seaborn   # other matplotlib themes
+- termcolor  # terminal colors
+- specutils
+- vaex>=4.4.0   # load HDF5 files  (version for custom HDF5 groups)
 - pip
 - pip:
   - beautifulsoup4 # parse ExoMol website
   - lxml           # parser used for ExoMol website
-  - pyarrow       # for the feather format (temporarily needed for ExoMol)
+  - hjson          # Json with comments (for default_radis.json)
   - publib>=0.3.2  # Plotting styles for Matplotlib
   - hitran-api     # HAPI, used to access TIPS partition functions
+  - peakutils
   - json-tricks>=3.15.0  # to deal with non jsonable formats
   - mpldatacursor
+  - progressbar2    # used in vaex
   - tuna           # to generate visual/interactive performance profiles
   - tables  # for pandas to HDF5 export
-  - vaex    # load HDF5 files
   - habanero  # CrossRef API to retrieve data from doi
diff --git a/examples/get_molecular_parameters.py b/examples/get_molecular_parameters.py
new file mode 100644
index 000000000..f2ca4e5e0
--- /dev/null
+++ b/examples/get_molecular_parameters.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+"""
+========================
+Get Molecular Parameters
+========================
+
+Retrieve isotopologue abundances or molecular mass, based on HITRAN data,
+using :py:class:`~radis.db.molparam.MolParams`
+
+See also how to use custom (non-terrestrial) abundances in
+:ref:`the Custom Abundances example <example_custom_abundances>`
+
+"""
+
+#%% Access the parameters:
+from radis.db.molparam import MolParams
+
+molpar = MolParams()
+
+print("CO2 abundances for the first 2 isotopes :")
+print(molpar.get("CO2", 1, "abundance"))  # 1 for isotopologue number
+print(molpar.get("CO2", 2, "abundance"))  # 1 for isotopologue number
+
+
+print("H2O molar mass for the first 2 isotopes")
+print(molpar.get("H2O", 1, "mol_mass"))  # 1 for isotopologue number
+print(molpar.get("CO2", 2, "mol_mass"))  # 1 for isotopologue number
+
+
+print("Tabulated CO partition function at 296 K for the first 2 isotopes")
+print(molpar.get("CO", 1, "Q_296K"))  # 1 for isotopologue number
+print(molpar.get("CO", 2, "Q_296K"))  # 1 for isotopologue number
+
+
+#%% All parameters are stored in a DataFrame:
+print("All parameters: ", list(molpar.df.columns))
+print(molpar.df)
diff --git a/examples/plot_SpecDatabase.py b/examples/plot_SpecDatabase.py
index 2f2590b1e..8265bcb3d 100644
--- a/examples/plot_SpecDatabase.py
+++ b/examples/plot_SpecDatabase.py
@@ -53,7 +53,7 @@
 
 # Multiple Spectrum calculation based on different values of Tgas and wstep
 for i in wstep:
-    sf.wstep = i
+    sf._wstep = i
     sf.params.wstep = i
     for j in Tgas:
         sf.eq_spectrum(Tgas=j, path_length=1)
diff --git a/examples/plot_compare_CO_exomol_hitemp.py b/examples/plot_compare_CO_exomol_hitemp.py
new file mode 100644
index 000000000..b35044e54
--- /dev/null
+++ b/examples/plot_compare_CO_exomol_hitemp.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+"""
+==============================================
+Compare CO from the ExoMol and HITEMP database
+==============================================
+
+Auto-download and calculate CO spectrum from the HITEMP database, and the
+ExoMol database. ExoMol references multiple databases for CO. Here we do not
+use the ExoMol recommended database (see :py:func:`~radis.io.exomol.get_exomol_database_list`)
+but we use the HITEMP database hosted on ExoMol.
+
+Output should be similar, but no ! By default these two databases provide
+different broadening coefficients. However, the Einstein coefficients & linestrengths
+should be the same, therefore the integrals under the lines should be similar.
+
+We verifiy this below :
+
+For further exploratino, ExoMol and HITEMP lines can be downloaded and accessed separately using
+:py:func:`~radis.io.exomol.fetch_exomol` and :py:func:`~radis.io.hitemp.fetch_hitemp`
+
+"""
+import astropy.units as u
+
+from radis import calc_spectrum, plot_diff
+
+conditions = {
+    "wmin": 2002 / u.cm,
+    "wmax": 2300 / u.cm,
+    "molecule": "CO",
+    "isotope": "2",
+    "pressure": 1.01325,  # bar
+    "Tgas": 1000,  # K
+    "mole_fraction": 0.1,
+    "path_length": 1,  # cm
+    "broadening_method": "fft",  # @ dev: Doesn't work with 'voigt'
+    "verbose": True,
+}
+
+s_exomol = calc_spectrum(
+    **conditions, databank="exomol", name="ExoMol's HITEMP (H2 broadened)"
+)
+s_hitemp = calc_spectrum(
+    **conditions,
+    databank="hitemp",
+    name="HITEMP (Air broadened)",
+)
+plot_diff(s_exomol, s_hitemp, "abscoeff")
+
+#%% Broadening coefficients are different but areas under the lines should be the same :
+import numpy as np
+
+assert np.isclose(
+    s_exomol.get_integral("abscoeff"), s_hitemp.get_integral("abscoeff"), rtol=0.001
+)
diff --git a/examples/plot_custom_abundances.py b/examples/plot_custom_abundances.py
index caf3e7d37..cb62e41ec 100644
--- a/examples/plot_custom_abundances.py
+++ b/examples/plot_custom_abundances.py
@@ -1,5 +1,7 @@
 # -*- coding: utf-8 -*-
 """
+.. _example_custom_abundances:
+
 =====================
 Use Custom Abundances
 =====================
diff --git a/examples/plot_exomol_spectrum.py b/examples/plot_exomol_spectrum.py
index db634a7df..67094b716 100644
--- a/examples/plot_exomol_spectrum.py
+++ b/examples/plot_exomol_spectrum.py
@@ -6,6 +6,9 @@
 
 Auto-download and compute a SiO spectrum from the ExoMol database ([ExoMol-2020]_)
 
+ExoMol lines can be downloaded and accessed separately using
+:py:func:`~radis.io.exomol.fetch_exomol`
+
 """
 
 from radis import calc_spectrum
@@ -20,7 +23,24 @@
     mole_fraction=0.1,
     path_length=1,  # cm
     broadening_method="fft",  # @ dev: Doesn't work with 'voigt'
-    databank="exomol",  # uses the recommended database. Use ('exomol', "EBJT") for a specific database ("EBJT")
+    databank=("exomol", "EBJT"),  # Simply use 'exomol' for the recommended database
 )
 s.apply_slit(1, "cm-1")  # simulate an experimental slit
 s.plot("radiance")
+
+
+#%% See line data:
+from radis.io.exomol import fetch_exomol
+
+df = fetch_exomol("SiO", database="EBJT", isotope="1", load_wavenum_max=5000)
+print(df)
+
+
+#%% See the list of recommended databases for the 1st isotope of SiO :
+from radis.io.exomol import get_exomol_database_list, get_exomol_full_isotope_name
+
+databases, recommended = get_exomol_database_list(
+    "SiO", get_exomol_full_isotope_name("SiO", 1)
+)
+print("Databases for SiO: ", databases)
+print("Database recommended by ExoMol: ", recommended)
diff --git a/examples/plot_hitemp_OH_database.py b/examples/plot_hitemp_OH_database.py
index b4f6e2254..920973167 100644
--- a/examples/plot_hitemp_OH_database.py
+++ b/examples/plot_hitemp_OH_database.py
@@ -31,6 +31,5 @@
 # function. The already downloaded database will be used:
 #
 
-fetch_hitemp("OH", load_wavenum_min=31500, load_wavenum_max=33000, isotope="1").plot(
-    "wav", "int"
-)
+df = fetch_hitemp("OH", load_wavenum_min=31500, load_wavenum_max=33000, isotope="1")
+df.plot("wav", "int")
diff --git a/examples/plot_hitran_full_range.py b/examples/plot_hitran_full_range.py
new file mode 100644
index 000000000..9a5959056
--- /dev/null
+++ b/examples/plot_hitran_full_range.py
@@ -0,0 +1,78 @@
+# -*- coding: utf-8 -*-
+"""
+.. _example_hitran_full_range:
+
+===============================
+Calculate a full range spectrum
+===============================
+
+We compute the transmittance of an homogeneous, 1-km atmosphere layer
+at 300 K and 1 atm, with 420 ppm CO2 and 2% H2O.
+
+This example makes use of a lot of the RADIS optimization and convenience functions :
+
+- The database is retrieved from the latest HITRAN data using [HAPI]_, parsed
+  with RADIS and stored to memory-mapping formats. After the first calls,
+  retrieving the database will be very fast
+
+- The RADIS algorithm includes a sparse wavenumber implementation, which is
+  activated by default in the following example where the waverange is very
+  large. This behavior can be changed by setting the `SPARSE_WAVENUMBER` key
+  of the :py:attr:`radis.config` dictionary, or of the ~/radis.json user file.
+
+- The wavenumber grid is automatically computed using the `wstep='auto'` parameter.
+
+- A linestrength cutoff reduces the initial number of lines.
+
+Eventually, the full-range spectrum (about 4.8M points) is computed within a minute,
+with about 140k lines resolved, i.e. a performance of about 1e10 gridpoints*lines/s.
+
+Extra optimization could be achieved by cutting the spectrum in small intervals,
+but requires an a-priori knowledge of the absorption & emission ranges of the
+spectrum. See the :ref:`Calculate a large spectrum by part <example_large_range_by_part>`
+
+
+"""
+
+
+#%%
+
+import astropy.units as u
+
+from radis import calc_spectrum
+
+s = calc_spectrum(
+    wmin=0.5 * u.um,
+    wmax=15 * u.um,  # cm-1
+    mole_fraction={"CO2": 420e-6, "H2O": 0.02},
+    isotope="1,2,3",
+    pressure=1.01325,  # bar
+    Tgas=300,  # K
+    path_length=1e5,  # 1 km in cm
+    verbose=2,
+    databank="hitran",
+    wstep="auto",
+)
+
+
+#%% Plot low and high resolution spectrum
+s.apply_slit(10, "nm")
+import matplotlib.pyplot as plt
+
+plt.figure(figsize=(16, 6))
+s.plot("transmittance_noslit", wunit="nm", color="k", alpha=0.1, nfig="same")
+s.plot("transmittance", wunit="nm", color="k", nfig="same")
+
+
+#%% Print some details about the computed spectrum :
+print("Number of grid points: ", len(s))
+print(
+    "Number of lines: ", s.c["total_lines"]
+)  # some are discarded by linestrength cutoff
+print("Number of lines: ", s.c["lines_calculated"])
+print("Whether the sparse wavenumber algorithm was activated: ", s.c["sparse_dlm"])
+print(f"Lineshape truncation used: {s.c['truncation']:.1f}cm-1")
+print(f"Total calculation time: {s.c['calculation_time']:.1f}s")
+print(
+    f"Gridpoints * lines/s : {len(s)*s.c['lines_calculated']/s.c['calculation_time']:.1e}"
+)
diff --git a/examples/plot_merged_large_spectrum.py b/examples/plot_merged_large_spectrum.py
index 068abcdb8..3691b79db 100644
--- a/examples/plot_merged_large_spectrum.py
+++ b/examples/plot_merged_large_spectrum.py
@@ -1,12 +1,20 @@
 # -*- coding: utf-8 -*-
 """
-==========================
-Calculate a large spectrum
-==========================
+.. _example_large_range_by_part:
 
-Calculate a full-range spectrum on several partial spectral ranges, and combine them.
+==================================
+Calculate a large spectrum by part
+==================================
+
+In may be faster to calculate a full-range spectrum on several partial spectral ranges,
+and combine them.
+
+Uses the :py:func:`~radis.los.slabs.MergeSlabs` function for that.
+
+Starting from 0.11, RADIS introduces a sparse waverange implementation
+that should make it possible to directly compute a full range spectrum.
+See the :ref:`HITRAN full-range example <example_hitran_full_range>`
 
-Uses the :py:func:`~radis.los.slabs.MergeSlabs` function
 """
 
 from radis import MergeSlabs, calc_spectrum
@@ -32,3 +40,7 @@
 s = MergeSlabs(*spectra, resample="full", out="transparent")
 print(s)
 s.plot("transmittance_noslit", wunit="nm")
+
+import matplotlib.pyplot as plt
+
+plt.ylim(0, 1)
diff --git a/examples/plot_remove_baseline.py b/examples/plot_remove_baseline.py
new file mode 100644
index 000000000..ce32f2318
--- /dev/null
+++ b/examples/plot_remove_baseline.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+"""
+=================
+Remove a baseline
+=================
+
+Remove a baseline from an experimental spectrum :
+
+"""
+
+
+# Get a spectrum:
+from radis import load_spec
+from radis.test.utils import getTestFile
+
+s_exp = (
+    load_spec(getTestFile(r"CO2_measured_spectrum_4-5um.spec"), binary=True)
+    .crop(4300, 5000)
+    .sort()
+)
+
+
+sb = s_exp.get_baseline(algorithm="als")
+
+s_exp.plot()
+sb.plot(nfig="same", lw=3)
+
+# Plot the spectrum minus the baseline :
+s = s_exp - sb
+s.plot(nfig="same", lw=2, zorder=0)
diff --git a/examples/plot_specutils_processing.py b/examples/plot_specutils_processing.py
new file mode 100644
index 000000000..54c4c364a
--- /dev/null
+++ b/examples/plot_specutils_processing.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+"""
+============================
+Post-process using Specutils
+============================
+
+Find peaks or uncertainties using the :py:mod:`specutils` library. A Radis Spectrum
+object can easily be converted to a ``specutils`` :py:class:`specutils.spectra.spectrum1d.Spectrum1D`
+using :py:meth:`~radis.spectrum.spectrum.Spectrum.to_specutils`.
+
+Below, we create a noisy spectrum based on a synthetic CO spectrum,
+we convert it to :py:mod:`specutils`, add uncertainties by targeting a
+noisy region, then determine the lines using :py:func:`~specutils.fitting.find_lines_threshold` :
+
+"""
+
+import astropy.units as u
+import numpy as np
+
+from radis import test_spectrum
+
+""" We create a synthetic CO spectrum"""
+
+s = (
+    test_spectrum(molecule="CO", wavenum_min=2000, wavenum_max=2030)
+    .apply_slit(1.5, "nm")
+    .take("radiance")
+)
+s.trim()  # removes nans created by the slit convolution boundary effects
+noise = np.random.normal(0.0, s.max().value * 0.03, len(s))
+s_exp = s + noise
+
+s_exp.plot()
+
+#%% Determine the noise level by selecting a noisy region from the graph above.
+
+spectrum = s_exp.to_specutils()
+
+from specutils import SpectralRegion
+from specutils.manipulation import noise_region_uncertainty
+
+noise_region = SpectralRegion(2010.5 / u.cm, 2009.5 / u.cm)
+spectrum = noise_region_uncertainty(spectrum, noise_region)
+
+
+#%% Find lines :
+
+from specutils.fitting import find_lines_threshold
+
+lines = find_lines_threshold(spectrum, noise_factor=2)
+
+print(lines)
+
+
+s_exp.plot(lw=2, show_ruler=True)
+import matplotlib.pyplot as plt
+
+for line in lines.to_pandas().line_center.values:
+    plt.axvline(line, color="r", zorder=-1)
+s.plot(nfig="same")
+plt.axvspan(noise_region.lower.value, noise_region.upper.value, color="b", alpha=0.1)
+
+
+#%% Note: we can also create a RADIS spectrum object from the Specutils Spectrum1D :
+
+from radis import Spectrum
+
+s2 = Spectrum.from_specutils(spectrum)
+
+s2.plot(Iunit="mW/cm2/sr/nm", wunit="nm")
+s_exp.plot(Iunit="mW/cm2/sr/nm", wunit="nm", nfig="same")
+assert s_exp == s2
diff --git a/radis/__init__.py b/radis/__init__.py
index f9a64fcea..af4d3ddc9 100644
--- a/radis/__init__.py
+++ b/radis/__init__.py
@@ -138,6 +138,21 @@
     --------
     :py:meth:`~radis.lbl.broadening.BroadenFactory._check_accuracy`
 
+"SPARSE_WAVERANGE": True
+    bool: if True, allow special optimizations to improve performances for
+    large spectra that are sparse, i.e there are no lines within ``truncation``
+    distance. In that case, spectral calculations can be an order of magnitude
+    faster, and result in less memory use.
+    This optimization may result in a small but unnecessary overhead
+    when there are lines everywhere in the spectral range considered. In such
+    cases, you may deactivate it by setting ``radis.config['SPARSE_WAVERANGE'] = False``
+    Default ``True``
+
+    See Also
+    --------
+    :py:meth:`~radis.lbl.broadening.BroadenFactory._apply_lineshape_DLM`
+
+
 
 Notes
 -----
diff --git a/radis/__version__.txt b/radis/__version__.txt
index a3f5a8ed4..d9df1bbc0 100644
--- a/radis/__version__.txt
+++ b/radis/__version__.txt
@@ -1,1 +1,1 @@
-0.10.3
+0.11.0
diff --git a/radis/db/classes.py b/radis/db/classes.py
index 38aecf9a8..02c95dbbd 100644
--- a/radis/db/classes.py
+++ b/radis/db/classes.py
@@ -206,6 +206,12 @@
     "47": "SO3",
     "48": "C2N2",
     "49": "COCl2",
+    "50": "SO",
+    "51": "CH3F",
+    "52": "GeH4",
+    "53": "CS2",
+    "54": "CH3I",
+    "55": "NF3",
 }
 HITRAN_MOLECULES = list(trans.values())
 """ str: list of [HITRAN-2016]_ molecules. """
@@ -278,16 +284,114 @@ def get_molecule(molecule_id):
 # %% ExoMol molecules
 
 
-KNOWN_EXOMOL_ISOTOPES_NAMES = {
-    ("FeH", 1): "56Fe-1H",
-    ("SiO", 1): "28Si-16O",  # Placeholder until all molecules parsed from website TODO
-    ("CN", 1): "12C-14N",  # Placeholder until all molecules parsed from website TODO
+from radis import config
+
+isotope_full_names = config["molparams"]["isotope_full_name"]
+
+EXOMOL_ONLY_ISOTOPES_NAMES = {
+    (molecule, int(iso_number)): iso_name
+    for molecule, iso_dict in isotope_full_names.items()
+    for iso_number, iso_name in iso_dict.items()
 }
 """All :py:data:`~radis.db.classes.HITRAN_MOLECULES` are also converted to their ExoMol full-name format
 in :py:func:`~radis.io.exomol.get_exomol_full_isotope_name`
 """
-EXOMOL_MOLECULES = list(set([M for M, iso in KNOWN_EXOMOL_ISOTOPES_NAMES.keys()]))
+assert EXOMOL_ONLY_ISOTOPES_NAMES[("FeH", 1)] == "56Fe-1H"
+assert EXOMOL_ONLY_ISOTOPES_NAMES[("SiO", 1)] == "28Si-16O"
+assert EXOMOL_ONLY_ISOTOPES_NAMES[("CN", 1)] == "12C-14N"
+
+EXOMOL_MOLECULES = [
+    "AlCl",
+    "AlF",
+    "AlH",
+    "AlO",
+    "AsH3",
+    "BeH",
+    "C2",
+    "C2H2",
+    "C2H4",
+    "CH",
+    "CH3",
+    "CH3Cl",
+    "CH3F",
+    "CH4",
+    "CN",
+    "CO",
+    "CO2",
+    "CP",
+    "CS",
+    "CaF",
+    "CaH",
+    "CaO",
+    "CrH",
+    "FeH",
+    "H2",
+    "H2CO",
+    "H2O",
+    "H2O2",
+    "H2S",
+    "H2_p",
+    "H3O_p",
+    "H3_p",
+    "HCN",
+    "HCl",
+    "HF",
+    "HNO3",
+    "HeH_p",
+    "KCl",
+    "KF",
+    "KOH",
+    "LiCl",
+    "LiF",
+    "LiH",
+    "LiH_p",
+    "MgF",
+    "MgH",
+    "MgO",
+    "N2",
+    "N2O",
+    "NH",
+    "NH3",
+    "NO",
+    "NS",
+    "NaCl",
+    "NaF",
+    "NaH",
+    "NaOH",
+    "NiH",
+    "O2",
+    "OCS",
+    "OH",
+    "OH_p",
+    "PF3",
+    "PH",
+    "PH3",
+    "PN",
+    "PO",
+    "PS",
+    "SH",
+    "SO2",
+    "SO3",
+    "ScH",
+    "SiH",
+    "SiH2",
+    "SiH4",
+    "SiO",
+    "SiO2",
+    "SiS",
+    "TiH",
+    "TiO",
+    "VO",
+    "YO",
+    "cis-P2H2",
+    "trans-P2H2",
+]
+EXOMOL_ONLY_MOLECULES = sorted(
+    list(set([M for M, iso in EXOMOL_ONLY_ISOTOPES_NAMES.keys()]))
+)
 
+for M in EXOMOL_ONLY_ISOTOPES_NAMES.keys():
+    assert M[0] in EXOMOL_MOLECULES
 
 # %% Molecule class
 
diff --git a/radis/db/molparam.py b/radis/db/molparam.py
index fe907e6a7..21c4adace 100644
--- a/radis/db/molparam.py
+++ b/radis/db/molparam.py
@@ -1,8 +1,5 @@
 # -*- coding: utf-8 -*-
-"""Created on Fri Apr 28 10:00:25 2017.
-
-@author: erwan
-
+"""
 -------------------------------------------------------------------------------
 """
 
@@ -117,6 +114,8 @@
     (32, 1): "H(12C)(16O)(16O)H",
     (33, 1): "H(16O)2",
     (34, 1): "(16O)",
+    (35, 1): "(35Cl)(16O)(14N)(16O)2",
+    (35, 2): "(37Cl)(16O)(14N)(16O)2",
     (36, 1): "(14N)(16O)+",
     (37, 1): "H(16O)(79Br)",
     (37, 2): "H(16O)(81Br)",
@@ -139,6 +138,22 @@
     (48, 1): "(12C)2(14N)2",
     (49, 1): "(12C)(16O)(35Cl)2",
     (49, 2): "(12C)(16O)(35Cl)(37Cl)",
+    (49, 2): "(12C)(16O)(35Cl)(37Cl)",
+    (50, 1): "(32S)(16O)",
+    (50, 2): "(34S)(16O)",
+    (50, 3): "(32S)(18O)",
+    (51, 1): "(12C)H3(19F)",
+    (52, 1): "(74Ge)H4",
+    (52, 2): "(72Ge)H4",
+    (52, 3): "(70Ge)H4",
+    (52, 4): "(73Ge)H4",
+    (52, 5): "(76Ge)H4",
+    (53, 1): "(12C)(32S)2",
+    (53, 2): "(32S)(12C)(34S)",
+    (53, 3): "(32S)(12C)(33S)",
+    (53, 4): "(13C)(32S)2",
+    (54, 1): "(12C)H3(127I)",
+    (55, 1): "(14N)(19F)3",
 }
 # TODO : add test in test_molparams comparing dictionary above to
 # latest values from HAPI; to make sure we never have any problem
@@ -146,37 +161,37 @@
 
 
 class MolParams(object):
-    """Easy access to molecular parameters taken from HITRAN molparam.txt.
-
-    Parameters
-    ----------
-    file: str
-        if None the one in RADIS is taken
+    __slots__ = ["df", "terrestrial_abundances"]
 
-    Examples
-    --------
-    Get earth abundance of CO2, isotope 1::
+    def __init__(self, file=None, terrestrial_abundances=True):
+        """Easy access to molecular parameters taken from HITRAN molparam.txt.
 
-        molpar = Molparams()
-        molpar.get(2, 1, 'abundance')         # 2 for CO2, 1 for isotope 1
+        Parameters
+        ----------
+        file: str
+            if None the one in RADIS is taken. See https://github.com/radis/radis/blob/master/radis/db/molparam.txt
 
-    .. minigallery:: radis.db.molparam.MolParams
-    :add-heading
+        Examples
+        --------
+        Get earth abundance of CO2, isotope 1::
 
-    Note
-    ----
-    Isotope number was derived manually assuming the isonames were ordered in the database
-    The isotope name (ex: CO2 626) is kept for comparison if ever needed
+            from radis.db.molparam import MolParams
+            molpar = MolParams()
+            molpar.get(2, 1, 'abundance')         # 2 for CO2, 1 for isotope 1
 
-    References
-    ----------
+        .. minigallery:: radis.db.molparam.MolParams
+        :add-heading:
 
-    http://hitran.org/media/molparam.txt
-    """
+        Note
+        ----
+        Isotope number was derived manually assuming the isonames were ordered in the database
+        The isotope name (ex: CO2 626) is kept for comparison if ever needed
 
-    __slots__ = ["df", "terrestrial_abundances"]
+        References
+        ----------
+        http://hitran.org/media/molparam.txt
+        """
 
-    def __init__(self, file=None, terrestrial_abundances=True):
         if file is None:
             file = getFile("molparam.txt")
 
@@ -194,15 +209,15 @@ def __init__(self, file=None, terrestrial_abundances=True):
         except AttributeError:
             pass  # old Python version
 
-    def get(self, M, I, key):
+    def get(self, molecule, isotope, key):
         """Get attribute of molecule, isotope.
 
         Parameters
         ----------
-        M: int
-            molecule id
-        I: int
-            molecule isotope #
+        molecule: str or int
+            molecule name or molecule id
+        iso: int
+            isotope number # (sorted by terrestrial abundance)
         key: ``'abundance'``, ``'mol_mass'``, ``'isotope_name'``, ``'isotope_name_exomol'``
             parameter
 
@@ -217,9 +232,10 @@ def get(self, M, I, key):
 
 
         .. minigallery:: radis.db.molparam.MolParams.get
-            :add-heading
+            :add-heading:
 
         """
+        M = molecule
         try:
             float(M)
         except ValueError:
@@ -227,7 +243,7 @@ def get(self, M, I, key):
             from radis.db.classes import get_molecule_identifier
 
             M = get_molecule_identifier(M)
-        return self.df.loc[(M, I), key]
+        return self.df.loc[(M, isotope), key]
 
 
 def _add_exomol_name(df):
diff --git a/radis/default_radis.json b/radis/default_radis.json
index f3d69d2a1..cf22119cd 100644
--- a/radis/default_radis.json
+++ b/radis/default_radis.json
@@ -1,19 +1,840 @@
+# default values for RADIS configuration parameters
+# the values of your ~/radis.json file will overwrite these.
 {
-"database":{},
-"spectroscopic_constants":{
-    "CO2": "molecules_data.json",
-    "CO": "molecules_data.json"
+    "database": {},                          # list of registered databases on this computer. See expected format in https://radis.readthedocs.io/en/latest/lbl/lbl.html#label-lbl-config-file
+    "spectroscopic_constants": {             # path to spectroscopic constants for non-LTE calculations. By default look for them in radis/db/[MOLECULE]/*
+        "CO2": "molecules_data.json",
+        "CO": "molecules_data.json"
     },
-"plot":{
-    "plotlib":"publib",
-    "context":"paper",
-    "style":"origin"
+    "plot": {
+        "plotlib": "publib",                 # library for plot style. "publib", "seaborn".
+        "context": "paper",                  # "paper", "talk", "poster"
+        "style": "origin"                    # default style. See Publib (https://github.com/erwanp/publib) or Seaborn (https://seaborn.pydata.org/)
     },
-"DEBUG_MODE":false,
-"AUTO_UPDATE_DATABASE":false,
-"AUTO_UPDATE_SPEC":false,
-"OLDEST_COMPATIBLE_VERSION":"0.9.1",
-"USE_CYTHON":true,
-"GRIDPOINTS_PER_LINEWIDTH_WARN_THRESHOLD":3,
-"GRIDPOINTS_PER_LINEWIDTH_ERROR_THRESHOLD":1
+    # global parameters
+    # -----------------
+    "DEBUG_MODE": false,
+    "AUTO_UPDATE_DATABASE": false,           # auto-convert 'pytables' files to 'vaex'
+    "AUTO_UPDATE_SPEC": false,               # auto-update saved Spectrum objects to latest format.
+    "OLDEST_COMPATIBLE_VERSION": "0.9.1",    # automatically regenerate cache files generated with versions anterior to this one
+    "USE_CYTHON": true,                      # use Cython module if available (else, default to Python)
+    "GRIDPOINTS_PER_LINEWIDTH_WARN_THRESHOLD": 3,    # raise a warning if less than THIS number of grid points per lineshape
+    "GRIDPOINTS_PER_LINEWIDTH_ERROR_THRESHOLD": 1,   # raise an error if less than THIS number of grid points per lineshape
+    "MEMORY_MAPPING_ENGINE": "vaex",         #  warning: on Spyder this may result in freezes. see https://github.com/spyder-ide/spyder/issues/16183.
+    "SPARSE_WAVERANGE": "auto",              # sparse DLM algorithm. May be smaller on dense spectra. If "auto", a scarcity criterion is used (Nlines/Ngrids > 1)
+
+    # molecular parameters
+    # --------------------
+    "molparams": {                           # molecular parameters for non-HITRAN species (the latter being defined in https://github.com/radis/radis/blob/master/radis/db/molparam.txt)
+        "isotope_full_name": {
+            "AlCl": {
+                "1": "27Al-35Cl",
+                "2": "27Al-37Cl"
+            },
+            "AlF": {
+                "1": "27Al-19F"
+            },
+            "AlH": {
+                "1": "27Al-1H",
+                "2": "27Al-2H",
+                "3": "26Al-1H"
+            },
+            "AlO": {
+                "1": "27Al-16O",
+                "2": "27Al-18O",
+                "3": "27Al-17O",
+                "4": "26Al-16O"
+            },
+            "AsH3": {
+                "1": "75As-1H3"
+            },
+            "BeH": {
+                "1": "9Be-1H",
+                "2": "9Be-2H",
+                "3": "9Be-3H"
+            },
+            "C2": {
+                "1": "12C2",
+                "2": "12C-13C",
+                "3": "13C2"
+            },
+            "CH": {
+                "1": "12C-1H",
+                "2": "13C-1H"
+            },
+            "CH3": {
+                "1": "12C-1H3"
+            },
+            "CH3F": {
+                "1": "12C-1H3-19F"
+            },
+            "CN": {
+                "1": "12C-14N",
+                "2": "13C-14N",
+                "3": "12C-15N",
+                "4": "13C-15N"
+            },
+            "CO": {
+                "7": "14C-16O",
+                "8": "14C-18O",
+                "9": "14C-17O"
+            },
+            "CO2": {
+                "13": "13C-17O2"
+            },
+            "CP": {
+                "1": "12C-31P"
+            },
+            "CS": {
+                "5": "13C-34S",
+                "6": "12C-36S",
+                "7": "13C-33S",
+                "8": "13C-36S"
+            },
+            "CaF": {
+                "1": "40Ca-19F",
+                "2": "44Ca-19F",
+                "3": "42Ca-19F",
+                "4": "48Ca-19F",
+                "5": "43Ca-19F",
+                "6": "46Ca-19F"
+            },
+            "CaH": {
+                "1": "40Ca-1H"
+            },
+            "CaO": {
+                "1": "40Ca-16O"
+            },
+            "CrH": {
+                "1": "52Cr-1H"
+            },
+            "FeH": {
+                "1": "56Fe-1H"
+            },
+            "H2_p": {
+                "1": "1H-2H_p"
+            },
+            "H3O_p": {
+                "1": "1H3-16O_p"
+            },
+            "H3_p": {
+                "1": "1H3_p",
+                "2": "1H2-2H_p"
+            },
+            "HeH_p": {
+                "1": "4He-1H_p",
+                "2": "4He-2H_p",
+                "3": "3He-1H_p",
+                "4": "3He-2H_p"
+            },
+            "KCl": {
+                "1": "39K-35Cl",
+                "2": "39K-37Cl",
+                "3": "41K-35Cl",
+                "4": "41K-37Cl"
+            },
+            "KF": {
+                "1": "39K-19F",
+                "2": "41K-19F"
+            },
+            "KOH": {
+                "1": "39K-16O-1H"
+            },
+            "LiCl": {
+                "1": "7Li-35Cl",
+                "2": "7Li-37Cl",
+                "3": "6Li-35Cl",
+                "4": "6Li-37Cl"
+            },
+            "LiF": {
+                "1": "7Li-19F",
+                "2": "6Li-19F"
+            },
+            "LiH": {
+                "1": "7Li-1H"
+            },
+            "LiH_p": {
+                "1": "7Li-1H_p"
+            },
+            "MgF": {
+                "1": "24Mg-19F",
+                "2": "26Mg-19F",
+                "3": "25Mg-19F"
+            },
+            "MgH": {
+                "1": "24Mg-1H",
+                "2": "26Mg-1H",
+                "3": "25Mg-1H"
+            },
+            "MgO": {
+                "1": "24Mg-16O",
+                "2": "26Mg-16O",
+                "3": "25Mg-16O",
+                "4": "24Mg-18O",
+                "5": "24Mg-17O"
+            },
+            "NH": {
+                "1": "14N-1H"
+            },
+            "NO": {
+                "4": "14N-17O",
+                "5": "15N-18O",
+                "6": "15N-17O"
+            },
+            "NS": {
+                "1": "14N-32S",
+                "2": "14N-34S",
+                "3": "14N-33S",
+                "4": "15N-32S",
+                "5": "14N-36S"
+            },
+            "NaCl": {
+                "1": "23Na-35Cl",
+                "2": "23Na-37Cl"
+            },
+            "NaF": {
+                "1": "23Na-19F"
+            },
+            "NaH": {
+                "1": "23Na-1H",
+                "2": "23Na-2H"
+            },
+            "NaOH": {
+                "1": "23Na-16O-1H"
+            },
+            "NiH": {
+                "1": "58Ni-1H",
+                "2": "60Ni-1H",
+                "3": "62Ni-1H"
+            },
+            "OH_p": {
+                "1": "16O-1H_p"
+            },
+            "PF3": {
+                "1": "31P-19F3"
+            },
+            "PH": {
+                "1": "31P-1H"
+            },
+            "PN": {
+                "1": "31P-14N",
+                "2": "31P-15N"
+            },
+            "PO": {
+                "1": "31P-16O"
+            },
+            "PS": {
+                "1": "31P-32S"
+            },
+            "SH": {
+                "1": "32S-1H",
+                "2": "34S-1H",
+                "3": "33S-1H",
+                "4": "32S-2H",
+                "5": "36S-1H"
+            },
+            "ScH": {
+                "1": "45Sc-1H"
+            },
+            "SiH": {
+                "1": "28Si-1H",
+                "2": "29Si-1H",
+                "3": "30Si-1H",
+                "4": "28Si-2H"
+            },
+            "SiH2": {
+                "1": "28Si-1H2"
+            },
+            "SiH4": {
+                "1": "28Si-1H4"
+            },
+            "SiO": {
+                "1": "28Si-16O",
+                "2": "29Si-16O",
+                "3": "30Si-16O",
+                "4": "28Si-18O",
+                "5": "28Si-17O"
+            },
+            "SiO2": {
+                "1": "28Si-16O2"
+            },
+            "SiS": {
+                "1": "28Si-32S",
+                "2": "29Si-32S",
+                "3": "28Si-34S",
+                "4": "30Si-32S",
+                "5": "28Si-33S",
+                "6": "29Si-34S",
+                "7": "30Si-34S",
+                "8": "29Si-33S",
+                "9": "30Si-33S",
+                "10": "28Si-36S",
+                "11": "29Si-36S",
+                "12": "30Si-36S"
+            },
+            "TiH": {
+                "1": "48Ti-1H"
+            },
+            "TiO": {
+                "1": "48Ti-16O",
+                "2": "46Ti-16O",
+                "3": "47Ti-16O",
+                "4": "49Ti-16O",
+                "5": "50Ti-16O"
+            },
+            "VO": {
+                "1": "51V-16O"
+            },
+            "YO": {
+                "1": "89Y-16O"
+            },
+            "cis-P2H2": {
+                "1": "cis-31P2-1H2"
+            },
+            "trans-P2H2": {
+                "1": "trans-31P2-1H2",
+                "2": "trans-31P2-1H-2H"
+            }
+        },
+        "abundances": {
+            "AlCl": {
+                "1": 0.755,
+                "2": 0.239
+            },
+            "AlF": {
+                "1": 1.0
+            },
+            "AlH": {
+                "1": 0.99972,
+                "2": 0.00028,
+                "3": 9.9972e-07
+            },
+            "AlO": {
+                "1": 0.9973799999999999,
+                "2": 0.0022199999999999998,
+                "3": 0.0004,
+                "4": 9.973799999999998e-07
+            },
+            "AsH3": {
+                "1": 0.9991602351780482
+            },
+            "BeH": {
+                "1": 0.99972,
+                "2": 0.00028,
+                "3": "1e-06"
+            },
+            "C2": {
+                "1": 0.9769345599999999,
+                "2": 0.011465439999999999,
+                "3": 0.00013455999999999999
+            },
+            "CH": {
+                "1": 0.988123248,
+                "2": 0.011596752
+            },
+            "CH3": {
+                "1": 0.9875699764499828
+            },
+            "CH3F": {
+                "1": 0.9875699764499828
+            },
+            "CN": {
+                "1": 0.9842289519999998,
+                "2": 0.011551047999999998,
+                "3": 0.004171048,
+                "4": 4.8952e-05
+            },
+            "CO": {
+                "7": 9.973799999999998e-07,
+                "8": 2.2199999999999998e-09,
+                "9": "4e-10"
+            },
+            "CO2": {
+                "13": 1.856e-09
+            },
+            "CP": {
+                "1": 0.9884
+            },
+            "CS": {
+                "5": 0.0005533199999999999,
+                "6": 0.0001848308,
+                "7": 9.245199999999999e-05,
+                "8": 2.1692e-06
+            },
+            "CaF": {
+                "1": 0.96941,
+                "2": 0.02086,
+                "3": 0.00647,
+                "4": 0.00187,
+                "5": 0.00135,
+                "6": "4e-05"
+            },
+            "CaH": {
+                "1": 0.9691385652000001
+            },
+            "CaO": {
+                "1": 0.9668701458
+            },
+            "CrH": {
+                "1": 0.8376553908
+            },
+            "FeH": {
+                "1": 0.9172830888
+            },
+            "H2_p": {
+                "1": 0.00027992159999999997
+            },
+            "H3O_p": {
+                "1": 0.9965424353618817
+            },
+            "H3_p": {
+                "1": 0.9991602351780482,
+                "2": 0.000279843221952
+            },
+            "HeH_p": {
+                "1": 0.99971800056,
+                "2": 0.00027999943999999994,
+                "3": 1.99944e-06,
+                "4": 5.599999999999999e-10
+            },
+            "KCl": {
+                "1": 0.704098655,
+                "2": 0.222886859,
+                "3": 0.05081301,
+                "4": 0.016085178
+            },
+            "KF": {
+                "1": 0.932581,
+                "2": 0.067302
+            },
+            "KOH": {
+                "1": 0.9298771992414215
+            },
+            "LiCl": {
+                "1": 0.69611,
+                "2": 0.220358,
+                "3": 0.05889,
+                "4": 0.018642
+            },
+            "LiF": {
+                "1": 0.922,
+                "2": 0.078
+            },
+            "LiH": {
+                "1": 0.9217418400000001
+            },
+            "LiH_p": {
+                "1": 0.9217418400000001
+            },
+            "MgF": {
+                "1": 0.7888,
+                "2": 0.1109,
+                "3": 0.10034
+            },
+            "MgH": {
+                "1": 0.788579136,
+                "2": 0.11086894800000001,
+                "3": 0.1003119048
+            },
+            "MgO": {
+                "1": 0.786733344,
+                "2": 0.11060944199999999,
+                "3": 0.10007710919999999,
+                "4": 0.0017511359999999997,
+                "5": 0.00031552
+            },
+            "NH": {
+                "1": 0.9955011815999999
+            },
+            "NO": {
+                "4": 0.00039831199999999995,
+                "5": 9.3684e-06,
+                "6": 1.6880000000000004e-06
+            },
+            "NS": {
+                "1": 0.940115898,
+                "2": 0.047498705999999995,
+                "3": 0.0079363666,
+                "4": 0.003984102000000001,
+                "5": 0.00018621086
+            },
+            "NaCl": {
+                "1": 0.755,
+                "2": 0.239
+            },
+            "NaF": {
+                "1": 1.0
+            },
+            "NaH": {
+                "1": 0.99972,
+                "2": 0.00028
+            },
+            "NaOH": {
+                "1": 0.9971007336
+            },
+            "NiH": {
+                "1": 0.68057838468,
+                "2": 0.26215757532,
+                "3": 0.03633482340000001
+            },
+            "OH_p": {
+                "1": 0.9971007336
+            },
+            "PF3": {
+                "1": 1.0
+            },
+            "PH": {
+                "1": 0.99972
+            },
+            "PN": {
+                "1": 0.9957799999999999,
+                "2": 0.004220000000000001
+            },
+            "PO": {
+                "1": 0.9973799999999999
+            },
+            "PS": {
+                "1": 0.9441
+            },
+            "SH": {
+                "1": 0.9438356520000001,
+                "2": 0.047686644,
+                "3": 0.0079677684,
+                "4": 0.00026434799999999997,
+                "5": 0.00018694764000000004
+            },
+            "ScH": {
+                "1": 0.99972
+            },
+            "SiH": {
+                "1": 0.9216518652000001,
+                "2": 0.0469768428,
+                "3": 0.031091292,
+                "4": 0.0002581348
+            },
+            "SiH2": {
+                "1": 0.9213938026777442
+            },
+            "SiH4": {
+                "1": 0.9208778943855188
+            },
+            "SiO": {
+                "1": 0.9194945957999999,
+                "2": 0.04686688619999999,
+                "3": 0.031018518,
+                "4": 0.0020466402,
+                "5": 0.000368764
+            },
+            "SiO2": {
+                "1": 0.9170855199590039
+            },
+            "SiS": {
+                "1": 0.870375231,
+                "2": 0.044363259,
+                "3": 0.043975107,
+                "4": 0.02936151,
+                "5": 0.0073476227,
+                "6": 0.0022414229999999998,
+                "7": 0.00148347,
+                "8": 0.0003745103,
+                "9": 0.000247867,
+                "10": 0.00017239717000000002,
+                "11": 8.78713e-06,
+                "12": 5.8157e-06
+            },
+            "TiH": {
+                "1": 0.736993584
+            },
+            "TiO": {
+                "1": 0.735268536,
+                "2": 0.08228384999999999,
+                "3": 0.07420507199999998,
+                "4": 0.053958258,
+                "5": 0.051664284
+            },
+            "VO": {
+                "1": 0.99488655
+            },
+            "YO": {
+                "1": 0.9973799999999999
+            },
+            "cis-P2H2": {
+                "1": 0.9994400784000002
+            },
+            "trans-P2H2": {
+                "1": 0.9994400784000002,
+                "2": 0.00027992159999999997
+            }
+        },
+        "molar_mass": {
+            "AlCl": {
+                "1": 61.9503912,
+                "2": 63.9474411
+            },
+            "AlF": {
+                "1": 45.979941663
+            },
+            "AlH": {
+                "1": 27.9893635322,
+                "2": 28.995640278099998,
+                "3": 26.9833635322
+            },
+            "AlO": {
+                "1": 42.97645312,
+                "2": 44.980698113,
+                "3": 43.980670257,
+                "4": 41.97045312
+            },
+            "AsH3": {
+                "1": 77.94507009659999
+            },
+            "BeH": {
+                "1": 10.0200081322,
+                "2": 11.0262848781,
+                "3": 12.0322848781
+            },
+            "C2": {
+                "1": 24.0,
+                "2": 25.003354835,
+                "3": 26.00670967
+            },
+            "CH": {
+                "1": 13.0078250322,
+                "2": 14.0111798672
+            },
+            "CH3": {
+                "1": 15.0234750966
+            },
+            "CH3F": {
+                "1": 34.0218782596
+            },
+            "CN": {
+                "1": 26.003074004,
+                "2": 27.006428839,
+                "3": 27.000108899,
+                "4": 28.003463734
+            },
+            "CO": {
+                "7": 30.004269455,
+                "8": 32.008514448,
+                "9": 31.008486592
+            },
+            "CO2": {
+                "13": 47.001618349
+            },
+            "CP": {
+                "1": 42.973761998
+            },
+            "CS": {
+                "5": 46.971221834999994,
+                "6": 47.967081,
+                "7": 45.974813745000006,
+                "8": 48.970435835
+            },
+            "CaF": {
+                "1": 58.960994063,
+                "2": 62.953885163,
+                "3": 60.957021163,
+                "4": 66.950925963,
+                "5": 61.957169162999996,
+                "6": 64.952093163
+            },
+            "CaH": {
+                "1": 40.970415932200005
+            },
+            "CaO": {
+                "1": 55.95750552
+            },
+            "CrH": {
+                "1": 52.9483310322
+            },
+            "FeH": {
+                "1": 56.9427610322
+            },
+            "H2_p": {
+                "1": 3.0219268103
+            },
+            "H3O_p": {
+                "1": 19.018389716599998
+            },
+            "H3_p": {
+                "1": 3.0234750966,
+                "2": 4.0297518425
+            },
+            "HeH_p": {
+                "1": 5.0104282863,
+                "2": 6.016705032200001,
+                "3": 4.0238543522,
+                "4": 5.0301310981
+            },
+            "KCl": {
+                "1": 73.93255919,
+                "2": 75.92960909,
+                "3": 75.93067796,
+                "4": 77.92772786
+            },
+            "KF": {
+                "1": 57.962109653,
+                "2": 59.960228423
+            },
+            "KOH": {
+                "1": 55.9664461422
+            },
+            "LiCl": {
+                "1": 41.98485614,
+                "2": 43.98190604,
+                "3": 40.983975587,
+                "4": 42.981025487
+            },
+            "LiF": {
+                "1": 26.014406602999998,
+                "2": 25.01352605
+            },
+            "LiH": {
+                "1": 8.0238284722
+            },
+            "LiH_p": {
+                "1": 8.0238284722
+            },
+            "MgF": {
+                "1": 42.983444863,
+                "2": 44.980996163,
+                "3": 43.984240162999996
+            },
+            "MgH": {
+                "1": 24.9928667322,
+                "2": 26.9904180322,
+                "3": 25.9936620322
+            },
+            "MgO": {
+                "1": 39.97995632,
+                "2": 41.97750762,
+                "3": 40.98075162,
+                "4": 41.984201313,
+                "5": 40.984173457
+            },
+            "NH": {
+                "1": 15.0108990362
+            },
+            "NO": {
+                "4": 31.002205761,
+                "5": 32.999268512,
+                "6": 31.999240656
+            },
+            "NS": {
+                "1": 45.975145178,
+                "2": 47.970941004,
+                "3": 46.974532914,
+                "4": 46.972180073000004,
+                "5": 49.970155004
+            },
+            "NaCl": {
+                "1": 57.958621980000004,
+                "2": 59.95567188
+            },
+            "NaF": {
+                "1": 41.988172442999996
+            },
+            "NaH": {
+                "1": 23.9975943122,
+                "2": 25.0038710581
+            },
+            "NaOH": {
+                "1": 39.9925089322
+            },
+            "NiH": {
+                "1": 58.9431670322,
+                "2": 60.9386110322,
+                "3": 62.9361700322
+            },
+            "OH_p": {
+                "1": 17.0027396522
+            },
+            "PF3": {
+                "1": 87.968971487
+            },
+            "PH": {
+                "1": 31.9815870302
+            },
+            "PN": {
+                "1": 44.976836002,
+                "2": 45.973870897
+            },
+            "PO": {
+                "1": 46.968676618
+            },
+            "PS": {
+                "1": 62.945833172
+            },
+            "SH": {
+                "1": 32.9798962062,
+                "2": 34.9756920322,
+                "3": 33.979283942200006,
+                "4": 33.9861729521,
+                "5": 36.9749060322
+            },
+            "ScH": {
+                "1": 45.963733032200004
+            },
+            "SiH": {
+                "1": 28.9847515672,
+                "2": 29.9843196972,
+                "3": 30.9815950422,
+                "4": 29.9910283131
+            },
+            "SiH2": {
+                "1": 29.9925765994
+            },
+            "SiH4": {
+                "1": 32.0082266638
+            },
+            "SiO": {
+                "1": 43.971841155,
+                "2": 44.971409285,
+                "3": 45.96868463,
+                "4": 45.976086148,
+                "5": 44.976058292000005
+            },
+            "SiO2": {
+                "1": 59.966755774999996
+            },
+            "SiS": {
+                "1": 59.948997709,
+                "2": 60.948565839,
+                "3": 61.944793535,
+                "4": 61.945841184,
+                "5": 60.948385445,
+                "6": 62.944361665,
+                "7": 63.941637009999994,
+                "8": 61.947953575,
+                "9": 62.945228920000005,
+                "10": 63.944007535,
+                "11": 64.943575665,
+                "12": 65.94085101
+            },
+            "TiH": {
+                "1": 48.9557670322
+            },
+            "TiO": {
+                "1": 63.94285662,
+                "2": 61.94754261999999,
+                "3": 62.94667362,
+                "4": 64.94278062,
+                "5": 65.93970162
+            },
+            "VO": {
+                "1": 66.93887162
+            },
+            "YO": {
+                "1": 104.90075462
+            },
+            "cis-P2H2": {
+                "1": 63.9631740604
+            },
+            "trans-P2H2": {
+                "1": 63.9631740604,
+                "2": 64.9694508063
+            }
+        }
+    }
 }
diff --git a/radis/io/cache_files.py b/radis/io/cache_files.py
index c7465c936..f721918fd 100644
--- a/radis/io/cache_files.py
+++ b/radis/io/cache_files.py
@@ -32,7 +32,6 @@
 from os.path import exists, splitext
 from warnings import warn
 
-import pandas as pd
 from packaging.version import parse
 
 import radis
@@ -56,6 +55,7 @@
 def load_h5_cache_file(
     cachefile,
     use_cached,
+    columns=None,
     valid_if_metadata_is={},
     relevant_if_metadata_above={},
     relevant_if_metadata_below={},
@@ -80,6 +80,8 @@ def load_h5_cache_file(
         if using the cache file, check if the file is deprecated. If it
         is deprecated, regenerate the file unless ``'force'`` was used
         (in that case, raise an error)
+    columns: list, or ``None``
+        columns to load
     valid_if_metadata_is: dict
         values are compared to cache file attributes. If they dont match,
         the file is considered deprecated. See ``use_cached`` to know
@@ -178,7 +180,7 @@ def load_h5_cache_file(
     try:
         # Load file :
         manager = HDF5Manager(engine)
-        df = manager.load(cachefile, key="df")
+        df = manager.load(cachefile, columns=columns, key="df")
 
     except KeyError as err:  # An error happened during file reading.
         # Fail safe by deleting cache file (unless we explicitely wanted it
@@ -232,7 +234,12 @@ def get_cache_file(fcache, engine="pytables", verbose=True):
 
 
 def check_cache_file(
-    fcache, use_cached=True, expected_metadata={}, verbose=True, engine="auto"
+    fcache,
+    use_cached=True,
+    expected_metadata={},
+    compare_as_close=[],
+    verbose=True,
+    engine="guess",
 ):
     """Quick function that check status of cache file generated by RADIS:
 
@@ -257,12 +264,15 @@ def check_cache_file(
         see notes above. Default ``True``.
     expected_metadata: dict
         attributes to check
+    compare_as_close: list of keys
+        compare with ``np.isclose(a,b)`` rather than ``a==b``
     verbose: boolean
         print stuff
+     engine: ``'h5py'``, ``'pytables'``, ``'vaex'``, ``'guess'``
+        which HDF5 library to use. If ``'guess'``, try to guess.
 
     Returns
     -------
-
     None
         whether the file was valid or not (and was removed).
         Raises a :py:class:`~radis.misc.warning.DeprecatedFileWarning` for
@@ -298,6 +308,7 @@ def check_cache_file(
             check_not_deprecated(
                 fcache,
                 metadata_is=expected_metadata,
+                compare_as_close=compare_as_close,
                 current_version=radis.__version__,
                 last_compatible_version=radis.config["OLDEST_COMPATIBLE_VERSION"],
                 engine=engine,
@@ -321,9 +332,10 @@ def check_not_deprecated(
     file,
     metadata_is={},
     metadata_keys_contain=[],
+    compare_as_close=[],
     current_version=None,
     last_compatible_version=radis.config["OLDEST_COMPATIBLE_VERSION"],
-    engine="auto",
+    engine="guess",
 ):
     """Make sure cache file is not deprecated: checks that ``metadata`` is the
     same, and that the version under which the file was generated is valid.
@@ -339,6 +351,8 @@ def check_not_deprecated(
     metadata_keys_contain: list
         expected list of variables in the file metadata. If the keys are not there,
         a :py:func:`~radis.misc.warning.DeprecatedFileWarning` error is raised.
+    compare_as_close: list of keys
+        compare with ``np.isclose(a,b)`` rather than ``a==b``
 
     Other Parameters
     ----------------
@@ -350,11 +364,10 @@ def check_not_deprecated(
         If the file was generated in a non-compatible version, an error is raised.
         (useful parameter to force regeneration of certain cache files after a
          breaking change in a new version)
-    engine: ``'h5py'``, ``'pytables'``, ``'vaex'``, ``'auto'``
-        which HDF5 library to use. If ``'auto'``, try to guess. Note: ``'vaex'``
-        uses ``'h5py'`` compatible HDF5.
+    engine: ``'h5py'``, ``'pytables'``, ``'vaex'``, ``'guess'``
+        which HDF5 library to use. If ``'guess'``, try to guess.
     """
-    if engine == "auto":
+    if engine == "guess":
         engine = HDF5Manager.guess_engine(file)
 
     # Get metadata :
@@ -433,6 +446,7 @@ def check_not_deprecated(
     out, compare_string = compare_dict(
         metadata_is,
         file_metadata,
+        compare_as_close=compare_as_close,
         verbose=False,
         return_string=True,
         df1_str="Expected",
@@ -449,13 +463,19 @@ def check_not_deprecated(
 
 
 def _h5_compatible(a_dict):
-    """Make dictionary ``a_dict`` h5 compatible."""
+    """Make dictionary ``a_dict`` compatible with HDF5 attributes.
+
+    Note that nested dictionaries are not supported, see for instance
+    https://gitlab.com/quantify-os/quantify-core/-/issues/158
+    """
     out = {}
     for k, v in a_dict.items():
         if v is None:
             continue  # dont store None
-        elif is_float(v):
+        elif is_float(v) or isinstance(v, bool):
             out[k] = v
+        # elif isinstance(v, dict):
+        #     raise ValueError(f"Value of key `{k}` is a dictionary and cannot be stored as attribute of an HDF5 file. Delete it, flatten it, or convert it to a string ?")
         else:
             out[k] = str(v)  # convert to str
     return out
@@ -466,8 +486,8 @@ def check_relevancy(
     relevant_if_metadata_above,
     relevant_if_metadata_below,
     verbose=True,
-    key="df",
-    engine="auto",
+    key="default",
+    engine="guess",
 ):
     """Make sure cache file is relevant.
 
@@ -490,6 +510,8 @@ def check_relevancy(
     ----------------
     key: str
         dataset key in storer.
+    engine: ``'h5py'``, ``'pytables'``, ``'vaex'``, ``'guess'``
+       which HDF5 library to use. If ``'guess'``, try to guess.
 
     Examples
     --------
@@ -503,7 +525,7 @@ def check_relevancy(
         the specified value.
 
     """
-    if engine == "auto":
+    if engine == "guess":
         engine = HDF5Manager.guess_engine(file)
 
     # Get metadata :
@@ -541,41 +563,51 @@ def _warn_if_object_columns(df, fname):
 
 
 def save_to_hdf(
-    df, fname, metadata, version=None, key="df", overwrite=True, verbose=True
+    df,
+    fname,
+    metadata,
+    version=None,
+    key="default",
+    overwrite=True,
+    verbose=True,
+    engine="pytables",
 ):
     """Save energy levels or lines to HDF5 file. Add metadata and version.
 
-    Parameters
-    ----------
-    df: a pandas DataFrame
-        data will be stored in the key ``'df'``
-    fname: str
-        ``.h5`` file where to store.
-    metadata: dict
-         dictionary of values that were used to generate the DataFrame. Metadata
-         will be asked again on file load to ensure it hasnt changed. ``None``
-         values are not stored.
-    version: str, or ``None``
-        file version. If ``None``, the current :data:`radis.__version__` is used.
-        On file loading, a warning will be raised if the current version is
-        posterior, or an error if the file version is set to be uncompatible.
-    key: str
-        dataset name. Default ``'df'``
-    overwrite: boolean
-        if ``True``, overwrites file. Else, raise an error if it exists.
-
-    Other Parameters
-    ----------------
-    verbose: bool
-        If >=2, also warns if non numeric values are present (it would make
-        calculations slower)
-
-    Notes
-    -----
-    ``None`` values are not stored
+     Parameters
+     ----------
+     df: a pandas/vaex DataFrame
+         data will be stored in this key.
+     fname: str
+         ``.h5`` file where to store.
+     metadata: dict
+          dictionary of values that were used to generate the DataFrame. Metadata
+          will be asked again on file load to ensure it hasnt changed. ``None``
+          values are not stored.
+     version: str, or ``None``
+         file version. If ``None``, the current :data:`radis.__version__` is used.
+         On file loading, a warning will be raised if the current version is
+         posterior, or an error if the file version is set to be uncompatible.
+     key: str
+         dataset name. Default ``'df'``
+     overwrite: boolean
+         if ``True``, overwrites file. Else, raise an error if it exists.
+
+     Other Parameters
+     ----------------
+     verbose: bool
+         If >=2, also warns if non numeric values are present (it would make
+         calculations slower)
+    engine: ``'h5py'``, ``'pytables'``, ``'vaex'``, ``'pytables-fixed'``
+        which HDF5 library to use. Note: ``'vaex'``
+        uses ``'h5py'`` compatible HDF5. Default ``pytables``
+
+     Notes
+     -----
+     ``None`` values are not stored
     """
     # Check file
-    assert fname.endswith(".h5")
+    assert fname.endswith(".h5") or fname.endswith(".hdf5")
     assert "version" not in metadata
     # ... 'object' columns slow everything down (not fixed format strings!)
     if verbose >= 2:
@@ -589,7 +621,8 @@ def save_to_hdf(
         raise ValueError("File exist: {0}".format(fname))
 
     # start by exporting dataframe
-    df.to_hdf(fname, key, format="fixed", mode="a", complevel=9, complib="blosc")
+    manager = HDF5Manager(engine)
+    manager.write(fname, df, append=False, key=key)
 
     # Add metadata
 
@@ -598,8 +631,7 @@ def save_to_hdf(
         version = radis.__version__
     metadata.update({"version": version})
 
-    with pd.HDFStore(fname, mode="a") as store:
-        store.get_storer(key).attrs.metadata = metadata
+    manager.add_metadata(fname, metadata)
 
     if verbose >= 3:
         print("... saved {0} with metadata: {1}".format(fname, metadata))
@@ -653,9 +685,20 @@ def some_function(*args):
     return metadata
 
 
-def cache_file_name(fname):
-    """Return the corresponding cache file name for fname."""
-    return splitext(fname)[0] + ".h5"
+def cache_file_name(fname, engine="pytables"):
+    """Return the corresponding cache file name for fname.
+
+    Other Parameters
+    ----------------
+    engine: ``'h5py'``, ``'pytables'``, ``'vaex'``
+       which HDF5 library to use. Default ``pytables``
+    """
+    if engine in ["pytables", "pytables-fixed"]:
+        return splitext(fname)[0] + ".h5"
+    elif engine in ["h5py", "vaex"]:
+        return splitext(fname)[0] + ".hdf5"
+    else:
+        raise ValueError(engine)
 
 
 if __name__ == "__main__":
diff --git a/radis/io/cdsd.py b/radis/io/cdsd.py
index 99a5a3c89..f40c57774 100644
--- a/radis/io/cdsd.py
+++ b/radis/io/cdsd.py
@@ -116,10 +116,12 @@ def cdsd2df(
     fname,
     version="hitemp",
     cache=True,
+    load_columns=None,
     verbose=True,
     drop_non_numeric=True,
     load_wavenum_min=None,
     load_wavenum_max=None,
+    engine="pytables",
 ):
     """Convert a CDSD-HITEMP [1]_ or CDSD-4000 [2]_ file to a Pandas dataframe.
 
@@ -135,6 +137,12 @@ def cdsd2df(
         improves performances a lot (but changes in the database are not
         taken into account). If ``False``, no database is used. If 'regen', temp
         file are reconstructed. Default ``True``.
+    load_columns: list
+        columns to load. If ``None``, loads everything
+
+        .. note::
+            this is only relevant if loading from a cache file. To generate
+            the cache file, all columns are loaded anyway.
 
     Other Parameters
     ----------------
@@ -147,6 +155,8 @@ def cdsd2df(
         if not ``'None'``, only load the cached file if it contains data for
         wavenumbers above/below the specified value. See :py:func`~radis.io.cache_files.load_h5_cache_file`.
         Default ``'None'``.
+    engine: 'pytables', 'vaex'
+        format for Hdf5 cache file. Default `pytables`
 
     Returns
     -------
@@ -231,7 +241,7 @@ def cdsd2df(
         raise ValueError("Unknown CDSD version: {0}".format(version))
 
     # Use cache file if possible
-    fcache = cache_file_name(fname)
+    fcache = cache_file_name(fname, engine=engine)
     if cache and exists(fcache):
         relevant_if_metadata_above = (
             {"wavenum_max": load_wavenum_min} if load_wavenum_min else {}
@@ -242,12 +252,14 @@ def cdsd2df(
         df = load_h5_cache_file(
             fcache,
             cache,
+            columns=load_columns,
             valid_if_metadata_is=metadata,
             relevant_if_metadata_above=relevant_if_metadata_above,
             relevant_if_metadata_below=relevant_if_metadata_below,
             current_version=radis.__version__,
             last_compatible_version=radis.config["OLDEST_COMPATIBLE_VERSION"],
             verbose=verbose,
+            engine=engine,
         )
         if df is not None:
             return df
@@ -284,6 +296,7 @@ def cdsd2df(
                 key="df",
                 overwrite=True,
                 verbose=verbose,
+                engine=engine,
             )
         except PermissionError:
             if verbose:
diff --git a/radis/io/dbmanager.py b/radis/io/dbmanager.py
index 820405e38..4528bf28b 100644
--- a/radis/io/dbmanager.py
+++ b/radis/io/dbmanager.py
@@ -22,7 +22,7 @@
     from radis.io.cache_files import check_not_deprecated
 
 from datetime import date
-from os.path import join
+from os.path import join, split
 
 import numpy as np
 import pandas as pd
@@ -58,6 +58,9 @@ class DatabaseManager(object):
     molecule: str
     local_databases: str
         path to local database
+    engine: 'vaex', 'pytables', 'h5py', or 'default'
+        memory-mapping library to use with this database. If 'default' use
+        the value from ~/radis.json
 
     Other Parameters
     ----------------
@@ -88,19 +91,38 @@ def __init__(
         name,
         molecule,
         local_databases,
+        engine,
         verbose=False,
         parallel=True,
         nJobs=-2,
         batch_size="auto",
     ):
+        if engine == "default":
+            from radis import config
+
+            engine = config["MEMORY_MAPPING_ENGINE"]  # 'pytables', 'vaex', 'feather'
+
+        # vaex processes are stuck if ran from Spyder. See https://github.com/spyder-ide/spyder/issues/16183
+        if engine == "vaex" and any("SPYDER" in name for name in os.environ):
+            from radis.misc.log import printwarn
+
+            printwarn(
+                "Spyder IDE detected while using memory_mapping_engine='vaex'.\nVaex is the fastest way to read database files in RADIS, but Vaex processes may be stuck if ran from Spyder. See https://github.com/spyder-ide/spyder/issues/16183. You may consider using another IDE, or using a different `memory_mapping_engine` such as 'pytables' or 'feather'. You can change the engine in Spectrum.fetch_databank() calls, or globally by setting the 'MEMORY_MAPPING_ENGINE' key in your ~/radis.json  (note: starting another iPython console somehow releases the freeze in Spyder)  \n"
+            )
 
         self.name = name
         self.molecule = molecule
         self.local_databases = local_databases
+        # create folder if needed
+        from radis.misc.basics import make_folders
+
+        make_folders(*split(abspath(local_databases)))
+
         self.downloadable = False  # by default
         self.format = ""
+        self.engine = engine
 
-        self.tempdir = join(self.local_databases, "downloads(can_be_deleted)")
+        self.tempdir = join(self.local_databases, "downloads__can_be_deleted")
         self.ds = DataSource(self.tempdir)
 
         self.verbose = verbose
@@ -112,7 +134,7 @@ def __init__(
             4  #: type: int. If there are less files, don't use parallel mode.
         )
 
-    def get_filenames(self, engine):
+    def get_filenames(self):
         """Get names of all files in the database (even if not downloaded yet)
 
         See Also
@@ -120,6 +142,7 @@ def get_filenames(self, engine):
         :py:meth:`~radis.io.linedb.get_files_to_download`"""
         verbose = self.verbose
         local_databases = self.local_databases
+        engine = self.engine
 
         # First; checking if the database is registered in radis.json
         if self.is_registered():
@@ -132,7 +155,7 @@ def get_filenames(self, engine):
                 )
             except AssertionError as err:
                 raise DeprecatedFileWarning(
-                    "Database entry {self.name} not valid anymore. See above."
+                    f"Database entry {self.name} not valid anymore. See above which keys are missing/wrong. Update or delete the entry in your ~/radis.json"
                 ) from err
             else:
                 local_files = entries["path"]
@@ -190,11 +213,12 @@ def fetch_urlnames(self) -> list:
             "This function should be overwritten by the DatabaseManager subclass"
         )
 
-    def check_deprecated_files(self, local_files, engine, remove=True):
+    def check_deprecated_files(self, local_files, auto_remove=True):
         """Check metadata of files and remove the deprecated ones
 
-        Unless remove=False: Then raise an error"""
+        Unless auto_remove=False: Then raise an error"""
         verbose = self.verbose
+        engine = self.engine
         for local_file in local_files:
             try:
                 check_not_deprecated(
@@ -204,7 +228,7 @@ def check_deprecated_files(self, local_files, engine, remove=True):
                     engine=engine,
                 )
             except DeprecatedFileWarning as err:
-                if not remove:
+                if not auto_remove:
                     raise err
                 else:  # delete file to regenerate it in the end of the script
                     if verbose:
@@ -268,11 +292,11 @@ def register(self, dict_entries):
             self.verbose,
         )
 
-    def get_hdf5_manager(self, engine):
-        return HDF5Manager(engine=engine)
+    def get_hdf5_manager(self):
+        return HDF5Manager(engine=self.engine)
 
-    def download_and_parse(self, urlnames, local_files, engine="pytables"):
-        all_local_files, _ = self.get_filenames(engine)
+    def download_and_parse(self, urlnames, local_files):
+        all_local_files, _ = self.get_filenames()
 
         verbose = self.verbose
         molecule = self.molecule
@@ -317,7 +341,6 @@ def download_and_parse_one_file(urlname, local_file, Ndownload):
                 pbar_Ntot_estimate_factor=pbar_Ntot_estimate_factor,
                 pbar_Nlines_already=Nlines_total,
                 pbar_last=(Ndownload == Ntotal_downloads),
-                engine=engine,
             )
             # except Exception as err:
             #     raise IOError("Problem parsing `{0}`. Check the error above. It may arise if the file wasn't properly downloaded. Try to delete it".format(self.ds._findfile(urlname))) from err
@@ -374,7 +397,6 @@ def load(
         columns,
         load_wavenum_min,
         load_wavenum_max,
-        engine="pytables",
     ):
         """
         Other Parameters
@@ -382,6 +404,7 @@ def load(
         columns: list of str
             list of columns to load. If ``None``, returns all columns in the file.
         """
+        engine = self.engine
         if engine == "pytables":
             df_all = []
             for local_file in local_files:
@@ -393,7 +416,7 @@ def load(
                         load_wavenum_min=load_wavenum_min,
                         load_wavenum_max=load_wavenum_max,
                         verbose=self.verbose,
-                        engine="pytables",
+                        engine=engine,
                     )
                 )
             return pd.concat(df_all)
@@ -407,7 +430,7 @@ def load(
                 load_wavenum_min=load_wavenum_min,
                 load_wavenum_max=load_wavenum_max,
                 verbose=self.verbose,
-                engine="vaex",
+                engine=engine,
             )
         else:
             raise NotImplementedError(engine)
@@ -420,12 +443,22 @@ def plot(self, local_files, isotope, wavenum_min, wavenum_max):
         )
         df.plot("wav", "int")
 
-    def get_nrows(self, local_file, engine="pytables"):
+    def get_nrows(self, local_file):
+        """ Get number of rows (without loading all DataFrame)"""
+        engine = self.engine
         if engine == "pytables":
             with pd.HDFStore(local_file, "r") as store:
                 nrows = store.get_storer("df").nrows
 
-        elif engine in ["vaex", "h5py"]:
+        elif engine == "vaex":
+            import vaex
+
+            # by default vaex does not load everything
+            df = vaex.open(local_file)
+            nrows = len(df)
+            df.close()
+
+        elif engine in ["h5py"]:
             raise NotImplementedError
         else:
             raise ValueError(engine)
diff --git a/radis/io/exomol.py b/radis/io/exomol.py
index 20b52826e..ea9e58933 100644
--- a/radis/io/exomol.py
+++ b/radis/io/exomol.py
@@ -50,10 +50,10 @@ def get_exomol_full_isotope_name(molecule, isotope):
     --------
     :py:func:`~radis.io.exomol.get_exomol_database_list`"""
 
-    from radis.db.classes import KNOWN_EXOMOL_ISOTOPES_NAMES
+    from radis.db.classes import EXOMOL_ONLY_ISOTOPES_NAMES
 
-    if (molecule, isotope) in KNOWN_EXOMOL_ISOTOPES_NAMES:
-        return KNOWN_EXOMOL_ISOTOPES_NAMES[(molecule, isotope)]
+    if (molecule, isotope) in EXOMOL_ONLY_ISOTOPES_NAMES:
+        return EXOMOL_ONLY_ISOTOPES_NAMES[(molecule, isotope)]
     else:
         # Read and convert from HITRAN molecules
         from radis.db.molparam import MolParams
@@ -77,10 +77,22 @@ def get_exomol_database_list(molecule, isotope_full_name):
 
     Examples
     --------
+    Get CH4 from ExoMol :
     ::
         databases, recommended = get_exomol_database_list("CH4", "12C-1H4")
         >>> ['xsec-YT10to10', 'YT10to10', 'YT34to10'], 'YT34to10'
 
+    Or combine with :py:func:`~radis.io.exomol.get_exomol_full_isotope_name` to
+    get the isopologue (sorted by terrestrial abundance) ::
+
+        from radis.io.exomol import get_exomol_database_list, get_exomol_full_isotope_name
+        databases, recommended = get_exomol_database_list("CH4", get_exomol_full_isotope_name("CH4", 1))
+        >>> ['xsec-YT10to10', 'YT10to10', 'YT34to10'], 'YT34to10'
+
+
+    .. minigallery:: radis.io.exomol.get_exomol_database_list
+
+
     See Also
     --------
     :py:func:`~radis.io.exomol.get_exomol_full_isotope_name`
@@ -106,26 +118,113 @@ def get_exomol_database_list(molecule, isotope_full_name):
     rows = soup.find_all("a", {"class": "list-group-item link-list-group-item"})
     databases = [r.get_attribute_list("title")[0] for r in rows]
 
-    assert len(databases_recommended) <= 1
+    if len(databases_recommended) > 1:
+        # Known exceptions :
+        if (
+            isotope_full_name == "28Si-16O"
+            and databases_recommended[0] == "xsec-SiOUVenIR"
+        ):
+            # this is a cross-section dataset, shouldn't be used. Reverse and use the other one:
+            databases_recommended = databases_recommended[::-1]
+        else:
+            print(
+                f"Multiple recommended databases found for {molecule} in ExoMol : {databases_recommended}. This is unexpected. Using the first"
+            )
 
     databases = databases + databases_recommended
 
     return databases, databases_recommended[0]
 
 
+# def fetch_exomol_molecule_list():
+#     """Parse ExoMol website and return list of available databases, and recommended database
+
+#     Parameters
+#     ----------
+#     molecule: str
+#     isotope_full_name: str
+#         isotope full name (ex. ``12C-1H4`` for CH4,1). Get it from
+#         :py:func:`radis.io.exomol.get_exomol_full_isotope_name`
+
+#     Returns
+#     -------
+
+#     Examples
+#     --------
+#     Get CH4 from ExoMol :
+#     ::
+#         databases, recommended = get_exomol_database_list("CH4", "12C-1H4")
+#         >>> ['xsec-YT10to10', 'YT10to10', 'YT34to10'], 'YT34to10'
+
+#     Or combine with :py:func:`~radis.io.exomol.get_exomol_full_isotope_name` to
+#     get the isopologue (sorted by terrestrial abundance) ::
+
+#         from radis.io.exomol import get_exomol_database_list, get_exomol_full_isotope_name
+#         databases, recommended = get_exomol_database_list("CH4", get_exomol_full_isotope_name("CH4", 1))
+#         >>> ['xsec-YT10to10', 'YT10to10', 'YT34to10'], 'YT34to10'
+
+
+#     .. minigallery:: radis.io.exomol.get_exomol_database_list
+
+
+#     See Also
+#     --------
+#     :py:func:`~radis.io.exomol.get_exomol_full_isotope_name`
+#     """
+
+# url = f"https://exomol.com/data/molecules/"
+# try:
+#     response = urlopen(url).read()
+# except HTTPError as err:
+#     raise ValueError(f"HTTPError opening url={url}") from err
+
+# soup = BeautifulSoup(
+#     response, features="lxml"
+# )  # make soup that is parse-able by bs
+
+# # Recommended database
+# rows = soup.find_all(
+#     "a", {"class": "list-group-item link-list-group-item recommended"}
+# )
+# databases_recommended = [r.get_attribute_list("title")[0] for r in rows]
+
+# # All others
+# rows = soup.find_all("a", {"class": "list-group-item link-list-group-item"})
+# databases = [r.get_attribute_list("title")[0] for r in rows]
+
+# if len(databases_recommended) > 1:
+#     # Known exceptions :
+#     if (
+#         isotope_full_name == "28Si-16O"
+#         and databases_recommended[0] == "xsec-SiOUVenIR"
+#     ):
+#         # this is a cross-section dataset, shouldn't be used. Reverse and use the other one:
+#         databases_recommended = databases_recommended[::-1]
+#     else:
+#         print(
+#             f"Multiple recommended databases found for {molecule} in ExoMol : {databases_recommended}. This is unexpected. Using the first"
+#         )
+
+# databases = databases + databases_recommended
+
+# return databases, databases_recommended[0]
+
+
 def fetch_exomol(
     molecule,
     database=None,
     local_databases=r"~/.radisdb/exomol/",
     databank_name="EXOMOL-{molecule}",
-    isotope=None,
+    isotope="1",
     load_wavenum_min=None,
     load_wavenum_max=None,
+    columns=None,
     cache=True,
     verbose=True,
     clean_cache_files=True,
     return_local_path=False,
     return_partition_function=False,
+    engine="default",
 ):
     """Stream ExoMol file from EXOMOL website. Unzip and build a HDF5 file directly.
 
@@ -133,21 +232,39 @@ def fetch_exomol(
 
     Parameters
     ----------
-    molecule: str
+    molecule: ``str``
         ExoMol molecule
-    database: str
+    database: ``str``
         database name. Ex:: ``POKAZATEL`` or ``BT2`` for ``H2O``. See
         :py:data:`~radis.io.exomol.KNOWN_EXOMOL_DATABASE_NAMES`. If ``None`` and
         there is only one database available, use it.
-    local_databases: str
+    local_databases: ``str``
         where to create the RADIS HDF5 files. Default ``"~/.radisdb/exomol"``
-    databank_name: str
+    databank_name: ``str``
         name of the databank in RADIS :ref:`Configuration file <label_lbl_config_file>`
         Default ``"EXOMOL-{molecule}"``
-    isotope: str
-        load only certain isotopes : ``'2'``, ``'1,2'``, etc. Default ``1``.
+    isotope: ``str`` or ``int``
+        load only certain isotopes, sorted by terrestrial abundances : ``'1'``, ``'2'``,
+        etc. Default ``1``.
+
+        .. note::
+
+            In RADIS, isotope abundance is included in the line intensity
+            calculation. However, the terrestrial abundances used may not be
+            relevant to non-terrestrial applications.
+            By default, the abundance is given reading HITRAN data. If the molecule
+            does not exist in the HITRAN database, the abundance is read from
+            the ``radis/radis_default.json`` configuration file, which can be
+            modified by editing ``radis.config`` after import or directly
+            by editing the user ``~/radis.json`` user configuration file
+            (overwrites ``radis_default.json``). In the ``radis/radis_default.json``
+            file, values were calculated with a simple model based on the
+            terrestrial isotopic abundance of each element.
+
     load_wavenum_min, load_wavenum_max: float (cm-1)
         load only specific wavenumbers.
+    columns: list of str
+        list of columns to load. If ``None``, returns all columns in the file.
 
     Other Parameters
     ----------------
@@ -161,6 +278,8 @@ def fetch_exomol(
         if ``True``, also returns the path of the local database file.
     return_partition_function: bool
         if ``True``, also returns a :py:class:`~radis.levels.partfunc.PartFuncExoMol` object.
+    engine: 'vaex', 'feather'
+        which memory-mapping library to use. If 'default' use the value from ~/radis.json
 
     Returns
     -------
@@ -172,6 +291,11 @@ def fetch_exomol(
     local_path: str
         path of local database file if ``return_local_path``
 
+    Examples
+    --------
+
+    .. minigallery:: radis.io.exomol.fetch_exomol
+
     Notes
     -----
     if using ``load_only_wavenum_above/below`` or ``isotope``, the whole
@@ -185,13 +309,25 @@ def fetch_exomol(
     :py:func:`~radis.io.hdf5.hdf2df`
 
     """
+    # TODO: implement columns= ... to load only specific columns.
+    # refactor with "self._quantumNumbers" (which serves the same purpose)
+
+    # Ensure isotope format:
+    try:
+        isotope = int(isotope)
+    except:
+        raise ValueError(
+            f"In fetch_exomol, ``isotope`` must be an integer. Got `{isotope}` "
+            + "Only one isotope can be queried at a time. "
+        )
+
     full_molecule_name = get_exomol_full_isotope_name(molecule, isotope)
     known_exomol_databases, recommended_database = get_exomol_database_list(
         molecule, full_molecule_name
     )
 
-    _exomol_use_hint = f"Select one of them with `radis.fetch_exomol(DATABASE_NAME)`, `SpectrumFactory.fetch_databank('exomol', exomol_database=DATABASE_NAME')`, or `calc_spectrum(..., databank=('exomol', DATABASE_NAME))` \n"
-    if database is None:
+    _exomol_use_hint = "Select one of them with `radis.fetch_exomol(DATABASE_NAME)`, `SpectrumFactory.fetch_databank('exomol', exomol_database=DATABASE_NAME')`, or `calc_spectrum(..., databank=('exomol', DATABASE_NAME))` \n"
+    if database is None or database == "default":
         if len(known_exomol_databases) == 1:
             database = known_exomol_databases[0]
             if verbose:
@@ -219,10 +355,39 @@ def fetch_exomol(
         / database
     )
 
-    mdb = MdbExomol(local_path, [load_wavenum_min, load_wavenum_max])
+    if load_wavenum_min is None:
+        load_wavenum_min = -np.inf
+    if load_wavenum_max is None:
+        load_wavenum_max = np.inf
+    mdb = MdbExomol(local_path, [load_wavenum_min, load_wavenum_max], engine=engine)
+
+    # Attributes of the DataFrame
+    from radis.db.classes import HITRAN_MOLECULES
+
+    attrs = {}
+    if molecule in HITRAN_MOLECULES:
+        attrs["id"] = get_molecule_identifier(
+            molecule
+        )  # HITRAN id-number (if available)
+    attrs["molecule"] = molecule
+    attrs["iso"] = isotope
+
+    df = mdb.to_df(attrs=attrs)
 
-    df = mdb.to_df()
+    # Replace Linestrength with Line Intensity taking into account Terrestrial isotopic abundance
+    from radis.db.molparam import MolParams
 
+    try:
+        Ia = MolParams().get(molecule, isotope, "abundance")
+    except NotImplementedError:
+        from radis import config
+
+        Ia = config["molparams"]["abundances"][molecule][str(isotope)]
+
+    df["Sij0"] *= Ia
+    df.rename(columns={"Sij0": "int"}, inplace=True)
+
+    # Return:
     out = [df]
 
     if return_local_path:
@@ -256,6 +421,12 @@ class MdbExomol(object):
 
     """
 
+    # TODO : inherit from DatabaseManager or similar
+
+    # @dev: In exojax this class is defined in exojax/spec/moldb.py
+    # see https://github.com/HajimeKawahara/exojax/blob/develop/src/exojax/spec/moldb.py
+    # It uses Jax arrays (jnp). Here RADIS uses Numpy arrays.
+
     def __init__(
         self,
         path,
@@ -264,6 +435,7 @@ def __init__(
         crit=-np.inf,
         bkgdatm="H2",
         broadf=True,
+        engine="vaex",
     ):
         """Molecular database for Exomol form
 
@@ -279,7 +451,21 @@ def __init__(
            The trans/states files can be very large. For the first time to read it, we convert it to the feather-format. After the second-time, we use the feather format instead.
 
         """
-        explanation = "Note: Couldn't find the feather format. We convert data to the feather format. After the second time, it will become much faster."
+        if engine == "default":
+            import radis
+
+            engine = radis.config["MEMORY_MAPPING_ENGINE"]
+
+        if engine == "vaex":
+            import vaex
+        elif engine == "feather":
+            pass
+            # vaex will be the future default, but it still fails on some systems
+            # Ex : on Spyder : See https://github.com/spyder-ide/spyder/issues/16183
+            # We keep "feather" as backup)
+        else:
+            raise NotImplementedError(f"{engine} is not implemented")
+        self.engine = engine  # which memory mapping engine to use : 'vaex', 'pytables' (HDF5), 'feather'
 
         self.path = pathlib.Path(path)
         t0 = self.path.parents[0].stem
@@ -342,12 +528,26 @@ def __init__(
             self.alpha_ref_def = 0.07
 
         # load states
-        if self.states_file.with_suffix(".feather").exists():
-            states = pd.read_feather(self.states_file.with_suffix(".feather"))
-        else:
-            print(explanation)
-            states = exomolapi.read_states(self.states_file, dic_def)
-            states.to_feather(self.states_file.with_suffix(".feather"))
+        if engine == "feather":
+            if self.states_file.with_suffix(".feather").exists():
+                ndstates = pd.read_feather(self.states_file.with_suffix(".feather"))
+            else:
+                print(
+                    "Note: Caching states data to the feather format. After the second time, it will become much faster."
+                )
+                ndstates = exomolapi.read_states(self.states_file, dic_def)
+                ndstates.to_feather(self.states_file.with_suffix(".feather"))
+        elif engine == "vaex":
+            if self.states_file.with_suffix(".bz2.hdf5").exists():
+                states = vaex.open(self.states_file.with_suffix(".bz2.hdf5"))
+                ndstates = vaex.array_types.to_numpy(states)
+            else:
+                print(
+                    "Note: Caching states data to the hdf5 format with vaex. After the second time, it will become much faster."
+                )
+                states = exomolapi.read_states(self.states_file, dic_def)
+                ndstates = vaex.array_types.to_numpy(states)
+
         # load pf
         pf = exomolapi.read_pf(self.pf_file)
         self.gQT = pf["QT"].to_numpy()  # grid QT
@@ -360,15 +560,41 @@ def __init__(
             if not self.trans_file.exists():
                 self.download(molec, [".trans.bz2"])
 
-            if self.trans_file.with_suffix(".feather").exists():
-                trans = pd.read_feather(self.trans_file.with_suffix(".feather"))
-            else:
-                print(explanation)
-                trans = exomolapi.read_trans(self.trans_file)
-                trans.to_feather(self.trans_file.with_suffix(".feather"))
+            if engine == "vaex":
+                if self.trans_file.with_suffix(".hdf5").exists():
+                    trans = vaex.open(self.trans_file.with_suffix(".hdf5"))
+                    cdt = 1
+                    if not np.isneginf(self.nurange[0]):
+                        cdt *= trans.nu_lines > self.nurange[0] - self.margin
+                    if not np.isinf(self.nurange[1]):
+                        cdt *= trans.nu_lines < self.nurange[1] - self.margin
+                    if not np.isneginf(self.crit):
+                        cdt *= trans.Sij0 > self.crit
+                    if cdt != 1:
+                        trans = trans[cdt]
+                    ndtrans = vaex.array_types.to_numpy(trans)
+
+                    # mask has been alraedy applied
+                    mask_needed = False
+                else:
+                    print(
+                        "Note: Caching line transition data to the HDF5 format with vaex. After the second time, it will become much faster."
+                    )
+                    trans = exomolapi.read_trans(self.trans_file)
+                    ndtrans = vaex.array_types.to_numpy(trans)
+
+                    # mask needs to be applied
+                    mask_needed = True
+            elif engine == "feather":
+                if self.trans_file.with_suffix(".feather").exists():
+                    ndtrans = pd.read_feather(self.trans_file.with_suffix(".feather"))
+                else:
+                    print(
+                        "Note: Caching line transition data to the feather format. After the second time, it will become much faster."
+                    )
+                    ndtrans = exomolapi.read_trans(self.trans_file)
+                    ndtrans.to_feather(self.trans_file.with_suffix(".feather"))
 
-            #!!TODO:restrict NOW the trans size to avoid useless overload of memory and CPU
-            # trans = trans[(trans['nu'] > self.nurange[0] - self.margin) & (trans['nu'] < self.nurange[1] + self.margin)]
             # compute gup and elower
             (
                 self._A,
@@ -377,9 +603,46 @@ def __init__(
                 self._gpp,
                 self._jlower,
                 self._jupper,
+                mask_zeronu,
                 self._quantumNumbers,
-            ) = exomolapi.pickup_gE(states, trans, dic_def)
-        else:
+            ) = exomolapi.pickup_gE(ndstates, ndtrans, self.trans_file, dic_def)
+
+            # Compute linestrengths or retrieve them from cache
+            self.Tref = 296.0
+            self.QTref = np.array(self.QT_interp(self.Tref))
+
+            if engine == "vaex" and self.trans_file.with_suffix(".hdf5").exists():
+                # if engine == 'feather' we recompute all the time
+                # Todo : we should get the column name instead ?
+                self.Sij0 = ndtrans[:, 4]
+            else:
+                ##Line strength:
+                from radis.lbl.base import (  # TODO: move elsewhere
+                    linestrength_from_Einstein,
+                )
+
+                self.Sij0 = linestrength_from_Einstein(
+                    A=self._A,
+                    gu=self._gpp,
+                    El=self._elower,
+                    Ia=1,  #  Sij0 is a linestrength calculated without taking into account isotopic abundance (unlike line intensity parameter of HITRAN. In RADIS this is corrected for in fetch_exomol()  )
+                    nu=self.nu_lines,
+                    Q=self.QTref,
+                    T=self.Tref,
+                )
+
+                # exclude the lines whose nu_lines evaluated inside exomolapi.pickup_gE (thus sometimes different from the "nu_lines" column in trans) is not positive
+                trans["nu_positive"] = mask_zeronu
+                trans = trans[trans.nu_positive].extract()
+                trans.drop("nu_positive", inplace=True)
+
+                trans["nu_lines"] = self.nu_lines
+                trans["Sij0"] = self.Sij0
+
+                if engine == "vaex":
+                    trans.export(self.trans_file.with_suffix(".hdf5"))
+
+        else:  # dic_def["numinf"] is not None
             imin = (
                 np.searchsorted(dic_def["numinf"], nurange[0], side="right") - 1
             )  # left side
@@ -395,15 +658,42 @@ def __init__(
                     self.download(
                         molec, extension=[".trans.bz2"], numtag=dic_def["numtag"][i]
                     )
-                if trans_file.with_suffix(".feather").exists():
-                    trans = pd.read_feather(trans_file.with_suffix(".feather"))
-                else:
-                    print(explanation)
-                    trans = exomolapi.read_trans(trans_file)
-                    trans.to_feather(trans_file.with_suffix(".feather"))
-                    #!!TODO:restrict NOW the trans size to avoid useless overload of memory and CPU
-                    # trans = trans[(trans['nu'] > self.nurange[0] - self.margin) & (trans['nu'] < self.nurange[1] + self.margin)]
-
+                if engine == "feather":
+                    if trans_file.with_suffix(".feather").exists():
+                        trans = pd.read_feather(trans_file.with_suffix(".feather"))
+                    else:
+                        print(
+                            "Note: Caching line transition data to the feather format. After the second time, it will become much faster."
+                        )
+                        ndtrans = exomolapi.read_trans(trans_file)
+                        ndtrans.to_feather(trans_file.with_suffix(".feather"))
+                        #!!TODO:restrict NOW the trans size to avoid useless overload of memory and CPU
+                        # trans = trans[(trans['nu'] > self.nurange[0] - self.margin) & (trans['nu'] < self.nurange[1] + self.margin)]
+                elif engine == "vaex":
+                    if trans_file.with_suffix(".hdf5").exists():
+                        trans = vaex.open(trans_file.with_suffix(".hdf5"))
+                        cdt = 1
+                        if not np.isneginf(self.nurange[0]):
+                            cdt *= trans.nu_lines > self.nurange[0] - self.margin
+                        if not np.isinf(self.nurange[1]):
+                            cdt *= trans.nu_lines < self.nurange[1] - self.margin
+                        if not np.isneginf(self.crit):
+                            cdt *= trans.Sij0 > self.crit
+                        if cdt != 1:
+                            trans = trans[cdt]
+                        ndtrans = vaex.array_types.to_numpy(trans)
+
+                        # mask has been already applied
+                        mask_needed = False
+                    else:
+                        print(
+                            "Note: Caching line transition data to the HDF5 format with vaex. After the second time, it will become much faster."
+                        )
+                        trans = exomolapi.read_trans(trans_file)
+                        ndtrans = vaex.array_types.to_numpy(trans)
+
+                        # mask needs to be applied
+                        mask_needed = True
                 self.trans_file.append(trans_file)
 
                 # compute gup and elower
@@ -415,9 +705,42 @@ def __init__(
                         self._gpp,
                         self._jlower,
                         self._jupper,
+                        mask_zeronu,
                         self._quantumNumbers,
-                    ) = exomolapi.pickup_gE(states, trans, dic_def)
-                else:
+                    ) = exomolapi.pickup_gE(ndstates, ndtrans, trans_file, dic_def)
+
+                    if (
+                        engine == "vaex"
+                        and self.trans_file.with_suffix(".hdf5").exists()
+                    ):
+                        # if engine == 'feather' we recompute all the time
+                        # Todo : we should get the column name instead ?
+                        self.Sij0 = ndtrans[:, 4]
+                    else:
+                        ##Line strength:
+                        from radis.lbl.base import (  # TODO: move elsewhere
+                            linestrength_from_Einstein,
+                        )
+
+                        self.Sij0 = linestrength_from_Einstein(
+                            A=self._A,
+                            gu=self._gpp,
+                            El=self._elower,
+                            Ia=1,  #  Sij0 is a linestrength calculated without taking into account isotopic abundance (unlike line intensity parameter of HITRAN. In RADIS this is corrected for in fetch_exomol()  )
+                            nu=self.nu_lines,
+                            Q=self.QTref,
+                            T=self.Tref,
+                        )
+
+                        # exclude the lines whose nu_lines evaluated inside exomolapi.pickup_gE (thus sometimes different from the "nu_lines" column in trans) is not positive
+                        trans["nu_positive"] = mask_zeronu
+                        trans = trans[trans.nu_positive].extract()
+                        trans.drop("nu_positive", inplace=True)
+
+                        trans["nu_lines"] = self.nu_lines
+                        trans["Sij0"] = self.Sij0
+                else:  # k!=0
+
                     (
                         Ax,
                         nulx,
@@ -425,8 +748,35 @@ def __init__(
                         gppx,
                         jlowerx,
                         jupperx,
-                        _quantumNumbersx,
-                    ) = exomolapi.pickup_gE(states, trans, dic_def)
+                        mask_zeronu,
+                        quantumNumbersx,
+                    ) = exomolapi.pickup_gE(ndstates, ndtrans, trans_file)
+                    if engine == "vaex" and trans_file.with_suffix(".hdf5").exists():
+                        Sij0x = ndtrans[:, 4]
+                    else:
+                        ##Line strength:
+                        from radis.lbl.base import (  # TODO: move elsewhere
+                            linestrength_from_Einstein,
+                        )
+
+                        Sij0x = linestrength_from_Einstein(
+                            A=Ax,
+                            gu=gppx,
+                            El=elowerx,
+                            Ia=1,  #  Sij0 is a linestrength calculated without taking into account isotopic abundance (unlike line intensity parameter of HITRAN. In RADIS this is corrected for in fetch_exomol()  )
+                            nu=nulx,
+                            Q=self.QTref,
+                            T=self.Tref,
+                        )
+
+                        # exclude the lines whose nu_lines evaluated inside exomolapi.pickup_gE (thus sometimes different from the "nu_lines" column in trans) is not positive
+                        trans["nu_positive"] = mask_zeronu
+                        trans = trans[trans.nu_positive].extract()
+                        trans.drop("nu_positive", inplace=True)
+
+                        trans["nu_lines"] = nulx
+                        trans["Sij0"] = Sij0x
+
                     self._A = np.hstack([self._A, Ax])
                     self.nu_lines = np.hstack([self.nu_lines, nulx])
                     self._elower = np.hstack([self._elower, elowerx])
@@ -434,25 +784,14 @@ def __init__(
                     self._jlower = np.hstack([self._jlower, jlowerx])
                     self._jupper = np.hstack([self._jupper, jupperx])
                     self._quantumNumbers = np.hstack(
-                        [self._quantumNumbers, _quantumNumbersx]
+                        [self._quantumNumbers, quantumNumbersx]
                     )
 
-        self.Tref = 296.0
-        self.QTref = np.array(self.QT_interp(self.Tref))
-
-        Ia = 1  #  TODO    Add isotope abundance
-
-        from radis.lbl.base import linestrength_from_Einstein  # TODO: move elsewhere
-
-        self.Sij0 = linestrength_from_Einstein(
-            A=self._A,
-            gu=self._gpp,
-            El=self._elower,
-            Ia=Ia,
-            nu=self.nu_lines,
-            Q=self.QTref,
-            T=self.Tref,
-        )
+                    if (
+                        engine == "vaex"
+                        and not trans_file.with_suffix(".hdf5").exists()
+                    ):
+                        trans.export(trans_file.with_suffix(".hdf5"))
 
         ### MASKING ###
         mask = (
@@ -461,27 +800,31 @@ def __init__(
             * (self.Sij0 > self.crit)
         )
 
-        self.masking(mask)
+        self.masking(mask, mask_needed)
 
-    def masking(self, mask):
+    def masking(self, mask, mask_needed=True):
         """applying mask and (re)generate jnp.arrays
 
         Args:
            mask: mask to be applied. self.mask is updated.
+           mask_needed: whether mask needs to be applied or not
 
         """
-        # TODO : replace with HDF5 masking ?
+        # TODO : with Vaex-HDF5, selection should happen on disk.
 
         # numpy float 64 Do not convert them jnp array
-        self.nu_lines = self.nu_lines[mask]
-        self.Sij0 = self.Sij0[mask]
-        self._A = self._A[mask]
-        self._elower = self._elower[mask]
-        self._gpp = self._gpp[mask]
-        self._jlower = self._jlower[mask]
-        self._jupper = self._jupper[mask]
-
-        # jnp arraysgamma_
+        if mask_needed:
+            self.nu_lines = self.nu_lines[mask]
+            self.Sij0 = self.Sij0[mask]
+            self._A = self._A[mask]
+            self._elower = self._elower[mask]
+            self._gpp = self._gpp[mask]
+            self._jlower = self._jlower[mask]
+            self._jupper = self._jupper[mask]
+            for key, array in self._quantumNumbers.items():
+                self._quantumNumbers[key] = array[mask]
+
+        # jnp arrays
         self.dev_nu_lines = np.array(self.nu_lines)
         self.logsij0 = np.array(np.log(self.Sij0))
         self.A = np.array(self._A)
@@ -493,9 +836,6 @@ def masking(self, mask):
         ##Broadening parameters
         self.set_broadening()
 
-        for key, array in self._quantumNumbers.items():
-            self._quantumNumbers[key] = array[mask]
-
     def set_broadening(self, alpha_ref_def=None, n_Texp_def=None):
         """setting broadening parameters
 
@@ -623,8 +963,14 @@ def to_partition_function_tabulator(self):
 
         return PartFuncExoMol(self.isotope_fullname, self.T_gQT, self.gQT)
 
-    def to_df(self):
-        """Export Line Database to a RADIS-friendly Pandas DataFrame"""
+    def to_df(self, attrs={}):
+        """Export Line Database to a RADIS-friendly Pandas DataFrame
+
+        Parameters
+        ----------
+        attrs: dict
+            add these as attributes of the DataFrame
+        """
 
         ##Broadening parameters
         self.set_broadening()
@@ -633,7 +979,7 @@ def to_df(self):
         df = pd.DataFrame(
             {
                 "wav": self.nu_lines,
-                "int": self.Sij0,
+                "Sij0": self.Sij0,  #  linestrength (not corrected for isotopic abundance)
                 "A": self._A,
                 "airbrd": self.alpha_ref,  # temperature dependance exponent for air broadening
                 # "selbrd": None,                  # self-temperature dependant exponent. Not implementedi in ExoMol
@@ -646,6 +992,8 @@ def to_df(self):
                 "Tdpair": self.n_Texp,  # temperature dependance exponent. Here we use Tdair; no Tdpsel. TODO
             }
         )
+
+        # Check format :
         for key, array in self._quantumNumbers.items():
             try:
                 df[key] = pd.to_numeric(array, errors="raise")
@@ -653,12 +1001,8 @@ def to_df(self):
                 df[key] = array
                 df[key] = df[key].astype("str")
 
-        from radis.db.classes import HITRAN_MOLECULES
-
-        if self.molecule in HITRAN_MOLECULES:
-            df.attrs["id"] = get_molecule_identifier(self.molecule)
-        df.attrs["molecule"] = self.molecule
-        df.attrs["iso"] = 1  # TODO : get isotope number while parsing ExoMol name
+        for k, v in attrs.items():
+            df.attrs[k] = v
 
         assert self.Tref == 296  # default in RADIS
 
@@ -688,22 +1032,6 @@ def to_df(self):
     # # s_hit = sf.eq_spectrum(500, name='HITRAN')
 
     #%% Test by direct caclulation
-    from radis import SpectrumFactory
-
-    sf = SpectrumFactory(
-        4310,
-        4320,
-        molecule="H2O",
-        isotope="1",
-        verbose=3,
-    )
-    sf.fetch_databank("exomol")
-    s = sf.eq_spectrum(500, name="ExoMol")
-    s.plot()
-
-#    mask=mdb.A>1.e-42
-#    mdb.masking(mask)
-#    mdb=MdbExomol("/home/kawahara/exojax/data/exomol/NH3/14N-1H3/CoYuTe/",nurange=[6050.0,6150.0])
-#    mdb=MdbExomol("/home/kawahara/exojax/data/exomol/H2S/1H2-32S/AYT2/",nurange=[6050.0,6150.0])
-#    mdb=MdbExomol("/home/kawahara/exojax/data/exomol/FeH/56Fe-1H/MoLLIST/",nurange=[6050.0,6150.0])
-#    mdb=MdbExomol("/home/kawahara/exojax/data/exomol/NO/14N-16O/NOname/14N-16O__NOname")
+    import pytest
+
+    print("Testing factory:", pytest.main(["../test/io/test_exomol.py"]))
diff --git a/radis/io/exomol_utils.py b/radis/io/exomol_utils.py
index 1ca69d4a9..dd6c50461 100644
--- a/radis/io/exomol_utils.py
+++ b/radis/io/exomol_utils.py
@@ -17,7 +17,8 @@ def e2s(molname_exact):
     Returns:
        simple molname
 
-    Examples:
+    Examples::
+
        >>> print(e2s("12C-1H4"))
        >>> CH4
        >>> print(e2s("23Na-16O-1H"))
diff --git a/radis/io/exomolapi.py b/radis/io/exomolapi.py
index 405faf283..326ec31c5 100644
--- a/radis/io/exomolapi.py
+++ b/radis/io/exomolapi.py
@@ -111,7 +111,7 @@ def read_pf(pff):
     return dat
 
 
-def read_trans(transf):
+def read_trans(transf, engine="vaex"):
     """Exomol IO for a transition file
     Note:
         i_upper=Upper state counting number
@@ -122,26 +122,46 @@ def read_trans(transf):
 
     Args:
         transf: transition file
+        engine: parsing engine to use ('vaex', 'csv')
     Returns:
         transition data in pandas DataFrame
 
     """
-    try:
-        dat = pd.read_csv(
-            transf,
-            compression="bz2",
-            sep="\s+",
-            names=("i_upper", "i_lower", "A", "nu_lines"),
-        )
-    except:
-        dat = pd.read_csv(
-            transf, sep="\s+", names=("i_upper", "i_lower", "A", "nu_lines")
-        )
+    if engine == "vaex":
+        import vaex
+
+        try:
+            dat = vaex.from_csv(
+                transf,
+                compression="bz2",
+                sep="\s+",
+                names=("i_upper", "i_lower", "A", "nu_lines"),
+                convert=True,
+            )
+        except:
+            dat = vaex.read_csv(
+                transf,
+                sep="\s+",
+                names=("i_upper", "i_lower", "A", "nu_lines"),
+                convert=True,
+            )
+    elif engine == "csv":
+        try:
+            dat = pd.read_csv(
+                transf,
+                compression="bz2",
+                sep="\s+",
+                names=("i_upper", "i_lower", "A", "nu_lines"),
+            )
+        except:
+            dat = pd.read_csv(
+                transf, sep="\s+", names=("i_upper", "i_lower", "A", "nu_lines")
+            )
 
     return dat
 
 
-def read_states(statesf, dic_def):
+def read_states(statesf, dic_def, engine="vaex"):
     """Exomol IO for a state file
     Note:
         i=state counting number
@@ -153,6 +173,7 @@ def read_states(statesf, dic_def):
     Args:
         statesf: state file
         dic_def: Info from def file to read extra quantum numbers
+        engine: parsing engine to use ('vaex', 'csv')
     Returns:
         states data in pandas DataFrame
 
@@ -163,41 +184,63 @@ def read_states(statesf, dic_def):
     N = np.size(dic_def["quantum_labels"])
     quantum_labels = dic_def["quantum_labels"]
 
+    usecol = np.arange(4)
+    names = ("i", "E", "g", "J")
     if dic_def["Landé"] and dic_def["lifetime"]:
-        usecol = np.concatenate((np.arange(4), 5 + 2 + np.arange(N)))
-        names = ("i", "E", "g", "J", "Lande", "lifetime") + tuple(quantum_labels)
+        usecol = np.concatenate((usecol, 5 + 2 + np.arange(N)))
+        names = names + ("Lande", "lifetime") + tuple(quantum_labels)
     elif dic_def["Landé"]:
-        usecol = np.concatenate((np.arange(4), 5 + 1 + np.arange(N)))
-        names = ("i", "E", "g", "J", "Lande") + tuple(quantum_labels)
+        usecol = np.concatenate((usecol, 5 + 1 + np.arange(N)))
+        names = names + ("Lande") + tuple(quantum_labels)
     elif dic_def["lifetime"]:
-        usecol = np.concatenate((np.arange(4), 5 + 1 + np.arange(N)))
-        names = ("i", "E", "g", "J", "lifetime") + tuple(quantum_labels)
+        usecol = np.concatenate((usecol, 5 + 1 + np.arange(N)))
+        names = names + ("lifetime") + tuple(quantum_labels)
     else:  # no lifetime, nor landé according to the def file
-        usecol = np.concatenate((np.arange(4), 5 + np.arange(N)))
-        names = ("i", "E", "g", "J") + tuple(quantum_labels)
-
-    try:
-        dat = pd.read_csv(
-            statesf, compression="bz2", sep="\s+", usecols=usecol, names=names
-        )
-    except:  #!!!TODO What was the expected error?
-        dat = pd.read_csv(statesf, sep="\s+", usecols=usecol, names=names)
+        usecol = np.concatenate((usecol, 5 + np.arange(N)))
+        names = names + tuple(quantum_labels)
+
+    if engine == "vaex":
+        import vaex
+
+        try:
+            dat = vaex.from_csv(
+                statesf,
+                compression="bz2",
+                sep="\s+",
+                usecols=usecol,
+                names=names,
+                convert=True,
+            )
+        except:
+            dat = vaex.read_csv(
+                statesf, sep="\s+", usecols=usecol, names=names, convert=True
+            )
+    elif engine == "csv":
+        try:
+            dat = pd.read_csv(
+                statesf, compression="bz2", sep="\s+", usecols=usecol, names=names
+            )
+        except:  #!!!TODO What was the expected error?
+            dat = pd.read_csv(statesf, sep="\s+", usecols=usecol, names=names)
+    else:
+        raise NotImplementedError(engine)
 
     return dat
 
 
-def pickup_gE(states, trans, dic_def, trans_lines=False):
+def pickup_gE(ndstates, ndtrans, trans_file, dic_def, trans_lines=False):
     """extract g_upper (gup), E_lower (elower), and J_lower and J_upper from states DataFrame and insert them to transition DataFrame.
 
     Args:
-       states: states pandas DataFrame
-       trans: transition pandas DataFrame
+       ndstates: states numpy array    - the i, E, g, J are in the 4 first columns
+       ndtrans: transition numpy array
+       trans_file: name of the transition file
        trans_lines: By default (False) we use nu_lines computed using the state file, i.e. E_upper - E_lower. If trans_nuline=True, we use the nu_lines in the transition file. Note that some trans files do not this info.
        dic_def: Informations about additional quantum labels
 
 
     Returns:
-       A, nu_lines, elower, gup, jlower, jupper, **quantum_labels
+       A, nu_lines, elower, gup, jlower, jupper, mask, **quantum_labels
 
     Note:
        We first convert pandas DataFrame to ndarray. The state counting numbers in states DataFrame is used as indices of the new array for states (newstates). We remove the state count numbers as the column of newstate, i.e. newstates[:,k] k=0: E, 1: g, 2: J. Then, we can directly use the state counting numbers as mask.
@@ -205,8 +248,6 @@ def pickup_gE(states, trans, dic_def, trans_lines=False):
 
     """
     ### Step 1. Essential quantum number for spectra
-    ndstates = states.to_numpy()[:, :4]  # the i, E, g, J are in the 4 first columns
-    ndtrans = trans.to_numpy()
 
     iorig = np.array(ndstates[:, 0], dtype=int)
     maxii = int(np.max(iorig) + 1)
@@ -216,21 +257,13 @@ def pickup_gE(states, trans, dic_def, trans_lines=False):
     i_upper = np.array(ndtrans[:, 0], dtype=int)
     i_lower = np.array(ndtrans[:, 1], dtype=int)
 
-    # use the state counting numbers (upper and lower) as masking.
-    elower = newstates[i_lower, 0]
-    eupper = newstates[i_upper, 0]
-    gup = newstates[i_upper, 1]
-    jlower = np.array(newstates[i_lower, 2], dtype=int)
-    jupper = np.array(newstates[i_upper, 2], dtype=int)
-
     ### Step 2. Extra quantum numbers (e/f parity, vib and rot numbers)
     #!!! Todo: Awfull management of memory, i know! @minou
     n0 = (
         4 + dic_def["Landé"] + dic_def["lifetime"]
     )  # we need to shift if these quantities exist
-    ndstates_extra = states.to_numpy()[
-        :, n0:
-    ]  # the i, E, g, J are in the 4 first columns
+    # ndstates_extra = ndstates.to_numpy()[
+    ndstates_extra = ndstates[:, n0:]  # the i, E, g, J are in the 4 first columns
     a, b = np.shape(ndstates_extra)
     dico_quantumNumbers = {}
     if b != 0:
@@ -241,14 +274,51 @@ def pickup_gE(states, trans, dic_def, trans_lines=False):
                 ]
                 # dico_quantumNumbers['{}_u'.format(label)] = ndstates_extra[index][i_upper]
 
+    # use the state counting numbers (upper and lower) as masking.
+    elower = newstates[i_lower, 0]
+    eupper = newstates[i_upper, 0]
+    gup = newstates[i_upper, 1]
+    jlower = np.array(newstates[i_lower, 2], dtype=int)
+    del i_lower
+    jupper = np.array(newstates[i_upper, 2], dtype=int)
+    del i_upper
+
     A = ndtrans[:, 2]
 
     if trans_lines:
         nu_lines = ndtrans[:, 3]
     else:
         nu_lines = eupper - elower
-
-    # See Issue #16
+    del eupper
+
+    # ### MASKING ###
+    mask = nu_lines > 0.0
+    if False in mask:
+        len_org = len(nu_lines)
+
+        A = A[mask]
+        nu_lines = nu_lines[mask]
+        elower = elower[mask]
+        gup = gup[mask]
+        jlower = jlower[mask]
+        jupper = jupper[mask]
+        for key, array in dico_quantumNumbers.items():
+            dico_quantumNumbers[key] = array[mask]
+        print(
+            "WARNING: {0:,} transitions with the wavenumber=zero in {1} have been ignored.".format(
+                len_org - len(nu_lines), trans_file
+            )
+        )
+        if trans_lines:
+            print(
+                "This is because the value for the wavenumber column in the transition file is zero for those transitions."
+            )
+        else:
+            print(
+                "This is because the upper and lower state IDs in the transition file indicate the same energy level when referring to the states file for those transitions."
+            )
+
+    # See Issue exojax#16
     # import matplotlib.pyplot as plt
     # nu_lines_t=ndtrans[:,3]
     # plt.plot(nu_lines_t-nu_lines,".",alpha=0.03)
@@ -257,7 +327,7 @@ def pickup_gE(states, trans, dic_def, trans_lines=False):
     # plt.savefig("nudiff.png", bbox_inches="tight", pad_inches=0.0)
     # plt.show()
 
-    return A, nu_lines, elower, gup, jlower, jupper, dico_quantumNumbers
+    return A, nu_lines, elower, gup, jlower, jupper, mask, dico_quantumNumbers
 
 
 # def pickup_gEslow(states, trans):
@@ -298,150 +368,138 @@ def pickup_gE(states, trans, dic_def, trans_lines=False):
 #     return A, nu_lines, elower, gup
 
 
-# def read_broad(broadf):
-#     """Reading braodening file (.broad)
-
-#     Args:
-#        broadf: .broad file
-
-#     Return:
-#        broadening info in bdat form (pandas), defined by this instance.
-
-#     Note:
-#        See Table 16 in https://arxiv.org/pdf/1603.05890.pdf
-
-#     """
-#     bdat = pd.read_csv(
-#         broadf,
-#         sep="\s+",
-#         names=(
-#             "code",
-#             "alpha_ref",
-#             "n_Texp",
-#             "jlower",
-#             "jupper",
-#             "kalower",
-#             "kclower",
-#             "kaupper",
-#             "kcupper",
-#             "v1lower",
-#             "v2lower",
-#             "v3lower",
-#             "v1upper",
-#             "v2upper",
-#             "v3upper",
-#         ),
-#     )
-#     return bdat
-
-
-# def check_bdat(bdat):
-#     """cheking codes in .broad
-
-#     Args:
-#        bdat: exomol .broad data given by exomolapi.read_broad
-
-#     Returns:
-#        code level: None, a0, a1, other codes unavailable currently,
-
-#     """
-
-#     def checkcode(code):
-#         cmask = bdat["code"] == code
-#         if len(bdat["code"][cmask]) > 0:
-#             return True
-#         else:
-#             return False
-
-#     codelv = None
-#     for code in ["a0", "a1"]:
-#         if checkcode(code):
-#             codelv = code
-
-#     return codelv
+def read_broad(broadf):
+    """Reading braodening file (.broad)
+    Args:
+       broadf: .broad file
 
+    Return:
+       broadening info in bdat form (pandas), defined by this instance.
+    Note:
+       See Table 16 in https://arxiv.org/pdf/1603.05890.pdf
+    """
+    bdat = pd.read_csv(
+        broadf,
+        sep="\s+",
+        names=(
+            "code",
+            "alpha_ref",
+            "n_Texp",
+            "jlower",
+            "jupper",
+            "kalower",
+            "kclower",
+            "kaupper",
+            "kcupper",
+            "v1lower",
+            "v2lower",
+            "v3lower",
+            "v1upper",
+            "v2upper",
+            "v3upper",
+        ),
+    )
+
+    return bdat
+
+
+def check_bdat(bdat):
+    """cheking codes in .broad
+    Args:
+       bdat: exomol .broad data given by exomolapi.read_broad
+    Returns:
+       code level: None, a0, a1, other codes unavailable currently,
+    """
 
-# def make_j2b(bdat, alpha_ref_default=0.07, n_Texp_default=0.5, jlower_max=None):
-#     """compute j2b (code a0, map from jlower to alpha_ref)
+    def checkcode(code):
+        cmask = bdat["code"] == code
+        if len(bdat["code"][cmask]) > 0:
+            return True
+        else:
+            return False
 
-#     Args:
-#        bdat: exomol .broad data given by exomolapi.read_broad
-#        alpha_ref_default: default value
-#        n_Texp_default: default value
-#        jlower_max: maximum number of jlower
+    codelv = None
+    for code in ["a0", "a1"]:
+        if checkcode(code):
+            codelv = code
 
-#     Returns:
-#        j2alpha_ref[jlower] provides alpha_ref for jlower
-#        j2n_Texp[jlower]  provides nT_exp for jlower
+    return codelv
 
-#     """
-#     # a0
-#     cmask = bdat["code"] == "a0"
-#     jlower_arr = np.array(bdat["jlower"][cmask], dtype=int)
-#     alpha_ref_arr = np.array(bdat["alpha_ref"][cmask])
-#     n_Texp_arr = np.array(bdat["n_Texp"][cmask])
-
-#     if jlower_max is None:
-#         Nblower = np.max(jlower_arr) + 1
-#     else:
-#         Nblower = np.max([jlower_max, np.max(jlower_arr)]) + 1
-#     j2alpha_ref = np.ones(Nblower) * alpha_ref_default
-#     j2n_Texp = np.ones(Nblower) * n_Texp_default
-
-#     j2alpha_ref[jlower_arr] = alpha_ref_arr
-#     j2n_Texp[jlower_arr] = n_Texp_arr
-
-#     Ndef = Nblower - (np.max(jlower_arr) + 1)
-#     if Ndef > 0:
-#         print(
-#             "default broadening parameters are used for ",
-#             Ndef,
-#             " J lower states in ",
-#             Nblower,
-#             " states",
-#         )
-
-#     return j2alpha_ref, j2n_Texp
-
-
-# def make_jj2b(bdat, j2alpha_ref_def, j2n_Texp_def, jupper_max=None):
-#     """compute jj2b (code a1, map from (jlower, jupper) to alpha_ref and n_Texp)
 
-#     Args:
-#        bdat: exomol .broad data given by exomolapi.read_broad
-#        j2alpha_ref_def: default value from a0
-#        j2n_Texp_def: default value from a0
-#        jupper_max: maximum number of jupper
+def make_j2b(bdat, alpha_ref_default=0.07, n_Texp_default=0.5, jlower_max=None):
+    """compute j2b (code a0, map from jlower to alpha_ref)
 
-#     Returns:
-#        jj2alpha_ref[jlower,jupper] provides alpha_ref for (jlower, jupper)
-#        jj2n_Texp[jlower,jupper]  provides nT_exp for (jlower, jupper)
+    Args:
+       bdat: exomol .broad data given by exomolapi.read_broad
+       alpha_ref_default: default value
+       n_Texp_default: default value
+       jlower_max: maximum number of jlower
+    Returns:
+       j2alpha_ref[jlower] provides alpha_ref for jlower
+       j2n_Texp[jlower]  provides nT_exp for jlower
+    """
+    # a0
+    cmask = bdat["code"] == "a0"
+    jlower_arr = np.array(bdat["jlower"][cmask], dtype=int)
+    alpha_ref_arr = np.array(bdat["alpha_ref"][cmask])
+    n_Texp_arr = np.array(bdat["n_Texp"][cmask])
+
+    if jlower_max is None:
+        Nblower = np.max(jlower_arr) + 1
+    else:
+        Nblower = np.max([jlower_max, np.max(jlower_arr)]) + 1
+    j2alpha_ref = np.ones(Nblower) * alpha_ref_default
+    j2n_Texp = np.ones(Nblower) * n_Texp_default
+
+    j2alpha_ref[jlower_arr] = alpha_ref_arr
+    j2n_Texp[jlower_arr] = n_Texp_arr
+
+    Ndef = Nblower - (np.max(jlower_arr) + 1)
+    if Ndef > 0:
+        print(
+            "default broadening parameters are used for ",
+            Ndef,
+            " J lower states in ",
+            Nblower,
+            " states",
+        )
 
-#     Note:
-#        The pair of (jlower, jupper) for which broadening parameters are not given, jj2XXX contains None.
+    return j2alpha_ref, j2n_Texp
 
-#     """
-#     # a1
-#     cmask = bdat["code"] == "a1"
-#     jlower_arr = np.array(bdat["jlower"][cmask], dtype=int)
-#     jupper_arr = np.array(bdat["jupper"][cmask], dtype=int)
-#     alpha_ref_arr = np.array(bdat["alpha_ref"][cmask])
-#     n_Texp_arr = np.array(bdat["n_Texp"][cmask])
 
-#     # Nblower = len(j2alpha_ref_def)
+def make_jj2b(bdat, j2alpha_ref_def, j2n_Texp_def, jupper_max=None):
+    """compute jj2b (code a1, map from (jlower, jupper) to alpha_ref and n_Texp)
 
-#     if jupper_max is None:
-#         Nbupper = np.max(jupper_arr) + 1
-#     else:
-#         Nbupper = np.max([jupper_max, np.max(jupper_arr)]) + 1
+    Args:
+       bdat: exomol .broad data given by exomolapi.read_broad
+       j2alpha_ref_def: default value from a0
+       j2n_Texp_def: default value from a0
+       jupper_max: maximum number of jupper
+    Returns:
+       jj2alpha_ref[jlower,jupper] provides alpha_ref for (jlower, jupper)
+       jj2n_Texp[jlower,jupper]  provides nT_exp for (jlower, jupper)
+    Note:
+       The pair of (jlower, jupper) for which broadening parameters are not given, jj2XXX contains None.
+    """
+    # a1
+    cmask = bdat["code"] == "a1"
+    jlower_arr = np.array(bdat["jlower"][cmask], dtype=int)
+    jupper_arr = np.array(bdat["jupper"][cmask], dtype=int)
+    alpha_ref_arr = np.array(bdat["alpha_ref"][cmask])
+    n_Texp_arr = np.array(bdat["n_Texp"][cmask])
+
+    if jupper_max is None:
+        Nbupper = np.max(jupper_arr) + 1
+    else:
+        Nbupper = np.max([jupper_max, np.max(jupper_arr)]) + 1
 
-#     jj2alpha_ref = j2alpha_ref_def[:, np.newaxis] * np.ones(Nbupper)
-#     jj2n_Texp = j2n_Texp_def[:, np.newaxis] * np.ones(Nbupper)
+    jj2alpha_ref = j2alpha_ref_def[:, np.newaxis] * np.ones(Nbupper)
+    jj2n_Texp = j2n_Texp_def[:, np.newaxis] * np.ones(Nbupper)
 
-#     jj2alpha_ref[jlower_arr, jupper_arr] = alpha_ref_arr
-#     jj2n_Texp[jlower_arr, jupper_arr] = n_Texp_arr
+    jj2alpha_ref[jlower_arr, jupper_arr] = alpha_ref_arr
+    jj2n_Texp[jlower_arr, jupper_arr] = n_Texp_arr
 
-#     return jj2alpha_ref, jj2n_Texp
+    return jj2alpha_ref, jj2n_Texp
 
 
 if __name__ == "__main__":
diff --git a/radis/io/hdf5.py b/radis/io/hdf5.py
index 4f218f005..d66714f6e 100644
--- a/radis/io/hdf5.py
+++ b/radis/io/hdf5.py
@@ -7,7 +7,7 @@
 
 import os
 import sys
-from os.path import exists
+from os.path import exists, splitext
 from time import time
 
 import h5py
@@ -15,33 +15,33 @@
 from tables.exceptions import NoSuchNodeError
 
 
-def update_pytables_to_vaex(fname, remove_initial=False, verbose=True):
+def update_pytables_to_vaex(fname, remove_initial=False, verbose=True, key="df"):
     """Convert a HDF5 file generated from PyTables to a
     Vaex-friendly HDF5 format, preserving metadata"""
     import vaex
 
-    if verbose:
-        print(f"Auto-updating {fname} to a Vaex-compatible HDF5 file")
-    df = pd.read_hdf(fname)
-    df = vaex.from_pandas(df)
     if fname.endswith(".h5"):
         fname_vaex = fname.replace(".h5", ".hdf5")
     else:
         fname_vaex = fname
 
+    if verbose:
+        print(f"Auto-updating {fname} to a Vaex-compatible HDF5 file {fname_vaex}")
+    df = pd.read_hdf(fname)
+    df = vaex.from_pandas(df)
+
+    pytables_manager = HDF5Manager(engine="pytables")
+
     # Read metadata
-    with pd.HDFStore(fname, mode="r") as store:
-        file_metadata = store.get_storer("df").attrs.metadata
+    file_metadata = pytables_manager.read_metadata(fname)
 
     # Write Vaex file
     df.export_hdf5(fname_vaex)
     df.close()  # try to fix file not closed()  TODO: remove?
     del df  # same TODO
 
-    with h5py.File(fname_vaex, "a") as hf:
-        # Add metadata
-        for k, v in file_metadata.items():
-            hf.attrs[k] = v
+    vaex_manager = HDF5Manager(engine="vaex")
+    vaex_manager.add_metadata(fname_vaex, file_metadata)
 
     if verbose:
         print(f"Converted to Vaex's HDF5 format {fname_vaex}")
@@ -56,8 +56,42 @@ def update_pytables_to_vaex(fname, remove_initial=False, verbose=True):
 
 
 class HDF5Manager(object):
-    def __init__(self, engine="pytables"):
+    def __init__(self, engine=None):
+        """Class to handle all memory-mapping-librairies with one common API
+
+        All functions may not be fully implemetned, will raise a NotImplementedError
+        if that's not the case.
+
+        Librairies ::
+
+            'vaex'     > HDF5,  column-based
+            'pytables' > Pandas's HDF5,  row-based
+            'h5py'     > HDF5
+
+        Functions ::
+
+            add_metadata
+            read_metadata
+            write
+            load
+            guess_engine
+
+        Examples
+        --------
+        ::
+
+            file = 'CO.hdf5'
+            from radis.io.hdf5 import HDF5Manager
+            engine = HDF5Manager.guess_engine(file)
+            mgr = HDF5Manager(engine)
+            mgr.read_metadata(file)
+
+
+        """
         self.engine = engine
+        self._temp_batch_files = (
+            []
+        )  # list of batch files when writing by part in vaex mode
 
     def open(self, file, mode="w"):
         if self.engine == "pytables":
@@ -69,38 +103,140 @@ def open(self, file, mode="w"):
         else:
             raise NotImplementedError(self.engine)
 
-    def write(self, handler, df, append=True):
+    def write(
+        self,
+        file,
+        df,
+        append=True,
+        key="default",
+        format="table",
+        data_columns=["iso", "wav"],
+    ):
+        """Write dataframe ``df`` to ``file``
+
+        Other Parameters
+        ----------------
+        key: str
+            group to write to. If ``None``, write at root level. If ``'default'``,
+            use engine's default (`/table` for `'vaex'`, `df` for `pytables`,
+            root for `h5py` )
+        data_columns : list
+            only these column names will be searchable directly on disk to
+            load certain lines only. See :py:func:`~radis.io.hdf5.hdf2df`
+        """
         if self.engine == "pytables":
-            DATA_COLUMNS = ["iso", "wav"]
-            """
-            list : only these column names will be searchable directly on disk to
-            only load certain lines. See :py:func:`~radis.io.hdf5.hdf2df`
-            """
-            handler.put(
-                key="df",
-                value=df,
-                append=append,
-                format="table",
-                data_columns=DATA_COLUMNS,
-            )
+            if key == "default":
+                key = "df"
+            with self.open(file, "a" if append else "w") as f:
+                f.put(
+                    key=key,
+                    value=df,
+                    append=append,
+                    format=format,
+                    data_columns=data_columns,
+                )
+        elif self.engine == "pytables-fixed":
+            assert not append
+            # export dataframe
+            df.to_hdf(file, key, format="fixed", mode="w", complevel=9, complib="blosc")
+        elif self.engine == "vaex":
+            if key == "default":
+                key = r"/table"
+            import vaex
+
+            if append == True:
+                # In vaex we cannot append. Here we write lots of small files then combine them.
+                # self.combine_temp_batch_files() should be combined at the end.
+                base, ext = splitext(file)
+                i = 0
+                temp_batch_file = base + "_temp" + str(i).zfill(6) + ext
+                while temp_batch_file in self._temp_batch_files:
+                    i += 1
+                    temp_batch_file = base + "_temp" + str(i).zfill(6) + ext
+                file = temp_batch_file
+                # Check no remaining one from a non-cleaned previous run:
+                if exists(file):
+                    from radis.misc.printer import printr
+
+                    printr(f"Temp file {temp_batch_file} already exists: deleting it")
+                    os.remove(file)
+                self._temp_batch_files.append(file)
+            # Write:
+            try:
+                df.export_hdf5(file, group=key, mode="w")
+            except AttributeError:  # case where df is not a Vaex dataFrame but (likely) a Pandas Dataframe
+                vaex.from_pandas(df).export_hdf5(file, group=key, mode="w")
         else:
             raise NotImplementedError(self.engine)
+            # h5py is not designed to write Pandas DataFrames
+
+    def combine_temp_batch_files(self, file, key="default"):
+        """Combine all batch files in ``self._temp_batch_files`` into one.
+        Removes all batch files.
+        """
+        if self.engine == "vaex":
+            if len(self._temp_batch_files) == 0:
+                raise ValueError(f"No batch temp files were written for {file}")
+            if key == "default":
+                key = r"/table"
+            import vaex
+
+            df = vaex.open(self._temp_batch_files, group=key)
+            df.export_hdf5(file, group=key, mode="w")
+            df.close()
+        self._close_temp_batch_files()
+
+    def _close_temp_batch_files(self):
+        for i in range(len(self._temp_batch_files) - 1, -1, -1):
+            os.remove(self._temp_batch_files[i])
+            del self._temp_batch_files[i]
+
+    def __del__(self):
+        """ clean before deleting"""
+        if len(self._temp_batch_files) > 0:
+            from radis.misc.printer import printr
+
+            printr(
+                "Warning : {0} files were written but not combined. Deleting them.\n\n{1}".format(
+                    len(self._temp_batch_files), self._temp_batch_files
+                )
+            )
+            self._close_temp_batch_files()
 
     def load(
-        self, fname, columns=None, where=None, key="df", **store_kwargs
-    ) -> pd.DataFrame:
+        self,
+        fname,
+        columns=None,
+        where=None,
+        key="default",
+        none_if_empty=False,
+        **store_kwargs,
+    ):
         """
         Parameters
         ----------
         columns: list of str
             list of columns to load. If ``None``, returns all columns in the file.
+        where: list of str
+            filtering conditions. Ex::
+
+                "wav > 2300"
 
         Other Parameters
         ----------------
-        key: store key in  ``'pytables'`` mode.
+        key: str
+            group to load from. If ``None``, load from root level. If ``'default'``,
+            use engine's default (`/table` for `'vaex'`, `df` for `pytables`,
+            root for `h5py` )
+
+        Returns
+        -------
+        pd.DataFrame or vaex.DataFrame
         """
 
-        if self.engine == "pytables":
+        if self.engine in ["pytables", "pytables-fixed"]:
+            if key == "default":
+                key = "df"
             try:
                 df = pd.read_hdf(
                     fname, columns=columns, where=where, key=key, **store_kwargs
@@ -110,46 +246,67 @@ def load(
                     raise TypeError(
                         f"radis.io.hdf5.hdf2df can only be used to load specific HDF5 files generated in a 'Table' which allows to select only certain columns or rows. Here the file {fname} is in 'Fixed' format. Regenerate it ? If it's a cache file of a .par file, load the .par file directly ?"
                     )
+                elif "cannot create a storer if the object is not existing" in str(err):
+                    raise TypeError(
+                        f"Missing group `{key}` in {fname}. Maybe the file has been generated by a different HDF5 library than Pytables. Try using `engine='vaex'` in the calling function (hdf2df, etc.)"
+                    )
                 else:
                     raise
             except NoSuchNodeError as err:
                 # Probably tried to read a Vaex/h5py HDF5 file forcing "engine='pytables'"
                 raise AttributeError(
-                    f"file {fname} does not seem to have been generated by Pytables. Try using `engine='vaex'` in hdf2df"
+                    f"file {fname} does not seem to have been generated by Pytables. Try using `engine='vaex'` in the calling function (hdf2df, etc.)"
                 ) from err
 
         elif self.engine == "vaex":
+            if key == "default":
+                key = r"/table"
+
+            import h5py
             import vaex
 
             # Open file
             assert len(store_kwargs) == 0
+            fname_list = fname if isinstance(fname, list) else [fname]
+            # vaex can open several files at the same time
+            # First check group exists
+            for fname in fname_list:
+                try:
+                    with h5py.File(fname, "r") as f:
+                        if key and key not in f:
+                            raise KeyError(
+                                key
+                            )  # before vaex raises the same error; which prints things on the console that cannot be caught
+                except (FileNotFoundError, OSError) as err:
+                    # error message with suggestion on how to convert from existing file
+                    for f in fname_list:
+                        if exists(f.replace(".hdf5", ".h5")):
+                            raise FileNotFoundError(
+                                f"`{f}` not found but `{f.replace('.hdf5', '.h5')}` exists (probably a row-based pytables HDF5 file). Try (1) using engine='pytables' in the calling function (`hdf2df`, `fetch_hitemp`, etc.)  ; (2) delete the file to re-download and re-parse it (this may take a lot of time !) ;  or (3, recommended) set `import radis; radis.config['AUTO_UPDATE_DATABASE']= True` in your script to auto-update to Vaex HDF5 file"
+                            ) from err
+                    raise
+
+            # Now, open with vaex
             try:
-                df = vaex.open(fname)
-            except FileNotFoundError as err:
-                # error message with suggestion on how to convert from existing file
-                fname_list = fname if isinstance(fname, list) else [fname]
-                for f in fname_list:
-                    if exists(f.replace(".hdf5", ".h5")):
-                        raise FileNotFoundError(
-                            f"`{f}` not found but `{f.replace('.hdf5', '.h5')}` exists (probably a row-based pytables HDF5 file). Try (1) using engine='pytables' in `hdf2df` or `fetch_hitemp` ; (2) delete the file to re-download and re-parse it (this may take a lot of time !) ;  or (3, recommended) set `import radis; radis.congig['AUTO_UPDATE_DATABASE']= True` in your script to auto-update to Vaex HDF5 file"
-                        ) from err
-                # else:
-                raise
+                df = vaex.open(fname_list, group=key)
             except OSError as err:
                 raise OSError(
-                    f"Cannot read {fname} with Vaex's HDF5 library (column-based). It may be a file generated by pytables (row-based). Try (1) using engine='pytables' in `hdf2df` or `fetch_hitemp` ; (2) delete the file to re-download and re-parse it (this may take a lot of time !) ;  or (3, recommended) set `import radis; radis.congig['AUTO_UPDATE_DATABASE'] = True` in your script to auto-update to Vaex HDF5 file"
+                    f"Cannot read {fname}, group `{key}` with Vaex HDF5 library (column-based). It may be a file generated by pytables (row-based). Try (1) using engine='pytables' in the calling function (`hdf2df`, `fetch_hitemp`, etc.)  ; (2) delete the file to re-download and re-parse it (this may take a lot of time !) ;  or (3, recommended) set `import radis; radis.config['AUTO_UPDATE_DATABASE'] = True` in your script to auto-update to Vaex HDF5 file"
                 ) from err
 
             return df
 
         elif self.engine == "h5py":
+            # TODO: define default key ?
+            if key == "default":
+                key = None
             import h5py
 
             with h5py.File(fname, "r") as f:
-                if key:
-                    load_from = f[key]
-                else:
+                if key is None:  # load from root level
                     load_from = f
+                else:
+                    load_from = f[key]
                 out = {}
                 for k in load_from.keys():
                     out[k] = f[k][()]
@@ -160,56 +317,133 @@ def load(
 
         return df
 
-    def add_metadata(self, fname: str, metadata: dict):
+    def add_metadata(
+        self, fname: str, metadata: dict, key="default", create_empty_dataset=False
+    ):
+        """
+        Parameters
+        ----------
+        fname: str
+            filename
+        metadata: dict
+            dictionary of metadata to add in group ``key``
+        key: str
+            group to add metadata to. If ``None``, add at root level. If ``'default'``,
+            use engine's default (`/table` for `'vaex'`, `df` for `pytables`,
+            root for `h5py` )
 
-        if self.engine == "pytables":
-            with pd.HDFStore(fname, mode="a", complib="blosc", complevel=9) as f:
+        Other Parameters
+        ----------------
+        create_empty_dataset: bool
+            if True, create an empty dataset to store the metadata as attribute
+
+        """
+        from radis.io.cache_files import _h5_compatible
 
-                # f.get_storer("df").attrs.update(metadata)
-                f.get_storer("df").attrs.metadata = metadata
+        if self.engine in ["pytables", "pytables-fixed"]:
+            if key == "default":
+                key = "df"
+            with pd.HDFStore(fname, mode="a", complib="blosc", complevel=9) as f:
+                if create_empty_dataset:
+                    assert self.engine != "pytables-fixed"
+                    # Not possible to create an empty node directly, so we create a dummy group  # TODO ?
+                    f.put(key, pd.Series([]))
+                f.get_storer(key).attrs.metadata = metadata
 
         elif self.engine == "h5py":
+            if key == "default":
+                key = None
             with h5py.File(fname, "a") as hf:
-                hf.attrs.update(metadata)
+                if create_empty_dataset:
+                    assert key is not None
+                    hf.create_dataset(key, dtype="f")
+                if key is None:  # add metadta at root level
+                    hf.attrs.update(_h5_compatible(metadata))
+                else:
+                    hf[key].attrs.update(_h5_compatible(metadata))
 
         elif self.engine == "vaex":
+            if key == "default":
+                key = r"/table"
+            # Should be able to deal with multiple files at a time
             if isinstance(fname, list):
                 assert isinstance(metadata, list)
                 for f, m in zip(fname, metadata):
                     with h5py.File(f, "a") as hf:
-                        hf.attrs.update(m)
+                        if create_empty_dataset:
+                            assert key is not None
+                            hf.create_dataset(key, dtype="f")
+                        if key is None:  # add metadta at root level
+                            hf.attrs.update(_h5_compatible(m))
+                        else:
+                            hf[key].attrs.update(_h5_compatible(m))
             else:
                 with h5py.File(fname, "a") as hf:
-                    hf.attrs.update(metadata)
+                    if create_empty_dataset:
+                        assert key is not None
+                        hf.create_dataset(key, dtype="f")
+                    if key is None:  # add metadta at root level
+                        hf.attrs.update(_h5_compatible(metadata))
+                    else:
+                        hf[key].attrs.update(_h5_compatible(metadata))
 
         else:
             raise NotImplementedError(self.engine)
 
-    def read_metadata(self, fname: str, key="df") -> dict:
+    def read_metadata(self, fname: str, key="default") -> dict:
         """
         Other Parameters
         ----------------
-        key: store key in  ``'pytables'`` mode.
+        key: str
+            group where to read metadat from. If ``None``, add at root level. If ``'default'``,
+            use engine's default (`/table` for `'vaex'`, `df` for `pytables`,
+            root for `h5py` )
         """
 
-        if self.engine == "pytables":
+        if self.engine in ["pytables", "pytables-fixed"]:
+            if key == "default":
+                key = "df"
             with pd.HDFStore(fname, mode="r", complib="blosc", complevel=9) as f:
-
-                metadata = f.get_storer("df").attrs.metadata
+                try:
+                    metadata = f.get_storer(key).attrs.metadata
+                except KeyError as err:
+                    print(f"Error reading metadata from {fname}")
+                    raise err
 
         elif self.engine == "h5py":
+            if key == "default":
+                key = None
             with h5py.File(fname, "r") as hf:
-                metadata = dict(hf.attrs)
+                if key is None:  # read metadta at root level
+                    metadata = dict(hf.attrs)
+                else:
+                    try:
+                        metadata = dict(hf[key].attrs)
+                    except KeyError as err:
+                        print(f"Error reading metadata from {fname}")
+                        raise err
 
         elif self.engine == "vaex":
+            if key == "default":
+                key = r"/table"
             if isinstance(fname, list):
                 metadata = []
                 for f in fname:
                     with h5py.File(f, "r") as hf:
-                        metadata.append(dict(hf.attrs))
+                        if key is None:  # read metadta at root level
+                            metadata.append(dict(hf.attrs))
+                        else:
+                            metadata.append(dict(hf[key].attrs))
             else:
                 with h5py.File(fname, "r") as hf:
-                    metadata = dict(hf.attrs)
+                    if key is None:  # add metadta at root level
+                        metadata = dict(hf.attrs)
+                    else:
+                        try:
+                            metadata = dict(hf[key].attrs)
+                        except KeyError as err:
+                            print(f"Error reading metadata from {fname}")
+                            raise err
 
         else:
             raise NotImplementedError(self.engine)
@@ -218,16 +452,40 @@ def read_metadata(self, fname: str, key="df") -> dict:
 
     @classmethod
     def guess_engine(self, file, verbose=True):
-        """Guess which HDF5 library ``file`` is compatible with"""
+        """Guess which HDF5 library ``file`` is compatible with
+
+        .. note::
+            it still take about 1 ms for this functino to execute. For extreme
+            performance you want to directly give the correct engine
+
+        Examples
+        --------
+        ::
+
+            file = 'CO.hdf5'
+            from radis.io.hdf5 import HDF5Manager
+            engine = HDF5Manager.guess_engine(file)
+            mgr = HDF5Manager(engine)
+            mgr.read_metadata(file)
+
+        """
         # See if it looks like PyTables
-        #  TODO : move in Manager
-        with pd.HDFStore(file, mode="r") as store:
-            if store.get_storer("df"):
-                engine = "pytables"
-            else:
-                engine = "h5py"
+        import tables
+
+        if tables.is_pytables_file(file):
+            engine = "pytables"
+        else:
+            # Try Vaex
+            with h5py.File(file, mode="r") as hf:
+                try:
+                    hf[r"/table"]
+                except KeyError:
+                    engine = "h5py"
+                else:
+                    engine = "vaex"
         if verbose:
             print(f"Guessed that {file} was compatible with `{engine}` hdf5 engine")
+        # raise
         return engine
 
 
@@ -239,7 +497,7 @@ def hdf2df(
     load_wavenum_max=None,
     verbose=True,
     store_kwargs={},
-    engine="auto",
+    engine="guess",
 ):
     """Load a HDF5 line databank into a Pandas DataFrame.
 
@@ -262,7 +520,7 @@ def hdf2df(
     store_kwargs: dict
         arguments forwarded to :py:meth:`~pandas.io.pytables.read_hdf`
     engine: ``'h5py'``, ``'pytables'``, ``'vaex'``, ``'auto'``
-        which HDF5 library to use. If ``'auto'``, try to guess. Note: ``'vaex'``
+        which HDF5 library to use. If ``'guess'``, try to guess. Note: ``'vaex'``
         uses ``'h5py'`` compatible HDF5.
 
     Returns
@@ -297,7 +555,7 @@ def hdf2df(
 
     """
 
-    if engine == "auto":
+    if engine == "guess":
         engine = HDF5Manager.guess_engine(fname)
 
     t0 = time()
@@ -316,13 +574,13 @@ def hdf2df(
             where.append(f'iso in {isotope.split(",")}')
 
     elif engine == "vaex":
-        #  Selection is done after opening the file time in vaex
-        where = []
+        # Selection is done after opening the file time in vaex
+        # see end of this function
+        where = None
     else:
         raise NotImplementedError(engine)
 
     # Load :
-
     manager = HDF5Manager(engine)
     df = manager.load(fname, columns=columns, where=where, **store_kwargs)
 
@@ -331,45 +589,28 @@ def hdf2df(
 
         # Selection
         selection = True
-        # limitation : so far the df.select(df.iso in isotope.split(",")) syntax
-        # fails in Vaex (and worse: still returns something.)
-        # For the moment, only implement with one isotope.
-        # TODO : add all cases manually...
+        b = True
+        if load_wavenum_min is not None:
+            b *= df.wav > load_wavenum_min
+        if load_wavenum_max is not None:
+            b *= df.wav < load_wavenum_max
         if isotope is not None:
-            if not isinstance(isotope, int):
-                try:
-                    isotope = int(isotope)
-                except:
-                    raise NotImplementedError(
-                        f"When reading HDF5 in vaex mode, selection works for a single isotope only (got {isotope})"
-                    )
-        if (
-            load_wavenum_min is not None
-            and load_wavenum_max is not None
-            and isotope is not None
-        ):
-            df.select(
-                (df.wav > load_wavenum_min)
-                & (df.wav < load_wavenum_max)
-                & (df.iso == isotope)
-            )
-        elif load_wavenum_min is not None and load_wavenum_max is not None:
-            df.select((df.wav > load_wavenum_min) & (df.wav < load_wavenum_max))
-        elif load_wavenum_min is not None and isotope is not None:
-            df.select((df.wav > load_wavenum_min) & (df.iso == isotope))
-        elif load_wavenum_max is not None and isotope is not None:
-            df.select((df.wav < load_wavenum_max) & (df.iso == isotope))
-        elif load_wavenum_min is not None:
-            df.select(df.wav > load_wavenum_min)
-        elif load_wavenum_max is not None:
-            df.select(df.wav < load_wavenum_max)
-        elif isotope is not None:
-            df.select(df.iso == isotope)
-        else:
-            selection = False
+            from radis.misc.basics import is_float
+
+            if is_float(isotope):
+                b *= df.iso == int(isotope)
+            else:
+                b2 = False
+                for iso in isotope.split(","):
+                    b2 += df.iso == int(iso)
+                b *= b2
+        if b != True and False in b:
+            df = df[b]  # note that this is a vaex Expression, not the DataFrame yet
 
         # Load
-        df = df.to_pandas_df(column_names=columns, selection=selection)
+        if columns:  # load only these columns (if they exist)
+            columns = [c for c in columns if c in df.columns]
+        df = df.to_pandas_df(column_names=columns)
 
     # Read and add metadata in the DataFrame
     metadata = manager.read_metadata(fname)
diff --git a/radis/io/hitemp.py b/radis/io/hitemp.py
index d2d7cdfaa..f12f64b0c 100644
--- a/radis/io/hitemp.py
+++ b/radis/io/hitemp.py
@@ -17,8 +17,6 @@
 
 import numpy as np
 
-import radis
-
 try:
     from .dbmanager import DatabaseManager
     from .hdf5 import update_pytables_to_vaex
@@ -107,12 +105,18 @@ def __init__(
         name,
         molecule,
         local_databases,
+        engine="default",
         verbose=True,
         chunksize=100000,
         parallel=True,
     ):
         super().__init__(
-            name, molecule, local_databases, verbose=verbose, parallel=parallel
+            name,
+            molecule,
+            local_databases,
+            engine,
+            verbose=verbose,
+            parallel=parallel,
         )
         self.chunksize = chunksize
         self.downloadable = True
@@ -269,7 +273,6 @@ def parse_to_local_file(
         pbar_Ntot_estimate_factor=None,
         pbar_Nlines_already=0,
         pbar_last=True,
-        engine="pytables",
     ):
         """Uncompress ``urlname`` into ``local_file``.
         Also add metadata
@@ -306,7 +309,7 @@ def parse_to_local_file(
         wmin = np.inf
         wmax = 0
 
-        writer = self.get_hdf5_manager(engine)
+        writer = self.get_hdf5_manager()
 
         with opener.open(urlname) as gfile:  # locally downloaded file
 
@@ -317,53 +320,49 @@ def parse_to_local_file(
 
             # assert not(exists(local_file))
 
-            with writer.open(local_file) as f:
+            b = np.zeros(chunksize, dtype=dt)  # receives the HITRAN 160-character data.
+
+            for nbytes in iter(lambda: gfile.readinto(b), 0):
+
+                if not b[-1]:
+                    # End of file flag within the chunk (but does not start
+                    # with End of file flag) so nbytes != 0
+                    b = get_last(b)
+
+                df = _ndarray2df(b, columns, linereturnformat)
+
+                # Post-processing :
+                # ... Add local quanta attributes, based on the HITRAN group
+                df = parse_local_quanta(df, molecule, verbose=verbose)
+
+                # ... Add global quanta attributes, based on the HITRAN class
+                df = parse_global_quanta(df, molecule, verbose=verbose)
+
+                # Switch 'P', 'Q', 'R' to -1, 0, 1
+                if "branch" in df:
+                    replace_PQR_with_m101(df)
+
+                writer.write(local_file, df, append=True)
+
+                wmin = np.min((wmin, df.wav.min()))
+                wmax = np.max((wmax, df.wav.max()))
+
+                Nlines += len(df)
+                Nlines_tot += len(df)
+                Nlines_raw += len(b)
+                if pbar_Ntot_estimate_factor is None:
+                    pbar_Ntot_message = f"{Ntotal_lines_expected:,} lines"
+                else:
+                    pbar_Ntot_message = f"~{Ntotal_lines_expected:,} lines (estimate)"
+                pb.update(
+                    Nlines_tot,
+                    message=f"  Parsed {Nlines_tot:,} / {pbar_Ntot_message}. Wavenumber range {wmin:.2f}-{wmax:.2f} cm-1 is complete.",
+                )
+                # Reinitialize for next read
                 b = np.zeros(
                     chunksize, dtype=dt
                 )  # receives the HITRAN 160-character data.
-
-                for nbytes in iter(lambda: gfile.readinto(b), 0):
-
-                    if not b[-1]:
-                        # End of file flag within the chunk (but does not start
-                        # with End of file flag) so nbytes != 0
-                        b = get_last(b)
-
-                    df = _ndarray2df(b, columns, linereturnformat)
-
-                    # Post-processing :
-                    # ... Add local quanta attributes, based on the HITRAN group
-                    df = parse_local_quanta(df, molecule)
-
-                    # ... Add global quanta attributes, based on the HITRAN class
-                    df = parse_global_quanta(df, molecule)
-
-                    # Switch 'P', 'Q', 'R' to -1, 0, 1
-                    if "branch" in df:
-                        replace_PQR_with_m101(df)
-
-                    writer.write(f, df, append=True)
-
-                    wmin = np.min((wmin, df.wav.min()))
-                    wmax = np.max((wmax, df.wav.max()))
-
-                    Nlines += len(df)
-                    Nlines_tot += len(df)
-                    Nlines_raw += len(b)
-                    if pbar_Ntot_estimate_factor is None:
-                        pbar_Ntot_message = f"{Ntotal_lines_expected:,} lines"
-                    else:
-                        pbar_Ntot_message = (
-                            f"~{Ntotal_lines_expected:,} lines (estimate)"
-                        )
-                    pb.update(
-                        Nlines_tot,
-                        message=f"  Parsed {Nlines_tot:,} / {pbar_Ntot_message}. Wavenumber range {wmin:.2f}-{wmax:.2f} cm-1 is complete.",
-                    )
-                    # Reinitialize for next read
-                    b = np.zeros(
-                        chunksize, dtype=dt
-                    )  # receives the HITRAN 160-character data.
+        writer.combine_temp_batch_files(local_file)  # used for vaex mode only
         if pbar_last:
             pb.update(
                 Nlines_tot,
@@ -377,6 +376,8 @@ def parse_to_local_file(
         assert Nlines == Nlines_raw
 
         # Add metadata
+        from radis import __version__
+
         writer.add_metadata(
             local_file,
             {
@@ -385,18 +386,16 @@ def parse_to_local_file(
                 "download_date": self.get_today(),
                 "download_url": urlname,
                 "total_lines": Nlines_raw,
-                "version": radis.__version__,
+                "version": __version__,
             },
         )
 
         return Nlines
 
-    def register(self, engine):
-        """register in ~/radis.json
+    def register(self):
+        """register in ~/radis.json"""
 
-        engine required to differentiate '.h5', '.hdf5'"""
-
-        local_files, urlnames = self.get_filenames(engine=engine)
+        local_files, urlnames = self.get_filenames()
         info = f"HITEMP {self.molecule} lines ({self.wmin:.1f}-{self.wmax:.1f} cm-1) with TIPS-2017 (through HAPI) for partition functions"
 
         if self.molecule in ["CO2", "H2O"]:
@@ -436,7 +435,7 @@ def fetch_hitemp(
     chunksize=100000,
     clean_cache_files=True,
     return_local_path=False,
-    engine="pytables",
+    engine="default",
     parallel=True,
 ):
     """Stream HITEMP file from HITRAN website. Unzip and build a HDF5 file directly.
@@ -452,7 +451,7 @@ def fetch_hitemp(
     databank_name: str
         name of the databank in RADIS :ref:`Configuration file <label_lbl_config_file>`
         Default ``"HITEMP-{molecule}"``
-    isotope: str
+    isotope: str, int or None
         load only certain isotopes : ``'2'``, ``'1,2'``, etc. If ``None``, loads
         everything. Default ``None``.
     load_wavenum_min, load_wavenum_max: float (cm-1)
@@ -462,8 +461,9 @@ def fetch_hitemp(
 
     Other Parameters
     ----------------
-    cache: bool, or ``'regen'``
-        if ``True``, use existing HDF5 file. If ``False`` or ``'regen'``, rebuild it..
+    cache: ``True``, ``False``, ``'regen'`` or ``'force'``
+        if ``True``, use existing HDF5 file. If ``False`` or ``'regen'``, rebuild it.
+        If ``'force'``, raise an error if cache file cannot be used (useful for debugging).
         Default ``True``.
     verbose: bool
     chunksize: int
@@ -473,8 +473,8 @@ def fetch_hitemp(
         if ``True`` clean downloaded cache files after HDF5 are created.
     return_local_path: bool
         if ``True``, also returns the path of the local database file.
-    engine: 'pytables', 'vaex'
-        which HDF5 library to use.
+    engine: 'pytables', 'vaex', 'default'
+        which HDF5 library to use. If 'default' use the value from ~/radis.json
     parallel: bool
         if ``True``, uses joblib.parallel to load database with multiple processes
 
@@ -516,10 +516,10 @@ def fetch_hitemp(
 
     """
 
-    if databank_name == "HITEMP-{molecule}":
+    if r"{molecule}" in databank_name:
         databank_name = databank_name.format(**{"molecule": molecule})
 
-    local_databases = abspath(local_databases.replace("~", expanduser("~")))
+    local_databases = abspath(expanduser(local_databases))
 
     ldb = HITEMPDatabaseManager(
         databank_name,
@@ -528,10 +528,11 @@ def fetch_hitemp(
         verbose=verbose,
         chunksize=chunksize,
         parallel=parallel,
+        engine=engine,
     )
 
     # Get list of all expected local files for this database:
-    local_files, urlnames = ldb.get_filenames(engine=engine)
+    local_files, urlnames = ldb.get_filenames()
 
     # Delete files if needed:
     relevant_files = ldb.keep_only_relevant(
@@ -541,8 +542,7 @@ def fetch_hitemp(
         ldb.remove_local_files(relevant_files)
     ldb.check_deprecated_files(
         ldb.get_existing_files(relevant_files),
-        engine=engine,
-        remove=True if cache != "force" else False,
+        auto_remove=True if cache != "force" else False,
     )
 
     # Get missing files
@@ -551,11 +551,11 @@ def fetch_hitemp(
         download_files, load_wavenum_min, load_wavenum_max
     )
     # do not re-download files if they exist in another format :
-    if engine in ["vaex", "auto"]:
+    if engine in ["vaex", "auto", "default"]:
         # ... convert files if asked:
-        import radis
+        from radis import config
 
-        if radis.config["AUTO_UPDATE_DATABASE"]:
+        if config["AUTO_UPDATE_DATABASE"]:
             converted = []
             for f in download_files:
                 if exists(f.replace(".hdf5", ".h5")):
@@ -574,7 +574,7 @@ def fetch_hitemp(
             urlnames = ldb.fetch_urlnames()
         filesmap = dict(zip(local_files, urlnames))
         download_urls = [filesmap[k] for k in download_files]
-        ldb.download_and_parse(download_urls, download_files, engine=engine)
+        ldb.download_and_parse(download_urls, download_files)
 
         # Done: add final checks
         if molecule not in ["CO2", "H2O"]:
@@ -592,22 +592,25 @@ def fetch_hitemp(
 
     # Register
     if not ldb.is_registered():
-        ldb.register(engine=engine)  # engine required to differentiate '.h5', '.hdf5'
+        ldb.register()
 
-    if clean_cache_files:
+    if len(download_files) > 0 and clean_cache_files:
         ldb.clean_download_files()
 
     # Load and return
     files_loaded = ldb.keep_only_relevant(
         local_files, load_wavenum_min, load_wavenum_max
     )
+
+    if isotope and type(isotope) == int:
+        isotope = str(isotope)
+
     df = ldb.load(
         files_loaded,  # filter other files,
         columns=columns,
         isotope=isotope,
         load_wavenum_min=load_wavenum_min,  # for relevant files, get only the right range
         load_wavenum_max=load_wavenum_max,
-        engine=engine,
     )
 
     return (df, files_loaded) if return_local_path else df
diff --git a/radis/io/hitran.py b/radis/io/hitran.py
index d3b05c991..4ef161861 100644
--- a/radis/io/hitran.py
+++ b/radis/io/hitran.py
@@ -20,17 +20,17 @@
 """
 
 
+import os
 import sys
 
 # from radis.test.utils import getTestFile
 import time
 from collections import OrderedDict
-from os.path import exists, getmtime
+from os.path import exists, getmtime, join, split
 
 import pandas as pd
 from numpy import int64
 
-import radis
 from radis.db.classes import (  # get_molecule_identifier,
     HITRAN_CLASS1,
     HITRAN_CLASS2,
@@ -51,6 +51,7 @@
     get_molecule,
 )
 from radis.io.cache_files import cache_file_name, load_h5_cache_file, save_to_hdf
+from radis.io.dbmanager import DatabaseManager
 from radis.io.tools import (
     drop_object_format_columns,
     parse_hitran_file,
@@ -99,10 +100,12 @@ def cast_to_int64_with_missing_values(dg, keys):
 def hit2df(
     fname,
     cache=True,
+    load_columns=None,
     verbose=True,
     drop_non_numeric=True,
     load_wavenum_min=None,
     load_wavenum_max=None,
+    engine="pytables",
 ):
     """Convert a HITRAN/HITEMP [1]_ file to a Pandas dataframe
 
@@ -116,6 +119,12 @@ def hit2df(
         improves performances a lot (but changes in the database are not
         taken into account). If False, no database is used. If ``'regen'``, temp
         file are reconstructed. Default ``True``.
+    load_columns: list
+        columns to load. If ``None``, loads everything
+
+        .. note::
+            this is only relevant if loading from a cache file. To generate
+            the cache file, all columns are loaded anyway.
 
     Other Parameters
     ----------------
@@ -128,6 +137,8 @@ def hit2df(
         if not ``'None'``, only load the cached file if it contains data for
         wavenumbers above/below the specified value. See :py:func`~radis.io.cache_files.load_h5_cache_file`.
         Default ``'None'``.
+    engine: 'pytables', 'vaex'
+        format for Hdf5 cache file. Default `pytables`
 
     Returns
     -------
@@ -141,16 +152,8 @@ def hit2df(
     .. [1] `HITRAN 1996, Rothman et al., 1998 <https://www.sciencedirect.com/science/article/pii/S0022407398000788>`__
 
 
-
-    Notes
-    -----
-
-    Performances: see CDSD-HITEMP parser
-
-
     See Also
     --------
-
     :func:`~radis.io.cdsd.cdsd2df`
     """
     metadata = {}
@@ -165,7 +168,7 @@ def hit2df(
     columns = columns_2004
 
     # Use cache file if possible
-    fcache = cache_file_name(fname)
+    fcache = cache_file_name(fname, engine=engine)
     if cache and exists(fcache):
         relevant_if_metadata_above = (
             {"wavenum_max": load_wavenum_min} if load_wavenum_min else {}
@@ -173,15 +176,19 @@ def hit2df(
         relevant_if_metadata_below = (
             {"wavenum_min": load_wavenum_max} if load_wavenum_max else {}
         )  # not relevant if wavenum_min of file is > wavenum max required
+        from radis import __version__, config
+
         df = load_h5_cache_file(
             fcache,
             cache,
+            columns=load_columns,
             valid_if_metadata_is=metadata,
             relevant_if_metadata_above=relevant_if_metadata_above,
             relevant_if_metadata_below=relevant_if_metadata_below,
-            current_version=radis.__version__,
-            last_compatible_version=radis.config["OLDEST_COMPATIBLE_VERSION"],
+            current_version=__version__,
+            last_compatible_version=config["OLDEST_COMPATIBLE_VERSION"],
             verbose=verbose,
+            engine=engine,
         )
         if df is not None:
             return df
@@ -224,7 +231,7 @@ def hit2df(
 
     # Add local quanta attributes, based on the HITRAN group
     try:
-        df = parse_local_quanta(df, mol)
+        df = parse_local_quanta(df, mol, verbose=verbose)
     except ValueError as err:
         # Empty strings (unlabelled lines) have been reported for HITEMP2010-H2O.
         # In this case, do not parse (makes non-equilibrium calculations impossible).
@@ -238,7 +245,7 @@ def hit2df(
 
     # Add global quanta attributes, based on the HITRAN class
     try:
-        df = parse_global_quanta(df, mol)
+        df = parse_global_quanta(df, mol, verbose=verbose)
     except ValueError as err:
         # Empty strings (unlabelled lines) have been reported for HITEMP2010-H2O.
         # In this case, do not parse (makes non-equilibrium calculations impossible).
@@ -270,15 +277,17 @@ def hit2df(
                     fcache, new_metadata
                 )
             )
+        from radis import __version__
+
         try:
             save_to_hdf(
                 df,
                 fcache,
                 metadata=new_metadata,
-                version=radis.__version__,
-                key="df",
+                version=__version__,
                 overwrite=True,
                 verbose=verbose,
+                engine=engine,
             )
         except PermissionError:
             if verbose:
@@ -297,7 +306,7 @@ def hit2df(
 # %% Hitran global quanta classes
 
 
-def _parse_HITRAN_class1(df):
+def _parse_HITRAN_class1(df, verbose=True):
     r"""Diatomic molecules: CO, HF, HCl, HBr, HI, N2, NO+
 
 
@@ -337,7 +346,7 @@ def _parse_HITRAN_class1(df):
     return pd.concat([df, dgu, dgl], axis=1)
 
 
-def _parse_HITRAN_class2(df):
+def _parse_HITRAN_class2(df, verbose=True):
     r"""Diatomic molecules with different electronic levels: O2
 
 
@@ -362,11 +371,14 @@ def _parse_HITRAN_class2(df):
     .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
 
     """
-    print("parse_global_quanta not implemented for molecules of HITRAN class 2")
+    if verbose > 2:
+        print(
+            f"parse_global_quanta not implemented for molecules of HITRAN class 2 ({HITRAN_CLASS2}). Non-LTE calculations will not be possible."
+        )
     return df
 
 
-def _parse_HITRAN_class3(df):
+def _parse_HITRAN_class3(df, verbose=True):
     r"""Diatomic molecules with doublet-Pi electronic state: NO, OH, ClO
 
 
@@ -391,11 +403,14 @@ def _parse_HITRAN_class3(df):
     .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
 
     """
-    print("parse_global_quanta not implemented for molecules of HITRAN class 3")
+    if verbose > 2:
+        print(
+            "parse_global_quanta not implemented for molecules of HITRAN class 3 ({HITRAN_CLASS3}). Non-LTE calculations will not be possible."
+        )
     return df
 
 
-def _parse_HITRAN_class4(df):
+def _parse_HITRAN_class4(df, verbose=True):
     r"""Parse linear triatomic class in HITRAN [1]_: N2O, OCS, HCN
 
     Parameters
@@ -450,7 +465,7 @@ def _parse_HITRAN_class4(df):
     return pd.concat([df, dgu, dgl], axis=1)
 
 
-def _parse_HITRAN_class5(df):
+def _parse_HITRAN_class5(df, verbose=True):
     r"""Parse linear triatomic with large Fermi resonance in HITRAN [1]_: CO2
 
     Parameters
@@ -505,7 +520,7 @@ def _parse_HITRAN_class5(df):
     return pd.concat([df, dgu, dgl], axis=1)
 
 
-def _parse_HITRAN_class6(df):
+def _parse_HITRAN_class6(df, verbose=True):
     r"""Parse non-linear triatomic in HITRAN [1]_: H2O, O3, SO2, NO2, HOCl, H2S, HO2, HOBr
 
     Parameters
@@ -571,7 +586,7 @@ def _parse_HITRAN_class6(df):
     return pd.concat([df, dgu, dgl], axis=1)
 
 
-def _parse_HITRAN_class7(df):
+def _parse_HITRAN_class7(df, verbose=True):
     r"""Parse linear tetratomic in HITRAN [1]_: C2H2
 
     Parameters
@@ -593,11 +608,14 @@ def _parse_HITRAN_class7(df):
     .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
 
     """
-    print("parse_global_quanta not implemented for molecules of HITRAN class 7")
+    if verbose > 2:
+        print(
+            "parse_global_quanta not implemented for molecules of HITRAN class 7 ({HITRAN_CLASS7}). Non-LTE calculations will not be possible."
+        )
     return df
 
 
-def _parse_HITRAN_class8(df):
+def _parse_HITRAN_class8(df, verbose=True):
     r"""Pyramidal tetratomic in HITRAN [1]_: NH3, PH3
 
 
@@ -621,11 +639,14 @@ def _parse_HITRAN_class8(df):
     .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
 
     """
-    print("parse_global_quanta not implemented for molecules of HITRAN class 8")
+    if verbose > 2:
+        print(
+            "parse_global_quanta not implemented for molecules of HITRAN class 8 ({HITRAN_CLASS8}). Non-LTE calculations will not be possible."
+        )
     return df
 
 
-def _parse_HITRAN_class9(df):
+def _parse_HITRAN_class9(df, verbose=True):
     r"""Non-linear tetratomic in HITRAN [1]_: H2CO, H2O2, COF2
 
 
@@ -649,11 +670,14 @@ def _parse_HITRAN_class9(df):
     .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
 
     """
-    print("parse_global_quanta not implemented for molecules of HITRAN class 9")
+    if verbose > 2:
+        print(
+            "parse_global_quanta not implemented for molecules of HITRAN class 9 ({HITRAN_CLASS9}). Non-LTE calculations will not be possible."
+        )
     return df
 
 
-def _parse_HITRAN_class10(df):
+def _parse_HITRAN_class10(df, verbose=True):
     r"""Pentatomic or greater polyatomic in HITRAN [1]_
 
 
@@ -677,14 +701,17 @@ def _parse_HITRAN_class10(df):
     .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
 
     """
-    print("parse_global_quanta not implemented for molecules of HITRAN class 10")
+    if verbose > 2:
+        print(
+            "parse_global_quanta not implemented for molecules of HITRAN class 10. Non-LTE calculations will not be possible."
+        )
     return df
 
 
 # %% HITRAN Local quanta
 
 
-def _parse_HITRAN_group1(df):
+def _parse_HITRAN_group1(df, verbose=True):
     r"""Parse asymmetric rotors (:py:attr:`~radis.db.classes.HITRAN_GROUP1` ):
     H2O, O3, SO2, NO2, HNO3, H2CO, HOCl, H2O2, COF2, H2S, HO2, HCOOH, ClONO2, HOBr, C2H4
 
@@ -749,7 +776,7 @@ def _parse_HITRAN_group1(df):
     return pd.concat([df, dgu, dgl], axis=1)
 
 
-def _parse_HITRAN_group2(df):
+def _parse_HITRAN_group2(df, verbose=True):
     r"""Parse diatomic and linear molecules (:py:attr:`~radis.db.classes.HITRAN_GROUP2` ):
     CO2, N2O, CO, HF, HCl, HBr, HI, OCS, N2, HCN, C2H2, NO+
 
@@ -815,7 +842,7 @@ def _parse_HITRAN_group2(df):
 #               'Fl',     ('a5',   str,   'lower state total angular momentum including nuclear spin', ''         )),(
 
 
-def _parse_HITRAN_group3(df):
+def _parse_HITRAN_group3(df, verbose=True):
     r"""Parse Spherical rotors (:py:attr:`~radis.db.classes.HITRAN_GROUP3` ) :
     SF6, CH4
 
@@ -839,11 +866,14 @@ def _parse_HITRAN_group3(df):
 
 
     """
-    print("parse_local_quanta not implemented for molecules of HITRAN group 3")
+    if verbose > 2:
+        print(
+            f"parse_local_quanta not implemented for molecules of HITRAN group 3 ({HITRAN_GROUP3}). Non-LTE calculations will not be possible."
+        )
     return df
 
 
-def _parse_HITRAN_group4(df):
+def _parse_HITRAN_group4(df, verbose=True):
     r"""Parse symmetric rotors (:py:attr:`~radis.db.classes.HITRAN_GROUP4` ):
     CH3D, CH3Cl, C2H6, NH3, PH3, CH3OH
 
@@ -867,11 +897,14 @@ def _parse_HITRAN_group4(df):
 
 
     """
-    print("parse_local_quanta not implemented for molecules of HITRAN group 4")
+    if verbose > 2:
+        print(
+            f"parse_local_quanta not implemented for molecules of HITRAN group 4 ({HITRAN_GROUP4}). Non-LTE calculations will not be possible."
+        )
     return df
 
 
-def _parse_HITRAN_group5(df):
+def _parse_HITRAN_group5(df, verbose=True):
     r"""Parse Triplet-Sigma ground electronic states (:py:attr:`~radis.db.classes.HITRAN_GROUP5` ) :
     O2
 
@@ -895,11 +928,14 @@ def _parse_HITRAN_group5(df):
 
 
     """
-    print("parse_local_quanta not implemented for molecules of HITRAN group 5")
+    if verbose > 2:
+        print(
+            "parse_local_quanta not implemented for molecules of HITRAN group 5 ({HITRAN_GROUP5}). Non-LTE calculations will not be possible."
+        )
     return df
 
 
-def _parse_HITRAN_group6(df):
+def _parse_HITRAN_group6(df, verbose=True):
     r"""Parse Doublet-Pi ground electronic states (:py:attr:`~radis.db.classes.HITRAN_GROUP6` ) :
     NO, OH, ClO
 
@@ -923,14 +959,17 @@ def _parse_HITRAN_group6(df):
 
 
     """
-    print("parse_local_quanta not implemented for molecules of HITRAN group 6")
+    if verbose > 2:
+        print(
+            "parse_local_quanta not implemented for molecules of HITRAN group 6 ({HITRAN_GROUP6}). Non-LTE calculations will not be possible."
+        )
     return df
 
 
 # %% Reading function
 
 
-def parse_local_quanta(df, mol):
+def parse_local_quanta(df, mol, verbose=True):
     r"""
     Parameters
     ----------
@@ -942,17 +981,17 @@ def parse_local_quanta(df, mol):
     """
 
     if mol in HITRAN_GROUP1:
-        df = _parse_HITRAN_group1(df)
+        df = _parse_HITRAN_group1(df, verbose=verbose)
     elif mol in HITRAN_GROUP2:
-        df = _parse_HITRAN_group2(df)
+        df = _parse_HITRAN_group2(df, verbose=verbose)
     elif mol in HITRAN_GROUP3:
-        df = _parse_HITRAN_group3(df)
+        df = _parse_HITRAN_group3(df, verbose=verbose)
     elif mol in HITRAN_GROUP4:
-        df = _parse_HITRAN_group4(df)
+        df = _parse_HITRAN_group4(df, verbose=verbose)
     elif mol in HITRAN_GROUP5:
-        df = _parse_HITRAN_group5(df)
+        df = _parse_HITRAN_group5(df, verbose=verbose)
     elif mol in HITRAN_GROUP6:
-        df = _parse_HITRAN_group6(df)
+        df = _parse_HITRAN_group6(df, verbose=verbose)
     else:
         raise ValueError(
             "Unknown group for molecule {0}. Cant parse local quanta".format(mol)
@@ -961,7 +1000,7 @@ def parse_local_quanta(df, mol):
     return df
 
 
-def parse_global_quanta(df, mol):
+def parse_global_quanta(df, mol, verbose=True):
     r"""
 
     Parameters
@@ -974,25 +1013,25 @@ def parse_global_quanta(df, mol):
     """
 
     if mol in HITRAN_CLASS1:
-        df = _parse_HITRAN_class1(df)
+        df = _parse_HITRAN_class1(df, verbose=verbose)
     elif mol in HITRAN_CLASS2:
-        df = _parse_HITRAN_class2(df)
+        df = _parse_HITRAN_class2(df, verbose=verbose)
     elif mol in HITRAN_CLASS3:
-        df = _parse_HITRAN_class3(df)
+        df = _parse_HITRAN_class3(df, verbose=verbose)
     elif mol in HITRAN_CLASS4:
-        df = _parse_HITRAN_class4(df)
+        df = _parse_HITRAN_class4(df, verbose=verbose)
     elif mol in HITRAN_CLASS5:
-        df = _parse_HITRAN_class5(df)
+        df = _parse_HITRAN_class5(df, verbose=verbose)
     elif mol in HITRAN_CLASS6:
-        df = _parse_HITRAN_class6(df)
+        df = _parse_HITRAN_class6(df, verbose=verbose)
     elif mol in HITRAN_CLASS7:
-        df = _parse_HITRAN_class7(df)
+        df = _parse_HITRAN_class7(df, verbose=verbose)
     elif mol in HITRAN_CLASS8:
-        df = _parse_HITRAN_class8(df)
+        df = _parse_HITRAN_class8(df, verbose=verbose)
     elif mol in HITRAN_CLASS9:
-        df = _parse_HITRAN_class9(df)
+        df = _parse_HITRAN_class9(df, verbose=verbose)
     elif mol in HITRAN_CLASS10:
-        df = _parse_HITRAN_class10(df)
+        df = _parse_HITRAN_class10(df, verbose=verbose)
     else:
         raise ValueError(
             "Unknown class for molecule {0}. Cant parse global quanta".format(mol)
@@ -1001,12 +1040,346 @@ def parse_global_quanta(df, mol):
     return df
 
 
+#%%
+
+
+class HITRANDatabaseManager(DatabaseManager):
+    def __init__(
+        self,
+        name,
+        molecule,
+        local_databases,
+        engine="default",
+        verbose=True,
+        parallel=True,
+    ):
+        super().__init__(
+            name,
+            molecule,
+            local_databases,
+            engine=engine,
+            verbose=verbose,
+            parallel=parallel,
+        )
+        self.downloadable = True
+        self.base_url = None
+        self.Nlines = None
+
+    def get_filenames(self):
+        if self.engine == "vaex":
+            return [join(self.local_databases, f"{self.molecule}.hdf5")]
+        else:
+            raise NotImplementedError()
+
+    def download_and_parse(
+        self,
+        local_file,
+        cache=True,
+    ):
+        """Download from HITRAN and parse into ``local_file``.
+        Also add metadata
+
+        Overwrites :py:meth:`radis.io.dbmanager.DatabaseManager.download_and_parse`
+        which downloads from a list of URL, because here we use [HAPI]_ to
+        download the files.
+
+        Parameters
+        ----------
+        opener: an opener with an .open() command
+        gfile : file handler. Filename: for info"""
+
+        from hapi import db_begin, fetch
+
+        from radis import hit2df
+        from radis.db.classes import get_molecule_identifier
+
+        if isinstance(local_file, list):
+            assert (
+                len(local_file) == 1
+            )  # fetch_hitran stores all lines of a given molecule in one file
+            local_file = local_file[0]
+
+        wmin = 1
+        wmax = 40000
+
+        def download_all_hitran_isotopes(molecule, directory):
+            """Blindly try to download all isotpes 1 - 9 for the given molecule
+
+            .. warning::
+                this won't be able to download higher isotopes (ex : isotope 10-11-12 for CO2)
+                Neglected for the moment, they're irrelevant for most calculations anyway
+
+            """
+            # create temp folder :
+            from radis.misc.basics import make_folders
+
+            make_folders(*split(abspath(directory)))
+
+            db_begin(directory)
+            isotope_list = []
+            data_file_list = []
+            header_file_list = []
+            for iso in range(1, 10):
+                file = f"{molecule}_{iso}"
+                if exists(join(directory, file + ".data")):
+                    if cache == "regen":
+                        # remove without printing message
+                        os.remove(join(directory, file + ".data"))
+                    else:
+                        from radis.misc.printer import printr
+
+                        printr(
+                            "File already exist: {0}. Deleting it.`".format(
+                                join(directory, file + ".data")
+                            )
+                        )
+                        os.remove(join(directory, file + ".data"))
+                try:
+                    fetch(file, get_molecule_identifier(molecule), iso, wmin, wmax)
+                except KeyError:
+                    # Isotope not defined:
+                    continue
+                else:
+                    isotope_list.append(iso)
+                    data_file_list.append(file + ".data")
+                    header_file_list.append(file + ".header")
+            return isotope_list, data_file_list, header_file_list
+
+        molecule = self.molecule
+        wmin_final = 100000
+        wmax_final = -1
+
+        # make_folders(dirname(self.local_databases), basename(self.local_databases))
+        # make_folders(self.local_databases, ["downloads"])
+
+        # Use HAPI only to download the files, then we'll parse them with RADIS's
+        # parsers, and convert to RADIS's fast HDF5 file formats.
+        isotope_list, data_file_list, header_file_list = download_all_hitran_isotopes(
+            molecule, self.tempdir
+        )
+
+        writer = self.get_hdf5_manager()
+
+        # Create HDF5 cache file for all isotopes
+        Nlines = 0
+        for iso, data_file in zip(isotope_list, data_file_list):
+            df = hit2df(
+                join(self.tempdir, data_file),
+                cache=False,  # do not generate cache yet
+            )
+            # if engine == 'vaex':
+            #     df.executor.async_method = "awaitio"   # Temp fix for https://github.com/spyder-ide/spyder/issues/16183
+
+            wmin_final = min(wmin_final, df.wav.min())
+            wmax_final = max(wmax_final, df.wav.max())
+            Nlines += len(df)
+
+            writer.write(
+                local_file, df, append=True
+            )  # create temporary files if required
+
+        # Open all HDF5 cache files and export in a single file with Vaex
+        writer.combine_temp_batch_files(local_file)  # used for vaex mode only
+
+        self.wmin = wmin_final
+        self.wmax = wmax_final
+
+        # Add metadata
+        from radis import __version__
+
+        writer.add_metadata(
+            local_file,
+            {
+                "wavenumber_min": self.wmin,
+                "wavenumber_max": self.wmax,
+                "download_date": self.get_today(),
+                "download_url": "downloaded by HAPI, parsed & store with RADIS",
+                "total_lines": Nlines,
+                "version": __version__,
+            },
+        )
+
+        # # clean downloaded files  TODO
+        # for file in data_file_list + header_file_list:
+        #     os.remove(join(self.local_databases, "downloads", file))
+
+    def register(self):
+        """register in ~/radis.json"""
+
+        from radis.db import MOLECULES_LIST_NONEQUILIBRIUM
+
+        local_files = self.get_filenames()
+        info = f"HITRAN {self.molecule} lines ({self.wmin:.1f}-{self.wmax:.1f} cm-1) with TIPS-2021 (through HAPI) for partition functions"
+
+        dict_entries = {
+            "info": info,
+            "path": local_files,
+            "format": "hdf5-radisdb",
+            "parfuncfmt": "hapi",
+            "wavenumber_min": self.wmin,
+            "wavenumber_max": self.wmax,
+            "download_date": self.get_today(),
+        }
+
+        # Add energy level calculation
+        if self.molecule in MOLECULES_LIST_NONEQUILIBRIUM:
+            dict_entries[
+                "info"
+            ] += " and RADIS spectroscopic constants for rovibrational energies (nonequilibrium)"
+            dict_entries["levelsfmt"] = "radis"
+
+        super().register(dict_entries)
+
+
+from os.path import abspath, expanduser
+
+
+# TODO: implement parallel=True for all isotopes ?
+def fetch_hitran(
+    molecule,
+    local_databases="~/.radisdb/hitran/",
+    databank_name="HITRAN-{molecule}",
+    isotope=None,
+    load_wavenum_min=None,
+    load_wavenum_max=None,
+    columns=None,
+    cache=True,
+    verbose=True,
+    clean_cache_files=True,
+    return_local_path=False,
+    engine="default",
+    parallel=True,
+):
+    """Download all HITRAN lines from HITRAN website. Unzip and build a HDF5 file directly.
+
+    Returns a Pandas DataFrame containing all lines.
+
+    Parameters
+    ----------
+    molecule: `"H2O", "CO2", "N2O", "CO", "CH4", "NO", "NO2", "OH"`
+        HITEMP molecule. See https://hitran.org/hitemp/
+    local_databases: str
+        where to create the RADIS HDF5 files. Default ``"~/.radisdb/"``
+    databank_name: str
+        name of the databank in RADIS :ref:`Configuration file <label_lbl_config_file>`
+        Default ``"HITRAN-{molecule}"``
+    isotope: str
+        load only certain isotopes : ``'2'``, ``'1,2'``, etc. If ``None``, loads
+        everything. Default ``None``.
+    load_wavenum_min, load_wavenum_max: float (cm-1)
+        load only specific wavenumbers.
+    columns: list of str
+        list of columns to load. If ``None``, returns all columns in the file.
+
+    Other Parameters
+    ----------------
+    cache: ``True``, ``False``, ``'regen'`` or ``'force'``
+        if ``True``, use existing HDF5 file. If ``False`` or ``'regen'``, rebuild it.
+        If ``'force'``, raise an error if cache file cannot be used (useful for debugging).
+        Default ``True``.
+    verbose: bool
+    clean_cache_files: bool
+        if ``True`` clean downloaded cache files after HDF5 are created.
+    return_local_path: bool
+        if ``True``, also returns the path of the local database file.
+    engine: 'pytables', 'vaex', 'default'
+        which HDF5 library to use. If 'default' use the value from ~/radis.json
+    parallel: bool
+        if ``True``, uses joblib.parallel to load database with multiple processes
+
+    Returns
+    -------
+    df: pd.DataFrame
+        Line list
+        A HDF5 file is also created in ``local_databases`` and referenced
+        in the :ref:`RADIS config file <label_lbl_config_file>` with name
+        ``databank_name``
+    local_path: str
+        path of local database file if ``return_local_path``
+
+    Examples
+    --------
+    ::
+
+        from radis import fetch_hitemp
+        df = fetch_hitemp("CO")
+        print(df.columns)
+        >>> Index(['id', 'iso', 'wav', 'int', 'A', 'airbrd', 'selbrd', 'El', 'Tdpair',
+            'Pshft', 'ierr', 'iref', 'lmix', 'gp', 'gpp', 'Fu', 'branch', 'jl',
+            'syml', 'Fl', 'vu', 'vl'],
+            dtype='object')
+
+    .. minigallery:: radis.io.hitemp.fetch_hitemp
+
+    Notes
+    -----
+    if using ``load_only_wavenum_above/below`` or ``isotope``, the whole
+    database is anyway downloaded and uncompressed to ``local_databases``
+    fast access .HDF5 files (which will take a long time on first call). Only
+    the expected wavenumber range & isotopes are returned. The .HFD5 parsing uses
+    :py:func:`~radis.io.hdf5.hdf2df`
+
+    See Also
+    --------
+    :py:func:`~radis.io.hdf5.hdf2df`, :py:meth:`~radis.lbl.loader.DatabankLoader.fetch_databank`
+
+    """
+
+    if r"{molecule}" in databank_name:
+        databank_name = databank_name.format(**{"molecule": molecule})
+
+    local_databases = abspath(local_databases.replace("~", expanduser("~")))
+
+    ldb = HITRANDatabaseManager(
+        databank_name,
+        molecule=molecule,
+        local_databases=local_databases,
+        engine=engine,
+        verbose=verbose,
+        parallel=parallel,
+    )
+
+    # Get expected local files for this database:
+    local_file = ldb.get_filenames()
+
+    # Delete files if needed:
+    if cache == "regen":
+        ldb.remove_local_files(local_file)
+    ldb.check_deprecated_files(
+        ldb.get_existing_files(local_file),
+        auto_remove=True if cache != "force" else False,
+    )
+
+    # Download files
+    download_files = ldb.get_missing_files(local_file)
+    if download_files:
+        ldb.download_and_parse(download_files, cache=cache)
+
+    # Register
+    if not ldb.is_registered():
+        ldb.register()
+
+    if len(download_files) > 0 and clean_cache_files:
+        ldb.clean_download_files()
+
+    # Load and return
+    df = ldb.load(
+        local_file,
+        columns=columns,
+        isotope=isotope,
+        load_wavenum_min=load_wavenum_min,  # for relevant files, get only the right range
+        load_wavenum_max=load_wavenum_max,
+    )
+
+    return (df, local_file) if return_local_path else df
+
+
 # ======================================================
 # %% Test
 
 
 if __name__ == "__main__":
 
-    from radis.test.test_io import _run_testcases
+    from radis.test.io.test_hitran_cdsd import _run_testcases
 
     print("Testing HITRAN parsing: ", _run_testcases())
diff --git a/radis/io/query.py b/radis/io/query.py
index 08d065513..c9e90d804 100644
--- a/radis/io/query.py
+++ b/radis/io/query.py
@@ -24,9 +24,12 @@
 import numpy as np
 import pandas as pd
 
-import radis
 from radis.db.classes import get_molecule, get_molecule_identifier
-from radis.io.cache_files import check_cache_file, get_cache_file, save_to_hdf
+
+try:
+    from .cache_files import check_cache_file, get_cache_file, save_to_hdf
+except ImportError:
+    from radis.io.cache_files import check_cache_file, get_cache_file, save_to_hdf
 from radis.misc import is_float
 from radis.misc.printer import printr
 
@@ -34,7 +37,14 @@
 
 
 def fetch_astroquery(
-    molecule, isotope, wmin, wmax, verbose=True, cache=True, expected_metadata={}
+    molecule,
+    isotope,
+    wmin,
+    wmax,
+    verbose=True,
+    cache=True,
+    expected_metadata={},
+    engine="pytables-fixed",
 ):
     """Download a HITRAN line database to a Pandas DataFrame.
 
@@ -114,12 +124,13 @@ def fetch_astroquery(
                 fcache=fcache,
                 use_cached=cache,
                 expected_metadata=expected_metadata,
+                compare_as_close=["wmin", "wmax"],
                 verbose=verbose,
-                engine="pytables",
+                engine=engine,
             )
             if exists(fcache):
                 try:
-                    return get_cache_file(fcache, verbose=verbose)
+                    return get_cache_file(fcache, verbose=verbose, engine=engine)
                 except Exception as err:
                     if verbose:
                         printr(
@@ -244,15 +255,18 @@ def fetch_astroquery(
                     fcache, new_metadata
                 )
             )
+        from radis import __version__
+
         try:
             save_to_hdf(
                 df,
                 fcache,
                 metadata=new_metadata,
-                version=radis.__version__,
+                version=__version__,
                 key="df",
                 overwrite=True,
                 verbose=verbose,
+                engine=engine,
             )
         except PermissionError:
             if verbose:
diff --git a/radis/io/spec_hdf.py b/radis/io/spec_hdf.py
new file mode 100644
index 000000000..6576557ea
--- /dev/null
+++ b/radis/io/spec_hdf.py
@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+"""
+Functions to read/write Spectrum objects under HDF5 format
+"""
+
+try:
+    from .hdf5 import HDF5Manager
+except ImportError:
+    from radis.io.hdf5 import HDF5Manager
+
+
+# Convert
+
+
+def spec2hdf(s, file, engine="pytables"):
+    """ Convert Spectrum s to HDF5 file"""
+
+    df_arrays = s.to_pandas(copy=False)
+
+    # Store
+    mgr = HDF5Manager(engine=engine)
+
+    # Store spectral arrays and lines as Datasets
+
+    mgr.write(file, df_arrays, key="arrays", append=False, data_columns=["wavespace"])
+    mgr.add_metadata(file, s.units, key="arrays")
+
+    if s.lines:
+        mgr.write(file, s.lines, key="lines", append=True)
+
+    # Store the rest as metadata
+
+    if s.populations:
+        mgr.add_metadata(
+            file, s.populations, key="populations", create_empty_dataset=True
+        )
+
+    mgr.add_metadata(file, s.conditions, key="conditions", create_empty_dataset=True)
+
+    if s.references:
+        mgr.add_metadata(
+            file, s.references, key="references", create_empty_dataset=True
+        )
+
+
+def hdf2spec(file, wmin=None, wmax=None, wunit=None, columns=None, engine="pytables"):
+    """ Read HDF5 file into a Spectrum object"""
+    from radis import Spectrum
+
+    mgr = HDF5Manager(engine=engine)
+
+    # Range selection
+    # TODO : convert if stored in wavelength/wavenumber
+    if wmin is not None or wmax is not None:
+        if engine == "pytables":
+            where = []
+            if wmin is not None:
+                where.append(f"wavespace > {wmin}")
+            if wmax is not None:
+                where.append(f"wavespace < {wmax}")
+        elif engine == "vaex":
+            #  Selection is done after opening the file in vaex
+            where = None
+        else:
+            raise NotImplementedError(engine)
+    else:
+        where = None
+
+    # Column selection
+    # ... always load 'wavespace'
+    if columns and "wavespace" not in columns:
+        columns.append("wavespace")
+
+    df2 = mgr.load(file, columns=columns, where=where, key="arrays")
+
+    # Convert to Pandas df (if vaex)
+    if engine == "vaex":
+        if wmin is not None and wmax is not None:
+            df2.select((df2.wavespace > wmin) & (df2.wavespace < wmax))
+            selection = True
+        elif wmin is not None:
+            df2.select(df2.wavespace > wmin)
+            selection = True
+        elif wmax is not None:
+            df2.select(df2.wavespace < wmax)
+            selection = True
+        else:
+            selection = False
+        df2 = df2.to_pandas_df(selection=selection, column_names=columns)
+
+    units2 = mgr.read_metadata(file, key="arrays")
+    conditions2 = mgr.read_metadata(file, key="conditions")
+    try:
+        populations2 = mgr.read_metadata(file, key="populations")
+    except KeyError:
+        populations2 = None
+    try:
+        references2 = mgr.read_metadata(file, key="references")
+    except KeyError:
+        references2 = None
+
+    try:
+        lines2 = mgr.load(file, key="lines")
+    except (KeyError, OSError):  # KeyError in PyTables ; OSError in Vaex
+        lines2 = None
+
+    s2 = Spectrum(
+        {k: v.values for k, v in dict(df2).items()},
+        lines=lines2,
+        units=units2,
+        conditions=conditions2,
+        references=references2,
+        populations=populations2,
+    )
+
+    return s2
diff --git a/radis/lbl/base.py b/radis/lbl/base.py
index ff25a5267..b7ac44071 100644
--- a/radis/lbl/base.py
+++ b/radis/lbl/base.py
@@ -79,8 +79,9 @@
     from .loader import KNOWN_LVLFORMAT, DatabankLoader, df_metadata
 except ImportError:  # if ran from here
     from radis.lbl.loader import KNOWN_LVLFORMAT, DatabankLoader, df_metadata
+
 from radis.misc.arrays import anynan
-from radis.misc.basics import all_in, transfer_metadata
+from radis.misc.basics import all_in, is_float, transfer_metadata
 from radis.misc.debug import printdbg
 from radis.misc.log import printwarn
 from radis.misc.plot import fix_style, set_style
@@ -239,23 +240,27 @@ def get_energy_levels(self, molecule, isotope, state, conditions=None):
 
         return energies
 
-    def plot_linestrength_hist(self):
+    def plot_linestrength_hist(self, cutoff=None):
         """Plot linestrength distribution (to help determine a cutoff
         criteria)"""
-        return self.plot_hist("df1", "S")
+        return self.plot_hist("df1", "S", axvline=np.log10(cutoff))
+
+    def plot_hist(self, dataframe="df0", what="int", axvline=None):
+        """Plot distribution of column ``what`` in ``dataframe``
 
-    def plot_hist(self, dataframe="df0", what="int"):
-        """Plot distribution (to help determine a cutoff criteria)
+        For instance, help determine a cutoff criteria ::
+
+            plot_hist("df1", "int")
 
         Parameters
         ----------
-
         dataframe: 'df0', 'df1'
             which dataframe to plot (df0 is the loaded one, df1 the scaled one)
-
         what: str
             which feature to plot. Default ``'S'`` (scaled linestrength). Could also
             be ``'int'`` (reference linestrength intensity), ``'A'`` (Einstein coefficient)
+        axvline: float
+            if not ``None``, plot a vertical line at this position.
         """
         import matplotlib.pyplot as plt
 
@@ -266,6 +271,8 @@ def plot_hist(self, dataframe="df0", what="int"):
         if np.isnan(a).any():
             printwarn("Nan values in log10(lines)")
         plt.hist(np.round(a[~np.isnan(a)]))
+        if axvline is not None:
+            plt.axvline(axvline, color="r")
         plt.xlabel("log10({0})".format(what))
         plt.ylabel("Count")
         plt.show()
@@ -1371,6 +1378,12 @@ def _add_ju(self, df):
         P, Q, R are replaced by -1, 0, 1 in the DataFrame to ensure that all
         terms are numeric (improves performances)
         """
+        if "branch" not in df:
+            raise KeyError(
+                f"`branch` not defined in database columns: ({list(df.columns)}). "
+                + "You can add it with `load_columns=['branch', ...]` or simply `load_columns='noneq'` / `load_columns='all'` in fetch_databank / load_databank()"
+            )
+
         if df.dtypes["branch"] != np.int64:
             raise DeprecationWarning(
                 "For performance purpose, numeric (-1, 0, 1) "
@@ -1436,8 +1449,9 @@ def _calc_noneq_parameters(self, vib_distribution, singleTvibmode):
                 if "globu" in df:
                     error_details = ". However, it looks like `globu` is defined. Maybe HITRAN-like database wasn't fully parsed? See radis.io.hitran.hit2df"
                 raise KeyError(
-                    f"`{k}` not defined in database ({list(df.columns)})"
+                    f"`{k}` not defined in database ({list(df.columns)}). "
                     + error_details
+                    + f"Make sure you properly load parameters required for non-LTE calculations by adding `load_columns=['{k}', ...]` or simply `load_columns='noneq'` in fetch_databank / load_databank()"
                 )
 
         # Make sure database has pre-computed non equilibrium quantities
@@ -1645,15 +1659,22 @@ def get_lines_abundance(self, df):
             return molpar.get(df.attrs["id"], iso, "abundance")
 
     def get_molar_mass(self, df):
-        """Returns molar mass.
+        """Returns molar mass for all lines of DataFrame ``df``.
 
         Parameters
         ----------
-        df: dataframe
+        df: pd.DataFrame
 
         Returns
         -------
         The molar mass of all the isotopes in the dataframe
+
+
+        Notes
+        -----
+
+        If molar mass is unknown, it can be temporarily added in the
+        :py:attr:`radis.lbl.loader.DatabankLoader._EXTRA_MOLAR_MASS` dictionary
         """
         molpar = self.molparam
 
@@ -1665,16 +1686,19 @@ def get_molar_mass(self, df):
             # HARDCODED molar mass; for WIP ExoMol implementation, until MolParams
             # is an attribute and can be updated with definitions from ExoMol.
             # https://github.com/radis/radis/issues/321
-            HARCODED_MOLAR_MASS = {
-                "SiO": {1: 43.971842},
-                "CN": {
-                    1: 26.0179
-                },  # https://www.chemicalaid.com/tools/molarmass.php?formula=CN%7B-%7D
-            }
+
+            # see :py:attr:`radis.lbl.loader.DatabankLoader._EXTRA_MOLAR_MASS`
             try:
-                return HARCODED_MOLAR_MASS[df.attrs["molecule"]][df.attrs["iso"]]
+                return self._EXTRA_MOLAR_MASS[df.attrs["molecule"]][
+                    str(df.attrs["iso"])
+                ]
             except KeyError:
-                raise NotImplementedError
+                raise NotImplementedError(
+                    "Molar mass of {0} (isotope {1}) is unknown.".format(
+                        df.attrs["molecule"], df.attrs["iso"]
+                    )
+                    + " You can manually add it in your radis.json file {'molparams':{'molar_mass':{'molecule':{'ISOTOPE':...}}}}; or in the SpectrumFactory._EXTRA_MOLAR_MASS[molecule][isotope] = M (g/mol) dictionary. Please also report on GitHub so we can update !"
+                )
 
         if "iso" in df.columns:
             iso_set = df.iso.unique()
@@ -1713,70 +1737,6 @@ def calc_weighted_trans_moment(self):
 
         self.profiler.start("calc_weight_trans", 2)
 
-        if "id" in df:
-            id_set = df.id.unique()
-            if len(id_set) > 1:
-                raise NotImplementedError("> 1 molecules in same DataFrame")
-            else:
-                self.warn(
-                    "There shouldn't be a Column 'id' with a unique value",
-                    "PerformanceWarning",
-                )
-            df.attrs["id"] = int(id_set)
-            if self.save_memory:
-                del df["id"]
-        molecule = get_molecule(df.attrs["id"])
-
-        if not "iso" in df:
-
-            # Shortcut if only 1 isotope. We attribute molar_mass & abundance
-            # as attributes of the line database, instead of columns. Much
-            # faster!
-
-            state = self.input.state
-            parsum = self.get_partition_function_calculator(
-                molecule, df.attrs["iso"], state
-            )  # partition function
-            df.attrs["Qref"] = parsum.at(
-                Tref, update_populations=False
-            )  # stored as attribute, not column
-            assert "Qref" not in df.columns
-
-            Qref = df.attrs["Qref"]
-
-        else:
-            iso_set = df.iso.unique()
-            if len(iso_set) == 1:
-                self.warn(
-                    "There shouldn't be a Column 'iso' with a unique value",
-                    "PerformanceWarning",
-                )
-
-            # normal method
-            # still much faster than the groupby().apply() method (see radis<=0.9.19)
-            # (tested + see https://stackoverflow.com/questions/44954514/efficient-way-to-conditionally-populate-elements-in-a-pandas-groupby-object-pos)
-
-            Qref_dict = {}
-
-            dgb = df.groupby(by=["iso"])
-            for (iso), idx in dgb.indices.items():
-                state = self.input.state
-                parsum = self.get_partition_function_calculator(
-                    molecule, iso, state
-                )  # partition function
-                Qref_dict[iso] = parsum.at(Tref, update_populations=False)
-                # ... note: do not update the populations here, so populations in the
-                # ... energy level list correspond to the one calculated for T and not Tref
-
-                if radis.config["DEBUG_MODE"]:
-                    if "id" in df:
-                        assert (df.loc[idx, "id"] == id).all()
-                    assert (df.loc[idx, "iso"] == iso).all()
-
-            Qref = df["iso"].map(Qref_dict)
-
-        # Get moment
-
         # get abundance
         abundance = self.get_lines_abundance(df)
         if not self.molparam.terrestrial_abundances:
@@ -1797,7 +1757,7 @@ def calc_weighted_trans_moment(self):
         weighted_trans_moment_sq = (
             (3 * h * c / 8 / pi ** 3)
             / nu
-            / (Ia * gl / Qref * exp(-hc_k * El / Tref))
+            / (Ia * gl / self.Qgas(df, Tref) * exp(-hc_k * El / Tref))
             / (1 - exp(-hc_k * nu / Tref))
             * 1e36
         ) * S
@@ -1814,7 +1774,8 @@ def calc_reference_linestrength(self):
         df = self.df0
         Tref = self.input.Tref
 
-        self.profiler.start("calc_ref_linestrength", 2)
+        if self.profiler:
+            self.profiler.start("calc_ref_linestrength", 2)
 
         if "id" in df:
             id_set = df.id.unique()
@@ -1879,7 +1840,10 @@ def calc_reference_linestrength(self):
             A=df.A, gu=df.gu, El=df.El, Ia=df.Ia, nu=df.wav, Q=Qref, T=Tref
         )
 
-        self.profiler.stop("calc_ref_linestrength", "Calculated reference linestrength")
+        if self.profiler:
+            self.profiler.stop(
+                "calc_ref_linestrength", "Calculated reference linestrength"
+            )
 
         return S0
 
@@ -1992,6 +1956,152 @@ def calc_lineshift(self):
 
         return
 
+    def _calc_Q(self, molecule, iso, state, T):
+        """Get partition function at temperature ``T`` from tabulated values, try with
+        calculated partition function (full summation) if Out of Bounds.
+
+        Returns
+        -------
+        Q: float
+            partition functions at temperature ``T``
+        """
+
+        try:
+            parsum = self.get_partition_function_interpolator(molecule, iso, state)
+            Q = parsum.at(T)
+        except OutOfBoundError as err:
+            # Try to calculate
+            try:
+                parsum = self.get_partition_function_calculator(molecule, iso, state)
+            except KeyError:  # partition function not defined, raise the initial error
+                raise err
+            else:
+                self.warn(
+                    "Error with tabulated partition function"
+                    + "({0}). Using calculated one instead".format(err.args[0]),
+                    "OutOfBoundWarning",
+                )
+            Q = parsum.at(T)
+        return Q
+
+    # Partition functions
+    def Qgas(self, df1, Tgas):
+        """Calculate partition function Qgas at temperature ``Tgas``, for all lines
+        of ``df1``. Returns a single value if all lines have the same Qgas value,
+        or a column if they are different
+
+        Parameters
+        ----------
+        Tgas: float (K)
+            gas temperature
+
+        Returns
+        -------
+        float or dict: Returns Qgas as a dictionary with isotope values as its keys
+
+        See Also
+        --------
+        :py:meth:`~radis.lbl.base.BaseFactory.Qgas_Qref_ratio`
+        """
+
+        if "id" in df1.columns:
+            id_set = df1.id.unique()
+            if len(id_set) > 1:
+                raise NotImplementedError(">1 molecule.")
+            else:
+                self.warn(
+                    "There shouldn't be a Column 'id' with a unique value",
+                    "PerformanceWarning",
+                )
+                df1.attrs["id"] = int(id_set)
+
+        if "molecule" in df1.attrs:
+            molecule = df1.attrs["molecule"]  # used for ExoMol, which has no HITRAN-id
+        else:
+            molecule = get_molecule(df1.attrs["id"])
+        state = self.input.state
+
+        if "iso" in df1:
+            iso_set = df1.iso.unique()
+            if len(iso_set) == 1:
+                self.warn(
+                    "There shouldn't be a Column 'iso' with a unique value",
+                    "PerformanceWarning",
+                )
+                iso = int(iso_set)
+                Q = self._calc_Q(molecule, iso, state, Tgas)
+                df1.attrs["Q"] = Q
+                return Q
+            else:
+                Qgas_dict = {}
+                for iso in iso_set:
+                    Qgas_dict[iso] = self._calc_Q(molecule, iso, state, Tgas)
+                return df1["iso"].map(Qgas_dict)
+
+        else:  # "iso" not in df:
+            iso = df1.attrs["iso"]
+            Q = self._calc_Q(molecule, iso, state, Tgas)
+            df1.attrs["Q"] = Q
+            return Q
+
+    def Qref_Qgas_ratio(self, df1, Tgas, Tref):
+        """Calculate Qref/Qgas at temperature ``Tgas``, ``Tref``, for all lines
+        of ``df1``. Returns a single value if all lines have the same Qref/Qgas ratio,
+        or a column if they are different
+
+        See Also
+        --------
+        :py:meth:`~radis.lbl.base.BaseFactory.Qgas`
+        """
+
+        if "id" in df1.columns:
+            id_set = df1.id.unique()
+            if len(id_set) > 1:
+                raise NotImplementedError(">1 molecule.")
+            else:
+                self.warn(
+                    "There shouldn't be a Column 'id' with a unique value",
+                    "PerformanceWarning",
+                )
+                df1.attrs["id"] = int(id_set)
+
+        if "molecule" in df1.attrs:
+            molecule = df1.attrs["molecule"]  # used for ExoMol, which has no HITRAN-id
+        else:
+            molecule = get_molecule(df1.attrs["id"])
+        state = self.input.state
+
+        if "iso" in df1:
+            iso_set = df1.iso.unique()
+            if len(iso_set) == 1:
+                self.warn(
+                    "There shouldn't be a Column 'iso' with a unique value",
+                    "PerformanceWarning",
+                )
+                iso = int(iso_set)
+                Qgas = self._calc_Q(molecule, iso, state, Tgas)
+                Qref = self._calc_Q(molecule, iso, state, Tref)
+                df1.attrs["Qgas"] = Qgas
+                df1.attrs["Qref"] = Qref
+                Qref_Qgas = Qref / Qgas
+
+            else:
+                Qref_Qgas_ratio = {}
+                for iso in iso_set:
+                    Qgas = self._calc_Q(molecule, iso, state, Tgas)
+                    Qref = self._calc_Q(molecule, iso, state, Tref)
+                    Qref_Qgas_ratio[iso] = Qref / Qgas
+                Qref_Qgas = df1["iso"].map(Qref_Qgas_ratio)
+
+        else:
+            iso = df1.attrs["iso"]
+            Qgas = self._calc_Q(molecule, iso, state, Tgas)
+            Qref = self._calc_Q(molecule, iso, state, Tref)
+            df1.attrs["Qgas"] = Qgas
+            df1.attrs["Qref"] = Qref
+            Qref_Qgas = Qref / Qgas
+        return Qref_Qgas
+
     def calc_linestrength_eq(self, Tgas):
         """Calculate linestrength at temperature Tgas correcting the database
         linestrength tabulated at temperature :math:`T_{ref}`.
@@ -2041,129 +2151,6 @@ def calc_linestrength_eq(self, Tgas):
             "scaled_eq_linestrength", 2, "... Scaling equilibrium linestrength"
         )
 
-        # %% Load partition function values
-
-        def _calc_Q(molecule, iso, state):
-            """Get partition function from tabulated values, try with
-            calculated one if Out of Bounds.
-
-            Returns
-            -------
-            Qref, Qgas: float
-                partition functions at reference temperature and gas temperature
-            """
-
-            try:
-                parsum = self.get_partition_function_interpolator(molecule, iso, state)
-                Qref = parsum.at(Tref)
-                Qgas = parsum.at(Tgas)
-            except OutOfBoundError as err:
-                # Try to calculate
-                try:
-                    parsum = self.get_partition_function_calculator(
-                        molecule, iso, state
-                    )
-                    Qref = parsum.at(Tref)
-                    Qgas = parsum.at(Tgas)
-                except:  # if an error occur, raise the initial error
-                    raise err
-                else:
-                    self.warn(
-                        "Error with tabulated partition function"
-                        + "({0}). Using calculated one instead".format(err.args[0]),
-                        "OutOfBoundWarning",
-                    )
-            df1.attrs["Qgas"] = Qgas
-            return Qref, Qgas
-
-        if "id" in df1.columns:
-            id_set = df1.id.unique()
-            if len(id_set) > 1:
-                raise NotImplementedError(">1 molecule.")
-            else:
-                self.warn(
-                    "There shouldn't be a Column 'id' with a unique value",
-                    "PerformanceWarning",
-                )
-                df1.attrs["id"] = int(id_set)
-
-        if "molecule" in df1.attrs:
-            molecule = df1.attrs["molecule"]  # used for ExoMol, which has no HITRAN-id
-        else:
-            molecule = get_molecule(df1.attrs["id"])
-        state = self.input.state
-
-        # Partition functions
-        def Qgas(Tgas):
-            """Aggregate the values of Qgas
-
-            Parameters
-            ----------
-            Tgas: float (K)
-                gas temperature
-
-            Returns
-            -------
-            float or dict: Returns Qgas as a dictionary with isotope values as its keys
-
-            """
-            if "iso" in df1:
-                iso_set = df1.iso.unique()
-                if len(iso_set) == 1:
-                    self.warn(
-                        "There shouldn't be a Column 'iso' with a unique value",
-                        "PerformanceWarning",
-                    )
-                    iso = int(iso_set)
-                    parsum = self.get_partition_function_interpolator(
-                        molecule, iso, state
-                    )
-                    # TODO : use _calc_Q instead ? (which switches to partition function calculator if possible?)
-                    Q = parsum.at(Tgas)
-                    df1.attrs["Q"] = Q
-                    return Q
-                else:
-                    Qgas_dict = {}
-                    for iso in iso_set:
-                        parsum = self.get_partition_function_interpolator(
-                            molecule, iso, state
-                        )
-                        Qgas_dict[iso] = parsum.at(Tgas)
-                    return df1["iso"].map(Qgas_dict)
-
-            else:  # "iso" not in df:
-                iso = df1.attrs["iso"]
-                parsum = self.get_partition_function_interpolator(molecule, iso, state)
-                Q = parsum.at(Tgas)
-                df1.attrs["Q"] = Q
-                return Q
-
-        def Qref_Qgas_ratio():
-
-            if "iso" in df1:
-                iso_set = df1.iso.unique()
-                if len(iso_set) == 1:
-                    self.warn(
-                        "There shouldn't be a Column 'iso' with a unique value",
-                        "PerformanceWarning",
-                    )
-                    iso = int(iso_set)
-                    Qref, Qgas = _calc_Q(molecule, iso, state)
-                    Qref_Qgas = Qref / Qgas
-
-                else:
-                    Qref_Qgas_ratio = {}
-                    for iso in iso_set:
-                        Qref, Qgas = _calc_Q(molecule, iso, state)
-                        Qref_Qgas_ratio[iso] = Qref / Qgas
-                    Qref_Qgas = df1["iso"].map(Qref_Qgas_ratio)
-
-            else:
-                iso = df1.attrs["iso"]
-                Qref, Qgas = _calc_Q(molecule, iso, state)
-                Qref_Qgas = Qref / Qgas
-            return Qref_Qgas
-
         # %% Calculate line strength at desired temperature
         # -------------------------------------------------
 
@@ -2175,7 +2162,7 @@ def Qref_Qgas_ratio():
             # correct for Partition Function
             df1["S"] = (
                 df1.int
-                * Qref_Qgas_ratio()
+                * self.Qref_Qgas_ratio(df1, Tgas, Tref)
                 *
                 # ratio of Boltzman populations
                 exp(-hc_k * df1.El * (1 / Tgas - 1 / Tref))
@@ -2196,7 +2183,7 @@ def Qref_Qgas_ratio():
 
             Ia = self.get_lines_abundance(df1)
             df1["S"] = linestrength_from_Einstein(
-                df1.A, df1.gu, df1.El, Ia, df1.wav, Qgas(Tgas), Tgas
+                df1.A, df1.gu, df1.El, Ia, df1.wav, self.Qgas(df1, Tgas), Tgas
             )
 
         assert "S" in self.df1
@@ -2245,64 +2232,6 @@ def calc_populations_eq(self, Tgas):
 
         self.profiler.start("calc_eq_population", 2)
 
-        # Partition functions
-        def Qgas(Tgas):
-            """Aggregate the values of Qgas
-
-            Parameters
-            ----------
-            Tgas: float (K)
-                gas temperature
-
-            Returns
-            -------
-            float or dict: Returns Qgas as a dictionary with isotope values as its keys
-
-            """
-            if "id" in df1:
-                id_set = df1.id.unique()
-                if len(id_set) > 1:
-                    raise NotImplementedError("> 1 molecules in same DataFrame")
-                else:
-                    self.warn(
-                        "There shouldn't be a Column 'id' with a unique value",
-                        "PerformanceWarning",
-                    )
-                    df1.attrs["id"] = int(id_set)
-
-            molecule = get_molecule(df1.attrs["id"])
-            state = self.input.state
-
-            if "iso" in df1:
-                iso_set = df1.iso.unique()
-                if len(iso_set) == 1:
-                    self.warn(
-                        "There shouldn't be a Column 'iso' with a unique value",
-                        "PerformanceWarning",
-                    )
-                    iso = int(iso_set)
-                    parsum = self.get_partition_function_interpolator(
-                        molecule, iso, state
-                    )
-                    # TODO : use _calc_Q instead ? (which switches to partition function calculator if possible?)
-                    Q = parsum.at(Tgas)
-                    df1.attrs["Q"] = Q
-                else:
-                    Qgas_dict = {}
-                    for iso in iso_set:
-                        parsum = self.get_partition_function_interpolator(
-                            molecule, iso, state
-                        )
-                        Qgas_dict[iso] = parsum.at(Tgas)
-                    Q = df1["iso"].map(Qgas_dict)
-
-            else:  # "iso" not in df:
-                iso = df1.attrs["iso"]
-                parsum = self.get_partition_function_interpolator(molecule, iso, state)
-                Q = parsum.at(Tgas)
-                df1.attrs["Q"] = Q
-            return Q
-
         # Calculate degeneracies
         # ----------------------------------------------------------------------
 
@@ -2319,7 +2248,9 @@ def Qgas(Tgas):
         # Calculate population
         # ----------------------------------------------------------------------
         # equilibrium: Boltzmann in any case
-        df1["nu"] = df1.gu.values * exp(-hc_k * df1.Eu.values / Tgas) / Qgas(Tgas)
+        df1["nu"] = (
+            df1.gu.values * exp(-hc_k * df1.Eu.values / Tgas) / self.Qgas(df1, Tgas)
+        )
 
         assert "nu" in self.df1
 
@@ -2327,28 +2258,227 @@ def Qgas(Tgas):
 
         return
 
-    # %%
-    def calc_populations_noneq(
-        self,
-        Tvib,
-        Trot,
-        vib_distribution="boltzmann",
-        rot_distribution="boltzmann",
-        overpopulation=None,
-    ):
-        """Calculate upper and lower state population for all active
-        transitions, as well as all levels (through
-        :meth:`~radis.levels.partfunc.RovibPartitionFunction.at_noneq`)
+    def Qneq(self, df, Tvib, Trot, vib_distribution, rot_distribution, overpopulation):
+        """Nonequilibrium partition function
 
-        Parameters
-        ----------
-        Tvib, Trot: float (K)
-            temperatures
-        vib_distribution: ``'boltzmann'``, ``'treanor'``
-            vibrational level distribution
-        rot_distribution: ``'boltzmann'``
-            rotational level distribution
-        overpopulation: dict, or ``None``
+        Returns
+        -------
+        column or float: depending if there are many isotopes or one"""
+        self.profiler.start("part_function", 3)
+
+        if "id" in df:
+            id_set = df.id.unique()
+            if len(id_set) > 1:
+                raise NotImplementedError("> 1 molecules in same DataFrame")
+            else:
+                self.warn(
+                    "There shouldn't be a Column 'id' with a unique value",
+                    "PerformanceWarning",
+                )
+                df.attrs["id"] = int(id_set)
+        molecule = get_molecule(df.attrs["id"])
+        state = self.input.state
+
+        if "iso" in df:
+            Q_dict = {}
+            iso_set = df.iso.unique()
+            if len(iso_set) == 1:
+                self.warn(
+                    "There shouldn't be a Column 'iso' with a unique value",
+                    "PerformanceWarning",
+                )
+            for iso in iso_set:
+                parsum = self.get_partition_function_calculator(molecule, iso, state)
+                if is_float(Tvib):
+                    Q_dict[iso] = parsum.at_noneq(
+                        Tvib,
+                        Trot,
+                        vib_distribution=vib_distribution,
+                        rot_distribution=rot_distribution,
+                        overpopulation=overpopulation,
+                        update_populations=self.misc.export_populations,
+                    )
+                else:
+                    Q_dict[iso] = parsum.at_noneq_3Tvib(
+                        Tvib,
+                        Trot,
+                        vib_distribution=vib_distribution,
+                        rot_distribution=rot_distribution,
+                        overpopulation=overpopulation,
+                        update_populations=self.misc.export_populations,
+                    )
+            Q = df["iso"].map(Q_dict)
+
+        else:  # "iso" not in df:
+            iso = df.attrs["iso"]
+            parsum = self.get_partition_function_calculator(molecule, iso, state)
+            if is_float(Tvib):
+                Q = parsum.at_noneq(
+                    Tvib,
+                    Trot,
+                    vib_distribution=vib_distribution,
+                    rot_distribution=rot_distribution,
+                    overpopulation=overpopulation,
+                    update_populations=self.misc.export_populations,
+                )
+            else:
+                Q = parsum.at_noneq_3Tvib(
+                    Tvib,
+                    Trot,
+                    vib_distribution=vib_distribution,
+                    rot_distribution=rot_distribution,
+                    overpopulation=overpopulation,
+                    update_populations=self.misc.export_populations,
+                )
+            df.attrs["Q"] = Q
+        self.profiler.stop("part_function", "partition functions")
+        return Q
+
+    def Qneq_Qvib_Qrotu_Qrotl(
+        self, df, Tvib, Trot, vib_distribution, rot_distribution, overpopulation
+    ):
+        """Nonequilibrium partition function; with the detail of
+        vibrational partition function and rotational partition functions"""
+        # TODO @ dev : implement the map(dict) approach to fill Q Qvib Qrotu Qrotl
+        # note : Qrot already use a map(dict) so we need a map with 2 keys. Is it worth it?
+        self.profiler.start("part_function", 3)
+
+        if "id" in df:
+            id_set = df.id.unique()
+            if len(id_set) > 1:
+                raise NotImplementedError("> 1 molecules in same DataFrame")
+            else:
+                self.warn(
+                    "There shouldn't be a Column 'id' with a unique value",
+                    "PerformanceWarning",
+                )
+                df.attrs["id"] = int(id_set)
+        molecule = get_molecule(df.attrs["id"])
+        state = self.input.state
+
+        if "iso" in df:  #  multiple isotopes
+            iso_set = df.iso.unique()
+            if len(iso_set) == 1:
+                self.warn(
+                    "There shouldn't be a Column 'iso' with a unique value",
+                    "PerformanceWarning",
+                )
+
+            dgb = df.groupby(by=["iso"])
+            for (iso), idx in dgb.indices.items():
+
+                # Get partition function for all lines
+                parsum = self.get_partition_function_calculator(molecule, iso, state)
+
+                if is_float(Tvib):
+                    Q, Qvib, dfQrot = parsum.at_noneq(
+                        Tvib,
+                        Trot,
+                        vib_distribution=vib_distribution,
+                        rot_distribution=rot_distribution,
+                        overpopulation=overpopulation,
+                        returnQvibQrot=True,
+                        update_populations=self.misc.export_populations,
+                    )
+                else:
+                    raise NotImplementedError(
+                        "Cannot return detail of Qvib, Qrot for 3-Tvib mode"
+                    )
+
+                # ... make sure PartitionFunction above is calculated with the same
+                # ... temperatures, rovibrational distributions and overpopulations
+                # ... as the populations of active levels (somewhere below)
+                df.at[idx, "Qvib"] = Qvib
+                df.at[idx, "Q"] = Q
+
+                # reindexing to get a direct access to Qrot database
+                # create the lookup dictionary
+                # dfQrot index is already 'viblvl'
+                dfQrot_dict = dict(list(zip(dfQrot.index, dfQrot.Qrot)))
+
+                dg = df.loc[idx]
+
+                # Add lower state Qrot
+                dg_sorted = dg.set_index(["viblvl_l"], inplace=False)
+                df.loc[idx, "Qrotl"] = dg_sorted.index.map(dfQrot_dict.get).values
+                # Add upper state energy
+                dg_sorted = dg.set_index(["viblvl_u"], inplace=False)
+                df.loc[idx, "Qrotu"] = dg_sorted.index.map(dfQrot_dict.get).values
+
+                if radis.config["DEBUG_MODE"]:
+                    assert (df.loc[idx, "iso"] == iso).all()
+
+            Q, Qvib, Qrotu, Qrotl = df.Q, df.Qvib, df.Qrotu, df.Qrotl
+
+        else:
+            iso = df.attrs["iso"]
+
+            parsum = self.get_partition_function_calculator(molecule, iso, state)
+
+            if is_float(Tvib):
+                Q, Qvib, dfQrot = parsum.at_noneq(
+                    Tvib,
+                    Trot,
+                    vib_distribution=vib_distribution,
+                    rot_distribution=rot_distribution,
+                    overpopulation=overpopulation,
+                    returnQvibQrot=True,
+                    update_populations=self.misc.export_populations,
+                )
+            else:
+                raise NotImplementedError(
+                    "Cannot return detail of Qvib, Qrot for 3-Tvib mode"
+                )
+
+            # ... make sure PartitionFunction above is calculated with the same
+            # ... temperatures, rovibrational distributions and overpopulations
+            # ... as the populations of active levels (somewhere below)
+            df.attrs["Qvib"] = Qvib
+            df.attrs["Q"] = Q
+            assert "Qvib" not in df.columns
+            assert "Q" not in df.columns
+
+            # reindexing to get a direct access to Qrot database
+            # create the lookup dictionary
+            # dfQrot index is already 'viblvl'
+            dfQrot_dict = dict(list(zip(dfQrot.index, dfQrot.Qrot)))
+
+            dg = df.loc[:]
+
+            # Add lower state Qrot
+            dg_sorted = dg.set_index(["viblvl_l"], inplace=False)
+            df.loc[:, "Qrotl"] = dg_sorted.index.map(dfQrot_dict.get).values
+            # Add upper state energy
+            dg_sorted = dg.set_index(["viblvl_u"], inplace=False)
+            df.loc[:, "Qrotu"] = dg_sorted.index.map(dfQrot_dict.get).values
+
+            Q, Qvib, Qrotu, Qrotl = Q, Qvib, df.Qrotu, df.Qrotl
+
+        self.profiler.stop("part_function", "partition functions")
+        return Q, Qvib, Qrotu, Qrotl
+
+    # %%
+    def calc_populations_noneq(
+        self,
+        Tvib,
+        Trot,
+        vib_distribution="boltzmann",
+        rot_distribution="boltzmann",
+        overpopulation=None,
+    ):
+        """Calculate upper and lower state population for all active
+        transitions, as well as all levels (through
+        :meth:`~radis.levels.partfunc.RovibPartitionFunction.at_noneq`)
+
+        Parameters
+        ----------
+        Tvib, Trot: float (K)
+            temperatures
+        vib_distribution: ``'boltzmann'``, ``'treanor'``
+            vibrational level distribution
+        rot_distribution: ``'boltzmann'``
+            rotational level distribution
+        overpopulation: dict, or ``None``
             dictionary of overpopulation factors for vibrational levels
 
         Returns
@@ -2434,166 +2564,6 @@ def calc_populations_noneq(
             assert "viblvl_u" in df
             assert "viblvl_l" in df
 
-        # partition function
-        # ... unlike the (tabulated) equilibrium case, here we recalculate it from
-        # scratch
-        # %%
-
-        if "id" in df:
-            id_set = df.id.unique()
-            if len(id_set) > 1:
-                raise NotImplementedError("> 1 molecules in same DataFrame")
-            else:
-                self.warn(
-                    "There shouldn't be a Column 'id' with a unique value",
-                    "PerformanceWarning",
-                )
-                df.attrs["id"] = int(id_set)
-        molecule = get_molecule(df.attrs["id"])
-        state = self.input.state
-
-        def Q(Tvib, Trot):
-            """Nonequilibrium partition function
-
-            Returns
-            -------
-            column or float: depending if there are many isotopes or one"""
-            self.profiler.start("part_function", 3)
-            if "iso" in df:
-                Q_dict = {}
-                iso_set = df.iso.unique()
-                if len(iso_set) == 1:
-                    self.warn(
-                        "There shouldn't be a Column 'iso' with a unique value",
-                        "PerformanceWarning",
-                    )
-                for iso in iso_set:
-                    parsum = self.get_partition_function_calculator(
-                        molecule, iso, state
-                    )
-                    Q_dict[iso] = parsum.at_noneq(
-                        Tvib,
-                        Trot,
-                        vib_distribution=vib_distribution,
-                        rot_distribution=rot_distribution,
-                        overpopulation=overpopulation,
-                        update_populations=self.misc.export_populations,
-                    )
-                Q = df["iso"].map(Q_dict)
-
-            else:  # "iso" not in df:
-                iso = df.attrs["iso"]
-                parsum = self.get_partition_function_calculator(molecule, iso, state)
-                Q = parsum.at_noneq(
-                    Tvib,
-                    Trot,
-                    vib_distribution=vib_distribution,
-                    rot_distribution=rot_distribution,
-                    overpopulation=overpopulation,
-                    update_populations=self.misc.export_populations,
-                )
-                df.attrs["Q"] = Q
-            self.profiler.stop("part_function", "partition functions")
-            return Q
-
-        def Q_Qvib_Qrotu_Qrotl(Tvib, Trot):
-            """Nonequilibrium partition function; with the detail of
-            vibrational partition function and rotational partition functions"""
-            # TODO @ dev : implement the map(dict) approach to fill Q Qvib Qrotu Qrotl
-            # note : Qrot already use a map(dict) so we need a map with 2 keys. Is it worth it?
-            self.profiler.start("part_function", 3)
-            if "iso" in df:  #  multiple isotopes
-                iso_set = df.iso.unique()
-                if len(iso_set) == 1:
-                    self.warn(
-                        "There shouldn't be a Column 'iso' with a unique value",
-                        "PerformanceWarning",
-                    )
-
-                dgb = df.groupby(by=["iso"])
-                for (iso), idx in dgb.indices.items():
-
-                    # Get partition function for all lines
-                    parsum = self.get_partition_function_calculator(
-                        molecule, iso, state
-                    )
-
-                    Q, Qvib, dfQrot = parsum.at_noneq(
-                        Tvib,
-                        Trot,
-                        vib_distribution=vib_distribution,
-                        rot_distribution=rot_distribution,
-                        overpopulation=overpopulation,
-                        returnQvibQrot=True,
-                        update_populations=self.misc.export_populations,
-                    )
-
-                    # ... make sure PartitionFunction above is calculated with the same
-                    # ... temperatures, rovibrational distributions and overpopulations
-                    # ... as the populations of active levels (somewhere below)
-                    df.at[idx, "Qvib"] = Qvib
-                    df.at[idx, "Q"] = Q
-
-                    # reindexing to get a direct access to Qrot database
-                    # create the lookup dictionary
-                    # dfQrot index is already 'viblvl'
-                    dfQrot_dict = dict(list(zip(dfQrot.index, dfQrot.Qrot)))
-
-                    dg = df.loc[idx]
-
-                    # Add lower state Qrot
-                    dg_sorted = dg.set_index(["viblvl_l"], inplace=False)
-                    df.loc[idx, "Qrotl"] = dg_sorted.index.map(dfQrot_dict.get).values
-                    # Add upper state energy
-                    dg_sorted = dg.set_index(["viblvl_u"], inplace=False)
-                    df.loc[idx, "Qrotu"] = dg_sorted.index.map(dfQrot_dict.get).values
-
-                    if radis.config["DEBUG_MODE"]:
-                        assert (df.loc[idx, "iso"] == iso).all()
-
-                Q, Qvib, Qrotu, Qrotl = df.Q, df.Qvib, df.Qrotu, df.Qrotl
-
-            else:
-                iso = df.attrs["iso"]
-
-                parsum = self.get_partition_function_calculator(molecule, iso, state)
-
-                Q, Qvib, dfQrot = parsum.at_noneq(
-                    Tvib,
-                    Trot,
-                    vib_distribution=vib_distribution,
-                    rot_distribution=rot_distribution,
-                    overpopulation=overpopulation,
-                    returnQvibQrot=True,
-                    update_populations=self.misc.export_populations,
-                )
-                # ... make sure PartitionFunction above is calculated with the same
-                # ... temperatures, rovibrational distributions and overpopulations
-                # ... as the populations of active levels (somewhere below)
-                df.attrs["Qvib"] = Qvib
-                df.attrs["Q"] = Q
-                assert "Qvib" not in df.columns
-                assert "Q" not in df.columns
-
-                # reindexing to get a direct access to Qrot database
-                # create the lookup dictionary
-                # dfQrot index is already 'viblvl'
-                dfQrot_dict = dict(list(zip(dfQrot.index, dfQrot.Qrot)))
-
-                dg = df.loc[:]
-
-                # Add lower state Qrot
-                dg_sorted = dg.set_index(["viblvl_l"], inplace=False)
-                df.loc[:, "Qrotl"] = dg_sorted.index.map(dfQrot_dict.get).values
-                # Add upper state energy
-                dg_sorted = dg.set_index(["viblvl_u"], inplace=False)
-                df.loc[:, "Qrotu"] = dg_sorted.index.map(dfQrot_dict.get).values
-
-                Q, Qvib, Qrotu, Qrotl = Q, Qvib, df.Qrotu, df.Qrotl
-
-            self.profiler.stop("part_function", "partition functions")
-            return Q, Qvib, Qrotu, Qrotl
-
         # %%
 
         #  Derive populations
@@ -2622,13 +2592,30 @@ def Q_Qvib_Qrotu_Qrotl(Tvib, Trot):
                     "Unknown rotational distribution: {0}".format(rot_distribution)
                 )
 
+            # ... Partition functions
+            Qneq = self.Qneq(
+                df,
+                Tvib,
+                Trot,
+                vib_distribution=vib_distribution,
+                rot_distribution=rot_distribution,
+                overpopulation=overpopulation,
+            )
+
             # ... Total
-            df["nu"] = df.nu_vib_x_Qvib * df.nu_rot_x_Qrot / Q(Tvib, Trot)
-            df["nl"] = df.nl_vib_x_Qvib * df.nl_rot_x_Qrot / Q(Tvib, Trot)
+            df["nu"] = df.nu_vib_x_Qvib * df.nu_rot_x_Qrot / Qneq
+            df["nl"] = df.nl_vib_x_Qvib * df.nl_rot_x_Qrot / Qneq
 
         else:  # self.misc.export_rovib_fraction:
             # ... Partition functions
-            Q, Qvib, Qrotu, Qrotl = Q_Qvib_Qrotu_Qrotl(Tvib, Trot)
+            Q, Qvib, Qrotu, Qrotl = self.Qneq_Qvib_Qrotu_Qrotl(
+                df,
+                Tvib,
+                Trot,
+                vib_distribution=vib_distribution,
+                rot_distribution=rot_distribution,
+                overpopulation=overpopulation,
+            )
 
             # ... vibrational distribution
             if vib_distribution == "boltzmann":
@@ -2753,63 +2740,6 @@ def _calc_populations_noneq_multiTvib(
         # ... unlike the (tabulated) equilibrium case, here we recalculate it from
         # scratch
 
-        if "id" in df:
-            id_set = df.id.unique()
-            if len(id_set) > 1:
-                raise NotImplementedError("> 1 molecules in same DataFrame")
-            else:
-                self.warn(
-                    "There shouldn't be a Column 'id' with a unique value",
-                    "PerformanceWarning",
-                )
-                df.attrs["id"] = int(id_set)
-        molecule = get_molecule(df.attrs["id"])
-        state = self.input.state
-
-        def Q(Tvib, Trot):
-            """Nonequilibrium partition function
-
-            Returns
-            -------
-            column or float: depending if there are many isotopes or one"""
-            self.profiler.start("part_function", 3)
-            if "iso" in df:
-                Q_dict = {}
-                iso_set = df.iso.unique()
-                if len(iso_set) == 1:
-                    self.warn(
-                        "There shouldn't be a Column 'iso' with a unique value",
-                        "PerformanceWarning",
-                    )
-                for iso in iso_set:
-                    parsum = self.get_partition_function_calculator(
-                        molecule, iso, state
-                    )
-                    Q_dict[iso] = parsum.at_noneq_3Tvib(
-                        Tvib,
-                        Trot,
-                        vib_distribution=vib_distribution,
-                        rot_distribution=rot_distribution,
-                        overpopulation=overpopulation,
-                        update_populations=self.misc.export_populations,
-                    )
-                Q_Tvib_Trot = df["iso"].map(Q_dict)
-
-            else:  # "iso" not in df:
-                iso = df.attrs["iso"]
-                parsum = self.get_partition_function_calculator(molecule, iso, state)
-                Q_Tvib_Trot = parsum.at_noneq_3Tvib(
-                    Tvib,
-                    Trot,
-                    vib_distribution=vib_distribution,
-                    rot_distribution=rot_distribution,
-                    overpopulation=overpopulation,
-                    update_populations=self.misc.export_populations,
-                )
-
-            self.profiler.stop("part_function", "partition functions")
-            return Q_Tvib_Trot
-
         #  Derive populations
         # ... vibrational distribution
         if vib_distribution == "boltzmann":
@@ -2820,24 +2750,14 @@ def Q(Tvib, Trot):
             nu_vib3Qvib3 = df.gvibu * exp(-hc_k * df.Evib3u / Tvib3)
             nl_vib3Qvib3 = df.gvibl * exp(-hc_k * df.Evib3l / Tvib3)
         elif vib_distribution == "treanor":
-            nu_vib1Qvib1 = df.gvibu * exp(
-                -hc_k * (df.Evib1u_h / Tvib1 + df.Evib1u_a / Trot)
-            )
-            nl_vib1Qvib1 = df.gvibl * exp(
-                -hc_k * (df.Evib1l_h / Tvib1 + df.Evib1l_a / Trot)
-            )
-            nu_vib2Qvib2 = df.gvibu * exp(
-                -hc_k * (df.Evib2u_h / Tvib2 + df.Evib2u_a / Trot)
-            )
-            nl_vib2Qvib2 = df.gvibl * exp(
-                -hc_k * (df.Evib2l_h / Tvib2 + df.Evib2l_a / Trot)
-            )
-            nu_vib3Qvib3 = df.gvibu * exp(
-                -hc_k * (df.Evib3u_h / Tvib3 + df.Evib3u_a / Trot)
-            )
-            nl_vib3Qvib3 = df.gvibl * exp(
-                -hc_k * (df.Evib3l_h / Tvib3 + df.Evib3l_a / Trot)
-            )
+            # fmt: off
+            nu_vib1Qvib1 = df.gvibu * exp(-hc_k * (df.Evib1u_h / Tvib1 + df.Evib1u_a / Trot))
+            nl_vib1Qvib1 = df.gvibl * exp(-hc_k * (df.Evib1l_h / Tvib1 + df.Evib1l_a / Trot))
+            nu_vib2Qvib2 = df.gvibu * exp(-hc_k * (df.Evib2u_h / Tvib2 + df.Evib2u_a / Trot))
+            nl_vib2Qvib2 = df.gvibl * exp(-hc_k * (df.Evib2l_h / Tvib2 + df.Evib2l_a / Trot))
+            nu_vib3Qvib3 = df.gvibu * exp(-hc_k * (df.Evib3u_h / Tvib3 + df.Evib3u_a / Trot))
+            nl_vib3Qvib3 = df.gvibl * exp(-hc_k * (df.Evib3l_h / Tvib3 + df.Evib3l_a / Trot))
+            # fmt: on
         else:
             raise ValueError(
                 "Unknown vibrational distribution: {0}".format(vib_distribution)
@@ -2865,6 +2785,14 @@ def Q(Tvib, Trot):
         # ... Total
         if rot_distribution == "boltzmann":
             # ... total
+            Qneq = self.Qneq(
+                df,
+                Tvib,
+                Trot,
+                vib_distribution=vib_distribution,
+                rot_distribution=rot_distribution,
+                overpopulation=overpopulation,
+            )
 
             df["nu"] = (
                 nu_vib1Qvib1
@@ -2872,7 +2800,7 @@ def Q(Tvib, Trot):
                 * nu_vib3Qvib3
                 * df.grotu
                 * exp(-df.Erotu * hc_k / Trot)
-                / Q(Tvib, Trot)
+                / Qneq
             )
             df["nl"] = (
                 nl_vib1Qvib1
@@ -2880,7 +2808,7 @@ def Q(Tvib, Trot):
                 * nl_vib3Qvib3
                 * df.grotl
                 * exp(-df.Erotl * hc_k / Trot)
-                / Q(Tvib, Trot)
+                / Qneq
             )
 
         else:
@@ -3055,67 +2983,6 @@ def calc_linestrength_noneq(self):
         except KeyError:
             raise KeyError("Calculate populations first")
 
-        # %% Calculation
-        self.profiler.start("map_part_func", 3)
-
-        if "id" in df:
-            id_set = df.id.unique()
-            if len(id_set) > 0:
-                raise NotImplementedError("> 1 molecules in same DataFrame")
-            else:
-                self.warn(
-                    "There shouldn't be a Column 'id' with a unique value",
-                    "PerformanceWarning",
-                )
-                df.attrs["id"] = int(id_set)
-        molecule = get_molecule(df.attrs["id"])
-        state = self.input.state
-
-        # Partition functions
-        def Qgas(Tref):
-            """Aggregate the values of Qgas
-
-            Parameters
-            ----------
-            Tref: float (K)
-                reference gas temperature of database
-
-            Returns
-            -------
-            float or dict: Returns Qgas as a dictionary with isotope values as its keys
-
-            """
-            self.profiler.start("corrected_population_se", 3)
-            if "iso" in df:
-                iso_set = df.iso.unique()
-                if len(iso_set) == 1:
-                    self.warn(
-                        "There shouldn't be a Column 'iso' with a unique value",
-                        "PerformanceWarning",
-                    )
-                    iso = int(iso_set)
-                    parsum = self.get_partition_function_interpolator(
-                        molecule, iso, state
-                    )
-                    Q = parsum.at(Tref, update_populations=False)
-                    df.attrs["Q"] = Q
-                else:
-                    Qref_dict = {}
-                    for iso in iso_set:
-                        parsum = self.get_partition_function_interpolator(
-                            molecule, iso, state
-                        )
-                        Qref_dict[iso] = parsum.at(Tref, update_populations=False)
-                    Q = df["iso"].map(Qref_dict)
-
-            else:  # "iso" not in df:
-                iso = df.attrs["iso"]
-                parsum = self.get_partition_function_interpolator(molecule, iso, state)
-                Q = parsum.at(Tref, update_populations=False)
-                df.attrs["Q"] = Q
-            self.profiler.stop("map_part_func", "map partition functions")
-            return Q
-
         # Correct linestrength
 
         # ... populations without abundance dependance (already in linestrength)
@@ -3130,7 +2997,7 @@ def Qgas(Tref):
             )
 
         # ... correct for lower state population
-        line_strength /= df.gl * exp(-hc_k * df.El / Tref) / Qgas(Tref)
+        line_strength /= df.gl * exp(-hc_k * df.El / Tref) / self.Qgas(df, Tref)
         line_strength *= nl
 
         # ... correct effect of stimulated emission
@@ -3138,10 +3005,6 @@ def Qgas(Tref):
         line_strength *= 1 - df.gl / df.gu * nu / nl
         df["S"] = line_strength
 
-        self.profiler.stop(
-            "corrected_population_se",
-            "corrected for populations and stimulated emission",
-        )
         self.profiler.stop(
             "scaled_non_eq_linestrength", "scaled nonequilibrium linestrength"
         )
@@ -3292,9 +3155,9 @@ def _cutoff_linestrength(self, cutoff=None):
         try:
             assert sum(~b) > 0
         except AssertionError as err:
-            self.plot_linestrength_hist()
+            self.plot_linestrength_hist(cutoff=cutoff)
             raise AssertionError(
-                "All lines discarded! Please increase cutoff. "
+                f"All lines discarded! Please increase cutoff (currently : {cutoff:.1e}). "
                 + "In your case: (min,max,mean)=({0:.2e},{1:.2e},{2:.2e}".format(
                     df.S.min(), df.S.max(), df.S.mean()
                 )
@@ -3468,9 +3331,11 @@ def _check_inputs(self, mole_fraction, Tmax):
 
         # Also check some computation parameters:
 
-        # Checks there if there is change in wstep value if initial wstep != "auto"
-        if self.wstep != "auto":
-            assert self.wstep == self.params.wstep
+        # Checks there if there is change in wstep value if initial wstep != "auto" (could happen if users modified the _wstep value directly)
+        if self._wstep != "auto":
+            assert self._wstep == self.params.wstep
+        if self._sparse_dlm != "auto":
+            assert self._sparse_dlm == self.params.sparse_dlm
 
         # Checks there if there is change in truncation value
         # (except in the case where truncation is None, where we set it to be the full range)
@@ -3487,17 +3352,6 @@ def _check_inputs(self, mole_fraction, Tmax):
             )
             # note @dev:  could be implemented; i.e. send `neighbour_lines` to load_databank instead of SpectrumFactory initialisation
 
-    def _get_parsum(self, molecule, iso, state):
-        """Get function that calculates the partition function.
-
-        By default, try to get the tabulated version. If does not exist,
-        returns the direct summation version
-        """
-        try:
-            return self.get_partition_function_interpolator(molecule, iso, state)
-        except KeyError:
-            return self.get_partition_function_calculator(molecule, iso, state)
-
     def plot_populations(self, what="vib", isotope=None, nfig=None):
         """Plot populations currently calculated in factory.
 
@@ -3599,23 +3453,30 @@ def get_waverange(
 
     # Checking consistency of all input variables
 
-    if (
-        wmin is None
-        and wmax is None
-        and wavelength_min is None
-        and wavelength_max is None
-        and wavenum_min is None
-        and wavenum_max is None
-    ):
-        raise ValueError("Give wavenumber or wavelength")
     w_present = wmin is not None and wmax is not None
     wavenum_present = wavenum_min is not None and wavenum_max is not None
     wavelength_present = wavelength_min is not None and wavelength_max is not None
     if w_present + wavenum_present + wavelength_present != 1:
+        _msg = "Got : "
+        _msg += f" wmin={wmin}, wmax={wmax}" if w_present else ""
+        _msg += (
+            f" wavenum_min={wavenum_min}, wavenum_max={wavenum_max}"
+            if wavenum_present
+            else ""
+        )
+        _msg += (
+            f" wavelength_min={wavelength_min}, wavelength_max={wavelength_max}"
+            if wavelength_present
+            else ""
+        )
         raise ValueError(
-            "Please pass exactly one set of values as input: "
-            "choose either wmin/wmax (with astropy.units), "
-            "wavenum_min/wavenum_max, or wavelength_min/wavelength_max"
+            "Please pass exactly one range for wavenumber/wavelength input. {}".format(
+                _msg
+            )
+            + "\nChoose either `wmin=..., wmax=...` (with astropy.units), "
+            "`wavenum_min=..., wavenum_max=...` (in cm-1), "
+            "or `wavelength_min=..., wavelength_max=...` (in nm)"
+            "We recommend to use units. Example: \n\n  import astropy.units as u\n  calc_spectrum(wmin=2000 / u.cm, wmax=2300 / u.cm, ..."
         )
 
     if not isinstance(wunit, Default):
@@ -3657,11 +3518,6 @@ def get_waverange(
         if not isinstance(wunit, Default):
             if not wmin.unit.is_equivalent(u.Unit(wunit)):
                 raise ValueError("Conflicting units passed for wmin/wmax and wunit")
-            # Should I keep this in?
-            # Deals with cases like: calc_spectrum(wmin=10*u.cm, wmax=1.u.m, wunit="cm")
-    #             else:
-    #                 if wmin.unit != wmax.unit and (wmin.unit != u.Unit(wunit.value) or wmax.unit != u.Unit(wunit.value)):
-    #                     raise Warning("Ambiguous units passed, ignoring wunit")
 
     # Conversion to base units
     if wmin is not None:
@@ -3689,8 +3545,13 @@ def get_waverange(
         wavenum_min = convert_and_strip_units(wavenum_min, 1 / u.cm)
         wavenum_max = convert_and_strip_units(wavenum_max, 1 / u.cm)
 
-    if wavelength_min is not None or wavelength_max is not None:
-        assert medium in ["air", "vacuum"]
+    elif wavelength_min is not None or wavelength_max is not None:
+        if not medium in ["air", "vacuum"]:
+            raise NotImplementedError(
+                "Unknown propagating medium: {0}. Choose `'air'` or `'vacuum'` to get the correct wavelengths".format(
+                    medium
+                )
+            )
         wavelength_min = convert_and_strip_units(wavelength_min, u.nm)
         wavelength_max = convert_and_strip_units(wavelength_max, u.nm)
         if medium == "air":
diff --git a/radis/lbl/broadening.py b/radis/lbl/broadening.py
index f93d3a301..f03d6b286 100644
--- a/radis/lbl/broadening.py
+++ b/radis/lbl/broadening.py
@@ -60,6 +60,7 @@
 ----------
 
 """
+
 from warnings import warn
 
 import numpy as np
@@ -72,7 +73,14 @@
 import radis
 from radis.db.references import doi
 from radis.lbl.base import BaseFactory
-from radis.misc.arrays import add_at, arange_len, numpy_add_at
+from radis.misc.arrays import (
+    add_at,
+    arange_len,
+    boolean_array_from_ranges,
+    non_zero_ranges_in_array,
+    numpy_add_at,
+    sparse_add_at,
+)
 from radis.misc.basics import is_float
 from radis.misc.debug import printdbg
 from radis.misc.plot import fix_style, set_style
@@ -279,13 +287,13 @@ def pressure_broadening_HWHM(
     References
     ----------
 
-    ..math::
+    .. math::
 
         \\gamma_{lb}={\\left(\\frac{T_{ref}}{T_{gas}}\\right)}^{n_{air}} \\gamma_{air} P \\left(1-x\\right)+{\\left(\\frac{T_{ref}}{T_{gas}}\\right)}^{n_{self}} \\gamma_{self} P x
 
     With :math:`n_{air}, n_{self}` the temperature dependance coefficients
     ``Tdpair, Tdpsel`` ; :math:`\\gamma_{air}, \\gamma_{self}` the air and resonant
-    broadening ``airbrd, selbrd``, :math:`x` the ``mole_fraction``.
+    HWHM broadening tabulated at :math:`T_{ref}`, :math:`x` the ``mole_fraction``.
 
     .. [1] `Rothman 1998 (HITRAN 1996) eq (A.14) <https://www.sciencedirect.com/science/article/pii/S0022407398000788>`_
 
@@ -1516,8 +1524,9 @@ def _init_w_axis(w_dat, log_p):
         return line_profile_DLM, wL, wG, wL_dat, wG_dat
 
     def plot_broadening(self, i=0, pressure_atm=None, mole_fraction=None, Tgas=None):
-        """just for testing. Recalculate and plot broadening for line of index
-        i.
+        """Recalculate and plot broadening for line of index ``i``.
+
+        Used mainly for testing.
 
         Parameters
         ----------
@@ -1533,6 +1542,8 @@ def plot_broadening(self, i=0, pressure_atm=None, mole_fraction=None, Tgas=None)
         Examples
         --------
         ::
+
+            from radis import SpectrumFactory
             sf=SpectrumFactory(...)
             sf.eq_spectrum(...)
             sf.plot_broadening(i=500)   # plot line number 500
@@ -1794,10 +1805,10 @@ def _apply_lineshape_DLM(
         # ...    (either because deactivated, or because not installed)
         if self.use_cython and add_at != numpy_add_at:
             _add_at = add_at
-            self.params.add_at_used = "cython"
+            self.misc.add_at_used = "cython"
         else:
             _add_at = numpy_add_at
-            self.params.add_at_used = "numpy"
+            self.misc.add_at_used = "numpy"
         # Vectorize the chunk of lines
         S = broadened_param
 
@@ -1876,11 +1887,20 @@ def _apply_lineshape_DLM(
         self.profiler.start("DLM_Distribute_lines", 3)
         # ... Initialize array on which to distribute the lineshapes
         if broadening_method in ["voigt", "convolve"]:
-            DLM = np.zeros((len(wavenumber_calc) + 2, len(wG), len(wL)))
-            # +2 to allocate one empty grid point on each side : case where a line is on the boundary
-            ki0 += 1
-            ki1 += 1
+            if self.params.sparse_dlm == True:
+                # DLM is constructed in a sparse-way later
+                pass
+            else:
+                DLM = np.zeros((len(wavenumber_calc) + 2, len(wG), len(wL)))
+                # +2 to allocate one empty grid point on each side : case where a line is on the boundary
+                ki0 += 1
+                ki1 += 1
         elif broadening_method == "fft":
+            if self.params.sparse_dlm == True:
+                if self.verbose >= 2:
+                    print(
+                        "SPARSE optimisation not implemented with 'fft' mode. Use 'voigt' for analytical voigt, or radis.config['SPARSE_WAVERANGE'] = False"
+                    )
             DLM = np.zeros(
                 (
                     2 * len(wavenumber_calc),  # TO-DO: Add  + self.misc.zero_padding
@@ -1892,18 +1912,145 @@ def _apply_lineshape_DLM(
             raise NotImplementedError(broadening_method)
 
         # Distribute all line intensities on the 2x2x2 bins.
-        _add_at(DLM, ki0, li0, mi0, Iv0 * awV00)
-        _add_at(DLM, ki0, li0, mi1, Iv0 * awV01)
-        _add_at(DLM, ki0, li1, mi0, Iv0 * awV10)
-        _add_at(DLM, ki0, li1, mi1, Iv0 * awV11)
-        _add_at(DLM, ki1, li0, mi0, Iv1 * awV00)
-        _add_at(DLM, ki1, li0, mi1, Iv1 * awV01)
-        _add_at(DLM, ki1, li1, mi0, Iv1 * awV10)
-        _add_at(DLM, ki1, li1, mi1, Iv1 * awV11)
+        if (
+            broadening_method in ["voigt", "convolve"]
+            and self.params.sparse_dlm == True
+        ):
 
-        if broadening_method in ["voigt", "convolve"]:
-            DLM = DLM[1:-1, :, :]
-            # 1:-1 to remove the empty grid point on each side
+            import pandas as pd
+
+            df = pd.DataFrame(
+                {
+                    "ki0": ki0,
+                    "li0": li0,
+                    "li1": li1,
+                    "mi0": mi0,
+                    "mi1": mi1,
+                    "Iv0": Iv0,
+                    "Iv1": Iv1,
+                    "awV00": awV00,
+                    "awV01": awV01,
+                    "awV10": awV10,
+                    "awV11": awV11,
+                },
+                copy=False,
+            )
+
+            def get_non_zero_wranges(groupby_parameters, max_range, intensity_weight):
+                """Get coordinates of non-zero wave ranges for all lines
+                that share the same ``groupby_parameters``
+
+                Parameters
+                ----------
+                groupby_parameters: str
+                max_range: int
+
+                Examples
+                --------
+                ::
+                    DLM_ranges_00 = get_non_zero_wranges(groupby_parameters=["li0", "mi0"], max_range=len(wavenumber_calc))
+                """
+                # EP 31/10: the for loop over df.groupby is the current bottleneck
+                # (not even the sparse_add_at function !)
+                dgb = df.groupby(groupby_parameters, sort=False)
+                DLM_ranges = {}
+                DLM_reduced = {}
+                for groupby_param, group in dgb:
+                    truncation_pts = int(self.params.truncation // self.params.wstep)
+                    # note: truncation can be unique for each point of the DLM basis
+                    # (allow to have line-dependant truncatino, at least as all
+                    # lines with same truncation are grouped together in the DLM basis)
+
+                    ki0 = group.ki0.values
+                    Iv0 = group.Iv0.values
+                    Iv1 = group.Iv1.values
+                    weight = group[intensity_weight].values
+
+                    # build the list of non-empty ranges for all lines with this lineshape :
+                    ranges, I = sparse_add_at(
+                        ki0, Iv0, Iv1, weight, max_range, truncation_pts
+                    )
+
+                    DLM_ranges[groupby_param] = ranges
+
+                    # generate reduced array:
+                    b = boolean_array_from_ranges(ranges, len(I))
+                    I_reduced = I[b]
+                    DLM_reduced[groupby_param] = I_reduced
+
+                return DLM_ranges, DLM_reduced
+
+            w = wavenumber_calc
+
+            DLM_ranges_00, DLM_reduced_00 = get_non_zero_wranges(
+                groupby_parameters=["li0", "mi0"],
+                max_range=len(w),
+                intensity_weight="awV00",
+            )
+            DLM_ranges_01, DLM_reduced_01 = get_non_zero_wranges(
+                groupby_parameters=["li0", "mi1"],
+                max_range=len(w),
+                intensity_weight="awV01",
+            )
+            DLM_ranges_10, DLM_reduced_10 = get_non_zero_wranges(
+                groupby_parameters=["li1", "mi0"],
+                max_range=len(w),
+                intensity_weight="awV10",
+            )
+            DLM_ranges_11, DLM_reduced_11 = get_non_zero_wranges(
+                groupby_parameters=["li1", "mi1"],
+                max_range=len(w),
+                intensity_weight="awV11",
+            )
+
+            # Combine all DLM ranges:
+            all_keys = (
+                set(DLM_ranges_00.keys())
+                | set(DLM_ranges_01.keys())
+                | set(DLM_ranges_10.keys())
+                | set(DLM_ranges_11.keys())
+            )
+            DLM_ranges = {}
+            DLM_reduced = {}
+            #  (note : could be combined faster by combining the ranges directly, rather than geenrating the boolean arrays?)
+            for param in all_keys:
+                b = np.zeros(len(w), dtype=bool)
+                I = np.zeros(len(w))
+                if param in DLM_ranges_00:
+                    bi = boolean_array_from_ranges(DLM_ranges_00[param], len(w))
+                    I[bi] += DLM_reduced_00[param]
+                    b += bi
+                if param in DLM_ranges_01:
+                    bi = boolean_array_from_ranges(DLM_ranges_01[param], len(w))
+                    I[bi] += DLM_reduced_01[param]
+                    b += bi
+                if param in DLM_ranges_10:
+                    bi = boolean_array_from_ranges(DLM_ranges_10[param], len(w))
+                    I[bi] += DLM_reduced_10[param]
+                    b += bi
+                if param in DLM_ranges_11:
+                    bi = boolean_array_from_ranges(DLM_ranges_11[param], len(w))
+                    I[bi] += DLM_reduced_11[param]
+                    b += bi
+
+                # Sparse storage (coordinates & non-zeros ranges) :
+                if b.any():
+                    DLM_ranges[param] = non_zero_ranges_in_array(b)
+                    DLM_reduced[param] = I[b]
+
+        else:
+            _add_at(DLM, ki0, li0, mi0, Iv0 * awV00)
+            _add_at(DLM, ki0, li0, mi1, Iv0 * awV01)
+            _add_at(DLM, ki0, li1, mi0, Iv0 * awV10)
+            _add_at(DLM, ki0, li1, mi1, Iv0 * awV11)
+            _add_at(DLM, ki1, li0, mi0, Iv1 * awV00)
+            _add_at(DLM, ki1, li0, mi1, Iv1 * awV01)
+            _add_at(DLM, ki1, li1, mi0, Iv1 * awV10)
+            _add_at(DLM, ki1, li1, mi1, Iv1 * awV11)
+
+            if broadening_method in ["voigt", "convolve"]:
+                DLM = DLM[1:-1, :, :]
+                # 1:-1 to remove the empty grid point on each side
 
         # All lines within each bins are convolved with the same lineshape.
         # Let's do it:
@@ -1921,7 +2068,17 @@ def _apply_lineshape_DLM(
             for l in range(len(wG)):
                 for m in range(len(wL)):
                     lineshape = line_profile_DLM[l][m]
-                    sumoflines_calc += oaconvolve(DLM[:, l, m], lineshape, "same")
+
+                    if self.params.sparse_dlm == True:
+                        if (l, m) in DLM_ranges.keys():
+                            mask = boolean_array_from_ranges(
+                                DLM_ranges[(l, m)], len(sumoflines_calc)
+                            )
+                            sumoflines_calc[mask] += oaconvolve(
+                                DLM_reduced[(l, m)], lineshape, "same"
+                            )
+                    else:
+                        sumoflines_calc += oaconvolve(DLM[:, l, m], lineshape, "same")
 
         elif broadening_method == "fft":
             # ... Initialize array in FT space
@@ -2870,3 +3027,5 @@ def rough_sum_on_grid():
     from radis.test.lbl.test_broadening import _run_testcases
 
     print("Testing broadening.py:", _run_testcases(verbose=True, plot=True))
+
+# %%
diff --git a/radis/lbl/calc.py b/radis/lbl/calc.py
index 85e9c8fa1..eff277ce1 100644
--- a/radis/lbl/calc.py
+++ b/radis/lbl/calc.py
@@ -21,22 +21,22 @@
 from os.path import exists
 
 try:  # Proper import
-    from .labels import SpectrumFactory
+    from .base import get_waverange
+    from .factory import SpectrumFactory
 except ImportError:  # if ran from here
     from radis.lbl.factory import SpectrumFactory
+    from radis.lbl.base import get_waverange
+
 from radis.misc.basics import all_in
 from radis.misc.utils import Default
-from radis.phys.air import air2vacuum
-from radis.phys.convert import nm2cm
 from radis.spectrum.spectrum import Spectrum
 
 
 # %%
 def calc_spectrum(
-    wavenum_min=None,
-    wavenum_max=None,
-    wavelength_min=None,
-    wavelength_max=None,
+    wmin=None,
+    wmax=None,
+    wunit=Default("cm-1"),
     Tgas=None,
     Tvib=None,
     Trot=None,
@@ -61,9 +61,10 @@ def calc_spectrum(
     mode="cpu",
     export_lines=False,
     verbose=True,
+    return_factory=False,
     **kwargs
 ) -> Spectrum:
-    r"""Multipurpose function to calculate a :py:class:`~radis.spectrum.spectrum.Spectrum`.
+    r"""Calculate a :py:class:`~radis.spectrum.spectrum.Spectrum`.
 
     Can automatically download databases (HITRAN/HITEMP) or use manually downloaded
     local databases, under equilibrium or non-equilibrium, with or without overpopulation,
@@ -75,15 +76,18 @@ def calc_spectrum(
 
     Parameters
     ----------
-    wavenum_min, wavenum_max: float [:math:`cm^{-1}`] or `~astropy.units.quantity.Quantity`
-        wavenumber range to be processed in :math:`cm^{-1}`. Use arbitrary units::
+    wmin, wmax: float [:math:`cm^{-1}`] or `~astropy.units.quantity.Quantity`
+        wavelength/wavenumber range. If no units are given, use :math:`cm^{-1}` ::
+
+            calc_spectrum(2000, 2300, ... )   # cm-1
+            calc_spectrum(4000, 4200, wunit='nm', ...)
+
+        You can use arbitrary units::
 
             import astropy.units as u
             calc_spectrum(2.5*u.um, 3.0*u.um, ...)
-
-    wavelength_min, wavelength_max: float [:math:`nm`] or `~astropy.units.quantity.Quantity`
-        wavelength range to be processed in :math:`nm`. Wavelength in ``'air'`` or
-        ``'vacuum'`` depending of the value of ``'medium'``.
+    wunit: ``'nm'``, ``'cm-1'``
+        unit for ``wmin`` and ``wmax``. Default is ``"cm-1"``.
     Tgas: float [:math:`K`]
         Gas temperature. If non equilibrium, is used for :math:`T_{translational}`.
         Default ``300`` K​
@@ -130,16 +134,33 @@ def calc_spectrum(
 
     databank: str or dict
         can be either:
-        - ``'hitran'``, to fetch automatically the latest HITRAN version
-          through :py:func:`~radis.io.query.fetch_astroquery` (based on [HAPI]_ ).
-        - ``'hitemp'``, to fetch automatically the latest HITEMP version
-          through :py:func:`~radis.io.hitemp.fetch_hitemp`.
+        - ``'hitran'``, to fetch the latest HITRAN version
+          through :py:func:`~radis.io.hitran.fetch_hitran` (download full database
+          with  [HAPI]_) or :py:func:`~radis.io.query.fetch_astroquery` (download
+          only the required range). To use one mode or the other, use ::
+
+            databank=('hitran', 'full')     # download and cache full database, all isotopes
+            databank=('hitran', 'range')    # download and cache required range, required isoope
+
+        - ``'hitemp'``, to fetch the latest HITEMP version
+          through :py:func:`~radis.io.hitemp.fetch_hitemp`. Downloads all lines
+          and all isotopes.
+        - ``'exomol'``, to fetch the latest ExoMol database
+          through :py:func:`~radis.io.hitemp.fetch_exomol`. To download a specific
+          database use (more info in fetch_exomol) ::
+
+            databank=('exomol', 'EBJT')   # 'EBJT' is a specific ExoMol database name
+
         - the name of a a valid database file, in which case the format is inferred.
           For instance, ``'.par'`` is recognized as ``hitran/hitemp`` format.
-          Accepts wildcards ``'*'`` to select multiple files.
+          Accepts wildcards ``'*'`` to select multiple files ::
+
+            databank='PATH/TO/co_*.par'
+
         - the name of a spectral database registered in your ``~/radis.json``
-          :ref:`configuration file <label_lbl_config_file>`.
-          This allows to use multiple database files.
+          :ref:`configuration file <label_lbl_config_file>` ::
+
+            databank='MY_SPECTRAL_DATABASE'
 
         Default ``'hitran'``. See :class:`~radis.lbl.loader.DatabankLoader` for more
         information on line databases, and :data:`~radis.misc.config.DBFORMAT` for
@@ -241,9 +262,18 @@ def calc_spectrum(
         Note that ``mode='gpu'`` requires CUDA compatible hardware to execute.
         For more information on how to setup your system to run GPU-accelerated
         methods using CUDA and Cython, check `GPU Spectrum Calculation on RADIS <https://radis.readthedocs.io/en/latest/lbl/gpu.html>`__
+    return_factory: bool
+        if ``True``, return the :py:class:`~radis.lbl.factory.SpectrumFactory` that
+        computes the spectrum. Useful to access computational parameters, the line database,
+        or to start batch-computations from a first spectrum calculation. Ex::
+
+                s, sf = calc_spectrum(..., return_factory=True)
+                sf.df1  # see the lines calculated
+                sf.eq_spectrum(...)  #  new calculation without reloading the database
+
     **kwargs: other inputs forwarded to SpectrumFactory
         For instance: ``warnings``.
-        See :class:`~radis.lbl.factory.SpectrumFactory` documentation for more
+        See :py:class:`~radis.lbl.factory.SpectrumFactory` documentation for more
         details on input.
 
     Returns
@@ -322,6 +352,25 @@ def calc_spectrum(
     --------
     :py:class:`~radis.lbl.factory.SpectrumFactory`
     """
+
+    # Check inputs
+
+    # ... wavelengths / wavenumbers
+
+    # Get wavenumber, based on whatever was given as input.
+    wavenum_min, wavenum_max = get_waverange(
+        wmin,
+        wmax,
+        wunit,
+        kwargs.pop("wavenum_min") if "wavenum_min" in kwargs else None,
+        kwargs.pop("wavenum_max") if "wavenum_max" in kwargs else None,
+        kwargs.pop("wavelength_min") if "wavelength_min" in kwargs else None,
+        kwargs.pop("wavelength_max") if "wavelength_max" in kwargs else None,
+        medium,
+    )
+
+    # Deal with Multi-molecule mode:
+
     from radis.los.slabs import MergeSlabs
 
     if molecule is not None and type(molecule) != list:
@@ -393,6 +442,7 @@ def _check_molecules_are_consistent(
 
     # ... Initialize and fill the master-dictionary
     molecule_dict = {}
+    factory_dict = {}
 
     for molecule in molecule_reference_set:
         molecule_dict[molecule] = {}
@@ -435,6 +485,17 @@ def _check_molecules_are_consistent(
 
     # Stage 3: Now let's calculate all the spectra
     s_list = []
+
+    """If we are computing spectrums for multiple molecules with wstep='auto',
+    each spectrum can have different wstep values, thus will require resample="intersect"
+    argument in MergeSlab() function to interpolate the different wstep values."""
+    condition_multiple_wstep = len(molecule_dict) > 1 and wstep == "auto"
+    if condition_multiple_wstep:
+        wstep = [
+            "auto",
+            float("inf"),
+        ]  # Using a list to store minimum wstep value at 1st index
+
     for molecule, dict_arguments in molecule_dict.items():
         kwargs_molecule = deepcopy(
             kwargs
@@ -443,53 +504,62 @@ def _check_molecules_are_consistent(
         # We add all of the DICT_INPUT_ARGUMENTS values:
         kwargs_molecule.update(**dict_arguments)
 
-        s_list.append(
-            _calc_spectrum(
-                wavenum_min=wavenum_min,
-                wavenum_max=wavenum_max,
-                wavelength_min=wavelength_min,
-                wavelength_max=wavelength_max,
-                Tgas=Tgas,
-                Tvib=Tvib,
-                Trot=Trot,
-                pressure=pressure,
-                # overpopulation=overpopulation,  # now in dict_arguments
-                molecule=molecule,
-                # isotope=isotope,                # now in dict_arguments
-                # mole_fraction=mole_fraction,    # now in dict_arguments
-                path_length=path_length,
-                # databank=databank,              # now in dict_arguments
-                medium=medium,
-                wstep=wstep,
-                truncation=truncation,
-                neighbour_lines=neighbour_lines,
-                cutoff=cutoff,
-                parsum_mode=parsum_mode,
-                optimization=optimization,
-                broadening_method=broadening_method,
-                name=name,
-                use_cached=use_cached,
-                verbose=verbose,
-                mode=mode,
-                export_lines=export_lines,
-                **kwargs_molecule
-            )
+        generated_spectrum = _calc_spectrum_one_molecule(
+            wavenum_min=wavenum_min,
+            wavenum_max=wavenum_max,
+            Tgas=Tgas,
+            Tvib=Tvib,
+            Trot=Trot,
+            pressure=pressure,
+            # overpopulation=overpopulation,  # now in dict_arguments
+            molecule=molecule,
+            # isotope=isotope,                # now in dict_arguments
+            # mole_fraction=mole_fraction,    # now in dict_arguments
+            path_length=path_length,
+            # databank=databank,              # now in dict_arguments
+            medium=medium,
+            wstep=wstep,
+            truncation=truncation,
+            neighbour_lines=neighbour_lines,
+            cutoff=cutoff,
+            parsum_mode=parsum_mode,
+            optimization=optimization,
+            broadening_method=broadening_method,
+            name=name,
+            use_cached=use_cached,
+            verbose=verbose,
+            mode=mode,
+            export_lines=export_lines,
+            return_factory=return_factory,
+            **kwargs_molecule
         )
+        if return_factory:
+            factory_dict[molecule] = generated_spectrum[1]
+            generated_spectrum = generated_spectrum[0]  # the spectrum
+        s_list.append(generated_spectrum)
+
+        if condition_multiple_wstep:
+            # Stores the minimum wstep value encountered
+            wstep[1] = generated_spectrum.get_conditions()["wstep"]
 
     # Stage 4: merge all molecules and return
-    s = MergeSlabs(*s_list)
+    if condition_multiple_wstep:
+        s = MergeSlabs(*s_list, resample="intersect")
+    else:
+        s = MergeSlabs(*s_list)
 
     if save_to:
         s.store(path=save_to, if_exists_then="replace", verbose=verbose)
 
-    return s
+    if return_factory:
+        return s, factory_dict
+    else:
+        return s
 
 
-def _calc_spectrum(
+def _calc_spectrum_one_molecule(
     wavenum_min,
     wavenum_max,
-    wavelength_min,
-    wavelength_max,
     Tgas,
     Tvib,
     Trot,
@@ -513,34 +583,13 @@ def _calc_spectrum(
     verbose,
     mode,
     export_lines,
+    return_factory=False,
     **kwargs
-):
+) -> Spectrum:
     """See :py:func:`~radis.lbl.calc.calc_spectrum`"""
 
     # Check inputs
 
-    # ... wavelengths / wavenumbers
-    if (wavelength_min is not None or wavelength_max is not None) and (
-        wavenum_min is not None or wavenum_max is not None
-    ):
-        raise ValueError("Wavenumber and Wavelength both given... it's time to choose!")
-
-    if not medium in ["air", "vacuum"]:
-        raise NotImplementedError("Unknown propagating medium: {0}".format(medium))
-
-    if wavenum_min is None and wavenum_max is None:
-        assert wavelength_max is not None
-        assert wavelength_min is not None
-        if medium == "vacuum":
-            wavenum_min = nm2cm(wavelength_max)
-            wavenum_max = nm2cm(wavelength_min)
-        else:
-            wavenum_min = nm2cm(air2vacuum(wavelength_max))
-            wavenum_max = nm2cm(air2vacuum(wavelength_min))
-    else:
-        assert wavenum_min is not None
-        assert wavenum_max is not None
-
     # ... temperatures
 
     if Tgas is None and Trot is None:
@@ -584,8 +633,8 @@ def _is_at_equilibrium():
 
     # Run calculations
     sf = SpectrumFactory(
-        wavenum_min,
-        wavenum_max,
+        wavenum_min=wavenum_min,
+        wavenum_max=wavenum_max,
         medium=medium,
         molecule=molecule,
         isotope=isotope,
@@ -601,8 +650,16 @@ def _is_at_equilibrium():
         export_lines=export_lines,
         **kwargs
     )
-    if databank in ["fetch", "hitran", "hitemp", "exomol",] or (
-        isinstance(databank, tuple) and databank[0] == "exomol"
+    if (
+        databank
+        in [
+            "fetch",
+            "hitran",
+            "hitemp",
+            "exomol",
+        ]
+        or (isinstance(databank, tuple) and databank[0] == "exomol")
+        or (isinstance(databank, tuple) and databank[0] == "hitran")
     ):  # mode to get databank without relying on  Line databases
         # Line database :
         if databank in ["fetch", "hitran"]:
@@ -610,6 +667,14 @@ def _is_at_equilibrium():
                 "source": "hitran",
                 "parfuncfmt": "hapi",  # use HAPI (TIPS) partition functions for equilibrium
             }
+        elif isinstance(databank, tuple) and databank[0] == "hitran":
+            conditions = {
+                "source": "hitran",
+                "database": databank[
+                    1
+                ],  # 'full' or 'partial', cf LoaderFactory.fetch_databank()
+                "parfuncfmt": "hapi",  # use HAPI (TIPS) partition functions for equilibrium
+            }
         elif databank in ["hitemp"]:
             conditions = {
                 "source": "hitemp",
@@ -623,7 +688,7 @@ def _is_at_equilibrium():
         elif isinstance(databank, tuple) and databank[0] == "exomol":
             conditions = {
                 "source": "exomol",
-                "exomol_database": databank[1],
+                "database": databank[1],
                 "parfuncfmt": "exomol",  # download & use Exo partition functions for equilibrium}
             }
         # Partition functions :
@@ -639,9 +704,19 @@ def _is_at_equilibrium():
             # constants (not all molecules are supported!)
             conditions["levelsfmt"] = "radis"
             conditions["lvl_use_cached"] = use_cached
+
+        # Columns to load
+        if export_lines:
+            conditions["load_columns"] = "all"
+        elif not _equilibrium:
+            conditions["load_columns"] = "noneq"
+        else:
+            conditions["load_columns"] = "equilibrium"
+
         conditions["load_energies"] = not _equilibrium
         # Details to identify lines
         conditions["parse_local_global_quanta"] = (not _equilibrium) or export_lines
+        # Finally, LOAD :
         sf.fetch_databank(**conditions)
     elif exists(databank):
         conditions = {
@@ -681,14 +756,34 @@ def _is_at_equilibrium():
                 + "and define the format there. More information on "
                 + "https://radis.readthedocs.io/en/latest/lbl/lbl.html#configuration-file"
             )
+
+        # Columns to load
+        if export_lines:
+            conditions["load_columns"] = "all"
+        elif not _equilibrium:
+            conditions["load_columns"] = "noneq"
+        else:
+            conditions["load_columns"] = "equilibrium"
+
         conditions["load_energies"] = not _equilibrium
+        # Finally, LOAD :
         sf.load_databank(**conditions)
 
     else:  # manual mode: get from user-defined line databases defined in ~/radis.json
+
+        # Columns to load
+        if export_lines:
+            load_columns = "all"
+        elif not _equilibrium:
+            load_columns = "noneq"
+        else:
+            load_columns = "equilibrium"
+
         sf.load_databank(
             databank,
             load_energies=not _equilibrium,  # no need to load/calculate energies at eq.
             drop_columns=drop_columns,
+            load_columns=load_columns,
         )
 
     #    # Get optimisation strategies
@@ -734,7 +829,10 @@ def _is_at_equilibrium():
             name=name,
         )
 
-    return s
+    if return_factory:
+        return s, sf
+    else:
+        return s
 
 
 # --------------------------
diff --git a/radis/lbl/factory.py b/radis/lbl/factory.py
index fd94894fd..709531854 100644
--- a/radis/lbl/factory.py
+++ b/radis/lbl/factory.py
@@ -465,7 +465,17 @@ def __init__(
         )
 
         # Storing inital value of wstep if wstep != "auto"
-        self.wstep = wstep
+        self._wstep = wstep
+
+        # Set default variables from config:
+        import radis
+
+        self._sparse_dlm = radis.config[
+            "SPARSE_WAVERANGE"
+        ]  # target value (can be 'auto'), stored for next calculatinos
+        self.params["sparse_dlm"] = radis.config[
+            "SPARSE_WAVERANGE"
+        ]  # value evaluated at each new spectrum calculation
 
         # Init variables
         # --------------
@@ -1572,17 +1582,24 @@ def _generate_wavenumber_arrays(self):
         ``SpectrumFactory.wavenumber_calc`` the spectral range used for calculation, that
         includes neighbour lines within ``neighbour_lines`` distance."""
 
+        import radis
+
         self.profiler.start("generate_wavenumber_arrays", 2)
         # calculates minimum FWHM of lines
         self._calc_min_width(self.df1)
 
         # Setting wstep to optimal value and rounding it to a degree 3
-        if self.wstep == "auto":
-            import radis
+        if self._wstep == "auto" or type(self.params.wstep) == list:
 
-            self.params.wstep = round_off(
+            wstep_calc = round_off(
                 self.min_width / radis.config["GRIDPOINTS_PER_LINEWIDTH_WARN_THRESHOLD"]
             )
+
+            if type(self.params.wstep) == list:
+                self.params.wstep = min(self.params.wstep[1], wstep_calc)
+            else:
+                self.params.wstep = wstep_calc
+
             self.warnings["AccuracyWarning"] = "ignore"
 
         truncation = self.params.truncation
@@ -1622,9 +1639,20 @@ def _generate_wavenumber_arrays(self):
         # AccuracyWarning. Check there are enough gridpoints per line.
         self._check_accuracy(self.params.wstep)
 
-        self.profiler.stop("generate_wavenumber_arrays", "Generated Wavenumber Arrays")
+        # Set sparse waverange mode
 
-        import radis
+        # Setting wstep to optimal value and rounding it to a degree 3
+        if self._sparse_dlm == "auto":
+            sparsity = len(wavenumber_calc) / len(self.df1)
+            self.params["sparse_dlm"] = (
+                sparsity > 1.0
+            )  # works ; TODO : set a threshold based on more data
+            if self.verbose >= 2:
+                print(
+                    f"Sparsity (grid points/lines) = {sparsity:.1f}. Set sparse_dlm to {self.params['sparse_dlm']}"
+                )
+
+        self.profiler.stop("generate_wavenumber_arrays", "Generated Wavenumber Arrays")
 
         if radis.config["DEBUG_MODE"]:
             assert (wavenumber_calc[woutrange[0] : woutrange[1]] == wavenumber).all()
diff --git a/radis/lbl/loader.py b/radis/lbl/loader.py
index 74c4dcf67..eac33bca3 100644
--- a/radis/lbl/loader.py
+++ b/radis/lbl/loader.py
@@ -58,6 +58,7 @@
 import numpy as np
 import pandas as pd
 
+from radis import config
 from radis.db.classes import get_molecule
 from radis.db.molecules import getMolecule
 from radis.db.molparam import MolParams
@@ -67,7 +68,12 @@
 from radis.io.exomol import fetch_exomol
 from radis.io.hdf5 import hdf2df
 from radis.io.hitemp import fetch_hitemp
-from radis.io.hitran import hit2df, parse_global_quanta, parse_local_quanta
+from radis.io.hitran import (
+    fetch_hitran,
+    hit2df,
+    parse_global_quanta,
+    parse_local_quanta,
+)
 from radis.io.query import fetch_astroquery
 from radis.io.tools import drop_object_format_columns, replace_PQR_with_m101
 from radis.levels.partfunc import (
@@ -199,7 +205,7 @@
     "Pshft",
     "El",
 ]
-""" dict: drop all columns but these if using ``drop_columns='all'`` in load_databank
+""" list: drop all columns but these if using ``drop_columns='all'`` in load_databank
 Note: nonequilibrium calculations wont be possible anymore and it wont be possible
 to identify lines with :py:meth:`~radis.spectrum.spectrum.Spectrum.line_survey`
 
@@ -209,6 +215,31 @@
 - 'cdsd-hitemp' (CDSD HITEMP): :data:`~radis.io.cdsd.columns_hitemp`,
 - 'cdsd-4000': (CDSD 4000) :data:`~radis.io.cdsd.columns_4000`,
 """
+required_non_eq = [
+    "branch",
+    "jl",
+    "vl",
+    "vu",
+    "v1l",
+    "v2l",
+    "l2l",
+    "v3l",
+    "v1u",
+    "v2u",
+    "l2u",
+    "v3u",
+    "polyl",
+    "wangl",
+    "rankl",
+    "polyu",
+    "wangu",
+    "ranku",
+]
+"""list: column names required for non-equilibrium calculations.
+See load_column= key of fetch_databank() and load_databank() """
+# TODO refactor : directly go & parse the identifications names (globu, locu, etc.)
+# in radis.io.hitran ?
+# For the moment we just try to be exhaustive
 
 # @dev: Sanity checks
 # (make sure all variables are defined everywhere)
@@ -406,7 +437,7 @@ class Parameters(ConditionDict):
 
     # hardcode attribute names, to prevent typos and the declaration of unwanted parameters
     __slots__ = [
-        "add_at_used",
+        # "add_at_used",
         "broadening_method",
         "truncation",
         "neighbour_lines",
@@ -428,6 +459,7 @@ class Parameters(ConditionDict):
         "parfuncpath",
         "parsum_mode",
         "pseudo_continuum_threshold",
+        "sparse_dlm",
         "warning_broadening_threshold",
         "warning_linestrength_cutoff",
         "wavenum_max_calc",
@@ -467,11 +499,12 @@ def __init__(self):
         self.dlm_log_pG = _gaussian_step(
             0.01
         )  #: float : Gaussian step DLM lineshape database. Default _gaussian_step(0.01)
-        self.add_at_used = None  # use Cython-accelerated code
+        # self.add_at_used = None  # use Cython-accelerated code
         self.include_neighbouring_lines = True
         """bool: if ``True``, includes the contribution of off-range, neighbouring
         lines because of lineshape broadening. Default ``True``."""
         self.parsum_mode = "full summation"  #: int : "full summation" or "tabulation"  . calculation mode of parittion function. See :py:class:`~radis.levels.partfunc.RovibParFuncCalculator`
+        self.sparse_dlm = "auto"  #: str: "auto", True, False  . Sparse DLM calculation. See :py:meth:`radis.lbl.broadening.BroadenFactory._apply_lineshape_DLM`
 
 
 class MiscParams(ConditionDict):
@@ -502,7 +535,8 @@ class MiscParams(ConditionDict):
         "warning_linestrength_cutoff",
         "total_lines",
         "zero_padding",
-        "hdf5_engine",
+        "memory_mapping_engine",
+        "add_at_used",  # function used in DIT ; a Cython and a pure-Python version exist
     ]
 
     def __init__(self):
@@ -522,7 +556,13 @@ def __init__(self):
         )
         self.warning_linestrength_cutoff = None  #: float [0-1]: raise a warning if the sum of linestrength cut is above that
         self.total_lines = 0  #: int : number of lines in database.
-        self.hdf5_engine = "pytables"  # 'pytables', 'vaex' (/!\ experimental in 0.9.30)
+        self.memory_mapping_engine = config[
+            "MEMORY_MAPPING_ENGINE"
+        ]  # 'pytables', 'vaex', 'feather'
+
+        self.add_at_used = (
+            ""  # function used in DIT ; a Cython and a pure-Python version exist
+        )
 
 
 def format_paths(s):
@@ -673,6 +713,33 @@ def __init__(self):
         """MolParam: contains information about molar mass; isotopic abundance.
 
         See :py:class:`~radis.db.molparam.MolParams`"""
+        # TODO @dev : Refactor : turn it into a Dictinoary? (easier to store as JSON Etc.)
+
+        # Extra paramaters :
+        # HARDCODED molar mass; for WIP ExoMol implementation, until MolParams
+        # is an attribute and can be updated with definitions from ExoMol.
+        # https://github.com/radis/radis/issues/321
+
+        self._EXTRA_MOLAR_MASS = config["molparams"]["molar_mass"]
+        """Extra molar mass when not found in HITRAN molecular parameter database
+        ::
+            self._EXTRA_MOLAR_MASS[molecule][isotope] = M (g/mol)
+
+        See :py:func:`radis.lbl.base.BaseFactory.get_molar_mass`
+        """
+
+        # HARDCODED isotopic abundance; for WIP ExoMol implementation, until MolParams
+        # is an attribute and can be updated with definitions from ExoMol.
+        # https://github.com/radis/radis/issues/321
+        self._EXTRA_ABUNDANCES = config["molparams"]["abundances"]
+        """Extra isotopic abundances when not found in HITRAN molecular parameter database
+        ::
+            self._EXTRA_ABUNDANCES[molecule][isotope] = Ia
+
+        """
+
+        # Profiler
+        self.profiler = None
 
     def _reset_profiler(self, verbose):
         """Reset :py:class:`~radis.misc.profiler.Profiler`
@@ -758,7 +825,6 @@ def init_databank(self, *args, **kwargs):
             ``True``, includes off-range, neighbouring lines that contribute
             because of lineshape broadening. The ``neighbour_lines``
             parameter is used to determine the limit. Default ``True``.
-        *Other arguments are related to how to open the files*
         drop_columns: list
             columns names to drop from Line DataFrame after loading the file.
             Not recommended to use, unless you explicitely want to drop information
@@ -767,6 +833,21 @@ def init_databank(self, *args, **kwargs):
             are dropped. See :data:`~radis.lbl.loader.drop_auto_columns_for_dbformat`
             and :data:`~radis.lbl.loader.drop_auto_columns_for_levelsfmt`.
             Default ``'auto'``.
+        load_columns: list, ``'all'``, ``'equilibrium'``, ``'noneq'``
+            columns names to load.
+            If ``'equilibrium'``, only load the columns required for equilibrium
+            calculations. If ``'noneq'``, also load the columns required for
+            non-LTE calculations. See :data:`~radis.lbl.loader.drop_all_but_these`.
+            If ``'all'``, load everything. Note that for performances, it is
+            better to load only certain columsn rather than loading them all
+            and dropping them with ``drop_columns``.
+            Default ``'equilibrium'``.
+
+            .. warning::
+                if using ``'equilibrium'``, not all parameters will be available
+                for a Spectrum :py:func:`~radis.spectrum.spectrum.Spectrum.line_survey`.
+
+        *Other arguments are related to how to open the files*
 
         Notes
         -----
@@ -781,6 +862,7 @@ def init_databank(self, *args, **kwargs):
         - Load from local files: :meth:`~radis.lbl.loader.DatabankLoader.load_databank`
         - Reload databank: :meth:`~radis.lbl.loader.DatabankLoader._check_line_databank`
         """
+        # TODO : refactor drop_columns/load_columns
 
         # Check inputs
         (
@@ -794,6 +876,7 @@ def init_databank(self, *args, **kwargs):
             db_use_cached,
             lvl_use_cached,
             drop_columns,
+            load_columns,
             load_energies,
             include_neighbouring_lines,
         ) = self._check_database_params(*args, **kwargs)
@@ -826,7 +909,7 @@ def init_databank(self, *args, **kwargs):
     def fetch_databank(
         self,
         source="hitran",
-        exomol_database=None,
+        database="default",
         parfunc=None,
         parfuncfmt="hapi",
         levels=None,
@@ -837,7 +920,8 @@ def fetch_databank(
         drop_non_numeric=True,
         db_use_cached=True,
         lvl_use_cached=True,
-        hdf5_engine="default",
+        memory_mapping_engine="default",
+        load_columns="equilibrium",
         parallel=True,
     ):
         """Fetch the latest databank files from HITRAN or HITEMP with the
@@ -848,9 +932,21 @@ def fetch_databank(
         source: ``'hitran'``, ``'hitemp'``, ``'exomol'``
             [Download database lines from the latest HITRAN (see [HITRAN-2016]_),
             HITEMP (see [HITEMP-2010]_  )] or EXOMOL see [ExoMol-2020]_  ) databases.
-        exomol_database: None
-            if fetching from ''`exomol`'', choose which database to use. Keep
-            ``None`` to use the recommended one. See all available databases
+        database: ``'full'``, ``'range'``, name of an ExoMol database, or ``'default'``
+            if fetching from HITRAN, ``'full'`` download the full database and register
+            it, ``'range'`` download only the lines in the range of the molecule.
+
+            .. note::
+                ``'range'`` will be faster, but will require a new download each time
+                you'll change the range. ``'full'`` is slower and takes more memory, but
+                will be downloaded only once.
+
+            Default is ``'full'``.
+
+            If fetching from HITEMP, only ``'full'`` is available.
+
+            if fetching from ''`exomol`'', use this parameter to choose which database
+            to use. Keep ``'default'`` to use the recommended one. See all available databases
             with :py:func:`radis.io.exomol.get_exomol_database_list`
 
 
@@ -894,12 +990,26 @@ def fetch_databank(
             will be left untouched.
         db_use_cached: bool, or ``'regen'``
             use cached
-        hdf5_engine: ``'pytables'``, ``'vaex'``
+        memory_mapping_engine: ``'pytables'``, ``'vaex'``, ``'feather'``
             which library to use to read HDF5 files (they are incompatible: ``'pytables'`` is
-            row-major while ``'vaex'`` is column-major)
+            row-major while ``'vaex'`` is column-major) or other memory-mapping formats
+            If ``'default'``, use the value from ~/radis.json `["MEMORY_MAPPING_ENGINE"]`
         parallel: bool
             if ``True``, uses joblib.parallel to load database with multiple processes
             (works only for HITEMP files)
+        load_columns: list, ``'all'``, ``'equilibrium'``, ``'noneq'``
+            columns names to load.
+            If ``'equilibrium'``, only load the columns required for equilibrium
+            calculations. If ``'noneq'``, also load the columns required for
+            non-LTE calculations. See :data:`~radis.lbl.loader.drop_all_but_these`.
+            If ``'all'``, load everything. Note that for performances, it is
+            better to load only certain columsn rather than loading them all
+            and dropping them with ``drop_columns``.
+            Default ``'equilibrium'``.
+
+            .. warning::
+                if using ``'equilibrium'``, not all parameters will be available
+                for a Spectrum :py:func:`~radis.spectrum.spectrum.Spectrum.line_survey`.
 
         Notes
         -----
@@ -933,22 +1043,25 @@ def fetch_databank(
             raise NotImplementedError("source: {0}".format(source))
         if source == "hitran":
             dbformat = "hitran"
+            if database == "default":
+                database = "full"
         elif source == "hitemp":
             dbformat = (
                 "hitemp-radisdb"  # downloaded in RADIS local databases ~/.radisdb
             )
+            if database == "default":
+                database = "full"
         elif source == "exomol":
             dbformat = "exomol-radisdb"  # downloaded in RADIS local databases ~/.radisdb  # Note @EP : still WIP.
-        if exomol_database != None:
-            assert source == "exomol"
+
         if [parfuncfmt, source].count("exomol") == 1:
             self.warn(
                 f"Using lines from {source} but partition functions from {parfuncfmt}"
                 + "for consistency we recommend using lines and partition functions from the same database",
                 "AccuracyWarning",
             )
-        if hdf5_engine == "default":
-            hdf5_engine = self.misc.hdf5_engine
+        if memory_mapping_engine == "default":
+            memory_mapping_engine = self.misc.memory_mapping_engine
 
         # Get inputs
         molecule = self.input.molecule
@@ -978,6 +1091,20 @@ def fetch_databank(
         self.params.db_use_cached = db_use_cached
         self.params.lvl_use_cached = lvl_use_cached
 
+        # %% Which columns to load
+        if load_columns == "equilibrium":
+            columns = list(drop_all_but_these)
+        elif load_columns == "noneq":
+            columns = list(set(drop_all_but_these) | set(required_non_eq))
+        elif load_columns == "all":
+            columns = None  # see fetch_hitemp, fetch_hitran, etc.
+        elif isinstance(load_columns, list):
+            columns = list(set(drop_all_but_these) | set(load_columns))
+        else:
+            raise ValueError(
+                f"Expected a list or 'all' for `load_columns`, got `load_columns={load_columns}"
+            )
+
         # %% Init Line database
         # ---------------------
         self._reset_references()  # bibliographic references
@@ -986,48 +1113,77 @@ def fetch_databank(
             self.reftracker.add(doi["HITRAN-2016"], "line database")  # [HITRAN-2016]_
             self.reftracker.add(doi["Astroquery"], "data retrieval")  # [Astroquery]_
 
-            if hdf5_engine != "pytables":
-                raise NotImplementedError(f"{hdf5_engine} with ExoMol files")
+            if database == "full":
 
-            # Query one isotope at a time
-            if isotope == "all":
-                raise ValueError(
-                    "Please define isotope explicitely (cannot use 'all' with fetch_databank('hitran'))"
-                )
-            isotope_list = self._get_isotope_list()
+                if isotope == "all":
+                    isotope_list = None
+                else:
+                    isotope_list = ",".join([str(k) for k in self._get_isotope_list()])
 
-            frames = []  # lines for all isotopes
-            for iso in isotope_list:
-                df = fetch_astroquery(
-                    molecule, iso, wavenum_min, wavenum_max, verbose=self.verbose
+                df, local_paths = fetch_hitran(
+                    molecule,
+                    isotope=isotope_list,
+                    load_wavenum_min=wavenum_min,
+                    load_wavenum_max=wavenum_max,
+                    columns=columns,
+                    cache=db_use_cached,
+                    verbose=self.verbose,
+                    return_local_path=True,
+                    engine=memory_mapping_engine,
+                    parallel=parallel,
                 )
-                if len(df) > 0:
-                    frames.append(df)
-                else:
-                    self.warn(
-                        "No line for isotope n°{}".format(iso),
-                        "EmptyDatabaseWarning",
-                        level=2,
+                self.params.dbpath = ",".join(local_paths)
+
+            elif database == "range":
+
+                # Query one isotope at a time
+                if isotope == "all":
+                    raise ValueError(
+                        "Please define isotope explicitely (cannot use 'all' with fetch_databank('hitran'))"
                     )
+                isotope_list = self._get_isotope_list()
 
-            # Merge
-            if frames == []:
-                raise EmptyDatabaseError(
-                    f"{molecule} has no lines on range "
-                    + "{0:.2f}-{1:.2f} cm-1".format(wavenum_min, wavenum_max)
-                )
-            if len(frames) > 1:
-                # Note @dev : may be faster/less memory hungry to keep lines separated for each isotope. TODO : test both versions
-                for df in frames:
-                    assert "iso" in df.columns
-                df = pd.concat(frames, ignore_index=True)  # reindex
+                frames = []  # lines for all isotopes
+                for iso in isotope_list:
+                    df = fetch_astroquery(
+                        molecule, iso, wavenum_min, wavenum_max, verbose=self.verbose
+                    )
+                    if len(df) > 0:
+                        frames.append(df)
+                    else:
+                        self.warn(
+                            "No line for isotope n°{}".format(iso),
+                            "EmptyDatabaseWarning",
+                            level=2,
+                        )
+
+                # Merge
+                if frames == []:
+                    raise EmptyDatabaseError(
+                        f"{molecule} has no lines on range "
+                        + "{0:.2f}-{1:.2f} cm-1".format(wavenum_min, wavenum_max)
+                    )
+                if len(frames) > 1:
+                    # Note @dev : may be faster/less memory hungry to keep lines separated for each isotope. TODO : test both versions
+                    for df in frames:
+                        assert "iso" in df.columns
+                    df = pd.concat(frames, ignore_index=True)  # reindex
+                else:
+                    df = frames[0]
+                self.params.dbpath = "fetched from hitran"
             else:
-                df = frames[0]
-            self.params.dbpath = "fetched from hitran"
+                raise ValueError(
+                    f"Got `database={database}`. When fetching HITRAN, choose `database='full'` to download all database (once for all) or `database='range'` to download only the lines in the current range."
+                )
 
         elif source == "hitemp":
             self.reftracker.add(doi["HITEMP-2010"], "line database")  # [HITEMP-2010]_
 
+            if database != "full":
+                raise ValueError(
+                    f"Got `database={database}`. When fetching HITEMP, only the `database='full'` option is available."
+                )
+
             # Download, setup local databases, and fetch (use existing if possible)
 
             if isotope == "all":
@@ -1040,10 +1196,11 @@ def fetch_databank(
                 isotope=isotope_list,
                 load_wavenum_min=wavenum_min,
                 load_wavenum_max=wavenum_max,
+                columns=columns,
                 cache=db_use_cached,
                 verbose=self.verbose,
                 return_local_path=True,
-                engine=hdf5_engine,
+                engine=memory_mapping_engine,
                 parallel=parallel,
             )
             self.params.dbpath = ",".join(local_paths)
@@ -1057,9 +1214,14 @@ def fetch_databank(
         elif source == "exomol":
             self.reftracker.add(doi["ExoMol-2020"], "line database")  # [ExoMol-2020]
 
+            if database in ["full", "range"]:
+                raise ValueError(
+                    f"Got `database={database}`. When fetching ExoMol, only the `database=` key to retrieve a speciifc database. Use `database='default'` to get the recommended database. See more informatino in radis.io.fetch_exomol()"
+                )
+
             # Download, setup local databases, and fetch (use existing if possible)
-            if hdf5_engine != "pytables":
-                raise NotImplementedError(f"{hdf5_engine} with ExoMol files")
+            if memory_mapping_engine not in ["vaex", "feather"]:
+                raise NotImplementedError(f"{memory_mapping_engine} with ExoMol files")
 
             if isotope == "all":
                 raise ValueError(
@@ -1075,14 +1237,16 @@ def fetch_databank(
             for iso in isotope_list:
                 df, local_path, Z_exomol = fetch_exomol(
                     molecule,
-                    database=exomol_database,
+                    database=database,
                     isotope=iso,
                     load_wavenum_min=wavenum_min,
                     load_wavenum_max=wavenum_max,
+                    columns=columns,
                     cache=db_use_cached,
                     verbose=self.verbose,
                     return_local_path=True,
                     return_partition_function=True,
+                    engine=memory_mapping_engine,
                 )
                 # @dev refactor : have a DatabaseClass from which we load lines and partition functions
                 if len(df) > 0:
@@ -1099,8 +1263,19 @@ def fetch_databank(
             if len(frames) > 1:
                 # Note @dev : may be faster/less memory hungry to keep lines separated for each isotope. TODO : test both versions
                 for df in frames:
-                    assert "iso" in df.columns
+                    if "iso" not in df.columns:
+                        assert "iso" in df.attrs
+                        df["iso"] = df.attrs["iso"]
+                # Keep attributes:
+                from radis.misc.basics import intersect
+
+                attrs = frames[0].attrs
+                for df in frames[1:]:
+                    attrs = intersect(attrs, df.attrs)
+                del attrs["iso"]  # added as a column (different for each line)
+                # Merge:
                 df = pd.concat(frames, ignore_index=True)  # reindex
+                df.attrs = attrs
                 self.params.dbpath = ",".join(local_paths)
             else:
                 df = frames[0]
@@ -1119,11 +1294,11 @@ def fetch_databank(
         # (note : this is now done in 'fetch_hitemp' before saving to the disk)
         # spectroscopic quantum numbers will be needed for nonequilibrium calculations, and line survey.
         if parse_local_global_quanta and "locu" in df:
-            df = parse_local_quanta(df, molecule)
+            df = parse_local_quanta(df, molecule, verbose=self.verbose)
         if (
             parse_local_global_quanta and "globu" in df
         ):  # spectroscopic quantum numbers will be needed for nonequilibrium calculations :
-            df = parse_global_quanta(df, molecule)
+            df = parse_global_quanta(df, molecule, verbose=self.verbose)
 
         # Remove non numerical attributes
         if drop_non_numeric:
@@ -1182,6 +1357,7 @@ def load_databank(
         load_energies=False,
         include_neighbouring_lines=True,
         drop_columns="auto",
+        load_columns="equilibrium",
     ):
         """Loads databank from shortname in the :ref:`Configuration file.
         <label_lbl_config_file>` (`~/radis.json`), or by manually setting all
@@ -1257,6 +1433,20 @@ def load_databank(
             available in :py:meth:`~radis.spectrum.spectrum.Spectrum` method.
             Warning: nonequilibrium calculations are not possible in this mode.
             Default ``'auto'``.
+        load_columns: list, ``'all'``, ``'equilibrium'``, ``'noneq'``
+            columns names to load.
+            If ``'equilibrium'``, only load the columns required for equilibrium
+            calculations. If ``'noneq'``, also load the columns required for
+            non-LTE calculations. See :data:`~radis.lbl.loader.drop_all_but_these`.
+            If ``'all'``, load everything. Note that for performances, it is
+            better to load only certain columsn rather than loading them all
+            and dropping them with ``drop_columns``.
+            Default ``'equilibrium'``.
+
+            .. warning::
+                if using ``'equilibrium'``, not all parameters will be available
+                for a Spectrum :py:func:`~radis.spectrum.spectrum.Spectrum.line_survey`.
+
 
         See Also
         --------
@@ -1289,6 +1479,7 @@ def load_databank(
             db_use_cached,
             lvl_use_cached,
             drop_columns,
+            load_columns,
             load_energies,
             include_neighbouring_lines,
         ) = self._check_database_params(
@@ -1302,8 +1493,9 @@ def load_databank(
             db_use_cached=db_use_cached,
             lvl_use_cached=lvl_use_cached,
             load_energies=load_energies,
-            include_neighbouring_lines=include_neighbouring_lines,
             drop_columns=drop_columns,
+            load_columns=load_columns,
+            include_neighbouring_lines=include_neighbouring_lines,
         )
         # Let's store all params so they can be parsed by "get_conditions()"
         # and saved in output spectra information
@@ -1332,6 +1524,7 @@ def load_databank(
             levelsfmt=levelsfmt,
             db_use_cached=db_use_cached,
             drop_columns=drop_columns,
+            load_columns=load_columns,
             include_neighbouring_lines=include_neighbouring_lines,
         )
         self.misc.total_lines = len(self.df0)  # will be stored in Spectrum metadata
@@ -1369,8 +1562,9 @@ def _check_database_params(
         db_use_cached=None,
         lvl_use_cached=None,
         load_energies=False,
-        include_neighbouring_lines=True,
         drop_columns="auto",
+        load_columns="required",
+        include_neighbouring_lines=True,
     ):
         """Check that database parameters are valid, in particular that paths
         exist. Loads all parameters if a Database from radis.json config file was
@@ -1507,6 +1701,7 @@ def _check_database_params(
             db_use_cached,
             lvl_use_cached,
             drop_columns,
+            load_columns,
             load_energies,
             include_neighbouring_lines,
         )
@@ -1561,6 +1756,7 @@ def init_database(
         add_info=["Tvib", "Trot"],
         add_date="%Y%m%d",
         compress=True,
+        **kwargs,
     ):
         """Init a :class:`~radis.tools.database.SpecDatabase` folder in
         ``path`` to later store our spectra. Spectra can also be automatically
@@ -1593,6 +1789,11 @@ def init_database(
             and takes less memory space. Default ``True``.
             If ``2``, additionaly remove all redundant quantities.
 
+        Other Parameters
+        ----------------
+        **kwargs: **dict
+            arguments sent to :py:class:`~radis.tools.database.SpecDatabase` initialization.
+
         Returns
         -------
         db: SpecDatabase
@@ -1602,7 +1803,9 @@ def init_database(
         .. minigallery:: radis.lbl.loader.DatabankLoader.init_database
         """
 
-        db = SpecDatabase(path, add_info=add_info, add_date=add_date, binary=compress)
+        db = SpecDatabase(
+            path, add_info=add_info, add_date=add_date, binary=compress, **kwargs
+        )
 
         self.SpecDatabase = db
         self.database = format_paths(path)  # just to appear in conditions
@@ -1793,7 +1996,8 @@ def _load_databank(
         dbformat,
         levelsfmt,
         db_use_cached,
-        drop_columns="auto",
+        drop_columns,
+        load_columns,
         include_neighbouring_lines=True,
     ) -> pd.DataFrame:
         """Loads all available database files and keep the relevant one.
@@ -1820,7 +2024,20 @@ def _load_databank(
             are dropped, including all information about lines that could be otherwise
             available in :py:meth:`~radis.spectrum.spectrum.Spectrum` method.
             Warning: nonequilibrium calculations are not possible in this mode.
-            Default ``'auto'``.
+        load_columns: list, ``'all'``, ``'equilibrium'``, ``'noneq'``
+            columns names to load.
+            If ``'equilibrium'``, only load the columns required for equilibrium
+            calculations. If ``'noneq'``, also load the columns required for
+            non-LTE calculations. See :data:`~radis.lbl.loader.drop_all_but_these`.
+            If ``'all'``, load everything. Note that for performances, it is
+            better to load only certain columsn rather than loading them all
+            and dropping them with ``drop_columns``.
+            Default ``'equilibrium'``.
+
+            .. warning::
+                if using ``'equilibrium'``, not all parameters will be available
+                for a Spectrum :py:func:`~radis.spectrum.spectrum.Spectrum.line_survey`.
+
 
         Other Parameters
         ----------------
@@ -1853,6 +2070,20 @@ def _load_databank(
                 + drop_auto_columns_for_levelsfmt[levelsfmt]
             )
 
+        # Which columns to load
+        if load_columns == "equilibrium":
+            columns = list(drop_all_but_these)
+        elif load_columns == "noneq":
+            columns = list(set(drop_all_but_these) | set(required_non_eq))
+        elif load_columns == "all":
+            columns = None  # see fetch_hitemp, fetch_hitran, etc.
+        elif isinstance(load_columns, list):
+            columns = list(set(drop_all_but_these) | set(load_columns))
+        else:
+            raise ValueError(
+                f"Expected a list or 'all' for `load_columns`, got `load_columns={load_columns}"
+            )
+
         # subroutine load_and_concat
         # --------------------------------------
         def load_and_concat(files):
@@ -1890,11 +2121,14 @@ def load_and_concat(files):
                             filename,
                             version="hitemp" if dbformat == "cdsd-hitemp" else "4000",
                             cache=db_use_cached,
+                            # load_columns=columns,  # not possible with "pytables-fixed"
                             verbose=verbose,
                             drop_non_numeric=True,
                             load_wavenum_min=wavenum_min,
                             load_wavenum_max=wavenum_max,
+                            engine="pytables",
                         )
+                        # TODO: implement load_columns
                     elif dbformat in ["hitran", "hitemp"]:
                         if dbformat == "hitran":
                             self.reftracker.add(
@@ -1907,10 +2141,12 @@ def load_and_concat(files):
                         df = hit2df(
                             filename,
                             cache=db_use_cached,
+                            # load_columns=columns,  # not possible with "pytables-fixed"
                             verbose=verbose,
                             drop_non_numeric=True,
                             load_wavenum_min=wavenum_min,
                             load_wavenum_max=wavenum_max,
+                            engine="pytables",
                         )
                     elif dbformat in ["hdf5-radisdb", "hitemp-radisdb"]:
                         if dbformat == "hitemp-radisdb":
@@ -1924,6 +2160,7 @@ def load_and_concat(files):
                             )
                         df = hdf2df(
                             filename,
+                            columns=columns,
                             # cache=db_use_cached,
                             verbose=verbose,
                             # drop_non_numeric=True,
@@ -1932,7 +2169,7 @@ def load_and_concat(files):
                             else None,
                             load_wavenum_min=wavenum_min,
                             load_wavenum_max=wavenum_max,
-                            engine="pytables",
+                            engine="guess",
                         )
                     elif dbformat in ["exomol"]:
                         # self.reftracker.add("10.1016/j.jqsrt.2020.107228", "line database")  # [ExoMol-2020]
@@ -2225,14 +2462,14 @@ def get_best_match():
 
                     raise ValueError(
                         "No spectrum found in database that matched "
-                        + "given conditions. See best case found above"
+                        + "given conditions. See best case found above. You can also add parameters in SpectrumFactory._ignoreautoretrieveconditions to ignore them"
                     )
             else:
                 # just a print, then calculate
                 if self.verbose:
                     print(
                         "No spectrum found in database that "
-                        + "matched given conditions."
+                        + "matched given conditions. You can also add parameters in SpectrumFactory._ignoreautoretrieveconditions to ignore them"
                     )
                 return None
 
@@ -2412,6 +2649,7 @@ def set_abundance(self, molecule, isotope, abundance):
 
         Examples
         --------
+        ::
 
             from radis import SpectrumFactory
 
diff --git a/radis/levels/partfunc.py b/radis/levels/partfunc.py
index 5ad933b5c..939a1ec33 100644
--- a/radis/levels/partfunc.py
+++ b/radis/levels/partfunc.py
@@ -54,6 +54,7 @@
 # TODO: store molecule_data.json in the H5 file metadata. If not done already.
 
 
+import os
 import sys
 from os.path import exists
 from warnings import warn
@@ -220,6 +221,15 @@ def __init__(self, electronic_state, mode="full summation", verbose=False):
 
         if not mode in ["full summation", "tabulation"]:
             raise ValueError("Choose mode = one of 'full summation', 'tabulation'")
+
+        # vaex processes are stuck if ran from Spyder. See https://github.com/spyder-ide/spyder/issues/16183
+        if mode == "tabulation" and any("SPYDER" in name for name in os.environ):
+            from radis.misc.log import printwarn
+
+            printwarn(
+                "Spyder IDE detected while using parsum_mode='tabulation', which depends on `vaex`. This is the fastest way to compute partition functions, but Vaex processes may be stuck if ran from Spyder. See https://github.com/radis/radis/issues/338. You may consider using another IDE, or use `parsum_mode='full summation'` for the moment (note: starting another iPython console somehow releases the freeze in Spyder) \n"
+            )
+
         self.mode = mode
         self._tab_at = None  # tabulated function
         self._tab_at_noneq = None  # tabulated function
@@ -301,6 +311,8 @@ def _eq_tabulation_setup(self, N_bins):
         import vaex  # import delayed until now (takes ~2s to import)
 
         df = vaex.from_pandas(self.df)
+        # Vaex processs may get stuck in Spyder IDE. https://github.com/radis/radis/issues/338
+        # Temp fix : >>> df.executor.async_method = "awaitio"     (doesn't always work here)
 
         epsilon = 1e-4  # prevent log(0)
         df["logE"] = np.log(df["E"] + epsilon)  # to bin on a log grid
@@ -495,6 +507,8 @@ def _noneq_tabulation_setup(self, N_bins, vib_distribution, rot_distribution):
         import vaex  # import delayed until now (takes ~2s to import)
 
         df = vaex.from_pandas(self.df)
+        # Vaex processs may get stuck in Spyder IDE. https://github.com/radis/radis/issues/338
+        # Temp fix : >>> df.executor.async_method = "awaitio"     (doesn't always work here)
 
         epsilon = 1e-4  # prevent log(0)
         df["logEvib"] = np.log(df["Evib"] + epsilon)  # to bin on a log grid
@@ -850,6 +864,8 @@ def _noneq_3Tvib_tabulation_setup(self, N_bins, vib_distribution, rot_distributi
         import vaex  # import delayed until now (takes ~2s to import)
 
         df = vaex.from_pandas(self.df)
+        # Vaex processs may get stuck in Spyder IDE. https://github.com/radis/radis/issues/338
+        # Temp fix : >>> df.executor.async_method = "awaitio"     (doesn't always work here)
 
         epsilon = 1e-4  # prevent log(0)
 
@@ -1204,8 +1220,7 @@ def _at(self, T):
         try:
             return self.partitionSum(self.M, self.I, T)
         except Exception as err:
-            if "TIPS2017" in str(err) or "TIPS2020" in str(err):
-                # TIPS 2017 OutOfBoundError
+            if "TIPS" in str(err):  # improve error message
                 from radis.db import MOLECULES_LIST_NONEQUILIBRIUM
 
                 if self.molecule in MOLECULES_LIST_NONEQUILIBRIUM:
@@ -1213,11 +1228,13 @@ def _at(self, T):
                 else:
                     tip = ""
                 raise OutOfBoundError(
-                    "Partition functions not computed in TIPS-2017 "
+                    "Partition functions not computed in TIPS "
                     + f"for molecule {self.molecule}, isotope {self.I}. "
                     + f"Max range : {str(err)}"
                     + tip
                 )
+            else:
+                raise
 
 
 # %% Calculated partition functions (either from energy levels, or ab initio)
diff --git a/radis/los/slabs.py b/radis/los/slabs.py
index f07ce589a..31227d596 100644
--- a/radis/los/slabs.py
+++ b/radis/los/slabs.py
@@ -25,7 +25,7 @@
 from numpy import abs, allclose, arange, diff
 
 from radis.misc.arrays import anynan
-from radis.misc.basics import in_all, merge_lists
+from radis.misc.basics import in_all, intersect, merge_lists
 from radis.misc.debug import printdbg
 from radis.spectrum.spectrum import Spectrum
 
@@ -33,14 +33,6 @@
 # ----------------------------------------------------------------------
 
 
-def intersect(a, b):
-    """Returns intersection of two dictionaries on values."""
-    c = {}
-    for k in set(a.keys()) & set(b.keys()):  # work in Python 2?
-        c[k] = a[k] if (a[k] == b[k]) else "N/A"
-    return c
-
-
 def SerialSlabs(*slabs, **kwargs) -> Spectrum:
     # type: (*Spectrum, **dict) -> Spectrum
     r"""Adds several slabs along the line-of-sight.
diff --git a/radis/misc/arrays.py b/radis/misc/arrays.py
index 62c28a9e5..eb183eeba 100644
--- a/radis/misc/arrays.py
+++ b/radis/misc/arrays.py
@@ -40,6 +40,7 @@
 
 import numba
 import numpy as np
+from numba import bool_, float64, int32, int64
 from numpy import hstack
 from scipy.interpolate import interp1d
 
@@ -480,6 +481,253 @@ def numpy_add_at(LDM, k, l, m, I):
     add_at = rcx.add_at
 
 
+# @numba.njit
+# def non_zero_values_around2(a, n):
+#     """return a boolean array of same size as ``a`` where each position ``i``
+#     is ``True`` if there are non-zero points less than ``n`` index position
+#     away from ``a[i]``, and ``False`` if all points in ``a`` are 0 ``n``  index
+#     position away from from ``a[i]``
+#     """
+#     # # Cleaner version, but about 2x slower on real-life test cases:
+#     # #
+#     # %timeit non_zero_values_around(DLM[:, l, m], truncation)
+#     # 2.92 ms ± 99.3 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
+
+#     # %timeit non_zero_values_around2(DLM[:, l, m], truncation)
+#     # 5.11 ms ± 110 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
+#     # --------------
+
+#     # non_zero_values_around2()
+#     b = np.zeros(len(a)+2*n+1,  dtype=np.bool_)
+#     for pos in np.nonzero(a)[0]:
+#         b[pos:pos+2*n+1] = True
+#     return b[n:len(a)+n]
+
+
+@numba.njit(
+    bool_[:](float64[:], int64),
+    cache=True,
+)
+def non_zero_values_around(a, n):
+    """return a boolean array of same size as ``a`` where each position ``i``
+    is ``True`` if there are non-zero points less than ``n`` index position
+    away from ``a[i]``, and ``False`` if all points in ``a`` are 0 ``n``  index
+    position away from from ``a[i]``
+    """
+
+    # build the list
+    L = []
+    pos = -1  # found position of non-zero point
+    start = -1  # start position of subrange
+
+    for i, ai in enumerate(a):
+        if ai != 0:
+            pos = i
+            if start == -1:
+                if i == 0:
+                    start = 0
+                else:
+                    start = i - n
+        else:
+            if start == -1:
+                continue
+            elif pos == i - n - 1:
+                L.append((start, i - 1))
+                start = -1
+                pos = -1
+
+    if start != -1:
+        L.append((start, len(a) - 1))
+
+    # turn it into a boolean array
+    b = np.zeros(len(a), dtype=np.bool_)
+    for start, end in L:
+        b[max(0, start) : end + 1] = 1
+
+    return b
+
+
+# @numba.njit(
+#     numba.types.List(numba.types.containers.UniTuple(int64, 2))(int32[:], int64),
+#     cache=True,
+# )
+# def non_zero_ranges_around(a, n):
+#     """return a list of coordinates corresponding to non-zero values
+#     where each position ``i``
+#     is ``True`` if there are non-zero points less than ``n`` index position
+#     away from ``a[i]``, and ``False`` if all points in ``a`` are 0 ``n``  index
+#     position away from from ``a[i]``
+
+#     Parameters
+#     ----------
+#     a : array of int, line centers
+#     n : int or array of int, width
+#     """
+
+#     # build the list
+#     L = []
+#     pos = -1  # found position of non-zero point
+#     start = -1  # start position of subrange
+
+#     for i, ai in enumerate(a):
+#         if ai != 0:
+#             pos = i
+#             if start == -1:
+#                 if i == 0:
+#                     start = 0
+#                 else:
+#                     start = i - n
+#         else:
+#             if start == -1:
+#                 continue
+#             elif pos == i - n - 1:
+#                 L.append((start, i - 1))
+#                 start = -1
+#                 pos = -1
+
+#     if start != -1:
+#         L.append((start, len(a) - 1))
+
+#     return L
+
+
+# @numba.njit(
+#     bool_[:](int32[:], int64),
+#     cache=True,
+# )
+# def non_zero_values_around(a, n):
+#     """return a boolean array of same size as ``a`` where each position ``i``
+#     is ``True`` if there are non-zero points less than ``n`` index position
+#     away from ``a[i]``, and ``False`` if all points in ``a`` are 0 ``n``  index
+#     position away from from ``a[i]``
+#     """
+#     L = non_zero_ranges_around(a, n)
+
+#     # turn it into a boolean array
+#     b = np.zeros(len(a), dtype=np.bool_)
+#     for start, end in L:
+#         b[max(0, start) : end + 1] = 1
+
+#     return b
+
+
+@numba.njit(
+    (int64[:, :])(bool_[:]),
+    cache=True,
+)
+def non_zero_ranges_in_array(b):
+    """return a list of coordinates corresponding to non-zero values
+    in boolean array ``b``
+
+    Parameters
+    ----------
+    b : boolean array
+
+    Examples
+    --------
+    ::
+
+        b = np.array([0,0,1,1,0,1,0,1], dtype=bool)
+        non_zero_ranges_in_array(b)
+        >> ([2, 5, 7],
+            [4, 6, 8])
+    """
+
+    # build the list
+    L = []
+    start = -1  # start position of subrange
+
+    for i, bi in enumerate(b):
+        if bi:
+            if start == -1:
+                start = i
+        else:
+            if start == -1:
+                continue
+            else:
+                L.append((start, i))
+                start = -1
+
+    if start != -1:
+        L.append((start, len(b)))
+
+    return np.array(L)
+
+
+@numba.njit(
+    (bool_[:])(int64[:, :], int64),
+    cache=True,
+)
+def boolean_array_from_ranges(ranges, n):
+    """return a boolean array of length ``n`` where (``L[i][0]``, ``L[i][1]``)
+    give the ranges set to ``True``
+
+    Examples
+    --------
+    ::
+
+        L = np.array([[2,4], [5,6], [7,8]])
+        boolean_array_from_coordinates(*L.T, 8)
+        >>> np.array([0, 0, 1, 1, 0, 1, 0, 1], dtype=bool)
+
+    """
+    # build the list
+    b = np.zeros(n, dtype=bool_)
+    for i in range(len(ranges)):
+        b[ranges[i][0] : ranges[i][1]] = True
+
+    return b
+
+
+@numba.njit(
+    numba.types.Tuple((int64[:, :], float64[:]))(
+        int32[:], float64[:], float64[:], float64[:], int64, int64
+    ),
+    cache=True,
+)
+def sparse_add_at(ki0, Iv0, Iv1, weight, max_range, truncation_pts):
+    """Returns (start, stop) of non-zero ranges, and intensity ``I`` (length
+    ``truncation_pts``) for all lines ``Iv0`` and ``Iv1`` summed at ``ki0``
+    and ``ki0+1`` with weights ``weight``"""
+    I = np.zeros(max_range)
+    L = [
+        (0, 0) for k in range(0)
+    ]  # empty list; helps Numba infer type. See https://numba.pydata.org/numba-doc/0.41.0/user/troubleshoot.html#my-code-has-an-untyped-list-problem
+    n = truncation_pts
+
+    # initiate start
+    pos = ki0[0]
+    start = max(pos - n, 0)
+    end = max_range  # will be rewritten in the first call of the loop
+
+    # Loop over all lines :
+    for i in range(len(ki0)):
+        pos = ki0[i]
+
+        # Adjust range:
+        if pos > end:
+            # close the previous range:
+            L.append((start, end))
+            # reopen new range :
+            start = max(pos - n, 0)
+            end = min(
+                pos + 1 + n, max_range
+            )  # TODO: check if not +2 because line is added on ki0 and ki1 ()
+        else:
+            # we're in the middle of a range:
+            # keep the start, move the end
+            end = min(pos + 1 + n, max_range)
+
+        # Sum intensity  (equivalent of "add-at")
+        I[pos] += Iv0[i] * weight[i]
+        I[pos + 1] += Iv1[i] * weight[i]
+    # final close:
+    if len(L) == 0 or L[-1] != (start, end):
+        L.append((start, end))
+
+    return np.array(L), I
+
+
 if __name__ == "__main__":
     import pytest
 
diff --git a/radis/misc/basics.py b/radis/misc/basics.py
index 3522e7941..58cf261ec 100644
--- a/radis/misc/basics.py
+++ b/radis/misc/basics.py
@@ -116,14 +116,36 @@ def partition(pred, iterable):
 #            falses.append(item)
 #    return trues, falses
 
+
+def str2bool(s):
+    """Used to convert Pandas columns in str type to boolean
+    (note that by default bool("False")==True !)
+    """
+    if s in [True, "true", "True", 1, "1", "1.0"]:
+        return True
+    elif s in [False, "false", "False", 0, "0", "0.0"]:
+        return False
+    else:
+        raise ValueError
+
+
 # %% Compare / merge tools
 
 
+def intersect(a, b):
+    """Returns intersection of two dictionaries on values."""
+    c = {}
+    for k in set(a.keys()) & set(b.keys()):
+        c[k] = a[k] if (a[k] == b[k]) else "N/A"
+    return c
+
+
 def compare_dict(
     d1,
     d2,
     verbose="if_different",
     compare_as_paths=[],
+    compare_as_close=[],
     return_string=False,
     df1_str="Left",
     df2_str="Right",
@@ -141,6 +163,8 @@ def compare_dict(
     compare_as_paths: list of keys
         compare the values corresponding to given keys as path (irrespective of
         forward / backward slashes, or case )
+    compare_as_close: list of keys
+        compare with ``np.isclose(a,b)`` rather than ``a==b``
     verbose: boolean, or ``'if_different'``
         ``'if_different'`` means results will be shown only if there is a difference.
     return_string: boolean
@@ -163,7 +187,7 @@ def compare_dict(
 
     try:
 
-        print("{0:15}{1}\t{2}".format("Key", df1_str, df2_str))
+        print("{0:15}{1:17}\t{2}".format("Key", df1_str, df2_str))
         print("-" * 40)
         all_keys = set(list(d1.keys()) + list(d2.keys()))
         all_keys = [k for k in all_keys if k not in ignore_keys]
@@ -176,6 +200,12 @@ def compare_dict(
                         print("{0:15}{1}\t{2}".format(k, d1[k], d2[k]))
                     else:
                         s += 1
+                # Deal with is close case
+                elif k in compare_as_close:
+                    if not np.isclose(d1[k], d2[k]):
+                        print("{0:15}{1}\t{2}".format(k, d1[k], d2[k]))
+                    else:
+                        s += 1
                 # Other cases
                 else:
                     if d1[k] != d2[k]:
@@ -211,16 +241,26 @@ def compare_dict(
         sys.stdout = old_stdout
 
 
-def compare_lists(l1, l2, verbose="if_different", return_string=False):
+def compare_lists(
+    l1,
+    l2,
+    verbose="if_different",
+    return_string=False,
+    l1_str="Left",
+    l2_str="Right",
+    print_index=False,
+):
     """Compare 2 lists of elements that may not be of the same length, irrespective
     of order. Returns the ratio of elements [0-1] present in both lists. If verbose,
     prints the differences
+
     Parameters
     ----------
     l1, l2: list-like
     verbose: boolean, or 'if_different'
         'if_different' means results will be shown only if there is a difference.
         function is called twice
+
     Other Parameters
     ----------------
     verbose: boolean, or ``'if_different'``
@@ -228,6 +268,7 @@ def compare_lists(l1, l2, verbose="if_different", return_string=False):
     return_string: boolean
         if ``True``, returns message instead of just printing it (useful in error messages)
         Default ``False``
+
     Returns
     -------
     out: float [0-1]
@@ -238,19 +279,27 @@ def compare_lists(l1, l2, verbose="if_different", return_string=False):
     sys.stdout = newstdout = StringIO()  # capture all print
 
     try:
-
-        print("{0:20}\t{1}".format("Left", "Right"))
-        print("-" * 40)
+        tab = "        " if print_index else ""
+        print(tab + "{0:20}\t\t{1}".format(l1_str, tab + l2_str))
+        print(tab + "-" * (44 + len(tab)))
         all_keys = set(list(l1) + list(l2))
         s = 0  # counter of all matching keys
-        for k in all_keys:
+        for i, k in enumerate(all_keys):
             if k in l1 and k in l2:  # key in both lists
                 s += 1
             elif k in l1:
-                print("{0:20}\tN/A".format("{0} ({1})".format(k, type(k))))
+                l1_index_str = f"|#{l1.index(k):3}|  " if print_index else ""
+                print(
+                    "{0:20}\t\t{1}".format(
+                        l1_index_str + "{0} ({1})".format(k, type(k)), tab + "N/A"
+                    )
+                )
             else:
-                print("{0:20}\t{1} ({2})".format("N/A", k, type(k)))
-        print("-" * 40)
+                l2_index_str = f"|#{l2.index(k):3}|  " if print_index else ""
+                print(
+                    "{0:20}\t\t{1} ({2})".format(tab + "N/A", l2_index_str + k, type(k))
+                )
+        print(tab + "-" * (44 + len(tab)))
 
         if len(all_keys) == 0:
             out = 1
diff --git a/radis/misc/config.py b/radis/misc/config.py
index eeb41fe11..0ee71508f 100644
--- a/radis/misc/config.py
+++ b/radis/misc/config.py
@@ -39,11 +39,12 @@
 import configparser
 import json
 import os
-import shutil
 import warnings
 from json import JSONDecodeError
 from os.path import dirname, exists, expanduser, join
 
+import hjson
+
 from radis.misc.basics import compare_dict, compare_lists, stdpath
 from radis.misc.utils import DatabankNotFound, getProjectRoot
 from radis.misc.warning import DatabaseAlreadyExists
@@ -66,7 +67,11 @@ def get_config(configpath=CONFIG_PATH_JSON):
     jsonfile = CONFIG_PATH_DEFAULT
     with open(jsonfile) as f:
         try:
-            config = json.load(f)
+            config = hjson.load(f)
+            # read with Hjson to allow comments in CONFIG_PATH_DEFAULT
+            # we do not allow comments in (user) CONFIG_PATH_JSON
+            # because it is read/written automatically. So far there is no
+            # two-way JSON editor with comments. See https://github.com/radis/radis/issues/328
         except JSONDecodeError as err:
             raise JSONDecodeError(
                 "Error reading '{0}' (line {2} col {3}): \n{1}".format(
@@ -391,14 +396,40 @@ def init_radis_json(config_path_json, config_path_old=CONFIG_PATH_OLD):
         convertRadisToJSON(config_path_json, config_path_old)
     else:
         # Create it from the default config path
-        try:
-            shutil.copy2(CONFIG_PATH_DEFAULT, config_path_json)
-        except Exception as err:
-            print(
-                f"Couldn't create RADIS configuration in {config_path_json} ({str(err)})"
-            )
-        else:
-            print(f"RADIS configuration file created in {config_path_json}")
+
+        with open(CONFIG_PATH_DEFAULT) as f:
+            try:
+                default_config = hjson.load(f)
+                # read with Hjson to allow comments in CONFIG_PATH_DEFAULT
+                # we do not allow comments in (user) CONFIG_PATH_JSON
+                # because it is read/written automatically. So far there is no
+                # two-way JSON editor with comments. See https://github.com/radis/radis/issues/328
+            except JSONDecodeError as err:
+                raise JSONDecodeError(
+                    "Error reading '{0}' (line {2} col {3}): \n{1}".format(
+                        CONFIG_PATH_DEFAULT, err.msg, err.lineno, err.colno
+                    ),
+                    err.doc,
+                    err.pos,
+                ) from err
+
+        # Creating json file
+        config = {
+            "_HELLO!": "This is your RADIS configuration file. Any key here will overwrite the values of the radis/default_radis.json file on your local computer (see online version of the latest release: https://github.com/radis/radis/blob/master/radis/default_radis.json). More information on the online docs : https://radis.readthedocs.io/en/latest/lbl/lbl.html#label-lbl-config-file"
+        }
+        config.update(default_config)
+        with open(config_path_json, "w") as outfile:
+            json.dump(config, outfile, indent=3)
+        # outfile.close()
+
+        # try:
+        #     shutil.copy2(CONFIG_PATH_DEFAULT, config_path_json)
+        # except Exception as err:
+        #     print(
+        #         f"Couldn't create RADIS configuration in {config_path_json} ({str(err)})"
+        #     )
+        # else:
+        #     print(f"RADIS configuration file created in {config_path_json}")
 
     # Check it user file exists
     if not exists(config_path_json):
@@ -827,6 +858,8 @@ def getDatabankEntries(dbname, get_extra_keys=[], configpath=CONFIG_PATH_JSON):
             + "See databank format above. More information in "
             + "https://radis.readthedocs.io/en/latest/lbl/lbl.html#configuration-file"
         )
+        if dbname in ["hitran", "hitemp", "exomol"]:
+            msg += f"\nIf querying {dbname.upper()} you may want to be using SpectrumFactory.fetch_databank({dbname}) instead of SpectrumFactory.load_databank"
         raise DatabankNotFound(msg)
 
     entries = config[dbname]
diff --git a/radis/misc/signal.py b/radis/misc/signal.py
index 8f5ade720..860a1b33f 100644
--- a/radis/misc/signal.py
+++ b/radis/misc/signal.py
@@ -6,15 +6,18 @@
 
 Signal processing functions
 
+Resampling  & smoothing.
+
 
 -------------------------------------------------------------------------------
 
 
 """
 
-
+import numpy as np
 from numpy import abs, isnan, linspace, nan, trapz, zeros_like
 from scipy.interpolate import splev, splrep
+from scipy.linalg import solveh_banded
 
 from radis.misc.arrays import (
     anynan,
@@ -105,7 +108,9 @@ def resample(
     elif is_sorted_backward(xspace):
         reverse = True
     else:
-        raise ValueError("Resampling requires wavespace to be sorted. It is not!")
+        raise ValueError(
+            "Resampling requires wavespace to be sorted. It is not! Use .sort()? "
+        )
 
     if reverse:
         xspace = xspace[::-1]
@@ -311,6 +316,130 @@ def resample_even(
     return xspace_new, vector_new
 
 
+def als_baseline(
+    intensities,
+    asymmetry_param=0.05,
+    smoothness_param=1e6,
+    max_iters=10,
+    conv_thresh=1e-5,
+    verbose=False,
+):
+    """Computes the asymmetric least squares baseline
+
+    Parameters
+    ----------
+    intensities: array_like
+        vector to smooth
+    asymmetry_param: float
+        value will shift the baseline fit below or above your average line. To cancel gaussian noise,
+        you would want the value to be ``0.5``. To remove a positive peak would want it to be close
+        to ``0``. To remove a negative peaks (from a transmittance signal for instance) you would
+        want it to be close to ``1``. Default ``0.05``
+    smoothness_param: float
+        Relative importance of smoothness of the predicted response:
+        the higher, the smoother the baseline. Suggested range: ``1e2`` to ``1e8``. Default ``1e6``
+
+    Other Parameters
+    ----------------
+    max_iters: int
+        number of iterations
+    conv_thresh: float
+        convergence
+    verbose: boolean
+
+
+    Examples
+    --------
+    ::
+
+        from neq.math.smooth import als_baseline
+        I = als_baseline(I, smoothness_param=1e5, aymmetry_param=0.1)
+
+    References
+    ----------
+
+    * https://zanran_storage.s3.amazonaws.com/www.science.uva.nl/ContentPages/443199618.pdf
+    * http://www.science.uva.nl/~hboelens/publications/draftpub/Eilers_2005.pdf
+
+    Notes
+    -----
+
+    Implementation:
+
+    - uses :func:`~neq.math.smooth.WhittakerSmoother` internally
+    - kudos to https://gist.github.com/perimosocordiae
+
+    See Also
+    --------
+
+    :func:`~radis.misc.signal.WhittakerSmoother`
+
+    """
+    smoother = WhittakerSmoother(intensities, smoothness_param, deriv_order=2)
+    # Rename p for concision.
+    p = asymmetry_param
+    # Initialize weights.
+    w = np.ones(intensities.shape[0])
+    for i in range(max_iters):
+        z = smoother.smooth(w)
+        mask = intensities > z
+        new_w = p * mask + (1 - p) * (~mask)
+        conv = np.linalg.norm(new_w - w)
+        if verbose:
+            print(i + 1, conv)
+        if conv < conv_thresh:
+            break
+        w = new_w
+    else:
+        print("ALS did not converge in %d iterations" % max_iters)
+    return z
+
+
+class WhittakerSmoother(object):
+    """
+
+    References
+    ----------
+    - kudos to https://gist.github.com/perimosocordiae
+
+    See Also
+    --------
+
+    :func:`~radis.misc.signal.als_baseline`
+    """
+
+    def __init__(self, signal, smoothness_param, deriv_order=1):
+        self.y = signal
+        assert deriv_order > 0, "deriv_order must be an int > 0"
+        # Compute the fixed derivative of identity (D).
+        d = np.zeros(deriv_order * 2 + 1, dtype=int)
+        d[deriv_order] = 1
+        d = np.diff(d, n=deriv_order)
+        n = self.y.shape[0]
+        k = len(d)
+        s = float(smoothness_param)
+
+        # Here be dragons: essentially we're faking a big banded matrix D,
+        # doing s * D.T.dot(D) with it, then taking the upper triangular bands.
+        diag_sums = np.vstack(
+            [
+                np.pad(s * np.cumsum(d[-i:] * d[:i]), ((k - i, 0),), "constant")
+                for i in range(1, k + 1)
+            ]
+        )
+        upper_bands = np.tile(diag_sums[:, -1:], n)
+        upper_bands[:, :k] = diag_sums
+        for i, ds in enumerate(diag_sums):
+            upper_bands[i, -i - 1 :] = ds[::-1][: i + 1]
+        self.upper_bands = upper_bands
+
+    def smooth(self, w):
+        foo = self.upper_bands.copy()
+        foo[-1] += w  # last row is the diagonal
+
+        return solveh_banded(foo, w * self.y, overwrite_ab=True, overwrite_b=True)
+
+
 if __name__ == "__main__":
     from radis.test.spectrum.test_spectrum import (
         test_resampling_function,
diff --git a/radis/spectrum/models.py b/radis/spectrum/models.py
index e7107c490..232159efd 100644
--- a/radis/spectrum/models.py
+++ b/radis/spectrum/models.py
@@ -53,17 +53,13 @@ def calculated_spectrum(
 
     Parameters
     ----------
-
     w: np.array
         wavelength, or wavenumber
-
     I: np.array
         intensity (no slit)
-
     wunit: ``'nm'``, ``'cm-1'``, ``'nm_vac'``
         wavespace unit: wavelength in air (``'nm'``), wavenumber
         (``'cm-1'``), or wavelength in vacuum (``'nm_vac'``). Default ``'nm'``.
-
     Iunit: str
         intensity unit (can be 'counts', 'mW/cm2/sr/nm', etc...). Default
         'mW/cm2/sr/nm' (note that non-convoluted Specair spectra are in 'mW/cm2/sr/µm')
@@ -71,16 +67,12 @@ def calculated_spectrum(
 
     Other Parameters
     ----------------
-
     conditions: dict
         (optional) calculation conditions to be stored with Spectrum. Default ``None``
-
     cond_units: dict
         (optional) calculation conditions units. Default ``None``
-
     populations: dict
         populations to be stored in Spectrum. Default ``None``
-
     name: str
         (optional) give a name
 
@@ -128,37 +120,29 @@ def transmittance_spectrum(
 
     Parameters
     ----------
-
     w: np.array
         wavelength, or wavenumber
-
     T: np.array
         transmittance (no slit)
-
     wunit: ``'nm'``, ``'cm-1'``, ``'nm_vac'``
         wavespace unit: wavelength in air (``'nm'``), wavenumber
         (``'cm-1'``), or wavelength in vacuum (``'nm_vac'``). Default ``'nm'``.
-
     Iunit: str
         intensity unit. Default ``""`` (adimensionned)
 
 
     Other Parameters
     ----------------
-
     conditions: dict
         (optional) calculation conditions to be stored with Spectrum
-
     cond_units: dict
         (optional) calculation conditions units
-
     name: str
         (optional) give a name
 
 
     Examples
     --------
-
     ::
 
         # w, T are numpy arrays for wavelength and transmittance
@@ -166,7 +150,6 @@ def transmittance_spectrum(
         s2 = transmittance_spectrum(w, T, wunit='nm')                       # creates 'transmittance_noslit'
 
 
-
     See Also
     --------
 
@@ -190,7 +173,7 @@ def transmittance_spectrum(
 
 
 def experimental_spectrum(
-    w, I, wunit="nm", Iunit="counts", conditions={}, cond_units=None, name=None
+    w, I, wunit="nm", Iunit="count", conditions={}, cond_units=None, name=None
 ) -> Spectrum:
     """Convert ``(w, I)`` into a :py:class:`~radis.spectrum.spectrum.Spectrum`
     object that has unit conversion and plotting capabilities. Convolution is
@@ -199,48 +182,39 @@ def experimental_spectrum(
 
     Parameters
     ----------
-
     w: np.array
         wavelength, or wavenumber
-
     I: np.array
         intensity
-
     wunit: ``'nm'``, ``'cm-1'``, ``'nm_vac'``
         wavespace unit: wavelength in air (``'nm'``), wavenumber
         (``'cm-1'``), or wavelength in vacuum (``'nm_vac'``). Default ``'nm'``.
-
     Iunit: str
-        intensity unit (can be 'counts', 'mW/cm2/sr/nm', etc...). Default
-        'counts' (default Winspec output)
+        intensity unit (can be ``'count'``, 'mW/cm2/sr/nm', etc...). Default
+        ``'count'`` (i.e., non calibrated output)
 
     Other Parameters
     ----------------
-
     conditions: dict
         (optional) calculation conditions to be stored with Spectrum
-
     cond_units: dict
         (optional) calculation conditions units
-
     name: str
         (optional) give a name
 
     Examples
     --------
-
     Load and plot an experimental spectrum::
 
         from numpy import loadtxt
         from radis import experimental_spectrum
-        w, I = loadtxt('my_file.txt').T    # assuming 2 columns
+        w, I = loadtxt('my_file.txt').T    # transpose is often useful, depending on your data.
         s = experimental_spectrum(w, I, Iunit='mW/cm2/sr/nm')             # creates 'radiance'
         s.plot()
 
 
     See Also
     --------
-
     :func:`~radis.spectrum.models.calculated_spectrum`,
     :func:`~radis.spectrum.models.transmittance_spectrum`,
     :meth:`~radis.spectrum.spectrum.Spectrum.from_array`,
diff --git a/radis/spectrum/operations.py b/radis/spectrum/operations.py
index 516bb7b0f..d961bac52 100644
--- a/radis/spectrum/operations.py
+++ b/radis/spectrum/operations.py
@@ -1046,34 +1046,69 @@ def offset(s, offset, unit, name=None, inplace=False):
     return s
 
 
-def get_baseline(s, var="radiance", Iunit=None):
+def get_baseline(
+    s, var="radiance", algorithm="als", wunit="default", Iunit="default", **kwargs
+):
     """Calculate and returns a baseline
 
     Parameters
     ----------
     s: Spectrum
         Spectrum which needs a baseline
-
     var: str
         on which spectral quantity to read the baseline. Default ``'radiance'``.
         See :py:data:`~radis.spectrum.utils.SPECTRAL_QUANTITIES`
+    algorithm: 'als', 'polynomial'
+        Asymmetric least square or Polynomial fit
+    **kwargs: dict
+        additional parameters to send to the algorithm. By default,
+        for 'polynomial':
+
+            **kwargs = **{"deg": 1, "max_it":500}
+
+        for 'als':
+
+            **kwargs = {"asymmetry_param": 0.05,
+                        "smoothness_param": 1e6}
 
     Returns
     -------
     baseline: Spectrum
         Spectrum object where intenisity is the baseline of s is computed by peakutils
 
+    Examples
+    --------
+
+    .. minigallery:: radis.spectrum.spectrum.Spectrum.get_baseline
+
     See Also
     --------
 
-    :py:func:`~radis.spectrum.operations.sub_baseline`
+    :py:func:`~radis.spectrum.operations.sub_baseline`,
+    :py:meth:`~radis.spectrum.spectrum.Spectrum.get_baseline`
     """
-    import peakutils
+    if wunit == "default":
+        wunit = s.get_waveunit()
+    if Iunit == "default":
+        Iunit = s.units[var]
+
+    w1, I1 = s.get(var=var, wunit=wunit, Iunit=Iunit)
+
+    if algorithm == "polynomial":
+        import peakutils
+
+        polyargs = {"deg": 1, "max_it": 500}
+        polyargs.update(kwargs)
+        baseline = peakutils.baseline(I1, **polyargs)
+    elif algorithm == "als":
+        from radis.misc.signal import als_baseline
+
+        alsargs = {"asymmetry_param": 0.05, "smoothness_param": 1e6}
+        alsargs.update(kwargs)
+        baseline = als_baseline(I1, **alsargs)
 
-    w1, I1 = s.get(var=var, Iunit=Iunit)
-    baseline = peakutils.baseline(I1, deg=1, max_it=500)
     baselineSpectrum = Spectrum.from_array(
-        w1, baseline, var, unit=Iunit, name=s.get_name() + "_baseline"
+        w1, baseline, var, wunit=wunit, unit=Iunit, name=s.get_name() + "_baseline"
     )
     return baselineSpectrum
 
diff --git a/radis/spectrum/spectrum.py b/radis/spectrum/spectrum.py
index 1ebeb57b3..9989e7d63 100644
--- a/radis/spectrum/spectrum.py
+++ b/radis/spectrum/spectrum.py
@@ -519,7 +519,18 @@ def __init__(
     # %% Constructors
 
     @classmethod
-    def from_array(self, w, I, quantity, wunit, unit, waveunit=None, *args, **kwargs):
+    def from_array(
+        self,
+        w,
+        I,
+        quantity,
+        wunit=None,
+        Iunit=None,
+        waveunit=None,
+        unit=None,
+        *args,
+        **kwargs,
+    ):
         """Construct Spectrum from 2 arrays.
 
         Parameters
@@ -531,8 +542,10 @@ def from_array(self, w, I, quantity, wunit, unit, waveunit=None, *args, **kwargs
         wunit: ``'nm'``, ``'cm-1'``, ``'nm_vac'``
             unit of waverange:         wavelength in air (``'nm'``), wavenumber
             (``'cm-1'``), or wavelength in vacuum (``'nm_vac'``).
-        unit: str
-            spectral quantity unit (arbitrary). Ex: 'mW/cm2/sr/nm' for radiance_noslit
+            If ``None``, then ``w`` must be a dimensionned array.
+        Iunit: str
+            spectral quantity unit (arbitrary). Ex: ``'mW/cm2/sr/nm'`` for radiance_noslit
+            If ``None``, then ``I`` must be a dimensionned array.
         *args, **kwargs
             see :class:`~radis.spectrum.spectrum.Spectrum` doc
 
@@ -561,19 +574,24 @@ def from_array(self, w, I, quantity, wunit, unit, waveunit=None, *args, **kwargs
 
         Returns
         -------
-
-        s: Spectrum
+        Spectrum
             creates a :class:`~radis.spectrum.spectrum.Spectrum` object
 
         Examples
         --------
-
         Create a spectrum::
 
             from radis import Spectrum
             s = Spectrum.from_array(w, I, 'radiance_noslit',
                                    wunit='nm', unit='mW/cm2/sr/nm')
 
+        Dimensionned arrays can also be used directly ::
+
+            import astropy.units as u
+            w = np.linspace(200, 300) * u.nm
+            I = np.random.rand(len(w)) * u.mW/u.cm**2/u.sr/u.nm
+            s = Spectrum.from_array(w, I, 'radiance_noslit')
+
         To create a spectrum with absorption and emission components
         (e.g: ``radiance_noslit`` and ``transmittance_noslit``, or ``emisscoeff``
         and ``abscoeff``) call the :class:`~radis.spectrum.spectrum.Spectrum`
@@ -604,9 +622,56 @@ class directly. Ex::
                 DeprecationWarning,
             )
             wunit = waveunit
+        if unit is not None:
+            warn(
+                "`unit=` parameter in from_array is now named `Iunit=`",
+                DeprecationWarning,
+            )
+            Iunit = unit
+
+        # Check if dimensionned arrays
+        try:
+            I.unit
+        except AttributeError:
+            # dimensionless
+            if Iunit is None:
+                raise ValueError(
+                    "I must be a dimensionned array, or ``Iunit=`` must be given."
+                )
+        else:
+            if Iunit is not None:
+                raise ValueError(
+                    f"I is a dimensionned array (in {Iunit.unit.to_string()}), therefore ``Iunit`` cannot be given too (got {Iunit}). Set ``Iunit=None``"
+                )
+            Iunit = I.unit.to_string()
+            I = I.value
+        try:
+            w.unit
+        except AttributeError:
+            # dimensionless
+            if wunit is None:
+                raise ValueError(
+                    "``w`` must be a dimensionned array, or ``Iunit=`` must be given."
+                )
+        else:
+            if wunit is not None:
+                raise ValueError(
+                    f"``w`` is a dimensionned array (in {wunit.unit.to_string()}), therefore ``wunit`` cannot be given too (got {wunit}). Set ``wunit=None``"
+                )
+            wunit = w.unit.to_string()
+            if wunit not in WAVELEN_UNITS + WAVENUM_UNITS:
+                # Convert to something we know, for instance 'nm' :
+                if w.unit.is_equivalent("nm"):
+                    w = w.to("nm")
+                    wunit = "nm"
+                elif w.unit.is_equivalent("1 / cm"):
+                    w = w.to("1 / cm")
+                    wunit = "cm-1"
+                # else, an error will be raised anyway on Spectrum creation.
+            w = w.value
 
         quantities = {quantity: (w, I)}
-        units = {quantity: unit}
+        units = {quantity: Iunit}
 
         # Update Spectrum conditions
         conditions = kwargs.pop("conditions", {})
@@ -751,6 +816,104 @@ class directly. Ex::
         s.file = file
         return s
 
+    @classmethod
+    def from_hdf5(
+        self, file, wmin=None, wmax=None, wunit=None, columns=None, engine="pytables"
+    ):
+        """Generates a Spectrum from an HDF5 file. Uses :py:func:`~radis.io.spec_hdf.hdf2spec`
+
+        Other Parameters
+        ----------------
+        wmin, wmax: float
+            range of wmin, wmax to load , using ``wunit`` (if None, load everything)
+        columns: list of str
+            spectral arrays to load (if None, load everything)
+
+        Examples
+        --------
+        ::
+
+            Spectrum.from_hdf5("rad_hdf.h5", wmin=2100, wmax=2200, columns=['abscoeff', 'emisscoeff'])
+
+        See Also
+        --------
+        :py:func:`~radis.spectrum.spectrum.Spectrum.to_hdf5`
+        """
+        from radis.io.spec_hdf import hdf2spec
+
+        s = hdf2spec(
+            file, wmin=wmin, wmax=wmax, wunit=wunit, columns=columns, engine=engine
+        )
+
+        # Store filename
+        s.file = file
+        return s
+
+    @classmethod
+    def from_spec(self, file):
+        """Generates a Spectrum from a .spec [json] file. Uses :py:func:`~radis.tools.database.load_spec`"""
+        from radis.tools.database import load_spec
+
+        return load_spec(file)
+
+    @classmethod
+    def from_specutils(self, spectrum, var="radiance"):
+        """Convert a ``specutils`` :py:class:`specutils.spectra.spectrum1d.Spectrum1D`
+        to a ``radis`` :py:class:`~radis.spectrum.spectrum.Spectrum` object.
+
+        Examples
+        --------
+
+        Taken from the Specutils website (https://specutils.readthedocs.io/en/stable/#getting-started-with-specutils)
+        ::
+
+            from astropy.io import fits
+            from astropy import units as u
+            from specutils import Spectrum1D
+
+            f = fits.open('https://data.sdss.org/sas/dr16/sdss/spectro/redux/26/spectra/1323/spec-1323-52797-0012.fits')
+            # The spectrum is in the second HDU of this file.
+            specdata = f[1].data
+
+            lamb = 10**specdata['loglam'] * u.AA
+            flux = specdata['flux'] * 10**-17 * u.Unit('erg cm-2 s-1 AA-1')
+            spec = Spectrum1D(spectral_axis=lamb, flux=flux)
+
+
+            from radis import Spectrum
+            s = Spectrum.from_specutils(spec)
+            s.plot(wunit='nm')
+
+
+        .. minigallery:: radis.spectrum.spectrum.Spectrum.from_specutils
+
+        See Also
+        --------
+        :py:func:`~radis.spectrum.spectrum.Spectrum.to_specutils`
+        """
+        try:
+            import specutils
+        except ModuleNotFoundError as err:
+            raise ModuleNotFoundError(
+                "Specutils is required to use this function."
+                "Install it with:\n"
+                "   conda install -c conda-forge specutils"
+                "\nor\n"
+                "   pip install specutils"
+            ) from err
+
+        assert isinstance(spectrum, specutils.Spectrum1D)
+
+        # Convert to radis Spectrum
+        # note : in Specutils wavelengths are given as wavelength in vacuum (1e7/X conversion
+        # to wavenumbers), so here we give the frequency unit.
+        return Spectrum.from_array(
+            spectrum.wavelength.to("1 / cm"),
+            spectrum.flux,
+            quantity=var,
+            conditions=spectrum.meta,
+        )
+
     # Public functions
     # %% ======================================================================
     # ----------------
@@ -836,8 +999,7 @@ def get(self, var, wunit="default", Iunit="default", copy=True, trim_nan=False):
         # Get wavespace (in correct unit, and correct medium)
         if wunit == "default":
             wunit = self.get_waveunit()
-        else:
-            wunit = cast_waveunit(wunit)
+        wunit = cast_waveunit(wunit)
         if wunit == "cm-1":
             w = self.get_wavenumber(copy=copy)
         elif wunit == "nm":
@@ -1169,7 +1331,13 @@ def update(self, quantity="all", optically_thin="default", verbose=True):
             If 'default', the value is determined from the self_absorption key
             in Spectrum.conditions. If not given, False is taken. Default 'default'
             Also updates the self_absorption value in conditions (creates it if
-            doesnt exist)
+            doesnt exist
+
+        Examples
+        --------
+        ::
+
+            s.update('transmittance_noslit')
 
         See Also
         --------
@@ -1216,6 +1384,7 @@ def rescale_path_length(
         Examples
         --------
         ::
+
             for path in [0.1, 10, 100]:
                 s.rescale_path_length(10, inplace=False).plot(nfig='same')
 
@@ -1279,6 +1448,7 @@ def rescale_mole_fraction(
         Examples
         --------
         ::
+
             s.rescale_mole_fraction(0.2)
 
         .. minigallery:: radis.spectrum.spectrum.Spectrum.rescale_mole_fraction
@@ -1411,6 +1581,11 @@ def sort(self, inplace=True):
             sorted Spectrum. If ``inplace=True``, Spectrum has been updated
             directly anyway. Allows :ref:`chaining <label_spectrum_chaining>`.
 
+        Examples
+        --------
+
+        .. minigallery:: radis.spectrum.spectrum.Spectrum.sort
+
         """
 
         if inplace:
@@ -1864,7 +2039,6 @@ def get_populations(self, molecule=None, isotope=None, electronic_state=None):
             if None, only one electronic state must be defined. Else, an error
             is raised
 
-
         Returns
         -------
         pandas dataframe of levels, where levels are the index,
@@ -2202,6 +2376,8 @@ def apply_slit(
                 wavelengths and intensity (doesn't have to be normalized)
             If ``array``:
                 format must be 2-columns with wavelengths and intensity (doesn't have to be normalized)
+                It is recommended to truncate the input slit function to its minimum useful spectral
+                extension (see Notes of :func:`~radis.tools.slit.convolve_with_slit`).
         unit: ``'nm'`` or ``'cm-1'``
             unit of slit_function (FWHM, or imported file)
         shape: ``'triangular'``, ``'trapezoidal'``, ``'gaussian'``, or any of :data:`~radis.tools.slit.SLIT_SHAPES`
@@ -2340,19 +2516,17 @@ def apply_slit(
 
             s.apply_slit(1.2, 'nm')
 
-        To manually apply the slit to a particular quantity use::
+        This applies the instrumental function to all available spectral arrays.
+        To manually apply the slit to a particular spectral array, use
+        :py:meth:`~radis.spectrum.spectrum.Spectrum.take` ::
 
-            wavenum, quantity = s['quantity']
-            s['convolved_quantity'] = convolve_slit(wavenum, quantity,
-                slit_function_base)
+            s.take('transmittance_noslit').apply_slit(1.2, 'nm')
 
         See :func:`~radis.tools.slit.convolve_with_slit` for more details on Units and Normalization
 
         The slit is made considering the "center wavelength" which is
         the mean wavelength of the full spectrum you are applying it to.
 
-        Examples using apply_slit :
-
         .. minigallery:: radis.Spectrum.apply_slit
             :add-heading:
 
@@ -2448,7 +2622,7 @@ def apply_slit(
                 w_nm, wslit0_nm, slit_dispersion, slit_dispersion_threshold
             )
         else:
-            slice_windows = [np.ones_like(w, dtype=np.bool)]
+            slice_windows = [np.ones_like(w, dtype=bool)]
 
         # Create dictionary to store convolved
         I_conv_slices = {}
@@ -2788,9 +2962,15 @@ def line_survey(
 
             Note: if not None, cutoff criteria is applied in this unit.
             Not used if plot is not 'S'
+        barwidth: float or str
+            if float, width of bars, in ``wunit``, as a fraction of full-range; i.e. ::
 
-        barwidth: float
-            With of bars in LineSurvey. Default 0.07
+                barwidth=0.01
+
+            makes bars span 1% of the full range.
+            if ``str``, uses the column as width. Example ::
+
+                barwidth = 'hwhm_voigt'
 
 
 
@@ -2819,7 +2999,7 @@ def line_survey(
             sf.load_databank('HITRAN-CO2-TEST')
             s = sf.eq_spectrum(Tgas=1500)
             s.apply_slit(0.5)
-            s.line_survey(overlay='radiance_noslit', barwidth=0.01)
+            s.line_survey(overlay='radiance_noslit', barwidth=0.01)  # or barwidth='hwhm_voigt'
 
         See the output in :ref:`Examples <label_examples>`
 
@@ -2997,11 +3177,6 @@ def store(
         Spectrum conditions and get Spectrum that suits specific parameters.
 
 
-        Implementation:
-
-            Shouldnt rely on a Database. One may just want to store/load a Spectrum
-            once.
-
         Examples
         --------
         Store a spectrum in compressed mode, regenerate quantities after loading::
@@ -3052,6 +3227,139 @@ def save(self, *args, **kwargs):
 
         return self.store(*args, **kwargs)
 
+    def to_json(self, *args, **kwargs):
+        """Alias to Spectrum.store(compress=False).
+
+        See Spectrum. :py:meth:`~radis.spectrum.spectrum.Spectrum.store` for documentation
+        """
+
+        return self.store(compress=False, *args, **kwargs)
+
+    def to_hdf5(self, file, engine="pytables"):
+        """Stores the Spectrum under HDF5 format. Uses :py:func:`~radis.io.spec_hdf.spec2hdf`
+
+        Examples
+        --------
+        ::
+
+            s.to_hdf5('spec01.h5')
+
+        See Also
+        --------
+        :py:func:`~radis.spectrum.spectrum.Spectrum.from_hdf5`
+        """
+        from radis.io.spec_hdf import spec2hdf
+
+        return spec2hdf(self, file, engine=engine)
+
+    def to_pandas(s, copy=True):
+        """Convert a Spectrum to a Pandas DataFrame
+
+        Returns
+        -------
+        pd.DataFrame : pandas DataFrame where columns are spectral arrays, and
+            units are stored in attributes ``df.attrs``
+
+        Notes
+        -----
+        Pandas does not support units yet. pint-pandas is an advanced
+        project but not fully working.
+        See discussion in https://github.com/pandas-dev/pandas/issues/10349
+
+        For the moment, we store units as metadata"""
+        import pandas as pd
+
+        df = pd.DataFrame(s._q, copy=copy)
+
+        df.attrs = s.units
+
+        return df
+
+    def to_specutils(self, var=None, wunit="default", Iunit="default"):
+        """Convert a ``radis`` :py:class:`~radis.spectrum.spectrum.Spectrum`
+        object to ``specutils`` :py:class:`specutils.spectra.spectrum1d.Spectrum1D`
+
+        Parameters
+        ----------
+        var: 'radiance', 'radiance_noslit', etc.
+            which spectral array to convert. If ``None`` and only one spectral
+            arry is defined, use it
+        wunit: ``'nm'``, ``'cm'``, ``'nm_vac'``.
+            wavespace unit: wavelength in air (``'nm'``), wavenumber
+            (``'cm-1'``), or wavelength in vacuum (``'nm_vac'``).
+            if ``"default"``, default unit for waveunit is used. See
+            :py:meth:`~radis.spectrum.spectrum.Spectrum.get_waveunit`.
+        Iunit: unit for variable ``var``
+            if ``"default"``, default unit for quantity `var` is used. See the
+            :py:attr:`~radis.spectrum.spectrum.Spectrum.units` attribute.
+            For ``var="radiance"``, one can use per wavelength (~ 'W/m2/sr/nm')
+            or per wavenumber (~ 'W/m2/sr/cm-1') units
+
+        .. note::
+            `nan`, that may have been added on the wings of the spectra if a
+            slit has been applied, are removed using ``trim_nan`` parameter of
+            :py:meth:`~radis.spectrum.spectrum.Spectrum.get` . The waverange
+            in the 1DSpectrum object may therefore be cropped.
+
+        Examples
+        --------
+        ::
+
+            spectrum = s.to_specutils()
+
+        Add uncertainties by reading a spectrum noise region with
+        :py:class:`~specutils.SpectralRegion` and
+        :py:func:`~specutils.manipulation.noise_region_uncertainty` ::
+
+            from specutils import SpectralRegion
+            from specutils.manipulation import noise_region_uncertainty
+            noise_region = SpectralRegion(2012 / u.cm, 2009 / u.cm)
+            spectrum = noise_region_uncertainty(spectrum, noise_region)
+
+        Find lines out of the noise using :py:func:`~specutils.fitting.find_lines_threshold` ::
+
+            from specutils.fitting import find_lines_threshold
+            lines = find_lines_threshold(spectrum)
+
+        .. minigallery:: radis.spectrum.spectrum.Spectrum.to_specutils
+            :add-heading:
+
+        See Also
+        --------
+        :py:func:`~radis.spectrum.spectrum.Spectrum.from_specutils`
+
+        """
+        import astropy.units as u
+
+        try:
+            from specutils.spectra import Spectrum1D
+        except ModuleNotFoundError as err:
+            raise ModuleNotFoundError(
+                "Specutils is required to use this function."
+                "Install it with:\n"
+                "   conda install -c conda-forge specutils"
+                "\nor\n"
+                "   pip install specutils"
+            ) from err
+
+        if var is None:
+            var = self._get_unique_var(operation_name="to_specutils")
+        if wunit == "default":
+            wunit = self.get_waveunit()
+        if Iunit == "default":
+            Iunit = self.units[var]
+
+        if wunit in WAVELEN_UNITS:
+            wunit = "nm_vac"  # AFAIK, specutil's Spectrum1D will only handle wavelengths as seen in vacuum
+            # so below we request wavelengths in vac :
+
+        w, I = self.get(var, wunit=wunit, Iunit=Iunit, copy=True, trim_nan=True)
+        return Spectrum1D(
+            flux=I * u.Unit(Iunit),
+            spectral_axis=w * u.Unit(wunit),
+            meta=self.get_conditions(),
+        )
+
     def resample(
         self,
         w_new,
@@ -3346,6 +3654,9 @@ def copy(self, copy_lines=True, quantity="all"):
         quantity: 'all', or one of 'radiance_noslit', 'absorbance', etc.
             if not 'all', copy only one quantity. Default ``'all'``
 
+        Examples
+        --------
+
         .. minigallery:: radis.spectrum.spectrum.Spectrum.copy
             :add-heading:
 
@@ -3685,7 +3996,10 @@ def cite(self, format="bibentry"):
 
         .. raw:: html
 
-        </details>
+            </details>
+
+        Other Examples
+        --------------
 
         .. minigallery:: radis.spectrum.spectrum.Spectrum.cite
 
@@ -3875,59 +4189,91 @@ def take(self, var, copy_lines=False):
     def _get_unique_var(self, operation_name="algebraic"):
         quantities = self.get_vars()
         if len(quantities) > 1:
-            raise KeyError(
-                "There is an ambiguity with the Spectrum {0} operation. ".format(
+            raise ValueError(
+                "There is an ambiguity with the Spectrum `{0}()` operation. ".format(
                     operation_name
                 )
-                + "There should be only one var in Spectrum {0}. Got {1}\n".format(
-                    self.get_name(), self.get_vars()
+                + "There are currently multiple spectral arrays in the Spectrum {0} ({1})\n\n".format(
+                    self.get_name(), ", ".join(self.get_vars())
                 )
-                + "Use `s.take('transmittance')` or `s.take('radiance')`, etc. to extract the "
-                "one spectral quantity you want."
+                + f"Use `s.take('transmittance').{operation_name}()` or `s.take('radiance').{operation_name}()`"
+                f", etc. to apply `{operation_name}()` to the one spectral array you want."
             )
         elif len(quantities) == 0:
-            raise KeyError(
-                "No spectral quantity defined in Spectrum {0}".format(self.get_name())
+            raise ValueError(
+                "No spectral array defined in Spectrum {0}".format(self.get_name())
             )
         else:
             var = quantities[0]
         return var
 
-    def max(self):
+    def max(self, value_only=False):
         """Maximum of the Spectrum, if only one spectral quantity is
         available::
 
             s.max()
 
-        Else, use :func:`~radis.spectrum.operations.Radiance`,
-        :func:`~radis.spectrum.operations.Radiance_noslit`,
-        :func:`~radis.spectrum.operations.Transmittance` or
-        :func:`~radis.spectrum.operations.Transmittance_noslit`  ::
+        Returns a dimensioned quantity by default, with the default unit of the spectrum.
+        If ``value_only=False``, a float is returned, without dimensions.
+
+        If there are multiple arrays in your Spectrum, use
+        :py:meth:`~radis.spectrum.spectrum.Spectrum.take`, e.g. ::
+
+            s.take('radiance').max()
+
+        Examples
+        --------
+        Normalize a spectrum (we could also have used :py:meth:`~radis.spectrum.spectrum.Spectrum.normalize`
+        directly) ::
+
+            s /= s.max()
+
+        Below is a slightly more advanced use case, convenient to process
+        non-calibrated experimental data.
+
+        Imagine we want to remove a baseline that we have identified to originate from
+        a particular species, modelled by spectrum ``s``, but whose intensity
+        (and units) are not the same as that of the experimental spectrum.
+
+        We substract exactly the intensity that correspond to the intensity on a given range ``w1, w2`` of the experimental
+        spectrum ``s_exp``, using a combination of :py:meth:`~radis.spectrum.spectrum.Spectrum.take`,
+        :py:meth:`~radis.spectrum.spectrum.Spectrum.normalize`,
+        :py:meth:`~radis.spectrum.spectrum.Spectrum.crop` and :py:meth:`~radis.spectrum.spectrum.Spectrum.max`:
+        ::
+
+            s_exp -= s.take('radiance').normalize() * s_exp.crop((w1, w2)), inplace=False).max()
 
-            Radiance(s).max()
         """
 
         var = self._get_unique_var(operation_name="max")
         w, I = self.get(var, wunit=self.get_waveunit(), copy=False)
-        return I[~np.isnan(I)].max()
+        if value_only:
+            return I[~np.isnan(I)].max()
+        else:
+            return I[~np.isnan(I)].max() * Unit(self.units[var])
 
-    def min(self):
+    def min(self, value_only=True):
         """Minimum of the Spectrum, if only one spectral quantity is available
         ::
 
             s.min()
 
-        Else, use :func:`~radis.spectrum.operations.Radiance`,
-        :func:`~radis.spectrum.operations.Radiance_noslit`,
-        :func:`~radis.spectrum.operations.Transmittance` or
-        :func:`~radis.spectrum.operations.Transmittance_noslit`  ::
+        Returns a dimensioned quantity by default, with the default unit of the spectrum.
+        If ``value_only=False``, a float is returned, without dimensions.
+
+        If there are multiple arrays in your Spectrum, use
+        :py:meth:`~radis.spectrum.spectrum.Spectrum.take`, e.g. ::
+
+            s.take('radiance').min()
 
-            Radiance(s).min()
         """
 
         var = self._get_unique_var(operation_name="min")
         w, I = self.get(var, wunit=self.get_waveunit(), copy=False)
-        return I[~np.isnan(I)].min()
+        if value_only:
+            return I[~np.isnan(I)].min()
+        else:
+            return I[~np.isnan(I)].min() * Unit(self.units[var])
 
     def normalize(
         self,
@@ -3964,8 +4310,14 @@ def normalize(
         Examples
         --------
         ::
+
             s.normalize("max", (4200, 4800), inplace=True).plot()
 
+        If there are multiple arrays in your Spectrum, use
+        :py:meth:`~radis.spectrum.spectrum.Spectrum.take`, e.g. ::
+
+            s.take('radiance').normalize(wrange=(4200, 4800)).plot()
+
         .. minigallery:: radis.spectrum.spectrum.Spectrum.normalize
 
         """
@@ -4026,6 +4378,51 @@ def normalize(
             print("Normalization factor : {0}".format(norm))
         return out
 
+    def get_baseline(self, algorithm="als", **kwargs):
+        """
+        Calculate and returns a baseline
+
+        Parameters
+        ----------
+        s: Spectrum
+            Spectrum which needs a baseline
+        var: str
+            on which spectral quantity to read the baseline. Default ``'radiance'``.
+            See :py:data:`~radis.spectrum.utils.SPECTRAL_QUANTITIES`
+        algorithm: 'als', 'polynomial'
+            Asymmetric least square or Polynomial fit
+        **kwargs: dict
+            additional parameters to send to the algorithm. By default,
+            for 'polynomial':
+
+                **kwargs = **{"deg": 1, "max_it":500}
+
+            for 'als':
+
+                **kwargs = {"asymmetry_param": 0.05,
+                            "smoothness_param": 1e6}
+
+        Returns
+        -------
+        baseline: Spectrum
+            Spectrum object where intenisity is the baseline of s
+
+        Examples
+        --------
+
+        .. minigallery:: radis.spectrum.spectrum.Spectrum.get_baseline
+
+        See also
+        --------
+        :py:func:`~radis.spectrum.operations.get_baseline`
+
+        """
+        from radis.spectrum.operations import get_baseline
+
+        var = self._get_unique_var(operation_name="get_baseline")
+
+        return get_baseline(self, var, **kwargs)
+
     # %% Performance / profilers
 
     def print_perf_profile(self, number_format="{:.3f}", precision=16):
@@ -4163,7 +4560,7 @@ def __add__(self, other):
         elif isinstance(other, np.ndarray):
             from radis.spectrum.operations import add_array
 
-            return add_array(self, other, inplace=True)
+            return add_array(self, other, inplace=False)
         elif isinstance(other, Spectrum):
             from radis.spectrum.operations import add_spectra
 
diff --git a/radis/spectrum/utils.py b/radis/spectrum/utils.py
index fe99f4fea..4d1a1851d 100644
--- a/radis/spectrum/utils.py
+++ b/radis/spectrum/utils.py
@@ -125,7 +125,7 @@
 
 
 def cast_waveunit(unit, force_match=True):
-    """Standardize unit formats."""
+    """Standardize unit formats, return either "nm", "nm_vac" or "cm-1"."""
     if unit in WAVELEN_UNITS:
         return "nm"
     if unit in WAVELENVAC_UNITS:
diff --git a/radis/tools/database.py b/radis/tools/database.py
index 525f6dc12..b079c4856 100644
--- a/radis/tools/database.py
+++ b/radis/tools/database.py
@@ -1884,6 +1884,7 @@ def __init__(
         nJobs=-2,
         batch_size="auto",
         lazy_loading=True,
+        update_register_only=False,
     ):
         # TODO @devs: generate a SpecDatabase from a dict.
         # use the key of the dict insted of the file.
@@ -1927,7 +1928,9 @@ def __init__(
         super(SpecDatabase, self).__init__()
         # now load from the folder with the update() function
         if not lazy_loading:
-            return self.update(force_reload=True, filt=filt)
+            return self.update(
+                force_reload=True, filt=filt, update_register_only=update_register_only
+            )
         else:
             if verbose >= 2:
                 print(
@@ -1946,7 +1949,9 @@ def __init__(
                 )
             spec_files = [f for f in os.listdir(self.path) if f.endswith(".spec")]
             # Initialize database without loading the spectra
-            self.df = read_conditions_file(join(self.path, csv_file[0]))
+            self.df = read_conditions_file(
+                join(self.path, csv_file[0]), verbose=self.verbose
+            )
 
             if len(self.df["file"]) != len(spec_files):
                 raise ValueError(
@@ -1982,7 +1987,7 @@ def __init__(
                             "Csv file wasn't created with last modifications informations. Update the database first with `SpecDatabase(..., lazy_loading=False)`."
                         )
 
-    def update(self, force_reload=False, filt=".spec"):
+    def update(self, force_reload=False, filt=".spec", update_register_only=False):
         """Reloads database, updates internal index structure and export it in
         ``<database>.csv``.
 
@@ -1993,6 +1998,12 @@ def update(self, force_reload=False, filt=".spec"):
         filt: str
             only consider files ending with ``filt``. Default ``.spec``
 
+        Other Parameters
+        ----------------
+        update_register_only: bool
+            if ``True``, load files and update csv but do not keep the Spectrum in memory.
+            Default ``False``
+
         Notes
         -----
         Can be loaded in parallel using joblib by setting the `nJobs` and `batch_size`
@@ -2005,7 +2016,9 @@ def update(self, force_reload=False, filt=".spec"):
         if force_reload:
             # Reloads whole database  (necessary on database init to create self.df
             files = [join(path, f) for f in os.listdir(path) if f.endswith(filt)]
-            self.df = self._load_new_files(files=files)
+            self.df = self._load_new_files(
+                files=files, update_register_only=update_register_only
+            )
         else:
             dbfiles = list(self.df["file"])
             files = [
@@ -2016,7 +2029,10 @@ def update(self, force_reload=False, filt=".spec"):
             # no parallelization here because the number of files is supposed to be small
             for f in files:
                 self.df = self.df.append(
-                    self._load_new_file(f, binary=self.binary), ignore_index=True
+                    self._load_new_file(
+                        f, binary=self.binary, update_register_only=update_register_only
+                    ),
+                    ignore_index=True,
                 )
 
         # Print index
@@ -2368,9 +2384,15 @@ def residual(s_exp, s, normalize):
 
         return spectra[i].copy()  # dont forget to copy the Spectrum we return
 
-    def _load_new_files(self, files):
+    def _load_new_files(self, files, update_register_only=False):
         """Parse files and generate a database.
 
+        Other Parameters
+        ----------------
+        update_register_only: bool
+            if ``True``, load files and update csv but do not keep the Spectrum in memory.
+            Default ``False``
+
         Returns
         -------
         db: pandas.DataFrame
@@ -2390,7 +2412,9 @@ def _load_new_files(self, files):
         minimum_nfiles = self.minimum_nfiles
 
         def funLoad(f):
-            return self._load_new_file(f, binary=self.binary)
+            return self._load_new_file(
+                f, binary=self.binary, update_register_only=update_register_only
+            )
 
         # Sequential loading
         if nJobs == 1 or len(files) < minimum_nfiles:
@@ -2414,12 +2438,18 @@ def funLoad(f):
             )(delayed(funLoad)(f) for f in files)
         return pd.DataFrame(db)
 
-    def _load_new_file(self, file, binary=False):
+    def _load_new_file(self, file, binary=False, update_register_only=False):
         """Load spectrum and return Spectrum attributes for insertion in database.
 
         The Spectrum itself is stored under the "Spectrum" key, and the filename
         under "file".
 
+        Other Parameters
+        ----------------
+        update_register_only: bool
+            if ``True``, load files and update csv but do not keep the Spectrum in memory.
+            Default ``False``
+
         Returns
         -------
         dict: dictionary of conditions of loaded spectrum"""
@@ -2435,7 +2465,11 @@ def _load_new_file(self, file, binary=False):
             out["name"] = s.get_name()
         else:
             out["name"] = s.name
-        out.update({"file": basename(file), "Spectrum": s})
+        out["file"] = basename(file)
+        if update_register_only:
+            out["Spectrum"] = None
+        else:
+            out["Spectrum"] = s
         out["last_modified"] = os.path.getmtime(file)
 
         return out
@@ -2482,7 +2516,7 @@ def to_dict(self):
         return dict(list(zip(self.df.file, self.df.Spectrum)))
 
 
-def read_conditions_file(path):
+def read_conditions_file(path, verbose=True):
     """Read .csv file with calculation/measurement conditions of all spectra.
 
     File must have at least the column "file"
@@ -2497,9 +2531,34 @@ def read_conditions_file(path):
     None.
 
     """
+    # import csv
+    # with open(path, 'r') as csvfile:  # Check if comma or semicolon # Note: dialect may be given directly to read_csv ?
+    #     dialect=csv.Sniffer().sniff(csvfile.read(), delimiters=',;')
+
     df = pd.read_csv(path, float_precision="round_trip")
     # thank you https://stackoverflow.com/questions/36909368/precision-lost-while-using-read-csv-in-pandas
 
+    # Force some types
+    for boolvar in [
+        "self_absorption",
+        "db_use_cached",
+        "lvl_use_cached",
+        "include_neighbouring_lines",
+        "export_lines",
+        "export_rovib_fractions",
+        "load_energies",
+        "thermal_equilibrium",
+    ]:
+        if boolvar in df:
+            if df.dtypes[boolvar] == object:
+                from radis.misc.basics import str2bool
+
+                if verbose:
+                    print(
+                        f"Reading {path} : casting column {boolvar} from string type to boolean"
+                    )
+                df[boolvar] = df[boolvar].map(str2bool)
+
     # if only "1" in isotopes they are read as numbers and later get() function fails.
     if "isotope" in df:
         df["isotope"] = df["isotope"].astype(str).str.replace(".0", "", regex=True)
diff --git a/radis/tools/line_survey.py b/radis/tools/line_survey.py
index c1e61a960..5c0ef99fc 100644
--- a/radis/tools/line_survey.py
+++ b/radis/tools/line_survey.py
@@ -62,7 +62,7 @@ def LineSurvey(
     cutoff=None,
     plot="S",
     lineinfo=["int", "A", "El"],
-    barwidth=0.07,
+    barwidth="hwhm_voigt",  # 0.01,
     yscale="log",
     writefile=None,
     xunit=None,
@@ -104,6 +104,15 @@ def LineSurvey(
         If ``None``, use the ``fig`` object returned to show the plot.
     yscale: ``'log'``, ``'linear'``
         Default ``'log'``
+    barwidth: float or str
+        if float, width of bars, in ``wunit``, as a fraction of full-range; i.e. ::
+
+            barwidth=0.01
+
+        makes bars span 1% of the full range.
+        if ``str``, uses the column as width. Example ::
+
+            barwidth = 'hwhm_voigt'
 
     Returns
     -------
@@ -166,32 +175,41 @@ def LineSurvey(
         )
 
     # Get input
-    T = spec.conditions["Tgas"]
-    P = spec.conditions["pressure_mbar"] / 1000  # bar1013.25 # atm
-    Xi = spec.conditions["mole_fraction"]
+    # T = spec.conditions["Tgas"]
+    # P = spec.conditions["pressure_mbar"] / 1000  # bar1013.25 # atm
+    # Xi = spec.conditions["mole_fraction"]
     sp = spec.lines.copy()
-    dbformat = spec.conditions["dbformat"]
 
     if not plot in list(sp.keys()):
         raise KeyError(
-            "Key {0} is not in line database: {1}".format(plot, list(sp.keys()))
+            "Key {0} is not in line database: {1}. Change `plot=` parameter of line_survey".format(
+                plot, list(sp.keys())
+            )
         )
 
-    def hitran2splot(S):
+    def hitran2splot(S, T):
         """convert Linestrength in HITRAN units (cm-1/(molecules.cm-2)) to
         SpectraPlot units (cm-2/atm)"""
 
         return S / (k_b * T) / 10
 
-    # Parsers to get units and more details
-    if dbformat in ["hitran", "hitemp"]:
-        columndescriptor = hitrancolumns
-    elif dbformat == "cdsd-hitemp":
-        columndescriptor = cdsdhitempcolumns
-    elif dbformat == "cdsd-4000":
-        columndescriptor = cdsd4000columns
+    # Known parsers to get units and more details
+    dbformat = None
+    if "dbformat" in spec.conditions:
+        dbformat = spec.conditions["dbformat"]
+        if dbformat in ["hitran", "hitemp", "hitemp-radisdb"]:
+            columndescriptor = hitrancolumns
+        elif dbformat in ["nist"]:
+            columndescriptor = hitrancolumns  # Placeholder. TODO replace with NIST (for the moment we copy the names anyway)
+        elif dbformat == "cdsd-hitemp":
+            columndescriptor = cdsdhitempcolumns
+        elif dbformat == "cdsd-4000":
+            columndescriptor = cdsd4000columns
+        else:
+            warn(NotImplemented(f"unknown dbformat {dbformat}"))
+            columndescriptor = {}
     else:
-        raise NotImplementedError(f"unknown dbformat {dbformat}")
+        columndescriptor = {}
 
     # Apply cutoff, get ylabel
     if plot == "S":
@@ -206,7 +224,8 @@ def hitran2splot(S):
                 # if None, use default cutoff expressed in Hitran units
                 sp = sp[(sp.S > cutoff)]
             else:
-                sp["S"] = hitran2splot(sp.S)
+                Tgas = spec.conditions["Tgas"]
+                sp["S"] = hitran2splot(sp.S, Tgas)
                 sp = sp[(sp.S > cutoff)]
             Iunit_str = "cm-1/atm"
         else:
@@ -214,7 +233,7 @@ def hitran2splot(S):
         ylabel = "Linestrength ({0})".format(Iunit_str)
     else:
         cutoff = 0
-        try:  # to find units and real name (if exists in initial databank)
+        try:  # to find units and real name (if columndescriptor is known exists in initial databank)
             _, _, name, unit = columndescriptor[plot]
             ylabel = "{0} - {1} [{2}]".format(name, plot, unit)
         except KeyError:
@@ -425,6 +444,33 @@ def get_label_cdsd_hitran(row, details):
 
         return label
 
+    def get_label_nist(row, attrs):
+        label = attrs[
+            "molecule"
+        ] + " [{Lower level}] ({El:.2f} eV) -> [{Upper level}] ({Eu:.2f} eV)".format(
+            **dict(
+                [
+                    (k, row[k])
+                    for k in [
+                        "Lower level",  # TODO: have nicer Term Symbol appear
+                        "Upper level",  # TODO: have nicer Term Symbol appear
+                        "El",
+                        "Eu",
+                    ]
+                ]
+            )
+        )
+
+        return label
+
+    def get_label_all(row):
+        """ print all lines details """
+        # label = row.__repr__()
+        label = "<br>".join(
+            [f"{k}: {v}" for k, v in row.items() if k not in ["wav", "shiftwav"]]
+        )
+        return label
+
     def get_label_none(row):
         return "unknown databank format. \ndetails cant be read"
 
@@ -445,18 +491,41 @@ def get_label_none(row):
                 details[k] = ("", None, "")  # keep short name
 
     # Get label
-    if dbformat in ["hitran", "hitemp"]:
+    if dbformat in ["hitran", "hitemp", "radisdb-hitemp"]:
         sp["label"] = sp.apply(lambda r: get_label_hitran(r, details, sp.attrs), axis=1)
     elif dbformat in ["cdsd-hitemp", "cdsd-4000"]:
         try:
             sp["label"] = sp.apply(lambda r: get_label_cdsd_hitran(r, details), axis=1)
         except KeyError:
             sp["label"] = sp.apply(lambda r: get_label_cdsd(r, details), axis=1)
+    elif dbformat in ["nist"]:
+        sp["label"] = sp.apply(lambda r: get_label_nist(r, sp.attrs), axis=1)
     else:
-        sp["label"] = sp.apply(get_label_none, axis=1)
+        sp["label"] = sp.apply(get_label_all, axis=1)
+        # TODO: add an option to print only certain columns?
+
+        # sp["label"] = sp.apply(get_label_none, axis=1)
 
     # from plotly.graph_objs import Scatter, Figure, Layout
-    #
+
+    if wunit == "nm":
+        xlabel = "Wavelength (nm) [{0}]".format(medium)
+        x_range = spec.get_wavelength()
+    elif wunit == "cm-1":
+        xlabel = "Wavenumber (cm-1)"
+        x_range = spec.get_wavenumber()
+    else:
+        raise ValueError("unknown wunit: {0}".format(wunit))
+
+    # TODO : implement barwidth -->  if ``hwhm_voigt``, bars are one half-width at half-maximum (in cm-1)
+    if isinstance(barwidth, str):
+        if not barwidth in sp.columns:
+            raise ValueError(
+                f"`{barwidth}` not in the lines Dataframe columns. Use one of the column names ({sp.columns}) or set `barwidth=[a 0-1 number]` to plot bars with widths as a fraction of the total range, ex : `barwidth=0.01`"
+            )
+        barwidth = sp[barwidth]
+    else:
+        barwidth = (x_range.max() - x_range.min()) * barwidth
     l = [
         go.Bar(
             x=get_x(sp.shiftwav),
@@ -467,28 +536,24 @@ def get_label_none(row):
         )
     ]
 
-    if wunit == "nm":
-        xlabel = "Wavelength (nm) [{0}]".format(medium)
-    elif wunit == "cm-1":
-        xlabel = "Wavenumber (cm-1)"
-    else:
-        raise ValueError("unknown wunit: {0}".format(wunit))
-
     if yscale == "log":
         plot_range = (
-            int(np.log10(max(cutoff, sp[plot].min()))),
+            int(np.log10(max(cutoff, sp[plot].min()))) - 1,
             int(np.log10(sp[plot].max())) + 1,
         )
     else:
         plot_range = (max(cutoff, sp[plot].min()), sp[plot].max() + 1)
 
     layout = go.Layout(
-        title="Line Survey ({T}K, {P:.3f}bar, Mfrac={Xi:.3f})".format(
-            **{"T": T, "P": P, "Xi": Xi}
-        ),
+        title="Line Survey",
+        # TODO : re-add some parameters in the title (if they exist)
+        # ({T}K, {P:.3f}bar, Mfrac={Xi:.3f})".format(
+        #    **{"T": T, "P": P, "Xi": Xi}
+        # ),
         hovermode="closest",
         xaxis=dict(
             title=xlabel,
+            range=(x_range.min(), x_range.max()),
         ),
         yaxis=dict(
             title=ylabel,
diff --git a/radis/tools/slit.py b/radis/tools/slit.py
index dfd6ae8c9..71901b8bc 100644
--- a/radis/tools/slit.py
+++ b/radis/tools/slit.py
@@ -78,7 +78,7 @@ def get_slit_function(
     verbose=True,
     auto_recenter_crop=True,
     *args,
-    **kwargs
+    **kwargs,
 ):
     """Import or generate slit function in correct wavespace
     Give a file path to import, or a float / tuple to generate arbitrary shapes
@@ -278,7 +278,7 @@ def check_input_gen():
                 wunit=return_unit,
                 scale=scale_slit,
                 *args,
-                **kwargs
+                **kwargs,
             )
 
         # Insert other slit shapes here
@@ -294,7 +294,7 @@ def check_input_gen():
                 wunit=return_unit,
                 scale=scale_slit,
                 *args,
-                **kwargs
+                **kwargs,
             )
 
         elif shape == "trapezoidal":
@@ -361,7 +361,7 @@ def check_input_gen():
             wunit=return_unit,
             scale=scale_slit,
             *args,
-            **kwargs
+            **kwargs,
         )
 
     # Or import it from file or numpy input
@@ -385,7 +385,7 @@ def check_input_gen():
             auto_recenter=auto_recenter_crop,
             bplot=False,  # we will plot after resampling
             *args,
-            **kwargs
+            **kwargs,
         )
 
         # ... get unit
@@ -500,7 +500,10 @@ def convolve_with_slit(
     waveunit=None,
 ):
     """Convolves spectrum (w,I) with instrumental slit function (w_slit, I_slit)
-    Returns a convolved spectrum on a valid range.
+
+        Returns a convolved spectrum on a valid range.
+
+        Check Notes section below for more details about its implementation.
 
         This function is called directly from a :py:class:`~radis.spectrum.spectrum.Spectrum`
         object with the :py:meth:`~radis.spectrum.spectrum.Spectrum.apply_slit` method.
@@ -520,17 +523,24 @@ def convolve_with_slit(
 
     w_slit, I_slit: array
         instrumental slit function  (wavespace unit (nm / cm-1))
+        It is recommended to truncate the input slit function to its minimum spectral
+        extension (see Notes).
 
         .. warning::
 
             Both wavespaces have to be the same!
 
     mode: ``'valid'``, ``'same'``
-        ``'same'`` returns output of same length as initial spectra,
-        but boundary effects are still visible. ``'valid'`` returns
-        output of length len(spectra) - len(slit) + 1, for
-        which lines outside of the calculated range have
-        no impact. Default ``'valid'``.
+
+        ``'same'``:
+        returns output of same length as initial spectra,
+        but boundary effects are still visible.
+
+        ``'valid'``:
+        returns output of length len(spectra) - len(slit) + 1, assuming len(I_slit) < len(I),
+        for which lines outside of the calculated range have no impact.
+
+        Default ``'valid'``.
 
     Other Parameters
     ----------------
@@ -561,12 +571,54 @@ def convolve_with_slit(
 
     Notes
     -----
-
-    Implementation is done in 5 steps:
-
-    - Check input
+    The discrete convolution operation is defined as
+
+    .. math:: (I * I_{slit})[n] = \\sum_{m = -\\infty}^{\\infty} I[m] I_{slit}[n - m]
+
+    This numerical discrete convolution doesn't take care of respecting the physical distances.
+    Therefore we have to make sure that the ``I`` and ``I_slit`` are expressed using the same
+    spectral spacing (e.g. that ``w`` and ``w`_slit`` have the same step in nm between
+    two adjacent points). The minimal step between values of ``w`` is used to determine
+    this reference spacing.
+
+    If the slit function covers a spectral interval much larger than its
+    Full Width a Half Maximum (FWHM), it will potentially add non-zero elements in the sum
+    and mix spectral features that are much further away from each other than the FWHM, as
+    it would have been expected.
+    For an experimental slit function, this also means introducing more noise and artificial
+    broadening. It is thus recommended to truncate the input slit function to its minimum
+    useful spectral range.
+
+    For instance, if you have an instrumental slit function that looks like so, we recommend
+    to cut it where the vertical lines are drawn.
+
+    ::
+
+        I_slit  ^
+                |     |   _   |
+                |     |  / \\  |
+                |     | /   \\ |
+               0|_____|/     \\|________
+              --|-----         --------->
+                    cut       cut        w_slit
+
+    When we apply a slit function, we should consider the contributions of spectral features outside
+    of the initial spectral range (i.e. ``w[0]`` - FWHM, ``w[-1]`` + FWHM) that would matter to reproduce
+    the entire spectrum. Those points are probably not taken into account during the simulation and
+    it could be interesting to extend the spectral range of ``w`` when possible to gauge this contribution.
+
+    Since, we don't have access to this extra points, we truncated the output spectrum to the spectral
+    range we can trust (no missing information). The ``valid`` mode operates this truncation by cutting
+    the edge of the spectrum ever a spectral width corresponding to the spectral width of the provided
+    slit function. This is meant to remove incorrect points on the edge. We recommend using this mode.
+    You can however access those removed points using the ``same`` mode (at your own risk).
+
+    Implementation of :func:`~radis.tools.slit.convolve_with_slit` is done in 5 steps:
+
+    - Check input slit function: compare input slit spectral range to spectrum spectral range
+      if ranges are comparable, check FHWM and send a warning.
     - Interpolate the slit function on the spectrum grid, resample it if not
-      evenly spaced
+      evenly spaced (in order to match physical distances)
     - Check slit aspect, plot slit if asked for
     - Convolve!
     - Remove boundary effects
@@ -591,16 +643,36 @@ def convolve_with_slit(
         wunit = waveunit
 
     # Assert slit function is thin enough
-    try:
-        assert abs(w[-1] - w[0]) > abs(w_slit[-1] - w_slit[0])
-    except AssertionError:
-        raise AssertionError(
-            "Slit function is broader ({0:.1f}nm) than spectrum".format(
-                abs(w_slit[-1] - w_slit[0])
-            )
-            + " ({0:.1f}nm). No valid range.".format(abs(w[-1] - w[0]))
+
+    w_range = abs(w[-1] - w[0])
+    w_slit_range = abs(w_slit[-1] - w_slit[0])
+    slit_FWHM = get_FWHM(w_slit, I_slit)
+
+    if w_slit_range > 10 * slit_FWHM:
+        warn(
+            f"FWHM >> spectral range!\n"
+            + f"Slit Function is provided with a spectral range {w_slit_range:.1f} nm that is much "
+            + f"wider than its estimated FWHM {slit_FWHM:.1f}. We recommend truncating the input "
+            + f"slit function. See radis documentation about convolve_with_slit for more information."
         )
 
+    if w_range < w_slit_range:
+        if mode == "valid":
+            raise AssertionError(
+                f"Slit function is provided with a spectral range ({w_slit_range:.1f} {waveunit}) larger than "
+                + f"the spectral range of the spectrum ({w_range:.1f} {waveunit}) : the output spectrum will therefore be empty "
+                + f"as boundary effects of the convolution are automatically discarded. If you still want to apply the slit "
+                + f"despite potential boundary effects, set `mode='same'`. However, we recommend truncating the input slit function if possible, or compute the spectrum on a larger spectral range. "
+                + f"See radis documentation about convolve_with_slit for more information."
+            )
+        else:
+            warn(
+                f"Slit spectral range > Spectrum spectral range!\n"
+                + f"Slit function is provided with a spectral range ({w_slit_range:.1f} nm) larger than "
+                + f"the spectral range of the spectrum ({w_range:.1f} nm). We recommend truncating the input "
+                + f"slit function. See radis documentation about convolve_with_slit for more information."
+            )
+
     # 2. Interpolate the slit function on the spectrum grid, resample it if not
     #    evenly spaced
     # --------------
@@ -722,15 +794,27 @@ def get_FWHM(w, I, return_index=False):
     """
     # TODO: Linearly interpolate at the boundary? insignificant for large number of points
 
-    upper = np.argwhere(I >= I.max() / 2)
+    half = I.max() / 2
+    I_offset = I - half
+    upper = np.argwhere(I_offset >= 0)
 
     xmin = upper.min()
     xmax = upper.max()
 
+    def get_zero(x1, y1, x2, y2):
+        """
+        Linear interpolation on data to get zero
+        Return location of zero by solving for f(x) = (y2-y1)/(x2-x1) * (x-x1) + y1 = 0
+        """
+        return x1 - y1 * (x2 - x1) / (y2 - y1)
+
+    wmin = get_zero(w[xmin - 1], I_offset[xmin - 1], w[xmin], I_offset[xmin])
+    wmax = get_zero(w[xmax], I_offset[xmax], w[xmax + 1], I_offset[xmax + 1])
+
     if return_index:
-        return abs(w[xmax] - w[xmin]), xmin, xmax
+        return abs(wmax - wmin), xmin, xmax
     else:
-        return abs(w[xmax] - w[xmin])
+        return abs(wmax - wmin)
 
 
 def get_effective_FWHM(w, I):
diff --git a/setup.py b/setup.py
index e8dcffe8b..87c1a21cf 100644
--- a/setup.py
+++ b/setup.py
@@ -242,35 +242,37 @@ def run_setup(with_binary):
         ],
         packages=find_packages(),
         install_requires=[
-            "hitran-api",
+            "astropy",  # Unit aware calculations
+            "astroquery>=0.3.9",  # to fetch HITRAN databases
             "beautifulsoup4",  # parse ExoMol website
+            "configparser",
+            "cython",
+            "hitran-api",
             "lxml",  # parser used for ExoMol website
-            "pyarrow",  # for the feather format (temporarily needed for ExoMol)
             "numpy",
-            "scipy>=1.4.0",
             "matplotlib",  # ">=3.4.0" to suppress the Ruler warning, but only available for Python >= 3.7
-            "seaborn",  # other matplotlib themes
-            "cython",
+            "habanero",  # CrossRef API to retrieve data from doi
+            "h5py",  # HDF5
+            "hjson",
+            "joblib",  # for parallel loading of SpecDatabase
+            "json-tricks>=3.15.0",  # to deal with non jsonable formats
             "pandas>=1.0.5",
             "plotly>=2.5.1",
+            "progressbar2",  # used in vaex
             "numba",
             "mpldatacursor",
-            "astropy",  # Unit aware calculations
             "publib>=0.3.2",  # Plotting styles for Matplotlib
             "plotly>=2.5.1",  # for line survey HTML output
-            "termcolor",  # terminal colors
-            "configparser",
-            "astroquery>=0.3.9",  # to fetch HITRAN databases
-            "json-tricks>=3.15.0",  # to deal with non jsonable formats
+            "peakutils",
+            "termcolor",
             "tables",  # for pandas to HDF5 export
             "pytest",  # to run test suite
-            "joblib",  # for parallel loading of SpecDatabase
             "numba",  # just-in-time compiler
             "psutil",  # for getting user RAM
+            "seaborn",  # other matplotlib themes
+            "scipy>=1.4.0",
             "tuna",  # to generate visual/interactive performance profiles
-            "vaex",  # HDF5,
-            "h5py",  # HDF5
-            "habanero",  # CrossRef API to retrieve data from doi
+            "vaex>=4.4.0",  # load HDF5 files  (version for custom HDF5 groups)
         ],
         extras_require={
             "dev": [


Test patch:
diff --git a/radis/test/db/test_molecule_lists.py b/radis/test/db/test_molecule_lists.py
new file mode 100644
index 000000000..2953b0349
--- /dev/null
+++ b/radis/test/db/test_molecule_lists.py
@@ -0,0 +1,364 @@
+# -*- coding: utf-8 -*-
+"""
+Test that RADIS molecule lists are always up-to-date
+
+Note: Radis HITRAN molecules remain hardcoded so we do not require an internet
+connection; fetching is only done while running tests
+"""
+
+from urllib.request import HTTPError, urlopen
+
+import pytest
+from bs4 import BeautifulSoup
+from tqdm import tqdm
+
+
+def fetch_ExoMol_molecules():
+    """ Get list of molecules in ExoMol by scrapping the ExoMol page """
+
+    url = "https://exomol.com/data/molecules/"
+    try:
+        response = urlopen(url).read()
+    except HTTPError as err:
+        raise ValueError(f"HTTPError opening url={url}") from err
+
+    soup = BeautifulSoup(
+        response, features="lxml"
+    )  # make soup that is parse-able by bs
+
+    # All others
+    rows = soup.find_all(
+        "a", {"class": "list-group-item link-list-group-item molecule_link"}
+    )
+    molecules = [r.get_attribute_list("href")[0] for r in rows]
+
+    return molecules
+
+
+@pytest.mark.needs_connection
+def test_ExoMol_molecules_list(verbose=True, *args, **kwargs):
+    """ Test that ExoMol molecule list in RADIS remains up to date"""
+
+    from radis.db.classes import EXOMOL_MOLECULES
+    from radis.misc.basics import compare_lists
+
+    molecules = fetch_ExoMol_molecules()
+
+    if verbose:
+        print("ExoMol molecules, fetched online ")
+        print(molecules)
+
+        print("Comparing Radis hardcoded ExoMol molecules list to the ExoMol website: ")
+
+    assert (
+        compare_lists(
+            EXOMOL_MOLECULES,
+            molecules,
+            l1_str="Radis molecules",
+            l2_str="Fetched from ExoMol website",
+            print_index=True,
+        )
+        == 1
+    )
+
+
+def fetch_ExoMol_isotopes(verbose=True, *args, **kwargs):
+    """ Get dictionary of isotope names in ExoMol by scrapping the ExoMol pages """
+
+    isotopes_full_names = {}
+    molecules = fetch_ExoMol_molecules()
+
+    if verbose:
+        print("Parsing ExoMol molecules", flush=True)
+    for molecule in tqdm(molecules, disable=not verbose):
+
+        url = f"https://exomol.com/data/molecules/{molecule}"
+        try:
+            response = urlopen(url).read()
+        except HTTPError as err:
+            raise ValueError(f"HTTPError opening url={url}") from err
+
+        soup = BeautifulSoup(
+            response, features="lxml"
+        )  # make soup that is parse-able by bs
+
+        # Recommended database
+        rows = soup.find_all("a", {"class": "list-group-item link-list-group-item"})
+        isotopes = [r.get_attribute_list("href")[0] for r in rows]
+
+        isotopes_full_names[molecule] = isotopes
+    return isotopes_full_names
+
+
+#%%
+
+
+def fetch_HITRAN_molecules():
+
+    import re
+
+    url = "https://hitran.org/lbl/"
+    try:
+        response = urlopen(url).read()
+    except HTTPError as err:
+        raise ValueError(f"HTTPError opening url={url}") from err
+
+    soup = BeautifulSoup(
+        response, features="lxml"
+    )  # make soup that is parse-able by bs
+
+    # All others
+    rows = soup.find_all("td", {"class": "molecule-formula"})
+    molecules = [re.sub(r"<[^>]*>", "", str(r)) for r in rows]
+
+    return molecules
+
+
+@pytest.mark.needs_connection
+def test_HITRAN_molecules_list(verbose=True, *args, **kwargs):
+    """ Test that HITRAN molecule list in RADIS remains up to date"""
+
+    from radis.db.classes import HITRAN_MOLECULES
+    from radis.misc.basics import compare_lists
+
+    molecules = fetch_HITRAN_molecules()
+
+    if verbose:
+        print("HITRAN molecules, fetched online ")
+        print(molecules)
+
+        print("Comparing Radis hardcoded HITRAN molecules list to the HITRAN website: ")
+
+    assert (
+        compare_lists(
+            HITRAN_MOLECULES,
+            molecules,
+            l1_str="Radis molecules",
+            l2_str="Fetched from HITRAN website",
+            print_index=True,
+        )
+        == 1
+    )
+
+
+#%%
+
+
+def generate_molparam_for_non_HITRAN_species():
+    """Simple estimator of molecular parameters when not given in HITRAN.
+
+    isotopic abundance  : in a simple approach, we multiply isotopic abundances
+    of all elements of the molecule .  We typically get 0.04% error for main isotope,
+    3-10% error in general , up to 80% in the worst cases, so always the same order of magnitude,
+    (when compared with species that exist in HITRAN)
+
+    molar mass : we sum the mass of all elements
+
+
+    Output ``EXTRA_ABUNDANCES_DICT, EXTRA_MOLAR_MASS_DICT, EXTRA_ISOTOPE_FULLNAME_DICT``
+    should be pasted in ``default_radis.json``
+    """
+
+    #%%
+    import numpy as np
+
+    exomol_molecules = fetch_ExoMol_molecules()
+    # hitran_molecules = fetch_HITRAN_molecules()
+    isotopes_full_names = fetch_ExoMol_isotopes()
+
+    #%%
+    from mendeleev import element  # may create ImportError, not added in requirement.
+
+    Ia_dict = {}
+    Mm_dict = {}
+    for atom in [
+        "H",
+        "C",
+        "O",
+        "N",
+        "S",
+        "F",
+        "Cl",
+        "Br",
+        "I",
+        "P",
+        "Mg",
+        "Na",
+        "Ni",
+        "Al",
+        "Cr",
+        "Ca",
+        "Be",
+        "Ti",
+        "Fe",
+        "Li",
+        "Sc",
+        "Si",
+        "V",
+        "Y",
+        "K",
+        "As",
+        "He",
+    ]:
+        el = element(atom)
+        Ia_dict[atom] = {}
+        Mm_dict[atom] = {}
+        for iso in el.isotopes:
+            if iso.abundance is None:
+                Ia_dict[atom][iso.mass_number] = 1e-6  # arbitrary
+            else:
+                Ia_dict[atom][iso.mass_number] = iso.abundance
+            Mm_dict[atom][iso.mass_number] = iso.mass
+    # add missing:
+    Ia_dict["Al"][26] = 1e-6  # arbitrary
+    Mm_dict["Al"][26] = 26.9815385 - 1.006  # doesn't exist by default
+    Mm_dict["H"][3] = 2.0141017781 + 1.006  # 3H  'None' by default
+    Mm_dict["C"][14] = 13.003354835 + 1.006  # 14C
+
+    import re
+
+    _parse_molecule = re.compile("(\d+)+([A-Z][a-z]?)(\d+)?")
+
+    def get_isotopic_atomic_composition(molecule):
+
+        atoms = {}
+        for mass_number, atom, number in _parse_molecule.findall(molecule):
+            if number == "":
+                number = 1
+            if atom in atoms:
+                atoms[atom][int(mass_number)] = int(number)
+            else:
+                atoms[atom] = {int(mass_number): int(number)}
+        return atoms
+
+        # out = dict(_parse_molecule.findall(molecule))
+        # for k, v in out.items():
+        #     if v == '':
+        #         out[k] = 1
+        #     else:
+        #         out[k] = int(v)
+        # return out
+
+    # print(get_atoms_in("CO2"))
+    print(get_isotopic_atomic_composition("12C-16O2"))
+
+    def estimate_terrestrial_abundance(molecule):
+        """``molecule`` given in ExoMol full isotope format"""
+        abundance = 1
+        atoms_isotopes = get_isotopic_atomic_composition(molecule)
+        for atom in atoms_isotopes:
+            for Z, N_Z in atoms_isotopes[atom].items():
+                abundance *= Ia_dict[atom][Z] ** N_Z
+                # if Z in Ia_dict[atom]:
+                # else:
+                #     abundance *= (1e-6)**N_Z  # arbitrary (low)
+        return abundance
+
+    def get_molar_mass(molecule):
+        """``molecule`` given in ExoMol full isotope format"""
+        atoms_isotopes = get_isotopic_atomic_composition(molecule)
+        molar_mass = sum(
+            [
+                Mm_dict[atom][Z] * N_Z
+                for atom in atoms_isotopes
+                for Z, N_Z in atoms_isotopes[atom].items()
+            ]
+        )
+        return molar_mass
+
+    assert get_molar_mass("12C-16O2") == 43.98982924
+
+    # Test : compare with terrestrial abundance when known:
+
+    from radis.db.classes import get_molecule
+    from radis.db.molparam import MolParams
+
+    molpar = MolParams()
+
+    for _, r in molpar.df.reset_index().iterrows():
+        print(get_molecule(r.id), "-", r.isotope_name_exomol)
+        if not r.isotope_name_exomol:
+            raise
+        estimate = estimate_terrestrial_abundance(r.isotope_name_exomol)
+        print(
+            "... Estim: {0:.3e}".format(
+                estimate_terrestrial_abundance(r.isotope_name_exomol)
+            )
+        )
+        print(
+            "... Real:  {0:.3e}".format(r.abundance),
+            "         ({0:.2%}% error)".format(
+                abs(estimate - r.abundance) / r.abundance
+            ),
+        )
+
+    #%% Generate dictionary of terrestrial abuneances for all molecules not in HITRAN
+    EXTRA_ABUNDANCES_DICT = {}
+    EXTRA_MOLAR_MASS_DICT = {}
+    EXTRA_ISOTOPE_FULLNAME_DICT = {}
+
+    print("Check if isotopologues in ExoMol are sorted as in HITRAN")
+    for M in exomol_molecules:
+        print(M)
+        abundances_naive = np.array(
+            [
+                estimate_terrestrial_abundance(isotope_fullname)
+                for isotope_fullname in isotopes_full_names[M]
+            ]
+        )
+        b_sort_naive = np.argsort(abundances_naive)[::-1]  # most abundant isotope first
+        abundances_naive = abundances_naive[b_sort_naive]
+        molar_mass = [
+            get_molar_mass(isotope_fullname)
+            for isotope_fullname in np.array(isotopes_full_names[M])[b_sort_naive]
+        ]
+        # sorted_naive = np.array(isotopes_full_names[M])[b]
+        for i, isotope_fullname in enumerate(
+            np.array(isotopes_full_names[M])[b_sort_naive]
+        ):
+            # check if isotopes are sorted by abundances
+            try:
+                sorted_equally = (
+                    molpar.get(M, i + 1, key="isotope_name_exomol") == isotope_fullname
+                )
+                # sorted_naively = molpar.get(M, i+1, key="isotope_name_exomol") == isotope_fullname
+                print(
+                    "... ",
+                    isotope_fullname,
+                    "\t",
+                    "ok"
+                    if sorted_equally
+                    else "⚠️ DIFFERENT ({0})".format(
+                        molpar.get(M, i + 1, key="isotope_name_exomol")
+                    ),
+                )
+            except (KeyError, NotImplementedError):
+                print("... ", isotope_fullname, "not in HITRAN")
+                try:  # add isotope i+1
+                    EXTRA_ABUNDANCES_DICT[M][str(i + 1)] = abundances_naive[i]
+                    EXTRA_MOLAR_MASS_DICT[M][str(i + 1)] = molar_mass[i]
+                    EXTRA_ISOTOPE_FULLNAME_DICT[M][str(i + 1)] = isotope_fullname
+                except KeyError:
+                    EXTRA_ABUNDANCES_DICT[M] = {str(i + 1): abundances_naive[i]}
+                    EXTRA_MOLAR_MASS_DICT[M] = {str(i + 1): molar_mass[i]}
+                    EXTRA_ISOTOPE_FULLNAME_DICT[M] = {str(i + 1): isotope_fullname}
+
+    EXTRA_ABUNDANCES_DICT = {
+        k: EXTRA_ABUNDANCES_DICT[k] for k in sorted(EXTRA_ABUNDANCES_DICT)
+    }
+    EXTRA_MOLAR_MASS_DICT = {
+        k: EXTRA_MOLAR_MASS_DICT[k] for k in sorted(EXTRA_MOLAR_MASS_DICT)
+    }
+    EXTRA_ISOTOPE_FULLNAME_DICT = {
+        k: EXTRA_ISOTOPE_FULLNAME_DICT[k] for k in sorted(EXTRA_ISOTOPE_FULLNAME_DICT)
+    }
+
+    return EXTRA_ABUNDANCES_DICT, EXTRA_MOLAR_MASS_DICT, EXTRA_ISOTOPE_FULLNAME_DICT
+
+
+#%%
+
+if __name__ == "__main__":
+
+    test_ExoMol_molecules_list()
+    test_HITRAN_molecules_list()
diff --git a/radis/test/files/CO2_measured_spectrum_4-5um.spec b/radis/test/files/CO2_measured_spectrum_4-5um.spec
index eb36394c6..1dde49e69 100644
Binary files a/radis/test/files/CO2_measured_spectrum_4-5um.spec and b/radis/test/files/CO2_measured_spectrum_4-5um.spec differ
diff --git a/radis/test/files/CO_Tgas1500K_mole_fraction0.01.spec b/radis/test/files/CO_Tgas1500K_mole_fraction0.01.spec
index 6b9033c97..34b8dd3c0 100644
Binary files a/radis/test/files/CO_Tgas1500K_mole_fraction0.01.spec and b/radis/test/files/CO_Tgas1500K_mole_fraction0.01.spec differ
diff --git a/radis/test/files/CO_Tgas1500K_mole_fraction0.5.spec b/radis/test/files/CO_Tgas1500K_mole_fraction0.5.spec
index 3483b8504..49748b0cc 100644
Binary files a/radis/test/files/CO_Tgas1500K_mole_fraction0.5.spec and b/radis/test/files/CO_Tgas1500K_mole_fraction0.5.spec differ
diff --git a/radis/test/files/N2C_specair_380nm.spec b/radis/test/files/N2C_specair_380nm.spec
index fdfdeb2e4..a78380b64 100644
--- a/radis/test/files/N2C_specair_380nm.spec
+++ b/radis/test/files/N2C_specair_380nm.spec
@@ -7525,7 +7525,6 @@
             ]
         }
     },
-    "_q_conv": {},
     "units": {
         "radiance_noslit": "mW/cm2/sr/um",
         "transmittance_noslit": "1"
@@ -7561,7 +7560,6 @@
         "wo2": 0.0,
         "nprt1": 2,
         "nms": 9,
-        "medium": "air",
         "narray": 2500,
         "wh2o": 0.0,
         "bandoriginsaver": "0",
@@ -7590,6 +7588,11 @@
         "wavelength_min": "nm"
     },
     "populations": {},
+    "references": {
+        "10.1016/j.jqsrt.2018.09.027": [
+            "post-processing"
+        ]
+    },
     "lines": null,
     "name": "N2C_specair_380nm",
     "_slit": {},
diff --git a/radis/test/io/test_exomol.py b/radis/test/io/test_exomol.py
index 4c3a45e64..9dd6d56c4 100644
--- a/radis/test/io/test_exomol.py
+++ b/radis/test/io/test_exomol.py
@@ -62,7 +62,7 @@ def test_calc_exomol_spectrum(verbose=True, plot=True, *args, **kwargs):
         mole_fraction=0.1,
         path_length=1,  # cm
         broadening_method="fft",  # @ dev: Doesn't work with 'voigt'
-        databank="exomol",  # or use ('exomol', "EBJT") for a specific database ("EBJT")
+        databank=("exomol", "EBJT"),
         verbose=verbose,
     )
     s.apply_slit(1, "cm-1")  # simulate an experimental slit
@@ -70,6 +70,56 @@ def test_calc_exomol_spectrum(verbose=True, plot=True, *args, **kwargs):
         s.plot("radiance")
 
 
+@pytest.mark.needs_connection
+def test_calc_exomol_vs_hitemp(verbose=True, plot=True, *args, **kwargs):
+    """Auto-fetch and calculate a CO spectrum from the ExoMol database
+    (HITEMP lineset)   and compare to HITEMP (on the HITRAN server)
+
+    https://github.com/radis/radis/pull/320#issuecomment-884508206
+    """
+    import astropy.units as u
+
+    from radis import calc_spectrum
+
+    conditions = {
+        "wmin": 2002 / u.cm,
+        "wmax": 2300 / u.cm,
+        "molecule": "CO",
+        "isotope": "2",
+        "pressure": 1.01325,  # bar
+        "Tgas": 1000,  # K
+        "mole_fraction": 0.1,
+        "path_length": 1,  # cm
+        "broadening_method": "fft",  # @ dev: Doesn't work with 'voigt'
+        "verbose": True,
+    }
+
+    s_exomol = calc_spectrum(
+        **conditions, databank="exomol", name="EXOMOL/HITEMP (H2 broadened)"
+    )
+    s_hitemp = calc_spectrum(
+        **conditions,
+        databank="hitemp",
+        name="HITEMP (Air broadened)",
+    )
+    if plot:
+        s_exomol.plot(
+            lw=3,
+        )
+        s_hitemp.plot(lw=1, nfig="same")
+        import matplotlib.pyplot as plt
+
+        plt.legend()
+
+    # Broadening coefficients are different but areas under the lines should be the same :
+    import numpy as np
+
+    assert np.isclose(
+        s_exomol.get_integral("abscoeff"), s_hitemp.get_integral("abscoeff"), rtol=0.001
+    )
+
+
 if __name__ == "__main__":
     test_exomol_parsing_functions()
     test_calc_exomol_spectrum()
+    test_calc_exomol_vs_hitemp()
diff --git a/radis/test/io/test_hdf5.py b/radis/test/io/test_hdf5.py
index e6edca831..031a8b328 100644
--- a/radis/test/io/test_hdf5.py
+++ b/radis/test/io/test_hdf5.py
@@ -48,7 +48,7 @@ def test_hdf5_io_engines(*args, **kwargs):
 
     # Test h5py engine : add_metadata ; load ; read_metadata
     manager = HDF5Manager(engine="h5py")
-    manager.add_metadata("test_h5py.h5", metadata0)
+    manager.add_metadata("test_h5py.h5", metadata0, key=None)
     df = manager.load("test_h5py.h5", key=None)
     assert (df == df0).all().all()
     assert manager.read_metadata("test_h5py.h5") == metadata0
@@ -56,13 +56,13 @@ def test_hdf5_io_engines(*args, **kwargs):
     # Test vaex engine : add_metadata ; load ; read_
     # .. also able to read h5py files
     manager = HDF5Manager(engine="vaex")
-    manager.add_metadata("test_h5py.h5", metadata0)
+    manager.add_metadata("test_h5py.h5", metadata0, key=None)
     df = manager.load("test_h5py.h5", key=None)
     # this time; df is a vaex DataFrame
     # ... it keeps the file open so we should close the file handle
     assert (df.to_pandas_df() == df0).all().all()
     df.close()
-    assert manager.read_metadata("test_h5py.h5") == metadata0
+    assert manager.read_metadata("test_h5py.h5", key=None) == metadata0
 
 
 @pytest.mark.needs_connection
diff --git a/radis/test/io/test_hitemp.py b/radis/test/io/test_hitemp.py
index 7350a6f61..678114927 100644
--- a/radis/test/io/test_hitemp.py
+++ b/radis/test/io/test_hitemp.py
@@ -98,15 +98,18 @@ def test_fetch_hitemp_partial_download_CO2(verbose=True, *args, **kwargs):
     # ... This is done at the HITEMPDatabaseManager level
     # ... unitest for this part:
     ldb = HITEMPDatabaseManager(
-        name="HITEMP-CO2", molecule="CO2", local_databases="~/.radisdb/hitemp/"
+        name="HITEMP-CO2",
+        molecule="CO2",
+        engine="default",
+        local_databases="~/.radisdb/hitemp/",
     )
-    local_files, _ = ldb.get_filenames(engine="auto")
+    local_files, _ = ldb.get_filenames()
     relevant_file, file_wmin, file_wmax = keep_only_relevant(
         local_files, wavenum_min=wmin, wavenum_max=wmax
     )
 
     assert len(relevant_file) == 1
-    assert basename(relevant_file[0]) == "CO2-02_02500-03000_HITEMP2010.h5"
+    assert basename(relevant_file[0]).startswith("CO2-02_02500-03000_HITEMP2010.")
     assert file_wmin == 2500
     assert file_wmax == 3000
 
@@ -123,7 +126,7 @@ def test_fetch_hitemp_partial_download_CO2(verbose=True, *args, **kwargs):
     assert "HITEMP-CO2" in getDatabankList()
 
     assert len(local_files) == 1
-    assert basename(local_files[0]) == "CO2-02_02500-03000_HITEMP2010.h5"
+    assert basename(local_files[0]).startswith("CO2-02_02500-03000_HITEMP2010.")
 
 
 @pytest.mark.needs_connection
@@ -161,7 +164,7 @@ def test_fetch_hitemp_all_molecules(molecule, verbose=True, *args, **kwargs):
         columns=["int", "wav"],
         verbose=verbose,
         return_local_path=True,
-        engine="pytables",
+        engine="default",
     )
 
     assert f"HITEMP-{molecule}" in getDatabankList()
@@ -170,6 +173,7 @@ def test_fetch_hitemp_all_molecules(molecule, verbose=True, *args, **kwargs):
         name=f"HITEMP-{molecule}",
         molecule=molecule,
         verbose=verbose,
+        engine="default",
         local_databases="~/.radisdb/hitemp/",
     )
     url, Nlines, _, _ = ldb.fetch_url_Nlines_wmin_wmax()
@@ -183,25 +187,39 @@ def test_partial_loading(*args, **kwargs):
 
     Also check 'vaex' engine and ``radis.config["AUTO_UPDATE_DATABASE"]``"""
 
-    wmin, wmax = 2500, 4500
+    from radis.test.utils import getTestFile
 
-    # First ensures that the database is wider than this (else test is irrelevant) :
-    df = fetch_hitemp("OH", engine="pytables")
-    assert df.wav.min() < wmin
-    assert df.wav.max() > wmax
+    df = fetch_hitemp(
+        "OH",
+        local_databases=getTestFile("."),
+        databank_name="HITEMP-OH-TEST-PARTIAL-LOADING",
+        engine="pytables",
+    )
 
     # Now fetch with partial loading
+    wmin, wmax = 2500, 4500
+    # ... First ensures that the database is wider than this (else test is irrelevant) :
+    assert df.wav.min() < wmin
+    assert df.wav.max() > wmax
+    # ... load :
     df = fetch_hitemp(
-        "OH", load_wavenum_min=wmin, load_wavenum_max=wmax, engine="pytables"
+        "OH",
+        local_databases=getTestFile("."),
+        databank_name="HITEMP-OH-TEST-PARTIAL-LOADING",
+        load_wavenum_min=wmin,
+        load_wavenum_max=wmax,
+        engine="pytables",
     )
     assert df.wav.min() >= wmin
     assert df.wav.max() <= wmax
 
     # Test with isotope:
-    wmin2 = 0
+    wmin2 = 1
     wmax2 = 300
     df = fetch_hitemp(
         "OH",
+        local_databases=getTestFile("."),
+        databank_name="HITEMP-OH-TEST-PARTIAL-LOADING",
         load_wavenum_min=wmin2,
         load_wavenum_max=wmax2,
         isotope="2",
@@ -210,6 +228,8 @@ def test_partial_loading(*args, **kwargs):
     assert df.iso.unique() == 2
     df = fetch_hitemp(
         "OH",
+        local_databases=getTestFile("."),
+        databank_name="HITEMP-OH-TEST-PARTIAL-LOADING",
         load_wavenum_min=wmin2,
         load_wavenum_max=wmax2,
         isotope="1,2",
@@ -223,21 +243,31 @@ def test_partial_loading(*args, **kwargs):
     old_config = radis.config["AUTO_UPDATE_DATABASE"]
     try:
         radis.config["AUTO_UPDATE_DATABASE"] = True
-        df = fetch_hitemp("OH", engine="vaex")
-    finally:
-        radis.config["AUTO_UPDATE_DATABASE"] = old_config
-    assert df.wav.min() < wmin
-    assert df.wav.max() > wmax
-
-    # multiple isotope selection not implemetned with vaex
-    with pytest.raises(NotImplementedError):
-        fetch_hitemp(
+        df = fetch_hitemp(
             "OH",
-            load_wavenum_min=wmin,
-            load_wavenum_max=wmax,
-            isotope="1,2,3",
+            local_databases=getTestFile("."),
+            databank_name="HITEMP-OH-TEST-PARTIAL-LOADING",
             engine="vaex",
         )
+    finally:
+        radis.config["AUTO_UPDATE_DATABASE"] = old_config
+    # assert that database is wider than future selection
+    assert df.wav.min() < wmin2
+    assert df.wav.max() > wmax2
+
+    # now test wrange & multiple isotope selection with vaex
+    df = fetch_hitemp(
+        "OH",
+        local_databases=getTestFile("."),
+        databank_name="HITEMP-OH-TEST-PARTIAL-LOADING",
+        load_wavenum_min=wmin2,
+        load_wavenum_max=wmax2,
+        isotope="2,3",
+        engine="vaex",
+    )
+    assert df.wav.min() >= wmin2
+    assert df.wav.max() <= wmax2
+    assert set(df.iso.unique()) == {2, 3}
 
 
 @pytest.mark.needs_connection
diff --git a/radis/test/io/test_query.py b/radis/test/io/test_query.py
index 6c359f931..ed5a775e5 100644
--- a/radis/test/io/test_query.py
+++ b/radis/test/io/test_query.py
@@ -99,11 +99,52 @@ def test_fetch_astroquery_cache(verbose=True, *args, **kwargs):
     return
 
 
+# ignored by pytest with argument -m "not needs_connection"
+@pytest.mark.needs_connection
+def test_fetch_hitran(*args, **kwargs):
+
+    from radis.io.hitran import fetch_hitran
+
+    df = fetch_hitran("CO")
+
+    assert len(df) == 5381
+    assert df.wav.min() == 3.40191
+    assert df.wav.max() == 14477.377142
+
+
+@pytest.mark.needs_connection
+def test_calc_hitran_spectrum(verbose=True, plot=False, *args, **kwargs):
+    """
+    Test full range & partial loading of CO spectrum
+    """
+
+    from radis import test_spectrum
+
+    # The main thing here is to make sure the two function calls work :
+    s = test_spectrum(databank=("hitran", "full"), name="full range", verbose=verbose)
+    s2 = test_spectrum(
+        databank=("hitran", "range"), name="partial range", verbose=verbose
+    )
+
+    if plot:
+        from radis import plot_diff
+
+        plot_diff(s, s2)
+
+    # assert s == s2
+    # Note: tiny differences (0.56%) probably due to how data is stored on disk
+    assert s.compare_with(s2, spectra_only="abscoeff", method="ratio", rtol=0.006)
+
+    return
+
+
 def _run_testcases(verbose=True, *args, **kwargs):
 
     test_fetch_astroquery(verbose=verbose, *args, **kwargs)
     test_fetch_astroquery_empty(verbose=verbose, *args, **kwargs)
     test_fetch_astroquery_cache(verbose=verbose, *args, **kwargs)
+    test_fetch_hitran(*args, **kwargs)
+    test_calc_hitran_spectrum(*args, **kwargs)
 
     return True
 
diff --git a/radis/test/lbl/test_base.py b/radis/test/lbl/test_base.py
index 26bbf60be..d4403e34e 100644
--- a/radis/test/lbl/test_base.py
+++ b/radis/test/lbl/test_base.py
@@ -189,7 +189,7 @@ def test_export_rovib_fractions(
     # Calculate populations using the non-equilibrium module:
     s = sf.non_eq_spectrum(300, 300)
     # Test calculated quantities are there
-    assert "Qref" in sf.df1.attrs
+    # assert "Qref" in sf.df1.attrs
     assert "Qvib" in sf.df1.attrs
     assert hasattr(sf.df1, "Qrotu")
     assert hasattr(sf.df1, "Qrotl")
@@ -241,7 +241,7 @@ def test_populations_CO2_hamiltonian(
         verbose=verbose,
     )
     sf.warnings["MissingSelfBroadeningWarning"] = "ignore"
-    sf.load_databank("HITEMP-CO2-HAMIL-TEST", load_energies=True)
+    sf.load_databank("HITEMP-CO2-HAMIL-TEST", load_energies=True, load_columns="noneq")
 
     # First run a calculation at equilibrium
     s = sf.eq_spectrum(300)
@@ -338,7 +338,7 @@ def test_optically_thick_limit_1iso(verbose=True, plot=True, *args, **kwargs):
             verbose=False,
         )
         sf.warnings["NegativeEnergiesWarning"] = "ignore"
-        sf.load_databank("HITEMP-CO2-TEST")
+        sf.load_databank("HITEMP-CO2-TEST", load_columns="noneq")
         pb = ProgressBar(3, active=verbose)
         s_eq = sf.eq_spectrum(Tgas=Tgas, mole_fraction=1, name="Equilibrium")
         pb.update(1)
@@ -450,7 +450,7 @@ def test_optically_thick_limit_2iso(verbose=True, plot=True, *args, **kwargs):
             pressure=P,
             verbose=False,
         )
-        sf.load_databank("HITEMP-CO2-TEST")
+        sf.load_databank("HITEMP-CO2-TEST", load_columns="noneq")
         sf.warnings["NegativeEnergiesWarning"] = "ignore"
         #        sf.warnings['MissingSelfBroadeningWarning'] = 'ignore'
         pb = ProgressBar(3, active=verbose)
@@ -507,6 +507,8 @@ def test_optically_thick_limit_2iso(verbose=True, plot=True, *args, **kwargs):
 
 def test_get_waverange(*args, **kwargs):
 
+    assert get_waverange(1 * u.um, 10 * u.um, medium="vacuum") == (1000, 10000)
+
     # 'wunit' is none
     # ...'wmin/wmax' is none
     # ...... wavenumber is passed > should return the same wavenumbers
diff --git a/radis/test/lbl/test_broadening.py b/radis/test/lbl/test_broadening.py
index a316beb1c..4aaed7b1c 100644
--- a/radis/test/lbl/test_broadening.py
+++ b/radis/test/lbl/test_broadening.py
@@ -91,7 +91,7 @@ def test_broadening_vs_hapi(rtol=1e-2, verbose=True, plot=False, *args, **kwargs
             "HighTemperatureWarning": "ignore",
             "GaussianBroadeningWarning": "ignore",
         },
-    )  # 0.2)
+    )
     sf.load_databank(
         path=join(hapi_data_path, "CO.data"), format="hitran", parfuncfmt="hapi"
     )
@@ -318,6 +318,7 @@ def test_broadening_DLM(verbose=True, plot=False, *args, **kwargs):
         pressure=p,
         truncation=truncation,
         neighbour_lines=neighbour_lines,
+        optimization=None,
         isotope="1",
         verbose=False,
         warnings={
@@ -326,16 +327,16 @@ def test_broadening_DLM(verbose=True, plot=False, *args, **kwargs):
             "HighTemperatureWarning": "ignore",
             "GaussianBroadeningWarning": "ignore",
         },
-    )  # 0.2)
+    )
     sf.load_databank("HITRAN-CO-TEST")
 
     # Reference: calculate without DLM
-    assert sf.misc["chunksize"] is None
+    assert sf.params["optimization"] is None
     s_ref = sf.eq_spectrum(Tgas=T)
     s_ref.name = "Reference ({0:.2f}s)".format(s_ref.conditions["calculation_time"])
 
     # DLM:
-    sf.misc["chunksize"] = "DLM"
+    sf.params["optimization"] = "simple"
     sf.params.broadening_method = "convolve"
     s_dlm = sf.eq_spectrum(Tgas=T)
     s_dlm.name = "DLM ({0:.2f}s)".format(s_dlm.conditions["calculation_time"])
@@ -398,14 +399,14 @@ def test_broadening_DLM_FT(verbose=True, plot=False, *args, **kwargs):
         neighbour_lines=neighbour_lines,
         isotope="1",
         verbose=verbose,
-        chunksize="DLM",
+        optimization="simple",
         warnings={
             "MissingSelfBroadeningWarning": "ignore",
             "NegativeEnergiesWarning": "ignore",
             "HighTemperatureWarning": "ignore",
             "GaussianBroadeningWarning": "ignore",
         },
-    )  # 0.2)
+    )
     sf.load_databank("HITRAN-CO-TEST")
 
     # DLM, real space
@@ -478,11 +479,11 @@ def test_broadening_DLM_noneq(verbose=True, plot=False, *args, **kwargs):
             "HighTemperatureWarning": "ignore",
             "GaussianBroadeningWarning": "ignore",
         },
-    )  # 0.2)
-    sf.load_databank("HITRAN-CO2-TEST")
+    )
+    sf.load_databank("HITRAN-CO2-TEST", load_columns="noneq")
 
     # DLM:
-    sf.misc["chunksize"] = "DLM"
+    sf.params["optimization"] = "simple"
     s_dlm_eq = sf.eq_spectrum(Tgas=3000)
     s_dlm_eq.name = "DLM eq ({0:.2f}s)".format(s_dlm_eq.conditions["calculation_time"])
 
@@ -830,9 +831,8 @@ def test_noneq_continuum(plot=False, verbose=2, warnings=True, *args, **kwargs):
         }
     )
     sf.fetch_databank(
-        "hitran"
+        "hitran", load_columns="noneq"
     )  # uses HITRAN: not really valid at this temperature, but runs on all machines without install
-    #        sf.load_databank('HITEMP-CO2-DUNHAM')       # to take a real advantage of abscoeff continuum, should calculate with HITEMP
     sf._export_continuum = True  # activate it
 
     # Calculate one without pseudo-continuum
diff --git a/radis/test/lbl/test_calc.py b/radis/test/lbl/test_calc.py
index 286e0e7ab..f4d053846 100644
--- a/radis/test/lbl/test_calc.py
+++ b/radis/test/lbl/test_calc.py
@@ -166,7 +166,17 @@ def test_calc_spectrum(verbose=True, plot=True, warnings=True, *args, **kwargs):
     #       0.19213799, 0.20150788, 0.17328113, 0.17212415, 0.15883971,
     #       0.17112437, 0.15403756, 0.13375255, 0.1193155 , 0.10882585,
     #       0.11113302, 0.04581781, 0.00247155, 0.00143631])
-    # Update on 17/06/20 (Radis 0.9.30) with the new HITRAN-2020 lines for CO2:
+    # Update on 17/06/21 (Radis 0.9.30) with the new HITRAN-2020 lines for CO2:
+    # I_ref = np.array([0.29009315, 0.29732781, 0.32959567, 0.32061864, 0.20588816,
+    #         0.19179586, 0.20101157, 0.17255937, 0.17163118, 0.15821026,
+    #         0.17120068, 0.15403946, 0.13356296, 0.11907705, 0.10890721,
+    #         0.11158052, 0.04583138, 0.00232573, 0.00136583])
+    # Update on 05/11/21 (Radis 0.10.4) with use of tabulated instead of ab-initio
+    # partition functions for Qref  (0.02% constant difference)
+    #
+    # Update on 14/11/21: after switching from HITRAN fetched by astroquery (partial range)
+    # to HITRAN fetched by HAPI (full database) > atol=1e-6 fails, because of
+    # numerical errors in how the two databases are stored. Switched to rtol=1e-3
     w_ref = np.array(
         [
             4197.60321744,
@@ -193,25 +203,25 @@ def test_calc_spectrum(verbose=True, plot=True, warnings=True, *args, **kwargs):
 
     I_ref = np.array(
         [
-            0.29009315,
-            0.29732781,
-            0.32959567,
-            0.32061864,
-            0.20588816,
-            0.19179586,
-            0.20101157,
-            0.17255937,
-            0.17163118,
-            0.15821026,
-            0.17120068,
-            0.15403946,
-            0.13356296,
-            0.11907705,
-            0.10890721,
-            0.11158052,
-            0.04583138,
-            0.00232573,
-            0.00136583,
+            0.29003961,
+            0.29727294,
+            0.32953484,
+            0.32055948,
+            0.20585016,
+            0.19176046,
+            0.20097447,
+            0.17252753,
+            0.17159951,
+            0.15818106,
+            0.17116909,
+            0.15401103,
+            0.13353831,
+            0.11905508,
+            0.10888712,
+            0.11155993,
+            0.04582293,
+            0.0023253,
+            0.00136557,
         ]
     )
 
@@ -222,7 +232,7 @@ def test_calc_spectrum(verbose=True, plot=True, warnings=True, *args, **kwargs):
     # [71:-71] because of the shift introduced in 0.9.30 where convolved
     # arrays are not cropped; but np.nan values are added
     assert np.allclose(w[71:-71][::100], w_ref, atol=1e-6)
-    assert np.allclose(I[71:-71][::100], I_ref, atol=1e-6)
+    assert np.allclose(I[71:-71][::100], I_ref, rtol=1e-3)
 
     return True
 
@@ -335,28 +345,38 @@ def test_calc_spectrum_overpopulations(
     #    0.32788457, 0.13512857, 0.00686548, 0.00386199])
 
     # Update on 17/06/20 (Radis 0.9.30) with the new HITRAN-2020 lines for CO2
+    # array([0.62009366, 0.66644973, 0.81033866, 0.79357758, 0.56690276,
+    #        0.58248594, 0.61106305, 0.52129611, 0.51819065, 0.47520791,
+    #        0.51455743, 0.46064655, 0.39761512, 0.35293948, 0.32169967,
+    #        0.3293075 , 0.13525292, 0.00645308, 0.00366828])
+    # Update on 05/11/21 (Radis 0.10.4) with use of tabulated instead of ab-initio
+    # partition functions for Qref  (0.02% constant difference)
+    #
+    # Update on 14/11/21: after switching from HITRAN fetched by astroquery (partial range)
+    # to HITRAN fetched by HAPI (full database) > atol=1e-6 fails, because of
+    # numerical errors in how the two databases are stored. Switched to rtol=1e-3
 
     I_ref = np.array(
         [
-            0.62009366,
-            0.66644973,
-            0.81033866,
-            0.79357758,
-            0.56690276,
-            0.58248594,
-            0.61106305,
-            0.52129611,
-            0.51819065,
-            0.47520791,
-            0.51455743,
-            0.46064655,
-            0.39761512,
-            0.35293948,
-            0.32169967,
-            0.3293075,
-            0.13525292,
-            0.00645308,
-            0.00366828,
+            0.61997922,
+            0.66632674,
+            0.81018912,
+            0.79343113,
+            0.56679814,
+            0.58237845,
+            0.61095028,
+            0.52119991,
+            0.51809502,
+            0.47512021,
+            0.51446247,
+            0.46056154,
+            0.39754174,
+            0.35287435,
+            0.3216403,
+            0.32924673,
+            0.13522796,
+            0.00645189,
+            0.0036676,
         ]
     )
     if plot:
@@ -366,7 +386,7 @@ def test_calc_spectrum_overpopulations(
 
     # [71:-71] because of the shift introduced in 0.9.30 where convolved
     # arrays are not cropped; but np.nan values are added
-    assert np.allclose(I[71:-71][::100], I_ref, atol=1e-6)
+    assert np.allclose(I[71:-71][::100], I_ref, rtol=1e-3)
 
     if verbose:
         printm("Test overpopulations: OK")
@@ -502,7 +522,7 @@ def test_all_calc_methods_CO2pcN(
     del database_kwargs["info"]
     database_kwargs["levelsfmt"] = "cdsd-pcN"
     # ... load the new database
-    sf.load_databank(**database_kwargs, load_energies=True)
+    sf.load_databank(**database_kwargs, load_energies=True, load_columns="noneq")
 
     # Now, define Evib:
     Q_calc = sf.parsum_calc["CO2"][1]["X"]
@@ -581,7 +601,7 @@ def test_eq_vs_noneq_isotope(verbose=True, plot=False, warnings=True, *args, **k
     sf.warnings["NegativeEnergiesWarning"] = "ignore"
     sf.warnings["HighTemperatureWarning"] = "ignore"
     sf.fetch_databank(
-        "hitran"
+        "hitran", load_columns="noneq"
     )  # uses HITRAN: not really valid at this temperature, but runs on all machines without install
     s_nq = sf.non_eq_spectrum(Tvib=Tgas, Trot=Tgas, name="Non-eq")
     s_eq = sf.eq_spectrum(Tgas=Tgas, name="Eq")
@@ -744,6 +764,34 @@ def test_calc_spectrum_multiple_molecules_inputerror(
     return True
 
 
+@pytest.mark.needs_connection
+def test_calc_spectrum_multiple_molecules_wstep_auto(
+    verbose=True, plot=True, warnings=True, *args, **kwargs
+):
+    """Tests multiple molecules spectrum for wstep = 'auto'
+    and checks that minimum wstep value is selected with
+    resample = "intersect"""
+    from radis import calc_spectrum
+
+    # Merging the CO, CO2 spectrum itself in calc_spectrum
+    s = calc_spectrum(
+        wavelength_min=4165,
+        wavelength_max=5000,  # cm-1
+        isotope="1,2,3",
+        pressure=1.01325,  # bar
+        Tgas=700,  # K
+        mole_fraction={"CO": 0.1, "CO2": 0.1},
+        path_length=1,  # cm
+        wstep="auto",
+        databank="hitran",  # or use 'hitemp'
+        verbose=verbose,
+    )
+
+    # Check calculation went fine:
+    assert set(s.conditions["molecule"]) == set(["CO2", "CO"])
+    assert s.get_conditions()["wstep"] in ("N/A", 0.013)
+
+
 def _run_testcases(plot=True, verbose=True, warnings=True, *args, **kwargs):
 
     # Test sPlanck and conversion functions
@@ -774,6 +822,7 @@ def _run_testcases(plot=True, verbose=True, warnings=True, *args, **kwargs):
     test_calc_spectrum_multiple_molecules()
     test_calc_spectrum_multiple_molecules_otherinputs()
     test_calc_spectrum_multiple_molecules_inputerror()
+    test_calc_spectrum_multiple_molecules_wstep_auto()
 
     return True
 
diff --git a/radis/test/lbl/test_factory.py b/radis/test/lbl/test_factory.py
index 572f143d2..b588ca25b 100644
--- a/radis/test/lbl/test_factory.py
+++ b/radis/test/lbl/test_factory.py
@@ -584,7 +584,7 @@ def test_wstep_auto_method_sf(verbose=True, plot=False, *args, **kwargs):
     sf.load_databank("HITRAN-CO-TEST")
 
     s1 = sf.eq_spectrum(300, pressure=1)
-    assert sf.wstep == "auto"
+    assert sf._wstep == "auto"
 
     wstep_calculated = s1.get_conditions()["wstep"]
 
@@ -594,10 +594,10 @@ def test_wstep_auto_method_sf(verbose=True, plot=False, *args, **kwargs):
     )
 
     s2 = sf.eq_spectrum(300, pressure=0.2)
-    assert sf.wstep == "auto"
+    assert sf._wstep == "auto"
 
     s3 = sf.eq_spectrum(300, pressure=0.001)
-    assert sf.wstep == "auto"
+    assert sf._wstep == "auto"
 
     assert (
         s1.get_conditions()["wstep"]
@@ -641,7 +641,7 @@ def test_all_spectrum_using_wstep_auto(verbose=True, plot=False, *args, **kwargs
     wstep_4 = sf.params.wstep
 
     assert wstep_1 == wstep_2 == wstep_3 == wstep_4
-    assert sf.wstep == "auto"
+    assert sf._wstep == "auto"
 
 
 # --------------------------
diff --git a/radis/test/lbl/test_loader.py b/radis/test/lbl/test_loader.py
index 925d0601d..8c21b19c9 100644
--- a/radis/test/lbl/test_loader.py
+++ b/radis/test/lbl/test_loader.py
@@ -57,7 +57,8 @@ def test_retrieve_from_database(
         )
         sf.warnings["MissingSelfBroadeningWarning"] = "ignore"
         sf.init_databank(
-            "HITEMP-CO2-TEST"
+            "HITEMP-CO2-TEST",
+            load_columns="noneq",
         )  # unlike load_databank, will automatically be built when needed
         db = sf.init_database(temp_database_name, autoretrieve=True)
 
@@ -180,7 +181,7 @@ def test_custom_abundance(verbose=True, plot=False, *args, **kwargs):
         verbose=0,
     )
     sf.warnings["MissingSelfBroadeningWarning"] = "ignore"
-    sf.load_databank("HITEMP-CO2-TEST")
+    sf.load_databank("HITEMP-CO2-TEST", load_columns="all")
 
     #%% Compute a spectrum ; set the terrestrial abundance to False to
     # force computation of linestrength from Einstein coefficients instead
diff --git a/radis/test/misc/test_arrays.py b/radis/test/misc/test_arrays.py
index 97fe964de..bc5b47eee 100644
--- a/radis/test/misc/test_arrays.py
+++ b/radis/test/misc/test_arrays.py
@@ -26,6 +26,7 @@
     is_sorted_backward,
     last_nonnan_index,
     logspace,
+    non_zero_values_around,
 )
 from radis.test.utils import setup_test_line_databases
 
@@ -291,17 +292,79 @@ def test_cython_add_at_spectra(*args, **kwargs):
     sf.use_cython = False
     s_numpy = sf.eq_spectrum(Tgas=T, name="numpy")
     s_numpy.apply_slit(0.5, "nm")
-    assert sf.params.add_at_used == "numpy"
+    assert sf.misc.add_at_used == "numpy"
 
     sf.use_cython = True
     s_cython = sf.eq_spectrum(Tgas=T, name="cython")
     s_cython.apply_slit(0.5, "nm")
-    assert sf.params.add_at_used == "cython"
+    assert sf.misc.add_at_used == "cython"
 
     res = get_residual(s_numpy, s_cython, "transmittance")
     assert res < 2e-4
 
 
+def test_non_zero_values_around(*args, **kwargs):
+    """return a boolean array of same size as ``a`` where each position ``i``
+    is ``True`` if there are non-zero points less than ``n`` index position
+    away from ``a[i]``, and ``False`` if all points in ``a`` are 0 ``n``  index
+    position away from from ``a[i]``
+    """
+
+    a = np.array(
+        [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0], dtype=np.float64
+    )
+
+    # n = 1
+    b = np.array([0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1])
+    out = non_zero_values_around(a, 1)
+    print(a, "width 1")
+    print(b)
+    print(np.array(out, dtype=int))
+    assert (out == b).all()
+
+    # n = 2
+    b = np.array([1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1])
+    out = non_zero_values_around(a, 2)
+    print(a, "width 2")
+    print(b)
+    print(np.array(out, dtype=int))
+    assert (out == b).all()
+
+    # n = 2
+    a = np.array(
+        [0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], dtype=np.float64
+    )
+    b = np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1])
+    out = non_zero_values_around(a, 2)
+    print(a, "width 2")
+    print(b)
+    print(np.array(out, dtype=int))
+    assert (out == b).all()
+
+    # n = 1
+    a = np.array(
+        [0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], dtype=np.float64
+    )
+    b = np.array([1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1])
+    out = non_zero_values_around(a, 1)
+    print(a, "width 1")
+    print(b)
+    print(np.array(out, dtype=int))
+    assert (out == b).all()
+
+    # Also test non_zero_ranges_in_array, boolean_array_from_ranges
+    from radis.misc.arrays import boolean_array_from_ranges, non_zero_ranges_in_array
+
+    b = np.array([0, 0, 1, 1, 0, 1, 0, 1], dtype=bool)
+    assert (non_zero_ranges_in_array(b) == np.array([(2, 4), (5, 6), (7, 8)])).all()
+
+    L = np.array([[2, 4], [5, 6], [7, 8]], dtype=np.int64)
+    assert (
+        boolean_array_from_ranges(L, 8)
+        == np.array([0, 0, 1, 1, 0, 1, 0, 1], dtype=bool)
+    ).all()
+
+
 if __name__ == "__main__":
 
     pytest.main(["test_arrays.py", "-s"])  # -s for showing console output
diff --git a/radis/test/misc/test_basics.py b/radis/test/misc/test_basics.py
index b14d378fd..dc3b795f7 100644
--- a/radis/test/misc/test_basics.py
+++ b/radis/test/misc/test_basics.py
@@ -23,3 +23,22 @@ def test_round_off(*args, **kwargs):
     assert round_off(0.024231242) == 0.024
     assert round_off(0.0019932133) == 0.002
     assert round_off(0.0000000000002193) == 0
+
+
+@pytest.mark.fast
+def test_str2bool(*args, **kwargs):
+    import pandas as pd
+
+    from radis.misc.basics import str2bool
+
+    df = pd.Series(["1.0", "true", "True", 1])
+    assert df.dtypes == object
+
+    # Test
+    assert df.map(str2bool).all()
+
+    df = pd.Series(["0.0", "false", "False", 0])
+    assert df.dtypes == object
+
+    # Test
+    assert not df.map(str2bool).any()
diff --git a/radis/test/spectrum/test_operations.py b/radis/test/spectrum/test_operations.py
index 7616f18ba..bf0fffc9b 100644
--- a/radis/test/spectrum/test_operations.py
+++ b/radis/test/spectrum/test_operations.py
@@ -204,7 +204,7 @@ def test_other_algebraic_operations(verbose=True, plot=False, *args, **kwargs):
 
     # There should be an error if algebraic operations are used when
     # multiple quantities are defined:
-    with pytest.raises(KeyError):
+    with pytest.raises(ValueError):
         2 * s
 
     s.apply_slit(0.1, "nm")
@@ -275,7 +275,7 @@ def test_dimensioned_operations(*args, **kwargs):
 
     # Test division
     # Example : a manual normalization
-    s /= s.max() * u.Unit("mW/cm2/sr/nm")
+    s /= s.max()  # no need to correct for  byunits anymore : * u.Unit("mW/cm2/sr/nm")
 
     assert s.units["radiance"] == ""  # normalized
     assert s.max() == 1.0
@@ -291,6 +291,30 @@ def test_dimensioned_operations(*args, **kwargs):
     assert s.units["radiance"] == "mW / (cm2 nm sr)"  # check units have been simplified
 
 
+@pytest.mark.fast
+def test_get_baseline(plot=False, *args, **kwargs):
+
+    from radis import load_spec
+    from radis.test.utils import getTestFile
+
+    s = load_spec(getTestFile(r"CO2_measured_spectrum_4-5um.spec"), binary=True)
+
+    from radis.spectrum.operations import get_baseline
+
+    sb1 = get_baseline(s.crop(4480, 5000), algorithm="polynomial", deg=3)
+    sb1.name = "polynomial N=3"
+    sb2 = get_baseline(s.crop(4480, 5000), algorithm="als")
+    sb2.name = "asymmetric least square"
+
+    if plot:
+        import matplotlib.pyplot as plt
+
+        s.plot()
+        sb1.plot(nfig="same", lw=3)
+        sb2.plot(nfig="same", lw=3)
+        plt.legend()
+
+
 def _run_testcases(verbose=True, plot=False, *args, **kwargs):
     """Test procedures"""
 
@@ -304,6 +328,7 @@ def _run_testcases(verbose=True, plot=False, *args, **kwargs):
     test_other_algebraic_operations(verbose=verbose, plot=plot, *args, **kwargs)
     test_TestBaseline(verbose=verbose, plot=plot, *args, **kwargs)
     test_dimensioned_operations(*args, **kwargs)
+    test_get_baseline(plot=plot, *args, **kwargs)
 
     return True
 
diff --git a/radis/test/test_examples.py b/radis/test/test_examples.py
index 24c9f9616..90b132c53 100644
--- a/radis/test/test_examples.py
+++ b/radis/test/test_examples.py
@@ -10,7 +10,9 @@
 
 import pytest
 
-EXAMPLE_FOLDER = abspath(join(dirname(__file__), "..", "..", "examples"))
+EXAMPLE_FOLDER = join(
+    abspath(join(dirname(__file__), os.pardir, os.pardir)), "examples"
+)
 
 scripts = [
     join(EXAMPLE_FOLDER, k) for k in os.listdir(EXAMPLE_FOLDER) if k.endswith(".py")
diff --git a/radis/test/tools/test_line_survey.py b/radis/test/tools/test_line_survey.py
index 65a02360c..c9d892e50 100644
--- a/radis/test/tools/test_line_survey.py
+++ b/radis/test/tools/test_line_survey.py
@@ -26,7 +26,7 @@ def test_line_survey(verbose=True, plot=False, warnings=True, *args, **kwargs):
         os.remove(_temp_file)
 
     s = load_spec(getTestFile("CO_Tgas1500K_mole_fraction0.01.spec"), binary=True)
-    s.line_survey(overlay="abscoeff", writefile=_temp_file)
+    s.line_survey(overlay="abscoeff", writefile=_temp_file, barwidth="fwhm_voigt")
 
     assert exists(_temp_file)