Repository: radis/radis (language: python)

Issue:
Non air diluent 2
This PR is to address the refactoring of  `radis.io.hitran.py` to use the `db_begin + read LOCAL_TABLE_CACHE `approach rather than use `hit2df` and update formula of pressure broadening to calculate `non-air diluent` broadening.
Following is the difference between lineshapes of CO molecule for different diluents:
<p align="center">
<img src="https://user-images.githubusercontent.com/38047163/189498261-871a1119-48cf-44bf-8014-2a7e608ee098.png">
</p>


- I've replaced hit2df  with pd.DataFrame(LOCAL_TABLE_CACHE[data_file_name[0]]["data"]) and renamed the columns as you had mentioned in the proof of concept of how to get a Pandas dictionary from the LOCAL_TABLE_CACHE. 
- It works just fine!
- Also, by passing Parameters = ['gamma_CO2','n_CO2'] in fetch() the columns are loaded in LOCAL_TABLE_CACHE and is parsed together along with all other columns.
- Hence, now just by passing these extra columns as Parameters = ['gamma_CO2','n_CO2'] in fetch() function we obtain a single CO.hdf5 file in which basic  + extra Columns are present . ( All row data / line data are now aligned)
- The final df returned by fetch_hitran() also contains basic  + extra columns
- If for a given molecule a column is not present then it simply adds `NaN`  values to df and .hdf5 file for that column



Implement all new fitting modules and accompanied gallery examples into RADIS
This pull request is to implement all of my modules, developed separately in my repo [RADIS-Spectrum-Fitting-Benchmark](https://github.com/TranHuuNhatHuy/RADIS-Spectrum-Fitting-Benchmark), into RADIS codebase. This pull request features:

- Add `new_fitting.py`, which contains my new fitting module and models, into radis/tools/.
- Add gallery examples to `radis/examples`, including:
   - `plot_newfitting_Tgas.py` : the most basic example of how to use new fitting module, including the formats and so on.
   - `plot_newfitting_Trot-Tvib-molfrac.py` : real-life fitting case provided by Mr. @CorentinGrimaldi , featuring non-LTE CO spectrum in which we will fit `Trot`, `Tvib` and `mole_fraction`.
- Add spectrum and other auxiliary files into `radis/test/files` that are necessary for those gallery examples above.

More illustrative cases, such as comparison between the old and new fitting modules, comparison between different algorithms, or Mr. @minouHub's case, will be pushed in another PR after this PR has been merged.

[This pull request is a newer version of the old one, #519 ].
number of trans files in H3O_p/Exomol error manually fixed
<!-- 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. -->

This pull request is to address a wrong value in [def file of H3O_p in Exomol](https://www.exomol.com/db/H3O_p/1H3-16O_p/eXeL/1H3-16O_p__eXeL.def). 

```
101                                                                             # No. of transition files
```

The actual number is 100. So, this mistake occurs a loading error of transition files. I fixed it to add a manual exception in `exomolapi.py`.

<!-- 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. -->



wide range CH4 cant be computed
### 🐛 Describe the bug

```

from radis import calc_spectrum
import astropy.units as u
s = calc_spectrum(1 * u.um, 10 * u.m,         # cm-1
                  molecule='CH4',
                  isotope='1,2,3',
                  pressure=1.01325,   # bar
                  Tgas=300,           # K
                  mole_fraction=1,
                  path_length=1,      # cm
                  databank='hitran',  # or 'hitemp', 'geisa', 'exomol'
                  wstep=0.01,
                  )
s.apply_slit(0.5, 'nm')       # simulate an experimental slit
s.plot('transmittance')
``` 


> d:\github\radis\radis\lbl\broadening.py(1441)_init_w_axis()
   1439             )  # Add small number to prevent w_max falling outside of the grid
   1440             N = np.ceil((np.log(w_max) - np.log(w_min)) / log_p) + 1
-> 1441             return w_min * np.exp(log_p * np.arange(N))
   1442 
   1443         log_pL = self.params.dxL  # LDM user params

np.arange(N)
*** ValueError: Maximum allowed size exceeded

N
inf

N in LDM axis becomes infinite

### 💡 Possible solutions

_No response_

### 🎲 Radis version

0.13.1

### 💻 Operating system

Windows


Discussion / hints:
Ok this is great ! The fact that missing values are handled without crash is good, too. 

Now comes the practical implementation details : 
- do you have a way to know all the possible extra parameters for a given molecule ?
- if not, which ones to download ? 
Also : 
- what if the database has already been downloaded (true for most current radis users) but we want to add new parameters? If you can only download selectively the new parameters, then we could append them to another file (siilar to the *_extra.hdf5 approach you had) . If you'll need to re-download everything anyway, then maybe it's just better to replace the existing database with the new one.   
- if replacing tge database, make sure that already downloaded parameters are still downloaded. Else you'll end up with users having to redownload the full database each time they'll change a gas composition and require first `gamma_CO2` then `gamma_H2O` for instance. Avoid this. 

Hello @erwanp, I've replied on Slack 




Fix patch:
diff --git a/.travis.yml b/.travis.yml
index eba5b72ff..05addf35a 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -25,12 +25,13 @@ jobs:
         # version is the same.
         # For now use http instead of https according to this comment:
         # https://github.com/mamba-org/mamba/issues/1675#issuecomment-1127160021
-        - wget -qO- http://micromamba.snakepit.net/api/micromamba/linux-64/latest | tar -xvj bin/micromamba
+        - wget -qO- https://micro.mamba.pm/api/micromamba/linux-64/0.25.1 | tar -xvj bin/micromamba
         - ./bin/micromamba shell init -s bash -p ~/micromamba
         - source ~/.bashrc
         - micromamba activate base
-        # Useful for debugging any issues with conda
+        # Useful for debugging any issues with conda/mamba
         - micromamba info
+        - micromamba config list
         # Update python version in environment
         - sed -i -E 's/(python=)(.*)/\1'$TRAVIS_PYTHON_VERSION'/' ./environment.yml
         # Create conda environment
diff --git a/MANIFEST.in b/MANIFEST.in
index c2b925364..d0fb139a9 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -15,4 +15,5 @@ recursive-include radis/db *.json
 include radis/test/validation/test_CO2_3Tvib_vs_klarenaar_data/*.csv
 include radis/test/validation/test_compare_torch_CO2_data/*.dat
 include radis/test/validation/test_validation_vs_specair_noneqCO_data/*.spec
-include radis/cython/radis_cython_extensions.pyx
+include radis/cython/*.pyx
+include radis/cython/*.pxd
diff --git a/docs/conf.py b/docs/conf.py
index c8dad57b4..b224f4e06 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -177,6 +177,7 @@ def setup(app):
     "fitroom": ("https://fitroom.readthedocs.io/en/latest/", None),
     "habanero": ("https://habanero.readthedocs.io/en/latest/", None),
     "joblib": ("https://joblib.readthedocs.io/en/latest/", None),
+    "lmfit": ("https://lmfit.github.io/lmfit-py/", None),
     "numpy": ("https://numpy.org/doc/stable/", None),
     "matplotlib": ("https://matplotlib.org/stable/", None),
     "pandas": ("https://pandas.pydata.org/pandas-docs/stable/", None),
diff --git a/examples/plot_newfitting_Tgas-molfrac.py b/examples/plot_newfitting_Tgas-molfrac.py
new file mode 100644
index 000000000..78badbce8
--- /dev/null
+++ b/examples/plot_newfitting_Tgas-molfrac.py
@@ -0,0 +1,122 @@
+# -*- coding: utf-8 -*-
+"""
+
+================================================================================
+Fit an LTE spectrum with multiple fit parameters using new fitting module
+================================================================================
+
+With the new fitting module introduced in :py:func:`~radis.tools.new_fitting.fit_spectrum` function,
+and in the example of Tgas fitting using new fitting module, we can see its 1-temperature fitting
+performance for equilibrium condition.
+
+This example features how new fitting module can fit an equilibrium spectrum, with multiple fit
+parameters, including gas temperature, mole fraction, and wavelength offset.
+
+This is a real fitting case introduced by Mr. Nicolas Minesi, featuring CO spectrum with absorbance
+as spectral quantity to be fitted. As we can see, he stored the experimental result in a MATLAB file,
+and from there a Spectrum object is generated. It is worth noticing that, the result seems to differ
+slightly from ground-truth, due to the fact that currently RADIS uses air broadening parameteres for
+calculation, while this experiment was originally conducted in Argon. Future updates on other molecules'
+broadening coefficients will increase the accuracy of these cases with non-air diluents.
+
+"""
+
+import astropy.units as u
+import scipy.io
+
+from radis import Spectrum
+from radis.test.utils import getTestFile
+from radis.tools.new_fitting import fit_spectrum
+
+# ------------------------------------ Step 1. Load experimental spectrum ------------------------------------ #
+
+
+data_file = "trimmed_1857_VoigtCO_Minesi.mat"
+data = scipy.io.loadmat(getTestFile(data_file), simplify_cells=True)["CO_resu_Voigt"]
+index = 20
+s_experimental = Spectrum.from_array(
+    data["nu"], data["A_exp"][:, index], "absorbance", wunit="cm-1", unit=""
+)  # adimensioned
+
+
+# ------------------------------------ Step 2. Fill ground-truths and data ------------------------------------ #
+
+
+# Experimental conditions which will be used for spectrum modeling. Basically, these are known ground-truths.
+experimental_conditions = {
+    "molecule": "CO",  # Molecule ID
+    "isotope": "1",  # Isotope ID, can have multiple at once
+    "wmin": 2010.6,  # Starting wavelength/wavenumber to be cropped out from the original experimental spectrum.
+    "wmax": 2011.6,  # Ending wavelength/wavenumber for the cropping range.
+    "wunit": "cm-1",  # Accompanying unit of those 2 wavelengths/wavenumbers above.
+    "pressure": 1
+    * u.bar,  # Total pressure of gas, in "bar" unit by default, but you can use Astropy units too.
+    "path_length": 10
+    * u.cm,  # Experimental path length, in "cm" unit by default, but you can use Astropy units too.
+    "databank": "hitemp",  # Databank used for calculation. Must be stated.
+}
+
+# List of parameters to be fitted.
+fit_parameters = {
+    "Tgas": 5000,  # Fit parameter, accompanied by its initial value.
+    "mole_fraction": 0.05,  # Species mole fraction, from 0 to 1.
+    "offset": "0 cm-1",  # Experimental offset, must be a blank space separating offset amount and unit.
+}
+
+# List of bounding ranges applied for those fit parameters above.
+bounding_ranges = {
+    "Tgas": [
+        2000,
+        9000,
+    ],  # Bounding ranges for each fit parameter stated above. You can skip this step, but not recommended.
+    "mole_fraction": [0, 1],  # Species mole fraction, from 0 to 1.
+    "offset": [
+        -0.1,
+        0.1,
+    ],  # Experimental offset, must be a blank space separating offset amount and unit
+}
+
+# Fitting pipeline setups.
+fit_properties = {
+    "method": "lbfgsb",  # Preferred fitting method. By default, "leastsq".
+    "fit_var": "absorbance",  # Spectral quantity to be extracted for fitting process, such as "radiance", "absorbance", etc.
+    "normalize": False,  # Either applying normalization on both spectra or not.
+    "max_loop": 300,  # Max number of loops allowed. By default, 100.
+    "tol": 1e-20,  # Fitting tolerance, only applicable for "lbfgsb" method.
+}
+
+"""
+
+For the fitting method, you can try one among 17 different fitting methods and algorithms of LMFIT,
+introduced in `LMFIT method list <https://lmfit.github.io/lmfit-py/fitting.html#choosing-different-fitting-methods>`.
+
+You can see the benchmark result of these algorithms here:
+`RADIS Newfitting Algorithm Benchmark <https://github.com/radis/radis-benchmark/blob/master/manual_benchmarks/plot_newfitting_comparison_algorithm.py>`.
+
+"""
+
+
+# ------------------------------------ Step 3. Run the fitting and retrieve results ------------------------------------ #
+
+
+# Conduct the fitting process!
+s_best, result, log = fit_spectrum(
+    s_exp=s_experimental,
+    fit_params=fit_parameters,
+    bounds=bounding_ranges,
+    model=experimental_conditions,
+    pipeline=fit_properties,
+)
+
+
+# Now investigate the log
+
+print("\nResidual history: \n")
+print(log["residual"])
+
+print("\nFitted values history: \n")
+for fit_val in log["fit_vals"]:
+    print(fit_val)
+
+print("\nTotal fitting time: ")
+print(log["time_fitting"], end=" s\n")
diff --git a/examples/plot_newfitting_Tgas.py b/examples/plot_newfitting_Tgas.py
new file mode 100644
index 000000000..ebcedc43a
--- /dev/null
+++ b/examples/plot_newfitting_Tgas.py
@@ -0,0 +1,132 @@
+# -*- coding: utf-8 -*-
+"""
+
+================================================================================
+New fitting module introduction - simple 1-temperature LTE case
+================================================================================
+
+RADIS has its own fitting feature, as shown in
+`1 temperature fit example <https://radis.readthedocs.io/en/latest/auto_examples/plot_1T_fit.html>`,
+where you have to manually create the spectrum model, input the experimental spectrum and other
+ground-truths into numerous RADIS native functions, and adjust the fitting pipeline yourself.
+
+Now with the new fitting module released, all you have to do is to prepare a .spec file containing
+your experimental spectrum, fill some JSON forms describing the ground-truth conditions just like how
+you fill a medical checkup paper, call the function :py:func:`~radis.tools.new_fitting.fit_spectrum`
+and let it do all the work! If you are not satisfied with the result, you can simply adjust the
+parameters in your JSON such as slit and path_length, recall the function until the results are good.
+
+Instruction:
+
+- Step 1: prepare a .spec file. Create a .spec file containing your experimental spectrum. You can
+  do it with RADIS by saving a Spectrum object with :py:meth:`~radis.spectrum.spectrum.Spectrum.store`.
+  If your current data is not a Spectrum object, you can convert it to a Spectrum object from Python
+  arrays or from text files, and then save it as .spec file as above.
+
+- Step 2: fill the JSON forms. There are 4 JSON forms you need to fill: `experimental_conditions` with
+  ground-truth data about your experimental environment, `fit_parameters` with the parameters you need
+  to fit (such as Tgas, mole fraction, etc.), `bounding_ranges` with fitting ranges for parameters you
+  listed in `fit_parameters`, and `fit_properties` for some fitting pipeline references.
+
+- Step 3: call :py:func:`~radis.tools.new_fitting.fit_spectrum` with the experimental spectrum and 4
+  JSON forms, then see the result.
+
+This example features fitting an experimental spectrum with Tgas, using new fitting modules.
+
+
+"""
+
+import astropy.units as u
+
+from radis import load_spec
+from radis.test.utils import getTestFile
+from radis.tools.new_fitting import fit_spectrum
+
+# ------------------------------------ Step 1. Load experimental spectrum ------------------------------------ #
+
+
+# Load an experimental spectrum. You can prepare yours, or fetch one of them in the radis/test/files directory.
+my_spec = getTestFile("synth-NH3-1-500-2000cm-P10-mf0.01-p1.spec")
+s_experimental = load_spec(my_spec)
+
+
+# ------------------------------------ Step 2. Fill ground-truths and data ------------------------------------ #
+
+
+# Experimental conditions which will be used for spectrum modeling. Basically, these are known ground-truths.
+experimental_conditions = {
+    "molecule": "NH3",  # Molecule ID
+    "isotope": "1",  # Isotope ID, can have multiple at once
+    "wmin": 1000,  # Starting wavelength/wavenumber to be cropped out from the original experimental spectrum.
+    "wmax": 1050,  # Ending wavelength/wavenumber for the cropping range.
+    "wunit": "cm-1",  # Accompanying unit of those 2 wavelengths/wavenumbers above.
+    "mole_fraction": 0.01,  # Species mole fraction, from 0 to 1.
+    "pressure": 1e6
+    * u.Pa,  # Total pressure of gas, in "bar" unit by default, but you can also use Astropy units.
+    "path_length": 10
+    * u.mm,  # Experimental path length, in "cm" unit by default, but you can also use Astropy units.
+    "slit": "1 nm",  # Experimental slit, must be a blank space separating slit amount and unit.
+    "offset": "-0.2 nm",  # Experimental offset, must be a blank space separating offset amount and unit.
+    "databank": "hitran",  # Databank used for the spectrum calculation. Must be stated.
+}
+
+# List of parameters to be fitted, accompanied by their initial values.
+fit_parameters = {
+    "Tgas": 700,  # Gas temperature, in K.
+}
+
+# List of bounding ranges applied for those fit parameters above.
+# You can skip this step and let it use default bounding ranges, but this is not recommended.
+# Bounding range must be at format [<lower bound>, <upper bound>].
+bounding_ranges = {
+    "Tgas": [
+        500,
+        2000,
+    ],
+}
+
+# Fitting pipeline setups.
+fit_properties = {
+    "method": "least_squares",  # Preferred fitting method from the 17 confirmed methods of LMFIT stated in week 4 blog. By default, "leastsq".
+    "fit_var": "radiance",  # Spectral quantity to be extracted for fitting process, such as "radiance", "absorbance", etc.
+    "normalize": False,  # Either applying normalization on both spectra or not.
+    "max_loop": 150,  # Max number of loops allowed. By default, 200.
+    "tol": 1e-15,  # Fitting tolerance, only applicable for "lbfgsb" method.
+}
+
+"""
+
+For the fitting method, you can try one among 17 different fitting methods and algorithms of LMFIT,
+introduced in `LMFIT method list <https://lmfit.github.io/lmfit-py/fitting.html#choosing-different-fitting-methods>`.
+
+You can see the benchmark result of these algorithms here:
+`RADIS Newfitting Algorithm Benchmark <https://github.com/radis/radis-benchmark/blob/master/manual_benchmarks/plot_newfitting_comparison_algorithm.py>`.
+
+"""
+
+
+# ------------------------------------ Step 3. Run the fitting and retrieve results ------------------------------------ #
+
+
+# Conduct the fitting process!
+s_best, result, log = fit_spectrum(
+    s_exp=s_experimental,  # Experimental spectrum.
+    fit_params=fit_parameters,  # Fit parameters.
+    bounds=bounding_ranges,  # Bounding ranges for those fit parameters.
+    model=experimental_conditions,  # Experimental ground-truths conditions.
+    pipeline=fit_properties,  # Fitting pipeline references.
+    fit_kws={"gtol": 1e-12},
+)
+
+
+# Now investigate the result logs for additional information about what's going on during the fitting process
+
+print("\nResidual history: \n")
+print(log["residual"])
+
+print("\nFitted values history: \n")
+for fit_val in log["fit_vals"]:
+    print(fit_val)
+
+print("\nTotal fitting time: ")
+print(log["time_fitting"], end=" s\n")
diff --git a/examples/plot_newfitting_Trot-Tvib-molfrac.py b/examples/plot_newfitting_Trot-Tvib-molfrac.py
new file mode 100644
index 000000000..964e86e0d
--- /dev/null
+++ b/examples/plot_newfitting_Trot-Tvib-molfrac.py
@@ -0,0 +1,158 @@
+# -*- coding: utf-8 -*-
+"""
+
+================================================================================
+Fit a non-LTE spectrum with multiple fit parameters using new fitting module
+================================================================================
+
+With the new fitting module introduced in :py:func:`~radis.tools.new_fitting.fit_spectrum` function,
+and in the example of Tgas fitting using new fitting module, we can see its 1-temperature fitting
+performance for equilibrium condition.
+
+This example features how new fitting module can fit non-equilibrium spectrum, with multiple fit
+parameters, such as vibrational/rotational temperatures, or mole fraction, etc.
+
+This is a real fitting case introduced by Mr. Corentin Grimaldi, a scientist and member of RADIS
+community. This case features CO molecule emitting on a wide range of spectrum. This case also includes
+a user-defined trapezoid slit function, which is accounted by the distorion (dispersion) of the slit,
+as a result of the influences from experimental parameters and spectrometer dispersion parameters
+during the experiment.
+
+"""
+
+import astropy.units as u
+import numpy as np
+
+from radis import load_spec
+from radis.test.utils import getTestFile
+from radis.tools.new_fitting import fit_spectrum
+
+# The following script is for Mr. Correnti Grimaldi's cases, used for his own slit settings.
+# This is not native to current fitting modules.
+
+# ----------------------- Begin of Mr. Grimaldi's script ----------------------- #
+
+
+def slit_dispersion(w):
+    phi = -6.33
+    f = 750
+    gr = 300
+    m = 1
+    phi *= -2 * np.pi / 360
+    d = 1e-3 / gr
+    disp = (
+        w
+        / (2 * f)
+        * (-np.tan(phi) + np.sqrt((2 * d / m / (w * 1e-9) * np.cos(phi)) ** 2 - 1))
+    )
+    return disp  # nm/mm
+
+
+slit = 1500  # µm
+pitch = 20  # µm
+top_slit_um = slit - pitch  # µm
+base_slit_um = slit + pitch  # µm
+center_slit = 5090
+dispersion = slit_dispersion(center_slit)
+top_slit_nm = top_slit_um * 1e-3 * dispersion
+base_slit_nm = base_slit_um * 1e-3 * dispersion * 1.33
+
+# ------------------------ End of Mr. Grimaldi's script ------------------------ #
+
+
+# ------------------------------------ Step 1. Load experimental spectrum ------------------------------------ #
+
+
+specName = (
+    "Corentin_0_100cm_DownSampled_20cm_10pctCO2_1-wc-gw450-gr300-sl1500-acc5000-.spec"
+)
+s_experimental = load_spec(getTestFile(specName)).offset(-0.6, "nm")
+
+
+# ------------------------------------ Step 2. Fill ground-truths and data ------------------------------------ #
+
+
+# Experimental conditions which will be used for spectrum modeling. Basically, these are known ground-truths.
+experimental_conditions = {
+    "molecule": "CO",  # Molecule ID
+    "isotope": "1,2,3",  # Isotope ID, can have multiple at once
+    "wmin": 2270
+    * u.nm,  # Starting wavelength/wavenumber to be cropped out from the original experimental spectrum.
+    "wmax": 2400 * u.nm,  # Ending wavelength/wavenumber for the cropping range.
+    "pressure": 1.01325
+    * u.bar,  # Total pressure of gas, in "bar" unit by default, but you can also use Astropy units.
+    "path_length": 1
+    / 195
+    * u.cm,  # Experimental path length, in "cm" unit by default, but you can also use Astropy units.
+    "slit": {  # Experimental slit. In simple form: "[value] [unit]", i.e. "-0.2 nm". In complex form: a dict with parameters of apply_slit()
+        "slit_function": (top_slit_nm, base_slit_nm),
+        "unit": "nm",
+        "shape": "trapezoidal",
+        "center_wavespace": center_slit,
+        "slit_dispersion": slit_dispersion,
+        "inplace": False,
+    },
+    "cutoff": 0,  # (RADIS native) Discard linestrengths that are lower that this to reduce calculation time, in cm-1.
+    "databank": "hitemp",  # Databank used for calculation. Must be stated.
+}
+
+# List of parameters to be fitted, accompanied by its initial value
+fit_parameters = {
+    "Tvib": 6000,  # Vibrational temperature, in K.
+    "Trot": 4000,  # Rotational temperature, in K.
+    "mole_fraction": 0.05,  # Species mole fraction, from 0 to 1.
+}
+
+# List of bounding ranges applied for those fit parameters above.
+# You can skip this step and let it use default bounding ranges, but this is not recommended.
+# Bounding range must be at format [<lower bound>, <upper bound>].
+bounding_ranges = {
+    "Tvib": [2000, 7000],
+    "Trot": [2000, 7000],
+    "mole_fraction": [0, 0.1],
+}
+
+# Fitting pipeline setups.
+fit_properties = {
+    "method": "lbfgsb",  # Preferred fitting method. By default, "leastsq".
+    "fit_var": "radiance",  # Spectral quantity to be extracted for fitting process, such as "radiance", "absorbance", etc.
+    "normalize": False,  # Either applying normalization on both spectra or not.
+    "max_loop": 300,  # Max number of loops allowed. By default, 200.
+    "tol": 1e-20,  # Fitting tolerance, only applicable for "lbfgsb" method.
+}
+
+"""
+
+For the fitting method, you can try one among 17 different fitting methods and algorithms of LMFIT,
+introduced in `LMFIT method list <https://lmfit.github.io/lmfit-py/fitting.html#choosing-different-fitting-methods>`.
+
+You can see the benchmark result of these algorithms here:
+`RADIS Newfitting Algorithm Benchmark <https://github.com/radis/radis-benchmark/blob/master/manual_benchmarks/plot_newfitting_comparison_algorithm.py>`.
+
+"""
+
+
+# ------------------------------------ Step 3. Run the fitting and retrieve results ------------------------------------ #
+
+
+# Conduct the fitting process!
+s_best, result, log = fit_spectrum(
+    s_exp=s_experimental,  # Experimental spectrum.
+    fit_params=fit_parameters,  # Fit parameters.
+    bounds=bounding_ranges,  # Bounding ranges for those fit parameters.
+    model=experimental_conditions,  # Experimental ground-truths conditions.
+    pipeline=fit_properties,  # # Fitting pipeline references.
+)
+
+
+# Now investigate the result logs for additional information about what's going on during the fitting process
+
+print("\nResidual history: \n")
+print(log["residual"])
+
+print("\nFitted values history: \n")
+for fit_val in log["fit_vals"]:
+    print(fit_val)
+
+print("\nTotal fitting time: ")
+print(log["time_fitting"], end=" s\n")
diff --git a/examples/plot_newfitting_comparison_oldnew.py b/examples/plot_newfitting_comparison_oldnew.py
new file mode 100644
index 000000000..e55da2b14
--- /dev/null
+++ b/examples/plot_newfitting_comparison_oldnew.py
@@ -0,0 +1,229 @@
+# -*- coding: utf-8 -*-
+"""
+
+================================================================================
+Compare performance between old 1T fitting example and new fitting module
+================================================================================
+
+This example will try to fit an experimental spectrum using two fitting pipelines:
+
+- Old fitting module of :py:func:`~radis.tools.fitting.fit_spectrum`, which is the
+  current RADIS fitting module. You can find the gallery example featuring it at
+  `1 temperature fit <https://radis.readthedocs.io/en/latest/auto_examples/plot_1T_fit.html>`.
+- New fitting module of :py:func:`~radis.tools.new_fitting.fit_spectrum`, a new
+  fitting interface for a more practical and interactive fitting experience. See
+  `1 temperature fit using new module <https://radis.readthedocs.io/en/latest/auto_examples/plot_newfitting_Tgas.html>`
+
+With this, you can compare their overall performance, including number of loops,
+fitting time, and final residuals between experimental and best-fit spectra, as
+an indicator of fit accuracy.
+
+"""
+
+import time
+
+import astropy.units as u
+
+from radis import SpectrumFactory, load_spec, plot_diff
+from radis.test.utils import getTestFile, setup_test_line_databases
+from radis.tools.fitting import LTEModel
+from radis.tools.new_fitting import fit_spectrum
+
+# ------------------------------------ OLD 1-TEMPERATURE FITTING EXAMPLE ------------------------------------ #
+
+setup_test_line_databases()
+
+# fit range
+wlmin = 4167
+wlmax = 4180
+
+s_exp = (
+    load_spec(getTestFile("CO2_measured_spectrum_4-5um.spec"))
+    .crop(wlmin, wlmax, "nm")
+    .normalize()
+    .sort()
+    .offset(-0.2, "nm")
+)
+
+
+def LTEModel_withslitnorm(factory, fit_parameters, fixed_parameters):
+    s = LTEModel(factory, fit_parameters, fixed_parameters)
+    # we could also have added a fittable parameter, such as an offset,
+    # or made the slit width a fittable parameter.
+    # ... any paramter in model_input will be fitted.
+    # s.offset(model_input["offset"], 'nm')
+    s.apply_slit(1.4, "nm")
+    return s.take("radiance").normalize()
+
+
+sf = SpectrumFactory(
+    wlmin * u.nm,
+    wlmax * u.nm,
+    wstep=0.001,  # cm-1
+    pressure=1 * 1e-3,  # bar
+    cutoff=1e-25,
+    isotope="1,2",
+    path_length=10,  # cm-1
+    mole_fraction=1,
+    truncation=1,  # cm-1
+)
+sf.warnings["MissingSelfBroadeningWarning"] = "ignore"
+sf.warnings["HighTemperatureWarning"] = "ignore"
+sf.load_databank("HITRAN-CO2-TEST")
+
+begin_time_mark = time.time()
+
+s_best, best = sf.fit_spectrum(
+    s_exp.take("radiance"),
+    model=LTEModel_withslitnorm,
+    fit_parameters={
+        "Tgas": 300,
+        # "offset": 0
+    },
+    bounds={
+        "Tgas": [300, 2000],
+        # "offset": [-1, 1],
+    },
+    plot=True,
+    solver_options={
+        "maxiter": 15,  # 👈 increase to let the fit converge
+        "ftol": 1e-15,
+    },
+    verbose=2,
+)
+
+end_time_mark = time.time()
+
+plot_diff(s_exp, s_best)
+
+# Information to report later in comparison result
+oldfitting_residual = best.fun
+oldfitting_loops = best.nfev
+oldfitting_time = end_time_mark - begin_time_mark
+
+# ------------------------------------------------------------------------------------------------------------ #
+
+
+# -------------------------------------------- NEW FITTING MODULE -------------------------------------------- #
+
+
+# ------------------------------------ Step 1. Load experimental spectrum ------------------------------------ #
+
+
+# Load an experimental spectrum. You can prepare yours, or fetch one of them in the radis/test/files directory.
+my_spec = getTestFile("CO2_measured_spectrum_4-5um.spec")
+s_experimental = load_spec(my_spec).offset(-0.2, "nm")
+
+
+# ------------------------------------ Step 2. Fill ground-truths and data ------------------------------------ #
+
+
+# Experimental conditions which will be used for spectrum modeling. Basically, these are known ground-truths.
+experimental_conditions = {
+    "molecule": "CO2",  # Molecule ID
+    "isotope": "1,2",  # Isotope ID, can have multiple at once
+    "wmin": 4167
+    * u.nm,  # Starting wavelength/wavenumber to be cropped out from the original experimental spectrum.
+    "wmax": 4180 * u.nm,  # Ending wavelength/wavenumber for the cropping range.
+    "mole_fraction": 1,  # Species mole fraction, from 0 to 1.
+    "pressure": 1
+    * 1e-3
+    * u.bar,  # Total pressure of gas, in "bar" unit by default, but you can also use Astropy units.
+    "path_length": 10
+    * u.cm,  # Experimental path length, in "cm" unit by default, but you can also use Astropy units.
+    "slit": "1.4 nm",  # Experimental slit, must be a blank space separating slit amount and unit.
+    "wstep": 0.001,  # Resolution of wavenumber grid, in cm-1.
+    "databank": "hitran",  # Databank used for the spectrum calculation. Must be stated.
+}
+
+# List of parameters to be fitted, accompanied by their initial values.
+fit_parameters = {
+    "Tgas": 1150,  # Gas temperature, in K.
+}
+
+# List of bounding ranges applied for those fit parameters above.
+# You can skip this step and let it use default bounding ranges, but this is not recommended.
+# Bounding range must be at format [<lower bound>, <upper bound>].
+bounding_ranges = {
+    "Tgas": [
+        300,
+        2000,
+    ],
+}
+
+# Fitting pipeline setups.
+fit_properties = {
+    "method": "lbfgsb",  # Preferred fitting method. By default, "leastsq".
+    "fit_var": "radiance",  # Spectral quantity to be extracted for fitting process, such as "radiance", "absorbance", etc.
+    "normalize": True,  # Either applying normalization on both spectra or not.
+    "max_loop": 150,  # Max number of loops allowed. By default, 200.
+    "tol": 1e-15,  # Fitting tolerance, only applicable for "lbfgsb" method.
+}
+
+"""
+
+For the fitting method, you can try one among 17 different fitting methods and algorithms of LMFIT,
+introduced in `LMFIT method list <https://lmfit.github.io/lmfit-py/fitting.html#choosing-different-fitting-methods>`.
+
+You can see the benchmark result of these algorithms here:
+`RADIS Newfitting Algorithm Benchmark <https://github.com/radis/radis-benchmark/blob/master/manual_benchmarks/plot_newfitting_comparison_algorithm.py>`.
+
+"""
+
+
+# ------------------------------------ Step 3. Run the fitting and retrieve results ------------------------------------ #
+
+
+# Conduct the fitting process!
+s_bestfit, result, log = fit_spectrum(
+    s_exp=s_experimental,  # Experimental spectrum.
+    fit_params=fit_parameters,  # Fit parameters.
+    bounds=bounding_ranges,  # Bounding ranges for those fit parameters.
+    model=experimental_conditions,  # Experimental ground-truths conditions.
+    pipeline=fit_properties,  # Fitting pipeline references.
+    verbose=False,  # If you want a clean result, stay False. If you want to see more about each loop, True.
+)
+
+# Information to report later in comparison result
+newfitting_residual = log["residual"][-1]
+newfitting_loops = result.nfev
+newfitting_time = log["time_fitting"]
+
+
+# ---------------------------------------------------------------------------------------------------------------------- #
+
+
+# ---------------------------------- PERFORMANCE COMPARISON BETWEEN 2 FITTING METHODS ---------------------------------- #
+
+print(
+    "\n\n\n====================  PERFORMANCE COMPARISON BETWEEN 2 FITTING METHODS  ===================="
+)
+
+print("\n1. LAST RESIDUAL\n")
+print(f"- Old 1T fitting example:   \t{oldfitting_residual}")
+print(f"- New fitting module:       \t{newfitting_residual}")
+
+print("\n2. NUMBER OF FITTING LOOPS\n")
+print(f"- Old 1T fitting example:   \t{oldfitting_loops} loops")
+print(f"- New fitting module:       \t{newfitting_loops} loops")
+
+print("\n3. TOTAL TIME TAKEN (s)\n")
+print(f"- Old 1T fitting example:   \t{oldfitting_time} s")
+print(f"- New fitting module:       \t{newfitting_time} s")
+
+print(
+    "\n==========================================================================================\n"
+)
+
+
+"""
+
+From the comparative result above, which includes last residual, total time fitting and total number of
+fitting loops of each fitting method, we can see that under exactly the same ground-truth conditions,
+fitting method (L-BFGS-B) and fitting pipeline, we can see that the new fitting module provides a better
+accuracy, while requiring less fitting loops and time for execution.
+
+You are free to change the experimental spectrum and its accompanied ground-truth conditions, but please
+make sure to keep the same inputs between the two for a transparent comparative result.
+
+"""
diff --git a/radis/__init__.py b/radis/__init__.py
index 2c8df456e..18658def2 100644
--- a/radis/__init__.py
+++ b/radis/__init__.py
@@ -94,10 +94,17 @@
 	    import radis
 	    radis.AUTO_UPDATE_DATABASE = True
 
+	See Also
+	--------
+	:py:func:`~radis.api.hdf5.hdf2df`
+
 
 "OLDEST_COMPATIBLE_VERSION": "0.9.1"
     str: forces to regenerate cache files that were created in a previous version
-    See also : :py:func:`~radis.io.cache_files.load_h5_cache_file`
+
+    See Also
+    --------
+    :py:func:`~radis.api.cache_files.load_h5_cache_file`
 
 
 "USE_CYTHON": True
@@ -209,8 +216,9 @@ def get_version(verbose=False, add_git_number=True):
     "__version__",
 ]
 
-# prevent cyclic importants:
-from . import db, io, lbl, los, misc, phys, spectrum, tools
+# prevent cyclic imports:
+from . import api, db, io, lbl, los, misc, phys, spectrum, tools
+from .api import *  # input / output common with ExoJax
 from .db import *  # database of molecules
 from .io import *  # input / output
 from .lbl import *  # line-by-line module
@@ -221,6 +229,7 @@ def get_version(verbose=False, add_git_number=True):
 from .test import *  # test
 from .tools import *  # slit, database, line survey, etc.
 
+__all__.extend(api.__all__)
 __all__.extend(db.__all__)
 __all__.extend(io.__all__)
 __all__.extend(lbl.__all__)
diff --git a/radis/__version__.txt b/radis/__version__.txt
index ed0d9e990..948a54727 100644
--- a/radis/__version__.txt
+++ b/radis/__version__.txt
@@ -1,1 +1,1 @@
-0.13.1
\ No newline at end of file
+0.14
diff --git a/radis/api/__init__.py b/radis/api/__init__.py
new file mode 100644
index 000000000..49af6d73c
--- /dev/null
+++ b/radis/api/__init__.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+"""Common API for RADIS & `Exojax <https://github.com/HajimeKawahara/exojax>`__
+
+-------------------------------------------------------------------------------
+
+"""
+
+from .cdsdapi import cdsd2df
+from .hitranapi import hit2df
+
+__all__ = [
+    "cdsd2df",
+    "hit2df",
+]
diff --git a/radis/io/cache_files.py b/radis/api/cache_files.py
similarity index 97%
rename from radis/io/cache_files.py
rename to radis/api/cache_files.py
index efac7845f..2348868ca 100644
--- a/radis/io/cache_files.py
+++ b/radis/api/cache_files.py
@@ -7,15 +7,15 @@
 ---------------
 
 
-- :func:`~radis.io.cache_files.check_cache_file`
-- :func:`~radis.io.cache_files.check_not_deprecated`
-- :func:`~radis.io.cache_files.save_to_hdf`
+- :func:`~radis.api.cache_files.check_cache_file`
+- :func:`~radis.api.cache_files.check_not_deprecated`
+- :func:`~radis.api.cache_files.save_to_hdf`
 
 See Also
 --------
 
-:py:func:`~radis.io.hitran.hit2df`,
-:py:func:`~radis.io.cdsd.cdsd2df`
+:py:func:`~radis.api.hitranapi.hit2df`,
+:py:func:`~radis.api.cdsdapi.cdsd2df`
 
 -------------------------------------------------------------------------------
 """
@@ -38,8 +38,11 @@
 
 try:
     from .hdf5 import DataFileManager
-except ImportError:  # if file is ran as a module
-    from radis.io.hdf5 import DataFileManager
+except ImportError:
+    if __name__ == "__main__":  # running from this file, as a script
+        from radis.api.hdf5 import DataFileManager
+    else:
+        raise
 from radis.misc.basics import compare_dict, is_float
 from radis.misc.printer import printm, printr
 from radis.misc.warning import DeprecatedFileWarning, IrrelevantFileWarning
@@ -251,7 +254,7 @@ def check_cache_file(
     - if ``'force'``, raise an error if file doesnt exist.
 
     Then look if it is deprecated (we just look at the attributes, the file
-    is never fully read). Deprecation is done by :py:func:`~radis.io.cache_files.check_not_deprecated`
+    is never fully read). Deprecation is done by :py:func:`~radis.api.cache_files.check_not_deprecated`
     comparing the ``metadata=`` content.
 
     - if deprecated, deletes it to regenerate later unless 'force' was used
@@ -282,7 +285,7 @@ def check_cache_file(
     See Also
     --------
 
-    :py:func:`~radis.io.cache_files.check_not_deprecated`
+    :py:func:`~radis.api.cache_files.check_not_deprecated`
     """
 
     # Test existence of file:
diff --git a/radis/io/cdsd.py b/radis/api/cdsdapi.py
similarity index 94%
rename from radis/io/cdsd.py
rename to radis/api/cdsdapi.py
index 13cac495c..eecda71c2 100644
--- a/radis/io/cdsd.py
+++ b/radis/api/cdsdapi.py
@@ -4,7 +4,7 @@
 Routine Listing
 ---------------
 
-- :func:`~radis.io.cdsd.cdsd2df`
+- :func:`~radis.api.cdsdapi.cdsd2df`
 
 
 References
@@ -23,13 +23,26 @@
 from os.path import exists, getmtime
 
 import radis
-from radis.io.cache_files import load_h5_cache_file, save_to_hdf
-from radis.io.hdf5 import DataFileManager
-from radis.io.tools import (
-    drop_object_format_columns,
-    parse_hitran_file,
-    replace_PQR_with_m101,
-)
+
+try:
+    from .cache_files import load_h5_cache_file, save_to_hdf
+    from .hdf5 import DataFileManager
+    from .tools import (
+        drop_object_format_columns,
+        parse_hitran_file,
+        replace_PQR_with_m101,
+    )
+except ImportError:
+    if __name__ == "__main__":  # running from this file, as a script
+        from radis.api.cache_files import load_h5_cache_file, save_to_hdf
+        from radis.api.hdf5 import DataFileManager
+        from radis.api.tools import (
+            drop_object_format_columns,
+            parse_hitran_file,
+            replace_PQR_with_m101,
+        )
+    else:
+        raise
 
 # fmt: off
 columns_cdsdhitemp = OrderedDict(
@@ -154,7 +167,7 @@ def cdsd2df(
         will be left untouched.
     load_wavenum_min, load_wavenum_max: float
         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`.
+        wavenumbers above/below the specified value. See :py:func`~radis.api.cache_files.load_h5_cache_file`.
         Default ``'None'``.
     engine: 'pytables', 'vaex'
         format for Hdf5 cache file. Default `pytables`
diff --git a/radis/io/dbmanager.py b/radis/api/dbmanager.py
similarity index 92%
rename from radis/io/dbmanager.py
rename to radis/api/dbmanager.py
index bbe8578b5..845636306 100644
--- a/radis/io/dbmanager.py
+++ b/radis/api/dbmanager.py
@@ -1,8 +1,6 @@
 # -*- coding: utf-8 -*-
 """
-Created on Mon Aug  9 19:42:51 2021
 
-@author: erwan
 """
 import os
 import shutil
@@ -18,8 +16,11 @@
     from .cache_files import check_not_deprecated
     from .hdf5 import DataFileManager
 except ImportError:
-    from radis.io.hdf5 import DataFileManager
-    from radis.io.cache_files import check_not_deprecated
+    if __name__ == "__main__":  # running from this file, as a script
+        from radis.api.cache_files import check_not_deprecated
+        from radis.api.hdf5 import DataFileManager
+    else:
+        raise
 
 from datetime import date
 
@@ -118,13 +119,13 @@ def __init__(
         if self.is_registered():
             registered_paths = getDatabankEntries(self.name)["path"]
             for registered_path in registered_paths:
-                if (
-                    not abspath(expanduser(registered_path))
-                    .lower()
-                    .startswith(abspath(expanduser(local_databases).lower()))
+                registered_path_abspath = abspath(expanduser(registered_path)).lower()
+                local_databases_abspath = abspath(expanduser(local_databases).lower())
+                if not registered_path_abspath.startswith(
+                    local_databases_abspath
                 ):  # TODO: replace with pathlib
                     raise ValueError(
-                        f"Databank `{self.name}` is already registered in radis.json but the declared path ({registered_path}) is not in the expected local databases folder ({local_databases}). Please fix/delete the radis.json entry, change the `databank_name`, or change the default local databases path entry 'DEFAULT_DOWNLOAD_PATH' in `radis.config` or ~/radis.json"
+                        f"Databank `{self.name}` is already registered in radis.json but the declared path ({registered_path_abspath}) is not in the expected local databases folder ({local_databases_abspath}). Please fix/delete the radis.json entry, change the `databank_name`, or change the default local databases path entry 'DEFAULT_DOWNLOAD_PATH' in `radis.config` or ~/radis.json"
                     )
 
         self.downloadable = False  # by default
@@ -148,7 +149,7 @@ def get_filenames(self):
 
         See Also
         --------
-        :py:meth:`~radis.io.linedb.get_files_to_download`"""
+        :py:meth:`~radis.api.dbmanager.DatabaseManager.get_files_to_download`"""
         verbose = self.verbose
         local_databases = self.local_databases
         engine = self.engine
@@ -172,13 +173,11 @@ def get_filenames(self):
 
             # Check that local files are the one we expect :
             for f in local_files:
-                if (
-                    not abspath(expanduser(f))
-                    .lower()
-                    .startswith(abspath(expanduser(local_databases)).lower())
-                ):
+                local_file_abspath = abspath(expanduser(f)).lower()
+                local_databaes_abspath = abspath(expanduser(local_databases)).lower()
+                if not local_file_abspath.startswith(local_databaes_abspath):
                     raise ValueError(
-                        f"Database {self.name} is inconsistent : it should be stored in {local_databases} but files registered in ~/radis.json contains {f}. Please fix or delete the ~/radis.json entry."
+                        f"Database {self.name} is inconsistent : it should be stored in {local_databaes_abspath} but files registered in ~/radis.json contains {local_file_abspath}. Please fix or delete the ~/radis.json entry."
                     )
 
         elif self.is_downloadable():
@@ -229,7 +228,7 @@ def fetch_urlnames(self) -> list:
         -------
         list: list of urlnames
 
-        See for instance :py:meth:`radis.io.hitemp.HITEMPDatabaseManager"""
+        See for instance :py:class:`radis.api.hitempapi.HITEMPDatabaseManager"""
 
         raise NotImplementedError(
             "This function should be overwritten by the DatabaseManager subclass"
@@ -266,7 +265,7 @@ def get_existing_files(self, files):
 
         See Also
         --------
-        :py:meth:`~radis.io.linedb.get_filenames`"""
+        :py:meth:`~radis.api.dbmanager.DatabaseManager.get_filenames`"""
         return [k for k in files if exists(k)]
 
     def get_missing_files(self, files):
@@ -279,7 +278,7 @@ def get_missing_files(self, files):
 
         See Also
         --------
-        :py:meth:`~radis.io.linedb.get_filenames`"""
+        :py:meth:`~radis.api.dbmanager.DatabaseManager.get_filenames`"""
         return [k for k in files if not exists(k)]
 
     def remove_local_files(self, local_files):
diff --git a/radis/api/exomolapi.py b/radis/api/exomolapi.py
new file mode 100644
index 000000000..0378f9019
--- /dev/null
+++ b/radis/api/exomolapi.py
@@ -0,0 +1,1720 @@
+"""Molecular database (MDB) class
+
+   * MdbExomol is the MDB for ExoMol
+
+Initial code borrowed from the `Exojax <https://github.com/HajimeKawahara/exojax>`__
+code (which you should also have a look at !), by @HajimeKawahara, under MIT License.
+
+"""
+import os
+import pathlib
+import warnings
+
+import numpy as np
+
+from radis.api.dbmanager import DatabaseManager
+from radis.db.classes import EXOMOL_MOLECULES, EXOMOL_ONLY_ISOTOPES_NAMES
+
+EXOMOL_URL = "http://www.exomol.com/db/"
+
+import bz2
+import re
+from urllib.request import HTTPError, urlopen
+
+import pandas as pd
+from bs4 import BeautifulSoup
+
+from radis.api.hdf5 import vaexsafe_colname
+from radis.misc.warning import InconsistentDatabaseError
+
+
+def e2s(molname_exact):
+    """convert the exact molname (used in ExoMol) to the simple molname
+
+    Args:
+       molname_exact: the exact molname
+
+    Returns:
+       simple molname
+
+    Examples::
+
+       >>> print(e2s("12C-1H4"))
+       >>> CH4
+       >>> print(e2s("23Na-16O-1H"))
+       >>> NaOH
+       >>> print(e2s("HeH_p"))
+       >>> HeH_p
+       >>> print(e2s("trans-31P2-1H-2H")) #not working
+       >>> Warning: Exact molname  trans-31P2-1H-2H cannot be converted to simple molname
+       >>> trans-31P2-1H-2H
+
+    """
+
+    try:
+        t = molname_exact.split("-")
+        molname_simple = ""
+        for ele in t:
+            alp = "".join(re.findall(r"\D", ele))
+            num0 = re.split("[A-Z]", ele)[1]
+            if num0.isdigit():
+                num = num0
+            else:
+                num = ""
+            molname_simple = molname_simple + alp + num
+        return molname_simple
+    except:
+        print(
+            "Warning: Exact molname ",
+            molname_exact,
+            "cannot be converted to simple molname",
+        )
+        return molname_exact
+
+
+def read_def(deff):
+    """Exomol IO for a definition file
+
+    Parameters
+    ----------
+    deff: definition file
+
+    Returns
+    -------
+    n_Texp : float
+        temperature exponent
+    alpha_ref : float
+        broadening parameter
+    molmass : float
+        molecular mass
+    numinf : List[float]
+        limit points (``[w(0), w(1), ..., w(n)]``, n+1 elements) defining
+        the spectral ranges appearing in the name of *.trans.bz2 files
+        (``["w(0)-w(1)", "w(1)-w(2)", ..., w(n-1)-w(n)]``, n elements)
+    numtag : List[str]
+        tag for wavelength ranges.
+
+    Note:
+       For some molecules, ExoMol provides multiple trans files. numinf and numtag are the ranges and identifiers for the multiple trans files.
+
+    """
+
+    dat = pd.read_csv(deff, sep="#", names=("VAL", "COMMENT"))
+    alpha_ref = None
+    # texp = None
+    n_Texp = None
+    ntransf = 1
+    maxnu = 0.0
+    quantum_labels = []
+    for i, com in enumerate(dat["COMMENT"]):
+        if "Default value of Lorentzian half-width" in com:
+            alpha_ref = float(dat["VAL"][i])
+        elif "Default value of temperature exponent" in com:
+            n_Texp = float(dat["VAL"][i])
+        elif "No. of transition files" in com:
+            ntransf = int(dat["VAL"][i])
+        elif "Maximum wavenumber (in cm-1)" in com:
+            maxnu = float(dat["VAL"][i])
+            # maxnu=20000.0
+        elif "Isotopologue mass (Da) and (kg)" in com:
+            molmass = float(dat["VAL"][i].split()[0])  # in Da (atomic unit)
+
+        elif "Lifetime availability" in com:
+            lifetime = int(dat["VAL"][i]) == 1
+        elif "Lande g-factor availability" in com:
+            lande = int(dat["VAL"][i]) == 1
+        elif "Quantum label" in com:
+            quantum_labels.append(dat["VAL"][i].strip(" "))
+
+    # SOME DEF FILES CONTAINS ERRORS. THESE ARE THE EXCEPTIONS
+    if deff.stem == "12C-16O2__UCL-4000":
+        ntransf = 20
+    if deff.stem == "1H3-16O_p__eXeL":
+        # fixes https://github.com/radis/radis/pull/531
+        ntransf = 100
+    if deff.stem == "14N-1H3__CoYuTe":
+        maxnu = 20000.0
+    if deff.stem == "12C2-1H2__aCeTY":
+        if molmass == 12.0:
+            molmass = 26.0
+            print(
+                f"Known error in ExoMol def file, molmass corrected from 12.0 to {molmass}"
+            )
+        if quantum_labels == [
+            "totalSym",
+            "v1",
+            "v2",
+            "v3",
+            "v4",
+            "v5",
+            "v5",
+            "v7",
+            "vibSym",
+            "K",
+            "rotSym",
+        ]:
+            quantum_labels = [
+                "totalSym",
+                "v1",
+                "v2",
+                "v3",
+                "v4",
+                "v5",
+                "v6",
+                "v7",
+                "vibSym",
+                "K",
+                "rotSym",
+            ]
+            print(
+                f"Known error in ExoMol def file, quantum_labels corrected from '['totalSym', 'v1', 'v2', 'v3', 'v4', 'v5', 'v5', 'v7', 'vibSym', 'K', 'rotSym']' to {quantum_labels}"
+            )
+
+    if ntransf > 1:
+        dnufile = maxnu / ntransf
+        numinf = dnufile * np.array(range(ntransf + 1))
+        numtag = []
+        for i in range(len(numinf) - 1):
+            imin = "{:05}".format(int(numinf[i]))
+            imax = "{:05}".format(int(numinf[i + 1]))
+            numtag.append(imin + "-" + imax)
+    else:
+        numinf = None
+        numtag = ""
+
+    output = {
+        "n_Texp": n_Texp,
+        "alpha_ref": alpha_ref,
+        "molmass": molmass,
+        "numinf": numinf,
+        "numtag": numtag,
+        "quantum_labels": quantum_labels,  # array
+        "Landé": lande,  # bool
+        "lifetime": lifetime,  # bool
+    }
+    return output
+
+
+def read_pf(pff):
+    """Exomol IO for partition file
+
+    Note:
+        T=temperature
+        QT=partition function
+
+    Args:
+        pff: partition file
+    Returns:
+        partition data in pandas DataFrame
+
+    """
+    dat = pd.read_csv(pff, sep=r"\s+", names=("T", "QT"))
+    return dat
+
+
+def read_trans(transf, engine="vaex"):
+    """Exomol IO for a transition file
+
+    Notes
+    -----
+
+    Transf format ::
+
+        i_upper=Upper state counting number
+        i_lower=Lower state counting number
+        A=Einstein coefficient in s-1
+        nu_lines=transition wavenumber in cm-1
+
+    See Table 12 in https://arxiv.org/pdf/1603.05890.pdf [Exomol-2016]_
+
+    Parameters
+    ----------
+    transf: transition file
+    engine: parsing engine to use ('vaex', 'csv')
+
+    Returns
+    -------
+    transition data in vaex/pandas DataFrame
+
+    """
+    if engine == "vaex":
+        import vaex
+
+        try:  # bz2 compression
+            dat = vaex.from_csv(
+                transf,
+                compression="bz2",
+                sep=r"\s+",
+                names=("i_upper", "i_lower", "A", "nu_lines"),
+                convert=False,  #  file is created by MdbMol
+            )
+        except:
+            try:
+                dat = vaex.read_csv(
+                    transf,
+                    sep=r"\s+",
+                    names=("i_upper", "i_lower", "A", "nu_lines"),
+                    convert=False,  #  file is created by MdbMol
+                )
+            except Exception as err:
+                raise Exception(
+                    f"Error reading {transf}. Maybe the file was corrupted during download ? You can try to delete it."
+                ) from err
+    elif engine == "csv":
+        try:  # bz2 compression
+            dat = pd.read_csv(
+                transf,
+                compression="bz2",
+                sep=r"\s+",
+                names=("i_upper", "i_lower", "A", "nu_lines"),
+            )
+        except:
+            try:
+                dat = pd.read_csv(
+                    transf, sep=r"\s+", names=("i_upper", "i_lower", "A", "nu_lines")
+                )
+            except Exception as err:
+                raise Exception(
+                    f"Error reading {transf}. Maybe the file was corrupted during download ? You can try to delete it."
+                ) from err
+    else:
+        raise NotImplementedError(engine)
+
+    return dat
+
+
+def read_states(statesf, dic_def, engine="vaex", skip_optional_data=True):
+    """Exomol IO for a state file
+
+    Notes
+    -----
+
+    States f format ::
+
+        i=state counting number
+        E=state energy
+        g=state degeneracy
+        J=total angular momentum
+
+    See Table 11 in https://arxiv.org/pdf/1603.05890.pdf
+
+    Parameters
+    ----------
+    statesf: str
+        state file
+    dic_def: dict
+        Info from def file to read extra quantum numbers
+    engine: str
+        parsing engine to use ('vaex', 'csv')
+    skip_optional_data: bool
+        If False, fetch all fields which are marked as available in the ExoMol definition
+        file. If True, load only the first 4 columns of the states file
+        ("i", "E", "g", "J"). The structure of the columns above 5 depend on the
+        the definitions file (*.def) and the Exomol version.
+        If ``skip_optional_data=False``, two errors may occur:
+
+            - a field is marked as present/absent in the *.def field but is
+              absent/present in the *.states file (ie both files are inconsistent).
+            - in the updated version of Exomol, new fields have been added in the
+              states file of some species. But it has not been done for all species,
+              so both structures exist. For instance, the states file of
+              https://exomol.com/data/molecules/HCl/1H-35Cl/HITRAN-HCl/ follows the
+              structure described in [1]_, unlike the states file of
+              https://exomol.com/data/molecules/NO/14N-16O/XABC/ which follows the
+              structure described in [2]_.
+
+    Returns
+    -------
+    states data in pandas DataFrame
+
+    If ``'vaex'``, also writes a local hdf5 file `statesf.with_suffix('.hdf5')`
+
+
+    References
+    ----------
+
+    .. [1] Tennyson, J., Yurchenko, S. N., Al-Refaie, A. F., Barton, E. J., Chubb, K. L., Coles, P. A., … Zak, E. (2016). The ExoMol database: molecular line lists for exoplanet and other hot atmospheres. https://doi.org/10.1016/j.jms.2016.05.002
+    .. [2] Tennyson, J., Yurchenko, S. N., Al-Refaie, A. F., Clark, V. H. J., Chubb, K. L., Conway, E. K., … Yurchenko, O. P. (2020). The 2020 release of the ExoMol database: Molecular line lists for exoplanet and other hot atmospheres. Journal of Quantitative Spectroscopy and Radiative Transfer, 255, 107228. https://doi.org/10.1016/j.jqsrt.2020.107228
+
+    """
+    # we read first 4 columns for ("i", "E", "g", "J"),
+    # skip lifetime, skip Landé g-factor,
+    # read quantum numbers
+    N = np.size(dic_def["quantum_labels"])
+    quantum_labels = dic_def["quantum_labels"]
+
+    mandatory_usecol = np.arange(4)
+    mandatory_fields = ("i", "E", "g", "J")
+    if skip_optional_data:
+        usecol = mandatory_usecol
+        names = mandatory_fields
+    else:
+        if dic_def["Landé"] and dic_def["lifetime"]:
+            usecol = np.concatenate((mandatory_usecol, 5 + 2 + np.arange(N)))
+            names = mandatory_fields + ("Lande", "lifetime") + tuple(quantum_labels)
+        elif dic_def["Landé"]:
+            usecol = np.concatenate((mandatory_usecol, 5 + 1 + np.arange(N)))
+            names = mandatory_fields + ("Lande",) + tuple(quantum_labels)
+        elif dic_def["lifetime"]:
+            usecol = np.concatenate((mandatory_usecol, 5 + 1 + np.arange(N)))
+            names = mandatory_fields + ("lifetime",) + tuple(quantum_labels)
+        else:  # no lifetime, nor landé according to the def file
+            usecol = np.concatenate((mandatory_usecol, 5 + np.arange(N)))
+            names = mandatory_fields + tuple(quantum_labels)
+        # The definitions file (*.def) specifies which fields are available
+        # in the states fiel (*.states). Check the number of available fields
+        # (see *.def) match the numbers of columns in the states file (*.states).
+        # Otherwise, both files are inconsistent.
+        with bz2.open(statesf, "rb") as f:
+            firstline = f.readline().decode("utf-8")  # read 1dt line
+            splitline = [x for x in re.split(r"\s+", firstline) if x != ""]
+            # splitline = re.split(r"\s+", firstline)
+            f.close()
+        if len(splitline) != len(names):
+            raise InconsistentDatabaseError(
+                "The EXOMOL definitions and states files are inconsistent.\n"
+                + "Some data are specified as available by the *.def file, but are absent in the *.bz2 file.\n"
+                + "Set `skip_optional_data=False` in `fetch_exomol()` to load only the required data for a LTE computation.\n"
+                + "The problematic optional data/columns will be ignored."
+            )
+
+    if engine == "vaex":
+        import vaex
+
+        # TODO Refactor: move in DataFileManager
+        try:
+            dat = vaex.from_csv(
+                statesf,
+                compression="bz2",
+                sep=r"\s+",
+                usecols=usecol,
+                names=names,
+                convert=False,  # written in MolDB
+            )
+        except:
+            try:
+                dat = vaex.read_csv(
+                    statesf,
+                    sep=r"\s+",
+                    usecols=usecol,
+                    names=names,
+                    convert=False,  # written in MolDB
+                )
+
+            except Exception as err:
+                raise Exception(
+                    f"Error reading {statesf}. Maybe the file was corrupted during download ? You can try to delete it."
+                ) from err
+    elif engine == "csv":
+        try:
+            dat = pd.read_csv(
+                statesf, compression="bz2", sep=r"\s+", usecols=usecol, names=names
+            )
+        except:  #!!!TODO What was the expected error?
+            dat = pd.read_csv(statesf, sep=r"\s+", usecols=usecol, names=names)
+    else:
+        raise NotImplementedError(engine)
+
+    return dat
+
+
+def pickup_gE(
+    states, trans, trans_file, dic_def, trans_lines=False, skip_optional_data=True
+):
+    """extract g_upper (gup), E_lower (elower), and J_lower and J_upper from states
+    DataFrame and insert them into the transition DataFrame.
+
+    Parameters
+    ----------
+    states: states DataFrame  - the i, E, g, J are in the 4 first columns
+    trans: 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
+    add_quantum_labels: bool . If True fetch all quantum labels in dic_def['quantum_labels'] from states into transitions
+
+    Returns
+    -------
+    A, nu_lines, elower, gup, jlower, jupper, mask, **quantum_labels
+
+    Notes
+    -----
+    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.
+
+    States by default has columns ::
+
+        #       i      E             g    J    v
+        0       1      0.0           1    0    0
+        1       2      1.448467      3    1    0
+        2       3      4.345384      5    2    0
+        3       4      8.690712      7    3    0
+
+
+    """
+
+    ### Step 1. Essential quantum number for spectra
+    # ----------------------------------------------
+
+    def map_add(col, new_col, trans_key, states_key="i"):
+        """Lookup `key` in states and add it in trans, using the level ``i``
+        for upper and lower state
+
+        Examples
+        --------
+        ::
+
+            map_add("E", "E_lower", "i_lower")
+        """
+        try:  # pytable
+            trans[new_col] = trans[trans_key].map(dict(states[col]))
+        except:  # a priori, vaex version  (TODO : replace with dict() approach in vaex too)
+            col = vaexsafe_colname(col)
+            new_col = vaexsafe_colname(new_col)
+
+            # WIP. First implementation with join(). Use Map() will probably be faster.
+            trans.join(
+                states[states_key, col],
+                left_on=trans_key,
+                right_on=states_key,
+                inplace=True,
+            )
+            trans.drop(states_key, inplace=True)
+            trans.rename(col, new_col)
+
+    map_add("g", "gup", "i_upper")
+    map_add("J", "jlower", "i_lower")
+    map_add("J", "jupper", "i_upper")
+
+    map_add("E", "elower", "i_lower")
+
+    def has_nan(column):
+        try:  # Vaex
+            return column.countnan() > 0
+        except AttributeError:  # Pandas
+            return column.hasnans
+
+    if not "nu_lines" in trans or has_nan(trans["nu_lines"]):
+        map_add("E", "eupper", "i_upper")
+        trans["nu_lines"] = trans["eupper"] - trans["elower"]
+
+    ### Step 2. Extra quantum numbers (e/f parity, vib and rot numbers)
+    # -----------------------------------------------------------------
+    if not skip_optional_data:
+        for q in dic_def["quantum_labels"]:
+            map_add(q, f"{q}_l", "i_lower")
+            # map_add(q, f"{q}_u", "i_upper")
+
+    return trans
+
+
+# def pickup_gEslow(states, trans):
+#     """Slow version to extract g_upper (gup) and E_lower (elower) from states DataFrame and insert them to transition DataFrame.
+
+#     Note:
+#        This code is the (thousands times) slower version of pickup_gE. However, we did not remove this one just because this version is much easier to understand.
+
+#     Args:
+#        states: states pandas DataFrame
+#        trans: transition pandas DataFrame
+
+#     Returns:
+#        A, nu_lines, elower, gup
+
+#     """
+#     import tqdm
+
+#     E = states["E"].values
+#     g = states["g"].values
+
+#     # insert new columns in transition array
+#     trans["gup"] = 0
+#     trans["elower"] = 0.0
+
+#     for k, i in tqdm.tqdm(enumerate(states["i"])):
+#         # transition upper state
+#         mask_upper = trans["i_upper"] == i
+#         trans["gup"][mask_upper] = g[k]
+#         # transition lower state
+#         mask_lower = trans["i_lower"] == i
+#         trans["elower"][mask_lower] = E[k]
+
+#     A = trans["A"].to_numpy()
+#     nu_lines = trans["nu_lines"].to_numpy()
+#     elower = trans["elower"].to_numpy()
+#     gup = trans["gup"].to_numpy()
+#     return A, nu_lines, elower, gup
+
+
+def read_broad(broadf):
+    """Reading braodening file (.broad)
+    Parameters
+    ----------
+    broadf: .broad file
+
+    Returns
+    -------
+    broadening info in bdat form (pandas), defined by this instance.
+
+    Notes
+    -----
+    See Table 16 in https://arxiv.org/pdf/1603.05890.pdf
+    """
+    bdat = pd.read_csv(
+        broadf,
+        sep=r"\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 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)
+
+    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",
+        )
+
+    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
+    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[jlower_arr, jupper_arr] = alpha_ref_arr
+    jj2n_Texp[jlower_arr, jupper_arr] = n_Texp_arr
+
+    return jj2alpha_ref, jj2n_Texp
+
+
+def get_exomol_full_isotope_name(molecule, isotope):
+    """Get full isotope name for ``molecule`` and ``isotope`` number
+
+    Parameters
+    ----------
+    molecule: str
+    isotope: int
+        terrestrial abundance
+
+    Examples
+    --------
+    ::
+
+        get_exomol_full_isotope_name("CH4", 1)
+        >>> '12C-1H4'
+
+    See Also
+    --------
+    :py:func:`~radis.api.exomolapi.get_exomol_database_list`"""
+
+    if molecule not in EXOMOL_MOLECULES:
+        raise ValueError(
+            f"Molecule {molecule} not available in ExoMol. Change database, or choose one of ExoMol available molecules: {sorted(EXOMOL_MOLECULES)}"
+        )
+
+    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
+
+        mp = MolParams()
+        return mp.get(molecule, isotope, "isotope_name_exomol")
+
+
+def get_list_of_known_isotopes(molecule):
+    """find all isotopes until error ensues"""
+
+    i = 1
+    isotope_list = []
+    while True:
+        try:
+            iso_name = get_exomol_full_isotope_name(molecule, i)
+        except:
+            break
+        else:
+            isotope_list.append(iso_name)
+        finally:
+            i += 1
+    return isotope_list
+
+
+def get_exomol_database_list(molecule, isotope_full_name=None):
+    """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.api.exomolapi.get_exomol_full_isotope_name`
+
+    Returns
+    -------
+    list of databases, database recommended by ExoMol
+
+    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.api.exomolapi.get_exomol_full_isotope_name` to
+    get the isopologue (sorted by terrestrial abundance) ::
+
+        from radis.api.exomolapi 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.api.exomolapi.get_exomol_database_list
+
+
+    See Also
+    --------
+    :py:func:`~radis.api.exomolapi.get_exomol_full_isotope_name`
+    """
+
+    if isotope_full_name is None:
+        raise ValueError(
+            f"Give isotope name. List of known isotopes for {molecule} : {get_list_of_known_isotopes(molecule)}"
+        )
+
+    url = f"https://exomol.com/data/molecules/{molecule}/{isotope_full_name}"
+    try:
+        response = urlopen(url).read()
+    except HTTPError as err:
+        if isotope_full_name not in get_list_of_known_isotopes(molecule):
+            extra = f". Isotope name {isotope_full_name} is not in list of known isotopes : {get_list_of_known_isotopes(molecule)}"
+        else:
+            extra = ""
+        raise ValueError(f"HTTPError opening url={url}" + extra) 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
+
+    if len(databases_recommended) > 0:
+        recommended_database = databases_recommended[0]
+    else:
+        recommended_database = False
+
+    return databases, recommended_database
+
+
+# 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.api.exomolapi.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.api.exomolapi.get_exomol_full_isotope_name` to
+#     get the isopologue (sorted by terrestrial abundance) ::
+
+#         from radis.api.exomolapi 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.api.exomolapi.get_exomol_database_list
+
+#     See Also
+#     --------
+#     :py:func:`~radis.api.exomolapi.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]
+
+
+class MdbExomol(DatabaseManager):
+    """molecular database of ExoMol
+
+    MdbExomol is a class for ExoMol.
+
+    Parameters
+    ----------
+    path: str
+        path for Exomol data directory/tag. For instance, "/home/CO/12C-16O/Li2015"
+    nurange: array
+        wavenumber range list (cm-1) or wavenumber array
+    margin: float
+        margin for nurange (cm-1)
+    crit: float
+        line strength lower limit for extraction
+    bkgdatm: str
+        background atmosphere for broadening. e.g. H2, He,
+    broadf: bool
+        if False, the default broadening parameters in .def file is used
+
+    Other Parameters
+    ----------------
+    engine : str
+        which memory mapping engine to use : 'vaex', 'pytables' (HDF5), 'feather'
+    skip_optional_data : bool
+        If False, fetch all fields which are marked as available in the ExoMol definition
+        file. If True, load only the first 4 columns of the states file
+        ("i", "E", "g", "J"). The structure of the columns above 5 depend on the
+        the definitions file (*.def) and the Exomol version.
+        If ``skip_optional_data=False``, two errors may occur:
+
+            - a field is marked as present/absent in the *.def field but is
+              absent/present in the *.states file (ie both files are inconsistent).
+            - in the updated version of Exomol, new fields have been added in the
+              states file of some species. But it has not been done for all species,
+              so both structures exist. For instance, the states file of
+              https://exomol.com/data/molecules/HCl/1H-35Cl/HITRAN-HCl/ follows the
+              structure described in [1]_, unlike the states file of
+              https://exomol.com/data/molecules/NO/14N-16O/XABC/ which follows the
+              structure described in [2]_.
+
+    Notes
+    -----
+
+    The trans/states files can be very large. For the first time to read it,
+    we convert it to the feather or hdf5-format. After the second-time,
+    we use the feather/hdf5 format instead.
+
+    Examples
+    --------
+    ::
+
+        # Init database, download files if needed.
+        mdb = MdbExomol(
+            local_path,
+            molecule=molecule,
+            name=databank_name,
+            local_databases=local_databases,
+            # nurange=[load_wavenum_min, load_wavenum_max],
+            engine="vaex",
+        )
+
+        # Get cache files to load :
+        mgr = mdb.get_datafile_manager()
+        local_files = [mgr.cache_file(f) for f in mdb.trans_file]
+
+        # Load files
+        df = mdb.load(
+            local_files,
+            columns=columns_exomol,
+            lower_bound=([('nu_lines', load_wavenum_min)] if load_wavenum_min else []) + ([("Sij0", mdb.crit)] if not np.isneginf(mdb.crit) else []),
+            upper_bound=([('nu_lines', load_wavenum_max)] if load_wavenum_max else []),
+            output="jax", # or "pytables", "vaex"
+        )
+
+    .. minigallery:: radis.fetch_exomol
+
+
+    DataFrame columns
+    -----------------
+
+    nu_lines (nd array): line center (cm-1)
+    Sij0 (nd array): line strength at T=Tref (cm)
+    dev_nu_lines (np array): line center in device (cm-1)
+    logsij0 (np array): log line strength at T=Tref
+    A (np array): Einstein A coefficient
+    elower (np array): the lower state energy (cm-1)
+    gpp (np array): statistical weight
+    jlower (np array): J_lower
+    jupper (np array): J_upper
+    n_Tref (np array): temperature exponent
+    alpha_ref (np array): alpha_ref (gamma0)
+    n_Tref_def: default temperature exponent in .def file, used for jlower not given in .broad
+    alpha_ref_def: default alpha_ref (gamma0) in .def file, used for jlower not given in .broad
+
+    References
+    ----------
+
+    .. [1] Tennyson, J., Yurchenko, S. N., Al-Refaie, A. F., Barton, E. J., Chubb, K. L., Coles, P. A., … Zak, E. (2016). The ExoMol database: molecular line lists for exoplanet and other hot atmospheres. https://doi.org/10.1016/j.jms.2016.05.002
+    .. [2] Tennyson, J., Yurchenko, S. N., Al-Refaie, A. F., Clark, V. H. J., Chubb, K. L., Conway, E. K., … Yurchenko, O. P. (2020). The 2020 release of the ExoMol database: Molecular line lists for exoplanet and other hot atmospheres. Journal of Quantitative Spectroscopy and Radiative Transfer, 255, 107228. https://doi.org/10.1016/j.jqsrt.2020.107228
+
+    """
+
+    """molecular database of ExoMol
+
+    MdbExomol is a class for ExoMol.
+
+    Parameters
+    ----------
+    path: str
+        path for Exomol data directory/tag. For instance, "/home/CO/12C-16O/Li2015"
+    nurange: array
+        wavenumber range list (cm-1) or wavenumber array
+    margin: float
+        margin for nurange (cm-1)
+    crit: float
+        line strength lower limit for extraction
+    bkgdatm: str
+        background atmosphere for broadening. e.g. H2, He,
+    broadf: bool
+        if False, the default broadening parameters in .def file is used
+
+    Other Parameters
+    ----------------
+    engine : str
+        which memory mapping engine to use : 'vaex', 'pytables' (HDF5), 'feather'
+    skip_optional_data : bool
+        If False, fetch all fields which are marked as available in the ExoMol definition
+        file. If True, load only the first 4 columns of the states file
+        ("i", "E", "g", "J"). The structure of the columns above 5 depend on the
+        the definitions file (*.def) and the Exomol version.
+        If ``skip_optional_data=False``, two errors may occur:
+
+            - a field is marked as present/absent in the *.def field but is
+              absent/present in the *.states file (ie both files are inconsistent).
+            - in the updated version of Exomol, new fields have been added in the
+              states file of some species. But it has not been done for all species,
+              so both structures exist. For instance, the states file of
+              https://exomol.com/data/molecules/HCl/1H-35Cl/HITRAN-HCl/ follows the
+              structure described in [1]_, unlike the states file of
+              https://exomol.com/data/molecules/NO/14N-16O/XABC/ which follows the
+              structure described in [2]_.
+
+    Notes
+    -----
+
+    The trans/states files can be very large. For the first time to read it,
+    we convert it to the feather or hdf5-format. After the second-time,
+    we use the feather/hdf5 format instead.
+
+    Examples
+    --------
+    ::
+
+        # Init database, download files if needed.
+        mdb = MdbExomol(
+            local_path,
+            molecule=molecule,
+            name=databank_name,
+            local_databases=local_databases,
+            # nurange=[load_wavenum_min, load_wavenum_max],
+            engine="vaex",
+        )
+
+        # Get cache files to load :
+        mgr = mdb.get_datafile_manager()
+        local_files = [mgr.cache_file(f) for f in mdb.trans_file]
+
+        # Load files
+        df = mdb.load(
+            local_files,
+            columns=columns_exomol,
+            lower_bound=([('nu_lines', load_wavenum_min)] if load_wavenum_min else []) + ([("Sij0", mdb.crit)] if not np.isneginf(mdb.crit) else []),
+            upper_bound=([('nu_lines', load_wavenum_max)] if load_wavenum_max else []),
+            output="jax", # or "pytables", "vaex"
+        )
+
+    .. minigallery:: radis.fetch_exomol
+
+
+    DataFrame columns
+    -----------------
+
+    nu_lines (nd array): line center (cm-1)
+    Sij0 (nd array): line strength at T=Tref (cm)
+    dev_nu_lines (np array): line center in device (cm-1)
+    logsij0 (np array): log line strength at T=Tref
+    A (np array): Einstein A coefficient
+    elower (np array): the lower state energy (cm-1)
+    gpp (np array): statistical weight
+    jlower (np array): J_lower
+    jupper (np array): J_upper
+    n_Tref (np array): temperature exponent
+    alpha_ref (np array): alpha_ref (gamma0)
+    n_Tref_def: default temperature exponent in .def file, used for jlower not given in .broad
+    alpha_ref_def: default alpha_ref (gamma0) in .def file, used for jlower not given in .broad
+
+    References
+    ----------
+
+    .. [1] Tennyson, J., Yurchenko, S. N., Al-Refaie, A. F., Barton, E. J., Chubb, K. L., Coles, P. A., … Zak, E. (2016). The ExoMol database: molecular line lists for exoplanet and other hot atmospheres. https://doi.org/10.1016/j.jms.2016.05.002
+    .. [2] Tennyson, J., Yurchenko, S. N., Al-Refaie, A. F., Clark, V. H. J., Chubb, K. L., Conway, E. K., … Yurchenko, O. P. (2020). The 2020 release of the ExoMol database: Molecular line lists for exoplanet and other hot atmospheres. Journal of Quantitative Spectroscopy and Radiative Transfer, 255, 107228. https://doi.org/10.1016/j.jqsrt.2020.107228
+
+    """
+
+    # 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,
+        molecule,
+        database=None,
+        local_databases=None,
+        name="EXOMOL-{molecule}",
+        nurange=[0.0, np.inf],
+        margin=0.0,
+        crit=-np.inf,
+        bkgdatm="Air",  # TODO: use Air whenever possible, to be consistent with HITRAN/HITEMP
+        broadf=True,
+        engine="vaex",
+        verbose=True,
+        cache=True,
+        skip_optional_data=True,
+    ):
+        super().__init__(
+            name,
+            molecule,
+            local_databases,
+            engine,
+            verbose=verbose,
+        )
+        assert cache  # cache only used for cache='regen' or cache='force' modes, cache=False is not expected
+
+        if engine == "default":
+            from radis import config
+
+            engine = config["MEMORY_MAPPING_ENGINE"]
+            if engine == "auto":
+                engine = "vaex"
+
+        self.path = pathlib.Path(path)
+        if local_databases is not None:
+            self.path = pathlib.Path(local_databases).expanduser() / self.path
+
+        t0 = self.path.parents[0].stem
+        molec = t0 + "__" + str(self.path.stem)
+        self.bkgdatm = bkgdatm
+        print("Background atmosphere: ", self.bkgdatm)
+        molecbroad = t0 + "__" + self.bkgdatm
+
+        self.crit = crit
+        self.margin = margin
+        self.nurange = [np.min(nurange), np.max(nurange)]
+        self.wmin, self.wmax = np.min(nurange), np.max(nurange)
+        self.broadf = broadf
+        # Where exomol files are
+        self.states_file = self.path / pathlib.Path(molec + ".states.bz2")
+        self.pf_file = self.path / pathlib.Path(molec + ".pf")
+        self.def_file = self.path / pathlib.Path(molec + ".def")
+        self.broad_file = self.path / pathlib.Path(molecbroad + ".broad")
+
+        if not self.def_file.exists():
+            self.download(molec, extension=[".def"])
+        if not self.pf_file.exists():
+            self.download(molec, extension=[".pf"])
+        if not self.states_file.exists():
+            self.download(molec, extension=[".states.bz2"])
+        if not self.broad_file.exists():
+            self.download(molec, extension=[".broad"])
+
+        # Add molecule name
+        tag = molec.split("__")
+        self.isotope_fullname = tag[0]
+        self.molecule = e2s(tag[0])
+        # self.isotope = 1  # Placeholder. TODO : impement parsing of other isotopes.
+
+        # load def
+        dic_def = read_def(self.def_file)  # approx. 3 ms
+        self.n_Texp_def = dic_def["n_Texp"]
+        self.alpha_ref_def = dic_def["alpha_ref"]
+        self.molmass = dic_def["molmass"]
+
+        #  default n_Texp value if not given
+        if self.n_Texp_def is None:
+            self.n_Texp_def = 0.5
+            warnings.warn(
+                Warning(
+                    f"""
+                    No default broadening exponent in def file. Assigned n = {self.n_Texp_def}
+                    """
+                )
+            )
+        #  default alpha_ref value if not given
+        if self.alpha_ref_def is None:
+            self.alpha_ref_def = 0.07
+            warnings.warn(
+                Warning(
+                    f"""
+                    No default broadening in def file. Assigned alpha_ref = {self.alpha_ref_def}
+                    """
+                )
+            )
+
+        # load states
+        mgr = self.get_datafile_manager()
+        if cache == "regen" and mgr.cache_file(self.states_file).exists():
+            if verbose:
+                print("Removing existing file ", mgr.cache_file(self.states_file))
+            os.remove(mgr.cache_file(self.states_file))
+        if mgr.cache_file(self.states_file).exists():
+            states = mgr.read(mgr.cache_file(self.states_file))
+        else:
+            if cache == "force":
+                raise ValueError(
+                    f"Cache file {str(mgr.cache_file(self.states_file))} does not exist"
+                )
+            print(
+                f"Note: Caching states data to the {engine} format. After the second time, it will become much faster."
+            )
+            states = read_states(
+                self.states_file,
+                dic_def,
+                engine="vaex" if engine == "vaex" else "csv",
+                skip_optional_data=skip_optional_data,
+            )
+            mgr.write(mgr.cache_file(self.states_file), states)
+
+        # load pf
+        pf = read_pf(self.pf_file)
+        self.gQT = pf["QT"].to_numpy()  # grid QT
+        self.T_gQT = pf["T"].to_numpy()  # T forgrid QT
+
+        # trans file(s)
+        # Compute linestrengths or retrieve them from cache
+        self.Tref = 296.0
+        self.QTref = np.array(self.QT_interp(self.Tref))
+
+        # Download files
+
+        # Generate list of files
+        # ---------------------
+        # Case1 : transitions are stored in a single file
+        if dic_def["numinf"] is None:
+            self.trans_file = [self.path / pathlib.Path(molec + ".trans.bz2")]
+            self.num_tag = [None]
+
+        # Case2 : Transitions are stored in multiple files:
+        else:  # dic_def["numinf"] is not None
+            # dic_def["numinf"] contains the limit points ``[w(0), w(1), ..., w(n)]``
+            # (n+1 elements) defining the spectral ranges appearing in the list
+            # dic_def["numtag"] which looks like ``["w(0)-w(1)", "w(1)-w(2)", ..., w(n-1)-w(n)]``
+            # (n elements)
+            # imin :
+            # index i of the "w(i)-w(i+1)" element in dic_def["numtag"] such
+            # that nurange[0]<=w(i)
+            imin = (
+                np.searchsorted(dic_def["numinf"], nurange[0] - margin, side="right")
+                - 1
+            )
+            # imax :
+            # index i of the "w(i)-w(i+1)" element in dic_def["numtag"] such
+            # that w(i+1)<=nurange[1]
+            imax = (
+                np.searchsorted(dic_def["numinf"], nurange[1] + margin, side="right")
+                - 1
+            )
+            self.trans_file = []
+            self.num_tag = []
+
+            for k, i in enumerate(range(imin, imax + 1)):
+                trans_file = self.path / pathlib.Path(
+                    molec + "__" + dic_def["numtag"][i] + ".trans.bz2"
+                )
+                self.trans_file.append(trans_file)
+                self.num_tag.append(dic_def["numtag"][i])
+
+        # Look-up missing parameters and write file
+        # -----------------------------------------
+
+        for trans_file, num_tag in zip(self.trans_file, self.num_tag):
+            print("Reading", trans_file)
+            if cache == "regen" and mgr.cache_file(trans_file).exists():
+                if verbose:
+                    print("Removing existing file ", mgr.cache_file(trans_file))
+                os.remove(mgr.cache_file(trans_file))
+
+            if not mgr.cache_file(trans_file).exists():
+                if cache == "force":
+                    raise ValueError(
+                        f"Cache file {str(mgr.cache_file(trans_file))} does not exist"
+                    )
+
+                if not trans_file.exists():
+                    self.download(molec, extension=[".trans.bz2"], numtag=num_tag)
+                    # TODO: add option to delete file at the end
+
+                print(
+                    f"Note: Caching line transition data to the {engine} format. After the second time, it will become much faster."
+                )
+                trans = read_trans(
+                    trans_file, engine="vaex" if engine == "vaex" else "csv"
+                )
+
+                # Complete transition data with lookup on upper & lower state :
+                # In particular, compute gup and elower
+
+                pickup_gE(
+                    states,
+                    trans,
+                    trans_file,
+                    dic_def,
+                    skip_optional_data=skip_optional_data,
+                )
+
+                ##Recompute Line strength:
+                from radis.lbl.base import (  # TODO: move elsewhere
+                    linestrength_from_Einstein,
+                )
+
+                self.Sij0 = linestrength_from_Einstein(
+                    A=trans["A"],
+                    gu=trans["gup"],
+                    El=trans["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=trans["nu_lines"],
+                    Q=self.QTref,
+                    T=self.Tref,
+                )
+
+                trans["Sij0"] = self.Sij0
+
+                mgr.write(mgr.cache_file(trans_file), trans)
+
+        # Database ready to be loaded.
+        # Proceed with mdb.load()
+        # self.set_broadening()
+        #
+
+    def set_broadening(self, df, alpha_ref_def=None, n_Texp_def=None, output=None):
+        """setting broadening parameters
+
+        Parameters
+        ----------
+        df: Data Frame
+        alpha_ref: set default alpha_ref and apply it. None=use self.alpha_ref_def
+        n_Texp_def: set default n_Texp and apply it. None=use self.n_Texp_def
+
+        Returns
+        -------
+        None. Store values in Data Frame.
+
+        """
+        self.compute_broadening(
+            jlower=df["jlower"],
+            jupper=df["jupper"],
+            alpha_ref_def=alpha_ref_def,
+            n_Texp_def=n_Texp_def,
+            output=output,
+        )
+        # Add values
+        self.add_column(df, "alpha_ref", self.alpha_ref)
+        self.add_column(df, "n_Texp", self.n_Texp)
+
+    def compute_broadening(
+        self, jlower, jupper, alpha_ref_def=None, n_Texp_def=None, output=None
+    ):
+        """computing broadening parameters
+
+        Parameters
+        ----------
+        jlower: jlower array
+        jupper: jupper array
+        alpha_ref: set default alpha_ref and apply it. None=use self.alpha_ref_def
+        n_Texp_def: set default n_Texp and apply it. None=use self.n_Texp_def
+
+        Returns
+        -------
+        None. Store values in self.n_Texp and self.alpha_ref.
+
+        """
+
+        if alpha_ref_def:
+            self.alpha_ref_def = alpha_ref_def
+        if n_Texp_def:
+            self.n_Texp_def = n_Texp_def
+
+        if self.broadf:
+            try:
+                print(".broad is used.")
+                bdat = read_broad(self.broad_file)
+                codelv = check_bdat(bdat)
+                print("Broadening code level=", codelv)
+                if codelv == "a0":
+                    j2alpha_ref, j2n_Texp = make_j2b(
+                        bdat,
+                        alpha_ref_default=self.alpha_ref_def,
+                        n_Texp_default=self.n_Texp_def,
+                        jlower_max=np.max(jlower),
+                    )
+                    self.alpha_ref = np.array(j2alpha_ref[jlower])
+                    self.n_Texp = np.array(j2n_Texp[jlower])
+                elif codelv == "a1":
+                    j2alpha_ref, j2n_Texp = make_j2b(
+                        bdat,
+                        alpha_ref_default=self.alpha_ref_def,
+                        n_Texp_default=self.n_Texp_def,
+                        jlower_max=np.max(jlower),
+                    )
+                    jj2alpha_ref, jj2n_Texp = make_jj2b(
+                        bdat,
+                        j2alpha_ref_def=j2alpha_ref,
+                        j2n_Texp_def=j2n_Texp,
+                        jupper_max=np.max(jupper),
+                    )
+                    self.alpha_ref = np.array(jj2alpha_ref[jlower, jupper])
+                    self.n_Texp = np.array(jj2n_Texp[jlower, jupper])
+            except FileNotFoundError:
+                print(
+                    "Warning: Cannot load .broad. The default broadening parameters are used."
+                )
+                self.alpha_ref = np.array(self.alpha_ref_def * np.ones_like(jlower))
+                self.n_Texp = np.array(self.n_Texp_def * np.ones_like(jlower))
+
+        else:
+            print("The default broadening parameters are used.")
+            self.alpha_ref = np.array(self.alpha_ref_def * np.ones_like(jlower))
+            self.n_Texp = np.array(self.n_Texp_def * np.ones_like(jlower))
+
+    def QT_interp(self, T):
+        """interpolated partition function
+
+        Parameters
+        ----------
+        T: temperature
+
+        Returns
+        -------
+        Q(T) interpolated in jnp.array
+
+        """
+        return np.interp(T, self.T_gQT, self.gQT)
+
+    def qr_interp(self, T):
+        """interpolated partition function ratio
+
+        Parameters
+        ----------
+        T: temperature
+
+        Returns
+        -------
+        qr(T)=Q(T)/Q(Tref) interpolated in jnp.array
+
+        """
+        return self.QT_interp(T) / self.QT_interp(self.Tref)
+
+    def download(self, molec, extension, numtag=None):
+        """Downloading Exomol files
+
+        Parameters
+        ----------
+        molec: like "12C-16O__Li2015"
+        extension: extension list e.g. [".pf",".def",".trans.bz2",".states.bz2",".broad"]
+        numtag: number tag of transition file if exists. e.g. "11100-11200"
+
+        Notes
+        -----
+        The download URL is written in exojax.utils.url.
+
+        """
+        import os
+        import urllib.request
+
+        tag = molec.split("__")
+        molname_simple = e2s(tag[0])
+
+        for ext in extension:
+            if ext == ".trans.bz2" and numtag is not None:
+                ext = "__" + numtag + ext
+
+            if ext == ".broad":
+                pfname_arr = [
+                    tag[0] + "__H2" + ext,
+                    tag[0] + "__He" + ext,
+                    tag[0] + "__air" + ext,
+                ]
+                url = EXOMOL_URL + molname_simple + "/" + tag[0] + "/"
+            else:
+                pfname_arr = [molec + ext]
+                url = EXOMOL_URL + molname_simple + "/" + tag[0] + "/" + tag[1] + "/"
+
+            for pfname in pfname_arr:
+                pfpath = url + pfname
+                os.makedirs(str(self.path), exist_ok=True)
+                print(
+                    "Downloading "
+                    + pfpath
+                    + " and saving as "
+                    + str(self.path / pfname)
+                )
+                try:
+                    urllib.request.urlretrieve(pfpath, str(self.path / pfname))
+                except HTTPError:
+                    print(f"Error: Couldn't download {ext} file at {pfpath} and save.")
+
+    def to_partition_function_tabulator(self):
+        """Generate a :py:class:`~radis.levels.partfunc.PartFuncExoMol` object"""
+        from radis.levels.partfunc import PartFuncExoMol
+
+        return PartFuncExoMol(self.isotope_fullname, self.T_gQT, self.gQT)
+
+
+if __name__ == "__main__":
+    print(e2s("12C-1H4"))
+    print(e2s("23Na-16O-1H"))
+    print(e2s("HeH_p"))
+    print(e2s("trans-31P2-1H-2H"))  # not working
+
+    # TODO : move to unitary tests of exomol_utils
+    assert e2s("12C-1H4") == "CH4"
+    assert e2s("23Na-16O-1H") == "NaOH"
+    assert e2s("HeH_p") == "HeH_p"
+    assert e2s("trans-31P2-1H-2H") == "trans-31P2-1H-2H"  # convert not working
+
+    assert e2s("12C-16O2") == "CO2"
+    assert e2s("13C-16O2") == "CO2"
+
+    # mdb=MdbExomol("/home/kawahara/exojax/data/CO/12C-16O/Li2015/")
+    # mdb=MdbExomol("/home/kawahara/exojax/data/CH4/12C-1H4/YT34to10/",nurange=[6050.0,6150.0])
+
+    # %% Test  by overriding Spectrumfactory's DataFrame df0
+    # mdb = MdbExomol(".database/H2O/1H2-16O/POKAZATEL", [4310.0, 4320.0], crit=1.0e-45)
+    # df = mdb.to_df()
+    # from radis import SpectrumFactory
+    # sf = SpectrumFactory(
+    #     4310,
+    #     4320,
+    #     molecule="H2O",
+    #     isotope="1",
+    # )
+    # sf.fetch_databank(
+    #     "hitran"
+    # )  # placeholder. Load lines (will be replaced), load partition function.
+    # sf.df0 = df  # override.
+    # s = sf.eq_spectrum(500, name="ExoMol")
+    # # sf.fetch_databank('hitran')  # placeholder. Load lines (will be replaced), load partition function.
+    # # s_hit = sf.eq_spectrum(500, name='HITRAN')
+
+    #%% Test by direct caclulation
+    # import pytest
+
+    # print("Testing factory:", pytest.main(["../test/io/test_exomol.py"]))
+
+    #%% RADIS-like Example
+    # uses fetch_exomol() internally
+
+    from radis import calc_spectrum
+
+    s = calc_spectrum(
+        wavelength_min=1.630e4,
+        wavelength_max=1.6305e4,
+        molecule="CH4",
+        isotope="1",
+        pressure=1.01325,  # bar
+        Tgas=1000,  # K
+        mole_fraction=0.1,
+        path_length=1,  # cm
+        # broadening_method="fft",  # @ dev: Doesn't work with 'voigt'
+        databank=(
+            "exomol",
+            "YT10to10",
+        ),  # Simply use 'exomol' for the recommended database
+    )
+    # s.apply_slit(1, "cm-1")  # simulate an experimental slit
+    s.plot("xsection")
+
+    # %% Exojax like Example
+
+    class mdbExoMol:
+
+        # hardcode attribute names, to prevent typos and the declaration of unwanted parameters
+        __slots__ = [
+            "Sij0",
+            "logsij0",
+            "nu_lines",
+            "A",
+            "elower",
+            "eupper",
+            "gupper",
+            "jlower",
+            "jupper",
+        ]
+
+        def __init__(
+            self,
+            molecule,
+            path,
+            nurange=[-np.inf, np.inf],
+            local_databases="~/exojax",
+        ):
+            """
+            Parameters
+            ----------
+            molecule: molecule name
+            path : local path, mirror of ExoMol path
+            nurange : TYPE, optional
+                DESCRIPTION. The default is [-np.inf, np.inf].
+            crit : TYPE, optional
+                DESCRIPTION. The default is -np.inf.
+
+            Returns
+            -------
+            DataFrame
+
+            Examples
+            --------
+            ::
+
+                mdbCH4 = mdbExoMol("CH4", '.database/CH4/12C-1H4/YT10to10/', nus, crit=1.e-30)
+                print(len(mdbCH4.nu_lines), "lines")
+                mdbCH4.elower
+
+            Available columns::
+
+                [
+                    "Sij0",
+                    "logsij0",
+                    "nu_lines",
+                    "A",
+                    "elower",
+                    "eupper",
+                    "gupper",
+                    "jlower",
+                    "jupper",
+                ]
+
+            """
+
+            wavenum_min, wavenum_max = np.min(nurange), np.max(nurange)
+            if wavenum_min == -np.inf:
+                wavenum_min = None
+            if wavenum_max == np.inf:
+                wavenum_max = None
+
+            # Set-up database, download files and set-up cache files if needed
+            mdb = MdbExomol(
+                path,
+                molecule=molecule,
+                local_databases=local_databases,
+                nurange=[wavenum_min, wavenum_max],
+            )
+
+            # Get cache files to load :
+            mgr = mdb.get_datafile_manager()
+            local_files = [mgr.cache_file(f) for f in mdb.trans_file]
+
+            # Load them: #temperature dependent criterion
+            jdict = mdb.load(
+                local_files,
+                columns=[k for k in self.__slots__ if k not in ["logsij0"]],
+                lower_bound=([("nu_lines", wavenum_min)] if wavenum_min else [])
+                + ([("Sij0", mdb.crit)] if not np.isneginf(mdb.crit) else []),
+                upper_bound=([("nu_lines", wavenum_max)] if wavenum_max else []),
+                output="jax",
+            )
+
+            # set attributes, accessible as e.g:  mdb.nu_lines
+            for k in jdict.keys():
+                setattr(self, k, jdict[k])
+
+    nus = np.linspace(1e7 / 1.630e4, 1e7 / 1.6305e4)
+
+    # Download new ExoMol repo (in ~/exomol)
+    mdbCH4 = mdbExoMol(
+        "CH4",
+        ".database/CH4/12C-1H4/YT10to10/",
+        nus,
+        crit=1.0e-30,
+        local_databases=".",  # use local folder
+    )
+
+    print(len(mdbCH4.nu_lines), "lines")
+    mdbCH4.elower
+
+    # Or use RADIS's folder  (# by default ~/.radisdb/exomol)
+    import radis
+
+    mdbCH4_2 = mdbExoMol(
+        "CH4",
+        "CH4/12C-1H4/YT10to10/",
+        nus,
+        crit=1.0e-30,
+        local_databases=pathlib.Path(radis.config["DEFAULT_DOWNLOAD_PATH"]) / "exomol",
+    )
+    # ... ready to run Jax calculations
+
+    #%%
+
+    # """ExoMol lines can be downloaded and accessed separately using
+    # :py:func:`~radis.io.exomol.fetch_exomol`
+    # """
+
+    # # 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.api.exomolapi 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/radis/api/geisaapi.py b/radis/api/geisaapi.py
new file mode 100644
index 000000000..845fce0ef
--- /dev/null
+++ b/radis/api/geisaapi.py
@@ -0,0 +1,460 @@
+# -*- coding: utf-8 -*-
+"""
+Summary
+-----------------------
+
+GEISA database parser
+
+-----------------------
+
+
+"""
+
+
+# import re
+import time
+from collections import OrderedDict
+from os.path import exists, getmtime
+
+import numpy as np
+
+import radis
+
+try:
+    from .cache_files import load_h5_cache_file, save_to_hdf
+    from .tools import drop_object_format_columns, parse_hitran_file
+except ImportError:
+    if __name__ == "__main__":  # running from this file, as a script
+        from radis.api.cache_files import load_h5_cache_file, save_to_hdf
+        from radis.io.tools import drop_object_format_columns, parse_hitran_file
+    else:
+        raise
+# from typing import Union
+from radis.api.dbmanager import DatabaseManager
+from radis.api.hdf5 import DataFileManager
+
+# %% Parsing functions
+
+# General case : GEISA 2020
+# Provided by Thibault Delahaye
+
+# BE ADVISED #1: the columns "int" and "ierrB" should be in format FLOAT instead of str as below.
+# However, because of Fortran's double-precision floating-point values used in these 2 columns,
+# which numpy cannot typecast to float. Thus, we set them as string here and will typecast them
+# to float again later in geisa_utils.
+
+# BE ADVISED #2: the columns "idH" for HITRAN molecular number and "isoH" for HITRAN isotope number
+# have been changed to "id" and "iso" respectively. Meanwhile, original columns "id" and "iso" have
+# also been changed to "idG" and "isoG" to avoid duplication. This is to make sure the parsing process
+# and spectrum calculation using HAPI partition function can operate normally.
+
+# fmt: off
+columns_GEISA = OrderedDict(
+    [
+        # name  # format  # type  # description  # unit
+        ("wav", ("a12", float, "vacuum wavenumber", "cm-1")),
+        ("int", ("a11", str, "intensity at 296K", "cm-1/(molecule/cm-2)")),
+        ("airbrd", ("a6", float, "air-broadened half-width at 296K", "cm-1.atm-1")),
+        ("El", ("a10", float, "lower-state energy", "cm-1")),
+        ("globu", ("a25", str, "electronic and vibrational global upper quanta", "")),
+        ("globl", ("a25", str, "electronic and vibrational global lower quanta", "")),
+        ("locu", ("a15", str, "electronic and vibrational local upper quanta", "")),
+        ("locl", ("a15", str, "electronic and vibrational local lower quanta", "")),
+        ("Tdpair", ("a4", float, "temperature-dependance exponent for Gamma air", "")),
+        ("isoG", ("a3", int, "GEISA isotope number", "")),
+        ("mol", ("a3", int, "GEISA molecular number", "")),
+        ("idG", ("a3", str, "Internal GEISA code for the data identification", "")),
+        ("id", ("a2", int, "Hitran molecular number", "")),
+        ("iso", ("a1", int, "Hitran isotope number", "")),
+        ("A", ("a10", float, "Einstein A coefficient", "s-1")),
+        ("selbrd", ("a7", float, "self-broadened half-width at 296K", "cm-1.atm-1")),
+        ("Pshft", ("a9", float, "air pressure-induced line shift at 296K", "cm-1.atm-1")),
+        ("Tdppair", ("a6", float, "temperature-dependance exponent for air pressure-induced line shift", "")),
+        ("ierrA", ("a10", float, "estimated accuracy on the line position", "cm-1")),
+        ("ierrB", ("a11", str, "estimated accuracy on the intensity of the line", "cm-1/(molecule/cm-2)")),
+        ("ierrC", ("a6", float, "estimated accuracy on the air collision halfwidth", "cm-1.atm-1")),
+        ("ierrF", ("a4", float, "estimated accuracy on the temperature dependence coefficient of the air-broadening halfwidth", "")),
+        ("ierrO", ("a9", float, "estimated accuracy on the air pressure shift of the line transition at 296K", "cm-1.atm-1")),
+        ("ierrR", ("a6", float, "estimated accuracy on the temperature dependence coefficient of the air pressure shift", "")),
+        ("ierrN", ("a7", float, "estimated accuracy on the self-broadened at 296K", "cm-1.atm-1")),
+        ("Tdpself", ("a4", float, "temperature-dependance exponent for self-broadening halfwidth", "")),
+        ("ierrS", ("a4", float, "estimated accuracy on the temperature dependence coefficient of the self-broadening halfwidth", "")),
+        ("Pshfts", ("a8", float, "self pressure-induced line shift at 296K", "cm-1.atm-1")),
+        ("ierrT", ("a8", float, "estimated accuracy on the self-pressure shift of the line transition at 296K", "cm-1.atm-1")),
+        ("Tdpnself", ("a4", float, "temperature-dependance exponent for self pressure-induced line shift", "")),
+        ("ierrU", ("a4", float, "estimated accuracy on the temperature dependence coefficient of the self pressure shift", "")),
+    ]
+)
+""" OrderedDict: parsing order of GEISA2020 format """
+
+# The columns required for equilibrium calculations only
+equilibrium_columns = OrderedDict(
+    [
+        # name  # format  # type  # description  # unit
+        ("wav", ("a12", float, "vacuum wavenumber", "cm-1")),
+        ("int", ("a11", float, "intensity at 296K", "cm-1/(molecule/cm-2)")),
+        ("airbrd", ("a6", float, "air-broadened half-width at 296K", "cm-1.atm-1")),
+        ("iso", ("a3", int, "GEISA isotope number", "")),
+        ("mol", ("a3", int, "GEISA molecular number", "")),
+        ("id", ("a3", str, "Internal GEISA code for the data identification", "")),
+        ("selbrd", ("a7", float, "self-broadened half-width at 296K", "cm-1.atm-1")),
+        ("Pshft", ("a9", float, "air pressure-induced line shift at 296K", "cm-1.atm-1")),
+        ("Pshfts", ("a8", float, "self pressure-induced line shift at 296K", "cm-1.atm-1")),
+    ]
+)
+# fmt: on
+
+GEISA_MOLECULES_Nlines = {
+    "H2O": 362222,
+    "CO2": 532533,
+    "O3": 389378,
+    "N2O": 50633,
+    "CO": 14985,
+    "CH4": 240858,
+    "O2": 6428,
+    "NO": 105079,
+    "SO2": 68728,
+    "NO2": 113880,
+    "NH3": 29082,
+    "PH3": 34542,
+    "HNO3": 691161,
+    "OH": 42866,
+    "HF": 20010,
+    "HCL": 35985,
+    "HBR": 8980,
+    "HI": 4751,
+    "CLO": 7230,
+    "OCS": 33809,
+    "H2CO": 37050,
+    "C2H6": 28439,
+    "CH3D": 49237,
+    "C2H2": 11340,
+    "C2H4": 18378,
+    "GEH4": 32372,
+    "HCN": 81889,
+    "C3H8": 8983,
+    "C2N2": 2577,
+    "C4H2": 119480,
+    "HC3N": 179347,
+    "HOCL": 17862,
+    "N2": 120,
+    "CH3CL": 18344,
+    "H2O2": 126983,
+    "H2S": 20788,
+    "HCOOH": 62684,
+    "COF2": 70904,
+    "SF6": 92398,
+    "C3H4": 19001,
+    "HO2": 38804,
+    "CLONO2": 356899,
+    "CH3BR": 36911,
+    "CH3OH": 19897,
+    "NO+": 1206,
+    "HNC": 5619,
+    "C6H6": 9797,
+    "C2HD": 15512,
+    "CF4": 60033,
+    "CH3CN": 17172,
+    "HDO": 63641,
+    "SO3": 10881,
+    "HONO": 26041,
+    "COFCL": 215639,
+    "CH3I": 70291,
+    "CH3F": 1499,
+    "RUO4": 30205,
+    "H2C3H2": 31686,
+}
+
+GEISA_MOLECULES = list(GEISA_MOLECULES_Nlines.keys())
+
+
+def gei2df(
+    fname,
+    cache=True,
+    load_columns=None,
+    verbose=True,
+    drop_non_numeric=True,
+    load_wavenum_min=None,
+    load_wavenum_max=None,
+    engine="pytables",
+):
+    """Convert a GEISA [1]_ file to a Pandas dataframe.
+    Parameters
+    ----------
+    fname: str
+        GEISA file name.
+    cache: boolean, or 'regen'
+        if ``True``, a pandas-readable HDF5 file is generated on first access,
+        and later used. This saves on the datatype cast and conversion and
+        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 when loading from a cache file. To generate
+            the cache file, all columns are loaded anyway.
+    Other Parameters
+    ----------------
+    drop_non_numeric: boolean
+        if ``True``, non numeric columns are dropped. This improves performances,
+        but make sure all the columns you need are converted to numeric formats
+        before hand. Default ``True``. Note that if a cache file is loaded it
+        will be left untouched.
+    load_wavenum_min, load_wavenum_max: float
+        if not ``'None'``, only load the cached file if it contains data for
+        wavenumbers above/below the specified value. See :py:func`~radis.api.cache_files.load_h5_cache_file`.
+        Default ``'None'``.
+    engine: 'pytables', 'vaex'
+        format for Hdf5 cache file, `pytables` by default.
+    Returns
+    -------
+    df: pandas Dataframe
+        dataframe containing all lines and parameters.
+    Notes
+    -----
+    GEISA Database 2020 release can be downloaded from [2]_
+    References
+    ----------
+    .. [1] `The 2020 edition of the GEISA spectroscopic database, Thibault Delahaye et al., 2021 <https://www.sciencedirect.com/science/article/abs/pii/S0022285221000928>`_
+    .. [2] `GEISA Database 2020 release <https://geisa.aeris-data.fr/interactive-access/?db=2020&info=ftp>`_
+    See Also
+    --------
+    :func:`~radis.io.hitran.hit2df`
+    :func:`~radis.io.cdsd.cdsd2df`
+    """
+
+    # Try to access last modification time of original file
+    metadata = {}
+    metadata["last_modification"] = time.ctime(getmtime(fname))
+    # Make sure the wavenums are legit
+    if load_wavenum_min and load_wavenum_max:
+        assert load_wavenum_min < load_wavenum_max
+    # Verbose
+    if verbose >= 2:
+        print("Opening file {0}, cache={1})".format(fname, cache))
+        print("Last Modification time: {0}".format(metadata["last_modification"]))
+
+    # Attempt to use cache file
+    fcache = DataFileManager(engine).cache_file(fname)
+    if cache and exists(fcache):
+        relevant_if_metadata_above = (
+            {"wavenum_max": load_wavenum_min} if load_wavenum_min else {}
+        )  # not relevant if wavenum_max of file is < wavenum min required
+        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
+        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
+
+    # If cache files are not found, commence reading of full file
+    df = parse_hitran_file(fname, columns_GEISA)
+
+    # Commence "D to E" conversion on 2nd and 20th columns, by using
+    # df.columns.str.replace() which is much faster than Python loop.
+    # Finally, typecast them to float.
+    df["int"] = df["int"].astype(str).str.replace("D", "E").astype(float)
+    df["ierrB"] = df["ierrB"].astype(str).str.replace("D", "E").astype(float)
+
+    # Remove non numerical attributes
+    if drop_non_numeric:
+        # The function replace_PQR_with_m101 below will be developed
+        # in later updates for GEISA's non-equilibrium calculations
+        # replace_PQR_with_m101(df)
+
+        # Basically ripping out 4 quanta columns
+        df = drop_object_format_columns(df, verbose=verbose)
+
+    # Generate cache file for later use
+    if cache:
+        new_metadata = {
+            # Last modification time of the original file :
+            "last_modification": time.ctime(getmtime(fname)),
+            "wavenum_min": df.wav.min(),
+            "wavenum_max": df.wav.max(),
+        }
+        if verbose:
+            print(
+                "Generating cache file {0} with metadata :\n{1}".format(
+                    fcache, new_metadata
+                )
+            )
+        try:
+            save_to_hdf(
+                df,
+                fcache,
+                metadata=new_metadata,
+                version=radis.__version__,
+                key="df",
+                overwrite=True,
+                verbose=verbose,
+                engine=engine,
+            )
+        except PermissionError:
+            if verbose:
+                print(
+                    "An error occured in cache file generation. Lookup access rights."
+                )
+            pass
+
+    return df
+
+
+#%%
+def get_last(b):
+    """Get non-empty lines of a chunk b, parsing the bytes."""
+    element_length = np.vectorize(lambda x: len(x.__str__()))(b)
+    non_zero = element_length > element_length[-1]
+    threshold = non_zero.argmin() - 1
+    assert (non_zero[: threshold + 1] == 1).all()
+    assert (non_zero[threshold + 1 :] == 0).all()
+    return b[non_zero]
+
+
+class GEISADatabaseManager(DatabaseManager):
+    def __init__(
+        self,
+        name,
+        molecule,
+        local_databases,
+        engine="default",
+        verbose=True,
+        chunksize=100000,
+        parallel=True,
+    ):
+        super().__init__(
+            name,
+            molecule,
+            local_databases,
+            engine,
+            verbose=verbose,
+            parallel=parallel,
+        )
+        self.chunksize = chunksize
+        self.downloadable = True
+        self.base_url = None
+        self.Nlines = None
+        self.wmin = None
+        self.wmax = None
+        self.urlnames = None
+
+    def fetch_urlnames(self):
+        """requires connexion"""
+
+        if self.urlnames is not None:
+            return self.urlnames
+
+        molecule = self.molecule
+        geisa_url = "https://aeris-geisa.ipsl.fr/geisa_files/2020/Lines/line_GEISA2020_asc_gs08_v1.0_"
+
+        print(f"Molecule: {molecule}")
+
+        if molecule.upper() in GEISA_MOLECULES:
+            url = geisa_url + molecule.lower()
+            urlnames = [url]
+        else:
+            raise KeyError(
+                f"Please choose one of GEISA molecules : {GEISA_MOLECULES}. Got '{molecule}'"
+            )
+
+        self.urlnames = urlnames
+
+        return urlnames
+
+    def parse_to_local_file(
+        self,
+        opener,
+        urlname,
+        local_file,
+        pbar_active=True,
+        pbar_t0=0,
+        pbar_Ntot_estimate_factor=None,
+        pbar_Nlines_already=0,
+        pbar_last=True,
+    ):
+        """Uncompress ``urlname`` into ``local_file``.
+        Also add metadata
+
+        Parameters
+        ----------
+        opener: an opener with an .open() command
+        gfile : file handler. Filename: for info"""
+
+        # molecule = self.molecule
+        # Ntotal_lines_expected = GEISA_MOLECULES_Nlines[molecule.upper()]
+
+        writer = self.get_datafile_manager()
+
+        with opener.open(urlname) as gfile:  # locally downloaded file
+
+            gfile  #  so the linter doesn't annoy us. We're not using this file anyway, just unzipping the cache file directly :
+            df = gei2df(opener.abspath(urlname), drop_non_numeric=False, cache=False)
+
+            writer.write(local_file, df, append=False)
+
+            self.wmin = df.wav.min()
+            self.wmax = df.wav.max()
+
+            Nlines = len(df)
+
+        writer.combine_temp_batch_files(local_file)  # used for vaex mode only
+
+        # Check number of lines is consistent
+        # assert Nlines == Ntotal_lines_expected
+
+        # 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": urlname,
+                "total_lines": Nlines,
+                "version": __version__,
+            },
+        )
+
+        return Nlines
+
+    def register(self):
+        """register in ~/radis.json"""
+
+        local_files, urlnames = self.get_filenames()
+        info = f"GEISA {self.molecule} lines ({self.wmin:.1f}-{self.wmax:.1f} cm-1)"
+
+        dict_entries = {
+            "info": info,
+            "path": local_files,
+            "format": "geisa-radisdb",
+            "parfuncfmt": "hapi",
+            "wavenumber_min": self.wmin,
+            "wavenumber_max": self.wmax,
+            "download_date": self.get_today(),
+            "download_url": urlnames,
+        }
+
+        super().register(dict_entries)
+
+
+#%%
+
+if __name__ == "__main__":
+
+    import pytest
+
+    print("Testing factory:", pytest.main(["../test/io/test_geisa.py"]))
diff --git a/radis/io/hdf5.py b/radis/api/hdf5.py
similarity index 97%
rename from radis/io/hdf5.py
rename to radis/api/hdf5.py
index 10bce3353..d9949a79d 100644
--- a/radis/io/hdf5.py
+++ b/radis/api/hdf5.py
@@ -1,8 +1,6 @@
 # -*- coding: utf-8 -*-
 """
-Created on Tue Jan 26 21:27:15 2021
-
-@author: erwan
+Defines the :py:class:`~radis.api.hdf5.DataFileManager` class
 """
 
 import os
@@ -96,9 +94,9 @@ def __init__(self, engine=None):
         ::
 
             file = 'CO.hdf5'
-            from radis.io.hdf5 import HDF5Manager
-            engine = HDF5Manager.guess_engine(file)
-            mgr = HDF5Manager(engine)
+            from radis.api.hdf5 import DataFileManager
+            engine = DataFileManager.guess_engine(file)
+            mgr = DataFileManager(engine)
             mgr.read_metadata(file)
 
 
@@ -141,7 +139,7 @@ def write(
             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`
+            load certain lines only. See :py:func:`~radis.api.hdf5.hdf2df`
         """
         file = expanduser(file)
         if self.engine == "pytables":
@@ -173,13 +171,15 @@ def write(
 
             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.
+                # self.combine_temp_batch_files() should be called at the end.
+
+                # To start with, get an available temp file name :
                 base, ext = splitext(file)
                 i = 0
-                temp_batch_file = base + "_temp" + str(i).zfill(6) + ext
+                temp_batch_file = base + "_temp" + str(i).zfill(5) + ext
                 while temp_batch_file in self._temp_batch_files:
                     i += 1
-                    temp_batch_file = base + "_temp" + str(i).zfill(6) + ext
+                    temp_batch_file = base + "_temp" + str(i).zfill(5) + ext
                 file = temp_batch_file
                 # Check no remaining one from a non-cleaned previous run:
                 if exists(file):
@@ -318,7 +318,7 @@ def load(
             )
 
         # Load
-        # Convert to output format
+        # Convert from reading format ("engine") to output format ("output")
         engine = self.engine
 
         if engine == output:
@@ -328,7 +328,9 @@ def load(
             if columns:  # load only these columns (if they exist)
                 columns = [c for c in columns if c in df.columns]
             if output == "pandas":
-                df = df.to_pandas_df(column_names=columns)
+                df_pandas = df.to_pandas_df(column_names=columns)
+                df.close()
+                df = df_pandas
             elif output == "jax":
                 if columns == None:
                     columns = list(df.columns)
@@ -347,6 +349,7 @@ def load(
                         out["logsij0"] = jnp.array(np.log(df[c].values))
                     else:
                         out[c] = jnp.array(df[c].values)
+                df.close()
                 df = out
             else:
                 raise NotImplementedError(f"output {output} for engine {engine}")
@@ -601,7 +604,7 @@ def add_metadata(
             if True, create an empty dataset to store the metadata as attribute
 
         """
-        from radis.io.cache_files import _h5_compatible
+        from radis.api.cache_files import _h5_compatible
 
         if self.engine in ["pytables", "pytables-fixed"]:
             fname = expanduser(fname)
diff --git a/radis/api/hitempapi.py b/radis/api/hitempapi.py
new file mode 100644
index 000000000..91f8dd334
--- /dev/null
+++ b/radis/api/hitempapi.py
@@ -0,0 +1,439 @@
+# -*- coding: utf-8 -*-
+"""
+Created on Tue Jan 26 22:40:51 2021
+
+@author: erwan
+
+
+https://stackoverflow.com/questions/55610891/numpy-load-from-io-bytesio-stream
+https://stupidpythonideas.blogspot.com/2014/07/three-ways-to-read-files.html
+
+"""
+
+import re
+import urllib.request
+from os.path import basename, join
+from typing import Union
+
+import numpy as np
+
+try:
+    from .dbmanager import DatabaseManager
+    from .hitranapi import columns_2004, parse_global_quanta, parse_local_quanta
+    from .tools import (
+        _create_dtype,
+        _get_linereturnformat,
+        _ndarray2df,
+        replace_PQR_with_m101,
+    )
+except ImportError:  # ran from here
+    if __name__ == "__main__":  # running from this file, as a script
+        from radis.api.dbmanager import DatabaseManager
+        from radis.api.hitranapi import (
+            columns_2004,
+            parse_global_quanta,
+            parse_local_quanta,
+        )
+        from radis.io.tools import (
+            _create_dtype,
+            _get_linereturnformat,
+            _ndarray2df,
+            replace_PQR_with_m101,
+        )
+    else:
+        raise
+
+from radis.db import MOLECULES_LIST_NONEQUILIBRIUM
+from radis.misc.progress_bar import ProgressBar
+
+HITEMP_MOLECULES = ["H2O", "CO2", "N2O", "CO", "CH4", "NO", "NO2", "OH"]
+
+
+def keep_only_relevant(
+    inputfiles,
+    wavenum_min=None,
+    wavenum_max=None,
+) -> Union[list, float, float]:
+    """Parser file names for ``wavenum_format`` (min and max) and only keep
+    relevant files if the requested range is ``[wavenum_min, wavenum_max]``
+
+    Returns
+    -------
+    relevant: list of relevant files
+    files_wmin, files_wmax: (float, float) : wavenum min & max of relevant range
+    """
+    wavenum_format = r"\d{5}"
+    relevantfiles = []
+    files_wmin = np.inf
+    files_wmax = 0
+    for filepath in inputfiles:
+        file = basename(filepath)
+        fname_wmin, fname_wmax = re.findall(wavenum_format, file)
+        relevant = False
+        if wavenum_min is not None and wavenum_max is not None:
+            if (float(fname_wmax) > wavenum_min) and (float(fname_wmin) < wavenum_max):
+                # strict '>' :  we exclude "CO2-02_02250-02500_HITEMP2010.h5'" if calculating 2500 - 3000 cm-1
+                # strict '<' :  we exclude "CO2-02_03000-03250_HITEMP2010.h5" if calculating 2500 - 3000 cm-1
+                relevant = True
+        elif wavenum_min is not None:
+            if float(fname_wmax) > wavenum_min:
+                # strict '>' :  we exclude "CO2-02_02250-02500_HITEMP2010.h5'" if calculating 2500 - 3000 cm-1
+                relevant = True
+        elif wavenum_max is not None:
+            if float(fname_wmin) < wavenum_max:
+                # strict '<' :  we exclude "CO2-02_03000-03250_HITEMP2010.h5" if calculating 2500 - 3000 cm-1
+                relevant = True
+        else:
+            relevant = True
+        if relevant:
+            relevantfiles.append(filepath)
+            files_wmin = min(float(fname_wmin), files_wmin)
+            files_wmax = max(float(fname_wmax), files_wmax)
+
+    print(f"HITEMP keep only relevant input files: {relevantfiles}")
+
+    return relevantfiles, files_wmin, files_wmax
+
+
+#%%
+def get_last(b):
+    """Get non-empty lines of a chunk b, parsing the bytes."""
+    element_length = np.vectorize(lambda x: len(x.__str__()))(b)
+    non_zero = element_length > element_length[-1]
+    threshold = non_zero.argmin() - 1
+    assert (non_zero[: threshold + 1] == 1).all()
+    assert (non_zero[threshold + 1 :] == 0).all()
+    return b[non_zero]
+
+
+class HITEMPDatabaseManager(DatabaseManager):
+    def __init__(
+        self,
+        name,
+        molecule,
+        local_databases,
+        engine="default",
+        verbose=True,
+        chunksize=100000,
+        parallel=True,
+    ):
+        super().__init__(
+            name,
+            molecule,
+            local_databases,
+            engine,
+            verbose=verbose,
+            parallel=parallel,
+        )
+        self.chunksize = chunksize
+        self.downloadable = True
+        self.base_url = None
+        self.Nlines = None
+        self.wmin = None  # available on HITEMP website. See HITEMPDatabaseManager.fetch_url_Nlines_wmin_wmax
+        self.wmax = None  # available on HITEMP website. See HITEMPDatabaseManager.fetch_url_Nlines_wmin_wmax
+        self.urlnames = None
+
+    def fetch_url_Nlines_wmin_wmax(self, hitemp_url="https://hitran.org/hitemp/"):
+        """requires connexion"""
+
+        molecule = self.molecule
+
+        if (
+            self.base_url is not None
+            and self.Nlines is not None
+            and self.wmin is not None
+            and self.wmax is not None
+        ):
+            return self.base_url, self.Nlines, self.wmin, self.wmax
+
+        else:
+
+            response = urllib.request.urlopen(hitemp_url)
+
+            # Alternative to return a Pandas Dataframe :
+            # ... Doesnt work because missing <tr> in HITEMP website table for N2O
+
+            # soup = BeautifulSoup(
+            #         response, features="lxml"
+            #     )
+            # table = soup.find(lambda tag: tag.name=='table' and tag.has_attr('id') and tag['id']=="hitemp-molecules-table")
+
+            # def tableDataText(table):
+            #     """Parses a html segment started with tag <table> followed
+            #     by multiple <tr> (table rows) and inner <td> (table data) tags.
+            #     It returns a list of rows with inner columns.
+            #     Accepts only one <th> (table header/data) in the first row.
+
+            #     From https://stackoverflow.com/a/58274853/5622825
+            #     """
+
+            #
+
+            #     def rowgetDataText(tr, coltag='td'): # td (data) or th (header)
+            #         return [td.get_text(strip=True) for td in tr.find_all(coltag)]
+            #     rows = []
+            #     trs = table.find_all('tr')
+            #     headerow = rowgetDataText(trs[0], 'th')
+            #     if headerow: # if there is a header row include first
+            #         rows.append(headerow)
+            #         trs = trs[1:]
+            #     for tr in trs: # for every table row
+            #         rows.append(rowgetDataText(tr, 'td') ) # data row
+
+            #     df = pd.DataFrame(rows[1:], columns=rows[0])
+            #     df.index = df.Formula
+
+            #     return df
+
+            # df = tableDataText(table)
+
+            text = response.read().decode()
+            text = text[
+                text.find(
+                    '<table id="hitemp-molecules-table" class="selectable-table list-table">'
+                ) : text.find("</table>")
+            ]
+            text = re.sub(r"<!--.+?-->\s*\n", "", text)  # remove commented lines
+            html_molecule = re.sub(r"(\d{1})", r"(<sub>\1</sub>)", molecule)
+            text = text[
+                re.search(
+                    "<td>(?:<strong>)?" + html_molecule + "(?:</strong>)?</td>", text
+                ).start() :
+            ]
+            lines = text.splitlines()
+
+            Nlines = int(re.findall(r"(\d+)", lines[3].replace("&nbsp;", ""))[0])
+            wmin = int(re.findall(r"(\d+)", lines[4].replace("&nbsp;", ""))[0])
+            wmax = int(re.findall(r"(\d+)", lines[5].replace("&nbsp;", ""))[0])
+            url = "https://hitran.org" + re.findall(r'href="(.+?)"', lines[7])[0]
+
+            self.base_url, self.Nlines, self.wmin, self.wmax = url, Nlines, wmin, wmax
+
+        return url, Nlines, wmin, wmax
+
+    def fetch_urlnames(self):
+        """requires connection"""
+
+        if self.urlnames is not None:
+            return self.urlnames
+
+        molecule = self.molecule
+
+        if molecule in ["H2O", "CO2"]:
+
+            base_url, Ntotal_lines_expected, _, _ = self.fetch_url_Nlines_wmin_wmax()
+            response = urllib.request.urlopen(base_url)
+            response_string = response.read().decode()
+            inputfiles = re.findall('href="(\S+.zip)"', response_string)
+
+            urlnames = [join(base_url, f) for f in inputfiles]
+
+        elif molecule in HITEMP_MOLECULES:
+            url, Ntotal_lines_expected, _, _ = self.fetch_url_Nlines_wmin_wmax()
+            urlnames = [url]
+        else:
+            raise KeyError(
+                f"Please choose one of HITEMP molecules : {HITEMP_MOLECULES}. Got '{molecule}'"
+            )
+
+        self.urlnames = urlnames
+
+        return urlnames
+
+    def keep_only_relevant(
+        self,
+        inputfiles,
+        wavenum_min=None,
+        wavenum_max=None,
+    ) -> list:
+        """For CO2 and H2O, return only relevant files for given wavenumber range.
+
+        If other molecule, return the file anyway.
+        see :py:func:`radis.api.hitempapi.keep_only_relevant`"""
+        if self.molecule in ["CO2", "H2O"]:
+            inputfiles, _, _ = keep_only_relevant(inputfiles, wavenum_min, wavenum_max)
+        return inputfiles
+
+    def get_linereturn_format(self, opener, urlname, columns):
+
+        with opener.open(urlname) as gfile:  # locally downloaded file
+            dt = _create_dtype(
+                columns, "a2"
+            )  # 'a2' allocates space to get \n or \n\r for linereturn character
+            b = np.zeros(1, dtype=dt)
+            try:
+                gfile.readinto(b)
+            except EOFError as err:
+                raise ValueError(
+                    f"End of file while parsing file {opener.abspath(urlname)}. May be due to download error. Delete file ?"
+                ) from err
+            linereturnformat = _get_linereturnformat(b, columns)
+
+        return linereturnformat
+
+    def parse_to_local_file(
+        self,
+        opener,
+        urlname,
+        local_file,
+        pbar_active=True,
+        pbar_t0=0,
+        pbar_Ntot_estimate_factor=None,
+        pbar_Nlines_already=0,
+        pbar_last=True,
+    ):
+        """Uncompress ``urlname`` into ``local_file``.
+        Also add metadata
+
+        Parameters
+        ----------
+        opener: an opener with an .open() command
+        gfile : file handler. Filename: for info"""
+
+        # Get linereturn (depends on OS, but file may also have been generated
+        # on a different OS. Here we simply read the file to find out)
+        columns = columns_2004
+        chunksize = self.chunksize
+        verbose = self.verbose
+        molecule = self.molecule
+
+        if not verbose:
+            pbar_active = False
+
+        linereturnformat = self.get_linereturn_format(opener, urlname, columns)
+
+        Nlines = 0
+        Nlines_raw = 0
+        Nlines_tot = Nlines + pbar_Nlines_already
+        _, Ntotal_lines_expected, _, _ = self.fetch_url_Nlines_wmin_wmax()
+        if pbar_Ntot_estimate_factor:
+            # multiply Ntotal_lines_expected by pbar_Ntot_estimate_factor
+            # (accounts for total lines divided in number of files, and
+            # not all files downloaded)
+            Ntotal_lines_expected = int(
+                Ntotal_lines_expected * pbar_Ntot_estimate_factor
+            )
+        pb = ProgressBar(N=Ntotal_lines_expected, active=pbar_active, t0=pbar_t0)
+        wmin = np.inf
+        wmax = 0
+
+        writer = self.get_datafile_manager()
+
+        with opener.open(urlname) as gfile:  # locally downloaded file
+
+            dt = _create_dtype(columns, linereturnformat)
+
+            if verbose:
+                print(f"Download complete. Parsing {molecule} database to {local_file}")
+
+            # assert not(exists(local_file))
+
+            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.
+        writer.combine_temp_batch_files(local_file)  # used for vaex mode only
+        if pbar_last:
+            pb.update(
+                Nlines_tot,
+                message=f"  Parsed {Nlines_tot:,} / {Nlines_tot:,} lines. Wavenumber range {wmin:.2f}-{wmax:.2f} cm-1 is complete.",
+            )
+            pb.done()
+        else:
+            print("")
+
+        # Check number of lines is consistent
+        assert Nlines == Nlines_raw
+
+        # Add metadata
+        from radis import __version__
+
+        writer.add_metadata(
+            local_file,
+            {
+                "wavenumber_min": wmin,
+                "wavenumber_max": wmax,
+                "download_date": self.get_today(),
+                "download_url": urlname,
+                "total_lines": Nlines_raw,
+                "version": __version__,
+            },
+        )
+
+        return Nlines
+
+    def register(self):
+        """register in ~/radis.json"""
+
+        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"]:
+            info = "(registered files will be downloaded only when required) " + info
+
+        dict_entries = {
+            "info": info,
+            "path": local_files,
+            "format": "hitemp-radisdb",
+            "parfuncfmt": "hapi",
+            "wavenumber_min": self.wmin,
+            "wavenumber_max": self.wmax,
+            "download_date": self.get_today(),
+            "download_url": urlnames,
+        }
+
+        # 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)
+
+
+#%%
+
+if __name__ == "__main__":
+
+    import pytest
+
+    print("Testing factory:", pytest.main(["../test/io/test_hitemp.py"]))
diff --git a/radis/api/hitranapi.py b/radis/api/hitranapi.py
new file mode 100644
index 000000000..708bcbdc4
--- /dev/null
+++ b/radis/api/hitranapi.py
@@ -0,0 +1,1421 @@
+# -*- coding: utf-8 -*-
+"""
+Summary
+-------
+
+HITRAN database parser
+
+
+Routine Listing
+---------------
+
+- :func:`~radis.api.hitranapi.hit2df`
+- :func:`~radis.api.hitranapi.parse_local_quanta`
+- :func:`~radis.api.hitranapi.parse_global_quanta`
+
+
+-------------------------------------------------------------------------------
+
+
+"""
+
+
+import os
+import sys
+
+# from radis.test.utils import getTestFile
+import time
+from collections import OrderedDict
+from os.path import abspath, exists, expanduser, getmtime, join, split
+
+import pandas as pd
+from numpy import int64
+
+from radis.db.classes import (  # get_molecule_identifier,
+    HITRAN_CLASS1,
+    HITRAN_CLASS2,
+    HITRAN_CLASS3,
+    HITRAN_CLASS4,
+    HITRAN_CLASS5,
+    HITRAN_CLASS6,
+    HITRAN_CLASS7,
+    HITRAN_CLASS8,
+    HITRAN_CLASS9,
+    HITRAN_CLASS10,
+    HITRAN_GROUP1,
+    HITRAN_GROUP2,
+    HITRAN_GROUP3,
+    HITRAN_GROUP4,
+    HITRAN_GROUP5,
+    HITRAN_GROUP6,
+    get_molecule,
+)
+
+try:
+    from .cache_files import load_h5_cache_file, save_to_hdf
+    from .dbmanager import DatabaseManager
+    from .hdf5 import DataFileManager
+    from .tools import (
+        drop_object_format_columns,
+        parse_hitran_file,
+        replace_PQR_with_m101,
+    )
+except ImportError:
+    if __name__ == "__main__":  # running from this file, as a script
+        from radis.api.cache_files import load_h5_cache_file, save_to_hdf
+        from radis.api.dbmanager import DatabaseManager
+        from radis.api.hdf5 import DataFileManager
+        from radis.api.tools import (
+            drop_object_format_columns,
+            parse_hitran_file,
+            replace_PQR_with_m101,
+        )
+    else:
+        raise
+
+# %% Parsing functions
+
+# General case : HITRAN 2004
+# fmt: off
+columns_2004 = OrderedDict(
+    [
+        # name    # format # type  # description                                 # unit
+        ("id", ("a2", int, "Molecular number", "")),
+        ("iso", ("a1", int, "isotope number", "")),
+        ("wav", ("a12", float, "vacuum wavenumber", "cm-1")),
+        ("int", ("a10", float, "intensity at 296K", "cm-1/(molecule/cm-2)")),
+        ("A", ("a10", float, "Einstein A coefficient", "s-1")),
+        ("airbrd", ("a5", float, "air-broadened half-width at 296K", "cm-1.atm-1")),
+        ("selbrd", ("a5", float, "self-broadened half-width at 296K", "cm-1.atm-1")),
+        ("El", ("a10", float, "lower-state energy", "cm-1")),
+        ("Tdpair", ("a4", float, "temperature-dependance exponent for Gamma air", "")),
+        ("Pshft", ("a8", float, "air pressure-induced line shift at 296K", "cm-1.atm-1")),
+        ("globu", ("a15", str, "electronic and vibrational global upper quanta", "")),
+        ("globl", ("a15", str, "electronic and vibrational global lower quanta", "")),
+        ("locu", ("a15", str, "electronic and vibrational local upper quanta", "")),
+        ("locl", ("a15", str, "electronic and vibrational local lower quanta", "")),
+        ("ierr", ("a6", str, "ordered list of indices corresponding to uncertainty estimates of transition parameters", "")),
+        ("iref", ("a12", str, "ordered list of reference identifiers for transition parameters", "")),
+        ("lmix", ("a1", str, "flag indicating the presence of additional data and code relating to line-mixing", "")),
+        ("gp", ("a7", float, "upper state degeneracy", "")),
+        ("gpp", ("a7", float, "lower state degeneracy", "")),
+    ]
+)
+""" OrderedDict: parsing order of HITRAN 2004 format """
+# fmt: on
+
+
+PARAMETER_GROUPS_HITRAN = {
+    "par_line": "PARLIST_DOTPAR",
+    "id": "PARLIST_ID",
+    "standard": "PARLIST_STANDARD",
+    "labels": "PARLIST_LABELS",
+    "voigt": "PARLIST_VOIGT_ALL",
+    "ht": "PARLIST_HT_ALL",
+}
+
+
+def cast_to_int64_with_missing_values(dg, keys):
+    """replace missing values of int64 columns with -1"""
+    for c in keys:
+        if dg.dtypes[c] != int64:
+            dg[c].replace(
+                r"^\s+$", -1, regex=True, inplace=True
+            )  # replace empty strings by -1, e.g. HCN
+            # Warning: -1 may be a valid non-equilibirum quantum number for some
+            # molecules, e.g. H2O, see https://github.com/radis/radis/issues/280#issuecomment-896120510
+            dg[c] = dg[c].fillna(-1).astype(int64)  # replace nans with -1
+
+
+def hit2df(
+    fname,
+    cache=True,
+    verbose=True,
+    drop_non_numeric=True,
+    load_wavenum_min=None,
+    load_wavenum_max=None,
+    engine="pytables",
+    parse_quanta=True,
+):
+    """Convert a HITRAN/HITEMP [1]_ file to a Pandas dataframe
+
+    Parameters
+    ----------
+    fname: str
+        HITRAN-HITEMP file name
+    cache: boolean, or ``'regen'`` or ``'force'``
+        if ``True``, a pandas-readable HDF5 file is generated on first access,
+        and later used. This saves on the datatype cast and conversion and
+        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``.
+
+    Other Parameters
+    ----------------
+    drop_non_numeric: boolean
+        if ``True``, non numeric columns are dropped. This improves performances,
+        but make sure all the columns you need are converted to numeric formats
+        before hand. Default ``True``. Note that if a cache file is loaded it
+        will be left untouched.
+    load_wavenum_min, load_wavenum_max: float
+        if not ``'None'``, only load the cached file if it contains data for
+        wavenumbers above/below the specified value. See :py:func`~radis.api.cache_files.load_h5_cache_file`.
+        Default ``'None'``.
+    engine: 'pytables', 'vaex'
+        format for Hdf5 cache file. Default `pytables`
+    parse_quanta: bool
+        if ``True``, parse local & global quanta (required to identify lines
+        for non-LTE calculations ; but sometimes lines are not labelled.)
+
+    Returns
+    -------
+    df: pandas Dataframe
+        dataframe containing all lines and parameters
+
+
+    References
+    ----------
+
+    .. [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.api.cdsdapi.cdsd2df`
+    """
+    metadata = {}
+    # Last modification time of the original file :
+    metadata["last_modification"] = time.ctime(getmtime(fname))
+    if verbose >= 2:
+        print("Opening file {0} (cache={1})".format(fname, cache))
+        print("Last modification time: {0}".format(metadata["last_modification"]))
+    if load_wavenum_min and load_wavenum_max:
+        assert load_wavenum_min < load_wavenum_max
+
+    columns = columns_2004
+
+    # Use cache file if possible
+    fcache = DataFileManager(engine).cache_file(fname)
+    if cache and exists(fcache):
+        relevant_if_metadata_above = (
+            {"wavenum_max": load_wavenum_min} if load_wavenum_min else {}
+        )  # not relevant if wavenum_max of file is < wavenum min required
+        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,
+            valid_if_metadata_is=metadata,
+            relevant_if_metadata_above=relevant_if_metadata_above,
+            relevant_if_metadata_below=relevant_if_metadata_below,
+            current_version=__version__,
+            last_compatible_version=config["OLDEST_COMPATIBLE_VERSION"],
+            verbose=verbose,
+            engine=engine,
+        )
+        if df is not None:
+            return df
+
+    #  %% Start reading the full file
+
+    # Detect the molecule by reading the start of the file
+    try:
+        with open(fname) as f:
+            mol = get_molecule(int(f.read(2)))
+    except UnicodeDecodeError as err:
+        raise ValueError(
+            "You're trying to read a binary file {0} ".format(fname)
+            + "instead of an HITRAN file"
+        ) from err
+
+    df = parse_hitran_file(fname, columns)
+
+    df = post_process_hitran_data(
+        df,
+        molecule=mol,
+        parse_quanta=parse_quanta,
+    )
+    # cached file mode but cached file doesn't exist yet (else we had returned)
+    if cache:
+        new_metadata = {
+            # Last modification time of the original file :
+            "last_modification": time.ctime(getmtime(fname)),
+            "wavenum_min": df.wav.min(),
+            "wavenum_max": df.wav.max(),
+        }
+        if verbose:
+            print(
+                "Generating cache file {0} with metadata :\n{1}".format(
+                    fcache, new_metadata
+                )
+            )
+        from radis import __version__
+
+        try:
+            save_to_hdf(
+                df,
+                fcache,
+                metadata=new_metadata,
+                version=__version__,
+                overwrite=True,
+                verbose=verbose,
+                engine=engine,
+            )
+        except PermissionError:
+            if verbose:
+                print(sys.exc_info())
+                print("An error occured in cache file generation. Lookup access rights")
+            pass
+
+    # TODO : get only wavenum above/below 'load_wavenum_min', 'load_wavenum_max'
+    # by parsing df.wav.   Completely irrelevant files are discarded in 'load_h5_cache_file'
+    # but files that have partly relevant lines are fully loaded.
+    # Note : cache file is generated with the full line list.
+
+    return df
+
+
+def post_process_hitran_data(
+    df,
+    molecule,
+    verbose=True,
+    drop_non_numeric=True,
+    parse_quanta=True,
+):
+    """Parsing non-equilibrum parameters in HITRAN/HITEMP [1]_ file to and return final Pandas Dataframe
+
+    Parameters
+    ----------
+    df: pandas Dataframe
+      dataframe containing generic parameters
+
+    molecule: str
+       molecule name
+
+    Other Parameters
+    ----------------
+    drop_non_numeric: boolean
+        if ``True``, non numeric columns are dropped. This improves performances,
+        but make sure all the columns you need are converted to numeric formats
+        before hand. Default ``True``. Note that if a cache file is loaded it
+        will be left untouched.
+    parse_quanta: bool
+        if ``True``, parse local & global quanta (required to identify lines
+        for non-LTE calculations ; but sometimes lines are not labelled.)
+
+    Returns
+    -------
+    df: pandas Dataframe
+        dataframe containing all lines and parameters
+
+
+    References
+    ----------
+
+    .. [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`
+    """
+
+    # %% Post processing
+
+    # assert one molecule per database only. Else the groupbase data reading
+    # above doesnt make sense
+    nmol = len(df["id"].unique())
+    if nmol == 0:
+        raise ValueError("Databank looks empty")
+    elif nmol != 1:
+        # Crash, give explicity error messages
+        try:
+            secondline = df.iloc[1]
+        except IndexError:
+            secondline = ""
+        raise ValueError(
+            "Multiple molecules in database ({0} : {1}). Current ".format(
+                nmol, [get_molecule(idi) for idi in df["id"].unique()]
+            )
+            + "spectral code only computes 1 species at the time. Use MergeSlabs. "
+            + "Verify the parsing was correct by looking at the first row below: "
+            + "\n{0}".format(df.iloc[0])
+            + "\n----------------\nand the second row "
+            + "below: \n{0}".format(secondline)
+        )
+
+    if parse_quanta:
+        # Add local quanta attributes, based on the HITRAN group
+        try:
+            df = parse_local_quanta(df, molecule, 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).
+            # see https://github.com/radis/radis/issues/211
+            if verbose:
+                print(str(err))
+                print("-" * 10)
+                print(
+                    f"Impossible to parse local quanta in {molecule}, probably an unlabelled line. Ignoring, but nonequilibrium calculations will not be possible. See details above."
+                )
+
+        # Add global quanta attributes, based on the HITRAN class
+        try:
+            df = parse_global_quanta(df, molecule, 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).
+            # see https://github.com/radis/radis/issues/211
+            if verbose:
+                print(str(err))
+                print("-" * 10)
+                print(
+                    f"Impossible to parse global quanta in {molecule}, probably an unlabelled line. Ignoring, but nonequilibrium calculations will not be possible. See details above."
+                )
+
+    # Remove non numerical attributes
+    if drop_non_numeric:
+        if "branch" in df:
+            replace_PQR_with_m101(df)
+        df = drop_object_format_columns(df, verbose=verbose)
+
+    return df
+
+
+# %% Hitran global quanta classes
+
+
+def _parse_HITRAN_class1(df, verbose=True):
+    r"""Diatomic molecules: CO, HF, HCl, HBr, HI, N2, NO+
+
+
+    Parameters
+    ----------
+    df: pandas Dataframe
+        lines read from a HITRAN-like database
+
+
+    Notes
+    -----
+    HITRAN syntax [1]_ :
+
+    >>>       v
+    >>>  13x I2
+
+    References
+    ----------
+
+    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
+
+
+    """
+
+    # 1. Parse
+    dgu = df["globu"].astype(str).str.extract(r"[ ]{13}(?P<vu>[\d ]{2})", expand=True)
+    dgl = df["globl"].astype(str).str.extract(r"[ ]{13}(?P<vl>[\d ]{2})", expand=True)
+
+    # 2. Convert to numeric
+    cast_to_int64_with_missing_values(dgu, ["vu"])
+    cast_to_int64_with_missing_values(dgl, ["vl"])
+
+    # 3. Clean
+    del df["globu"]
+    del df["globl"]
+
+    return pd.concat([df, dgu, dgl], axis=1)
+
+
+def _parse_HITRAN_class2(df, verbose=True):
+    r"""Diatomic molecules with different electronic levels: O2
+
+
+    Parameters
+    ----------
+
+    df: pandas Dataframe
+        lines read from a HITRAN-like database
+
+
+    Notes
+    -----
+
+    HITRAN syntax [1]_ :
+
+    >>>      X   v
+    >>>  12x A1 I2
+
+    References
+    ----------
+
+    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
+
+    """
+    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, verbose=True):
+    r"""Diatomic molecules with doublet-Pi electronic state: NO, OH, ClO
+
+
+    Parameters
+    ----------
+
+    df: pandas Dataframe
+        lines read from a HITRAN-like database
+
+
+    Notes
+    -------
+
+    HITRAN syntax [1]_:
+
+    >>>      X i     v1
+    >>>  7x A1 A3 2x I2
+
+    References
+    ----------
+
+    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
+
+    """
+    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, verbose=True):
+    r"""Parse linear triatomic class in HITRAN [1]_: N2O, OCS, HCN
+
+    Parameters
+    ----------
+
+    df: pandas Dataframe
+        lines read from a HITRAN-like database
+
+    Notes
+    -----
+
+    HITRAN syntax:
+
+    >>>     v1 v2 l2 v3
+    >>>  7x I2 I2 I2 I2
+
+    Note: I2 in regexp: [\d ]{2}
+
+    References
+    ----------
+
+    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
+
+    """
+
+    # 1. Parse
+    dgu = (
+        df["globu"]
+        .astype(str)
+        .str.extract(
+            r"[ ]{7}(?P<v1u>[\d ]{2})(?P<v2u>[\d ]{2})(?P<l2u>[\d ]{2})(?P<v3u>[\d ]{2})",
+            expand=True,
+        )
+    )
+    dgl = (
+        df["globl"]
+        .astype(str)
+        .str.extract(
+            r"[ ]{7}(?P<v1l>[\d ]{2})(?P<v2l>[\d ]{2})(?P<l2l>[\d ]{2})(?P<v3l>[\d ]{2})",
+            expand=True,
+        )
+    )
+
+    # 2. Convert to numeric
+    cast_to_int64_with_missing_values(dgu, ["v1u", "v2u", "l2u", "v3u"])
+    cast_to_int64_with_missing_values(dgl, ["v1l", "v2l", "l2l", "v3l"])
+
+    # 3. Clean
+    del df["globu"]
+    del df["globl"]
+
+    return pd.concat([df, dgu, dgl], axis=1)
+
+
+def _parse_HITRAN_class5(df, verbose=True):
+    r"""Parse linear triatomic with large Fermi resonance in HITRAN [1]_: CO2
+
+    Parameters
+    ----------
+
+    df: pandas Dataframe
+        lines read from a HITRAN-like database
+
+    Notes
+    -----
+
+    HITRAN syntax:
+
+    >>>     v1 v2 l2 v3 r
+    >>>  6x I2 I2 I2 I2 I1
+
+    Note: I2 in regexp: [\d ]{2}
+
+    References
+    ----------
+
+    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
+
+    """
+
+    # 1. Parse
+    dgu = (
+        df["globu"]
+        .astype(str)
+        .str.extract(
+            r"[ ]{6}(?P<v1u>[\d ]{2})(?P<v2u>[\d ]{2})(?P<l2u>[\d ]{2})(?P<v3u>[\d ]{2})(?P<ru>\d)",
+            expand=True,
+        )
+    )
+    dgl = (
+        df["globl"]
+        .astype(str)
+        .str.extract(
+            r"[ ]{6}(?P<v1l>[\d ]{2})(?P<v2l>[\d ]{2})(?P<l2l>[\d ]{2})(?P<v3l>[\d ]{2})(?P<rl>\d)",
+            expand=True,
+        )
+    )
+
+    # 2. Convert to numeric
+    cast_to_int64_with_missing_values(dgu, ["v1u", "v2u", "l2u", "v3u", "ru"])
+    cast_to_int64_with_missing_values(dgl, ["v1l", "v2l", "l2l", "v3l", "rl"])
+
+    # 3. Clean
+    del df["globu"]
+    del df["globl"]
+
+    return pd.concat([df, dgu, dgl], axis=1)
+
+
+def _parse_HITRAN_class6(df, verbose=True):
+    r"""Parse non-linear triatomic in HITRAN [1]_: H2O, O3, SO2, NO2, HOCl, H2S, HO2, HOBr
+
+    Parameters
+    ----------
+
+    df: pandas Dataframe
+        lines read from a HITRAN-like database
+
+    Notes
+    -----
+
+    HITRAN syntax:
+
+    >>>     v1 v2 v3
+    >>>  9x I2 I2 I2
+
+    Note: I2 in regexp: [\d ]{2}
+
+    References
+    ----------
+
+    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
+
+    """
+
+    # 1. Parse
+    dgu = (
+        df["globu"]
+        .astype(str)
+        .str.extract(
+            #        '[ ]{9}(?P<v1u>[\d ]{2})(?P<v2u>[\d ]{2})(?P<v3u>[\d ]{2})',
+            r"[ ]{9}(?P<v1u>[\-\d ]{2})(?P<v2u>[\-\d ]{2})(?P<v3u>[\-\d ]{2})",
+            expand=True,
+        )
+    )
+    dgl = (
+        df["globl"]
+        .astype(str)
+        .str.extract(
+            #        '[ ]{9}(?P<v1l>[\d ]{2})(?P<v2l>[\d ]{2})(?P<v3l>[\d ]{2})',
+            r"[ ]{9}(?P<v1l>[\-\d ]{2})(?P<v2l>[\-\d ]{2})(?P<v3l>[\-\d ]{2})",
+            expand=True,
+        )
+    )
+    # ... note @EP: in HITRAN H2O files, for iso=2, vibrational levels are
+    # ... somehow negative. The regex above is adapted to catch negation signs with \-
+
+    # 2. Convert to numeric
+    cast_to_int64_with_missing_values(
+        dgu,
+        [
+            "v1u",
+            "v2u",
+            "v3u",
+        ],
+    )
+    cast_to_int64_with_missing_values(dgl, ["v1l", "v2l", "v3l"])
+
+    # 3. Clean
+    del df["globu"]
+    del df["globl"]
+
+    return pd.concat([df, dgu, dgl], axis=1)
+
+
+def _parse_HITRAN_class7(df, verbose=True):
+    r"""Parse linear tetratomic in HITRAN [1]_: C2H2
+
+    Parameters
+    ----------
+
+    df: pandas Dataframe
+        lines read from a HITRAN-like database
+
+    Notes
+    -----
+
+    HITRAN syntax:
+
+    >>>
+
+    References
+    ----------
+
+    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
+
+    """
+    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, verbose=True):
+    r"""Pyramidal tetratomic in HITRAN [1]_: NH3, PH3
+
+
+    Parameters
+    ----------
+
+    df: pandas Dataframe
+        lines read from a HITRAN-like database
+
+
+    Notes
+    -----
+
+    HITRAN syntax:
+
+    >>>
+
+    References
+    ----------
+
+    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
+
+    """
+    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, verbose=True):
+    r"""Non-linear tetratomic in HITRAN [1]_: H2CO, H2O2, COF2
+
+
+    Parameters
+    ----------
+
+    df: pandas Dataframe
+        lines read from a HITRAN-like database
+
+
+    Notes
+    -----
+
+    HITRAN syntax:
+
+    >>>
+
+    References
+    ----------
+
+    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
+
+    """
+    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, verbose=True):
+    r"""Pentatomic or greater polyatomic in HITRAN [1]_
+
+
+    Parameters
+    ----------
+
+    df: pandas Dataframe
+        lines read from a HITRAN-like database
+
+
+    Notes
+    -----
+
+    HITRAN syntax:
+
+    >>>
+
+    References
+    ----------
+
+    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
+
+    """
+    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, 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
+
+    Parameters
+    ----------
+
+    df: pandas Dataframe
+        lines read from a HITRAN-like database
+
+
+    Notes
+    -----
+
+    HITRAN syntax: [1]_
+
+
+    References
+    ----------
+
+    .. [1] `Table 4 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
+
+
+    """
+
+    # 1. Parse
+
+    # Ref [1] : locu
+    # --------------
+    # J'  | Ka' | Kc' | F'  | Sym'
+    # I3  | I3  | I3  | A5  | A1
+    dgu = (
+        df["locu"]
+        .astype(str)
+        .str.extract(
+            r"(?P<ju>[\d ]{3})(?P<Kau>[\-\d ]{3})(?P<Kcu>[\-\d ]{3})(?P<Fu>.{5})(?P<symu>.)",
+            expand=True,
+        )
+    )
+    # Ref [1] : locl
+    # --------------
+    # J'' | Ka''| Kc''| F'' | Sym''
+    # I3  | I3  | I3  | A5  | A1
+    dgl = (
+        df["locl"]
+        .astype(str)
+        .str.extract(
+            r"(?P<jl>[\d ]{3})(?P<Kal>[\-\d ]{3})(?P<Kcl>[\-\d ]{3})(?P<Fl>.{5})(?P<syml>.)",
+            expand=True,
+        )
+    )
+    # ... note @EP: in HITRAN H2O files, for iso=2, the Kau, Kcu can somehow
+    # ... be negative. The regex above is adapted to catch negation signs with \-
+
+    # 2. Convert to numeric
+    cast_to_int64_with_missing_values(dgu, ["ju", "Kau", "Kcu"])
+    cast_to_int64_with_missing_values(dgl, ["jl", "Kal", "Kcl"])
+
+    # 3. Clean
+    del df["locu"]
+    del df["locl"]
+
+    return pd.concat([df, dgu, dgl], axis=1)
+
+
+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+
+
+    Parameters
+    ----------
+
+    df: pandas Dataframe
+        lines read from a HITRAN-like database
+
+
+    Notes
+    -----
+
+    HITRAN syntax: [1]
+
+
+    References
+    ----------
+
+    .. [1] `Table 4 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
+
+
+    """
+
+    # 1. Parse
+
+    # Ref [1] : locu
+    # --------------
+    #     | F'  |
+    # 10X | A5  |
+    dgu = df["locu"].astype(str).str.extract(r"[ ]{10}(?P<Fu>.{5})", expand=True)
+    # Ref [1] : locl
+    # --------------
+    #     | Br  | J'' | Sym''| F'' |
+    # 5X  | A1  | I3  | A1   | A5  |
+    dgl = (
+        df["locl"]
+        .astype(str)
+        .str.extract(
+            r"[ ]{5}(?P<branch>[\S]{1})(?P<jl>[\d ]{3})(?P<syml>.)(?P<Fl>.{5})",
+            expand=True,
+        )
+    )
+
+    # 2. Convert to numeric
+
+    # dgl['jl'] = dgl.jl.apply(pd.to_numeric)
+    cast_to_int64_with_missing_values(dgl, ["jl"])
+
+    # 3. Clean
+    del df["locu"]
+    del df["locl"]
+
+    return pd.concat([df, dgu, dgl], axis=1)
+
+
+#                # 10X included in Fu
+#               'Fu',     ('a15',  str,  'upper state total angular momentum including nuclear spin'  ,''         )),(
+#               #'locl',   ('a15',  str,   'electronic and vibrational local lower quanta'  ,''                      )),(
+#               'branch', ('a6',   str,     'O, P, Q, R, S branch symbol'                  ,''                      )),(
+#               'jl',     ('a3',   int,    'lower state rotational quantum number'         ,''                      )),(
+#               'sym',    ('a1',   str,     'symmetry'                                     ,''                      )),(
+#               'Fl',     ('a5',   str,   'lower state total angular momentum including nuclear spin', ''         )),(
+
+
+def _parse_HITRAN_group3(df, verbose=True):
+    r"""Parse Spherical rotors (:py:attr:`~radis.db.classes.HITRAN_GROUP3` ) :
+    SF6, CH4
+
+    Parameters
+    ----------
+
+    df: pandas Dataframe
+        lines read from a HITRAN-like database
+
+
+    Notes
+    -----
+
+    HITRAN syntax [1]_:
+
+
+    References
+    ----------
+
+    .. [1] `Table 4 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
+
+
+    """
+    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, verbose=True):
+    r"""Parse symmetric rotors (:py:attr:`~radis.db.classes.HITRAN_GROUP4` ):
+    CH3D, CH3Cl, C2H6, NH3, PH3, CH3OH
+
+    Parameters
+    ----------
+
+    df: pandas Dataframe
+        lines read from a HITRAN-like database
+
+
+    Notes
+    -----
+
+    HITRAN syntax [1]_:
+
+
+    References
+    ----------
+
+    .. [1] `Table 4 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
+
+
+    """
+    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, verbose=True):
+    r"""Parse Triplet-Sigma ground electronic states (:py:attr:`~radis.db.classes.HITRAN_GROUP5` ) :
+    O2
+
+    Parameters
+    ----------
+
+    df: pandas Dataframe
+        lines read from a HITRAN-like database
+
+
+    Notes
+    -----
+
+    HITRAN syntax [1]_:
+
+
+    References
+    ----------
+
+    .. [1] `Table 4 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
+
+
+    """
+    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, verbose=True):
+    r"""Parse Doublet-Pi ground electronic states (:py:attr:`~radis.db.classes.HITRAN_GROUP6` ) :
+    NO, OH, ClO
+
+    Parameters
+    ----------
+
+    df: pandas Dataframe
+        lines read from a HITRAN-like database
+
+
+    Notes
+    -----
+
+    HITRAN syntax [1]_:
+
+
+    References
+    ----------
+
+    .. [1] `Table 4 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
+
+
+    """
+    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, verbose=True):
+    r"""
+    Parameters
+    ----------
+
+    df: pandas Dataframe
+
+    mol: str
+        molecule name
+    """
+
+    if mol in HITRAN_GROUP1:
+        df = _parse_HITRAN_group1(df, verbose=verbose)
+    elif mol in HITRAN_GROUP2:
+        df = _parse_HITRAN_group2(df, verbose=verbose)
+    elif mol in HITRAN_GROUP3:
+        df = _parse_HITRAN_group3(df, verbose=verbose)
+    elif mol in HITRAN_GROUP4:
+        df = _parse_HITRAN_group4(df, verbose=verbose)
+    elif mol in HITRAN_GROUP5:
+        df = _parse_HITRAN_group5(df, verbose=verbose)
+    elif mol in HITRAN_GROUP6:
+        df = _parse_HITRAN_group6(df, verbose=verbose)
+    else:
+        raise ValueError(
+            "Unknown group for molecule {0}. Cant parse local quanta".format(mol)
+        )
+
+    return df
+
+
+def parse_global_quanta(df, mol, verbose=True):
+    r"""
+
+    Parameters
+    ----------
+
+    df: pandas Dataframe
+
+    mol: str
+        molecule name
+    """
+
+    if mol in HITRAN_CLASS1:
+        df = _parse_HITRAN_class1(df, verbose=verbose)
+    elif mol in HITRAN_CLASS2:
+        df = _parse_HITRAN_class2(df, verbose=verbose)
+    elif mol in HITRAN_CLASS3:
+        df = _parse_HITRAN_class3(df, verbose=verbose)
+    elif mol in HITRAN_CLASS4:
+        df = _parse_HITRAN_class4(df, verbose=verbose)
+    elif mol in HITRAN_CLASS5:
+        df = _parse_HITRAN_class5(df, verbose=verbose)
+    elif mol in HITRAN_CLASS6:
+        df = _parse_HITRAN_class6(df, verbose=verbose)
+    elif mol in HITRAN_CLASS7:
+        df = _parse_HITRAN_class7(df, verbose=verbose)
+    elif mol in HITRAN_CLASS8:
+        df = _parse_HITRAN_class8(df, verbose=verbose)
+    elif mol in HITRAN_CLASS9:
+        df = _parse_HITRAN_class9(df, verbose=verbose)
+    elif mol in HITRAN_CLASS10:
+        df = _parse_HITRAN_class10(df, verbose=verbose)
+    else:
+        raise ValueError(
+            "Unknown class for molecule {0}. Cant parse global quanta".format(mol)
+        )
+
+    return df
+
+
+#%%
+
+
+class HITRANDatabaseManager(DatabaseManager):
+    def __init__(
+        self,
+        name,
+        molecule,
+        local_databases,
+        engine="default",
+        extra_params=None,
+        verbose=True,
+        parallel=True,
+    ):
+        super().__init__(
+            name,
+            molecule,
+            local_databases,
+            engine=engine,
+            extra_params=extra_params,
+            verbose=verbose,
+            parallel=parallel,
+        )
+        self.downloadable = True
+        self.base_url = None
+        self.Nlines = None
+        self.wmin = None
+        self.wmax = None
+
+    def get_filenames(self):
+        if self.engine == "vaex":
+            return [join(self.local_databases, f"{self.molecule}.hdf5")]
+        elif self.engine == "pytables":
+            return [join(self.local_databases, f"{self.molecule}.h5")]
+        else:
+            raise NotImplementedError()
+
+    def download_and_parse(self, local_file, cache=True, parse_quanta=True):
+        """Download from HITRAN and parse into ``local_file``.
+        Also add metadata
+
+        Overwrites :py:meth:`radis.api.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 LOCAL_TABLE_CACHE, db_begin, fetch
+
+        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, extra_params):
+            """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
+            .. Isotope Missing:
+                When an isotope is missing for a particular molecule then a key error `(molecule_id, isotope_id)
+                is raised.
+
+            """
+            directory = abspath(expanduser(directory))
+
+            # create temp folder :
+            from radis.misc.basics import make_folders
+
+            make_folders(*split(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:
+                    if extra_params == "all":
+                        fetch(
+                            file,
+                            get_molecule_identifier(molecule),
+                            iso,
+                            wmin,
+                            wmax,
+                            ParameterGroups=[*PARAMETER_GROUPS_HITRAN],
+                        )
+                    elif extra_params is None:
+                        fetch(file, get_molecule_identifier(molecule), iso, wmin, wmax)
+                    else:
+                        raise ValueError("extra_params can only be 'all' or None ")
+                except KeyError as err:
+                    list_pattern = ["(", ",", ")"]
+                    import re
+
+                    if (
+                        set(list_pattern).issubset(set(str(err)))
+                        and len(re.findall("\d", str(err))) >= 2
+                        and get_molecule_identifier(molecule)
+                        == int(
+                            re.findall(r"[\w']+", str(err))[0]
+                        )  # The regex are cryptic
+                    ):
+                        # Isotope not defined, go to next isotope
+                        continue
+                    else:
+                        raise KeyError("Error: {0}".format(str(err)))
+                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
+
+        # create database in a subfolder to isolate molecules from one-another
+        # (HAPI doesn't check and may mix molecules --> see failure at https://app.travis-ci.com/github/radis/radis/jobs/548126303#L2676)
+        tempdir = join(self.tempdir, molecule)
+        extra_params = self.extra_params
+
+        # 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, tempdir, extra_params
+        )
+
+        writer = self.get_datafile_manager()
+
+        # Create HDF5 cache file for all isotopes
+        Nlines = 0
+        for iso, data_file in zip(isotope_list, data_file_list):
+            df = pd.DataFrame(LOCAL_TABLE_CACHE[data_file.split(".")[0]]["data"])
+            df.rename(
+                columns={
+                    "molec_id": "id",
+                    "local_iso_id": "iso",
+                    "nu": "wav",
+                    "sw": "int",
+                    "a": "A",
+                    "gamma_air": "airbrd",
+                    "gamma_self": "selbrd",
+                    "elower": "El",
+                    "n_air": "Tdpair",
+                    "delta_air": "Pshft",
+                    "global_upper_quanta": "globu",
+                    "global_lower_quanta": "globl",
+                    "local_upper_quanta": "locu",
+                    "local_lower_quanta": "locl",
+                    "gp": "gp",
+                    "gpp": "gpp",
+                },
+                inplace=True,
+            )
+            df = post_process_hitran_data(
+                df,
+                molecule=molecule,
+                parse_quanta=parse_quanta,
+            )
+
+            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, sort_values="wav"
+        )  # used for vaex mode only
+        # Note: by construction, in Pytables mode the database is not sorted
+        # by 'wav' but by isotope
+
+        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()
+
+        if self.wmin is None or self.wmax is None:
+            print(
+                "Somehow wmin and wmax was not given for this database. Reading from the files"
+            )
+            ##  fix:
+            # (can happen if database was downloaded & parsed, but registration failed a first time)
+            df_full = self.load(
+                local_files,
+                columns=["wav"],
+                within=[],
+                lower_bound=[],
+                upper_bound=[],
+            )
+            self.wmin = df_full.wav.min()
+            self.wmax = df_full.wav.max()
+            print(
+                f"Somehow wmin and wmax was not given for this database. Read {self.wmin}, {self.wmax} directly from the files"
+            )
+
+        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)
+
+
+# ======================================================
+# %% Test
+
+
+if __name__ == "__main__":
+
+    from radis.test.io.test_hitran_cdsd import _run_testcases
+
+    print("Testing HITRAN parsing: ", _run_testcases())
+    from radis.test.io.test_query import _run_testcases
+
+    print("Testing HITRAN fetch: ", _run_testcases())
diff --git a/radis/io/tools.py b/radis/api/tools.py
similarity index 98%
rename from radis/io/tools.py
rename to radis/api/tools.py
index 92c7d3a11..2b5f966b1 100644
--- a/radis/io/tools.py
+++ b/radis/api/tools.py
@@ -32,7 +32,7 @@ def parse_hitran_file(fname, columns, count=-1):
 
     See Also
     --------
-    Used in :py:func:`~radis.io.hitran.hit2df` and :py:func:`~radis.io.cdsd.cdsd2df`
+    Used in :py:func:`~radis.api.hitranapi.hit2df` and :py:func:`~radis.api.cdsdapi.cdsd2df`
     """
 
     # To be faster, we read file totally in bytes mode with fromfiles. But that
diff --git a/radis/db/classes.py b/radis/db/classes.py
index 05a90a0c9..e4b02c73f 100644
--- a/radis/db/classes.py
+++ b/radis/db/classes.py
@@ -300,6 +300,7 @@ def get_molecule(molecule_id):
 assert EXOMOL_ONLY_ISOTOPES_NAMES[("SiO", 1)] == "28Si-16O"
 assert EXOMOL_ONLY_ISOTOPES_NAMES[("CN", 1)] == "12C-14N"
 
+
 EXOMOL_MOLECULES = [
     "AlCl",
     "AlF",
@@ -328,6 +329,7 @@ def get_molecule(molecule_id):
     "FeH",
     "H2",
     "H2CO",
+    "H2CS",
     "H2O",
     "H2O2",
     "H2S",
@@ -342,6 +344,7 @@ def get_molecule(molecule_id):
     "KCl",
     "KF",
     "KOH",
+    "LaO",
     "LiCl",
     "LiF",
     "LiH",
diff --git a/radis/io/__init__.py b/radis/io/__init__.py
index 41886aa58..4f1c6c150 100644
--- a/radis/io/__init__.py
+++ b/radis/io/__init__.py
@@ -5,17 +5,13 @@
 
 """
 
-
-from .cdsd import cdsd2df
 from .exomol import fetch_exomol
 from .geisa import fetch_geisa
 from .hitemp import fetch_hitemp
-from .hitran import fetch_hitran, hit2df
+from .hitran import fetch_hitran
 from .query import fetch_astroquery
 
 __all__ = [
-    "cdsd2df",
-    "hit2df",
     "fetch_hitemp",
     "fetch_hitran",
     "fetch_exomol",
diff --git a/radis/io/exomol.py b/radis/io/exomol.py
index 29b865476..a78cd0ff7 100644
--- a/radis/io/exomol.py
+++ b/radis/io/exomol.py
@@ -1,253 +1,20 @@
-"""Molecular database (MDB) class
-
-   * MdbExomol is the MDB for ExoMol
-
-Initial code borrowed from the `Exojax <https://github.com/HajimeKawahara/exojax>`__
-code (which you should also have a look at !), by @HajimeKawahara, under MIT License.
+# -*- coding: utf-8 -*-
+"""
+Created on Sun May 22 18:11:23 2022
 
+@author: erwan
 """
-import os
+
 import pathlib
-import warnings
 
 import numpy as np
 
-try:
-    from . import exomolapi
-    from .dbmanager import DatabaseManager
-    from .exomol_utils import e2s
-except ImportError:  # if local import
-    from radis.io import exomolapi
-    from radis.io.dbmanager import DatabaseManager
-    from radis.io.exomol_utils import e2s
-
-from radis.db.classes import (
-    EXOMOL_MOLECULES,
-    EXOMOL_ONLY_ISOTOPES_NAMES,
-    get_molecule_identifier,
+from radis.api.exomolapi import (
+    MdbExomol,
+    get_exomol_database_list,
+    get_exomol_full_isotope_name,
 )
-
-EXOMOL_URL = "http://www.exomol.com/db/"
-
-from urllib.request import HTTPError, urlopen
-
-from bs4 import BeautifulSoup
-
-
-def get_exomol_full_isotope_name(molecule, isotope):
-    """Get full isotope name for ``molecule`` and ``isotope`` number
-
-    Parameters
-    ----------
-    molecule: str
-    isotope: int
-        terrestrial abundance
-
-    Examples
-    --------
-    ::
-
-        get_exomol_full_isotope_name("CH4", 1)
-        >>> '12C-1H4'
-
-    See Also
-    --------
-    :py:func:`~radis.io.exomol.get_exomol_database_list`"""
-
-    if molecule not in EXOMOL_MOLECULES:
-        raise ValueError(
-            f"Molecule {molecule} not available in ExoMol. Change database, or choose one of ExoMol available molecules: {sorted(EXOMOL_MOLECULES)}"
-        )
-
-    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
-
-        mp = MolParams()
-        return mp.get(molecule, isotope, "isotope_name_exomol")
-
-
-def get_list_of_known_isotopes(molecule):
-    """find all isotopes until error ensues"""
-
-    i = 1
-    isotope_list = []
-    while True:
-        try:
-            iso_name = get_exomol_full_isotope_name(molecule, i)
-        except:
-            break
-        else:
-            isotope_list.append(iso_name)
-        finally:
-            i += 1
-    return isotope_list
-
-
-def get_exomol_database_list(molecule, isotope_full_name=None):
-    """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
-    -------
-    list of databases, database recommended by ExoMol
-
-    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`
-    """
-
-    if isotope_full_name is None:
-        raise ValueError(
-            f"Give isotope name. List of known isotopes for {molecule} : {get_list_of_known_isotopes(molecule)}"
-        )
-
-    url = f"https://exomol.com/data/molecules/{molecule}/{isotope_full_name}"
-    try:
-        response = urlopen(url).read()
-    except HTTPError as err:
-        if isotope_full_name not in get_list_of_known_isotopes(molecule):
-            extra = f". Isotope name {isotope_full_name} is not in list of known isotopes : {get_list_of_known_isotopes(molecule)}"
-        else:
-            extra = ""
-        raise ValueError(f"HTTPError opening url={url}" + extra) 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
-
-    if len(databases_recommended) > 0:
-        recommended_database = databases_recommended[0]
-    else:
-        recommended_database = False
-
-    return databases, recommended_database
-
-
-# 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]
+from radis.db.classes import get_molecule_identifier
 
 
 def fetch_exomol(
@@ -278,7 +45,7 @@ def fetch_exomol(
         ExoMol molecule
     database: ``str``
         database name. Ex:: ``POKAZATEL`` or ``BT2`` for ``H2O``. See
-        :py:data:`~radis.io.exomol.KNOWN_EXOMOL_DATABASE_NAMES`. If ``None`` and
+        :py:data:`~radis.api.exomolapi.KNOWN_EXOMOL_DATABASE_NAMES`. If ``None`` and
         there is only one database available, use it.
     local_databases: ``str``
         where to create the RADIS HDF5 files. Default ``"~/.radisdb/exomol"``.
@@ -369,7 +136,7 @@ def fetch_exomol(
     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`
+    :py:func:`~radis.api.hdf5.hdf2df`
 
     References
     ----------
@@ -380,7 +147,7 @@ def fetch_exomol(
     See Also
     --------
     :py:func:`~radis.io.hitran.fetch_hitran`, :py:func:`~radis.io.hitemp.fetch_hitemp`
-    :py:func:`~radis.io.hdf5.hdf2df`
+    :py:func:`~radis.api.hdf5.hdf2df`
 
     """
     # TODO: implement columns= ... to load only specific columns.
@@ -550,495 +317,3 @@ def fetch_exomol(
         out.append(mdb.to_partition_function_tabulator())
 
     return out
-
-
-class MdbExomol(DatabaseManager):
-
-    """molecular database of ExoMol
-
-    MdbExomol is a class for ExoMol.
-
-    Parameters
-    ----------
-    path: str
-        path for Exomol data directory/tag. For instance, "/home/CO/12C-16O/Li2015"
-    nurange: array
-        wavenumber range list (cm-1) or wavenumber array
-    margin: float
-        margin for nurange (cm-1)
-    crit: float
-        line strength lower limit for extraction
-    bkgdatm: str
-        background atmosphere for broadening. e.g. H2, He,
-    broadf: bool
-        if False, the default broadening parameters in .def file is used
-
-    Other Parameters
-    ----------------
-    engine : str
-        which memory mapping engine to use : 'vaex', 'pytables' (HDF5), 'feather'
-    skip_optional_data : bool
-        If False, fetch all fields which are marked as available in the ExoMol definition
-        file. If True, load only the first 4 columns of the states file
-        ("i", "E", "g", "J"). The structure of the columns above 5 depend on the
-        the definitions file (*.def) and the Exomol version.
-        If ``skip_optional_data=False``, two errors may occur:
-
-            - a field is marked as present/absent in the *.def field but is
-              absent/present in the *.states file (ie both files are inconsistent).
-            - in the updated version of Exomol, new fields have been added in the
-              states file of some species. But it has not been done for all species,
-              so both structures exist. For instance, the states file of
-              https://exomol.com/data/molecules/HCl/1H-35Cl/HITRAN-HCl/ follows the
-              structure described in [1]_, unlike the states file of
-              https://exomol.com/data/molecules/NO/14N-16O/XABC/ which follows the
-              structure described in [2]_.
-
-    Notes
-    -----
-
-    The trans/states files can be very large. For the first time to read it,
-    we convert it to the feather or hdf5-format. After the second-time,
-    we use the feather/hdf5 format instead.
-
-    Examples
-    --------
-    ::
-
-        # Init database, download files if needed.
-        mdb = MdbExomol(
-            local_path,
-            molecule=molecule,
-            name=databank_name,
-            local_databases=local_databases,
-            # nurange=[load_wavenum_min, load_wavenum_max],
-            engine="vaex",
-        )
-
-        # Get cache files to load :
-        mgr = mdb.get_datafile_manager()
-        local_files = [mgr.cache_file(f) for f in mdb.trans_file]
-
-        # Load files
-        df = mdb.load(
-            local_files,
-            columns=columns_exomol,
-            lower_bound=([('nu_lines', load_wavenum_min)] if load_wavenum_min else []) + ([("Sij0", mdb.crit)] if not np.isneginf(mdb.crit) else []),
-            upper_bound=([('nu_lines', load_wavenum_max)] if load_wavenum_max else []),
-            output="jax", # or "pytables", "vaex"
-        )
-
-    .. minigallery:: radis.fetch_exomol
-
-
-    DataFrame columns
-    -----------------
-
-    nu_lines (nd array): line center (cm-1)
-    Sij0 (nd array): line strength at T=Tref (cm)
-    dev_nu_lines (np array): line center in device (cm-1)
-    logsij0 (np array): log line strength at T=Tref
-    A (np array): Einstein A coefficient
-    elower (np array): the lower state energy (cm-1)
-    gpp (np array): statistical weight
-    jlower (np array): J_lower
-    jupper (np array): J_upper
-    n_Tref (np array): temperature exponent
-    alpha_ref (np array): alpha_ref (gamma0)
-    n_Tref_def: default temperature exponent in .def file, used for jlower not given in .broad
-    alpha_ref_def: default alpha_ref (gamma0) in .def file, used for jlower not given in .broad
-
-    References
-    ----------
-
-    .. [1] Tennyson, J., Yurchenko, S. N., Al-Refaie, A. F., Barton, E. J., Chubb, K. L., Coles, P. A., … Zak, E. (2016). The ExoMol database: molecular line lists for exoplanet and other hot atmospheres. https://doi.org/10.1016/j.jms.2016.05.002
-    .. [2] Tennyson, J., Yurchenko, S. N., Al-Refaie, A. F., Clark, V. H. J., Chubb, K. L., Conway, E. K., … Yurchenko, O. P. (2020). The 2020 release of the ExoMol database: Molecular line lists for exoplanet and other hot atmospheres. Journal of Quantitative Spectroscopy and Radiative Transfer, 255, 107228. https://doi.org/10.1016/j.jqsrt.2020.107228
-
-    """
-
-    # 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,
-        molecule,
-        database=None,
-        local_databases=None,
-        name="EXOMOL-{molecule}",
-        nurange=[0.0, np.inf],
-        margin=1.0,
-        crit=-np.inf,
-        bkgdatm="Air",  # TODO: use Air whenever possible, to be consistent with HITRAN/HITEMP
-        broadf=True,
-        engine="vaex",
-        verbose=True,
-        cache=True,
-        skip_optional_data=True,
-    ):
-        super().__init__(
-            name,
-            molecule,
-            local_databases,
-            engine,
-            verbose=verbose,
-        )
-
-        assert cache  # cache only used for cache='regen' or cache='force' modes, cache=False is not expected
-
-        if engine == "default":
-            from radis import config
-
-            engine = config["MEMORY_MAPPING_ENGINE"]
-            if engine == "auto":
-                engine = "vaex"
-
-        self.path = pathlib.Path(path)
-
-        if local_databases is not None:
-            self.path = pathlib.Path(local_databases).expanduser() / self.path
-
-        t0 = self.path.parents[0].stem
-        molec = t0 + "__" + str(self.path.stem)
-        self.bkgdatm = bkgdatm
-        print("Background atmosphere: ", self.bkgdatm)
-        molecbroad = t0 + "__" + self.bkgdatm
-
-        self.crit = crit
-        self.margin = margin
-        self.nurange = [np.min(nurange), np.max(nurange)]
-        self.wmin, self.wmax = self.nurange
-        self.broadf = broadf
-        # Where exomol files are
-        self.states_file = self.path / pathlib.Path(molec + ".states.bz2")
-        self.pf_file = self.path / pathlib.Path(molec + ".pf")
-        self.def_file = self.path / pathlib.Path(molec + ".def")
-        self.broad_file = self.path / pathlib.Path(molecbroad + ".broad")
-
-        if not self.def_file.exists():
-            self.download(molec, extension=[".def"])
-        if not self.pf_file.exists():
-            self.download(molec, extension=[".pf"])
-        if not self.states_file.exists():
-            self.download(molec, extension=[".states.bz2"])
-        if not self.broad_file.exists():
-            self.download(molec, extension=[".broad"])
-
-        # Add molecule name
-        tag = molec.split("__")
-        self.isotope_fullname = tag[0]
-        self.molecule = e2s(tag[0])
-        # self.isotope = 1  # Placeholder. TODO : impement parsing of other isotopes.
-
-        # load def
-        dic_def = exomolapi.read_def(self.def_file)  # approx. 3 ms
-        self.n_Texp_def = dic_def["n_Texp"]
-        self.alpha_ref_def = dic_def["alpha_ref"]
-
-        #  default n_Texp value if not given
-        if self.n_Texp_def is None:
-            self.n_Texp_def = 0.5
-            warnings.warn(
-                Warning(
-                    f"""
-                    No default broadening exponent in def file. Assigned n = {self.n_Texp_def}
-                    """
-                )
-            )
-        #  default alpha_ref value if not given
-        if self.alpha_ref_def is None:
-            self.alpha_ref_def = 0.07
-            warnings.warn(
-                Warning(
-                    f"""
-                    No default broadening in def file. Assigned alpha_ref = {self.alpha_ref_def}
-                    """
-                )
-            )
-
-        # load states
-        mgr = self.get_datafile_manager()
-        if cache == "regen" and mgr.cache_file(self.states_file).exists():
-            if verbose:
-                print("Removing existing file ", mgr.cache_file(self.states_file))
-            os.remove(mgr.cache_file(self.states_file))
-        if mgr.cache_file(self.states_file).exists():
-            states = mgr.read(mgr.cache_file(self.states_file))
-        else:
-            if cache == "force":
-                raise ValueError(
-                    f"Cache file {str(mgr.cache_file(self.states_file))} does not exist"
-                )
-            print(
-                f"Note: Caching states data to the {engine} format. After the second time, it will become much faster."
-            )
-            states = exomolapi.read_states(
-                self.states_file,
-                dic_def,
-                engine="vaex" if engine == "vaex" else "csv",
-                skip_optional_data=skip_optional_data,
-            )
-            mgr.write(mgr.cache_file(self.states_file), states)
-
-        # load pf
-        pf = exomolapi.read_pf(self.pf_file)
-        self.gQT = pf["QT"].to_numpy()  # grid QT
-        self.T_gQT = pf["T"].to_numpy()  # T forgrid QT
-
-        # trans file(s)
-        print("Reading transition file")
-
-        # Compute linestrengths or retrieve them from cache
-        self.Tref = 296.0
-        self.QTref = np.array(self.QT_interp(self.Tref))
-
-        # Download files
-
-        # Generate list of files
-        # ---------------------
-        # Case1 : transitions are stored in a single file
-        if dic_def["numinf"] is None:
-            self.trans_file = [self.path / pathlib.Path(molec + ".trans.bz2")]
-            self.num_tag = [None]
-
-        # Case2 : Transitions are stored in multiple files:
-        else:  # dic_def["numinf"] is not None
-            # dic_def["numinf"] contains the limit points ``[w(0), w(1), ..., w(n)]``
-            # (n+1 elements) defining the spectral ranges appearing in the list
-            # dic_def["numtag"] which looks like ``["w(0)-w(1)", "w(1)-w(2)", ..., w(n-1)-w(n)]``
-            # (n elements)
-            # imin :
-            # index i of the "w(i)-w(i+1)" element in dic_def["numtag"] such
-            # that nurange[0]<=w(i)
-            imin = np.searchsorted(dic_def["numinf"], nurange[0], side="right") - 1
-            # imax :
-            # index i of the "w(i)-w(i+1)" element in dic_def["numtag"] such
-            # that w(i+1)<=nurange[1]
-            imax = np.searchsorted(dic_def["numinf"], nurange[1], side="right") - 2
-            self.trans_file = []
-            self.num_tag = []
-            for k, i in enumerate(range(imin, imax + 1)):
-                trans_file = self.path / pathlib.Path(
-                    molec + "__" + dic_def["numtag"][i] + ".trans.bz2"
-                )
-                self.trans_file.append(trans_file)
-                self.num_tag.append(dic_def["numtag"][i])
-
-        # Look-up missing parameters and write file
-        # -----------------------------------------
-        for trans_file, num_tag in zip(self.trans_file, self.num_tag):
-
-            if cache == "regen" and mgr.cache_file(trans_file).exists():
-                if verbose:
-                    print("Removing existing file ", mgr.cache_file(trans_file))
-                os.remove(mgr.cache_file(trans_file))
-
-            if not mgr.cache_file(trans_file).exists():
-
-                if cache == "force":
-                    raise ValueError(
-                        f"Cache file {str(mgr.cache_file(trans_file))} does not exist"
-                    )
-
-                if not trans_file.exists():
-                    self.download(molec, extension=[".trans.bz2"], numtag=num_tag)
-                    # TODO: add option to delete file at the end
-
-                print(
-                    f"Note: Caching line transition data to the {engine} format. After the second time, it will become much faster."
-                )
-                trans = exomolapi.read_trans(
-                    trans_file, engine="vaex" if engine == "vaex" else "csv"
-                )
-
-                # Complete transition data with lookup on upper & lower state :
-                # In particular, compute gup and elower
-
-                exomolapi.pickup_gE(
-                    states,
-                    trans,
-                    trans_file,
-                    dic_def,
-                    skip_optional_data=skip_optional_data,
-                )
-
-                ##Recompute Line strength:
-                from radis.lbl.base import (  # TODO: move elsewhere
-                    linestrength_from_Einstein,
-                )
-
-                self.Sij0 = linestrength_from_Einstein(
-                    A=trans["A"],
-                    gu=trans["gup"],
-                    El=trans["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=trans["nu_lines"],
-                    Q=self.QTref,
-                    T=self.Tref,
-                )
-
-                trans["Sij0"] = self.Sij0
-
-                mgr.write(mgr.cache_file(trans_file), trans)
-
-        # Database ready to be loaded.
-        # Proceed with mdb.load()
-
-    def set_broadening(self, df, alpha_ref_def=None, n_Texp_def=None, output=None):
-        """setting broadening parameters
-
-        Parameters
-        ----------
-        alpha_ref: set default alpha_ref and apply it. None=use self.alpha_ref_def
-        n_Texp_def: set default n_Texp and apply it. None=use self.n_Texp_def
-
-        Returns
-        -------
-        None. Updates ``df`` with ``alpha_ref``, ``n_Texp_def``
-        """
-        if alpha_ref_def:
-            self.alpha_ref_def = alpha_ref_def
-        if n_Texp_def:
-            self.n_Texp_def = n_Texp_def
-
-        if self.broadf:
-            try:
-                print(".broad is used.")
-                bdat = exomolapi.read_broad(self.broad_file)
-                codelv = exomolapi.check_bdat(bdat)
-                print("Broadening code level=", codelv)
-                if codelv == "a0":
-                    j2alpha_ref, j2n_Texp = exomolapi.make_j2b(
-                        bdat,
-                        alpha_ref_default=self.alpha_ref_def,
-                        n_Texp_default=self.n_Texp_def,
-                        jlower_max=np.max(df["jlower"]),
-                    )
-                    self.alpha_ref = np.array(j2alpha_ref[df["jlower"]])
-                    self.n_Texp = np.array(j2n_Texp[df["jlower"]])
-                elif codelv == "a1":
-                    j2alpha_ref, j2n_Texp = exomolapi.make_j2b(
-                        bdat,
-                        alpha_ref_default=self.alpha_ref_def,
-                        n_Texp_default=self.n_Texp_def,
-                        jlower_max=np.max(df["jlower"]),
-                    )
-                    jj2alpha_ref, jj2n_Texp = exomolapi.make_jj2b(
-                        bdat,
-                        j2alpha_ref_def=j2alpha_ref,
-                        j2n_Texp_def=j2n_Texp,
-                        jupper_max=np.max(df["jupper"]),
-                    )
-                    self.alpha_ref = np.array(jj2alpha_ref[df["jlower"], df["jupper"]])
-                    self.n_Texp = np.array(jj2n_Texp[df["jlower"], df["jupper"]])
-            except FileNotFoundError:
-                print(
-                    "Warning: Cannot load .broad. The default broadening parameters are used."
-                )
-                self.alpha_ref = np.array(
-                    self.alpha_ref_def * np.ones_like(df["jlower"])
-                )
-                self.n_Texp = np.array(self.n_Texp_def * np.ones_like(df["jlower"]))
-
-        else:
-            print("The default broadening parameters are used.")
-            self.alpha_ref = np.array(self.alpha_ref_def * np.ones_like(df["jlower"]))
-            self.n_Texp = np.array(self.n_Texp_def * np.ones_like(df["jlower"]))
-
-        # Add values
-        self.add_column(df, "alpha_ref", self.alpha_ref)
-        self.add_column(df, "n_Texp", self.n_Texp)
-
-    def QT_interp(self, T):
-        """interpolated partition function
-
-        Parameters
-        ----------
-        T: temperature
-
-        Returns
-        -------
-        Q(T) interpolated in jnp.array
-
-        """
-        return np.interp(T, self.T_gQT, self.gQT)
-
-    def qr_interp(self, T):
-        """interpolated partition function ratio
-
-        Parameters
-        ----------
-        T: temperature
-
-        Returns
-        -------
-        qr(T)=Q(T)/Q(Tref) interpolated in jnp.array
-
-        """
-        return self.QT_interp(T) / self.QT_interp(self.Tref)
-
-    def download(self, molec, extension, numtag=None):
-        """Downloading Exomol files
-
-        Parameters
-        ----------
-        molec: like "12C-16O__Li2015"
-        extension: extension list e.g. [".pf",".def",".trans.bz2",".states.bz2",".broad"]
-        numtag: number tag of transition file if exists. e.g. "11100-11200"
-
-        Notes
-        -----
-        The download URL is written in exojax.utils.url.
-
-        """
-        import os
-        import urllib.request
-
-        tag = molec.split("__")
-        molname_simple = e2s(tag[0])
-
-        for ext in extension:
-            if ext == ".trans.bz2" and numtag is not None:
-                ext = "__" + numtag + ext
-
-            if ext == ".broad":
-                pfname_arr = [
-                    tag[0] + "__H2" + ext,
-                    tag[0] + "__He" + ext,
-                    tag[0] + "__air" + ext,
-                ]
-                url = EXOMOL_URL + molname_simple + "/" + tag[0] + "/"
-            else:
-                pfname_arr = [molec + ext]
-                url = EXOMOL_URL + molname_simple + "/" + tag[0] + "/" + tag[1] + "/"
-
-            for pfname in pfname_arr:
-                pfpath = url + pfname
-                os.makedirs(str(self.path), exist_ok=True)
-                print("Downloading " + pfpath)
-                try:
-                    urllib.request.urlretrieve(pfpath, str(self.path / pfname))
-                except HTTPError:
-                    print(f"Error: Couldn't download {ext} file at {pfpath} and save.")
-
-    def to_partition_function_tabulator(self):
-        """Generate a :py:class:`~radis.levels.partfunc.PartFuncExoMol` object"""
-        from radis.levels.partfunc import PartFuncExoMol
-
-        return PartFuncExoMol(self.isotope_fullname, self.T_gQT, self.gQT)
-
-
-if __name__ == "__main__":
-
-    from radis.test.io.test_exomol import (
-        test_calc_exomol_spectrum,
-        test_calc_exomol_vs_hitemp,
-        test_exomol_parsing_functions,
-    )
-
-    test_exomol_parsing_functions()
-    test_calc_exomol_spectrum()
-    test_calc_exomol_vs_hitemp()
diff --git a/radis/io/exomol_utils.py b/radis/io/exomol_utils.py
deleted file mode 100644
index dd6c50461..000000000
--- a/radis/io/exomol_utils.py
+++ /dev/null
@@ -1,68 +0,0 @@
-"""Utils to read ExoMol
-
-Borrowed from the `Exojax <https://github.com/HajimeKawahara/exojax>`__
-code (which you should also have a look at !), by @HajimeKawahara, under MIT License.
-
-"""
-
-import re
-
-
-def e2s(molname_exact):
-    """convert the exact molname (used in ExoMol) to the simple molname
-
-    Args:
-       molname_exact: the exact molname
-
-    Returns:
-       simple molname
-
-    Examples::
-
-       >>> print(e2s("12C-1H4"))
-       >>> CH4
-       >>> print(e2s("23Na-16O-1H"))
-       >>> NaOH
-       >>> print(e2s("HeH_p"))
-       >>> HeH_p
-       >>> print(e2s("trans-31P2-1H-2H")) #not working
-       >>> Warning: Exact molname  trans-31P2-1H-2H cannot be converted to simple molname
-       >>> trans-31P2-1H-2H
-
-    """
-
-    try:
-        t = molname_exact.split("-")
-        molname_simple = ""
-        for ele in t:
-            alp = "".join(re.findall(r"\D", ele))
-            num0 = re.split("[A-Z]", ele)[1]
-            if num0.isdigit():
-                num = num0
-            else:
-                num = ""
-            molname_simple = molname_simple + alp + num
-        return molname_simple
-    except:
-        print(
-            "Warning: Exact molname ",
-            molname_exact,
-            "cannot be converted to simple molname",
-        )
-        return molname_exact
-
-
-if __name__ == "__main__":
-    print(e2s("12C-1H4"))
-    print(e2s("23Na-16O-1H"))
-    print(e2s("HeH_p"))
-    print(e2s("trans-31P2-1H-2H"))  # not working
-
-    # TODO : move to unitary tests of exomol_utils
-    assert e2s("12C-1H4") == "CH4"
-    assert e2s("23Na-16O-1H") == "NaOH"
-    assert e2s("HeH_p") == "HeH_p"
-    assert e2s("trans-31P2-1H-2H") == "trans-31P2-1H-2H"  # convert not working
-
-    assert e2s("12C-16O2") == "CO2"
-    assert e2s("13C-16O2") == "CO2"
diff --git a/radis/io/exomolapi.py b/radis/io/exomolapi.py
deleted file mode 100644
index 5456324b4..000000000
--- a/radis/io/exomolapi.py
+++ /dev/null
@@ -1,629 +0,0 @@
-"""API for Exomol molecular database
-
-Borrowed from the `Exojax <https://github.com/HajimeKawahara/exojax>`__
-code (which you should also have a look at !), by @HajimeKawahara, under MIT License.
-
-"""
-import bz2
-import re
-
-import numpy as np
-import pandas as pd
-
-try:
-    from .hdf5 import vaexsafe_colname
-except:
-    from radis.io.hdf5 import vaexsafe_colname
-
-from radis.misc.warning import InconsistentDatabaseError
-
-
-def read_def(deff):
-    """Exomol IO for a definition file
-
-    Parameters
-    ----------
-    deff: definition file
-
-    Returns
-    -------
-    n_Texp : float
-        temperature exponent
-    alpha_ref : float
-        broadening parameter
-    molmass : float
-        molecular mass
-    numinf : List[float]
-        limit points (``[w(0), w(1), ..., w(n)]``, n+1 elements) defining
-        the spectral ranges appearing in the name of *.trans.bz2 files
-        (``["w(0)-w(1)", "w(1)-w(2)", ..., w(n-1)-w(n)]``, n elements)
-    numtag : List[str]
-        tag for wavelength ranges.
-
-    Note:
-       For some molecules, ExoMol provides multiple trans files. numinf and numtag are the ranges and identifiers for the multiple trans files.
-
-    """
-
-    dat = pd.read_csv(deff, sep="#", names=("VAL", "COMMENT"))
-    alpha_ref = None
-    # texp = None
-    n_Texp = None
-    ntransf = 1
-    maxnu = 0.0
-    quantum_labels = []
-    for i, com in enumerate(dat["COMMENT"]):
-        if "Default value of Lorentzian half-width" in com:
-            alpha_ref = float(dat["VAL"][i])
-        elif "Default value of temperature exponent" in com:
-            n_Texp = float(dat["VAL"][i])
-        elif "No. of transition files" in com:
-            ntransf = int(dat["VAL"][i])
-        elif "Maximum wavenumber (in cm-1)" in com:
-            maxnu = float(dat["VAL"][i])
-            # maxnu=20000.0
-        elif "Isotopologue mass (Da) and (kg)" in com:
-            molmass = float(dat["VAL"][i].split()[0])  # in Da (atomic unit)
-
-        elif "Lifetime availability" in com:
-            lifetime = int(dat["VAL"][i]) == 1
-        elif "Lande g-factor availability" in com:
-            lande = int(dat["VAL"][i]) == 1
-        elif "Quantum label" in com:
-            quantum_labels.append(dat["VAL"][i].strip(" "))
-
-    # SOME DEF FILES CONTAINS ERRORS. THESE ARE THE EXCEPTIONS
-    if deff.stem == "12C-16O2__UCL-4000":
-        ntransf = 20
-    if deff.stem == "14N-1H3__CoYuTe":
-        maxnu = 20000.0
-    if deff.stem == "12C2-1H2__aCeTY":
-        if molmass == 12.0:
-            molmass = 26.0
-            print(
-                f"Known error in ExoMol def file, molmass corrected from 12.0 to {molmass}"
-            )
-        if quantum_labels == [
-            "totalSym",
-            "v1",
-            "v2",
-            "v3",
-            "v4",
-            "v5",
-            "v5",
-            "v7",
-            "vibSym",
-            "K",
-            "rotSym",
-        ]:
-            quantum_labels = [
-                "totalSym",
-                "v1",
-                "v2",
-                "v3",
-                "v4",
-                "v5",
-                "v6",
-                "v7",
-                "vibSym",
-                "K",
-                "rotSym",
-            ]
-            print(
-                f"Known error in ExoMol def file, quantum_labels corrected from '['totalSym', 'v1', 'v2', 'v3', 'v4', 'v5', 'v5', 'v7', 'vibSym', 'K', 'rotSym']' to {quantum_labels}"
-            )
-
-    if ntransf > 1:
-        dnufile = maxnu / ntransf
-        numinf = dnufile * np.array(range(ntransf + 1))
-        numtag = []
-        for i in range(len(numinf) - 1):
-            imin = "{:05}".format(int(numinf[i]))
-            imax = "{:05}".format(int(numinf[i + 1]))
-            numtag.append(imin + "-" + imax)
-    else:
-        numinf = None
-        numtag = ""
-
-    output = {
-        "n_Texp": n_Texp,
-        "alpha_ref": alpha_ref,
-        "molmass": molmass,
-        "numinf": numinf,
-        "numtag": numtag,
-        "quantum_labels": quantum_labels,  # array
-        "Landé": lande,  # bool
-        "lifetime": lifetime,  # bool
-    }
-    return output
-
-
-def read_pf(pff):
-    """Exomol IO for partition file
-
-    Note:
-        T=temperature
-        QT=partition function
-
-    Args:
-        pff: partition file
-    Returns:
-        partition data in pandas DataFrame
-
-    """
-    dat = pd.read_csv(pff, sep=r"\s+", names=("T", "QT"))
-    return dat
-
-
-def read_trans(transf, engine="vaex"):
-    """Exomol IO for a transition file
-
-    Notes
-    -----
-
-    Transf format ::
-
-        i_upper=Upper state counting number
-        i_lower=Lower state counting number
-        A=Einstein coefficient in s-1
-        nu_lines=transition wavenumber in cm-1
-
-    See Table 12 in https://arxiv.org/pdf/1603.05890.pdf [Exomol-2016]_
-
-    Parameters
-    ----------
-    transf: transition file
-    engine: parsing engine to use ('vaex', 'csv')
-
-    Returns
-    -------
-    transition data in vaex/pandas DataFrame
-
-    """
-    if engine == "vaex":
-        import vaex
-
-        try:  # bz2 compression
-            dat = vaex.from_csv(
-                transf,
-                compression="bz2",
-                sep=r"\s+",
-                names=("i_upper", "i_lower", "A", "nu_lines"),
-                convert=False,  #  file is created by MdbMol
-            )
-        except:
-            try:
-                dat = vaex.read_csv(
-                    transf,
-                    sep=r"\s+",
-                    names=("i_upper", "i_lower", "A", "nu_lines"),
-                    convert=False,  #  file is created by MdbMol
-                )
-            except Exception as err:
-                raise Exception(
-                    f"Error reading {transf}. Maybe the file was corrupted during download ? You can try to delete it."
-                ) from err
-    elif engine == "csv":
-        try:  # bz2 compression
-            dat = pd.read_csv(
-                transf,
-                compression="bz2",
-                sep=r"\s+",
-                names=("i_upper", "i_lower", "A", "nu_lines"),
-            )
-        except:
-            try:
-                dat = pd.read_csv(
-                    transf, sep=r"\s+", names=("i_upper", "i_lower", "A", "nu_lines")
-                )
-            except Exception as err:
-                raise Exception(
-                    f"Error reading {transf}. Maybe the file was corrupted during download ? You can try to delete it."
-                ) from err
-    else:
-        raise NotImplementedError(engine)
-
-    return dat
-
-
-def read_states(statesf, dic_def, engine="vaex", skip_optional_data=True):
-    """Exomol IO for a state file
-
-    Notes
-    -----
-
-    States f format ::
-
-        i=state counting number
-        E=state energy
-        g=state degeneracy
-        J=total angular momentum
-
-    See Table 11 in https://arxiv.org/pdf/1603.05890.pdf
-
-    Parameters
-    ----------
-    statesf: str
-        state file
-    dic_def: dict
-        Info from def file to read extra quantum numbers
-    engine: str
-        parsing engine to use ('vaex', 'csv')
-    skip_optional_data: bool
-        If False, fetch all fields which are marked as available in the ExoMol definition
-        file. If True, load only the first 4 columns of the states file
-        ("i", "E", "g", "J"). The structure of the columns above 5 depend on the
-        the definitions file (*.def) and the Exomol version.
-        If ``skip_optional_data=False``, two errors may occur:
-
-            - a field is marked as present/absent in the *.def field but is
-              absent/present in the *.states file (ie both files are inconsistent).
-            - in the updated version of Exomol, new fields have been added in the
-              states file of some species. But it has not been done for all species,
-              so both structures exist. For instance, the states file of
-              https://exomol.com/data/molecules/HCl/1H-35Cl/HITRAN-HCl/ follows the
-              structure described in [1]_, unlike the states file of
-              https://exomol.com/data/molecules/NO/14N-16O/XABC/ which follows the
-              structure described in [2]_.
-
-    Returns
-    -------
-    states data in pandas DataFrame
-
-    If ``'vaex'``, also writes a local hdf5 file `statesf.with_suffix('.hdf5')`
-
-
-    References
-    ----------
-
-    .. [1] Tennyson, J., Yurchenko, S. N., Al-Refaie, A. F., Barton, E. J., Chubb, K. L., Coles, P. A., … Zak, E. (2016). The ExoMol database: molecular line lists for exoplanet and other hot atmospheres. https://doi.org/10.1016/j.jms.2016.05.002
-    .. [2] Tennyson, J., Yurchenko, S. N., Al-Refaie, A. F., Clark, V. H. J., Chubb, K. L., Conway, E. K., … Yurchenko, O. P. (2020). The 2020 release of the ExoMol database: Molecular line lists for exoplanet and other hot atmospheres. Journal of Quantitative Spectroscopy and Radiative Transfer, 255, 107228. https://doi.org/10.1016/j.jqsrt.2020.107228
-
-    """
-    # we read first 4 columns for ("i", "E", "g", "J"),
-    # skip lifetime, skip Landé g-factor,
-    # read quantum numbers
-    N = np.size(dic_def["quantum_labels"])
-    quantum_labels = dic_def["quantum_labels"]
-
-    mandatory_usecol = np.arange(4)
-    mandatory_fields = ("i", "E", "g", "J")
-    if skip_optional_data:
-        usecol = mandatory_usecol
-        names = mandatory_fields
-    else:
-        if dic_def["Landé"] and dic_def["lifetime"]:
-            usecol = np.concatenate((mandatory_usecol, 5 + 2 + np.arange(N)))
-            names = mandatory_fields + ("Lande", "lifetime") + tuple(quantum_labels)
-        elif dic_def["Landé"]:
-            usecol = np.concatenate((mandatory_usecol, 5 + 1 + np.arange(N)))
-            names = mandatory_fields + ("Lande",) + tuple(quantum_labels)
-        elif dic_def["lifetime"]:
-            usecol = np.concatenate((mandatory_usecol, 5 + 1 + np.arange(N)))
-            names = mandatory_fields + ("lifetime",) + tuple(quantum_labels)
-        else:  # no lifetime, nor landé according to the def file
-            usecol = np.concatenate((mandatory_usecol, 5 + np.arange(N)))
-            names = mandatory_fields + tuple(quantum_labels)
-        # The definitions file (*.def) specifies which fields are available
-        # in the states fiel (*.states). Check the number of available fields
-        # (see *.def) match the numbers of columns in the states file (*.states).
-        # Otherwise, both files are inconsistent.
-        with bz2.open(statesf, "rb") as f:
-            firstline = f.readline().decode("utf-8")  # read 1dt line
-            splitline = [x for x in re.split(r"\s+", firstline) if x != ""]
-            # splitline = re.split(r"\s+", firstline)
-            f.close()
-        if len(splitline) != len(names):
-            raise InconsistentDatabaseError(
-                "The EXOMOL definitions and states files are inconsistent.\n"
-                + "Some data are specified as available by the *.def file, but are absent in the *.bz2 file.\n"
-                + "Set `skip_optional_data=False` in `fetch_exomol()` to load only the required data for a LTE computation.\n"
-                + "The problematic optional data/columns will be ignored."
-            )
-
-    if engine == "vaex":
-        import vaex
-
-        # TODO Refactor: move in DataFileManager
-        try:
-            dat = vaex.from_csv(
-                statesf,
-                compression="bz2",
-                sep=r"\s+",
-                usecols=usecol,
-                names=names,
-                convert=False,  # written in MolDB
-            )
-        except:
-            try:
-                dat = vaex.read_csv(
-                    statesf,
-                    sep=r"\s+",
-                    usecols=usecol,
-                    names=names,
-                    convert=False,  # written in MolDB
-                )
-
-            except Exception as err:
-                raise Exception(
-                    f"Error reading {statesf}. Maybe the file was corrupted during download ? You can try to delete it."
-                ) from err
-    elif engine == "csv":
-        try:
-            dat = pd.read_csv(
-                statesf, compression="bz2", sep=r"\s+", usecols=usecol, names=names
-            )
-        except:  #!!!TODO What was the expected error?
-            dat = pd.read_csv(statesf, sep=r"\s+", usecols=usecol, names=names)
-    else:
-        raise NotImplementedError(engine)
-
-    return dat
-
-
-def pickup_gE(
-    states, trans, trans_file, dic_def, trans_lines=False, skip_optional_data=True
-):
-    """extract g_upper (gup), E_lower (elower), and J_lower and J_upper from states
-    DataFrame and insert them into the transition DataFrame.
-
-    Parameters
-    ----------
-    states: states DataFrame  - the i, E, g, J are in the 4 first columns
-    trans: 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
-    add_quantum_labels: bool . If True fetch all quantum labels in dic_def['quantum_labels'] from states into transitions
-
-    Returns
-    -------
-    A, nu_lines, elower, gup, jlower, jupper, mask, **quantum_labels
-
-    Notes
-    -----
-    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.
-
-    States by default has columns ::
-
-        #       i      E             g    J    v
-        0       1      0.0           1    0    0
-        1       2      1.448467      3    1    0
-        2       3      4.345384      5    2    0
-        3       4      8.690712      7    3    0
-
-
-    """
-    ### Step 1. Essential quantum number for spectra
-    # ----------------------------------------------
-
-    def map_add(col, new_col, trans_key, states_key="i"):
-        """Lookup `key` in states and add it in trans, using the level ``i``
-        for upper and lower state
-
-        Examples
-        --------
-        ::
-
-            map_add("E", "E_lower", "i_lower")
-        """
-        try:  # pytable
-            trans[new_col] = trans[trans_key].map(dict(states[col]))
-        except:  # a priori, vaex version  (TODO : replace with dict() approach in vaex too)
-            col = vaexsafe_colname(col)
-            new_col = vaexsafe_colname(new_col)
-
-            # WIP. First implementation with join(). Use Map() will probably be faster.
-            trans.join(
-                states[states_key, col],
-                left_on=trans_key,
-                right_on=states_key,
-                inplace=True,
-            )
-            trans.drop(states_key, inplace=True)
-            trans.rename(col, new_col)
-
-    map_add("g", "gup", "i_upper")
-    map_add("J", "jlower", "i_lower")
-    map_add("J", "jupper", "i_upper")
-
-    map_add("E", "elower", "i_lower")
-
-    def has_nan(column):
-        try:  # Vaex
-            return column.countnan() > 0
-        except AttributeError:  # Pandas
-            return column.hasnans
-
-    if not "nu_lines" in trans or has_nan(trans["nu_lines"]):
-        map_add("E", "eupper", "i_upper")
-        trans["nu_lines"] = trans["eupper"] - trans["elower"]
-
-    ### Step 2. Extra quantum numbers (e/f parity, vib and rot numbers)
-    # -----------------------------------------------------------------
-    if not skip_optional_data:
-        for q in dic_def["quantum_labels"]:
-            map_add(q, f"{q}_l", "i_lower")
-            # map_add(q, f"{q}_u", "i_upper")
-
-    return trans
-
-
-# def pickup_gEslow(states, trans):
-#     """Slow version to extract g_upper (gup) and E_lower (elower) from states DataFrame and insert them to transition DataFrame.
-
-#     Note:
-#        This code is the (thousands times) slower version of pickup_gE. However, we did not remove this one just because this version is much easier to understand.
-
-#     Args:
-#        states: states pandas DataFrame
-#        trans: transition pandas DataFrame
-
-#     Returns:
-#        A, nu_lines, elower, gup
-
-#     """
-#     import tqdm
-
-#     E = states["E"].values
-#     g = states["g"].values
-
-#     # insert new columns in transition array
-#     trans["gup"] = 0
-#     trans["elower"] = 0.0
-
-#     for k, i in tqdm.tqdm(enumerate(states["i"])):
-#         # transition upper state
-#         mask_upper = trans["i_upper"] == i
-#         trans["gup"][mask_upper] = g[k]
-#         # transition lower state
-#         mask_lower = trans["i_lower"] == i
-#         trans["elower"][mask_lower] = E[k]
-
-#     A = trans["A"].to_numpy()
-#     nu_lines = trans["nu_lines"].to_numpy()
-#     elower = trans["elower"].to_numpy()
-#     gup = trans["gup"].to_numpy()
-#     return A, nu_lines, elower, gup
-
-
-def read_broad(broadf):
-    """Reading braodening file (.broad)
-    Parameters
-    ----------
-    broadf: .broad file
-
-    Returns
-    -------
-    broadening info in bdat form (pandas), defined by this instance.
-
-    Notes
-    -----
-    See Table 16 in https://arxiv.org/pdf/1603.05890.pdf
-    """
-    bdat = pd.read_csv(
-        broadf,
-        sep=r"\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 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)
-
-    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",
-        )
-
-    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
-    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[jlower_arr, jupper_arr] = alpha_ref_arr
-    jj2n_Texp[jlower_arr, jupper_arr] = n_Texp_arr
-
-    return jj2alpha_ref, jj2n_Texp
-
-
-if __name__ == "__main__":
-    pass
diff --git a/radis/io/geisa.py b/radis/io/geisa.py
index f8049be24..c6fe2f70e 100644
--- a/radis/io/geisa.py
+++ b/radis/io/geisa.py
@@ -1,5 +1,6 @@
 # -*- coding: utf-8 -*-
 """
+
 Summary
 -----------------------
 
@@ -7,448 +8,14 @@
 
 -----------------------
 
+Defines :py:func:`~radis.io.fetch_geisa` based on :py:class:`~radis.api.geisaapi.GEISADatabaseManager`
 
-"""
-
-
-# import re
-import time
-from collections import OrderedDict
-from os.path import abspath, exists, expanduser, getmtime, join
-
-import numpy as np
-
-import radis
-
-try:
-    from .cache_files import load_h5_cache_file, save_to_hdf
-    from .dbmanager import DatabaseManager
-    from .hdf5 import DataFileManager
-    from .tools import drop_object_format_columns, parse_hitran_file
-except ImportError:
-    from radis.io.cache_files import load_h5_cache_file, save_to_hdf
-    from radis.io.dbmanager import DatabaseManager
-    from radis.io.hdf5 import DataFileManager
-    from radis.io.tools import drop_object_format_columns, parse_hitran_file
-# from typing import Union
-
-
-# %% Parsing functions
-
-# General case : GEISA 2020
-# Provided by Thibault Delahaye
-
-# BE ADVISED #1: the columns "int" and "ierrB" should be in format FLOAT instead of str as below.
-# However, because of Fortran's double-precision floating-point values used in these 2 columns,
-# which numpy cannot typecast to float. Thus, we set them as string here and will typecast them
-# to float again later in geisa_utils.
-
-# BE ADVISED #2: the columns "idH" for HITRAN molecular number and "isoH" for HITRAN isotope number
-# have been changed to "id" and "iso" respectively. Meanwhile, original columns "id" and "iso" have
-# also been changed to "idG" and "isoG" to avoid duplication. This is to make sure the parsing process
-# and spectrum calculation using HAPI partition function can operate normally.
-
-# fmt: off
-columns_GEISA = OrderedDict(
-    [
-        # name  # format  # type  # description  # unit
-        ("wav", ("a12", float, "vacuum wavenumber", "cm-1")),
-        ("int", ("a11", str, "intensity at 296K", "cm-1/(molecule/cm-2)")),
-        ("airbrd", ("a6", float, "air-broadened half-width at 296K", "cm-1.atm-1")),
-        ("El", ("a10", float, "lower-state energy", "cm-1")),
-        ("globu", ("a25", str, "electronic and vibrational global upper quanta", "")),
-        ("globl", ("a25", str, "electronic and vibrational global lower quanta", "")),
-        ("locu", ("a15", str, "electronic and vibrational local upper quanta", "")),
-        ("locl", ("a15", str, "electronic and vibrational local lower quanta", "")),
-        ("Tdpair", ("a4", float, "temperature-dependance exponent for Gamma air", "")),
-        ("isoG", ("a3", int, "GEISA isotope number", "")),
-        ("mol", ("a3", int, "GEISA molecular number", "")),
-        ("idG", ("a3", str, "Internal GEISA code for the data identification", "")),
-        ("id", ("a2", int, "Hitran molecular number", "")),
-        ("iso", ("a1", int, "Hitran isotope number", "")),
-        ("A", ("a10", float, "Einstein A coefficient", "s-1")),
-        ("selbrd", ("a7", float, "self-broadened half-width at 296K", "cm-1.atm-1")),
-        ("Pshft", ("a9", float, "air pressure-induced line shift at 296K", "cm-1.atm-1")),
-        ("Tdppair", ("a6", float, "temperature-dependance exponent for air pressure-induced line shift", "")),
-        ("ierrA", ("a10", float, "estimated accuracy on the line position", "cm-1")),
-        ("ierrB", ("a11", str, "estimated accuracy on the intensity of the line", "cm-1/(molecule/cm-2)")),
-        ("ierrC", ("a6", float, "estimated accuracy on the air collision halfwidth", "cm-1.atm-1")),
-        ("ierrF", ("a4", float, "estimated accuracy on the temperature dependence coefficient of the air-broadening halfwidth", "")),
-        ("ierrO", ("a9", float, "estimated accuracy on the air pressure shift of the line transition at 296K", "cm-1.atm-1")),
-        ("ierrR", ("a6", float, "estimated accuracy on the temperature dependence coefficient of the air pressure shift", "")),
-        ("ierrN", ("a7", float, "estimated accuracy on the self-broadened at 296K", "cm-1.atm-1")),
-        ("Tdpself", ("a4", float, "temperature-dependance exponent for self-broadening halfwidth", "")),
-        ("ierrS", ("a4", float, "estimated accuracy on the temperature dependence coefficient of the self-broadening halfwidth", "")),
-        ("Pshfts", ("a8", float, "self pressure-induced line shift at 296K", "cm-1.atm-1")),
-        ("ierrT", ("a8", float, "estimated accuracy on the self-pressure shift of the line transition at 296K", "cm-1.atm-1")),
-        ("Tdpnself", ("a4", float, "temperature-dependance exponent for self pressure-induced line shift", "")),
-        ("ierrU", ("a4", float, "estimated accuracy on the temperature dependence coefficient of the self pressure shift", "")),
-    ]
-)
-""" OrderedDict: parsing order of GEISA2020 format """
-
-# The columns required for equilibrium calculations only
-equilibrium_columns = OrderedDict(
-    [
-        # name  # format  # type  # description  # unit
-        ("wav", ("a12", float, "vacuum wavenumber", "cm-1")),
-        ("int", ("a11", float, "intensity at 296K", "cm-1/(molecule/cm-2)")),
-        ("airbrd", ("a6", float, "air-broadened half-width at 296K", "cm-1.atm-1")),
-        ("iso", ("a3", int, "GEISA isotope number", "")),
-        ("mol", ("a3", int, "GEISA molecular number", "")),
-        ("id", ("a3", str, "Internal GEISA code for the data identification", "")),
-        ("selbrd", ("a7", float, "self-broadened half-width at 296K", "cm-1.atm-1")),
-        ("Pshft", ("a9", float, "air pressure-induced line shift at 296K", "cm-1.atm-1")),
-        ("Pshfts", ("a8", float, "self pressure-induced line shift at 296K", "cm-1.atm-1")),
-    ]
-)
-# fmt: on
-
-GEISA_MOLECULES_Nlines = {
-    "H2O": 362222,
-    "CO2": 532533,
-    "O3": 389378,
-    "N2O": 50633,
-    "CO": 14985,
-    "CH4": 240858,
-    "O2": 6428,
-    "NO": 105079,
-    "SO2": 68728,
-    "NO2": 113880,
-    "NH3": 29082,
-    "PH3": 34542,
-    "HNO3": 691161,
-    "OH": 42866,
-    "HF": 20010,
-    "HCL": 35985,
-    "HBR": 8980,
-    "HI": 4751,
-    "CLO": 7230,
-    "OCS": 33809,
-    "H2CO": 37050,
-    "C2H6": 28439,
-    "CH3D": 49237,
-    "C2H2": 11340,
-    "C2H4": 18378,
-    "GEH4": 32372,
-    "HCN": 81889,
-    "C3H8": 8983,
-    "C2N2": 2577,
-    "C4H2": 119480,
-    "HC3N": 179347,
-    "HOCL": 17862,
-    "N2": 120,
-    "CH3CL": 18344,
-    "H2O2": 126983,
-    "H2S": 20788,
-    "HCOOH": 62684,
-    "COF2": 70904,
-    "SF6": 92398,
-    "C3H4": 19001,
-    "HO2": 38804,
-    "CLONO2": 356899,
-    "CH3BR": 36911,
-    "CH3OH": 19897,
-    "NO+": 1206,
-    "HNC": 5619,
-    "C6H6": 9797,
-    "C2HD": 15512,
-    "CF4": 60033,
-    "CH3CN": 17172,
-    "HDO": 63641,
-    "SO3": 10881,
-    "HONO": 26041,
-    "COFCL": 215639,
-    "CH3I": 70291,
-    "CH3F": 1499,
-    "RUO4": 30205,
-    "H2C3H2": 31686,
-}
-
-GEISA_MOLECULES = list(GEISA_MOLECULES_Nlines.keys())
-
-
-def gei2df(
-    fname,
-    cache=True,
-    load_columns=None,
-    verbose=True,
-    drop_non_numeric=True,
-    load_wavenum_min=None,
-    load_wavenum_max=None,
-    engine="pytables",
-):
-    """Convert a GEISA [1]_ file to a Pandas dataframe.
-    Parameters
-    ----------
-    fname: str
-        GEISA file name.
-    cache: boolean, or 'regen'
-        if ``True``, a pandas-readable HDF5 file is generated on first access,
-        and later used. This saves on the datatype cast and conversion and
-        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 when loading from a cache file. To generate
-            the cache file, all columns are loaded anyway.
-    Other Parameters
-    ----------------
-    drop_non_numeric: boolean
-        if ``True``, non numeric columns are dropped. This improves performances,
-        but make sure all the columns you need are converted to numeric formats
-        before hand. Default ``True``. Note that if a cache file is loaded it
-        will be left untouched.
-    load_wavenum_min, load_wavenum_max: float
-        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, `pytables` by default.
-    Returns
-    -------
-    df: pandas Dataframe
-        dataframe containing all lines and parameters.
-    Notes
-    -----
-    GEISA Database 2020 release can be downloaded from [2]_
-    References
-    ----------
-    .. [1] `The 2020 edition of the GEISA spectroscopic database, Thibault Delahaye et al., 2021 <https://www.sciencedirect.com/science/article/abs/pii/S0022285221000928>`_
-    .. [2] `GEISA Database 2020 release <https://geisa.aeris-data.fr/interactive-access/?db=2020&info=ftp>`_
-    See Also
-    --------
-    :func:`~radis.io.hitran.hit2df`
-    :func:`~radis.io.cdsd.cdsd2df`
-    """
-
-    # Try to access last modification time of original file
-    metadata = {}
-    metadata["last_modification"] = time.ctime(getmtime(fname))
-    # Make sure the wavenums are legit
-    if load_wavenum_min and load_wavenum_max:
-        assert load_wavenum_min < load_wavenum_max
-    # Verbose
-    if verbose >= 2:
-        print("Opening file {0}, cache={1})".format(fname, cache))
-        print("Last Modification time: {0}".format(metadata["last_modification"]))
-
-    # Attempt to use cache file
-    fcache = DataFileManager(engine).cache_file(fname)
-    if cache and exists(fcache):
-        relevant_if_metadata_above = (
-            {"wavenum_max": load_wavenum_min} if load_wavenum_min else {}
-        )  # not relevant if wavenum_max of file is < wavenum min required
-        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
-        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
-
-    # If cache files are not found, commence reading of full file
-    df = parse_hitran_file(fname, columns_GEISA)
-
-    # Commence "D to E" conversion on 2nd and 20th columns, by using
-    # df.columns.str.replace() which is much faster than Python loop.
-    # Finally, typecast them to float.
-    df["int"] = df["int"].astype(str).str.replace("D", "E").astype(float)
-    df["ierrB"] = df["ierrB"].astype(str).str.replace("D", "E").astype(float)
-
-    # Remove non numerical attributes
-    if drop_non_numeric:
-        # The function replace_PQR_with_m101 below will be developed
-        # in later updates for GEISA's non-equilibrium calculations
-        # replace_PQR_with_m101(df)
-
-        # Basically ripping out 4 quanta columns
-        df = drop_object_format_columns(df, verbose=verbose)
-
-    # Generate cache file for later use
-    if cache:
-        new_metadata = {
-            # Last modification time of the original file :
-            "last_modification": time.ctime(getmtime(fname)),
-            "wavenum_min": df.wav.min(),
-            "wavenum_max": df.wav.max(),
-        }
-        if verbose:
-            print(
-                "Generating cache file {0} with metadata :\n{1}".format(
-                    fcache, new_metadata
-                )
-            )
-        try:
-            save_to_hdf(
-                df,
-                fcache,
-                metadata=new_metadata,
-                version=radis.__version__,
-                key="df",
-                overwrite=True,
-                verbose=verbose,
-                engine=engine,
-            )
-        except PermissionError:
-            if verbose:
-                print(
-                    "An error occured in cache file generation. Lookup access rights."
-                )
-            pass
-
-    return df
 
+"""
 
-#%%
-def get_last(b):
-    """Get non-empty lines of a chunk b, parsing the bytes."""
-    element_length = np.vectorize(lambda x: len(x.__str__()))(b)
-    non_zero = element_length > element_length[-1]
-    threshold = non_zero.argmin() - 1
-    assert (non_zero[: threshold + 1] == 1).all()
-    assert (non_zero[threshold + 1 :] == 0).all()
-    return b[non_zero]
-
-
-class GEISADatabaseManager(DatabaseManager):
-    def __init__(
-        self,
-        name,
-        molecule,
-        local_databases,
-        engine="default",
-        verbose=True,
-        chunksize=100000,
-        parallel=True,
-    ):
-        super().__init__(
-            name,
-            molecule,
-            local_databases,
-            engine,
-            verbose=verbose,
-            parallel=parallel,
-        )
-        self.chunksize = chunksize
-        self.downloadable = True
-        self.base_url = None
-        self.Nlines = None
-        self.wmin = None
-        self.wmax = None
-        self.urlnames = None
-
-    def fetch_urlnames(self):
-        """requires connexion"""
-
-        if self.urlnames is not None:
-            return self.urlnames
-
-        molecule = self.molecule
-        geisa_url = "https://aeris-geisa.ipsl.fr/geisa_files/2020/Lines/line_GEISA2020_asc_gs08_v1.0_"
-
-        print(f"Molecule: {molecule}")
-
-        if molecule.upper() in GEISA_MOLECULES:
-            url = geisa_url + molecule.lower()
-            urlnames = [url]
-        else:
-            raise KeyError(
-                f"Please choose one of GEISA molecules : {GEISA_MOLECULES}. Got '{molecule}'"
-            )
-
-        self.urlnames = urlnames
-
-        return urlnames
-
-    def parse_to_local_file(
-        self,
-        opener,
-        urlname,
-        local_file,
-        pbar_active=True,
-        pbar_t0=0,
-        pbar_Ntot_estimate_factor=None,
-        pbar_Nlines_already=0,
-        pbar_last=True,
-    ):
-        """Uncompress ``urlname`` into ``local_file``.
-        Also add metadata
-
-        Parameters
-        ----------
-        opener: an opener with an .open() command
-        gfile : file handler. Filename: for info"""
-
-        # molecule = self.molecule
-        # Ntotal_lines_expected = GEISA_MOLECULES_Nlines[molecule.upper()]
-
-        writer = self.get_datafile_manager()
-
-        with opener.open(urlname) as gfile:  # locally downloaded file
-
-            gfile  #  so the linter doesn't annoy us. We're not using this file anyway, just unzipping the cache file directly :
-            df = gei2df(opener.abspath(urlname), drop_non_numeric=False, cache=False)
-
-            writer.write(local_file, df, append=False)
-
-            self.wmin = df.wav.min()
-            self.wmax = df.wav.max()
-
-            Nlines = len(df)
-
-        writer.combine_temp_batch_files(local_file)  # used for vaex mode only
-
-        # Check number of lines is consistent
-        # assert Nlines == Ntotal_lines_expected
-
-        # 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": urlname,
-                "total_lines": Nlines,
-                "version": __version__,
-            },
-        )
-
-        return Nlines
-
-    def register(self):
-        """register in ~/radis.json"""
-
-        local_files, urlnames = self.get_filenames()
-        info = f"GEISA {self.molecule} lines ({self.wmin:.1f}-{self.wmax:.1f} cm-1)"
-
-        dict_entries = {
-            "info": info,
-            "path": local_files,
-            "format": "geisa-radisdb",
-            "parfuncfmt": "hapi",
-            "wavenumber_min": self.wmin,
-            "wavenumber_max": self.wmax,
-            "download_date": self.get_today(),
-            "download_url": urlnames,
-        }
+from os.path import abspath, expanduser, join
 
-        super().register(dict_entries)
+from radis.api.geisaapi import GEISADatabaseManager
 
 
 def fetch_geisa(
@@ -507,12 +74,7 @@ def fetch_geisa(
         which HDF5 library to use to parse local files. If 'default' use the value from ~/radis.json
     output: 'pandas', 'vaex', 'jax'
         format of the output DataFrame. If ``'jax'``, returns a dictionary of
-        jax arrays. If ``'vaex'``, output is a :py:class:`vaex.dataframe.DataFrameLocal`
-
-        .. note::
-            Vaex DataFrames are memory-mapped. They do not take any space in RAM
-            and are extremelly useful to deal with the largest databases.
-
+        jax arrays.
     parallel: bool
         if ``True``, uses joblib.parallel to load database with multiple processes
 
@@ -547,12 +109,12 @@ def fetch_geisa(
     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`
+    :py:func:`~radis.api.hdf5.hdf2df`
 
     See Also
     --------
     :py:func:`~radis.io.hitran.fetch_hitran`, :py:func:`~radis.io.exomol.fetch_exomol`
-    :py:func:`~radis.io.hitemp.fetch_hitemp`, :py:func:`~radis.io.hdf5.hdf2df`
+    :py:func:`~radis.io.hitemp.fetch_hitemp`, :py:func:`~radis.api.hdf5.hdf2df`
     :py:meth:`~radis.lbl.loader.DatabankLoader.fetch_databank`
 
     """
@@ -612,10 +174,9 @@ def fetch_geisa(
     df = ldb.load(
         local_files,  # filter other files,
         columns=columns,
-        within=[("iso", isotope)] if isotope is not None else [],
-        # for relevant files, get only the right range :
-        lower_bound=[("wav", load_wavenum_min)] if load_wavenum_min is not None else [],
-        upper_bound=[("wav", load_wavenum_max)] if load_wavenum_max is not None else [],
+        isotope=isotope,
+        load_wavenum_min=load_wavenum_min,  # for relevant files, get only the right range
+        load_wavenum_max=load_wavenum_max,
         output=output,
     )
 
diff --git a/radis/io/hitemp.py b/radis/io/hitemp.py
index 837f4cca1..7a38006e0 100644
--- a/radis/io/hitemp.py
+++ b/radis/io/hitemp.py
@@ -1,428 +1,14 @@
 # -*- coding: utf-8 -*-
 """
-Created on Tue Jan 26 22:40:51 2021
+Created on Sun May 22 17:35:05 2022
 
 @author: erwan
-
-
-https://stackoverflow.com/questions/55610891/numpy-load-from-io-bytesio-stream
-https://stupidpythonideas.blogspot.com/2014/07/three-ways-to-read-files.html
-
 """
 
-import re
-import urllib.request
-from os.path import abspath, basename, exists, expanduser, join
-from typing import Union
-
-import numpy as np
-
-try:
-    from .dbmanager import DatabaseManager
-    from .hdf5 import update_pytables_to_vaex
-    from .hitran import columns_2004, parse_global_quanta, parse_local_quanta
-    from .tools import (
-        _create_dtype,
-        _get_linereturnformat,
-        _ndarray2df,
-        replace_PQR_with_m101,
-    )
-except ImportError:  # ran from here
-    from radis.io.dbmanager import DatabaseManager
-    from radis.io.hitran import columns_2004, parse_global_quanta, parse_local_quanta
-    from radis.io.hdf5 import update_pytables_to_vaex
-    from radis.io.tools import (
-        _create_dtype,
-        _get_linereturnformat,
-        _ndarray2df,
-        replace_PQR_with_m101,
-    )
-
-from radis.db import MOLECULES_LIST_NONEQUILIBRIUM
-from radis.misc.progress_bar import ProgressBar
-
-HITEMP_MOLECULES = ["H2O", "CO2", "N2O", "CO", "CH4", "NO", "NO2", "OH"]
+from os.path import abspath, exists, expanduser, join
 
-
-def keep_only_relevant(
-    inputfiles,
-    wavenum_min=None,
-    wavenum_max=None,
-) -> Union[list, float, float]:
-    """Parser file names for ``wavenum_format`` (min and max) and only keep
-    relevant files if the requested range is ``[wavenum_min, wavenum_max]``
-
-    Returns
-    -------
-    relevant: list of relevant files
-    files_wmin, files_wmax: (float, float) : wavenum min & max of relevant range
-    """
-    wavenum_format = r"\d{5}"
-    relevantfiles = []
-    files_wmin = np.inf
-    files_wmax = 0
-    for filepath in inputfiles:
-        file = basename(filepath)
-        fname_wmin, fname_wmax = re.findall(wavenum_format, file)
-        relevant = False
-        if wavenum_min is not None and wavenum_max is not None:
-            if (float(fname_wmax) > wavenum_min) and (float(fname_wmin) < wavenum_max):
-                # strict '>' :  we exclude "CO2-02_02250-02500_HITEMP2010.h5'" if calculating 2500 - 3000 cm-1
-                # strict '<' :  we exclude "CO2-02_03000-03250_HITEMP2010.h5" if calculating 2500 - 3000 cm-1
-                relevant = True
-        elif wavenum_min is not None:
-            if float(fname_wmax) > wavenum_min:
-                # strict '>' :  we exclude "CO2-02_02250-02500_HITEMP2010.h5'" if calculating 2500 - 3000 cm-1
-                relevant = True
-        elif wavenum_max is not None:
-            if float(fname_wmin) < wavenum_max:
-                # strict '<' :  we exclude "CO2-02_03000-03250_HITEMP2010.h5" if calculating 2500 - 3000 cm-1
-                relevant = True
-        else:
-            relevant = True
-        if relevant:
-            relevantfiles.append(filepath)
-            files_wmin = min(float(fname_wmin), files_wmin)
-            files_wmax = max(float(fname_wmax), files_wmax)
-
-    print(f"HITEMP keep only relevant input files: {relevantfiles}")
-
-    return relevantfiles, files_wmin, files_wmax
-
-
-#%%
-def get_last(b):
-    """Get non-empty lines of a chunk b, parsing the bytes."""
-    element_length = np.vectorize(lambda x: len(x.__str__()))(b)
-    non_zero = element_length > element_length[-1]
-    threshold = non_zero.argmin() - 1
-    assert (non_zero[: threshold + 1] == 1).all()
-    assert (non_zero[threshold + 1 :] == 0).all()
-    return b[non_zero]
-
-
-class HITEMPDatabaseManager(DatabaseManager):
-    def __init__(
-        self,
-        name,
-        molecule,
-        local_databases,
-        engine="default",
-        verbose=True,
-        chunksize=100000,
-        parallel=True,
-    ):
-        super().__init__(
-            name,
-            molecule,
-            local_databases,
-            engine,
-            verbose=verbose,
-            parallel=parallel,
-        )
-        self.chunksize = chunksize
-        self.downloadable = True
-        self.base_url = None
-        self.Nlines = None
-        self.wmin = None  # available on HITEMP website. See HITEMPDatabaseManager.fetch_url_Nlines_wmin_wmax
-        self.wmax = None  # available on HITEMP website. See HITEMPDatabaseManager.fetch_url_Nlines_wmin_wmax
-        self.urlnames = None
-
-    def fetch_url_Nlines_wmin_wmax(self, hitemp_url="https://hitran.org/hitemp/"):
-        """requires connexion"""
-
-        molecule = self.molecule
-
-        if (
-            self.base_url is not None
-            and self.Nlines is not None
-            and self.wmin is not None
-            and self.wmax is not None
-        ):
-            return self.base_url, self.Nlines, self.wmin, self.wmax
-
-        else:
-
-            response = urllib.request.urlopen(hitemp_url)
-
-            # Alternative to return a Pandas Dataframe :
-            # ... Doesnt work because missing <tr> in HITEMP website table for N2O
-
-            # soup = BeautifulSoup(
-            #         response, features="lxml"
-            #     )
-            # table = soup.find(lambda tag: tag.name=='table' and tag.has_attr('id') and tag['id']=="hitemp-molecules-table")
-
-            # def tableDataText(table):
-            #     """Parses a html segment started with tag <table> followed
-            #     by multiple <tr> (table rows) and inner <td> (table data) tags.
-            #     It returns a list of rows with inner columns.
-            #     Accepts only one <th> (table header/data) in the first row.
-
-            #     From https://stackoverflow.com/a/58274853/5622825
-            #     """
-
-            #
-
-            #     def rowgetDataText(tr, coltag='td'): # td (data) or th (header)
-            #         return [td.get_text(strip=True) for td in tr.find_all(coltag)]
-            #     rows = []
-            #     trs = table.find_all('tr')
-            #     headerow = rowgetDataText(trs[0], 'th')
-            #     if headerow: # if there is a header row include first
-            #         rows.append(headerow)
-            #         trs = trs[1:]
-            #     for tr in trs: # for every table row
-            #         rows.append(rowgetDataText(tr, 'td') ) # data row
-
-            #     df = pd.DataFrame(rows[1:], columns=rows[0])
-            #     df.index = df.Formula
-
-            #     return df
-
-            # df = tableDataText(table)
-
-            text = response.read().decode()
-            text = text[
-                text.find(
-                    '<table id="hitemp-molecules-table" class="selectable-table list-table">'
-                ) : text.find("</table>")
-            ]
-            text = re.sub(r"<!--.+?-->\s*\n", "", text)  # remove commented lines
-            html_molecule = re.sub(r"(\d{1})", r"(<sub>\1</sub>)", molecule)
-            text = text[
-                re.search(
-                    "<td>(?:<strong>)?" + html_molecule + "(?:</strong>)?</td>", text
-                ).start() :
-            ]
-            lines = text.splitlines()
-
-            Nlines = int(re.findall(r"(\d+)", lines[3].replace("&nbsp;", ""))[0])
-            wmin = int(re.findall(r"(\d+)", lines[4].replace("&nbsp;", ""))[0])
-            wmax = int(re.findall(r"(\d+)", lines[5].replace("&nbsp;", ""))[0])
-            url = "https://hitran.org" + re.findall(r'href="(.+?)"', lines[7])[0]
-
-            self.base_url, self.Nlines, self.wmin, self.wmax = url, Nlines, wmin, wmax
-
-        return url, Nlines, wmin, wmax
-
-    def fetch_urlnames(self):
-        """requires connexion"""
-
-        if self.urlnames is not None:
-            return self.urlnames
-
-        molecule = self.molecule
-
-        if molecule in ["H2O", "CO2"]:
-
-            base_url, Ntotal_lines_expected, _, _ = self.fetch_url_Nlines_wmin_wmax()
-            response = urllib.request.urlopen(base_url)
-            response_string = response.read().decode()
-            inputfiles = re.findall(r'href="(\S+.zip)"', response_string)
-
-            urlnames = [join(base_url, f) for f in inputfiles]
-
-        elif molecule in HITEMP_MOLECULES:
-            url, Ntotal_lines_expected, _, _ = self.fetch_url_Nlines_wmin_wmax()
-            urlnames = [url]
-        else:
-            raise KeyError(
-                f"Please choose one of HITEMP molecules : {HITEMP_MOLECULES}. Got '{molecule}'"
-            )
-
-        self.urlnames = urlnames
-
-        return urlnames
-
-    def keep_only_relevant(
-        self,
-        inputfiles,
-        wavenum_min=None,
-        wavenum_max=None,
-    ) -> list:
-        """For CO2 and H2O, return only relevant files for given wavenumber range.
-
-        If other molecule, return the file anyway.
-        see :py:func:`radis.io.hitemp.keep_only_relevant`"""
-        if self.molecule in ["CO2", "H2O"]:
-            inputfiles, _, _ = keep_only_relevant(inputfiles, wavenum_min, wavenum_max)
-        return inputfiles
-
-    def get_linereturn_format(self, opener, urlname, columns):
-
-        with opener.open(urlname) as gfile:  # locally downloaded file
-            dt = _create_dtype(
-                columns, "a2"
-            )  # 'a2' allocates space to get \n or \n\r for linereturn character
-            b = np.zeros(1, dtype=dt)
-            try:
-                gfile.readinto(b)
-            except EOFError as err:
-                raise ValueError(
-                    f"End of file while parsing file {opener.abspath(urlname)}. May be due to download error. Delete file ?"
-                ) from err
-            linereturnformat = _get_linereturnformat(b, columns)
-
-        return linereturnformat
-
-    def parse_to_local_file(
-        self,
-        opener,
-        urlname,
-        local_file,
-        pbar_active=True,
-        pbar_t0=0,
-        pbar_Ntot_estimate_factor=None,
-        pbar_Nlines_already=0,
-        pbar_last=True,
-    ):
-        """Uncompress ``urlname`` into ``local_file``.
-        Also add metadata
-
-        Parameters
-        ----------
-        opener: an opener with an .open() command
-        gfile : file handler. Filename: for info"""
-
-        # Get linereturn (depends on OS, but file may also have been generated
-        # on a different OS. Here we simply read the file to find out)
-        columns = columns_2004
-        chunksize = self.chunksize
-        verbose = self.verbose
-        molecule = self.molecule
-
-        if not verbose:
-            pbar_active = False
-
-        linereturnformat = self.get_linereturn_format(opener, urlname, columns)
-
-        Nlines = 0
-        Nlines_raw = 0
-        Nlines_tot = Nlines + pbar_Nlines_already
-        _, Ntotal_lines_expected, _, _ = self.fetch_url_Nlines_wmin_wmax()
-        if pbar_Ntot_estimate_factor:
-            # multiply Ntotal_lines_expected by pbar_Ntot_estimate_factor
-            # (accounts for total lines divided in number of files, and
-            # not all files downloaded)
-            Ntotal_lines_expected = int(
-                Ntotal_lines_expected * pbar_Ntot_estimate_factor
-            )
-        pb = ProgressBar(N=Ntotal_lines_expected, active=pbar_active, t0=pbar_t0)
-        wmin = np.inf
-        wmax = 0
-
-        writer = self.get_datafile_manager()
-
-        with opener.open(urlname) as gfile:  # locally downloaded file
-
-            dt = _create_dtype(columns, linereturnformat)
-
-            if verbose:
-                print(f"Download complete. Parsing {molecule} database to {local_file}")
-
-            # assert not(exists(local_file))
-
-            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.
-        writer.combine_temp_batch_files(local_file)  # used for vaex mode only
-        if pbar_last:
-            pb.update(
-                Nlines_tot,
-                message=f"  Parsed {Nlines_tot:,} / {Nlines_tot:,} lines. Wavenumber range {wmin:.2f}-{wmax:.2f} cm-1 is complete.",
-            )
-            pb.done()
-        else:
-            print("")
-
-        # Check number of lines is consistent
-        assert Nlines == Nlines_raw
-
-        # Add metadata
-        from radis import __version__
-
-        writer.add_metadata(
-            local_file,
-            {
-                "wavenumber_min": wmin,
-                "wavenumber_max": wmax,
-                "download_date": self.get_today(),
-                "download_url": urlname,
-                "total_lines": Nlines_raw,
-                "version": __version__,
-            },
-        )
-
-        return Nlines
-
-    def register(self):
-        """register in ~/radis.json"""
-
-        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"]:
-            info = "(registered files will be downloaded only when required) " + info
-
-        dict_entries = {
-            "info": info,
-            "path": local_files,
-            "format": "hitemp-radisdb",
-            "parfuncfmt": "hapi",
-            "wavenumber_min": self.wmin,
-            "wavenumber_max": self.wmax,
-            "download_date": self.get_today(),
-            "download_url": urlnames,
-        }
-
-        # 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 radis.api.hdf5 import update_pytables_to_vaex
+from radis.api.hitempapi import HITEMPDatabaseManager
 
 
 def fetch_hitemp(
@@ -521,7 +107,7 @@ def fetch_hitemp(
     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`
+    :py:func:`~radis.api.hdf5.hdf2df`
 
     See Also
     --------
@@ -637,8 +223,6 @@ def fetch_hitemp(
     return (df, files_loaded) if return_local_path else df
 
 
-#%%
-
 if __name__ == "__main__":
 
     import pytest
diff --git a/radis/io/hitran.py b/radis/io/hitran.py
index 82202ef0a..266b7cbd8 100644
--- a/radis/io/hitran.py
+++ b/radis/io/hitran.py
@@ -1,1411 +1,12 @@
 # -*- coding: utf-8 -*-
 """
-Summary
--------
-
-HITRAN database parser
-
-
-Routine Listing
----------------
-
-- :func:`~radis.io.hitran.hit2df`
-- :func:`~radis.io.hitran.parse_local_quanta`
-- :func:`~radis.io.hitran.parse_global_quanta`
-
-
--------------------------------------------------------------------------------
-
-
+RADIS Hitran Functions; based on Common API hitranapi.py
 """
 
+from os.path import abspath, expanduser, join
 
-import os
-import sys
-
-# from radis.test.utils import getTestFile
-import time
-from collections import OrderedDict
-from os.path import exists, getmtime, join, split
-
-import pandas as pd
-from numpy import int64
-
-from radis.db.classes import (  # get_molecule_identifier,
-    HITRAN_CLASS1,
-    HITRAN_CLASS2,
-    HITRAN_CLASS3,
-    HITRAN_CLASS4,
-    HITRAN_CLASS5,
-    HITRAN_CLASS6,
-    HITRAN_CLASS7,
-    HITRAN_CLASS8,
-    HITRAN_CLASS9,
-    HITRAN_CLASS10,
-    HITRAN_GROUP1,
-    HITRAN_GROUP2,
-    HITRAN_GROUP3,
-    HITRAN_GROUP4,
-    HITRAN_GROUP5,
-    HITRAN_GROUP6,
-    get_molecule,
-)
-
-from ..misc.warning import AccuracyWarning
-
-try:
-    from .cache_files import load_h5_cache_file, save_to_hdf
-    from .dbmanager import DatabaseManager
-    from .hdf5 import DataFileManager
-    from .tools import (
-        drop_object_format_columns,
-        parse_hitran_file,
-        replace_PQR_with_m101,
-    )
-except ImportError:
-    from radis.io.cache_files import load_h5_cache_file, save_to_hdf
-    from radis.io.dbmanager import DatabaseManager
-    from radis.io.hdf5 import DataFileManager
-    from radis.io.tools import (
-        drop_object_format_columns,
-        parse_hitran_file,
-        replace_PQR_with_m101,
-    )
-
-
-# %% Parsing functions
-
-# General case : HITRAN 2004
-# fmt: off
-columns_2004 = OrderedDict(
-    [
-        # name    # format # type  # description                                 # unit
-        ("id", ("a2", int, "Molecular number", "")),
-        ("iso", ("a1", int, "isotope number", "")),
-        ("wav", ("a12", float, "vacuum wavenumber", "cm-1")),
-        ("int", ("a10", float, "intensity at 296K", "cm-1/(molecule/cm-2)")),
-        ("A", ("a10", float, "Einstein A coefficient", "s-1")),
-        ("airbrd", ("a5", float, "air-broadened half-width at 296K", "cm-1.atm-1")),
-        ("selbrd", ("a5", float, "self-broadened half-width at 296K", "cm-1.atm-1")),
-        ("El", ("a10", float, "lower-state energy", "cm-1")),
-        ("Tdpair", ("a4", float, "temperature-dependance exponent for Gamma air", "")),
-        ("Pshft", ("a8", float, "air pressure-induced line shift at 296K", "cm-1.atm-1")),
-        ("globu", ("a15", str, "electronic and vibrational global upper quanta", "")),
-        ("globl", ("a15", str, "electronic and vibrational global lower quanta", "")),
-        ("locu", ("a15", str, "electronic and vibrational local upper quanta", "")),
-        ("locl", ("a15", str, "electronic and vibrational local lower quanta", "")),
-        ("ierr", ("a6", str, "ordered list of indices corresponding to uncertainty estimates of transition parameters", "")),
-        ("iref", ("a12", str, "ordered list of reference identifiers for transition parameters", "")),
-        ("lmix", ("a1", str, "flag indicating the presence of additional data and code relating to line-mixing", "")),
-        ("gp", ("a7", float, "upper state degeneracy", "")),
-        ("gpp", ("a7", float, "lower state degeneracy", "")),
-    ]
-)
-""" OrderedDict: parsing order of HITRAN 2004 format """
-# fmt: on
-
-
-PARAMETER_GROUPS_HITRAN = {
-    "par_line": "PARLIST_DOTPAR",
-    "id": "PARLIST_ID",
-    "standard": "PARLIST_STANDARD",
-    "labels": "PARLIST_LABELS",
-    "voigt": "PARLIST_VOIGT_ALL",
-    "ht": "PARLIST_HT_ALL",
-}
-
-
-def cast_to_int64_with_missing_values(dg, keys):
-    """replace missing values of int64 columns with -1"""
-    for c in keys:
-        if dg.dtypes[c] != int64:
-            dg[c].replace(
-                r"^\s+$", -1, regex=True, inplace=True
-            )  # replace empty strings by -1, e.g. HCN
-            # Warning: -1 may be a valid non-equilibirum quantum number for some
-            # molecules, e.g. H2O, see https://github.com/radis/radis/issues/280#issuecomment-896120510
-            dg[c] = dg[c].fillna(-1).astype(int64)  # replace nans with -1
-
-
-def hit2df(
-    fname,
-    cache=True,
-    verbose=True,
-    drop_non_numeric=True,
-    load_wavenum_min=None,
-    load_wavenum_max=None,
-    engine="pytables",
-    parse_quanta=True,
-):
-    """Convert a HITRAN/HITEMP [1]_ file to a Pandas dataframe
-
-    Parameters
-    ----------
-    fname: str
-        HITRAN-HITEMP file name
-    cache: boolean, or ``'regen'`` or ``'force'``
-        if ``True``, a pandas-readable HDF5 file is generated on first access,
-        and later used. This saves on the datatype cast and conversion and
-        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``.
-
-    Other Parameters
-    ----------------
-    drop_non_numeric: boolean
-        if ``True``, non numeric columns are dropped. This improves performances,
-        but make sure all the columns you need are converted to numeric formats
-        before hand. Default ``True``. Note that if a cache file is loaded it
-        will be left untouched.
-    load_wavenum_min, load_wavenum_max: float
-        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`
-    parse_quanta: bool
-        if ``True``, parse local & global quanta (required to identify lines
-        for non-LTE calculations ; but sometimes lines are not labelled.)
-
-    Returns
-    -------
-    df: pandas Dataframe
-        dataframe containing all lines and parameters
-
-
-    References
-    ----------
-
-    .. [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 = {}
-    # Last modification time of the original file :
-    metadata["last_modification"] = time.ctime(getmtime(fname))
-    if verbose >= 2:
-        print("Opening file {0} (cache={1})".format(fname, cache))
-        print("Last modification time: {0}".format(metadata["last_modification"]))
-    if load_wavenum_min and load_wavenum_max:
-        assert load_wavenum_min < load_wavenum_max
-
-    columns = columns_2004
-
-    # Use cache file if possible
-    fcache = DataFileManager(engine).cache_file(fname)
-    if cache and exists(fcache):
-        relevant_if_metadata_above = (
-            {"wavenum_max": load_wavenum_min} if load_wavenum_min else {}
-        )  # not relevant if wavenum_max of file is < wavenum min required
-        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,
-            valid_if_metadata_is=metadata,
-            relevant_if_metadata_above=relevant_if_metadata_above,
-            relevant_if_metadata_below=relevant_if_metadata_below,
-            current_version=__version__,
-            last_compatible_version=config["OLDEST_COMPATIBLE_VERSION"],
-            verbose=verbose,
-            engine=engine,
-        )
-        if df is not None:
-            return df
-
-    #  %% Start reading the full file
-
-    # Detect the molecule by reading the start of the file
-    try:
-        with open(fname) as f:
-            mol = get_molecule(int(f.read(2)))
-    except UnicodeDecodeError as err:
-        raise ValueError(
-            "You're trying to read a binary file {0} ".format(fname)
-            + "instead of an HITRAN file"
-        ) from err
-
-    df = parse_hitran_file(fname, columns)
-
-    df = post_process_hitran_data(
-        df,
-        molecule=mol,
-        parse_quanta=parse_quanta,
-    )
-    # cached file mode but cached file doesn't exist yet (else we had returned)
-    if cache:
-        new_metadata = {
-            # Last modification time of the original file :
-            "last_modification": time.ctime(getmtime(fname)),
-            "wavenum_min": df.wav.min(),
-            "wavenum_max": df.wav.max(),
-        }
-        if verbose:
-            print(
-                "Generating cache file {0} with metadata :\n{1}".format(
-                    fcache, new_metadata
-                )
-            )
-        from radis import __version__
-
-        try:
-            save_to_hdf(
-                df,
-                fcache,
-                metadata=new_metadata,
-                version=__version__,
-                overwrite=True,
-                verbose=verbose,
-                engine=engine,
-            )
-        except PermissionError:
-            if verbose:
-                print(sys.exc_info())
-                print("An error occured in cache file generation. Lookup access rights")
-            pass
-
-    # TODO : get only wavenum above/below 'load_wavenum_min', 'load_wavenum_max'
-    # by parsing df.wav.   Completely irrelevant files are discarded in 'load_h5_cache_file'
-    # but files that have partly relevant lines are fully loaded.
-    # Note : cache file is generated with the full line list.
-
-    return df
-
-
-def post_process_hitran_data(
-    df,
-    molecule,
-    verbose=True,
-    drop_non_numeric=True,
-    parse_quanta=True,
-):
-    """Parsing non-equilibrum parameters in HITRAN/HITEMP [1]_ file to and return final Pandas Dataframe
-
-    Parameters
-    ----------
-    df: pandas Dataframe
-      dataframe containing generic parameters
-
-    molecule: str
-       molecule name
-
-    Other Parameters
-    ----------------
-    drop_non_numeric: boolean
-        if ``True``, non numeric columns are dropped. This improves performances,
-        but make sure all the columns you need are converted to numeric formats
-        before hand. Default ``True``. Note that if a cache file is loaded it
-        will be left untouched.
-    parse_quanta: bool
-        if ``True``, parse local & global quanta (required to identify lines
-        for non-LTE calculations ; but sometimes lines are not labelled.)
-
-    Returns
-    -------
-    df: pandas Dataframe
-        dataframe containing all lines and parameters
-
-
-    References
-    ----------
-
-    .. [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`
-    """
-
-    # %% Post processing
-
-    # assert one molecule per database only. Else the groupbase data reading
-    # above doesnt make sense
-    nmol = len(df["id"].unique())
-    if nmol == 0:
-        raise ValueError("Databank looks empty")
-    elif nmol != 1:
-        # Crash, give explicity error messages
-        try:
-            secondline = df.iloc[1]
-        except IndexError:
-            secondline = ""
-        raise ValueError(
-            "Multiple molecules in database ({0} : {1}). Current ".format(
-                nmol, [get_molecule(idi) for idi in df["id"].unique()]
-            )
-            + "spectral code only computes 1 species at the time. Use MergeSlabs. "
-            + "Verify the parsing was correct by looking at the first row below: "
-            + "\n{0}".format(df.iloc[0])
-            + "\n----------------\nand the second row "
-            + "below: \n{0}".format(secondline)
-        )
-
-    if parse_quanta:
-        # Add local quanta attributes, based on the HITRAN group
-        try:
-            df = parse_local_quanta(df, molecule, 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).
-            # see https://github.com/radis/radis/issues/211
-            if verbose:
-                print(str(err))
-                print("-" * 10)
-                print(
-                    f"Impossible to parse local quanta in {molecule}, probably an unlabelled line. Ignoring, but nonequilibrium calculations will not be possible. See details above."
-                )
-
-        # Add global quanta attributes, based on the HITRAN class
-        try:
-            df = parse_global_quanta(df, molecule, 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).
-            # see https://github.com/radis/radis/issues/211
-            if verbose:
-                print(str(err))
-                print("-" * 10)
-                print(
-                    f"Impossible to parse global quanta in {molecule}, probably an unlabelled line. Ignoring, but nonequilibrium calculations will not be possible. See details above."
-                )
-
-    # Remove non numerical attributes
-    if drop_non_numeric:
-        if "branch" in df:
-            replace_PQR_with_m101(df)
-        df = drop_object_format_columns(df, verbose=verbose)
-
-    return df
-
-
-# %% Hitran global quanta classes
-
-
-def _parse_HITRAN_class1(df, verbose=True):
-    r"""Diatomic molecules: CO, HF, HCl, HBr, HI, N2, NO+
-
-
-    Parameters
-    ----------
-    df: pandas Dataframe
-        lines read from a HITRAN-like database
-
-
-    Notes
-    -----
-    HITRAN syntax [1]_ :
-
-    >>>       v
-    >>>  13x I2
-
-    References
-    ----------
-
-    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
-
-
-    """
-
-    # 1. Parse
-    dgu = df["globu"].astype(str).str.extract(r"[ ]{13}(?P<vu>[\d ]{2})", expand=True)
-    dgl = df["globl"].astype(str).str.extract(r"[ ]{13}(?P<vl>[\d ]{2})", expand=True)
-
-    # 2. Convert to numeric
-    cast_to_int64_with_missing_values(dgu, ["vu"])
-    cast_to_int64_with_missing_values(dgl, ["vl"])
-
-    # 3. Clean
-    del df["globu"]
-    del df["globl"]
-
-    return pd.concat([df, dgu, dgl], axis=1)
-
-
-def _parse_HITRAN_class2(df, verbose=True):
-    r"""Diatomic molecules with different electronic levels: O2
-
-
-    Parameters
-    ----------
-
-    df: pandas Dataframe
-        lines read from a HITRAN-like database
-
-
-    Notes
-    -----
-
-    HITRAN syntax [1]_ :
-
-    >>>      X   v
-    >>>  12x A1 I2
-
-    References
-    ----------
-
-    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
-
-    """
-    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, verbose=True):
-    r"""Diatomic molecules with doublet-Pi electronic state: NO, OH, ClO
-
-
-    Parameters
-    ----------
-
-    df: pandas Dataframe
-        lines read from a HITRAN-like database
-
-
-    Notes
-    -------
-
-    HITRAN syntax [1]_:
-
-    >>>      X i     v1
-    >>>  7x A1 A3 2x I2
-
-    References
-    ----------
-
-    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
-
-    """
-    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, verbose=True):
-    r"""Parse linear triatomic class in HITRAN [1]_: N2O, OCS, HCN
-
-    Parameters
-    ----------
-
-    df: pandas Dataframe
-        lines read from a HITRAN-like database
-
-    Notes
-    -----
-
-    HITRAN syntax:
-
-    >>>     v1 v2 l2 v3
-    >>>  7x I2 I2 I2 I2
-
-    Note: I2 in regexp: [\d ]{2}
-
-    References
-    ----------
-
-    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
-
-    """
-
-    # 1. Parse
-    dgu = (
-        df["globu"]
-        .astype(str)
-        .str.extract(
-            r"[ ]{7}(?P<v1u>[\d ]{2})(?P<v2u>[\d ]{2})(?P<l2u>[\d ]{2})(?P<v3u>[\d ]{2})",
-            expand=True,
-        )
-    )
-    dgl = (
-        df["globl"]
-        .astype(str)
-        .str.extract(
-            r"[ ]{7}(?P<v1l>[\d ]{2})(?P<v2l>[\d ]{2})(?P<l2l>[\d ]{2})(?P<v3l>[\d ]{2})",
-            expand=True,
-        )
-    )
-
-    # 2. Convert to numeric
-    cast_to_int64_with_missing_values(dgu, ["v1u", "v2u", "l2u", "v3u"])
-    cast_to_int64_with_missing_values(dgl, ["v1l", "v2l", "l2l", "v3l"])
-
-    # 3. Clean
-    del df["globu"]
-    del df["globl"]
-
-    return pd.concat([df, dgu, dgl], axis=1)
-
-
-def _parse_HITRAN_class5(df, verbose=True):
-    r"""Parse linear triatomic with large Fermi resonance in HITRAN [1]_: CO2
-
-    Parameters
-    ----------
-
-    df: pandas Dataframe
-        lines read from a HITRAN-like database
-
-    Notes
-    -----
-
-    HITRAN syntax:
-
-    >>>     v1 v2 l2 v3 r
-    >>>  6x I2 I2 I2 I2 I1
-
-    Note: I2 in regexp: [\d ]{2}
-
-    References
-    ----------
-
-    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
-
-    """
-
-    # 1. Parse
-    dgu = (
-        df["globu"]
-        .astype(str)
-        .str.extract(
-            r"[ ]{6}(?P<v1u>[\d ]{2})(?P<v2u>[\d ]{2})(?P<l2u>[\d ]{2})(?P<v3u>[\d ]{2})(?P<ru>\d)",
-            expand=True,
-        )
-    )
-    dgl = (
-        df["globl"]
-        .astype(str)
-        .str.extract(
-            r"[ ]{6}(?P<v1l>[\d ]{2})(?P<v2l>[\d ]{2})(?P<l2l>[\d ]{2})(?P<v3l>[\d ]{2})(?P<rl>\d)",
-            expand=True,
-        )
-    )
-
-    # 2. Convert to numeric
-    cast_to_int64_with_missing_values(dgu, ["v1u", "v2u", "l2u", "v3u", "ru"])
-    cast_to_int64_with_missing_values(dgl, ["v1l", "v2l", "l2l", "v3l", "rl"])
-
-    # 3. Clean
-    del df["globu"]
-    del df["globl"]
-
-    return pd.concat([df, dgu, dgl], axis=1)
-
-
-def _parse_HITRAN_class6(df, verbose=True):
-    r"""Parse non-linear triatomic in HITRAN [1]_: H2O, O3, SO2, NO2, HOCl, H2S, HO2, HOBr
-
-    Parameters
-    ----------
-
-    df: pandas Dataframe
-        lines read from a HITRAN-like database
-
-    Notes
-    -----
-
-    HITRAN syntax:
-
-    >>>     v1 v2 v3
-    >>>  9x I2 I2 I2
-
-    Note: I2 in regexp: [\d ]{2}
-
-    References
-    ----------
-
-    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
-
-    """
-
-    # 1. Parse
-    dgu = (
-        df["globu"]
-        .astype(str)
-        .str.extract(
-            #        '[ ]{9}(?P<v1u>[\d ]{2})(?P<v2u>[\d ]{2})(?P<v3u>[\d ]{2})',
-            r"[ ]{9}(?P<v1u>[\-\d ]{2})(?P<v2u>[\-\d ]{2})(?P<v3u>[\-\d ]{2})",
-            expand=True,
-        )
-    )
-    dgl = (
-        df["globl"]
-        .astype(str)
-        .str.extract(
-            #        '[ ]{9}(?P<v1l>[\d ]{2})(?P<v2l>[\d ]{2})(?P<v3l>[\d ]{2})',
-            r"[ ]{9}(?P<v1l>[\-\d ]{2})(?P<v2l>[\-\d ]{2})(?P<v3l>[\-\d ]{2})",
-            expand=True,
-        )
-    )
-    # ... note @EP: in HITRAN H2O files, for iso=2, vibrational levels are
-    # ... somehow negative. The regex above is adapted to catch negation signs with \-
-
-    # 2. Convert to numeric
-    cast_to_int64_with_missing_values(
-        dgu,
-        [
-            "v1u",
-            "v2u",
-            "v3u",
-        ],
-    )
-    cast_to_int64_with_missing_values(dgl, ["v1l", "v2l", "v3l"])
-
-    # 3. Clean
-    del df["globu"]
-    del df["globl"]
-
-    return pd.concat([df, dgu, dgl], axis=1)
-
-
-def _parse_HITRAN_class7(df, verbose=True):
-    r"""Parse linear tetratomic in HITRAN [1]_: C2H2
-
-    Parameters
-    ----------
-
-    df: pandas Dataframe
-        lines read from a HITRAN-like database
-
-    Notes
-    -----
-
-    HITRAN syntax:
-
-    >>>
-
-    References
-    ----------
-
-    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
-
-    """
-    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, verbose=True):
-    r"""Pyramidal tetratomic in HITRAN [1]_: NH3, PH3
-
-
-    Parameters
-    ----------
-
-    df: pandas Dataframe
-        lines read from a HITRAN-like database
-
-
-    Notes
-    -----
-
-    HITRAN syntax:
-
-    >>>
-
-    References
-    ----------
-
-    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
-
-    """
-    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, verbose=True):
-    r"""Non-linear tetratomic in HITRAN [1]_: H2CO, H2O2, COF2
-
-
-    Parameters
-    ----------
-
-    df: pandas Dataframe
-        lines read from a HITRAN-like database
-
-
-    Notes
-    -----
-
-    HITRAN syntax:
-
-    >>>
-
-    References
-    ----------
-
-    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
-
-    """
-    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, verbose=True):
-    r"""Pentatomic or greater polyatomic in HITRAN [1]_
-
-
-    Parameters
-    ----------
-
-    df: pandas Dataframe
-        lines read from a HITRAN-like database
-
-
-    Notes
-    -----
-
-    HITRAN syntax:
-
-    >>>
-
-    References
-    ----------
-
-    .. [1] `Table 3 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
-
-    """
-    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, 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
-
-    Parameters
-    ----------
-
-    df: pandas Dataframe
-        lines read from a HITRAN-like database
-
-
-    Notes
-    -----
-
-    HITRAN syntax: [1]_
-
-
-    References
-    ----------
-
-    .. [1] `Table 4 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
-
-
-    """
-
-    # 1. Parse
-
-    # Ref [1] : locu
-    # --------------
-    # J'  | Ka' | Kc' | F'  | Sym'
-    # I3  | I3  | I3  | A5  | A1
-    dgu = (
-        df["locu"]
-        .astype(str)
-        .str.extract(
-            r"(?P<ju>[\d ]{3})(?P<Kau>[\-\d ]{3})(?P<Kcu>[\-\d ]{3})(?P<Fu>.{5})(?P<symu>.)",
-            expand=True,
-        )
-    )
-    # Ref [1] : locl
-    # --------------
-    # J'' | Ka''| Kc''| F'' | Sym''
-    # I3  | I3  | I3  | A5  | A1
-    dgl = (
-        df["locl"]
-        .astype(str)
-        .str.extract(
-            r"(?P<jl>[\d ]{3})(?P<Kal>[\-\d ]{3})(?P<Kcl>[\-\d ]{3})(?P<Fl>.{5})(?P<syml>.)",
-            expand=True,
-        )
-    )
-    # ... note @EP: in HITRAN H2O files, for iso=2, the Kau, Kcu can somehow
-    # ... be negative. The regex above is adapted to catch negation signs with \-
-
-    # 2. Convert to numeric
-    cast_to_int64_with_missing_values(dgu, ["ju", "Kau", "Kcu"])
-    cast_to_int64_with_missing_values(dgl, ["jl", "Kal", "Kcl"])
-
-    # 3. Clean
-    del df["locu"]
-    del df["locl"]
-
-    return pd.concat([df, dgu, dgl], axis=1)
-
-
-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+
-
-    Parameters
-    ----------
-
-    df: pandas Dataframe
-        lines read from a HITRAN-like database
-
-
-    Notes
-    -----
-
-    HITRAN syntax: [1]
-
-
-    References
-    ----------
-
-    .. [1] `Table 4 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
-
-
-    """
-
-    # 1. Parse
-
-    # Ref [1] : locu
-    # --------------
-    #     | F'  |
-    # 10X | A5  |
-    dgu = df["locu"].astype(str).str.extract(r"[ ]{10}(?P<Fu>.{5})", expand=True)
-    # Ref [1] : locl
-    # --------------
-    #     | Br  | J'' | Sym''| F'' |
-    # 5X  | A1  | I3  | A1   | A5  |
-    dgl = (
-        df["locl"]
-        .astype(str)
-        .str.extract(
-            r"[ ]{5}(?P<branch>[\S]{1})(?P<jl>[\d ]{3})(?P<syml>.)(?P<Fl>.{5})",
-            expand=True,
-        )
-    )
-
-    # 2. Convert to numeric
-
-    # dgl['jl'] = dgl.jl.apply(pd.to_numeric)
-    cast_to_int64_with_missing_values(dgl, ["jl"])
-
-    # 3. Clean
-    del df["locu"]
-    del df["locl"]
-
-    return pd.concat([df, dgu, dgl], axis=1)
-
-
-#                # 10X included in Fu
-#               'Fu',     ('a15',  str,  'upper state total angular momentum including nuclear spin'  ,''         )),(
-#               #'locl',   ('a15',  str,   'electronic and vibrational local lower quanta'  ,''                      )),(
-#               'branch', ('a6',   str,     'O, P, Q, R, S branch symbol'                  ,''                      )),(
-#               'jl',     ('a3',   int,    'lower state rotational quantum number'         ,''                      )),(
-#               'sym',    ('a1',   str,     'symmetry'                                     ,''                      )),(
-#               'Fl',     ('a5',   str,   'lower state total angular momentum including nuclear spin', ''         )),(
-
-
-def _parse_HITRAN_group3(df, verbose=True):
-    r"""Parse Spherical rotors (:py:attr:`~radis.db.classes.HITRAN_GROUP3` ) :
-    SF6, CH4
-
-    Parameters
-    ----------
-
-    df: pandas Dataframe
-        lines read from a HITRAN-like database
-
-
-    Notes
-    -----
-
-    HITRAN syntax [1]_:
-
-
-    References
-    ----------
-
-    .. [1] `Table 4 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
-
-
-    """
-    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, verbose=True):
-    r"""Parse symmetric rotors (:py:attr:`~radis.db.classes.HITRAN_GROUP4` ):
-    CH3D, CH3Cl, C2H6, NH3, PH3, CH3OH
-
-    Parameters
-    ----------
-
-    df: pandas Dataframe
-        lines read from a HITRAN-like database
-
-
-    Notes
-    -----
-
-    HITRAN syntax [1]_:
-
-
-    References
-    ----------
-
-    .. [1] `Table 4 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
-
-
-    """
-    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, verbose=True):
-    r"""Parse Triplet-Sigma ground electronic states (:py:attr:`~radis.db.classes.HITRAN_GROUP5` ) :
-    O2
-
-    Parameters
-    ----------
-
-    df: pandas Dataframe
-        lines read from a HITRAN-like database
-
-
-    Notes
-    -----
-
-    HITRAN syntax [1]_:
-
-
-    References
-    ----------
-
-    .. [1] `Table 4 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
-
-
-    """
-    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, verbose=True):
-    r"""Parse Doublet-Pi ground electronic states (:py:attr:`~radis.db.classes.HITRAN_GROUP6` ) :
-    NO, OH, ClO
-
-    Parameters
-    ----------
-
-    df: pandas Dataframe
-        lines read from a HITRAN-like database
-
-
-    Notes
-    -----
-
-    HITRAN syntax [1]_:
-
-
-    References
-    ----------
-
-    .. [1] `Table 4 of Rothman et al. HITRAN 2004 <https://www.cfa.harvard.edu/hitran/Download/HITRAN04paper.pdf>`__
-
-
-    """
-    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, verbose=True):
-    r"""
-    Parameters
-    ----------
-
-    df: pandas Dataframe
-
-    mol: str
-        molecule name
-    """
-
-    if mol in HITRAN_GROUP1:
-        df = _parse_HITRAN_group1(df, verbose=verbose)
-    elif mol in HITRAN_GROUP2:
-        df = _parse_HITRAN_group2(df, verbose=verbose)
-    elif mol in HITRAN_GROUP3:
-        df = _parse_HITRAN_group3(df, verbose=verbose)
-    elif mol in HITRAN_GROUP4:
-        df = _parse_HITRAN_group4(df, verbose=verbose)
-    elif mol in HITRAN_GROUP5:
-        df = _parse_HITRAN_group5(df, verbose=verbose)
-    elif mol in HITRAN_GROUP6:
-        df = _parse_HITRAN_group6(df, verbose=verbose)
-    else:
-        raise ValueError(
-            "Unknown group for molecule {0}. Cant parse local quanta".format(mol)
-        )
-
-    return df
-
-
-def parse_global_quanta(df, mol, verbose=True):
-    r"""
-
-    Parameters
-    ----------
-
-    df: pandas Dataframe
-
-    mol: str
-        molecule name
-    """
-
-    if mol in HITRAN_CLASS1:
-        df = _parse_HITRAN_class1(df, verbose=verbose)
-    elif mol in HITRAN_CLASS2:
-        df = _parse_HITRAN_class2(df, verbose=verbose)
-    elif mol in HITRAN_CLASS3:
-        df = _parse_HITRAN_class3(df, verbose=verbose)
-    elif mol in HITRAN_CLASS4:
-        df = _parse_HITRAN_class4(df, verbose=verbose)
-    elif mol in HITRAN_CLASS5:
-        df = _parse_HITRAN_class5(df, verbose=verbose)
-    elif mol in HITRAN_CLASS6:
-        df = _parse_HITRAN_class6(df, verbose=verbose)
-    elif mol in HITRAN_CLASS7:
-        df = _parse_HITRAN_class7(df, verbose=verbose)
-    elif mol in HITRAN_CLASS8:
-        df = _parse_HITRAN_class8(df, verbose=verbose)
-    elif mol in HITRAN_CLASS9:
-        df = _parse_HITRAN_class9(df, verbose=verbose)
-    elif mol in HITRAN_CLASS10:
-        df = _parse_HITRAN_class10(df, verbose=verbose)
-    else:
-        raise ValueError(
-            "Unknown class for molecule {0}. Cant parse global quanta".format(mol)
-        )
-
-    return df
-
-
-#%%
-
-
-class HITRANDatabaseManager(DatabaseManager):
-    def __init__(
-        self,
-        name,
-        molecule,
-        local_databases,
-        engine="default",
-        extra_params=None,
-        verbose=True,
-        parallel=True,
-    ):
-        super().__init__(
-            name,
-            molecule,
-            local_databases,
-            engine=engine,
-            extra_params=extra_params,
-            verbose=verbose,
-            parallel=parallel,
-        )
-        self.downloadable = True
-        self.base_url = None
-        self.Nlines = None
-        self.wmin = None
-        self.wmax = None
-
-    def get_filenames(self):
-        if self.engine == "vaex":
-            return [join(self.local_databases, f"{self.molecule}.hdf5")]
-        elif self.engine == "pytables":
-            return [join(self.local_databases, f"{self.molecule}.h5")]
-        else:
-            raise NotImplementedError()
-
-    def download_and_parse(self, local_file, cache=True, parse_quanta=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 LOCAL_TABLE_CACHE, db_begin, fetch
-
-        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, extra_params):
-            """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
-            .. Isotope Missing:
-                When an isotope is missing for a particular molecule then a key error `(molecule_id, isotope_id)
-                is raised.
-
-            """
-            # 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:
-                    if extra_params == "all":
-                        fetch(
-                            file,
-                            get_molecule_identifier(molecule),
-                            iso,
-                            wmin,
-                            wmax,
-                            ParameterGroups=[*PARAMETER_GROUPS_HITRAN],
-                        )
-                    elif extra_params is None:
-                        fetch(file, get_molecule_identifier(molecule), iso, wmin, wmax)
-                    else:
-                        raise ValueError("extra_params can only be 'all' or None ")
-                except KeyError as err:
-                    list_pattern = ["(", ",", ")"]
-                    import re
-
-                    if (
-                        set(list_pattern).issubset(set(str(err)))
-                        and len(re.findall("\d", str(err))) >= 2
-                        and get_molecule_identifier(molecule)
-                        == int(
-                            re.findall(r"[\w']+", str(err))[0]
-                        )  # The regex are cryptic
-                    ):
-                        # Isotope not defined, go to next isotope
-                        continue
-                    else:
-                        raise KeyError("Error: {0}".format(str(err)))
-                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
-
-        # create database in a subfolder to isolate molecules from one-another
-        # (HAPI doesn't check and may mix molecules --> see failure at https://app.travis-ci.com/github/radis/radis/jobs/548126303#L2676)
-        tempdir = join(self.tempdir, molecule)
-        extra_params = self.extra_params
-
-        # 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, tempdir, extra_params
-        )
-
-        writer = self.get_datafile_manager()
-
-        # Create HDF5 cache file for all isotopes
-        Nlines = 0
-        for iso, data_file in zip(isotope_list, data_file_list):
-            df = pd.DataFrame(LOCAL_TABLE_CACHE[data_file.split(".")[0]]["data"])
-            df.rename(
-                columns={
-                    "molec_id": "id",
-                    "local_iso_id": "iso",
-                    "nu": "wav",
-                    "sw": "int",
-                    "a": "A",
-                    "gamma_air": "airbrd",
-                    "gamma_self": "selbrd",
-                    "elower": "El",
-                    "n_air": "Tdpair",
-                    "delta_air": "Pshft",
-                    "global_upper_quanta": "globu",
-                    "global_lower_quanta": "globl",
-                    "local_upper_quanta": "locu",
-                    "local_lower_quanta": "locl",
-                    "gp": "gp",
-                    "gpp": "gpp",
-                },
-                inplace=True,
-            )
-            df = post_process_hitran_data(
-                df,
-                molecule=molecule,
-                parse_quanta=parse_quanta,
-            )
-
-            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, sort_values="wav"
-        )  # used for vaex mode only
-        # Note: by construction, in Pytables mode the database is not sorted
-        # by 'wav' but by isotope
-
-        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()
-
-        if self.wmin is None or self.wmax is None:
-            print(
-                "Somehow wmin and wmax was not given for this database. Reading from the files"
-            )
-            ##  fix:
-            # (can happen if database was downloaded & parsed, but registration failed a first time)
-            df_full = self.load(
-                local_files,
-                columns=["wav"],
-                within=[],
-                lower_bound=[],
-                upper_bound=[],
-            )
-            self.wmin = df_full.wav.min()
-            self.wmax = df_full.wav.max()
-            print(
-                f"Somehow wmin and wmax was not given for this database. Read {self.wmin}, {self.wmax} directly from the files"
-            )
-
-        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
+from radis.api.hitranapi import HITRANDatabaseManager
+from radis.misc.warning import AccuracyWarning
 
 
 # TODO: implement parallel=True for all isotopes ?
@@ -1520,8 +121,9 @@ def fetch_hitran(
 
     See Also
     --------
-    :py:func:`~radis.io.hitemp.fetch_hitemp`, :py:func:`~radis.io.exomol.fetch_exomol`
-    :py:func:`~radis.io.hdf5.hdf2df`, :py:meth:`~radis.lbl.loader.DatabankLoader.fetch_databank`
+    :py:func:`~radis.io.hitemp.fetch_hitemp`, :py:func:`~radis.io.exomol.fetch_exomol`,
+    :py:func:`~radis.io.geisa.fetch_geisa`,
+    :py:func:`~radis.api.hdf5.hdf2df`, :py:meth:`~radis.lbl.loader.DatabankLoader.fetch_databank`
 
     """
 
diff --git a/radis/io/npy.py b/radis/io/npy.py
index 578416d55..fc4b3bf42 100644
--- a/radis/io/npy.py
+++ b/radis/io/npy.py
@@ -34,7 +34,7 @@ def npy2df(keywords, verbose=True):
           'Tdpair':'PATH/TO/Tdpair.npy',
           'El':'PATH/TO/Tdpair.npy'}
 
-    See definitions for instance in :py:data:`~radis.io.hitran.columns_2004`
+    See definitions for instance in :py:data:`~radis.api.hitranapi.column_2004`
     """
 
     database = keywords  # this has to change. Now database should be a dictionary (cf Example) # TODO
diff --git a/radis/io/query.py b/radis/io/query.py
index 26e8dd7de..7923aa05a 100644
--- a/radis/io/query.py
+++ b/radis/io/query.py
@@ -29,7 +29,7 @@
 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.api.cache_files import check_cache_file, get_cache_file, save_to_hdf
 from radis.misc import is_float
 from radis.misc.printer import printr
 
diff --git a/radis/io/spec_hdf.py b/radis/io/spec_hdf.py
index 268b7ae0b..60a0c464e 100644
--- a/radis/io/spec_hdf.py
+++ b/radis/io/spec_hdf.py
@@ -3,11 +3,7 @@
 Functions to read/write Spectrum objects under HDF5 format
 """
 
-try:
-    from .hdf5 import HDF5Manager
-except ImportError:
-    from radis.io.hdf5 import HDF5Manager
-
+from radis.io.hdf5 import DataFileManager
 
 # Convert
 
@@ -18,7 +14,7 @@ def spec2hdf(s, file, engine="pytables"):
     df_arrays = s.to_pandas(copy=False)
 
     # Store
-    mgr = HDF5Manager(engine=engine)
+    mgr = DataFileManager(engine=engine)
 
     # Store spectral arrays and lines as Datasets
 
@@ -47,7 +43,7 @@ def hdf2spec(file, wmin=None, wmax=None, wunit=None, columns=None, engine="pytab
     """Read HDF5 file into a Spectrum object"""
     from radis import Spectrum
 
-    mgr = HDF5Manager(engine=engine)
+    mgr = DataFileManager(engine=engine)
 
     # Range selection
     # TODO : convert if stored in wavelength/wavenumber
diff --git a/radis/lbl/bands.py b/radis/lbl/bands.py
index 0df41e63b..5a2b33eab 100644
--- a/radis/lbl/bands.py
+++ b/radis/lbl/bands.py
@@ -51,7 +51,7 @@
 import pandas as pd
 from numpy import exp
 
-from radis.io.hitran import HITRAN_CLASS1, get_molecule
+from radis.api.hitranapi import HITRAN_CLASS1, get_molecule
 from radis.lbl.broadening import BroadenFactory
 from radis.lbl.labels import (
     vib_lvl_name_cdsd_pc,
@@ -105,6 +105,7 @@ def eq_bands(
         self,
         Tgas,
         mole_fraction=None,
+        diluent=None,
         path_length=None,
         pressure=None,
         levels="all",
@@ -233,6 +234,9 @@ def eq_bands(
 
         # Line broadening
 
+        # ... generates molefraction for diluents
+        self._generate_diluent_molefraction(mole_fraction, diluent)
+
         # ... calculate broadening  HWHM
         self._calc_broadening_HWHM()
 
@@ -395,6 +399,7 @@ def non_eq_bands(
         Trot,
         Ttrans=None,
         mole_fraction=None,
+        diluent=None,
         path_length=None,
         pressure=None,
         vib_distribution="boltzmann",
@@ -556,6 +561,9 @@ def non_eq_bands(
 
         # Line broadening
 
+        # ... generates molefraction for diluents
+        self._generate_diluent_molefraction(mole_fraction, diluent)
+
         # ... calculate broadening  HWHM
         self._calc_broadening_HWHM()
 
diff --git a/radis/lbl/base.py b/radis/lbl/base.py
index 1f260edac..8b155b7f3 100644
--- a/radis/lbl/base.py
+++ b/radis/lbl/base.py
@@ -1458,7 +1458,7 @@ def _calc_noneq_parameters(self, vib_distribution, singleTvibmode):
             if k not in df:
                 error_details = ""
                 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"
+                    error_details = ". However, it looks like `globu` is defined. Maybe HITRAN-like database wasn't fully parsed? See radis.api.hitranapi.hit2df"
                 raise KeyError(
                     f"`{k}` not defined in database ({list(df.columns)}). "
                     + error_details
diff --git a/radis/lbl/broadening.py b/radis/lbl/broadening.py
index 8d3fece75..cbef9e8cd 100644
--- a/radis/lbl/broadening.py
+++ b/radis/lbl/broadening.py
@@ -85,8 +85,6 @@
 from radis.misc.debug import printdbg
 from radis.misc.plot import fix_style, set_style
 from radis.misc.progress_bar import ProgressBar
-
-# from radis.misc.warning import AccuracyError, AccuracyWarning
 from radis.misc.warning import reset_warnings
 from radis.phys.constants import Na, c_CGS, k_b_CGS
 
@@ -248,7 +246,16 @@ def gaussian_FT(w_centered, hwhm):
 
 
 def pressure_broadening_HWHM(
-    airbrd, selbrd, Tdpair, Tdpsel, pressure_atm, mole_fraction, Tgas, Tref
+    airbrd,
+    selbrd,
+    Tdpair,
+    Tdpsel,
+    pressure_atm,
+    mole_fraction,
+    Tgas,
+    Tref,
+    diluent,
+    diluent_broadening_coeff,
 ):
     """Calculates collisional broadening HWHM over all lines by scaling
     tabulated HWHM for new pressure and mole fractions conditions [1]_
@@ -278,6 +285,11 @@ def pressure_broadening_HWHM(
     Tref: float [K]
         reference temperature at which tabulated HWHM pressure
         broadening coefficients were tabulated
+    diluent: dictionary
+        contains diluent and their mole fraction
+    diluent_broadening_coeff: dictionary
+        contains all non air diluents broadening coefficients
+
 
     Returns
     -------
@@ -314,15 +326,37 @@ def pressure_broadening_HWHM(
     # | dev note: in that case we simplify the expression by calculation the
     # | power function once only.
 
-    if Tdpsel is None:
-        gamma_lb = ((Tref / Tgas) ** Tdpair) * (
-            (airbrd * pressure_atm * (1 - mole_fraction))
-            + (selbrd * pressure_atm * mole_fraction)
-        )
-    else:
-        gamma_lb = ((Tref / Tgas) ** Tdpair) * (
-            airbrd * pressure_atm * (1 - mole_fraction)
-        ) + ((Tref / Tgas) ** Tdpsel) * (selbrd * pressure_atm * mole_fraction)
+    diluent_molecules = diluent.keys()
+
+    # check if gamma_diluent and n_diluent exists or not
+    try:
+        gamma_lb = 0
+        for diluent_molecule, diluent_mole_fraction in diluent.items():
+            if diluent_molecule != "air":
+                gamma_lb += (
+                    (Tref / Tgas)
+                    ** diluent_broadening_coeff["n_" + diluent_molecule.lower()]
+                ) * (
+                    diluent_broadening_coeff["gamma_" + diluent_molecule.lower()]
+                    * pressure_atm
+                    * diluent_mole_fraction
+                )
+        # Adding air coefficient
+        if "air" in diluent_molecules:
+            gamma_lb += ((Tref / Tgas) ** Tdpair) * (
+                (airbrd * pressure_atm * diluent["air"])
+            )
+        # Adding self coefficient
+        if Tdpsel is None:  # use Tdpair instead
+            gamma_lb += ((Tref / Tgas) ** Tdpair) * (
+                (selbrd * pressure_atm * mole_fraction)
+            )
+        else:
+            gamma_lb += ((Tref / Tgas) ** Tdpsel) * (
+                selbrd * pressure_atm * mole_fraction
+            )
+    except KeyError as err:
+        raise KeyError("Column not found {0}".format(err))
 
     return gamma_lb
 
@@ -408,6 +442,8 @@ def voigt_broadening_HWHM(
     mole_fraction,
     Tgas,
     Tref,
+    diluent,
+    diluent_broadening_coeff,
 ):
     """Calculate Voigt profile half-width at half-maximum (HWHM) from the
     Gaussian and Collisional broadening with the empirical formula of [Olivero-1977]_
@@ -474,7 +510,16 @@ def voigt_broadening_HWHM(
 
     # Collisional broadening HWHM
     gamma_lb = pressure_broadening_HWHM(
-        airbrd, selbrd, Tdpair, Tdpsel, pressure_atm, mole_fraction, Tgas, Tref
+        airbrd,
+        selbrd,
+        Tdpair,
+        Tdpsel,
+        pressure_atm,
+        mole_fraction,
+        Tgas,
+        Tref,
+        diluent,
+        diluent_broadening_coeff,
     )
 
     # Doppler Broadening HWHM:
@@ -815,8 +860,10 @@ def _calc_broadening_HWHM(self):
 
         Run this method before using `_calc_lineshape`
         """
+
         # Init variables
         df = self.df1
+        diluent = self._diluent
 
         if len(df) == 0:
             return  # no lines
@@ -838,14 +885,37 @@ def _calc_broadening_HWHM(self):
             self.params.broadening_method
         )  # Lineshape broadening algorithm
 
+        # diluent and their broadening coeff dictionary
+        diluent_broadening_coeff = {}
+        for key in diluent:
+            if key != "air":
+                diluent_broadening_coeff["gamma_" + key.lower()] = df[
+                    "gamma_" + key.lower()
+                ]
+                diluent_broadening_coeff["n_" + key.lower()] = df["n_" + key.lower()]
+
         # Get broadenings
         if broadening_method == "voigt":
             # Adds hwhm_voigt, hwhm_gauss, hwhm_lorentz:
-            self._add_voigt_broadening_HWHM(df, pressure_atm, mole_fraction, Tgas, Tref)
+            self._add_voigt_broadening_HWHM(
+                df,
+                pressure_atm,
+                mole_fraction,
+                Tgas,
+                Tref,
+                diluent,
+                diluent_broadening_coeff,
+            )
         elif broadening_method in ["convolve", "fft"]:
             # Adds hwhm_lorentz:
             self._add_collisional_broadening_HWHM(
-                df, pressure_atm, mole_fraction, Tgas, Tref
+                df,
+                pressure_atm,
+                mole_fraction,
+                Tgas,
+                Tref,
+                diluent,
+                diluent_broadening_coeff,
             )
             # Add hwhm_gauss:
             self._add_doppler_broadening_HWHM(df, Tgas)
@@ -946,7 +1016,16 @@ def _check_accuracy(self, wstep):
 
         return
 
-    def _add_voigt_broadening_HWHM(self, df, pressure_atm, mole_fraction, Tgas, Tref):
+    def _add_voigt_broadening_HWHM(
+        self,
+        df,
+        pressure_atm,
+        mole_fraction,
+        Tgas,
+        Tref,
+        diluent,
+        diluent_broadening_coeff,
+    ):
         """Update dataframe with Voigt HWHM.
 
         Returns
@@ -997,6 +1076,8 @@ def _add_voigt_broadening_HWHM(self, df, pressure_atm, mole_fraction, Tgas, Tref
             mole_fraction,
             Tgas,
             Tref,
+            diluent,
+            diluent_broadening_coeff,
         )
 
         # Update dataframe
@@ -1007,7 +1088,14 @@ def _add_voigt_broadening_HWHM(self, df, pressure_atm, mole_fraction, Tgas, Tref
         return
 
     def _add_collisional_broadening_HWHM(
-        self, df, pressure_atm, mole_fraction, Tgas, Tref
+        self,
+        df,
+        pressure_atm,
+        mole_fraction,
+        Tgas,
+        Tref,
+        diluent,
+        diluent_broadening_coeff,
     ):
         """Update dataframe with collisional HWHM [1]_
 
@@ -1063,11 +1151,15 @@ def _add_collisional_broadening_HWHM(
             mole_fraction,
             Tgas,
             Tref,
+            diluent,
+            diluent_broadening_coeff,
         )
 
         # Update dataframe
         df["hwhm_lorentz"] = wl
 
+        #####
+
         return
 
     def _add_doppler_broadening_HWHM(self, df, Tgas):
@@ -1434,6 +1526,15 @@ def _calc_lineshape_LDM(self, df):
 
         def _init_w_axis(w_dat, log_p):
             w_min = w_dat.min()
+            if w_min == 0:
+                self.warn(
+                    f"{(w_dat==0).sum()}"
+                    + " line(s) had a calculated broadening of 0 cm-1. Check the database. At least this line is faulty: \n\n"
+                    + "{}".format(self.df1.iloc[(w_min == 0).argmax()])
+                    + "\n\nIf you want to ignore, use `warnings['ZeroBroadeningWarning'] = 'ignore'`",
+                    category="ZeroBroadeningWarning",
+                )
+                w_min = w_dat[w_dat > 0].min()
             w_max = (
                 w_dat.max() + 1e-4
             )  # Add small number to prevent w_max falling outside of the grid
diff --git a/radis/lbl/calc.py b/radis/lbl/calc.py
index 0786794b6..33d3dc973 100644
--- a/radis/lbl/calc.py
+++ b/radis/lbl/calc.py
@@ -46,6 +46,7 @@ def calc_spectrum(
     molecule=None,
     isotope="all",
     mole_fraction=1,
+    diluent="air",
     path_length=1,
     databank="hitran",
     medium="air",
@@ -129,6 +130,17 @@ def calc_spectrum(
 
             mole_fraction={'CO2': 0.8, 'CO':0.2}​
 
+    diluent: str or dictionary
+            can be a string of a single diluent or a dictionary containing diluent
+            name as key and its mole_fraction as value
+            For single diluent ::
+
+                diluent = 'CO2'
+
+            For multiple diluents ::
+
+                diluent = { 'CO2': 0.6, 'H2O':0.2}
+
     path_length: float [:math:`cm`] or `~astropy.units.quantity.Quantity`
         slab size. Default ``1`` cm​. Use arbitrary units::
 
@@ -399,6 +411,12 @@ def calc_spectrum(
     # (dealt with separately because we cannot use them to guess what are the input molecules)
     DICT_INPUT_DICT_ARGUMENTS = {"overpopulation": overpopulation}
 
+    # Check if mole_fraction is dictionary of multiple molecules and have non air diluent, if yes then raise NotImplementedError
+    if isinstance(mole_fraction, dict) and isinstance(diluent, dict):
+        raise NotImplementedError(
+            "Spectrum calculation for multiple molecules in multiple diluents is not yet implemented."
+        )
+
     def _check_molecules_are_consistent(
         molecule_reference_set, reference_name, new_argument, new_argument_name
     ):
@@ -462,8 +480,6 @@ def _check_molecules_are_consistent(
             if isinstance(argument_dict, dict):
                 # Choose the correspond value
                 molecule_dict[molecule][argument_name] = argument_dict[molecule]
-                # Will raise a KeyError if not defined. That's fine!
-                # TODO: maybe need to catch KeyError and raise a better error message?
             else:  # argument_name is not a dictionary.
                 # Let's distribute the same value to every molecule:
                 molecule_dict[molecule][argument_name] = argument_dict
@@ -544,6 +560,7 @@ def _check_molecules_are_consistent(
             mode=mode,
             export_lines=export_lines,
             return_factory=return_factory,
+            diluent=diluent,
             **kwargs_molecule,
         )
         if return_factory:
@@ -601,6 +618,7 @@ def _calc_spectrum_one_molecule(
     mode,
     export_lines,
     return_factory=False,
+    diluent="air",
     **kwargs,
 ) -> Spectrum:
     """See :py:func:`~radis.lbl.calc.calc_spectrum`
@@ -668,6 +686,7 @@ def _is_at_equilibrium():
         pressure=pressure,
         wstep=wstep,
         truncation=truncation,
+        mole_fraction=mole_fraction,
         neighbour_lines=neighbour_lines,
         cutoff=cutoff,
         parsum_mode=parsum_mode,
@@ -676,11 +695,23 @@ def _is_at_equilibrium():
         chunksize=chunksize,
         broadening_method=broadening_method,
         export_lines=export_lines,
+        diluent=diluent,
         **kwargs,
     )
     # Have consistent output units
     sf.input_wunit = input_wunit
 
+    # Checking diluent other than air present
+    if isinstance(diluent, str):
+        if diluent == "air":
+            diluent_other_than_air = False
+        else:
+            diluent_other_than_air = True
+    else:
+        diluent_other_than_air = len(diluent) > 1 or (
+            len(diluent) == 1 and "air" not in diluent
+        )
+
     # Load databank
     # -------------
 
@@ -746,18 +777,25 @@ 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"
+            conditions["extra_params"] = "all"
+        elif not _equilibrium and diluent_other_than_air:
+            conditions["load_columns"] = ["noneq", "diluent"]
+            conditions["extra_params"] = "all"
         elif not _equilibrium:
             conditions["load_columns"] = "noneq"
+        elif diluent_other_than_air:
+            conditions["load_columns"] = ["equilibrium", "diluent"]
+            conditions["extra_params"] = "all"
         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):
@@ -802,8 +840,12 @@ def _is_at_equilibrium():
         # Columns to load
         if export_lines:
             conditions["load_columns"] = "all"
+        elif not _equilibrium and diluent_other_than_air:
+            conditions["load_columns"] = ["noneq", "diluent"]
         elif not _equilibrium:
             conditions["load_columns"] = "noneq"
+        elif diluent_other_than_air:
+            conditions["load_columns"] = ["equilibrium", "diluent"]
         else:
             conditions["load_columns"] = "equilibrium"
 
@@ -816,8 +858,12 @@ def _is_at_equilibrium():
         # Columns to load
         if export_lines:
             load_columns = "all"
+        elif not _equilibrium and diluent_other_than_air:
+            load_columns = ["noneq", "diluent"]
         elif not _equilibrium:
             load_columns = "noneq"
+        elif diluent_other_than_air:
+            load_columns = ["equilibrium", "diluent"]
         else:
             load_columns = "equilibrium"
 
diff --git a/radis/lbl/factory.py b/radis/lbl/factory.py
index cd1e3239b..3bd21b360 100644
--- a/radis/lbl/factory.py
+++ b/radis/lbl/factory.py
@@ -155,6 +155,9 @@ class SpectrumFactory(BandFactory):
     medium: ``'air'``, ``'vacuum'``
         propagating medium when giving inputs with ``'wavenum_min'``, ``'wavenum_max'``.
         Does not change anything when giving inputs in wavenumber. Default ``'air'``
+    diluent: ``str`` or ``dictionary``
+            can be a string of a single diluent or a dictionary containing diluent
+            name as key and its mole_fraction as value. Default ``air``.
 
     Other Parameters
     ----------------
@@ -398,6 +401,7 @@ def __init__(
         export_populations=None,
         export_lines=False,
         emulate_gpu=False,
+        diluent="air",
         **kwargs,
     ):
 
@@ -514,6 +518,16 @@ def __init__(
         if not isinstance(isotope, str):
             isotope = ",".join([str(k) for k in list_if_float(isotope)])
 
+        # If molecule present in diluent, raise error
+        if (isinstance(diluent, str) and diluent == molecule) or (
+            isinstance(diluent, dict) and molecule in diluent.keys()
+        ):
+            raise KeyError(
+                "{0} is being called as molecule and diluent, please remove it from diluent.".format(
+                    molecule
+                )
+            )
+
         # Initialize input conditions
         self.input.wavenum_min = wavenum_min
         self.input.wavenum_max = wavenum_max
@@ -535,6 +549,8 @@ def __init__(
         # Initialize computation variables
         self.params.wstep = wstep
         self.params.pseudo_continuum_threshold = pseudo_continuum_threshold
+        self.params.diluent = diluent
+        self._diluent = None
 
         if cutoff is None:
             # If None, use no cutoff : https://github.com/radis/radis/pull/259
@@ -648,7 +664,13 @@ def __init__(
     # XXX =====================================================================
 
     def eq_spectrum(
-        self, Tgas, mole_fraction=None, path_length=None, pressure=None, name=None
+        self,
+        Tgas,
+        mole_fraction=None,
+        path_length=None,
+        diluent=None,
+        pressure=None,
+        name=None,
     ) -> Spectrum:
         """Generate a spectrum at equilibrium.
 
@@ -658,6 +680,9 @@ def eq_spectrum(
             Gas temperature (K)
         mole_fraction: float
             database species mole fraction. If None, Factory mole fraction is used.
+        diluent: str or dictionary
+            can be a string of a single diluent or a dictionary containing diluent
+            name as key and its mole_fraction as value
         path_length: float or `~astropy.units.quantity.Quantity`
             slab size (cm). If ``None``, the default Factory
             :py:attr:`~radis.lbl.factory.SpectrumFactor.input.path_length` is used.
@@ -705,7 +730,6 @@ def eq_spectrum(
         --------
         :meth:`~radis.lbl.factory.SpectrumFactory.non_eq_spectrum`
         """
-
         # %% Preprocessing
         # --------------------------------------------------------------------
 
@@ -786,6 +810,9 @@ def eq_spectrum(
         # ----------------------------------------------------------------------
         # Line broadening
 
+        # ... generates molefraction for diluents
+        self._generate_diluent_molefraction(mole_fraction, diluent)
+
         # ... calculate broadening  HWHM
         self._calc_broadening_HWHM()
 
@@ -850,6 +877,7 @@ def eq_spectrum(
                 "lines_cutoff": self._Nlines_cutoff,
                 "lines_in_continuum": self._Nlines_in_continuum,
                 "thermal_equilibrium": True,
+                "diluents": self._diluent,
                 "radis_version": version,
                 "spectral_points": (
                     self.params.wavenum_max_calc - self.params.wavenum_min_calc
@@ -925,6 +953,7 @@ def eq_spectrum_gpu(
         self,
         Tgas,
         mole_fraction=None,
+        diluent=None,
         path_length=None,
         pressure=None,
         name=None,
@@ -1193,6 +1222,7 @@ def eq_spectrum_gpu(
                 ],
                 "lines_calculated": _Nlines_calculated,
                 "thermal_equilibrium": True,
+                "diluents": self._diluent,
                 "radis_version": version,
                 "emulate_gpu": emulate,
                 "spectral_points": (
@@ -1411,6 +1441,7 @@ def non_eq_spectrum(
         Ttrans=None,
         mole_fraction=None,
         path_length=None,
+        diluent=None,
         pressure=None,
         vib_distribution="boltzmann",
         rot_distribution="boltzmann",
@@ -1435,6 +1466,9 @@ def non_eq_spectrum(
             which is the RT characteristic time)
         mole_fraction: float
             database species mole fraction. If None, Factory mole fraction is used.
+        diluent: str or dictionary
+            can be a string of a single diluent or a dictionary containing diluent
+            name as key and its mole_fraction as value
         path_length: float or `~astropy.units.quantity.Quantity`
             slab size (cm). If ``None``, the default Factory
             :py:attr:`~radis.lbl.factory.SpectrumFactor.input.path_length` is used.
@@ -1476,6 +1510,7 @@ def non_eq_spectrum(
             wavenum_max=3000,
             molecule="CO",
             isotope="1,2,3",
+            wstep="auto"
             )
             sf.fetch_databank("hitemp", load_columns='noneq')
 
@@ -1517,7 +1552,6 @@ def non_eq_spectrum(
 
         # %% Preprocessing
         # --------------------------------------------------------------------
-
         # Convert units
         Tvib = convert_and_strip_units(Tvib, u.K)
         Trot = convert_and_strip_units(Trot, u.K)
@@ -1548,7 +1582,6 @@ def non_eq_spectrum(
         self.input.overpopulation = overpopulation
         self.input.rot_distribution = rot_distribution
         self.input.vib_distribution = vib_distribution
-
         # Get translational temperature
         Tgas = Ttrans
         if Tgas is None:
@@ -1565,7 +1598,6 @@ def non_eq_spectrum(
 
         # New Profiler object
         self._reset_profiler(verbose)
-
         # Check variables
         self._check_inputs(mole_fraction, max(flatten(Tgas, Tvib, Trot)))
 
@@ -1627,6 +1659,9 @@ def non_eq_spectrum(
 
         # Line broadening
 
+        # ... generates molefraction for diluents
+        self._generate_diluent_molefraction(mole_fraction, diluent)
+
         # ... calculate broadening  HWHM
         self._calc_broadening_HWHM()
 
@@ -1724,6 +1759,7 @@ def non_eq_spectrum(
                 "lines_cutoff": self._Nlines_cutoff,
                 "lines_in_continuum": self._Nlines_in_continuum,
                 "thermal_equilibrium": False,  # dont even try to guess if it's at equilibrium
+                "diluents": self._diluent,
                 "radis_version": version,
                 "spectral_points": (
                     self.params.wavenum_max_calc - self.params.wavenum_min_calc
@@ -1883,6 +1919,50 @@ def _generate_wavenumber_arrays(self):
 
         return
 
+    def _generate_diluent_molefraction(self, mole_fraction, diluent):
+        from radis.misc.warning import MoleFractionError
+
+        molecule = self.input.molecule
+        # Check if diluent is same as molecule or not
+        if (isinstance(diluent, str) and diluent == molecule) or (
+            isinstance(diluent, dict) and molecule in diluent.keys()
+        ):
+            raise KeyError(
+                "{0} is being called as molecule and diluent, please remove it from diluent.".format(
+                    molecule
+                )
+            )
+
+        # Using Spectrum Factory values of diluents
+        if diluent is None:
+            # If diluent is air, then add remaining molefraction as 'air'
+            if isinstance(self.params.diluent, str):
+                diluents = {self.params.diluent: 1 - mole_fraction}
+            else:
+                diluents = self.params.diluent.copy()
+        else:
+            if isinstance(diluent, str):
+                diluents = {diluent: 1 - mole_fraction}
+            else:
+                diluents = diluent.copy()
+
+        # Checking mole_fraction of molecule and diluent
+        total_mole_fraction = mole_fraction + sum(list(diluents.values()))
+        if total_mole_fraction > 1:
+            raise MoleFractionError(
+                "Total molefraction = {0} of molecule and diluents greater than 1. Please set appropriate molefraction value of molecule and diluents.".format(
+                    total_mole_fraction
+                )
+            )
+        elif total_mole_fraction < 1:
+            raise MoleFractionError(
+                "Total molefraction = {0} of molecule and diluents less than 1. Please set appropriate molefraction value of molecule and diluents".format(
+                    total_mole_fraction
+                )
+            )
+
+        self._diluent = diluents
+
     def predict_time(self):
         """predict_time(self) uses the input parameters like Spectral Range, Number of lines, wstep,
         truncation to predict the estimated calculation time for the Spectrum
diff --git a/radis/lbl/gpu.py b/radis/lbl/gpu.py
index a7a96e442..c04b11d81 100644
--- a/radis/lbl/gpu.py
+++ b/radis/lbl/gpu.py
@@ -140,7 +140,7 @@ def py_calc_gaussian_envelope_params(log_2vMm, verbose=False):
 
 
 try:
-    from radis_cython_extensions import (
+    from radis_cython_extensions import (  # isort:skip
         calc_gaussian_envelope_params,
         calc_lorentzian_envelope_params,
     )
@@ -466,7 +466,7 @@ def gpu_init(
         from numpy import complex64, float32, zeros
 
         array = lambda arr: arr
-        from radis_cython_extensions import set_init_params
+        from radis_cython_extensions import set_init_params  # isort:skip
 
     init_h.v_min = np.min(v_arr)  # 2000.0
     init_h.v_max = np.max(v_arr)  # 2400.0
@@ -610,7 +610,8 @@ def gpu_iterate(p, T, mole_fraction, l=1.0, slit_FWHM=0.0, verbose=0, gpu=False)
         # isort: off
         from numpy import complex64, float32, zeros
         from numpy.fft import irfft, rfft
-        from radis_cython_extensions import (
+
+        from radis_cython_extensions import (  # isort:skip
             applyGaussianSlit,
             applyLineshapes,
             calcTransmittanceNoslit,
diff --git a/radis/lbl/labels.py b/radis/lbl/labels.py
index aee0f60f6..d97629afb 100644
--- a/radis/lbl/labels.py
+++ b/radis/lbl/labels.py
@@ -37,7 +37,7 @@
 
 def vib_lvl_name_hitran_class1(v1):
     """Write vibrational level for a HITRAN class 1 molecule: see
-    :data:`~radis.io.hitran.HITRAN_CLASS1`
+    :data:`~radis.api.hitranapi.HITRAN_CLASS1`
     """
     (v1,) = _format_str(v1)
     return "(" + v1 + ")"
@@ -45,7 +45,7 @@ def vib_lvl_name_hitran_class1(v1):
 
 def vib_lvl_name_hitran_class5(v1, v2, l2, v3):
     """Write vibrational level for a HITRAN class 5 molecule: see
-    :data:`~radis.io.hitran.HITRAN_CLASS5`
+    :data:`~radis.api.hitranapi.HITRAN_CLASS5`
     """
     v1, v2, l2, v3 = _format_str(v1, v2, l2, v3)
     return "(" + v1 + "," + v2 + "," + l2 + "," + v3 + ")"
@@ -53,7 +53,7 @@ def vib_lvl_name_hitran_class5(v1, v2, l2, v3):
 
 def vib_lvl_name_hitran_class5_short(v1, v2, l2, v3):
     """Write vibrational level for a HITRAN class 5 molecule: see
-    :data:`~radis.io.hitran.HITRAN_CLASS5`
+    :data:`~radis.api.hitranapi.HITRAN_CLASS5`
     """
     v1, v2, l2, v3 = _format_str(v1, v2, l2, v3)
     return v1 + v2 + "`" + l2 + "`" + v3
diff --git a/radis/lbl/loader.py b/radis/lbl/loader.py
index 27ca734c1..e56c16b5b 100644
--- a/radis/lbl/loader.py
+++ b/radis/lbl/loader.py
@@ -59,25 +59,19 @@
 import pandas as pd
 
 from radis import config
+from radis.api.cdsdapi import cdsd2df
+from radis.api.hdf5 import hdf2df
+from radis.api.hitranapi import hit2df, parse_global_quanta, parse_local_quanta
+from radis.api.tools import drop_object_format_columns, replace_PQR_with_m101
 from radis.db.classes import get_molecule
 from radis.db.molecules import getMolecule
 from radis.db.molparam import MOLPARAMS_EXTRA_PATH, MolParams
 from radis.db.references import doi
-from radis.io.cdsd import cdsd2df
 from radis.io.exomol import fetch_exomol
 from radis.io.geisa import fetch_geisa
-
-# from radis.io.geisa import gei2df
-from radis.io.hdf5 import hdf2df
 from radis.io.hitemp import fetch_hitemp
-from radis.io.hitran import (
-    fetch_hitran,
-    hit2df,
-    parse_global_quanta,
-    parse_local_quanta,
-)
+from radis.io.hitran import fetch_hitran
 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 (
     PartFunc_Dunham,
     PartFuncTIPS,
@@ -174,10 +168,10 @@
 
 See Also
 --------
-- 'hitran': (HITRAN / HITEMP) :data:`~radis.io.hitran.columns_2004`,
-- 'cdsd-hitemp' (CDSD HITEMP): :data:`~radis.io.cdsd.columns_hitemp`,
-- 'cdsd-4000': (CDSD 4000) :data:`~radis.io.cdsd.columns_4000`,
-- 'geisa': (GEISA 2020) :data:`~radis.io.geisa.columns_GEISA`,
+- 'hitran': (HITRAN / HITEMP) :data:`~radis.api.hitranapi.columns_2004`,
+- 'cdsd-hitemp' (CDSD HITEMP): :data:`~radis.api.cdsdapi.columns_hitemp`,
+- 'cdsd-4000': (CDSD 4000) :data:`~radis.api.cdsdapi.columns_4000`,
+- 'geisa': (GEISA 2020) :data:`~radis.api.geisaapi.columns_GEISA`,
 """
 drop_auto_columns_for_levelsfmt = {
     "radis": [],
@@ -191,10 +185,10 @@
 
 See Also
 --------
-- 'radis': :data:`~radis.io.hitran.columns_2004`,
-- 'cdsd-pc': :data:`~radis.io.hitran.columns_2004`,
-- 'cdsd-pcN' (CDSD-HITEMP): :data:`~radis.io.cdsd.columns_hitemp`,
-- 'cdsd-hamil': :data:`~radis.io.cdsd.columns_4000`,
+- 'radis': :data:`~radis.api.hitranapi.columns_2004`,
+- 'cdsd-pc': :data:`~radis.api.hitranapi.columns_2004`,
+- 'cdsd-pcN' (CDSD-HITEMP): :data:`~radis.api.cdsdapi.columns_hitemp`,
+- 'cdsd-hamil': :data:`~radis.api.cdsdapi.columns_4000`,
 """
 # TODO @dev : switch from a model where we drop certain useless columns (RADIS==0.9.28)
 # to a model where we only-load the required ones initially (if possible with lazy-loading,
@@ -219,9 +213,9 @@
 
 See Also
 --------
-- 'hitran': (HITRAN / HITEMP) :data:`~radis.io.hitran.columns_2004`,
-- 'cdsd-hitemp' (CDSD HITEMP): :data:`~radis.io.cdsd.columns_hitemp`,
-- 'cdsd-4000': (CDSD 4000) :data:`~radis.io.cdsd.columns_4000`,
+- 'hitran': (HITRAN / HITEMP) :data:`~radis.api.hitranapi.columns_2004`,
+- 'cdsd-hitemp' (CDSD HITEMP): :data:`~radis.api.cdsdapi.columns_hitemp`,
+- 'cdsd-4000': (CDSD 4000) :data:`~radis.api.cdsdapi.columns_4000`,
 """
 required_non_eq = [
     "branch",
@@ -245,6 +239,18 @@
 ]
 """list: column names required for non-equilibrium calculations.
 See load_column= key of fetch_databank() and load_databank() """
+
+broadening_coeff = [
+    "gamma_co2",
+    "n_co2",
+    "gamma_h2o",
+    "n_h2o",
+    "gamma_he",
+    "n_he",
+    "gamma_h2",
+    "n_h2",
+]
+"""list: column names required for non-air diluent calculations."""
 # TODO refactor : directly go & parse the identifications names (globu, locu, etc.)
 # in radis.io.hitran ?
 # For the moment we just try to be exhaustive
@@ -474,6 +480,7 @@ class Parameters(ConditionDict):
         "wavenum_min_calc",
         "waveunit",
         "wstep",
+        "diluent",
     ]
 
     def __init__(self):
@@ -501,6 +508,7 @@ def __init__(self):
         self.wavenum_min_calc = None  #: float: minimum calculated wavenumber (cm-1) initialized by SpectrumFactory
         self.waveunit = "cm-1"  #: waverange unit: should be cm-1.
         self.wstep = None  #: float: spectral resolution (cm-1)
+        self.diluent = {}  # dict: molecule : mole fraction
         self.dxL = _lorentzian_step(
             0.01
         )  #: float : Lorentzian step for LDM lineshape database. Default _lorentzian_step(0.01)
@@ -891,6 +899,20 @@ def init_databank(self, *args, **kwargs):
         self.df0 = None  # type : pd.DataFrame
         self._reset_references()  # bibliographic references
 
+    def columns_list_to_load(self, load_columns_type):
+        # Which columns to load
+        if load_columns_type == "equilibrium":
+            columns = list(drop_all_but_these)
+        elif load_columns_type == "noneq":
+            columns = list(set(drop_all_but_these) | set(required_non_eq))
+        elif load_columns_type == "diluent":
+            columns = list(broadening_coeff)
+        else:
+            raise ValueError(
+                f"Expected a list or 'all' for `load_columns`, got `load_columns={load_columns_type}"
+            )
+        return columns
+
     def fetch_databank(
         self,
         source="hitran",
@@ -987,7 +1009,7 @@ def fetch_databank(
         parallel: bool
             if ``True``, uses joblib.parallel to load database with multiple processes
             (works only for HITEMP files)
-        load_columns: list, ``'all'``, ``'equilibrium'``, ``'noneq'``
+        load_columns: list, ``'all'``, ``'equilibrium'``, ``'noneq'``, ``diluent``,
             columns names to load.
             If ``'equilibrium'``, only load the columns required for equilibrium
             calculations. If ``'noneq'``, also load the columns required for
@@ -995,11 +1017,16 @@ def fetch_databank(
             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``.
+            If ``diluent`` then all additional columns required for calculating spectrum
+            in that diluent is loaded.
             Default ``'equilibrium'``.
 
             .. warning::
                 if using ``'equilibrium'``, not all parameters will be available
                 for a Spectrum :py:func:`~radis.spectrum.spectrum.Spectrum.line_survey`.
+                If you are calculating equilibrium (LTE) spectra, it is recommended to
+                use ``'equilibrium'``. If you are calculating non-LTE spectra, it is
+                recommended to use ``'noneq'``.
 
         Notes
         -----
@@ -1089,20 +1116,32 @@ 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":
+        # Which columns to load
+        columns = []
+        if "all" in load_columns:
             columns = None  # see fetch_hitemp, fetch_hitran, etc.
-        elif isinstance(load_columns, list):
-            columns = list(set(drop_all_but_these) | set(load_columns))
-        else:
+        elif isinstance(load_columns, str) and load_columns in ["equilibrium", "noneq"]:
+            columns = self.columns_list_to_load(load_columns)
+        elif load_columns == "diluent":
             raise ValueError(
-                f"Expected a list or 'all' for `load_columns`, got `load_columns={load_columns}"
+                "Please use diluent along with 'equilibrium' or 'noneq' in a list like ['diluent','noneq']"
             )
 
+        elif isinstance(load_columns, list) and "all" not in load_columns:
+            for load_columns_type in load_columns:
+                if load_columns_type in ["equilibrium", "noneq", "diluent"]:
+                    for col in self.columns_list_to_load(load_columns_type):
+                        columns.append(col)
+                elif load_columns_type in list(
+                    set(drop_all_but_these)
+                    | set(required_non_eq)
+                    | set(broadening_coeff)
+                ):
+                    columns.append(load_columns_type)
+                else:
+                    raise ValueError("invalid column name provided")
+            columns = list(set(columns))
+
         # %% Init Line database
         # ---------------------
         self._reset_references()  # bibliographic references
@@ -2071,6 +2110,7 @@ def _load_databank(
         load_columns,
         include_neighbouring_lines=True,
     ) -> pd.DataFrame:
+
         """Loads all available database files and keep the relevant one.
         Returns a Pandas dataframe.
 
@@ -2117,7 +2157,6 @@ def _load_databank(
             because of lineshape broadening. The ``neighbour_lines``
             parameter is used to determine the limit. Default ``True``.
         """
-
         # Check inputs
         assert db_use_cached in [True, False, "regen", "force"]
 
@@ -2141,20 +2180,32 @@ 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":
+        # which columns to load
+        columns = []
+        if "all" in load_columns:
             columns = None  # see fetch_hitemp, fetch_hitran, etc.
-        elif isinstance(load_columns, list):
-            columns = list(set(drop_all_but_these) | set(load_columns))
-        else:
+        elif isinstance(load_columns, str) and load_columns in ["equilibrium", "noneq"]:
+            columns = self.columns_list_to_load(load_columns)
+        elif load_columns == "diluent":
             raise ValueError(
-                f"Expected a list or 'all' for `load_columns`, got `load_columns={load_columns}"
+                "Please use diluent along with 'equilibrium' or 'noneq' in a list like ['diluent','noneq']"
             )
 
+        elif isinstance(load_columns, list) and "all" not in load_columns:
+            for load_columns_type in load_columns:
+                if load_columns_type in ["equilibrium", "noneq", "diluent"]:
+                    for col in self.columns_list_to_load(load_columns_type):
+                        columns.append(col)
+                elif load_columns_type in list(
+                    set(drop_all_but_these)
+                    | set(required_non_eq)
+                    | set(broadening_coeff)
+                ):
+                    columns.append(load_columns_type)
+                else:
+                    raise ValueError("invalid column name provided")
+            columns = list(set(columns))
+
         # subroutine load_and_concat
         # --------------------------------------
         def load_and_concat(files):
diff --git a/radis/levels/partfunc.py b/radis/levels/partfunc.py
index 962beed03..0d0f2fa80 100644
--- a/radis/levels/partfunc.py
+++ b/radis/levels/partfunc.py
@@ -63,6 +63,7 @@
 from numpy import exp
 
 import radis
+from radis.api.cache_files import load_h5_cache_file, save_to_hdf
 from radis.db.classes import (
     HITRAN_CLASS1,
     HITRAN_CLASS2,
@@ -72,7 +73,6 @@
     get_molecule,
     get_molecule_identifier,
 )
-from radis.io.cache_files import load_h5_cache_file, save_to_hdf
 from radis.lbl.labels import vib_lvl_name_hitran_class1, vib_lvl_name_hitran_class5
 from radis.misc.basics import all_in
 from radis.misc.debug import printdbg
@@ -1108,7 +1108,8 @@ class PartFuncExoMol(RovibParFuncTabulator):
     One can use :py:func:`~radis.io.exomol.fetch_exomol` which has the option to
     return a :py:class:`~radis.levels.partfunc.PartFuncExoMol` object ::
 
-        from radis.io.exomol import fetch_exomol, get_exomol_database_list
+        from radis.io.exomol import fetch_exomol
+        from radis.api.exomolapi import get_exomol_database_list
 
         print(get_exomol_database_list("SiO", "28Si-16O"))  # 'EBJT" is one database
         _, _, Z_exomol = fetch_exomol("SiO", "EBJT", return_local_path=True, return_partition_function=True)
@@ -1573,7 +1574,7 @@ def _add_extra(self):
     def build_energy_levels_class1(self):  # , ZPE=0):
         """in the case where only Ediss is given. Deal with vmax, Jmax later.
 
-        Applies to molecules in :data:`~radis.io.hitran.HITRAN_CLASS1`
+        Applies to molecules in :data:`~radis.api.hitranapi.HITRAN_CLASS1`
 
         Returns
         -------
@@ -1710,7 +1711,7 @@ def build_energy_levels_class5(
     ):
         """in the case where only Ediss is given. Deal with vmax, Jmax later.
 
-        :data:`~radis.io.hitran.HITRAN_CLASS5` = ['CO2']
+        :data:`~radis.api.hitranapi.HITRAN_CLASS5` = ['CO2']
         # Linear triatomic with large Fermi resonance
 
         Parameters
diff --git a/radis/levels/partfunc_cdsd.py b/radis/levels/partfunc_cdsd.py
index 95ab1d2c3..a19deabc0 100644
--- a/radis/levels/partfunc_cdsd.py
+++ b/radis/levels/partfunc_cdsd.py
@@ -28,8 +28,8 @@
 from scipy.interpolate import splev, splrep
 
 import radis
+from radis.api.cache_files import filter_metadata, load_h5_cache_file, save_to_hdf
 from radis.db.molecules import ElectronicState
-from radis.io.cache_files import filter_metadata, load_h5_cache_file, save_to_hdf
 from radis.lbl.labels import (
     vib_lvl_name_cdsd_p,
     vib_lvl_name_cdsd_pc,
diff --git a/radis/misc/warning.py b/radis/misc/warning.py
index bce18936c..7c84baad8 100644
--- a/radis/misc/warning.py
+++ b/radis/misc/warning.py
@@ -79,6 +79,10 @@ class InconsistentDatabaseError(ValueError):
     pass
 
 
+class MoleFractionError(ValueError):
+    pass
+
+
 # Warnings
 
 
@@ -143,6 +147,15 @@ class MissingSelfBroadeningWarning(UserWarning):
     pass
 
 
+class ZeroBroadeningWarning(UserWarning):
+    """At least one line has a calculated broadening of 0
+
+    See :py:meth:`~radis.lbl.broadening.BroadenFactory._calc_lineshape_LDM`
+    """
+
+    pass
+
+
 class MissingPressureShiftWarning(UserWarning):
     """Pressure-shift coefficient is missing in Line Database."""
 
@@ -214,6 +227,7 @@ class DatabaseNotFoundError(FileNotFoundError):
     "NegativeEnergiesWarning": NegativeEnergiesWarning,
     "MissingSelfBroadeningTdepWarning": MissingSelfBroadeningTdepWarning,
     "MissingSelfBroadeningWarning": MissingSelfBroadeningWarning,
+    "ZeroBroadeningWarning": ZeroBroadeningWarning,
     "MissingPressureShiftWarning": MissingPressureShiftWarning,
     "LinestrengthCutoffWarning": LinestrengthCutoffWarning,
     "InputConditionsWarning": InputConditionsWarning,
@@ -261,6 +275,7 @@ class DatabaseNotFoundError(FileNotFoundError):
     # warning if self-broadening abs coefficnet missing (Air is used instead)
     "MissingSelfBroadeningTdepWarning": "warn",
     "MissingSelfBroadeningWarning": "warn",
+    "ZeroBroadeningWarning": "warn",
     "MissingPressureShiftWarning": "warn",
     "InputConditionsWarning": "warn",
     "DeprecatedFileWarning": "warn",
diff --git a/radis/spectrum/operations.py b/radis/spectrum/operations.py
index 822e6c88e..59c19062e 100644
--- a/radis/spectrum/operations.py
+++ b/radis/spectrum/operations.py
@@ -72,6 +72,7 @@
     vacuum2air,
 )
 from radis.phys.units import Unit
+from radis.phys.units_astropy import convert_and_strip_units
 from radis.spectrum.spectrum import Spectrum
 
 # %% Filter Spectra
@@ -342,12 +343,43 @@ def crop(s: Spectrum, wmin=None, wmax=None, wunit=None, inplace=False) -> Spectr
         crop(s, 2000, 2300, 'cm-1')
     """
 
+    stored_waveunit = s.get_waveunit()
+
+    wlunit_list = {
+        "km": u.km,
+        "m": u.m,
+        "cm": u.cm,
+        "mm": u.mm,
+        "um": u.um,
+    }
+
     # Check inputs
+
     if wmin is None and wmax is None:
         raise ValueError("Choose at least `wmin=` or `wmax=`")
+
+    if (
+        type(wmin) == type(wmax) == u.quantity.Quantity
+    ):  # User states astropy units directly to wmin and wmax
+        # Convert wmin and wmax to nm unit for later Spectrum wavespace conversion
+        # The stored waveunit can only be nm or cm-1, but we don't need to deal with wavenumber unit conversion, only wavelength
+        # So I convert them all to nm
+        wmin = convert_and_strip_units(wmin, u.nm)
+        wmax = convert_and_strip_units(wmax, u.nm)
+
     if wunit is None:
         raise ValueError("Please precise unit for wmin and wmax with `unit=`")
-    assert wunit in ["nm", "cm-1"]
+    elif wunit not in ["nm", "cm-1"]:
+        if wunit in wlunit_list:
+            wmin = convert_and_strip_units(wmin * wlunit_list[wunit], u.nm)
+            wmax = convert_and_strip_units(wmax * wlunit_list[wunit], u.nm)
+            wunit = "nm"
+        else:
+            raise ValueError(
+                f"Unsupported wunit, should be cm-1, nm or {wlunit_list.keys}"
+            )
+    # assert wunit in ["nm", "cm-1"]
+
     if (wmin is not None and wmax is not None) and wmin >= wmax:
         raise ValueError(
             "wmin should be < wmax (Got: {0:.2f}, {1:.2f})".format(wmin, wmax)
@@ -359,7 +391,6 @@ def crop(s: Spectrum, wmin=None, wmax=None, wunit=None, inplace=False) -> Spectr
     # Convert wmin, wmax to Spectrum wavespace  (stored_waveunit)
     # (deal with cases where wavelength are given in 'air' or 'vacuum')
     # TODO @dev: rewrite with wunit='cm-1', 'nm_air', 'nm_vac'
-    stored_waveunit = s.get_waveunit()
     wmin0, wmax0 = wmin, wmax
     if stored_waveunit == "cm-1":
         # convert wmin, wmax to wavenumber
diff --git a/radis/tools/fitting.py b/radis/tools/fitting.py
index 59c3a0646..25d65b269 100644
--- a/radis/tools/fitting.py
+++ b/radis/tools/fitting.py
@@ -49,6 +49,7 @@ def LTEModel(factory, fittable_parameters, fixed_parameters={}) -> Spectrum:
     See Also
     --------
     :py:func:`~radis.tools.fitting.Tvib12Tvib3Trot_NonLTEModel`
+    :py:func:`~radis.tools.new_fitting.residual_LTE`
 
     """
 
@@ -103,6 +104,7 @@ def Tvib12Tvib3Trot_NonLTEModel(
     See Also
     --------
     :py:func:`~radis.tools.fitting.LTEModel`
+    :py:func:`~radis.tools.new_fitting.residual_NonLTE`
     """
 
     fittable_parameters = fittable_parameters.copy()
@@ -207,6 +209,7 @@ def fit_spectrum(
     See Also
     --------
     :py:meth:`~radis.lbl.factory.SpectrumFactory.fit_spectrum`,
+    :py:func:`~radis.tools.new_fitting.fit_spectrum`,
     :py:mod:`fitroom`
     """
 
diff --git a/radis/tools/line_survey.py b/radis/tools/line_survey.py
index 6b5750d94..06360ee6b 100644
--- a/radis/tools/line_survey.py
+++ b/radis/tools/line_survey.py
@@ -13,15 +13,15 @@
 
 import numpy as np
 
-from radis.db.classes import get_molecule, get_molecule_identifier
-from radis.io.cdsd import columns_4000 as cdsd4000columns
-from radis.io.cdsd import columns_cdsdhitemp as cdsdhitempcolumns
-from radis.io.hitran import (  # HITRAN_CLASS2,; HITRAN_CLASS3,; HITRAN_CLASS6,; HITRAN_CLASS7,; HITRAN_CLASS8,; HITRAN_CLASS9,; HITRAN_CLASS10,
+from radis.api.cdsdapi import columns_4000 as cdsd4000columns
+from radis.api.cdsdapi import columns_cdsdhitemp as cdsdhitempcolumns
+from radis.api.hitranapi import (  # HITRAN_CLASS2,; HITRAN_CLASS3,; HITRAN_CLASS6,; HITRAN_CLASS7,; HITRAN_CLASS8,; HITRAN_CLASS9,; HITRAN_CLASS10,
     HITRAN_CLASS1,
     HITRAN_CLASS4,
     HITRAN_CLASS5,
 )
-from radis.io.hitran import columns_2004 as hitrancolumns
+from radis.api.hitranapi import columns_2004 as hitrancolumns
+from radis.db.classes import get_molecule, get_molecule_identifier
 from radis.misc.basics import is_float
 from radis.misc.printer import superscript_map
 from radis.misc.utils import NotInstalled
diff --git a/radis/tools/new_fitting.py b/radis/tools/new_fitting.py
new file mode 100644
index 000000000..14cca0b60
--- /dev/null
+++ b/radis/tools/new_fitting.py
@@ -0,0 +1,948 @@
+# -*- coding: utf-8 -*-
+"""
+
+Module for New RADIS Fitting Interface
+
+This module is to conduct benchmarking process on spectra (LTE or Non-LTE) with
+temperatures (gas, vibrational, rotational, translational), mole fraction, cutoff,
+as well as other characteristics as fitting parameters.
+
+This module is implemented into RADIS from the Fitting Benchmarking repository, as
+a 2022 GSOC project: https://github.com/TranHuuNhatHuy/RADIS-Spectrum-Fitting-Benchmark
+
+This module aims to provide RADIS with a unified fitting interface which covers most of
+current fitting cases encountered from fellow scientists and researchers, while being
+simple and easy-to-use to RADIS newcomers, as you only need to input the parameters into
+pre-defined Dictionary objects in a simplified script, just as filling a form. This also
+serves as a bridge between RADIS codebase and a future fitting feature on RADIS App.
+
+"""
+
+
+import json
+import time
+from typing import Union
+
+import numpy as np
+from lmfit import Parameters, fit_report, minimize
+from lmfit.minimizer import MinimizerResult
+
+from radis import Spectrum, SpectrumFactory
+from radis.spectrum import plot_diff
+from radis.spectrum.compare import get_residual
+from radis.tools.database import load_spec
+
+# --------------------- MODELS / COST FUNCTIONS --------------------- #
+
+
+def residual_LTE(params, conditions, s_data, sf, log, verbose):
+    """A cost function that calculates an LTE spectrum based on the
+    initial conditions and values of fit parameters, then returning a
+    scalar containing difference between experimental and data spectra.
+
+    Parameters
+    ----------
+    params : LMFIT.Parameters
+        basically a dict containing fit parameters that will be varied
+        during the minimization.
+    conditions: dict
+        a dict containing conditions (fixed parameters) for generating
+        model spectra.
+    s_data: Spectrum
+        data spectrum, loaded from the directory stated in JSON file.
+    sf: SpectrumFactory
+        the SpectrumFactory object used for modeling spectra, generated
+        before the minimize loop occurs.
+    log: list
+        a Dictionary storing runtime log of the fitting process that are
+        not quite covered by the Minimizer, including: residual and fit
+        values after each fitting loop, and total time elapsed.
+
+    Other parameters
+    ----------------
+    verbose : bool
+        if True, will print out result of each fitting loop.
+
+    Returns
+    -------
+    residual: float
+        residuals of the two spectra, using RADIS's get_residual().
+
+    Examples
+    --------
+
+    .. minigallery:: radis.tools.new_fitting.residual_LTE
+
+    See Also
+    --------
+    :py:func:`~radis.tools.new_fitting.residual_NonLTE`
+    :py:func:`~radis.tools.fitting.LTEModel`
+
+    """
+
+    # GENERATE LTE SPECTRUM BASED ON THOSE PARAMETERS
+
+    # Load initial values of fit parameters
+
+    # These keys must be ignored when parsing parameters. They will be dealt with separately
+    ignore_keys = [
+        "offsetnm",
+        "offsetcm1",
+    ]
+
+    # Retrieve current values of parameters, for calculating model spectrum
+    kwargs = {}
+    for param in params:
+        if param not in ignore_keys:
+            kwargs[param] = float(params[param])
+
+    # Model spectrum calculation
+    s_model = sf.eq_spectrum(**kwargs)
+
+    # Deal with "offset"
+    if "offsetnm" in params:
+        offset_value = float(params["offsetnm"])
+        s_model = s_model.offset(offset_value, "nm")
+    if "offsetcm1" in params:
+        offset_value = float(params["offsetcm1"])
+        s_model = s_model.offset(offset_value, "cm-1")
+
+    # FURTHER REFINE THE MODELED SPECTRUM BEFORE CALCULATING DIFF
+
+    pipeline = conditions["pipeline"]
+    model = conditions["model"]
+
+    # Apply slit stated in "model"
+    if "slit" in model:
+
+        slit_model = model["slit"]
+
+        # The user uses simple format of "[value] [unit]", such as "-0.2 nm"
+        if isinstance(slit_model, str):
+            slit_val, slit_unit = slit_model.split()
+            s_model = s_model.apply_slit(float(slit_val), slit_unit)
+
+        # The user uses a dict with complex format of slit function, unit, shape, center wavespace, dispersion, etc.
+        if isinstance(slit_model, dict):
+            kwargs = {}
+            for cond in slit_model:
+                kwargs[cond] = slit_model[cond]
+            s_model = s_model.apply_slit(**kwargs)
+
+    # Take spectral quantity
+    fit_var = pipeline["fit_var"]
+    s_model = s_model.take(fit_var)
+
+    # Apply offset stated in "model"
+    if "offset" in model:
+        off_val, off_unit = model["offset"].split()
+        s_model = s_model.offset(float(off_val), off_unit)
+
+    # Apply normalization
+    if "normalize" in pipeline:
+        if pipeline["normalize"]:
+            s_model = s_model.normalize()
+
+    # ACQUIRE AND RETURN DIFF, ALSO LOG FITTING HISTORY
+
+    # Acquire diff
+    residual = get_residual(s_model, s_data, fit_var, norm="L2", ignore_nan="True")
+
+    # Log the current residual
+    log["residual"].append(residual)
+
+    # Log the current fitting values of fit parameters
+    current_fitvals = []
+    for param in params:
+        current_fitvals.append(float(params[param]))
+    log["fit_vals"].append(current_fitvals)
+
+    return residual
+
+
+def residual_NonLTE(params, conditions, s_data, sf, log, verbose):
+    """A cost function that calculates a non-LTE spectrum based on the
+    initial conditions and values of fit parameters, then returning a
+    scalar containing difference between experimental and data spectra.
+
+    Parameters
+    ----------
+    params : LMFIT.Parameters
+        basically a dict containing fit parameters that will be varied
+        during the minimization.
+    conditions: dict
+        a dict containing conditions (fixed parameters) for generating
+        model spectra.
+    s_data: Spectrum
+        data spectrum, loaded from the directory stated in JSON file.
+    sf: SpectrumFactory
+        the SpectrumFactory object used for modeling spectra, generated
+        before the minimize loop occurs.
+    log: list
+        a Dictionary storing runtime log of the fitting process that are
+        not quite covered by the Minimizer, including: residual and fit
+        values after each fitting loop, and total time elapsed.
+
+    Other parameters
+    ----------------
+    verbose : bool
+        if True, will print out result of each fitting loop.
+
+    Returns
+    -------
+    residual: float
+        residuals of the two spectra, using RADIS's get_residual().
+
+    Examples
+    --------
+
+    .. minigallery:: radis.tools.new_fitting.residual_NonLTE
+
+    See Also
+    --------
+    :py:func:`~radis.tools.new_fitting.residual_LTE`
+    :py:func:`~radis.tools.fitting.radis.tools.fitting.Tvib12Tvib3Trot_NonLTEModel`
+
+    """
+
+    # GENERATE NON-LTE SPECTRUM BASED ON THOSE PARAMETERS
+
+    # Load initial values of fit parameters
+
+    # These keys must be ignored when parsing parameters. They will be dealt with separately
+    ignore_keys = [
+        "offsetnm",
+        "offsetcm-1",
+    ]
+
+    # Retrieve current values of parameters, for calculating model spectrum
+    kwargs = {}
+    for param in params:
+        if param not in ignore_keys:
+            kwargs[param] = float(params[param])
+
+    # Deal with the case of multiple Tvib temperatures
+    if "Tvib0" in kwargs:  # There is trace of a "Tvib fragmentation" before
+        Tvib = []
+        for kw in kwargs:
+            if "Tvib" in kw:  # Such as "Tvib0", "Tvib1" or so
+                Tvib.append(kwargs[kw])  # Bring them altogether, uwu
+                kwargs.pop(kw)  # Dispose the fragmented one in kwargs
+        kwargs["Tvib"] = tuple(Tvib)  # Finally, we have the tuple of Tvib
+
+    # Model spectrum calculation
+    s_model = sf.non_eq_spectrum(**kwargs)
+
+    # Deal with "offset"
+    if "offsetnm" in params:
+        offset_value = float(params["offsetnm"])
+        s_model = s_model.offset(offset_value, "nm")
+    if "offsetcm-1" in params:
+        offset_value = float(params["offsetcm-1"])
+        s_model = s_model.offset(offset_value, "cm-1")
+
+    # FURTHER REFINE THE MODELED SPECTRUM BEFORE CALCULATING DIFF
+
+    pipeline = conditions["pipeline"]
+    model = conditions["model"]
+
+    # Apply slit stated in "model"
+    if "slit" in model:
+
+        slit_model = model["slit"]
+
+        # The user uses simple format of "[value] [unit]", such as "-0.2 nm"
+        if isinstance(slit_model, str):
+            slit_val, slit_unit = slit_model.split()
+            s_model = s_model.apply_slit(float(slit_val), slit_unit)
+
+        # The user uses a dict with complex format of slit function, unit, shape, center wavespace, dispersion, etc.
+        if isinstance(slit_model, dict):
+            kwargs = {}
+            for cond in slit_model:
+                kwargs[cond] = slit_model[cond]
+            s_model = s_model.apply_slit(**kwargs)
+
+    # Take spectral quantity
+    fit_var = pipeline["fit_var"]
+    s_model = s_model.take(fit_var)
+
+    # Apply offset
+    if "offset" in model:
+        off_val, off_unit = model["offset"].split()
+        s_model = s_model.offset(float(off_val), off_unit)
+
+    # Apply normalization
+    if "normalize" in pipeline:
+        if pipeline["normalize"]:
+            s_model = s_model.normalize()
+
+    # ACQUIRE AND RETURN DIFF, ALSO LOG FITTING HISTORY
+
+    # Acquire diff
+    residual = get_residual(s_data, s_model, fit_var, norm="L2", ignore_nan="True")
+
+    # Log the current residual
+    log["residual"].append(residual)
+
+    # Log the current fitting values of fit parameters
+    current_fitvals = []
+    for param in params:
+        current_fitvals.append(float(params[param]))
+    log["fit_vals"].append(current_fitvals)
+
+    return residual
+
+
+# ----------------------------------------------------------------------- #
+
+
+# --------------------- FITTING METHODS / FUNCTIONS --------------------- #
+
+
+def get_conditions(
+    input_conditions,
+    verbose=True,
+) -> Union[dict, Parameters]:
+    """Get the ground-truth information by reading from input - either a JSON file
+    or a dict parameter. Return a dict parameter containing fixed conditions for
+    spectrum calculation, and an LMFIT.Parameters object containing fit parameters.
+
+    Parameters
+    ----------
+    input_conditions : str or dict
+        If str, the code assumes this is path to the JSON input file from the script
+        calling it, for example:: "./test_dir/CO2_measured_spectrum_4-5um.json", and
+        then parses the JSON data as a dict.
+        If dict, the code just simply parses the dict, yeah, that simple.
+
+    Other Parameters
+    ----------------
+    verbose: bool
+        by default, True, print details about the fitting progress.
+
+    Examples
+    --------
+
+    .. minigallery:: radis.tools.new_fitting.get_conditions
+
+    """
+
+    # LOG THE INPUT CONDITIONS (JSON OR DICT)
+
+    if type(input_conditions) == str:  # Must be a JSON path
+
+        with open(input_conditions, "r") as f:  # Load JSON as dict
+            conditions = json.load(f)
+            f.close()
+
+    elif type(input_conditions) == dict:  # Must be just a dict
+
+        conditions = input_conditions
+
+    else:
+
+        raise TypeError(
+            f"Unrecognized input. Got type {type(input_conditions)}, expecting str or dict."
+        )
+
+    # READ "fit" KEY AND LOAD IT INTO NEWLY INITIALIZED PARAMETERS OBJECT
+
+    params = Parameters()  # Initiate Parameters object
+
+    fit_params = conditions.pop("fit")
+    dhb = 1000  # Default half-bound for fit temperatures, in case user don't specify bounds
+
+    # Deal with "Tvib", especially if it has multiple temperatures
+
+    if "Tvib" in fit_params:
+
+        Tvib = fit_params["Tvib"]
+
+        if (type(Tvib) == list) or (
+            type(Tvib) == tuple
+        ):  # Multiple temperatures detected
+
+            # Split Tvib[] into Tvib0, Tvib1, Tvib2,... for later being put into Parameters
+            for i in range(len(Tvib)):
+                fit_params[f"Tvib{i}"] = Tvib[i]
+
+            # Destroy the original "Tvib" key
+            fit_params.pop("Tvib")
+
+    # Deal with "offset", which is something like "-0.2 nm", as requested by Mr. Minou
+
+    if "offset" in fit_params:
+
+        offset_value, offset_unit = fit_params.pop("offset").split()
+        if offset_unit == "cm-1":
+            offset_unitName = "cm1"
+        elif offset_unit == "nm":
+            offset_unitName = "nm"
+        else:
+            raise ValueError(
+                f'Unrecognized offset unit {offset_unit}. Only "nm" and "cm-1" are accepted.'
+            )
+
+        # The reason for all the mess above is, LMFIT does not accept parameter name with hyphen like cm-1.
+        # Thus, I remove the hyphen when assigning its name into LMFIT Parameters object.
+
+        # Get numerical value only, and add unit to name for later identification
+        fit_params[f"offset{offset_unitName}"] = float(offset_value)
+
+    # Put every fit parameter into Parameters, with the default bounding range
+
+    for param in fit_params:
+
+        init_val = fit_params[param]
+
+        if param == "mole_fraction":
+            init_bound = [0, 1]
+        elif "offset" in param:  # Either "offsetnm" or "offsetcm1"
+            init_bound = [-1, 1]
+        else:
+            init_bound = [(init_val - dhb) if init_val > dhb else 0, init_val + dhb]
+
+        params.add(
+            param,
+            value=init_val,
+            min=init_bound[0],
+            max=init_bound[1],
+        )
+
+    # READ "bounds" KEY AND RE-ASSIGN DEFAULT BOUNDING RANGES WITH USER-DEFINED ONES
+
+    if "bounds" in conditions:
+
+        bounds = conditions.pop("bounds")
+
+        # Deal with "Tvib", especially if it has multiple temperature-bounding ranges
+
+        if "Tvib" in bounds:
+
+            bound_Tvib = bounds["Tvib"]
+
+            if (
+                len(bound_Tvib) > 1
+            ):  # Not sure if it's multiple ranges, or just 1 range with 2 ends
+                if type(bound_Tvib[0]) in [
+                    list,
+                    tuple,
+                ]:  # Now we sure it's multiple ranges
+
+                    # Split Tvib[] into Tvib0, Tvib1, Tvib2,... for later being put into Parameters
+                    for i in range(len(bound_Tvib)):
+                        bounds[f"Tvib{i}"] = bound_Tvib[i]
+
+                    # Destroy the original key
+                    bounds.pop("Tvib")
+
+        # Deal with "offset", especially now the offset parameter is either "offsetnm" or "offsetcm1"
+
+        if "offset" in bounds:
+
+            bound_offset = bounds.pop("offset")
+
+            if "offsetnm" in params:
+                bounds["offsetnm"] = bound_offset
+
+            if "offsetcm1" in params:
+                bounds["offsetcm1"] = bound_offset
+
+        # Set user-defined bounding ranges
+
+        for bound_param in bounds:
+
+            bound_val = bounds[bound_param]
+
+            params[bound_param].set(min=bound_val[0], max=bound_val[1])
+
+    return conditions, params
+
+
+# -------------------------------------------------------------------------------------------- #
+
+
+def spectrum_refinement(s_data, conditions, verbose) -> Union[Spectrum, dict]:
+    """Receive an experimental spectrum and further refine it according to the provided pipeline
+    and ground-truths. Refinement process includes extracting the desired spectrum quantity,
+    removing NaN values, and other additional convolutions. Finally, a refined Spectrum object is
+    returned, along with the original condition as a Dictionary object.
+
+    Parameters
+    ----------
+    s_data : Spectrum
+        experimental spectrum loaded from the path given in input JSON file.
+    conditions : dict
+        a Dictionary containing all ground-truth information, and also spectrum refinements or
+        convolutions (desired spectrum quantity, normalization, etc.), as well as fitting process
+        (max loop allowed, terminal tolerance, etc.).
+
+    Other parameters
+    ----------------
+    verbose : bool
+        by default, True, print details about the refinement progress.
+
+    Returns
+    -------
+    s_refined : Spectrum
+        the refined spectrum.
+    conditions : dict
+        the input conditions that might have been added fit_var (in case user didn't).
+
+    Examples
+    --------
+
+    .. minigallery:: radis.tools.new_fitting.spectrum_refinement
+
+    """
+
+    # Extract spectrum
+
+    pipeline = conditions["pipeline"]
+
+    if "fit_var" in pipeline:
+        fit_var = pipeline["fit_var"]  # Acquire the stated quantity
+    else:
+        fit_var = s_data.get_vars()[0]  # If not stated, take first quantity
+        conditions["pipeline"]["fit_var"] = fit_var  # And add to the dict
+
+    s_data_wav, s_data_val = s_data.get(fit_var)
+
+    if verbose:
+        print(f"\nAcquired spectral quantity '{fit_var}' from the spectrum.")
+
+    # Remove NaN values. A wise man once said, "Nan is good but only in India"
+
+    s_data_mtr = np.vstack(
+        (s_data_wav, s_data_val)
+    )  # Merge wavelength/wavenumber and intensity into pairs
+    s_data_mtr = s_data_mtr[:, ~np.isnan(s_data_mtr).any(axis=0)]  # Purge NaN pairs
+
+    if verbose:
+        print(f"NaN values successfully purged.", end="")
+        print(f"Number of data points left: {len(s_data_mtr[0])} points.")
+
+    # Recreate the data spectrum with the spectral quantity
+
+    s_refined = (
+        Spectrum(
+            {fit_var: (s_data_mtr[0], s_data_mtr[1])},
+            wunit=s_data.get_waveunit(),
+            units={fit_var: s_data.units[fit_var]},
+        )
+        .take(fit_var)
+        .sort()
+    )
+
+    # Further refinement
+
+    # Apply normalization if required
+    if "normalize" in pipeline:
+        if pipeline["normalize"]:
+            s_refined = s_refined.normalize()
+            if verbose:
+                print("Normalization applied.")
+
+    # Currently supporting normalization. May add more in the future if requested.
+
+    return s_refined, conditions
+
+
+# -------------------------------------------------------------------------------------------- #
+
+
+def fit_spectrum(
+    s_exp=None,
+    fit_params=None,
+    model=None,
+    pipeline=None,
+    bounds=None,
+    input_file=None,
+    verbose=True,
+    show_plot=True,
+    fit_kws={},
+) -> Union[Spectrum, MinimizerResult, dict]:
+    """Fit an experimental spectrum (from here referred as "data spectrum") with a modeled one,
+    then derive the fit results. Data spectrum is loaded from the path stated in the JSON file,
+    while model spectrum is generated based on the conditions stated in the JSON file, too.
+
+    Parameters
+    ----------
+    s_exp : Spectrum
+        a Spectrum object, containing the experimental spectrum.
+    fit_params : dict
+        a dict object, containing fit parameters and their initial values.
+    bounds : dict
+        a dict object, optional, containing bounding ranges of those fit parameters. If not stated,
+        all fit parameters will receive default bounding ranges.
+    model : dict
+        a dict object, containing experimental conditions and some additional convolutions for
+        modeling the spectrum.
+    pipeline : dict
+        a dict object, containing some preference properties of fitting process.
+    input_file : str
+        path to the JSON input file from the script calling it, by default, None. If this parameter
+        is defined, it means user uses JSON file for inputing everything, and so will discard all
+        other input parameters. For example:: "./test_dir/CO2_measured_spectrum_4-5um.json"
+
+    Other parameters
+    ----------
+    verbose : bool
+        print details about each loop of the fitting process. By default, False.
+    show_plot : bool
+        show experimental spectrum before and after being fitted. By default, True. Showing the plot
+        will pause the runtime, so if you are running benchmark which involves multiple fitting cases,
+        set it to False.
+    fit_kws: dict
+        Options to pass to the minimizer being used. See :py:func:`~lmfit.minimizer.minimize`
+
+    Returns
+    -------
+    s_best: Spectrum
+        visualization of the best fit results obtained, as a spectrum.
+    best_fit: MinimizerResult
+        best fit results, output of LMFIT MinimizeResult.
+    log: dict
+        a Dictionary storing runtime log of the fitting process that are
+        not quite covered by the Minimizer, including: residual and fit
+        values after each fitting loop, and total time elapsed.
+
+    Examples
+    --------
+
+    ::
+        from radis import load_spec
+        from radis.test.utils import getTestFile
+        from radis.tools.new_fitting import fit_spectrum
+
+        # Load an experimental spectrum. You can prepare yours, or fetch one of them in the radis/test/files directory.
+        my_spec = getTestFile("synth-NH3-1-500-2000cm-P10-mf0.01-p1.spec")
+        s_experimental = load_spec(my_spec)
+
+        # Experimental conditions which will be used for spectrum modeling. Basically, these are known ground-truths.
+        experimental_conditions = {
+            "molecule": "NH3",  # Molecule ID
+            "isotope": "1",  # Isotope ID, can have multiple at once
+            "wmin": 1000,  # Starting wavelength/wavenumber to be cropped out from the original experimental spectrum.
+            "wmax": 1050,  # Ending wavelength/wavenumber for the cropping range.
+            "wunit": "cm-1",  # Accompanying unit of those 2 wavelengths/wavenumbers above.
+            "mole_fraction": 0.01,  # Species mole fraction, from 0 to 1.
+            "pressure": 10,  # Total pressure of gas, in "bar" unit by default, but you can also use Astropy units.
+            "path_length": 1,  # Experimental path length, in "cm" unit by default, but you can also use Astropy units.
+            "slit": "1 nm",  # Experimental slit, must be a blank space separating slit amount and unit.
+            "offset": "-0.2 nm",  # Experimental offset, must be a blank space separating offset amount and unit.
+            "wstep": 0.003,  # Resolution of wavenumber grid, in cm-1.
+            "databank": "hitran",  # Databank used for the spectrum calculation. Must be stated.
+        }
+
+        # List of parameters to be fitted, accompanied by their initial values.
+        fit_parameters = {
+            "Tgas": 700,  # Gas temperature, in K.
+        }
+
+        # List of bounding ranges applied for those fit parameters above.
+        bounding_ranges = {
+            "Tgas": [
+                500,
+                2000,
+            ],
+        }
+
+        # Fitting pipeline setups.
+        fit_properties = {
+            "method": "leastsq",  # Preferred fitting method. By default, "leastsq".
+            "fit_var": "radiance",  # Spectral quantity to be extracted for fitting process, such as "radiance", "absorbance", etc.
+            "normalize": False,  # Either applying normalization on both spectra or not.
+            "max_loop": 150,  # Max number of loops allowed. By default, 200.
+            "tol": 1e-15,  # Fitting tolerance, only applicable for "lbfgsb" method.
+        }
+
+        # Conduct the fitting process!
+        s_best, result, log = fit_spectrum(
+            s_exp=s_experimental,  # Experimental spectrum.
+            fit_params=fit_parameters,  # Fit parameters.
+            bounds=bounding_ranges,  # Bounding ranges for those fit parameters.
+            model=experimental_conditions,  # Experimental ground-truths conditions.
+            pipeline=fit_properties,  # Fitting pipeline references.
+            verbose=False,  # If you want a clean result, stay False. If you want to see more about each loop, True.
+        )
+    .. minigallery:: radis.tools.new_fitting.fit_spectrum
+    Notes
+    -----
+    For the fitting method, you can try one among 17 different fitting methods and algorithms of LMFIT, introduced
+    in `LMFIT method list <https://lmfit.github.io/lmfit-py/fitting.html#choosing-different-fitting-methods>`.
+    See Also
+    --------
+    :py:func:`~radis.tools.fitting.fit_spectrum()`,
+    :py:meth:`~radis.lbl.factory.SpectrumFactory.fit_spectrum`,
+    :py:mod:`fitroom`
+
+    """
+
+    begin = time.time()  # Start the fitting time counter
+
+    if verbose:
+        print(
+            "\n======================= COMMENCE FITTING PROCESS ======================="
+        )
+
+    # ACQUIRE AND REFINE EXPERIMENTAL SPECTRUM s_data
+
+    if input_file != None:  # The user uses JSON file
+
+        # Load data from JSON file, then create a Parameters object
+        conditions, params = get_conditions(input_file)
+
+        # Get s_data spectrum from the path stated in acquired JSON data, assuming the JSON
+        # and the .spec files are in the same directory (it SHOULD be!)
+
+        fileName = conditions["model"].pop("fileName")
+        if fileName[-5:] != ".spec":  # fileName doesn't have ".spec" extension
+            raise ValueError('Warning: fileName must include ".spec" extension!')
+
+        input_dir = "/".join(input_file.split("/")[0:-1]) + "/"
+        spec_path = input_dir + fileName
+
+        # Load and crop the experimental spectrum
+        s_data = load_spec(spec_path).crop(
+            conditions["model"]["wmin"],
+            conditions["model"]["wmax"],
+            conditions["model"]["wunit"],
+        )
+
+    else:  # The user states a bunch of dicts separately
+
+        # Merge all separated dict inputs into a single dict first
+
+        conditions = {
+            "model": model,
+            "fit": fit_params,
+            "bounds": bounds if bounds is not None else {},
+            "pipeline": pipeline,
+        }
+
+        # Load data, create Parameters object, from the above dict
+        conditions, params = get_conditions(conditions)
+
+        # Load and crop the experimental spectrum
+        if "wunit" in conditions["model"]:  # Users state wunit directly
+            s_data = s_exp.crop(
+                conditions["model"]["wmin"],
+                conditions["model"]["wmax"],
+                conditions["model"]["wunit"],
+            )
+        else:  # Users don't state wunit directly, probably astropy units
+            s_data = s_exp.crop(
+                conditions["model"]["wmin"],
+                conditions["model"]["wmax"],
+            )
+
+    # Log the time mark when experimental data is retrieved
+    end_exp_load = time.time()
+    time_exp_load = end_exp_load - begin
+
+    if verbose:
+        print(f"\nSuccessfully retrieved the experimental data in {time_exp_load}s.")
+
+    # Further refine the data spectrum before calculating diff
+
+    s_data, conditions = spectrum_refinement(s_data, conditions, verbose)
+
+    pipeline = conditions["pipeline"]
+    fit_var = pipeline["fit_var"]
+
+    # Log the time mark when spectrum refinement is over
+    end_exp_refine = time.time()
+    time_exp_refine = end_exp_refine - end_exp_load
+
+    if verbose:
+        print(f"Successfully refined the experimental data in {time_exp_refine}s.")
+    if show_plot:
+        s_data.plot(show=True)
+
+    # PRE-MINIMIZATION SETUP
+
+    # Create a Spectrum Factory object for modeling spectra
+
+    kwargs = {}
+    model = conditions["model"]
+
+    # List of keys that are included in "model", but not included in SpectrumFactory syntax
+    ignore_keys = [
+        "slit",
+        "offset",
+        "databank",
+    ]
+
+    for cond in model:
+        if cond not in ignore_keys:
+            kwargs[cond] = model[cond]
+
+    sf = SpectrumFactory(
+        **kwargs,
+        verbose=False,
+        warnings={
+            "MissingSelfBroadeningWarning": "ignore",
+            "UserWarning": "ignore",
+            "NegativeEnergiesWarning": "ignore",
+            "HighTemperatureWarning": "ignore",
+        },
+    )
+
+    # Decide the type of model - LTE or Non-LTE
+    LTE = True  # LTE == True means it's LTE
+    if ("Tvib" in params) or ("Tvib0" in params):
+        LTE = False  # LTE == False means it's non-LTE
+
+    # Determine fitting method, either stated by user or "lbfgsb" by default
+    if "method" in pipeline:
+        method = pipeline["method"]  # User-defined
+    else:
+        method = "leastsq"  # By default
+
+    # Determine additional fitting conditions for the minimizer
+
+    fit_kws.update(
+        {
+            "max_nfev": pipeline["max_loop"]
+            if "max_loop" in pipeline
+            else 200,  # Default max number of loops: 200
+        }
+    )
+
+    if "tol" in pipeline:
+        if pipeline["method"] == "lbfgsb":
+            fit_kws["tol"] = pipeline["tol"]
+        else:
+            print('"tol" parameter spotted but "method" is not "lbfgsb"!')
+
+    # Prepare fitting log
+    log = {"residual": [], "fit_vals": [], "time_fitting": 0}
+
+    # COMMENCE THE FITTING PROCESS
+
+    databank = model.pop("databank")
+
+    # Log the time mark when fitting begins
+    begin_fitting = time.time()
+
+    # For LTE spectra
+    if LTE:
+
+        sf.fetch_databank(databank, load_columns="equilibrium")
+
+        if verbose:
+            print("\nCommence fitting process for LTE spectrum!\n")
+
+        result = minimize(
+            residual_LTE,
+            params,
+            method=method,
+            args=[conditions, s_data, sf, log, verbose],
+            **fit_kws,
+        )
+
+    # For non-LTE spectra
+    if not (LTE):
+
+        sf.fetch_databank(databank, load_columns="noneq")
+
+        if verbose:
+            print("\nCommence fitting process for non-LTE spectrum!")
+
+        result = minimize(
+            residual_NonLTE,
+            params,
+            method=method,
+            args=[conditions, s_data, sf, log, verbose],
+            **fit_kws,
+        )
+
+    # POST-MINIMIZATION REPORT
+
+    print(fit_report(result), end="\n")  # Report the fitting result
+
+    # REGENERATE THE BEST-FIT SPECTRUM, AS A RESULT FROM THE MINIMIZER
+
+    # Load initial values of fit parameters
+    fit_show = {}
+    offset_unit = ""
+    for name, param in result.params.items():
+        if "offset" in name:  # "offset" detected
+            offset_value = float(param.value)
+            offset_unit = name[6:]
+            # Deal with "cm1"
+            if offset_unit == "cm1":
+                offset_unit = "cm-1"
+        else:  # Other parameters not related to offset
+            fit_show[name] = float(param.value)
+    fit_show["name"] = "best_fit"
+
+    # Generate best fitted spectrum result
+    if LTE:
+        s_result = sf.eq_spectrum(**fit_show)
+    else:
+        s_result = sf.non_eq_spectrum(**fit_show)
+
+    # In case offset is detected in parameters
+    if offset_unit != "":
+        s_result = s_result.offset(offset_value, offset_unit)
+
+    # Apply slit stated in "model"
+    if "slit" in model:
+
+        slit_model = model["slit"]
+
+        # The user uses simple format of "[value] [unit]", such as "-0.2 nm"
+        if isinstance(slit_model, str):
+            slit_val, slit_unit = slit_model.split()
+            s_result = s_result.apply_slit(float(slit_val), slit_unit)
+
+        # The user uses a dict with complex format of slit function, unit, shape, center wavespace, dispersion, etc.
+        if isinstance(slit_model, dict):
+            kwargs = {}
+            for cond in slit_model:
+                kwargs[cond] = slit_model[cond]
+            s_result = s_result.apply_slit(**kwargs)
+
+    # Take spectral quantity
+    s_result = s_result.take(fit_var)
+
+    # Apply offset stated in "model"
+    if "offset" in model:
+        off_val, off_unit = model["offset"].split()
+        s_result = s_result.offset(float(off_val), off_unit)
+
+    # Apply normalization
+    if "normalize" in pipeline:
+        if pipeline["normalize"]:
+            s_result = s_result.normalize()
+
+    # Log the time mark when fitting is over
+    end_fitting = time.time()
+    time_fitting = float(end_fitting - begin_fitting)
+
+    if verbose:
+        print(f"\nSuccessfully finished the fitting process in {time_fitting}s.")
+
+    log["time_fitting"] = time_fitting
+
+    # PLOT THE DIFFERENCE BETWEEN THE TWO
+
+    # If show_plot = True by default, show the diff plot
+    if show_plot:
+        plot_diff(
+            s_data,
+            s_result,
+            fit_var,
+            method=["diff", "ratio"],
+            show=True,
+        )
+
+    if verbose:
+        print(
+            "\n======================== END OF FITTING PROCESS ========================\n"
+        )
+
+    return s_result, result, log
diff --git a/setup.py b/setup.py
index 7ba1f9679..b23194a87 100644
--- a/setup.py
+++ b/setup.py
@@ -278,6 +278,7 @@ def run_setup(with_binary):
             "scipy>=1.4.0",
             # "tuna",  # to generate visual/interactive performance profiles
             "vaex>=4.9.2",  # load HDF5 files  (version needed to fix https://github.com/radis/radis/issues/486). #TODO : install only required sub-packages
+            "lmfit",  # for new fitting modules
         ],
         extras_require={
             "dev": [


Test patch:
diff --git a/docs/dev/_test.rst b/docs/dev/_test.rst
index 6d24882c8..a7ae367e5 100644
--- a/docs/dev/_test.rst
+++ b/docs/dev/_test.rst
@@ -108,6 +108,9 @@ adding the following lines within your test function::
         import matplotlib.pyplot as plt
         plt.ion()   # dont get stuck with Matplotlib if executing through pytest
 
+You can refer to Pull Request: https://github.com/radis/radis/pull/495 to see
+how test cases are written when a new feature is added.
+
 See: https://github.com/statsmodels/statsmodels/issues/3697
 
 
@@ -127,7 +130,7 @@ The path to these test files can be retrieved using the :py:func:`~radis.test.ut
 Load a line database file ::
 
     from radis.test.utils import getTestFile
-    from radis.io.hitran import hit2df
+    from radis.api.hitranapi import hit2df
     df = hit2df(getTestFile("hitran_CO_fragment.par"))
 
     print(df)  # replace with your test code
diff --git a/radis/test/files/Corentin_0_100cm_DownSampled_20cm_10pctCO2_1-wc-gw450-gr300-sl1500-acc5000-.spec b/radis/test/files/Corentin_0_100cm_DownSampled_20cm_10pctCO2_1-wc-gw450-gr300-sl1500-acc5000-.spec
new file mode 100644
index 000000000..840573e03
Binary files /dev/null and b/radis/test/files/Corentin_0_100cm_DownSampled_20cm_10pctCO2_1-wc-gw450-gr300-sl1500-acc5000-.spec differ
diff --git a/radis/test/files/synth-NH3-1-500-2000cm-P10-mf0.01-p1.spec b/radis/test/files/synth-NH3-1-500-2000cm-P10-mf0.01-p1.spec
new file mode 100644
index 000000000..68d44abf3
Binary files /dev/null and b/radis/test/files/synth-NH3-1-500-2000cm-P10-mf0.01-p1.spec differ
diff --git a/radis/test/files/trimmed_1857_VoigtCO_Minesi.mat b/radis/test/files/trimmed_1857_VoigtCO_Minesi.mat
new file mode 100644
index 000000000..67eac2100
Binary files /dev/null and b/radis/test/files/trimmed_1857_VoigtCO_Minesi.mat differ
diff --git a/radis/test/io/test_geisa.py b/radis/test/io/test_geisa.py
index 293f74351..7b0b8b7d7 100644
--- a/radis/test/io/test_geisa.py
+++ b/radis/test/io/test_geisa.py
@@ -12,9 +12,9 @@
 -------------------------------------------------------------------------------
 """
 
-from radis.io.geisa import gei2df
+import pytest
 
-# from radis.misc.warning import IrrelevantFileWarning
+from radis.api.geisaapi import gei2df
 from radis.test.utils import getTestFile
 
 files = {
@@ -24,6 +24,7 @@
 }
 
 
+@pytest.mark.fast
 def test_local_geisa_co(verbose=True, warnings=True, **kwargs):
 
     # 1. Load local file
@@ -54,6 +55,7 @@ def test_local_geisa_co(verbose=True, warnings=True, **kwargs):
     return True
 
 
+@pytest.mark.fast
 def test_local_geisa_h2o(verbose=True, warnings=True, **kwargs):
 
     # 1. Load local file
@@ -84,6 +86,7 @@ def test_local_geisa_h2o(verbose=True, warnings=True, **kwargs):
     return True
 
 
+@pytest.mark.fast
 def test_local_geisa_co2(verbose=True, warnings=True, **kwargs):
 
     # 1. Load local file
diff --git a/radis/test/io/test_hdf5.py b/radis/test/io/test_hdf5.py
index cbbdafc2f..d4832ee84 100644
--- a/radis/test/io/test_hdf5.py
+++ b/radis/test/io/test_hdf5.py
@@ -7,14 +7,14 @@
 
 import pytest
 
-from radis.io.hdf5 import DataFileManager, hdf2df
+from radis.api.hdf5 import DataFileManager, hdf2df
 from radis.io.hitemp import fetch_hitemp
 from radis.misc.config import getDatabankEntries
 
 
 @pytest.mark.fast
 def test_hdf5_io_engines(*args, **kwargs):
-    """Test different engines implemented in :py:class:`radis.io.hdf5.HDF5Manager`"""
+    """Test different engines implemented in :py:class:`radis.api.hdf5.DataFileManager`"""
 
     import os
     from os.path import exists
@@ -78,7 +78,7 @@ def test_hdf5_io_engines(*args, **kwargs):
 def test_local_hdf5_lines_loading(*args, **kwargs):
     """
     We use the OH HITEMP line database to test :py:func:`~radis.io.hitemp.fetch_hitemp`
-    and :py:func:`~radis.io.hdf5.hdf2df`
+    and :py:func:`~radis.api.hdf5.hdf2df`
 
     - Partial loading (only specific wavenumbers)
     - Only certain isotopes
diff --git a/radis/test/io/test_hitemp.py b/radis/test/io/test_hitemp.py
index 06a1883bf..b49a99462 100644
--- a/radis/test/io/test_hitemp.py
+++ b/radis/test/io/test_hitemp.py
@@ -8,7 +8,12 @@
 
 import pytest
 
-from radis.io.hitemp import HITEMP_MOLECULES, HITEMPDatabaseManager, fetch_hitemp
+from radis.api.hitempapi import (
+    HITEMP_MOLECULES,
+    HITEMPDatabaseManager,
+    keep_only_relevant,
+)
+from radis.io.hitemp import fetch_hitemp
 from radis.misc.config import getDatabankList
 
 
@@ -37,7 +42,6 @@ def test_no_redownload(*args, **kwargs):
 
 @pytest.mark.fast
 def test_relevant_files_filter():
-    from radis.io.hitemp import keep_only_relevant
 
     files = [
         "02_00000-00500_HITEMP2010.zip",
@@ -178,8 +182,6 @@ def test_fetch_hitemp_partial_download_CO2(verbose=True, *args, **kwargs):
     """
     from os.path import basename
 
-    from radis.io.hitemp import keep_only_relevant
-
     wmin = 2700
     wmax = 3000
 
diff --git a/radis/test/io/test_hitran_cdsd.py b/radis/test/io/test_hitran_cdsd.py
index 683cc7395..66cc99fd1 100644
--- a/radis/test/io/test_hitran_cdsd.py
+++ b/radis/test/io/test_hitran_cdsd.py
@@ -28,16 +28,16 @@
 import numpy as np
 import pytest
 
-from radis.io.cdsd import cdsd2df
-from radis.io.hitran import hit2df
+from radis.api.cdsdapi import cdsd2df
+from radis.api.hitranapi import hit2df
 from radis.misc.warning import IrrelevantFileWarning
 from radis.test.utils import getTestFile, setup_test_line_databases
 
 
 @pytest.mark.fast
 def test_hitran_names_match(verbose=True, warnings=True, *args, **kwargs):
-    """Compare that HITRAN species defined in :mod:`radis.io.hitran` match
-    the nomenclature dictionary : :py:data:`radis.io.hitran.trans`.
+    """Compare that HITRAN species defined in :mod:`radis.api.hitranapi` match
+    the nomenclature dictionary : :py:data:`radis.api.hitranapi.trans`.
 
     This should be ensured by developers when adding new species.
     """
diff --git a/radis/test/lbl/test_base.py b/radis/test/lbl/test_base.py
index 75bab4e51..184300484 100644
--- a/radis/test/lbl/test_base.py
+++ b/radis/test/lbl/test_base.py
@@ -29,7 +29,7 @@ def test_linestrength_calculations(*args, **kwargs):
     """
 
     # Test linestrength calculations
-    from radis.io.hitran import hit2df
+    from radis.api.hitranapi import hit2df
     from radis.test.utils import getTestFile
 
     df = hit2df(getTestFile("hitran_co_3iso_2000_2300cm.par"))
diff --git a/radis/test/lbl/test_calc.py b/radis/test/lbl/test_calc.py
index 883f30c1d..64262a9f0 100644
--- a/radis/test/lbl/test_calc.py
+++ b/radis/test/lbl/test_calc.py
@@ -819,6 +819,84 @@ def test_check_wavelength_range(verbose=True, warnings=True, *args, **kwargs):
     return True
 
 
+def test_non_air_diluent_calc(verbose=True, plot=False, warnings=True, *args, **kwargs):
+    from radis import plot_diff
+
+    if verbose:
+        printm("Regenerating database with all params, and calculating non_air diluent")
+
+    # Regenerating database of CO and calculating non_air_diluent spectrum
+    s1 = calc_spectrum(
+        wavenum_min=2245,
+        wavenum_max=2250,
+        Tgas=700,
+        path_length=0.1,
+        molecule="CO",
+        mole_fraction=0.2,
+        isotope=1,
+        databank="hitran",
+        use_cached="regen",
+        diluent={"CO2": 0.2, "H2O": 0.1, "air": 0.5},
+        export_lines=True,
+    )
+
+    # Calculating spectrum with air as diluent
+    s2 = calc_spectrum(
+        wavenum_min=2245,
+        wavenum_max=2250,
+        Tgas=700,
+        path_length=0.1,
+        molecule="CO",
+        mole_fraction=0.2,
+        isotope=1,
+        databank="hitran",
+        export_lines=True,
+    )
+
+    assert s1.get_conditions()["diluents"] == {"CO2": 0.2, "H2O": 0.1, "air": 0.5}
+    assert s2.get_conditions()["diluents"] == {"air": 0.8}
+
+    hwhm_voigt_s1 = s1.lines.hwhm_voigt
+    hwhm_voigt_s2 = s2.lines.hwhm_voigt
+
+    assert (hwhm_voigt_s2 < hwhm_voigt_s1).all()
+    assert np.isclose(hwhm_voigt_s2[0], 0.022718389546218788)
+    assert np.isclose(hwhm_voigt_s1[0], 0.02530070148135749)
+
+    if plot:
+        plot_diff(
+            s1,
+            s2,
+            method=["diff"],
+            label1="Diluent CO2, H2O, air",
+            label2="Diluent air",
+            title="Diluent CO2:0.2, H2O:0.1, air:0.5",
+        )
+
+    return True
+
+
+def test_diluent_invalid(verbose=True, plot=False, *args, **kwargs):
+
+    with pytest.raises(KeyError) as err:
+        calc_spectrum(
+            wavenum_min=2245,
+            wavenum_max=2250,
+            Tgas=700,
+            path_length=0.1,
+            molecule="CO",
+            mole_fraction=0.2,
+            isotope=1,
+            databank="hitran",
+            diluent="CO",
+        )
+
+    assert (
+        "is being called as molecule and diluent, please remove it from diluent."
+        in str(err.value)
+    )
+
+
 def _run_testcases(plot=True, verbose=True, warnings=True, *args, **kwargs):
 
     # Test sPlanck and conversion functions
@@ -852,6 +930,7 @@ def _run_testcases(plot=True, verbose=True, warnings=True, *args, **kwargs):
     test_calc_spectrum_multiple_molecules_wstep_auto()
 
     test_check_wavelength_range()
+    test_non_air_diluent_calc()
 
     return True
 
diff --git a/radis/test/lbl/test_factory.py b/radis/test/lbl/test_factory.py
index be5dccf6f..90bdce7ac 100644
--- a/radis/test/lbl/test_factory.py
+++ b/radis/test/lbl/test_factory.py
@@ -644,6 +644,87 @@ def test_all_spectrum_using_wstep_auto(verbose=True, plot=False, *args, **kwargs
     assert sf._wstep == "auto"
 
 
+@pytest.mark.fast
+def test_non_air_diluent(verbose=True, plot=False, *args, **kwargs):
+
+    sf = SpectrumFactory(
+        wavelength_min=4200,
+        wavelength_max=4500,
+        cutoff=1e-23,
+        molecule="CO",
+        isotope="1,2",
+        truncation=5,
+        neighbour_lines=10,
+        path_length=0.1,
+        mole_fraction=0.1,
+        medium="vacuum",
+        optimization=None,
+        verbose=verbose,
+    )
+    sf.warnings.update(
+        {
+            "MissingSelfBroadeningWarning": "ignore",
+            "NegativeEnergiesWarning": "ignore",
+            "LinestrengthCutoffWarning": "ignore",
+            "HighTemperatureWarning": "ignore",
+            "AccuracyWarning": "ignore",
+            "PerformanceWarning": "ignore",
+        }
+    )
+
+    sf.load_databank("HITRAN-CO", load_columns=["diluent", "equilibrium"])
+
+    # Calculating spectrum for different diluents
+    sf.eq_spectrum(Tgas=2000)
+    wl1 = sf.df1["hwhm_lorentz"]
+    assert sf._diluent == {"air": 0.9}
+
+    sf.eq_spectrum(Tgas=2000, diluent={"CO2": 0.4, "air": 0.5})
+    wl2 = sf.df1["hwhm_lorentz"]
+    assert sf._diluent == {"CO2": 0.4, "air": 0.5}
+
+    sf.eq_spectrum(Tgas=2000, diluent="CO2")
+    wl3 = sf.df1["hwhm_lorentz"]
+    assert sf._diluent == {"CO2": 0.9}
+
+    assert (wl1 < wl2).all() and (wl2 < wl3).all()
+
+
+@pytest.mark.fast
+def test_diluents_molefraction(verbose=True, plot=False, *args, **kwargs):
+    from radis.misc.warning import MoleFractionError
+
+    sf = SpectrumFactory(
+        wavelength_min=4300,
+        wavelength_max=4500,
+        wstep=0.01,
+        cutoff=1e-30,
+        pressure=1,
+        isotope=[1],
+        verbose=verbose,
+        diluent={"CO2": 0.4, "air": 0.2},
+    )
+    sf.load_databank("HITRAN-CO", load_columns=["diluent", "equilibrium"])
+    # Molefraction (molecule + diluent) < 1
+    with pytest.raises(MoleFractionError) as err:
+        sf.eq_spectrum(Tgas=300, mole_fraction=0.3)
+    assert (
+        "of molecule and diluents less than 1. Please set appropriate molefraction value of molecule and diluents"
+        in str(err.value)
+    )
+
+    # Molefraction (molecule + diluent) > 1
+    with pytest.raises(MoleFractionError) as err:
+        sf.eq_spectrum(Tgas=300, mole_fraction=0.6)
+    assert (
+        "of molecule and diluents greater than 1. Please set appropriate molefraction value of molecule and diluents."
+        in str(err.value)
+    )
+
+    # Molefraction (molecule + diluent) == 1
+    sf.eq_spectrum(Tgas=300, mole_fraction=0.4)
+
+
 # --------------------------
 if __name__ == "__main__":
 
diff --git a/radis/test/spectrum/test_operations.py b/radis/test/spectrum/test_operations.py
index f7cceb4da..6fb383dde 100644
--- a/radis/test/spectrum/test_operations.py
+++ b/radis/test/spectrum/test_operations.py
@@ -5,6 +5,7 @@
 @author: erwan
 """
 
+import astropy.units as u
 import numpy as np
 import pytest
 
@@ -37,9 +38,31 @@ def test_crop(verbose=True, *args, **kwargs):
     # Focus on N2(C->B), v'=0, v''=2 band:
     s1.crop(4530, 4533, "nm")
 
-    w = s1.get_wavelength()
-    assert w.min() >= 4530
-    assert w.max() <= 4533
+    w1 = s1.get_wavelength()
+    assert w1.min() >= 4530
+    assert w1.max() <= 4533
+
+    # 3) A crop example replicating the (1) case with astropy unit input - method 1
+
+    # Work with a Spectrum object that was generated by Specair
+    s2 = load_spec(getTestFile("N2C_specair_380nm.spec"), binary=False)
+    # Crop the exactly same range of (1) example
+    s2.crop(0.376 * u.um, 0.381 * u.um)
+
+    w2 = s2.get_wavelength()
+    assert np.array_equal(w, w2)
+
+    # 4) A crop example replicating the (1) case with astropy unit input - method 2
+
+    # Work with a Spectrum object that was generated by Specair
+    s3 = load_spec(getTestFile("N2C_specair_380nm.spec"), binary=False)
+    # Crop the exactly same range of (1) example
+    s3.crop(376e-6, 381e-6, "mm")
+
+    w3 = s3.get_wavelength()
+    assert np.array_equal(w, w3)
+
+    print("Crop test cases passed successfully.")
 
     return True