Skip to content

API Reference

Full documentation for all public classes and functions in sparta-solar, generated from the source docstrings.


Atmospheric data sources

MERRA-2 daily reanalysis

MERRA2DailyAtmosphere

MERRA2DailyAtmosphere()

Bases: BaseAtmosphere

MERRA-2 daily atmospheric data accessor.

Provides methods to load and interpolate MERRA-2 daily atmospheric data for specific locations or regular grids. Data is automatically cached locally and loaded from Zarr archives organized by year.

The class inherits from BaseAtmosphere and provides two main factory methods: - at_sites(): Extract data at specific point locations - on_regular_grid(): Extract data on a regular lat/lon mesh

Attributes: database_path: Path to local MERRA-2 data storage directory

Available Variables: - pressure: Surface air pressure [Pa] - albedo: Surface albedo [0-1] - pwater: Precipitable water [kg/m²] - ozone: Total column ozone [kg/m²] - beta: Angström turbidity parameter - alpha: Angström wavelength exponent - ssa: Aerosol single scattering albedo

Validates that the database path exists (if specified) and initializes the internal atmosphere dataset to None.

Raises:

Methods:

  • __init_subclass__

    Automatically sets the database path for subclasses.

  • at_sites

    Load atmospheric data at specific geographic locations.

  • compute

    Compute clear-sky solar radiation using a radiative transfer model.

  • on_regular_grid

    Load atmospheric data on a regular lat/lon grid.

Source code in src/spartasolar/atmoslib/_base.py
def __init__(self):
    """Initialize the atmosphere instance.

    Validates that the database path exists (if specified) and initializes
    the internal atmosphere dataset to None.

    Raises
    ------
    AttributeError
        If database_path is specified but does not exist
    """
    if self.database_path is not None and not self.database_path.exists():
        raise AttributeError(f"missing path `{self.database_path}`")

    self._atmosphere: xr.DataArray = None

__init_subclass__

__init_subclass__(database_path: str, **kwargs)

Automatically sets the database path for subclasses.

Parameters:

  • database_path
    (str or None) –

    The directory path where the specific atmosphere data is stored. Pass None for sources that do not use a file database (e.g. CustomAtmosphere or API-based retrievers).

Source code in src/spartasolar/atmoslib/_base.py
def __init_subclass__(cls, database_path: str, **kwargs):
    """Automatically sets the database path for subclasses.

    Parameters
    ----------
    database_path : str or None
        The directory path where the specific atmosphere data is stored.
        Pass ``None`` for sources that do not use a file database
        (e.g. ``CustomAtmosphere`` or API-based retrievers).
    """
    super().__init_subclass__(**kwargs)
    cls.database_path = None if database_path is None else Path(database_path)

at_sites classmethod

at_sites(
    times: ndarray[tuple[int], datetime64] | DatetimeIndex,
    latitude: Sequence[float] | float,
    longitude: Sequence[float] | float,
    site_names: Sequence[str] | None = None,
) -> Self

Load atmospheric data at specific geographic locations.

Extracts and interpolates MERRA-2 data for one or more point locations. Performs bilinear spatial interpolation and quadratic temporal interpolation.

Args: times: Time points for data extraction. Can be numpy datetime64 array or pandas DatetimeIndex. latitude: Latitude coordinate(s) in decimal degrees. Single value or sequence. Range: -90° < lat < 90°. longitude: Longitude coordinate(s) in decimal degrees. Single value or sequence. Range: -180° ≤ lon < 180°. site_names: Optional names for the sites. If provided, added as a coordinate in the output dataset.

Returns: MERRA2DailyAtmosphere: Instance containing interpolated atmospheric data.

Raises: ValueError: If latitude and longitude have different lengths, or if coordinates are out of valid range. NotImplementedError: If required data files are not found locally and downloading is not yet implemented.

Examples: >>> import pandas as pd >>> from spartasolar.atmoslib import MERRA2DailyAtmosphere

>>> # Single location
>>> times = pd.date_range("2020-01-01", periods=5, freq="D")
>>> atmos = MERRA2DailyAtmosphere.at_sites(
...     times=times,
...     latitude=40.4168,
...     longitude=-3.7038,
...     site_names="Madrid"
... )

>>> # Multiple locations
>>> lats = [40.4168, 41.3851, 36.7213]  # Madrid, Barcelona, Málaga
>>> lons = [-3.7038, 2.1734, -4.4214]
>>> names = ["Madrid", "Barcelona", "Málaga"]
>>> atmos = MERRA2DailyAtmosphere.at_sites(
...     times=times,
...     latitude=lats,
...     longitude=lons,
...     site_names=names
... )

>>> # Access data
>>> pressure = atmos.dataset["pressure"]
>>> print(pressure.dims)
('time', 'site')
Source code in src/spartasolar/atmoslib/merra2_daily.py
@classmethod
def at_sites(
    cls,
    times: np.ndarray[tuple[int], np.datetime64] | pd.DatetimeIndex,
    latitude: Sequence[float] | float,
    longitude: Sequence[float] | float,
    site_names: Sequence[str] | None = None,
) -> Self:
    """Load atmospheric data at specific geographic locations.

    Extracts and interpolates MERRA-2 data for one or more point locations.
    Performs bilinear spatial interpolation and quadratic temporal interpolation.

    Args:
        times: Time points for data extraction. Can be numpy datetime64 array
            or pandas DatetimeIndex.
        latitude: Latitude coordinate(s) in decimal degrees. Single value or
            sequence. Range: -90° < lat < 90°.
        longitude: Longitude coordinate(s) in decimal degrees. Single value or
            sequence. Range: -180° ≤ lon < 180°.
        site_names: Optional names for the sites. If provided, added as a
            coordinate in the output dataset.

    Returns:
        MERRA2DailyAtmosphere: Instance containing interpolated atmospheric data.

    Raises:
        ValueError: If latitude and longitude have different lengths, or if
            coordinates are out of valid range.
        NotImplementedError: If required data files are not found locally
            and downloading is not yet implemented.

    Examples:
        >>> import pandas as pd
        >>> from spartasolar.atmoslib import MERRA2DailyAtmosphere

        >>> # Single location
        >>> times = pd.date_range("2020-01-01", periods=5, freq="D")
        >>> atmos = MERRA2DailyAtmosphere.at_sites(
        ...     times=times,
        ...     latitude=40.4168,
        ...     longitude=-3.7038,
        ...     site_names="Madrid"
        ... )

        >>> # Multiple locations
        >>> lats = [40.4168, 41.3851, 36.7213]  # Madrid, Barcelona, Málaga
        >>> lons = [-3.7038, 2.1734, -4.4214]
        >>> names = ["Madrid", "Barcelona", "Málaga"]
        >>> atmos = MERRA2DailyAtmosphere.at_sites(
        ...     times=times,
        ...     latitude=lats,
        ...     longitude=lons,
        ...     site_names=names
        ... )

        >>> # Access data
        >>> pressure = atmos.dataset["pressure"]
        >>> print(pressure.dims)
        ('time', 'site')
    """

    latitude = [latitude] if isinstance(latitude, (float, int)) else latitude
    latitude = np.asarray([validate_type(lat, Latitude) for lat in latitude], dtype=float).reshape(-1)
    longitude = [longitude] if isinstance(longitude, (float, int)) else longitude
    longitude = np.asarray([validate_type(lon, Longitude) for lon in longitude], dtype=float).reshape(-1)

    if len(latitude) != len(longitude):
        raise ValueError('latitude and longitude must have the same length')

    # load the dataset. Check for local availability. If not available, download.
    dataset = cls._load_dataset(times)

    # lat-lon interpolation
    output_lat = xr.DataArray(latitude, dims="site", name="lat")
    output_lon = xr.DataArray(longitude, dims="site", name="lon")
    # # if interpolation was `nearest` it is faster to use .sel than .interp
    # output_dataset = dataset.sel(lat=output_lat, lon=output_lon, method='nearest')
    # output_dataset = dataset.interp(lat=output_lat, lon=output_lon, method='nearest')
    output_dataset = dataset.interp(lat=output_lat, lon=output_lon, method='linear')

    # time interpolation
    if "time" in output_dataset.coords:
        output_dataset = output_dataset.interp(time=times, method='quadratic')

    global_attrs = {
        "title": "Daily Clear-sky Atmospheric Dataset for SPARTA",
        "references": "doi:10.5067/KLICLTZ8EM9D, doi:10.5067/Q9QMY5PBNV1T, doi:10.5067/VJAFPLI1CSIV",
    }

    obj = cls()
    obj._atmosphere = build_atmosphere_of_sites(
        times=times,
        latitude=latitude,
        longitude=longitude,
        constituents=output_dataset.data_vars,
        site_names=site_names,
        global_attrs=global_attrs)
    return obj

compute

compute(
    model: Model = "SPARTA",
    include_atmosphere: bool = False,
    model_kwargs: dict | None = None,
) -> Dataset

Compute clear-sky solar radiation using a radiative transfer model.

This method integrates solar position calculations with atmospheric constituent data to compute clear-sky irradiance components (GHI, DNI, DHI, etc.) using the specified radiative transfer model.

Parameters:

  • model
    (Model, default: "SPARTA" ) –

    Name of the clear-sky model to use. Options: "SPARTA", "Bird"

  • include_atmosphere
    (bool, default: False ) –

    If True, include atmospheric constituents in the output dataset. If False, only radiation components are returned.

  • model_kwargs
    (dict, default: None ) –

    Additional keyword arguments to pass to the model function

Returns:

  • Dataset

    CF-compliant dataset containing computed irradiance components: - ghi: Global Horizontal Irradiance (W/m²) - dni: Direct Normal Irradiance (W/m²) - dhi or dif: Diffuse Horizontal Irradiance (W/m²) - csi: Circumsolar Irradiance (W/m², SPARTA only)

Examples:

>>> import pandas as pd
>>> from spartasolar.atmoslib import MERRA2DailyAtmosphere
>>>
>>> times = pd.date_range("2020-06-15", periods=24, freq="h")
>>> atm = MERRA2DailyAtmosphere.at_sites(
...     times=times,
...     latitude=36.72,
...     longitude=-4.42
... )
>>> result = atm.compute(model="SPARTA")
>>> print(result.ghi.values)

Use different model with custom parameters:

>>> result = atm.compute(
...     model="Bird",
...     model_kwargs={"scheme": "transmittance_parameterization"}
... )
Notes

The method automatically: - Calculates solar position (zenith angle, Earth-Sun distance) - Converts atmospheric units to model requirements - Handles both gridded and site-based data structures

See Also

spartasolar.modlib.sparta : SPARTA model implementation spartasolar.modlib.bird : Bird clear-sky model

Source code in src/spartasolar/atmoslib/_base.py
def compute(
    self,
    model: Model = "SPARTA",
    include_atmosphere: bool = False,
    model_kwargs: dict | None = None,
) -> xr.Dataset:
    """Compute clear-sky solar radiation using a radiative transfer model.

    This method integrates solar position calculations with atmospheric
    constituent data to compute clear-sky irradiance components (GHI, DNI,
    DHI, etc.) using the specified radiative transfer model.

    Parameters
    ----------
    model : Model, default "SPARTA"
        Name of the clear-sky model to use. Options: "SPARTA", "Bird"
    include_atmosphere : bool, default False
        If True, include atmospheric constituents in the output dataset.
        If False, only radiation components are returned.
    model_kwargs : dict, optional
        Additional keyword arguments to pass to the model function

    Returns
    -------
    xr.Dataset
        CF-compliant dataset containing computed irradiance components:
        - ghi: Global Horizontal Irradiance (W/m²)
        - dni: Direct Normal Irradiance (W/m²)
        - dhi or dif: Diffuse Horizontal Irradiance (W/m²)
        - csi: Circumsolar Irradiance (W/m², SPARTA only)

    Examples
    --------
    >>> import pandas as pd
    >>> from spartasolar.atmoslib import MERRA2DailyAtmosphere
    >>>
    >>> times = pd.date_range("2020-06-15", periods=24, freq="h")
    >>> atm = MERRA2DailyAtmosphere.at_sites(
    ...     times=times,
    ...     latitude=36.72,
    ...     longitude=-4.42
    ... )
    >>> result = atm.compute(model="SPARTA")
    >>> print(result.ghi.values)

    Use different model with custom parameters:

    >>> result = atm.compute(
    ...     model="Bird",
    ...     model_kwargs={"scheme": "transmittance_parameterization"}
    ... )

    Notes
    -----
    The method automatically:
    - Calculates solar position (zenith angle, Earth-Sun distance)
    - Converts atmospheric units to model requirements
    - Handles both gridded and site-based data structures

    See Also
    --------
    spartasolar.modlib.sparta : SPARTA model implementation
    spartasolar.modlib.bird : Bird clear-sky model
    """

    model = validate_type(model, Model)
    model_func = getattr(modlib, model)
    model_vars = inspect.getfullargspec(model_func).args

    is_regular_grid = "site" not in self.dataset.dims

    # compute solar geometry...
    sw_eval = sunwhere.regular_grid if is_regular_grid else sunwhere.sites
    solpos = sw_eval(
        self.dataset.time.values,
        latitude=self.dataset.lat.values,
        longitude=self.dataset.lon.values,
        algorithm=config.get_option("sunwhere.algorithm", default="psa"),
        refraction=config.get_option("sunwhere.refraction", default=True),
        engine=config.get_option("sunwhere.engine", default="numexpr")
    )

    new_coord_names = {"location": "site", "latitude": "lat", "longitude": "lon"}
    cosz = solpos.cosz.rename({old: new for old, new in new_coord_names.items() if old in solpos.cosz.dims})

    if is_regular_grid:
        n_lats = self.dataset.sizes["lat"]
        n_lons = self.dataset.sizes["lon"]
        ecf = solpos.ecf.expand_dims(dim={"lat": n_lats, "lon": n_lons}, axis=(1, 2))
    else:
        n_locs = self.dataset.sizes["site"]
        ecf = solpos.ecf.expand_dims(dim={"site": n_locs}, axis=1)

    kwargs = {"cosz": cosz, "ecf": ecf}

    # atmosphere...
    def get_with_proper_units(var):
        if var not in self.dataset.data_vars:
            raise ValueError(f"variable `{var}` required by the model `{model}` is not available in this atmosphere")
        if var == "pressure":
            return self.dataset[var] * 1e-2  # Pa to hPa
        if var == "pwater":
            return pwater_in_kg_m2_to_cm(self.dataset[var])
        if var == "ozone":
            return ozone_in_kg_m2_to_cm(self.dataset[var])
        return self.dataset[var]
    variables = set(model_vars).intersection(self.dataset.data_vars)  # variables required by this model
    kwargs = kwargs | {var: get_with_proper_units(var) for var in variables}

    # and call the clearsky model...
    result = model_func(**(kwargs | (model_kwargs or {})))

    # encapsulate the result in a CF-compliant xarray Dataset
    if is_regular_grid:
        return build_atmosphere_on_regular_grid(
            times=self.dataset.time.values,
            latitude=self.dataset.lat.values,
            longitude=self.dataset.lon.values,
            constituents=result,
            global_attrs=get_global_attrs(feature_type="grid")
        )
    else:
        site_values = self.dataset.coords.get("site").values
        n_sites = self.dataset.sizes["site"]
        site_names = validate_site_names(site_values, n_sites)
        return build_atmosphere_of_sites(
            times=self.dataset.time.values,
            latitude=self.dataset.lat.values,
            longitude=self.dataset.lon.values,
            constituents=result,
            site_names=site_names,
            global_attrs=get_global_attrs(feature_type="timeSeries")
        )

on_regular_grid classmethod

on_regular_grid(
    times: ndarray[tuple[int], datetime64] | DatetimeIndex,
    latitude: Sequence[float] | float,
    longitude: Sequence[float] | float,
) -> Self

Load atmospheric data on a regular lat/lon grid.

Extracts and interpolates MERRA-2 data onto a user-specified regular grid. The output has dimensions (time, lat, lon). NaN values in albedo are filled with zeros.

Args: times: Time points for data extraction. latitude: Latitude coordinates for the grid in decimal degrees. Must be a sequence (list, array). longitude: Longitude coordinates for the grid in decimal degrees. Must be a sequence (list, array).

Returns: MERRA2DailyAtmosphere: Instance containing gridded atmospheric data.

Raises: ValueError: If coordinates are out of valid range. NotImplementedError: If required data files are not found locally.

Examples: >>> import pandas as pd >>> import numpy as np >>> from spartasolar.atmoslib import MERRA2DailyAtmosphere

>>> # Define a regular grid over Iberian Peninsula
>>> times = pd.date_range("2020-06-01", periods=10, freq="D")
>>> lats = np.linspace(36, 44, 9)  # 9 latitude points
>>> lons = np.linspace(-10, 4, 15)  # 15 longitude points

>>> atmos = MERRA2DailyAtmosphere.on_regular_grid(
...     times=times,
...     latitude=lats,
...     longitude=lons
... )

>>> # Access gridded data
>>> albedo = atmos.dataset["albedo"]
>>> print(albedo.dims)
('time', 'lat', 'lon')
>>> print(albedo.shape)
(10, 9, 15)
Source code in src/spartasolar/atmoslib/merra2_daily.py
@classmethod
def on_regular_grid(
    cls,
    times: np.ndarray[tuple[int], np.datetime64] | pd.DatetimeIndex,
    latitude: Sequence[float] | float,
    longitude: Sequence[float] | float,
) -> Self:
    """Load atmospheric data on a regular lat/lon grid.

    Extracts and interpolates MERRA-2 data onto a user-specified regular grid.
    The output has dimensions (time, lat, lon). NaN values in albedo are filled
    with zeros.

    Args:
        times: Time points for data extraction.
        latitude: Latitude coordinates for the grid in decimal degrees.
            Must be a sequence (list, array).
        longitude: Longitude coordinates for the grid in decimal degrees.
            Must be a sequence (list, array).

    Returns:
        MERRA2DailyAtmosphere: Instance containing gridded atmospheric data.

    Raises:
        ValueError: If coordinates are out of valid range.
        NotImplementedError: If required data files are not found locally.

    Examples:
        >>> import pandas as pd
        >>> import numpy as np
        >>> from spartasolar.atmoslib import MERRA2DailyAtmosphere

        >>> # Define a regular grid over Iberian Peninsula
        >>> times = pd.date_range("2020-06-01", periods=10, freq="D")
        >>> lats = np.linspace(36, 44, 9)  # 9 latitude points
        >>> lons = np.linspace(-10, 4, 15)  # 15 longitude points

        >>> atmos = MERRA2DailyAtmosphere.on_regular_grid(
        ...     times=times,
        ...     latitude=lats,
        ...     longitude=lons
        ... )

        >>> # Access gridded data
        >>> albedo = atmos.dataset["albedo"]
        >>> print(albedo.dims)
        ('time', 'lat', 'lon')
        >>> print(albedo.shape)
        (10, 9, 15)
    """

    latitude = [latitude] if isinstance(latitude, (float, int)) else latitude
    latitude = np.asarray([validate_type(lat, Latitude) for lat in latitude], dtype=float).reshape(-1)
    longitude = [longitude] if isinstance(longitude, (float, int)) else longitude
    longitude = np.asarray([validate_type(lon, Longitude) for lon in longitude], dtype=float).reshape(-1)

    # load the dataset. Check for local availability. If not available, download.
    dataset = cls._load_dataset(times)
    dataset["albedo"] = dataset["albedo"].fillna(0.)

    # lat-lon interpolation
    output_dataset = dataset.interp(lat=latitude, lon=longitude, method='linear')

    # time interpolation
    if "time" in output_dataset.coords:
        output_dataset = output_dataset.interp(time=times, method='quadratic')

    global_attrs = {
        "title": "Daily Clear-sky Atmospheric Dataset for SPARTA",
        "references": "doi:10.5067/KLICLTZ8EM9D, doi:10.5067/Q9QMY5PBNV1T, doi:10.5067/VJAFPLI1CSIV",
    }

    obj = cls()
    obj._atmosphere = build_atmosphere_on_regular_grid(
        times=times,
        latitude=latitude,
        longitude=longitude,
        constituents=output_dataset.data_vars,
        global_attrs=global_attrs)
    return obj

MERRA-2 long-term averages

MERRA2LTAAtmosphere

MERRA2LTAAtmosphere()

Bases: BaseAtmosphere

MERRA-2 long-term monthly average atmospheric database.

Provides climatological monthly averages (1999-2018) of atmospheric constituents from NASA MERRA-2 reanalysis. Data is interpolated spatially and temporally to match requested coordinates and times.

See module documentation for examples.

Validates that the database path exists (if specified) and initializes the internal atmosphere dataset to None.

Raises:

Methods:

  • __init_subclass__

    Automatically sets the database path for subclasses.

  • at_sites

    Retrieve monthly climatology at specific sites.

  • compute

    Compute clear-sky solar radiation using a radiative transfer model.

  • on_regular_grid

    Retrieve monthly climatology on a regular spatial grid.

Source code in src/spartasolar/atmoslib/_base.py
def __init__(self):
    """Initialize the atmosphere instance.

    Validates that the database path exists (if specified) and initializes
    the internal atmosphere dataset to None.

    Raises
    ------
    AttributeError
        If database_path is specified but does not exist
    """
    if self.database_path is not None and not self.database_path.exists():
        raise AttributeError(f"missing path `{self.database_path}`")

    self._atmosphere: xr.DataArray = None

__init_subclass__

__init_subclass__(database_path: str, **kwargs)

Automatically sets the database path for subclasses.

Parameters:

  • database_path
    (str or None) –

    The directory path where the specific atmosphere data is stored. Pass None for sources that do not use a file database (e.g. CustomAtmosphere or API-based retrievers).

Source code in src/spartasolar/atmoslib/_base.py
def __init_subclass__(cls, database_path: str, **kwargs):
    """Automatically sets the database path for subclasses.

    Parameters
    ----------
    database_path : str or None
        The directory path where the specific atmosphere data is stored.
        Pass ``None`` for sources that do not use a file database
        (e.g. ``CustomAtmosphere`` or API-based retrievers).
    """
    super().__init_subclass__(**kwargs)
    cls.database_path = None if database_path is None else Path(database_path)

at_sites classmethod

Retrieve monthly climatology at specific sites.

Parameters:

  • times
    (ndarray or DatetimeIndex) –

    Time stamps for climatology retrieval. Monthly climatology is repeated for each year and interpolated to exact times.

  • latitude
    (Sequence[float]) –

    Latitude(s) in degrees North [-90, 90]

  • longitude
    (Sequence[float]) –

    Longitude(s) in degrees East [-180, 180]

  • site_names
    (Sequence[str], default: None ) –

    Names for each site

Returns:

Examples:

>>> times = pd.date_range("2023-01-01", "2023-12-31", freq="D")
>>> atm = MERRA2LTAAtmosphere.at_sites(
...     times=times,
...     latitude=36.72,
...     longitude=-4.42,
...     site_names="Málaga"
... )
Source code in src/spartasolar/atmoslib/merra2_lta.py
@classmethod
def at_sites(
    cls,
    times: np.ndarray[tuple[int], np.datetime64] | pd.DatetimeIndex,
    latitude: Sequence[float] | float,
    longitude: Sequence[float] | float,
    site_names: Sequence[str] | None = None,
) -> Self:
    """Retrieve monthly climatology at specific sites.

    Parameters
    ----------
    times : np.ndarray or pd.DatetimeIndex
        Time stamps for climatology retrieval. Monthly climatology is
        repeated for each year and interpolated to exact times.
    latitude : Sequence[float]
        Latitude(s) in degrees North [-90, 90]
    longitude : Sequence[float]
        Longitude(s) in degrees East [-180, 180]
    site_names : Sequence[str], optional
        Names for each site

    Returns
    -------
    MERRA2LTAAtmosphere
        Instance with interpolated climatological data

    Examples
    --------
    >>> times = pd.date_range("2023-01-01", "2023-12-31", freq="D")
    >>> atm = MERRA2LTAAtmosphere.at_sites(
    ...     times=times,
    ...     latitude=36.72,
    ...     longitude=-4.42,
    ...     site_names="Málaga"
    ... )
    """

    latitude = np.asarray(latitude, dtype=float).reshape(-1)
    latitude = [validate_type(lat, Latitude) for lat in latitude]
    longitude = np.asarray(longitude, dtype=float).reshape(-1)
    longitude = [validate_type(lon, Longitude) for lon in longitude]

    if len(latitude) != len(longitude):
        raise ValueError('latitude and longitude must have the same length')

    # load the dataset. Check for local availability. If not available, download.
    dataset = cls._load_dataset(times)

    # lat-lon interpolation
    output_lat = xr.DataArray(latitude, dims="site", name="lat")
    output_lon = xr.DataArray(longitude, dims="site", name="lon")
    output_dataset = dataset.interp(lat=output_lat, lon=output_lon, method='linear')

    # time interpolation
    if "time" in output_dataset.coords:
        output_dataset = output_dataset.interp(time=times, method='quadratic')

    global_attrs = {
        "title": "Long Term Average Clear-sky Atmospheric Dataset for SPARTA",
        "references": "doi:10.5067/KLICLTZ8EM9D, doi:10.5067/Q9QMY5PBNV1T, doi:10.5067/VJAFPLI1CSIV",
    }

    obj = cls()
    obj._atmosphere = build_atmosphere_of_sites(
        times=times,
        latitude=latitude,
        longitude=longitude,
        constituents=output_dataset.data_vars,
        site_names=site_names,
        global_attrs=global_attrs)
    return obj

compute

compute(
    model: Model = "SPARTA",
    include_atmosphere: bool = False,
    model_kwargs: dict | None = None,
) -> Dataset

Compute clear-sky solar radiation using a radiative transfer model.

This method integrates solar position calculations with atmospheric constituent data to compute clear-sky irradiance components (GHI, DNI, DHI, etc.) using the specified radiative transfer model.

Parameters:

  • model
    (Model, default: "SPARTA" ) –

    Name of the clear-sky model to use. Options: "SPARTA", "Bird"

  • include_atmosphere
    (bool, default: False ) –

    If True, include atmospheric constituents in the output dataset. If False, only radiation components are returned.

  • model_kwargs
    (dict, default: None ) –

    Additional keyword arguments to pass to the model function

Returns:

  • Dataset

    CF-compliant dataset containing computed irradiance components: - ghi: Global Horizontal Irradiance (W/m²) - dni: Direct Normal Irradiance (W/m²) - dhi or dif: Diffuse Horizontal Irradiance (W/m²) - csi: Circumsolar Irradiance (W/m², SPARTA only)

Examples:

>>> import pandas as pd
>>> from spartasolar.atmoslib import MERRA2DailyAtmosphere
>>>
>>> times = pd.date_range("2020-06-15", periods=24, freq="h")
>>> atm = MERRA2DailyAtmosphere.at_sites(
...     times=times,
...     latitude=36.72,
...     longitude=-4.42
... )
>>> result = atm.compute(model="SPARTA")
>>> print(result.ghi.values)

Use different model with custom parameters:

>>> result = atm.compute(
...     model="Bird",
...     model_kwargs={"scheme": "transmittance_parameterization"}
... )
Notes

The method automatically: - Calculates solar position (zenith angle, Earth-Sun distance) - Converts atmospheric units to model requirements - Handles both gridded and site-based data structures

See Also

spartasolar.modlib.sparta : SPARTA model implementation spartasolar.modlib.bird : Bird clear-sky model

Source code in src/spartasolar/atmoslib/_base.py
def compute(
    self,
    model: Model = "SPARTA",
    include_atmosphere: bool = False,
    model_kwargs: dict | None = None,
) -> xr.Dataset:
    """Compute clear-sky solar radiation using a radiative transfer model.

    This method integrates solar position calculations with atmospheric
    constituent data to compute clear-sky irradiance components (GHI, DNI,
    DHI, etc.) using the specified radiative transfer model.

    Parameters
    ----------
    model : Model, default "SPARTA"
        Name of the clear-sky model to use. Options: "SPARTA", "Bird"
    include_atmosphere : bool, default False
        If True, include atmospheric constituents in the output dataset.
        If False, only radiation components are returned.
    model_kwargs : dict, optional
        Additional keyword arguments to pass to the model function

    Returns
    -------
    xr.Dataset
        CF-compliant dataset containing computed irradiance components:
        - ghi: Global Horizontal Irradiance (W/m²)
        - dni: Direct Normal Irradiance (W/m²)
        - dhi or dif: Diffuse Horizontal Irradiance (W/m²)
        - csi: Circumsolar Irradiance (W/m², SPARTA only)

    Examples
    --------
    >>> import pandas as pd
    >>> from spartasolar.atmoslib import MERRA2DailyAtmosphere
    >>>
    >>> times = pd.date_range("2020-06-15", periods=24, freq="h")
    >>> atm = MERRA2DailyAtmosphere.at_sites(
    ...     times=times,
    ...     latitude=36.72,
    ...     longitude=-4.42
    ... )
    >>> result = atm.compute(model="SPARTA")
    >>> print(result.ghi.values)

    Use different model with custom parameters:

    >>> result = atm.compute(
    ...     model="Bird",
    ...     model_kwargs={"scheme": "transmittance_parameterization"}
    ... )

    Notes
    -----
    The method automatically:
    - Calculates solar position (zenith angle, Earth-Sun distance)
    - Converts atmospheric units to model requirements
    - Handles both gridded and site-based data structures

    See Also
    --------
    spartasolar.modlib.sparta : SPARTA model implementation
    spartasolar.modlib.bird : Bird clear-sky model
    """

    model = validate_type(model, Model)
    model_func = getattr(modlib, model)
    model_vars = inspect.getfullargspec(model_func).args

    is_regular_grid = "site" not in self.dataset.dims

    # compute solar geometry...
    sw_eval = sunwhere.regular_grid if is_regular_grid else sunwhere.sites
    solpos = sw_eval(
        self.dataset.time.values,
        latitude=self.dataset.lat.values,
        longitude=self.dataset.lon.values,
        algorithm=config.get_option("sunwhere.algorithm", default="psa"),
        refraction=config.get_option("sunwhere.refraction", default=True),
        engine=config.get_option("sunwhere.engine", default="numexpr")
    )

    new_coord_names = {"location": "site", "latitude": "lat", "longitude": "lon"}
    cosz = solpos.cosz.rename({old: new for old, new in new_coord_names.items() if old in solpos.cosz.dims})

    if is_regular_grid:
        n_lats = self.dataset.sizes["lat"]
        n_lons = self.dataset.sizes["lon"]
        ecf = solpos.ecf.expand_dims(dim={"lat": n_lats, "lon": n_lons}, axis=(1, 2))
    else:
        n_locs = self.dataset.sizes["site"]
        ecf = solpos.ecf.expand_dims(dim={"site": n_locs}, axis=1)

    kwargs = {"cosz": cosz, "ecf": ecf}

    # atmosphere...
    def get_with_proper_units(var):
        if var not in self.dataset.data_vars:
            raise ValueError(f"variable `{var}` required by the model `{model}` is not available in this atmosphere")
        if var == "pressure":
            return self.dataset[var] * 1e-2  # Pa to hPa
        if var == "pwater":
            return pwater_in_kg_m2_to_cm(self.dataset[var])
        if var == "ozone":
            return ozone_in_kg_m2_to_cm(self.dataset[var])
        return self.dataset[var]
    variables = set(model_vars).intersection(self.dataset.data_vars)  # variables required by this model
    kwargs = kwargs | {var: get_with_proper_units(var) for var in variables}

    # and call the clearsky model...
    result = model_func(**(kwargs | (model_kwargs or {})))

    # encapsulate the result in a CF-compliant xarray Dataset
    if is_regular_grid:
        return build_atmosphere_on_regular_grid(
            times=self.dataset.time.values,
            latitude=self.dataset.lat.values,
            longitude=self.dataset.lon.values,
            constituents=result,
            global_attrs=get_global_attrs(feature_type="grid")
        )
    else:
        site_values = self.dataset.coords.get("site").values
        n_sites = self.dataset.sizes["site"]
        site_names = validate_site_names(site_values, n_sites)
        return build_atmosphere_of_sites(
            times=self.dataset.time.values,
            latitude=self.dataset.lat.values,
            longitude=self.dataset.lon.values,
            constituents=result,
            site_names=site_names,
            global_attrs=get_global_attrs(feature_type="timeSeries")
        )

on_regular_grid classmethod

Retrieve monthly climatology on a regular spatial grid.

Parameters:

Returns:

Examples:

>>> import numpy as np
>>> lats = np.linspace(36.0, 41.0, 20)
>>> lons = np.linspace(-5.0, -3.0, 20)
>>> times = pd.date_range("2023-01-15", periods=12, freq="MS") + pd.Timedelta(14.5, "d")
>>> atm = MERRA2LTAAtmosphere.on_regular_grid(
...     times=times,
...     latitude=lats,
...     longitude=lons
... )
Source code in src/spartasolar/atmoslib/merra2_lta.py
@classmethod
def on_regular_grid(
    cls,
    times: np.ndarray[tuple[int], np.datetime64] | pd.DatetimeIndex,
    latitude: Sequence[float],
    longitude: Sequence[float],
) -> Self:
    """Retrieve monthly climatology on a regular spatial grid.

    Parameters
    ----------
    times : np.ndarray or pd.DatetimeIndex
        Time stamps for climatology retrieval
    latitude : Sequence[float]
        Latitude grid coordinates in degrees North
    longitude : Sequence[float]
        Longitude grid coordinates in degrees East

    Returns
    -------
    MERRA2LTAAtmosphere
        Instance with gridded climatological data

    Examples
    --------
    >>> import numpy as np
    >>> lats = np.linspace(36.0, 41.0, 20)
    >>> lons = np.linspace(-5.0, -3.0, 20)
    >>> times = pd.date_range("2023-01-15", periods=12, freq="MS") + pd.Timedelta(14.5, "d")
    >>> atm = MERRA2LTAAtmosphere.on_regular_grid(
    ...     times=times,
    ...     latitude=lats,
    ...     longitude=lons
    ... )
    """

    latitude = np.asarray(latitude, dtype=float).reshape(-1)
    latitude = [validate_type(lat, Latitude) for lat in latitude]
    longitude = np.asarray(longitude, dtype=float).reshape(-1)
    longitude = [validate_type(lon, Longitude) for lon in longitude]

    # load the dataset. Check for local availability. If not available, download.
    dataset = cls._load_dataset(times)

    # lat-lon interpolation
    output_dataset = dataset.interp(lat=latitude, lon=longitude, method='linear')

    # time interpolation
    if "time" in output_dataset.coords:
        output_dataset = output_dataset.interp(time=times, method='quadratic')

    global_attrs = {
        "title": "Long Term Average Clear-sky Atmospheric Dataset for SPARTA",
        "references": "doi:10.5067/KLICLTZ8EM9D, doi:10.5067/Q9QMY5PBNV1T, doi:10.5067/VJAFPLI1CSIV",
    }

    obj = cls()
    obj._atmosphere = build_atmosphere_on_regular_grid(
        times=times,
        latitude=latitude,
        longitude=longitude,
        constituents=output_dataset.data_vars,
        global_attrs=global_attrs)
    return obj

MERRA-2 via Google Earth Engine

MERRA2GEEAtmosphere

MERRA2GEEAtmosphere()

Bases: BaseAtmosphere

MERRA-2 atmospheric database via Google Earth Engine.

Provides access to NASA MERRA-2 reanalysis via GEE API. Automatically corrects for GEE's latitude grid offset and time stamp convention.

Requires GEE authentication and active project configuration.

See module documentation for setup instructions and examples.

Validates that the database path exists (if specified) and initializes the internal atmosphere dataset to None.

Raises:

Methods:

  • __init_subclass__

    Automatically sets the database path for subclasses.

  • at_site

    Retrieve MERRA-2 data from GEE for a specific site.

  • compute

    Compute clear-sky solar radiation using a radiative transfer model.

  • distill_crude_data

    Refine raw GEE MERRA-2 data for clear-sky modeling.

  • get_filename

    Generate cache filename for GEE MERRA-2 data.

Source code in src/spartasolar/atmoslib/_base.py
def __init__(self):
    """Initialize the atmosphere instance.

    Validates that the database path exists (if specified) and initializes
    the internal atmosphere dataset to None.

    Raises
    ------
    AttributeError
        If database_path is specified but does not exist
    """
    if self.database_path is not None and not self.database_path.exists():
        raise AttributeError(f"missing path `{self.database_path}`")

    self._atmosphere: xr.DataArray = None

__init_subclass__

__init_subclass__(database_path: str, **kwargs)

Automatically sets the database path for subclasses.

Parameters:

  • database_path
    (str or None) –

    The directory path where the specific atmosphere data is stored. Pass None for sources that do not use a file database (e.g. CustomAtmosphere or API-based retrievers).

Source code in src/spartasolar/atmoslib/_base.py
def __init_subclass__(cls, database_path: str, **kwargs):
    """Automatically sets the database path for subclasses.

    Parameters
    ----------
    database_path : str or None
        The directory path where the specific atmosphere data is stored.
        Pass ``None`` for sources that do not use a file database
        (e.g. ``CustomAtmosphere`` or API-based retrievers).
    """
    super().__init_subclass__(**kwargs)
    cls.database_path = None if database_path is None else Path(database_path)

at_site classmethod

at_site(
    times: DatetimeIndex,
    latitude: float,
    longitude: float,
    site_name: str | None = None,
) -> Self

Retrieve MERRA-2 data from GEE for a specific site.

Downloads data from GEE if not cached, applies spatial/temporal corrections, then interpolates to requested times.

Parameters:

  • times
    (DatetimeIndex) –

    Time stamps for data retrieval (UTC)

  • latitude
    (float) –

    Latitude in degrees North [-90, 90]

  • longitude
    (float) –

    Longitude in degrees East [-180, 180]

  • site_name
    (str, default: None ) –

    Name identifier for the site

Returns:

Examples:

>>> import pandas as pd
>>> from spartasolar import config
>>> config.set_option('merra2_gee.project', 'my-gee-project')
>>>
>>> times = pd.date_range("2020-06-15", periods=24, freq="h")
>>> atm = MERRA2GEEAtmosphere.at_site(
...     times=times,
...     latitude=36.72,
...     longitude=-4.42,
...     site_name="Málaga"
... )
>>> result = atm.compute(model="SPARTA")
Notes
  • Requires merra2_gee.project configuration
  • Automatically corrects GEE's 0.25° latitude offset
  • Adjusts time stamps from hour-start to hour-center
  • Data is cached locally to minimize API calls
Source code in src/spartasolar/atmoslib/merra2_geeapi.py
@classmethod
def at_site(
    cls,
    times: pd.DatetimeIndex,
    latitude: float,
    longitude: float,
    site_name: str | None = None,
) -> Self:
    """Retrieve MERRA-2 data from GEE for a specific site.

    Downloads data from GEE if not cached, applies spatial/temporal
    corrections, then interpolates to requested times.

    Parameters
    ----------
    times : pd.DatetimeIndex
        Time stamps for data retrieval (UTC)
    latitude : float
        Latitude in degrees North [-90, 90]
    longitude : float
        Longitude in degrees East [-180, 180]
    site_name : str, optional
        Name identifier for the site

    Returns
    -------
    MERRA2GEEAtmosphere
        Instance with interpolated atmospheric data

    Examples
    --------
    >>> import pandas as pd
    >>> from spartasolar import config
    >>> config.set_option('merra2_gee.project', 'my-gee-project')
    >>>
    >>> times = pd.date_range("2020-06-15", periods=24, freq="h")
    >>> atm = MERRA2GEEAtmosphere.at_site(
    ...     times=times,
    ...     latitude=36.72,
    ...     longitude=-4.42,
    ...     site_name="Málaga"
    ... )
    >>> result = atm.compute(model="SPARTA")

    Notes
    -----
    - Requires `merra2_gee.project` configuration
    - Automatically corrects GEE's 0.25° latitude offset
    - Adjusts time stamps from hour-start to hour-center
    - Data is cached locally to minimize API calls
    """

    latitude = validate_type(latitude, Latitude)
    longitude = validate_type(longitude, Longitude)

    def fetch_and_distill_and_archive(year: int, path: Path) -> None:
        logger.info(f"fetching GEE data for year={int(year)} and path={path.as_posix()}")
        if not (gee_project := get_option("merra2_gee.project")):
            raise ValueError("missing Google cloud's project. Add `project = \"<your_gee_project>\"` "
                             f"in the `merra2_gee` table in `{get_config_path()}` and reload spartasolar "
                             "or use `spartasolar.config.set_option(\'merra2_gee.project\', <your_project>)`")
        logger.info(f"using GEE project <green>{gee_project}</green>")
        data = fetch_merra2_data_from_gee_api(
            latitude=latitude,
            longitude=longitude,
            start_date=f"{year}-01-01",
            end_date=f"{year}-12-31",
            project=gee_project)

        data = cls.distill_crude_data(data, latitude, longitude)
        logger.debug(f"{data.head()=}")
        data.to_parquet(path)
        logger.success(f"data downloaded and archived in <blue>{path.name}</blue>")

    # load data from one year before and one year after the requested times_utc, but
    # clipping the years on 2004 and the current year
    paths = []
    years = cls._infer_years_from_times(times)
    for year in sorted(years):
        if not (path := cls.get_filename(year, latitude, longitude)).exists():
            fetch_and_distill_and_archive(year, path)
        paths.append(path)
    logger.debug([path.as_posix() for path in paths])
    data = pd.read_parquet(paths)

    # interpolate to times_utc
    x = times.astype("datetime64[s]").to_numpy().astype(float)
    xi = data.times_utc.astype("datetime64[s]").to_numpy().astype(float)
    data_i = data.drop(columns=["times_utc", "albedo"])
    y = interp1d(xi, data_i.values, kind="quadratic", axis=0)(x)
    y_dict = {key: value for key, value in zip(data_i.columns, y.T)}
    y_dict["albedo"] = interp1d(xi, data.albedo.values, kind="linear", axis=0)(x)
    data_interp = pd.DataFrame({"times": times} | y_dict)

    global_attrs = {
        "title:": "HourlyEarth Engine Data Catalog dataset for SPARTA",
        "source": "NASA/GMAO MERRA-2 reanalysis via Google Earth Engine API",
        "references": "doi:10.5067/KLICLTZ8EM9D, doi:10.5067/Q9QMY5PBNV1T, doi:10.5067/VJAFPLI1CSIV",
    }

    obj = cls()
    obj._atmosphere = build_atmosphere_of_sites(
        times=times,
        latitude=latitude,
        longitude=longitude,
        constituents=data_interp.drop(columns=["times"]),
        site_names=site_name,
        global_attrs=global_attrs)
    return obj

compute

compute(
    model: Model = "SPARTA",
    include_atmosphere: bool = False,
    model_kwargs: dict | None = None,
) -> Dataset

Compute clear-sky solar radiation using a radiative transfer model.

This method integrates solar position calculations with atmospheric constituent data to compute clear-sky irradiance components (GHI, DNI, DHI, etc.) using the specified radiative transfer model.

Parameters:

  • model
    (Model, default: "SPARTA" ) –

    Name of the clear-sky model to use. Options: "SPARTA", "Bird"

  • include_atmosphere
    (bool, default: False ) –

    If True, include atmospheric constituents in the output dataset. If False, only radiation components are returned.

  • model_kwargs
    (dict, default: None ) –

    Additional keyword arguments to pass to the model function

Returns:

  • Dataset

    CF-compliant dataset containing computed irradiance components: - ghi: Global Horizontal Irradiance (W/m²) - dni: Direct Normal Irradiance (W/m²) - dhi or dif: Diffuse Horizontal Irradiance (W/m²) - csi: Circumsolar Irradiance (W/m², SPARTA only)

Examples:

>>> import pandas as pd
>>> from spartasolar.atmoslib import MERRA2DailyAtmosphere
>>>
>>> times = pd.date_range("2020-06-15", periods=24, freq="h")
>>> atm = MERRA2DailyAtmosphere.at_sites(
...     times=times,
...     latitude=36.72,
...     longitude=-4.42
... )
>>> result = atm.compute(model="SPARTA")
>>> print(result.ghi.values)

Use different model with custom parameters:

>>> result = atm.compute(
...     model="Bird",
...     model_kwargs={"scheme": "transmittance_parameterization"}
... )
Notes

The method automatically: - Calculates solar position (zenith angle, Earth-Sun distance) - Converts atmospheric units to model requirements - Handles both gridded and site-based data structures

See Also

spartasolar.modlib.sparta : SPARTA model implementation spartasolar.modlib.bird : Bird clear-sky model

Source code in src/spartasolar/atmoslib/_base.py
def compute(
    self,
    model: Model = "SPARTA",
    include_atmosphere: bool = False,
    model_kwargs: dict | None = None,
) -> xr.Dataset:
    """Compute clear-sky solar radiation using a radiative transfer model.

    This method integrates solar position calculations with atmospheric
    constituent data to compute clear-sky irradiance components (GHI, DNI,
    DHI, etc.) using the specified radiative transfer model.

    Parameters
    ----------
    model : Model, default "SPARTA"
        Name of the clear-sky model to use. Options: "SPARTA", "Bird"
    include_atmosphere : bool, default False
        If True, include atmospheric constituents in the output dataset.
        If False, only radiation components are returned.
    model_kwargs : dict, optional
        Additional keyword arguments to pass to the model function

    Returns
    -------
    xr.Dataset
        CF-compliant dataset containing computed irradiance components:
        - ghi: Global Horizontal Irradiance (W/m²)
        - dni: Direct Normal Irradiance (W/m²)
        - dhi or dif: Diffuse Horizontal Irradiance (W/m²)
        - csi: Circumsolar Irradiance (W/m², SPARTA only)

    Examples
    --------
    >>> import pandas as pd
    >>> from spartasolar.atmoslib import MERRA2DailyAtmosphere
    >>>
    >>> times = pd.date_range("2020-06-15", periods=24, freq="h")
    >>> atm = MERRA2DailyAtmosphere.at_sites(
    ...     times=times,
    ...     latitude=36.72,
    ...     longitude=-4.42
    ... )
    >>> result = atm.compute(model="SPARTA")
    >>> print(result.ghi.values)

    Use different model with custom parameters:

    >>> result = atm.compute(
    ...     model="Bird",
    ...     model_kwargs={"scheme": "transmittance_parameterization"}
    ... )

    Notes
    -----
    The method automatically:
    - Calculates solar position (zenith angle, Earth-Sun distance)
    - Converts atmospheric units to model requirements
    - Handles both gridded and site-based data structures

    See Also
    --------
    spartasolar.modlib.sparta : SPARTA model implementation
    spartasolar.modlib.bird : Bird clear-sky model
    """

    model = validate_type(model, Model)
    model_func = getattr(modlib, model)
    model_vars = inspect.getfullargspec(model_func).args

    is_regular_grid = "site" not in self.dataset.dims

    # compute solar geometry...
    sw_eval = sunwhere.regular_grid if is_regular_grid else sunwhere.sites
    solpos = sw_eval(
        self.dataset.time.values,
        latitude=self.dataset.lat.values,
        longitude=self.dataset.lon.values,
        algorithm=config.get_option("sunwhere.algorithm", default="psa"),
        refraction=config.get_option("sunwhere.refraction", default=True),
        engine=config.get_option("sunwhere.engine", default="numexpr")
    )

    new_coord_names = {"location": "site", "latitude": "lat", "longitude": "lon"}
    cosz = solpos.cosz.rename({old: new for old, new in new_coord_names.items() if old in solpos.cosz.dims})

    if is_regular_grid:
        n_lats = self.dataset.sizes["lat"]
        n_lons = self.dataset.sizes["lon"]
        ecf = solpos.ecf.expand_dims(dim={"lat": n_lats, "lon": n_lons}, axis=(1, 2))
    else:
        n_locs = self.dataset.sizes["site"]
        ecf = solpos.ecf.expand_dims(dim={"site": n_locs}, axis=1)

    kwargs = {"cosz": cosz, "ecf": ecf}

    # atmosphere...
    def get_with_proper_units(var):
        if var not in self.dataset.data_vars:
            raise ValueError(f"variable `{var}` required by the model `{model}` is not available in this atmosphere")
        if var == "pressure":
            return self.dataset[var] * 1e-2  # Pa to hPa
        if var == "pwater":
            return pwater_in_kg_m2_to_cm(self.dataset[var])
        if var == "ozone":
            return ozone_in_kg_m2_to_cm(self.dataset[var])
        return self.dataset[var]
    variables = set(model_vars).intersection(self.dataset.data_vars)  # variables required by this model
    kwargs = kwargs | {var: get_with_proper_units(var) for var in variables}

    # and call the clearsky model...
    result = model_func(**(kwargs | (model_kwargs or {})))

    # encapsulate the result in a CF-compliant xarray Dataset
    if is_regular_grid:
        return build_atmosphere_on_regular_grid(
            times=self.dataset.time.values,
            latitude=self.dataset.lat.values,
            longitude=self.dataset.lon.values,
            constituents=result,
            global_attrs=get_global_attrs(feature_type="grid")
        )
    else:
        site_values = self.dataset.coords.get("site").values
        n_sites = self.dataset.sizes["site"]
        site_names = validate_site_names(site_values, n_sites)
        return build_atmosphere_of_sites(
            times=self.dataset.time.values,
            latitude=self.dataset.lat.values,
            longitude=self.dataset.lon.values,
            constituents=result,
            site_names=site_names,
            global_attrs=get_global_attrs(feature_type="timeSeries")
        )

distill_crude_data staticmethod

distill_crude_data(
    data: DataFrame, lat: Latitude, lon: Longitude
) -> DataFrame

Refine raw GEE MERRA-2 data for clear-sky modeling.

Performs post-processing on GEE data: 1. Adjusts time stamps from hour-start to hour-center (NASA convention) 2. Calculates solar zenith angle for albedo masking 3. Computes Ångström turbidity coefficient (beta) from AOD and alpha 4. Calculates aerosol single-scattering albedo (SSA) 5. Converts ozone from DU to kg/m²

Parameters:

  • data
    (DataFrame) –

    Raw data from GEE API with columns: TOTEXTTAU, TOTSCATAU, TOTANGSTR, PS, TO3, TQV, ALBEDO

  • lat
    (float) –

    Site latitude (for solar position calculation)

  • lon
    (float) –

    Site longitude (for solar position calculation)

Returns:

  • DataFrame

    Processed DataFrame with columns: times_utc, albedo, pressure, ozone, pwater, beta, alpha, ssa

Notes

GEE time stamps are at hour start (e.g., 01:00 UTC), but MERRA-2 hourly averages represent the period centered at half-past (e.g., 01:30 UTC). This function adds 30 minutes to correct the convention.

Albedo is masked to 0 for solar zenith angles > 89°.

Source code in src/spartasolar/atmoslib/merra2_geeapi.py
@staticmethod
def distill_crude_data(data: pd.DataFrame, lat: Latitude, lon: Longitude) -> pd.DataFrame:
    """Refine raw GEE MERRA-2 data for clear-sky modeling.

    Performs post-processing on GEE data:
    1. Adjusts time stamps from hour-start to hour-center (NASA convention)
    2. Calculates solar zenith angle for albedo masking
    3. Computes Ångström turbidity coefficient (beta) from AOD and alpha
    4. Calculates aerosol single-scattering albedo (SSA)
    5. Converts ozone from DU to kg/m²

    Parameters
    ----------
    data : pd.DataFrame
        Raw data from GEE API with columns: TOTEXTTAU, TOTSCATAU,
        TOTANGSTR, PS, TO3, TQV, ALBEDO
    lat : float
        Site latitude (for solar position calculation)
    lon : float
        Site longitude (for solar position calculation)

    Returns
    -------
    pd.DataFrame
        Processed DataFrame with columns: times_utc, albedo, pressure,
        ozone, pwater, beta, alpha, ssa

    Notes
    -----
    GEE time stamps are at hour start (e.g., 01:00 UTC), but MERRA-2
    hourly averages represent the period centered at half-past (e.g., 01:30 UTC).
    This function adds 30 minutes to correct the convention.

    Albedo is masked to 0 for solar zenith angles > 89°.
    """

    # N.B. The Half-Hour Shift (Time-Averaged vs. GEE Timestamp)
    # These MERRA-2 products are time-averaged (tavg1_2d) hourly collections.
    #  - *The NASA Convention*: In raw MERRA-2 NetCDF files, hourly averages represent the mean
    #    value across a full hour. NASA time-stamps these intervals at the midpoint of the hour.
    #    For example, the average for the hour between 01:00 UTC and 02:00 UTC is stamped
    #    as 01:30 UTC.
    #  - *The GEE Adjustment (system:time_start)*: Google Earth Engine forces all dataset tracking
    #    into discrete start/end boundaries. To make this compatible with standard data querying,
    #    GEE strips the half-hour offset from the tracking metadata. The system:time_start property
    #    is adjusted back to the beginning of that hourly window (e.g., 01:00:00 UTC).

    # hence, make a tz-naive datetimeindex and move the timestamps to the center of the interval...
    data = (data
            .set_index("times_utc", drop=True)
            .tz_convert(tz=None)
            .pipe(lambda df: df.set_index(df.index + pd.Timedelta(30, "min"))))

    solpos = sunwhere.sites(data.index, lat, lon)
    sza = solpos.sza.isel(site=0).to_pandas()

    return (
        data.assign(
            albedo=data.ALBEDO.where(sza < 89., 0.),
            pressure=data.PS,  # Pa
            ozone=ozone_in_du_to_kg_m2(data.TO3),  # DU to kg m-2
            pwater=data.TQV,  # kg m-2
            beta=data.TOTEXTTAU*(0.55**data.TOTANGSTR),  # Angstrom's turbidity
            alpha=data.TOTANGSTR,  # Angstrom's wavelength parameter
            ssa=(data.TOTSCATAU/data.TOTEXTTAU).clip(0., 1.))
        .drop(columns=["TOTEXTTAU", "TOTSCATAU", "TOTANGSTR", "PS", "TO3", "TQV", "ALBEDO"])
        .rename_axis("times_utc", axis=0)
        .interpolate()  # to fill nans (sometimes albedo has remaining nans)
        .reset_index()
    )

get_filename classmethod

get_filename(
    year: int, latitude: float, longitude: float
) -> Path

Generate cache filename for GEE MERRA-2 data.

Constructs a standardized filename for cached data with encoded coordinates and year.

Parameters:

  • year
    (int) –

    Year for the data

  • latitude
    (float) –

    Latitude in degrees North [-90, 90]

  • longitude
    (float) –

    Longitude in degrees East [-180, 180]

Returns:

  • Path

    Absolute path to the .parquet cache file

Examples:

>>> path = MERRA2GEEAtmosphere.get_filename(2023, 40.4168, -3.7038)
>>> print(path.name)
# "merra2_gee_hourly_404168N_37038W_2023.parquet"
Source code in src/spartasolar/atmoslib/merra2_geeapi.py
@classmethod
def get_filename(cls, year: int, latitude: float, longitude: float) -> Path:
    """Generate cache filename for GEE MERRA-2 data.

    Constructs a standardized filename for cached data with encoded
    coordinates and year.

    Parameters
    ----------
    year : int
        Year for the data
    latitude : float
        Latitude in degrees North [-90, 90]
    longitude : float
        Longitude in degrees East [-180, 180]

    Returns
    -------
    Path
        Absolute path to the .parquet cache file

    Examples
    --------
    >>> path = MERRA2GEEAtmosphere.get_filename(2023, 40.4168, -3.7038)
    >>> print(path.name)
    # "merra2_gee_hourly_404168N_37038W_2023.parquet"
    """
    filename_pattern = "merra2_gee_hourly_{latitude}_{longitude}_{year}.parquet"
    latitude_str = f"{float(latitude)*1e4:.0f}"
    latitude_str = latitude_str[1:]+"S" if latitude_str.startswith("-") else latitude_str + "N"
    longitude_str = f"{float(longitude)*1e4:.0f}"
    longitude_str = longitude_str[1:]+"W" if longitude_str.startswith("-") else longitude_str + "E"
    filename = filename_pattern.format(latitude=latitude_str, longitude=longitude_str, year=year)
    return cls.database_path / filename

Copernicus CRS via SODA API

CRSSODAAtmosphere

CRSSODAAtmosphere()

Bases: BaseAtmosphere

CRS SODA (Copernicus Radiation Service) atmospheric database.

Provides access to atmospheric constituent data from the SODA McClear service via WPS API. Data is cached locally after first retrieval.

Requires user registration at https://www.soda-pro.com/ and email configuration via crs_soda.user_email config option.

See module documentation for examples.

Validates that the database path exists (if specified) and initializes the internal atmosphere dataset to None.

Raises:

Methods:

  • __init_subclass__

    Automatically sets the database path for subclasses.

  • at_site

    Retrieve CRS SODA atmospheric data for a specific site.

  • compute

    Compute clear-sky solar radiation using a radiative transfer model.

  • distill_crude_data

    Refine and enrich raw CRS data for clear-sky modeling.

  • get_filename

    Generate the cache filename for CRS SODA data.

Source code in src/spartasolar/atmoslib/_base.py
def __init__(self):
    """Initialize the atmosphere instance.

    Validates that the database path exists (if specified) and initializes
    the internal atmosphere dataset to None.

    Raises
    ------
    AttributeError
        If database_path is specified but does not exist
    """
    if self.database_path is not None and not self.database_path.exists():
        raise AttributeError(f"missing path `{self.database_path}`")

    self._atmosphere: xr.DataArray = None

__init_subclass__

__init_subclass__(database_path: str, **kwargs)

Automatically sets the database path for subclasses.

Parameters:

  • database_path
    (str or None) –

    The directory path where the specific atmosphere data is stored. Pass None for sources that do not use a file database (e.g. CustomAtmosphere or API-based retrievers).

Source code in src/spartasolar/atmoslib/_base.py
def __init_subclass__(cls, database_path: str, **kwargs):
    """Automatically sets the database path for subclasses.

    Parameters
    ----------
    database_path : str or None
        The directory path where the specific atmosphere data is stored.
        Pass ``None`` for sources that do not use a file database
        (e.g. ``CustomAtmosphere`` or API-based retrievers).
    """
    super().__init_subclass__(**kwargs)
    cls.database_path = None if database_path is None else Path(database_path)

at_site classmethod

at_site(
    times: DatetimeIndex,
    latitude: float,
    longitude: float,
    site_name: str | None = None,
) -> Self

Retrieve CRS SODA atmospheric data for a specific site.

Downloads data from SODA API if not cached, then interpolates to requested times. Data is automatically resampled to hourly resolution.

Parameters:

  • times
    (DatetimeIndex) –

    Time stamps for data retrieval (UTC)

  • latitude
    (float) –

    Latitude in degrees North [-90, 90]

  • longitude
    (float) –

    Longitude in degrees East [-180, 180]

  • site_name
    (str, default: None ) –

    Name identifier for the site

Returns:

Examples:

>>> import pandas as pd
>>> from spartasolar import config
>>> config.set_option('crs_soda.user_email', 'user@example.com')
>>>
>>> times = pd.date_range("2020-01-01", "2020-01-31", freq="h")
>>> atm = CRSSODAAtmosphere.at_site(
...     times=times,
...     latitude=36.72,
...     longitude=-4.42,
...     site_name="Málaga"
... )
>>> result = atm.compute(model="SPARTA")
Notes
  • Requires crs_soda.user_email configuration
  • Data is cached locally to minimize API calls
  • Temporal interpolation uses quadratic splines
Source code in src/spartasolar/atmoslib/crs_sodaapi.py
@classmethod
def at_site(
    cls,
    times: pd.DatetimeIndex,
    latitude: float,
    longitude: float,
    site_name: str | None = None,
) -> Self:
    """Retrieve CRS SODA atmospheric data for a specific site.

    Downloads data from SODA API if not cached, then interpolates to
    requested times. Data is automatically resampled to hourly resolution.

    Parameters
    ----------
    times : pd.DatetimeIndex
        Time stamps for data retrieval (UTC)
    latitude : float
        Latitude in degrees North [-90, 90]
    longitude : float
        Longitude in degrees East [-180, 180]
    site_name : str, optional
        Name identifier for the site

    Returns
    -------
    CRSSODAAtmosphere
        Instance with interpolated atmospheric data

    Examples
    --------
    >>> import pandas as pd
    >>> from spartasolar import config
    >>> config.set_option('crs_soda.user_email', 'user@example.com')
    >>>
    >>> times = pd.date_range("2020-01-01", "2020-01-31", freq="h")
    >>> atm = CRSSODAAtmosphere.at_site(
    ...     times=times,
    ...     latitude=36.72,
    ...     longitude=-4.42,
    ...     site_name="Málaga"
    ... )
    >>> result = atm.compute(model="SPARTA")

    Notes
    -----
    - Requires `crs_soda.user_email` configuration
    - Data is cached locally to minimize API calls
    - Temporal interpolation uses quadratic splines
    """

    version: str = "1.0.0"
    save_csv: bool = False  # for debugging and reproducibility. The csv files are saved in the same directory as the parquet files, with the same name but .csv extension instead of .parquet

    latitude = validate_type(latitude, Latitude)
    longitude = validate_type(longitude, Longitude)

    def fetch_and_distill_and_archive(year: int, path: Path) -> None:
        if (user_email := get_option("crs_soda.user_email")) is None:
            raise ValueError("missing soda user. Add `user_email = \"<your_email_for_crs_soda>\"` in "
                             f"the `crs_soda` table in `{get_config_path()}` and reload spartasolar or use "
                             "`spartasolar.config.set_option(\'crs_soda.user_email\', <your_email_for_crs_soda>)`")
        data, metadata = fetch_crs_data_from_soda_api(
            latitude=latitude,
            longitude=longitude,
            date_begin=f"{year}-01-01",
            date_end=f"{year}-12-31",
            user_email=user_email,
            time_step="PT01M",
            stream="mcclear",
            version=version,
            timeout=30,
            to_csv=path.with_suffix(".csv") if save_csv else None)  # save the raw response as csv for debugging and reproducibility

        data = cls.distill_crude_data(data, metadata)
        logger.debug(f"{data.head()=}")
        data.to_parquet(path)
        logger.success(f"data downloaded and archived: <blue>{path.name}</blue>")

    # load data from one year before and one year after the requested times_utc, but
    # clipping the years on 2004 and the current year
    paths = []
    years = cls._infer_years_from_times(times)
    for year in sorted(years):
        if not (path := cls.get_filename(year, latitude, longitude, version)).exists():
            fetch_and_distill_and_archive(year, path)
        paths.append(path)
    logger.debug([path.as_posix() for path in paths])
    data = pd.read_parquet(paths)

    # interpolate to times_utc
    x = times.astype("datetime64[s]").to_numpy().astype(float)
    xi = data.times_utc.astype("datetime64[s]").to_numpy().astype(float)
    data_i = data.drop(columns=["times_utc", "albedo"])
    y = interp1d(xi, data_i.values, kind="quadratic", axis=0)(x)
    y_dict = {key: value for key, value in zip(data_i.columns, y.T)}
    y_dict["albedo"] = interp1d(xi, data.albedo.values, kind="linear", axis=0)(x)
    data_interp = pd.DataFrame({"times": times} | y_dict)

    global_attrs = {
        "title:": "Hourly CRS SODA McClear dataset for SPARTA",
        "source": "WPS SODA API, https://www.soda-pro.com/web-services/radiation/cams-mcclear",
        "version": version,
        "references": "https://confluence.ecmwf.int/display/CKB/CAMS+solar+radiation+time-series%3A+data+documentation",
    }

    obj = cls()
    obj._atmosphere = build_atmosphere_of_sites(
        times=times,
        latitude=latitude,
        longitude=longitude,
        constituents=data_interp.drop(columns=["times"]),
        site_names=site_name,
        global_attrs=global_attrs)
    return obj

compute

compute(
    model: Model = "SPARTA",
    include_atmosphere: bool = False,
    model_kwargs: dict | None = None,
) -> Dataset

Compute clear-sky solar radiation using a radiative transfer model.

This method integrates solar position calculations with atmospheric constituent data to compute clear-sky irradiance components (GHI, DNI, DHI, etc.) using the specified radiative transfer model.

Parameters:

  • model
    (Model, default: "SPARTA" ) –

    Name of the clear-sky model to use. Options: "SPARTA", "Bird"

  • include_atmosphere
    (bool, default: False ) –

    If True, include atmospheric constituents in the output dataset. If False, only radiation components are returned.

  • model_kwargs
    (dict, default: None ) –

    Additional keyword arguments to pass to the model function

Returns:

  • Dataset

    CF-compliant dataset containing computed irradiance components: - ghi: Global Horizontal Irradiance (W/m²) - dni: Direct Normal Irradiance (W/m²) - dhi or dif: Diffuse Horizontal Irradiance (W/m²) - csi: Circumsolar Irradiance (W/m², SPARTA only)

Examples:

>>> import pandas as pd
>>> from spartasolar.atmoslib import MERRA2DailyAtmosphere
>>>
>>> times = pd.date_range("2020-06-15", periods=24, freq="h")
>>> atm = MERRA2DailyAtmosphere.at_sites(
...     times=times,
...     latitude=36.72,
...     longitude=-4.42
... )
>>> result = atm.compute(model="SPARTA")
>>> print(result.ghi.values)

Use different model with custom parameters:

>>> result = atm.compute(
...     model="Bird",
...     model_kwargs={"scheme": "transmittance_parameterization"}
... )
Notes

The method automatically: - Calculates solar position (zenith angle, Earth-Sun distance) - Converts atmospheric units to model requirements - Handles both gridded and site-based data structures

See Also

spartasolar.modlib.sparta : SPARTA model implementation spartasolar.modlib.bird : Bird clear-sky model

Source code in src/spartasolar/atmoslib/_base.py
def compute(
    self,
    model: Model = "SPARTA",
    include_atmosphere: bool = False,
    model_kwargs: dict | None = None,
) -> xr.Dataset:
    """Compute clear-sky solar radiation using a radiative transfer model.

    This method integrates solar position calculations with atmospheric
    constituent data to compute clear-sky irradiance components (GHI, DNI,
    DHI, etc.) using the specified radiative transfer model.

    Parameters
    ----------
    model : Model, default "SPARTA"
        Name of the clear-sky model to use. Options: "SPARTA", "Bird"
    include_atmosphere : bool, default False
        If True, include atmospheric constituents in the output dataset.
        If False, only radiation components are returned.
    model_kwargs : dict, optional
        Additional keyword arguments to pass to the model function

    Returns
    -------
    xr.Dataset
        CF-compliant dataset containing computed irradiance components:
        - ghi: Global Horizontal Irradiance (W/m²)
        - dni: Direct Normal Irradiance (W/m²)
        - dhi or dif: Diffuse Horizontal Irradiance (W/m²)
        - csi: Circumsolar Irradiance (W/m², SPARTA only)

    Examples
    --------
    >>> import pandas as pd
    >>> from spartasolar.atmoslib import MERRA2DailyAtmosphere
    >>>
    >>> times = pd.date_range("2020-06-15", periods=24, freq="h")
    >>> atm = MERRA2DailyAtmosphere.at_sites(
    ...     times=times,
    ...     latitude=36.72,
    ...     longitude=-4.42
    ... )
    >>> result = atm.compute(model="SPARTA")
    >>> print(result.ghi.values)

    Use different model with custom parameters:

    >>> result = atm.compute(
    ...     model="Bird",
    ...     model_kwargs={"scheme": "transmittance_parameterization"}
    ... )

    Notes
    -----
    The method automatically:
    - Calculates solar position (zenith angle, Earth-Sun distance)
    - Converts atmospheric units to model requirements
    - Handles both gridded and site-based data structures

    See Also
    --------
    spartasolar.modlib.sparta : SPARTA model implementation
    spartasolar.modlib.bird : Bird clear-sky model
    """

    model = validate_type(model, Model)
    model_func = getattr(modlib, model)
    model_vars = inspect.getfullargspec(model_func).args

    is_regular_grid = "site" not in self.dataset.dims

    # compute solar geometry...
    sw_eval = sunwhere.regular_grid if is_regular_grid else sunwhere.sites
    solpos = sw_eval(
        self.dataset.time.values,
        latitude=self.dataset.lat.values,
        longitude=self.dataset.lon.values,
        algorithm=config.get_option("sunwhere.algorithm", default="psa"),
        refraction=config.get_option("sunwhere.refraction", default=True),
        engine=config.get_option("sunwhere.engine", default="numexpr")
    )

    new_coord_names = {"location": "site", "latitude": "lat", "longitude": "lon"}
    cosz = solpos.cosz.rename({old: new for old, new in new_coord_names.items() if old in solpos.cosz.dims})

    if is_regular_grid:
        n_lats = self.dataset.sizes["lat"]
        n_lons = self.dataset.sizes["lon"]
        ecf = solpos.ecf.expand_dims(dim={"lat": n_lats, "lon": n_lons}, axis=(1, 2))
    else:
        n_locs = self.dataset.sizes["site"]
        ecf = solpos.ecf.expand_dims(dim={"site": n_locs}, axis=1)

    kwargs = {"cosz": cosz, "ecf": ecf}

    # atmosphere...
    def get_with_proper_units(var):
        if var not in self.dataset.data_vars:
            raise ValueError(f"variable `{var}` required by the model `{model}` is not available in this atmosphere")
        if var == "pressure":
            return self.dataset[var] * 1e-2  # Pa to hPa
        if var == "pwater":
            return pwater_in_kg_m2_to_cm(self.dataset[var])
        if var == "ozone":
            return ozone_in_kg_m2_to_cm(self.dataset[var])
        return self.dataset[var]
    variables = set(model_vars).intersection(self.dataset.data_vars)  # variables required by this model
    kwargs = kwargs | {var: get_with_proper_units(var) for var in variables}

    # and call the clearsky model...
    result = model_func(**(kwargs | (model_kwargs or {})))

    # encapsulate the result in a CF-compliant xarray Dataset
    if is_regular_grid:
        return build_atmosphere_on_regular_grid(
            times=self.dataset.time.values,
            latitude=self.dataset.lat.values,
            longitude=self.dataset.lon.values,
            constituents=result,
            global_attrs=get_global_attrs(feature_type="grid")
        )
    else:
        site_values = self.dataset.coords.get("site").values
        n_sites = self.dataset.sizes["site"]
        site_names = validate_site_names(site_values, n_sites)
        return build_atmosphere_of_sites(
            times=self.dataset.time.values,
            latitude=self.dataset.lat.values,
            longitude=self.dataset.lon.values,
            constituents=result,
            site_names=site_names,
            global_attrs=get_global_attrs(feature_type="timeSeries")
        )

distill_crude_data staticmethod

distill_crude_data(
    data: DataFrame, metadata: list[str]
) -> DataFrame

Refine and enrich raw CRS data for clear-sky modeling.

Performs post-processing on SODA API data: 1. Calculates AOD550 and estimates Ångström exponent ($\alpha$) using weighted average of aerosol mixture optical properties 2. Estimates surface pressure from site altitude using barometric formula 3. Resamples to 1-hour intervals with center-time alignment

Parameters:

  • data
    (DataFrame) –

    Raw data from SODA API

  • metadata
    (list[str]) –

    Metadata lines from API response (contains altitude)

Returns:

  • DataFrame

    Processed DataFrame with columns: times_utc, pressure, albedo, pwater, ozone, aod550, beta, alpha

Notes

The $\alpha$ estimation uses typical values for aerosol species from CAMS Reanalysis: - Desert dust (DU): 0.3 (coarse particles) - Sea salt (SS): 0.5 (large hygroscopic particles) - Black carbon (BC): 1.2 (fine combustion particles) - Organic matter (OR): 1.8 (fine particles) - Sulphates (SU): 1.7 (very fine scatterers) - Nitrates (NI), Ammonium (AM): 1.9

References

.. [1] Bozzo et al. (2017). Implementation of a CAMS-based aerosol climatology in the IFS, ECMWF.

Source code in src/spartasolar/atmoslib/crs_sodaapi.py
@staticmethod
def distill_crude_data(data: pd.DataFrame, metadata: list[str]) -> pd.DataFrame:
    r"""Refine and enrich raw CRS data for clear-sky modeling.

    Performs post-processing on SODA API data:
    1. Calculates AOD550 and estimates Ångström exponent ($\alpha$) using
       weighted average of aerosol mixture optical properties
    2. Estimates surface pressure from site altitude using barometric formula
    3. Resamples to 1-hour intervals with center-time alignment

    Parameters
    ----------
    data : pd.DataFrame
        Raw data from SODA API
    metadata : list[str]
        Metadata lines from API response (contains altitude)

    Returns
    -------
    pd.DataFrame
        Processed DataFrame with columns: times_utc, pressure, albedo,
        pwater, ozone, aod550, beta, alpha

    Notes
    -----
    The $\alpha$ estimation uses typical values for aerosol species
    from CAMS Reanalysis:
    - Desert dust (DU): 0.3 (coarse particles)
    - Sea salt (SS): 0.5 (large hygroscopic particles)
    - Black carbon (BC): 1.2 (fine combustion particles)
    - Organic matter (OR): 1.8 (fine particles)
    - Sulphates (SU): 1.7 (very fine scatterers)
    - Nitrates (NI), Ammonium (AM): 1.9

    References
    ----------
    .. [1] Bozzo et al. (2017). Implementation of a CAMS-based aerosol
           climatology in the IFS, ECMWF.
    """
    def barometric_formula_laplace(
        z: float,
        L: float = 0.0065,  # vertical thermal gradient, K m-1
        Po: float = 101325.,  # sea-level pressure, Pa
        To: float = 288.15,  # sea-level temperature, K
    ) -> float:

        g = 9.80665  # acceleration of gravity, m s-2
        M = 0.02896  # dry air molar mass, kg mol-1
        R = 8.31447  # state constant of ideal gas, J mol-1 K-1

        gamma = (g*M) / (R*L)
        return Po * (1.-(L*z)/To)**gamma

    # The cams radiation service does not provide alpha. I make then a rough estimation
    # from typical alpha values of each aerosol species. For a better estimation, I could
    # have used the description of the aerosol mixtures (DU, BC, OM...) used in the
    # CAMS Reanalysis EC4, which is provided in Bozzo et al. (2017) [1], combined
    # with the OPAC's characterization of the spectral properties and the distribution
    # of single species and mixtures, but this requires too much effort for the moment.
    # [1] Bozzo et al. (2017) Implementation of a CAMS-based aerosol climatology in
    #     the IFS, ECMWF. Available at: https://www.ecmwf.int/sites/default/files/elibrary/2017/17219-implementation-cams-based-aerosol-climatology-ifs.pdf
    alpha_of_mixtures = {
        "DU": 0.3,  # desert dust. Large particles. CAMS uses 3-5 bins. Coarser particles have alpha close to 0
        "SS": 0.5,  # sea salt. Large particles from sea. Highly hygroscopic. Humidity affects their size
        "BC": 1.2,  # black carbon. Small "dark" particles from combustion processes. Sharp spectral dependence.
        "OR": 1.8,  # organic matter. Fine particles. Includes primary and secondary organic matters.
        "SU": 1.7,  # sulphates. Very fine particles, purely scatterers. Sharp spectral dependence.
        "NI": 1.9,  # nitrates. Similar to sulphates, but mostly in the fine mode.
        "AM": 1.9,  # ammonium. Similar to sulphates, but mostly in the fine mode.
    }

    data = data.assign(
        times_utc=pd.to_datetime(data["Observation period"].str.split("/").str[0]),
        aod550=data.filter(like="AOD").sum(axis=1),
        # N.B. The sza column in the crs data for night-time is 0 !!!
        albedo=data.albedo.where((data.sza < 89) & (data.sza != 0.), 0))

    # make a weighted average of the alphas of the mixtures
    weighted_alpha = 0.
    existing_aods = []
    for mixture, alpha in alpha_of_mixtures.items():
        if (aod_label := f"AOD {mixture}") in data:
            weighted_alpha += alpha *data[aod_label]
            existing_aods.append(aod_label)
    if len(existing_aods):
        weighted_alpha /= data.get(existing_aods).sum(axis=1)

    data["alpha"] = data["alpha"].where(data["alpha"].notna(), weighted_alpha)
    data["beta"] = data["aod550"]*(0.55**data["alpha"])

    # estimate surface pressure from altitude (surface pressure is missing in crs)
    regex = re.compile(r"^#\s*Altitude \(m\):\s*([-+]?\d*\.?\d+)")
    for line in metadata:
        if (m := regex.search(line)):
            altitude = float(m.groups()[0])
            break

    data["pressure"] = barometric_formula_laplace(altitude)  # surface pressure, Pa
    data["ozone"] = ozone_in_du_to_kg_m2(data["tco3"])  # DU to kg m-2
    data["pwater"] = data["tcwv"]  # in kg m-2

    # keep only the relevant columns for clear-sky evaluations with sparta
    data = data.get(["times_utc", "pressure", "albedo", "pwater", "ozone", "aod550", "beta", "alpha"])

    # resample hourly: the 1-min dataset is just an interpolation. It does not
    # make sense wasting disk space just to store this kind of 1-min data
    return (data.set_index("times_utc")
            .resample("1h").mean()  # time alignment = left
            .pipe(lambda df: df.set_index(df.index + pd.Timedelta("30min")))  # time alignment = center
            .reset_index())

get_filename classmethod

get_filename(
    year: int,
    latitude: float,
    longitude: float,
    version: str,
) -> Path

Generate the cache filename for CRS SODA data.

Constructs a standardized filename pattern for cached data including version, encoded coordinates, and year. Coordinates are multiplied by $10^4$ and suffixed with cardinal direction indicators.

Parameters:

  • year
    (int) –

    Year for the data

  • latitude
    (float) –

    Latitude in degrees North [-90, 90]

  • longitude
    (float) –

    Longitude in degrees East [-180, 180]

  • version
    (str) –

    API version string (e.g., "1.0.0")

Returns:

  • Path

    Absolute path to the .parquet cache file

Examples:

>>> path = CRSSODAAtmosphere.get_filename(2023, 40.4168, -3.7038, "1.0.0")
>>> print(path.name)
# "crs_soda_mcclear_v1.0.0_404168N_37038W_2023.parquet"
Notes

Coordinate encoding: - Multiplied by $10^4$ and converted to integers - Signs replaced by suffixes: 'N'/'S' for latitude, 'E'/'W' for longitude - Example: latitude -12.34 becomes "123400S"

Source code in src/spartasolar/atmoslib/crs_sodaapi.py
@classmethod
def get_filename(cls, year: int, latitude: float, longitude: float, version: str) -> Path:
    r"""Generate the cache filename for CRS SODA data.

    Constructs a standardized filename pattern for cached data including
    version, encoded coordinates, and year. Coordinates are multiplied by
    $10^4$ and suffixed with cardinal direction indicators.

    Parameters
    ----------
    year : int
        Year for the data
    latitude : float
        Latitude in degrees North [-90, 90]
    longitude : float
        Longitude in degrees East [-180, 180]
    version : str
        API version string (e.g., "1.0.0")

    Returns
    -------
    Path
        Absolute path to the .parquet cache file

    Examples
    --------
    >>> path = CRSSODAAtmosphere.get_filename(2023, 40.4168, -3.7038, "1.0.0")
    >>> print(path.name)
    # "crs_soda_mcclear_v1.0.0_404168N_37038W_2023.parquet"

    Notes
    -----
    Coordinate encoding:
    - Multiplied by $10^4$ and converted to integers
    - Signs replaced by suffixes: 'N'/'S' for latitude, 'E'/'W' for longitude
    - Example: latitude -12.34 becomes "123400S"
    """
    filename_pattern = "crs_soda_mcclear_v{version}_{latitude}_{longitude}_{year}.parquet"
    latitude_str = f"{float(latitude)*1e4:.0f}"
    latitude_str = latitude_str[1:]+"S" if latitude_str.startswith("-") else latitude_str + "N"
    longitude_str = f"{float(longitude)*1e4:.0f}"
    longitude_str = longitude_str[1:]+"W" if longitude_str.startswith("-") else longitude_str + "E"
    filename = filename_pattern.format(version=version, latitude=latitude_str, longitude=longitude_str, year=year)
    return cls.database_path / filename

Custom atmosphere

CustomAtmosphere

CustomAtmosphere()

Bases: BaseAtmosphere

Custom atmospheric data provider.

This class allows users to supply their own atmospheric constituent data from measurements, numerical weather prediction models, or other sources. Data can be provided for specific sites (time series) or regular grids.

See module documentation for examples.

Validates that the database path exists (if specified) and initializes the internal atmosphere dataset to None.

Raises:

Methods:

  • __init_subclass__

    Automatically sets the database path for subclasses.

  • at_sites

    Create custom atmospheric data for specific sites.

  • compute

    Compute clear-sky solar radiation using a radiative transfer model.

  • on_regular_grid

    Create custom atmospheric data on a regular spatial grid.

Source code in src/spartasolar/atmoslib/_base.py
def __init__(self):
    """Initialize the atmosphere instance.

    Validates that the database path exists (if specified) and initializes
    the internal atmosphere dataset to None.

    Raises
    ------
    AttributeError
        If database_path is specified but does not exist
    """
    if self.database_path is not None and not self.database_path.exists():
        raise AttributeError(f"missing path `{self.database_path}`")

    self._atmosphere: xr.DataArray = None

__init_subclass__

__init_subclass__(database_path: str, **kwargs)

Automatically sets the database path for subclasses.

Parameters:

  • database_path
    (str or None) –

    The directory path where the specific atmosphere data is stored. Pass None for sources that do not use a file database (e.g. CustomAtmosphere or API-based retrievers).

Source code in src/spartasolar/atmoslib/_base.py
def __init_subclass__(cls, database_path: str, **kwargs):
    """Automatically sets the database path for subclasses.

    Parameters
    ----------
    database_path : str or None
        The directory path where the specific atmosphere data is stored.
        Pass ``None`` for sources that do not use a file database
        (e.g. ``CustomAtmosphere`` or API-based retrievers).
    """
    super().__init_subclass__(**kwargs)
    cls.database_path = None if database_path is None else Path(database_path)

at_sites classmethod

Create custom atmospheric data for specific sites.

Parameters:

  • times
    (ndarray or DatetimeIndex) –

    Time stamps for the data (length n_times)

  • latitude
    (float or Sequence[float]) –

    Latitude(s) in degrees North [-90, 90] (length n_sites)

  • longitude
    (float or Sequence[float]) –

    Longitude(s) in degrees East [-180, 180] (length n_sites)

  • constituents
    (dict[str, ndarray]) –

    Atmospheric variables as 2D arrays with shape (n_times, n_sites). Standard variable names: 'pressure' (Pa), 'pwater' (cm), 'ozone' (atm-cm), 'alpha', 'beta', 'ssa', 'albedo'

  • site_names
    (Sequence[str], default: None ) –

    Names for each site

  • var_attrs
    (dict, default: None ) –

    Custom attributes for variables (CF conventions)

  • global_attrs
    (dict, default: None ) –

    Custom global attributes for the dataset

Returns:

Examples:

>>> times = pd.date_range("2020-01-01", periods=24, freq="h")
>>> atm = CustomAtmosphere.at_sites(
...     times=times,
...     latitude=36.72,
...     longitude=-4.42,
...     constituents={
...         "pressure": np.full((24, 1), 101325.0),
...         "pwater": np.linspace(1.0, 2.0, 24).reshape(24, 1),
...         "ozone": np.full((24, 1), 0.3)
...     }
... )
Source code in src/spartasolar/atmoslib/custom.py
@classmethod
def at_sites(
    cls,
    times: np.ndarray[tuple[int], np.datetime64] | pd.DatetimeIndex,
    latitude: Sequence[float] | float,
    longitude: Sequence[float] | float,
    constituents: dict[str, np.ndarray[tuple[int, int], float]],  # shape: (time, site)
    site_names: Sequence[str] | None = None,
    var_attrs: dict | None = None,
    global_attrs: dict | None = None,
) -> Self:
    """Create custom atmospheric data for specific sites.

    Parameters
    ----------
    times : np.ndarray or pd.DatetimeIndex
        Time stamps for the data (length n_times)
    latitude : float or Sequence[float]
        Latitude(s) in degrees North [-90, 90] (length n_sites)
    longitude : float or Sequence[float]
        Longitude(s) in degrees East [-180, 180] (length n_sites)
    constituents : dict[str, np.ndarray]
        Atmospheric variables as 2D arrays with shape (n_times, n_sites).
        Standard variable names: 'pressure' (Pa), 'pwater' (cm), 'ozone' (atm-cm),
        'alpha', 'beta', 'ssa', 'albedo'
    site_names : Sequence[str], optional
        Names for each site
    var_attrs : dict, optional
        Custom attributes for variables (CF conventions)
    global_attrs : dict, optional
        Custom global attributes for the dataset

    Returns
    -------
    CustomAtmosphere
        Instance with atmospheric data loaded

    Examples
    --------
    >>> times = pd.date_range("2020-01-01", periods=24, freq="h")
    >>> atm = CustomAtmosphere.at_sites(
    ...     times=times,
    ...     latitude=36.72,
    ...     longitude=-4.42,
    ...     constituents={
    ...         "pressure": np.full((24, 1), 101325.0),
    ...         "pwater": np.linspace(1.0, 2.0, 24).reshape(24, 1),
    ...         "ozone": np.full((24, 1), 0.3)
    ...     }
    ... )
    """

    obj = cls()
    obj._atmosphere = build_atmosphere_of_sites(
        times=times,
        latitude=latitude,
        longitude=longitude,
        constituents=constituents,
        site_names=site_names,
        var_attrs=var_attrs,
        global_attrs=global_attrs)
    return obj

compute

compute(
    model: Model = "SPARTA",
    include_atmosphere: bool = False,
    model_kwargs: dict | None = None,
) -> Dataset

Compute clear-sky solar radiation using a radiative transfer model.

This method integrates solar position calculations with atmospheric constituent data to compute clear-sky irradiance components (GHI, DNI, DHI, etc.) using the specified radiative transfer model.

Parameters:

  • model
    (Model, default: "SPARTA" ) –

    Name of the clear-sky model to use. Options: "SPARTA", "Bird"

  • include_atmosphere
    (bool, default: False ) –

    If True, include atmospheric constituents in the output dataset. If False, only radiation components are returned.

  • model_kwargs
    (dict, default: None ) –

    Additional keyword arguments to pass to the model function

Returns:

  • Dataset

    CF-compliant dataset containing computed irradiance components: - ghi: Global Horizontal Irradiance (W/m²) - dni: Direct Normal Irradiance (W/m²) - dhi or dif: Diffuse Horizontal Irradiance (W/m²) - csi: Circumsolar Irradiance (W/m², SPARTA only)

Examples:

>>> import pandas as pd
>>> from spartasolar.atmoslib import MERRA2DailyAtmosphere
>>>
>>> times = pd.date_range("2020-06-15", periods=24, freq="h")
>>> atm = MERRA2DailyAtmosphere.at_sites(
...     times=times,
...     latitude=36.72,
...     longitude=-4.42
... )
>>> result = atm.compute(model="SPARTA")
>>> print(result.ghi.values)

Use different model with custom parameters:

>>> result = atm.compute(
...     model="Bird",
...     model_kwargs={"scheme": "transmittance_parameterization"}
... )
Notes

The method automatically: - Calculates solar position (zenith angle, Earth-Sun distance) - Converts atmospheric units to model requirements - Handles both gridded and site-based data structures

See Also

spartasolar.modlib.sparta : SPARTA model implementation spartasolar.modlib.bird : Bird clear-sky model

Source code in src/spartasolar/atmoslib/_base.py
def compute(
    self,
    model: Model = "SPARTA",
    include_atmosphere: bool = False,
    model_kwargs: dict | None = None,
) -> xr.Dataset:
    """Compute clear-sky solar radiation using a radiative transfer model.

    This method integrates solar position calculations with atmospheric
    constituent data to compute clear-sky irradiance components (GHI, DNI,
    DHI, etc.) using the specified radiative transfer model.

    Parameters
    ----------
    model : Model, default "SPARTA"
        Name of the clear-sky model to use. Options: "SPARTA", "Bird"
    include_atmosphere : bool, default False
        If True, include atmospheric constituents in the output dataset.
        If False, only radiation components are returned.
    model_kwargs : dict, optional
        Additional keyword arguments to pass to the model function

    Returns
    -------
    xr.Dataset
        CF-compliant dataset containing computed irradiance components:
        - ghi: Global Horizontal Irradiance (W/m²)
        - dni: Direct Normal Irradiance (W/m²)
        - dhi or dif: Diffuse Horizontal Irradiance (W/m²)
        - csi: Circumsolar Irradiance (W/m², SPARTA only)

    Examples
    --------
    >>> import pandas as pd
    >>> from spartasolar.atmoslib import MERRA2DailyAtmosphere
    >>>
    >>> times = pd.date_range("2020-06-15", periods=24, freq="h")
    >>> atm = MERRA2DailyAtmosphere.at_sites(
    ...     times=times,
    ...     latitude=36.72,
    ...     longitude=-4.42
    ... )
    >>> result = atm.compute(model="SPARTA")
    >>> print(result.ghi.values)

    Use different model with custom parameters:

    >>> result = atm.compute(
    ...     model="Bird",
    ...     model_kwargs={"scheme": "transmittance_parameterization"}
    ... )

    Notes
    -----
    The method automatically:
    - Calculates solar position (zenith angle, Earth-Sun distance)
    - Converts atmospheric units to model requirements
    - Handles both gridded and site-based data structures

    See Also
    --------
    spartasolar.modlib.sparta : SPARTA model implementation
    spartasolar.modlib.bird : Bird clear-sky model
    """

    model = validate_type(model, Model)
    model_func = getattr(modlib, model)
    model_vars = inspect.getfullargspec(model_func).args

    is_regular_grid = "site" not in self.dataset.dims

    # compute solar geometry...
    sw_eval = sunwhere.regular_grid if is_regular_grid else sunwhere.sites
    solpos = sw_eval(
        self.dataset.time.values,
        latitude=self.dataset.lat.values,
        longitude=self.dataset.lon.values,
        algorithm=config.get_option("sunwhere.algorithm", default="psa"),
        refraction=config.get_option("sunwhere.refraction", default=True),
        engine=config.get_option("sunwhere.engine", default="numexpr")
    )

    new_coord_names = {"location": "site", "latitude": "lat", "longitude": "lon"}
    cosz = solpos.cosz.rename({old: new for old, new in new_coord_names.items() if old in solpos.cosz.dims})

    if is_regular_grid:
        n_lats = self.dataset.sizes["lat"]
        n_lons = self.dataset.sizes["lon"]
        ecf = solpos.ecf.expand_dims(dim={"lat": n_lats, "lon": n_lons}, axis=(1, 2))
    else:
        n_locs = self.dataset.sizes["site"]
        ecf = solpos.ecf.expand_dims(dim={"site": n_locs}, axis=1)

    kwargs = {"cosz": cosz, "ecf": ecf}

    # atmosphere...
    def get_with_proper_units(var):
        if var not in self.dataset.data_vars:
            raise ValueError(f"variable `{var}` required by the model `{model}` is not available in this atmosphere")
        if var == "pressure":
            return self.dataset[var] * 1e-2  # Pa to hPa
        if var == "pwater":
            return pwater_in_kg_m2_to_cm(self.dataset[var])
        if var == "ozone":
            return ozone_in_kg_m2_to_cm(self.dataset[var])
        return self.dataset[var]
    variables = set(model_vars).intersection(self.dataset.data_vars)  # variables required by this model
    kwargs = kwargs | {var: get_with_proper_units(var) for var in variables}

    # and call the clearsky model...
    result = model_func(**(kwargs | (model_kwargs or {})))

    # encapsulate the result in a CF-compliant xarray Dataset
    if is_regular_grid:
        return build_atmosphere_on_regular_grid(
            times=self.dataset.time.values,
            latitude=self.dataset.lat.values,
            longitude=self.dataset.lon.values,
            constituents=result,
            global_attrs=get_global_attrs(feature_type="grid")
        )
    else:
        site_values = self.dataset.coords.get("site").values
        n_sites = self.dataset.sizes["site"]
        site_names = validate_site_names(site_values, n_sites)
        return build_atmosphere_of_sites(
            times=self.dataset.time.values,
            latitude=self.dataset.lat.values,
            longitude=self.dataset.lon.values,
            constituents=result,
            site_names=site_names,
            global_attrs=get_global_attrs(feature_type="timeSeries")
        )

on_regular_grid classmethod

Create custom atmospheric data on a regular spatial grid.

Parameters:

  • times
    (ndarray or DatetimeIndex) –

    Time stamps for the data (length n_times)

  • latitude
    (Sequence[float]) –

    Latitude coordinates in degrees North (length n_lats)

  • longitude
    (Sequence[float]) –

    Longitude coordinates in degrees East (length n_lons)

  • constituents
    (dict[str, ndarray]) –

    Atmospheric variables as 3D arrays with shape (n_times, n_lats, n_lons). Standard names: 'pressure', 'pwater', 'ozone', 'alpha', 'beta', 'ssa', 'albedo'

  • var_attrs
    (dict, default: None ) –

    Custom variable attributes

  • global_attrs
    (dict, default: None ) –

    Custom global dataset attributes

Returns:

Examples:

>>> lats = np.linspace(36.0, 41.0, 20)
>>> lons = np.linspace(-5.0, -3.0, 20)
>>> times = pd.date_range("2020-06-15", periods=5, freq="h")
>>> atm = CustomAtmosphere.on_regular_grid(
...     times=times,
...     latitude=lats,
...     longitude=lons,
...     constituents={
...         "pressure": np.full((5, 20, 20), 101325.0),
...         "pwater": np.random.uniform(1.0, 3.0, (5, 20, 20)),
...     }
... )
Source code in src/spartasolar/atmoslib/custom.py
@classmethod
def on_regular_grid(
    cls,
    times: np.ndarray[tuple[int], np.datetime64] | pd.DatetimeIndex,
    latitude: Sequence[float],
    longitude: Sequence[float],
    constituents: dict[str, np.ndarray[tuple[int, int, int], float]],  # shape: (time, lat, lon)
    var_attrs: dict | None = None,
    global_attrs: dict | None = None,
) -> Self:
    """Create custom atmospheric data on a regular spatial grid.

    Parameters
    ----------
    times : np.ndarray or pd.DatetimeIndex
        Time stamps for the data (length n_times)
    latitude : Sequence[float]
        Latitude coordinates in degrees North (length n_lats)
    longitude : Sequence[float]
        Longitude coordinates in degrees East (length n_lons)
    constituents : dict[str, np.ndarray]
        Atmospheric variables as 3D arrays with shape (n_times, n_lats, n_lons).
        Standard names: 'pressure', 'pwater', 'ozone', 'alpha', 'beta', 'ssa', 'albedo'
    var_attrs : dict, optional
        Custom variable attributes
    global_attrs : dict, optional
        Custom global dataset attributes

    Returns
    -------
    CustomAtmosphere
        Instance with gridded atmospheric data

    Examples
    --------
    >>> lats = np.linspace(36.0, 41.0, 20)
    >>> lons = np.linspace(-5.0, -3.0, 20)
    >>> times = pd.date_range("2020-06-15", periods=5, freq="h")
    >>> atm = CustomAtmosphere.on_regular_grid(
    ...     times=times,
    ...     latitude=lats,
    ...     longitude=lons,
    ...     constituents={
    ...         "pressure": np.full((5, 20, 20), 101325.0),
    ...         "pwater": np.random.uniform(1.0, 3.0, (5, 20, 20)),
    ...     }
    ... )
    """

    obj = cls()
    obj._atmosphere = build_atmosphere_on_regular_grid(
        times=times,
        latitude=latitude,
        longitude=longitude,
        constituents=constituents,
        var_attrs=var_attrs,
        global_attrs=global_attrs)
    return obj

Base class

BaseAtmosphere

BaseAtmosphere()

Abstract base class for atmospheric database interfaces.

This class defines the common interface that all atmospheric data sources must implement. It provides methods for loading atmospheric constituent data and computing clear-sky solar radiation using various radiative transfer models.

Subclasses must implement methods to retrieve atmospheric data either at specific sites (time series) or on regular grids. The class handles: - Loading and validation of atmospheric datasets - Integration with solar position calculations - Execution of clear-sky radiation models (SPARTA, Bird, etc.) - CF-compliant metadata management

Attributes:

  • database_path (Path or None) –

    Directory containing the database files (set by subclass)

  • dataset (Dataset) –

    The loaded atmospheric dataset

Examples:

Subclasses should define database_path and implement data retrieval:

>>> class MyAtmosphere(BaseAtmosphere, database_path="/path/to/data"):
...     @classmethod
...     def at_sites(cls, times, latitude, longitude, **kwargs):
...         # Implementation here
...         pass
Notes

This is an abstract base class and cannot be instantiated directly. Use concrete implementations like MERRA2DailyAtmosphere or CustomAtmosphere.

See Also

MERRA2DailyAtmosphere : Most commonly used implementation CustomAtmosphere : For user-provided data

Validates that the database path exists (if specified) and initializes the internal atmosphere dataset to None.

Raises:

Methods:

  • __init_subclass__

    Automatically sets the database path for subclasses.

  • compute

    Compute clear-sky solar radiation using a radiative transfer model.

Source code in src/spartasolar/atmoslib/_base.py
def __init__(self):
    """Initialize the atmosphere instance.

    Validates that the database path exists (if specified) and initializes
    the internal atmosphere dataset to None.

    Raises
    ------
    AttributeError
        If database_path is specified but does not exist
    """
    if self.database_path is not None and not self.database_path.exists():
        raise AttributeError(f"missing path `{self.database_path}`")

    self._atmosphere: xr.DataArray = None

__init_subclass__

__init_subclass__(database_path: str, **kwargs)

Automatically sets the database path for subclasses.

Parameters:

  • database_path
    (str or None) –

    The directory path where the specific atmosphere data is stored. Pass None for sources that do not use a file database (e.g. CustomAtmosphere or API-based retrievers).

Source code in src/spartasolar/atmoslib/_base.py
def __init_subclass__(cls, database_path: str, **kwargs):
    """Automatically sets the database path for subclasses.

    Parameters
    ----------
    database_path : str or None
        The directory path where the specific atmosphere data is stored.
        Pass ``None`` for sources that do not use a file database
        (e.g. ``CustomAtmosphere`` or API-based retrievers).
    """
    super().__init_subclass__(**kwargs)
    cls.database_path = None if database_path is None else Path(database_path)

compute

compute(
    model: Model = "SPARTA",
    include_atmosphere: bool = False,
    model_kwargs: dict | None = None,
) -> Dataset

Compute clear-sky solar radiation using a radiative transfer model.

This method integrates solar position calculations with atmospheric constituent data to compute clear-sky irradiance components (GHI, DNI, DHI, etc.) using the specified radiative transfer model.

Parameters:

  • model
    (Model, default: "SPARTA" ) –

    Name of the clear-sky model to use. Options: "SPARTA", "Bird"

  • include_atmosphere
    (bool, default: False ) –

    If True, include atmospheric constituents in the output dataset. If False, only radiation components are returned.

  • model_kwargs
    (dict, default: None ) –

    Additional keyword arguments to pass to the model function

Returns:

  • Dataset

    CF-compliant dataset containing computed irradiance components: - ghi: Global Horizontal Irradiance (W/m²) - dni: Direct Normal Irradiance (W/m²) - dhi or dif: Diffuse Horizontal Irradiance (W/m²) - csi: Circumsolar Irradiance (W/m², SPARTA only)

Examples:

>>> import pandas as pd
>>> from spartasolar.atmoslib import MERRA2DailyAtmosphere
>>>
>>> times = pd.date_range("2020-06-15", periods=24, freq="h")
>>> atm = MERRA2DailyAtmosphere.at_sites(
...     times=times,
...     latitude=36.72,
...     longitude=-4.42
... )
>>> result = atm.compute(model="SPARTA")
>>> print(result.ghi.values)

Use different model with custom parameters:

>>> result = atm.compute(
...     model="Bird",
...     model_kwargs={"scheme": "transmittance_parameterization"}
... )
Notes

The method automatically: - Calculates solar position (zenith angle, Earth-Sun distance) - Converts atmospheric units to model requirements - Handles both gridded and site-based data structures

See Also

spartasolar.modlib.sparta : SPARTA model implementation spartasolar.modlib.bird : Bird clear-sky model

Source code in src/spartasolar/atmoslib/_base.py
def compute(
    self,
    model: Model = "SPARTA",
    include_atmosphere: bool = False,
    model_kwargs: dict | None = None,
) -> xr.Dataset:
    """Compute clear-sky solar radiation using a radiative transfer model.

    This method integrates solar position calculations with atmospheric
    constituent data to compute clear-sky irradiance components (GHI, DNI,
    DHI, etc.) using the specified radiative transfer model.

    Parameters
    ----------
    model : Model, default "SPARTA"
        Name of the clear-sky model to use. Options: "SPARTA", "Bird"
    include_atmosphere : bool, default False
        If True, include atmospheric constituents in the output dataset.
        If False, only radiation components are returned.
    model_kwargs : dict, optional
        Additional keyword arguments to pass to the model function

    Returns
    -------
    xr.Dataset
        CF-compliant dataset containing computed irradiance components:
        - ghi: Global Horizontal Irradiance (W/m²)
        - dni: Direct Normal Irradiance (W/m²)
        - dhi or dif: Diffuse Horizontal Irradiance (W/m²)
        - csi: Circumsolar Irradiance (W/m², SPARTA only)

    Examples
    --------
    >>> import pandas as pd
    >>> from spartasolar.atmoslib import MERRA2DailyAtmosphere
    >>>
    >>> times = pd.date_range("2020-06-15", periods=24, freq="h")
    >>> atm = MERRA2DailyAtmosphere.at_sites(
    ...     times=times,
    ...     latitude=36.72,
    ...     longitude=-4.42
    ... )
    >>> result = atm.compute(model="SPARTA")
    >>> print(result.ghi.values)

    Use different model with custom parameters:

    >>> result = atm.compute(
    ...     model="Bird",
    ...     model_kwargs={"scheme": "transmittance_parameterization"}
    ... )

    Notes
    -----
    The method automatically:
    - Calculates solar position (zenith angle, Earth-Sun distance)
    - Converts atmospheric units to model requirements
    - Handles both gridded and site-based data structures

    See Also
    --------
    spartasolar.modlib.sparta : SPARTA model implementation
    spartasolar.modlib.bird : Bird clear-sky model
    """

    model = validate_type(model, Model)
    model_func = getattr(modlib, model)
    model_vars = inspect.getfullargspec(model_func).args

    is_regular_grid = "site" not in self.dataset.dims

    # compute solar geometry...
    sw_eval = sunwhere.regular_grid if is_regular_grid else sunwhere.sites
    solpos = sw_eval(
        self.dataset.time.values,
        latitude=self.dataset.lat.values,
        longitude=self.dataset.lon.values,
        algorithm=config.get_option("sunwhere.algorithm", default="psa"),
        refraction=config.get_option("sunwhere.refraction", default=True),
        engine=config.get_option("sunwhere.engine", default="numexpr")
    )

    new_coord_names = {"location": "site", "latitude": "lat", "longitude": "lon"}
    cosz = solpos.cosz.rename({old: new for old, new in new_coord_names.items() if old in solpos.cosz.dims})

    if is_regular_grid:
        n_lats = self.dataset.sizes["lat"]
        n_lons = self.dataset.sizes["lon"]
        ecf = solpos.ecf.expand_dims(dim={"lat": n_lats, "lon": n_lons}, axis=(1, 2))
    else:
        n_locs = self.dataset.sizes["site"]
        ecf = solpos.ecf.expand_dims(dim={"site": n_locs}, axis=1)

    kwargs = {"cosz": cosz, "ecf": ecf}

    # atmosphere...
    def get_with_proper_units(var):
        if var not in self.dataset.data_vars:
            raise ValueError(f"variable `{var}` required by the model `{model}` is not available in this atmosphere")
        if var == "pressure":
            return self.dataset[var] * 1e-2  # Pa to hPa
        if var == "pwater":
            return pwater_in_kg_m2_to_cm(self.dataset[var])
        if var == "ozone":
            return ozone_in_kg_m2_to_cm(self.dataset[var])
        return self.dataset[var]
    variables = set(model_vars).intersection(self.dataset.data_vars)  # variables required by this model
    kwargs = kwargs | {var: get_with_proper_units(var) for var in variables}

    # and call the clearsky model...
    result = model_func(**(kwargs | (model_kwargs or {})))

    # encapsulate the result in a CF-compliant xarray Dataset
    if is_regular_grid:
        return build_atmosphere_on_regular_grid(
            times=self.dataset.time.values,
            latitude=self.dataset.lat.values,
            longitude=self.dataset.lon.values,
            constituents=result,
            global_attrs=get_global_attrs(feature_type="grid")
        )
    else:
        site_values = self.dataset.coords.get("site").values
        n_sites = self.dataset.sizes["site"]
        site_names = validate_site_names(site_values, n_sites)
        return build_atmosphere_of_sites(
            times=self.dataset.time.values,
            latitude=self.dataset.lat.values,
            longitude=self.dataset.lon.values,
            constituents=result,
            site_names=site_names,
            global_attrs=get_global_attrs(feature_type="timeSeries")
        )

Clear-sky models

SPARTA

SPARTA

SPARTA(
    cosz: float | ndarray = 0.5,
    pressure: float | ndarray = 1013.25,
    albedo: float | ndarray = 0.2,
    pwater: float | ndarray = 1.4,
    ozone: float | ndarray = 0.3,
    beta: float | ndarray = 0.1,
    alpha: float | ndarray = 1.3,
    ssa: float | ndarray = 0.92,
    asy: float | ndarray = 0.65,
    ecf: float | ndarray = 1.0,
    csi_param: str = "sparta",
    csi_hfov: float = 2.5,
    transmittance_scheme: str = "interdependent",
) -> dict[str, ndarray]

Compute clear-sky solar irradiance using the SPARTA model.

SPARTA (Solar PArameterization of the Radiative Transfer of the Atmosphere) is a high-accuracy 2-band broadband clear-sky model that computes direct, diffuse, global, and circumsolar irradiance. The model is fully vectorized and handles nighttime masking automatically.

Parameters:

  • cosz

    (float or ndarray, default: 0.5 ) –

    Cosine of the solar zenith angle [0, 1]. Values ≤ cos(90.5°) ≈ 0.00872 are treated as nighttime.

  • pressure

    (float or ndarray, default: 1013.25 ) –

    Atmospheric surface pressure in hPa (or mb). Typical range: 800-1100 hPa.

  • albedo

    (float or ndarray, default: 0.2 ) –

    Ground surface albedo [0, 1]. Typical values: 0.1-0.3 (vegetation), 0.6-0.9 (snow), 0.05-0.15 (water).

  • pwater

    (float or ndarray, default: 1.4 ) –

    Precipitable water vapor in cm. Typical range: 0.5-6.0 cm.

  • ozone

    (float or ndarray, default: 0.3 ) –

    Total column ozone in atm-cm. Note: 1 atm-cm = 1000 DU (Dobson Units). Typical range: 0.2-0.5 atm-cm (200-500 DU).

  • beta

    (float or ndarray, default: 0.1 ) –

    Ångström turbidity coefficient (aerosol optical depth at 1000 nm). Automatically clipped to [0, 2.2]. Typical range: 0.02-0.5.

  • alpha

    (float or ndarray, default: 1.3 ) –

    Ångström wavelength exponent. Automatically clipped to [0, 2.5]. Typical values: 0.5-1.0 (coarse aerosols), 1.5-2.0 (fine aerosols).

  • ssa

    (float or ndarray, default: 0.92 ) –

    Aerosol single-scattering albedo at ~700 nm [0, 1]. Typical values: 0.85-0.95 (urban), 0.95-0.99 (desert dust).

  • asy

    (float or ndarray, default: 0.65 ) –

    Aerosol asymmetry parameter at ~700 nm [-1, 1]. Typical range: 0.6-0.8.

  • ecf

    (float or ndarray, default: 1.0 ) –

    Eccentricity correction factor for Sun-Earth distance. Range: ~0.967 (early July) to ~1.034 (early January).

  • csi_param

    (str, default: 'sparta' ) –

    Circumsolar irradiance parameterization method: - 'none': Neglects circumsolar component - 'sparta': Uses SPARTA native parameterization (recommended)

  • csi_hfov

    (float, default: 2.5 ) –

    Half field of view angle in degrees for circumsolar evaluation. Typical pyrheliometer values: 2.5° (standard), 2.9° (some instruments).

  • transmittance_scheme

    (str, default: 'interdependent' ) –

    Method for computing atmospheric transmittances: - 'independent': Treats each constituent separately (faster, less accurate) - 'interdependent': Accounts for constituent interactions (more accurate)

Returns:

  • dict[str, float or ndarray]

    Dictionary with the following irradiance components (all in W/m²):

    • 'dni': Direct Normal Irradiance (beam on plane perpendicular to sun)
    • 'dhi': Direct Horizontal Irradiance (beam on horizontal plane)
    • 'dif': Diffuse Horizontal Irradiance (scattered radiation)
    • 'ghi': Global Horizontal Irradiance (total on horizontal plane)
    • 'csi': Circumsolar Normal Irradiance (forward-scattered aureole)

Examples:

Single location and time:

>>> from spartasolar.modlib import SPARTA
>>> result = SPARTA(
...     cosz=0.866,  # 30° solar zenith angle
...     pressure=1013.25,
...     pwater=2.0,
...     ozone=0.3,
...     beta=0.1,
...     alpha=1.3
... )
>>> print(f"DNI: {result['dni']:.1f} W/m²")
>>> print(f"GHI: {result['ghi']:.1f} W/m²")

Vectorized computation for time series:

>>> import numpy as np
>>> # Simulate conditions at different solar elevations
>>> zenith_angles = np.linspace(0, 85, 20)  # degrees
>>> cosz_values = np.cos(np.radians(zenith_angles))
>>> 
>>> result = SPARTA(
...     cosz=cosz_values,
...     pressure=1013.25,
...     pwater=np.linspace(1.0, 3.0, 20),  # varying water vapor
...     ozone=0.3,
...     beta=0.08,
...     alpha=1.4
... )
>>> print(result['ghi'].shape)  # (20,)

High aerosol loading scenario:

>>> result = SPARTA(
...     cosz=0.7,
...     beta=0.5,  # high turbidity
...     alpha=0.8,  # coarse particles
...     ssa=0.88,  # slightly absorbing
...     csi_param='sparta'
... )
>>> csi_fraction = result['csi'] / result['dni']
>>> print(f"CSI fraction: {csi_fraction:.2%}")

Disable circumsolar correction:

>>> result_no_csi = SPARTA(cosz=0.8, csi_param='none')
>>> print(result_no_csi['csi'])  # All zeros
Notes
  • Solar constant used: 1361.1 W/m²
  • Nighttime threshold: cosz ≤ cos(90.5°) ≈ 0.00872
  • All input arrays are automatically broadcast to compatible shapes
  • Invalid/missing values (-999, NaN) are handled gracefully
  • The 'interdependent' scheme is recommended for highest accuracy

The model has been validated against radiative transfer simulations and ground measurements, showing typical errors < 2% for GHI and DNI under most atmospheric conditions.

See Also

spartasolar.modlib.bird : Alternative Bird clear-sky model

References

.. [1] Ruiz-Arias, J. A. (2023). SPARTA: Solar parameterization for the radiative transfer of the cloudless atmosphere. Renewable and Sustainable Energy Reviews*, 188, 113833. https://doi.org/10.1016/j.rser.2023.113833

Source code in src/spartasolar/modlib/sparta.py
def SPARTA(
    cosz: float | np.ndarray = 0.5,
    pressure: float | np.ndarray = 1013.25,
    albedo: float | np.ndarray = 0.2,
    pwater: float | np.ndarray = 1.4,
    ozone: float | np.ndarray = 0.3,
    beta: float | np.ndarray = 0.1,
    alpha: float | np.ndarray = 1.3,
    ssa: float | np.ndarray = 0.92,
    asy: float | np.ndarray = 0.65,
    ecf: float | np.ndarray = 1.0,
    csi_param: str = 'sparta',
    csi_hfov: float = 2.5,
    transmittance_scheme: str = 'interdependent'
) -> dict[str, np.ndarray]:
    r"""Compute clear-sky solar irradiance using the SPARTA model.

    SPARTA (Solar PArameterization of the Radiative Transfer of the
    Atmosphere) is a high-accuracy 2-band broadband clear-sky model
    that computes direct, diffuse, global, and circumsolar irradiance.
    The model is fully vectorized and handles nighttime masking automatically.

    Parameters
    ----------
    cosz : float or np.ndarray, default 0.5
        Cosine of the solar zenith angle [0, 1]. Values ≤ cos(90.5°) ≈ 0.00872
        are treated as nighttime.
    pressure : float or np.ndarray, default 1013.25
        Atmospheric surface pressure in hPa (or mb). Typical range: 800-1100 hPa.
    albedo : float or np.ndarray, default 0.2
        Ground surface albedo [0, 1]. Typical values: 0.1-0.3 (vegetation),
        0.6-0.9 (snow), 0.05-0.15 (water).
    pwater : float or np.ndarray, default 1.4
        Precipitable water vapor in cm. Typical range: 0.5-6.0 cm.
    ozone : float or np.ndarray, default 0.3
        Total column ozone in atm-cm. Note: 1 atm-cm = 1000 DU (Dobson Units).
        Typical range: 0.2-0.5 atm-cm (200-500 DU).
    beta : float or np.ndarray, default 0.1
        Ångström turbidity coefficient (aerosol optical depth at 1000 nm).
        Automatically clipped to [0, 2.2]. Typical range: 0.02-0.5.
    alpha : float or np.ndarray, default 1.3
        Ångström wavelength exponent. Automatically clipped to [0, 2.5].
        Typical values: 0.5-1.0 (coarse aerosols), 1.5-2.0 (fine aerosols).
    ssa : float or np.ndarray, default 0.92
        Aerosol single-scattering albedo at ~700 nm [0, 1].
        Typical values: 0.85-0.95 (urban), 0.95-0.99 (desert dust).
    asy : float or np.ndarray, default 0.65
        Aerosol asymmetry parameter at ~700 nm [-1, 1].
        Typical range: 0.6-0.8.
    ecf : float or np.ndarray, default 1.0
        Eccentricity correction factor for Sun-Earth distance.
        Range: ~0.967 (early July) to ~1.034 (early January).
    csi_param : str, default 'sparta'
        Circumsolar irradiance parameterization method:
        - 'none': Neglects circumsolar component
        - 'sparta': Uses SPARTA native parameterization (recommended)
    csi_hfov : float, default 2.5
        Half field of view angle in degrees for circumsolar evaluation.
        Typical pyrheliometer values: 2.5° (standard), 2.9° (some instruments).
    transmittance_scheme : str, default 'interdependent'
        Method for computing atmospheric transmittances:
        - 'independent': Treats each constituent separately (faster, less accurate)
        - 'interdependent': Accounts for constituent interactions (more accurate)

    Returns
    -------
    dict[str, float or np.ndarray]
        Dictionary with the following irradiance components (all in W/m²):

        - 'dni': Direct Normal Irradiance (beam on plane perpendicular to sun)
        - 'dhi': Direct Horizontal Irradiance (beam on horizontal plane)
        - 'dif': Diffuse Horizontal Irradiance (scattered radiation)
        - 'ghi': Global Horizontal Irradiance (total on horizontal plane)
        - 'csi': Circumsolar Normal Irradiance (forward-scattered aureole)

    Examples
    --------
    Single location and time:

    >>> from spartasolar.modlib import SPARTA
    >>> result = SPARTA(
    ...     cosz=0.866,  # 30° solar zenith angle
    ...     pressure=1013.25,
    ...     pwater=2.0,
    ...     ozone=0.3,
    ...     beta=0.1,
    ...     alpha=1.3
    ... )
    >>> print(f"DNI: {result['dni']:.1f} W/m²")
    >>> print(f"GHI: {result['ghi']:.1f} W/m²")

    Vectorized computation for time series:

    >>> import numpy as np
    >>> # Simulate conditions at different solar elevations
    >>> zenith_angles = np.linspace(0, 85, 20)  # degrees
    >>> cosz_values = np.cos(np.radians(zenith_angles))
    >>> 
    >>> result = SPARTA(
    ...     cosz=cosz_values,
    ...     pressure=1013.25,
    ...     pwater=np.linspace(1.0, 3.0, 20),  # varying water vapor
    ...     ozone=0.3,
    ...     beta=0.08,
    ...     alpha=1.4
    ... )
    >>> print(result['ghi'].shape)  # (20,)

    High aerosol loading scenario:

    >>> result = SPARTA(
    ...     cosz=0.7,
    ...     beta=0.5,  # high turbidity
    ...     alpha=0.8,  # coarse particles
    ...     ssa=0.88,  # slightly absorbing
    ...     csi_param='sparta'
    ... )
    >>> csi_fraction = result['csi'] / result['dni']
    >>> print(f"CSI fraction: {csi_fraction:.2%}")

    Disable circumsolar correction:

    >>> result_no_csi = SPARTA(cosz=0.8, csi_param='none')
    >>> print(result_no_csi['csi'])  # All zeros

    Notes
    -----
    - Solar constant used: 1361.1 W/m²
    - Nighttime threshold: cosz ≤ cos(90.5°) ≈ 0.00872
    - All input arrays are automatically broadcast to compatible shapes
    - Invalid/missing values (-999, NaN) are handled gracefully
    - The 'interdependent' scheme is recommended for highest accuracy

    The model has been validated against radiative transfer simulations
    and ground measurements, showing typical errors < 2% for GHI and DNI
    under most atmospheric conditions.

    See Also
    --------
    spartasolar.modlib.bird : Alternative Bird clear-sky model

    References
    ----------
    .. [1] Ruiz-Arias, J. A. (2023). SPARTA: Solar parameterization for
           the radiative transfer of the cloudless atmosphere. Renewable
           and Sustainable Energy Reviews*, 188, 113833.
           https://doi.org/10.1016/j.rser.2023.113833
    """

    cosz, pressure, albedo, pwater, ozone, beta, alpha, ssa, asy, ecf, restore_shape = \
        cast_arrays(cosz, pressure, albedo, pwater, ozone, beta, alpha, ssa, asy, ecf)

    hfov = csi_hfov
    if np.isscalar(hfov):
        hfov = np.full(cosz.shape, hfov)

    INP_SHAPE = cosz.shape
    COSZ_MIN = np.cos(np.radians(90.5))
    SC = 1361.1  # W/m2, solar constant
    BF = (0.46472, 0.52113)  # band fractions

    nighttime = cosz <= COSZ_MIN

    def notna(ar):
        return (~np.isnan(ar)) & (ar != -999) & (~nighttime)

    domain = (
        notna(cosz) & notna(ecf) & notna(pressure) & notna(ozone) & notna(pwater) &
        notna(albedo) & notna(beta) & notna(alpha) & notna(ssa) & notna(asy)
    )

    # .. initialize outputs
    Ebn = np.full(INP_SHAPE, np.nan)  # direct normal irradiance, W/m2
    Ebh = np.full(INP_SHAPE, np.nan)  # direct horizontal irradiance, W/m2
    Edh = np.full(INP_SHAPE, np.nan)  # diffuse horizontal irradiance, W/m2
    Egh = np.full(INP_SHAPE, np.nan)  # global horizontal irradiance, W/m2
    Ecn = np.full(INP_SHAPE, np.nan)  # circumsolar normal irradiance, W/m2

    # .. airmasses
    amo = airmass(cosz[domain], "ozone")
    amr = airmass(cosz[domain], "rayleigh")
    amw = airmass(cosz[domain], "water")
    ama = airmass(cosz[domain], "aerosol")
    amp = np.full(ama.shape, 1.66)  # air mass for sky reflectance

    # DIRECT IRRADIANCE...

    To1, To2 = ozone_transmittance(amo, ozone[domain], transmittance_scheme)
    TR1, TR2 = rayleigh_transmittance(amr, pressure[domain], transmittance_scheme)
    Tg1, Tg2 = umgas_transmittance(amr, pressure[domain], transmittance_scheme)
    Tw1, Tw2 = water_transmittance(amw, pwater[domain], transmittance_scheme)
    Ta1, Ta2 = aerosol_transmittance(ama, beta[domain], alpha[domain], transmittance_scheme)

    # .. aerosol absorption band transmittances
    Taa1 = Ta1**(1.-ssa[domain])
    Taa2 = Ta2**(1.-ssa[domain])

    # .. aerosol scattering band transmittances
    Tas1 = Ta1**ssa[domain]
    Tas2 = Ta2**ssa[domain]

    # .. absorption band transmittances
    Tabs1 = To1*Tg1*Tw1*Taa1
    Tabs2 = To2*Tg2*Tw2*Taa2

    # .. scattering band transmittances
    Tscat1 = TR1*Tas1
    Tscat2 = TR2*Tas2

    # .. extinction band transmittances
    T1 = Tabs1*Tscat1
    T2 = Tabs2*Tscat2

    Tb = BF[0]*T1 + BF[1]*T2  # extinction broadband transmittance

    Ebn[domain] = np.clip(SC*ecf[domain]*Tb, 0., np.inf)
    Ebh = Ebn*cosz

    # DIFFUSE IRRADIANCE...

    FR = rayleigh_forward_scattering(amr, pressure[domain], transmittance_scheme)
    Fa = aerosol_forward_scattering(ama)
    rsky1, rsky2 = sky_reflectance(amp, ozone[domain], pressure[domain], pwater[domain],
                                   beta[domain], alpha[domain], ssa[domain], transmittance_scheme)

    # .. rayleigh scattering
    ray_scat1 = FR*Tabs1*(1.-TR1)*cosz[domain]
    ray_scat2 = FR*Tabs2*(1.-TR2)*cosz[domain]

    # .. aerosol scattering
    aer_scat1 = Fa*Tabs1*TR1*(1.-Tas1)*cosz[domain]
    aer_scat2 = Fa*Tabs2*TR2*(1.-Tas2)*cosz[domain]

    # .. ground-sky multiple scattering
    sky_scat1 = rsky1*albedo[domain]*(T1*cosz[domain] + ray_scat1 + aer_scat1) / (1.-rsky1*albedo[domain])
    sky_scat2 = rsky2*albedo[domain]*(T2*cosz[domain] + ray_scat2 + aer_scat2) / (1.-rsky2*albedo[domain])

    scat1 = ray_scat1 + aer_scat1 + sky_scat1  # scattering "transmittance" in band 1
    scat2 = ray_scat2 + aer_scat2 + sky_scat2  # scattering "transmittance" in band 2
    scatb = BF[0]*scat1 + BF[1]*scat2  # broadband scattering "transmittance"

    Edh[domain] = np.clip(SC*ecf[domain]*scatb, 0., np.inf)

    # GLOBAL IRRADIANCE...

    Egh = Ebh + Edh

    # CIRCUMSOLAR IRRADIANCE...

    csr = np.full(Ebn.shape, 0.)
    if csi_param == "sparta":
        Tab = BF[0]*Ta1 + BF[1]*Ta2
        Tab[(Ta1 >= 0.9999) & (Ta2 >= 0.9999)] = 1.
        csr[domain] = aerosol_circumsolar_ratio(alpha[domain], asy[domain], Tab, hfov[domain])
    Ecn = (csr / (1. - csr)) * Ebn

    # .. circumsolar correction
    Ebn = Ebn / (1. - csr)
    Ebh = Ebn*cosz
    Edh = Egh - Ebh

    # .. mask nighttime
    Ebn[nighttime] = 0.
    Ebh[nighttime] = 0.
    Edh[nighttime] = 0.
    Egh[nighttime] = 0.
    Ecn[nighttime] = 0.

    Ebn = restore_shape(Ebn)
    Ebh = restore_shape(Ebh)
    Edh = restore_shape(Edh)
    Egh = restore_shape(Egh)
    Ecn = restore_shape(Ecn)

    return {"dni": Ebn, "dhi": Ebh, "dif": Edh, "ghi": Egh, "csi": Ecn}

Bird & Hulstrom

BIRD

BIRD(
    cosz: float | ndarray = 0.5,
    pressure: float | ndarray = 1013.25,
    albedo: float | ndarray = 0.2,
    pwater: float | ndarray = 1.4,
    ozone: float | ndarray = 0.3,
    beta: float | ndarray = 0.1,
    alpha: float | ndarray = 1.3,
    ssa: float | ndarray = 0.92,
    asy: float | ndarray = 0.65,
    ecf: float | ndarray = 1.0,
) -> dict[str, ndarray]

Calculates solar irradiance using the Bird & Hulstrom model.

The model estimates solar radiation through individual transmittance processes for Rayleigh scattering, ozone absorption, uniformly mixed gases, water vapor, and aerosol extinction. It also accounts for multiple reflections between the ground and the sky.

Args: cosz: Cosine of the solar zenith angle. pressure: Atmospheric surface pressure in hPa. albedo: Ground surface albedo (0 to 1). pwater: Precipitable water in cm. ozone: Ozone vertical pathlength in atm-cm (1 atm-cm = 1000 DU). beta: Ångström's turbidity coefficient (AOD at 1000 nm). alpha: Ångström's wavelength exponent. ssa: Aerosol single-scattering albedo at ~700 nm. asy: Aerosol asymmetry parameter. ecf: Eccentricity correction factor for the Sun-Earth orbit.

Returns: dict[str, np.ndarray]: A dictionary containing: - dni: Direct normal irradiance [W/m²]. - dhi: Direct horizontal irradiance [W/m²]. - dif: Diffuse horizontal irradiance [W/m²]. - ghi: Global horizontal irradiance [W/m²].

Notes: - The model uses a fixed solar constant ((G_{sc})) of 1353 W/m². - Nighttime values are automatically masked (set to 0) for zenith angles greater than 90.5°. - The algorithm includes a 0.9662 correction factor for the direct normal component as per the original publication.

References: - Bird, R. E., & Hulstrom, R. L. (1981). A simplified clear sky model for direct and diffuse insolation on horizontal surfaces. Solar Energy Research Institute (SERI).

Source code in src/spartasolar/modlib/bird.py
def BIRD(
    cosz: float | np.ndarray = 0.5,
    pressure: float | np.ndarray = 1013.25,
    albedo: float | np.ndarray = 0.2,
    pwater: float | np.ndarray = 1.4,
    ozone: float | np.ndarray = 0.3,
    beta: float | np.ndarray = 0.1,
    alpha: float | np.ndarray = 1.3,
    ssa: float | np.ndarray = 0.92,
    asy: float | np.ndarray = 0.65,
    ecf: float | np.ndarray = 1.0
) -> dict[str, np.ndarray]:
    r"""Calculates solar irradiance using the Bird & Hulstrom model.

    The model estimates solar radiation through individual transmittance 
    processes for Rayleigh scattering, ozone absorption, uniformly mixed gases, 
    water vapor, and aerosol extinction. It also accounts for multiple 
    reflections between the ground and the sky.

    Args:
        cosz: Cosine of the solar zenith angle.
        pressure: Atmospheric surface pressure in hPa.
        albedo: Ground surface albedo (0 to 1).
        pwater: Precipitable water in cm.
        ozone: Ozone vertical pathlength in atm-cm (1 atm-cm = 1000 DU).
        beta: Ångström's turbidity coefficient (AOD at 1000 nm).
        alpha: Ångström's wavelength exponent.
        ssa: Aerosol single-scattering albedo at ~700 nm.
        asy: Aerosol asymmetry parameter.
        ecf: Eccentricity correction factor for the Sun-Earth orbit.

    Returns:
        dict[str, np.ndarray]: A dictionary containing:
            - `dni`: Direct normal irradiance [W/m²].
            - `dhi`: Direct horizontal irradiance [W/m²].
            - `dif`: Diffuse horizontal irradiance [W/m²].
            - `ghi`: Global horizontal irradiance [W/m²].

    Notes:
        - The model uses a fixed solar constant (\(G_{sc}\)) of 1353 W/m².
        - Nighttime values are automatically masked (set to 0) for zenith 
          angles greater than 90.5°.
        - The algorithm includes a 0.9662 correction factor for the 
          direct normal component as per the original publication.

    References:
        - Bird, R. E., & Hulstrom, R. L. (1981). A simplified clear sky 
          model for direct and diffuse insolation on horizontal surfaces. 
          Solar Energy Research Institute (SERI).
    """

    cosz, pressure, albedo, pwater, ozone, beta, alpha, ssa, asy, ecf, restore_shape = \
        cast_arrays(cosz, pressure, albedo, pwater, ozone, beta, alpha, ssa, asy, ecf)

    INP_SHAPE = cosz.shape
    COSZ_MIN = np.cos(np.radians(90.5))
    SC = 1353.  # W/m2, solar constant

    nighttime = cosz <= COSZ_MIN

    def notna(ar):
        return (~np.isnan(ar)) & (ar != -999) & (~nighttime)

    domain = (
        notna(cosz) & notna(ecf) & notna(pressure) &
        notna(ozone) & notna(pwater) & notna(albedo) &
        notna(beta) & notna(alpha) & notna(ssa) & notna(asy)
    )

    # .. initialize outputs
    Ebn = np.full(INP_SHAPE, np.nan)  # direct normal irradiance, W/m2
    Ebh = np.full(INP_SHAPE, np.nan)  # direct horizontal irradiance, W/m2
    Edh = np.full(INP_SHAPE, np.nan)  # diffuse horizontal irradiance, W/m2
    Egh = np.full(INP_SHAPE, np.nan)  # global horizontal irradiance, W/m2

    # .. airmass
    c = [0.48353, 0.095846,  96.741, 1.7540]
    sza = np.degrees(np.arccos(cosz))[domain]
    am = np.maximum(1., 1. / (cosz[domain] + c[0]*(sza**c[1])/((c[2]-sza)**c[3])))
    amr = am * (pressure[domain] / 1013.25)

    # DIRECT IRRADIANCE...

    TR = np.clip(np.exp(-0.0903*(amr**.84)*(1.+amr-amr**1.01)), 0., 1.)
    uo = am*ozone[domain]
    To = np.clip(
        1 - (0.1611*uo/((1.+139.48*uo)**0.3035) -
             0.002715*uo/((1.+(0.044*uo))+0.0003*uo**2)), 0., 1.)
    Tg = np.clip(np.exp(-0.0127*amr**0.26), 0., 1.)
    uw = am*pwater[domain]
    Tw = np.clip(1 - 2.4959*uw / (((1 + 79.034*uw)**0.6828) + 6.385*uw), 0., 1.)
    taua = beta[domain]*(0.2758*(0.38**(-alpha[domain])) + 0.35*(0.5**(-alpha[domain])))
    Ta = np.clip(np.exp(-(taua**0.873)*(1+taua-taua**0.7088)*am**0.9108), 0., 1.)

    Ebn[domain] = np.clip(0.9662*SC*ecf[domain]*TR*To*Tg*Tw*Ta, 0., np.inf)
    Ebh = Ebn*cosz

    # DIFFUSE IRRADIANCE...

    Taa = np.clip(1-(1-ssa[domain])*(1-am+am**1.06)*(1-Ta), 0., 1.)
    Tas = Ta/Taa
    Tabs = To*Tg*Taa*Tw
    Ba = 0.5*(1+asy[domain])
    rhos = 0.0685 + (1-Ba)*(1-Tas)
    Ed0h = SC*cosz[domain]*np.clip(0.79*Tabs*(0.5*(1-TR) + Ba*(1-Tas))/(1.-am+am**1.02), 0., 1.)

    Egh[domain] = (Ebh[domain] + Ed0h)/(1-albedo[domain]*rhos)
    Edh[domain] = Egh[domain] - Ebh[domain]

    # .. mask nighttime
    Ebn[nighttime] = 0.
    Ebh[nighttime] = 0.
    Edh[nighttime] = 0.
    Egh[nighttime] = 0.

    Ebn = restore_shape(Ebn)
    Ebh = restore_shape(Ebh)
    Edh = restore_shape(Edh)
    Egh = restore_shape(Egh)

    return {"dni": Ebn, "dhi": Ebh, "dif": Edh, "ghi": Egh}

Configuration

config

Configuration Management for SPARTA-Solar.

This module handles the persistent storage and retrieval of user settings using a TOML configuration file located in the standard user configuration directory.

The configuration system manages: - API credentials (e.g., SODA's user email) - Local storage paths for cached data - Algorithm preferences (e.g., solar position algorithm) - Service-specific settings

Configuration Flow: 1. On first import, checks if config file exists 2. If not found, creates it with default template 3. Loads configuration into memory (_GLOBAL_CONFIG) 4. Changes via set_option() are session-only 5. Persistent changes require manual editing of config.toml

Configuration File Location: - Linux: ~/.config/spartasolar/config.toml - macOS: ~/Library/Application Support/spartasolar/config.toml - Windows: C:\Users\\AppData\Local\spartasolar\config.toml

Examples: >>> from spartasolar.config import get_config_path, get_option, set_option

>>> # Get configuration file path
>>> config_path = get_config_path()
>>> print(config_path)
PosixPath('/home/user/.config/spartasolar/config.toml')

>>> # Retrieve an option
>>> email = get_option('crs_soda.user_email')

>>> # Set option for current session only
>>> set_option('sunwhere.algorithm', 'spa')

>>> # Get data directory (returns Path object)
>>> data_dir = get_option('merra2_daily.data_dir')

See Also: - get_config_path(): Get path to configuration file - get_option(): Retrieve configuration value - set_option(): Modify configuration (session only) - show_config(): Display all current settings

Functions:

  • get_config_path

    Get the path to the user's configuration file.

  • get_option

    Retrieve the value of a specific configuration option.

  • set_option

    Temporarily update a global option for the current session.

  • show_config

    Print all current global options to the console.

get_config_path

get_config_path() -> Path

Get the path to the user's configuration file.

Returns: Path: The absolute path to config.toml within the standard system-specific user configuration directory.

Source code in src/spartasolar/config.py
def get_config_path() -> Path:
    """Get the path to the user's configuration file.

    Returns:
        Path: The absolute path to `config.toml` within the standard 
            system-specific user configuration directory.
    """
    path = platformdirs.user_config_path(appname="sparta-solar", ensure_exists=True)
    return path / "config.toml"

get_option

get_option(name: str, default: Any = None) -> Any

Retrieve the value of a specific configuration option.

Options are organized in tables (sections) within the TOML file. This function uses dot notation to access nested values.

Args: name: The name of the option to retrieve using the format <table-name>.<option-name> (e.g., 'crs_soda.user_email'). default: Value to return if the option is not found. Defaults to None.

Returns: Any: The value of the option. Returns default if the option is missing. Special case: options named 'data_dir' are automatically converted to Path objects.

Examples: >>> from spartasolar.config import get_option

>>> # Get solar position algorithm
>>> algorithm = get_option('sunwhere.algorithm')
>>> print(algorithm)
'psa'

>>> # Get with default value
>>> email = get_option('crs_soda.user_email', default='user@example.com')

>>> # Data directories return Path objects
>>> from pathlib import Path
>>> data_dir = get_option('merra2_daily.data_dir')
>>> isinstance(data_dir, (Path, type(None)))
True
Source code in src/spartasolar/config.py
def get_option(name: str, default: Any = None) -> Any:
    """Retrieve the value of a specific configuration option.

    Options are organized in tables (sections) within the TOML file.
    This function uses dot notation to access nested values.

    Args:
        name: The name of the option to retrieve using the format
            `<table-name>.<option-name>` (e.g., 'crs_soda.user_email').
        default: Value to return if the option is not found. Defaults to None.

    Returns:
        Any: The value of the option. Returns `default` if the option 
            is missing. Special case: options named 'data_dir' are 
            automatically converted to `Path` objects.

    Examples:
        >>> from spartasolar.config import get_option

        >>> # Get solar position algorithm
        >>> algorithm = get_option('sunwhere.algorithm')
        >>> print(algorithm)
        'psa'

        >>> # Get with default value
        >>> email = get_option('crs_soda.user_email', default='user@example.com')

        >>> # Data directories return Path objects
        >>> from pathlib import Path
        >>> data_dir = get_option('merra2_daily.data_dir')
        >>> isinstance(data_dir, (Path, type(None)))
        True
    """
    table_name, option_name = name.split(".")
    if (table := _GLOBAL_CONFIG.get(table_name, None)) is None:
        logger.warning(f"missing table `{table_name}`")
        return default
    if (value := table.get(option_name, None)) is None:
        return default
    if option_name == "data_dir":
        return Path(value)
    return value

set_option

set_option(name: str, value: Any) -> None

Temporarily update a global option for the current session.

Modifies configuration values in memory only. Changes are lost when the Python session ends. To make persistent changes, edit the config.toml file directly.

Args: name: The name of the option to update in format <table>.<option>. value: The new value to assign. Path objects for 'data_dir' options are automatically converted to strings.

Returns: None

Warning: Session-only changes are NOT saved to the config file. Restart the Python session to revert to file values.

Examples: >>> from spartasolar.config import set_option, get_option

>>> # Change solar position algorithm
>>> set_option('sunwhere.algorithm', 'spa')
>>> get_option('sunwhere.algorithm')
'spa'

>>> # Set data directory with Path object
>>> from pathlib import Path
>>> set_option('merra2_daily.data_dir', Path('/custom/path'))

Note: To persist changes, manually edit the configuration file at the path returned by get_config_path().

Source code in src/spartasolar/config.py
def set_option(name: str, value: Any) -> None:
    """Temporarily update a global option for the current session.

    Modifies configuration values in memory only. Changes are lost when
    the Python session ends. To make persistent changes, edit the
    config.toml file directly.

    Args:
        name: The name of the option to update in format `<table>.<option>`.
        value: The new value to assign. Path objects for 'data_dir' options
            are automatically converted to strings.

    Returns:
        None

    Warning:
        Session-only changes are NOT saved to the config file. Restart
        the Python session to revert to file values.

    Examples:
        >>> from spartasolar.config import set_option, get_option

        >>> # Change solar position algorithm
        >>> set_option('sunwhere.algorithm', 'spa')
        >>> get_option('sunwhere.algorithm')
        'spa'

        >>> # Set data directory with Path object
        >>> from pathlib import Path
        >>> set_option('merra2_daily.data_dir', Path('/custom/path'))

    Note:
        To persist changes, manually edit the configuration file at the
        path returned by get_config_path().
    """

    table_name, option_name = name.split(".")
    if _GLOBAL_CONFIG.get(table_name, None) is None:
        logger.warning(f"missing table `{table_name}`")
        return None
    if option_name == "data_dir" and isinstance(value, Path):
        value = value.as_posix()
    _GLOBAL_CONFIG[table_name][option_name] = value

show_config

show_config() -> None

Print all current global options to the console.

Note: This function uses pprint for a formatted output of the global configuration state.

Source code in src/spartasolar/config.py
def show_config() -> None:
    """Print all current global options to the console.

    Note:
        This function uses `pprint` for a formatted output of the 
        global configuration state.
    """
    from pprint import pprint
    return pprint(_GLOBAL_CONFIG, indent=2, width=20)