Skip to content

Extending Data Sources

Implementing a custom data source

This example will demonstrate how to extend Earth2Studio by implementing a custom data source to use in a built in workflow.

In this example you will learn:

  • API requirements of data soruces
  • Implementing a custom data soruce

Custom Data Source

Earth2Studio defines the required APIs for data sources in [earth2studio.data.base.DataSource][] which requires just a call function. For this example, we will consider extending an existing remote data source with another atmospheric field we can calculate.

The earth2studio.data.ARCO_ERA5 data source provides the ERA5 dataset in a cloud optimized format, however it only provides specific humidity. This is a problem for models that may use relative humidity as an input. Based on ECMWF documentation we can calculate the relative humidity based on temperature and geo-potential.

import os

os.makedirs("outputs", exist_ok=True)
from dotenv import load_dotenv

load_dotenv()  # TODO: make common example prep function

from datetime import datetime

import numpy as np
import xarray as xr

from earth2studio.data import ARCO_ERA5, GFS
from earth2studio.data.utils import prep_data_inputs
from earth2studio.utils.type import TimeArray, VariableArray


class CustomDataSource:
    """Custom ARCO ERA5 data source."""

    relative_humidity_ids = [
        "r50",
        "r100",
        "r150",
        "r200",
        "r250",
        "r300",
        "r400",
        "r500",
        "r600",
        "r700",
        "r850",
        "r925",
        "r1000",
    ]

    def __init__(self, cache: bool = True, verbose: bool = True):
        self.arco = ARCO_ERA5(cache, verbose)

    def __call__(
        self,
        time: datetime | list[datetime] | TimeArray,
        variable: str | list[str] | VariableArray,
    ) -> xr.DataArray:
        """Function to get data.

        Parameters
        ----------
        time : datetime | list[datetime] | TimeArray
            Timestamps to return data for (UTC).
        variable : str | list[str] | VariableArray
            String, list of strings or array of strings that refer to variables to
            return. Must be in IFS lexicon.

        Returns
        -------
        xr.DataArray
        """
        time, variable = prep_data_inputs(time, variable)

        # Replace relative humidity with respective temperature
        # and specifc humidity fields
        variable_expanded = []
        for v in variable:
            if v in self.relative_humidity_ids:
                level = int(v[1:])
                variable_expanded.extend([f"t{level}", f"q{level}"])
            else:
                variable_expanded.append(v)
        variable_expanded = list(set(variable_expanded))

        # Fetch from ARCO ERA5
        da_exp = self.arco(time, variable_expanded)

        # Calculate relative humidity when needed
        arrays = []
        for v in variable:
            if v in self.relative_humidity_ids:
                level = int(v[1:])
                t = da_exp.sel(variable=f"t{level}").values
                q = da_exp.sel(variable=f"q{level}").values
                rh = self.calc_relative_humdity(t, q, 100 * level)
                arrays.append(rh)
            else:
                arrays.append(da_exp.sel(variable=v).values)

        da = xr.DataArray(
            data=np.stack(arrays, axis=1),
            dims=["time", "variable", "lat", "lon"],
            coords=dict(
                time=da_exp.coords["time"].values,
                variable=np.array(variable),
                lat=da_exp.coords["lat"].values,
                lon=da_exp.coords["lon"].values,
            ),
        )
        return da

    def calc_relative_humdity(
        self, temperature: np.array, specific_humidity: np.array, pressure: float
    ) -> np.array:
        """Relative humidity calculation

        Parameters
        ----------
        temperature : np.array
            Temperature field (K)
        specific_humidity : np.array
            Specific humidity field (g.kg-1)
        pressure : float
            Pressure (Pa)

        Returns
        -------
        np.array
        """
        epsilon = 0.621981
        p = pressure
        q = specific_humidity
        t = temperature

        e = (p * q * (1.0 / epsilon)) / (1 + q * (1.0 / (epsilon) - 1))

        es_w = 611.21 * np.exp(17.502 * (t - 273.16) / (t - 32.19))
        es_i = 611.21 * np.exp(22.587 * (t - 273.16) / (t + 0.7))

        alpha = np.clip((t - 250.16) / (273.16 - 250.16), 0, 1.2) ** 2
        es = alpha * es_w + (1 - alpha) * es_i
        rh = 100 * e / es

        return rh

__call__ API

The call function is the main API of data source which return the Xarray data array with the requested data. For this custom data source we intercept relative humidity variables, replace them with temperature and specific humidity requests then calculate the relative humidity from these fields. Note that the ARCO ERA5 data source is handling the remote complexity, we are just manipulating Numpy arrays

calc_relative_humdity

Based on the calculations ECMWF uses in their IFS numerical simulator which accounts for estimating the water vapor and ice present in the atmosphere.

Verification

Before plugging this into our workflow, let's quickly verify our data source is consistent with when GFS provides for relative humidity.

ds = CustomDataSource()
da_custom = ds(time=datetime(2022, 1, 1, hour=0), variable=["r500"])

ds_gfs = GFS()
da_gfs = ds_gfs(time=datetime(2022, 1, 1, hour=0), variable=["r500"])

print(da_custom)
Console output27 lines
Fetching ARCO data:   0%|          | 0/2 [00:00<?, ?it/s]
Fetching ARCO data:  50%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ     | 1/2 [00:02<00:02,  2.42s/it]
Fetching ARCO data: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 2/2 [00:03<00:00,  1.40s/it]
Fetching ARCO data: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 2/2 [00:03<00:00,  1.55s/it]

Fetching GFS data:   0%|          | 0/1 [00:00<?, ?it/s]
Fetching GFS data: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 1/1 [00:00<00:00,  1.95it/s]
Fetching GFS data: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 1/1 [00:00<00:00,  1.95it/s]
<xarray.DataArray (time: 1, variable: 1, lat: 721, lon: 1440)> Size: 8MB
array([[[[ 28.01413468,  28.01413468,  28.01413468, ...,  28.01413468,
           28.01413468,  28.01413468],
         [ 29.75579658,  29.75314258,  29.81191126, ...,  29.77167483,
           29.76636404,  29.76376009],
         [ 31.28908409,  31.28075462,  31.27237381, ...,  31.31699177,
           31.30860053,  31.29746972],
         ...,
         [ 97.36539026,  97.35685903,  97.39891442, ...,  97.34011022,
           97.32321665,  97.31468906],
         [ 97.50236648,  97.49380369,  97.54603948, ...,  97.5280597 ,
           97.51949448,  97.51949448],
         [102.58417544, 102.58417544, 102.58417544, ..., 102.58417544,
          102.58417544, 102.58417544]]]])
Coordinates:
  * time      (time) datetime64[ns] 8B 2022-01-01
  * variable  (variable) <U4 16B 'r500'
  * lat       (lat) float64 6kB 90.0 89.75 89.5 89.25 ... -89.5 -89.75 -90.0
  * lon       (lon) float64 12kB 0.0 0.25 0.5 0.75 ... 359.0 359.2 359.5 359.8
import cartopy.crs as ccrs
import matplotlib.pyplot as plt

fig, ax = plt.subplots(
    1,
    2,
    figsize=(10, 3),
    subplot_kw={"projection": ccrs.Mollweide()},
    constrained_layout=True,
)

ax[0].imshow(
    da_custom.sel(variable="r500")[0], transform=ccrs.PlateCarree(), vmin=0, vmax=100
)
ax[1].imshow(
    da_gfs.sel(variable="r500")[0], transform=ccrs.PlateCarree(), vmin=0, vmax=100
)

ax[0].set_title("Custom ARCO ERA5")
ax[1].set_title("GFS")
plt.suptitle("r500", fontsize=24)
cbar = plt.cm.ScalarMappable()
cbar.set_array(da_custom.sel(variable="r500")[0])
cbar.set_clim(0, 100)
cbar = fig.colorbar(cbar, ax=ax[-1], orientation="vertical", shrink=0.8)

plt.savefig("outputs/03_custom_datasource_gfs_versus_custom.jpg")

Output from Extending Data Sources

Execute Workflow

We will use this custom data source to run deterministic inference with a model that requires relative humidity. earth2studio.models.px.FCN is one such model. Since we are using ARCO ERA5, we can run inference for a time quite far back in time.

Let's instantiate the components needed.

from dotenv import load_dotenv

load_dotenv()  # TODO: make common example prep function

import earth2studio.run as run
from earth2studio.io import ZarrBackend
from earth2studio.models.px import FCN

package = FCN.load_default_package()
model = FCN.load_model(package)

# Create the data source
data = CustomDataSource()

# Create the IO handler, store in memory
io = ZarrBackend()

nsteps = 4
io = run.deterministic(["1993-04-05"], nsteps, model, data, io)

print(io.root.tree())
Console output103 lines
Downloading config.json: 0%|          | 0.00/22.0 [00:00<?, ?B/s]
Downloading config.json: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 22.0/22.0 [00:00<00:00, 166B/s]
Downloading config.json: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 22.0/22.0 [00:00<00:00, 165B/s]

Downloading fcn.mdlus: 0%|          | 0.00/287M [00:00<?, ?B/s]
Downloading fcn.mdlus: 3%|โ–Ž         | 10.0M/287M [00:01<00:34, 8.47MB/s]
Downloading fcn.mdlus: 7%|โ–‹         | 20.0M/287M [00:01<00:16, 17.1MB/s]
Downloading fcn.mdlus: 10%|โ–ˆ         | 30.0M/287M [00:01<00:09, 27.5MB/s]
Downloading fcn.mdlus: 14%|โ–ˆโ–        | 40.0M/287M [00:01<00:07, 36.5MB/s]
Downloading fcn.mdlus: 17%|โ–ˆโ–‹        | 50.0M/287M [00:01<00:05, 46.5MB/s]
Downloading fcn.mdlus: 21%|โ–ˆโ–ˆ        | 60.0M/287M [00:01<00:04, 52.9MB/s]
Downloading fcn.mdlus: 24%|โ–ˆโ–ˆโ–       | 70.0M/287M [00:02<00:03, 57.4MB/s]
Downloading fcn.mdlus: 28%|โ–ˆโ–ˆโ–Š       | 80.0M/287M [00:02<00:04, 53.3MB/s]
Downloading fcn.mdlus: 31%|โ–ˆโ–ˆโ–ˆโ–      | 90.0M/287M [00:02<00:03, 53.0MB/s]
Downloading fcn.mdlus: 35%|โ–ˆโ–ˆโ–ˆโ–      | 100M/287M [00:02<00:03, 56.9MB/s] 
Downloading fcn.mdlus: 38%|โ–ˆโ–ˆโ–ˆโ–Š      | 110M/287M [00:02<00:02, 62.4MB/s]
Downloading fcn.mdlus: 42%|โ–ˆโ–ˆโ–ˆโ–ˆโ–     | 120M/287M [00:02<00:02, 65.3MB/s]
Downloading fcn.mdlus: 45%|โ–ˆโ–ˆโ–ˆโ–ˆโ–Œ     | 130M/287M [00:03<00:02, 69.6MB/s]
Downloading fcn.mdlus: 49%|โ–ˆโ–ˆโ–ˆโ–ˆโ–Š     | 140M/287M [00:03<00:02, 72.2MB/s]
Downloading fcn.mdlus: 52%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–    | 150M/287M [00:03<00:02, 59.4MB/s]
Downloading fcn.mdlus: 56%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–Œ    | 160M/287M [00:03<00:02, 64.3MB/s]
Downloading fcn.mdlus: 59%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‰    | 170M/287M [00:03<00:01, 64.0MB/s]
Downloading fcn.mdlus: 63%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–Ž   | 180M/287M [00:03<00:01, 59.4MB/s]
Downloading fcn.mdlus: 66%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–Œ   | 190M/287M [00:04<00:01, 55.6MB/s]
Downloading fcn.mdlus: 70%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‰   | 200M/287M [00:04<00:02, 45.2MB/s]
Downloading fcn.mdlus: 73%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–Ž  | 210M/287M [00:04<00:01, 47.6MB/s]
Downloading fcn.mdlus: 77%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‹  | 220M/287M [00:04<00:01, 49.9MB/s]
Downloading fcn.mdlus: 80%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ  | 230M/287M [00:05<00:01, 56.5MB/s]
Downloading fcn.mdlus: 84%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–Ž | 240M/287M [00:05<00:00, 52.5MB/s]
Downloading fcn.mdlus: 87%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‹ | 250M/287M [00:05<00:00, 59.0MB/s]
Downloading fcn.mdlus: 91%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ | 260M/287M [00:05<00:00, 65.3MB/s]
Downloading fcn.mdlus: 94%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–| 270M/287M [00:05<00:00, 61.4MB/s]
Downloading fcn.mdlus: 97%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‹| 280M/287M [00:05<00:00, 59.6MB/s]
Downloading fcn.mdlus: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 287M/287M [00:05<00:00, 50.8MB/s]

Downloading global_means.npy: 0%|          | 0.00/336 [00:00<?, ?B/s]
Downloading global_means.npy: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 336/336 [00:00<00:00, 811B/s]
Downloading global_means.npy: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 336/336 [00:00<00:00, 806B/s]

Downloading global_stds.npy: 0%|          | 0.00/336 [00:00<?, ?B/s]
Downloading global_stds.npy: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 336/336 [00:00<00:00, 725B/s]
Downloading global_stds.npy: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 336/336 [00:00<00:00, 721B/s]
2026-08-25 13:41:05.717 | INFO     | earth2studio.run:deterministic:85 - Running simple workflow!
2026-08-25 13:41:05.717 | INFO     | earth2studio.run:deterministic:92 - Inference device: cuda

Fetching ARCO data:   0%|          | 0/13 [00:00<?, ?it/s]
Fetching ARCO data:   8%|โ–Š         | 1/13 [00:00<00:06,  1.80it/s]
Fetching ARCO data:  15%|โ–ˆโ–Œ        | 2/13 [00:00<00:04,  2.58it/s]
Fetching ARCO data:  38%|โ–ˆโ–ˆโ–ˆโ–Š      | 5/13 [00:00<00:01,  6.76it/s]
Fetching ARCO data:  62%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–   | 8/13 [00:01<00:00, 10.82it/s]
Fetching ARCO data:  77%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‹  | 10/13 [00:04<00:01,  1.73it/s]
Fetching ARCO data:  92%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–| 12/13 [00:06<00:00,  1.40it/s]
Fetching ARCO data: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 13/13 [00:08<00:00,  1.01s/it]
Fetching ARCO data: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 13/13 [00:08<00:00,  1.51it/s]
2026-08-25 13:41:15.694 | SUCCESS  | earth2studio.run:deterministic:154 - Fetched data from CustomDataSource
2026-08-25 13:41:15.695 | INFO     | earth2studio.run:deterministic:162 - Inference starting!


Running inference:   0%|          | 0/5 [00:00<?, ?it/s]

Running inference:  20%|โ–ˆโ–ˆ        | 1/5 [00:00<00:00,  8.45it/s]

Running inference:  40%|โ–ˆโ–ˆโ–ˆโ–ˆ      | 2/5 [00:00<00:01,  2.87it/s]

Running inference:  60%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ    | 3/5 [00:00<00:00,  3.48it/s]

Running inference:  80%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ  | 4/5 [00:01<00:00,  3.76it/s]

Running inference: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 5/5 [00:01<00:00,  3.90it/s]
Running inference: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 5/5 [00:01<00:00,  3.80it/s]
2026-08-25 13:41:17.011 | SUCCESS  | earth2studio.run:deterministic:189 - 
Inference complete
/
โ”œโ”€โ”€ lat (720,) float64
โ”œโ”€โ”€ lead_time (5,) timedelta64[h]
โ”œโ”€โ”€ lon (1440,) float64
โ”œโ”€โ”€ msl (1, 5, 720, 1440) float32
โ”œโ”€โ”€ r500 (1, 5, 720, 1440) float32
โ”œโ”€โ”€ r850 (1, 5, 720, 1440) float32
โ”œโ”€โ”€ sp (1, 5, 720, 1440) float32
โ”œโ”€โ”€ t250 (1, 5, 720, 1440) float32
โ”œโ”€โ”€ t2m (1, 5, 720, 1440) float32
โ”œโ”€โ”€ t500 (1, 5, 720, 1440) float32
โ”œโ”€โ”€ t850 (1, 5, 720, 1440) float32
โ”œโ”€โ”€ tcwv (1, 5, 720, 1440) float32
โ”œโ”€โ”€ time (1,) datetime64[ns]
โ”œโ”€โ”€ u1000 (1, 5, 720, 1440) float32
โ”œโ”€โ”€ u100m (1, 5, 720, 1440) float32
โ”œโ”€โ”€ u10m (1, 5, 720, 1440) float32
โ”œโ”€โ”€ u250 (1, 5, 720, 1440) float32
โ”œโ”€โ”€ u500 (1, 5, 720, 1440) float32
โ”œโ”€โ”€ u850 (1, 5, 720, 1440) float32
โ”œโ”€โ”€ v1000 (1, 5, 720, 1440) float32
โ”œโ”€โ”€ v100m (1, 5, 720, 1440) float32
โ”œโ”€โ”€ v10m (1, 5, 720, 1440) float32
โ”œโ”€โ”€ v250 (1, 5, 720, 1440) float32
โ”œโ”€โ”€ v500 (1, 5, 720, 1440) float32
โ”œโ”€โ”€ v850 (1, 5, 720, 1440) float32
โ”œโ”€โ”€ z1000 (1, 5, 720, 1440) float32
โ”œโ”€โ”€ z250 (1, 5, 720, 1440) float32
โ”œโ”€โ”€ z50 (1, 5, 720, 1440) float32
โ”œโ”€โ”€ z500 (1, 5, 720, 1440) float32
โ””โ”€โ”€ z850 (1, 5, 720, 1440) float32

Post Processing

To confirm that our model is working as expected, we will plot the total column water vapor field for a few time-steps.

forecast = "1993-04-05"
variable = "tcwv"

plt.close("all")

# Create a figure and axes with the specified projection
fig, ax = plt.subplots(2, 2, figsize=(6, 4))

# Plot tcwv every 6 hours
ax[0, 0].imshow(io[variable][0, 0], vmin=0, vmax=80, cmap="magma")
ax[0, 1].imshow(io[variable][0, 1], vmin=0, vmax=80, cmap="magma")
ax[1, 0].imshow(io[variable][0, 2], vmin=0, vmax=80, cmap="magma")
ax[1, 1].imshow(io[variable][0, 3], vmin=0, vmax=80, cmap="magma")

# Set title
plt.suptitle(f"{variable} - {forecast}")
times = (
    io["lead_time"][:].astype("timedelta64[ns]").astype("timedelta64[h]").astype(int)
)
ax[0, 0].set_title(f"Lead time: {times[0]}hrs")
ax[0, 1].set_title(f"Lead time: {times[1]}hrs")
ax[1, 0].set_title(f"Lead time: {times[2]}hrs")
ax[1, 1].set_title(f"Lead time: {times[3]}hrs")

plt.savefig("outputs/03_custom_datasource_prediction.jpg", bbox_inches="tight")

Output from Extending Data Sources


Execution profile

Runtime telemetry

Total runtime50.0 s

Execution environment

CPUAMD EPYC 7313P 16-Core Processor
GPUNVIDIA H100 PCIe ยท 79.6 GiB
System RAM58.5 GiB
PlatformLinux 6.8.0-137-generic
Python3.13.13
GPU driver / CUDADriver 595.84 ยท CUDA support 13.2