AerocomEvaluation.update_interface() is not working properly anymore in main-dev
Running AerocomEvaluation.update_interface() by itself (not the call to update_interface in run_evaluation()) fails with the following error:


  File "/home/hansb/lustre/storeA/project/fou/kl/CAMS61/postprocessed_data/repositories/aerocom_evaluation/data_subbed/config_files/cfg_cams61_p3_BASE.py", line 708, in <module>
    stp.update_interface()

  File "/home/hansb/pyaerocom/pyaerocom/web/aerocom_evaluation.py", line 1728, in update_interface
    self.update_menu(**opts)

  File "/home/hansb/pyaerocom/pyaerocom/web/aerocom_evaluation.py", line 1738, in update_menu
    update_menu_evaluation_iface(self, **opts)

  File "/home/hansb/pyaerocom/pyaerocom/web/helpers_evaluation_iface.py", line 365, in update_menu_evaluation_iface
    avail = _get_available_results_dict(config)

  File "/home/hansb/pyaerocom/pyaerocom/web/helpers_evaluation_iface.py", line 225, in _get_available_results_dict
    tab = config.get_web_overview_table()

  File "/home/hansb/pyaerocom/pyaerocom/web/aerocom_evaluation.py", line 1668, in get_web_overview_table
    for f in self.all_map_files:

  File "/home/hansb/pyaerocom/pyaerocom/web/aerocom_evaluation.py", line 379, in all_map_files
    if not os.path.exists(self.out_dirs['map']):

KeyError: 'map'


diff --git a/pyaerocom/colocateddata.py b/pyaerocom/colocateddata.py
index 9d784ca5..9597e220 100644
--- a/pyaerocom/colocateddata.py
+++ b/pyaerocom/colocateddata.py
@@ -1276,6 +1276,28 @@ class ColocatedData(object):
                       .format(os.path.basename(file_path)))
 
     def filter_altitude(self, alt_range, inplace=False):
+        """
+        Apply altitude filter
+
+        Parameters
+        ----------
+        alt_range : list or tuple
+            altitude range to be applied to data (2-element list)
+        inplace : bool, optional
+            Apply filter to this object directly or to a copy.
+            The default is False.
+
+        Raises
+        ------
+        NotImplementedError
+            If data is 4D, i.e. it contains latitude and longitude dimensions.
+
+        Returns
+        -------
+        ColocatedData
+            Filtered data object .
+
+        """
         if not self._check_latlon_coords():
             raise NotImplementedError('Altitude filtering for data with '
                                       'lat and lon dimension is not yet '
@@ -1290,9 +1312,27 @@ class ColocatedData(object):
 
     @staticmethod
     def _filter_altitude_2d(arr, alt_range):
-        if not 'station_name' in arr.dims:
-            raise DataDimensionError('Cannot filter region, require dimension '
-                                      'station_name')
+        """
+        Apply altitude filter to 2D spatiotemporal data
+
+        Parameters
+        ----------
+        arr : DataArray
+            input array to be filtered
+        alt_range : list or tuple
+            altitude range to be applied to data (2-element list)
+
+        Raises
+        ------
+        DataDimensionError
+            if station_name is not the 3rd index
+
+        Returns
+        -------
+        DataArray
+            Filtered object.
+
+        """
         if not list(arr.dims).index('station_name') == 2:
             raise DataDimensionError('station_name must be 3. dimensional index')
 
@@ -1303,6 +1343,22 @@ class ColocatedData(object):
         return filtered
 
     def _check_latlon_coords(self):
+        """
+        Check if latitude and longitude coordinates exists and are NOT dimensions
+
+        Raises
+        ------
+        CoordinateError
+            - if any or both coordinates are missing.
+            - if one (and only one) of both coords is also a dimension
+
+        Returns
+        -------
+        bool
+            True if latitude and longitude are existing coordinates, False if
+            they are also dimensions.
+
+        """
         _check = ('latitude', 'longitude')
         if not all([x in self.coords for x in _check]):
             raise CoordinateError('Missing latitude or longitude coordinate '
@@ -1316,12 +1372,38 @@ class ColocatedData(object):
 
     @staticmethod
     def _filter_country_2d(arr, country, use_country_code):
-        if not 'country' in arr.coords:
+        """
+        Apply country filter in 2D spatiotemporal data
+
+        Parameters
+        ----------
+        arr : DataArray
+            data to be filtered for country.
+        country : str
+            name of country.
+        use_country_code : bool
+            If True, input value for `country` is evaluated against country
+            codes rather than country names.
+
+        Raises
+        ------
+        DataDimensionError
+            If `country` is not a coordinate of input `DataArray`, or, in case
+            `use_country_code=True`, if `country_code` is not a coordinate.
+        DataCoverageError
+            if filtering results in empty data object.
+
+        Returns
+        -------
+        TYPE
+            DESCRIPTION.
+
+        """
+        what = 'country' if not use_country_code else 'country_code'
+        if not what in arr.coords:
             raise DataDimensionError('Cannot filter country {}. No country '
                                      'information available in DataArray'
                                      .format(country))
-
-        what = 'country' if not use_country_code else 'country_code'
         mask = arr[what] == country
         if mask.sum() == 0:
             raise DataCoverageError(
@@ -1331,13 +1413,50 @@ class ColocatedData(object):
 
     @staticmethod
     def _filter_latlon_2d(arr, lat_range, lon_range):
+        """
+        Apply rectangular latitude / longitude filter for 2D data
+
+        2D here means in terms of spatiotemporal dimensions, that is, the
+        object has to have one spatial dimension `station_name` and one
+        temporal dimension `time`.
+
+        ToDo
+        ----
+        Use `sel` with slice, or similar, need to check though how that will
+        apply to filtering that crosses the +180 -> -180 degree longitude
+        border.
+
+        Parameters
+        ----------
+        arr : DataArray
+            data to be filtered.
+        lat_range : list, optional
+            latitude range that is supposed to be applied. If specified, then
+            also lon_range need to be specified, else, region_id is checked
+            against AeroCom default regions (and used if applicable)
+        lon_range : list, optional
+            longitude range that is supposed to be applied. If specified, then
+            also lat_range need to be specified, else, region_id is checked
+            against AeroCom default regions (and used if applicable)
+
+        Raises
+        ------
+        DataDimensionError
+            If index of `station_name` dimension is not at 3rd position in the
+            underlying array.
+        DataCoverageError
+            If no data is available in the specified rectangular region.
 
-        if not 'station_name' in arr.dims:
-            raise DataDimensionError('Cannot filter region, require dimension '
-                                      'station_name')
+        Returns
+        -------
+        ColocatedData
+            Filtered data object.
 
+        """
         if not list(arr.dims).index('station_name') == 2:
-            raise DataDimensionError('station_name must be 3. dimensional index')
+            raise DataDimensionError(
+                'station_name dimension must be at 3rd index position'
+                )
 
         lons, lats = arr.longitude.data, arr.latitude.data
 
@@ -1361,6 +1480,41 @@ class ColocatedData(object):
 
     @staticmethod
     def _filter_latlon_3d(arr, lat_range, lon_range):
+        """
+        Apply rectangular latitude / longitude filter for 3D data
+
+        32D here means in terms of spatiotemporal dimensions, that is, the
+        object has to have two spatial dimensions `latitude` and `longitude`
+        and one temporal dimension `time`.
+
+        ToDo
+        ----
+        Implement filtering that crosses the +180 -> -180 degree longitude
+        border.
+
+        Parameters
+        ----------
+        arr : DataArray
+            data to be filtered.
+        lat_range : list, optional
+            latitude range that is supposed to be applied. If specified, then
+            also lon_range need to be specified, else, region_id is checked
+            against AeroCom default regions (and used if applicable)
+        lon_range : list, optional
+            longitude range that is supposed to be applied. If specified, then
+            also lat_range need to be specified, else, region_id is checked
+            against AeroCom default regions (and used if applicable)
+
+        Raises
+        ------
+        NotImplementedError
+            If input longitude bounds cross the +180 -> -180 border.
+
+        Returns
+        -------
+        ColocatedData
+            Filtered data object.
+        """
         if lon_range[0] > lon_range[1]:
             raise NotImplementedError(
                 'Filtering longitude over 180 deg edge is not yet possible in '
@@ -1374,6 +1528,31 @@ class ColocatedData(object):
 
     def apply_country_filter(self, region_id, use_country_code=False,
                              inplace=False):
+        """
+        Apply country filter
+
+        Parameters
+        ----------
+        region_id : str
+            country name or code.
+        use_country_code : bool, optional
+            If True, input value for `country` is evaluated against country
+            codes rather than country names. Defaults to False.
+        inplace : bool, optional
+            Apply filter to this object directly or to a copy.
+            The default is False.
+
+        Raises
+        ------
+        NotImplementedError
+            if data is 4D (i.e. it has latitude and longitude dimensions).
+
+        Returns
+        -------
+        ColocatedData
+            filtered data object.
+
+        """
         data = self if inplace else self.copy()
         is_2d = data._check_latlon_coords()
         if is_2d:
@@ -1387,9 +1566,7 @@ class ColocatedData(object):
 
     def apply_latlon_filter(self, lat_range=None, lon_range=None,
                             region_id=None, inplace=False):
-        """Apply regional filter
-
-        Returns new object filtered for input coordinate range
+        """Apply rectangular latitude/longitude filter
 
         Parameters
         ----------
@@ -1404,6 +1581,14 @@ class ColocatedData(object):
         region_id : str
             name of region to be applied. If provided (i.e. not None) then
             input args `lat_range` and `lon_range` are ignored
+        inplace : bool, optional
+            Apply filter to this object directly or to a copy.
+            The default is False.
+
+        Raises
+        ------
+        ValueError
+            if lower latitude bound exceeds upper latitude bound.
 
         Returns
         -------
@@ -1463,6 +1648,11 @@ class ColocatedData(object):
             If True, the current instance, is modified, else a new instance
             of `ColocatedData` is created and filtered. The default is False.
 
+        Raises
+        ------
+        DataCoverageError
+            if filtering results in empty data object.
+
         Returns
         -------
         data : ColocatedData
@@ -1478,12 +1668,15 @@ class ColocatedData(object):
             data = data.flatten_latlondim_station_name()
         arr = data.data
         drop_idx = []
+        nstats = len(arr.station_name)
         for (lat, lon, stat) in data._iter_stats():
-
             if get_mask_value(lat, lon, mask) < 1:
                 drop_idx.append(stat)
 
-        if len(drop_idx) > 0:
+        ndrop = len(drop_idx)
+        if ndrop == nstats:
+            raise DataCoverageError(f'No data available in region {region_id}')
+        elif ndrop > 0:
             arr = arr.drop(dim='station_name', labels=drop_idx)
         data.data = arr
         return data
@@ -1575,6 +1768,29 @@ class ColocatedData(object):
 
     def plot_coordinates(self, marker='x', markersize=12, fontsize_base=10,
                          **kwargs):
+        """
+        Plot station coordinates
+
+        Uses :func:`pyaerocom.plot.plotcoordinates.plot_coordinates`.
+
+        Parameters
+        ----------
+        marker : str, optional
+            matplotlib marker name used to plot site locations.
+            The default is 'x'.
+        markersize : int, optional
+            Size of site markers. The default is 12.
+        fontsize_base : int, optional
+            Basic fontsize. The default is 10.
+        **kwargs
+            additional keyword args passed to
+            :func:`pyaerocom.plot.plotcoordinates.plot_coordinates`
+
+        Returns
+        -------
+        matplotlib.axes.Axes
+
+        """
 
         from pyaerocom.plot.plotcoordinates import plot_coordinates
 
diff --git a/pyaerocom/colocation_auto.py b/pyaerocom/colocation_auto.py
index c2fe8c4f..97cf3777 100644
--- a/pyaerocom/colocation_auto.py
+++ b/pyaerocom/colocation_auto.py
@@ -6,9 +6,10 @@ Classes and methods to perform high-level colocation.
 from datetime import datetime
 import numpy as np
 import os
+from pathlib import Path
 import traceback
 
-from pyaerocom._lowlevel_helpers import BrowseDict, chk_make_subdir
+from pyaerocom._lowlevel_helpers import (BrowseDict, chk_make_subdir)
 from pyaerocom import const, print_log
 from pyaerocom.helpers import (to_pandas_timestamp, to_datestring_YYYYMMDD,
                                get_lowest_resolution, start_stop)
@@ -21,7 +22,7 @@ from pyaerocom.colocateddata import ColocatedData
 from pyaerocom.filter import Filter
 from pyaerocom.io import ReadUngridded, ReadGridded, ReadMscwCtm
 from pyaerocom.tstype import TsType
-from pyaerocom.exceptions import (DataCoverageError,
+from pyaerocom.exceptions import (ColocationError, DataCoverageError,
                                   VariableDefinitionError)
 
 class ColocationSetup(BrowseDict):
@@ -206,20 +207,16 @@ class ColocationSetup(BrowseDict):
 
         if isinstance(obs_vars, str):
             obs_vars = [obs_vars]
-        try:
-            Filter(filter_name)
-        except Exception:
-            raise ValueError('Invalid input for filter_name {}'.format(filter_name))
+
+        if basedir_coldata is not None:
+            basedir_coldata = self._check_input_basedir_coldata(basedir_coldata)
+        else:
+            basedir_coldata = const.COLOCATEDDATADIR
+
+        Filter(filter_name) #crashes if input filter name is invalid
+
         self.save_coldata = save_coldata
-        if save_coldata:
-            if basedir_coldata is None:
-                basedir_coldata = const.COLOCATEDDATADIR
-                if not os.path.exists(basedir_coldata):
-                    const.print_log.info(f'Creating directory: {basedir_coldata}')
-                    os.mkdir(basedir_coldata)
-                elif not os.path.exists(basedir_coldata):
-                    raise ValueError(f'Input directory {basedir_coldata} does '
-                                     f'not exist')
+        self.basedir_coldata = basedir_coldata
 
         self._obs_cache_only = False
         self.obs_vars = obs_vars
@@ -275,8 +272,6 @@ class ColocationSetup(BrowseDict):
         self.regrid_res_deg = regrid_res_deg
         self.ignore_station_names = None
 
-        self.basedir_coldata = basedir_coldata
-
         self.model_ts_type_read = model_ts_type_read
         self.model_read_aux = model_read_aux
         self.model_use_climatology = False
@@ -291,6 +286,68 @@ class ColocationSetup(BrowseDict):
 
         self.update(**kwargs)
 
+    def _check_input_basedir_coldata(self, basedir_coldata):
+        """
+        Make sure input basedir_coldata is str and exists
+
+        Parameters
+        ----------
+        basedir_coldata : str or Path
+            basic output directory for colocated data
+
+        Raises
+        ------
+        ValueError
+            If input is invalid.
+
+        Returns
+        -------
+        str
+            valid output directory
+
+        """
+        if isinstance(basedir_coldata, Path):
+            basedir_coldata = str(basedir_coldata)
+        if isinstance(basedir_coldata, str):
+            if not os.path.exists(basedir_coldata):
+                os.mkdir(basedir_coldata)
+            return basedir_coldata
+        raise ValueError(
+            f'Invalid input for basedir_coldata: {basedir_coldata}'
+            )
+
+    def _check_basedir_coldata(self):
+        """
+        Make sure output directory for colocated data files exists
+
+        Raises
+        ------
+        FileNotFoundError
+            If :attr:`basedir_coldata` does not exist and cannot be created.
+
+        Returns
+        -------
+        str
+            current value of :attr:`basedir_coldata`
+
+        """
+        basedir_coldata = self.basedir_coldata
+        if basedir_coldata is None:
+            basedir_coldata = const.COLOCATEDDATADIR
+            if not os.path.exists(basedir_coldata):
+                const.print_log.info(f'Creating directory: {basedir_coldata}')
+                os.mkdir(basedir_coldata)
+        elif isinstance(basedir_coldata, Path):
+            basedir_coldata = str(basedir_coldata)
+        if isinstance(basedir_coldata, str) and not os.path.exists(basedir_coldata):
+            os.mkdir(basedir_coldata)
+        if not os.path.exists(basedir_coldata):
+            raise FileNotFoundError(
+                f'Output directory for colocated data files {basedir_coldata} '
+                f'does not exist')
+        self.basedir_coldata = basedir_coldata
+        return basedir_coldata
+
     @property
     def basedir_logfiles(self):
         """Base directory for storing logfiles"""
@@ -309,14 +366,15 @@ class ColocationSetup(BrowseDict):
         for key, val in kwargs.items():
             if key in self and isinstance(self[key], dict):
                 if not isinstance(val, dict):
-                    raise ValueError('Cannot update dict {} with non-dict input {}'
-                                     .format(key, val))
+                    raise ValueError(
+                        f'Cannot update dict {key} with non-dict input {val}'
+                        )
                 self[key].update(val)
+            elif key == 'basedir_coldata':
+                self[key] = self._check_input_basedir_coldata(val)
             else:
                 self[key] = val
-        sd = self.basedir_coldata
-        if isinstance(sd, str) and not os.path.exists(sd):
-            os.mkdir(sd)
+
 
     def _check_outdated_outlier_defs(self):
         if 'var_outlier_ranges' in self:
@@ -387,6 +445,8 @@ class Colocator(ColocationSetup):
 
         """
         self.update(**opts)
+        if self.save_coldata:
+            self._check_basedir_coldata()
         self._check_outdated_outlier_defs()
         # ToDo: setting the defaults for time resampling here should be
         # unnecessary since this is done in TimeResampler. Ensure that and
@@ -417,7 +477,7 @@ class Colocator(ColocationSetup):
             self._write_log(msg)
             if self.raise_exceptions:
                 self._close_log()
-                raise Exception(traceback.format_exc())
+                raise ColocationError(traceback.format_exc())
         finally:
             self._close_log()
 
@@ -665,7 +725,7 @@ class Colocator(ColocationSetup):
             if not 'ts_type' in kwargs:
                 kwargs['ts_type'] = ts_type_read
 
-            if isinstance(kwargs['ts_type'], dict):
+            if isinstance(kwargs['ts_type'], dict) and var_name in kwargs:
                 kwargs['ts_type'] = kwargs['ts_type'][var_name]
 
             data = reader.read_var(var_name,
@@ -907,7 +967,7 @@ class Colocator(ColocationSetup):
 
                 if self.raise_exceptions:
                     self._close_log()
-                    raise Exception(msg)
+                    raise ColocationError(msg)
                 else:
                     continue
             ts_type_src = model_data.ts_type
@@ -929,11 +989,11 @@ class Colocator(ColocationSetup):
                 really_do_reanalysis = False
                 savename = self._coldata_savename(model_data, start, stop,
                                                   ts_type, var_name=model_var)
-
+                self._check_basedir_coldata()
+                out_dir = chk_make_subdir(self.basedir_coldata, self.model_id)
                 file_exists = self._check_coldata_exists(model_data.data_id,
                                                          savename)
 
-                out_dir = chk_make_subdir(self.basedir_coldata, self.model_id)
                 if file_exists:
                     if not self.reanalyse_existing:
                         if self._log:
@@ -1009,7 +1069,7 @@ class Colocator(ColocationSetup):
                 self._write_log(msg + '\n')
                 if self.raise_exceptions:
                     self._close_log()
-                    raise Exception(msg)
+                    raise ColocationError(msg)
 
         return data_objs
 
@@ -1059,7 +1119,7 @@ class Colocator(ColocationSetup):
 
                 if self.raise_exceptions:
                     self._close_log()
-                    raise Exception(msg)
+                    raise ColocationError(msg)
                 else:
                     continue
 
@@ -1081,7 +1141,7 @@ class Colocator(ColocationSetup):
 
                 if self.raise_exceptions:
                     self._close_log()
-                    raise Exception(msg)
+                    raise ColocationError(msg)
                 else:
                     continue
 
@@ -1098,6 +1158,7 @@ class Colocator(ColocationSetup):
                 ts_type = lowest
 
             if self.save_coldata:
+                self._check_basedir_coldata()
                 out_dir = chk_make_subdir(self.basedir_coldata,
                                           self.model_id)
 
@@ -1157,7 +1218,7 @@ class Colocator(ColocationSetup):
                 self._write_log(msg)
                 if self.raise_exceptions:
                     self._close_log()
-                    raise Exception(msg)
+                    raise ColocationError(msg)
         return data_objs
 
     def _init_log(self):
@@ -1218,6 +1279,7 @@ class Colocator(ColocationSetup):
 
     def _check_coldata_exists(self, model_id, coldata_savename):
         """Check if colocated data file exists"""
+        self._check_basedir_coldata()
         folder = os.path.join(self.basedir_coldata,
                               model_id)
         if not os.path.exists(folder):
diff --git a/pyaerocom/griddeddata.py b/pyaerocom/griddeddata.py
index dd84bd34..04b1c96a 100644
--- a/pyaerocom/griddeddata.py
+++ b/pyaerocom/griddeddata.py
@@ -536,7 +536,7 @@ class GriddedData(object):
         try:
             return self.metadata['data_id']
         except KeyError:
-            return 'N/D'
+            return 'undefined'
 
     @property
     def is_climatology(self):
diff --git a/pyaerocom/io/read_airnow.py b/pyaerocom/io/read_airnow.py
index 639d5ca6..703ceccf 100644
--- a/pyaerocom/io/read_airnow.py
+++ b/pyaerocom/io/read_airnow.py
@@ -34,7 +34,7 @@ class ReadAirNow(ReadUngriddedBase):
     _FILEMASK = f'/**/*{_FILETYPE}'
 
     #: Version log of this class (for caching)
-    __version__ = '0.06'
+    __version__ = '0.07'
 
     #: Column delimiter
     FILE_COL_DELIM = '|'
@@ -136,7 +136,16 @@ class ReadAirNow(ReadUngriddedBase):
     def __init__(self, dataset_to_read=None, data_dir=None):
         super(ReadAirNow, self).__init__(dataset_to_read=dataset_to_read,
                                          dataset_path=data_dir)
-        self.make_datetime64_array = np.vectorize(self._date_time_str_to_datetime64)
+        self.make_datetime64_array = np.vectorize(
+            self._date_time_str_to_datetime64)
+        self._station_metadata = None
+
+    @property
+    def station_metadata(self):
+        """Dictionary containing global metadata for each site"""
+        if self._station_metadata is None:
+            self._init_station_metadata()
+        return self._station_metadata
 
     def _date_time_str_to_datetime64(self, date, time):
         """
@@ -226,7 +235,6 @@ class ReadAirNow(ReadUngriddedBase):
             dictionary with metadata dictionaries for all stations
 
         """
-
         cfg = self._read_metadata_file()
         meta_map = self.STATION_META_MAP
 
@@ -248,7 +256,7 @@ class ReadAirNow(ReadUngriddedBase):
             stat['data_id'] = self.data_id
             stat['ts_type'] = self.TS_TYPE
             stats[sid] = stat
-
+        self._station_metadata = stats
         return stats
 
     def get_file_list(self):
@@ -307,18 +315,9 @@ class ReadAirNow(ReadUngriddedBase):
             list of StationData objects
 
         """
-
-        stat_meta = self._init_station_metadata()
-        stat_ids = list(stat_meta.keys())
         const.print_log.info('Read AirNow data file(s)')
         # initialize empty dataframe
-
         varcol = self.FILE_COL_NAMES.index('variable')
-        statcol = self.FILE_COL_NAMES.index('station_id')
-        tzonecol = self.FILE_COL_NAMES.index('time_zone')
-        unitcol = self.FILE_COL_NAMES.index('unit')
-        valcol = self.FILE_COL_NAMES.index('value')
-
         arrs = []
         for i in tqdm(range(len(files))):
             fp = files[i]
@@ -326,10 +325,11 @@ class ReadAirNow(ReadUngriddedBase):
             arr = filedata.values
 
             for i, var in enumerate(vars_to_retrieve):
+                cond = arr[:, varcol]==self.VAR_MAP[var]
                 if i == 0:
-                    mask = arr[:, varcol] == self.VAR_MAP[var]
+                    mask = cond
                 else:
-                    mask = np.logical_or(mask, arr[:, varcol] == self.VAR_MAP[var])
+                    mask = np.logical_or(mask, cond)
             matches = mask.sum()
             if matches:
                 vardata = arr[mask]
@@ -337,18 +337,49 @@ class ReadAirNow(ReadUngriddedBase):
         if len(arrs) == 0:
             raise DataRetrievalError(
                 'None of the input variables could be found in input list')
+        return self._filedata_to_statlist(arrs, vars_to_retrieve)
+
+    def _filedata_to_statlist(self, arrs, vars_to_retrieve):
+        """
+        Convert loaded filedata into list of StationData objects
+
+        Parameters
+        ----------
+        arrs : list
+            list of numpy arrays extracted from each file
+            (see :func:`_read_files`).
+        vars_to_retrieve : list
+            list of variables to be retrieved from input data.
+
+        Returns
+        -------
+        stats : list
+            list of :class:`StationData` objects, one for each var and station.
+
+        """
         data = np.concatenate(arrs)
 
+        const.print_log.info('Converting filedata to list os StationData')
+        stat_meta = self.station_metadata
+        stat_ids = list(stat_meta.keys())
+        varcol = self.FILE_COL_NAMES.index('variable')
+        statcol = self.FILE_COL_NAMES.index('station_id')
+        tzonecol = self.FILE_COL_NAMES.index('time_zone')
+        unitcol = self.FILE_COL_NAMES.index('unit')
+        valcol = self.FILE_COL_NAMES.index('value')
+
+
         dtime = self.make_datetime64_array(data[:, 0], data[:, 1])
         stats = []
         for var in vars_to_retrieve:
+
             # extract only variable data (should speed things up)
             var_in_file = self.VAR_MAP[var]
             mask = data[:, varcol] == var_in_file
             subset = data[mask]
             dtime_subset = dtime[mask]
             statlist = np.unique(subset[:, statcol])
-            for stat_id in statlist:
+            for stat_id in tqdm(statlist, desc=var):
                 if not stat_id in stat_ids:
                     continue
                 statmask = subset[:, statcol] == stat_id
@@ -356,28 +387,18 @@ class ReadAirNow(ReadUngriddedBase):
                     continue
                 statdata = subset[statmask]
                 timestamps = dtime_subset[statmask]
-
+                # timezone offsets
+                toffs = statdata[:, tzonecol].astype(int)
                 stat = StationData(**stat_meta[stat_id])
-                offs = np.unique(statdata[:, tzonecol])
 
-
-                if not len(offs) == 1:
-                    raise NotImplementedError(
-                        f'Encountered several timezones for station ID {stat_id}'
-                        )
-                # account for timezone
-                timedelta = np.timedelta64(int(offs[0]), 'h')
                 vals = statdata[:, valcol]
                 units = np.unique(statdata[:, unitcol])
-                if len(units) != 1:
-                    raise NotImplementedError(
-                        f'Encountered several units for {var}'
-                        )
-                elif not units[0] in self.UNIT_MAP:
-                    raise AttributeError(
-                        'Encountered unregistered unit {units[0]} for {var}'
-                        )
-                stat['dtime'] = timestamps + timedelta
+                # errors that did not occur in v0 but that may occur
+                assert len(units) == 1
+                assert units[0] in self.UNIT_MAP
+                toffs = toffs.astype('timedelta64[h]')
+                timestamps += toffs
+                stat['dtime'] = timestamps
                 stat['timezone'] ='UTC'
                 stat[var] = vals
                 unit = self.UNIT_MAP[units[0]]
@@ -421,7 +442,7 @@ class ReadAirNow(ReadUngriddedBase):
             vars_to_retrieve = self.DEFAULT_VARS
         elif isinstance(vars_to_retrieve, str):
             vars_to_retrieve = [vars_to_retrieve]
-
+        self._init_station_metadata()
         files = self.get_file_list()
         if first_file is None:
             first_file = 0
@@ -441,6 +462,7 @@ class ReadAirNow(ReadUngriddedBase):
 
 
 if __name__ == '__main__':
-    reader = ReadAirNow()
+    loc =  '/home/jonasg/MyPyaerocom/data/obsdata/MACC_INSITU_AirNow'
+    reader = ReadAirNow(data_dir=loc)
 
-    reader.read('concpm10',first_file=0)
\ No newline at end of file
+    reader.read('concpm25',last_file=10)
\ No newline at end of file
diff --git a/pyaerocom/io/test/test_read_airnow.py b/pyaerocom/io/test/test_read_airnow.py
index 468f09ac..117c7266 100644
--- a/pyaerocom/io/test/test_read_airnow.py
+++ b/pyaerocom/io/test/test_read_airnow.py
@@ -28,7 +28,7 @@ class TestReadAirNow(object):
     _FILEMASK = f'/**/*{_FILETYPE}'
 
     #: version log of this class (for caching)
-    __version__ = '0.06'
+    __version__ = '0.07'
 
     #: column delimiter
     FILE_COL_DELIM = '|'
diff --git a/pyaerocom/test/test_colocation_auto.py b/pyaerocom/test/test_colocation_auto.py
index 8ee93449..155b8a6b 100644
--- a/pyaerocom/test/test_colocation_auto.py
+++ b/pyaerocom/test/test_colocation_auto.py
@@ -5,9 +5,12 @@ from pyaerocom.conftest import tda, does_not_raise_exception, testdata_unavail
 from pyaerocom.colocation_auto import ColocationSetup, Colocator
 from pyaerocom import ColocatedData, GriddedData, UngriddedData
 from pyaerocom.io import ReadGridded, ReadMscwCtm
-from pyaerocom.exceptions import DataCoverageError
+from pyaerocom.exceptions import ColocationError, DataCoverageError
 from pyaerocom.io.aux_read_cubes import add_cubes
 
+HOME = os.path.expanduser('~')
+COL_OUT_DEFAULT = os.path.join(HOME, 'MyPyaerocom/colocated_data')
+
 default_setup = {'save_coldata': True, '_obs_cache_only': False,
                  'obs_vars': None, 'obs_vert_type': None,
                  'model_vert_type_alt': None, 'read_opts_ungridded': None,
@@ -24,7 +27,7 @@ default_setup = {'save_coldata': True, '_obs_cache_only': False,
                  'obs_outlier_ranges': None, 'model_outlier_ranges': None,
                  'harmonise_units': False, 'vert_scheme': None,
                  'regrid_res_deg': None, 'ignore_station_names': None,
-                 'basedir_coldata': '/home/jonasg/MyPyaerocom/colocated_data',
+                 'basedir_coldata': COL_OUT_DEFAULT,
                  'model_ts_type_read': None, 'model_read_aux': None,
                  'model_use_climatology': False, 'colocate_time': False,
                  'flex_ts_type_gridded': True, 'reanalyse_existing': False,
@@ -34,14 +37,16 @@ default_setup = {'save_coldata': True, '_obs_cache_only': False,
 
 @testdata_unavail
 @pytest.fixture(scope='function')
-def col_tm5_aero(model_id='TM5-met2010_CTRL-TEST',
-                 obs_id='AeronetSunV3L2Subset.daily',
-                 obs_vars='od550aer'):
-    coloc = Colocator(model_id=model_id, obs_id=obs_id, obs_vars=obs_vars)
-    coloc.raise_exceptions = True
-    coloc.reanalyse_existing = True
-    coloc.start = 2010
-    return coloc
+def tm5_aero_stp():
+    return dict(
+        model_id='TM5-met2010_CTRL-TEST',
+        obs_id='AeronetSunV3L2Subset.daily',
+        obs_vars='od550aer',
+        start = 2010,
+        raise_exceptions = True,
+        reanalyse_existing = True
+        )
+
 
 @pytest.fixture(scope='function')
 def col():
@@ -51,30 +56,57 @@ def col():
     (ColocationSetup(), default_setup)
     ])
 def test_colocation_setup(stp, should_be):
-    for key, val in stp.items():
+    for key, val in should_be.items():
+        assert key in stp
         if key == 'basedir_coldata':
-            assert os.path.exists(val)
+            assert os.path.samefile(val, stp['basedir_coldata'])
         else:
-            assert key in should_be
-            assert val == should_be[key], key
-
-def test_model_ts_type_read(col_tm5_aero):
-    model_var = 'abs550aer'
-    obs_var = 'od550aer'
-    col_tm5_aero.save_coldata = False
-    # Problem with saving since obs_id is different
-    # from obs_data.contains_dataset[0]...
-    col_tm5_aero.model_add_vars = {obs_var:model_var}
-    col_tm5_aero.model_ts_type_read = {model_var:'daily', obs_var:'monthly'}
-    data = col_tm5_aero._run_gridded_ungridded()
-    assert data[model_var].metadata['ts_type_src'] == ['daily', 'daily']
-    assert data[obs_var].metadata['ts_type_src'] == ['daily', 'monthly']
+            assert val == stp[key], key
 
 def test_colocator(col):
     assert isinstance(col, Colocator)
     col.obs_vars = 'var'
     assert isinstance(col.obs_vars, str)
 
+@pytest.mark.parametrize('ts_type_desired, ts_type, flex, raises', [
+    ('minutely', 'daily', False, pytest.raises(ColocationError)),
+    ('daily', 'monthly', False, does_not_raise_exception()),
+    ])
+def test_colocator_model_ts_type_read(tm5_aero_stp,ts_type_desired,
+                                      ts_type, flex, raises):
+    col = Colocator(**tm5_aero_stp)
+    obs_var = 'od550aer'
+    assert tm5_aero_stp['obs_vars'] == obs_var
+    col.save_coldata = False
+    col.flex_ts_type_gridded = flex
+    col.ts_type = ts_type
+    # Problem with saving since obs_id is different
+    # from obs_data.contains_dataset[0]...
+    col.model_ts_type_read = {obs_var : ts_type_desired}
+    with raises:
+        data = col._run_gridded_ungridded()
+        assert isinstance(data, dict)
+        assert obs_var in data
+        coldata = data[obs_var]
+        assert coldata.ts_type == ts_type
+        assert coldata.meta['ts_type_src'][0] == 'daily'
+        if not flex:
+            assert coldata.meta['ts_type_src'][1] == ts_type_desired
+
+def test_colocator_model_add_vars(tm5_aero_stp):
+    col = Colocator(**tm5_aero_stp)
+    model_var = 'abs550aer'
+    obs_var = 'od550aer'
+    col.save_coldata = False
+    # Problem with saving since obs_id is different
+
+    col.model_add_vars = {obs_var : model_var}
+    data = col._run_gridded_ungridded(var_name=model_var)
+    assert isinstance(data, dict)
+    assert model_var in data
+    coldata = data[model_var]
+    assert coldata.var_name == ['od550aer', 'abs550aer']
+
 def test_colocator_init_basedir_coldata(tmpdir):
     basedir = os.path.join(tmpdir, 'basedir')
     Colocator(raise_exceptions=True, basedir_coldata=basedir)
@@ -91,40 +123,59 @@ def test_colocator__coldata_savename(data_tm5):
     assert isinstance(savename, str)
     assert savename == 'od550aer_REF-obs_MOD-model_20100101_20101231_monthly_WORLD.nc'
 
-def test_colocator_update(tmpdir):
-    col = Colocator(raise_exceptions=True)
-    col.update(test="test")
-    assert col.test == 'test'
 
-    obs_id = 'test'
-    col.update(obs_id=obs_id)
-    assert col.obs_id == obs_id
+def test_colocator_update_basedir_coldata(tmpdir):
+    col = Colocator(raise_exceptions=True)
 
     basedir = os.path.join(tmpdir, 'basedir')
     assert not os.path.isdir(basedir)
     col.update(basedir_coldata=basedir)
     assert os.path.isdir(basedir)
 
-@pytest.mark.parametrize('gridded_gridded',[False, True])
-def test_colocator_run(col_tm5_aero, gridded_gridded):
+@pytest.mark.parametrize('what,raises', [
+    (dict(blaa=42), does_not_raise_exception()),
+    (dict(obs_id='test', model_id='test'), does_not_raise_exception()),
+    (dict(gridded_reader_id='test'), pytest.raises(ValueError)),
+    (dict(gridded_reader_id={'test' : 42}), does_not_raise_exception()),
+    ])
+def test_colocator_update(what,raises):
+    colref = Colocator(raise_exceptions=True)
+    col = Colocator(raise_exceptions=True)
+    with raises:
+        col.update(**what)
+        for key, val in what.items():
+            if key in colref and isinstance(colref[key], dict):
+                colref[key].update(val)
+                assert col[key] == colref[key]
+            else:
+                assert col[key] == val
+
+
+@pytest.mark.parametrize('gridded_gridded', [False, True])
+def test_colocator_run(tm5_aero_stp, gridded_gridded):
+    col = Colocator(**tm5_aero_stp)
     if gridded_gridded:
-        col_tm5_aero.obs_id = col_tm5_aero.model_id
-    col_tm5_aero.run()
-    coldata = col_tm5_aero.data[col_tm5_aero.model_id][col_tm5_aero.obs_vars[0]]
+        col.obs_id = col.model_id
+    col.run()
+    coldata = col.data[col.model_id][col.obs_vars[0]]
     assert isinstance(coldata, ColocatedData)
 
-def test__run_gridded_ungridded(col_tm5_aero):
-    obs_vars = col_tm5_aero.obs_vars[0]
-    colocated_dict = col_tm5_aero._run_gridded_ungridded()
-    assert isinstance(colocated_dict[obs_vars], ColocatedData)
-
-def test__run_gridded_gridded(col_tm5_aero):
-    obs_vars = col_tm5_aero.obs_vars[0]
-    col_tm5_aero.obs_id = col_tm5_aero.model_id
-    colocated = col_tm5_aero._run_gridded_gridded(obs_vars)
-    assert isinstance(colocated[obs_vars], ColocatedData)
-    assert isinstance(col_tm5_aero.data, dict)
-    assert col_tm5_aero.data == {}
+def test_colocator__run_gridded_ungridded(tm5_aero_stp):
+    col = Colocator(**tm5_aero_stp)
+    data = col._run_gridded_ungridded()
+    for ovar in col.obs_vars:
+        assert ovar in data
+        assert isinstance(data[ovar], ColocatedData)
+
+def test_colocator__run_gridded_gridded(tm5_aero_stp):
+    col = Colocator(**tm5_aero_stp)
+    col.obs_id = col.model_id
+    ovar = col.obs_vars[0]
+    data = col._run_gridded_gridded(ovar)
+    assert ovar in data
+    assert isinstance(data[ovar], ColocatedData)
+    assert isinstance(col.data, dict)
+    assert col.data == {}
 
 def test_colocator_filter_name():
     with does_not_raise_exception():
@@ -254,14 +305,15 @@ def test_colocator__find_var_matches_model_add_vars(col):
     var_matches = col._find_var_matches(obs_var, r)
     assert var_matches == {model_var:obs_var, obs_var:obs_var}
 
-def test_model_add_vars(col_tm5_aero):
+def test_model_add_vars(tm5_aero_stp):
+    col = Colocator(**tm5_aero_stp)
     model_var = 'abs550aer'
     obs_var = 'od550aer'
-    col_tm5_aero.model_add_vars = {obs_var:model_var}
-    model_reader = ReadGridded(col_tm5_aero.model_id)
-    var_matches = col_tm5_aero._check_model_add_var(obs_var, model_reader, {})
+    col.model_add_vars = {obs_var:model_var}
+    model_reader = ReadGridded(col.model_id)
+    var_matches = col._check_model_add_var(obs_var, model_reader, {})
     assert var_matches == {model_var: obs_var}
-    var_matches = col_tm5_aero._find_var_matches([obs_var], model_reader)
+    var_matches = col._find_var_matches([obs_var], model_reader)
     assert len(var_matches) == 2
     assert (model_var in var_matches)
     assert (obs_var in var_matches)
diff --git a/pyaerocom/ungriddeddata.py b/pyaerocom/ungriddeddata.py
index e6e67e83..faf79307 100644
--- a/pyaerocom/ungriddeddata.py
+++ b/pyaerocom/ungriddeddata.py
@@ -25,7 +25,6 @@ from pyaerocom.helpers import (same_meta_dict,
 
 from pyaerocom.metastandards import STANDARD_META_KEYS
 from pyaerocom.units_helpers import get_unit_conversion_fac
-from pyaerocom.metastandards import StationMetaData
 
 from pyaerocom.helpers_landsea_masks import (load_region_mask_xr,
                                              get_mask_value)
@@ -146,6 +145,24 @@ class UngriddedData(object):
         self.filter_hist = od()
 
     def _get_data_revision_helper(self, data_id):
+        """
+        Helper method to get last data revision
+
+        Parameters
+        ----------
+        data_id : str
+            ID of dataset for which revision is to be retrieved
+
+        Raises
+        ------
+        MetaDataError
+            If multiple revisions are found for this dataset.
+
+        Returns
+        -------
+        latest revision (None if no revision is available).
+
+        """
         rev = None
         for meta in self.metadata.values():
             if meta['data_id'] == data_id:
@@ -153,7 +170,7 @@ class UngriddedData(object):
                     rev = meta['data_revision']
                 elif not meta['data_revision'] == rev:
                     raise MetaDataError('Found different data revisions for '
-                                         'dataset {}'.format(data_id))
+                                        'dataset {}'.format(data_id))
         if data_id in self.data_revision:
             if not rev == self.data_revision[data_id]:
                 raise MetaDataError('Found different data revisions for '
@@ -202,9 +219,10 @@ class UngriddedData(object):
                 var_idx_data = np.unique(self._data[indices, self._VARINDEX])
                 assert len(var_idx_data) == 1, ('Found multiple variable indices for '
                           'var {}: {}'.format(var, var_idx_data))
-                assert var_idx_data[0] == vars_avail[var], ('Mismatch between variable '
-                          'index assigned in data and var_idx for {} in meta-block'
-                          .format(var, idx))
+                assert var_idx_data[0] == vars_avail[var], (
+                    f'Mismatch between {var} index assigned in data and '
+                    f'var_idx for {idx} in meta-block'
+                    )
 
     @staticmethod
     def from_station_data(stats, add_meta_keys=None):
@@ -235,7 +253,7 @@ class UngriddedData(object):
         if add_meta_keys is None:
             add_meta_keys = []
         elif isinstance(add_meta_keys, str):
-            add_metad_keys = [add_meta_keys]
+            add_meta_keys = [add_meta_keys]
         elif not isinstance(add_meta_keys, list):
             raise ValueError(
                 f'Invalid input for add_meta_keys {add_meta_keys}... need list'
diff --git a/pyaerocom/web/aerocom_evaluation.py b/pyaerocom/web/aerocom_evaluation.py
index 3d709c7f..db9a9711 100644
--- a/pyaerocom/web/aerocom_evaluation.py
+++ b/pyaerocom/web/aerocom_evaluation.py
@@ -4,6 +4,7 @@ from fnmatch import fnmatch
 import glob
 import os
 import numpy as np
+from pathlib import Path
 import shutil
 import simplejson
 from traceback import format_exc
@@ -172,6 +173,13 @@ class AerocomEvaluation(object):
         instantiating the class. These are updated *after* the reading of the
         json file, which will overwrite affected attributes defined in the json
         file.
+    init_output_dirs : bool
+        if True, all required output directories for json files and colocated
+        NetCDF files are already created when instantiating the class. This is
+        recommended if you intend to use individual methods of this class such
+        as :func:`run_colocation` or :func:`find_coldata_files` and is of
+        particular relevance for the storage location of the colocated data
+        files. Defaults to True.
     """
     OUT_DIR_NAMES = ['map', 'ts', 'ts/dw', 'scat', 'hm', 'profiles',
                      'contour']
@@ -199,9 +207,13 @@ class AerocomEvaluation(object):
             'raise_exceptions'    : 'Raise exceptions if they occur'
     }
 
+    #: status of experiment
     EXP_STATUS_VALS = ['public', 'experimental']
-    def __init__(self, proj_id=None, exp_id=None, config_dir=None,
-                 try_load_json=True, **settings):
+
+    #: attributes that are not supported by this interface
+    FORBIDDEN_ATTRS = ['basedir_coldata']
+    def __init__(self, proj_id, exp_id, config_dir=None,
+                 try_load_json=True, init_output_dirs=False, **settings):
 
         self._log = const.print_log
 
@@ -224,7 +236,7 @@ class AerocomEvaluation(object):
         self.annual_stats_constrained=False
 
         #: Base directory for output
-        self.out_basedir = None
+        self.out_basedir = os.path.join(const.OUTPUTDIR, 'aeroval')
 
         #: Base directory to store colocated data (sub dirs for proj and
         #: experiment will be created automatically)
@@ -243,8 +255,8 @@ class AerocomEvaluation(object):
         self.only_maps = False
 
         #: Output directories for different types of json files (will be filled
-        #: in :func:`init_dirs`)
-        self.out_dirs = {}
+        #: in :func:`init_json_output_dirs`)
+        self._out_dirs = {}
 
         #: Dictionary specifying default settings for colocation
         self.colocation_settings = ColocationSetup()
@@ -291,6 +303,9 @@ class AerocomEvaluation(object):
                     f'experiment {exp_id}. Reason:\n{format_exc()}'
                     )
         self.update(**settings)
+        self._check_init_col_outdir()
+        if init_output_dirs:
+            self.init_json_output_dirs()
 
     @property
     def proj_dir(self):
@@ -307,6 +322,12 @@ class AerocomEvaluation(object):
         """Base directory for colocated data files"""
         return self.colocation_settings['basedir_coldata']
 
+    @property
+    def out_dirs(self):
+        if len(self._out_dirs) == 0:
+            self.init_json_output_dirs()
+        return self._out_dirs
+
     @property
     def regions_file(self):
         """json file containing region specifications"""
@@ -515,7 +536,11 @@ class AerocomEvaluation(object):
         self._update_custom_read_methods()
 
     def __setitem__(self, key, val):
-        if key in self.colocation_settings:
+        if key in self.FORBIDDEN_ATTRS:
+            raise AttributeError(
+                f'Attr {key} is not allowed in AerocomEvaluation'
+                )
+        elif key in self.colocation_settings:
             self.colocation_settings[key] = val
         elif key == 'obs_config':
             self._set_obsconfig(val)
@@ -534,6 +559,7 @@ class AerocomEvaluation(object):
                 self.__dict__[key] = val
         elif key in self.__dict__:
             self.__dict__[key] = val
+
         else:
             const.print_log.warning(
                 f'Invalid input key {key} for AerocomEvaluation. Will be '
@@ -546,38 +572,57 @@ class AerocomEvaluation(object):
         elif key in self.colocation_settings:
             return self.colocation_settings[key]
 
-    def init_dirs(self, out_basedir=None):
-        """Check and create directories"""
+    def init_json_output_dirs(self, out_basedir=None):
+        """Check and create directories for json files"""
         if out_basedir is not None:
             self.out_basedir = out_basedir
-        if self.out_basedir is None:
-            self.out_basedir = const.OUTPUTDIR
+        if not os.path.exists(self.out_basedir):
+            os.mkdir(self.out_basedir)
         check_dirs_exist(self.out_basedir, self.proj_dir, self.exp_dir)
         outdirs = {}
         for dname in self.OUT_DIR_NAMES:
             outdirs[dname] = os.path.join(self.exp_dir, dname)
         check_dirs_exist(**outdirs)
-        self.out_dirs = outdirs
-        self._check_init_col_outdir()
+        self._out_dirs = outdirs
+        return outdirs
 
     def _check_init_col_outdir(self):
+        cs = self.colocation_settings
+
         cbd = self.coldata_basedir
-        if isinstance(cbd, str) and os.path.exists(cbd):
-            if not self.proj_id in cbd:
-                cbd1 = chk_make_subdir(cbd, self.proj_id)
-                col_out = chk_make_subdir(cbd1, self.exp_id)
-                const.print_log.info(
-                    f'Setting output directory for colocated data files to'
-                    f'{col_out}')
-                self.colocation_settings['basedir_coldata'] = col_out
+        if cbd is None:
+            # this will make sure the base directory exists (or crash) and
+            # returns a string
+            cbd = cs._check_basedir_coldata()
+        elif isinstance(cbd, Path):
+            cbd = str(cbd)
+
+        if not os.path.exists(cbd):
+            os.mkdir(cbd)
+
+        add_dirs = f'{self.proj_id}/{self.exp_id}'
+        if cbd.endswith(add_dirs):
+            col_out = cbd
+        else:
+            col_out = os.path.join(cbd, add_dirs)
+        if not os.path.exists(col_out):
+            const.print_log.info(
+                f'Creating output directory for colocated data files: {col_out}')
+            os.makedirs(col_out, exist_ok=True)
+        else:
+            const.print_log.info(
+                f'Setting output directory for colocated data files to:\n{col_out}'
+                )
+        self.coldata_basedir = cbd
+        self.colocation_settings['basedir_coldata'] = col_out
 
     def check_config(self):
         if not isinstance(self.proj_id, str):
-            raise AttributeError(f'proj_id must be specified, '
+            raise AttributeError(f'proj_id must be str, '
                                  f'(current value: {self.proj_id})')
 
         if not isinstance(self.exp_id, str):
-            raise AttributeError(f'exp_id must be specified, '
+            raise AttributeError(f'exp_id must be str, '
                                  f'(current value: {self.exp_id})')
 
         if not isinstance(self.exp_descr, str):
@@ -991,8 +1036,7 @@ class AerocomEvaluation(object):
         if self.colocation_settings['reanalyse_existing']:
             self.delete_all_colocateddata_files(model_name, obs_name)
 
-        col = Colocator()
-        col.update(**self.colocation_settings)
+        col = Colocator(**self.colocation_settings)
 
         if not model_name in self.model_config:
             raise KeyError('No such model name in configuration: {}. Available '
@@ -1516,7 +1560,6 @@ class AerocomEvaluation(object):
         if only_maps is not None:
             self.only_maps = only_maps
 
-        self.init_dirs()
         if self.clear_existing_json:
             self.clean_json_files()
 
@@ -1989,12 +2032,16 @@ class AerocomEvaluation(object):
         self.update_summary_str()
         indent = 2
         _indent_str = indent*' '
-        head = "Pyaerocom {}".format(type(self).__name__)
-        s = "\n{}\n{}".format(head, len(head)*"-")
-        s += ('\nProject ID: {}'
-              '\nEperiment ID: {}'
-              '\nExperiment name: {}'
-              .format(self.proj_id, self.exp_id, self.exp_name))
+        head = f"pyaerocom {type(self).__name__}"
+        underline = len(head)*"-"
+        out_dirs = dict_to_str(self.out_dirs, indent=indent)
+        s = f"\n{head}\n{underline}"
+        s += (
+            f'\nProject ID (proj_id): {self.proj_id}'
+            f'\nExperiment ID (exp_id): {self.exp_id}'
+            f'\nExperiment name (exp_name): {self.exp_name}'
+            f'\nOutput directories for json files: {out_dirs}'
+            )
         s += '\ncolocation_settings: (will be updated for each run from model_config and obs_config entry)'
         for k, v in self.colocation_settings.items():
             s += '\n{}{}: {}'.format(_indent_str, k, v)
diff --git a/pyaerocom/web/test/test_aerocom_evaluation.py b/pyaerocom/web/test/test_aerocom_evaluation.py
index 5e7d4b9f..dbf51c05 100644
--- a/pyaerocom/web/test/test_aerocom_evaluation.py
+++ b/pyaerocom/web/test/test_aerocom_evaluation.py
@@ -4,9 +4,13 @@ import json
 
 import numpy as np
 from pandas import DataFrame
+from pyaerocom import const
+from pyaerocom.colocation_auto import ColocationSetup, Colocator
+from pyaerocom.colocateddata import ColocatedData
+from pyaerocom.griddeddata import GriddedData
+from pyaerocom.ungriddeddata import UngriddedData
 
-from pyaerocom import Colocator, ColocatedData, GriddedData, UngriddedData
-from pyaerocom.conftest import tda
+from pyaerocom.conftest import tda, does_not_raise_exception
 from pyaerocom.web import AerocomEvaluation
 from pyaerocom.io.aux_read_cubes import add_cubes
 
@@ -67,11 +71,45 @@ def stp_min(tmpdir):
                              raise_exceptions=True,
                              out_basedir=str(tmpdir))
 
-
-def test_aerocom_evaluation(stp_min):
+def test_AerocomEvaluation_type(stp_min):
     assert isinstance(stp_min, AerocomEvaluation)
 
-def test_aerocom_evaluation_run_colocation(stp):
+@pytest.mark.parametrize('args,raises,chk_attrs', [
+    ({},pytest.raises(TypeError),{}),
+    ({'proj_id':'bla'}, pytest.raises(TypeError),{}),
+    ({'proj_id':'bla', 'exp_id' : 'blub'},
+     does_not_raise_exception(),{}),
+    ({'proj_id' : 'bla', 'exp_id' : 'blub',
+      'init_output_dirs' : False}, does_not_raise_exception(),{}),
+    ({'proj_id' : 'bla', 'exp_id' : 'blub',
+      'basedir_coldata' : '/home'}, pytest.raises(AttributeError),{}),
+    ({'proj_id' : 'bla', 'exp_id' : 'blub',
+      'basedir_coldata' : '/home'}, pytest.raises(AttributeError),{}),
+    ])
+def test_AerocomEvaluation___init__(args,raises,chk_attrs):
+    with raises:
+        stp = AerocomEvaluation(**args)
+        assert isinstance(stp, AerocomEvaluation)
+        for key, val in chk_attrs.items():
+            _val = getattr(stp, key)
+            assert _val == val
+
+def test_AerocomEvaluation_init_json_output_dirs_default():
+    stp = AerocomEvaluation('bla','blub')
+    stp.init_json_output_dirs()
+    bdir = stp.out_basedir
+    dirs_keys = ['map', 'ts', 'ts/dw', 'scat', 'hm', 'profiles', 'contour']
+    assert os.path.exists(bdir)
+    default_out = os.path.join(const.OUTPUTDIR, 'aeroval')
+    assert os.path.samefile(default_out, bdir)
+    dirs = stp.out_dirs
+    assert isinstance(dirs, dict)
+    for key, val in dirs.items():
+        assert key in dirs_keys
+        assert os.path.exists(val)
+
+
+def test_AerocomEvaluation_run_colocation(stp):
 
     mid = 'TM5-met2010_CTRL-TEST'
     var_name = 'od550aer'
@@ -87,42 +125,42 @@ def test_aerocom_evaluation_run_colocation(stp):
     assert isinstance(coldata, ColocatedData)
     assert coldata.shape == (2, 12, 8)
 
-def test_aerocom_evaluation_run_evaluation(stp):
+def test_AerocomEvaluation_run_evaluation(stp):
     col_paths = stp.run_evaluation(update_interface=False,
                                    reanalyse_existing=False) #reuse model colocated data from prev. test
     assert len(col_paths) == 1
     assert os.path.isfile(col_paths[0])
 
-def test_aerocom_evaluation_get_web_overview_table(stp, tmpdir):
+def test_AerocomEvaluation_get_web_overview_table(stp, tmpdir):
     stp.update()
     stp.run_evaluation(update_interface=False)
     table = stp.get_web_overview_table()
     assert isinstance(table, DataFrame)
     assert len(table) > 0
 
-def test_aerocom_evaluation_get_custom_read_method_model_file(stp,
+def test_AerocomEvaluation_get_custom_read_method_model_file(stp,
                                                               model_config_aux):
     stp.add_methods_file = METHODS_FILE
     stp.model_config = model_config_aux
     fun = stp.get_custom_read_method_model('add_cubes')
     assert fun == add_cubes
 
-def test_aerocom_evaluation_get_custom_read_method_model_parameter(stp,
+def test_AerocomEvaluation_get_custom_read_method_model_parameter(stp,
                                                                    model_config_aux):
     stp.add_methods={'add_cubes':add_cubes}
     fun = stp.get_custom_read_method_model('add_cubes')
     assert fun == add_cubes
 
-def test_aerocom_evaluation_all_obs_vars(stp):
+def test_AerocomEvaluation_all_obs_vars(stp):
     assert stp.all_obs_vars == [OBS_VARS]
 
-def test_aerocom_evaluation_get_model_name(stp):
+def test_AerocomEvaluation_get_model_name(stp):
     assert stp.get_model_name(MODEL_ID) == MODEL_NAME
 
-def test_aerocom_evaluation___str__(stp):
+def test_AerocomEvaluation___str__(stp):
     assert isinstance(str(stp), str)
 
-def test_aerocom_evaluation_output_files(stp, tmpdir):
+def test_AerocomEvaluation_output_files(stp, tmpdir):
     stp.out_basedir = tmpdir
     stp.run_evaluation(update_interface=False)
 
@@ -147,27 +185,25 @@ def test_aerocom_evaluation_output_files(stp, tmpdir):
     assert data['daily_obs'] == []
     assert data['daily_mod'] == []
 
-def test_aerocom_evaluation_to_from_json(stp, tmpdir):
+def test_AerocomEvaluation_to_from_json(stp, tmpdir):
     stp.to_json(tmpdir)
     config_filename = 'cfg_{}_{}.json'.format(PROJ_ID, EXP_ID)
-    stp_new = AerocomEvaluation(proj_id='project2', exp_id='exp2', raise_exceptions=True)
-    stp_new.from_json(os.path.join(tmpdir, config_filename))
-    for old, new in zip(dir(stp), dir(stp_new)):
-        assert stp[old] == stp_new[new]
-
-    stp_new = AerocomEvaluation(proj_id='project2', exp_id='exp2', raise_exceptions=True)
-    stp_new.config_dir = tmpdir
-    stp_new.load_config(PROJ_ID, EXP_ID)
-    for old, new in zip(dir(stp), dir(stp_new)):
-        assert stp[old] == stp_new[new]
-
-def test_aerocom_evaluation_read_model_data(stp):
+
+    cfg_fp = os.path.join(tmpdir, config_filename)
+    assert os.path.exists(cfg_fp)
+    stp_new = AerocomEvaluation(PROJ_ID, EXP_ID, config_dir=tmpdir,
+                                try_load_json=True)
+    assert stp.colocation_settings == stp_new.colocation_settings
+    assert stp.obs_config == stp_new.obs_config
+    assert stp.model_config == stp_new.model_config
+
+def test_AerocomEvaluation_read_model_data(stp):
     data = stp.read_model_data(MODEL_NAME, OBS_VARS)
     assert isinstance(data, GriddedData)
     with pytest.raises(ValueError):
         stp.read_model_data('model_name', 'od550aer')
 
-def test_aerocom_evaluation_read_ungridded_obsdata(stp):
+def test_AerocomEvaluation_read_ungridded_obsdata(stp):
     data = stp.read_ungridded_obsdata(OBS_NAME, vars_to_read=[OBS_VARS])
     assert isinstance(data, UngriddedData)
 
@@ -175,16 +211,16 @@ def test_aerocom_evaluation_read_ungridded_obsdata(stp):
     ('TM*', [MODEL_NAME], 'find_model_matches'),
     ('Aero*', [OBS_NAME], 'find_obs_matches'),
 ])
-def test_aerocom_evaluation_find_matches(stp, search, expected, fun_name):
+def test_AerocomEvaluation_find_matches(stp, search, expected, fun_name):
     search_fun = getattr(AerocomEvaluation, fun_name)
     assert search_fun(stp, search) == expected
 
-# @ejgal: keep these tests until deciding if test_aerocom_evaluation_find_matches is ok
-# def test_aerocom_evaluation_find_model_matches(stp):
+# @ejgal: keep these tests until deciding if test_AerocomEvaluation_find_matches is ok
+# def test_AerocomEvaluation_find_model_matches(stp):
 #     matches = stp.find_model_matches('TM*')
 #     assert matches == [MODEL_NAME]
 #
-# def test_aerocom_evaluation_find_obs_matches(stp):
+# def test_AerocomEvaluation_find_obs_matches(stp):
 #     matches = stp.find_obs_matches('Aero*')
 #     assert matches == [OBS_NAME]
 
@@ -192,21 +228,21 @@ def test_aerocom_evaluation_find_matches(stp, search, expected, fun_name):
     ([MODEL_NAME], 'all_model_names'),
     ([OBS_NAME], 'all_obs_names'),
 ])
-def test_aerocom_evaluation_all_names(stp, expected, property):
+def test_AerocomEvaluation_all_names(stp, expected, property):
     assert getattr(stp, property) == expected
 
-# @ejgal: keep these tests until deciding if test_aerocom_evaluation_all_names is ok.
-# def test_aerocom_evaluation_all_model_names(stp):
+# @ejgal: keep these tests until deciding if test_AerocomEvaluation_all_names is ok.
+# def test_AerocomEvaluation_all_model_names(stp):
 #     assert stp.all_model_names == [MODEL_NAME]
 #
-# def test_aerocom_evaluation_all_obs_names(stp):
+# def test_AerocomEvaluation_all_obs_names(stp):
 #     assert stp.all_obs_names == [OBS_NAME]
 
-def test_aerocom_evaluation_find_obs_name(stp):
+def test_AerocomEvaluation_find_obs_name(stp):
     obs_name = stp.find_obs_name(OBS_ID, OBS_VARS)
     assert obs_name == OBS_NAME
 
-def test_aerocom_evaluation_find_model_name(stp):
+def test_AerocomEvaluation_find_model_name(stp):
     model_name = stp.find_model_name(MODEL_ID)
     assert model_name == MODEL_NAME
 
@@ -214,7 +250,7 @@ def test_aerocom_evaluation_find_model_name(stp):
     ('proj_id', ['project'], AttributeError),
     ('exp_id', ['exp'], AttributeError)
 ])
-def test_aerocom_evaluation_check_config(stp_min, key, val, expected):
+def test_AerocomEvaluation_check_config(stp_min, key, val, expected):
     stp_min[key] = val
     with pytest.raises(expected):
         stp_min.check_config()
@@ -223,7 +259,7 @@ def test_aerocom_evaluation_check_config(stp_min, key, val, expected):
     ('model_config', MODEL_NAME, 'model_id'),
     ('obs_config', OBS_NAME, 'obs_id')
 ])
-def test_aerocom_evaluation_check_config_model_obs(stp, config_name, name, id):
+def test_AerocomEvaluation_check_config_model_obs(stp, config_name, name, id):
     del stp[config_name][name][id]
     with pytest.raises(KeyError):
         stp.check_config()
diff --git a/pyaerocom/web/test/test_helpers_evaluation_iface.py b/pyaerocom/web/test/test_helpers_evaluation_iface.py
index d6891cfe..aa827881 100644
--- a/pyaerocom/web/test/test_helpers_evaluation_iface.py
+++ b/pyaerocom/web/test/test_helpers_evaluation_iface.py
@@ -302,7 +302,7 @@ def test_compute_json_files_from_colocateddata(coldata, tmpdir, which, obs_name,
     proj = 'proj'
     exp = 'exp'
     stp = AerocomEvaluation(proj_id=proj, exp_id=exp,out_basedir=outdir)
-    stp.init_dirs()
+    stp.init_json_output_dirs()
     cs = stp.colocation_settings
     out_dirs = stp.out_dirs
     regions_json = stp.regions_file
diff --git a/pyaerocom_env.yml b/pyaerocom_env.yml
index 28a66003..fdf2b931 100644
--- a/pyaerocom_env.yml
+++ b/pyaerocom_env.yml
@@ -16,4 +16,5 @@ dependencies:
   - requests
   - reverse-geocode
   - tqdm
+  - openpyxl
   - nbsphinx # for docs via rtd configuration file v2
['pyaerocom/web/test/test_helpers_evaluation_iface.py', 'pyaerocom_env.yml', 'pyaerocom/test/test_colocation_auto.py', 'pyaerocom/web/aerocom_evaluation.py', 'pyaerocom/web/test/test_aerocom_evaluation.py', 'pyaerocom/griddeddata.py', 'pyaerocom/ungriddeddata.py', 'pyaerocom/colocateddata.py', 'pyaerocom/io/read_airnow.py', 'pyaerocom/colocation_auto.py', 'pyaerocom/io/test/test_read_airnow.py']