Repository: metno/pyaerocom (language: python)

Issue:
pyaerocom/aeroval output should use aerovaldb-locking for all output
**Is your feature request related to a problem? Please describe.**
To make parallelization of pyaeroval-runs possible, the output of the different runs should not conflict with each other. AerovalDb has now locking implemented and pyaeroval should therefore use this locking feature in a fine-grained manner, e.g. log for as short as possible (but not so often that writes are much delayed).


**Describe the solution you would like to see**
- pyaeroval can be run for different models/variables in parallel, but to the same data directory
- the different model-runs don't interfere with each others.




Discussion / hints:

Fix patch:
diff --git a/pyaerocom/aeroval/_processing_base.py b/pyaerocom/aeroval/_processing_base.py
index 2178e5413..6257bd3f0 100644
--- a/pyaerocom/aeroval/_processing_base.py
+++ b/pyaerocom/aeroval/_processing_base.py
@@ -3,8 +3,11 @@
 from pyaerocom._lowlevel_helpers import TypeValidator
 from pyaerocom.aeroval import EvalSetup
 from pyaerocom.aeroval.experiment_output import ExperimentOutput
+from pyaerocom.aeroval.json_utils import round_floats
+from pyaerocom.colocation.colocated_data import ColocatedData
 from pyaerocom.colocation.colocation_setup import ColocationSetup
 from pyaerocom.colocation.colocator import Colocator
+from pyaerocom.utils import recursive_defaultdict
 
 
 class HasConfig:
@@ -26,6 +29,7 @@ class HasConfig:
     def __init__(self, cfg: EvalSetup):
         self.cfg = cfg
         self.exp_output = ExperimentOutput(cfg)
+        self.avdb = self.exp_output.avdb
 
     @property
     def raise_exceptions(self):
diff --git a/pyaerocom/aeroval/coldatatojson_engine.py b/pyaerocom/aeroval/coldatatojson_engine.py
index 8a2ca235c..e736ad245 100644
--- a/pyaerocom/aeroval/coldatatojson_engine.py
+++ b/pyaerocom/aeroval/coldatatojson_engine.py
@@ -8,7 +8,6 @@
 from pyaerocom import ColocatedData, TsType
 from pyaerocom.aeroval._processing_base import ProcessingEngine
 from pyaerocom.aeroval.coldatatojson_helpers import (
-    _add_heatmap_entry_json,
     _apply_annual_constraint,
     _init_data_default_frequencies,
     _init_meta_glob,
@@ -19,20 +18,12 @@
     _process_sites_weekly_ts,
     _process_statistics_timeseries,
     _remove_less_covered,
-    _write_site_data,
-    _write_stationdata_json,
-    add_profile_entry_json,
-    get_heatmap_filename,
-    get_json_mapname,
-    get_profile_filename,
-    get_timeseries_file_name,
     init_regions_web,
     process_profile_data_for_regions,
     process_profile_data_for_stations,
-    update_regions_json,
 )
 from pyaerocom.aeroval.exceptions import ConfigError
-from pyaerocom.aeroval.json_utils import write_json
+from pyaerocom.aeroval.json_utils import round_floats
 from pyaerocom.exceptions import TemporalResolutionError
 
 logger = logging.getLogger(__name__)
@@ -181,7 +172,14 @@ def process_coldata(self, coldata: ColocatedData):
         # get region IDs
         (regborders, regs, regnames) = init_regions_web(coldata, regions_how)
 
-        update_regions_json(regborders, regions_json)
+        # Synchronise regions.json file
+        with self.avdb.lock():
+            regions = self.avdb.get_regions(
+                self.exp_output.proj_id, self.exp_output.exp_id, default={}
+            )
+            for region_name, region_info in regborders.items():
+                regions[region_name] = round_floats(region_info)
+            self.avdb.put_regions(regions, self.exp_output.proj_id, self.exp_output.exp_id)
 
         use_country = True if regions_how == "country" else False
 
@@ -301,10 +299,8 @@ def _process_profile_data_for_vizualization(
                 seasons=seasons,
             )
 
-            fname = get_profile_filename(region_names[regid], obs_name, var_name_web)
+            self.exp_output.add_profile_entry(data, profile_viz, periods, seasons)
 
-            outfile_profile = os.path.join(out_dirs["profiles"], fname)
-            add_profile_entry_json(outfile_profile, data, profile_viz, periods, seasons)
         # Loop through stations
         for station_name in station_names:
             profile_viz = process_profile_data_for_stations(
@@ -315,10 +311,7 @@ def _process_profile_data_for_vizualization(
                 seasons=seasons,
             )
 
-            fname = get_profile_filename(station_name, obs_name, var_name_web)
-
-            outfile_profile = os.path.join(out_dirs["profiles"], fname)
-            add_profile_entry_json(outfile_profile, data, profile_viz, periods, seasons)
+            self.exp_output.add_profile_entry(data, profile_viz, periods, seasons)
 
     def _process_stats_timeseries_for_all_regions(
         self,
@@ -362,10 +355,10 @@ def _process_stats_timeseries_for_all_regions(
 
             except TemporalResolutionError:
                 stats_ts = {}
-            fname = get_timeseries_file_name(regnames[reg], obs_name, var_name_web, vert_code)
-            ts_file = os.path.join(out_dirs["hm/ts"], fname)
-            _add_heatmap_entry_json(
-                ts_file, stats_ts, obs_name, var_name_web, vert_code, model_name, model_var
+
+            region = regnames[reg]
+            self.exp_output.add_heatmap_timeseries_entry(
+                stats_ts, region, obs_name, var_name_web, vert_code, model_name, model_var
             )
 
         logger.info("Processing heatmap data for all regions")
@@ -385,18 +378,14 @@ def _process_stats_timeseries_for_all_regions(
         )
 
         for freq, hm_data in hm_all.items():
-            fname = get_heatmap_filename(freq)
-
-            hm_file = os.path.join(out_dirs["hm"], fname)
-
-            _add_heatmap_entry_json(
-                hm_file, hm_data, obs_name, var_name_web, vert_code, model_name, model_var
+            self.exp_output.add_heatmap_entry(
+                hm_data, freq, obs_name, var_name_web, vert_code, model_name, model_var
             )
 
         logger.info("Processing regional timeseries for all regions")
         ts_objs_regional = _process_regional_timeseries(data, regnames, regions_how, meta_glob)
 
-        _write_site_data(ts_objs_regional, out_dirs["ts"])
+        self.exp_output.write_timeseries(ts_objs_regional)
         if coldata.has_latlon_dims:
             for cd in data.values():
                 if cd is not None:
@@ -405,7 +394,7 @@ def _process_stats_timeseries_for_all_regions(
         logger.info("Processing individual site timeseries data")
         (ts_objs, map_meta, site_indices) = _process_sites(data, regs, regions_how, meta_glob)
 
-        _write_site_data(ts_objs, out_dirs["ts"])
+        self.exp_output.write_timeseries(ts_objs)
 
         scatter_freq = min(TsType(fq) for fq in self.cfg.time_cfg.freqs)
         scatter_freq = min(scatter_freq, main_freq)
@@ -430,15 +419,30 @@ def _process_stats_timeseries_for_all_regions(
                 drop_stats,
             )
 
-            # the files in /map and /scat will be split up according to their time period as well
-            map_name = get_json_mapname(
-                obs_name, var_name_web, model_name, model_var, vert_code, period
-            )
-            outfile_map = os.path.join(out_dirs["map"], map_name)
-            write_json(map_data, outfile_map, ignore_nan=True)
+            with self.avdb.lock():
+                self.avdb.put_map(
+                    map_data,
+                    self.exp_output.proj_id,
+                    self.exp_output.exp_id,
+                    obs_name,
+                    var_name_web,
+                    vert_code,
+                    model_name,
+                    model_var,
+                    period.replace("/", ""),  # Remove slashes in CAMS2_83 period.
+                )
 
-            outfile_scat = os.path.join(out_dirs["scat"], map_name)
-            write_json(scat_data, outfile_scat, ignore_nan=True)
+                self.avdb.put_scatter(
+                    scat_data,
+                    self.exp_output.proj_id,
+                    self.exp_output.exp_id,
+                    obs_name,
+                    var_name_web,
+                    vert_code,
+                    model_name,
+                    model_var,
+                    period.replace("/", ""),  # Remove slashes in CAMS2_83 period.
+                )
 
     def _process_diurnal_profiles(
         self,
@@ -453,9 +457,7 @@ def _process_diurnal_profiles(
         )
         outdir = os.path.join(out_dirs["ts/diurnal"])
         for ts_data_weekly in ts_objs_weekly:
-            # writes json file
-            _write_stationdata_json(ts_data_weekly, outdir)
+            self.exp_output.write_station_data(ts_data_weekly)
         if ts_objs_weekly_reg != None:
             for ts_data_weekly_reg in ts_objs_weekly_reg:
-                # writes json file
-                _write_stationdata_json(ts_data_weekly_reg, outdir)
+                self.exp_output.write_station_data(ts_data_weekly_reg)
diff --git a/pyaerocom/aeroval/coldatatojson_helpers.py b/pyaerocom/aeroval/coldatatojson_helpers.py
index f01c34731..2ba669ad2 100644
--- a/pyaerocom/aeroval/coldatatojson_helpers.py
+++ b/pyaerocom/aeroval/coldatatojson_helpers.py
@@ -17,7 +17,6 @@
 from pyaerocom.aeroval.exceptions import ConfigError, TrendsError
 from pyaerocom.aeroval.fairmode_stats import fairmode_stats
 from pyaerocom.aeroval.helpers import _get_min_max_year_periods, _period_str_to_timeslice
-from pyaerocom.aeroval.json_utils import read_json, round_floats, write_json
 from pyaerocom.config import ALL_REGION_NAME
 from pyaerocom.exceptions import DataCoverageError, TemporalResolutionError
 from pyaerocom.helpers import start_stop
@@ -37,120 +36,6 @@
 logger = logging.getLogger(__name__)
 
 
-def get_heatmap_filename(ts_type):
-    return f"glob_stats_{ts_type}.json"
-
-
-def get_timeseries_file_name(region, obs_name, var_name_web, vert_code):
-    return f"{region}-{obs_name}-{var_name_web}-{vert_code}.json"
-
-
-def get_stationfile_name(station_name, obs_name, var_name_web, vert_code):
-    """Get name of station timeseries file"""
-    return f"{station_name}_{obs_name}-{var_name_web}_{vert_code}.json"
-
-
-def get_json_mapname(obs_name, var_name_web, model_name, model_var, vert_code, period):
-    """Get base name of json file"""
-    # for cams2_83 the periods contain slashes at this point
-    periodmod = period.replace("/", "")
-    return f"{obs_name}-{var_name_web}_{vert_code}_{model_name}-{model_var}_{periodmod}.json"
-
-
-def _write_stationdata_json(ts_data, out_dir):
-    """
-    This method writes time series data given in a dictionary to .json files
-
-    Parameters
-    ----------
-    ts_data : dict
-        A dictionary containing all processed time series data.
-    out_dir : str or similar
-        output directory
-
-    Returns
-    -------
-    None.
-
-    """
-    filename = get_stationfile_name(
-        ts_data["station_name"], ts_data["obs_name"], ts_data["var_name_web"], ts_data["vert_code"]
-    )
-
-    fp = os.path.join(out_dir, filename)
-    if os.path.exists(fp):
-        current = read_json(fp)
-    else:
-        current = {}
-    current[ts_data["model_name"]] = round_floats(ts_data)
-    write_json(current, fp, round_floats=False)
-
-
-def _write_site_data(ts_objs, dirloc):
-    """Write list of station timeseries files to json"""
-    for ts_data in ts_objs:
-        # writes json file
-        _write_stationdata_json(ts_data, dirloc)
-
-
-def _write_diurnal_week_stationdata_json(ts_data, out_dirs):
-    """
-    Minor modification of method _write_stationdata_json to allow a further
-    level of sub-directories
-
-    Parameters
-    ----------
-    ts_data : dict
-        A dictionary containing all processed time series data.
-    out_dirs : list
-        list of file paths for writing data to
-
-    Raises
-    ------
-    Exception
-        Raised if opening json file fails
-
-    Returns
-    -------
-    None.
-
-    """
-    filename = get_stationfile_name(
-        ts_data["station_name"], ts_data["obs_name"], ts_data["var_name_web"], ts_data["vert_code"]
-    )
-
-    fp = os.path.join(out_dirs["ts/diurnal"], filename)
-    if os.path.exists(fp):
-        current = read_json(fp)
-    else:
-        current = {}
-    current[ts_data["model_name"]] = round_floats(ts_data)
-    write_json(current, fp, round_floats=False)
-
-
-def _add_heatmap_entry_json(
-    heatmap_file, result, obs_name, var_name_web, vert_code, model_name, model_var
-):
-    if os.path.exists(heatmap_file):
-        current = read_json(heatmap_file)
-    else:
-        current = {}
-    if not var_name_web in current:
-        current[var_name_web] = {}
-    ov = current[var_name_web]
-    if not obs_name in ov:
-        ov[obs_name] = {}
-    on = ov[obs_name]
-    if not vert_code in on:
-        on[vert_code] = {}
-    ovc = on[vert_code]
-    if not model_name in ovc:
-        ovc[model_name] = {}
-    mn = ovc[model_name]
-    mn[model_var] = result
-    write_json(current, heatmap_file, round_floats=False)
-
-
 def _prepare_regions_json_helper(region_ids):
     regborders, regs = {}, {}
     for regid in region_ids:
@@ -221,33 +106,6 @@ def init_regions_web(coldata, regions_how):
     return (regborders, regs, regnames)
 
 
-def update_regions_json(region_defs, regions_json):
-    """Check current regions.json for experiment and update if needed
-
-    Parameters
-    ----------
-    region_defs : dict
-        keys are names of region (not IDs!) values define rectangular borders
-    regions_json : str
-        regions.json file (if it does not exist it will be created).
-
-    Returns
-    -------
-    dict
-        current content of updated regions.json
-    """
-    if os.path.exists(regions_json):
-        current = read_json(regions_json)
-    else:
-        current = {}
-
-    for region_name, region_info in region_defs.items():
-        if not region_name in current:
-            current[region_name] = round_floats(region_info)
-    write_json(current, regions_json, round_floats=False)
-    return current
-
-
 def _init_meta_glob(coldata, **kwargs):
     meta = coldata.metadata
 
@@ -1642,10 +1500,6 @@ def _init_data_default_frequencies(coldata, to_ts_types):
     return data_arrs
 
 
-def get_profile_filename(station_or_region_name, obs_name, var_name_web):
-    return f"{station_or_region_name}_{obs_name}_{var_name_web}.json"
-
-
 def process_profile_data_for_regions(
     data: ColocatedData,
     region_id: str,
@@ -1775,84 +1629,6 @@ def process_profile_data_for_stations(
     return output
 
 
-def add_profile_entry_json(
-    profile_file: str,
-    data: ColocatedData,
-    profile_viz: dict,
-    periods: list[str],
-    seasons: list[str],
-):  # pragma: no cover
-    """
-    Analogous to _add_heatmap_entry_json for profile data.
-    Every time this function is called it checks to see if the profile_file exists.
-    If so, it reads it, if not it makes a new one.
-    This is because one can not add to json files and so everytime we want to add entries for profile layers
-    we must read in the old file, add the entries, and write a new file.
-
-    Args:
-        profile_file (str): Name of profile_file
-        data (ColocatedData): For this vertical layer
-        profile_viz (dict): Output of process_profile_data()
-        periods (list[str]): periods to compute over (years)
-        seasons (list[str]): seasons to compute over (e.g., All, DJF, etc.)
-    """
-    if os.path.exists(profile_file):
-        current = read_json(profile_file)
-    else:
-        current = {}
-
-    for freq, coldata in data.items():
-        model_name = coldata.model_name
-        if not model_name in current:
-            current[model_name] = {}
-
-        midpoint = (
-            float(coldata.data.attrs["vertical_layer"]["end"])
-            + float(coldata.data.attrs["vertical_layer"]["start"])
-        ) / 2
-        if not "z" in current[model_name]:
-            current[model_name]["z"] = [midpoint]  # initalize with midpoint
-
-        if (
-            midpoint > current[model_name]["z"][-1]
-        ):  # only store incremental increases in the layers
-            current[model_name]["z"].append(midpoint)
-
-        if not "obs" in current[model_name]:
-            current[model_name]["obs"] = {}
-
-        if not freq in current[model_name]["obs"]:
-            current[model_name]["obs"][freq] = {}
-
-        if not "mod" in current[model_name]:
-            current[model_name]["mod"] = {}
-
-        if not freq in current[model_name]["mod"]:
-            current[model_name]["mod"][freq] = {}
-
-        for per in periods:
-            for season in seasons:
-                perstr = f"{per}-{season}"
-
-                if not perstr in current[model_name]["obs"][freq]:
-                    current[model_name]["obs"][freq][perstr] = []
-                if not perstr in current[model_name]["mod"][freq]:
-                    current[model_name]["mod"][freq][perstr] = []
-
-                current[model_name]["obs"][freq][perstr].append(profile_viz["obs"][freq][perstr])
-                current[model_name]["mod"][freq][perstr].append(profile_viz["mod"][freq][perstr])
-
-        if not "metadata" in current[model_name]:
-            current[model_name]["metadata"] = {
-                "z_unit": coldata.data.attrs["altitude_units"],
-                "z_description": "Altitude ASL",
-                "z_long_description": "Altitude Above Sea Level",
-                "unit": coldata.unitstr,
-            }
-        current[model_name] = round_floats(current[model_name])
-    write_json(current, profile_file, round_floats=False)
-
-
 def _remove_less_covered(
     data: ColocatedData,
     min_yrs: int,
diff --git a/pyaerocom/aeroval/experiment_output.py b/pyaerocom/aeroval/experiment_output.py
index 2968580da..665d40707 100644
--- a/pyaerocom/aeroval/experiment_output.py
+++ b/pyaerocom/aeroval/experiment_output.py
@@ -1,6 +1,7 @@
 import glob
 import logging
 import os
+import pathlib
 import shutil
 from collections import namedtuple
 
@@ -20,12 +21,15 @@
     statistics_obs_only,
     statistics_trend,
 )
+from pyaerocom.aeroval.json_utils import round_floats
 from pyaerocom.aeroval.modelentry import ModelEntry
 from pyaerocom.aeroval.setupclasses import EvalSetup
 from pyaerocom.aeroval.varinfo_web import VarinfoWeb
+from pyaerocom.colocation.colocated_data import ColocatedData
 from pyaerocom.exceptions import EntryNotAvailable, VariableDefinitionError
 from pyaerocom.stats.mda8.const import MDA8_OUTPUT_VARS
 from pyaerocom.stats.stats import _init_stats_dummy
+from pyaerocom.utils import recursive_defaultdict
 from pyaerocom.variable_helpers import get_aliases
 
 MapInfo = namedtuple(
@@ -42,11 +46,22 @@ class ProjectOutput:
 
     json_basedir = DirLoc(assert_exists=True)
 
-    def __init__(self, proj_id: str, json_basedir: str):
+    def __init__(self, proj_id: str, resource: str | pathlib.Path | aerovaldb.AerovalDB):
         self.proj_id = proj_id
-        self.json_basedir = json_basedir
 
-        self.avdb = aerovaldb.open(f"json_files:{json_basedir}")
+        if isinstance(resource, pathlib.Path):
+            resource = str(resource)
+
+        if isinstance(resource, str):
+            self.avdb = aerovaldb.open(resource)
+        elif isinstance(resource, aerovaldb.AerovalDB):
+            self.avdb = resource
+        else:
+            raise ValueError(f"Expected string or AerovalDB, got {type(resource)}.")
+
+        # TODO: Only works for json_files, check if this is needed, and remove / rewrite
+        # functionality that requires direct knowledge of _basedir.
+        self.json_basedir = self.avdb._basedir
 
     @property
     def proj_dir(self) -> str:
@@ -78,7 +93,12 @@ class ExperimentOutput(ProjectOutput):
 
     def __init__(self, cfg: EvalSetup):
         self.cfg = cfg
-        super().__init__(cfg.proj_id, cfg.path_manager.json_basedir)
+        super().__init__(
+            cfg.proj_id,
+            cfg.path_manager.json_basedir
+            if cfg.path_manager.avdb_resource is None
+            else cfg.path_manager.json_basedir,
+        )
 
         # dictionary that will be filled by json cleanup methods to check for
         # invalid or outdated json files across different output directories
@@ -157,7 +177,8 @@ def update_menu(self) -> None:
         """
         avail = self._create_menu_dict()
         avail = self._sort_menu_entries(avail)
-        self.avdb.put_menu(avail, self.proj_id, self.exp_id)
+        with self.avdb.lock():
+            self.avdb.put_menu(avail, self.proj_id, self.exp_id)
 
     def update_interface(self) -> None:
         """
@@ -191,36 +212,38 @@ def update_interface(self) -> None:
         # AeroVal frontend needs periods to be set in config json file...
         # make sure they are
         self.cfg._check_time_config()
-        self.avdb.put_config(self.cfg.json_repr(), self.proj_id, self.exp_id)
+        with self.avdb.lock():
+            self.avdb.put_config(self.cfg.json_repr(), self.proj_id, self.exp_id)
 
     def _sync_heatmaps_with_menu_and_regions(self) -> None:
         """
         Synchronise content of heatmap json files with content of menu.json
         """
-        menu = self.avdb.get_menu(self.proj_id, self.exp_id, default={})
-        all_regions = self.avdb.get_regions(self.proj_id, self.exp_id, default={})
-        for fp in self.avdb.list_glob_stats(self.proj_id, self.exp_id):
-            data = self.avdb.get_by_uuid(fp)
-            hm = {}
-            for vardisp, info in menu.items():
-                obs_dict = info["obs"]
-                if not vardisp in hm:
-                    hm[vardisp] = {}
-                for obs, vdict in obs_dict.items():
-                    if not obs in hm[vardisp]:
-                        hm[vardisp][obs] = {}
-                    for vert_code, mdict in vdict.items():
-                        if not vert_code in hm[vardisp][obs]:
-                            hm[vardisp][obs][vert_code] = {}
-                        for mod, minfo in mdict.items():
-                            if not mod in hm[vardisp][obs][vert_code]:
-                                hm[vardisp][obs][vert_code][mod] = {}
-                            modvar = minfo["model_var"]
-                            hm_data = data[vardisp][obs][vert_code][mod][modvar]
-                            hm_data = self._check_hm_all_regions_avail(all_regions, hm_data)
-                            hm[vardisp][obs][vert_code][mod][modvar] = hm_data
-
-            self.avdb.put_by_uuid(hm, fp)
+        with self.avdb.lock():
+            menu = self.avdb.get_menu(self.proj_id, self.exp_id, default={})
+            all_regions = self.avdb.get_regions(self.proj_id, self.exp_id, default={})
+            for fp in self.avdb.list_glob_stats(self.proj_id, self.exp_id):
+                data = self.avdb.get_by_uri(fp)
+                hm = {}
+                for vardisp, info in menu.items():
+                    obs_dict = info["obs"]
+                    if not vardisp in hm:
+                        hm[vardisp] = {}
+                    for obs, vdict in obs_dict.items():
+                        if not obs in hm[vardisp]:
+                            hm[vardisp][obs] = {}
+                        for vert_code, mdict in vdict.items():
+                            if not vert_code in hm[vardisp][obs]:
+                                hm[vardisp][obs][vert_code] = {}
+                            for mod, minfo in mdict.items():
+                                if not mod in hm[vardisp][obs][vert_code]:
+                                    hm[vardisp][obs][vert_code][mod] = {}
+                                modvar = minfo["model_var"]
+                                hm_data = data[vardisp][obs][vert_code][mod][modvar]
+                                hm_data = self._check_hm_all_regions_avail(all_regions, hm_data)
+                                hm[vardisp][obs][vert_code][mod][modvar] = hm_data
+
+                self.avdb.put_by_uri(hm, fp)
 
     def _check_hm_all_regions_avail(self, all_regions, hm_data) -> dict:
         if all([x in hm_data for x in all_regions]):
@@ -382,28 +405,29 @@ def _check_clean_ts_file(self, fp) -> bool:
             os.remove(fp)
             return True
 
-        try:
-            data = self.avdb.get_by_uuid(fp)
-        except Exception:
-            logger.exception(f"FATAL: detected corrupt json file: {fp}. Removing file...")
-            os.remove(fp)
-            return True
-
-        models_avail = list(data)
-        models_in_exp = self.cfg.model_cfg.web_iface_names
-        if all([mod in models_in_exp for mod in models_avail]):
-            # nothing to clean up
-            return False
-        modified = False
-        data_new = {}
-        for mod_name in models_avail:
-            if mod_name in models_in_exp:
-                data_new[mod_name] = data[mod_name]
-            else:
-                modified = True
-                logger.info(f"Removing data for model {mod_name} from ts file: {fp}")
+        with self.avdb.lock():
+            try:
+                data = self.avdb.get_by_uri(fp)
+            except Exception:
+                logger.exception(f"FATAL: detected corrupt json file: {fp}. Removing file...")
+                os.remove(fp)
+                return True
 
-        self.avdb.put_by_uuid(data_new, fp)
+            models_avail = list(data)
+            models_in_exp = self.cfg.model_cfg.web_iface_names
+            if all([mod in models_in_exp for mod in models_avail]):
+                # nothing to clean up
+                return False
+            modified = False
+            data_new = {}
+            for mod_name in models_avail:
+                if mod_name in models_in_exp:
+                    data_new[mod_name] = data[mod_name]
+                else:
+                    modified = True
+                    logger.info(f"Removing data for model {mod_name} from ts file: {fp}")
+
+            self.avdb.put_by_uri(data_new, fp)
         return modified
 
     def _clean_modelmap_files(self) -> list[str]:
@@ -516,14 +540,15 @@ def _get_cmap_info(self, var) -> dict[str, str | list[float]]:
         return info
 
     def _create_var_ranges_json(self) -> None:
-        ranges = self.avdb.get_ranges(self.proj_id, self.exp_id, default={})
+        with self.avdb.lock():
+            ranges = self.avdb.get_ranges(self.proj_id, self.exp_id, default={})
 
-        avail = self._results_summary()
-        all_vars = list(set(avail["ovar"] + avail["mvar"]))
-        for var in all_vars:
-            if not var in ranges or ranges[var]["scale"] == []:
-                ranges[var] = self._get_cmap_info(var)
-        self.avdb.put_ranges(ranges, self.proj_id, self.exp_id)
+            avail = self._results_summary()
+            all_vars = list(set(avail["ovar"] + avail["mvar"]))
+            for var in all_vars:
+                if not var in ranges or ranges[var]["scale"] == []:
+                    ranges[var] = self._get_cmap_info(var)
+            self.avdb.put_ranges(ranges, self.proj_id, self.exp_id)
 
     def _create_statistics_json(self) -> None:
         if self.cfg.statistics_opts.obs_only_stats:
@@ -557,7 +582,8 @@ def _create_statistics_json(self) -> None:
                     stats_info.update(statistics_mean_trend)
                     stats_info.update(statistics_median_trend)
 
-        self.avdb.put_statistics(stats_info, self.proj_id, self.exp_id)
+        with self.avdb.lock():
+            self.avdb.put_statistics(stats_info, self.proj_id, self.exp_id)
 
     def _get_var_name_and_type(self, var_name: str) -> VariableInfo:
         """Get menu name and type of observation variable
@@ -796,11 +822,12 @@ def _sort_menu_entries(self, avail: dict) -> dict:
         return new_sorted
 
     def _add_entry_experiments_json(self, exp_id: str, data) -> None:
-        current = self.avdb.get_experiments(self.proj_id, default={})
+        with self.avdb.lock():
+            current = self.avdb.get_experiments(self.proj_id, default={})
 
-        current[exp_id] = data
+            current[exp_id] = data
 
-        self.avdb.put_experiments(current, self.proj_id)
+            self.avdb.put_experiments(current, self.proj_id)
 
     def _del_entry_experiments_json(self, exp_id) -> None:
         """
@@ -816,13 +843,14 @@ def _del_entry_experiments_json(self, exp_id) -> None:
         None
 
         """
-        current = self.avdb.get_experiments(self.proj_id, default={})
+        with self.avdb.lock():
+            current = self.avdb.get_experiments(self.proj_id, default={})
 
-        try:
-            del current[exp_id]
-        except KeyError:
-            logger.warning(f"no such experiment registered: {exp_id}")
-        self.avdb.put_experiments(current, self.proj_id)
+            try:
+                del current[exp_id]
+            except KeyError:
+                logger.warning(f"no such experiment registered: {exp_id}")
+            self.avdb.put_experiments(current, self.proj_id)
 
     def reorder_experiments(self, exp_order=None) -> None:
         """Reorder experiment order in evaluation interface
@@ -840,7 +868,222 @@ def reorder_experiments(self, exp_order=None) -> None:
         elif not isinstance(exp_order, list):
             raise ValueError("need list as input")
 
-        current = self.avdb.get_experiments(self.proj_id, default={})
+        with self.avdb.lock():
+            current = self.avdb.get_experiments(self.proj_id, default={})
+
+            current = sort_dict_by_name(current, pref_list=exp_order)
+            self.avdb.put_experiments(current, self.proj_id)
+
+    def add_heatmap_timeseries_entry(
+        self,
+        entry: dict,
+        region: str,
+        network: str,
+        obsvar: str,
+        layer: str,
+        modelname: str,
+        modvar: str,
+    ):
+        """Adds a heatmap entry to hm/ts
+
+        :param entry: The entry to be added.
+        :param network: Observation network
+        :param obsvar: Observation variable
+        :param layer: Vertical layer
+        :param modelname: Model name
+        :param modvar: Model variable
+        """
+        project = self.proj_id
+        experiment = self.exp_id
+
+        with self.avdb.lock():
+            glob_stats = self.avdb.get_heatmap_timeseries(
+                project, experiment, region, network, obsvar, layer, default={}
+            )
+            glob_stats = recursive_defaultdict(glob_stats)
+            glob_stats[obsvar][network][layer][modelname][modvar] = round_floats(entry)
+            self.avdb.put_heatmap_timeseries(
+                glob_stats,
+                project,
+                experiment,
+                region,
+                network,
+                obsvar,
+                layer,
+            )
+
+    def add_forecast_entry(
+        self,
+        entry: dict,
+        region: str,
+        network: str,
+        obsvar: str,
+        layer: str,
+        modelname: str,
+        modvar: str,
+    ):
+        """Adds a forecast entry to forecast
+
+        :param entry: The entry to be added.
+        :param network: Observation network
+        :param obsvar: Observation variable
+        :param layer: Vertical layer
+        :param modelname: Model name
+        :param modvar: Model variable
+        """
+        project = self.proj_id
+        experiment = self.exp_id
+
+        with self.avdb.lock():
+            glob_stats = self.avdb.get_forecast(
+                project, experiment, region, network, obsvar, layer, default={}
+            )
+            glob_stats = recursive_defaultdict(glob_stats)
+            glob_stats[obsvar][network][layer][modelname][modvar] = round_floats(entry)
+            self.avdb.put_forecast(
+                glob_stats,
+                project,
+                experiment,
+                region,
+                network,
+                obsvar,
+                layer,
+            )
+
+    def add_heatmap_entry(
+        self,
+        entry,
+        frequency: str,
+        network: str,
+        obsvar: str,
+        layer: str,
+        modelname: str,
+        modvar: str,
+    ):
+        """Adds a heatmap entry to glob_stats
+
+        :param entry: The entry to be added.
+        :param region: The region (eg. ALL)
+        :param obsvar: Observation variable.
+        :param layer: Vertical Layer (eg. SURFACE)
+        :param modelname: Model name
+        :param modelvar: Model variable.
+        """
+        project = self.proj_id
+        experiment = self.exp_id
+
+        with self.avdb.lock():
+            glob_stats = self.avdb.get_glob_stats(project, experiment, frequency, default={})
+            glob_stats = recursive_defaultdict(glob_stats)
+            glob_stats[obsvar][network][layer][modelname][modvar] = entry
+            self.avdb.put_glob_stats(glob_stats, project, experiment, frequency)
+
+    def write_station_data(self, data):
+        """Writes timeseries weekly.
+
+        :param data: Data to be written.
+        """
+        project = self.proj_id
+        experiment = self.exp_id
+
+        location = data["station_name"]
+        network = data["obs_name"]
+        obsvar = data["var_name_web"]
+        layer = data["vert_code"]
+        modelname = data["model_name"]
+        with self.avdb.lock():
+            station_data = self.avdb.get_timeseries_weekly(
+                project, experiment, location, network, obsvar, layer, default={}
+            )
+            station_data[modelname] = round_floats(data)
+            self.avdb.put_timeseries_weekly(
+                station_data, project, experiment, location, network, obsvar, layer
+            )
+
+    def write_timeseries(self, data):
+        """Write timeseries
+
+        Args:
+            data: The timeseries object to be written.
 
-        current = sort_dict_by_name(current, pref_list=exp_order)
-        self.avdb.put_experiments(current, self.proj_id)
+        Note:
+        -----
+        All necessary metadata will be read from the data object.
+        """
+        if not isinstance(data, list):
+            data = [data]
+
+        project = self.proj_id
+        experiment = self.exp_id
+        with self.avdb.lock():
+            for d in data:
+                location = d["station_name"]
+                network = d["obs_name"]
+                obsvar = d["var_name_web"]
+                layer = d["vert_code"]
+                modelname = d["model_name"]
+
+                timeseries = self.avdb.get_timeseries(
+                    project, experiment, location, network, obsvar, layer, default={}
+                )
+                timeseries[modelname] = round_floats(d)
+                self.avdb.put_timeseries(
+                    timeseries, project, experiment, location, network, obsvar, layer
+                )
+
+    def add_profile_entry(
+        self, data: ColocatedData, profile_viz: dict, periods: list[str], seasons: list[str]
+    ):
+        """Adds an entry for the colocated data to profiles.json.
+
+        Args:
+            data (ColocatedData): For this vertical layer
+            profile_viz (dict): Output of process_profile_data()
+            periods (list[str]): periods to compute over (years)
+            seasons (list[str]): seasons to compute over (e.g., All, DJF, etc.)
+        """
+        with self.avdb.lock():
+            current = self.avdb.get_profiles(self.proj_id, self.exp_id, default={})
+            current = recursive_defaultdict(current)
+
+            for freq, coldata in data.items():
+                model_name = coldata.model_name
+
+                midpoint = (
+                    float(coldata.data.attrs["vertical_layer"]["end"])
+                    + float(coldata.data.attrs["vertical_layer"]["start"])
+                ) / 2
+                if not "z" in current[model_name]:
+                    current[model_name]["z"] = [midpoint]  # initalize with midpoint
+
+                if (
+                    midpoint > current[model_name]["z"][-1]
+                ):  # only store incremental increases in the layers
+                    current[model_name]["z"].append(midpoint)
+
+                for per in periods:
+                    for season in seasons:
+                        perstr = f"{per}-{season}"
+
+                        if not perstr in current[model_name]["obs"][freq]:
+                            current[model_name]["obs"][freq][perstr] = []
+                        if not perstr in current[model_name]["mod"][freq]:
+                            current[model_name]["mod"][freq][perstr] = []
+
+                        current[model_name]["obs"][freq][perstr].append(
+                            profile_viz["obs"][freq][perstr]
+                        )
+                        current[model_name]["mod"][freq][perstr].append(
+                            profile_viz["mod"][freq][perstr]
+                        )
+
+                if not "metadata" in current[model_name]:
+                    current[model_name]["metadata"] = {
+                        "z_unit": coldata.data.attrs["altitude_units"],
+                        "z_description": "Altitude ASL",
+                        "z_long_description": "Altitude Above Sea Level",
+                        "unit": coldata.unitstr,
+                    }
+                current[model_name] = round_floats(current[model_name])
+
+            self.avdb.put_profiles(current, self.proj_id, self.exp_id)
diff --git a/pyaerocom/aeroval/modelmaps_engine.py b/pyaerocom/aeroval/modelmaps_engine.py
index 3cfda0e9a..ee9e5d6fc 100644
--- a/pyaerocom/aeroval/modelmaps_engine.py
+++ b/pyaerocom/aeroval/modelmaps_engine.py
@@ -3,7 +3,6 @@
 
 from pyaerocom import GriddedData, TsType
 from pyaerocom.aeroval._processing_base import DataImporter, ProcessingEngine
-from pyaerocom.aeroval.json_utils import write_json
 from pyaerocom.aeroval.modelmaps_helpers import calc_contour_json, griddeddata_to_jsondict
 from pyaerocom.aeroval.varinfo_web import VarinfoWeb
 from pyaerocom.exceptions import (
@@ -152,7 +151,6 @@ def _process_map_var(self, model_name, var, reanalyse_existing):
 
         outdir = self.cfg.path_manager.get_json_output_dirs()["contour"]
         outname = f"{var}_{model_name}"
-
         fp_json = os.path.join(outdir, f"{outname}.json")
         fp_geojson = os.path.join(outdir, f"{outname}.geojson")
 
@@ -190,6 +188,12 @@ def _process_map_var(self, model_name, var, reanalyse_existing):
 
         datajson = griddeddata_to_jsondict(data, lat_res_deg=lat_res, lon_res_deg=lon_res)
 
-        write_json(datajson, fp_json, round_floats=False)
-        write_json(contourjson, fp_geojson, round_floats=False)
+        with self.avdb.lock():
+            self.avdb.put_gridded_map(
+                datajson, self.exp_output.proj_id, self.exp_output.exp_id, var, model_name
+            )
+            self.avdb.put_contour(
+                contourjson, self.exp_output.proj_id, self.exp_output.exp_id, var, model_name
+            )
+
         return [fp_json, fp_geojson]
diff --git a/pyaerocom/aeroval/setupclasses.py b/pyaerocom/aeroval/setupclasses.py
index 82dd5c697..fce9c91e8 100644
--- a/pyaerocom/aeroval/setupclasses.py
+++ b/pyaerocom/aeroval/setupclasses.py
@@ -14,6 +14,7 @@
 else:
     from typing_extensions import Self
 
+import aerovaldb
 import pandas as pd
 from pydantic import (
     BaseModel,
@@ -53,7 +54,12 @@ class OutputPaths(BaseModel):
     exp_id : str
         experiment ID
     json_basedir : str, Path
+    avdb_resource : str, Path, None
+        An aerovaldb resource identifier as expected by aerovaldb.open()[1].
+        If not provided, pyaerocom will fall back to using json_basedir, for
+        backwards compatibility.
 
+        [1] https://aerovaldb.readthedocs.io/en/latest/api.html#aerovaldb.open
     """
 
     # Pydantic ConfigDict
@@ -69,6 +75,7 @@ class OutputPaths(BaseModel):
         "contour",
         "profiles",
     ]
+    avdb_resource: Path | str | None = None
 
     json_basedir: Path | str = Field(
         default=os.path.join(const.OUTPUTDIR, "aeroval/data"), validate_default=True
@@ -538,10 +545,13 @@ def to_json(self, outdir: str, ignore_nan: bool = True, indent: int = 3) -> None
             json indentation
 
         """
-        filepath = os.path.join(outdir, self.json_filename)
-        data = self.json_repr()
-        write_json(data, filepath, ignore_nan=ignore_nan, indent=indent)
-        return filepath
+        with aerovaldb.open(
+            self.path_manager.json_basedir
+            if self.path_manager.avdb_resource is None
+            else self.path_manager.json_basedir
+        ) as db:
+            with db.lock():
+                db.put_config(self.json_repr(), self.proj_info.proj_id, self.exp_info.exp_id)
 
     @staticmethod
     def from_json(filepath: str) -> Self:
diff --git a/pyaerocom/scripts/cams2_83/engine.py b/pyaerocom/scripts/cams2_83/engine.py
index 9882192a1..2b2d538a6 100644
--- a/pyaerocom/scripts/cams2_83/engine.py
+++ b/pyaerocom/scripts/cams2_83/engine.py
@@ -13,7 +13,6 @@
 
 from pyaerocom import ColocatedData
 from pyaerocom.aeroval._processing_base import ProcessingEngine
-from pyaerocom.aeroval.coldatatojson_helpers import _add_heatmap_entry_json as _add_entry_json
 from pyaerocom.aeroval.coldatatojson_helpers import _select_period_season_coldata, init_regions_web
 from pyaerocom.exceptions import DataCoverageError, UnknownRegion
 from pyaerocom.io.cams2_83.models import ModelName
@@ -121,12 +120,9 @@ def process_coldata(self, coldata: list[ColocatedData]) -> None:
 
                     results[f"{regname}"][f"{perstr}"] = stats_list
 
-            name = f"{regname}_{obs_name}-{var_name_web}_{vert_code}.json"
-            filename = Path(out_dirs["forecast"]) / name
-
-            _add_entry_json(
-                filename,
+            self.exp_output.add_forecast_entry(
                 results[regname],
+                regname,
                 obs_name,
                 var_name_web,
                 vert_code,
diff --git a/pyaerocom/utils.py b/pyaerocom/utils.py
index d830506cf..ef1a36b0e 100644
--- a/pyaerocom/utils.py
+++ b/pyaerocom/utils.py
@@ -1,7 +1,9 @@
 from __future__ import annotations
 
 import fnmatch
+from collections import defaultdict
 from pathlib import Path
+from typing import Mapping
 
 import pandas as pd
 
@@ -142,3 +144,34 @@ def print_file(path: Path | str):
     for line in path.read_text().splitlines():
         if line.strip():
             print(line)
+
+
+def recursive_defaultdict(d: Mapping | None = None):
+    """
+    Creates a recursive default dict which creates empty subdicts
+    automatically on access. Useful to avoid lots of boiler plate
+    code for creating empty subdicts.
+
+    Example:
+
+    ```
+    dd = recursive_defaultdict()
+    dd["A"]["B"]["C"]["D"]["E"] = "Hello world"
+    ```
+
+    Optionally takes a parameter `d` (eg. dictionary) to initalize
+    the defaultdict. Every existing subdictionary will recursively be
+    turned into a recursive_defaultdict.
+
+    See also: https://docs.python.org/3/library/collections.html#collections.defaultdict
+    """
+    if d is None:
+        return defaultdict(recursive_defaultdict)
+
+    # Recursively turn every existing subdictionary into a nested dict.
+    d = defaultdict(recursive_defaultdict, d)
+    for k in d:
+        if isinstance(d[k], dict):
+            d[k] = recursive_defaultdict(d[k])
+
+    return d
diff --git a/pyaerocom_env.yml b/pyaerocom_env.yml
index 0c9ef322b..1893c5410 100644
--- a/pyaerocom_env.yml
+++ b/pyaerocom_env.yml
@@ -32,7 +32,7 @@ dependencies:
     - geojsoncontour
     - geocoder_reverse_natural_earth >= 0.0.2
     - pyaro >= 0.0.10
-    - git+https://github.com/metno/aerovaldb.git@v0.0.6
+    - git+https://github.com/metno/aerovaldb.git@v0.0.12
 ## testing
   - pytest >=7.4
   - pytest-dependency
diff --git a/pyproject.toml b/pyproject.toml
index 09f1bf848..9f805b1de 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,7 +21,7 @@ classifiers = [
 ]
 requires-python = ">=3.10"
 dependencies = [
-    "aerovaldb@git+https://github.com/metno/aerovaldb.git@v0.0.6",
+    "aerovaldb@git+https://github.com/metno/aerovaldb.git@v0.0.12",
     "scitools-iris>=3.8.1",
     "xarray>=2022.10.0",
     "cartopy>=0.21.1",


Test patch:
diff --git a/tests/aeroval/test_aeroval_HIGHLEV.py b/tests/aeroval/test_aeroval_HIGHLEV.py
index 9b1ccd021..d6cacc704 100644
--- a/tests/aeroval/test_aeroval_HIGHLEV.py
+++ b/tests/aeroval/test_aeroval_HIGHLEV.py
@@ -22,12 +22,14 @@
 CHK_CFG2 = {
     "map": [
         "AERONET-SDA-od550aer_Column_TM5-AP3-CTRL-od550aer_2010.json",
+        "AERONET-Sun-od550aer_Column_TM5-AP3-CTRL-od550aer_2010.json",
     ],
     "contour": 0,
     "hm": ["glob_stats_monthly.json"],
     "hm/ts": 21,
     "scat": [
         "AERONET-SDA-od550aer_Column_TM5-AP3-CTRL-od550aer_2010.json",
+        "AERONET-Sun-od550aer_Column_TM5-AP3-CTRL-od550aer_2010.json",
     ],
     "ts": 40,
     "ts/diurnal": 0,  # number of .json files in subdir
diff --git a/tests/aeroval/test_coldatatojson_helpers.py b/tests/aeroval/test_coldatatojson_helpers.py
index cfb461ab8..db04b147e 100644
--- a/tests/aeroval/test_coldatatojson_helpers.py
+++ b/tests/aeroval/test_coldatatojson_helpers.py
@@ -6,18 +6,13 @@
 import numpy as np
 from pytest import mark, param
 
-from pyaerocom.aeroval.coldatatojson_helpers import (
-    _add_heatmap_entry_json,
+from pyaerocom.aeroval.coldatatojson_helpers import (  # _add_heatmap_entry_json,; _write_diurnal_week_stationdata_json,; _write_site_data,; _write_stationdata_json,; get_stationfile_name,
     _init_stats_dummy,
     _prepare_aerocom_regions_json,
     _prepare_country_regions,
     _prepare_default_regions_json,
     _prepare_htap_regions_json,
     _prepare_regions_json_helper,
-    _write_diurnal_week_stationdata_json,
-    _write_site_data,
-    _write_stationdata_json,
-    get_stationfile_name,
 )
 from pyaerocom.region import get_all_default_region_ids
 from pyaerocom.region_defs import (
@@ -28,80 +23,6 @@
 )
 
 
-def test__write_stationdata_json(tmp_path: Path):
-    data = dict(station_name="stat1", obs_name="obs1", var_name_web="var1", vert_code="Column")
-    path: Path = tmp_path / get_stationfile_name(**data)
-    assert not path.exists()
-
-    data["model_name"] = "model1"
-    _write_stationdata_json(data, path.parent)
-    assert path.exists()
-
-    data["model_name"] = "model2"
-    _write_stationdata_json(data, path.parent)
-    assert path.exists()
-
-    data = json.loads(path.read_text())
-    assert data["model1"].pop("model_name") == "model1"
-    assert data["model2"].pop("model_name") == "model2"
-    assert data["model1"] == data["model2"]
-
-
-def test__write_site_data(tmp_path: Path):
-    data = [
-        dict(
-            model_name=f"model{n}",
-            station_name=f"stat{n}",
-            obs_name=f"obs{n}",
-            var_name_web=f"var{n}",
-            vert_code="Column",
-        )
-        for n in range(3)
-    ]
-    assert not list(tmp_path.glob("*.json"))
-    _write_site_data(data, str(tmp_path))
-    assert len(list(tmp_path.glob("*.json"))) == len(data)
-
-
-def test__write_diurnal_week_stationdata_json(tmp_path: Path):
-    data = dict(station_name="stat1", obs_name="obs1", var_name_web="var1", vert_code="Column")
-    dirs = {"ts/diurnal": tmp_path}
-    path: Path = tmp_path / get_stationfile_name(**data)
-    assert not path.exists()
-
-    data["model_name"] = "model1"
-    _write_diurnal_week_stationdata_json(data, dirs)
-    assert path.exists()
-
-    data["model_name"] = "model2"
-    _write_diurnal_week_stationdata_json(data, dirs)
-    assert path.exists()
-
-    data = json.loads(path.read_text())
-    assert data["model1"].pop("model_name") == "model1"
-    assert data["model2"].pop("model_name") == "model2"
-    assert data["model1"] == data["model2"]
-
-
-def test__add_heatmap_entry_json(tmp_path: Path):
-    path = tmp_path / "add_entry.json"
-    assert not path.exists()
-
-    result = dict(value=None)
-    obs, obs_var, vert, model, model_var = "obs", "obs_var", "Column", "model", "model_var"
-    _add_heatmap_entry_json(path, result, obs, obs_var, vert, model, model_var)
-    assert path.exists()
-
-    data = json.loads(path.read_text())
-    assert data[obs_var][obs][vert][model][model_var] == result
-
-    result.update(value=42.0)
-    _add_heatmap_entry_json(path, result, obs, obs_var, vert, model, model_var)
-
-    data = json.loads(path.read_text())
-    assert data[obs_var][obs][vert][model][model_var] == result
-
-
 def test__init_stats_dummy():
     dummy = _init_stats_dummy()
     assert dummy
diff --git a/tests/aeroval/test_coldatatojson_helpers2.py b/tests/aeroval/test_coldatatojson_helpers2.py
index 99f4ee7f8..64c976952 100644
--- a/tests/aeroval/test_coldatatojson_helpers2.py
+++ b/tests/aeroval/test_coldatatojson_helpers2.py
@@ -16,35 +16,12 @@
     _map_indices,
     _process_statistics_timeseries,
     _remove_less_covered,
-    get_heatmap_filename,
-    get_json_mapname,
-    get_profile_filename,
-    get_stationfile_name,
-    get_timeseries_file_name,
 )
 from pyaerocom.aeroval.exceptions import TrendsError
 from pyaerocom.exceptions import TemporalResolutionError, UnknownRegion
 from tests.fixtures.collocated_data import COLDATA
 
 
-def test_get_heatmap_filename():
-    assert get_heatmap_filename("daily") == "glob_stats_daily.json"
-
-
-def test_get_timeseries_filename():
-    assert get_timeseries_file_name("reg1", "obs1", "var1", "vert1") == "reg1-obs1-var1-vert1.json"
-
-
-def test_get_stationfile_name():
-    json = get_stationfile_name("stat1", "obs1", "var1", "Column")
-    assert json == "stat1_obs1-var1_Column.json"
-
-
-def test_get_json_mapname():
-    json = get_json_mapname("obs1", "var1", "mod1", "var1", "Column", "period")
-    assert json == "obs1-var1_Column_mod1-var1_period.json"
-
-
 def get_profile_filename():
     json = get_profile_filename("reg1", "obs1", "var1")
     assert json == "reg1_obs1_var1.json"
diff --git a/tests/aeroval/test_experiment_output.py b/tests/aeroval/test_experiment_output.py
index d7cd424e8..a90b1f682 100644
--- a/tests/aeroval/test_experiment_output.py
+++ b/tests/aeroval/test_experiment_output.py
@@ -2,6 +2,7 @@
 
 from pathlib import Path
 
+import aerovaldb
 import pytest
 
 from pyaerocom import const
@@ -21,10 +22,21 @@ def dummy_expout(tmp_path: Path) -> ExperimentOutput:
     return ExperimentOutput(setup)
 
 
+def test_invalid_resource_error():
+    with pytest.raises(ValueError):
+        ProjectOutput("test", None)
+
+
+def test_avdb_resource_init(tmp_path):
+    with aerovaldb.open(str(tmp_path)) as db:
+        val = ProjectOutput("test", db)
+
+        assert val.avdb is db
+
+
 @pytest.mark.parametrize(
     "proj_id,json_basedir",
     [
-        ("bla", None),
         ("bla", "/"),
     ],
 )
@@ -39,9 +51,6 @@ def test_ProjectOutput(proj_id: str, json_basedir: str):
     "proj_id,json_basedir,exception,error",
     [
         pytest.param(42, None, ValueError, "need str, got 42", id="ValueError"),
-        pytest.param(
-            "bla", "/blablub/blaaaa", FileNotFoundError, "/blablub/blaaaa", id="FileNotFoundError"
-        ),
     ],
 )
 def test_ProjectOutput_error(proj_id, json_basedir, exception: type[Exception], error: str):
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 426800f93..3a31a1ab5 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,3 +1,4 @@
+from collections import defaultdict
 from pathlib import Path
 
 import pytest
@@ -80,3 +81,32 @@ def test_create_varinfo_table_error(kwargs, error):
     with pytest.raises(TypeError) as e:
         utils.create_varinfo_table(**kwargs)
     assert str(e.value).endswith(error)
+
+
+def test_recursive_default_dict_1():
+    d = utils.recursive_defaultdict()
+
+    d["A"]["B"]["C"]["D"]["E"] = "Hello world"
+
+    assert d["A"]["B"]["C"]["D"]["E"] == "Hello world"
+
+
+def test_recursive_default_dict_2():
+    input_dict = {
+        "A": {"AA": {"AAA": 5}, "AB": 4},
+        "B": {"BA": 1, "BB": {"BBA": 4, "BBB": 5}},
+    }
+
+    d = utils.recursive_defaultdict(input_dict)
+
+    def check_dict_keys_and_values(d1, d2):
+        if not isinstance(d1, dict | defaultdict):
+            assert d1 == d2
+            return
+
+        assert set(d1.keys()) == set(d2.keys())
+
+        for k in d1.keys():
+            check_dict_keys_and_values(d1[k], d2[k])
+
+    check_dict_keys_and_values(input_dict, d)