Skip to content

PVGIS features an algorithmic repository, independent from the core API, the CLI and the Web API components.

algorithms

Modules:

Name Description
finkelstein_schafer
hargreaves
hofierka
huld
iqbal
jenco
martin_ruiz
milne1921
muneer
noaa
pelland
pvlib
skyfield
usno

finkelstein_schafer

Modules:

Name Description
cumulative_distribution
statistics
univariate_statistics

cumulative_distribution

Functions:

Name Description
calculate_ecdf

Calculate the empirical cumulative distribution function (CDF) of the data, ensuring unique coordinates.

calculate_long_term_monthly_ecdfs

Calculate the long-term CDF for each month.

calculate_yearly_monthly_ecdfs

Calculate monthly CDFs for each variable for each month and year.

calculate_ecdf

calculate_ecdf(sample)

Calculate the empirical cumulative distribution function (CDF) of the data, ensuring unique coordinates.

Source code in pvgisprototype/algorithms/finkelstein_schafer/cumulative_distribution.py
@log_function_call
def calculate_ecdf(sample):
    """Calculate the empirical cumulative distribution function (CDF) of the data, ensuring unique coordinates."""
    from scipy.stats import ecdf

    ecdfs = ecdf(sample)
    return DataArray(
        ecdfs.cdf.probabilities, coords=[ecdfs.cdf.quantiles], dims=["quantile"]
    )

calculate_long_term_monthly_ecdfs

calculate_long_term_monthly_ecdfs(dataset, variable)

Calculate the long-term CDF for each month.

Source code in pvgisprototype/algorithms/finkelstein_schafer/cumulative_distribution.py
@log_function_call
def calculate_long_term_monthly_ecdfs(
    dataset,
    variable,
):
    """Calculate the long-term CDF for each month."""
    return dataset[variable].groupby("time.month").map(lambda x: calculate_ecdf(x))

calculate_yearly_monthly_ecdfs

calculate_yearly_monthly_ecdfs(dataset, variable)

Calculate monthly CDFs for each variable for each month and year.

Source code in pvgisprototype/algorithms/finkelstein_schafer/cumulative_distribution.py
@log_function_call
def calculate_yearly_monthly_ecdfs(
    dataset,
    variable,
):
    """Calculate monthly CDFs for each variable for each month and year."""
    return (
        dataset[variable]
        .groupby("time.year")
        .map(lambda x: x.groupby("time.month").map(lambda y: calculate_ecdf(y)))
    )

statistics

Functions:

Name Description
align_and_broadcast

Expand dimensions and broadcast a data array to match the structure of a

calculate_finkelstein_schafer_statistics

Calculate Finkelstein-Schafer statistics for typical meteorological year

align_and_broadcast

align_and_broadcast(data_array, reference_array)

Expand dimensions and broadcast a data array to match the structure of a template array.

Parameters:

Name Type Description Default
data_array

The data array that needs to be aligned and broadcasted.

required
template_array

The array providing the structure to which data_array should be aligned.

required

Returns:

Type Description
xarray.DataArray: The broadcasted data array with the same structure as the
template_array.
Source code in pvgisprototype/algorithms/finkelstein_schafer/statistics.py
@log_function_call
def align_and_broadcast(data_array, reference_array):
    """Expand dimensions and broadcast a data array to match the structure of a
    template array.

    Parameters
    ----------
    data_array: xarray.DataArray
        The data array that needs to be aligned and broadcasted.
    template_array: xarray.DataArray
        The array providing the structure to which data_array should be aligned.

    Returns
    -------
    xarray.DataArray: The broadcasted data array with the same structure as the
    template_array.

    """
    return data_array.expand_dims(year=reference_array.year).broadcast_like(
        reference_array
    )

calculate_finkelstein_schafer_statistics

calculate_finkelstein_schafer_statistics(
    location_series_data_array: DataArray | Dataset,
) -> tuple[DataArray, Dataset, DataArray, DataArray]

Calculate Finkelstein-Schafer statistics for typical meteorological year selection.

The Finkelstein-Schafer (FS) statistic quantifies how well a candidate month represents the long-term climate by comparing its cumulative distribution function (CDF) to the multi-year average CDF. Lower FS values indicate months that are more representative of typical conditions.

This implementation follows the ISO 15927-4:2005 standard for determining typical meteorological data and the methodology described in Hall et al. (1978) [1]_.

Algorithm

The Finkelstein-Schafer statistic is computed through the following steps:

  1. Daily aggregation: Calculate daily mean values from sub-daily (e.g., hourly) time series data.

  2. Long-term CDF: For each calendar month m, compute the empirical cumulative distribution function :math:\phi(q, m) using daily values aggregated across all years in the dataset.

  3. Yearly CDFs: For each calendar month m and year y for a quantity q, compute the empirical cumulative distribution function :math:F(q, m, y) using only the daily values from that specific month and year.

  4. Finkelstein-Schafer statistic: For each month m and year y for a quantity q, calculate the Finkelstein-Schafer statistic by integrating the absolute difference between the yearly and long-term CDFs:

.. math::

   FS(q, m, y) = \sum_{i} |F(q_i, m, y) - \phi(q_i, m)|

where the summation is over all quantile values :math:q_i in the distribution.

  1. Ranking: For each calendar month m for the quantity q, candidate months from different years are ranked by increasing Finkelstein-Schafer statistic FS(q, m, y). Lower values indicate months that better represent typical conditions.

Parameters:

Name Type Description Default
location_series_data_array DataArray or Dataset

Time series meteorological data with a temporal dimension (typically hourly or sub-daily resolution). The data should span multiple years to enable meaningful long-term statistics. Acceptable formats include:

  • xarray.DataArray: Single variable time series
  • xarray.Dataset: Multi-variable time series

The time coordinate should be datetime-like and parseable by pandas.

required

Returns:

Name Type Description
finkelstein_schafer_statistic DataArray

The computed Finkelstein-Schafer statistic with dimensions (month, year), where:

  • month: Calendar month (1-12)
  • year: Individual years in the input dataset

Lower values indicate months that better represent long-term typical conditions.

daily_statistics Dataset

Daily aggregated statistics including mean, min, max, and std computed from the input time series. Contains dimensions (time,) where time represents daily timestamps.

yearly_monthly_ecdfs DataArray

Empirical cumulative distribution functions for each individual month-year combination. Dimensions: (month, year, quantile).

long_term_monthly_ecdfs DataArray

Long-term empirical cumulative distribution functions computed across all years for each calendar month. Dimensions: (month, quantile).

Notes

The Finkelstein-Schafer statistic is a non-parametric measure that:

  • Does not assume any particular distribution shape (e.g., normal, uniform) Accounts for the entire distribution, not just central tendency (mean/median)
  • Is robust to outliers and extreme values
  • Enables objective selection of typical months based on distributional similarity

Typical use case: This function is the core computational step in generating Typical Meteorological Year (TMY) datasets. After computing FS statistics for multiple meteorological variables (temperature, irradiance, humidity, wind speed), a weighted sum across variables is used to select the most representative month for each calendar month position in the TMY.

Data requirements:

  • Minimum 5-10 years of data recommended for stable statistics
  • Complete or near-complete temporal coverage (missing data impacts CDF accuracy)
  • Sub-daily resolution (hourly preferred) to capture diurnal patterns
References

.. [1] Hall, I. J., Prairie, R. R., Anderson, H. E., & Boes, E. C. (1978). "Generation of a Typical Meteorological Year." Proceedings of the 1978 Annual Meeting of the American Section of the International Solar Energy Society (AS/ISES), Denver, CO.

.. [2] ISO 15927-4:2005. "Hygrothermal performance of buildings - Calculation and presentation of climatic data - Part 4: Hourly data for assessing the annual energy use for heating and cooling."

.. [3] Wilcox, S., & Marion, W. (2008). "Users Manual for TMY3 Data Sets." Technical Report NREL/TP-581-43156, National Renewable Energy Laboratory.

Examples:

>>> import xarray as xr
>>> import numpy as np
>>> import pandas as pd
>>> from pvgisprototype.algorithms.finkelstein_schafer.statistics import (
...     calculate_finkelstein_schafer_statistics
... )
>>> 
>>> # Create sample hourly temperature data for 3 years
>>> time = pd.date_range('2015-01-01', '2017-12-31 23:00', freq='H')
>>> temperature = 15 + 10 * np.sin(2 * np.pi * np.arange(len(time)) / (365.25 * 24))
>>> temperature += np.random.normal(0, 2, len(time))  # Add noise
>>> 
>>> data = xr.DataArray(
...     temperature,
...     coords={'time': time},
...     dims=['time'],
...     name='temperature'
... )
>>> 
>>> # Calculate Finkelstein-Schafer statistics
>>> fs_statistics, daily_statistics, yearly_ecdfs, long_term_ecdfs = (
...     calculate_finkelstein_schafer_statistics(data)
... )
>>> 
>>> # Find the most representative January across all years
>>> january_fs = fs_stat.sel(month=1)
>>> best_january_year = january_fs.idxmin(dim='year').values
>>> print(f"Most typical January: {best_january_year}")
>>> print(f"FS statistic: {january_fs.min().values:.3f}")
See Also
  • calculate_daily_univariate_statistics() : Compute daily statistics from sub-daily data
  • calculate_yearly_monthly_ecdfs() : Compute yearly-monthly empirical CDFs
  • calculate_long_term_monthly_ecdfs() : Compute long-term monthly empirical CDFs
  • align_and_broadcast() : Align arrays for element-wise operations
Source code in pvgisprototype/algorithms/finkelstein_schafer/statistics.py
@log_function_call
def calculate_finkelstein_schafer_statistics(
    location_series_data_array: DataArray | Dataset,
) -> tuple[DataArray, Dataset, DataArray, DataArray]:
    """
    Calculate Finkelstein-Schafer statistics for typical meteorological year
    selection.

    The Finkelstein-Schafer (FS) statistic quantifies how well a candidate
    month represents the long-term climate by comparing its cumulative
    distribution function (CDF) to the multi-year average CDF. Lower FS values
    indicate months that are more representative of typical conditions.

    This implementation follows the ISO 15927-4:2005 standard for determining 
    typical meteorological data and the methodology described in Hall et al.
    (1978) [1]_.

    Algorithm
    ---------
    The Finkelstein-Schafer statistic is computed through the following steps:

    1. **Daily aggregation**: Calculate daily mean values from sub-daily 
       (e.g., hourly) time series data.

    2. **Long-term CDF**: For each calendar month *m*, compute the empirical 
       cumulative distribution function :math:`\\phi(q, m)` using daily values 
       aggregated across all years in the dataset.

    3. **Yearly CDFs**: For each calendar month *m* and year *y* for a quantity
       *q*, compute the empirical cumulative distribution function
       :math:`F(q, m, y)` using only the daily values from that specific month
       and year.

    4. **Finkelstein-Schafer statistic**: For each month *m* and year *y* for a
       quantity *q*, calculate the Finkelstein-Schafer statistic by integrating
       the absolute difference between the yearly and long-term CDFs:

       .. math::

           FS(q, m, y) = \\sum_{i} |F(q_i, m, y) - \\phi(q_i, m)|

       where the summation is over all quantile values :math:`q_i` in the
       distribution.

    5. **Ranking**: For each calendar month *m* for the quantity *q*, candidate
       months from different years are ranked by increasing Finkelstein-Schafer
       statistic FS(q, m, y). Lower values indicate months that better
       represent typical conditions.

    Parameters
    ----------
    location_series_data_array : DataArray or Dataset
        Time series meteorological data with a temporal dimension (typically
        hourly or sub-daily resolution). The data should span multiple years to
        enable meaningful long-term statistics. Acceptable formats include:

        - ``xarray.DataArray``: Single variable time series
        - ``xarray.Dataset``: Multi-variable time series

        The time coordinate should be datetime-like and parseable by pandas.

    Returns
    -------
    finkelstein_schafer_statistic : xarray.DataArray
        The computed Finkelstein-Schafer statistic with dimensions ``(month,
        year)``, where:

        - ``month``: Calendar month (1-12)
        - ``year``: Individual years in the input dataset

        Lower values indicate months that better represent long-term typical
        conditions.

    daily_statistics : xarray.Dataset
        Daily aggregated statistics including mean, min, max, and std computed
        from the input time series. Contains dimensions ``(time,)`` where time
        represents daily timestamps.

    yearly_monthly_ecdfs : xarray.DataArray
        Empirical cumulative distribution functions for each individual
        month-year combination. Dimensions: ``(month, year, quantile)``.

    long_term_monthly_ecdfs : xarray.DataArray
        Long-term empirical cumulative distribution functions computed across
        all years for each calendar month. Dimensions: ``(month, quantile)``.

    Notes
    -----
    The Finkelstein-Schafer statistic is a non-parametric measure that:

    - Does **not** assume any particular distribution shape (e.g., normal,
      uniform) Accounts for the **entire distribution**, not just central
      tendency (mean/median)
    - Is robust to outliers and extreme values
    - Enables objective selection of typical months based on distributional
      similarity

    **Typical use case**: This function is the core computational step in
    generating Typical Meteorological Year (TMY) datasets. After computing FS
    statistics for multiple meteorological variables (temperature, irradiance,
    humidity, wind speed), a weighted sum across variables is used to select
    the most representative month for each calendar month position in the TMY.

    **Data requirements**:

    - Minimum 5-10 years of data recommended for stable statistics
    - Complete or near-complete temporal coverage (missing data impacts CDF
      accuracy)
    - Sub-daily resolution (hourly preferred) to capture diurnal patterns

    References
    ----------
    .. [1] Hall, I. J., Prairie, R. R., Anderson, H. E., & Boes, E. C. (1978). 
           "Generation of a Typical Meteorological Year." Proceedings of the
           1978 Annual Meeting of the American Section of the International
           Solar Energy Society (AS/ISES), Denver, CO.

    .. [2] ISO 15927-4:2005. "Hygrothermal performance of buildings -
           Calculation and presentation of climatic data - Part 4: Hourly data
           for assessing the annual energy use for heating and cooling."

    .. [3] Wilcox, S., & Marion, W. (2008). "Users Manual for TMY3 Data Sets." 
           Technical Report NREL/TP-581-43156, National Renewable Energy
           Laboratory.

    Examples
    --------
    >>> import xarray as xr
    >>> import numpy as np
    >>> import pandas as pd
    >>> from pvgisprototype.algorithms.finkelstein_schafer.statistics import (
    ...     calculate_finkelstein_schafer_statistics
    ... )
    >>> 
    >>> # Create sample hourly temperature data for 3 years
    >>> time = pd.date_range('2015-01-01', '2017-12-31 23:00', freq='H')
    >>> temperature = 15 + 10 * np.sin(2 * np.pi * np.arange(len(time)) / (365.25 * 24))
    >>> temperature += np.random.normal(0, 2, len(time))  # Add noise
    >>> 
    >>> data = xr.DataArray(
    ...     temperature,
    ...     coords={'time': time},
    ...     dims=['time'],
    ...     name='temperature'
    ... )
    >>> 
    >>> # Calculate Finkelstein-Schafer statistics
    >>> fs_statistics, daily_statistics, yearly_ecdfs, long_term_ecdfs = (
    ...     calculate_finkelstein_schafer_statistics(data)
    ... )
    >>> 
    >>> # Find the most representative January across all years
    >>> january_fs = fs_stat.sel(month=1)
    >>> best_january_year = january_fs.idxmin(dim='year').values
    >>> print(f"Most typical January: {best_january_year}")  # doctest: +SKIP
    >>> print(f"FS statistic: {january_fs.min().values:.3f}")  # doctest: +SKIP

    See Also
    --------
    - calculate_daily_univariate_statistics() : Compute daily statistics from sub-daily data
    - calculate_yearly_monthly_ecdfs() : Compute yearly-monthly empirical CDFs
    - calculate_long_term_monthly_ecdfs() : Compute long-term monthly empirical CDFs
    - align_and_broadcast() : Align arrays for element-wise operations
    """
    # 1. Calculate daily means from hourly values
    daily_statistics = calculate_daily_univariate_statistics(
        data_array=location_series_data_array,
    )

    # 2. Calculate yearly-monthly ECDFs: F(q, m, y)
    yearly_monthly_ecdfs = calculate_yearly_monthly_ecdfs(
        dataset=daily_statistics,
        variable="mean",
    )

    # 3. Calculate long-term monthly ECDFs: φ(q, m)
    long_term_monthly_ecdfs = calculate_long_term_monthly_ecdfs(
        dataset=daily_statistics,
        variable="mean",
    )

    # 4. Align long-term ECDFs to yearly-monthly structure for subtraction
    aligned_long_term_monthly_ecdfs = align_and_broadcast(
        long_term_monthly_ecdfs, yearly_monthly_ecdfs
    )

    # 5. Calculate FS statistic: ∑|F(q,m,y) - φ(q,m)|
    finkelstein_schafer_statistic = abs(
        yearly_monthly_ecdfs - aligned_long_term_monthly_ecdfs
    ).sum(dim="quantile")

    return (
        finkelstein_schafer_statistic,
        daily_statistics,
        yearly_monthly_ecdfs,
        long_term_monthly_ecdfs,
    )

univariate_statistics

Functions:

Name Description
calculate_daily_univariate_statistics

Calculate daily maximum, minimum, and mean for each variable in the dataset using pandas.

calculate_daily_univariate_statistics

calculate_daily_univariate_statistics(
    data_array: DataArray,
) -> Dataset

Calculate daily maximum, minimum, and mean for each variable in the dataset using pandas. Preserves latitude (lat) and longitude (lon) coordinates of the original data.

Source code in pvgisprototype/algorithms/finkelstein_schafer/univariate_statistics.py
@log_function_call
def calculate_daily_univariate_statistics(
    data_array: DataArray,
) -> Dataset:
    """
    Calculate daily maximum, minimum, and mean for each variable in the dataset using pandas.
    Preserves latitude (lat) and longitude (lon) coordinates of the original data.
    """
    # Convert xarray DataArray to pandas DataFrame and resample to daily frequency and compute statistics
    daily_statistics = (
        data_array.to_dataframe(name="value").resample("1D").agg(["max", "min", "mean"])
    )

    # Extract lat/lon from the original data_array
    lat = data_array.coords["lat"].values if "lat" in data_array.coords else None
    lon = data_array.coords["lon"].values if "lon" in data_array.coords else None

    # Convert pandas DataFrame back to xarray Dataset
    result = Dataset(
        {
            "max": (["time"], daily_statistics["value"]["max"].values),
            "min": (["time"], daily_statistics["value"]["min"].values),
            "mean": (["time"], daily_statistics["value"]["mean"].values),
        },
        coords={
            "lon": lon,
            "lat": lat,
            "time": daily_statistics.index,
        },
    )

    return result

hargreaves

Modules:

Name Description
solar_declination

solar_declination

Functions:

Name Description
calculate_solar_declination_series_hargreaves

Approximate the solar declination for a series of timestamps

calculate_solar_declination_series_hargreaves

calculate_solar_declination_series_hargreaves(
    timestamps: DatetimeIndex,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarDeclination

Approximate the solar declination for a series of timestamps using the Hargreaves formula, with days-in-year from each timestamp's year.

              ⎛360   ⎛                    ⎛360            ⎞⎞⎞

δ = 23.45° ⋅ sin ⎜─── ⋅ ⎜284 + n + 0.4 ⋅ sin ⎜─── ⋅ (n - 100)⎟⎟⎟ ⎝365 ⎝ ⎝365 ⎠⎠⎠

Notes
  • 365.25: The number 365.25 represents the average number of days in a year. This value is used to scale the orbital position of the Earth.

  • 284: The number 284 represents a constant term added to the day of the year. It adjusts the calculation to align with the Earth's position during the winter solstice, which usually occurs around December 21st (approximately 284 days into the year).

  • 0.4: The number 0.4 is a constant that determines the amplitude of the seasonal variation. It is multiplied by the second sine term to modulate the seasonal change in the solar declination.

  • 100: The number 100 represents an offset to the day of the year. It is subtracted from the original day of the year before calculating the second sine term. This offset helps adjust the timing of the seasonal variation and is usually chosen to align with the summer solstice, which typically occurs around June 21st.

Source code in pvgisprototype/algorithms/hargreaves/solar_declination.py
@log_function_call
@custom_cached
def calculate_solar_declination_series_hargreaves(
    timestamps: DatetimeIndex,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarDeclination:
    """
    Approximate the solar declination for a series of timestamps 
    using the Hargreaves formula, with days-in-year from each timestamp's year.

                      ⎛360   ⎛                    ⎛360            ⎞⎞⎞
     δ = 23.45° ⋅ sin ⎜─── ⋅ ⎜284 + n + 0.4 ⋅ sin ⎜─── ⋅ (n - 100)⎟⎟⎟
                      ⎝365   ⎝                    ⎝365            ⎠⎠⎠

     Notes
     -----

     - 365.25: The number 365.25 represents the average number of days in a
     year. This value is used to scale the orbital position of the Earth.

     - 284: The number 284 represents a constant term added to the day of the
     year. It adjusts the calculation to align with the Earth's position
     during the winter solstice, which usually occurs around December 21st
     (approximately 284 days into the year).

     - 0.4: The number 0.4 is a constant that determines the amplitude of the
     seasonal variation. It is multiplied by the second sine term to
     modulate the seasonal change in the solar declination.

     - 100: The number 100 represents an offset to the day of the year. It is
     subtracted from the original day of the year before calculating the
     second sine term. This offset helps adjust the timing of the seasonal
     variation and is usually chosen to align with the summer solstice,
     which typically occurs around June 21st.
    """
    if not isinstance(timestamps, DatetimeIndex):
        raise TypeError("timestamps must be a pandas.DatetimeIndex")

    days_in_years = get_days_in_years(timestamps.year)
    days_of_year = timestamps.dayofyear

    inner_angle = numpy.array(360.0 / days_in_years * (days_of_year - 100.0))
    solar_declination_series = numpy.sin(
        numpy.radians(
            360.0
            / days_in_years
            * (284.0 + days_of_year + 0.4 * numpy.sin(numpy.radians(inner_angle)))
        )
    ).to_numpy().astype(dtype)

    out_of_range, out_of_range_index = identify_values_out_of_range_x(
        series=solar_declination_series,
        shape=timestamps.shape,
        data_model=SolarDeclination(unit=RADIANS),
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_declination_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return SolarDeclination(
        value=solar_declination_series,
        unit=RADIANS,
        out_of_range=out_of_range,
        out_of_range_index=out_of_range_index,
        algorithm=SolarDeclinationModel.hargreaves,
    )

hofierka

Modules:

Name Description
irradiance
position
power
read
spectrum

irradiance

Modules:

Name Description
diffuse
direct
extraterrestrial
shortwave

diffuse

Modules:

Name Description
clear_sky
ground_reflected
horizontal
clear_sky

Modules:

Name Description
altitude

Diffuse Solar Altitude Function

ground_reflected
horizontal
transmission_function
altitude

Diffuse Solar Altitude Function

This module implements the diffuse solar altitude function and associated coefficients from Hofierka's clear-sky radiation model (2002). These functions characterize how diffuse irradiance varies with solar elevation under cloudless atmospheric conditions.

Theoretical Background

Under cloudless skies, atmospheric turbidity affects the partitioning of solar radiation into direct and diffuse components. As turbidity increases, diffuse irradiance increases while direct irradiance decreases.

See Also

pvgisprototype.algorithms.hofierka.irradiance.diffuse.clear_sky.transmission_function Implements Tₙ(T_LK) diffuse transmission function pvgisprototype.algorithms.hofierka.irradiance.diffuse.clear_sky.horizontal Combines transmission and altitude functions for total diffuse irradiance

Functions:

Name Description
calculate_diffuse_solar_altitude_coefficients_series_hofierka

Calculate the diffuse solar altitude coefficients.

calculate_diffuse_solar_altitude_function_series_hofierka

Calculate the diffuse solar altitude function.

calculate_diffuse_solar_altitude_coefficients_series_hofierka
calculate_diffuse_solar_altitude_coefficients_series_hofierka(
    linke_turbidity_factor_series: LinkeTurbidityFactor,
    verbose: int = 0,
    log: int = 0,
) -> tuple

Calculate the diffuse solar altitude coefficients.

Calculates the diffuse solar altitude coefficients (a1, a2, a3) over a time series required for calculating the diffuse solar altitude function in Hofierka's model.

These coefficients are derived from the Linke Turbidity factor and the diffuse transmission function.

Parameters:

Name Type Description Default
linke_turbidity_factor_series LinkeTurbidityFactor

The Linke turbidity factors as a list of LinkeTurbidityFactor objects or a single object.

required
verbose int

Verbosity level for debugging output. When set to DEBUG_AFTER_THIS_VERBOSITY_LEVEL, prints local variables. Default is 0.

0
log int

Logging level for data fingerprinting. When exceeding HASH_AFTER_THIS_VERBOSITY_LEVEL, logs data hashes for traceability. Default is 0.

0

Returns:

Type Description
tuple of numpy.ndarray

Three arrays containing the coefficient series:

  • a1_series : First coefficient, bounded by diffuse transmission
  • a2_series : Second coefficient
  • a3_series : Third coefficient
Notes

The coefficients are calculated using empirical formulas from Hofierka's model :

  • a1 is constrained to ensure a1 * Td >= 0.0022 where Td is diffuse transmission
  • a2 and a3 are derived from quadratic functions of the Linke turbidity factor
See Also

calculate_diffuse_transmission_function_series_hofierka : Computes diffuse transmission calculate_diffuse_solar_altitude_function_series_hofierka : Uses these coefficients

References

.. [1] Hofierka, J., & Šúri, M. (2002). The solar radiation model for Open source GIS: implementation and applications. Proceedings of the Open source GIS - GRASS users conference, Trento, Italy.

Examples:

>>> from pvgisprototype import LinkeTurbidityFactor
>>> from pvgisprototype.algorithms.hofierka.irradiance.diffuse.clear_sky.altitude import calculate_diffuse_solar_altitude_function_series_hofierka
>>> from pvgisprototype.algorithms.hofierka.irradiance.diffuse.clear_sky.transmission_function import calculate_diffuse_transmission_function_series_hofierka
>>> ltf = LinkeTurbidityFactor(value=np.array([2.0, 2.5, 3.0]))
>>> a1, a2, a3 = calculate_diffuse_solar_altitude_coefficients_series_hofierka(ltf)
>>> print(a1.shape)
(3,)
Source code in pvgisprototype/algorithms/hofierka/irradiance/diffuse/clear_sky/altitude.py
@log_function_call
@custom_cached
def calculate_diffuse_solar_altitude_coefficients_series_hofierka(
    # linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    linke_turbidity_factor_series: LinkeTurbidityFactor,
    verbose: int = 0,
    log: int = 0,
) -> tuple:
    """Calculate the diffuse solar altitude coefficients.

    Calculates the diffuse solar altitude coefficients (a1, a2, a3) over a time
    series required for calculating the diffuse solar altitude function in
    Hofierka's model.

    These coefficients are derived from the Linke Turbidity factor and the
    diffuse transmission function.

    Parameters
    ----------
    linke_turbidity_factor_series: (List[LinkeTurbidityFactor] or LinkeTurbidityFactor)
        The Linke turbidity factors as a list of LinkeTurbidityFactor objects
        or a single object.
    verbose : int, optional
        Verbosity level for debugging output. When set to 
        `DEBUG_AFTER_THIS_VERBOSITY_LEVEL`, prints local variables.
        Default is 0.
    log : int, optional
        Logging level for data fingerprinting. When exceeding 
        `HASH_AFTER_THIS_VERBOSITY_LEVEL`, logs data hashes for traceability.
        Default is 0.

    Returns
    -------
    tuple of numpy.ndarray
        Three arrays containing the coefficient series:

        - a1_series : First coefficient, bounded by diffuse transmission
        - a2_series : Second coefficient  
        - a3_series : Third coefficient

    Notes
    -----
    The coefficients are calculated using empirical formulas from Hofierka's
    model :

    - a1 is constrained to ensure `a1 * Td >= 0.0022` where Td is diffuse
      transmission
    - a2 and a3 are derived from quadratic functions of the Linke turbidity
      factor

    See Also
    --------
    calculate_diffuse_transmission_function_series_hofierka : Computes diffuse
    transmission
    calculate_diffuse_solar_altitude_function_series_hofierka : Uses these
    coefficients

    References
    ----------
    .. [1] Hofierka, J., & Šúri, M. (2002). The solar radiation model for Open
           source GIS: implementation and applications. *Proceedings of the
           Open source GIS - GRASS users conference*, Trento, Italy.

    Examples
    --------
    >>> from pvgisprototype import LinkeTurbidityFactor
    >>> from pvgisprototype.algorithms.hofierka.irradiance.diffuse.clear_sky.altitude import calculate_diffuse_solar_altitude_function_series_hofierka
    >>> from pvgisprototype.algorithms.hofierka.irradiance.diffuse.clear_sky.transmission_function import calculate_diffuse_transmission_function_series_hofierka
    >>> ltf = LinkeTurbidityFactor(value=np.array([2.0, 2.5, 3.0]))
    >>> a1, a2, a3 = calculate_diffuse_solar_altitude_coefficients_series_hofierka(ltf)
    >>> print(a1.shape)
    (3,)
    """
    linke_turbidity_factor_series_squared_array = np.power(
        linke_turbidity_factor_series.value, 2
    )
    diffuse_transmission_series = calculate_diffuse_transmission_function_series_hofierka(
        linke_turbidity_factor_series
    )
    diffuse_transmission_series_array = np.array(diffuse_transmission_series)
    a1_prime_series = (
        0.26463
        - 0.061581 * linke_turbidity_factor_series.value
        + 0.0031408 * linke_turbidity_factor_series_squared_array
    )
    a1_series = np.where(
        a1_prime_series * diffuse_transmission_series < 0.0022,
        # 0.0022 / diffuse_transmission_series_array,
        np.maximum(0.0022 / diffuse_transmission_series_array, a1_prime_series),
        a1_prime_series,
    )
    a2_series = (
        2.04020
        + 0.018945 * linke_turbidity_factor_series.value
        - 0.011161 * linke_turbidity_factor_series_squared_array
    )
    a3_series = (
        -1.3025
        + 0.039231 * linke_turbidity_factor_series.value
        + 0.0085079 * linke_turbidity_factor_series_squared_array
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=(a1_series, a2_series, a3_series),
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return a1_series, a2_series, a3_series
calculate_diffuse_solar_altitude_function_series_hofierka
calculate_diffuse_solar_altitude_function_series_hofierka(
    solar_altitude_series: SolarAltitude,
    linke_turbidity_factor_series: LinkeTurbidityFactor,
    verbose: int = 0,
    log: int = 0,
) -> ndarray

Calculate the diffuse solar altitude function.

Calculates the diffuse solar altitude function (Fd) for a time series using Hofierka's empirical model. This function combines solar altitude angles with turbidity-dependent coefficients to estimate diffuse irradiance patterns.

Parameters:

Name Type Description Default
solar_altitude_series SolarAltitude

Solar altitude angles for the time series. Must contain radians attribute with angle values in radians.

required
linke_turbidity_factor_series LinkeTurbidityFactor

Linke turbidity factor values corresponding to the solar altitude time points.

required
verbose int

Verbosity level for debugging output. When set to DEBUG_AFTER_THIS_VERBOSITY_LEVEL, prints local variables. Default is 0.

0
log int

Logging level for data fingerprinting. When exceeding HASH_AFTER_THIS_VERBOSITY_LEVEL, logs data hashes for traceability. Default is 0.

0

Returns:

Type Description
ndarray

Diffuse solar altitude function values (Fd) for each time point in the series. Array shape matches input series dimensions.

Notes

The diffuse solar altitude function Fₐ(Solar Altitude) characterizes the angular distribution of diffuse radiation :

.. math:: F_d(Solar Altitude) = a_1 + a_2 \sin(Solar Altitude) + a_3 \sin^2(Solar Altitude)

where:

  • :math:a_1, a_2, a_3 are coefficients derived from Linke turbidity, the a₁ coefficient is bounded to ensure physical validity when multiplied by the transmission function.
See Also

calculate_diffuse_solar_altitude_coefficients_series_hofierka : Computes the a1, a2, a3 coefficients SolarAltitude : Solar altitude data model LinkeTurbidityFactor : Linke turbidity factor data model

References

.. [1] Hofierka, J., & Šúri, M. (2002). The solar radiation model for Open source GIS: implementation and applications. Proceedings of the Open source GIS - GRASS users conference, Trento, Italy.

Examples:

>>> from pvgisprototype import SolarAltitude, LinkeTurbidityFactor
>>> import numpy as np
>>> from pvgisprototype.algorithms.hofierka.irradiance.diffuse.clear_sky.altitude import (
>>> calculate_diffuse_solar_altitude_coefficients_series_hofierka,
>>> calculate_diffuse_solar_altitude_function_series_hofierka,
>>> )
>>> from pvgisprototype.algorithms.hofierka.irradiance.diffuse.clear_sky.transmission_function import calculate_diffuse_transmission_function_series_hofierka
>>> altitude = SolarAltitude(value=np.array([0.5, 0.8, 1.0]), unit='radians')
>>> ltf = LinkeTurbidityFactor(value=np.array([2.0, 2.5, 3.0]))
>>> fd = calculate_diffuse_solar_altitude_function_series_hofierka(altitude, ltf)
>>> print(fd.shape)
(3,)
Source code in pvgisprototype/algorithms/hofierka/irradiance/diffuse/clear_sky/altitude.py
@log_function_call
@custom_cached
def calculate_diffuse_solar_altitude_function_series_hofierka(
    solar_altitude_series: SolarAltitude,
    # linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    linke_turbidity_factor_series: LinkeTurbidityFactor,
    verbose: int = 0,
    log: int = 0,
) -> np.ndarray:
    """Calculate the diffuse solar altitude function.

    Calculates the diffuse solar altitude function (Fd) for a time series using
    Hofierka's empirical model. This function combines solar altitude angles
    with turbidity-dependent coefficients to estimate diffuse irradiance
    patterns.

    Parameters
    ----------
    solar_altitude_series : SolarAltitude
        Solar altitude angles for the time series. Must contain `radians` attribute
        with angle values in radians.
    linke_turbidity_factor_series : LinkeTurbidityFactor
        Linke turbidity factor values corresponding to the solar altitude time points.
    verbose : int, optional
        Verbosity level for debugging output. When set to 
        `DEBUG_AFTER_THIS_VERBOSITY_LEVEL`, prints local variables.
        Default is 0.
    log : int, optional
        Logging level for data fingerprinting. When exceeding 
        `HASH_AFTER_THIS_VERBOSITY_LEVEL`, logs data hashes for traceability.
        Default is 0.

    Returns
    -------
    numpy.ndarray
        Diffuse solar altitude function values (Fd) for each time point in the series.
        Array shape matches input series dimensions.

    Notes
    -----
    The diffuse solar altitude function Fₐ(Solar Altitude) characterizes the
    angular distribution of diffuse radiation :

    .. math::
        F_d(Solar Altitude) = a_1 + a_2 \sin(Solar Altitude) + a_3 \sin^2(Solar Altitude)

    where:

    - :math:`a_1, a_2, a_3` are coefficients derived from Linke turbidity, the
      a₁ coefficient is bounded to ensure physical validity when multiplied by
      the transmission function.

    See Also
    --------
    calculate_diffuse_solar_altitude_coefficients_series_hofierka : Computes
    the a1, a2, a3 coefficients
    SolarAltitude : Solar altitude data model
    LinkeTurbidityFactor : Linke turbidity factor data model

    References
    ----------
    .. [1] Hofierka, J., & Šúri, M. (2002). The solar radiation model for Open
           source GIS: implementation and applications. *Proceedings of the
           Open source GIS - GRASS users conference*, Trento, Italy.

    Examples
    --------
    >>> from pvgisprototype import SolarAltitude, LinkeTurbidityFactor
    >>> import numpy as np
    >>> from pvgisprototype.algorithms.hofierka.irradiance.diffuse.clear_sky.altitude import (
    >>> calculate_diffuse_solar_altitude_coefficients_series_hofierka,
    >>> calculate_diffuse_solar_altitude_function_series_hofierka,
    >>> )
    >>> from pvgisprototype.algorithms.hofierka.irradiance.diffuse.clear_sky.transmission_function import calculate_diffuse_transmission_function_series_hofierka
    >>> altitude = SolarAltitude(value=np.array([0.5, 0.8, 1.0]), unit='radians')
    >>> ltf = LinkeTurbidityFactor(value=np.array([2.0, 2.5, 3.0]))
    >>> fd = calculate_diffuse_solar_altitude_function_series_hofierka(altitude, ltf)
    >>> print(fd.shape)
    (3,)
    """
    a1_series, a2_series, a3_series = (
        calculate_diffuse_solar_altitude_coefficients_series_hofierka(
            linke_turbidity_factor_series,
            verbose=verbose,
        )
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=(a1_series, a2_series, a3_series),
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return (
        a1_series
        + a2_series * np.sin(solar_altitude_series.radians)
        + a3_series * np.power(np.sin(solar_altitude_series.radians), 2)
    )
ground_reflected

Functions:

Name Description
calculate_clear_sky_ground_reflected_inclined_irradiance_series_pvgis

Calculate the clear-sky ground-reflected diffuse irradiance on an inclined

calculate_clear_sky_ground_reflected_inclined_irradiance_series_pvgis
calculate_clear_sky_ground_reflected_inclined_irradiance_series_pvgis(
    longitude: float,
    latitude: float,
    elevation: float,
    timestamps: DatetimeIndex = DatetimeIndex(
        [now(tz="UTC")]
    ),
    timezone: ZoneInfo = ZoneInfo("UTC"),
    surface_orientation: float = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: float = SURFACE_TILT_DEFAULT,
    surface_tilt_threshold=SURFACE_TILT_HORIZONTALLY_FLAT_PANEL_THRESHOLD,
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    adjust_for_atmospheric_refraction: bool = ATMOSPHERIC_REFRACTION_FLAG_DEFAULT,
    albedo: float | None = ALBEDO_DEFAULT,
    solar_position_model: SolarPositionModel = noaa,
    solar_time_model: SolarTimeModel = noaa,
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> ClearSkyDiffuseGroundReflectedInclinedIrradiance

Calculate the clear-sky ground-reflected diffuse irradiance on an inclined surface.

Calculates the component of diffuse irradiance that reaches an inclined surface after reflection from the ground under clear-sky conditions. This component is proportional to the global horizontal irradiance [Ghc], the solar surface tilt angle, the mean ground albedo [ρg] and a fraction of the ground viewed by an inclined surface [rg(γN)]. The calculation follows the isotropic sky model where ground-reflected radiation is uniformly distributed.

The ground-reflected irradiance is calculated as:

.. math:: I_{gr} = I_g \cdot F_{gv} \cdot \rho

where:

  • :math:I_{gr} is the ground-reflected irradiance on the inclined surface
  • :math:I_g is the global horizontal irradiance
  • :math:F_{gv} is the ground view fraction: :math:(1 - \cos(\beta)) / 2
  • :math:\beta is the surface tilt angle
  • :math:\rho is the ground albedo

For near-horizontal surfaces (tilt ≤ threshold), ground reflection is negligible and set to zero.

Parameters:

Name Type Description Default
longitude float

Geographic longitude in decimal degrees. East is positive, west is negative. Valid range: [-180, 180].

required
latitude float

Geographic latitude in decimal degrees. North is positive, south is negative. Valid range: [-90, 90].

required
elevation float

Site elevation above sea level in meters. Used for atmospheric corrections in irradiance calculations.

required
timestamps DatetimeIndex

Time series index for calculations. Must be timezone-aware for accurate solar position computations.

DatetimeIndex([now(tz='UTC')])
timezone str

IANA timezone identifier (e.g., 'UTC', 'Europe/Rome'). Used for local time conversions in solar position algorithms.

ZoneInfo('UTC')
surface_orientation float

Surface azimuth angle in degrees. 0° = North, 90° = East, 180° = South, 270° = West. Determines horizontal orientation of the inclined surface.

SURFACE_ORIENTATION_DEFAULT
surface_tilt float

Surface tilt angle from horizontal in degrees. 0° = horizontal, 90° = vertical. Valid range: [0, 90].

SURFACE_TILT_DEFAULT
surface_tilt_threshold float

Minimum tilt angle in degrees below which ground reflection is considered negligible and set to zero. Default is typically a small value (e.g., 1°).

SURFACE_TILT_HORIZONTALLY_FLAT_PANEL_THRESHOLD
albedo float

Ground surface albedo (reflectivity), dimensionless. Typical values: 0.2 (grass, soil), 0.3-0.5 (sand, concrete), 0.8 (fresh snow). Valid range: [0, 1]. Default varies by application.

ALBEDO_DEFAULT
linke_turbidity_factor_series LinkeTurbidityFactor

Linke turbidity factor values characterizing atmospheric turbidity. Typical range: [1.0, 6.0]. Default is LinkeTurbidityFactor().

LinkeTurbidityFactor()
adjust_for_atmospheric_refraction bool

Whether to apply atmospheric refraction correction to solar angles. Affects accuracy near horizon. Default is True.

ATMOSPHERIC_REFRACTION_FLAG_DEFAULT
solar_position_model str

Algorithm for solar position calculations (e.g., 'NOAA', 'PSA'). Default is SOLAR_POSITION_MODEL_DEFAULT.

noaa
solar_time_model str

Solar time calculation method. Default is SOLAR_TIME_MODEL_DEFAULT.

noaa
solar_constant float

Solar constant in W·m⁻². Default is SOLAR_CONSTANT (~1361 W·m⁻²).

SOLAR_CONSTANT
eccentricity_phase_offset float

Phase offset for Earth's orbital eccentricity correction in radians. Default is ECCENTRICITY_PHASE_OFFSET.

ECCENTRICITY_PHASE_OFFSET
eccentricity_amplitude float

Amplitude of orbital eccentricity correction. Default is ECCENTRICITY_CORRECTION_FACTOR.

ECCENTRICITY_CORRECTION_FACTOR
dtype str

NumPy data type for array operations. Default is DATA_TYPE_DEFAULT.

DATA_TYPE_DEFAULT
array_backend str

Array backend ('numpy', 'cupy', 'dask'). Default is ARRAY_BACKEND_DEFAULT.

ARRAY_BACKEND_DEFAULT
verbose int

Verbosity level for debugging output. When exceeding DEBUG_AFTER_THIS_VERBOSITY_LEVEL, prints all local variables using devtools.debug. Default is VERBOSE_LEVEL_DEFAULT.

VERBOSE_LEVEL_DEFAULT
log int

Logging level for data fingerprinting. When exceeding HASH_AFTER_THIS_VERBOSITY_LEVEL, logs data hashes for reproducibility tracking. Default is LOG_LEVEL_DEFAULT.

LOG_LEVEL_DEFAULT

Returns:

Type Description
ClearSkyDiffuseGroundReflectedInclinedIrradiance

Data model containing:

  • value : numpy.ndarray Ground-reflected irradiance values in W·m⁻² for each timestamp
  • out_of_range : bool Flag indicating if any values exceed physical bounds
  • out_of_range_index : numpy.ndarray Indices of out-of-range values
  • ground_view_fraction : float Fraction of ground visible from tilted surface (0 to 0.5)
  • albedo : float Ground albedo used in calculation
  • global_horizontal_irradiance : numpy.ndarray Global horizontal irradiance values used
  • direct_horizontal_irradiance : array or None Direct component of horizontal irradiance
  • diffuse_horizontal_irradiance : array or None Diffuse component of horizontal irradiance
  • solar_positioning_algorithm : str Solar position algorithm used
  • solar_timing_algorithm : str Solar time algorithm used
  • location : tuple (longitude, latitude) coordinates
  • elevation : float Site elevation in meters
  • surface_orientation : float Surface azimuth in degrees
  • surface_tilt : float Surface tilt in degrees
  • surface_tilt_threshold : float Tilt threshold used
Notes

Ground View Fraction:

The ground view fraction represents the solid angle subtended by the ground as seen from the tilted surface:

  • Horizontal surface (0°): F_gv = 0 (no ground visible)
  • Vertical surface (90°): F_gv = 0.5 (half hemisphere is ground)
  • Tilted surface: F_gv = (1 - cos(β)) / 2

Tilt Threshold Behavior:

When surface_tilt <= surface_tilt_threshold, the function:

  1. Sets ground view fraction to 0
  2. Creates zero-valued arrays for ground reflection
  3. Avoids unnecessary computation for negligible contributions

This optimization is physically justified as near-horizontal surfaces receive minimal ground-reflected radiation.

Global Horizontal Irradiance

The function internally computes the global horizontal irradiance as:

.. math:: I_g = I_{bh} + I_{dh}

where I_bh is direct horizontal and I_dh is diffuse horizontal irradiance, both calculated using clear-sky models.

Physical Constraints

  • Ground-reflected irradiance cannot exceed global horizontal irradiance
  • Albedo values outside [0, 1] produce unphysical results
  • Results valid only for clear-sky conditions
  • Model assumes isotropic ground reflection (Lambertian surface)
See Also

calculate_clear_sky_direct_horizontal_irradiance_series : Computes direct component calculate_clear_sky_diffuse_horizontal_irradiance : Computes diffuse component ClearSkyDiffuseGroundReflectedInclinedIrradiance : Output data model

References

.. [1] Duffie, J. A., & Beckman, W. A. (2013). Solar Engineering of Thermal Processes (4th ed.). Wiley. Chapter 2: Available Solar Radiation. .. [2] Perez, R., et al. (1987). A new simplified version of the Perez diffuse irradiance model for tilted surfaces. Solar Energy, 39(3), 221-231.

Examples:

Calculate ground reflection for a south-facing tilted surface:

>>> import pandas as pd
>>> import numpy as np
>>> from pvgisprototype import Longitude, Latitude
>>> from pvgisprototype.algorithms.hofierka.irradiance.diffuse.clear_sky.ground_reflected import (
...     calculate_clear_sky_ground_reflected_inclined_irradiance_series_pvgis
... )
>>> 
>>> # Summer day, hourly data
>>> timestamps = pd.date_range('2024-06-21', periods=24, freq='h', tz='UTC')
>>> 
>>> # Location: Rome, Italy
>>> longitude = Longitude(value=12.496, unit='degrees')
>>> latitude = Latitude(value=41.903, unit='degrees')
>>> # Solar surface position
>>> surface_orientation = SurfaceOrientation(value=180, unit='degrees')
>>> surface_tilt = SurfaceTilt(value=180, unit='degrees')
>>> # Calculate irradiance
>>> result = calculate_clear_sky_ground_reflected_inclined_irradiance_series_pvgis(
...     longitude=longitude.radians,
...     latitude=latitude.radians,
...     elevation=20,
...     timestamps=timestamps,
...     timezone='Europe/Rome',
...     surface_orientation=surface_orientation.radians,  # South-facing
...     surface_tilt=surface_tilt.radians,
...     albedo=0.2                # Grass/soil
... )
>>> 
>>> print(f"Ground view fraction: {result.ground_view_fraction:.3f}")
Ground view fraction: 1.000
>>> print(f"Peak ground-reflected irradiance: {result.value.max():.1f} W/m²")
Peak ground-reflected irradiance: 241.5 W/m²
>>> print(f"Daily total: {result.value.sum():.1f} Wh/m²")
Daily total: 2221.9 Wh/m²

Compare albedo effects

>>> # Low albedo (dark soil)
>>> result_dark = calculate_clear_sky_ground_reflected_inclined_irradiance_series_pvgis(
...     longitude=longitude.radians,
...     latitude=latitude.radians,
...     elevation=20,
...     timestamps=timestamps,
...     timezone='Europe/Rome',
...     surface_orientation=surface_orientation.radians,
...     surface_tilt=surface_tilt.radians,
...     albedo=0.15
... )
>>> 
>>> # High albedo (concrete)
>>> result_bright = calculate_clear_sky_ground_reflected_inclined_irradiance_series_pvgis(
...     longitude=longitude.radians,
...     latitude=latitude.radians,
...     elevation=20,
...     timestamps=timestamps,
...     timezone='Europe/Rome',
...     surface_orientation=surface_orientation.radians,
...     surface_tilt=surface_tilt.radians,
...     albedo=0.4
... )
>>> 
>>> albedo_ratio = result_bright.value.max() / result_dark.value.max()
>>> print(f"Bright/dark albedo ratio: {albedo_ratio:.2f}")
Bright/dark albedo ratio: 2.67

Near-horizontal surface (negligible ground reflection)

>>> result_flat = calculate_clear_sky_ground_reflected_inclined_irradiance_series_pvgis(
...     longitude=longitude.radians,
...     latitude=latitude.radians,
...     elevation=20,
...     timestamps=timestamps,
...     timezone='Europe/Rome',
...     surface_orientation=surface_orientation.radians,  # South-facing
...     surface_tilt=0.1,  # Nearly horizontal
...     surface_tilt_threshold=1.0,
...     albedo=0.2                # Grass/soil
... )
>>> 
>>> print(f"Ground view fraction: {result_flat.ground_view_fraction}")
0.0  # Threshold triggered, no ground reflection
>>> print(f"All values zero: {np.all(result_flat.value == 0)}")
True
Warnings
  • Tilt angle must be in degrees, not radians
  • Albedo values should be realistic for the surface type
  • Model assumes isotropic reflection (not accurate for specular surfaces)
  • Clear-sky model; not valid under cloudy conditions
  • Input timestamps must be timezone-aware
Source code in pvgisprototype/algorithms/hofierka/irradiance/diffuse/clear_sky/ground_reflected.py
@log_function_call
@custom_cached
def calculate_clear_sky_ground_reflected_inclined_irradiance_series_pvgis(
    longitude: float,
    latitude: float,
    elevation: float,
    timestamps: DatetimeIndex = DatetimeIndex([Timestamp.now(tz='UTC')]),
    timezone: ZoneInfo = ZoneInfo('UTC'),
    surface_orientation: float = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: float = SURFACE_TILT_DEFAULT,
    surface_tilt_threshold = SURFACE_TILT_HORIZONTALLY_FLAT_PANEL_THRESHOLD,
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    adjust_for_atmospheric_refraction: bool = ATMOSPHERIC_REFRACTION_FLAG_DEFAULT,
    # unrefracted_solar_zenith: UnrefractedSolarZenith | None = UNREFRACTED_SOLAR_ZENITH_ANGLE_DEFAULT,  # radians
    albedo: float | None = ALBEDO_DEFAULT,
    solar_position_model: SolarPositionModel = SolarPositionModel.noaa,
    solar_time_model: SolarTimeModel = SolarTimeModel.noaa,
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> ClearSkyDiffuseGroundReflectedInclinedIrradiance:
    """
    Calculate the clear-sky ground-reflected diffuse irradiance on an inclined
    surface.

    Calculates the component of diffuse irradiance that reaches an inclined
    surface after reflection from the ground under clear-sky conditions. This
    component is proportional to the global horizontal irradiance [Ghc], the
    solar surface tilt angle, the mean ground albedo [ρg] and a fraction of the
    ground viewed by an inclined surface [rg(γN)]. The calculation follows the
    isotropic sky model where ground-reflected radiation is uniformly
    distributed.

    The ground-reflected irradiance is calculated as:

    .. math::
        I_{gr} = I_g \cdot F_{gv} \cdot \\rho

    where:

    - :math:`I_{gr}` is the ground-reflected irradiance on the inclined surface
    - :math:`I_g` is the global horizontal irradiance
    - :math:`F_{gv}` is the ground view fraction: :math:`(1 - \cos(\\beta)) / 2`
    - :math:`\\beta` is the surface tilt angle
    - :math:`\\rho` is the ground albedo

    For near-horizontal surfaces (tilt ≤ threshold), ground reflection is
    negligible and set to zero.

    Parameters
    ----------
    longitude : float
        Geographic longitude in decimal degrees. East is positive, west is
        negative. Valid range: [-180, 180].
    latitude : float
        Geographic latitude in decimal degrees. North is positive, south is
        negative. Valid range: [-90, 90].
    elevation : float
        Site elevation above sea level in meters. Used for atmospheric
        corrections in irradiance calculations.
    timestamps : pandas.DatetimeIndex
        Time series index for calculations. Must be timezone-aware for
        accurate solar position computations.
    timezone : str
        IANA timezone identifier (e.g., 'UTC', 'Europe/Rome'). Used for
        local time conversions in solar position algorithms.
    surface_orientation : float
        Surface azimuth angle in degrees. 0° = North, 90° = East, 180° = South,
        270° = West. Determines horizontal orientation of the inclined surface.
    surface_tilt : float
        Surface tilt angle from horizontal in degrees. 0° = horizontal,
        90° = vertical. Valid range: [0, 90].
    surface_tilt_threshold : float, optional
        Minimum tilt angle in degrees below which ground reflection is
        considered negligible and set to zero. Default is typically a small
        value (e.g., 1°).
    albedo : float, optional
        Ground surface albedo (reflectivity), dimensionless. Typical values:
        0.2 (grass, soil), 0.3-0.5 (sand, concrete), 0.8 (fresh snow).
        Valid range: [0, 1]. Default varies by application.
    linke_turbidity_factor_series : LinkeTurbidityFactor, optional
        Linke turbidity factor values characterizing atmospheric turbidity.
        Typical range: [1.0, 6.0]. Default is
        ``LinkeTurbidityFactor()``.
    adjust_for_atmospheric_refraction : bool, optional
        Whether to apply atmospheric refraction correction to solar angles.
        Affects accuracy near horizon. Default is True.
    solar_position_model : str, optional
        Algorithm for solar position calculations (e.g., 'NOAA', 'PSA').
        Default is ``SOLAR_POSITION_MODEL_DEFAULT``.
    solar_time_model : str, optional
        Solar time calculation method. Default is ``SOLAR_TIME_MODEL_DEFAULT``.
    solar_constant : float, optional
        Solar constant in W·m⁻². Default is ``SOLAR_CONSTANT`` (~1361 W·m⁻²).
    eccentricity_phase_offset : float, optional
        Phase offset for Earth's orbital eccentricity correction in radians.
        Default is ``ECCENTRICITY_PHASE_OFFSET``.
    eccentricity_amplitude : float, optional
        Amplitude of orbital eccentricity correction. Default is
        ``ECCENTRICITY_CORRECTION_FACTOR``.
    dtype : str, optional
        NumPy data type for array operations. Default is ``DATA_TYPE_DEFAULT``.
    array_backend : str, optional
        Array backend ('numpy', 'cupy', 'dask'). Default is
        ``ARRAY_BACKEND_DEFAULT``.
    verbose : int, optional
        Verbosity level for debugging output. When exceeding
        ``DEBUG_AFTER_THIS_VERBOSITY_LEVEL``, prints all local variables
        using devtools.debug. Default is ``VERBOSE_LEVEL_DEFAULT``.
    log : int, optional
        Logging level for data fingerprinting. When exceeding
        ``HASH_AFTER_THIS_VERBOSITY_LEVEL``, logs data hashes for
        reproducibility tracking. Default is ``LOG_LEVEL_DEFAULT``.

    Returns
    -------
    ClearSkyDiffuseGroundReflectedInclinedIrradiance
        Data model containing:

        - **value** : numpy.ndarray
            Ground-reflected irradiance values in W·m⁻² for each timestamp
        - **out_of_range** : bool
            Flag indicating if any values exceed physical bounds
        - **out_of_range_index** : numpy.ndarray
            Indices of out-of-range values
        - **ground_view_fraction** : float
            Fraction of ground visible from tilted surface (0 to 0.5)
        - **albedo** : float
            Ground albedo used in calculation
        - **global_horizontal_irradiance** : numpy.ndarray
            Global horizontal irradiance values used
        - **direct_horizontal_irradiance** : array or None
            Direct component of horizontal irradiance
        - **diffuse_horizontal_irradiance** : array or None
            Diffuse component of horizontal irradiance
        - **solar_positioning_algorithm** : str
            Solar position algorithm used
        - **solar_timing_algorithm** : str
            Solar time algorithm used
        - **location** : tuple
            (longitude, latitude) coordinates
        - **elevation** : float
            Site elevation in meters
        - **surface_orientation** : float
            Surface azimuth in degrees
        - **surface_tilt** : float
            Surface tilt in degrees
        - **surface_tilt_threshold** : float
            Tilt threshold used

    Notes
    -----
    **Ground View Fraction:**

    The ground view fraction represents the solid angle subtended by the ground
    as seen from the tilted surface:

    - Horizontal surface (0°): F_gv = 0 (no ground visible)
    - Vertical surface (90°): F_gv = 0.5 (half hemisphere is ground)
    - Tilted surface: F_gv = (1 - cos(β)) / 2

    **Tilt Threshold Behavior:**

    When ``surface_tilt <= surface_tilt_threshold``, the function:

    1. Sets ground view fraction to 0
    2. Creates zero-valued arrays for ground reflection
    3. Avoids unnecessary computation for negligible contributions

    This optimization is physically justified as near-horizontal surfaces
    receive minimal ground-reflected radiation.

    **Global Horizontal Irradiance**

    The function internally computes the global horizontal irradiance as:

    .. math::
        I_g = I_{bh} + I_{dh}

    where I_bh is direct horizontal and I_dh is diffuse horizontal irradiance,
    both calculated using clear-sky models.

    **Physical Constraints**

    - Ground-reflected irradiance cannot exceed global horizontal irradiance
    - Albedo values outside [0, 1] produce unphysical results
    - Results valid only for clear-sky conditions
    - Model assumes isotropic ground reflection (Lambertian surface)

    See Also
    --------
    calculate_clear_sky_direct_horizontal_irradiance_series : Computes direct component
    calculate_clear_sky_diffuse_horizontal_irradiance : Computes diffuse component
    ClearSkyDiffuseGroundReflectedInclinedIrradiance : Output data model

    References
    ----------
    .. [1] Duffie, J. A., & Beckman, W. A. (2013). *Solar Engineering of Thermal
           Processes* (4th ed.). Wiley. Chapter 2: Available Solar Radiation.
    .. [2] Perez, R., et al. (1987). A new simplified version of the Perez diffuse
           irradiance model for tilted surfaces. *Solar Energy*, 39(3), 221-231.

    Examples
    --------
    Calculate ground reflection for a south-facing tilted surface:

    >>> import pandas as pd
    >>> import numpy as np
    >>> from pvgisprototype import Longitude, Latitude
    >>> from pvgisprototype.algorithms.hofierka.irradiance.diffuse.clear_sky.ground_reflected import (
    ...     calculate_clear_sky_ground_reflected_inclined_irradiance_series_pvgis
    ... )
    >>> 
    >>> # Summer day, hourly data
    >>> timestamps = pd.date_range('2024-06-21', periods=24, freq='h', tz='UTC')
    >>> 
    >>> # Location: Rome, Italy
    >>> longitude = Longitude(value=12.496, unit='degrees')
    >>> latitude = Latitude(value=41.903, unit='degrees')
    >>> # Solar surface position
    >>> surface_orientation = SurfaceOrientation(value=180, unit='degrees')
    >>> surface_tilt = SurfaceTilt(value=180, unit='degrees')
    >>> # Calculate irradiance
    >>> result = calculate_clear_sky_ground_reflected_inclined_irradiance_series_pvgis(
    ...     longitude=longitude.radians,
    ...     latitude=latitude.radians,
    ...     elevation=20,
    ...     timestamps=timestamps,
    ...     timezone='Europe/Rome',
    ...     surface_orientation=surface_orientation.radians,  # South-facing
    ...     surface_tilt=surface_tilt.radians,
    ...     albedo=0.2                # Grass/soil
    ... )
    >>> 
    >>> print(f"Ground view fraction: {result.ground_view_fraction:.3f}")
    Ground view fraction: 1.000
    >>> print(f"Peak ground-reflected irradiance: {result.value.max():.1f} W/m²")
    Peak ground-reflected irradiance: 241.5 W/m²
    >>> print(f"Daily total: {result.value.sum():.1f} Wh/m²")
    Daily total: 2221.9 Wh/m²

    Compare albedo effects

    >>> # Low albedo (dark soil)
    >>> result_dark = calculate_clear_sky_ground_reflected_inclined_irradiance_series_pvgis(
    ...     longitude=longitude.radians,
    ...     latitude=latitude.radians,
    ...     elevation=20,
    ...     timestamps=timestamps,
    ...     timezone='Europe/Rome',
    ...     surface_orientation=surface_orientation.radians,
    ...     surface_tilt=surface_tilt.radians,
    ...     albedo=0.15
    ... )
    >>> 
    >>> # High albedo (concrete)
    >>> result_bright = calculate_clear_sky_ground_reflected_inclined_irradiance_series_pvgis(
    ...     longitude=longitude.radians,
    ...     latitude=latitude.radians,
    ...     elevation=20,
    ...     timestamps=timestamps,
    ...     timezone='Europe/Rome',
    ...     surface_orientation=surface_orientation.radians,
    ...     surface_tilt=surface_tilt.radians,
    ...     albedo=0.4
    ... )
    >>> 
    >>> albedo_ratio = result_bright.value.max() / result_dark.value.max()
    >>> print(f"Bright/dark albedo ratio: {albedo_ratio:.2f}")
    Bright/dark albedo ratio: 2.67

    Near-horizontal surface (negligible ground reflection)

    >>> result_flat = calculate_clear_sky_ground_reflected_inclined_irradiance_series_pvgis(
    ...     longitude=longitude.radians,
    ...     latitude=latitude.radians,
    ...     elevation=20,
    ...     timestamps=timestamps,
    ...     timezone='Europe/Rome',
    ...     surface_orientation=surface_orientation.radians,  # South-facing
    ...     surface_tilt=0.1,  # Nearly horizontal
    ...     surface_tilt_threshold=1.0,
    ...     albedo=0.2                # Grass/soil
    ... )
    >>> 
    >>> print(f"Ground view fraction: {result_flat.ground_view_fraction}")
    0.0  # Threshold triggered, no ground reflection
    >>> print(f"All values zero: {np.all(result_flat.value == 0)}")
    True

    Warnings
    --------
    - Tilt angle must be in degrees, not radians
    - Albedo values should be realistic for the surface type
    - Model assumes isotropic reflection (not accurate for specular surfaces)
    - Clear-sky model; not valid under cloudy conditions
    - Input timestamps must be timezone-aware
    """
    # build reusable parameter dictionaries
    coordinates = {
        'longitude': longitude,
        'latitude': latitude,
    }
    time = {
        'timestamps': timestamps,
        'timezone': timezone,
    }
    solar_positioning = {
        'solar_position_model': solar_position_model,
        'adjust_for_atmospheric_refraction': adjust_for_atmospheric_refraction,
        'solar_time_model': solar_time_model,
    }
    earth_orbit = {
        'eccentricity_phase_offset': eccentricity_phase_offset,
        'eccentricity_amplitude': eccentricity_amplitude,
    }
    array_parameters = {
        "dtype": dtype,
        "array_backend": array_backend,
    }
    output_parameters = {
        'verbose': verbose,  # Is this wanted here ? i.e. not setting = 0 ?
        'log': log,
    }

    if surface_tilt <= surface_tilt_threshold:  # No ground reflection for a flat or nearly flat surface
        ground_view_fraction = 0
        # in order to avoid 'NameError's
        flat_surface_array_parameters = {
            "shape": timestamps.shape,
            "dtype": dtype,
            "init_method": "zeros",
            "backend": array_backend,
        }  # Borrow shape from timestamps
        global_horizontal_irradiance_series = create_array(**flat_surface_array_parameters)

    else:
        ground_view_fraction = (1 - cos(surface_tilt)) / 2

    direct_horizontal_irradiance_series = calculate_clear_sky_direct_horizontal_irradiance_series(
        **coordinates,
        elevation=elevation,
        **time,
        linke_turbidity_factor_series=linke_turbidity_factor_series,
        solar_time_model=solar_time_model,
        solar_constant=solar_constant,
        **earth_orbit,
        **array_parameters,
        **output_parameters,
    )
    diffuse_horizontal_irradiance_series = calculate_clear_sky_diffuse_horizontal_irradiance(
        **coordinates,
        **time,
        linke_turbidity_factor_series=linke_turbidity_factor_series,
        # unrefracted_solar_zenith=unrefracted_solar_zenith,
        **solar_positioning,
        solar_constant=solar_constant,
        **earth_orbit,
        **array_parameters,
        **output_parameters,
    )
    global_horizontal_irradiance_series = (
        direct_horizontal_irradiance_series.value
        + diffuse_horizontal_irradiance_series.value
    )
    # --------------------------------------------------------------------
    # At this point, the `global_horizontal_irradiance_series` is either :
    # _read_ from external time series  Or  simulated 
    # --------------------------------------------------------------------

    ground_reflected_inclined_irradiance_series = asarray(
        global_horizontal_irradiance_series * ground_view_fraction * albedo,
        dtype=dtype
    ).reshape(-1)

    out_of_range, out_of_range_index = identify_values_out_of_range(
        series=ground_reflected_inclined_irradiance_series,
        shape=timestamps.shape,
        data_model=ClearSkyDiffuseGroundReflectedInclinedIrradiance(),
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=ground_reflected_inclined_irradiance_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    # Common output structure
    function_output = {
        "value": ground_reflected_inclined_irradiance_series,
        "out_of_range": out_of_range,
        "out_of_range_index": out_of_range_index,
        "ground_view_fraction": ground_view_fraction,
        "albedo": albedo,
        "global_horizontal_irradiance": global_horizontal_irradiance_series,
        "direct_horizontal_irradiance": (
            direct_horizontal_irradiance_series
            if "direct_horizontal_irradiance_series" in locals()
            else None
        ),
        "diffuse_horizontal_irradiance": (
            diffuse_horizontal_irradiance_series
            if "diffuse_horizontal_irradiance_series" in locals()
            else None
        ),
        "solar_positioning_algorithm": getattr(
            diffuse_horizontal_irradiance_series,
            "solar_positioning_algorithm",
            None,
        ),
        "solar_timing_algorithm": getattr(
            diffuse_horizontal_irradiance_series, "solar_timing_algorithm", None
        ),
        "location": (longitude, latitude),
        "elevation": elevation,
        "surface_orientation": surface_orientation,
        "surface_tilt": surface_tilt,
        "surface_tilt_threshold": surface_tilt_threshold,
    }
    return ClearSkyDiffuseGroundReflectedInclinedIrradiance(**function_output)
horizontal

Functions:

Name Description
calculate_clear_sky_diffuse_horizontal_irradiance_hofierka

Calculate clear-sky diffuse sky-reflected horizontal irradiance using

calculate_clear_sky_diffuse_horizontal_irradiance_hofierka
calculate_clear_sky_diffuse_horizontal_irradiance_hofierka(
    timestamps: DatetimeIndex,
    linke_turbidity_factor_series: LinkeTurbidityFactor,
    solar_altitude_series: SolarAltitude | None = None,
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> DiffuseSkyReflectedHorizontalIrradiance

Calculate clear-sky diffuse sky-reflected horizontal irradiance using Hofierka's model.

Calculates the diffuse sky-reflected component of solar irradiance on a horizontal surface under cloudless sky conditions using Hofierka's empirical clear-sky model. This implementation follows the theoretical framework where diffuse irradiance is the product of extraterrestrial irradiance, a diffuse transmission function, and a solar altitude function.

The model estimates diffuse horizontal irradiance (Dₕc) as:

.. math:: D_{hc} = G_0 \cdot T_n(T_{LK}) \cdot F_d(h_0)

where G₀ is the extraterrestrial normal irradiance, Tₙ is the diffuse transmission function dependent on Linke turbidity, and Fₐ is the solar altitude function.

Parameters:

Name Type Description Default
timestamps DatetimeIndex

Time series index for which to calculate diffuse irradiance. Must be timezone-aware for accurate solar position calculations.

required
linke_turbidity_factor_series LinkeTurbidityFactor

Linke turbidity factor values for each timestamp, characterizing atmospheric turbidity. Typical values range from 1.0 (clean) to 6.0 (very turbid). Default is set in LinkeTurbidityFactor().

required
solar_altitude_series SolarAltitude or None

Pre-calculated solar altitude angles in radians. If None, must be calculated externally before calling this function. Negative values (sun below horizon) are automatically set to NaN. Default is None.

None
solar_constant float

Solar constant in W·m⁻². Represents the solar irradiance at mean Earth-Sun distance outside the atmosphere. Default is SOLAR_CONSTANT (typically 1361 W·m⁻²).

SOLAR_CONSTANT
eccentricity_phase_offset float

Phase offset for Earth's orbital eccentricity correction in radians. Accounts for perihelion timing. Default is ECCENTRICITY_PHASE_OFFSET.

ECCENTRICITY_PHASE_OFFSET
eccentricity_amplitude float

Amplitude of Earth's orbital eccentricity correction factor. Adjusts for varying Earth-Sun distance throughout the year. Default is ECCENTRICITY_CORRECTION_FACTOR.

ECCENTRICITY_CORRECTION_FACTOR
dtype str

NumPy data type for array operations (e.g., 'float32', 'float64'). Default is DATA_TYPE_DEFAULT.

DATA_TYPE_DEFAULT
array_backend str

Array backend to use for computations ('numpy', 'cupy', 'dask'). Default is ARRAY_BACKEND_DEFAULT.

ARRAY_BACKEND_DEFAULT
verbose int

Verbosity level for debugging output. When exceeding DEBUG_AFTER_THIS_VERBOSITY_LEVEL, prints all local variables. Default is VERBOSE_LEVEL_DEFAULT.

VERBOSE_LEVEL_DEFAULT
log int

Logging level for data fingerprinting. When exceeding HASH_AFTER_THIS_VERBOSITY_LEVEL, logs data hashes for reproducibility tracking. Default is LOG_LEVEL_DEFAULT.

LOG_LEVEL_DEFAULT

Returns:

Type Description
DiffuseSkyReflectedHorizontalIrradiance

Data model containing:

  • value : numpy.ndarray Diffuse horizontal irradiance values in W·m⁻² for each timestamp
  • out_of_range : bool Flag indicating if any values exceed physical bounds
  • out_of_range_index : numpy.ndarray Indices of out-of-range values
  • extraterrestrial_normal_irradiance : array Calculated extraterrestrial irradiance values
  • linke_turbidity_factor : LinkeTurbidityFactor Input turbidity values used in calculation
  • solar_altitude : SolarAltitude Solar altitude angles used in calculation
  • solar_positioning_algorithm : str Algorithm used for solar position calculations
  • adjust_for_atmospheric_refraction : bool Whether atmospheric refraction correction was applied
Notes

Implementation Details

  • Negative solar altitudes (sun below horizon) are automatically set to NaN, then converted to zero irradiance
  • Out-of-range values are identified and flagged based on physical constraints
  • The function is cached for performance optimization
  • All calculations are vectorized for efficiency over time series

Physical Validity

  • Valid only for cloudless sky conditions
  • Results are physically meaningful only when sun is above horizon
  • Linke turbidity should be within [1.0, 6.0] range
  • Output irradiance cannot exceed extraterrestrial values

Model Limitations

  • Assumes horizontally homogeneous atmosphere
  • Does not include ground-reflected component
  • Model calibrated for mid-latitude conditions (?)
See Also

calculate_diffuse_transmission_function_series_hofierka : Calculates Tₙ(T_LK) calculate_diffuse_solar_altitude_function_series_hofierka : Calculates Fₐ(h₀) calculate_extraterrestrial_normal_irradiance_series : Calculates G₀ pvgisprototype.algorithms.hofierka.irradiance.diffuse.clear_sky.altitude : Altitude function module

References

.. [1] Hofierka, J., & Šúri, M. (2002). The solar radiation model for Open source GIS: implementation and applications. Proceedings of the Open source GIS - GRASS users conference, Trento, Italy. .. [2] Rigollier, C., Bauer, O., & Wald, L. (2000). On the clear sky model of the ESRA—European Solar Radiation Atlas—with respect to the Heliosat method. Solar Energy, 68(1), 33-48.

Examples:

Calculate diffuse irradiance for a single day :

>>> import pandas as pd
>>> import numpy as np
>>> from pvgisprototype import SolarAltitude, LinkeTurbidityFactor
>>> from pvgisprototype.algorithms.hofierka.irradiance.diffuse.clear_sky.horizontal import (
...     calculate_clear_sky_diffuse_horizontal_irradiance_hofierka
... )
>>>
>>> # Create hourly timestamps for one day
>>> timestamps = pd.date_range(
...     '2024-06-21', periods=24, freq='h', tz='UTC'
... )
>>>
>>> # Calculate solar altitude (normally done via solar position function)
>>> # Here using example values for demonstration
>>> altitude_values = np.array([
...     -0.5, -0.3, 0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.1, 1.2, 1.2, 1.15,
...     1.1, 1.0, 0.8, 0.6, 0.4, 0.2, 0.0, -0.2, -0.4, -0.5, -0.6, -0.6
... ])
>>> solar_altitude = SolarAltitude(value=altitude_values, unit='radians')
>>>
>>> # Constant moderate turbidity
>>> turbidity = LinkeTurbidityFactor(value=np.full(24, 3.0))
>>>
>>> # Calculate diffuse irradiance
>>> result = calculate_clear_sky_diffuse_horizontal_irradiance_hofierka(
...     timestamps=timestamps,
...     solar_altitude_series=solar_altitude,
...     linke_turbidity_factor_series=turbidity
... )
>>>
>>> # Display results
>>> print(f"Max diffuse irradiance: {result.value.max():.1f} W/m²")
>>> print(f"Daily total: {result.value.sum():.1f} Wh/m²")
>>>
>>> # Check for out-of-range values
>>> if result.out_of_range is not None:
...     print(f"Warning: {len(result.out_of_range_index)} values out of range")

Calculate with time-varying turbidity :

>>> # Simulate varying atmospheric conditions
>>> turbidity_varying = LinkeTurbidityFactor(
...     value=np.linspace(2.5, 3.5, 24)  # Increasing turbidity
... )
>>> 
>>> result_varying = calculate_clear_sky_diffuse_horizontal_irradiance_hofierka(
...     timestamps=timestamps,
...     solar_altitude_series=solar_altitude,
...     linke_turbidity_factor_series=turbidity_varying
... )
>>> 
>>> print(f"Turbidity effect: {result_varying.value.max() - result.value.max():.1f} W/m²")
Warnings
  • Negative solar altitudes are automatically converted to zero irradiance
  • Input arrays (timestamps, turbidity, altitude) must have matching dimensions
  • Very high turbidity values (>6.0) may produce physically unrealistic results (?)
Source code in pvgisprototype/algorithms/hofierka/irradiance/diffuse/clear_sky/horizontal.py
@log_function_call
@custom_cached
def calculate_clear_sky_diffuse_horizontal_irradiance_hofierka(
    timestamps: DatetimeIndex,
    # linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    linke_turbidity_factor_series: LinkeTurbidityFactor,
    solar_altitude_series: SolarAltitude | None = None,
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> DiffuseSkyReflectedHorizontalIrradiance:
    """
    Calculate clear-sky diffuse sky-reflected horizontal irradiance using
    Hofierka's model.

    Calculates the diffuse sky-reflected component of solar irradiance on a
    horizontal surface under cloudless sky conditions using Hofierka's
    empirical clear-sky model. This implementation follows the theoretical
    framework where diffuse irradiance is the product of extraterrestrial
    irradiance, a diffuse transmission function, and a solar altitude function.

    The model estimates diffuse horizontal irradiance (Dₕc) as:

    .. math::
        D_{hc} = G_0 \cdot T_n(T_{LK}) \cdot F_d(h_0)

    where G₀ is the extraterrestrial normal irradiance, Tₙ is the diffuse
    transmission function dependent on Linke turbidity, and Fₐ is the solar
    altitude function.

    Parameters
    ----------
    timestamps : pandas.DatetimeIndex
        Time series index for which to calculate diffuse irradiance. Must be
        timezone-aware for accurate solar position calculations.
    linke_turbidity_factor_series : LinkeTurbidityFactor, optional
        Linke turbidity factor values for each timestamp, characterizing
        atmospheric turbidity. Typical values range from 1.0 (clean) to 6.0
        (very turbid). Default is set in ``LinkeTurbidityFactor()``.
    solar_altitude_series : SolarAltitude or None, optional
        Pre-calculated solar altitude angles in radians. If None, must be
        calculated externally before calling this function. Negative values
        (sun below horizon) are automatically set to NaN. Default is None.
    solar_constant : float, optional
        Solar constant in W·m⁻². Represents the solar irradiance at mean
        Earth-Sun distance outside the atmosphere. Default is ``SOLAR_CONSTANT``
        (typically 1361 W·m⁻²).
    eccentricity_phase_offset : float, optional
        Phase offset for Earth's orbital eccentricity correction in radians.
        Accounts for perihelion timing. Default is ``ECCENTRICITY_PHASE_OFFSET``.
    eccentricity_amplitude : float, optional
        Amplitude of Earth's orbital eccentricity correction factor. Adjusts
        for varying Earth-Sun distance throughout the year. Default is
        ``ECCENTRICITY_CORRECTION_FACTOR``.
    dtype : str, optional
        NumPy data type for array operations (e.g., 'float32', 'float64').
        Default is ``DATA_TYPE_DEFAULT``.
    array_backend : str, optional
        Array backend to use for computations ('numpy', 'cupy', 'dask').
        Default is ``ARRAY_BACKEND_DEFAULT``.
    verbose : int, optional
        Verbosity level for debugging output. When exceeding
        ``DEBUG_AFTER_THIS_VERBOSITY_LEVEL``, prints all local variables.
        Default is ``VERBOSE_LEVEL_DEFAULT``.
    log : int, optional
        Logging level for data fingerprinting. When exceeding
        ``HASH_AFTER_THIS_VERBOSITY_LEVEL``, logs data hashes for
        reproducibility tracking. Default is ``LOG_LEVEL_DEFAULT``.

    Returns
    -------
    DiffuseSkyReflectedHorizontalIrradiance
        Data model containing:

        - **value** : numpy.ndarray
            Diffuse horizontal irradiance values in W·m⁻² for each timestamp
        - **out_of_range** : bool
            Flag indicating if any values exceed physical bounds
        - **out_of_range_index** : numpy.ndarray
            Indices of out-of-range values
        - **extraterrestrial_normal_irradiance** : array
            Calculated extraterrestrial irradiance values
        - **linke_turbidity_factor** : LinkeTurbidityFactor
            Input turbidity values used in calculation
        - **solar_altitude** : SolarAltitude
            Solar altitude angles used in calculation
        - **solar_positioning_algorithm** : str
            Algorithm used for solar position calculations
        - **adjust_for_atmospheric_refraction** : bool
            Whether atmospheric refraction correction was applied

    Notes
    -----
    **Implementation Details**

    - Negative solar altitudes (sun below horizon) are automatically set to NaN,
      then converted to zero irradiance
    - Out-of-range values are identified and flagged based on physical constraints
    - The function is cached for performance optimization
    - All calculations are vectorized for efficiency over time series

    **Physical Validity**

    - Valid only for cloudless sky conditions
    - Results are physically meaningful only when sun is above horizon
    - Linke turbidity should be within [1.0, 6.0] range
    - Output irradiance cannot exceed extraterrestrial values

    **Model Limitations**

    - Assumes horizontally homogeneous atmosphere
    - Does not include ground-reflected component
    - Model calibrated for mid-latitude conditions (?)

    See Also
    --------
    calculate_diffuse_transmission_function_series_hofierka : Calculates Tₙ(T_LK)
    calculate_diffuse_solar_altitude_function_series_hofierka : Calculates Fₐ(h₀)
    calculate_extraterrestrial_normal_irradiance_series : Calculates G₀
    pvgisprototype.algorithms.hofierka.irradiance.diffuse.clear_sky.altitude : Altitude function module

    References
    ----------
    .. [1] Hofierka, J., & Šúri, M. (2002). The solar radiation model for Open
           source GIS: implementation and applications. *Proceedings of the
           Open source GIS - GRASS users conference*, Trento, Italy.
    .. [2] Rigollier, C., Bauer, O., & Wald, L. (2000). On the clear sky model
           of the ESRA—European Solar Radiation Atlas—with respect to the
           Heliosat method. *Solar Energy*, 68(1), 33-48.

    Examples
    --------
    Calculate diffuse irradiance for a single day :

    >>> import pandas as pd
    >>> import numpy as np
    >>> from pvgisprototype import SolarAltitude, LinkeTurbidityFactor
    >>> from pvgisprototype.algorithms.hofierka.irradiance.diffuse.clear_sky.horizontal import (
    ...     calculate_clear_sky_diffuse_horizontal_irradiance_hofierka
    ... )
    >>>
    >>> # Create hourly timestamps for one day
    >>> timestamps = pd.date_range(
    ...     '2024-06-21', periods=24, freq='h', tz='UTC'
    ... )
    >>>
    >>> # Calculate solar altitude (normally done via solar position function)
    >>> # Here using example values for demonstration
    >>> altitude_values = np.array([
    ...     -0.5, -0.3, 0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.1, 1.2, 1.2, 1.15,
    ...     1.1, 1.0, 0.8, 0.6, 0.4, 0.2, 0.0, -0.2, -0.4, -0.5, -0.6, -0.6
    ... ])
    >>> solar_altitude = SolarAltitude(value=altitude_values, unit='radians')
    >>>
    >>> # Constant moderate turbidity
    >>> turbidity = LinkeTurbidityFactor(value=np.full(24, 3.0))
    >>>
    >>> # Calculate diffuse irradiance
    >>> result = calculate_clear_sky_diffuse_horizontal_irradiance_hofierka(
    ...     timestamps=timestamps,
    ...     solar_altitude_series=solar_altitude,
    ...     linke_turbidity_factor_series=turbidity
    ... )
    >>>
    >>> # Display results
    >>> print(f"Max diffuse irradiance: {result.value.max():.1f} W/m²")

    >>> print(f"Daily total: {result.value.sum():.1f} Wh/m²")

    >>>
    >>> # Check for out-of-range values
    >>> if result.out_of_range is not None:
    ...     print(f"Warning: {len(result.out_of_range_index)} values out of range")

    Calculate with time-varying turbidity :

    >>> # Simulate varying atmospheric conditions
    >>> turbidity_varying = LinkeTurbidityFactor(
    ...     value=np.linspace(2.5, 3.5, 24)  # Increasing turbidity
    ... )
    >>> 
    >>> result_varying = calculate_clear_sky_diffuse_horizontal_irradiance_hofierka(
    ...     timestamps=timestamps,
    ...     solar_altitude_series=solar_altitude,
    ...     linke_turbidity_factor_series=turbidity_varying
    ... )
    >>> 
    >>> print(f"Turbidity effect: {result_varying.value.max() - result.value.max():.1f} W/m²")

    Warnings
    --------
    - Negative solar altitudes are automatically converted to zero irradiance
    - Input arrays (timestamps, turbidity, altitude) must have matching
      dimensions
    - Very high turbidity values (>6.0) may produce physically unrealistic
      results (?)
    """
    extraterrestrial_normal_irradiance_series = (
        calculate_extraterrestrial_normal_irradiance_series(
            timestamps=timestamps,
            solar_constant=solar_constant,
            eccentricity_phase_offset=eccentricity_phase_offset,
            eccentricity_amplitude=eccentricity_amplitude,
            dtype=dtype,
            array_backend=array_backend,
            verbose=verbose,
            log=log,
        )
    )
    # Should this maybe happen already outside this function ? ---------------
    # Suppress negative solar altitude, else we get high-negative diffuse output
    solar_altitude_series.value[solar_altitude_series.value < 0] = np.nan
    # ------------------------------------------------------------------------

    diffuse_horizontal_irradiance_series = (
        extraterrestrial_normal_irradiance_series.value
        * calculate_diffuse_transmission_function_series_hofierka(
            linke_turbidity_factor_series=linke_turbidity_factor_series,
            verbose=verbose,
        )
        * calculate_diffuse_solar_altitude_function_series_hofierka(
            solar_altitude_series=solar_altitude_series,
            linke_turbidity_factor_series=linke_turbidity_factor_series,
            verbose=verbose,
        )
    )
    # ------------------------------------------------------------------------
    diffuse_horizontal_irradiance_series = np.nan_to_num(
        diffuse_horizontal_irradiance_series, nan=0
    )  # safer ? -------------------------------------------------------------

    out_of_range, out_of_range_index = identify_values_out_of_range(
        series=diffuse_horizontal_irradiance_series,
        shape=timestamps.shape,
        data_model=DiffuseSkyReflectedHorizontalIrradiance(),
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=diffuse_horizontal_irradiance_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return DiffuseSkyReflectedHorizontalIrradiance(
        value=diffuse_horizontal_irradiance_series,
        out_of_range=out_of_range,
        out_of_range_index=out_of_range_index,
        extraterrestrial_normal_irradiance=extraterrestrial_normal_irradiance_series,
        linke_turbidity_factor=linke_turbidity_factor_series,
        solar_altitude=solar_altitude_series,
        solar_positioning_algorithm=solar_altitude_series.solar_positioning_algorithm,
        adjust_for_atmospheric_refraction=solar_altitude_series.adjusted_for_atmospheric_refraction,
    )
transmission_function

Functions:

Name Description
calculate_diffuse_transmission_function_series_hofierka

Diffuse transmission function over a period of time

calculate_diffuse_transmission_function_series_hofierka
calculate_diffuse_transmission_function_series_hofierka(
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = 0,
    log: int = 0,
) -> ndarray

Diffuse transmission function over a period of time

The diffuse transmission function Tₙ(T_LK) represents the theoretical diffuse irradiance on a horizontal surface with the sun at zenith, normalized for air mass 2 :

.. math:: T_n(T_{LK}) = -0.015843 + 0.030543 T_{LK} + 0.0003797 T_{LK}^2

This quadratic relationship captures how atmospheric turbidity modulates the transmission of diffuse radiation.

Parameters:

Name Type Description Default
linke_turbidity_factor_series LinkeTurbidityFactor

linke_turbidity_factor_series

LinkeTurbidityFactor()
dtype str

dtype

DATA_TYPE_DEFAULT
array_backend str

array_backend

ARRAY_BACKEND_DEFAULT
verbose int

verbose

0
log int

log

0

Returns:

Type Description
ndarray
Notes

From Thomas Huld's custom r.pv source code:

tn = -0.015843 + locLinke * (0.030543 + 0.0003797 * locLinke);

From Hofierka (2002) :

The estimate of the transmission function Tn(TLK) gives a theoretical
diffuse irradiance on a horizontal surface with the sun vertically
overhead for the air mass 2 Linke turbidity factor. The following
second order polynomial expression is used:

Tn(TLK) = -0.015843 + 0.030543 TLK + 0.0003797 TLK^2
References

.. [1] Hofierka, J., & Šúri, M. (2002). The solar radiation model for Open source GIS: implementation and applications. Proceedings of the Open source GIS - GRASS users conference, Trento, Italy.

Source code in pvgisprototype/algorithms/hofierka/irradiance/diffuse/clear_sky/transmission_function.py
@log_function_call
@custom_cached
def calculate_diffuse_transmission_function_series_hofierka(
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = 0,
    log: int = 0,
) -> ndarray:
    """Diffuse transmission function over a period of time

    The diffuse transmission function Tₙ(T_LK) represents the theoretical
    diffuse irradiance on a horizontal surface with the sun at zenith,
    normalized for air mass 2 :

    .. math::
        T_n(T_{LK}) = -0.015843 + 0.030543 T_{LK} + 0.0003797 T_{LK}^2

    This quadratic relationship captures how atmospheric turbidity modulates the
    transmission of diffuse radiation.


    Parameters
    ----------
    linke_turbidity_factor_series :
        linke_turbidity_factor_series
    dtype : str
        dtype
    array_backend : str
        array_backend
    verbose : int
        verbose
    log : int
        log

    Returns
    -------
    ndarray

    Notes
    -----
    From Thomas Huld's custom `r.pv` source code:

        tn = -0.015843 + locLinke * (0.030543 + 0.0003797 * locLinke);

    From Hofierka (2002) :

        The estimate of the transmission function Tn(TLK) gives a theoretical
        diffuse irradiance on a horizontal surface with the sun vertically
        overhead for the air mass 2 Linke turbidity factor. The following
        second order polynomial expression is used:

        Tn(TLK) = -0.015843 + 0.030543 TLK + 0.0003797 TLK^2

    References
    ----------
    .. [1] Hofierka, J., & Šúri, M. (2002). The solar radiation model for Open
           source GIS: implementation and applications. *Proceedings of the
           Open source GIS - GRASS users conference*, Trento, Italy.
    """
    linke_turbidity_factor_series_squared_array = np.power(
        linke_turbidity_factor_series.value, 2, dtype=dtype
    )
    diffuse_transmission_series = (
        -0.015843
        + 0.030543 * linke_turbidity_factor_series.value
        + 0.0003797 * linke_turbidity_factor_series_squared_array
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=diffuse_transmission_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return diffuse_transmission_series
ground_reflected

Functions:

Name Description
calculate_ground_reflected_inclined_irradiance_series_pvgis

Calculate the clear-sky diffuse ground reflected irradiance on an inclined surface (Ri).

calculate_ground_reflected_inclined_irradiance_series_pvgis
calculate_ground_reflected_inclined_irradiance_series_pvgis(
    longitude: float,
    latitude: float,
    elevation: float,
    timestamps: DatetimeIndex = DatetimeIndex(
        [now(tz="UTC")]
    ),
    surface_orientation: float = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: float = SURFACE_TILT_DEFAULT,
    surface_tilt_threshold=SURFACE_TILT_HORIZONTALLY_FLAT_PANEL_THRESHOLD,
    albedo: float | None = ALBEDO_DEFAULT,
    global_horizontal_irradiance: ndarray | None = None,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
)

Calculate the clear-sky diffuse ground reflected irradiance on an inclined surface (Ri).

The calculation assumes isotropy. The ground reflected clear-sky irradiance received on an inclined surface [W.m-2] is proportional to the global horizontal irradiance Ghc, to the mean ground albedo ρg and a fraction of the ground viewed by an inclined surface rg(γN).

Parameters:

Name Type Description Default
longitude float

longitude

required
latitude float

latitude

required
elevation float

elevation

required
timestamps DatetimeIndex

timestamps

DatetimeIndex([now(tz='UTC')])
surface_orientation float

surface_orientation

SURFACE_ORIENTATION_DEFAULT
surface_tilt float

surface_tilt

SURFACE_TILT_DEFAULT
surface_tilt_threshold

surface_tilt_threshold

SURFACE_TILT_HORIZONTALLY_FLAT_PANEL_THRESHOLD
albedo float | None

albedo

ALBEDO_DEFAULT
global_horizontal_irradiance ndarray | None

global_horizontal_irradiance

None
dtype str

dtype

DATA_TYPE_DEFAULT
array_backend str

array_backend

ARRAY_BACKEND_DEFAULT
verbose int

verbose

VERBOSE_LEVEL_DEFAULT
log int

log

LOG_LEVEL_DEFAULT
Source code in pvgisprototype/algorithms/hofierka/irradiance/diffuse/ground_reflected.py
@log_function_call
@custom_cached
def calculate_ground_reflected_inclined_irradiance_series_pvgis(
    longitude: float,
    latitude: float,
    elevation: float,
    timestamps: DatetimeIndex = DatetimeIndex([Timestamp.now(tz='UTC')]),
    surface_orientation: float = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: float = SURFACE_TILT_DEFAULT,
    surface_tilt_threshold = SURFACE_TILT_HORIZONTALLY_FLAT_PANEL_THRESHOLD,
    albedo: float | None = ALBEDO_DEFAULT,
    global_horizontal_irradiance: ndarray | None = None,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
):
    """Calculate the clear-sky diffuse ground reflected irradiance on an inclined surface (Ri).

    The calculation assumes isotropy. The ground reflected clear-sky irradiance
    received on an inclined surface [W.m-2] is proportional to the global
    horizontal irradiance Ghc, to the mean ground albedo ρg and a fraction of
    the ground viewed by an inclined surface rg(γN).

    Parameters
    ----------
    longitude : float
        longitude
    latitude : float
        latitude
    elevation : float
        elevation
    timestamps : DatetimeIndex
        timestamps
    surface_orientation : float
        surface_orientation
    surface_tilt : float
        surface_tilt
    surface_tilt_threshold :
        surface_tilt_threshold
    albedo : float | None
        albedo
    global_horizontal_irradiance : ndarray | None
        global_horizontal_irradiance
    dtype : str
        dtype
    array_backend : str
        array_backend
    verbose : int
        verbose
    log : int
        log

    """
    if surface_tilt <= surface_tilt_threshold:  # No ground reflection for a flat or nearly flat surface
        ground_view_fraction = 0
        # in order to avoid 'NameError's
        flat_surface_array_parameters = {
            "shape": timestamps.shape,
            "dtype": dtype,
            "init_method": "zeros",
            "backend": array_backend,
        }  # Borrow shape from timestamps
        global_horizontal_irradiance = create_array(**flat_surface_array_parameters)
    else:
        ground_view_fraction = (1 - cos(surface_tilt)) / 2

    ground_reflected_inclined_irradiance_series = asarray(
        global_horizontal_irradiance * ground_view_fraction * albedo,
        dtype=dtype
    ).reshape(-1)

    out_of_range, out_of_range_index = identify_values_out_of_range(
        series=ground_reflected_inclined_irradiance_series,
        shape=timestamps.shape,
        data_model=DiffuseGroundReflectedInclinedIrradiance(),
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=ground_reflected_inclined_irradiance_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    # Common output structure
    function_output = {
        "value": ground_reflected_inclined_irradiance_series,
        "out_of_range": out_of_range,
        "out_of_range_index": out_of_range_index,
        "ground_view_fraction": ground_view_fraction,
        "albedo": albedo,
        "global_horizontal_irradiance": global_horizontal_irradiance,
        "location": (longitude, latitude),
        "elevation": elevation,
        "surface_orientation": surface_orientation,
        "surface_tilt": surface_tilt,
        "surface_tilt_threshold": surface_tilt_threshold,
    }
    return DiffuseGroundReflectedInclinedIrradiance(**function_output)
horizontal

Functions:

Name Description
calculate_diffuse_horizontal_irradiance_hofierka

Calculate the diffuse horizontal irradiance from SARAH time series.

calculate_diffuse_horizontal_irradiance_hofierka
calculate_diffuse_horizontal_irradiance_hofierka(
    global_horizontal_irradiance_series: ndarray,
    direct_horizontal_irradiance_series: ndarray,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> (
    DiffuseSkyReflectedHorizontalIrradianceFromExternalData
)

Calculate the diffuse horizontal irradiance from SARAH time series.

Calculate the diffuse horizontal irradiance incident on a solar surface from SARAH time series.

Parameters:

Name Type Description Default
global_horizontal_irradiance_series ndarray

The global horizontal irradiance, also known as surface short-wave solar radiation downwards, is the solar radiation that reaches a horizontal plane at the surface of the Earth. This parameter comprises both direct and diffuse solar radiation.

required
direct_horizontal_irradiance_series ndarray

direct_horizontal_irradiance_series

required
dtype str

dtype

DATA_TYPE_DEFAULT
array_backend str

array_backend

ARRAY_BACKEND_DEFAULT
verbose int

verbose

VERBOSE_LEVEL_DEFAULT
log int

log

LOG_LEVEL_DEFAULT

Returns:

Type Description
DiffuseSkyReflectedHorizontalIrradianceFromExternalData

The diffuse radiant flux incident on a surface per unit area in W/m².

Notes
The corresponding ECMWF product variable is named `ssrd`.
Source code in pvgisprototype/algorithms/hofierka/irradiance/diffuse/horizontal.py
@log_function_call
@custom_cached
def calculate_diffuse_horizontal_irradiance_hofierka(
    global_horizontal_irradiance_series: ndarray,
    direct_horizontal_irradiance_series: ndarray,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,  # Not yet integrated !
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> DiffuseSkyReflectedHorizontalIrradianceFromExternalData:
    """Calculate the diffuse horizontal irradiance from SARAH time series.

    Calculate the diffuse horizontal irradiance incident on a solar surface
    from SARAH time series.

    Parameters
    ----------
    global_horizontal_irradiance_series : ndarray
        The global horizontal irradiance, also known as surface short-wave
        solar radiation downwards, is the solar radiation that reaches a
        horizontal plane at the surface of the Earth. This parameter comprises
        both direct and diffuse solar radiation.
    direct_horizontal_irradiance_series : ndarray
        direct_horizontal_irradiance_series
    dtype : str
        dtype
    array_backend : str
        array_backend
    verbose : int
        verbose
    log : int
        log

    Returns
    -------
    DiffuseSkyReflectedHorizontalIrradianceFromExternalData
        The diffuse radiant flux incident on a surface per unit area in W/m².

    Notes
    -----
        The corresponding ECMWF product variable is named `ssrd`.

    """
    diffuse_horizontal_irradiance_series = (
        global_horizontal_irradiance_series - direct_horizontal_irradiance_series
    ).astype(dtype=dtype)

    if diffuse_horizontal_irradiance_series.size == 1 and diffuse_horizontal_irradiance_series.shape == ():
        diffuse_horizontal_irradiance_series = numpy.array(
            [diffuse_horizontal_irradiance_series], dtype=dtype
        )
        single_value = float(diffuse_horizontal_irradiance_series)
        warning = (
            f"{exclamation_mark} The selected timestamp "
            + " matches the single value "
            + f"{single_value}"
        )
        logger.warning(warning)

    out_of_range, out_of_range_index = identify_values_out_of_range(
        series=diffuse_horizontal_irradiance_series,
        shape=global_horizontal_irradiance_series.shape,
        data_model=DiffuseSkyReflectedHorizontalIrradianceFromExternalData(),
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=diffuse_horizontal_irradiance_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return DiffuseSkyReflectedHorizontalIrradianceFromExternalData(
        value=diffuse_horizontal_irradiance_series,
        out_of_range=out_of_range,
        out_of_range_index=out_of_range_index,
        global_horizontal_irradiance=global_horizontal_irradiance_series,
        direct_horizontal_irradiance=direct_horizontal_irradiance_series,
    )

direct

Modules:

Name Description
clear_sky
inclined

This Python module is part of PVGIS' API. It implements functions to calculate

normal

This Python module is part of PVGIS' Algorithms. It implements a function to

clear_sky

Modules:

Name Description
horizontal

This Python module is part of PVGIS' Algorithms. It implements functions to calculate

inclined

This Python module is part of PVGIS' API. It implements functions to calculate

linke_turbidity_factor
normal

This Python module is part of PVGIS' Algorithms. It implements a function to

horizontal

This Python module is part of PVGIS' Algorithms. It implements functions to calculate the direct solar irradiance.

Direct or beam irradiance is one of the main components of solar irradiance. It comes perpendicular from the Sun and is not scattered before it irradiates a surface.

During a cloudy day the sunlight will be partially absorbed and scattered by different air molecules. The latter part is defined as the diffuse irradiance. The remaining part is the direct irradiance.

Functions:

Name Description
calculate_clear_sky_direct_horizontal_irradiance_hofierka

Calculate the direct horizontal irradiance

calculate_clear_sky_direct_horizontal_irradiance_hofierka
calculate_clear_sky_direct_horizontal_irradiance_hofierka(
    elevation: float,
    timestamps: DatetimeIndex = DatetimeIndex(
        [now(tz="UTC")]
    ),
    solar_altitude_series: SolarAltitude | None = None,
    surface_in_shade_series: LocationShading | None = None,
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> DirectHorizontalIrradiance

Calculate the direct horizontal irradiance

This function implements the algorithm described by Hofierka [1]_.

Parameters:

Name Type Description Default
elevation float

elevation

required
timestamps DatetimeIndex

timestamps

DatetimeIndex([now(tz='UTC')])
solar_altitude_series SolarAltitude | None

solar_altitude_series

None
surface_in_shade_series LocationShading | None

surface_in_shade_series

None
linke_turbidity_factor_series LinkeTurbidityFactor

linke_turbidity_factor_series

LinkeTurbidityFactor()
solar_constant float

solar_constant

SOLAR_CONSTANT
eccentricity_phase_offset float

eccentricity_phase_offset

ECCENTRICITY_PHASE_OFFSET
eccentricity_amplitude float

eccentricity_amplitude

ECCENTRICITY_CORRECTION_FACTOR
dtype str

dtype

DATA_TYPE_DEFAULT
array_backend str

array_backend

ARRAY_BACKEND_DEFAULT
verbose int

verbose

VERBOSE_LEVEL_DEFAULT
log int

log

LOG_LEVEL_DEFAULT

Returns:

Type Description
DirectHorizontalIrradiance
Notes

Known also as : SID, units : W*m-2

References

.. [1] Hofierka, J. (2002). Some title of the paper. Journal Name, vol(issue), pages.

Source code in pvgisprototype/algorithms/hofierka/irradiance/direct/clear_sky/horizontal.py
@log_function_call
@custom_cached
def calculate_clear_sky_direct_horizontal_irradiance_hofierka(
    elevation: float,
    timestamps: DatetimeIndex = DatetimeIndex([Timestamp.now(tz='UTC')]),
    solar_altitude_series: SolarAltitude | None = None,
    surface_in_shade_series: LocationShading | None = None,
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> DirectHorizontalIrradiance:
    """Calculate the direct horizontal irradiance

    This function implements the algorithm described by Hofierka [1]_.

    Parameters
    ----------
    elevation : float
        elevation
    timestamps : DatetimeIndex
        timestamps
    solar_altitude_series : SolarAltitude | None
        solar_altitude_series
    surface_in_shade_series : LocationShading | None
        surface_in_shade_series
    linke_turbidity_factor_series : LinkeTurbidityFactor
        linke_turbidity_factor_series
    solar_constant : float
        solar_constant
    eccentricity_phase_offset : float
        eccentricity_phase_offset
    eccentricity_amplitude : float
        eccentricity_amplitude
    dtype : str
        dtype
    array_backend : str
        array_backend
    verbose : int
        verbose
    log : int
        log

    Returns
    -------
    DirectHorizontalIrradiance

    Notes
    -----
    Known also as : SID, units : W*m-2

    References
    ----------
    .. [1] Hofierka, J. (2002). Some title of the paper. Journal Name, vol(issue), pages.

    """
    # expects solar altitude in degrees! ----------------------------------vvv
    #
    refracted_solar_altitude_series = calculate_refracted_solar_altitude_series(
        solar_altitude_series=solar_altitude_series,  # expects altitude in degrees!
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
    )
    optical_air_mass_series = calculate_optical_air_mass_series(
        elevation=elevation,
        refracted_solar_altitude_series=refracted_solar_altitude_series,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
    )
    #
    # ^^^ ---------------------------------------- expects solar altitude in degrees!

    direct_normal_irradiance_series = calculate_direct_normal_irradiance_hofierka(
        timestamps=timestamps,
        linke_turbidity_factor_series=linke_turbidity_factor_series,
        optical_air_mass_series=optical_air_mass_series,
        solar_constant=solar_constant,
        eccentricity_phase_offset=eccentricity_phase_offset,
        eccentricity_amplitude=eccentricity_amplitude,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
    )

    # Mask conditions
    mask_solar_altitude_positive = solar_altitude_series.radians > 0
    mask_not_in_shade = ~surface_in_shade_series.value
    mask_sunlit_surface_series = np.logical_and.reduce((mask_solar_altitude_positive, mask_not_in_shade))

    # Initialize the direct irradiance series to zeros
    array_parameters = {
        "shape": timestamps.shape,  # Borrow shape from timestamps
        "dtype": dtype,
        "init_method": "zeros",
        "backend": array_backend,
    }
    direct_horizontal_irradiance_series = create_array(**array_parameters)

    if np.any(mask_sunlit_surface_series):
        direct_horizontal_irradiance_series[mask_sunlit_surface_series] = (
            direct_normal_irradiance_series.value
            * np.sin(solar_altitude_series.radians)
        )[mask_sunlit_surface_series]

    out_of_range, out_of_range_index = identify_values_out_of_range(
        series=direct_horizontal_irradiance_series,
        shape=timestamps.shape,
        data_model=DirectHorizontalIrradiance(),
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=direct_horizontal_irradiance_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return DirectHorizontalIrradiance(
        value=direct_horizontal_irradiance_series,
        out_of_range=out_of_range,
        out_of_range_index=out_of_range_index,
        elevation=elevation,
        solar_altitude=solar_altitude_series,
        refracted_solar_altitude=refracted_solar_altitude_series.value,
        optical_air_mass=optical_air_mass_series,
        direct_normal_irradiance=direct_normal_irradiance_series,
        surface_in_shade=surface_in_shade_series,
        solar_radiation_model=HOFIERKA_2002,
        data_source=HOFIERKA_2002,
    )
inclined

This Python module is part of PVGIS' API. It implements functions to calculate the direct solar irradiance.

Direct or beam irradiance is one of the main components of solar irradiance. It comes perpendicular from the Sun and is not scattered before it irradiates a surface.

During a cloudy day the sunlight will be partially absorbed and scattered by different air molecules. The latter part is defined as the diffuse irradiance. The remaining part is the direct irradiance.

Functions:

Name Description
calculate_clear_sky_direct_inclined_irradiance_hofierka

Calculate the direct irradiance incident on a tilted surface [W*m-2].

calculate_clear_sky_direct_inclined_irradiance_hofierka
calculate_clear_sky_direct_inclined_irradiance_hofierka(
    elevation: float,
    surface_orientation: (
        SurfaceOrientation | None
    ) = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt | None = SURFACE_TILT_DEFAULT,
    timestamps: DatetimeIndex | None = DatetimeIndex(
        [now(tz="UTC")]
    ),
    timezone: ZoneInfo | None = ZoneInfo("UTC"),
    direct_horizontal_irradiance: ndarray | None = None,
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    apply_reflectivity_factor: bool = True,
    solar_incidence_series: SolarIncidence | None = None,
    solar_altitude_series: SolarAltitude | None = None,
    surface_in_shade_series: LocationShading | None = None,
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> DirectInclinedIrradiance

Calculate the direct irradiance incident on a tilted surface [W*m-2].

Calculate the direct irradiance on an inclined surface based on the solar radiation model by Hofierka, 2002. [1]_

Parameters:

Name Type Description Default
elevation float

elevation

required
surface_orientation SurfaceOrientation | None

surface_orientation

SURFACE_ORIENTATION_DEFAULT
surface_tilt SurfaceTilt | None

surface_tilt

SURFACE_TILT_DEFAULT
timestamps DatetimeIndex | None

timestamps

DatetimeIndex([now(tz='UTC')])
timezone ZoneInfo | None

timezone

ZoneInfo('UTC')
direct_horizontal_irradiance ndarray | None

direct_horizontal_irradiance

None
linke_turbidity_factor_series LinkeTurbidityFactor

linke_turbidity_factor_series

LinkeTurbidityFactor()
apply_reflectivity_factor bool

apply_reflectivity_factor

True
solar_incidence_series SolarIncidence | None

solar_incidence_series

None
solar_altitude_series SolarAltitude | None

solar_altitude_series

None
surface_in_shade_series LocationShading | None

surface_in_shade_series

None
solar_constant float

solar_constant

SOLAR_CONSTANT
eccentricity_phase_offset float

eccentricity_phase_offset

ECCENTRICITY_PHASE_OFFSET
eccentricity_amplitude float

eccentricity_amplitude

ECCENTRICITY_CORRECTION_FACTOR
dtype str

dtype

DATA_TYPE_DEFAULT
array_backend str

array_backend

ARRAY_BACKEND_DEFAULT
validate_output bool

validate_output

VALIDATE_OUTPUT_DEFAULT
verbose int

verbose

VERBOSE_LEVEL_DEFAULT
log int

log

LOG_LEVEL_DEFAULT

Returns:

Type Description
DirectInclinedIrradiance
Notes

In Hofierka (2002) [1]_, see equation 11 :

Bic = B0c sin δexp

or equation 12 :

      B   ⋅ sin ⎛δ   ⎞
       hc       ⎝ exp⎠         ⎛ W ⎞
B   = ────────────────     in  ⎜───⎟
 ic       sin ⎛h ⎞             ⎜ -2⎟
              ⎝ 0⎠             ⎝m  ⎠

where :

  • δexp is the solar incidence angle measured between the sun and an inclined surface defined in equation 16

or else :

Direct Inclined = Direct Horizontal * sin( Solar Incidence ) / sin( Solar Altitude )

The implementation by Hofierka (2002) uses the solar incidence angle between the sun-vector and the plane of the reference surface (as per Jenčo, 1992). This is very important and relates to the hardcoded value True for the complementary_incidence_angle input parameter of the function. We call this angle (definition) the complementary incidence angle.

For the losses due to reflectivity, the incidence angle modifier by Martin & Ruiz (2005) expects the incidence angle between the sun-vector and the surface-normal. Hence, the respective call of the function calculate_reflectivity_factor_for_direct_irradiance_series(), expects the complement of the angle defined by Jenčo (1992). We call the incidence angle expected by the incidence angle modifier by Martin & Ruiz (2005) the typical incidence angle.

See also the documentation of the function calculate_solar_incidence_series_jenco().

References

.. [1] Hofierka, J. (2002). Some title of the paper. Journal Name, vol(issue), pages.

Source code in pvgisprototype/algorithms/hofierka/irradiance/direct/clear_sky/inclined.py
@log_function_call
@custom_cached
def calculate_clear_sky_direct_inclined_irradiance_hofierka(
    elevation: float,
    surface_orientation: SurfaceOrientation | None = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt | None = SURFACE_TILT_DEFAULT,
    #
    timestamps: DatetimeIndex | None = DatetimeIndex([Timestamp.now(tz='UTC')]),
    timezone: ZoneInfo | None = ZoneInfo('UTC'),
    #
    direct_horizontal_irradiance: ndarray | None = None,
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    apply_reflectivity_factor: bool = True,
    solar_incidence_series: SolarIncidence | None = None,
    solar_altitude_series: SolarAltitude | None = None,
    # solar_azimuth_series: SolarAzimuth | None = None,
    surface_in_shade_series: LocationShading | None = None,
    #
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    #
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
    #
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> DirectInclinedIrradiance:
    """Calculate the direct irradiance incident on a tilted surface [W*m-2].

    Calculate the direct irradiance on an inclined surface based on the
    solar radiation model by Hofierka, 2002. [1]_

    Parameters
    ----------
    elevation : float
        elevation
    surface_orientation : SurfaceOrientation | None
        surface_orientation
    surface_tilt : SurfaceTilt | None
        surface_tilt
    timestamps : DatetimeIndex | None
        timestamps
    timezone : ZoneInfo | None
        timezone
    direct_horizontal_irradiance : ndarray | None
        direct_horizontal_irradiance
    linke_turbidity_factor_series : LinkeTurbidityFactor
        linke_turbidity_factor_series
    apply_reflectivity_factor : bool
        apply_reflectivity_factor
    solar_incidence_series : SolarIncidence | None
        solar_incidence_series
    solar_altitude_series : SolarAltitude | None
        solar_altitude_series
    surface_in_shade_series : LocationShading | None
        surface_in_shade_series
    solar_constant : float
        solar_constant
    eccentricity_phase_offset : float
        eccentricity_phase_offset
    eccentricity_amplitude : float
        eccentricity_amplitude
    dtype : str
        dtype
    array_backend : str
        array_backend
    validate_output : bool
        validate_output
    verbose : int
        verbose
    log : int
        log

    Returns
    -------
    DirectInclinedIrradiance

    Notes
    -----
    In Hofierka (2002) [1]_, see equation 11 :

        Bic = B0c sin δexp

    or equation 12 :

              B   ⋅ sin ⎛δ   ⎞
               hc       ⎝ exp⎠         ⎛ W ⎞
        B   = ────────────────     in  ⎜───⎟
         ic       sin ⎛h ⎞             ⎜ -2⎟
                      ⎝ 0⎠             ⎝m  ⎠

    where :

    - δexp is the solar incidence angle measured between the sun and an
      inclined surface defined in equation 16

    or else :

        Direct Inclined = Direct Horizontal * sin( Solar Incidence ) / sin( Solar Altitude )

    The implementation by Hofierka (2002) uses the solar incidence angle
    between the sun-vector and the plane of the reference surface (as per Jenčo,
    1992). This is very important and relates to the hardcoded value `True` for
    the `complementary_incidence_angle` input parameter of the function. We
    call this angle (definition) the _complementary_ incidence angle.

    For the losses due to reflectivity, the incidence angle modifier by Martin
    & Ruiz (2005) expects the incidence angle between the sun-vector and the
    surface-normal. Hence, the respective call of the function
    `calculate_reflectivity_factor_for_direct_irradiance_series()`,
    expects the complement of the angle defined by Jenčo (1992). We call the
    incidence angle expected by the incidence angle modifier by Martin & Ruiz
    (2005) the _typical_ incidence angle.

    See also the documentation of the function
    `calculate_solar_incidence_series_jenco()`.

    References
    ----------
    .. [1] Hofierka, J. (2002). Some title of the paper. Journal Name, vol(issue), pages.

    """
    # Create a sunlit surface time series mask
    # - solar altitude > 0
    # - surface not in shade
    # - solar incidence > 0
    mask_solar_altitude_positive = solar_altitude_series.radians > 0
    # Following, the _complementary_ solar incidence angle is used (Jenčo, 1992)!
    mask_solar_incidence_positive = solar_incidence_series.radians > 0
    mask_not_in_shade = ~surface_in_shade_series.value
    mask_sunlit_surface_series = numpy.logical_and.reduce(
        (mask_solar_altitude_positive, mask_solar_incidence_positive, mask_not_in_shade)
    )
    # Else, the following runs:
    # --------------------------------- Review & Add ?
    # 1. surface is shaded
    # 3. solar incidence = 0
    # --------------------------------- Review & Add ?
    # ========================================================================
    if direct_horizontal_irradiance is not None:
        logger.error(
            "The `direct_horizontal_irradiance` should be None at this point !",
            alt = ":information: [bold red]The [code]direct_horizontal_irradiance[/code] input should be [code]None[/code] at this point ![/bold red]",
        )
        raise ValueError(
            ":information: The `direct_horizontal_irradiance` input should be None at this point !",
        )
    else:
        if verbose > 0:
            logger.debug(
                ":information: Modelling direct horizontal irradiance...",
                alt=":information: [bold][magenta]Modelling[/magenta] direct horizontal irradiance[/bold]...",
            )
        direct_horizontal_irradiance_series = (
            calculate_clear_sky_direct_horizontal_irradiance_hofierka(
                elevation=elevation,
                timestamps=timestamps,
                solar_altitude_series=solar_altitude_series,
                surface_in_shade_series=surface_in_shade_series,
                linke_turbidity_factor_series=linke_turbidity_factor_series,
                solar_constant=solar_constant,
                eccentricity_phase_offset=eccentricity_phase_offset,
                eccentricity_amplitude=eccentricity_amplitude,
                dtype=dtype,
                array_backend=array_backend,
                verbose=verbose,
                log=log,
            )
        )
    try:
        # the number of timestamps should match the number of "x" values
        if verbose > 0:
            logger.debug(
                "\ni Calculating the direct inclined irradiance ..",
                alt="\ni [bold]Calculating[/bold] the [magenta]direct inclined irradiance[/magenta] .."
            )
        compare_temporal_resolution(
            timestamps, direct_horizontal_irradiance_series.value
        )

        # Else, the following runs:
        # --------------------------------- Review & Add ?
        # 1. surface is shaded
        # 3. solar incidence = 0
        # --------------------------------- Review & Add ?
        # ========================================================================

        # Initialize the direct irradiance series to zeros
        # array_parameters = {
        #     "shape": timestamps.shape,
        #     "dtype": dtype,
        #     "init_method": "zeros",
        #     "backend": array_backend,
        # }  # Borrow shape from timestamps
        # direct_inclined_irradiance_series = create_array(**array_parameters)
        # if mask_sunlit_surface_series.any():
        direct_inclined_irradiance_series = (
        direct_horizontal_irradiance_series.value
        * sin(
            solar_incidence_series.radians
        )  # Should be the _complementary_ incidence angle!
        / sin(solar_altitude_series.radians)
    )
    except ZeroDivisionError:
        logger.error(
            "Error: Division by zero in calculating the direct inclined irradiance!"
        )
        logger.debug(
                "Is the solar altitude angle zero ?",
                alt="[bold red]Is the solar altitude angle zero ?"
                )
        # should this return something? Like in r.sun's simpler's approach?
        raise ValueError

    if not apply_reflectivity_factor:
        direct_inclined_irradiance_before_reflectivity_series = None
        direct_inclined_irradiance_reflectivity_factor_series = None

    else:
        # Calculate the reflectivity factor
        #
        # per Martin & Ruiz 2005 :
        # expects the _typical_ sun-vector-to-normal-of-surface incidence angles
        # which is the _complement_ of the incidence angle per Hofierka 2002
        direct_inclined_irradiance_reflectivity_factor_series = (
            calculate_reflectivity_factor_for_direct_irradiance_series(
                solar_incidence_series=(pi / 2 - solar_incidence_series.radians),
                verbose=0,
            )
        )

        # Apply the reflectivity factor
        direct_inclined_irradiance_series *= (
            direct_inclined_irradiance_reflectivity_factor_series
        )

        # Avoid copying to save memory and time ... ? ----------------- Is this safe ? -
        with errstate(divide="ignore", invalid="ignore"):
            # this quantity is exclusively generated for the output dictionary !
            direct_inclined_irradiance_before_reflectivity_series = where(
                direct_inclined_irradiance_reflectivity_factor_series != 0,
                direct_inclined_irradiance_series
                / direct_inclined_irradiance_reflectivity_factor_series,
                0,
            )
        # ------------------------------------------------------------------------------

    out_of_range, out_of_range_index = identify_values_out_of_range(
        series=direct_inclined_irradiance_series,
        shape=timestamps.shape,
        data_model=DirectInclinedIrradiance(),
    )

    if numpy.any(direct_inclined_irradiance_series < 0):
        logger.warning(
            "\n[red]Warning: Negative values found in `direct_inclined_irradiance_series`![/red]"
        )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=direct_inclined_irradiance_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return DirectInclinedIrradiance(
        value=direct_inclined_irradiance_series,
        out_of_range=out_of_range,
        out_of_range_index=out_of_range_index,
        ## Irradiance components
        direct_horizontal_irradiance=direct_horizontal_irradiance_series,
        reflected = calculate_reflectivity_effect(
                irradiance=direct_inclined_irradiance_before_reflectivity_series,
                reflectivity_factor=direct_inclined_irradiance_reflectivity_factor_series,
            ),
        reflectivity_factor=direct_inclined_irradiance_reflectivity_factor_series,
        value_before_reflectivity=direct_inclined_irradiance_before_reflectivity_series,
        ## Location
        elevation=elevation,
        surface_orientation=surface_orientation,
        surface_tilt=surface_tilt,
        #
        solar_positioning_algorithm=solar_incidence_series.solar_positioning_algorithm,
        solar_timing_algorithm=solar_incidence_series.solar_timing_algorithm,
        refracted_solar_altitude=direct_horizontal_irradiance_series.refracted_solar_altitude,
        solar_incidence_model=solar_incidence_series.algorithm,
        solar_incidence_definition=solar_incidence_series.definition,
        shading_algorithm=surface_in_shade_series.shading_algorithm,
        #
        surface_in_shade=surface_in_shade_series,
        solar_incidence=solar_incidence_series,
        solar_altitude=solar_altitude_series,
        # solar_azimuth=solar_azimuth_series,
        #
        linke_turbidity_factor=linke_turbidity_factor_series,
    )
linke_turbidity_factor

Functions:

Name Description
correct_linke_turbidity_factor_series

Calculate the air mass 2 Linke turbidity factor.

correct_linke_turbidity_factor_series
correct_linke_turbidity_factor_series(
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = 0,
) -> LinkeTurbidityFactor

Calculate the air mass 2 Linke turbidity factor.

Calculate the air mass 2 Linke atmospheric turbidity factor for a time series.

Parameters:

Name Type Description Default
linke_turbidity_factor_series LinkeTurbidityFactor

The Linke turbidity factors as a list of LinkeTurbidityFactor objects or a single object.

LinkeTurbidityFactor()
dtype str

dtype

DATA_TYPE_DEFAULT
array_backend str

array_backend

ARRAY_BACKEND_DEFAULT
verbose int

verbose

VERBOSE_LEVEL_DEFAULT
log int

log

0

Returns:

Type Description
List[LinkeTurbidityFactor] or LinkeTurbidityFactor

The corrected Linke turbidity factors as a list of LinkeTurbidityFactor objects or a single object.

Source code in pvgisprototype/algorithms/hofierka/irradiance/direct/clear_sky/linke_turbidity_factor.py
@log_function_call
@custom_cached
def correct_linke_turbidity_factor_series(
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = 0,
) -> LinkeTurbidityFactor:
    """Calculate the air mass 2 Linke turbidity factor.

    Calculate the air mass 2 Linke atmospheric turbidity factor for a time series.

    Parameters
    ----------
    linke_turbidity_factor_series: List[LinkeTurbidityFactor] | LinkeTurbidityFactor
        The Linke turbidity factors as a list of LinkeTurbidityFactor objects
        or a single object.
    dtype : str
        dtype
    array_backend : str
        array_backend
    verbose : int
        verbose
    log : int
        log

    Returns
    -------
    List[LinkeTurbidityFactor] or LinkeTurbidityFactor
        The corrected Linke turbidity factors as a list of LinkeTurbidityFactor
        objects or a single object.

    """
    corrected_linke_turbidity_factors = -0.8662 * numpy_array(
        linke_turbidity_factor_series.value, dtype=dtype
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=corrected_linke_turbidity_factors,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return LinkeTurbidityFactor(
        value=corrected_linke_turbidity_factors,
    )
normal

This Python module is part of PVGIS' Algorithms. It implements a function to calculate the direct normal solar irradiance.

Direct or beam irradiance is one of the main components of solar irradiance. It comes perpendicular from the Sun and is not scattered before it irradiates a surface.

During a cloudy day the sunlight will be partially absorbed and scattered by different air molecules. The latter part is defined as the diffuse irradiance. The remaining part is the direct irradiance.

Functions:

Name Description
calculate_direct_normal_irradiance_hofierka

Calculate the direct normal irradiance.

calculate_direct_normal_irradiance_hofierka
calculate_direct_normal_irradiance_hofierka(
    timestamps: DatetimeIndex | None,
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    optical_air_mass_series: OpticalAirMass = [
        OPTICAL_AIR_MASS_TIME_SERIES_DEFAULT
    ],
    clip_to_physically_possible_limits: bool = True,
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> DirectNormalIrradiance

Calculate the direct normal irradiance.

The direct normal irradiance attenuated by the cloudless atmosphere, represents the amount of solar radiation received per unit area by a surface that is perpendicular (normal) to the rays that come in a straight line from the direction of the sun at its current position in the sky.

This function implements the algorithm described by Hofierka, 2002. [1]_

Parameters:

Name Type Description Default
timestamps DatetimeIndex | None

timestamps

required
linke_turbidity_factor_series LinkeTurbidityFactor

linke_turbidity_factor_series

LinkeTurbidityFactor()
optical_air_mass_series OpticalAirMass

optical_air_mass_series

[OPTICAL_AIR_MASS_TIME_SERIES_DEFAULT]
clip_to_physically_possible_limits bool

clip_to_physically_possible_limits

True
solar_constant float

solar_constant

SOLAR_CONSTANT
eccentricity_phase_offset float

eccentricity_phase_offset

ECCENTRICITY_PHASE_OFFSET
eccentricity_amplitude float

eccentricity_amplitude

ECCENTRICITY_CORRECTION_FACTOR
dtype str

dtype

DATA_TYPE_DEFAULT
array_backend str

array_backend

ARRAY_BACKEND_DEFAULT
verbose int

verbose

VERBOSE_LEVEL_DEFAULT
log int

log

LOG_LEVEL_DEFAULT
fingerprint bool

fingerprint

required

Returns:

Type Description
DirectNormalIrradiance
Notes

B0c = G0 exp {-0.8662 TLK m δR(m)}

where : - -0.8662 TLK is the air mass 2 Linke atmospheric turbidity factor [dimensionless] corrected by Kasten [24].

  • m is the relative optical air mass [-] calculated using the formula:

m = (p/p0)/(sin h0ref + 0.50572 (h0ref + 6.07995)-1.6364)

where :

  • h0ref is the corrected solar altitude h0 in degrees by the atmospheric refraction component ∆h0ref

where :

  • ∆h0ref = 0.061359 (0.1594+1.123 h0 + 0.065656 h02)/(1 + 28.9344 h0 + 277.3971 h02)
  • h0ref = h0 + ∆h0ref

  • The p/p0 component is correction for given elevation z [m]:

    p/p0 = exp (-z/8434.5)

  • δR(m) is the Rayleigh optical thickness at air mass m and is calculated according to the improved formula by Kasten as follows:

  • for m <= 20:

δR(m) = 1/(6.6296 + 1.7513 m - 0.1202 m2 + 0.0065 m3 - 0.00013 m4)

  • for m > 20

δR(m) = 1/(10.4 + 0.718 m)

References

.. [1] Hofierka, J. (2002). Some title of the paper. Journal Name, vol(issue), pages.

Source code in pvgisprototype/algorithms/hofierka/irradiance/direct/clear_sky/normal.py
@log_function_call
@custom_cached
def calculate_direct_normal_irradiance_hofierka(
    timestamps: DatetimeIndex | None,
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    optical_air_mass_series: OpticalAirMass = [
        OPTICAL_AIR_MASS_TIME_SERIES_DEFAULT
    ],  # REVIEW-ME + ?
    clip_to_physically_possible_limits: bool = True,
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> DirectNormalIrradiance:
    """Calculate the direct normal irradiance.

    The direct normal irradiance attenuated by the cloudless atmosphere,
    represents the amount of solar radiation received per unit area by a
    surface that is perpendicular (normal) to the rays that come in a straight
    line from the direction of the sun at its current position in the sky.

    This function implements the algorithm described by Hofierka, 2002. [1]_

    Parameters
    ----------
    timestamps : DatetimeIndex | None
        timestamps
    linke_turbidity_factor_series : LinkeTurbidityFactor
        linke_turbidity_factor_series
    optical_air_mass_series : OpticalAirMass
        optical_air_mass_series
    clip_to_physically_possible_limits : bool
        clip_to_physically_possible_limits
    solar_constant : float
        solar_constant
    eccentricity_phase_offset : float
        eccentricity_phase_offset
    eccentricity_amplitude : float
        eccentricity_amplitude
    dtype : str
        dtype
    array_backend : str
        array_backend
    verbose : int
        verbose
    log : int
        log
    fingerprint : bool
        fingerprint

    Returns
    -------
    DirectNormalIrradiance

    Notes
    -----
    B0c = G0 exp {-0.8662 TLK m δR(m)}

    where :
    - -0.8662 TLK is the air mass 2 Linke atmospheric turbidity factor [dimensionless] corrected by Kasten [24].

    - m is the relative optical air mass [-] calculated using the formula:

      m = (p/p0)/(sin h0ref + 0.50572 (h0ref + 6.07995)-1.6364)

      where :

      - h0ref is the corrected solar altitude h0 in degrees by the atmospheric refraction component ∆h0ref

      where : 

      - ∆h0ref = 0.061359 (0.1594+1.123 h0 + 0.065656 h02)/(1 + 28.9344 h0 + 277.3971 h02)
      - h0ref = h0 + ∆h0ref

      - The p/p0 component is correction for given elevation z [m]:

        p/p0 = exp (-z/8434.5)

    - δR(m) is the Rayleigh optical thickness at air mass m and is calculated according to the improved formula by Kasten as follows:

    - for m <= 20:

      δR(m) = 1/(6.6296 + 1.7513 m - 0.1202 m2 + 0.0065 m3 - 0.00013 m4)

    - for m > 20

      δR(m) = 1/(10.4 + 0.718 m)

    References
    ----------
    .. [1] Hofierka, J. (2002). Some title of the paper. Journal Name, vol(issue), pages.

    """
    extraterrestrial_normal_irradiance_series = calculate_extraterrestrial_normal_irradiance_hofierka(
            timestamps=timestamps,
            solar_constant=solar_constant,
            eccentricity_phase_offset=eccentricity_phase_offset,
            eccentricity_amplitude=eccentricity_amplitude,
            dtype=dtype,
            array_backend=array_backend,
        )
    corrected_linke_turbidity_factor_series = correct_linke_turbidity_factor_series(
        linke_turbidity_factor_series,
        verbose=verbose,
    )
    rayleigh_optical_thickness_series = calculate_rayleigh_optical_thickness_series(
        optical_air_mass_series,
        verbose=verbose,
    )  # _quite_ high when the sun is below the horizon. Makes sense ?

    # Calculate
    with np.errstate(divide="ignore", invalid="ignore", over="ignore"):
        exponent = (
            corrected_linke_turbidity_factor_series.value
            * optical_air_mass_series.value
            * rayleigh_optical_thickness_series.value
        )
        direct_normal_irradiance_series = extraterrestrial_normal_irradiance_series.value * np.exp(exponent)

    out_of_range, out_of_range_index = identify_values_out_of_range(
        series=direct_normal_irradiance_series,
        shape=timestamps.shape,
        data_model=DirectNormalIrradiance(),
        clip_to_physically_possible_limits=clip_to_physically_possible_limits,
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=direct_normal_irradiance_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return DirectNormalIrradiance(
        value=direct_normal_irradiance_series,
        out_of_range=out_of_range,
        out_of_range_index=out_of_range_index,
        #
        extraterrestrial_normal_irradiance=extraterrestrial_normal_irradiance_series,
        linke_turbidity_factor_adjusted=corrected_linke_turbidity_factor_series,
        linke_turbidity_factor=linke_turbidity_factor_series,
        rayleigh_optical_thickness=rayleigh_optical_thickness_series,
        optical_air_mass=optical_air_mass_series,
        #
        solar_constant=solar_constant,
        eccentricity_phase_offset=eccentricity_phase_offset,
        eccentricity_amplitude=eccentricity_amplitude,
    )
inclined

This Python module is part of PVGIS' API. It implements functions to calculate the direct solar irradiance.

Direct or beam irradiance is one of the main components of solar irradiance. It comes perpendicular from the Sun and is not scattered before it irradiates a surface.

During a cloudy day the sunlight will be partially absorbed and scattered by different air molecules. The latter part is defined as the diffuse irradiance. The remaining part is the direct irradiance.

Functions:

Name Description
calculate_direct_inclined_irradiance_hofierka

Calculate the direct irradiance incident on a tilted surface [W*m-2].

calculate_direct_inclined_irradiance_hofierka
calculate_direct_inclined_irradiance_hofierka(
    timestamps: DatetimeIndex | None = DatetimeIndex(
        [now(tz="UTC")]
    ),
    timezone: ZoneInfo | None = ZoneInfo("UTC"),
    direct_horizontal_irradiance: ndarray | None = None,
    apply_reflectivity_factor: bool = True,
    solar_incidence_series: SolarIncidence | None = None,
    solar_altitude_series: SolarAltitude | None = None,
    surface_in_shade_series: LocationShading | None = None,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> DirectInclinedIrradianceFromExternalData

Calculate the direct irradiance incident on a tilted surface [W*m-2].

Calculate the direct irradiance on an inclined surface based on the solar radiation model by Hofierka, 2002. [1]_

Notes

Bic = B0c sin δexp (equation 11)

or

  B   ⋅ sin ⎛δ   ⎞
   hc       ⎝ exp⎠         ⎛ W ⎞

B = ──────────────── in ⎜───⎟ ic sin ⎛h ⎞ ⎜ -2⎟ ⎝ 0⎠ ⎝m ⎠

(equation 12)

where :

  • δexp is the solar incidence angle measured between the sun and an inclined surface defined in equation (16).

or else :

Direct Inclined = Direct Horizontal * sin( Solar Incidence ) / sin( Solar Altitude )

The implementation by Hofierka (2002) uses the solar incidence angle between the sun-vector and the plane of the reference surface (as per Jenčo, 1992). This is very important and relates to the hardcoded value True for the complementary_incidence_angle input parameter of the function. We call this angle (definition) the complementary incidence angle.

For the losses due to reflectivity, the incidence angle modifier by Martin & Ruiz (2005) expects the incidence angle between the sun-vector and the surface-normal. Hence, the respective call of the function calculate_reflectivity_factor_for_direct_irradiance_series(), expects the complement of the angle defined by Jenčo (1992). We call the incidence angle expected by the incidence angle modifier by Martin & Ruiz (2005) the typical incidence angle.

See also the documentation of the function calculate_solar_incidence_series_jenco().

References

.. [1] Hofierka, J. (2002). Some title of the paper. Journal Name, vol(issue), pages.

Source code in pvgisprototype/algorithms/hofierka/irradiance/direct/inclined.py
@log_function_call
@custom_cached
def calculate_direct_inclined_irradiance_hofierka(
    timestamps: DatetimeIndex | None = DatetimeIndex([Timestamp.now(tz='UTC')]),
    timezone: ZoneInfo | None = ZoneInfo('UTC'),
    direct_horizontal_irradiance: ndarray | None = None,
    apply_reflectivity_factor: bool = True,
    solar_incidence_series: SolarIncidence | None = None,
    solar_altitude_series: SolarAltitude | None = None,
    # solar_azimuth_series: SolarAzimuth | None = None,
    surface_in_shade_series: LocationShading | None = None,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> DirectInclinedIrradianceFromExternalData:
    """Calculate the direct irradiance incident on a tilted surface [W*m-2].

    Calculate the direct irradiance on an inclined surface based on the
    solar radiation model by Hofierka, 2002. [1]_

    Notes
    -----
    Bic = B0c sin δexp (equation 11)

    or

          B   ⋅ sin ⎛δ   ⎞
           hc       ⎝ exp⎠         ⎛ W ⎞
    B   = ────────────────     in  ⎜───⎟
     ic       sin ⎛h ⎞             ⎜ -2⎟
                  ⎝ 0⎠             ⎝m  ⎠

        (equation 12)

    where :

    - δexp is the solar incidence angle measured between the sun and an
      inclined surface defined in equation (16).

    or else :

        Direct Inclined = Direct Horizontal * sin( Solar Incidence ) / sin( Solar Altitude )

    The implementation by Hofierka (2002) uses the solar incidence angle
    between the sun-vector and the plane of the reference surface (as per Jenčo,
    1992). This is very important and relates to the hardcoded value `True` for
    the `complementary_incidence_angle` input parameter of the function. We
    call this angle (definition) the _complementary_ incidence angle.

    For the losses due to reflectivity, the incidence angle modifier by Martin
    & Ruiz (2005) expects the incidence angle between the sun-vector and the
    surface-normal. Hence, the respective call of the function
    `calculate_reflectivity_factor_for_direct_irradiance_series()`,
    expects the complement of the angle defined by Jenčo (1992). We call the
    incidence angle expected by the incidence angle modifier by Martin & Ruiz
    (2005) the _typical_ incidence angle.

    See also the documentation of the function
    `calculate_solar_incidence_series_jenco()`.

    References
    ----------
    .. [1] Hofierka, J. (2002). Some title of the paper. Journal Name, vol(issue), pages.

    """
    # Create a Sunlit surface time series mask :
    # - solar altitude > 0
    # - surface not in shade
    # - solar incidence > 0
    mask_solar_altitude_positive = solar_altitude_series.radians > 0
    # Here we use the _complementary_ solar incidence angle,
    # sun-to-surface-plane as per Jenčo 1992.
    mask_solar_incidence_positive = solar_incidence_series.radians > 0
    mask_not_in_shade = ~surface_in_shade_series.value
    mask_sunlit_surface_series = numpy.logical_and.reduce(
        (mask_solar_altitude_positive, mask_solar_incidence_positive, mask_not_in_shade)
    )
    # Else, the following runs:
    # --------------------------------- Review & Add ?
    # 1. surface is shaded
    # 3. solar incidence = 0
    # --------------------------------- Review & Add ?
    # ========================================================================
    if not isinstance(direct_horizontal_irradiance, ndarray):
        logger.error(
            "The `direct_horizontal_irradiance` should be a NumPy array at this point !",
            alt = ":information: [bold red]The [code]direct_horizontal_irradiance[/code] input should be a [code]NumPy array[/code] at this point ![/bold red]",
        )
        raise ValueError(
            ":information: The `direct_horizontal_irradiance` input should be a NumPy array at this point !",
        )
    # However, generate a native data model for it :
    direct_horizontal_irradiance = DirectHorizontalIrradianceFromExternalData(
        value=direct_horizontal_irradiance
    )
    try:
        # the number of timestamps should match the number of "x" values
        if verbose > 0:
            logger.info(
                "\ni [bold]Calculating[/bold] the [magenta]direct inclined irradiance[/magenta] .."
            )
        compare_temporal_resolution(
            timestamps, direct_horizontal_irradiance.value
        )

        # Else, the following runs:
        # --------------------------------- Review & Add ?
        # 1. surface is shaded
        # 3. solar incidence = 0
        # --------------------------------- Review & Add ?
        # ========================================================================

        # Initialize the direct irradiance series to zeros
        # array_parameters = {
        #     "shape": timestamps.shape,
        #     "dtype": dtype,
        #     "init_method": "zeros",
        #     "backend": array_backend,
        # }  # Borrow shape from timestamps
        # direct_inclined_irradiance_series = create_array(**array_parameters)
        # if mask_sunlit_surface_series.any():
        direct_inclined_irradiance_series = (
        direct_horizontal_irradiance.value
        * sin(
            solar_incidence_series.radians
        )  # Should be the _complementary_ incidence angle!
        / sin(solar_altitude_series.radians)
    )
    except ZeroDivisionError:
        logger.error(
            "Error: Division by zero in calculating the direct inclined irradiance!"
        )
        logger.debug("Is the solar altitude angle zero ?")
        # should this return something? Like in r.sun's simpler's approach?
        raise ValueError

    if not apply_reflectivity_factor:
        direct_inclined_irradiance_before_reflectivity_series = None
        direct_inclined_irradiance_reflectivity_factor_series = None

    else:
        # Calculate the reflectivity factor
        #
        # per Martin & Ruiz 2005 :
        # expects the _typical_ sun-vector-to-normal-of-surface incidence angles
        # which is the _complement_ of the incidence angle per Hofierka 2002.
        # Hence, we need to convert !
        direct_inclined_irradiance_reflectivity_factor_series = (
            calculate_reflectivity_factor_for_direct_irradiance_series(
                solar_incidence_series=(pi / 2 - solar_incidence_series.radians),
                verbose=0,
            )
        )

        # Apply the reflectivity factor
        direct_inclined_irradiance_series *= (
            direct_inclined_irradiance_reflectivity_factor_series
        )

        # Avoid copying to save memory and time ... ? ----------------- Is this safe ? -
        with errstate(divide="ignore", invalid="ignore"):
            # this quantity is exclusively generated for the output dictionary !
            direct_inclined_irradiance_before_reflectivity_series = where(
                direct_inclined_irradiance_reflectivity_factor_series != 0,
                direct_inclined_irradiance_series
                / direct_inclined_irradiance_reflectivity_factor_series,
                0,
            )
        # ------------------------------------------------------------------------------

    out_of_range, out_of_range_index = identify_values_out_of_range(
        series=direct_inclined_irradiance_series,
        shape=timestamps.shape,
        data_model=DirectInclinedIrradianceFromExternalData(),
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=direct_inclined_irradiance_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return DirectInclinedIrradianceFromExternalData(
        value=direct_inclined_irradiance_series,
        out_of_range=out_of_range,
        out_of_range_index=out_of_range_index,
        ## Irradiance components
        direct_horizontal_irradiance=direct_horizontal_irradiance,
        reflected = calculate_reflectivity_effect(
                irradiance=direct_inclined_irradiance_before_reflectivity_series,
                reflectivity_factor=direct_inclined_irradiance_reflectivity_factor_series,
            ),
        reflectivity_factor=direct_inclined_irradiance_reflectivity_factor_series,
        value_before_reflectivity=direct_inclined_irradiance_before_reflectivity_series,
        ## Location
        surface_in_shade=surface_in_shade_series,
        solar_incidence=solar_incidence_series,
        solar_altitude=solar_altitude_series,
    )
normal

This Python module is part of PVGIS' Algorithms. It implements a function to calculate the direct normal solar irradiance.

Direct or beam irradiance is one of the main components of solar irradiance. It comes perpendicular from the Sun and is not scattered before it irradiates a surface.

During a cloudy day the sunlight will be partially absorbed and scattered by different air molecules. The latter part is defined as the diffuse irradiance. The remaining part is the direct irradiance.

Functions:

Name Description
calculate_direct_normal_from_horizontal_irradiance_hofierka

Calculate the direct normal from the direct horizontal irradiance.

calculate_direct_normal_from_horizontal_irradiance_hofierka
calculate_direct_normal_from_horizontal_irradiance_hofierka(
    direct_horizontal_irradiance: ndarray,
    solar_altitude_series: SolarAltitude | None = None,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = 0,
    fingerprint: bool = FINGERPRINT_FLAG_DEFAULT,
) -> DirectNormalFromHorizontalIrradiance

Calculate the direct normal from the direct horizontal irradiance.

The direct normal irradiance represents the amount of solar radiation received per unit area by a surface that is perpendicular (normal) to the rays that come in a straight line from the direction of the sun at its current position in the sky. This function calculates the direct normal irradiance based on the direct horizontal irradiance and the solar altitude.

Parameters:

Name Type Description Default
direct_horizontal_irradiance ndarray

direct_horizontal_irradiance

required
solar_altitude_series SolarAltitude | None

solar_altitude_series

None
dtype str

dtype

DATA_TYPE_DEFAULT
array_backend str

array_backend

ARRAY_BACKEND_DEFAULT
verbose int

verbose

VERBOSE_LEVEL_DEFAULT
log int

log

0
fingerprint bool

fingerprint

FINGERPRINT_FLAG_DEFAULT

Returns:

Type Description
DirectNormalFromHorizontalIrradiance
Notes
References

.. [1] Hofierka, J. (2002). Some title of the paper. Journal Name, vol(issue), pages.

Source code in pvgisprototype/algorithms/hofierka/irradiance/direct/normal.py
@log_function_call
@custom_cached
def calculate_direct_normal_from_horizontal_irradiance_hofierka(
    direct_horizontal_irradiance: ndarray,
    solar_altitude_series: SolarAltitude | None = None,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = 0,
    fingerprint: bool = FINGERPRINT_FLAG_DEFAULT,
) -> DirectNormalFromHorizontalIrradiance:
    """Calculate the direct normal from the direct horizontal irradiance.

    The direct normal irradiance represents the amount of solar radiation
    received per unit area by a surface that is perpendicular (normal) to the
    rays that come in a straight line from the direction of the sun at its
    current position in the sky. This function calculates the direct normal
    irradiance based on the direct horizontal irradiance and the solar
    altitude.

    Parameters
    ----------
    direct_horizontal_irradiance : ndarray
        direct_horizontal_irradiance
    solar_altitude_series : SolarAltitude | None
        solar_altitude_series
    dtype : str
        dtype
    array_backend : str
        array_backend
    verbose : int
        verbose
    log : int
        log
    fingerprint : bool
        fingerprint

    Returns
    -------
    DirectNormalFromHorizontalIrradiance

    Notes
    -----

    References
    ----------
    .. [1] Hofierka, J. (2002). Some title of the paper. Journal Name, vol(issue), pages.

    """
    mask_solar_altitude_positive = solar_altitude_series.radians > 0
    mask_not_in_shade = np.full_like(
        solar_altitude_series.radians, True
    )  # Stub, replace with actual condition
    mask = np.logical_and.reduce((mask_solar_altitude_positive, mask_not_in_shade))

    direct_normal_irradiance_series = np.zeros_like(solar_altitude_series.radians)
    if np.any(mask):
        direct_normal_irradiance_series[mask] = (
            direct_horizontal_irradiance / np.sin(solar_altitude_series.radians)
        )[mask]

    out_of_range, out_of_range_index = identify_values_out_of_range(
        series=direct_normal_irradiance_series,
        shape=solar_altitude_series.value.shape,
        data_model=DirectNormalFromHorizontalIrradiance(),
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=direct_normal_irradiance_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return DirectNormalFromHorizontalIrradiance(
        value=direct_normal_irradiance_series,
        out_of_range=out_of_range,
        out_of_range_index=out_of_range_index,
        direct_horizontal_irradiance=direct_horizontal_irradiance,
        solar_altitude=solar_altitude_series,
    )

extraterrestrial

Modules:

Name Description
horizontal
normal
horizontal

Functions:

Name Description
calculate_extraterrestrial_horizontal_irradiance_series_hofierka

Calculate the horizontal extraterrestrial irradiance over a period of time.

calculate_extraterrestrial_horizontal_irradiance_series_hofierka
calculate_extraterrestrial_horizontal_irradiance_series_hofierka(
    extraterrestrial_normal_irradiance: ExtraterrestrialNormalIrradiance = ExtraterrestrialNormalIrradiance(),
    solar_altitude_series: SolarAltitude = SolarAltitude(),
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = 0,
) -> ExtraterrestrialHorizontalIrradiance

Calculate the horizontal extraterrestrial irradiance over a period of time. This function implements the algorithm described by Hofierka [1]_.

Notes

In [1]_ : G0h = G0 ⋅ sin(h0) or else : G0 horizontal = G0 normal ⋅ sin(solar altitude)

In the context of PVGIS, one question is : does it make sense to have negative extraterrestrial horizontal irradiance for moments in time when the sun is below the horizon (i.e. solar altitude < 0 radians or degrees) ?

For PVGIS the answer is no, hence the output horizontal extraterrestrial irradiance is set to 0 for this moments.

References

.. [1] Hofierka, J. (2002). Some title of the paper. Journal Name, vol(issue), pages.

Source code in pvgisprototype/algorithms/hofierka/irradiance/extraterrestrial/horizontal.py
@log_function_call
@custom_cached
def calculate_extraterrestrial_horizontal_irradiance_series_hofierka(
    extraterrestrial_normal_irradiance: ExtraterrestrialNormalIrradiance = ExtraterrestrialNormalIrradiance(),
    solar_altitude_series: SolarAltitude = SolarAltitude(),
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = 0,
) -> ExtraterrestrialHorizontalIrradiance:
    """
    Calculate the horizontal extraterrestrial irradiance over a period of time.
    This function implements the algorithm described by Hofierka [1]_.

    Notes
    -----
    In [1]_ : G0h = G0 ⋅ sin(h0)  or else : G0 horizontal = G0 normal ⋅ sin(solar altitude)


    In the context of PVGIS, one question is : does it make sense to have negative
    extraterrestrial horizontal irradiance for moments in time when the sun is
    below the horizon (i.e. solar altitude < 0 radians or degrees) ?

    For PVGIS the answer is no, hence the output horizontal extraterrestrial
    irradiance is set to 0 for this moments.

    References
    ----------
    .. [1] Hofierka, J. (2002). Some title of the paper. Journal Name, vol(issue), pages.

    """
    if verbose > 0:
        logger.info(
            ":information: Modelling the horizontal extraterrestrial irradiance",
            alt=":information: [bold]Modelling[/bold] the horizontal extraterrestrial irradiance",
        )
    extraterrestrial_horizontal_irradiance = (
        extraterrestrial_normal_irradiance.value * np.sin(solar_altitude_series.radians)
    )
    extraterrestrial_horizontal_irradiance[solar_altitude_series.radians < 0] = (
        0  # In the context of PVGIS, does it make sense to have negative extraterrestrial horizontal irradiance
    )

    out_of_range, out_of_range_index = identify_values_out_of_range(
        series=extraterrestrial_horizontal_irradiance,
        shape=solar_altitude_series.value.shape,
        data_model=ExtraterrestrialHorizontalIrradiance(),
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=extraterrestrial_horizontal_irradiance,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return ExtraterrestrialHorizontalIrradiance(
        value=extraterrestrial_horizontal_irradiance,
        out_of_range=out_of_range,
        out_of_range_index=out_of_range_index,
        normal=extraterrestrial_normal_irradiance,
        quality="Not validated!",
    )
normal

Functions:

Name Description
calculate_extraterrestrial_normal_irradiance_hofierka

Calculate the normal extraterrestrial irradiance over a period of time.

calculate_extraterrestrial_normal_irradiance_hofierka
calculate_extraterrestrial_normal_irradiance_hofierka(
    timestamps: DatetimeIndex | None,
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = 0,
) -> ExtraterrestrialNormalIrradiance

Calculate the normal extraterrestrial irradiance over a period of time. This function implements the algorithm described by Hofierka [1]_.

Outside the atmosphere, at the mean solar distance, the direct (beam) irradiance, also known as the solar constant (I0), is 1367 W.m-2. The earth’s orbit is lightly eccentric and the sun- earth distance varies slightly across the year. Therefore a correction factor ε, to allow for the varying solar distance, is applied in calculation of the extraterrestrial irradiance G0 normal to the solar beam [W.m-2] :

G0 = I0 ⋅ ε (1)

where :

ε = 1 + 0.03344 cos (j’ - 0.048869) (2)

where the day angle j’ is in radians :

    j’ = 2 π j/365.25 (3)

and j is the day number which varies from 1 on January 1st to 365 (366) on December 31st.

Parameters:

Name Type Description Default
timestamps DatetimeIndex | None
required
solar_constant float
SOLAR_CONSTANT
eccentricity_phase_offset float
ECCENTRICITY_PHASE_OFFSET
eccentricity_amplitude float
ECCENTRICITY_CORRECTION_FACTOR
dtype str
DATA_TYPE_DEFAULT
array_backend str
ARRAY_BACKEND_DEFAULT
verbose int
VERBOSE_LEVEL_DEFAULT
log int
0

Returns:

Type Description
ExtraterrestrialNormalIrradiance
Notes

In [1]_ : G0h = G0 sin(h0) or else : G0 horizontal = G0 ⋅ sin(solar altitude)

References

.. [1] Hofierka, J. (2002). Some title of the paper. Journal Name, vol(issue), pages.

Source code in pvgisprototype/algorithms/hofierka/irradiance/extraterrestrial/normal.py
@log_function_call
@custom_cached
def calculate_extraterrestrial_normal_irradiance_hofierka(
    timestamps: DatetimeIndex | None,
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = 0,
) -> ExtraterrestrialNormalIrradiance:
    """
    Calculate the normal extraterrestrial irradiance over a period of time.
    This function implements the algorithm described by Hofierka [1]_.

    Outside the atmosphere, at the mean solar distance, the direct (beam) irradiance, also known as
    the solar constant (I0), is 1367 W.m-2. The earth’s orbit is lightly eccentric and the sun-
    earth distance varies slightly across the year. Therefore a correction factor ε, to allow for
    the varying solar distance, is applied in calculation of the
    extraterrestrial irradiance `G0` normal to the solar beam [W.m-2] :

        G0 = I0 ⋅ ε (1)
    where :

        ε = 1 + 0.03344 cos (j’ - 0.048869) (2)

        where the day angle j’ is in radians :

            j’ = 2 π j/365.25 (3)

    and `j` is the day number which varies from 1 on January 1st to 365 (366) on
    December 31st.

    Parameters
    ----------
    timestamps: DatetimeIndex

    solar_constant: float

    eccentricity_phase_offset: float

    eccentricity_amplitude: float

    dtype:

    array_backend:

    verbose: int

    log: int

    Returns
    -------
    ExtraterrestrialNormalIrradiance

    Notes
    -----
    In [1]_ : G0h = G0 sin(h0)  or else : G0 horizontal = G0 ⋅ sin(solar altitude)

    References
    ----------
    .. [1] Hofierka, J. (2002). Some title of the paper. Journal Name, vol(issue), pages.

    """
    years_in_timestamps = timestamps.year
    years, indices = unique(years_in_timestamps, return_inverse=True)
    days_per_year = get_days_per_year(years).astype(dtype)
    days_in_years = days_per_year[indices]
    day_of_year_series = timestamps.dayofyear.to_numpy().astype(dtype)
    # day angle == fractional year, hence : use model_fractional_year_series()
    day_angle_series = 2 * pi * day_of_year_series / days_in_years
    distance_correction_factor_series = 1 + eccentricity_amplitude * cos(
        day_angle_series - eccentricity_phase_offset
    )
    extraterrestrial_normal_irradiance = (
        solar_constant * distance_correction_factor_series
    )

    out_of_range, out_of_range_index = identify_values_out_of_range(
        series=extraterrestrial_normal_irradiance,
        shape=timestamps.shape,
        data_model=ExtraterrestrialNormalIrradiance(),
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=extraterrestrial_normal_irradiance,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return ExtraterrestrialNormalIrradiance(
        value=extraterrestrial_normal_irradiance,
        out_of_range=out_of_range,
        out_of_range_index=out_of_range_index,
        day_of_year=day_of_year_series,
        day_angle=day_angle_series,
        solar_constant=solar_constant,
        eccentricity_phase_offset=eccentricity_phase_offset,
        eccentricity_amplitude=eccentricity_amplitude,
        distance_correction_factor=distance_correction_factor_series,
        quality="Not validated!",
    )

shortwave

Modules:

Name Description
clear_sky
inclined

API module to calculate the global (shortwave) irradiance over a

clear_sky

Modules:

Name Description
inclined

API module to calculate the global (shortwave) irradiance over a

inclined

API module to calculate the global (shortwave) irradiance over a location for a period in time.

Functions:

Name Description
calculate_clear_sky_global_inclined_irradiance_hofierka

Calculate the global irradiance on an inclined surface [W.m-2]

calculate_clear_sky_global_inclined_irradiance_hofierka
calculate_clear_sky_global_inclined_irradiance_hofierka(
    longitude: float,
    latitude: float,
    elevation: float,
    surface_orientation: SurfaceOrientationModel = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTiltModel = SURFACE_TILT_DEFAULT,
    surface_tilt_horizontally_flat_panel_threshold: float = SURFACE_TILT_HORIZONTALLY_FLAT_PANEL_THRESHOLD,
    timestamps: DatetimeIndex = DatetimeIndex(
        [now(tz="UTC")]
    ),
    timezone: ZoneInfo | None = None,
    global_horizontal_irradiance: ndarray | None = None,
    direct_horizontal_irradiance: ndarray | None = None,
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    adjust_for_atmospheric_refraction: bool = True,
    albedo: float | None = ALBEDO_DEFAULT,
    apply_reflectivity_factor: bool = ANGULAR_LOSS_FACTOR_FLAG_DEFAULT,
    solar_altitude_series: SolarAltitude | None = None,
    solar_azimuth_series: SolarAzimuth | None = None,
    solar_incidence_series: SolarIncidence | None = None,
    solar_position_model: SolarPositionModel = noaa,
    surface_in_shade_series: NpNDArray | None = None,
    sun_horizon_position: List[
        SunHorizonPositionModel
    ] = SUN_HORIZON_POSITION_DEFAULT,
    shading_states: List[ShadingState] = [all],
    solar_time_model: SolarTimeModel = noaa,
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    fingerprint: bool = FINGERPRINT_FLAG_DEFAULT,
) -> GlobalInclinedIrradiance

Calculate the global irradiance on an inclined surface [W.m-2]

Calculate the global irradiance on an inclined surface as the sum of the direct, the diffuse and the ground-reflected radiation components. The radiation, selectively attenuated by the atmosphere, which is not reflected or scattered and reaches the surface directly is the direct radiation. The scattered radiation that reaches the ground is the diffuse radiation. In addition, a smaller part of radiation is reflected from the ground onto the inclined surface. Only small percents of reflected radiation contribute to inclined surfaces, thus it is sometimes ignored. PVGIS, however, inherits the solutions adopted in the r.sun solar radiation model in which both the diffuse and reflected radiation components are considered.

Source code in pvgisprototype/algorithms/hofierka/irradiance/shortwave/clear_sky/inclined.py
@log_function_call
@custom_cached
def calculate_clear_sky_global_inclined_irradiance_hofierka(
    longitude: float,
    latitude: float,
    elevation: float,
    surface_orientation: SurfaceOrientationModel = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTiltModel = SURFACE_TILT_DEFAULT,
    surface_tilt_horizontally_flat_panel_threshold: float = SURFACE_TILT_HORIZONTALLY_FLAT_PANEL_THRESHOLD,
    timestamps: DatetimeIndex = DatetimeIndex([Timestamp.now(tz='UTC')]),
    timezone: ZoneInfo | None = None,
    global_horizontal_irradiance: ndarray | None = None,
    direct_horizontal_irradiance: ndarray | None = None,
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    adjust_for_atmospheric_refraction: bool = True,
    # unrefracted_solar_zenith: UnrefractedSolarZenith | None = UNREFRACTED_SOLAR_ZENITH_ANGLE_DEFAULT,  # radians
    albedo: float | None = ALBEDO_DEFAULT,
    apply_reflectivity_factor: bool = ANGULAR_LOSS_FACTOR_FLAG_DEFAULT,
    solar_altitude_series: SolarAltitude | None = None,
    solar_azimuth_series: SolarAzimuth | None = None,
    solar_incidence_series: SolarIncidence | None = None,
    solar_position_model: SolarPositionModel = SolarPositionModel.noaa,
    surface_in_shade_series: NpNDArray | None = None,
    sun_horizon_position: List[SunHorizonPositionModel] = SUN_HORIZON_POSITION_DEFAULT,
    shading_states: List[ShadingState] = [ShadingState.all],
    solar_time_model: SolarTimeModel = SolarTimeModel.noaa,
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    validate_output:bool = VALIDATE_OUTPUT_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    fingerprint: bool = FINGERPRINT_FLAG_DEFAULT,
) -> GlobalInclinedIrradiance:
    """Calculate the global irradiance on an inclined surface [W.m-2]

    Calculate the global irradiance on an inclined surface as the sum of the
    direct, the diffuse and the ground-reflected radiation components.
    The radiation, selectively attenuated by the atmosphere, which is not
    reflected or scattered and reaches the surface directly is the direct
    radiation. The scattered radiation that reaches the ground is the
    diffuse radiation. In addition, a smaller part of radiation is reflected
    from the ground onto the inclined surface. Only small percents of reflected
    radiation contribute to inclined surfaces, thus it is sometimes ignored.
    PVGIS, however, inherits the solutions adopted in the r.sun solar radiation
    model in which both the diffuse and reflected radiation components are
    considered.

    """
    # In order to avoid unbound errors we pre-define `_series` objects
    array_parameters = {
        "shape": timestamps.shape,
        "dtype": dtype,
        "init_method": "zeros",
        "backend": array_backend,
    }  # Borrow shape from timestamps
    zero_array = create_array(**array_parameters)
    if not solar_azimuth_series:
        solar_azimuth_series = SolarAzimuth(
                value=zero_array,
                unit='Unitless',
                origin='Not Required!',
                )

    # # direct
    # # ----------------------------------------------- Important !
    # if direct_horizontal_irradiance is None:
    #     direct_horizontal_irradiance = zero_array
    # # Important ! -----------------------------------------------

    direct_inclined_irradiance_series = DirectInclinedIrradiance(
        value=zero_array,
    )
    # direct_inclined_irradiance_series.reflectivity = zero_array
    # direct_inclined_irradiance_series.value_before_reflectivity = zero_array
    # direct_inclined_irradiance_series.reflectivity_factor = zero_array
    # direct_inclined_irradiance_series.direct_horizontal_irradiance = direct_horizontal_irradiance
    # direct_inclined_irradiance_series.solar_incidence_model = solar_incidence_model
    # # direct_inclined_irradiance_series.solar_incidence.definition = 

    # # extraterrestrial normal to be fed indirectly to diffuse_inclined_irradiance
    # extraterrestrial_normal_irradiance = ExtraterrestrialNormalIrradiance(value=zero_array)
    # direct_normal_irradiance = DirectNormalIrradiance(
    #     value=zero_array,
    #     extraterrestrial_normal_irradiance=extraterrestrial_normal_irradiance,
    # )
    # direct_horizontal_irradiance = DirectHorizontalIrradiance(
    #     value=direct_horizontal_irradiance, direct_normal_irradiance=direct_normal_irradiance
    # )

    # diffuse sky-reflected
    diffuse_inclined_irradiance_series = DiffuseSkyReflectedInclinedIrradiance(
        value=zero_array,
        solar_azimuth=solar_azimuth_series,
        # direct_horizontal_irradiance=direct_horizontal_irradiance,
    )
    # diffuse_inclined_irradiance_series.reflected = zero_array
    # diffuse_inclined_irradiance_series.value_before_reflectivity = zero_array
    # diffuse_inclined_irradiance_series.reflectivity_factor = zero_array
    # diffuse_inclined_irradiance_series.diffuse_horizontal_irradiance = DiffuseSkyReflectedHorizontalIrradiance(value=zero_array)

    # diffuse ground-reflected
    # note : there is no ground-reflected _horizontal_ component as such !
    ground_reflected_inclined_irradiance_series = DiffuseGroundReflectedInclinedIrradiance(
        value=zero_array
    )
    # ground_reflected_inclined_irradiance_series.reflectivity = zero_array
    # ground_reflected_inclined_irradiance_series.value_before_reflectivity = zero_array
    # ground_reflected_inclined_irradiance_series.reflectivity_factor = zero_array
    global_inclined_irradiance_series = zero_array

    # Select which solar positions related to the horizon to process
    sun_horizon_positions = select_models(
        SunHorizonPositionModel, sun_horizon_position
    )  # Using a Typer callback fails !
    # and keep track of the position of the sun relative to the horizon
    sun_horizon_position_series = create_array(
        timestamps.shape, dtype="object", init_method="empty", backend=array_backend
    )

    # Following, create masks based on the solar altitude series --------

    # For sun below the horizon
    if SunHorizonPositionModel.below in sun_horizon_positions:
        mask_below_horizon = solar_altitude_series.value < 0
        sun_horizon_position_series[mask_below_horizon] = [SunHorizonPositionModel.below.value]
        if numpy.any(mask_below_horizon):
            logger.info(
                f"Positions of the sun below horizon :\n{sun_horizon_position_series}",
                alt=f"Positions of the sun [bold gray50]below horizon[/bold gray50] :\n{sun_horizon_position_series}"
            )
            # no incident radiance without direct sunlight !
            direct_inclined_irradiance_series.value[mask_below_horizon] = 0
            diffuse_inclined_irradiance_series.value[mask_below_horizon] = 0
            ground_reflected_inclined_irradiance_series.value[mask_below_horizon] = 0

    # For very low sun angles
    if SunHorizonPositionModel.low_angle in sun_horizon_positions:
        mask_low_angle = numpy.logical_and(
            solar_altitude_series.value >= 0,
            solar_altitude_series.value
            < solar_altitude_series.low_angle_threshold_radians,  # attribute in SolarAltitude data model
            sun_horizon_position_series == None,  # operate only on unset elements
        )
        sun_horizon_position_series[mask_low_angle] = [
            SunHorizonPositionModel.low_angle.value
        ]
        direct_inclined_irradiance_series.value[mask_low_angle] = (
            0  # Direct radiation is negligible
        )

    # For sun above the horizon
    if SunHorizonPositionModel.above in sun_horizon_positions:
        mask_above_horizon = numpy.logical_and(
            solar_altitude_series.value > 0,
            sun_horizon_position_series == None,  # operate only on unset elements
        )
        sun_horizon_position_series[mask_above_horizon] = [
            SunHorizonPositionModel.above.value
        ]

        # For sun above horizon and not in shade
        mask_not_in_shade = ~surface_in_shade_series.value
        mask_above_horizon_not_in_shade = numpy.logical_and(
            mask_above_horizon,
            mask_not_in_shade,
            sun_horizon_position_series == None,
        )

        if numpy.any(mask_above_horizon_not_in_shade):
            # sun_horizon_position_series[mask_above_horizon_not_in_shade] = [SunHorizonPositionModel.above.name]
            logger.info(
                f"Including positions of the sun above horizon and not in shade :\n{sun_horizon_position_series}",
                alt=f"Including positions of the sun [bold yellow]above horizon[/bold yellow] and [bold red]not in shade[/bold red] :\n{sun_horizon_position_series}"
            )
            if verbose > HASH_AFTER_THIS_VERBOSITY_LEVEL:
                logger.info(
                    "i Calculating the direct inclined irradiance for moments not in shade ..",
                    alt="i [bold]Calculating[/bold] the [magenta]direct inclined irradiance[/magenta] for moments not in shade .."
                )
            # if not read from external time series,
            # will calculate the clear-sky index !
        direct_inclined_irradiance_series = (
            calculate_clear_sky_direct_inclined_irradiance_hofierka(
                elevation=elevation,
                surface_orientation=surface_orientation,
                surface_tilt=surface_tilt,
                timestamps=timestamps,
                timezone=timezone,
                direct_horizontal_irradiance=direct_horizontal_irradiance,  # FixMe
                linke_turbidity_factor_series=linke_turbidity_factor_series,
                apply_reflectivity_factor=apply_reflectivity_factor,
                solar_incidence_series=solar_incidence_series,
                solar_altitude_series=solar_altitude_series,
                # solar_azimuth_series=solar_azimuth_series,
                surface_in_shade_series=surface_in_shade_series,
                solar_constant=solar_constant,
                eccentricity_phase_offset=eccentricity_phase_offset,
                eccentricity_amplitude=eccentricity_amplitude,
                dtype=dtype,
                array_backend=array_backend,
                validate_output=validate_output,
                verbose=verbose,
                log=log,
            )
        )
        direct_inclined_irradiance_series.build_output(
            verbose=verbose, fingerprint=fingerprint
        )

        # Calculate diffuse and reflected irradiance for sun above horizon
        if not numpy.any(mask_above_horizon):
            logger.info(
                "i [yellow bold]Apparently there is no moment of the sun above the horizon in the requested time series![/yellow bold] "
            )
        else:
            if verbose > HASH_AFTER_THIS_VERBOSITY_LEVEL:
                logger.info(
                    "i [bold]Calculating[/bold] the [magenta]diffuse inclined irradiance[/magenta] for daylight moments .."
                )
            diffuse_inclined_irradiance_series = (
                calculate_clear_sky_diffuse_inclined_irradiance_muneer(
                    elevation=elevation,
                    surface_orientation=surface_orientation,
                    surface_tilt=surface_tilt,
                    surface_tilt_horizontally_flat_panel_threshold=surface_tilt_horizontally_flat_panel_threshold,
                    timestamps=timestamps,
                    timezone=timezone,
                    global_horizontal_irradiance_series=global_horizontal_irradiance,
                    direct_horizontal_irradiance_series=direct_horizontal_irradiance,
                    linke_turbidity_factor_series=linke_turbidity_factor_series,
                    apply_reflectivity_factor=apply_reflectivity_factor,
                    solar_altitude_series=solar_altitude_series,
                    solar_azimuth_series=solar_azimuth_series,
                    solar_incidence_series=solar_incidence_series,
                    surface_in_shade_series=surface_in_shade_series,
                    shading_states=shading_states,
                    solar_constant=solar_constant,
                    eccentricity_phase_offset=eccentricity_phase_offset,
                    eccentricity_amplitude=eccentricity_amplitude,
                    dtype=dtype,
                    array_backend=array_backend,
                    verbose=verbose,
                    log=log,
                )
            )
            diffuse_inclined_irradiance_series.build_output(
                verbose=verbose, fingerprint=fingerprint
            )
            if verbose > HASH_AFTER_THIS_VERBOSITY_LEVEL:
                logger.info(
                    "i [bold]Calculating[/bold] the [magenta]reflected inclined irradiance[/magenta] for daylight moments .."
                )
            ground_reflected_inclined_irradiance_series = calculate_ground_reflected_inclined_irradiance_series(
                longitude=longitude,
                latitude=latitude,
                elevation=elevation,
                surface_orientation=surface_orientation,
                surface_tilt=surface_tilt,
                timestamps=timestamps,
                timezone=timezone,
                global_horizontal_irradiance=global_horizontal_irradiance,  # time series, optional
                linke_turbidity_factor_series=linke_turbidity_factor_series,
                adjust_for_atmospheric_refraction=adjust_for_atmospheric_refraction,
                # unrefracted_solar_zenith=unrefracted_solar_zenith,
                albedo=albedo,
                apply_reflectivity_factor=apply_reflectivity_factor,
                solar_position_model=solar_position_model,
                solar_time_model=solar_time_model,
                solar_constant=solar_constant,
                eccentricity_phase_offset=eccentricity_phase_offset,
                eccentricity_amplitude=eccentricity_amplitude,
                dtype=dtype,
                array_backend=array_backend,
                verbose=verbose,
                log=log,
                fingerprint=fingerprint,
            )

    # sum components
    if verbose > HASH_AFTER_THIS_VERBOSITY_LEVEL:
        logger.info(
            "\ni [bold]Calculating[/bold] the [magenta]global inclined irradiance[/magenta] .."
        )
    global_inclined_irradiance_series[mask_above_horizon_not_in_shade] += (
        direct_inclined_irradiance_series.value[mask_above_horizon_not_in_shade]
    )
    global_inclined_irradiance_series[mask_above_horizon] += (
        + diffuse_inclined_irradiance_series.value[mask_above_horizon]
        + ground_reflected_inclined_irradiance_series.value[mask_above_horizon]
    )
    global_inclined_reflectivity_series = (
        direct_inclined_irradiance_series.reflected
        + diffuse_inclined_irradiance_series.reflected
        + ground_reflected_inclined_irradiance_series.reflected
    )
    global_inclined_irradiance_before_reflectivity_series = (
        direct_inclined_irradiance_series.value_before_reflectivity
        + diffuse_inclined_irradiance_series.value_before_reflectivity
        + ground_reflected_inclined_irradiance_series.value_before_reflectivity
    )

    out_of_range, out_of_range_index = identify_values_out_of_range(
        series=global_inclined_irradiance_series,
        shape=timestamps.shape,
        data_model=GlobalInclinedIrradiance(),
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=global_inclined_irradiance_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return GlobalInclinedIrradiance(
        value=global_inclined_irradiance_series,
        out_of_range=out_of_range,
        out_of_range_index=out_of_range_index,
        #
        ## Inclined Irradiance Components
        direct_inclined_irradiance=direct_inclined_irradiance_series.value,
        diffuse_inclined_irradiance=diffuse_inclined_irradiance_series.value,
        ground_reflected_inclined_irradiance=ground_reflected_inclined_irradiance_series.value,
        #
        ## Loss due to Reflectivity
        reflected=global_inclined_reflectivity_series,
        direct_inclined_reflected=direct_inclined_irradiance_series.reflected,
        diffuse_inclined_reflected=diffuse_inclined_irradiance_series.reflected,
        ground_reflected_inclined_reflected=ground_reflected_inclined_irradiance_series.reflected,
        #
        ## Reflectivity Factor for Irradiance Components
        direct_inclined_reflectivity_factor=direct_inclined_irradiance_series.reflectivity_factor,
        diffuse_inclined_reflectivity_factor=diffuse_inclined_irradiance_series.reflectivity_factor,
        ground_reflected_inclined_reflectivity_factor=ground_reflected_inclined_irradiance_series.reflectivity_factor,
        #
        ## Reflectivity Coefficient which defines the Reflectivity Factor for Irradiance Components
        # direct_inclined_reflectivity_coefficient=direct_inclined_reflectivity_coefficient_series,
        diffuse_inclined_reflectivity_coefficient=diffuse_inclined_irradiance_series.reflectivity_coefficient,
        # ground_reflected_inclined_reflectivity_coefficient=ground_reflected_inclined_reflectivity_coefficient_series,
        #
        ## Inclined Irradiance before loss due to Reflectivity
        value_before_reflectivity=global_inclined_irradiance_before_reflectivity_series,
        direct_inclined_before_reflectivity=direct_inclined_irradiance_series.value_before_reflectivity,
        diffuse_inclined_before_reflectivity=diffuse_inclined_irradiance_series.value_before_reflectivity,
        ground_reflected_inclined_before_reflectivity=ground_reflected_inclined_irradiance_series.value_before_reflectivity,
        #
        ## Horizontal Irradiance Components
        global_horizontal_irradiance=global_horizontal_irradiance,
        direct_horizontal_irradiance=direct_inclined_irradiance_series.direct_horizontal_irradiance,
        diffuse_horizontal_irradiance=diffuse_inclined_irradiance_series.diffuse_horizontal_irradiance,
        #
        ## Components of the diffuse sky-reflected irradiance
        diffuse_sky_irradiance=diffuse_inclined_irradiance_series.diffuse_sky_irradiance,
        term_n=diffuse_inclined_irradiance_series.term_n,
        kb_ratio=diffuse_inclined_irradiance_series.kb_ratio,
        #
        ## Components of the Extraterrestrial irradiance
        extraterrestrial_horizontal_irradiance=diffuse_inclined_irradiance_series.extraterrestrial_horizontal_irradiance,
        extraterrestrial_normal_irradiance=diffuse_inclined_irradiance_series.extraterrestrial_normal_irradiance,
        linke_turbidity_factor=linke_turbidity_factor_series,
        #
        ## Location and Position
        elevation=elevation,
        surface_orientation=surface_orientation,
        surface_tilt=surface_tilt,
        sun_horizon_positions=sun_horizon_positions,  # states != sun_horizon_position
        #
        ## Solar Position parameters
        surface_in_shade=surface_in_shade_series,
        solar_incidence=direct_inclined_irradiance_series.solar_incidence,
        shading_state=diffuse_inclined_irradiance_series.shading_state,
        sun_horizon_position=sun_horizon_position_series,  # positions != sun_horizon_positions
        solar_altitude=solar_altitude_series,
        refracted_solar_altitude=direct_inclined_irradiance_series.refracted_solar_altitude,
        solar_azimuth=diffuse_inclined_irradiance_series.solar_azimuth,
        # azimuth_difference=azimuth_difference_series,
        #
        ## Positioning, Timing and Atmospheric algorithms
        solar_positioning_algorithm=direct_inclined_irradiance_series.solar_positioning_algorithm,
        solar_timing_algorithm=direct_inclined_irradiance_series.solar_timing_algorithm,
        adjusted_for_atmospheric_refraction=solar_altitude_series.adjusted_for_atmospheric_refraction,
        solar_incidence_model=direct_inclined_irradiance_series.solar_incidence_model,
        solar_incidence_definition=direct_inclined_irradiance_series.solar_incidence.definition,
        shading_algorithm=surface_in_shade_series.shading_algorithm,
        shading_states=shading_states,
        #
        ## Sources
        data_source=HOFIERKA_2002,
        solar_radiation_model=HOFIERKA_2002,
    )
inclined

API module to calculate the global (shortwave) irradiance over a location for a period in time.

Functions:

Name Description
calculate_global_inclined_irradiance_hofierka

Calculate the global irradiance on an inclined surface [W.m-2]

calculate_global_inclined_irradiance_hofierka
calculate_global_inclined_irradiance_hofierka(
    longitude: float,
    latitude: float,
    elevation: float,
    surface_orientation: SurfaceOrientationModel = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTiltModel = SURFACE_TILT_DEFAULT,
    surface_tilt_horizontally_flat_panel_threshold: float = SURFACE_TILT_HORIZONTALLY_FLAT_PANEL_THRESHOLD,
    timestamps: DatetimeIndex = DatetimeIndex(
        [now(tz="UTC")]
    ),
    timezone: ZoneInfo | None = ZoneInfo("UTC"),
    global_horizontal_irradiance: ndarray = array(
        [], dtype=DATA_TYPE_DEFAULT
    ),
    direct_horizontal_irradiance: ndarray = array(
        [], dtype=DATA_TYPE_DEFAULT
    ),
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    adjust_for_atmospheric_refraction: bool = True,
    albedo: float | None = ALBEDO_DEFAULT,
    apply_reflectivity_factor: bool = ANGULAR_LOSS_FACTOR_FLAG_DEFAULT,
    solar_altitude_series: SolarAltitude | None = None,
    solar_azimuth_series: SolarAzimuth | None = None,
    solar_incidence_series: SolarIncidence | None = None,
    solar_position_model: SolarPositionModel = noaa,
    surface_in_shade_series: NpNDArray | None = None,
    sun_horizon_position: List[
        SunHorizonPositionModel
    ] = SUN_HORIZON_POSITION_DEFAULT,
    shading_states: List[ShadingState] = [all],
    solar_time_model: SolarTimeModel = noaa,
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    fingerprint: bool = FINGERPRINT_FLAG_DEFAULT,
)

Calculate the global irradiance on an inclined surface [W.m-2]

Calculate the global irradiance on an inclined surface as the sum of the direct, the diffuse and the ground-reflected radiation components. The radiation, selectively attenuated by the atmosphere, which is not reflected or scattered and reaches the surface directly is the direct radiation. The scattered radiation that reaches the ground is the diffuse radiation. In addition, a smaller part of radiation is reflected from the ground onto the inclined surface. Only small percents of reflected radiation contribute to inclined surfaces, thus it is sometimes ignored. PVGIS, however, inherits the solutions adopted in the r.sun solar radiation model in which both the diffuse and reflected radiation components are considered.

Source code in pvgisprototype/algorithms/hofierka/irradiance/shortwave/inclined.py
@log_function_call
@custom_cached
def calculate_global_inclined_irradiance_hofierka(
    longitude: float,
    latitude: float,
    elevation: float,
    surface_orientation: SurfaceOrientationModel = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTiltModel = SURFACE_TILT_DEFAULT,
    surface_tilt_horizontally_flat_panel_threshold: float = SURFACE_TILT_HORIZONTALLY_FLAT_PANEL_THRESHOLD,
    timestamps: DatetimeIndex = DatetimeIndex([Timestamp.now(tz='UTC')]),
    timezone: ZoneInfo | None = ZoneInfo("UTC"),
    global_horizontal_irradiance: ndarray = numpy.array([], dtype=DATA_TYPE_DEFAULT),
    direct_horizontal_irradiance: ndarray = numpy.array([], dtype=DATA_TYPE_DEFAULT), 
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    adjust_for_atmospheric_refraction: bool = True,
    # unrefracted_solar_zenith: UnrefractedSolarZenith | None = UNREFRACTED_SOLAR_ZENITH_ANGLE_DEFAULT,  # radians
    albedo: float | None = ALBEDO_DEFAULT,
    apply_reflectivity_factor: bool = ANGULAR_LOSS_FACTOR_FLAG_DEFAULT,
    solar_altitude_series: SolarAltitude | None = None,
    solar_azimuth_series: SolarAzimuth | None = None,
    solar_incidence_series: SolarIncidence | None = None,
    solar_position_model: SolarPositionModel = SolarPositionModel.noaa,
    surface_in_shade_series: NpNDArray | None = None,
    sun_horizon_position: List[SunHorizonPositionModel] = SUN_HORIZON_POSITION_DEFAULT,
    shading_states: List[ShadingState] = [ShadingState.all],
    solar_time_model: SolarTimeModel = SolarTimeModel.noaa,
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    validate_output:bool = VALIDATE_OUTPUT_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    fingerprint: bool = FINGERPRINT_FLAG_DEFAULT,
):
    """Calculate the global irradiance on an inclined surface [W.m-2]

    Calculate the global irradiance on an inclined surface as the sum of the
    direct, the diffuse and the ground-reflected radiation components.
    The radiation, selectively attenuated by the atmosphere, which is not
    reflected or scattered and reaches the surface directly is the direct
    radiation. The scattered radiation that reaches the ground is the
    diffuse radiation. In addition, a smaller part of radiation is reflected
    from the ground onto the inclined surface. Only small percents of reflected
    radiation contribute to inclined surfaces, thus it is sometimes ignored.
    PVGIS, however, inherits the solutions adopted in the r.sun solar radiation
    model in which both the diffuse and reflected radiation components are
    considered.

    """

    # In order to avoid unbound errors we pre-define `_series` objects
    array_parameters = {
        "shape": timestamps.shape,
        "dtype": dtype,
        "init_method": "zeros",
        "backend": array_backend,
    }  # Borrow shape from timestamps
    zero_array = create_array(**array_parameters)
    extended_array_parameters = {
        "shape": timestamps.shape,
        "dtype": dtype,
        "backend": array_backend,
    }
    unset_series = create_array(**extended_array_parameters, init_method="unset")

    # # direct
    # # ----------------------------------------------- Important !
    # if direct_horizontal_irradiance is None:
    #     direct_horizontal_irradiance = zero_array
    # # Important ! -----------------------------------------------

    direct_inclined_irradiance_series = DirectInclinedIrradiance(
        value=zero_array,
        solar_incidence=SolarIncidence(value=unset_series),
    )
    direct_inclined_irradiance_series.reflected = zero_array
    direct_inclined_irradiance_series.value_before_reflectivity = zero_array
    direct_inclined_irradiance_series.reflectivity_factor = zero_array
    direct_normal_irradiance = DirectNormalIrradiance(
        value=zero_array,
        # extraterrestrial_normal_irradiance=extraterrestrial_normal_irradiance,
    )
    direct_inclined_irradiance_series.direct_horizontal_irradiance = (
        DirectHorizontalIrradianceFromExternalData(
            value=direct_horizontal_irradiance,
            direct_normal_irradiance=direct_normal_irradiance,
        )
    )
    # direct_inclined_irradiance_series.solar_incidence_model = solar_incidence_model
    # # direct_inclined_irradiance_series.solar_incidence.definition = 

    no_earth_orbit = {
        'eccentricity_phase_offset': None,
        'eccentricity_amplitude': None,
    }
    # extraterrestrial normal to be fed indirectly to diffuse_inclined_irradiance
    extraterrestrial_normal_irradiance_series = ExtraterrestrialNormalIrradiance(
        value=unset_series,
        unit=NOT_AVAILABLE,
        day_angle=unset_series,
        solar_constant=None,
        **no_earth_orbit,
        distance_correction_factor=None,
    )
    extraterrestrial_horizontal_irradiance_series = ExtraterrestrialHorizontalIrradiance(
        value=unset_series,
        unit=NOT_AVAILABLE,
        day_angle=unset_series,
        solar_constant=None,
        **no_earth_orbit,
        distance_correction_factor=None,
    )

    # diffuse sky-reflected
    diffuse_inclined_irradiance_series = DiffuseSkyReflectedInclinedIrradiance(
        value=zero_array,
        extraterrestrial_normal_irradiance=extraterrestrial_normal_irradiance_series,
        extraterrestrial_horizontal_irradiance=extraterrestrial_horizontal_irradiance_series,
        solar_azimuth=SolarAzimuth(value=unset_series),
        # direct_horizontal_irradiance=direct_horizontal_irradiance,
    )
    diffuse_inclined_irradiance_series.reflected = zero_array
    diffuse_inclined_irradiance_series.value_before_reflectivity = zero_array
    diffuse_inclined_irradiance_series.reflectivity_factor = zero_array
    diffuse_inclined_irradiance_series.diffuse_horizontal_irradiance = DiffuseSkyReflectedHorizontalIrradianceFromExternalData(value=zero_array)

    # diffuse ground-reflected
    # note : there is no ground-reflected _horizontal_ component as such !
    ground_reflected_inclined_irradiance_series = DiffuseGroundReflectedInclinedIrradiance(
        value=zero_array
    )
    ground_reflected_inclined_irradiance_series.reflected = zero_array
    ground_reflected_inclined_irradiance_series.value_before_reflectivity = zero_array
    ground_reflected_inclined_irradiance_series.reflectivity_factor = zero_array
    global_inclined_irradiance_series = zero_array

    # Select which solar positions related to the horizon to process
    sun_horizon_positions = select_models(
        SunHorizonPositionModel, sun_horizon_position
    )  # Using a Typer callback fails !
    # and keep track of the position of the sun relative to the horizon
    sun_horizon_position_series = create_array(
        timestamps.shape, dtype="object", init_method="empty", backend=array_backend
    )
    # Following, create masks based on the solar altitude series --------

    # For sun below the horizon
    if SunHorizonPositionModel.below in sun_horizon_positions:
        mask_below_horizon = solar_altitude_series.value < 0
        sun_horizon_position_series[mask_below_horizon] = [SunHorizonPositionModel.below.value]
        if numpy.any(mask_below_horizon):
            logger.info(
                f"Positions of the sun below horizon :\n{sun_horizon_position_series}",
                alt=f"Positions of the sun [bold gray50]below horizon[/bold gray50] :\n{sun_horizon_position_series}"
            )
            # no incident radiance without direct sunlight !
            direct_inclined_irradiance_series.value[mask_below_horizon] = 0
            diffuse_inclined_irradiance_series.value[mask_below_horizon] = 0
            ground_reflected_inclined_irradiance_series.value[mask_below_horizon] = 0

    # For very low sun angles
    if SunHorizonPositionModel.low_angle in sun_horizon_positions:
        mask_low_angle = numpy.logical_and(
            solar_altitude_series.value >= 0,
            solar_altitude_series.value < solar_altitude_series.low_angle_threshold_radians,  # attribute in SolarAltitude data model
            sun_horizon_position_series == None,  # operate only on unset elements
        )
        sun_horizon_position_series[mask_low_angle] = [
            SunHorizonPositionModel.low_angle.value
        ]
        direct_inclined_irradiance_series.value[mask_low_angle] = (
            0  # Direct radiation is negligible
        )

    # For sun above the horizon
    if SunHorizonPositionModel.above in sun_horizon_positions:
        mask_above_horizon = numpy.logical_and(
            solar_altitude_series.value > 0,
            sun_horizon_position_series == None,  # operate only on unset elements
        )
        sun_horizon_position_series[mask_above_horizon] = [
            SunHorizonPositionModel.above.value
        ]

        # For sun above horizon and not in shade
        mask_not_in_shade = ~surface_in_shade_series.value
        mask_above_horizon_not_in_shade = numpy.logical_and(
            mask_above_horizon,
            mask_not_in_shade,
            sun_horizon_position_series == None,
        )

        if numpy.any(mask_above_horizon_not_in_shade):
            # sun_horizon_position_series[mask_above_horizon_not_in_shade] = [SunHorizonPositionModel.above.name]
            logger.info(
                f"Including positions of the sun above horizon and not in shade :\n{sun_horizon_position_series}",
                alt=f"Including positions of the sun [bold yellow]above horizon[/bold yellow] and [bold red]not in shade[/bold red] :\n{sun_horizon_position_series}"
            )
            if verbose > HASH_AFTER_THIS_VERBOSITY_LEVEL:
                logger.info(
                    "i Calculating the direct inclined irradiance for moments not in shade ..",
                    alt="i [bold]Calculating[/bold] the [magenta]direct inclined irradiance[/magenta] for moments not in shade .."
                )
            # if not read from external time series,
            # will calculate the clear-sky index !
            direct_inclined_irradiance_series = calculate_direct_inclined_irradiance_hofierka(
                timestamps=timestamps,
                timezone=timezone,
                direct_horizontal_irradiance=direct_horizontal_irradiance,
                apply_reflectivity_factor=apply_reflectivity_factor,
                solar_incidence_series=solar_incidence_series,
                solar_altitude_series=solar_altitude_series,
                # solar_azimuth_series=solar_azimuth_series,
                surface_in_shade_series=surface_in_shade_series,
                dtype=dtype,
                array_backend=array_backend,
                validate_output=validate_output,
                verbose=verbose,
                log=log,
            )
            direct_inclined_irradiance_series.build_output(
                verbose=verbose, fingerprint=fingerprint
            )

        # Calculate diffuse and reflected irradiance for sun above horizon
        if not numpy.any(mask_above_horizon):
            logger.info(
                "i [yellow bold]Apparently there is no moment of the sun above the horizon in the requested time series![/yellow bold] "
            )
        else:
            if verbose > HASH_AFTER_THIS_VERBOSITY_LEVEL:
                logger.info(
                    "i [bold]Calculating[/bold] the [magenta]diffuse inclined irradiance[/magenta] for daylight moments .."
                )
            diffuse_inclined_irradiance_series = (
                calculate_diffuse_inclined_irradiance_muneer(
                    surface_orientation=surface_orientation,
                    surface_tilt=surface_tilt,
                    surface_tilt_horizontally_flat_panel_threshold=surface_tilt_horizontally_flat_panel_threshold,
                    timestamps=timestamps,
                    timezone=timezone,
                    global_horizontal_irradiance_series=global_horizontal_irradiance,
                    direct_horizontal_irradiance_series=direct_horizontal_irradiance,
                    apply_reflectivity_factor=apply_reflectivity_factor,
                    solar_altitude_series=solar_altitude_series,
                    solar_azimuth_series=solar_azimuth_series,
                    solar_incidence_series=solar_incidence_series,
                    surface_in_shade_series=surface_in_shade_series,
                    shading_states=shading_states,
                    solar_constant=solar_constant,
                    eccentricity_phase_offset=eccentricity_phase_offset,
                    eccentricity_amplitude=eccentricity_amplitude,
                    dtype=dtype,
                    array_backend=array_backend,
                    verbose=verbose,
                    log=log,
                )
            )
            if verbose > HASH_AFTER_THIS_VERBOSITY_LEVEL:
                logger.info(
                    "i [bold]Calculating[/bold] the [magenta]reflected inclined irradiance[/magenta] for daylight moments .."
                )
            ground_reflected_inclined_irradiance_series = calculate_ground_reflected_inclined_irradiance_series(
                longitude=longitude,
                latitude=latitude,
                elevation=elevation,
                surface_orientation=surface_orientation,
                surface_tilt=surface_tilt,
                timestamps=timestamps,
                timezone=timezone,
                global_horizontal_irradiance=global_horizontal_irradiance,  # time series, optional
                linke_turbidity_factor_series=linke_turbidity_factor_series,
                adjust_for_atmospheric_refraction=adjust_for_atmospheric_refraction,
                # unrefracted_solar_zenith=unrefracted_solar_zenith,
                albedo=albedo,
                apply_reflectivity_factor=apply_reflectivity_factor,
                solar_position_model=solar_position_model,
                solar_time_model=solar_time_model,
                solar_constant=solar_constant,
                eccentricity_phase_offset=eccentricity_phase_offset,
                eccentricity_amplitude=eccentricity_amplitude,
                dtype=dtype,
                array_backend=array_backend,
                verbose=verbose,
                log=log,
                fingerprint=fingerprint,
            )

    # sum components
    if verbose > HASH_AFTER_THIS_VERBOSITY_LEVEL:
        logger.info(
            "\ni [bold]Calculating[/bold] the [magenta]global inclined irradiance[/magenta] .."
        )
    global_inclined_irradiance_series[mask_above_horizon_not_in_shade] += (
        direct_inclined_irradiance_series.value[mask_above_horizon_not_in_shade]
    )
    global_inclined_irradiance_series[mask_above_horizon] += (
        + diffuse_inclined_irradiance_series.value[mask_above_horizon]
        + ground_reflected_inclined_irradiance_series.value[mask_above_horizon]
    )
    global_inclined_reflected_series = (
        direct_inclined_irradiance_series.reflected
        + diffuse_inclined_irradiance_series.reflected
        + ground_reflected_inclined_irradiance_series.reflected
    )
    global_inclined_irradiance_before_reflectivity_series = (
        direct_inclined_irradiance_series.value_before_reflectivity
        + diffuse_inclined_irradiance_series.value_before_reflectivity
        + ground_reflected_inclined_irradiance_series.value_before_reflectivity
    )
    out_of_range, out_of_range_index = identify_values_out_of_range(
        series=global_inclined_irradiance_series,
        shape=timestamps.shape,
        data_model=GlobalInclinedIrradianceFromExternalData(),
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=global_inclined_irradiance_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    # We need to build this for the returned data model !
    diffuse_inclined_irradiance_series.build_output(
        verbose=verbose, fingerprint=fingerprint
    )

    return GlobalInclinedIrradianceFromExternalData(
        value=global_inclined_irradiance_series,
        out_of_range=out_of_range,
        out_of_range_index=out_of_range_index,
        #
        ## Inclined Irradiance Components
        direct_inclined_irradiance=direct_inclined_irradiance_series.value,
        diffuse_inclined_irradiance=diffuse_inclined_irradiance_series.value,
        ground_reflected_inclined_irradiance=ground_reflected_inclined_irradiance_series.value,
        #
        ## Loss due to Reflectivity
        reflected=global_inclined_reflected_series,
        direct_inclined_reflected=direct_inclined_irradiance_series.reflected,
        diffuse_inclined_reflected=diffuse_inclined_irradiance_series.reflected,
        ground_reflected_inclined_reflected=ground_reflected_inclined_irradiance_series.reflected,
        #
        ## Reflectivity Factor for Irradiance Components
        direct_inclined_reflectivity_factor=direct_inclined_irradiance_series.reflectivity_factor,
        diffuse_inclined_reflectivity_factor=diffuse_inclined_irradiance_series.reflectivity_factor,
        ground_reflected_inclined_reflectivity_factor=ground_reflected_inclined_irradiance_series.reflectivity_factor,
        #
        ## Reflectivity Coefficient which defines the Reflectivity Factor for Irradiance Components
        # direct_inclined_reflectivity_coefficient=direct_inclined_reflectivity_coefficient_series,
        diffuse_inclined_reflectivity_coefficient=diffuse_inclined_irradiance_series.reflectivity_coefficient,
        # ground_reflected_inclined_reflectivity_coefficient=ground_reflected_inclined_reflectivity_coefficient_series,
        #
        ## Inclined Irradiance before loss due to Reflectivity
        value_before_reflectivity=global_inclined_irradiance_before_reflectivity_series,
        direct_inclined_before_reflectivity=direct_inclined_irradiance_series.value_before_reflectivity,
        diffuse_inclined_before_reflectivity=diffuse_inclined_irradiance_series.value_before_reflectivity,
        ground_reflected_inclined_before_reflectivity=ground_reflected_inclined_irradiance_series.value_before_reflectivity,
        #
        ## Horizontal Irradiance Components
        global_horizontal_irradiance=global_horizontal_irradiance,
        direct_horizontal_irradiance=direct_inclined_irradiance_series.direct_horizontal_irradiance,
        diffuse_horizontal_irradiance=diffuse_inclined_irradiance_series.diffuse_horizontal_irradiance,
        #
        ## Components of the diffuse sky-reflected irradiance
        diffuse_sky_irradiance=diffuse_inclined_irradiance_series.diffuse_sky_irradiance,
        term_n=diffuse_inclined_irradiance_series.term_n,
        kb_ratio=diffuse_inclined_irradiance_series.kb_ratio,
        #
        ## Components of the Extraterrestrial irradiance
        extraterrestrial_horizontal_irradiance=diffuse_inclined_irradiance_series.extraterrestrial_horizontal_irradiance,
        extraterrestrial_normal_irradiance=diffuse_inclined_irradiance_series.extraterrestrial_normal_irradiance,
        linke_turbidity_factor=linke_turbidity_factor_series,
        #
        ## Location and Position
        elevation=elevation,
        surface_orientation=surface_orientation,
        surface_tilt=surface_tilt,
        sun_horizon_positions=sun_horizon_positions,  # states != sun_horizon_position
        #
        ## Solar Position parameters
        surface_in_shade=surface_in_shade_series,
        solar_incidence=direct_inclined_irradiance_series.solar_incidence,
        shading_state=diffuse_inclined_irradiance_series.shading_state,
        sun_horizon_position=sun_horizon_position_series,  # positions != sun_horizon_positions
        solar_altitude=solar_altitude_series,
        refracted_solar_altitude=direct_inclined_irradiance_series.refracted_solar_altitude,
        solar_azimuth=diffuse_inclined_irradiance_series.solar_azimuth,
        # azimuth_difference=azimuth_difference_series,
        #
        ## Positioning, Timing and Atmospheric algorithms
        solar_positioning_algorithm=direct_inclined_irradiance_series.solar_positioning_algorithm,
        solar_timing_algorithm=direct_inclined_irradiance_series.solar_timing_algorithm,
        adjusted_for_atmospheric_refraction=solar_altitude_series.adjusted_for_atmospheric_refraction,
        solar_incidence_model=direct_inclined_irradiance_series.solar_incidence_model,
        solar_incidence_definition=direct_inclined_irradiance_series.solar_incidence.definition,
        shading_algorithm=surface_in_shade_series.shading_algorithm,
        shading_states=shading_states,
        #
        ## Sources
        data_source=HOFIERKA_2002,
        solar_radiation_model=HOFIERKA_2002,
    )

position

Modules:

Name Description
event_hour_angle
fractional_year
shading
solar_altitude
solar_azimuth
solar_declination
solar_hour_angle
solar_incidence

event_hour_angle

Functions:

Name Description
calculate_event_hour_angle_pvis

Calculate the hour angle (ω) at sunrise and sunset

calculate_event_hour_angle_pvis
calculate_event_hour_angle_pvis(
    latitude: Latitude,
    surface_tilt: float = 0,
    solar_declination: float = 0,
) -> EventHourAngle

Calculate the hour angle (ω) at sunrise and sunset

Hour angle = acos(-tan(Latitude Angle-Tilt Angle)*tan(Declination Angle))

The hour angle (ω) at sunrise and sunset measures the angular distance between the sun at the local solar time and the sun at solar noon.

ω = acos(-tan(Φ-β)*tan(δ))

Parameters:

Name Type Description Default
latitude Latitude

Latitude (Φ) is the angle between the sun's rays and its projection on the horizontal surface measured in radians

required
surface_tilt float

Surface tilt (or slope) (β) is the angle between the inclined surface (slope) and the horizontal plane.

0
solar_declination float

Solar declination (δ) is the angle between the equator and a line drawn from the centre of the Earth to the centre of the sun measured in radians.

0

Returns:

Name Type Description
hour_angle_sunrise float

Hour angle (ω) is the angle at any instant through which the earth has to turn to bring the meridian of the observer directly in line with the sun's rays measured in radian.

Source code in pvgisprototype/algorithms/hofierka/position/event_hour_angle.py
@log_function_call
@custom_cached
def calculate_event_hour_angle_pvis(
    latitude: Latitude,
    surface_tilt: float = 0,
    solar_declination: float = 0,
) -> EventHourAngle:
    """Calculate the hour angle (ω) at sunrise and sunset

    Hour angle = acos(-tan(Latitude Angle-Tilt Angle)*tan(Declination Angle))

    The hour angle (ω) at sunrise and sunset measures the angular distance
    between the sun at the local solar time and the sun at solar noon.

    ω = acos(-tan(Φ-β)*tan(δ))

    Parameters
    ----------

    latitude: float
        Latitude (Φ) is the angle between the sun's rays and its projection on the
        horizontal surface measured in radians

    surface_tilt: float
        Surface tilt (or slope) (β) is the angle between the inclined surface
        (slope) and the horizontal plane.

    solar_declination: float
        Solar declination (δ) is the angle between the equator and a line drawn
        from the centre of the Earth to the centre of the sun measured in
        radians.

    Returns
    -------
    hour_angle_sunrise: float
        Hour angle (ω) is the angle at any instant through which the earth has
        to turn to bring the meridian of the observer directly in line with the
        sun's rays measured in radian.
    """
    hour_angle_sunrise_value = arccos(
        -arctan(latitude - surface_tilt) * arctan(solar_declination)
    )

    return EventHourAngle(
        value=hour_angle_sunrise_value,
        unit=RADIANS,
    )

fractional_year

Functions:

Name Description
calculate_day_angle_series_hofierka

Calculate the day angle for a time series.

calculate_day_angle_series_hofierka
calculate_day_angle_series_hofierka(
    timestamps: DatetimeIndex,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> FractionalYear

Calculate the day angle for a time series.

Parameters:

Name Type Description Default
timestamps DatetimeIndex

A Pandas DatetimeIndex representing the timestamps.

required
dtype str

The data type for the calculations (the default is 'float32').

DATA_TYPE_DEFAULT
array_backend str

The backend used for calculations (the default is 'NUMPY').

ARRAY_BACKEND_DEFAULT
verbose int

Verbosity level

VERBOSE_LEVEL_DEFAULT
log int

Log level

LOG_LEVEL_DEFAULT

Returns:

Type Description
FractionalYear

A FractionalYear object containing the calculated fractional year series.

Notes

The day angle [0]_ is the same quantity called Fractional Year in NOAA's solar geometry equations, i.e. the function calculate_fractional_year_time_series_noaa().

In PVGIS' C source code, this is called day_angle

NOAA's corresponding equation:

fractional_year = (
    2
    * pi
    / 365
    * (timestamp.timetuple().tm_yday - 1 + float(timestamp.hour - 12) / 24)
)
References

.. [0] Hofierka, 2002

NOAA's corresponding equation:

day_angle = (
    2
    * pi
    / 365
    * (timestamp.timetuple().tm_yday - 1 + float(timestamp.hour - 12) / 24)
)
Source code in pvgisprototype/algorithms/hofierka/position/fractional_year.py
@log_function_call
@custom_cached
def calculate_day_angle_series_hofierka(
    timestamps: DatetimeIndex,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> FractionalYear:
    """Calculate the day angle for a time series.

    Parameters
    ----------
    timestamps : DatetimeIndex
        A Pandas DatetimeIndex representing the timestamps.

    dtype : str, optional
        The data type for the calculations (the default is 'float32').

    array_backend : str, optional
        The backend used for calculations (the default is 'NUMPY').

    verbose: int
        Verbosity level

    log: int
        Log level

    Returns
    -------
    FractionalYear
        A FractionalYear object containing the calculated fractional year
        series.

    Notes
    -----
    The day angle [0]_ is the same quantity called Fractional Year in NOAA's
    solar geometry equations, i.e. the function
    calculate_fractional_year_time_series_noaa().

    In PVGIS' C source code, this is called `day_angle`

    NOAA's corresponding equation:

        fractional_year = (
            2
            * pi
            / 365
            * (timestamp.timetuple().tm_yday - 1 + float(timestamp.hour - 12) / 24)
        )

    References
    ----------
    .. [0] Hofierka, 2002

    NOAA's corresponding equation:

        day_angle = (
            2
            * pi
            / 365
            * (timestamp.timetuple().tm_yday - 1 + float(timestamp.hour - 12) / 24)
        )
    """
    days_of_year = timestamps.dayofyear
    days_in_years = get_days_in_years(timestamps.year)
    array_parameters = {
        "shape": timestamps.shape,
        "dtype": dtype,
        "init_method": "zeros",
        "backend": array_backend,
    }  # Borrow shape from timestamps
    day_angle_series = create_array(**array_parameters)
    day_angle_series = numpy.array(
        2 * pi * days_of_year / days_in_years,
        dtype=dtype,
    )

    if not numpy.all(
        (FractionalYear().min_radians <= day_angle_series)
        & (day_angle_series <= FractionalYear().max_radians)
    ):
        index_of_out_of_range_values = numpy.where(
            (day_angle_series < FractionalYear().min_radians)
            | (day_angle_series > FractionalYear().max_radians)
        )
        out_of_range_values = day_angle_series[index_of_out_of_range_values]
        # Report values in "human readable" degrees
        raise ValueError(
            f"{WARNING_OUT_OF_RANGE_VALUES} "
            f"[{FractionalYear().min_radians}, {FractionalYear().max_radians}] radians"
            f" in [code]day_angle_series[/code] : {out_of_range_values}"
        )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=day_angle_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return FractionalYear(
        value=day_angle_series,
        unit=RADIANS,
        solar_positioning_algorithm=SolarPositionModel.hofierka,
    )

shading

Functions:

Name Description
calculate_horizon_height_series
calculate_surface_in_shade_series_pvgis

Determine a time series for when a location of observation (or else in

calculate_horizon_height_series
calculate_horizon_height_series(
    solar_azimuth_series: SolarAzimuth,
    horizon_profile: DataArray | None = None,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> HorizonHeight
Source code in pvgisprototype/algorithms/hofierka/position/shading.py
@log_function_call
@custom_cached
@validate_with_pydantic(CalculateHorizonHeightSeriesInputModel)
def calculate_horizon_height_series(
    solar_azimuth_series: SolarAzimuth,
    horizon_profile: DataArray | None = None,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> HorizonHeight:
    """ """
    # Ensure _all_ azimuth values are measured in radians !

    # Tolerance for matching azimuths (set this based on acceptable precision)
    # tolerance = finfo(float).eps  # Machine epsilon for floating point comparison
    # or :
    # tolerance = 0.001  # or a smaller value, depending on your precision needs
    # ?

    # select closest azimuth values using Xarray's `sel` with `method="nearest"`
    # closest_horizon_heights = horizon_profile.sel(azimuth=solar_azimuth_series.radians, method="nearest", tolerance=tolerance)

    # # Identify where the tolerance is exceeded (i.e., where interpolation is needed)
    # azimuth_difference = abs(closest_horizon_heights.azimuth - solar_azimuth_series.radians)
    # needs_interpolation = azimuth_difference > tolerance

    # We need the index of the selected values !

    # # Assign exact or almost exact matches from the horizon profile
    # close_enough = ~needs_interpolation
    # horizon_height_series[close_enough] = horizon_profile.sel(azimuth=solar_azimuth_series.radians[close_enough])

    # # Interpolate values not close enough
    # import numpy
    # if numpy.any(needs_interpolation):
    #     horizon_height_series[needs_interpolation] = horizon_profile.interp(
    #         azimuth=solar_azimuth_series.radians[needs_interpolation]
    #     )
    if isinstance(horizon_profile, DataArray):

        if (horizon_profile == 0).all():
            from pvgisprototype.core.arrays import create_array

            # If all values are zero, assume flat terrain
            array_parameters = {
                "shape": solar_azimuth_series.value.shape,
                "dtype": dtype,
                "init_method": "zeros",
                "backend": array_backend,
            }  # Borrow shape from solar_azimuth_series
            horizon_height_series = create_array(**array_parameters)

        else:
            max_horizon_azimuth = horizon_profile.values.max().item()
            max_solar_azimuth = solar_azimuth_series.value.max().item()

            if max_solar_azimuth > max_horizon_azimuth:
                # Before interpollate, append one more pair of solar azimuth at 360 degrees (or 2*pi) and
                # repeat the 0 degrees solar azimuth value at the 360 degrees solar azimuth
                from pvgisprototype.constants import pi
                import xarray as xr

                new_max_horizon_azimuth = 2 * pi
                last_horizon_height_value = horizon_profile[0].values
                new_point = xr.DataArray(
                    [last_horizon_height_value],
                    dims="azimuth",
                    coords={"azimuth": [new_max_horizon_azimuth]},
                )
                horizon_profile = xr.concat([horizon_profile, new_point], dim="azimuth")

            horizon_height_series = horizon_profile.interp(
                azimuth=solar_azimuth_series.radians,
            ).values  # retrieve the NumPy array of values here !

    else:  # we assume a flat terrain
        from pvgisprototype.core.arrays import create_array

        array_parameters = {
            "shape": solar_azimuth_series.value.shape,
            "dtype": dtype,
            "init_method": "zeros",
            "backend": array_backend,
        }  # Borrow shape from solar_azimuth_series
        horizon_height_series = create_array(**array_parameters)

    return HorizonHeight(value=horizon_height_series, unit=RADIANS)
calculate_surface_in_shade_series_pvgis
calculate_surface_in_shade_series_pvgis(
    solar_altitude_series: SolarAltitude,
    solar_azimuth_series: SolarAzimuth,
    horizon_profile: DataArray | None = None,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> LocationShading

Determine a time series for when a location of observation (or else in the context of photovoltaic analysis a solar surface) is in shade.

The function compares time series of solar altitude and horizon height, the latter measured at directions corresponding to the solar azimuth series. The comparison derives a qualitative series of the sun being visible from the location of observation or indeed behind the horizon.

Parameters:

Name Type Description Default
solar_altitude_series_array

Array of solar altitude angles for each timestamp.

required

Returns:

Type Description
NumPy array: Boolean array indicating whether the surface is in shade at
each timestamp.
Source code in pvgisprototype/algorithms/hofierka/position/shading.py
@log_function_call
@custom_cached
@validate_with_pydantic(CalculateSurfaceInShadePVGISInputModel)
def calculate_surface_in_shade_series_pvgis(
    solar_altitude_series: SolarAltitude,
    solar_azimuth_series: SolarAzimuth,
    horizon_profile: DataArray | None = None,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> LocationShading:
    """Determine a time series for when a location of observation (or else in
    the context of photovoltaic analysis a solar surface) is in shade.

    The function compares time series of solar altitude and horizon height, the
    latter measured at directions corresponding to the solar azimuth series.
    The comparison derives a qualitative series of the sun being visible from
    the location of observation or indeed behind the horizon.

    Parameters
    ----------
    solar_altitude_series_array: numpy array
        Array of solar altitude angles for each timestamp.

    Returns
    -------
    NumPy array: Boolean array indicating whether the surface is in shade at
    each timestamp.

    """
    horizon_height_series = calculate_horizon_height_series(
        solar_azimuth_series=solar_azimuth_series,
        horizon_profile=horizon_profile,
        dtype=dtype,
        array_backend=array_backend,
        validate_output=validate_output,
        verbose=verbose,
        log=log,
    )
    horizon_height_series.build_output(verbose=verbose, fingerprint=None)
    surface_in_shade_series = where(
        solar_altitude_series.value < horizon_height_series.value, True, False
    )
    logger.debug(f"In shade : {surface_in_shade_series}")

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=surface_in_shade_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return LocationShading(
        value=surface_in_shade_series,
        unit=UNITLESS,
        solar_altitude=solar_altitude_series.value,
        solar_azimuth=solar_azimuth_series.value,
        horizon_height=horizon_height_series,
        visible=~surface_in_shade_series,
        shading_algorithm="PVGIS",
        solar_positioning_algorithm=solar_altitude_series.solar_positioning_algorithm,
        solar_timing_algorithm=solar_altitude_series.solar_timing_algorithm,
    )

solar_altitude

Functions:

Name Description
calculate_solar_altitude_series_hofierka

Calculate the solar altitude angle.

calculate_solar_altitude_series_hofierka
calculate_solar_altitude_series_hofierka(
    longitude: Longitude,
    latitude: Latitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    eccentricity_phase_offset: float,
    eccentricity_amplitude: float,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarAltitude

Calculate the solar altitude angle.

Calculate the solar altitude angle based on the equation

sine_solar_altitude = ( sin(latitude.radians) * np.sin(solar_declination_series.radians) + cos(latitude.radians) * np.cos(solar_declination_series.radians) * np.cos(solar_hour_angle_series.radians)

Parameters:

Name Type Description Default
longitude float

The longitude in degrees. This value will be converted to radians. It should be in the range [-180, 180].

required
latitude float

The latitude in degrees. This value will be converted to radians. It should be in the range [-90, 90].

required
timestamp datetime

The timestamp for which to calculate the solar altitude. If not provided, the current UTC time will be used.

required
timezone str

The timezone to use for the calculation. If not provided, the system's local timezone will be used.

required

Returns:

Type Description
float

The calculated solar altitude.

Notes

NOAA's equation is practically the same, though it targets the cosine function of the solar zenith angle which it the complementary of the solar altitude angle.

cosine_solar_zenith = ( sin(latitude.radians) * np.sin(solar_declination_series.radians) + cos(latitude.radians) * np.cos(solar_declination_series.radians) * np.cos(solar_hour_angle_series.radians)

Source code in pvgisprototype/algorithms/hofierka/position/solar_altitude.py
@log_function_call
@custom_cached
# @validate_with_pydantic(CalculateSolarAltitudePVISInputModel)
def calculate_solar_altitude_series_hofierka(
    longitude: Longitude,
    latitude: Latitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    eccentricity_phase_offset: float,
    eccentricity_amplitude: float,
    # solar_time_model: SolarTimeModel,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarAltitude:
    """Calculate the solar altitude angle.

    Calculate the solar altitude angle based on the equation

    sine_solar_altitude = (
        sin(latitude.radians)
        * np.sin(solar_declination_series.radians)
        + cos(latitude.radians)
        * np.cos(solar_declination_series.radians)
        * np.cos(solar_hour_angle_series.radians)

    Parameters
    ----------
    longitude : float
        The longitude in degrees. This value will be converted to radians.
        It should be in the range [-180, 180].

    latitude : float
        The latitude in degrees. This value will be converted to radians.
        It should be in the range [-90, 90].

    timestamp : datetime, optional
        The timestamp for which to calculate the solar altitude.
        If not provided, the current UTC time will be used.

    timezone : str, optional
        The timezone to use for the calculation.
        If not provided, the system's local timezone will be used.

    Returns
    -------
    float
        The calculated solar altitude.

    Notes
    -----

    NOAA's equation is practically the same, though it targets the cosine
    function of the solar zenith angle which it the complementary of the solar
    altitude angle.

    cosine_solar_zenith = (
        sin(latitude.radians)
        * np.sin(solar_declination_series.radians)
        + cos(latitude.radians)
        * np.cos(solar_declination_series.radians)
        * np.cos(solar_hour_angle_series.radians)

    """
    solar_declination_series = calculate_solar_declination_series_hofierka(
        timestamps=timestamps,
        eccentricity_phase_offset=eccentricity_phase_offset,
        eccentricity_amplitude=eccentricity_amplitude,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
    )

    # Idea for alternative solar time modelling, i.e. Milne 1921 -------------
    # solar_time = model_solar_time(
    #     longitude=longitude,
    #     latitude=latitude,
    #     timestamp=timestamp,
    #     timezone=timezone,
    #     solar_time_model=solar_time_model,  # returns datetime.time object
    #     eccentricity_phase_offset=eccentricity_phase_offset,
    #     eccentricity_amplitude=eccentricity_amplitude,
    # )
    # hour_angle = calculate_solar_hour_angle_pvis(
    #         solar_time=solar_time,
    # )
    # ------------------------------------------------------------------------

    solar_hour_angle_series = calculate_solar_hour_angle_series_hofierka(
        longitude=longitude,
        timestamps=timestamps,
        timezone=timezone,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
    )
    C31 = cos(latitude.radians) * numpy.cos(solar_declination_series.radians)
    C33 = sin(latitude.radians) * numpy.sin(solar_declination_series.radians)
    sine_solar_altitude_series = C31 * numpy.cos(solar_hour_angle_series.radians) + C33
    solar_altitude_series = numpy.arcsin(sine_solar_altitude_series)

    # mask_positive_C31 = C31 > 1e-7
    # solar_altitude_series[mask_positive_C31] = numpy.where(
    #     sine_solar_altitude_series < 0,
    #     NO_SOLAR_INCIDENCE,
    #     solar_altitude_series,
    # )

    # The hour angle of the time of sunrise/sunset over a horizontal surface
    # Thr,s can be calculated then as:
    # cos(event_hour_angle_horizontal) = -C33 / C31

    if (
        (solar_altitude_series < SolarAltitude().min_radians)
        | (solar_altitude_series > SolarAltitude().max_radians)
    ).any():
        out_of_range_values = solar_altitude_series[
            (solar_altitude_series < SolarAltitude().min_radians)
            | (solar_altitude_series > SolarAltitude().max_radians)
        ]
        # raise ValueError(# ?
        logger.warning(
            f"{WARNING_NEGATIVE_VALUES} "
            f"[{SolarAltitude().min_radians}, {SolarAltitude().max_radians}] radians"
            f" in [code]solar_altitude_series[/code] : {out_of_range_values}"
        )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_altitude_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return SolarAltitude(
        value=solar_altitude_series,
        unit=RADIANS,
        solar_positioning_algorithm=SolarPositionModel.hofierka,
        solar_timing_algorithm=solar_hour_angle_series.solar_timing_algorithm,
    )

solar_azimuth

Functions:

Name Description
calculate_solar_azimuth_series_hofierka

Calculate the solar azimuth angle

calculate_solar_azimuth_series_hofierka
calculate_solar_azimuth_series_hofierka(
    longitude: Longitude,
    latitude: Latitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarAzimuth

Calculate the solar azimuth angle

Returns:

Name Type Description
solar_azimuth float

Returns:

Name Type Description
solar_azimuth float
Notes

According to Hofierka! solar azimuth is measured from East! Conflict with Jenvco 1992?

Source code in pvgisprototype/algorithms/hofierka/position/solar_azimuth.py
@log_function_call
@custom_cached
# @validate_with_pydantic(CalculateSolarAzimuthPVISInputModel)
def calculate_solar_azimuth_series_hofierka(
    longitude: Longitude,  # radians
    latitude: Latitude,  # radians
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarAzimuth:
    """Calculate the solar azimuth angle

    Returns
    -------
    solar_azimuth: float


    Returns
    -------
    solar_azimuth: float

    Notes
    -----
    According to Hofierka! solar azimuth is measured from East!
    Conflict with Jenvco 1992?
    """
    solar_declination_series = calculate_solar_declination_series_hofierka(
        timestamps=timestamps,
        eccentricity_phase_offset=eccentricity_phase_offset,
        eccentricity_amplitude=eccentricity_amplitude,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
    )
    # Idea for alternative solar time modelling, i.e. Milne 1921 -------------
    # solar_time = model_solar_time(
    #     longitude=longitude,
    #     latitude=latitude,
    #     timestamp=timestamp,
    #     timezone=timezone,
    #     solar_time_model=solar_time_model,  # returns datetime.time object
    #     eccentricity_phase_offset=eccentricity_phase_offset,
    #     eccentricity_amplitude=eccentricity_amplitude,
    # )
    # hour_angle = calculate_solar_hour_angle_pvis(
    #         solar_time=solar_time,
    # )
    # ------------------------------------------------------------------------
    solar_hour_angle_series = calculate_solar_hour_angle_series_hofierka(
        longitude=longitude,
        timestamps=timestamps,
        timezone=timezone,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
    )
    C11 = sin(latitude.radians) * numpy.cos(solar_declination_series.radians)
    C13 = -cos(latitude.radians) * numpy.sin(
        solar_declination_series.radians
    )  # Attention to the - sign
    C22 = numpy.cos(solar_declination_series.radians)
    x_solar_vector_component = -C22 * numpy.sin(solar_hour_angle_series.radians)
    y_solar_vector_component = C11 * numpy.cos(solar_hour_angle_series.radians) + C13

    # numerator = y_solar_vector_component
    denominator_a = numpy.power(x_solar_vector_component, 2)
    denominator_b = numpy.power(y_solar_vector_component, 2)
    event_hour_angle_inclined = numpy.power(denominator_a + denominator_b, 0.5)

    # mask_positive_event_hour_angle_inclined = event_hour_angle_inclined > 1e-7
    mask_negative_event_hour_angle_inclined = event_hour_angle_inclined < 1e-7  # ?

    azimuth_origin = "East"
    cosine_solar_azimuth_series = numpy.clip(
        y_solar_vector_component / event_hour_angle_inclined, -1, 1
    )
    solar_azimuth_series = numpy.arccos(cosine_solar_azimuth_series)
    solar_azimuth_series = numpy.where(
        x_solar_vector_component < 0,
        numpy.pi * 2 - solar_azimuth_series,
        solar_azimuth_series,
    )
    solar_azimuth_series = numpy.where(
        mask_negative_event_hour_angle_inclined,
        NO_SOLAR_INCIDENCE,
        solar_azimuth_series,
    )

    from math import pi

    solar_azimuth_series = numpy.where(
        solar_azimuth_series < pi / 2,
        pi / 2 - solar_azimuth_series,
        5 * pi / 2 - solar_azimuth_series,
    )
    solar_azimuth_series = numpy.where(
        solar_azimuth_series >= 2 * pi,
        solar_azimuth_series - 2 * pi,
        solar_azimuth_series,
    )

    # -------------------------- convert east to north zero degrees convention
    # PVGIS' follows Hofierka (2002) who states : azimuth is measured from East
    # solar_azimuth_series = convert_east_to_north_radians_convention(solar_azimuth_series)
    # convert east to north zero degrees convention --------------------------
    # if (
    #     not isfinite(solar_azimuth.degrees)
    #     or not solar_azimuth.min_degrees <= solar_azimuth.degrees <= solar_azimuth.max_degrees
    # ):
    #     raise ValueError(
    #         f"The calculated solar azimuth angle {solar_azimuth.degrees} is out of the expected range\
    #         [{solar_azimuth.min_degrees}, {solar_azimuth.max_degrees}] degrees"
    #     )
    if (
        (solar_azimuth_series < SolarAzimuth().min_radians)
        | (solar_azimuth_series > SolarAzimuth().max_radians)
    ).any():
        out_of_range_values = solar_azimuth_series[
            (solar_azimuth_series < SolarAzimuth().min_radians)
            | (solar_azimuth_series > SolarAzimuth().max_radians)
        ]
        # raise ValueError(# ?
        logger.warning(
            f"{WARNING_NEGATIVE_VALUES} "
            f"[{SolarAzimuth().min_radians}, {SolarAzimuth().max_radians}] radians"
            f" in [code]solar_azimuth_series[/code] : {out_of_range_values}"
        )
    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_azimuth_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return SolarAzimuth(
        value=solar_azimuth_series,
        unit=RADIANS,
        solar_positioning_algorithm=SolarPositionModel.hofierka,
        solar_timing_algorithm=SolarTimeModel.pvgis,
        origin=azimuth_origin,
        # definition=incidence_angle_definition,
        # description=incidence_angle_description,
    )  # zero_direction='East'

solar_declination

Functions:

Name Description
calculate_solar_declination_series_hofierka

Approximate the sun's declination for a given day of the year.

calculate_solar_declination_series_hofierka
calculate_solar_declination_series_hofierka(
    timestamps: DatetimeIndex,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarDeclination

Approximate the sun's declination for a given day of the year.

The solar declination is the angle between the Sun's rays and the equatorial plane of Earth. It varies throughout the year due to the tilt of the Earth's axis and is an important parameter in determining the seasons and the amount of solar radiation received at different latitudes.

The function calculates the proportion of the way through the year (in radians), which is given by (2 * pi * day_of_year) / 365.25. The 0.3978, 1.4, and 0.0355 are constants in the approximation formula, with the 0.0489 being an adjustment factor for the slight eccentricity of Earth's orbit.

Parameters:

Name Type Description Default
day_of_year

The day of the year (ranging from 1 to 365 or 366 in a leap year).

required

Returns:

Name Type Description
solar_declination float

The solar declination in radians for the given day of the year.

Notes

The equation used here is a simple approximation and bases upon a direct translation from PVGIS' rsun3 source code:

  • from file: rsun_base.cpp
  • function: com_declin(no_of_day)

For more accurate calculations of solar position, comprehensive models like the Solar Position Algorithm (SPA) are typically used.

Source code in pvgisprototype/algorithms/hofierka/position/solar_declination.py
@log_function_call
@custom_cached
def calculate_solar_declination_series_hofierka(
    timestamps: DatetimeIndex,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarDeclination:
    """Approximate the sun's declination for a given day of the year.

    The solar declination is the angle between the Sun's rays and the
    equatorial plane of Earth. It varies throughout the year due to the tilt of
    the Earth's axis and is an important parameter in determining the seasons
    and the amount of solar radiation received at different latitudes.

    The function calculates the `proportion` of the way through the year (in
    radians), which is given by `(2 * pi * day_of_year) / 365.25`.
    The `0.3978`, `1.4`, and `0.0355` are constants in the approximation
    formula, with the `0.0489` being an adjustment factor for the slight
    eccentricity of Earth's orbit.

    Parameters
    ----------
    day_of_year: int
        The day of the year (ranging from 1 to 365 or 366 in a leap year).

    Returns
    -------
    solar_declination: float
        The solar declination in radians for the given day of the year.

    Notes
    -----

    The equation used here is a simple approximation and bases upon a direct
    translation from PVGIS' rsun3 source code:

      - from file: rsun_base.cpp
      - function: com_declin(no_of_day)

    For more accurate calculations of solar position, comprehensive models like
    the Solar Position Algorithm (SPA) are typically used.
    """
    day_angle_series = calculate_day_angle_series_hofierka(
        timestamps=timestamps,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
    )
    # Note the - sign for the output solar declination, as is in PVGIS v5.2
    # see : com_declin() in rsun_base.c
    solar_declination_series = numpy.arcsin(
        0.3978
        * numpy.sin(
            day_angle_series.radians
            - 1.4
            + eccentricity_amplitude
            * numpy.sin(day_angle_series.radians - eccentricity_phase_offset)
        )
    )
    out_of_range, out_of_range_index = identify_values_out_of_range_x(
        series=solar_declination_series,
        shape=timestamps.shape,
        data_model=SolarDeclination(),
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_declination_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return SolarDeclination(
        value=solar_declination_series,
        unit=RADIANS,
        out_of_range=out_of_range,
        out_of_range_index=out_of_range_index,
        solar_positioning_algorithm=day_angle_series.solar_positioning_algorithm,  # ?
    )

solar_hour_angle

Functions:

Name Description
calculate_solar_hour_angle_series_hofierka

Calculate the hour angle ω'

calculate_solar_hour_angle_series_hofierka
calculate_solar_hour_angle_series_hofierka(
    longitude: Longitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarHourAngle

Calculate the hour angle ω'

ω = (ST / 3600 - 12) * 15 * pi / 180

Parameters:

Name Type Description Default
solar_time

The solar time (ST) is a calculation of the passage of time based on the position of the Sun in the sky. It is expected to be decimal hours in a 24 hour format and measured internally in seconds.

required

Returns:

Name Type Description
hour_angle float

The solar hour angle (ω) is the angle at any instant through which the earth has to turn to bring the meridian of the observer directly in line with the sun's rays measured in radian.

Notes

If not mistaken, in PVGIS' C source code, the conversion function is:

hour_angle = (solar_time / 3600 - 12) * 15 * 0.0175

where the solar time was given in seconds.
Source code in pvgisprototype/algorithms/hofierka/position/solar_hour_angle.py
@log_function_call
@custom_cached
def calculate_solar_hour_angle_series_hofierka(
    longitude: Longitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarHourAngle:
    """Calculate the hour angle ω'

    ω = (ST / 3600 - 12) * 15 * pi / 180

    Parameters
    ----------

    solar_time: float
        The solar time (ST) is a calculation of the passage of time based on the
        position of the Sun in the sky. It is expected to be decimal hours in a
        24 hour format and measured internally in seconds.

    Returns
    --------

    hour_angle: float
        The solar hour angle (ω) is the angle at any instant through which the
        earth has to turn to bring the meridian of the observer directly in
        line with the sun's rays measured in radian.

    Notes
    -----
    If not mistaken, in PVGIS' C source code, the conversion function is:

        hour_angle = (solar_time / 3600 - 12) * 15 * 0.0175

        where the solar time was given in seconds.

    """
    from pvgisprototype.algorithms.noaa.solar_time import (
        calculate_true_solar_time_series_noaa,
    )

    true_solar_time_series = calculate_true_solar_time_series_noaa(
        longitude=longitude,
        timestamps=timestamps,
        timezone=timezone,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
    )
    solar_hour_angle_series = (true_solar_time_series.minutes - 720.0) * (
        numpy.pi / 720.0
    )
    # if (
    #     not isfinite(hour_angle.degrees)
    #     or not hour_angle.min_degrees <= hour_angle.degrees <= hour_angle.max_degrees
    # ):
    #     raise ValueError(
    #         f"The calculated solar hour angle {hour_angle.degrees} is out of the expected range\
    #         [{hour_angle.min_degrees}, {hour_angle.max_degrees}] degrees"
    #     )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_hour_angle_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return SolarHourAngle(
        value=solar_hour_angle_series,
        unit=RADIANS,
        solar_positioning_algorithm=SolarPositionModel.hofierka,
        solar_timing_algorithm="Hofierka",
    )

solar_incidence

Functions:

Name Description
calculate_relative_longitude

Notes

calculate_solar_incidence_series_hofierka

Calculate the angle of incidence (θ) between the direction of the sun

calculate_relative_longitude
calculate_relative_longitude(
    latitude: Latitude,
    surface_orientation: SurfaceOrientation = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt = SURFACE_TILT_DEFAULT,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> RelativeLongitude
Notes

Hofierka (2002) uses equations presented by Jenčo (1992) :

tangent_relative_longitude =
    (
        - sin(surface_tilt)
        * sin(surface_orientation)
    ) / (
        sin(latitude)
        * sin(surface_tilt)
        * cos(surface_orientation)
        + cos(latitude)
        * cos(surface_tilt)
    )

In PVGIS' C source code, there is an error of one negative sign in either of the expressions! That is so because : cos(pi/2 + x) = -sin(x). As a consequence, the numerator becomes a positive number.

Source code :

/* These calculations depend on slope and aspect. Constant for the day if not tracking */
sin_phi_l = -gridGeom->coslat * cos_u * sin_v + gridGeom->sinlat * sin_u;
latid_l = asin(sin_phi_l);
cos_latid_l = cos(latid_l);
q1 = gridGeom->sinlat * cos_u * sin_v + gridGeom->coslat * sin_u;
tan_lam_l = - cos_u * cos_v / q1;
longit_l = atan (tan_lam_l);
if((aspect<M_PI)&&(longit_l<0.))
{
    longit_l += M_PI;
}
else if((aspect>M_PI)&&(longit_l>0.))
{
    longit_l -= M_PI;
}


Translation in to Python / pseudocode :

tangent_relative_longitude =
    (
      - cos(half_pi - surface_tilt)         # cos(pi/2 - x) = sin(x)
      * cos(half_pi + surface_orientation)  # cos(pi/2 + x) = -sin(x) #
    ) / (
      sin(latitude)
      * cos(half_pi - surface_tilt)
      * sin(half_pi + surface_orientation)  # sin(pi/2 + x) = cos(x)
      + cos(latitude)
      * sin(half_pi - surface_tilt)         # sin(pi/2 - x) = cos(x)
    )

As a consequence, PVGIS is like (note the positive numerator!) :

tangent_relative_longitude =
    (
        sin(surface_tilt)
        * sin(surface_orientation)
    ) / (
        sin(latitude)
        * sin(surface_tilt)
        * cos(surface_orientation)
        + cos(latitude)
        * cos(surface_tilt)
    )
Source code in pvgisprototype/algorithms/hofierka/position/solar_incidence.py
@log_function_call
@custom_cached
# @validate_with_pydantic(CalculateRelativeLongitudeInputModel)
def calculate_relative_longitude(
    latitude: Latitude,
    surface_orientation: SurfaceOrientation = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt = SURFACE_TILT_DEFAULT,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> RelativeLongitude:
    """
    Notes
    -----
    Hofierka (2002) uses equations presented by Jenčo (1992) :

        tangent_relative_longitude =
            (
                - sin(surface_tilt)
                * sin(surface_orientation)
            ) / (
                sin(latitude)
                * sin(surface_tilt)
                * cos(surface_orientation)
                + cos(latitude)
                * cos(surface_tilt)
            )

    In PVGIS' C source code, there is an error of one negative sign in either
    of the expressions! That is so because : cos(pi/2 + x) = -sin(x).
    As a consequence, the numerator becomes a positive number.

        Source code :

        /* These calculations depend on slope and aspect. Constant for the day if not tracking */
        sin_phi_l = -gridGeom->coslat * cos_u * sin_v + gridGeom->sinlat * sin_u;
        latid_l = asin(sin_phi_l);
        cos_latid_l = cos(latid_l);
        q1 = gridGeom->sinlat * cos_u * sin_v + gridGeom->coslat * sin_u;
        tan_lam_l = - cos_u * cos_v / q1;
        longit_l = atan (tan_lam_l);
        if((aspect<M_PI)&&(longit_l<0.))
        {
            longit_l += M_PI;
        }
        else if((aspect>M_PI)&&(longit_l>0.))
        {
            longit_l -= M_PI;
        }


        Translation in to Python / pseudocode :

        tangent_relative_longitude =
            (
              - cos(half_pi - surface_tilt)         # cos(pi/2 - x) = sin(x)
              * cos(half_pi + surface_orientation)  # cos(pi/2 + x) = -sin(x) #
            ) / (
              sin(latitude)
              * cos(half_pi - surface_tilt)
              * sin(half_pi + surface_orientation)  # sin(pi/2 + x) = cos(x)
              + cos(latitude)
              * sin(half_pi - surface_tilt)         # sin(pi/2 - x) = cos(x)
            )

    As a consequence, PVGIS is like (note the positive numerator!) :

        tangent_relative_longitude =
            (
                sin(surface_tilt)
                * sin(surface_orientation)
            ) / (
                sin(latitude)
                * sin(surface_tilt)
                * cos(surface_orientation)
                + cos(latitude)
                * cos(surface_tilt)
            )
    """
    # -----------------------------------------------------------------------
    # in PVGIS an extra minus sign results to an all positive numerator!
    # tangent_relative_longitude_numerator = sin(surface_tilt.radians) * sin(
    #     surface_orientation.radians
    # )
    # -----------------------------------------------------------------------
    tangent_relative_longitude_numerator = -sin(
        surface_tilt.radians
    ) * -sin(  # cos(pi/2 - surface_tilt.radians)
        surface_orientation.radians
    )  # cos(pi/2 + surface_orientation.radians)
    tangent_relative_longitude_denominator = sin(latitude.radians) * sin(
        surface_tilt.radians
    ) * cos(  # cos(pi/2 - surface_tilt.radians)
        surface_orientation.radians
    ) + cos(  # sin(pi/2 + surface_orientation.radians)
        latitude.radians
    ) * cos(
        surface_tilt.radians
    )  # sin(pi/2 - surface_tilt.radians)
    # force dtype !
    relative_longitude = numpy.array(
        [
            numpy.arctan(
                tangent_relative_longitude_numerator
                / tangent_relative_longitude_denominator
            )
        ],
        dtype=dtype,
    )

    if surface_orientation.radians < pi and relative_longitude < 0:
        relative_longitude += pi

    if surface_orientation.radians > pi and relative_longitude > 0:
        relative_longitude -= pi

    log_data_fingerprint(
        data=relative_longitude,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return RelativeLongitude(
        value=relative_longitude,
        unit=RADIANS,
    )
calculate_solar_incidence_series_hofierka
calculate_solar_incidence_series_hofierka(
    longitude: Longitude,
    latitude: Latitude,
    surface_orientation: SurfaceOrientation = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt = SURFACE_TILT_DEFAULT,
    timestamps: DatetimeIndex = str(now_utc_datetimezone()),
    timezone: ZoneInfo | None = None,
    adjust_for_atmospheric_refraction: bool = ATMOSPHERIC_REFRACTION_FLAG_DEFAULT,
    complementary_incidence_angle: bool = COMPLEMENTARY_INCIDENCE_ANGLE_DEFAULT,
    zero_negative_solar_incidence_angle: bool = ZERO_NEGATIVE_INCIDENCE_ANGLE_DEFAULT,
    solar_time_model: SolarTimeModel = milne,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarIncidence

Calculate the angle of incidence (θ) between the direction of the sun ray and the line normal to the surface measured in radian.

θ = acos( sin(Φ) * ( sin(δ) * cos(β) + cos(δ) * cos(γ) * cos(ω) * sin(β) ) + cos(Φ) * (cos(δ) * cos(ω) * cos(β) - sin(δ) * cos(γ) * sin(β)) + cos(δ) * sin(γ) * sin(ω) * sin(β) )

Parameters:

Name Type Description Default
latitude Latitude

Latitude is the angle (Φ) between the sun's rays and its projection on the horizontal surface measured in radian.

required
surface_tilt SurfaceTilt

Surface tilt or slope is the angle (β) between the inclined slope and the horizontal plane measured in radian.

SURFACE_TILT_DEFAULT
surface_orientiation

Surface orientation or azimuth is the angle (γ) in the horizontal plane between the line due south and the horizontal projection of the normal to the inclined plane surface measured in radian.

required

Returns:

Name Type Description
solar_incidence float

The angle of incidence (θ) is the angle between the direction of the sun ray and the line normal to the surface measured in radian.

Source code in pvgisprototype/algorithms/hofierka/position/solar_incidence.py
@log_function_call
# @validate_with_pydantic(CalculateSolarIncidenceTimeSeriesPVISInputModel)
def calculate_solar_incidence_series_hofierka(
    longitude: Longitude,
    latitude: Latitude,
    surface_orientation: SurfaceOrientation = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt = SURFACE_TILT_DEFAULT,
    timestamps: DatetimeIndex = str(now_utc_datetimezone()),
    timezone: ZoneInfo | None = None,
    adjust_for_atmospheric_refraction: bool = ATMOSPHERIC_REFRACTION_FLAG_DEFAULT,
    complementary_incidence_angle: bool = COMPLEMENTARY_INCIDENCE_ANGLE_DEFAULT,
    zero_negative_solar_incidence_angle: bool = ZERO_NEGATIVE_INCIDENCE_ANGLE_DEFAULT,
    solar_time_model: SolarTimeModel = SolarTimeModel.milne,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarIncidence:
    """Calculate the angle of incidence (θ) between the direction of the sun
    ray and the line normal to the surface measured in radian.

    θ =
    acos(
         sin(Φ)
         * (
           sin(δ) * cos(β) + cos(δ) * cos(γ) * cos(ω) * sin(β)
           )
         + cos(Φ) * (cos(δ) * cos(ω) * cos(β) - sin(δ) * cos(γ) * sin(β))
         + cos(δ)
         * sin(γ)
         * sin(ω)
         * sin(β)
        )

    Parameters
    ----------

    latitude: float
        Latitude is the angle (Φ) between the sun's rays and its projection on
        the horizontal surface measured in radian.

    surface_tilt: float
        Surface tilt or slope is the angle (β) between the inclined slope and
        the horizontal plane measured in radian.

    surface_orientiation: float
        Surface orientation or azimuth is the angle (γ) in the horizontal plane
        between the line due south and the horizontal projection of the normal
        to the inclined plane surface measured in radian.

    Returns
    -------
    solar_incidence: float
        The angle of incidence (θ) is the angle between the direction of the
        sun ray and the line normal to the surface measured in radian.
    """
    solar_declination_series = calculate_solar_declination_series_hofierka(
        timestamps=timestamps,
        eccentricity_phase_offset=eccentricity_phase_offset,
        eccentricity_amplitude=eccentricity_amplitude,
    )
    solar_hour_angle_series = calculate_solar_hour_angle_series_hofierka(
        longitude=longitude,
        timestamps=timestamps,
        timezone=timezone,
    )
    solar_altitude_series = calculate_solar_altitude_series_hofierka(
        longitude=longitude,
        latitude=latitude,
        timestamps=timestamps,
        timezone=timezone,
        eccentricity_phase_offset=eccentricity_phase_offset,
        eccentricity_amplitude=eccentricity_amplitude,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
    )
    sine_relative_inclined_latitude = -cos(latitude.radians) * sin(
        surface_tilt.radians
    ) * cos(  # cos(pi/2 - surface_tilt.radians)
        surface_orientation.radians
    ) + sin(  # sin(pi/2 + surface_orientation.radians)
        latitude.radians
    ) * cos(
        surface_tilt.radians
    )  # sin(pi/2 - surface_tilt.radians)
    relative_inclined_latitude = asin(sine_relative_inclined_latitude)
    c_inclined_31_series = cos(relative_inclined_latitude) * numpy.cos(
        solar_declination_series.radians
    )
    c_inclined_33_series = sin(relative_inclined_latitude) * numpy.sin(
        solar_declination_series.radians
    )
    relative_longitude = calculate_relative_longitude(
        latitude=latitude,
        surface_orientation=surface_orientation,
        surface_tilt=surface_tilt,
    )
    sine_solar_incidence_series = (
        c_inclined_31_series
        * numpy.cos(-solar_hour_angle_series.radians - relative_longitude.radians)
        + c_inclined_33_series
    )
    solar_incidence_series = numpy.arcsin(sine_solar_incidence_series)

    incidence_angle_definition = SolarIncidence().definition_complementary
    incidence_angle_description = SolarIncidence().description_complementary
    if not complementary_incidence_angle:
        logger.debug(
            f":information: Converting solar incidence angle to {COMPLEMENTARY_INCIDENCE_ANGLE_DEFINITION}...",
            alt=f":information: [bold][magenta]Converting[/magenta] solar incidence angle to {COMPLEMENTARY_INCIDENCE_ANGLE_DEFINITION}[/bold]...",
        )
        solar_incidence_series = (pi / 2) - solar_incidence_series
        incidence_angle_definition = SolarIncidence().definition
        incidence_angle_description = SolarIncidence().description

    # set negative or below horizon angles to 0 !
    if zero_negative_solar_incidence_angle:
        solar_incidence_series[
            (solar_incidence_series < 0) | (solar_altitude_series.value < 0)
        ] = NO_SOLAR_INCIDENCE

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_incidence_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return SolarIncidence(
        value=solar_incidence_series,
        unit=RADIANS,
        solar_positioning_algorithm=solar_declination_series.solar_positioning_algorithm,  #
        solar_timing_algorithm=solar_hour_angle_series.solar_timing_algorithm,  #
        incidence_algorithm=SolarIncidenceModel.hofierka,
        definition=incidence_angle_definition,
        description=incidence_angle_description,
        # azimuth_origin=solar_azimuth_series.origin,
    )

power

Functions:

Name Description
calculate_spectral_photovoltaic_power_output

Calculate the photovoltaic power output over a location (latitude), surface

calculate_spectrally_resolved_global_inclined_irradiance_series

Calculate the photovoltaic power output over a location (latitude), surface

calculate_spectral_photovoltaic_power_output

calculate_spectral_photovoltaic_power_output(
    longitude: float,
    latitude: float,
    elevation: float,
    timestamps: datetime | None = None,
    timezone: str | None = None,
    spectrally_resolved_global_horizontal_irradiance_series: (
        Path | None
    ) = None,
    spectrally_resolved_direct_horizontal_irradiance_series: (
        Path | None
    ) = None,
    number_of_junctions: int = 1,
    spectral_response_data: Path | None = None,
    standard_conditions_response: Path | None = None,
    temperature_series: ndarray = array(
        average_air_temperature
    ),
    wind_speed_series: ndarray = array(WIND_SPEED_DEFAULT),
    mask_and_scale: bool = False,
    neighbor_lookup: MethodForInexactMatches | None = None,
    tolerance: float | None = TOLERANCE_DEFAULT,
    in_memory: bool = False,
    surface_tilt: float | None = SURFACE_TILT_DEFAULT,
    surface_orientation: (
        float | None
    ) = SURFACE_ORIENTATION_DEFAULT,
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    adjust_for_atmospheric_refraction: bool = True,
    albedo: float | None = ALBEDO_DEFAULT,
    apply_angular_loss_factor: bool = True,
    solar_position_model: SolarPositionModel = SOLAR_POSITION_ALGORITHM_DEFAULT,
    solar_incidence_model: SolarIncidenceModel = jenco,
    solar_time_model: SolarTimeModel = SOLAR_TIME_ALGORITHM_DEFAULT,
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    time_output_units: str = MINUTES,
    angle_units: str = RADIANS,
    angle_output_units: str = RADIANS,
    system_efficiency: (
        float | None
    ) = SYSTEM_EFFICIENCY_DEFAULT,
    power_model: PhotovoltaicModulePerformanceModel = None,
    temperature_model: ModuleTemperatureAlgorithm = None,
    efficiency: float | None = None,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
)

Calculate the photovoltaic power output over a location (latitude), surface and atmospheric conditions, and an arbitrary period of time based on spectrally resolved direct (beam), diffuse, reflected solar irradiation and ambient temperature to account for the varying effects of the solar spectrum. Considering shadowing effects of the local topography are optionally incorporated. The solar geometry relevant parameters (e.g.sunrise and sunset, declination, extraterrestrial irradiance, daylight length) can be optionally saved in a file.

Source code in pvgisprototype/algorithms/hofierka/power.py
def calculate_spectral_photovoltaic_power_output(
    longitude: float,
    latitude: float,
    elevation: float,
    timestamps: datetime | None = None,
    timezone: str | None = None,
    spectrally_resolved_global_horizontal_irradiance_series: Path | None = None,  # global_spectral_radiation,  # g_rad_spec
    spectrally_resolved_direct_horizontal_irradiance_series: Path | None = None,  # direct_spectral_radiation,  # d_rad_spec,
    number_of_junctions: int = 1,
    spectral_response_data: Path | None = None,
    standard_conditions_response: Path | None = None,  #: float = 1,  # STCresponse : read from external data
    # extraterrestrial_normal_irradiance_series,  # spectral_ext,
    temperature_series: np.ndarray = np.array(TemperatureSeries().average_air_temperature),  # pres_temperature ?
    wind_speed_series: np.ndarray = np.array(WIND_SPEED_DEFAULT),
    mask_and_scale: bool = False,
    neighbor_lookup: MethodForInexactMatches | None = None,
    tolerance: float | None = TOLERANCE_DEFAULT,
    in_memory: bool = False,
    surface_tilt: float | None = SURFACE_TILT_DEFAULT,
    surface_orientation: float | None = SURFACE_ORIENTATION_DEFAULT,
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    adjust_for_atmospheric_refraction: bool = True,
    # unrefracted_solar_zenith: UnrefractedSolarZenith | None = UNREFRACTED_SOLAR_ZENITH_ANGLE_DEFAULT,
    albedo: float | None = ALBEDO_DEFAULT,
    apply_angular_loss_factor: bool = True,
    solar_position_model: SolarPositionModel = SOLAR_POSITION_ALGORITHM_DEFAULT,
    solar_incidence_model: SolarIncidenceModel = SolarIncidenceModel.jenco,
    solar_time_model: SolarTimeModel = SOLAR_TIME_ALGORITHM_DEFAULT,
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    # module_temperature,
    # horizonpointer,
    time_output_units: str = MINUTES,
    angle_units: str = RADIANS,
    angle_output_units: str = RADIANS,
    system_efficiency: float | None = SYSTEM_EFFICIENCY_DEFAULT,
    power_model: PhotovoltaicModulePerformanceModel = None,
    temperature_model: ModuleTemperatureAlgorithm = None,
    efficiency: float | None = None,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
):
    """
    Calculate the photovoltaic power output over a location (latitude), surface
    and atmospheric conditions, and an arbitrary period of time based on
    spectrally resolved direct (beam), diffuse, reflected solar irradiation and
    ambient temperature to account for the varying effects of the solar
    spectrum. Considering shadowing effects of the local topography are
    optionally incorporated. The solar geometry relevant parameters
    (e.g.sunrise and sunset, declination, extraterrestrial irradiance, daylight
    length) can be optionally saved in a file.
    """
    spectrally_resolved_global_irradiance_series = calculate_spectrally_resolved_global_inclined_irradiance_series(
        longitude=longitude,
        latitude=latitude,
        elevation=elevation,
        timestamps=timestamps,
        timezone=timezone,
        spectrally_resolved_global_horizontal_irradiance_series=spectrally_resolved_global_horizontal_irradiance_series,
        spectrally_resolved_direct_horizontal_irradiance_series=spectrally_resolved_direct_horizontal_irradiance_series,
        mask_and_scale=mask_and_scale,
        neighbor_lookup=neighbor_lookup,
        tolerance=tolerance,
        in_memory=in_memory,
        surface_tilt=surface_tilt,
        surface_orientation=surface_orientation,
        linke_turbidity_factor_series=linke_turbidity_factor_series,
        adjust_for_atmospheric_refraction=adjust_for_atmospheric_refraction,
        # unrefracted_solar_zenith=unrefracted_solar_zenith,
        albedo=albedo,
        apply_angular_loss_factor=apply_angular_loss_factor,
        solar_position_model=solar_position_model,
        solar_incidence_model=solar_incidence_model,
        solar_time_model=solar_time_model,
        solar_constant=solar_constant,
        eccentricity_phase_offset=eccentricity_phase_offset,
        eccentricity_amplitude=eccentricity_amplitude,
        time_output_units=time_output_units,
        angle_units=angle_units,
        angle_output_units=angle_output_units,
        system_efficiency=system_efficiency,
        power_model=power_model,
        temperature_model=temperature_model,
        efficiency=efficiency,
        verbose=verbose,
    )

    # In PVGIS' source code :
    # if spectral_band_number < 19:
    # global_power_1050 += global_spectral_power[spectral_band_number]

    index_1050 = np.max(np.where(BAND_LIMITS < 1050)[0])
    global_irradiance_series_up_to_1050 = spectrally_resolved_global_irradiance_series[
        :, index_1050
    ].sum()
    bandwidths = np.diff(BAND_LIMITS)
    spectral_power_density_up_to_1050 = global_irradiance_series_up_to_1050 / bandwidths

    spectral_response_data = read_spectral_response(spectral_response_data)
    spectral_response_wavelengths = spectral_response_data[0]  # response_wavelengths,
    spectral_response = spectral_response_data[1]
    standard_conditions_response = spectral_response_data[2]  # STCresponse

    spectral_factor = calculate_spectral_factor(
        global_total_power=spectrally_resolved_global_irradiance_series,
        spectral_power_density=spectral_power_density_up_to_1050,  # !
        number_of_junctions=number_of_junctions,
        response_wavelengths=spectral_response_wavelengths,
        spectral_response=spectral_response,
        standard_conditions_response=standard_conditions_response,
    )
    spectrally_resolved_photovoltaic_power = (
        spectrally_resolved_global_irradiance_series * spectral_factor
    )

    if efficiency:  # user-set
        efficiency_coefficient_series = calculate_photovoltaic_efficiency_series(
            irradiance_series=spectrally_resolved_global_irradiance_series,  # global_total_power,
            spectrally_factor=spectral_factor,  # internally will do *= global_total_power
            temperature_series=temperature_series,  # pres_temperature,
            model_constants=EFFICIENCY_MODEL_COEFFICIENTS_DEFAULT,
            standard_test_temperature=TemperatureSeries().average_air_temperature,
            wind_speed_series=wind_speed_series,
            power_model=power_model,
            temperature_model=temperature_model,
            verbose=0,  # no verbosity here by choice!
        )
        spectrally_resolved_photovoltaic_power *= efficiency_coefficient_series

    return spectrally_resolved_photovoltaic_power

calculate_spectrally_resolved_global_inclined_irradiance_series

calculate_spectrally_resolved_global_inclined_irradiance_series(
    longitude: float,
    latitude: float,
    elevation: float,
    timestamps: datetime | None = None,
    timezone: str | None = None,
    spectrally_resolved_global_horizontal_irradiance_series: (
        Path | None
    ) = None,
    spectrally_resolved_direct_horizontal_irradiance_series: (
        Path | None
    ) = None,
    mask_and_scale: bool = False,
    neighbor_lookup: MethodForInexactMatches = None,
    tolerance: float | None = TOLERANCE_DEFAULT,
    in_memory: bool = False,
    surface_tilt: float | None = SURFACE_TILT_DEFAULT,
    surface_orientation: (
        float | None
    ) = SURFACE_ORIENTATION_DEFAULT,
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    adjust_for_atmospheric_refraction: bool = True,
    albedo: float | None = ALBEDO_DEFAULT,
    apply_angular_loss_factor: bool = True,
    solar_position_model: SolarPositionModel = SOLAR_POSITION_ALGORITHM_DEFAULT,
    solar_incidence_model: SolarIncidenceModel = jenco,
    solar_time_model: SolarTimeModel = SOLAR_TIME_ALGORITHM_DEFAULT,
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    time_output_units: str = MINUTES,
    angle_units: str = RADIANS,
    angle_output_units: str = RADIANS,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
)

Calculate the photovoltaic power output over a location (latitude), surface and atmospheric conditions, and an arbitrary period of time based on spectrally resolved direct (beam), diffuse, reflected solar irradiation and ambient temperature to account for the varying effects of the solar spectrum. Considering shadowing effects of the local topography are optionally incorporated. The solar geometry relevant parameters (e.g.sunrise and sunset, declination, extraterrestrial irradiance, daylight length) can be optionally saved in a file.

Source code in pvgisprototype/algorithms/hofierka/power.py
def calculate_spectrally_resolved_global_inclined_irradiance_series(
    longitude: float,
    latitude: float,
    elevation: float,
    timestamps: datetime | None = None,
    timezone: str | None = None,
    spectrally_resolved_global_horizontal_irradiance_series: Path | None = None,  # global_spectral_radiation,  # g_rad_spec
    spectrally_resolved_direct_horizontal_irradiance_series: Path | None = None,  # direct_spectral_radiation,  # d_rad_spec,
    mask_and_scale: bool = False,
    neighbor_lookup: MethodForInexactMatches = None,
    tolerance: float | None = TOLERANCE_DEFAULT,
    in_memory: bool = False,
    surface_tilt: float | None = SURFACE_TILT_DEFAULT,
    surface_orientation: float | None = SURFACE_ORIENTATION_DEFAULT,
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    adjust_for_atmospheric_refraction: bool = True,
    # unrefracted_solar_zenith: UnrefractedSolarZenith | None = UNREFRACTED_SOLAR_ZENITH_ANGLE_DEFAULT,
    albedo: float | None = ALBEDO_DEFAULT,
    apply_angular_loss_factor: bool = True,
    solar_position_model: SolarPositionModel = SOLAR_POSITION_ALGORITHM_DEFAULT,
    solar_incidence_model: SolarIncidenceModel = SolarIncidenceModel.jenco,
    solar_time_model: SolarTimeModel = SOLAR_TIME_ALGORITHM_DEFAULT,
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    # module_temperature,
    # horizonpointer,
    time_output_units: str = MINUTES,
    angle_units: str = RADIANS,
    angle_output_units: str = RADIANS,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
):
    """
    Calculate the photovoltaic power output over a location (latitude), surface
    and atmospheric conditions, and an arbitrary period of time based on
    spectrally resolved direct (beam), diffuse, reflected solar irradiation and
    ambient temperature to account for the varying effects of the solar
    spectrum. Considering shadowing effects of the local topography are
    optionally incorporated. The solar geometry relevant parameters
    (e.g.sunrise and sunset, declination, extraterrestrial irradiance, daylight
    length) can be optionally saved in a file.
    """
    # Initialize arrays ?
    spectrally_resolved_direct_irradiance_series = np.zeros_like(
        spectrally_resolved_global_horizontal_irradiance_series
    )
    spectrally_resolved_diffuse_irradiance_series = np.zeros_like(
        spectrally_resolved_global_horizontal_irradiance_series
    )
    spectrally_resolved_reflected_irradiance_series = np.zeros_like(
        spectrally_resolved_global_horizontal_irradiance_series
    )

    # In r.pv_spec : s0 = lumcline2()
    solar_incidence_series = model_solar_incidence_series(
        longitude=longitude,
        latitude=latitude,
        timestamps=timestamps,
        timezone=timezone,
        solar_incidence_model=solar_incidence_model,
        surface_tilt=surface_tilt,
        surface_orientation=surface_orientation,
        eccentricity_phase_offset=eccentricity_phase_offset,
        eccentricity_amplitude=eccentricity_amplitude,
        time_output_units=time_output_units,
        angle_units=angle_units,
        verbose=0,
    )
    solar_altitude_series = model_solar_altitude_series(
        longitude=longitude,
        latitude=latitude,
        timestamps=timestamps,
        timezone=timezone,
        solar_position_model=solar_position_model,
        adjust_for_atmospheric_refraction=adjust_for_atmospheric_refraction,
        # unrefracted_solar_zenith=unrefracted_solar_zenith,
        solar_time_model=solar_time_model,
        eccentricity_phase_offset=eccentricity_phase_offset,
        eccentricity_amplitude=eccentricity_amplitude,
        time_output_units=time_output_units,
        angle_units=angle_units,
        verbose=0,
    )
    sun_above_horizon = solar_altitude_series.value > 0  # or : .radians ?
    positive_solar_incidence = solar_incidence_series.radians > 0
    surface_in_shade = is_surface_in_shade_series(solar_altitude_series.value)
    surface_not_in_shade = ~surface_in_shade
    positive_solar_incidence_and_surface_not_in_shade = np.logical_and(
        positive_solar_incidence, surface_not_in_shade
    )

    if not np.any(sun_above_horizon):
        spectrally_resolved_global_irradiance_series = 0

    else:  # if np.any(positive_solar_altitude)
        # We don't need a for loop! ==========================================
        # for spectral_band_number in range(1, number_of_spectral_bands + 1):
        # We don't need a for loop! ==========================================

        # The following stems from PVGIS' C/C++ source code ==================

        # Is it necessary ?
        # Is it possible for the direct component to be greater than the global one ?
        # Does it imply issues in the input data ?

        spectrally_resolved_global_horizontal_irradiance_series[
            spectrally_resolved_global_horizontal_irradiance_series < 0
        ] = 0

        spectrally_resolved_direct_horizontal_irradiance_series = np.minimum(
            spectrally_resolved_global_horizontal_irradiance_series,
            spectrally_resolved_direct_horizontal_irradiance_series,
        )
        # Above stems from PVGIS' C/C++ source code =========================

        if not np.any(
            positive_solar_incidence_and_surface_not_in_shade
        ):  # get the direct irradiance
            spectrally_resolved_direct_irradiance_series = 0
            spectrally_resolved_direct_horizontal_irradiance_series = 0

        else:
            # sunRadVar["cbh"] = global_spectral_radiation[spectral_band_number]
            # sunRadVar["cdh"] = direct_spectral_radiation[spectral_band_number]

            # extraterrestrial_normal_irradiance_series = EXTRATERRESTRIAL_NORMAL_IRRADIANCE[spectral_band_number]
            # extraterrestrial_normal_irradiance_series = (
            #     calculate_extraterrestrial_normal_irradiance_series(
            #         timestamps=timestamps,
            #         solar_constant=solar_constant,
            #         eccentricity_phase_offset=eccentricity_phase_offset,
            #         eccentricity_amplitude=eccentricity_amplitude,
            #     )
            # )

            # following is in r.pv_spec : `ra`
            spectrally_resolved_direct_irradiance_series[
                positive_solar_incidence_and_surface_not_in_shade
            ] = (
                calculate_direct_inclined_irradiance(
                    longitude=longitude,
                    latitude=latitude,
                    elevation=elevation,
                    timestamps=timestamps,
                    timezone=timezone,
                    direct_horizontal_component=spectrally_resolved_direct_horizontal_irradiance_series,
                    mask_and_scale=mask_and_scale,
                    neighbor_lookup=neighbor_lookup,
                    tolerance=tolerance,
                    in_memory=in_memory,
                    surface_tilt=surface_tilt,
                    surface_orientation=surface_orientation,
                    linke_turbidity_factor_series=linke_turbidity_factor_series,
                    adjust_for_atmospheric_refraction=adjust_for_atmospheric_refraction,
                    # unrefracted_solar_zenith=unrefracted_solar_zenith,
                    apply_angular_loss_factor=apply_angular_loss_factor,
                    solar_position_model=solar_position_model,
                    solar_incidence_model=solar_incidence_model,
                    solar_time_model=solar_time_model,
                    solar_constant=solar_constant,
                    eccentricity_phase_offset=eccentricity_phase_offset,
                    eccentricity_amplitude=eccentricity_amplitude,
                    time_output_units=time_output_units,
                    angle_units=angle_units,
                    angle_output_units=angle_output_units,
                    verbose=0,  # no verbosity here by choice!
                )
            )[
                positive_solar_incidence_and_surface_not_in_shade
            ]

        # Calculate diffuse and reflected irradiance for sun above horizon
        spectrally_resolved_diffuse_irradiance_series[sun_above_horizon] = (
            calculate_diffuse_inclined_irradiance(
                longitude=longitude,
                latitude=latitude,
                elevation=elevation,
                timestamps=timestamps,
                timezone=timezone,
                surface_tilt=surface_tilt,
                surface_orientation=surface_orientation,
                linke_turbidity_factor_series=linke_turbidity_factor_series,
                adjust_for_atmospheric_refraction=adjust_for_atmospheric_refraction,
                # unrefracted_solar_zenith=unrefracted_solar_zenith,
                global_horizontal_component=spectrally_resolved_global_horizontal_irradiance_series,
                direct_horizontal_component=spectrally_resolved_direct_horizontal_irradiance_series,
                apply_angular_loss_factor=apply_angular_loss_factor,
                solar_position_model=solar_position_model,
                solar_time_model=solar_time_model,
                solar_constant=solar_constant,
                eccentricity_phase_offset=eccentricity_phase_offset,
                eccentricity_amplitude=eccentricity_amplitude,
                time_output_units=time_output_units,
                angle_units=angle_units,
                angle_output_units=angle_output_units,
                neighbor_lookup=neighbor_lookup,
                verbose=0,  # no verbosity here by choice!
            )[sun_above_horizon]
        )

        spectrally_resolved_reflected_irradiance_series[sun_above_horizon] = (
            calculate_ground_reflected_inclined_irradiance_series(
                longitude=longitude,
                latitude=latitude,
                elevation=elevation,
                timestamps=timestamps,
                timezone=timezone,
                surface_tilt=surface_tilt,
                surface_orientation=surface_orientation,
                linke_turbidity_factor_series=linke_turbidity_factor_series,
                adjust_for_atmospheric_refraction=adjust_for_atmospheric_refraction,
                # unrefracted_solar_zenith=unrefracted_solar_zenith,
                albedo=albedo,
                direct_horizontal_component=spectrally_resolved_direct_horizontal_irradiance_series,
                apply_angular_loss_factor=apply_angular_loss_factor,
                solar_position_model=solar_position_model,
                solar_time_model=solar_time_model,
                solar_constant=solar_constant,
                eccentricity_phase_offset=eccentricity_phase_offset,
                eccentricity_amplitude=eccentricity_amplitude,
                time_output_units=time_output_units,
                angle_units=angle_units,
                verbose=0,  # no verbosity here by choice!
            )[sun_above_horizon]
        )

        spectrally_resolved_global_irradiance_series = (
            spectrally_resolved_direct_irradiance_series
            + spectrally_resolved_diffuse_irradiance_series
            + spectrally_resolved_reflected_irradiance_series
        )

    return spectrally_resolved_global_irradiance_series

read

Functions:

Name Description
read_spectral_response

Read spectral response data from a file.

read_spectral_response

read_spectral_response(filename: str) -> tuple

Read spectral response data from a file.

Parameters:

Name Type Description Default
filename str

Path to the file containing spectral response data.

required

Returns:

Type Description
tuple

A tuple containing: - wavelengths: 1D NumPy array of wavelengths. - response: 2D NumPy array of spectral response values. - STCresponse: Float value of Standard Test Condition response.

Raises:

Type Description
IOError

If the file cannot be opened or read correctly.

Source code in pvgisprototype/algorithms/hofierka/read.py
def read_spectral_response(
    filename: str,
) -> tuple:
    """
    Read spectral response data from a file.

    Parameters
    ----------
    filename : str
        Path to the file containing spectral response data.

    Returns
    -------
    tuple
        A tuple containing:
        - wavelengths: 1D NumPy array of wavelengths.
        - response: 2D NumPy array of spectral response values.
        - STCresponse: Float value of Standard Test Condition response.

    Raises
    ------
    IOError
        If the file cannot be opened or read correctly.
    """
    try:
        with open(filename, "r") as fp:
            STCresponse = float(fp.readline().strip())
            data = np.loadtxt(fp, delimiter=",", ndmin=2)
    except IOError as e:
        raise IOError(
            f"Could not open or read spectral response file: {filename}"
        ) from e

    if data.size == 0:
        return np.array([]), np.array([]), STCresponse

    wavelengths = data[:, 0]
    response = data[:, 1:].T  # Transposing to rearrange spectral responses

    return wavelengths, response, STCresponse

spectrum

Modules:

Name Description
average_photon_energy
spectral_factor

average_photon_energy

Functions:

Name Description
calculate_average_photon_energy

The Average Photon Energy (APE) characterises the energetic distribution

calculate_average_photon_energy
calculate_average_photon_energy(
    spectrally_resolved_global_irradiance_series,
    global_irradiance_series_up_to_1050,
    photon_flux_density,
    electron_charge=ELECTRON_CHARGE,
)

The Average Photon Energy (APE) characterises the energetic distribution in an irradiance spectrum. It is calculated by dividing the irradiance [W/m² or eV/m²/sec] by the photon flux density [number of photons/m²/sec].

Notes

From [1]_ :

"The average photon energy is a useful parameter for examining spectral
effects on the performance of amorphous silicon cells. It is strongly
correlated with the useful fraction, but is a device independent
parameter that does not require knowledge of the absorption profile of
a given device.

It is possible to examine the purely spectral performance of amorphous
silicon devices after removing temperature effects from the data.
Single junction devices show a linear increase in corrected ISC/Gtotal
as the received radiation becomes more blue shifted, as a greater
proportion of the insolation lies within its absorption window.

Double and triple junction devices do not vary linearly. The devices
investigated here reach maxima at 1.72 and 1.7 eV, respectively. As the
received spectrum becomes either red or blue shifted from this ideal,
performance drops off due to mismatch between the absorption profile
and the received spectrum. The output of multijunction devices is
essentially limited by the layer generating the least current. The
performance of triple junction cells is more susceptible to changes in
the incident spectrum than double junction cells, although this will be
countermanded with lower degradation in the case of a-Si devices.

The maximum spectral performance of multijunction devices occurs at
APEs higher than the APE where most energy is received. There is an
opportunity to improve the spectral performance of multijunction
devices such that they are most efficient at APEs where the majority of
the energy is delivered.
References

.. [1] Jardine, C.N. & Gottschalg, Ralph & Betts, Thomas & Infield, David. (2002). Influence of Spectral Effects on the Performance of Multijunction Amorphous Silicon Cells. to be published.

Source code in pvgisprototype/algorithms/hofierka/spectrum/average_photon_energy.py
def calculate_average_photon_energy(  # series ?
    spectrally_resolved_global_irradiance_series,
    global_irradiance_series_up_to_1050,
    photon_flux_density,
    electron_charge=ELECTRON_CHARGE,
):
    """
    The Average Photon Energy (APE) characterises the energetic distribution
    in an irradiance spectrum. It is calculated by dividing the irradiance
    [W/m² or eV/m²/sec] by the photon flux density [number of photons/m²/sec].

    Notes
    -----
    From [1]_ :

        "The average photon energy is a useful parameter for examining spectral
        effects on the performance of amorphous silicon cells. It is strongly
        correlated with the useful fraction, but is a device independent
        parameter that does not require knowledge of the absorption profile of
        a given device.

        It is possible to examine the purely spectral performance of amorphous
        silicon devices after removing temperature effects from the data.
        Single junction devices show a linear increase in corrected ISC/Gtotal
        as the received radiation becomes more blue shifted, as a greater
        proportion of the insolation lies within its absorption window.

        Double and triple junction devices do not vary linearly. The devices
        investigated here reach maxima at 1.72 and 1.7 eV, respectively. As the
        received spectrum becomes either red or blue shifted from this ideal,
        performance drops off due to mismatch between the absorption profile
        and the received spectrum. The output of multijunction devices is
        essentially limited by the layer generating the least current. The
        performance of triple junction cells is more susceptible to changes in
        the incident spectrum than double junction cells, although this will be
        countermanded with lower degradation in the case of a-Si devices.

        The maximum spectral performance of multijunction devices occurs at
        APEs higher than the APE where most energy is received. There is an
        opportunity to improve the spectral performance of multijunction
        devices such that they are most efficient at APEs where the majority of
        the energy is delivered.

    References
    ----------
    .. [1] Jardine, C.N. & Gottschalg, Ralph & Betts, Thomas & Infield, David.
      (2002). Influence of Spectral Effects on the Performance of Multijunction
      Amorphous Silicon Cells. to be published.
    """
    # name it series ?
    # In PVGIS' source code :
    # if spectral_band_number < 19:
    # number_of_photons += (
    #     global_spectral_power[spectral_band_number]
    #     / photon_energies[spectral_band_number]
    # )
    index_1050 = np.max(np.where(BAND_LIMITS < 1050)[0])
    # photon_energies_up_to_1050 = PHOTON_ENERGIES[index_1050]
    global_irradiance_series_up_to_1050 = spectrally_resolved_global_irradiance_series[
        :, index_1050
    ].sum()
    # ? ----------------------------------------------------------------------
    # number_of_photons_up_to_1050 = (
    #     spectrally_resolved_global_irradiance_series[:, index_1050]
    #     / photon_energies_up_to_1050
    # )
    # ------------------------------------------------------------------------
    average_photon_energy = (
        global_irradiance_series_up_to_1050 / photon_flux_density * electron_charge
    )

    return average_photon_energy  # series

spectral_factor

Functions:

Name Description
calculate_minimum_spectral_mismatch

Returns

integrate_spectrum_response
calculate_minimum_spectral_mismatch
calculate_minimum_spectral_mismatch(
    response_wavelengths,
    spectral_response,
    number_of_junctions: int,
    spectral_power_density,
)

Returns:

Name Type Description
minimum_spectral_mismatch float
minimum_junction

By Kirchoff’s Law the overall current produced by the device is only equal to the smallest current produced by an individual junction. This means that the least productive layer in a multi-junction device limits the performance of a multijunction cell [1]_

References

.. [1] Jardine, C.N. & Gottschalg, Ralph & Betts, Thomas & Infield, David. (2002). Influence of Spectral Effects on the Performance of Multijunction Amorphous Silicon Cells. to be published.

Source code in pvgisprototype/algorithms/hofierka/spectrum/spectral_factor.py
def calculate_minimum_spectral_mismatch(
    response_wavelengths,
    spectral_response,
    number_of_junctions: int,
    spectral_power_density,
):
    """
    Returns
    -------
    minimum_spectral_mismatch: float

    minimum_junction:

        By Kirchoff’s Law the overall current produced by the device is only
        equal to the smallest current produced by an individual junction. This
        means that the least productive layer in a multi-junction device limits
        the performance of a multijunction cell [1]_

    References
    ----------
    .. [1] Jardine, C.N. & Gottschalg, Ralph & Betts, Thomas & Infield, David.
      (2002). Influence of Spectral Effects on the Performance of Multijunction
      Amorphous Silicon Cells. to be published.
    """
    minimum_spectral_mismatch = 0  # FixMe
    minimum_junction = 1  # FixMe
    for junction in range(number_of_junctions):
        spectral_mismatch = integrate_spectrum_response(
            spectral_response_frequencies=response_wavelengths,
            spectral_response=spectral_response,
            kato_limits=junction,
            spectral_power_density=spectral_power_density,
        )
        if spectral_mismatch < minimum_spectral_mismatch:
            minimum_spectral_mismatch = spectral_mismatch
            minimum_junction = junction

    return minimum_spectral_mismatch, minimum_junction
integrate_spectrum_response
integrate_spectrum_response(
    spectral_response_frequencies: NDArray[float32] = None,
    spectral_response: NDArray[float32] = None,
    kato_limits: NDArray[float32] = None,
    spectral_power_density: NDArray[float32] = None,
) -> float
Source code in pvgisprototype/algorithms/hofierka/spectrum/spectral_factor.py
def integrate_spectrum_response(
    spectral_response_frequencies: NDArray[np.float32] = None,
    spectral_response: NDArray[np.float32] = None,
    kato_limits: NDArray[np.float32] = None,
    spectral_power_density: NDArray[np.float32] = None,
) -> float:
    """ """
    m = 0
    n = 0
    # nu_high = float()
    # response_low = float()
    # response_high = float()
    photovoltaic_power = 0
    response_low = spectral_response[0]
    # nu_low = float()
    nu_low = spectral_response_frequencies[0]

    number_of_response_values = len(spectral_response_frequencies)
    # number_of_kato_limits = len(kato_limits)

    while n < number_of_response_values - 1:
        if spectral_response_frequencies[n + 1] < kato_limits[m + 1]:
            nu_high = spectral_response_frequencies[n + 1]
            response_high = spectral_response[n + 1]

        else:
            nu_high = kato_limits[m + 1]
            response_high = spectral_response[n] + (
                nu_high - spectral_response_frequencies[n]
            ) / (
                spectral_response_frequencies[n + 1] - spectral_response_frequencies[n]
            ) * (
                spectral_response[n + 1] - spectral_response[n]
            )
        photovoltaic_power += (
            spectral_power_density[m]
            * 0.5
            * (response_high + response_low)
            * (nu_high - nu_low)
        )

        if spectral_response_frequencies[n + 1] < kato_limits[m + 1]:
            n += 1
        else:
            m += 1

        nu_low = nu_high
        response_low = response_high

    return photovoltaic_power

huld

Modules:

Name Description
efficiency_factor
photovoltaic_module

efficiency_factor

Functions:

Name Description
calculate_efficiency_factor_series

Calculate the efficiency factor series specific to the photovoltaic

calculate_efficiency_factor_series

calculate_efficiency_factor_series(
    effective_irradiance_series: EffectiveIrradiance,
    radiation_cutoff_threshold: float = RADIATION_CUTOFF_THRESHHOLD,
    photovoltaic_module: PhotovoltaicModuleModel = CSI_FREE_STANDING,
    power_model: PhotovoltaicModulePerformanceModel = king,
    temperature_series: TemperatureSeries = TemperatureSeries(
        value=average_air_temperature
    ),
    standard_test_temperature: float = standard_test_temperature,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> PhotovoltaicEfficiencyFactor

Calculate the efficiency factor series specific to the photovoltaic module technology.

Calculate the photovoltaic efficiency factor series for a solar irradiance component specific to the photovoltaic module technology, the photovoltaic power rating model and the (adjusted) temperature series. The implemented power rating model [0] is a variant of King's model [1, 2].

References

[0] Thomas Huld, Gabi Friesen, Artur Skoczek, Robert P. Kenny, Tony Sample, Michael Field, Ewan D. Dunlop, A power-rating model for crystalline silicon PV modules, Solar Energy Materials and Solar Cells, Volume 95, Issue 12, 2011, Pages 3359-3369, ISSN 0927-0248, https://doi.org/10.1016/j.solmat.2011.07.026.

[1] D.L. King, J.A. Kratochvil, W.E. Boyson, W.I. Bower, Field experience with a new performance characterization procedure for photovoltaic arrays, in: Proceedings of the second World Conference and Exhibition on Photovoltaic Solar Energy Conversion, Vienna, 1998, pp. 1947–1952.

[2] D.L. King, W.E. Boyson, J.A. Kratochvil, Photovoltaic Array Performance Model, SAND2004-3535, Sandia National Laboratories, 2004.

Source code in pvgisprototype/algorithms/huld/efficiency_factor.py
@log_function_call
def calculate_efficiency_factor_series(
    effective_irradiance_series: EffectiveIrradiance,  # effective irradiance
    radiation_cutoff_threshold: float = RADIATION_CUTOFF_THRESHHOLD,
    photovoltaic_module: PhotovoltaicModuleModel = PhotovoltaicModuleModel.CSI_FREE_STANDING,
    power_model: PhotovoltaicModulePerformanceModel = PhotovoltaicModulePerformanceModel.king,
    temperature_series: TemperatureSeries = TemperatureSeries(
        value=TemperatureSeries().average_air_temperature
    ),
    standard_test_temperature: float = TemperatureSeries().standard_test_temperature,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> PhotovoltaicEfficiencyFactor:
    """Calculate the efficiency factor series specific to the photovoltaic
    module technology.

    Calculate the photovoltaic efficiency factor series for a solar irradiance
    component specific to the photovoltaic module technology, the photovoltaic
    power rating model and the (adjusted) temperature series. The implemented
    power rating model [0] is a variant of King's model [1, 2].

    References
    ----------

    [0] Thomas Huld, Gabi Friesen, Artur Skoczek, Robert P. Kenny, Tony Sample,
    Michael Field, Ewan D. Dunlop, A power-rating model for crystalline silicon
    PV modules, Solar Energy Materials and Solar Cells, Volume 95, Issue 12,
    2011, Pages 3359-3369, ISSN 0927-0248,
    https://doi.org/10.1016/j.solmat.2011.07.026.

    [1] D.L. King, J.A. Kratochvil, W.E. Boyson, W.I. Bower, Field experience
    with a new performance characterization procedure for photovoltaic arrays,
    in: Proceedings of the second World Conference and Exhibition on
    Photovoltaic Solar Energy Conversion, Vienna, 1998, pp. 1947–1952.

    [2] D.L. King, W.E. Boyson, J.A. Kratochvil, Photovoltaic Array
    Performance Model, SAND2004-3535, Sandia National Laboratories, 2004.

    """
    array_parameters = {
        "shape": effective_irradiance_series.shape,
        "dtype": dtype,
        "init_method": "ones",
        "backend": array_backend,
    }
    efficiency_factor_series = create_array(**array_parameters)

    # Radiation cutoff handling
    relative_irradiance_series = (
        0.001 * effective_irradiance_series
    )  # Conversion to Unit PV Power ?
    efficiency_factor_series = where(
        relative_irradiance_series <= radiation_cutoff_threshold,
        0,
        efficiency_factor_series,
    )
    radiation_cutoff_loss_series = efficiency_factor_series - 1
    radiation_cutoff_loss_percentage_series = 100 * radiation_cutoff_loss_series

    # low_irradiance_series = ((irradiance_series * efficiency_factor_series) - irradiance_series)
    # low_irradiance_percentage_series = 100 * where(
    #         irradiance_series != 0,
    #     ((irradiance_series * efficiency_factor_series) - irradiance_series) / irradiance_series,
    #     0
    # )

    photovoltaic_module_efficiency_coefficients = (
        get_coefficients_for_photovoltaic_module(photovoltaic_module)
    )
    # --------------------------------------------------- Is this safe ? -
    with np.errstate(divide="ignore", invalid="ignore"):
        log_relative_irradiance_series = where(
            relative_irradiance_series > 0,
            numpy_log(relative_irradiance_series),
            0,  # -numpy_inf,
        )
    temperature_deviation_series = temperature_series.value - standard_test_temperature

    # power output fom King (using PV eff coeffs for a specific tech) -  ... ?
    # difference between real conditions and stadard conditions -- should be close to zero

    if power_model.value == PhotovoltaicModulePerformanceModel.king:
        efficiency_factor_series = (
            photovoltaic_module_efficiency_coefficients[0]
            + log_relative_irradiance_series
            * (
                photovoltaic_module_efficiency_coefficients[1]
                + log_relative_irradiance_series
                * photovoltaic_module_efficiency_coefficients[2]
            )
            + temperature_deviation_series
            * (
                photovoltaic_module_efficiency_coefficients[3]
                + log_relative_irradiance_series
                * (
                    photovoltaic_module_efficiency_coefficients[4]
                    + log_relative_irradiance_series
                    * photovoltaic_module_efficiency_coefficients[5]
                )
                + photovoltaic_module_efficiency_coefficients[6]
                * temperature_deviation_series
            )
        )

        efficiency_factor_series /= photovoltaic_module_efficiency_coefficients[0]

    if (
        power_model.value == PhotovoltaicModulePerformanceModel.iv
    ):  # 'IV' Model ( Name ? )
        current_series = (
            CURRENT_AT_STANDARD_TEST_CONDITIONS
            + CURRENT_AT_STANDARD_TEST_CONDITIONS_TEMPERATURE_COEFFICIENT
            * temperature_deviation_series
        )
        voltage_series = (
            VOLTAGE_AT_STANDARD_TEST_CONDITIONS
            + VOLTAGE_AT_STANDARD_TEST_CONDITIONS_COEFFICIENT_1
            * log_relative_irradiance_series
            + VOLTAGE_AT_STANDARD_TEST_CONDITIONS_COEFFICIENT_2
            * log_relative_irradiance_series**2
            + VOLTAGE_AT_STANDARD_TEST_CONDITIONS_TEMPERATURE_COEFFICIENT
            * temperature_deviation_series
        )
        efficiency_factor_series = (
            current_series * voltage_series
        ) / POWER_AT_STANDARD_TEST_CONDITIONS

    # # Mask where efficiency is out of range [0, 1]
    # mask_invalid_efficiency = (efficiency_series < 0) | (efficiency_series > 1)
    # if np.any(mask_invalid_efficiency):
    #     return "Some calculated efficiencies are out of the expected range [0, 1]!"

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=efficiency_factor_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return PhotovoltaicEfficiencyFactor(
        value=efficiency_factor_series,
        effective_irradiance=effective_irradiance_series,
        log_relative_irradiance=log_relative_irradiance_series,
        relative_irradiance=relative_irradiance_series,
        low_irradiance=relative_irradiance_series <= radiation_cutoff_threshold,
        photovoltaic_module=photovoltaic_module,
        photovoltaic_module_efficiency_coefficients=photovoltaic_module_efficiency_coefficients,
        power_model=power_model,
        radiation_cutoff_threshold=radiation_cutoff_threshold,
        radiation_cutoff_loss_percentage=radiation_cutoff_loss_percentage_series,
        temperature_deviation=temperature_deviation_series,
    )

photovoltaic_module

Functions:

Name Description
get_coefficients_for_photovoltaic_module

Retrieve model coefficients based on the selected PhotovoltaicModuleModel Enum.

get_coefficients_for_photovoltaic_module

get_coefficients_for_photovoltaic_module(
    photovoltaic_module: PhotovoltaicModuleModel,
) -> List[float]

Retrieve model coefficients based on the selected PhotovoltaicModuleModel Enum.

Source code in pvgisprototype/algorithms/huld/photovoltaic_module.py
def get_coefficients_for_photovoltaic_module(
    photovoltaic_module: PhotovoltaicModuleModel,
) -> List[float]:
    """Retrieve model coefficients based on the selected PhotovoltaicModuleModel Enum."""
    coefficients = PHOTOVOLTAIC_MODULE_COEFFICIENTS_MAP.get(photovoltaic_module, [])
    if len(coefficients) < 7:  # should be at least
        raise ValueError("Insufficient number of model constants!")

    return coefficients

iqbal

Modules:

Name Description
solar_incidence

Calculate the solar incidence angle for a surface that is tilted to any

solar_incidence

Calculate the solar incidence angle for a surface that is tilted to any horizontal and vertical angle, as described by Iqbal [0].

[0] Iqbal, M. “An Introduction to Solar Radiation”. New York: 1983; pp. 23-25.

Functions:

Name Description
calculate_solar_incidence_series_iqbal

Calculate the solar incidence angle for a surface oriented in any

calculate_solar_incidence_series_iqbal

calculate_solar_incidence_series_iqbal(
    longitude: Longitude,
    latitude: Latitude,
    surface_orientation: SurfaceOrientation = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt = SURFACE_TILT_DEFAULT,
    timestamps: DatetimeIndex = now_utc_datetimezone(),
    timezone: ZoneInfo | None = None,
    adjust_for_atmospheric_refraction: bool = ATMOSPHERIC_REFRACTION_FLAG_DEFAULT,
    sun_horizon_position: List[
        SunHorizonPositionModel
    ] = SUN_HORIZON_POSITION_DEFAULT,
    surface_in_shade_series: NpNDArray | None = None,
    complementary_incidence_angle: bool = COMPLEMENTARY_INCIDENCE_ANGLE_DEFAULT,
    zero_negative_solar_incidence_angle: bool = ZERO_NEGATIVE_INCIDENCE_ANGLE_DEFAULT,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
) -> SolarIncidence

Calculate the solar incidence angle for a surface oriented in any direction.

Calculate the solar incidence angle between the sun position unit vector and the surface normal unit vector for a surface oriented in any direction; in other words, the cosine of the angle of incidence. Optionally, the output may be the complementary incidence angle between the sun-to-surface vector and the surface plane.

Notes

The equation for the incidence angle I by Iqbal (1983) [0]_ [1]_

I = Arc cos( cos(θ) * cos(ω) + sin(ω) * sin(θ) * (Γ - γ) )

where :

  • θ is the solar zenith angle
  • ω is the surface tilt angle
  • Γ is the astronomers topocentric azimuth angle
  • γ is the navigators topocentric azimuth angle

Important observations are :

  • The topocentric astronomers azimuth angle Γ which is measured westward from south.

  • The surface orientation angle γ (also referred to as surface azimuth rotation angle) is measured from south to the projection of the surface normal on the horizontal plane, positive or negative if oriented west or east from south, respectively.

  • The topocentric azimuth angle Φ for navigators and solar radiation users (equation 46, p. 588) is measured eastward from north and thus equals the astronomers one plus π or else 180 degrees :

Φ = Γ + 180

and thus

Γ = Φ - 180 [*]

In equation I, the surface orientation angle γ measured from south, is subtracted from the astronomers topocentric azimuth angle which is likewise measured from south. Given that most applications measure azimuthal angles from North, care must be taken to feed the correct "version" of these angles in this function.

PVGIS measures the user-requested azimuthal angles Solar Azimuth (follownig denoted also with Φ) and the "solar radiation"-relevant Surface Orientation from North (follownig denoted as γΝ). Equation I based on [*] becomes relevant for PVGIS in the following form :

I = Arc cos( cos(θ) * cos(ω) + sin(ω) * sin(θ) * (Φ - 180 - γN - 180) )

or else

cosine_solar_incidence_series = (
    numpy.cos(solar_zenith_series.radians)
    * cos(surface_tilt.radians)
    + sin(surface_tilt.radians)
    * numpy.sin(solar_zenith_series.radians)
    * numpy.cos(solar_azimuth_series.radians -
    surface_orientation.radians - 2 * pi)
)

Nonetheless, and for the sake of consistency with the author's original definition, such conversion are preferrable to be performed in advance and outside the scope of the current function, for both the solar azimuth and the surface orientation angles. Therefore the internal form of the equation uses as per its definition the astronomers topocentric solar azimuth angle and the surface azimuth rotation angle measured from south -- see also source code of this function.

References

.. [0] Iqbal, 1983

.. [1] Equation 47, p. 588),

Parameters:

Name Type Description Default
longitude Longitude
required
latitude Latitude
required
timestamps DatetimeIndex
now_utc_datetimezone()
timezone ZoneInfo
None
surface_orientation SurfaceOrientation

Panel azimuth from north.

SURFACE_ORIENTATION_DEFAULT
surface_tilt SurfaceTilt

Panel tilt from horizontal.

SURFACE_TILT_DEFAULT
adjust_for_atmospheric_refraction bool
ATMOSPHERIC_REFRACTION_FLAG_DEFAULT
complementary_incidence_angle bool
COMPLEMENTARY_INCIDENCE_ANGLE_DEFAULT
zero_negative_solar_incidence_angle bool
ZERO_NEGATIVE_INCIDENCE_ANGLE_DEFAULT
dtype str
DATA_TYPE_DEFAULT
array_backend str
ARRAY_BACKEND_DEFAULT
verbose int
VERBOSE_LEVEL_DEFAULT
log int
LOG_LEVEL_DEFAULT

Returns:

Name Type Description
solar_incidence_series SolarIncidence

A times series of solar incidence angles between the sun position vector and the surface normal (or plane)

Notes

Notes from the original pvlib function :

  • Usage note: When the sun is behind the surface the value returned is negative. For many uses negative values must be set to zero.

  • Input all angles in degrees.

References

.. [0] Iqbal, M. “An Introduction to Solar Radiation”. New York: 1983; pp. 23-25.

Source code in pvgisprototype/algorithms/iqbal/solar_incidence.py
@log_function_call
@custom_cached
def calculate_solar_incidence_series_iqbal(
    longitude: Longitude,
    latitude: Latitude,
    surface_orientation: SurfaceOrientation = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt = SURFACE_TILT_DEFAULT,
    timestamps: DatetimeIndex = now_utc_datetimezone(),
    timezone: ZoneInfo | None = None,
    adjust_for_atmospheric_refraction: bool = ATMOSPHERIC_REFRACTION_FLAG_DEFAULT,
    sun_horizon_position: List[SunHorizonPositionModel] = SUN_HORIZON_POSITION_DEFAULT,
    surface_in_shade_series: NpNDArray | None = None,
    complementary_incidence_angle: bool = COMPLEMENTARY_INCIDENCE_ANGLE_DEFAULT,
    zero_negative_solar_incidence_angle: bool = ZERO_NEGATIVE_INCIDENCE_ANGLE_DEFAULT,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
) -> SolarIncidence:
    """Calculate the solar incidence angle for a surface oriented in any
    direction.

    Calculate the solar incidence angle between the sun position unit vector
    and the surface normal unit vector for a surface oriented in any direction;
    in other words, the cosine of the angle of incidence. Optionally, the
    output may be the complementary incidence angle between the sun-to-surface
    vector and the surface plane.

    Notes
    -----

    The equation for the incidence angle `I` by Iqbal (1983) [0]_ [1]_

        I = Arc cos( cos(θ) * cos(ω) + sin(ω) * sin(θ) * (Γ - γ) )

    where :

    - θ is the solar zenith angle
    - ω is the surface tilt angle
    - Γ is the astronomers topocentric azimuth angle
    - γ is the navigators topocentric azimuth angle

    Important observations are :

    - The topocentric astronomers azimuth angle `Γ` which is measured _westward
      from south_.

    - The surface orientation angle `γ` (also referred to as surface azimuth
      rotation angle) is measured from south to the projection of the surface
      normal on the horizontal plane, positive or negative if oriented west or
      east from south, respectively.

    - The topocentric azimuth angle `Φ` for navigators and solar radiation
      users (equation 46, p. 588) is measured _eastward from north_ and thus
      equals the astronomers one plus π or else 180 degrees :

       Φ = Γ + 180

       and thus

       Γ = Φ - 180    [*]

    In equation I, the surface orientation angle `γ` measured from south, is
    subtracted from the astronomers topocentric azimuth angle which is likewise
    measured from south. Given that most applications measure azimuthal angles
    from North, care must be taken to feed the correct "version" of these
    angles in this function.

    PVGIS measures the user-requested azimuthal angles Solar Azimuth (follownig
    denoted also with Φ) and the "solar radiation"-relevant Surface Orientation
    from North (follownig denoted as `γΝ`). Equation I based on [*] becomes
    relevant for PVGIS in the following form :

        I = Arc cos( cos(θ) * cos(ω) + sin(ω) * sin(θ) * (Φ - 180 - γN - 180) )

        or else

        cosine_solar_incidence_series = (
            numpy.cos(solar_zenith_series.radians)
            * cos(surface_tilt.radians)
            + sin(surface_tilt.radians)
            * numpy.sin(solar_zenith_series.radians)
            * numpy.cos(solar_azimuth_series.radians -
            surface_orientation.radians - 2 * pi)
        )

    Nonetheless, and for the sake of consistency with the author's original
    definition, such conversion are preferrable to be performed in advance and
    outside the scope of the current function, for both the solar azimuth and
    the surface orientation angles. Therefore the internal form of the equation
    uses as per its definition the astronomers topocentric solar azimuth angle
    and the surface azimuth rotation angle measured from south -- see also
    source code of this function.

    References
    ----------
    .. [0] Iqbal, 1983

    .. [1] Equation 47, p. 588),

    Parameters
    ----------
    longitude : Longitude
    latitude : Latitude
    timestamps : DatetimeIndex
    timezone : ZoneInfo
    surface_orientation : SurfaceOrientation
        Panel azimuth from north.
    surface_tilt : SurfaceTilt
        Panel tilt from horizontal.
    adjust_for_atmospheric_refraction : bool
    complementary_incidence_angle : bool
    zero_negative_solar_incidence_angle : bool
    dtype : str
    array_backend : str
    verbose : int
    log : int

    Returns
    -------
    solar_incidence_series : SolarIncidence
        A times series of solar incidence angles between the sun position
        vector and the surface normal (or plane)

    Notes
    -----
    Notes from the original pvlib function :

    - Usage note: When the sun is behind the surface the value returned is
      negative.  For many uses negative values must be set to zero.

    - Input all angles in degrees.

    References
    ----------
    .. [0] Iqbal, M. “An Introduction to Solar Radiation”. New York: 1983; pp. 23-25.

    """
    solar_zenith_series = calculate_solar_zenith_series_noaa(
        longitude=longitude,
        latitude=latitude,
        timestamps=timestamps,
        timezone=timezone,
        adjust_for_atmospheric_refraction=adjust_for_atmospheric_refraction,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
        validate_output=validate_output,
    )
    solar_azimuth_series_north_based = calculate_solar_azimuth_series_noaa(
        longitude=longitude,
        latitude=latitude,
        timestamps=timestamps,
        timezone=timezone,
        adjust_for_atmospheric_refraction=adjust_for_atmospheric_refraction,
        dtype=dtype,
        array_backend=array_backend,
        verbose=0,
        log=log,
        validate_output=validate_output,
    )  # North = 0 according to NOAA's solar geometry equations

    # array_parameters = {
    #     "shape": timestamps.shape,
    #     "dtype": dtype,
    #     "init_method": "empty",
    #     "backend": array_backend,
    # }  # Borrow shape from timestamps
    # solar_incidence_series = create_array(**array_parameters)

    # Convert to south-based
    solar_azimuth_series = SolarAzimuth(
        value=(solar_azimuth_series_north_based.radians - pi),
        unit=RADIANS,
    )
    # Φimit Φ to the range from 0° to 360°.
    # Divide Φ by 360, record the decimal fraction of the division as F.
    # Divide phi by 360 and get the remainder and the fractional part
    fraction_series, _ = numpy.modf(solar_azimuth_series.radians / (2 * pi))

    # If Φ is positive, then the limited Φ = 360 * F .
    # If Φ is negative, then the limited Φ = 360 - 360 *F.

    # Remember all of the metadata ! Review-Me & Abstract-Me !
    solar_azimuth_series = SolarAzimuth(
        value=numpy.where(
            solar_azimuth_series.radians >= 0,
            2 * pi * fraction_series,
            2 * pi - (2 * pi * numpy.abs(fraction_series)),
        ),
        unit=RADIANS,
        solar_positioning_algorithm=solar_azimuth_series_north_based.solar_positioning_algorithm,
        solar_timing_algorithm=solar_azimuth_series_north_based.solar_timing_algorithm,
        origin=solar_azimuth_series_north_based.origin,
    )
    # named 'projection' in pvlib
    cosine_solar_incidence_series = numpy.cos(solar_zenith_series.radians) * cos(
        surface_tilt.radians
    ) + sin(surface_tilt.radians) * numpy.sin(solar_zenith_series.radians) * numpy.cos(
        solar_azimuth_series.radians - surface_orientation.radians
    )  # where :
    # solar_azimuth_series : is the astronomers topocentric solar azimuth measured from south
    # surface_orientation : is the surface rotation azimuth angle measured from south

    # GH 1185 : This is a note from pvlib ?
    # projection = numpy.clip(projection, -1, 1)
    cosine_solar_incidence_series = numpy.clip(cosine_solar_incidence_series, -1, 1)
    solar_incidence_series = numpy.arccos(cosine_solar_incidence_series)

    incidence_angle_definition = SolarIncidence().definition_typical  # This is the "standard"
    incidence_angle_description = SolarIncidence().description_typical
    if complementary_incidence_angle:
        logger.debug(
            f":information: Converting solar incidence angle to {COMPLEMENTARY_INCIDENCE_ANGLE_DEFINITION}...",
            alt=f":information: [bold][magenta]Converting[/magenta] solar incidence angle to {COMPLEMENTARY_INCIDENCE_ANGLE_DEFINITION}[/bold]...",
        )
        solar_incidence_series = (pi / 2) - solar_incidence_series
        incidence_angle_definition = SolarIncidence().definition_complementary
        incidence_angle_description = SolarIncidence().description_complementary

    # set negative or below horizon angles ( == solar zenith > 90 ) to 0 !

    # Select which solar positions related to the horizon to process
    sun_horizon_positions = select_models(
        SunHorizonPositionModel, sun_horizon_position
    )  # Using a callback fails!
    # and keep track of the position of the sun relative to the horizon
    sun_horizon_position_series = create_array(
        timestamps.shape, dtype="object", init_method="empty", backend=array_backend
    )
    mask_below_horizon = create_array(
        timestamps.shape, dtype="bool", init_method="empty", backend=array_backend
    )
    mask_low_angle = create_array(
        timestamps.shape, dtype="bool", init_method="empty", backend=array_backend
    )
    mask_above_horizon = create_array(
        timestamps.shape, dtype="bool", init_method="empty", backend=array_backend
    )

    # For sun below the horizon
    if SunHorizonPositionModel.below in sun_horizon_positions:
        mask_below_horizon = solar_zenith_series.value > pi / 2
        sun_horizon_position_series[mask_below_horizon] = [
            SunHorizonPositionModel.below.value
        ]

    # For very low sun angles
    if SunHorizonPositionModel.low_angle in sun_horizon_positions:
        mask_low_angle = (
            (solar_zenith_series.value <= pi / 2)
            & (
                solar_zenith_series.value > solar_zenith_series.low_angle_threshold_radians
            )
            & (sun_horizon_position_series == None)  # Operate only on unset elements
        )
        sun_horizon_position_series[mask_low_angle] = (
            SunHorizonPositionModel.low_angle.value
        )

    if SunHorizonPositionModel.above in sun_horizon_positions:
        mask_above_horizon = numpy.logical_and(
            (solar_zenith_series.value < pi / 2),
            sun_horizon_position_series == None,  # operate only on unset elements
        )
        sun_horizon_position_series[mask_above_horizon] = [
            SunHorizonPositionModel.above.value
        ]

    # Combine relevant conditions for no solar incidence
    mask_no_solar_incidence_series = numpy.logical_or(
        (solar_incidence_series < 0)
        | mask_below_horizon
        | surface_in_shade_series.value,
        sun_horizon_position_series == None,
    )

    # Zero out negative solar incidence angles : is the default behavior !
    if zero_negative_solar_incidence_angle:
        logger.debug(
            f":information: Setting negative solar incidence angle values to zero...",
            alt=f":information: [bold][magenta]Setting[/magenta] [red]negative[/red] solar incidence angle values to [bold]zero[/bold]...",
        )
        solar_incidence_series = numpy.where(
            mask_no_solar_incidence_series,
            # (solar_incidence_series < 0) | (solar_altitude_series.value < 0),
            NO_SOLAR_INCIDENCE,
            solar_incidence_series,
        )

    log_data_fingerprint(
        data=solar_incidence_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    return SolarIncidence(
        value=solar_incidence_series,
        sun_horizon_position=sun_horizon_position_series,
        solar_positioning_algorithm=solar_zenith_series.solar_positioning_algorithm,
        solar_timing_algorithm=solar_zenith_series.solar_timing_algorithm,
        algorithm=SolarIncidenceModel.iqbal,
        definition=incidence_angle_definition,  # either the 'typical' or the 'complementary'
        description=incidence_angle_description,  # same as above
        azimuth_origin=solar_azimuth_series.origin,
    )

jenco

Modules:

Name Description
solar_altitude
solar_azimuth
solar_declination
solar_incidence

solar_altitude

Functions:

Name Description
calculate_solar_altitude_series_jenco

Calculate the solar altitude angle (θ) for a time series at a specific

calculate_solar_altitude_series_jenco

calculate_solar_altitude_series_jenco(
    longitude: Longitude,
    latitude: Latitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo | None,
    eccentricity_phase_offset: float,
    eccentricity_amplitude: float,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = 0,
    log: int = 0,
) -> SolarAltitude

Calculate the solar altitude angle (θ) for a time series at a specific geographic latitude and longitude.

Parameters:

Name Type Description Default
longitude float

Longitude of the location in radians.

required
latitude float

Latitude of the location in radians.

required
timestamps DatetimeIndex

Times for which the solar azimuth will be calculated.

required
timezone ZoneInfo

Timezone of the location.

required
dtype str

Data type for the calculations.

DATA_TYPE_DEFAULT
array_backend str

Backend array library to use.

ARRAY_BACKEND_DEFAULT
verbose int

Verbosity level of the function.

0
log int

Log level for the function.

0

Returns:

Type Description
SolarAltitude

A custom data class that hold a NumPy NDArray of calculated solar azimuth angles in radians, a method to convert the angles to degrees and other metadata.

Notes
References

Examples:

Source code in pvgisprototype/algorithms/jenco/solar_altitude.py
@log_function_call
@custom_cached
# @validate_with_pydantic(CalculateSolarAltitudeTimeSeriesJencoInput)
def calculate_solar_altitude_series_jenco(
    longitude: Longitude,  # radians
    latitude: Latitude,  # radians
    timestamps: DatetimeIndex,
    timezone: ZoneInfo | None,
    eccentricity_phase_offset: float,
    eccentricity_amplitude: float,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = 0,
    log: int = 0,
) -> SolarAltitude:
    """Calculate the solar altitude angle (θ) for a time series at a specific
    geographic latitude and longitude.

    Parameters
    ----------
    longitude : float
        Longitude of the location in radians.
    latitude : float
        Latitude of the location in radians.
    timestamps : DatetimeIndex
        Times for which the solar azimuth will be calculated.
    timezone : ZoneInfo
        Timezone of the location.
    dtype : str, optional
        Data type for the calculations.
    array_backend : str, optional
        Backend array library to use.
    verbose : int, optional
        Verbosity level of the function.
    log : int, optional
        Log level for the function.

    Returns
    -------
    SolarAltitude
        A custom data class that hold a NumPy NDArray of calculated solar
        azimuth angles in radians, a method to convert the angles to degrees
        and other metadata.

    Notes
    -----

    References
    ----------

    Examples
    --------

    """
    solar_declination_series = calculate_solar_declination_series_hofierka(
        timestamps=timestamps,
        eccentricity_phase_offset=eccentricity_phase_offset,
        eccentricity_amplitude=eccentricity_amplitude,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
    )
    solar_hour_angle_series = calculate_solar_hour_angle_series_hofierka(
        longitude=longitude,
        timestamps=timestamps,
        timezone=timezone,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
    )
    C31 = cos(latitude.radians) * numpy.cos(solar_declination_series.radians)
    C33 = sin(latitude.radians) * numpy.sin(solar_declination_series.radians)
    sine_solar_altitude_series = C31 * numpy.cos(solar_hour_angle_series.radians) + C33
    solar_altitude_series = numpy.arcsin(sine_solar_altitude_series)

    # mask_positive_C31 = C31 > 1e-7
    # solar_altitude_series[mask_positive_C31] = numpy.where(
    #     sine_solar_altitude_series < 0,
    #     NO_SOLAR_INCIDENCE,
    #     solar_altitude_series,
    # )

    if (
        (solar_altitude_series < SolarAltitude().min_radians)
        | (solar_altitude_series > SolarAltitude().max_radians)
    ).any():
        out_of_range_values = solar_altitude_series[
            (solar_altitude_series < SolarAltitude().min_radians)
            | (solar_altitude_series > SolarAltitude().max_radians)
        ]
        # raise ValueError(# ?
        logger.warning(
            f"{WARNING_OUT_OF_RANGE_VALUES} "
            f"[{SolarAltitude().min_radians}, {SolarAltitude().max_radians}] radians"
            f" in [code]solar_altitude_series[/code] : {out_of_range_values}"
        )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_altitude_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return SolarAltitude(
        value=solar_altitude_series,
        unit=RADIANS,
        solar_positioning_algorithm=solar_declination_series.solar_positioning_algorithm,  #
        solar_timing_algorithm=solar_hour_angle_series.solar_timing_algorithm,  #
    )

solar_azimuth

Functions:

Name Description
calculate_solar_azimuth_series_jenco

Calculate the solar azimuth angle (θ) between the sun and meridian

calculate_solar_azimuth_series_jenco

calculate_solar_azimuth_series_jenco(
    longitude: Longitude,
    latitude: Latitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    adjust_for_atmospheric_refraction: bool = True,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = 0,
    log: int = 0,
) -> SolarAzimuth

Calculate the solar azimuth angle (θ) between the sun and meridian measured from East for a time series at a specific geographic latitude and longitude.

Parameters:

Name Type Description Default
longitude float

Longitude of the location in radians.

required
latitude float

Latitude of the location in radians.

required
timestamps DatetimeIndex

Times for which the solar azimuth will be calculated.

required
timezone ZoneInfo

Timezone of the location.

required
adjust_for_atmospheric_refraction bool

Whether to correct the solar zenith angle for atmospheric refraction.

True
dtype str

Data type for the calculations.

DATA_TYPE_DEFAULT
array_backend str

Backend array library to use.

ARRAY_BACKEND_DEFAULT
verbose int

Verbosity level of the function.

0
log int

Log level for the function.

0

Returns:

Type Description
SolarAzimuth

A custom data class that hold a NumPy NDArray of calculated solar azimuth angles in radians, a method to convert the angles to degrees and other metadata.

Notes

Two important notes on the calculation of the solar azimuth angle :

  • The equation implemented here follows upon the relevant Wikipedia article for the "Solar azimuth angle" [1]_.

  • The angle derived fom the arccosine function requires an adjustment to correctly represent both morning and afternoon solar azimuth angles.

Adjusting the solar azimuth based on the time of day

Given that arccosine ranges in [0, π] or else in [0°, 180°], the raw calculated solar azimuth angle will likewise range in [0, π]. This necessitates an adjustment based on the time of day.

  • Morning (solar hour angle < 0): the azimuth angle is correctly derived directly from the arccosine function representing angles from the North clockwise to the South [0°, 180°].

  • Afternoon (solar hour angle > 0): the azimuth angle needs to be adjusted in order to correctly represent angles going further from the South to the West [180°, 360°]. This is achieved by subtracting the azimuth from 360°.

On the use of arctan2 from Wikipedia :

Corollary: if (y1, x1) and (y2, x2) are 2-dimensional vectors, the difference formula is frequently used in practice to compute the angle between those vectors with the help of atan2, since the resulting computation behaves benign in the range (−π, π] and can thus be used without range checks in many practical situations.

The atan2 function was originally designed for the convention in pure mathematics that can be termed east-counterclockwise. In practical applications, however, the north-clockwise and south-clockwise conventions are often the norm. The solar azimuth angle for example, that uses both the north-clockwise and south-clockwise conventions widely, can be calculated similarly with the east- and north-components of the solar vector as its arguments. Different conventions can be realized by swapping the positions and changing the signs of the x- and y-arguments as follows:

  • atan2(y,x) : East-Counterclockwise Convention
  • atan2(x,y) : North-Clockwise Convention
  • atan2(-x,-y) : South-Clockwise Convention

Changing the sign of the x- and/or y-arguments and/or swapping their positions can create 8 possible variations of the atan2 function and they, interestingly, correspond to 8 possible definitions of the angle, namely, clockwise or counterclockwise starting from each of the 4 cardinal directions, north, east, south and west.

References

.. [0] https://gml.noaa.gov/grad/solcalc/solareqns.PDF

.. [1] https://en.wikipedia.org/wiki/Solar_azimuth_angle#Conventional_Trigonometric_Formulas

.. [2] https://github.com/pvlib/pvlib-python

.. [3] https://github.com/skyfielders/python-skyfield/

.. [4] https://github.com/kylebarron/suncalc-py

Examples:

>>> from math import radians
>>> from pvgisprototype.api.utilities.timestamp import generate_datetime_series
>>> timestamps = generate_datetime_series(start_time='2010-01-27', end_time='2010-01-28')
>>> from zoneinfo import ZoneInfo
>>> from pvgisprototype.api.position.azimuth_series import calculate_solar_azimuth_series_noaa
>>> solar_azimuth_series = calculate_solar_azimuth_series_noaa(
... longitude=radians(8.628),
... latitude=radians(45.812),
... timestamps=timestamps,
... timezone=ZoneInfo("UTC"),
... adjust_for_atmospheric_refraction=True
... )
>>> print(solar_azimuth_series)
>>> print(solar_azimuth_series.degrees)
Source code in pvgisprototype/algorithms/jenco/solar_azimuth.py
@log_function_call
@custom_cached
# @validate_with_pydantic(CalculateSolarAzimuthTimeSeriesJencoInput)
def calculate_solar_azimuth_series_jenco(
    longitude: Longitude,  # radians
    latitude: Latitude,  # radians
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    adjust_for_atmospheric_refraction: bool = True,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = 0,
    log: int = 0,
) -> SolarAzimuth:
    """Calculate the solar azimuth angle (θ) between the sun and meridian
    measured from East for a time series at a specific geographic latitude
    and longitude.

    Parameters
    ----------
    longitude : float
        Longitude of the location in radians.
    latitude : float
        Latitude of the location in radians.
    timestamps : DatetimeIndex
        Times for which the solar azimuth will be calculated.
    timezone : ZoneInfo
        Timezone of the location.
    adjust_for_atmospheric_refraction : bool, optional
        Whether to correct the solar zenith angle for atmospheric refraction.
    dtype : str, optional
        Data type for the calculations.
    array_backend : str, optional
        Backend array library to use.
    verbose : int, optional
        Verbosity level of the function.
    log : int, optional
        Log level for the function.

    Returns
    -------
    SolarAzimuth
        A custom data class that hold a NumPy NDArray of calculated solar
        azimuth angles in radians, a method to convert the angles to degrees
        and other metadata.

    Notes
    -----
    Two important notes on the calculation of the solar azimuth angle :

    - The equation implemented here follows upon the relevant Wikipedia article
      for the "Solar azimuth angle" [1]_.

    - The angle derived fom the arccosine function requires an adjustment to
    correctly represent both morning and afternoon solar azimuth angles.


    Adjusting the solar azimuth based on the time of day

    Given that arccosine ranges in [0, π] or else in [0°, 180°], the raw
    calculated solar azimuth angle will likewise range in [0, π]. This
    necessitates an adjustment based on the time of day.

    - Morning (solar hour angle < 0): the azimuth angle is correctly
      derived directly from the arccosine function representing angles from
      the North clockwise to the South [0°, 180°].

    - Afternoon (solar hour angle > 0): the azimuth angle needs to be
      adjusted in order to correctly represent angles going further from
      the South to the West [180°, 360°]. This is achieved by subtracting
      the azimuth from 360°.

    On the use of arctan2 from Wikipedia :

    Corollary: if (y1, x1) and (y2, x2) are 2-dimensional vectors, the
    difference formula is frequently used in practice to compute the angle
    between those vectors with the help of atan2, since the resulting
    computation behaves benign in the range (−π, π] and can thus be used
    without range checks in many practical situations.

    The `atan2` function was originally designed for the convention in pure
    mathematics that can be termed east-counterclockwise. In practical
    applications, however, the north-clockwise and south-clockwise conventions
    are often the norm. The solar azimuth angle for example, that uses both the
    north-clockwise and south-clockwise conventions widely, can be calculated
    similarly with the east- and north-components of the solar vector as its
    arguments. Different conventions can be realized by swapping the positions
    and changing the signs of the x- and y-arguments as follows:

    - atan2(y,x) : East-Counterclockwise Convention
    - atan2(x,y) : North-Clockwise Convention
    - atan2(-x,-y) : South-Clockwise Convention

    Changing the sign of the x- and/or y-arguments and/or swapping their
    positions can create 8 possible variations of the atan2 function and they,
    interestingly, correspond to 8 possible definitions of the angle, namely,
    clockwise or counterclockwise starting from each of the 4 cardinal
    directions, north, east, south and west.

    References
    ----------
    .. [0] https://gml.noaa.gov/grad/solcalc/solareqns.PDF

    .. [1] https://en.wikipedia.org/wiki/Solar_azimuth_angle#Conventional_Trigonometric_Formulas

    .. [2] https://github.com/pvlib/pvlib-python

    .. [3] https://github.com/skyfielders/python-skyfield/

    .. [4] https://github.com/kylebarron/suncalc-py

    Examples
    --------
    >>> from math import radians
    >>> from pvgisprototype.api.utilities.timestamp import generate_datetime_series
    >>> timestamps = generate_datetime_series(start_time='2010-01-27', end_time='2010-01-28')
    >>> from zoneinfo import ZoneInfo
    >>> from pvgisprototype.api.position.azimuth_series import calculate_solar_azimuth_series_noaa
    >>> solar_azimuth_series = calculate_solar_azimuth_series_noaa(
    ... longitude=radians(8.628),
    ... latitude=radians(45.812),
    ... timestamps=timestamps,
    ... timezone=ZoneInfo("UTC"),
    ... adjust_for_atmospheric_refraction=True
    ... )
    >>> print(solar_azimuth_series)
    >>> print(solar_azimuth_series.degrees)

    """
    solar_declination_series = calculate_solar_declination_series_jenco(
        timestamps=timestamps,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
    )
    solar_hour_angle_series = calculate_solar_hour_angle_series_noaa(
        longitude=longitude,
        timestamps=timestamps,
        timezone=timezone,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
    )
    C11 = sin(latitude.radians) * numpy.cos(solar_declination_series.radians)
    C13 = cos(latitude.radians) * numpy.sin(solar_declination_series.radians)
    C22 = numpy.cos(solar_declination_series.radians)
    x_solar_vector_component = C22 * numpy.sin(solar_hour_angle_series.radians)
    y_solar_vector_component = C11 * numpy.cos(solar_hour_angle_series.radians) - C13
    # `x` to `y` derives North-Clockwise azimuth
    azimuth_origin = "North"
    solar_azimuth_series = numpy.mod(
        (pi + numpy.arctan2(x_solar_vector_component, y_solar_vector_component)), 2 * pi
    )

    if (
        (solar_azimuth_series < SolarAzimuth().min_radians)
        | (solar_azimuth_series > SolarAzimuth().max_radians)
    ).any():
        out_of_range_values = solar_azimuth_series[
            (solar_azimuth_series < SolarAzimuth().min_radians)
            | (solar_azimuth_series > SolarAzimuth().max_radians)
        ]
        # raise ValueError(# ?
        logger.warning(
            f"{WARNING_OUT_OF_RANGE_VALUES} "
            f"[{SolarAzimuth().min_radians}, {SolarAzimuth().max_radians}] radians"
            f" in [code]solar_azimuth_series[/code] : {out_of_range_values}"
        )
    log_data_fingerprint(
        data=solar_azimuth_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return SolarAzimuth(
        value=solar_azimuth_series,
        unit=RADIANS,
        # positioning_algorithm=solar_declination_series.solar_positioning_algorithm,  #
        solar_timing_algorithm=solar_hour_angle_series.solar_timing_algorithm,  #
        origin=azimuth_origin,
    )

solar_declination

Functions:

Name Description
calculate_solar_declination_series_jenco

Approximate the sun's declination for a time series.

calculate_solar_declination_series_jenco

calculate_solar_declination_series_jenco(
    timestamps: DatetimeIndex,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarDeclination

Approximate the sun's declination for a time series.

δ = arcsin (0.3978 sin (j’ - 1.4 + 0.0355 sin (j’ - 0.0489)))

The solar declination is the angle between the Sun's rays and the equatorial plane of Earth. It varies throughout the year due to the tilt of the Earth's axis and is an important parameter in determining the seasons and the amount of solar radiation received at different latitudes.

The function calculates the proportion of the way through the year (in radians), which is given by (2 * pi * day_of_year) / 365.25. The 0.3978, 1.4, and 0.0355 are constants in the approximation formula, with the 0.0489 being an adjustment factor for the slight eccentricity of Earth's orbit.

Parameters:

Name Type Description Default
day_of_year

The day of the year (ranging from 1 to 365 or 366 in a leap year).

required

Returns:

Name Type Description
solar_declination float

The solar declination in radians for the given day of the year.

Notes

The equation used here is a simple approximation and bases upon a direct translation from PVGIS' rsun3 source code:

  • from file: rsun_base.cpp
  • function: com_declin(no_of_day)

For more accurate calculations of solar position, comprehensive models like the Solar Position Algorithm (SPA) are typically used.

Source code in pvgisprototype/algorithms/jenco/solar_declination.py
@log_function_call
def calculate_solar_declination_series_jenco(
    timestamps: DatetimeIndex,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarDeclination:
    """Approximate the sun's declination for a time series.

    δ = arcsin (0.3978 sin (j’ - 1.4 + 0.0355 sin (j’ - 0.0489)))

    The solar declination is the angle between the Sun's rays and the
    equatorial plane of Earth. It varies throughout the year due to the tilt of
    the Earth's axis and is an important parameter in determining the seasons
    and the amount of solar radiation received at different latitudes.

    The function calculates the `proportion` of the way through the year (in
    radians), which is given by `(2 * pi * day_of_year) / 365.25`.
    The `0.3978`, `1.4`, and `0.0355` are constants in the approximation
    formula, with the `0.0489` being an adjustment factor for the slight
    eccentricity of Earth's orbit.

    Parameters
    ----------
    day_of_year: int
        The day of the year (ranging from 1 to 365 or 366 in a leap year).

    Returns
    -------
    solar_declination: float
        The solar declination in radians for the given day of the year.

    Notes
    -----
    The equation used here is a simple approximation and bases upon a direct
    translation from PVGIS' rsun3 source code:

      - from file: rsun_base.cpp
      - function: com_declin(no_of_day)

    For more accurate calculations of solar position, comprehensive models like
    the Solar Position Algorithm (SPA) are typically used.

    """
    day_angle_series = calculate_day_angle_series_hofierka(
        timestamps=timestamps,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
    )
    solar_declination_series = numpy.arcsin(
        0.3978
        * numpy.sin(
            day_angle_series.radians
            - 1.4
            + eccentricity_amplitude
            * numpy.sin(day_angle_series.radians - eccentricity_phase_offset)
        )
    )
    if (
        (solar_declination_series < SolarDeclination().min_radians)
        | (solar_declination_series > SolarDeclination().max_radians)
    ).any():
        out_of_range_values = solar_declination_series[
            (solar_declination_series < SolarDeclination().min_radians)
            | (solar_declination_series > SolarDeclination().max_radians)
        ]
        # raise ValueError(# ?
        logger.warning(
            f"{WARNING_OUT_OF_RANGE_VALUES} "
            f"[{SolarDeclination().min_radians}, {SolarDeclination().max_radians}] radians"
            f" in [code]solar_declination_series[/code] : {out_of_range_values}"
        )
    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_declination_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return SolarDeclination(
        value=solar_declination_series,
        unit=RADIANS,
        solar_positioning_algorithm=SolarPositionModel.jenco,
        solar_timing_algorithm="Jenčo",
    )

solar_incidence

Functions:

Name Description
calculate_relative_longitude

Notes

calculate_solar_incidence_series_jenco

Calculate the solar incidence angle between the position of the sun and

calculate_relative_longitude

calculate_relative_longitude(
    latitude: Latitude,
    surface_orientation: SurfaceOrientation = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt = SURFACE_TILT_DEFAULT,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> RelativeLongitude
Notes

Hofierka (2002) uses equations presented by Jenčo (1992) :

tangent_relative_longitude =
    (
        - sin(surface_tilt)
        * sin(surface_orientation)
    ) / (
        sin(latitude)
        * sin(surface_tilt)
        * cos(surface_orientation)
        + cos(latitude)
        * cos(surface_tilt)
    )

In PVGIS' C source code, there is an error of one negative sign in either of the expressions! That is so because : cos(pi/2 + x) = -sin(x). As a consequence, the numerator becomes a positive number.

Source code :

/* These calculations depend on slope and aspect. Constant for the day if not tracking */
sin_phi_l = -gridGeom->coslat * cos_u * sin_v + gridGeom->sinlat * sin_u;
latid_l = asin(sin_phi_l);
cos_latid_l = cos(latid_l);
q1 = gridGeom->sinlat * cos_u * sin_v + gridGeom->coslat * sin_u;
tan_lam_l = - cos_u * cos_v / q1;
longit_l = atan (tan_lam_l);
if((aspect<M_PI)&&(longit_l<0.))
{
    longit_l += M_PI;
}
else if((aspect>M_PI)&&(longit_l>0.))
{
    longit_l -= M_PI;
}


Translation in to Python / pseudocode :

tangent_relative_longitude =
    (
      - cos(half_pi - surface_tilt)         # cos(pi/2 - x) = sin(x)
      * cos(half_pi + surface_orientation)  # cos(pi/2 + x) = -sin(x) #
    ) / (
      sin(latitude)
      * cos(half_pi - surface_tilt)
      * sin(half_pi + surface_orientation)  # sin(pi/2 + x) = cos(x)
      + cos(latitude)
      * sin(half_pi - surface_tilt)         # sin(pi/2 - x) = cos(x)
    )

As a consequence, PVGIS is like (note the positive numerator!) :

tangent_relative_longitude =
    (
        sin(surface_tilt)
        * sin(surface_orientation)
    ) / (
        sin(latitude)
        * sin(surface_tilt)
        * cos(surface_orientation)
        + cos(latitude)
        * cos(surface_tilt)
    )
Source code in pvgisprototype/algorithms/jenco/solar_incidence.py
@log_function_call
@validate_with_pydantic(CalculateRelativeLongitudeInputModel)
def calculate_relative_longitude(
    latitude: Latitude,
    surface_orientation: SurfaceOrientation = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt = SURFACE_TILT_DEFAULT,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> RelativeLongitude:
    """
    Notes
    -----
    Hofierka (2002) uses equations presented by Jenčo (1992) :

        tangent_relative_longitude =
            (
                - sin(surface_tilt)
                * sin(surface_orientation)
            ) / (
                sin(latitude)
                * sin(surface_tilt)
                * cos(surface_orientation)
                + cos(latitude)
                * cos(surface_tilt)
            )

    In PVGIS' C source code, there is an error of one negative sign in either
    of the expressions! That is so because : cos(pi/2 + x) = -sin(x).
    As a consequence, the numerator becomes a positive number.

        Source code :

        /* These calculations depend on slope and aspect. Constant for the day if not tracking */
        sin_phi_l = -gridGeom->coslat * cos_u * sin_v + gridGeom->sinlat * sin_u;
        latid_l = asin(sin_phi_l);
        cos_latid_l = cos(latid_l);
        q1 = gridGeom->sinlat * cos_u * sin_v + gridGeom->coslat * sin_u;
        tan_lam_l = - cos_u * cos_v / q1;
        longit_l = atan (tan_lam_l);
        if((aspect<M_PI)&&(longit_l<0.))
        {
            longit_l += M_PI;
        }
        else if((aspect>M_PI)&&(longit_l>0.))
        {
            longit_l -= M_PI;
        }


        Translation in to Python / pseudocode :

        tangent_relative_longitude =
            (
              - cos(half_pi - surface_tilt)         # cos(pi/2 - x) = sin(x)
              * cos(half_pi + surface_orientation)  # cos(pi/2 + x) = -sin(x) #
            ) / (
              sin(latitude)
              * cos(half_pi - surface_tilt)
              * sin(half_pi + surface_orientation)  # sin(pi/2 + x) = cos(x)
              + cos(latitude)
              * sin(half_pi - surface_tilt)         # sin(pi/2 - x) = cos(x)
            )

    As a consequence, PVGIS is like (note the positive numerator!) :

        tangent_relative_longitude =
            (
                sin(surface_tilt)
                * sin(surface_orientation)
            ) / (
                sin(latitude)
                * sin(surface_tilt)
                * cos(surface_orientation)
                + cos(latitude)
                * cos(surface_tilt)
            )
    """
    # -----------------------------------------------------------------------
    # in PVGIS an extra minus sign results to an all positive numerator!
    # tangent_relative_longitude_numerator = sin(surface_tilt.radians) * sin(
    #     surface_orientation.radians
    # )
    # -----------------------------------------------------------------------
    tangent_relative_longitude_numerator = -(
        sin(surface_tilt.radians) * sin(surface_orientation.radians)
    )
    tangent_relative_longitude_denominator = -(
        sin(latitude.radians)
        * sin(surface_tilt.radians)
        * cos(surface_orientation.radians)
        + cos(latitude.radians) * cos(surface_tilt.radians)
    )
    # force dtype !
    relative_longitude = np.array(
        [tangent_relative_longitude_numerator / tangent_relative_longitude_denominator],
        dtype=dtype,
    )
    log_data_fingerprint(
        data=relative_longitude,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return RelativeLongitude(
        value=relative_longitude,
        unit=RADIANS,
    )

calculate_solar_incidence_series_jenco

calculate_solar_incidence_series_jenco(
    longitude: Longitude,
    latitude: Latitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo | None,
    surface_orientation: SurfaceOrientation = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt = SURFACE_TILT_DEFAULT,
    adjust_for_atmospheric_refraction: bool = ATMOSPHERIC_REFRACTION_FLAG_DEFAULT,
    surface_in_shade_series: NpNDArray | None = None,
    complementary_incidence_angle: bool = COMPLEMENTARY_INCIDENCE_ANGLE_DEFAULT,
    zero_negative_solar_incidence_angle: bool = ZERO_NEGATIVE_INCIDENCE_ANGLE_DEFAULT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarIncidence

Calculate the solar incidence angle between the position of the sun and a reference solar surface.

Calculate the solar incidence angle based on the position of the sun (sun-vector) and the a reference solar surface. Typically the solar incidence is the angle between the sun-vector and the normal to the solar surface (surface-normal). However, the underlying functions that convert horizontal irradiance components to inclined, expect the complementary incidence angle which is defined as the angle between the sun-vector and the inclination or plane of the reference solar surface (surface-plane). We call this the "complementary" incidence angle contrasting typical definitions of the incidence angle between the sun-vector and the normal to the surface in question. Alternatively the function can return the angle between the sun-vector and the normal vector to the reference surface.

Parameters:

Name Type Description Default
longitude float

Longitude in degrees

required
latitude float

Latitude in degrees

required
surface_orientation float

Orientation of the surface (azimuth angle in degrees)

SURFACE_ORIENTATION_DEFAULT
surface_tilt float

Tilt of the surface in degrees

SURFACE_TILT_DEFAULT

Returns:

Type Description
ndarray

Solar incidence angle or NO_SOLAR_INCIDENCE series if a shadow is detected.

Notes

The solar incidence angle is the single most important quantity in the solar geometry setup. Its definition will affect all subsequent operations excluding none of the irradiance components, nor the photovoltaic power or energy estimations.

Attention! There is no one, and only one, definition of the solar incidence angle! While many authors refer to the angle between the sun-vector and the normal to the reference surface, for example Martin and Ruiz (2002, 2005), others, like for example Jenčo (1992) and Hofierka (2002), consider as incidence the angle between the sun-vector and the reference surface-plane. These angles are complementary to each other. This fact is an important one when treating trigonometric relationships that affect the calculation of the incidence angle.

In this program, we implement and consider as complementary the incidence angle as defined by Jenčo (1992) between the direction of the sun-rays and the inclination angle, or plane, of a reference surface. Hence, the default modus of this function returns the complementary incidence angle.

Following is meant to visualise a flat horizontal surface and the direction of a sun-ray. The angle between the two is what we call complementary.

                     ____

Optionally, the function can return the typical incidence angle between the direction of the sun-rays and the normal direction to the reference surface in question.

Following means to visualise the same direction of a sun-ray and then the normal (vertical here) direction to the flat horizontal surface. The angle between the sun-ray and the normal (vertical here) direction is the typical definition of the solar incidence angle. This angle can be generated via the complementary_incidence_angle flag.

 \  |
  \ |
   \|

Reading the paper by Jenčo (1992) [1]_ (last page is a summary translation in English):

Orientation of georelief An with respect to Cardinal points and slopes
γN of georelief in the direction of slope curves in the given point on
the georelief with the latitude φ determines the latitude φ' and in
relation to longitude λ of this point also the relative longitude λ' of
the contact point of contact plane to reference spheric surface of the
Earth with identical course of the insolation (Fig. 1). Thus sinus of
insolation angle δexp on georelief for the local time moment T can be
expressed by modification of a relation that is well known in astronomy
for the calculation of sinus of solar altitude h0 a form of equation
(3). Latitude φ' and longitude λ' in the equation (3) is definitely
determined on the basis of transformation equations (4.3.31) in the
work [5] in dependence on the assessment of the basic direction of
orientation of georelief AN with respect to the cardinal points by
goniometric equations (5), (6) or (9), (10). After determination of the
hour of sunrise (Tv)s from shadow to light and hour of sunset (Tr)s
from light to shadow by equations (12), (13) abstracting also from hill
shading of georelief, total quantity of direct solar irradiance Qd on
the unit of area of georelief for one day under blue sky conditions can
be expressed by equation (14).

Although Hofierka (2002) does not explicitly state "the angle between the sun-vector and the plane to the surface," he borrows the equation defined by Jenčo (1992) who measures the incidence angle between the sun-vector and plane of the reference solar surface. Care needs to be taken when comparing or using other definitions of the solar incidence angle.

An inclined surface is defined by the inclination angle (also known as slope or tilt) γN and the azimuth (also known as aspect or orientation) AN. The latter is the angle between the projection of the normal on the horizontal surface and East.

tg(λ') = - (sin(γN) * sin(AN)) / (sin(ϕ) * sin(γN) * cos(AN) + cos(ϕ) * cos(γN)) sin(ϕ') = - cos(ϕ) * sin(γN) * cos(AN) + sin(ϕ) * cos(γN) C'31 = cos ϕ' cos δ C'33 = sin ϕ' sin δ

From PVGIS' C++ source code:

The timeAngle (which is the solar hour angle) in rsun_standalone_hourly_opt.cpp is calculated via:

`sunGeom->timeAngle = (solarTimeOfDay - 12) * HOURANGLE;`

where HOUR_ANGLE is defined as HOUR_ANGLE = pi / 12.0 which is the conversion factor to radians (π / 12 = 0.261799) and hence this is the equation to calculate the hour angle in radians, e.g., as per NOAA's equation:

`solar_hour_angle = (true_solar_time - 720) * (pi / 720)`.

in which 720 is minutes, whereas 60 is hours in PVGIS' C++ code.

  • Shadow check not implemented.
Source code in pvgisprototype/algorithms/jenco/solar_incidence.py
@log_function_call
@custom_cached
@validate_with_pydantic(CalculateSolarIncidenceTimeSeriesJencoInputModel)
def calculate_solar_incidence_series_jenco(
    longitude: Longitude,
    latitude: Latitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo | None,
    surface_orientation: SurfaceOrientation = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt = SURFACE_TILT_DEFAULT,
    adjust_for_atmospheric_refraction: bool = ATMOSPHERIC_REFRACTION_FLAG_DEFAULT,
    surface_in_shade_series: NpNDArray | None = None,
    complementary_incidence_angle: bool = COMPLEMENTARY_INCIDENCE_ANGLE_DEFAULT,
    zero_negative_solar_incidence_angle: bool = ZERO_NEGATIVE_INCIDENCE_ANGLE_DEFAULT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarIncidence:
    """Calculate the solar incidence angle between the position of the sun and
    a reference solar surface.

    Calculate the solar incidence angle based on the position of the sun
    (sun-vector) and the a reference solar surface. Typically the solar
    incidence is the angle between the sun-vector and the normal to the solar
    surface (surface-normal). However, the underlying functions that convert
    horizontal irradiance components to inclined, expect the complementary
    incidence angle which is defined as the angle between the sun-vector and
    the inclination or plane of the reference solar surface (surface-plane).
    We call this the "complementary" incidence angle contrasting typical
    definitions of the incidence angle between the sun-vector and the normal to
    the surface in question. Alternatively the function can return the angle
    between the sun-vector and the normal vector to the reference surface.

    Parameters
    ----------
    longitude : float
        Longitude in degrees
    latitude : float
        Latitude in degrees
    surface_orientation : float
        Orientation of the surface (azimuth angle in degrees)
    surface_tilt : float
        Tilt of the surface in degrees

    Returns
    -------
    ndarray
        Solar incidence angle or NO_SOLAR_INCIDENCE series if a shadow is detected.

    Notes
    -----
    The solar incidence angle is the single most important quantity in the
    solar geometry setup. Its definition will affect all subsequent operations
    excluding none of the irradiance components, nor the photovoltaic power or
    energy estimations.

    Attention! There is no one, and only one, definition of the solar incidence
    angle! While many authors refer to the angle between the sun-vector and the
    normal to the reference surface, for example Martin and Ruiz (2002, 2005),
    others, like for example Jenčo (1992) and Hofierka (2002), consider as
    incidence the angle between the sun-vector and the reference surface-plane.
    These angles are complementary to each other. This fact is an important one
    when treating trigonometric relationships that affect the calculation of
    the incidence angle.

    In this program, we implement and consider as _complementary_ the incidence
    angle as defined by Jenčo (1992) between the direction of the sun-rays and
    the inclination angle, or plane, of a reference surface. Hence, the default
    modus of this function returns the _complementary_ incidence angle.

    Following is meant to visualise a flat horizontal surface and the
    direction of a sun-ray. The angle between the two is what we call
    complementary.

          \
           \
        ____\

    Optionally, the function can return the _typical_ incidence angle between
    the direction of the sun-rays and the normal direction to the reference
    surface in question.

    Following means to visualise the same direction of a sun-ray and then the
    normal (vertical here) direction to the flat horizontal surface. The angle
    between the sun-ray and the normal (vertical here) direction is the
    _typical_ definition of the solar incidence angle. This angle can be
    generated via the `complementary_incidence_angle` flag.

         \  |
          \ |
           \|
        -----

    Reading the paper by Jenčo (1992) [1]_ (last page is a summary translation in
    English):

        Orientation of georelief An with respect to Cardinal points and slopes
        γN of georelief in the direction of slope curves in the given point on
        the georelief with the latitude φ determines the latitude φ' and in
        relation to longitude λ of this point also the relative longitude λ' of
        the contact point of contact plane to reference spheric surface of the
        Earth with identical course of the insolation (Fig. 1). Thus sinus of
        insolation angle δexp on georelief for the local time moment T can be
        expressed by modification of a relation that is well known in astronomy
        for the calculation of sinus of solar altitude h0 a form of equation
        (3). Latitude φ' and longitude λ' in the equation (3) is definitely
        determined on the basis of transformation equations (4.3.31) in the
        work [5] in dependence on the assessment of the basic direction of
        orientation of georelief AN with respect to the cardinal points by
        goniometric equations (5), (6) or (9), (10). After determination of the
        hour of sunrise (Tv)s from shadow to light and hour of sunset (Tr)s
        from light to shadow by equations (12), (13) abstracting also from hill
        shading of georelief, total quantity of direct solar irradiance Qd on
        the unit of area of georelief for one day under blue sky conditions can
        be expressed by equation (14).

    Although Hofierka (2002) does not explicitly state "the angle between the
    sun-vector and the plane to the surface," he borrows the equation defined
    by Jenčo (1992) who measures the incidence angle between the sun-vector and
    plane of the reference solar surface. Care needs to be taken when comparing
    or using other definitions of the solar incidence angle.

    An inclined surface is defined by the inclination angle (also known as
    slope or tilt) `γN` and the azimuth (also known as aspect or orientation)
    `AN`. The latter is the angle between the projection of the normal on the
    horizontal surface and East.

    tg(λ') = - (sin(γN) * sin(AN)) / (sin(ϕ) * sin(γN) * cos(AN) + cos(ϕ) * cos(γN))
    sin(ϕ') = - cos(ϕ) * sin(γN) * cos(AN) + sin(ϕ) * cos(γN)
    C'31 = cos ϕ' cos δ
    C'33 = sin ϕ' sin δ

    From PVGIS' C++ source code:

    The `timeAngle` (which is the solar hour
    angle) in `rsun_standalone_hourly_opt.cpp` is calculated via:

        `sunGeom->timeAngle = (solarTimeOfDay - 12) * HOURANGLE;`

    where HOUR_ANGLE is defined as `HOUR_ANGLE = pi / 12.0`
    which is the conversion factor to radians (π / 12 = 0.261799)
    and hence this is the equation to calculate the hour angle in radians,
    e.g., as per NOAA's equation:

        `solar_hour_angle = (true_solar_time - 720) * (pi / 720)`.

    in which 720 is minutes, whereas 60 is hours in PVGIS' C++ code.

    - Shadow check not implemented.
    """
    # Identify times without solar insolation
    solar_altitude_series = calculate_solar_altitude_series_jenco(
        longitude=longitude,
        latitude=latitude,
        timestamps=timestamps,
        timezone=timezone,
        eccentricity_phase_offset=eccentricity_phase_offset,
        eccentricity_amplitude=eccentricity_amplitude,
        dtype=dtype,
        array_backend=array_backend,
        verbose=0,
        log=log,
    )
    solar_azimuth_east_based_series = calculate_solar_azimuth_series_jenco(
        longitude=longitude,
        latitude=latitude,
        timestamps=timestamps,
        timezone=timezone,
        adjust_for_atmospheric_refraction=adjust_for_atmospheric_refraction,
        dtype=dtype,
        array_backend=array_backend,
        verbose=0,
        log=log,
    )  # Origin ?

    # from pvgisprototype import SolarAzimuth
    # solar_azimuth_series = SolarAzimuth(
    #     value=(solar_azimuth_east_based_series.value + np.pi/2),
    #     unit=RADIANS,
    # )

    # Prepare relevant quantities
    sine_relative_inclined_latitude = -cos(latitude.radians) * sin(
        surface_tilt.radians
    ) * cos(surface_orientation.radians) + sin(latitude.radians) * cos(
        surface_tilt.radians
    )
    relative_inclined_latitude = asin(sine_relative_inclined_latitude)

    solar_declination_series = calculate_solar_declination_series_jenco(
        timestamps=timestamps,
        dtype=dtype,
        array_backend=array_backend,
        log=log,
    )

    c_inclined_31_series = cos(relative_inclined_latitude) * np.cos(
        solar_declination_series.radians
    )
    c_inclined_33_series = sin(relative_inclined_latitude) * np.sin(
        solar_declination_series.radians
    )
    solar_hour_angle_series = calculate_solar_hour_angle_series_noaa(
        longitude=longitude,
        timestamps=timestamps,
        timezone=timezone,
        dtype=dtype,
        array_backend=array_backend,
        log=log,
    )
    relative_longitude = calculate_relative_longitude(
        latitude=latitude,
        surface_orientation=surface_orientation,
        surface_tilt=surface_tilt,
        dtype=dtype,
        log=log,
    )

    # Note the - in front of the solar_hour_angle_series ! Explain-Me !
    sine_solar_incidence_series = (
        c_inclined_31_series
        * np.cos(solar_hour_angle_series.radians - relative_longitude.radians)
        + c_inclined_33_series
    )
    solar_incidence_series = np.arcsin(sine_solar_incidence_series)

    incidence_angle_definition = SolarIncidence().definition_complementary
    incidence_angle_description = SolarIncidence().description_complementary
    if not complementary_incidence_angle:
        logger.debug(
            f":information: Converting solar incidence angle to {COMPLEMENTARY_INCIDENCE_ANGLE_DEFINITION}...",
            alt=f":information: [bold][magenta]Converting[/magenta] solar incidence angle to {COMPLEMENTARY_INCIDENCE_ANGLE_DEFINITION}[/bold]...",
        )
        solar_incidence_series = (pi / 2) - solar_incidence_series
        incidence_angle_definition = SolarIncidence().definition
        incidence_angle_description = SolarIncidence().description

    # Combined mask for no solar incidence, negative solar incidence or below horizon angles
    mask_below_horizon_or_in_shade = (
        solar_altitude_series.radians < 0
    ) | surface_in_shade_series.value

    mask_no_solar_incidence_series = (
        solar_incidence_series < 0
    ) | mask_below_horizon_or_in_shade

    if zero_negative_solar_incidence_angle:
        solar_incidence_series = np.where(
            mask_no_solar_incidence_series,
            # (solar_incidence_series < 0) | (solar_altitude_series.value < 0),
            NO_SOLAR_INCIDENCE,
            solar_incidence_series,
        )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_incidence_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return SolarIncidence(
        value=solar_incidence_series,
        unit=RADIANS,
        solar_positioning_algorithm=solar_declination_series.solar_positioning_algorithm,  #
        solar_timing_algorithm=solar_hour_angle_series.solar_timing_algorithm,  #
        incidence_algorithm=SolarIncidenceModel.jenco,
        definition=incidence_angle_definition,
        description=incidence_angle_description,
        # azimuth_origin=solar_azimuth_east_based_series.origin,
        azimuth_origin=None,
    )

martin_ruiz

Modules:

Name Description
reflectivity

reflectivity

Functions:

Name Description
calculate_reflectivity_effect

Calculate absolute reflectivity effect

calculate_reflectivity_effect_percentage
calculate_reflectivity_factor_for_direct_irradiance_series

Calculate the angular loss factor for the direct horizontal radiation

calculate_reflectivity_factor_for_nondirect_irradiance

Calculate the reflectivity factor as for small angles of solar

calculate_reflectivity_effect

calculate_reflectivity_effect(
    irradiance, reflectivity_factor
)

Calculate absolute reflectivity effect

The total loss due to the reflectivity effect (which depends on the solar incidence angle) as the difference of the irradiance after and before.

Notes

Other ideas :

  • Sum of reflectivity effect as a difference between the irradiance time series after and before the reflectivity effect, i.e. :

    return reflectivity_effect = np.nansum(irradiance_series * reflectivity - irradiance_series) # Total lost energy due to AOI over the period

  • Average reflectivity effect :

    REFLECTIVITY_EFFECT_AVERAGE_COLUMN_NAME: np.nanmean(calculate_reflectivity_effect(inclined_irradiance_series, reflectivity_factor_series)),

Source code in pvgisprototype/algorithms/martin_ruiz/reflectivity.py
@log_function_call
def calculate_reflectivity_effect(
    irradiance,
    reflectivity_factor,
):
    """Calculate absolute reflectivity effect

    The total loss due to the reflectivity effect (which depends on the solar
    incidence angle) as the difference of the irradiance after and before.

    Notes
    -----
    Other ideas :

    - Sum of reflectivity effect as a difference between the irradiance time
      series after and before the reflectivity effect, i.e. :

        return reflectivity_effect = np.nansum(irradiance_series * reflectivity - irradiance_series)  # Total lost energy due to AOI over the period

    - Average reflectivity effect :

        REFLECTIVITY_EFFECT_AVERAGE_COLUMN_NAME: np.nanmean(calculate_reflectivity_effect(inclined_irradiance_series, reflectivity_factor_series)),

    """
    effect = (irradiance * reflectivity_factor) - irradiance

    return np.atleast_1d(np.nan_to_num(effect, nan=0))  # Force 1D array output, safer output ?

calculate_reflectivity_effect_percentage

calculate_reflectivity_effect_percentage(
    irradiance, reflectivity
)
Source code in pvgisprototype/algorithms/martin_ruiz/reflectivity.py
@log_function_call
def calculate_reflectivity_effect_percentage(
    irradiance,
    reflectivity,
):
    """ """
    # --------------------------------------------------- Is this safe ? -
    with np.errstate(divide="ignore", invalid="ignore"):
        percentage = np.where(
            irradiance != 0,
            100 * (1 - ((irradiance * reflectivity) / irradiance)),
            0,
        )

    return percentage

calculate_reflectivity_factor_for_direct_irradiance_series

calculate_reflectivity_factor_for_direct_irradiance_series(
    solar_incidence_series: SolarIncidence,
    angular_loss_coefficient: float = ANGULAR_LOSS_COEFFICIENT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
)

Calculate the angular loss factor for the direct horizontal radiation based on the solar incidence angle.

This function implements the solar incidence angle modifier as per Martin & Ruiz (2005). Expected is the angle between the sun-solar-surface vector and the vector normal to the reference solar surface. We call this the typical incidence angle as opposed to the complementary incidence angle defined by Jenčo (1992).

The adjustment factor represents the fraction of the original direct_radiation that is retained after accounting for the loss of radiation due to the angle of incidence or the orientation of the surface with respect to the sun.

Parameters:

Name Type Description Default
solar_incidence_series float

Solar incidence angle series

required
angular_loss_coefficient float
ANGULAR_LOSS_COEFFICIENT

Returns:

Name Type Description
reflectivity_factor_series float
Notes

The adjustment involves:

  1. computes the fraction of radiation that is not lost due to the solar_incidence_angle angle divided by the solar_declination ranging between 0 (complete loss) and 1 (no loss):

    ( 1 - exp( -solar_incidence_angle / angle_of_incidence_constant ) )

    • The exponential function exp, raises the mathematical constant e (approximately 2.71828) to the power of the given argument.

    • The negative exponential term of the fraction solar_altitude / solar_declination calculates the exponential decay or attenuation factor based on the ratio of solar_altitude to the solar_declination.

  2. rescales the adjusted value to bring it within a suitable range, by multiplying it by the reciprocal of the exponential term with the reciprocal of the solar_declination:

    1 / ( 1 - exp( - 1 / solar_declination) )

ensuring no excessive amplification or diminishing the effect (over-amplification or under-amplification).

The paper :

-                            -
|  1 - exp( -cos(a) / a_r )  |

1 - | -------------------------- |

|     1 - exp( -1 / a_r)     |

In PVGIS :

``` c
angular_loss_denom = 1. / (1 - exp(-1. / a_r));
br *= (1 - exp(-sh / a_r)) * angular_loss_denom;
```

where:

- br : direct inclined irradiance
- sh : _sine_ of _complementary_ solar incidence angle (as per Jenčo, 1992)
- a_r : angular loss coefficient

In pvlib :

``` python
with np.errstate(invalid='ignore'):
    iam = (1 - np.exp(-cosd(aoi) / a_r)) / (1 - np.exp(-1 / a_r))
    iam = np.where(np.abs(aoi) >= 90.0, 0.0, iam)
```

where:

- iam : incidence angle modifier
- aoi : angle of incidence
- a_r : angular loss coefficient

Review Me : --------------------------------------------------------------

This function will return a time series incidence_angle_modifier_series of floating point numbers. As it may generate NaN elements, further processing in a time series context, would required attention for example when summing arrays with NaN elements.

To circumvent eventual "problems" and the need for special handling downstream in the code, we replace all NaN elements with 0 just before returning the final time series.

Source code in pvgisprototype/algorithms/martin_ruiz/reflectivity.py
@log_function_call
def calculate_reflectivity_factor_for_direct_irradiance_series(
    solar_incidence_series: SolarIncidence,
    angular_loss_coefficient: float = ANGULAR_LOSS_COEFFICIENT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
):
    """Calculate the angular loss factor for the direct horizontal radiation
    based on the solar incidence angle.

    This function implements the solar incidence angle modifier as per Martin &
    Ruiz (2005). Expected is the angle between the sun-solar-surface vector and
    the vector normal to the reference solar surface. We call this the
    _typical_ incidence angle as opposed to the _complementary_ incidence angle
    defined by Jenčo (1992).

    The adjustment factor represents the fraction of the original
    `direct_radiation` that is retained after accounting for the loss of
    radiation due to the angle of incidence or the orientation of the surface
    with respect to the sun.

    Parameters
    ----------
    solar_incidence_series : float
        Solar incidence angle series

    angular_loss_coefficient : float

    Returns
    -------
    reflectivity_factor_series : float

    Notes
    -----

    The adjustment involves:

    1. computes the fraction of radiation that is _not_ lost due to
    the `solar_incidence_angle` angle divided by the `solar_declination` ranging between
    0 (complete loss) and 1 (no loss):

        `( 1 - exp( -solar_incidence_angle / angle_of_incidence_constant ) )`

        - The exponential function `exp`, raises the mathematical constant `e`
          (approximately 2.71828) to the power of the given argument.

        - The negative exponential term of the fraction `solar_altitude /
          solar_declination` calculates the exponential decay or attenuation
          factor based on the ratio of `solar_altitude` to the `solar_declination`.

    2. rescales the adjusted value to bring it within a suitable range,
    by multiplying it by the reciprocal of the exponential term with the
    reciprocal of the `solar_declination`:

        `1 / ( 1 - exp( - 1 / solar_declination) )`

    ensuring no excessive amplification or diminishing the effect
    (over-amplification or under-amplification).

    The paper :

        -                            -
        |  1 - exp( -cos(a) / a_r )  |
    1 - | -------------------------- |
        |     1 - exp( -1 / a_r)     |
        -                            -

    In PVGIS :

        ``` c
        angular_loss_denom = 1. / (1 - exp(-1. / a_r));
        br *= (1 - exp(-sh / a_r)) * angular_loss_denom;
        ```

        where:

        - br : direct inclined irradiance
        - sh : _sine_ of _complementary_ solar incidence angle (as per Jenčo, 1992)
        - a_r : angular loss coefficient


    In pvlib :

        ``` python
        with np.errstate(invalid='ignore'):
            iam = (1 - np.exp(-cosd(aoi) / a_r)) / (1 - np.exp(-1 / a_r))
            iam = np.where(np.abs(aoi) >= 90.0, 0.0, iam)
        ```

        where:

        - iam : incidence angle modifier
        - aoi : angle of incidence
        - a_r : angular loss coefficient

    Review Me : --------------------------------------------------------------

    This function will return a time series `incidence_angle_modifier_series`
    of floating point numbers. As it may generate NaN elements, further
    processing in a time series context, would required attention for example
    when summing arrays with NaN elements.

    To circumvent eventual "problems" and the need for special handling
    downstream in the code, we replace all NaN elements with 0 just before
    returning the final time series.

    """
    logger.debug(
        f"> Executing solar radiation modelling function calculate_reflectivity_factor_for_direct_irradiance_series()",
        alt=f"> Executing [underline]solar radiation modelling[/underline] function calculate_reflectivity_factor_for_direct_irradiance_series()",
    )
    try:
        numerator = 1 - np.exp(
            -np.cos(solar_incidence_series) / angular_loss_coefficient
        )
        denominator = 1 / (1 - exp(-1 / angular_loss_coefficient))
        incidence_angle_modifier_series = numerator / denominator
        incidence_angle_modifier_series = np.where(
            np.abs(solar_incidence_series) >= pi / 2, 0, incidence_angle_modifier_series
        )  # Borrowed from pvlib !

        if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
            debug(locals())

        if verbose > 0:
            print(f"Incidence angle modifier series: {incidence_angle_modifier_series}")

        log_data_fingerprint(
            data=incidence_angle_modifier_series,
            log_level=log,
            hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
        )

        logger.debug(
            f"  < Returning incidence angle modifier series :\n{incidence_angle_modifier_series}",
            alt=f"  [green]<[/green] Returning incidence angle modifier series :\n{incidence_angle_modifier_series}",
        )

        return incidence_angle_modifier_series

    # Review-Me !
    except ZeroDivisionError as e:
        logger.error(f"Zero Division Error: {e}")
        print("Error: Division by zero in calculating the angular loss factor.")
        return np.array([1])  # Return an array with a single element as 1

calculate_reflectivity_factor_for_nondirect_irradiance

calculate_reflectivity_factor_for_nondirect_irradiance(
    indirect_angular_loss_coefficient,
    angular_loss_coefficient=ANGULAR_LOSS_COEFFICIENT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
) -> float

Calculate the reflectivity factor as for small angles of solar incidence.

Notes

Review Me : --------------------------------------------------------------

This function will return a single incidence_angle_modifier (or call it loss factor) float number. Further processing in a time series context can be done by simply replicating the reflectivity factor for all timestamps.

Further, this structure will not generate any NaNs across a time series which often need special handling, i.e. when summing arrays with NaN elements.

Other implementations

In PVGIS v5.2 :

  • AOIConstants[0]
  • AOIConstants[1]

c1 = 4./(3.*M_PI); diff_coeff_angleloss = sinslope + (M_PI-sunSlopeGeom->slope - sinslope) / (1+cosslope); diff_loss_factor = 1. - exp( - ( c1 * diff_coeff_angleloss + AOIConstants[0] * diff_coeff_angleloss * diff_coeff_angleloss ) / AOIConstants[1]);

Source code in pvgisprototype/algorithms/martin_ruiz/reflectivity.py
@log_function_call
def calculate_reflectivity_factor_for_nondirect_irradiance(
    indirect_angular_loss_coefficient,
    angular_loss_coefficient=ANGULAR_LOSS_COEFFICIENT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
) -> float:
    """Calculate the reflectivity factor as for small angles of solar
    incidence.

    Notes
    -----

    Review Me : --------------------------------------------------------------

    This function will return a single `incidence_angle_modifier` (or call it
    loss factor) float number. Further processing in a time series context can
    be done by simply replicating the reflectivity factor for all timestamps.

    Further, this structure will not generate any NaNs across a time series
    which often need special handling, i.e. when summing arrays with NaN
    elements.

    Other implementations

    In PVGIS v5.2 :

    - AOIConstants[0]
    - AOIConstants[1]

    c1 = 4./(3.*M_PI);
    diff_coeff_angleloss = sinslope + (M_PI-sunSlopeGeom->slope - sinslope)
                           /
                           (1+cosslope);
    diff_loss_factor =
                       1. - exp(
                                 - (   c1 * diff_coeff_angleloss
                                     + AOIConstants[0]
                                     * diff_coeff_angleloss
                                     * diff_coeff_angleloss
                                   ) /
                                   AOIConstants[1]);

    """
    logger.debug(
        f"> Executing solar radiation modelling function calculate_reflectivity_factor_for_nondirect_irradiance()",
        alt=f"> Executing [underline]solar radiation modelling[/underline] function calculate_reflectivity_factor_for_nondirect_irradiance()",
    )
    angular_loss_coefficient_product = angular_loss_coefficient / 2 - 0.154
    c1 = 4 / (3 * pi)
    incidence_angle_modifier = 1 - exp(
        -(
            c1 * indirect_angular_loss_coefficient
            + angular_loss_coefficient_product * pow(angular_loss_coefficient, 2)
        )
        / angular_loss_coefficient
    )
    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    logger.debug(
        f"  < Returning incidence angle modifier :\n{incidence_angle_modifier}",
        alt=f"  [green]<[/green] Returning incidence angle modifier :\n{incidence_angle_modifier}",
    )

    return incidence_angle_modifier

milne1921

Modules:

Name Description
solar_time

solar_time

Functions:

Name Description
calculate_apparent_solar_time_milne1921

Calculate the apparent solar time based on the equation of time by Milne 1921

calculate_apparent_solar_time_series_milne1921

Calculate the apparent solar time based on the equation of time by Milne 1921 for a series of timestamps

calculate_apparent_solar_time_milne1921

calculate_apparent_solar_time_milne1921(
    longitude: Longitude,
    timestamp: Timestamp,
    verbose: int = 0,
) -> Timestamp

Calculate the apparent solar time based on the equation of time by Milne 1921

Notes
  • Local Time (LT)

  • Local Standard Time Meridian (LSTM)

    • 1 hour in time equals to 15° degrees of earth's rotation (from: 360°/24 hours)

    • Examples:

      • Sydney Australia is UTC +10 so the LSTM is 10 * 15° = 150 °E.
      • Phoenix, USA is UTC -7 so the LSTM is -7 * 15° = -105°E or 105 °W
  • The equation of time (EoT) (in minutes) is an empirical equation that corrects for the eccentricity of the Earth's orbit and the Earth's axial tilt. An approximation accurate to within ½ minute is:

EoT = 9.87 * sin(2*B) - 7.53 * cos(B) - 1.5 * sin(B)

where:

- DeltaTUTC : Local time minus UTC, in hours, also equal to the time zone
- B = 360 / 365 * (day_of_year - 81)  # in degrees
- Time correction (TC) factor = 4 * (longitude - LSTM) + EoT
  • Solar time (or local solar time) = LT + TC / 60

    • The solar (or local) solar time here equals to :
    • the apparent solar time (AST)
    • or corrected local solar time
    • or true solar time or (TST) as termed in other models/equations.
  • Hour angle = 15 * (LST - 12)

_Milne

@article{Milne1921, doi = {10.2307/3604631}, year = 1921, publisher = {Cambridge University Press ({CUP})}, volume = {10}, number = {155}, pages = {372--375}, author = {R. M. Milne}, title = {593. Note on the Equation of Time}, journal = {The Mathematical Gazette} }

See also:

@incollection{KALOGIROU201451, title = {Chapter 2 - Environmental Characteristics}, editor = {Soteris A. Kalogirou}, booktitle = {Solar Energy Engineering (Second Edition)}, publisher = {Academic Press}, edition = {Second Edition}, address = {Boston}, pages = {51-123}, year = {2014}, isbn = {978-0-12-397270-5}, doi = {https://doi.org/10.1016/B978-0-12-397270-5.00002-9}, url = {https://www.sciencedirect.com/science/article/pii/B9780123972705000029}, author = {Soteris A. Kalogirou}, keywords = {Atmospheric attenuation, Extraterrestrial radiation, Radiation exchange between surfaces, Shadow determination, Solar angles, Solar radiation measuring instruments, Solar radiation, Terrestrial irradiation, Total radiation on tilted surfaces, Typical meteorological year}, abstract = {Chapter 2 gives an analysis of the environmental characteristics of solar radiation and in particular the reckoning of time and solar angles. In the latter the basic solar geometry equations are given including declination, hour angle, altitude angle, azimuth angle as well as the incidence angle for stationary and moving surfaces, sun path diagrams, and shadow determination including the way to calculate shading effects. This is followed by a description of the basic principles of solar radiation heat transfer including transparent plates, radiation exchange between surfaces, extraterrestrial solar radiation, atmospheric attenuation, terrestrial irradiation, and total radiation on tilted surfaces. It concludes with a review of the solar radiation measuring instruments and the way to construct typical meteorological year files.} }

Source code in pvgisprototype/algorithms/milne1921/solar_time.py
@validate_with_pydantic(CalculateSolarTimeMilne1921InputModel)
def calculate_apparent_solar_time_milne1921(
    longitude: Longitude,
    timestamp: Timestamp,
    verbose: int = 0,
) -> Timestamp:
    """Calculate the apparent solar time based on the equation of time by Milne 1921

    Notes
    -----

    - Local Time (LT)

    - Local Standard Time Meridian (LSTM)

        - 1 hour in time equals to 15° degrees of earth's rotation
          (from: 360°/24 hours)

        - Examples:
            - Sydney Australia is UTC +10 so the LSTM is 10 * 15° = 150 °E.
            - Phoenix, USA is UTC -7 so the LSTM is -7 * 15° = -105°E or 105 °W

    - The equation of time (EoT) (in minutes) is an empirical equation that
      corrects for the eccentricity of the Earth's orbit and the Earth's axial
      tilt. An approximation accurate to within ½ minute is:

      EoT = 9.87 * sin(2*B) - 7.53 * cos(B) - 1.5 * sin(B)

        where:

        - DeltaTUTC : Local time minus UTC, in hours, also equal to the time zone
        - B = 360 / 365 * (day_of_year - 81)  # in degrees
        - Time correction (TC) factor = 4 * (longitude - LSTM) + EoT

    - Solar time (or local solar time) = LT + TC / 60
        * The solar (or local) solar time here equals to :
        - the apparent solar time (AST)
        - or corrected local solar time
        - or true solar time or (TST)
        as termed in other models/equations.

    - Hour angle = 15 * (LST - 12)

    _Milne

    @article{Milne1921,
        doi = {10.2307/3604631},
        year = 1921,
        publisher = {Cambridge University Press ({CUP})},
        volume = {10},
        number = {155},
        pages = {372--375},
        author = {R. M. Milne},
        title = {593. Note on the Equation of Time},
        journal = {The Mathematical Gazette}
    }

    See also:

    @incollection{KALOGIROU201451,
    title = {Chapter 2 - Environmental Characteristics},
    editor = {Soteris A. Kalogirou},
    booktitle = {Solar Energy Engineering (Second Edition)},
    publisher = {Academic Press},
    edition = {Second Edition},
    address = {Boston},
    pages = {51-123},
    year = {2014},
    isbn = {978-0-12-397270-5},
    doi = {https://doi.org/10.1016/B978-0-12-397270-5.00002-9},
    url = {https://www.sciencedirect.com/science/article/pii/B9780123972705000029},
    author = {Soteris A. Kalogirou},
    keywords = {Atmospheric attenuation, Extraterrestrial radiation, Radiation exchange between surfaces, Shadow determination, Solar angles, Solar radiation measuring instruments, Solar radiation, Terrestrial irradiation, Total radiation on tilted surfaces, Typical meteorological year},
    abstract = {Chapter 2 gives an analysis of the environmental characteristics of solar radiation and in particular the reckoning of time and solar angles. In the latter the basic solar geometry equations are given including declination, hour angle, altitude angle, azimuth angle as well as the incidence angle for stationary and moving surfaces, sun path diagrams, and shadow determination including the way to calculate shading effects. This is followed by a description of the basic principles of solar radiation heat transfer including transparent plates, radiation exchange between surfaces, extraterrestrial solar radiation, atmospheric attenuation, terrestrial irradiation, and total radiation on tilted surfaces. It concludes with a review of the solar radiation measuring instruments and the way to construct typical meteorological year files.}
    }
    """
    # # Handle Me during input validation? -------------------------------------
    # if timezone != timestamp.tzinfo:
    #     try:
    #         timestamp = timestamp.astimezone(timezone)
    #     except Exception as e:
    #         logging.warning(f'Error setting tzinfo for timestamp = {timestamp}: {e}')
    # # Handle Me during input validation? -------------------------------------

    # Equation of Time, Milne 1921 -------------------------------------------

    # The difference of local time from UTC equals the time zone, in hours
    local_time_minus_utc = timestamp.utcoffset().total_seconds() / 3600
    local_standard_time_meridian = 15 * local_time_minus_utc

    # `b` for the equation of time -------------------------------------------
    day_of_year = timestamp.timetuple().tm_yday
    b = radians(360 / 365 * (day_of_year - 81))  # from degrees to radians

    # however, in :
    # Solar Energy Engineering (Second Edition),
    # Chapter 2 - Environmental Characteristics,
    # Soteris A. Kalogirou, Academic Press, 2014,
    # ISBN 9780123972705, https://doi.org/10.1016/B978-0-12-397270-5.00002-9.
    # Pages 51-123,
    # b = (day_of_year - 81) * 360 / 364  (in degrees)
    # -----------------------------------------------------------------------

    equation_of_time = 9.87 * sin(2 * b) - 7.53 * cos(b) - 1.5 * sin(b)

    # ------------------------------------------------------------------------
    # the following equation requires longitude in degrees!
    time_correction_factor = (
        4 * (longitude.degrees - local_standard_time_meridian) + equation_of_time
    )
    # ------------------------------------------------------------------------

    time_correction_factor_hours = time_correction_factor / 60
    apparent_solar_time = timestamp + Timedelta(hours=time_correction_factor_hours)

    if verbose > 0:
        print("Day of year : {day_of_year}")
        print("Equation of time : {equation_of_time}")
        print("Time correction factor : {time_correction_factor}")

    if verbose == 3:
        debug(locals())

    return TrueSolarTime(
        value=apparent_solar_time,
        unit=MINUTES,
        timing_algorithm=SolarTimeModel.milne,
    )

calculate_apparent_solar_time_series_milne1921

calculate_apparent_solar_time_series_milne1921(
    longitude: Longitude,
    timestamps: DatetimeIndex,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
)

Calculate the apparent solar time based on the equation of time by Milne 1921 for a series of timestamps

Source code in pvgisprototype/algorithms/milne1921/solar_time.py
@log_function_call
def calculate_apparent_solar_time_series_milne1921(
    longitude: Longitude,
    timestamps: DatetimeIndex,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
):
    """Calculate the apparent solar time based on the equation of time by Milne 1921 for a series of timestamps"""
    # We need a timezone!
    utc_zoneinfo = ZoneInfo("UTC")
    local_standard_time_meridian_minutes_series = 0  # in UTC the offest is 0

    if timestamps.tzinfo is None:  # set to UTC
        timestamps = timestamps.tz_localize(utc_zoneinfo)

    elif timestamps.tz != utc_zoneinfo:  # convert to UTC
        timestamps = timestamps.tz_convert(utc_zoneinfo)

        # # ------------------------------------------- Further Optimisation ?
        # Optimisation : calculate unique offsets
        unique_timezones = timestamps.map(lambda ts: ts.tzinfo)
        unique_offsets = {
            tz: tz.utcoffset().total_seconds() / 60 for tz in set(unique_timezones)
        }
        # Map offsets back to timestamps
        local_standard_time_meridian_minutes_series = array(
            [unique_offsets[tz] for tz in unique_timezones], dtype=dtype
        )
        # ------------------------------------------------- Further Optimisation ?

    days_of_year = timestamps.dayofyear
    days_in_years = get_days_in_years(timestamps.year)
    # In the original equation : days_in_years = 365
    b = numpy_radians(360 / days_in_years * (days_of_year - 81))
    equation_of_time = (
        9.87 * numpy_sin(2 * b) - 7.53 * numpy_cos(b) - 1.5 * numpy_sin(b)
    )

    # In the original equation : 
    # time_correction_factor = 4 * (longitude - local_standard_time_meridian) + equation_of_time  in hours
    time_correction_factor_minutes = (
        longitude.as_minutes
        - local_standard_time_meridian_minutes_series
        + equation_of_time
    )
    # time_correction_factor_hours = time_correction_factor / 60  # We are already in minutes !
    true_solar_time_series = (
        timestamps - timestamps.normalize()
    ).total_seconds() + time_correction_factor_minutes * 60
    # array_parameters = {
    #     "shape": true_solar_time_series.shape,
    #     "dtype": dtype,
    #     "init_method": "zeros",
    #     "backend": array_backend,
    # }
    # true_solar_time_series_in_minutes = create_array(**array_parameters)
    true_solar_time_series_in_minutes = numpy_mod(
        true_solar_time_series.astype(dtype) / 60, 1440
    )

    if validate_output:
        if not (
            (TrueSolarTime().min_minutes <= true_solar_time_series_in_minutes)
            & (true_solar_time_series_in_minutes <= TrueSolarTime().max_minutes)
        ).all():
            out_of_range_values = true_solar_time_series_in_minutes[
                ~(
                    (TrueSolarTime().min_minutes <= true_solar_time_series_in_minutes)
                    & (true_solar_time_series_in_minutes <= TrueSolarTime().max_minutes)
                )
            ]
            raise ValueError(
                f"{WARNING_OUT_OF_RANGE_VALUES} "
                f"[{TrueSolarTime().min_minutes}, {TrueSolarTime().max_minutes}] minutes"
                f" in [code]true_solar_time_series_in_minutes[/code] : {out_of_range_values}"
            )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=true_solar_time_series_in_minutes,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return TrueSolarTime(
        value=array(true_solar_time_series_in_minutes, dtype=dtype),
        unit=MINUTES,
        timing_algorithm=SolarTimeModel.milne,
    )

muneer

Modules:

Name Description
irradiance

irradiance

Modules:

Name Description
diffuse

diffuse

Modules:

Name Description
clear_sky
in_shade
inclined
potentially_sunlit
sky_irradiance
sunlit
term_n
clear_sky

Modules:

Name Description
inclined
inclined

Functions:

Name Description
calculate_clear_sky_diffuse_inclined_irradiance_muneer

Calculate the diffuse irradiance incident on a solar surface.

calculate_clear_sky_diffuse_inclined_irradiance_muneer
calculate_clear_sky_diffuse_inclined_irradiance_muneer(
    elevation: float,
    surface_orientation: SurfaceOrientation = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt = SURFACE_TILT_DEFAULT,
    surface_tilt_horizontally_flat_panel_threshold: float = SURFACE_TILT_HORIZONTALLY_FLAT_PANEL_THRESHOLD,
    timestamps: DatetimeIndex | None = DatetimeIndex(
        [now(tz="UTC")]
    ),
    timezone: ZoneInfo | None = ZoneInfo("UTC"),
    global_horizontal_irradiance_series: (
        ndarray | None
    ) = None,
    direct_horizontal_irradiance_series: (
        ndarray | None
    ) = None,
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    apply_reflectivity_factor: bool = ANGULAR_LOSS_FACTOR_FLAG_DEFAULT,
    solar_altitude_series: SolarAltitude | None = None,
    solar_azimuth_series: (
        SolarAzimuth | None
    ) = SolarAzimuth(),
    solar_incidence_series: SolarIncidence | None = None,
    surface_in_shade_series: NpNDArray | None = None,
    shading_states: List[ShadingState] = [all],
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> DiffuseSkyReflectedInclinedIrradiance

Calculate the diffuse irradiance incident on a solar surface.

Notes

In order or appearance:

  • extraterrestrial_normal_irradiance : G0
  • extraterrestrial_horizontal_irradiance : G0h = G0 sin(h0)
  • kb : Proportion between direct (beam) and extraterrestrial irradiance : Kb
  • diffuse_horizontal_component : Dhc [W.m-2]
  • diffuse_transmission_function() :
  • linke_turbidity_factor :
  • diffuse_solar_altitude_function() :
  • solar_altitude :
  • calculate_term_n():
  • n : the N term
  • diffuse_sky_irradiance()
  • sine_solar_incidence_angle
  • sine_solar_altitude
  • diffuse_sky_irradiance
  • calculate_diffuse_sky_irradiance() : F(γN)
  • surface_tilt :
  • diffuse_inclined_irradiance :
  • diffuse_horizontal_component :
  • azimuth_difference :
  • solar_azimuth :
  • surface_orientation :
  • diffuse_irradiance
Source code in pvgisprototype/algorithms/muneer/irradiance/diffuse/clear_sky/inclined.py
@log_function_call
@custom_cached
def calculate_clear_sky_diffuse_inclined_irradiance_muneer(
    elevation: float,
    surface_orientation: SurfaceOrientation = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt = SURFACE_TILT_DEFAULT,
    surface_tilt_horizontally_flat_panel_threshold: float = SURFACE_TILT_HORIZONTALLY_FLAT_PANEL_THRESHOLD,
    timestamps: DatetimeIndex | None = DatetimeIndex([Timestamp.now(tz='UTC')]),
    timezone: ZoneInfo | None = ZoneInfo('UTC'),
    global_horizontal_irradiance_series: ndarray | None = None,
    direct_horizontal_irradiance_series: ndarray | None = None,
    linke_turbidity_factor_series: LinkeTurbidityFactor = LinkeTurbidityFactor(),
    apply_reflectivity_factor: bool = ANGULAR_LOSS_FACTOR_FLAG_DEFAULT,
    solar_altitude_series: SolarAltitude | None = None,
    solar_azimuth_series: SolarAzimuth | None = SolarAzimuth(),
    solar_incidence_series: SolarIncidence | None = None,
    surface_in_shade_series: NpNDArray | None = None,
    shading_states: List[ShadingState] = [ShadingState.all],
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> DiffuseSkyReflectedInclinedIrradiance:
    """Calculate the diffuse irradiance incident on a solar surface.

    Notes
    -----

    In order or appearance:

    - extraterrestrial_normal_irradiance : G0
    - extraterrestrial_horizontal_irradiance : G0h = G0 sin(h0)
    - kb : Proportion between direct (beam) and extraterrestrial irradiance : Kb
    - diffuse_horizontal_component : Dhc [W.m-2]
    - diffuse_transmission_function() :
    - linke_turbidity_factor :
    - diffuse_solar_altitude_function() :
    - solar_altitude :
    - calculate_term_n():
    - n : the N term
    - diffuse_sky_irradiance()
    - sine_solar_incidence_angle
    - sine_solar_altitude
    - diffuse_sky_irradiance
    - calculate_diffuse_sky_irradiance() : F(γN)
    - surface_tilt :
    - diffuse_inclined_irradiance :
    - diffuse_horizontal_component :
    - azimuth_difference :
    - solar_azimuth :
    - surface_orientation :
    - diffuse_irradiance

    """
    if (
        global_horizontal_irradiance_series is not None
        and direct_horizontal_irradiance_series is not None
    ):
        logger.error(
            ":information: The global_horizontal_irradiance and/or direct_horizontal_irradiance inputs should be None at this point !",
            alt = ":information: [bold red]The [code]global_horizontal_irradiance[/code] and/or [code]direct_horizontal_irradiance[/code] inputs should be [code]None[/code] at this point ![/bold red]",
        )
        raise ValueError(
            ":information: The `global_horizontal_irradiance` and/or `direct_horizontal_irradiance` inputs should be `None` at this point !",
        )

    else:  # model the diffuse horizontal irradiance
        if verbose > 0:
            logger.info(
                ":information: Modelling clear-sky diffuse horizontal irradiance ...",
                alt=":information: [bold]Modelling[/bold] clear-sky diffuse horizontal irradiance ...",
            )
        # create a placeholder array for global horizontal irradiance
        global_horizontal_irradiance_series = create_array(
            timestamps.shape, dtype=dtype, init_method=np.nan, backend=array_backend
        )

        # in which case, however and if NOT read from external series already,
        # we need the direct component for the kb series
        direct_horizontal_irradiance_series = (
            calculate_clear_sky_direct_horizontal_irradiance_hofierka(
                elevation=elevation,
                timestamps=timestamps,
                solar_altitude_series=solar_altitude_series,
                surface_in_shade_series=surface_in_shade_series,
                linke_turbidity_factor_series=linke_turbidity_factor_series,
                solar_constant=solar_constant,
                eccentricity_phase_offset=eccentricity_phase_offset,
                eccentricity_amplitude=eccentricity_amplitude,
                dtype=dtype,
                array_backend=array_backend,
                verbose=verbose,
                log=log,
            )
        )
        diffuse_horizontal_irradiance_series = (
            calculate_clear_sky_diffuse_horizontal_irradiance_hofierka(
                timestamps=timestamps,
                linke_turbidity_factor_series=linke_turbidity_factor_series,
                solar_altitude_series=solar_altitude_series,
                solar_constant=solar_constant,
                eccentricity_phase_offset=eccentricity_phase_offset,
                eccentricity_amplitude=eccentricity_amplitude,
                dtype=dtype,
                array_backend=array_backend,
                verbose=verbose,
                log=log,
            )
        )
    #
    # ----------------------------------- Diffuse Horizontal Irradiance -- ^^^

    # At this point, the diffuse_horizontal_irradiance_series are either :
    # calculated from external time series  Or  modelled

    # Initialise shading_state_series to avoid the "UnboundLocalError"
    shading_state_series = create_array(
        timestamps.shape, dtype='object', init_method="empty", backend=array_backend
    )
    nan_series = create_array(
        timestamps.shape, dtype=dtype, init_method=np.nan, backend=array_backend
    )
    unset_series = create_array(
        timestamps.shape, dtype=dtype, init_method="unset", backend=array_backend
    )
    # np.full(timestamps.shape, "Unset", "object")

    if surface_tilt <= surface_tilt_horizontally_flat_panel_threshold:
        if verbose > 0:
            logger.info(
                ":information: Modelling clear-sky diffuse inclined irradiance for a horizontally flat panel...",
                alt=":information: [bold]Modelling[/bold] clear-sky diffuse inclined irradiance for a horizontally flat panel...",
            )
        diffuse_inclined_irradiance_series = np.copy(
            diffuse_horizontal_irradiance_series.value
        )
        # to not break the output !
        diffuse_sky_irradiance_series = unset_series
        n_series = unset_series
        kb_series = unset_series
        azimuth_difference_series = unset_series
        solar_incidence_series = SolarIncidence(
            value=unset_series,
            incidence_algorithm=NOT_AVAILABLE,
            definition=NOT_AVAILABLE,
            azimuth_origin=NOT_AVAILABLE,
        )
        extraterrestrial_normal_irradiance_series = ExtraterrestrialNormalIrradiance(
            value=unset_series,
            unit=NOT_AVAILABLE,
            day_angle=unset_series,
            solar_constant=None,
            eccentricity_phase_offset=None,
            eccentricity_amplitude=None,
            distance_correction_factor=None,
        )
        extraterrestrial_horizontal_irradiance_series = ExtraterrestrialHorizontalIrradiance(
            value=unset_series,
            unit=NOT_AVAILABLE,
            day_angle=unset_series,
            solar_constant=None,
            eccentricity_phase_offset=None,
            eccentricity_amplitude=None,
            distance_correction_factor=None,
        )

    else:  # tilted (or inclined) surface
        # Note: in PVGIS: if surface_orientation != 'UNDEF' and surface_tilt != 0:
        # --------------------------------------------------- Is this safe ? -

        # Calculate or get quantities required : ---------------------------- >>> >>> >>>
        # 1. to model the diffuse horizontal irradiance [optional]
        # 2. to calculate the diffuse sky ... to consider shaded, sunlit and potentially sunlit surfaces
        #
        if verbose > 0:
            logger.info(
                ":information: Modelling clear-sky diffuse inclined irradiance for a tilted panel...",
                alt=":information: [bold]Modelling[/bold] clear-sky diffuse inclined irradiance for a tilted panel...",
            )

        extraterrestrial_normal_irradiance_series = (
            calculate_extraterrestrial_normal_irradiance_hofierka(
                timestamps=timestamps,
                solar_constant=solar_constant,
                eccentricity_phase_offset=eccentricity_phase_offset,
                eccentricity_amplitude=eccentricity_amplitude,
                dtype=dtype,
                array_backend=array_backend,
                verbose=0,  # no verbosity here by choice!
                log=log,
            )
        )
        extraterrestrial_horizontal_irradiance_series = calculate_extraterrestrial_horizontal_irradiance_series_hofierka(
            extraterrestrial_normal_irradiance=extraterrestrial_normal_irradiance_series,
            solar_altitude_series=solar_altitude_series,
        )
        #
        # Calculate quantities required : ---------------------------- <<< <<< <<<

        with np.errstate(divide="ignore", invalid="ignore"):
            kb_series = (  # proportion between direct and extraterrestrial
                direct_horizontal_irradiance_series.value
                / extraterrestrial_horizontal_irradiance_series.value
            )
        n_series = calculate_term_n_series_hofierka(
            kb_series,
            dtype=dtype,
            array_backend=array_backend,
            verbose=verbose,
        )
        diffuse_sky_irradiance_series = where(
            np.isnan(n_series),  # handle NaN cases
            0,
            calculate_diffuse_sky_irradiance_series_hofierka(
                n_series=n_series,
                surface_tilt=surface_tilt,
            ),
        )

        # prepare size of output array!

        diffuse_inclined_irradiance_series = create_array(
            timestamps.shape, dtype=dtype, init_method="zeros", backend=array_backend
        )

        # prepare cases : surfaces in shade, sunlit, potentially sunlit

        from pvgisprototype.api.position.models import select_models
        shading_states = select_models(
            ShadingState, shading_states
        )  # Using a callback fails!

        diffuse_inclined_irradiance_series = calculate_diffuse_inclined_irradiance_in_shade(
            # mask_surface_in_shade_series=mask_surface_in_shade_series,
            solar_incidence=solar_incidence_series,
            surface_in_shade=surface_in_shade_series,
            shading_states=shading_states,
            shading_state_series=shading_state_series,
            diffuse_sky_irradiance=diffuse_sky_irradiance_series,
            timestamps=timestamps,
            surface_tilt=surface_tilt,
            diffuse_inclined_irradiance=diffuse_inclined_irradiance_series,
            diffuse_horizontal_irradiance=diffuse_horizontal_irradiance_series,
        )

        diffuse_inclined_irradiance_series = calculate_diffuse_inclined_irradiance_sunlit(
            solar_incidence=solar_incidence_series,
            shading_states=shading_states,
            solar_altitude=solar_altitude_series,
            shading_state_series=shading_state_series,
            diffuse_sky_irradiance=diffuse_sky_irradiance_series,
            kb_series=kb_series,
            diffuse_inclined_irradiance=diffuse_inclined_irradiance_series,
            diffuse_horizontal_irradiance=diffuse_horizontal_irradiance_series,
        )
        #
        azimuth_difference_series = NOT_AVAILABLE  # not always required, set to avoid UnboundLocalError!
        #
        diffuse_inclined_irradiance_series = calculate_diffuse_inclined_irradiance_potentially_sunlit(
            surface_orientation=surface_orientation,
            surface_tilt=surface_tilt,
            solar_azimuth=solar_azimuth_series,
            shading_states=shading_states,
            solar_altitude=solar_altitude_series,
            shading_state_series=shading_state_series,
            diffuse_sky_irradiance=diffuse_sky_irradiance_series,
            kb_series=kb_series,
            diffuse_inclined_irradiance=diffuse_inclined_irradiance_series,
            diffuse_horizontal_irradiance=diffuse_horizontal_irradiance_series,
        )

    # Replace None with a placeholder -- this is important for printing !
    shading_state_series = np.where(
        shading_state_series == None, "Unset", shading_state_series
    )
    # ------------------------------------------------------------------------

    # diffuse_inclined_irradiance_series = np.nan_to_num(
    #     diffuse_inclined_irradiance_series, nan=0
    # )

    diffuse_irradiance_reflectivity_coefficient = None
    diffuse_inclined_irradiance_reflectivity_factor_series = nan_series
    diffuse_inclined_irradiance_before_reflectivity_series = nan_series

    if apply_reflectivity_factor:
        if abs(surface_tilt - pi) < MINIMAL_DIFFERENCE_THRESHOLD:
            surface_tilt -= MINIMAL_DIFFERENCE_THRESHOLD

        # Get the reflectivity coefficient
        diffuse_irradiance_reflectivity_coefficient = sin(surface_tilt) + (
            pi - surface_tilt - sin(surface_tilt)
        ) / (1 + cos(surface_tilt))
        diffuse_irradiance_reflectivity_factor = calculate_reflectivity_factor_for_nondirect_irradiance(
            indirect_angular_loss_coefficient=diffuse_irradiance_reflectivity_coefficient,
        )

        # Get the reflectivity factor from ...
        diffuse_inclined_irradiance_reflectivity_factor_series = create_array(
            timestamps.shape,
            dtype=dtype,
            init_method=diffuse_irradiance_reflectivity_factor,
            backend=array_backend,
        )

        # Apply the reflectivity factor
        diffuse_inclined_irradiance_series *= (
            diffuse_inclined_irradiance_reflectivity_factor_series
        )

        # avoid copying to save memory and time ... ? ----------------- Is this safe ? -
        with errstate(divide="ignore", invalid="ignore"):
            # this quantity is exclusively generated for the output dictionary !
            diffuse_inclined_irradiance_before_reflectivity_series = where(
                diffuse_inclined_irradiance_reflectivity_factor_series != 0,
                diffuse_inclined_irradiance_series
                / diffuse_inclined_irradiance_reflectivity_factor_series,
                0,
            )
        # ------------------------------------------------------------------------------

    out_of_range, out_of_range_index = identify_values_out_of_range(
        series=diffuse_inclined_irradiance_series,
        shape=timestamps.shape,
        data_model=DiffuseSkyReflectedInclinedIrradiance(),
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=diffuse_inclined_irradiance_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return DiffuseSkyReflectedInclinedIrradiance(
        value=diffuse_inclined_irradiance_series,
        out_of_range=out_of_range,
        out_of_range_index=out_of_range_index,
        #
        global_horizontal_irradiance=global_horizontal_irradiance_series,
        direct_horizontal_irradiance=direct_horizontal_irradiance_series,
        diffuse_horizontal_irradiance=diffuse_horizontal_irradiance_series,
        extraterrestrial_horizontal_irradiance=extraterrestrial_horizontal_irradiance_series,
        #
        extraterrestrial_normal_irradiance=extraterrestrial_normal_irradiance_series,
        #
        reflected=calculate_reflectivity_effect(
            irradiance=diffuse_inclined_irradiance_before_reflectivity_series,
            reflectivity_factor=diffuse_inclined_irradiance_reflectivity_factor_series,
        ),
        reflectivity_factor=diffuse_inclined_irradiance_reflectivity_factor_series,
        reflectivity_coefficient=diffuse_irradiance_reflectivity_coefficient,
        value_before_reflectivity=diffuse_inclined_irradiance_before_reflectivity_series,
        #
        diffuse_sky_irradiance=diffuse_sky_irradiance_series,
        term_n=n_series,
        kb_ratio=kb_series,
        #
        elevation=elevation,
        surface_orientation=surface_orientation,
        surface_tilt=surface_tilt,
        surface_tilt_threshold=surface_tilt_horizontally_flat_panel_threshold,
        #
        surface_in_shade=surface_in_shade_series,
        solar_incidence=solar_incidence_series,
        shading_state=shading_state_series,
        solar_altitude=solar_altitude_series,
        solar_azimuth=solar_azimuth_series,
        solar_azimuth_origin=solar_azimuth_series.origin,
        azimuth_difference=azimuth_difference_series,
        #
        solar_incidence_model=solar_incidence_series.algorithm,
        solar_incidence_definition=solar_incidence_series.definition,
        shading_states=shading_states,
    )
in_shade

Functions:

Name Description
calculate_diffuse_inclined_irradiance_in_shade
calculate_diffuse_inclined_irradiance_in_shade
calculate_diffuse_inclined_irradiance_in_shade(
    timestamps,
    surface_tilt,
    diffuse_inclined_irradiance,
    diffuse_sky_irradiance,
    diffuse_horizontal_irradiance,
    solar_incidence,
    surface_in_shade,
    shading_states,
    shading_state_series,
)
Source code in pvgisprototype/algorithms/muneer/irradiance/diffuse/in_shade.py
@log_function_call
def calculate_diffuse_inclined_irradiance_in_shade(
    timestamps,
    surface_tilt,
    diffuse_inclined_irradiance, # updated in-place
    diffuse_sky_irradiance,
    diffuse_horizontal_irradiance,
    solar_incidence,
    surface_in_shade,
    shading_states,
    shading_state_series,
):
    """
    """
    if ShadingState.in_shade in shading_states:
        #  ----------------------------------------------------- Review Me
        mask_surface_in_shade_series = np.logical_or(
            # np.sin(solar_incidence.radians) < 0,  # in shade
            # will never be True ! when negative incidence angles set to 0 !
            solar_incidence.radians < 0,  # in shade
            # ---------------------------------------------- Review Me ---
            # solar_altitude_series.radians >= 0,  # yet there is ambient light
            surface_in_shade.value  # pre-calculated in-shade moments 
        )
        # Is this the _complementary_ incidence angle series ?
        #  Review Me -----------------------------------------------------
        if np.any(mask_surface_in_shade_series):
            shading_state_series[mask_surface_in_shade_series] = ShadingState.in_shade.value
            logger.info(
                f"Shading state series including {ShadingState.in_shade.value} :\n{shading_state_series}",
                alt=f"[bold]Shading state[/bold] series including [bold white]{ShadingState.in_shade.value}[/bold white] :\n{shading_state_series}",
            )
            diffuse_sky_irradiance[mask_surface_in_shade_series] = (
                calculate_diffuse_sky_irradiance_series_hofierka(
                    n_series=full(timestamps.size, TERM_N_IN_SHADE),
                    surface_tilt=surface_tilt,
                )[mask_surface_in_shade_series]
            )
            diffuse_inclined_irradiance[mask_surface_in_shade_series] = (
                diffuse_horizontal_irradiance.value[mask_surface_in_shade_series]
                * diffuse_sky_irradiance[mask_surface_in_shade_series]
            )

    return diffuse_inclined_irradiance
inclined

Functions:

Name Description
calculate_diffuse_inclined_irradiance_muneer

Calculate the diffuse irradiance incident on a solar surface.

calculate_diffuse_inclined_irradiance_muneer
calculate_diffuse_inclined_irradiance_muneer(
    surface_orientation: SurfaceOrientation = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt = SURFACE_TILT_DEFAULT,
    surface_tilt_horizontally_flat_panel_threshold: float = SURFACE_TILT_HORIZONTALLY_FLAT_PANEL_THRESHOLD,
    timestamps: DatetimeIndex = DatetimeIndex(
        [now(tz="UTC")]
    ),
    timezone: ZoneInfo | None = ZoneInfo("UTC"),
    global_horizontal_irradiance_series: (
        ndarray | None
    ) = None,
    direct_horizontal_irradiance_series: (
        ndarray | None
    ) = None,
    apply_reflectivity_factor: bool = ANGULAR_LOSS_FACTOR_FLAG_DEFAULT,
    solar_altitude_series: SolarAltitude | None = None,
    solar_azimuth_series: SolarAzimuth | None = None,
    solar_incidence_series: SolarIncidence | None = None,
    surface_in_shade_series: NpNDArray | None = None,
    shading_states: List[ShadingState] = [all],
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> DiffuseSkyReflectedInclinedIrradianceFromExternalData

Calculate the diffuse irradiance incident on a solar surface.

Source code in pvgisprototype/algorithms/muneer/irradiance/diffuse/inclined.py
@log_function_call
@custom_cached
def calculate_diffuse_inclined_irradiance_muneer(
    surface_orientation: SurfaceOrientation = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt = SURFACE_TILT_DEFAULT,
    surface_tilt_horizontally_flat_panel_threshold: float = SURFACE_TILT_HORIZONTALLY_FLAT_PANEL_THRESHOLD,
    timestamps: DatetimeIndex = DatetimeIndex([Timestamp.now(tz='UTC')]),
    timezone: ZoneInfo | None = ZoneInfo('UTC'),
    global_horizontal_irradiance_series: ndarray | None = None,
    direct_horizontal_irradiance_series: ndarray | None = None,
    apply_reflectivity_factor: bool = ANGULAR_LOSS_FACTOR_FLAG_DEFAULT,
    solar_altitude_series: SolarAltitude | None = None,
    solar_azimuth_series: SolarAzimuth | None = None,
    solar_incidence_series: SolarIncidence | None = None,
    surface_in_shade_series: NpNDArray | None = None,
    shading_states: List[ShadingState] = [ShadingState.all],
    solar_constant: float = SOLAR_CONSTANT,
    eccentricity_phase_offset: float = ECCENTRICITY_PHASE_OFFSET,
    eccentricity_amplitude: float = ECCENTRICITY_CORRECTION_FACTOR,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> DiffuseSkyReflectedInclinedIrradianceFromExternalData:
    """Calculate the diffuse irradiance incident on a solar surface."""
    # build reusable parameter dictionaries
    no_earth_orbit = {
        'eccentricity_phase_offset': None,
        'eccentricity_amplitude': None,
    }
    earth_orbit = {
        'eccentricity_phase_offset': eccentricity_phase_offset,
        'eccentricity_amplitude': eccentricity_amplitude,
    }
    array_parameters = {
        "dtype": dtype,
        "array_backend": array_backend,
    }
    extended_array_parameters = {
        "shape": timestamps.shape,
        "dtype": dtype,
        "backend": array_backend,
    }

    if not isinstance(global_horizontal_irradiance_series, ndarray) and not isinstance(
        direct_horizontal_irradiance_series, ndarray
    ):
        logger.error(
            ":information: The global_horizontal_irradiance and/or direct_horizontal_irradiance inputs should be NumPy arrays at this point !",
            alt = ":information: [bold red]The [code]global_horizontal_irradiance[/code] and/or [code]direct_horizontal_irradiance[/code] inputs should be [code]NumPy arrays[/code] at this point ![/bold red]",
        )
        raise ValueError(
            ":information: The `global_horizontal_irradiance` and/or `direct_horizontal_irradiance` inputs should be NumPy arrays at this point !",
        )
    logger.info(
        ":information: Calculating sky-diffuse horizontal irradiance from external time series ...",
        alt = ":information: [bold]Calculating[/bold] sky-diffuse horizontal irradiance from external time series ..."
    )
    diffuse_horizontal_irradiance = calculate_diffuse_horizontal_irradiance_hofierka(
        global_horizontal_irradiance_series=global_horizontal_irradiance_series,
        direct_horizontal_irradiance_series=direct_horizontal_irradiance_series,
        **array_parameters,
        verbose=verbose,
        log=log,
    )

    # At this point, the diffuse_horizontal_irradiance_series are either :
    # calculated from external time series  Or  modelled

    # Initialise shading_state_series to avoid the "UnboundLocalError"
    shading_state_series = create_array(
        shape=timestamps.shape,
        dtype="object",
        init_method="empty",
        backend=array_backend,
    )
    nan_series = create_array(**extended_array_parameters, init_method=np.nan)
    unset_series = create_array(**extended_array_parameters, init_method="unset")
    # np.full(timestamps.shape, "Unset", "object")

    if surface_tilt <= surface_tilt_horizontally_flat_panel_threshold:
        diffuse_inclined_irradiance_series = np.copy(
            diffuse_horizontal_irradiance.value
        )
        # to not break the output !
        diffuse_sky_irradiance_series = unset_series
        n_series = unset_series
        kb_series = unset_series
        azimuth_difference_series = unset_series
        solar_incidence_series = SolarIncidence(
            value=unset_series,
            algorithm=NOT_AVAILABLE,
            definition=NOT_AVAILABLE,
            azimuth_origin=NOT_AVAILABLE,
        )
        extraterrestrial_normal_irradiance_series = ExtraterrestrialNormalIrradiance(
            value=unset_series,
            unit=NOT_AVAILABLE,
            day_angle=unset_series,
            solar_constant=None,
            **no_earth_orbit,
            distance_correction_factor=None,
        )
        extraterrestrial_horizontal_irradiance_series = ExtraterrestrialHorizontalIrradiance(
            value=unset_series,
            unit=NOT_AVAILABLE,
            day_angle=unset_series,
            solar_constant=None,
            **no_earth_orbit,
            distance_correction_factor=None,
        )

    else:  # tilted (or inclined) surface

        # Calculate or get quantities required : ---------------------------- >>> >>> >>>
        # 1. to model the diffuse horizontal irradiance [optional]
        # 2. to calculate the diffuse sky ... to consider shaded, sunlit and potentially sunlit surfaces
        #
        extraterrestrial_normal_irradiance_series = (
            calculate_extraterrestrial_normal_irradiance_hofierka(
                timestamps=timestamps,
                solar_constant=solar_constant,
                **earth_orbit,
                **array_parameters,
                verbose=0,  # no verbosity here by choice!
                log=log,
            )
        )
        extraterrestrial_horizontal_irradiance_series = calculate_extraterrestrial_horizontal_irradiance_series_hofierka(
            extraterrestrial_normal_irradiance=extraterrestrial_normal_irradiance_series,
            solar_altitude_series=solar_altitude_series,
        )
        #
        # Calculate quantities required : ---------------------------- <<< <<< <<<

        with np.errstate(divide="ignore", invalid="ignore"):
            kb_series = (  # proportion between direct and extraterrestrial
                direct_horizontal_irradiance_series  # Array from external data !
                / extraterrestrial_horizontal_irradiance_series.value
            )
        n_series = calculate_term_n_series_hofierka(
            kb_series,
            **array_parameters,
            verbose=verbose,
        )
        diffuse_sky_irradiance_series = where(
            np.isnan(n_series),  # handle NaN cases
            0,
            calculate_diffuse_sky_irradiance_series_hofierka(
                n_series=n_series,
                surface_tilt=surface_tilt,
            ),
        )

        # prepare size of output array!

        diffuse_inclined_irradiance_series = create_array(
            **extended_array_parameters, init_method="zeros"
        )
        diffuse_irradiance = {
            'diffuse_inclined_irradiance': diffuse_inclined_irradiance_series,
            'diffuse_horizontal_irradiance': diffuse_horizontal_irradiance,
        }

        # prepare cases : surfaces in-shade, sunlit, potentially sunlit

        from pvgisprototype.api.position.models import select_models

        shading_states = select_models(
            ShadingState, shading_states
        )  # Using a callback fails!

        diffuse_inclined_irradiance_series = calculate_diffuse_inclined_irradiance_in_shade(
            # mask_surface_in_shade_series=mask_surface_in_shade_series,
            solar_incidence=solar_incidence_series,
            surface_in_shade=surface_in_shade_series,
            shading_states=shading_states,
            shading_state_series=shading_state_series,
            diffuse_sky_irradiance=diffuse_sky_irradiance_series,
            timestamps=timestamps,
            surface_tilt=surface_tilt,
            **diffuse_irradiance,
        )

        diffuse_inclined_irradiance_series = calculate_diffuse_inclined_irradiance_sunlit(
            solar_incidence=solar_incidence_series,
            shading_states=shading_states,
            solar_altitude=solar_altitude_series,
            shading_state_series=shading_state_series,
            diffuse_sky_irradiance=diffuse_sky_irradiance_series,
            kb_series=kb_series,
            **diffuse_irradiance,
        )
        #
        azimuth_difference_series = NOT_AVAILABLE  # not always required, set to avoid UnboundLocalError!
        #
        diffuse_inclined_irradiance_series = calculate_diffuse_inclined_irradiance_potentially_sunlit(
            surface_orientation=surface_orientation,
            surface_tilt=surface_tilt,
            solar_azimuth=solar_azimuth_series,
            shading_states=shading_states,
            solar_altitude=solar_altitude_series,
            shading_state_series=shading_state_series,
            diffuse_sky_irradiance=diffuse_sky_irradiance_series,
            kb_series=kb_series,
            **diffuse_irradiance,
        )

    # Replace None with a placeholder -- this is important for printing !
    shading_state_series = np.where(
        shading_state_series == None, "Unset", shading_state_series
    )
    # ------------------------------------------------------------------------

    # diffuse_inclined_irradiance_series = np.nan_to_num(
    #     diffuse_inclined_irradiance_series, nan=0
    # )


    EPSILON = 0.1 #1e-10  # Define a small threshold for comparison
    diffuse_irradiance_reflectivity_coefficient = None
    diffuse_inclined_irradiance_reflectivity_factor_series = nan_series
    diffuse_inclined_irradiance_before_reflectivity_series = nan_series

    if apply_reflectivity_factor:
        if abs(surface_tilt - pi) < EPSILON:
            surface_tilt -= EPSILON

        # Get the reflectivity coefficient
        diffuse_irradiance_reflectivity_coefficient = sin(surface_tilt) + (
            pi - surface_tilt - sin(surface_tilt)
        ) / (1 + cos(surface_tilt))
        diffuse_irradiance_reflectivity_factor = calculate_reflectivity_factor_for_nondirect_irradiance(
            indirect_angular_loss_coefficient=diffuse_irradiance_reflectivity_coefficient,
        )

        # Get the reflectivity factor from ...
        diffuse_inclined_irradiance_reflectivity_factor_series = create_array(
                **extended_array_parameters,
            init_method=diffuse_irradiance_reflectivity_factor,
        )

        # Apply the reflectivity factor
        diffuse_inclined_irradiance_series *= (
            diffuse_inclined_irradiance_reflectivity_factor_series
        )

        # avoid copying to save memory and time ... ? ----------------- Is this safe ? -
        with errstate(divide="ignore", invalid="ignore"):
            # this quantity is exclusively generated for the output dictionary !
            diffuse_inclined_irradiance_before_reflectivity_series = where(
                diffuse_inclined_irradiance_reflectivity_factor_series != 0,
                diffuse_inclined_irradiance_series
                / diffuse_inclined_irradiance_reflectivity_factor_series,
                0,
            )
        # ------------------------------------------------------------------------------

    out_of_range, out_of_range_index = identify_values_out_of_range(
        series=diffuse_inclined_irradiance_series,
        shape=timestamps.shape,
        data_model=DiffuseSkyReflectedInclinedIrradianceFromExternalData(),
    )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=diffuse_inclined_irradiance_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return DiffuseSkyReflectedInclinedIrradianceFromExternalData(
        value=diffuse_inclined_irradiance_series,
        out_of_range=out_of_range,
        out_of_range_index=out_of_range_index,
        #
        global_horizontal_irradiance=global_horizontal_irradiance_series,
        direct_horizontal_irradiance=direct_horizontal_irradiance_series,
        diffuse_horizontal_irradiance=diffuse_horizontal_irradiance,
        extraterrestrial_horizontal_irradiance=extraterrestrial_horizontal_irradiance_series,
        #
        extraterrestrial_normal_irradiance=extraterrestrial_normal_irradiance_series,
        #
        reflected=calculate_reflectivity_effect(
            irradiance=diffuse_inclined_irradiance_before_reflectivity_series,
            reflectivity_factor=diffuse_inclined_irradiance_reflectivity_factor_series,
        ),
        reflectivity_factor=diffuse_inclined_irradiance_reflectivity_factor_series,
        reflectivity_coefficient=diffuse_irradiance_reflectivity_coefficient,
        value_before_reflectivity=diffuse_inclined_irradiance_before_reflectivity_series,
        #
        diffuse_sky_irradiance=diffuse_sky_irradiance_series,
        term_n=n_series,
        kb_ratio=kb_series,
        #
        surface_orientation=surface_orientation,
        surface_tilt=surface_tilt,
        surface_tilt_threshold=surface_tilt_horizontally_flat_panel_threshold,
        #
        surface_in_shade=surface_in_shade_series,
        solar_incidence=solar_incidence_series,
        shading_state=shading_state_series,
        solar_altitude=solar_altitude_series,
        solar_azimuth=solar_azimuth_series,
        solar_azimuth_origin=solar_azimuth_series.origin,
        azimuth_difference=azimuth_difference_series,
        #
        solar_incidence_model=solar_incidence_series.algorithm,
        solar_incidence_definition=solar_incidence_series.definition,
        shading_states=shading_states,
        #
    )
potentially_sunlit

Functions:

Name Description
calculate_diffuse_inclined_irradiance_potentially_sunlit
calculate_diffuse_inclined_irradiance_potentially_sunlit
calculate_diffuse_inclined_irradiance_potentially_sunlit(
    surface_orientation,
    surface_tilt,
    solar_azimuth,
    solar_altitude,
    shading_states,
    shading_state_series,
    diffuse_inclined_irradiance,
    diffuse_sky_irradiance,
    kb_series,
    diffuse_horizontal_irradiance,
)
Source code in pvgisprototype/algorithms/muneer/irradiance/diffuse/potentially_sunlit.py
@log_function_call
def calculate_diffuse_inclined_irradiance_potentially_sunlit(
    surface_orientation,
    surface_tilt,
    solar_azimuth,
    solar_altitude,
    shading_states,
    shading_state_series,
    diffuse_inclined_irradiance,
    diffuse_sky_irradiance,
    kb_series,
    diffuse_horizontal_irradiance,
):
    """
    """
    if ShadingState.potentially_sunlit in shading_states:
        mask_potentially_sunlit_surface_series = np.logical_and(
                solar_altitude.radians > 0,  #  sun above horizon
            solar_altitude.radians < 0.1,  #  radians or < 5.7 degrees
            shading_state_series == None  # operate only on unset elements
        )
        # else:  # if solar altitude < 0.1 : potentially sunlit surface series
        if np.any(mask_potentially_sunlit_surface_series):
            shading_state_series[mask_potentially_sunlit_surface_series] = ShadingState.potentially_sunlit.value
            logger.info(
                f"Shading state series including {ShadingState.potentially_sunlit.value} :\n{shading_state_series}",
                alt=f"[bold]Shading state[/bold] series including [bold orange]{ShadingState.potentially_sunlit.value}[/bold orange] :\n{shading_state_series}",
            )
            # requires the solar azimuth
            # Normalize the azimuth difference to be within the range -pi to pi
            # A0 : solar azimuth _measured from East_ in radians
            # ALN : angle between the vertical surface containing the normal to the
            #   surface and vertical surface passing through the centre of the solar
            #   disc [rad]
            if isinstance(surface_orientation, SurfaceOrientation):
                surface_orientation = surface_orientation.value

            azimuth_difference_series = (
                    solar_azimuth.value - surface_orientation
                )
            azimuth_difference_series = np.arctan2(
                np.sin(azimuth_difference_series),
                np.cos(azimuth_difference_series),
            )
            diffuse_inclined_irradiance[
                mask_potentially_sunlit_surface_series
            ] = diffuse_horizontal_irradiance.value[
                mask_potentially_sunlit_surface_series
            ] * (
                diffuse_sky_irradiance[mask_potentially_sunlit_surface_series]
                * (1 - kb_series[mask_potentially_sunlit_surface_series])
                + kb_series[mask_potentially_sunlit_surface_series]
                * sin(surface_tilt)
                * np.cos(
                    azimuth_difference_series[
                        mask_potentially_sunlit_surface_series
                    ]
                )
                / (
                    0.1
                    - 0.008
                    * solar_altitude.radians[
                        mask_potentially_sunlit_surface_series
                    ]
                )
            )

    return diffuse_inclined_irradiance
sky_irradiance

Functions:

Name Description
calculate_diffuse_sky_irradiance_series_hofierka

Calculate the diffuse sky irradiance

calculate_diffuse_sky_irradiance_series_hofierka
calculate_diffuse_sky_irradiance_series_hofierka(
    n_series: ndarray,
    surface_tilt: float = SURFACE_TILT_DEFAULT,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
)

Calculate the diffuse sky irradiance

The diffuse sky irradiance function F(γN) depends on the surface tilt γN (in radians)

Parameters:

Name Type Description Default
surface_tilt float

The tilt (also referred to as : inclination or slope) angle of a solar surface

SURFACE_TILT_DEFAULT
n_series ndarray

The term N

required

Returns:

Type Description
Notes
-----
Internally the function calculates first the dimensionless fraction of the
sky dome viewed by a tilted (or inclined) surface `ri(γN)`.
The sky-view fraction as defined in Hofierka, 2002 [1]_ is :

(1 + cos(surface_tilt)) / 2

which in turn is trigonometrically identical to the definition in Muneer,
1990 [2]_

power(cos(surface_tilt / 2), 2)

Source code in pvgisprototype/algorithms/muneer/irradiance/diffuse/sky_irradiance.py
@log_function_call
@custom_cached
def calculate_diffuse_sky_irradiance_series_hofierka(
    n_series: ndarray,
    surface_tilt: float = SURFACE_TILT_DEFAULT,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
):
    """Calculate the diffuse sky irradiance

    The diffuse sky irradiance function F(γN) depends on the surface tilt `γN`
    (in radians)

    Parameters
    ----------
    surface_tilt: float (radians)
        The tilt (also referred to as : inclination or slope) angle of a solar
        surface

    n_series: float
        The term N

    Returns
    -------

    Notes
    -----
    Internally the function calculates first the dimensionless fraction of the
    sky dome viewed by a tilted (or inclined) surface `ri(γN)`.

    The sky-view fraction as defined in Hofierka, 2002 [1]_ is :

        (1 + cos(surface_tilt)) / 2

    which in turn is trigonometrically identical to the definition in Muneer,
    1990 [2]_

        power(cos(surface_tilt / 2), 2)

    """
    diffuse_sky_irradiance_series = ((1 + cos(surface_tilt)) / 2) + (
        sin(surface_tilt)
        - surface_tilt * cos(surface_tilt)
        - pi * power((sin(surface_tilt / 2)), 2)
    ) * n_series

    # out_of_range, out_of_range_index = identify_values_out_of_range(
    #     series=diffuse_sky_irradiance_series,
    #     shape=n_series.shape,
    #     # data_model=DiffuseSkyReflectedHorizontalIrradianceFromExternalData(),
    # )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=diffuse_sky_irradiance_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return array(diffuse_sky_irradiance_series, dtype=dtype)
sunlit

Functions:

Name Description
calculate_diffuse_inclined_irradiance_sunlit
calculate_diffuse_inclined_irradiance_sunlit
calculate_diffuse_inclined_irradiance_sunlit(
    solar_incidence,
    shading_states,
    solar_altitude,
    shading_state_series,
    diffuse_sky_irradiance,
    kb_series,
    diffuse_inclined_irradiance,
    diffuse_horizontal_irradiance,
)
Source code in pvgisprototype/algorithms/muneer/irradiance/diffuse/sunlit.py
@log_function_call
def calculate_diffuse_inclined_irradiance_sunlit(
    solar_incidence,
    shading_states,
    solar_altitude,
    shading_state_series,
    diffuse_sky_irradiance,
    kb_series,
    diffuse_inclined_irradiance,
    diffuse_horizontal_irradiance,
):
    """
    """
    if ShadingState.sunlit in shading_states:
        mask_sunlit_surface_series = np.logical_and(
            solar_altitude.radians >= 0.1,  # or >= 5.7 degrees
            shading_state_series == None  # operate only on unset elements
        )
        # else:  # sunlit surface and non-overcast sky
        #     # ----------------------------------------------------------------
        #     solar_azimuth_series = None ?
        #     # ----------------------------------------------------------------
        if np.any(mask_sunlit_surface_series):
            shading_state_series[mask_sunlit_surface_series] = ShadingState.sunlit.value
            logger.info(
                f"Shading state series including {ShadingState.sunlit.value} :\n{shading_state_series}",
                alt=f"[bold]Shading state[/bold] series including [bold yellow]{ShadingState.sunlit.value}[/bold yellow] :\n{shading_state_series}",
            )
            diffuse_inclined_irradiance[
                mask_sunlit_surface_series
            ] = diffuse_horizontal_irradiance.value[mask_sunlit_surface_series] * (
                diffuse_sky_irradiance[mask_sunlit_surface_series]
                * (1 - kb_series[mask_sunlit_surface_series])
                + kb_series[mask_sunlit_surface_series]
                * np.sin(
                    solar_incidence.radians[mask_sunlit_surface_series]
                )  # Should be the _complementary_ incidence angle!
                / np.sin(solar_altitude.radians[mask_sunlit_surface_series])
            )

    return diffuse_inclined_irradiance
term_n

Functions:

Name Description
calculate_term_n_series_hofierka

Define the N term for a period of time

calculate_term_n_series_hofierka
calculate_term_n_series_hofierka(
    kb_series: ndarray,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> NpNDArray

Define the N term for a period of time

N = 0.00263 − 0.712 × kb − 0.6883 × kb2

Parameters:

Name Type Description Default
kb_series ndarray

Direct horizontal to extraterrestrial horizontal irradiance ratio

required

Returns:

Name Type Description
N float

The N term

Notes

Muneer's model treats the shaded and sunlit surfaces separately and further distinguishes between overcast and non-overcast conditions of the sunlit surface. The diffuse sky-reflected irradiance for both surfaces in-shade and sunlit under overcast sky is computed as :

and a sunlit surface under non-overcast sky as :

According to Muneer (1990) the radiance distribution index b (dimensionless)

Source code in pvgisprototype/algorithms/muneer/irradiance/diffuse/term_n.py
@log_function_call
@custom_cached
def calculate_term_n_series_hofierka(
    kb_series: ndarray,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> NpNDArray:
    """Define the N term for a period of time

    N = 0.00263 − 0.712 × kb − 0.6883 × kb2

    Parameters
    ----------
    kb_series: float
        Direct horizontal to extraterrestrial horizontal irradiance ratio

    Returns
    -------
    N: float
        The N term

    Notes
    -----

    Muneer's model treats the shaded and sunlit surfaces separately and further
    distinguishes between overcast and non-overcast conditions of the sunlit
    surface.  The diffuse sky-reflected irradiance for both surfaces in-shade and
    sunlit under _overcast_ sky is computed as :

    and a sunlit surface under non-overcast sky as :


    According to Muneer (1990) the radiance distribution index `b` (dimensionless) 
    """
    kb_series = np.array(kb_series, dtype=dtype)
    term_n_series = (
        0.00263 - (0.712 * kb_series) - (0.6883 * np.power(kb_series, 2, dtype=dtype))
    )
    log_data_fingerprint(
        data=term_n_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )
    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    return term_n_series

noaa

Modules:

Name Description
equation_of_time

The Equation of Time based on the General Solar Position Calculations provided

event_hour_angle
event_time
events

This module contains days within the year that have some solar significance.

fractional_year
local_time
parameter_models
solar_altitude
solar_azimuth
solar_declination
solar_hour_angle
solar_time

The true solar time based on NOAA's General Solar Position Calculations.

solar_zenith
time_offset

The time offset based on NOAA's General Solar Position Calculations.

equation_of_time

The Equation of Time based on the General Solar Position Calculations provided by the NOAA Global Monitoring Division.

See also: https://unpkg.com/solar-calculator@0.1.0/index.js

Functions:

Name Description
calculate_equation_of_time_series_noaa

Calculate the Equation of Time for a time series in minutes.

calculate_equation_of_time_series_noaa

calculate_equation_of_time_series_noaa(
    timestamps: DatetimeIndex,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
) -> EquationOfTime

Calculate the Equation of Time for a time series in minutes.

The Equation of Time is the difference between the apparent solar time and the mean solar time.

Returns:

Name Type Description
equation_of_time_series numeric

Difference in time between solar time and mean solar time in minutes.

Source code in pvgisprototype/algorithms/noaa/equation_of_time.py
@log_function_call
@custom_cached
@validate_with_pydantic(CalculateEquationOfTimeTimeSeriesNOAAInput)
def calculate_equation_of_time_series_noaa(
    timestamps: DatetimeIndex,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
) -> EquationOfTime:
    """Calculate the Equation of Time for a time series in minutes.

    The Equation of Time is the difference between the apparent solar time and
    the mean solar time.

    Returns
    -------
    equation_of_time_series : numeric
        Difference in time between solar time and mean solar time in minutes.

    """
    fractional_year_series = calculate_fractional_year_series_noaa(
        timestamps=timestamps,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
        validate_output=validate_output,
    )
    equation_of_time_series = 229.18 * (
        0.000075
        + 0.001868 * np.cos(fractional_year_series.radians)
        - 0.032077 * np.sin(fractional_year_series.radians)
        - 0.014615 * np.cos(2 * fractional_year_series.radians)
        - 0.040849 * np.sin(2 * fractional_year_series.radians)
    )

    if validate_output:
        if not np.all(
            (EquationOfTime().min_minutes <= equation_of_time_series)
            & (equation_of_time_series <= EquationOfTime().max_minutes)
        ):
            raise ValueError(
                "The equation of time must be within the range [{EquationOfTime().min_minutes}, {EquationOfTime().max_minutes()}] minutes for all timestamps."
            )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=equation_of_time_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return EquationOfTime(
        value=equation_of_time_series,
        unit=MINUTES,
        position_algorithm=fractional_year_series.position_algorithm,
        timing_algorithm=SolarTimeModel.noaa,
    )

event_hour_angle

Functions:

Name Description
calculate_event_hour_angle_series_noaa

Calculates the event hour angle using the NOAA method.

calculate_event_hour_angle_series_noaa

calculate_event_hour_angle_series_noaa(
    latitude: Latitude,
    timestamps: DatetimeIndex,
    unrefracted_solar_zenith: UnrefractedSolarZenith,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> EventHourAngle

Calculates the event hour angle using the NOAA method.

Parameters:

Name Type Description Default
latitude Latitude

The geographic latitude for which to calculate the event hour angle.

required
timestamp datetime

The date and time for which to calculate the event hour angle.

required
unrefracted_solar_zenith UnrefractedSolarZenith

The zenith of the sun, adjusted for atmospheric refraction. Defaults to 1.5853349194640094 radians, which corresponds to 90.833 degrees. This is the zenith at sunrise or sunset, adjusted for the approximate correction for atmospheric refraction at those times, and the size of the solar disk.

required
angle_units str

The unit in which the angles are input. Defaults to 'radians'.

required

Returns:

Type Description
EventHourAngle

PVGIS native data structure carrying the calculated event hour angle along with relevant metadata.

Notes

The function implements NOAA calculations for the solar declination and the event hour angle. The solar declination is calculated first in radians, followed by the event hour angle in radians.

Commented out: If the output units are 'degrees', the function will convert the calculated event hour angle from radians to degrees.

Source code in pvgisprototype/algorithms/noaa/event_hour_angle.py
@log_function_call
@custom_cached
@validate_with_pydantic(CalculateEventHourAngleTimeSeriesNOAAInput)
def calculate_event_hour_angle_series_noaa(
    latitude: Latitude,
    timestamps: DatetimeIndex,
    unrefracted_solar_zenith: UnrefractedSolarZenith,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> EventHourAngle:
    """
    Calculates the event hour angle using the NOAA method.

    Parameters
    ----------
    latitude : Latitude
        The geographic latitude for which to calculate the event hour angle.

    timestamp : datetime
        The date and time for which to calculate the event hour angle.

    unrefracted_solar_zenith : UnrefractedSolarZenith, optional
        The zenith of the sun, adjusted for atmospheric refraction. Defaults to
        1.5853349194640094 radians, which corresponds to 90.833 degrees. This
        is the zenith at sunrise or sunset, adjusted for the approximate
        correction for atmospheric refraction at those times, and the size of
        the solar disk.

    angle_units : str, optional
        The unit in which the angles are input. Defaults to 'radians'.

    Returns
    -------
    EventHourAngle
        PVGIS native data structure carrying the calculated event hour angle
        along with relevant metadata.

    Notes
    -----
    The function implements NOAA calculations for the solar declination and
    the event hour angle. The solar declination is calculated first in radians,
    followed by the event hour angle in radians.

    Commented out: If the output units are 'degrees', the function
    will convert the calculated event hour angle from radians to degrees.

    """
    solar_declination_series = calculate_solar_declination_series_noaa(
        timestamps=timestamps,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
    )  # radians
    cosine_event_hour_angle_series = np.cos(unrefracted_solar_zenith.radians) / (
        np.cos(latitude.radians) * np.cos(solar_declination_series.radians)
    ) - np.tan(latitude.radians) * np.tan(solar_declination_series.radians)
    event_hour_angle_series = np.arccos(
        np.clip(cosine_event_hour_angle_series, -1, 1)
    )  # radians

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=event_hour_angle_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return EventHourAngle(
        value=event_hour_angle_series,
        unit=RADIANS,
        timing_algorithm=SolarTimeModel.noaa,
    )

event_time

Functions:

Name Description
calculate_solar_event_time_in_minutes

Calculate the event time in minutes based on the event type.

calculate_solar_event_time_series_noaa

Calculate the time of solar events like sunrise, noon, or sunset.

match_event_times_to_timestamps

calculate_solar_event_time_in_minutes

calculate_solar_event_time_in_minutes(
    longitude: Longitude,
    event_hour_angle: float,
    equation_of_time: float,
    timezone_offset_hours_utc: float,
    event_type: str,
) -> float

Calculate the event time in minutes based on the event type.

Parameters:

Name Type Description Default
longitude Longitude

The longitude in radians.

required
event_hour_angle float

The event hour angle in minutes.

required
equation_of_time float

The equation of time in minutes.

required
timezone_offset_hours_utc float

The timezone offset in hours.

required
event_type str

The type of solar event ('sunrise', 'noon', 'sunset').

required

Returns:

Type Description
float

The calculated event time in minutes.

Source code in pvgisprototype/algorithms/noaa/event_time.py
def calculate_solar_event_time_in_minutes(
    longitude: Longitude,
    event_hour_angle: float,
    equation_of_time: float,
    timezone_offset_hours_utc: float,
    event_type: str,
) -> float:
    """
    Calculate the event time in minutes based on the event type.

    Parameters
    ----------
    longitude : Longitude
        The longitude in radians.
    event_hour_angle : float
        The event hour angle in minutes.
    equation_of_time : float
        The equation of time in minutes.
    timezone_offset_hours_utc : float
        The timezone offset in hours.
    event_type : str
        The type of solar event ('sunrise', 'noon', 'sunset').

    Returns
    -------
    float
        The calculated event time in minutes.
    """
    # Calculate the base time
    base = 720 - equation_of_time.minutes + timezone_offset_hours_utc * 60
    longitude_minutes = longitude.as_minutes
    event_hour_angle_minutes = event_hour_angle.as_minutes

    # Define event calculations
    event_calculations = {
        SolarEvent.astronomical_twilight.name: lambda: base - (longitude_minutes + event_hour_angle_minutes + 4 * 18),
        SolarEvent.nautical_twilight.name: lambda: base - (longitude_minutes + event_hour_angle_minutes + 4 * 12),
        SolarEvent.civil_twilight.name: lambda: base - (longitude_minutes + event_hour_angle_minutes + 4 * 6),
        SolarEvent.sunrise.name: lambda: base - (longitude_minutes + event_hour_angle_minutes),
        SolarEvent.noon.name: lambda: base - longitude_minutes,
        SolarEvent.sunset.name: lambda: base - (longitude_minutes - event_hour_angle_minutes),
        SolarEvent.daylength.name: lambda: (
            base - (longitude_minutes - event_hour_angle_minutes) - 
            (base - (longitude_minutes + event_hour_angle_minutes))
        ),
    }

    # Calculate and return the event time
    if event_type in event_calculations:
        return event_calculations[event_type]()
    else:
        # raise ValueError(f"Unknown event type: {event_type}")
        logger.warning(
                f"Calculation for the {event_type} yet not implemented!",
                alt= f"Calculation for the [code]{event_type}[/code] yet [bold]not implemented[/bold]!",
                )
        return NaT

calculate_solar_event_time_series_noaa

calculate_solar_event_time_series_noaa(
    longitude: Longitude,
    latitude: Latitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    event: List[SolarEvent | None] = [None],
    unrefracted_solar_zenith: UnrefractedSolarZenith = UNREFRACTED_SOLAR_ZENITH_ANGLE_DEFAULT,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> EventTime

Calculate the time of solar events like sunrise, noon, or sunset.

For sunrise and sunset, the zenith is set to 90.833 degrees (the approximate correction for atmospheric refraction and the size of the solar disk).

Parameters:

Name Type Description Default
latitude Latitude

The latitude in radians.

required
longitude Longitude

The longitude in radians.

required
timestamps DatetimeIndex

The date to calculate the event for.

required
timezone ZoneInfo

The timezone information.

required
unrefracted_solar_zenith UnrefractedSolarZenith

The zenith of the sun, adjusted for atmospheric refraction. Defaults to 1.5853349194640094 radians, which corresponds to 90.833 degrees. This is the zenith at sunrise or sunset, adjusted for the approximate correction for atmospheric refraction at those times, and the size of the solar disk.

UNREFRACTED_SOLAR_ZENITH_ANGLE_DEFAULT
event List[Optional[SolarEvent]]

A list of solar events to calculate the hour angle for, i.e. 'noon', 'sunrise', or 'sunset'.

[None]
dtype str

Data type for calculations. Default is DATA_TYPE_DEFAULT.

DATA_TYPE_DEFAULT
array_backend str

Backend for array operations. Default is ARRAY_BACKEND_DEFAULT.

ARRAY_BACKEND_DEFAULT
verbose int

Verbosity level for logging. Default is VERBOSE_LEVEL_DEFAULT.

VERBOSE_LEVEL_DEFAULT
log int

Log level for logging. Default is LOG_LEVEL_DEFAULT.

LOG_LEVEL_DEFAULT

Returns:

Type Description
EventTime

The calculated times of the requested solar events as a DatetimeIndex.

Notes
  • All angles are in radians.
  • The calculation (1440 / (2 * pi)) * value_in_radians maps a 'value in radians' from a range of [0, 2 * pi] which is a full circle to a range of [0, 1440] which is a full day in minutes.
Source code in pvgisprototype/algorithms/noaa/event_time.py
@log_function_call
@custom_cached
@validate_with_pydantic(CalculateEventTimeTimeSeriesNOAAInput)
def calculate_solar_event_time_series_noaa(
    longitude: Longitude,
    latitude: Latitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    event: List[SolarEvent | None] = [None],
    unrefracted_solar_zenith: UnrefractedSolarZenith = UNREFRACTED_SOLAR_ZENITH_ANGLE_DEFAULT,
    # adjust_for_atmospheric_refraction: bool = False,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> EventTime:
    """Calculate the time of solar events like sunrise, noon, or sunset.

    For sunrise and sunset, the zenith is set to 90.833 degrees (the
    approximate correction for atmospheric refraction and the size of the solar
    disk).

    Parameters
    ----------
    latitude : Latitude
        The latitude in radians.
    longitude : Longitude
        The longitude in radians.
    timestamps : DatetimeIndex
        The date to calculate the event for.
    timezone : ZoneInfo
        The timezone information.
    unrefracted_solar_zenith : UnrefractedSolarZenith, optional
        The zenith of the sun, adjusted for atmospheric refraction. Defaults to
        1.5853349194640094 radians, which corresponds to 90.833 degrees. This
        is the zenith at sunrise or sunset, adjusted for the approximate
        correction for atmospheric refraction at those times, and the size of
        the solar disk.
    event : List[Optional[SolarEvent]], optional
        A list of solar events to calculate the hour angle for, i.e. 'noon', 'sunrise', or 'sunset'.
    dtype : str, optional
        Data type for calculations. Default is DATA_TYPE_DEFAULT.
    array_backend : str, optional
        Backend for array operations. Default is ARRAY_BACKEND_DEFAULT.
    verbose : int, optional
        Verbosity level for logging. Default is VERBOSE_LEVEL_DEFAULT.
    log : int, optional
        Log level for logging. Default is LOG_LEVEL_DEFAULT.

    Returns
    -------
    EventTime
        The calculated times of the requested solar events as a DatetimeIndex.

    Notes
    -----
    - All angles are in radians.
    - The calculation `(1440 / (2 * pi)) * value_in_radians` maps a 'value in
      radians' from a range of [0, 2 * pi] which is a full circle to a range of
      [0, 1440] which is a full day in minutes.

    """
    if event == [SolarEvent.none]:
        return EventTime(
            value=None,
            event=None,
        )

    # Create an array of NaTs based on unique days from timestamps
    unique_days = timestamps.normalize().unique()
    event_hour_angle_series = calculate_event_hour_angle_series_noaa(
        latitude=latitude,
        timestamps=unique_days,
        unrefracted_solar_zenith=unrefracted_solar_zenith,
    )
    equation_of_time_series = calculate_equation_of_time_series_noaa(
        timestamps=unique_days,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
    )
    timezone_offset_timedelta = timezone.utcoffset(None)
    timezone_offset_hours_utc = timezone_offset_timedelta.total_seconds() / 3600

    from pvgisprototype.api.position.models import select_models
    requested_solar_events = select_models(
        SolarEvent, event
    )  # Using a callback fails!

    # Vectorized calculation of event times
    solar_events_time_series_in_minutes = {
        solar_event: calculate_solar_event_time_in_minutes(
            longitude,
            event_hour_angle_series,
            equation_of_time_series,
            timezone_offset_hours_utc,
            solar_event.name,
        )
        for solar_event in requested_solar_events if solar_event in SolarEvent
    }

    solar_event_series = dict()
    for solar_event in requested_solar_events:

        # Check if the event has calculated times
        if solar_event in solar_events_time_series_in_minutes:

            # Get the event times in minutes
            solar_event_series[solar_event] = DatetimeIndex(
                [
                    Timestamp(ts.date()) + timedelta(minutes=float(et))
                    for ts, et in zip(
                        unique_days, solar_events_time_series_in_minutes[solar_event]
                    )
                ]
            )
        else:
            logger.error(f"Event {event} not found in event_time_series_in_minutes.")

    event_types, event_timestamps = match_event_times_to_timestamps(
            solar_event_series=solar_event_series,
            timestamps=timestamps,
            array_backend=array_backend,
            )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_event_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return EventTime(
        value=event_timestamps,
        event_type=event_types,
        hour_angle=event_hour_angle_series,
        equation_of_time=equation_of_time_series,
    )

match_event_times_to_timestamps

match_event_times_to_timestamps(
    solar_event_series: dict,
    timestamps: DatetimeIndex,
    array_backend: str,
)
Source code in pvgisprototype/algorithms/noaa/event_time.py
def match_event_times_to_timestamps(
    solar_event_series: dict,
    timestamps: DatetimeIndex,
    array_backend: str,
):
    """
    """
    frequency_string = timestamps.freqstr
    try:
        # Define matching threshold dynamically based on inferred frequency
        if 'W-' in str(frequency_string):
            frequency_string = '1' + str(frequency_string).split('-')[0]  # 'W'
        else:
            frequency_string = timestamps.freq
        frequency_threshold = Timedelta(frequency_string)

    except:
        raise ValueError("Unable to infer frequency from timestamps.")


    event_timestamps = numpy.full(
            shape=timestamps.shape,
            fill_value=numpy.datetime64('NaT'),
            dtype='datetime64[ns]',
    )
    event_types = create_array(
        timestamps.shape, dtype="object", init_method="empty", backend=array_backend
    )
    naive_timestamps = timestamps.tz_localize(None)

    for event_type, event_times in solar_event_series.items():

        # Find indices of closest matching timestamps
        event_indexer = naive_timestamps.get_indexer(event_times, method='nearest')

        # However, assign event times to indices only if within the frequency threshold
        for idx, event_time in zip(event_indexer, event_times):
            if idx >= 0:  # Valid index
                closest_timestamp = naive_timestamps[idx]
                if abs(event_time - closest_timestamp) <= frequency_threshold:
                    event_timestamps[idx] = event_time.to_numpy()
                    event_types[idx] = event_type

    return event_types, event_timestamps

events

This module contains days within the year that have some solar significance.

The SIGNIFICANT_DAYS list includes key dates such as solstices, equinoxes, and points of perihelion and aphelion, among others. These dates are significant in understanding the Earth's orbit around the Sun and its effects on solar irradiance.

Each date is represented as a tuple, where the first element is the day of the year, and the second element is an event or description.

Constants: SIGNIFICANT_DAYS (list): A list of tuples representing significant dates. Each tuple contains a day of the year and a description of the significant event on that day. Source : Significant Days of the Year, https://andrewmarsh.com/articles/2019/significant-dates/

fractional_year

Functions:

Name Description
calculate_fractional_year_series_noaa

Calculate the fractional year for a time series.

calculate_fractional_year_series_noaa

calculate_fractional_year_series_noaa(
    timestamps: DatetimeIndex,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
) -> FractionalYear

Calculate the fractional year for a time series.

Parameters:

Name Type Description Default
timestamps DatetimeIndex

A Pandas DatetimeIndex representing the timestamps.

required
dtype str

The data type for the calculations (the default is 'float32').

DATA_TYPE_DEFAULT
array_backend str

The backend used for calculations (the default is 'NUMPY').

ARRAY_BACKEND_DEFAULT
verbose int

Verbosity level

VERBOSE_LEVEL_DEFAULT
log int

Log level

LOG_LEVEL_DEFAULT

Returns:

Type Description
FractionalYear

A FractionalYear object containing the calculated fractional year series.

Raises:

Type Description
ValueError

If any calculated fractional year value is outside the expected range.

Examples:

>>> timestamps = pd.date_range(start='2020-01-01', end='2020-12-31', freq='D')
>>> fractional_year_series = calculate_fractional_year_series_noaa(timestamps)
>>> print(fractional_year_series)
Notes

The function calculates the fractional year considering leap years and converts the timestamps into fractional values considering their position within the year.

See also

The default data type (dtype) and backend (array_backend) for arrays are set in constants.py via the global variables DATA_TYPE_DEFAULT and ARRAY_BACKEND_DEFAULT.

Source code in pvgisprototype/algorithms/noaa/fractional_year.py
@log_function_call
@custom_cached
@validate_with_pydantic(CalculateFractionalYearTimeSeriesNOAAInput)
def calculate_fractional_year_series_noaa(
    timestamps: DatetimeIndex,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT
) -> FractionalYear:
    """Calculate the fractional year for a time series.

    Parameters
    ----------
    timestamps : DatetimeIndex
        A Pandas DatetimeIndex representing the timestamps.

    dtype : str, optional
        The data type for the calculations (the default is 'float32').

    array_backend : str, optional
        The backend used for calculations (the default is 'NUMPY').

    verbose: int
        Verbosity level

    log: int
        Log level

    Returns
    -------
    FractionalYear
        A FractionalYear object containing the calculated fractional year
        series.

    Raises
    ------
    ValueError
        If any calculated fractional year value is outside the expected range.

    Examples
    --------
    >>> timestamps = pd.date_range(start='2020-01-01', end='2020-12-31', freq='D')
    >>> fractional_year_series = calculate_fractional_year_series_noaa(timestamps)
    >>> print(fractional_year_series)

    Notes
    -----
    The function calculates the fractional year considering leap years and
    converts the timestamps into fractional values considering their position
    within the year.

    See also
    --------
    The default data type (`dtype`) and backend (`array_backend`) for arrays
    are set in `constants.py` via the global variables `DATA_TYPE_DEFAULT` and
    `ARRAY_BACKEND_DEFAULT`.

    """
    days_of_year = timestamps.dayofyear
    hours = timestamps.hour
    days_in_years = get_days_in_years(timestamps.year)
    array_parameters = {
        "shape": timestamps.shape,
        "dtype": dtype,
        "init_method": "zeros",
        "backend": array_backend,
    }  # Borrow shape from timestamps
    fractional_year_series = create_array(**array_parameters)
    fractional_year_series = np.array(
        2 * np.pi / days_in_years * (days_of_year - 1 + (hours - 12) / 24),
        dtype=dtype,
    )
    # Is this "restriction" correct ?
    fractional_year_series[fractional_year_series < 0] = 0

    if validate_output:
        if not np.all(
            (FractionalYear().min_radians <= fractional_year_series)
            & (fractional_year_series <= FractionalYear().max_radians)
        ):
            index_of_out_of_range_values = np.where(
                (fractional_year_series < FractionalYear().min_radians)
                | (fractional_year_series > FractionalYear().max_radians)
            )
            out_of_range_values = fractional_year_series[index_of_out_of_range_values]
            # Report values in "human readable" degrees
            raise ValueError(
                f"{WARNING_OUT_OF_RANGE_VALUES} "
                f"[{FractionalYear().min_radians}, {FractionalYear().max_radians}] radians"
                f" in [code]fractional_year_series[/code] : {out_of_range_values}"
            )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=fractional_year_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return FractionalYear(
        value=fractional_year_series,
        unit=RADIANS,
        solar_positioning_algorithm=SolarPositionModel.noaa,
    )

local_time

Functions:

Name Description
calculate_local_solar_time_noaa

Calculate the Local Solar Time (LST) based on the sun's position.

calculate_local_solar_time_noaa

calculate_local_solar_time_noaa(
    longitude: Longitude,
    latitude: Latitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    event: SolarEvent | None = noon,
    unrefracted_solar_zenith: UnrefractedSolarZenith = UNREFRACTED_SOLAR_ZENITH_ANGLE_DEFAULT,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> DatetimeIndex

Calculate the Local Solar Time (LST) based on the sun's position.

This function computes the local solar time for a series of timestamps, considering solar noon and corrections for the Equation of Time (ET) and longitude. Local solar time reflects the actual position of the sun in the sky, which may differ from local standard time.

Parameters:

Name Type Description Default
longitude Longitude

Longitude of the location in radians.

required
latitude Latitude

Latitude of the location in radians.

required
timestamps DatetimeIndex

Timestamps for which to calculate the local solar time.

required
timezone ZoneInfo

Timezone information for the location.

required
unrefracted_solar_zenith UnrefractedSolarZenith

The zenith of the sun, adjusted for atmospheric refraction. Defaults to 1.5853349194640094 radians, which corresponds to 90.833 degrees. This is the zenith at sunrise or sunset, adjusted for the approximate correction for atmospheric refraction at those times, and the size of the solar disk.

UNREFRACTED_SOLAR_ZENITH_ANGLE_DEFAULT
dtype str

Data type for the output (default is DATA_TYPE_DEFAULT).

DATA_TYPE_DEFAULT
array_backend str

Backend for array operations (default is ARRAY_BACKEND_DEFAULT).

ARRAY_BACKEND_DEFAULT
verbose int

Verbosity level for debugging (default is VERBOSE_LEVEL_DEFAULT).

VERBOSE_LEVEL_DEFAULT
log int

Logging level (default is LOG_LEVEL_DEFAULT).

LOG_LEVEL_DEFAULT

Returns:

Type Description
DatetimeIndex

A series of local solar times corresponding to the input timestamps.

Notes
In solar energy calculations, the Apparent Solar Time (AST) is based on the

apparent angular motion of the sun across the sky, expressing the time of day. The moment when the sun crosses the meridian of the observer is known as local solar noon. This does not usually coincide with 12:00 PM on the clock.

Local Solar Time (LST) is calculated based on the Apparent Solar Time (AST), which is determined by the sun's position. The general equation for converting between Local Solar Time (LST) and Apparent Solar Time (AST) is:

AST = LST + ET ± 4 * (SL - LL) - DS

Where: - AST: Apparent Solar Time - LST: Local Standard Time - ET: Equation of Time - SL: Standard Longitude for the time zone - LL: Local Longitude - DS: Daylight Saving Time adjustment. Working with UTC, DS is removed from the equation.

Thus, the LST is given by :

LST = AST - ET ± 4 * (SL - LL)

For noon, AST = 12 thus :

LST = 12 - ET ± 4 * (SL - LL)

Definitions:

  • Local Solar Time (LST): Time based on the sun's position at a specific location.
  • Apparent Solar Time (AST): Time measured by the sun's position, specifically at solar noon.
  • Equation of Time (ET): Difference between apparent solar time and mean solar time.
  • Standard Longitude: Longitude that defines the center of a time zone.
  • Local Longitude: Specific longitude of a location, used for corrections.
  • Daylight Saving Time (DS): Adjustment of the clock to extend evening daylight.

This function is useful for solar energy calculations and other applications where the actual solar time is relevant.

Source code in pvgisprototype/algorithms/noaa/local_time.py
@validate_with_pydantic(CalculateLocalSolarTimeNOAAInput)
def calculate_local_solar_time_noaa(
    longitude: Longitude,  # radians
    latitude: Latitude,  # radians
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    event: SolarEvent | None = SolarEvent.noon,
    unrefracted_solar_zenith: UnrefractedSolarZenith = UNREFRACTED_SOLAR_ZENITH_ANGLE_DEFAULT,  # radians
    # adjust_for_atmospheric_refraction: bool = False,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> DatetimeIndex:
    """
    Calculate the Local Solar Time (LST) based on the sun's position.

    This function computes the local solar time for a series of timestamps,
    considering solar noon and corrections for the Equation of Time (ET) 
    and longitude. Local solar time reflects the actual position of the sun 
    in the sky, which may differ from local standard time.

    Parameters
    ----------
    longitude : Longitude
        Longitude of the location in radians.
    latitude : Latitude
        Latitude of the location in radians.
    timestamps : DatetimeIndex
        Timestamps for which to calculate the local solar time.
    timezone : ZoneInfo
        Timezone information for the location.
    unrefracted_solar_zenith : UnrefractedSolarZenith, optional
        The zenith of the sun, adjusted for atmospheric refraction. Defaults to
        1.5853349194640094 radians, which corresponds to 90.833 degrees. This
        is the zenith at sunrise or sunset, adjusted for the approximate
        correction for atmospheric refraction at those times, and the size of
        the solar disk.
    # adjust_for_atmospheric_refraction : bool, optional
    #     Whether to apply atmospheric refraction corrections (default is False).
    dtype : str, optional
        Data type for the output (default is DATA_TYPE_DEFAULT).
    array_backend : str, optional
        Backend for array operations (default is ARRAY_BACKEND_DEFAULT).
    verbose : int, optional
        Verbosity level for debugging (default is VERBOSE_LEVEL_DEFAULT).
    log : int, optional
        Logging level (default is LOG_LEVEL_DEFAULT).

    Returns
    -------
    DatetimeIndex
        A series of local solar times corresponding to the input timestamps.

    Notes
    -----

        In solar energy calculations, the Apparent Solar Time (AST) is based on the
    apparent angular motion of the sun across the sky, expressing the time of day.
    The moment when the sun crosses the meridian of the observer is known as 
    local solar noon. This does not usually coincide with 12:00 PM on the clock.

    Local Solar Time (LST) is calculated based on the Apparent Solar Time (AST),
    which is determined by the sun's position. The general equation for converting 
    between Local Solar Time (LST) and Apparent Solar Time (AST) is:

        AST = LST + ET ± 4 * (SL - LL) - DS

    Where:
    - AST: Apparent Solar Time
    - LST: Local Standard Time
    - ET: Equation of Time
    - SL: Standard Longitude for the time zone
    - LL: Local Longitude
    - DS: Daylight Saving Time adjustment. Working with UTC, DS is removed from the equation.

    Thus, the LST is given by :

        LST = AST - ET ± 4 * (SL - LL)

    For noon, AST = 12 thus :

        LST = 12 - ET ± 4 * (SL - LL)

    Definitions:

    - **Local Solar Time (LST)**: Time based on the sun's position at a specific location.
    - **Apparent Solar Time (AST)**: Time measured by the sun's position, specifically at solar noon.
    - **Equation of Time (ET)**: Difference between apparent solar time and mean solar time.
    - **Standard Longitude**: Longitude that defines the center of a time zone.
    - **Local Longitude**: Specific longitude of a location, used for corrections.
    - **Daylight Saving Time (DS)**: Adjustment of the clock to extend evening daylight.

    This function is useful for solar energy calculations and other applications
    where the actual solar time is relevant.

    """
    # Calculate solar noon for the given timestamps
    solar_noon_series = calculate_solar_event_time_series_noaa(
        longitude=longitude,
        latitude=latitude,
        timestamps=timestamps,
        timezone=timezone,
        event=event.name,
        unrefracted_solar_zenith=unrefracted_solar_zenith,
        # adjust_for_atmospheric_refraction=adjust_for_atmospheric_refraction,
    )

    # Calculate the time difference from solar noon
    local_solar_time_delta = np.where(
        timestamps < solar_noon_series,
        timestamps - (solar_noon_series - timedelta(days=1)),  # Previous solar noon
        timestamps - solar_noon_series,  # Current solar noon
    )

    # Convert time difference to seconds and add to timestamps
    total_seconds = int(local_solar_time_delta.total_seconds())
    local_solar_time_series = timestamps + timedelta(seconds=total_seconds)

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    return local_solar_time_series

parameter_models

Classes:

Name Description
AngleInRadiansOutputUnitsModel

The angle in radians output units argument is passed along with the

AngleInRadiansOutputUnitsModel

Bases: BaseModel

The angle in radians output units argument is passed along with the returned value. This is not a real test. Hopefully, and however, it helps for clarity and understanding of what the function should return.

solar_altitude

Functions:

Name Description
calculate_solar_altitude_series_noaa

Calculate the solar altitude angle for a location over a time series

calculate_solar_altitude_series_noaa

calculate_solar_altitude_series_noaa(
    longitude: Longitude,
    latitude: Latitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    adjust_for_atmospheric_refraction: bool = True,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarAltitude

Calculate the solar altitude angle for a location over a time series

Source code in pvgisprototype/algorithms/noaa/solar_altitude.py
@log_function_call
@custom_cached
@validate_with_pydantic(CalculateSolarAltitudeTimeSeriesNOAAInput)
def calculate_solar_altitude_series_noaa(
    longitude: Longitude,
    latitude: Latitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    adjust_for_atmospheric_refraction: bool = True,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarAltitude:
    """Calculate the solar altitude angle for a location over a time series"""
    solar_zenith_series = calculate_solar_zenith_series_noaa(
        longitude=longitude,
        latitude=latitude,
        timestamps=timestamps,
        timezone=timezone,
        adjust_for_atmospheric_refraction=adjust_for_atmospheric_refraction,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
        validate_output=validate_output,
    )
    solar_altitude_series = np.pi / 2 - solar_zenith_series.radians

    out_of_range, out_of_range_index = identify_values_out_of_range_x(
        series=solar_altitude_series,
        shape=timestamps.shape,
        data_model=SolarAltitude(),
    )
    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_altitude_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return SolarAltitude(
        value=solar_altitude_series,
        unit=RADIANS,
        out_of_range=out_of_range,
        out_of_range_index=out_of_range_index,
        solar_positioning_algorithm=solar_zenith_series.solar_positioning_algorithm,
        solar_timing_algorithm=solar_zenith_series.solar_timing_algorithm,
        adjusted_for_atmospheric_refraction=solar_zenith_series.adjusted_for_atmospheric_refraction,
    )

solar_azimuth

Functions:

Name Description
calculate_solar_azimuth_series_noaa

Calculate the solar azimuth angle (θ) for a time series at a specific

calculate_solar_azimuth_series_noaa

calculate_solar_azimuth_series_noaa(
    longitude: Longitude,
    latitude: Latitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo | None,
    adjust_for_atmospheric_refraction: bool = True,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
) -> SolarAzimuth

Calculate the solar azimuth angle (θ) for a time series at a specific geographic latitude and longitude.

Calculate the solar azimuth angle (θ) for a time series at a specific geographic latitude and longitude, by default correcting the solar zenith angle for atmospheric refraction, as described in the following steps:

1. Calculate the solar declination, solar hour angle, and solar zenith
using NOAA's General Solar Position Calculations.

2. Calculate the solar azimuth angle using the equation reported in
Wikipedia's article "Solar azimuth angle" adjusting for the time of
day -- see also Notes :

    - Morning (solar hour angle < 0): azimuth derived directly from the
      arccosine function.

    - Afternoon (solar hour angle > 0): azimuth adjusted to range in
      [180°, 360°] by subtracting it from 360°.

Parameters:

Name Type Description Default
longitude float

Longitude of the location in radians.

required
latitude float

Latitude of the location in radians.

required
timestamps DatetimeIndex

Times for which the solar azimuth will be calculated.

required
timezone ZoneInfo

Timezone of the location.

required
adjust_for_atmospheric_refraction bool

Whether to correct the solar zenith angle for atmospheric refraction.

True
dtype str

Data type for the calculations.

DATA_TYPE_DEFAULT
array_backend str

Backend array library to use.

ARRAY_BACKEND_DEFAULT
verbose int

Verbosity level of the function.

VERBOSE_LEVEL_DEFAULT
log int

Log level for the function.

LOG_LEVEL_DEFAULT

Returns:

Type Description
SolarAzimuth

A custom data class that hold a NumPy NDArray of calculated solar azimuth angles in radians, a method to convert the angles to degrees and other metadata.

Notes

From .. Excel sheet :

=IF(AC2>0, MOD( DEGREES(ACOS(((SIN(RADIANS(\(B\)3))*COS(RADIANS(AD2)))-SIN(RADIANS(T2)))/(COS(RADIANS(\(B\)3))*SIN(RADIANS(AD2)))))+180, 360), MOD( 540-DEGREES(ACOS(((SIN(RADIANS(\(B\)3))*COS(RADIANS(AD2)))-SIN(RADIANS(T2)))/(COS(RADIANS(\(B\)3))*SIN(RADIANS(AD2))))), 360) )

numerator = (sin(latitude.radians) * cos(solar_zenith.radians)) - sin(solar_declination.radians) denominator = (cos(latitude.radians) * sin(solar_zenith.radians)) cosine_solar_azimuth = numerator / denominator

if solar_hour_angle > 0: (pi + arccos(cosine_solar_azimuth)) % 2*pi

else: (3*pi - arccos(cosine_solar_azimuth)) % 2*pi

Two important notes on the calculation of the solar azimuth angle :

  • The equation implemented here follows upon the relevant Wikipedia article for the "Solar azimuth angle" [1]_.

  • The angle derived fom the arccosine function requires an adjustment to correctly represent both morning and afternoon solar azimuth angles.

  • The equation given in NOAA's General Solar Position Calculations [0]_ is

                 sin(latitude) * cos(solar_zenith) - sin(solar_declination)
    

    cos(180 - θ) = - ---------------------------------------------------------- cos(latitude) * sin(solar_zenith)

    or after converting cos(180 - θ) to - cos(θ) :

             sin(latitude) * cos(solar_zenith) - sin(solar_declination)
    
    • cos(θ) = - ------------------------------------------------------------ cos(latitude) * sin(solar_zenith)

    or :

         sin(latitude) * cos(solar_zenith) - sin(solar_declination)
    

    cos(θ) = ---------------------------------------------------------- cos(latitude) * sin(solar_zenith)

    and finally :

              sin(latitude) * cos(solar_zenith) - sin(solar_declination)
    

    θ = arccos( -------------------------------------------------------------- ) cos(latitude) * sin(solar_zenith)

    where θ is the wanted solar azimuth angle.

    However, comparing this equation with the almost identical equation reported in Wikipedia's relevant article and subsection titled "Conventional Trigonometric Formulas" [1]_, there seems to be a difference of a minus sign :

             sin(declination) - cos(zenith) * sin(latitude)
    

    φs = arccos( ---------------------------------------------- ) sin(zenith) * cos(latitude)

    where φs is the wanted solar azimuth angle.

    A cross-comparison of the solar azimuth angle derived by the equation reported in Wikipedia's article [1]_ and the libraries pvlib [2], Skyfield [3] and suncalc [4]_, show a high agreement across all of them.

  • Adjusting the solar azimuth based on the time of day

    Given that arccosine ranges in [0, π] or else in [0°, 180°], the raw calculated solar azimuth angle will likewise range in [0, π]. This necessitates an adjustment based on the time of day.

    • Morning (solar hour angle < 0): the azimuth angle is correctly derived directly from the arccosine function representing angles from the North clockwise to the South [0°, 180°].

    • Afternoon (solar hour angle > 0): the azimuth angle needs to be adjusted in order to correctly represent angles going further from the South to the West [180°, 360°]. This is achieved by subtracting the azimuth from 360°.

References

.. [0] https://gml.noaa.gov/grad/solcalc/solareqns.PDF

.. [1] https://en.wikipedia.org/wiki/Solar_azimuth_angle#Conventional_Trigonometric_Formulas

.. [2] https://github.com/pvlib/pvlib-python

.. [3] https://github.com/skyfielders/python-skyfield/

.. [4] https://github.com/kylebarron/suncalc-py

Examples:

>>> from math import radians
>>> from pvgisprototype.api.datetime.datetimeindex import generate_datetime_series
>>> timestamps = generate_datetime_series(start_time='2010-01-27', end_time='2010-01-28')
>>> from zoneinfo import ZoneInfo
>>> from pvgisprototype.api.position.azimuth import calculate_solar_azimuth_series_noaa
>>> solar_azimuth_series = calculate_solar_azimuth_series_noaa(
... longitude=radians(8.628),
... latitude=radians(45.812),
... timestamps=timestamps,
... timezone=ZoneInfo("UTC"),
... adjust_for_atmospheric_refraction=True
... )
>>> print(solar_azimuth_series)
solar_timing_algorithm='NOAA' solar_positioning_algorithm='NOAA' min_degrees=0 min_radians=0 data_source=None equation=None algorithm=None unit='radians' value=array([0.20324755, 0.68162394, 1.0347457 , 1.2956891 , 1.5057368 ,
       1.6915469 , 1.8700204 , 2.0612502 , 2.250216  , 2.466558  ,
       2.7086406 , 2.9751859 , 3.2346225 , 3.50884   , 3.757659  ,
       3.9807858 , 4.1786404 , 4.3685203 , 4.547683  , 4.73073   ,
       4.933111  , 5.178298  , 5.503539  , 5.949113  , 0.19983101],
      dtype=float32) symbol='󱦥' description='Solar azimuth angle data for a location and period in time' label=None title='Solar Azimuth' supertitle='Solar Irradiance' shortname='Azimuth' name='Solar Azimuth' max_radians=6.283185307179586 max_degrees=360 definition='Solar azimuth angle' origin='North'
>>> print(solar_azimuth_series.degrees)
[ 11.6452265  39.054173   59.286556   74.23751    86.27236    96.918495
 107.14427   118.10093   128.92787   141.32335   155.19366   170.46559
 185.3302    201.04172   215.29799   228.08221   239.41844   250.29776
 260.56302   271.05084   282.64642   296.6946    315.32956   340.85904
  11.449472 ]
Source code in pvgisprototype/algorithms/noaa/solar_azimuth.py
@log_function_call
@custom_cached
@validate_with_pydantic(CalculateSolarAzimuthTimeSeriesNOAAInput)
def calculate_solar_azimuth_series_noaa(
    longitude: Longitude,  # radians
    latitude: Latitude,  # radians
    timestamps: DatetimeIndex,
    timezone: ZoneInfo | None,
    adjust_for_atmospheric_refraction: bool = True,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output:bool = VALIDATE_OUTPUT_DEFAULT,
) -> SolarAzimuth:
    """Calculate the solar azimuth angle (θ) for a time series at a specific
    geographic latitude and longitude.

    Calculate the solar azimuth angle (θ) for a time series at a specific
    geographic latitude and longitude, by default correcting the solar zenith
    angle for atmospheric refraction, as described in the following steps:

        1. Calculate the solar declination, solar hour angle, and solar zenith
        using NOAA's General Solar Position Calculations.

        2. Calculate the solar azimuth angle using the equation reported in
        Wikipedia's article "Solar azimuth angle" adjusting for the time of
        day -- see also Notes :

            - Morning (solar hour angle < 0): azimuth derived directly from the
              arccosine function.

            - Afternoon (solar hour angle > 0): azimuth adjusted to range in
              [180°, 360°] by subtracting it from 360°.

    Parameters
    ----------
    longitude : float
        Longitude of the location in radians.
    latitude : float
        Latitude of the location in radians.
    timestamps : DatetimeIndex
        Times for which the solar azimuth will be calculated.
    timezone : ZoneInfo
        Timezone of the location.
    adjust_for_atmospheric_refraction : bool, optional
        Whether to correct the solar zenith angle for atmospheric refraction.
    dtype : str, optional
        Data type for the calculations.
    array_backend : str, optional
        Backend array library to use.
    verbose : int, optional
        Verbosity level of the function.
    log : int, optional
        Log level for the function.

    Returns
    -------
    SolarAzimuth
        A custom data class that hold a NumPy NDArray of calculated solar
        azimuth angles in radians, a method to convert the angles to degrees
        and other metadata.

    Notes
    -----

    From .. Excel sheet :

    =IF(AC2>0,
        MOD(
            DEGREES(ACOS(((SIN(RADIANS($B$3))*COS(RADIANS(AD2)))-SIN(RADIANS(T2)))/(COS(RADIANS($B$3))*SIN(RADIANS(AD2)))))+180,
        360),
        MOD(
            540-DEGREES(ACOS(((SIN(RADIANS($B$3))*COS(RADIANS(AD2)))-SIN(RADIANS(T2)))/(COS(RADIANS($B$3))*SIN(RADIANS(AD2))))),
        360)
    )

    numerator = (sin(latitude.radians) * cos(solar_zenith.radians)) - sin(solar_declination.radians)
    denominator = (cos(latitude.radians) * sin(solar_zenith.radians))
    cosine_solar_azimuth = numerator / denominator

    if solar_hour_angle > 0:
        (pi + arccos(cosine_solar_azimuth)) % 2*pi

    else:
        (3*pi - arccos(cosine_solar_azimuth)) % 2*pi


    Two important notes on the calculation of the solar azimuth angle :

    - The equation implemented here follows upon the relevant Wikipedia article
      for the "Solar azimuth angle" [1]_.

    - The angle derived fom the arccosine function requires an adjustment to
    correctly represent both morning and afternoon solar azimuth angles.


    1. The equation given in NOAA's General Solar Position Calculations [0]_ is

                         sin(latitude) * cos(solar_zenith) - sin(solar_declination)
        cos(180 - θ) = - ----------------------------------------------------------
                                  cos(latitude) * sin(solar_zenith)


        or after converting cos(180 - θ) to - cos(θ) :

                     sin(latitude) * cos(solar_zenith) - sin(solar_declination)
        - cos(θ) = - ------------------------------------------------------------
                                cos(latitude) * sin(solar_zenith)


        or :

                 sin(latitude) * cos(solar_zenith) - sin(solar_declination)
        cos(θ) = ----------------------------------------------------------
                             cos(latitude) * sin(solar_zenith)


        and finally :

                      sin(latitude) * cos(solar_zenith) - sin(solar_declination)
        θ = arccos( -------------------------------------------------------------- )
                                  cos(latitude) * sin(solar_zenith)

        where θ is the wanted solar azimuth angle.

        However, comparing this equation with the _almost identical_ equation
        reported in Wikipedia's relevant article and subsection titled
        "Conventional Trigonometric Formulas" [1]_, there seems to be a
        difference of a minus sign :

                     sin(declination) - cos(zenith) * sin(latitude)
        φs = arccos( ---------------------------------------------- )
                             sin(zenith) * cos(latitude)

        where φs is the wanted solar azimuth angle.

        A cross-comparison of the solar azimuth angle derived by the equation
        reported in Wikipedia's article [1]_ and the libraries pvlib [2]_,
        Skyfield [3]_ and suncalc [4]_, show a high agreement
        across all of them.


    2. Adjusting the solar azimuth based on the time of day

        Given that arccosine ranges in [0, π] or else in [0°, 180°], the raw
        calculated solar azimuth angle will likewise range in [0, π]. This
        necessitates an adjustment based on the time of day.

        - Morning (solar hour angle < 0): the azimuth angle is correctly
          derived directly from the arccosine function representing angles from
          the North clockwise to the South [0°, 180°].

        - Afternoon (solar hour angle > 0): the azimuth angle needs to be
          adjusted in order to correctly represent angles going further from
          the South to the West [180°, 360°]. This is achieved by subtracting
          the azimuth from 360°.

    References
    ----------
    .. [0] https://gml.noaa.gov/grad/solcalc/solareqns.PDF

    .. [1] https://en.wikipedia.org/wiki/Solar_azimuth_angle#Conventional_Trigonometric_Formulas

    .. [2] https://github.com/pvlib/pvlib-python

    .. [3] https://github.com/skyfielders/python-skyfield/

    .. [4] https://github.com/kylebarron/suncalc-py

    Examples
    --------
    >>> from math import radians
    >>> from pvgisprototype.api.datetime.datetimeindex import generate_datetime_series
    >>> timestamps = generate_datetime_series(start_time='2010-01-27', end_time='2010-01-28')
    >>> from zoneinfo import ZoneInfo
    >>> from pvgisprototype.api.position.azimuth import calculate_solar_azimuth_series_noaa
    >>> solar_azimuth_series = calculate_solar_azimuth_series_noaa(
    ... longitude=radians(8.628),
    ... latitude=radians(45.812),
    ... timestamps=timestamps,
    ... timezone=ZoneInfo("UTC"),
    ... adjust_for_atmospheric_refraction=True
    ... )
    >>> print(solar_azimuth_series)
    solar_timing_algorithm='NOAA' solar_positioning_algorithm='NOAA' min_degrees=0 min_radians=0 data_source=None equation=None algorithm=None unit='radians' value=array([0.20324755, 0.68162394, 1.0347457 , 1.2956891 , 1.5057368 ,
           1.6915469 , 1.8700204 , 2.0612502 , 2.250216  , 2.466558  ,
           2.7086406 , 2.9751859 , 3.2346225 , 3.50884   , 3.757659  ,
           3.9807858 , 4.1786404 , 4.3685203 , 4.547683  , 4.73073   ,
           4.933111  , 5.178298  , 5.503539  , 5.949113  , 0.19983101],
          dtype=float32) symbol='\U000f19a5' description='Solar azimuth angle data for a location and period in time' label=None title='Solar Azimuth' supertitle='Solar Irradiance' shortname='Azimuth' name='Solar Azimuth' max_radians=6.283185307179586 max_degrees=360 definition='Solar azimuth angle' origin='North'

    >>> print(solar_azimuth_series.degrees)
    [ 11.6452265  39.054173   59.286556   74.23751    86.27236    96.918495
     107.14427   118.10093   128.92787   141.32335   155.19366   170.46559
     185.3302    201.04172   215.29799   228.08221   239.41844   250.29776
     260.56302   271.05084   282.64642   296.6946    315.32956   340.85904
      11.449472 ]

    """
    solar_declination_series = calculate_solar_declination_series_noaa(
        timestamps=timestamps,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
        validate_output=validate_output,
    )
    solar_hour_angle_series = calculate_solar_hour_angle_series_noaa(
        longitude=longitude,
        timestamps=timestamps,
        timezone=timezone,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
        validate_output=validate_output,
    )
    solar_zenith_series = calculate_solar_zenith_series_noaa(
        longitude=longitude,
        latitude=latitude,
        timestamps=timestamps,
        timezone=timezone,
        adjust_for_atmospheric_refraction=adjust_for_atmospheric_refraction,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
        validate_output=validate_output,
    )
    numerator_series = sin(latitude.radians) * np.cos(
        solar_zenith_series.radians
    ) - np.sin(solar_declination_series.radians)
    denominator_series = cos(latitude.radians) * np.sin(solar_zenith_series.radians)
    cosine_solar_azimuth_series = numerator_series / denominator_series
    solar_azimuth_series = np.arccos(np.clip(cosine_solar_azimuth_series, -1, 1))
    solar_azimuth_series = np.where(
        solar_hour_angle_series.radians > 0,  # afternoon hours !
        np.mod((pi + solar_azimuth_series), 2 * pi),
        np.mod(3 * pi - solar_azimuth_series, 2 * pi),
    )

    if validate_output:
        if (
            (solar_azimuth_series < SolarAzimuth().min_radians)
            | (solar_azimuth_series > SolarAzimuth().max_radians)
        ).any():
            out_of_range_values = solar_azimuth_series[
                (solar_azimuth_series < SolarAzimuth().min_radians)
                | (solar_azimuth_series > SolarAzimuth().max_radians)
            ]
            # raise ValueError(# ?
            raise ValueError(
                f"{WARNING_OUT_OF_RANGE_VALUES} "
                f"[{SolarAzimuth().min_radians}, {SolarAzimuth().max_radians}] radians"
                f" in [code]solar_azimuth_series[/code] : {out_of_range_values}"
            )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_azimuth_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return SolarAzimuth(
        value=solar_azimuth_series,
        unit=RADIANS,
        solar_positioning_algorithm=SolarPositionModel.noaa,
        solar_timing_algorithm=SolarTimeModel.noaa,
        origin="North",
    )

solar_declination

Functions:

Name Description
calculate_solar_declination_series_noaa

Calculate the solar declination for a time series.

calculate_solar_declination_series_noaa

calculate_solar_declination_series_noaa(
    timestamps: DatetimeIndex,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
) -> SolarDeclination

Calculate the solar declination for a time series.

Notes

From NOAA's .. Excel sheet:

sine_solar_declination = ASIN( SIN(RADIANS(R2))*SIN(RADIANS(P2)) )

where:

P2 = M2-0.00569-0.00478*SIN(RADIANS(125.04-1934.136*G2))

where:

M2 = Geom Mean Long Sun (deg) + Geom Mean Anom Sun (deg)

R2 = Q2 + 0.00256 * COS(RADIANS(125.04 - 1934.136*G2))

where :

Q2 = Mean Obliq Ecliptic (deg)

Source code in pvgisprototype/algorithms/noaa/solar_declination.py
@log_function_call
@custom_cached
@validate_with_pydantic(CalculateSolarDeclinationTimeSeriesNOAAInput)
def calculate_solar_declination_series_noaa(
    timestamps: DatetimeIndex,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output:bool = VALIDATE_OUTPUT_DEFAULT,
) -> SolarDeclination:
    """Calculate the solar declination for a time series.

    Notes
    -----
    From NOAA's .. Excel sheet:

    sine_solar_declination
    = ASIN(
        SIN(RADIANS(R2))*SIN(RADIANS(P2))
    )

    where:

    P2 = M2-0.00569-0.00478*SIN(RADIANS(125.04-1934.136*G2))

        where:

        M2 = Geom Mean Long Sun (deg) + Geom Mean Anom Sun (deg)


    R2 = Q2 + 0.00256 * COS(RADIANS(125.04 - 1934.136*G2))

        where :

       Q2 = Mean Obliq Ecliptic (deg)

    """
    fractional_year_series = calculate_fractional_year_series_noaa(
        timestamps=timestamps,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
        validate_output=validate_output,
    )
    solar_declination_series = (
        0.006918
        - 0.399912 * np.cos(fractional_year_series.radians)
        + 0.070257 * np.sin(fractional_year_series.radians)
        - 0.006758 * np.cos(2 * fractional_year_series.radians)
        + 0.000907 * np.sin(2 * fractional_year_series.radians)
        - 0.002697 * np.cos(3 * fractional_year_series.radians)
        + 0.00148 * np.sin(3 * fractional_year_series.radians)
    )
    out_of_range = None
    out_of_range_index = None
    if validate_output:
        out_of_range, out_of_range_index = identify_values_out_of_range_x(
            series=solar_declination_series,
            shape=timestamps.shape,
            data_model=SolarDeclination(),
        )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_declination_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return SolarDeclination(
        value=solar_declination_series,
        unit=RADIANS,
        out_of_range=out_of_range if out_of_range is not None else None,
        out_of_range_index=out_of_range_index if out_of_range is not None else None,
        solar_positioning_algorithm=fractional_year_series.solar_positioning_algorithm,
    )

solar_hour_angle

Functions:

Name Description
calculate_solar_hour_angle_series_noaa

Calculate the solar hour angle for a time series.

calculate_solar_hour_angle_series_noaa

calculate_solar_hour_angle_series_noaa(
    longitude: Longitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
) -> SolarHourAngle

Calculate the solar hour angle for a time series.

The solar hour angle calculation converts the local solar time (LST) into the number of degrees which the sun moves across the sky. In other words, it reflects the Earth's rotation and indicates the time of the day relative to the position of the Sun. It bases on the longitude and timestamp and by definition, the solar hour angle is :

  • 0° at solar noon
  • negative in the morning
  • positive in the afternoon.

Since the Earth rotates 15° per hour (or pi / 12 in radians), each hour away from solar noon corresponds to an angular motion of the sun in the sky of 15°. Practically, the calculation converts a timestamp into a solar time.

Parameters:

Name Type Description Default
Returns
required
Notes

In the "original" equation, the solar hour angle is measured in degrees.

`hour_angle = true_solar_time / 4 - 180`

which is the same as

`hour_angle = true_solar_time * 0.25 - 180`

In the present implementation, we calculate the solar hour angle directly in radians. A full circle corresponds to 360 degrees or 2π radians. With 1440 minutes in a day, the angular change per minute is calculated as 2π radians divided by 1440 minutes. This results in approximately 0.004363323129985824 radians per minute.

To find the solar hour angle, we first calculate the time difference from solar noon (by subtracting the true solar time in minutes from 720). We then multiply this difference by the angular change per minute (0.004363323129985824) to convert the time difference into radians. This approach (accurately?) represents the solar hour angle as an angular measurement in radians, reflecting the Earth's rotation and the position of the sun in the sky relative to a given location on Earth.

In NREL's SPA ... , equation 32:

Η = ν + σ  α

Where :
    - σ the observer geographical longitude, positive or negative
      for east or west of Greenwich, respectively.

Limit Η to the range from 0 to 360 degrees using step 3.2.6 and note that it
is measured westward from south in this algorithm.

Step 3.2.6 :

    Limit L to the range from 0 to 360 degrees. That can be
    accomplished by dividing L by 360 and recording the decimal
    fraction of the division as F. If L is positive, then the limited L
    = 360 * F. If L is negative, then the limited L = 360 - 360 * F.
Source code in pvgisprototype/algorithms/noaa/solar_hour_angle.py
@log_function_call
@custom_cached
@validate_with_pydantic(CalculateSolarHourAngleTimeSeriesNOAAInput)
def calculate_solar_hour_angle_series_noaa(
    longitude: Longitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output:bool = VALIDATE_OUTPUT_DEFAULT
) -> SolarHourAngle:
    """Calculate the solar hour angle for a time series.

    The solar hour angle calculation converts the local solar time (LST) into
    the number of degrees which the sun moves across the sky. In other words,
    it reflects the Earth's rotation and indicates the time of the day relative
    to the position of the Sun. It bases on the longitude and timestamp and by
    definition, the solar hour angle is :

      - 0° at solar noon
      - negative in the morning
      - positive in the afternoon.

    Since the Earth rotates 15° per hour (or pi / 12 in radians), each hour
    away from solar noon corresponds to an angular motion of the sun in the sky
    of 15°. Practically, the calculation converts a timestamp into a solar
    time.

    Parameters
    ----------

    Returns
    -------

    Notes
    -----
    In the "original" equation, the solar hour angle is measured in degrees.

        `hour_angle = true_solar_time / 4 - 180`

        which is the same as

        `hour_angle = true_solar_time * 0.25 - 180`

    In the present implementation, we calculate the solar hour angle directly
    in radians. A full circle corresponds to 360 degrees or 2π radians. With
    1440 minutes in a day, the angular change per minute is calculated as 2π
    radians divided by 1440 minutes. This results in approximately
    0.004363323129985824 radians per minute.

    To find the solar hour angle, we first calculate the time difference from
    solar noon (by subtracting the true solar time in minutes from 720). We
    then multiply this difference by the angular change per minute
    (0.004363323129985824) to convert the time difference into radians. This
    approach (accurately?) represents the solar hour angle as an angular
    measurement in radians, reflecting the Earth's rotation and the position of
    the sun in the sky relative to a given location on Earth.

    In NREL's SPA ... , equation 32:

        Η = ν + σ − α

        Where :
            - σ the observer geographical longitude, positive or negative
              for east or west of Greenwich, respectively.

        Limit Η to the range from 0 to 360 degrees using step 3.2.6 and note that it
        is measured westward from south in this algorithm.

        Step 3.2.6 :

            Limit L to the range from 0 to 360 degrees. That can be
            accomplished by dividing L by 360 and recording the decimal
            fraction of the division as F. If L is positive, then the limited L
            = 360 * F. If L is negative, then the limited L = 360 - 360 * F.

    """
    true_solar_time_series = calculate_true_solar_time_series_noaa(
        longitude=longitude,
        timestamps=timestamps,
        timezone=timezone,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
        validate_output=validate_output,
    )
    solar_hour_angle_series = (true_solar_time_series.minutes - 720.0) * (np.pi / 720.0)
    # solar_hour_angle_series = np.where(
    #         # true_solar_time_series.minutes < 0,
    #         solar_hour_angle_series < 0,
    #         solar_hour_angle_series + pi,
    #         solar_hour_angle_series - pi,
    #         )

    if validate_output:
        if not np.all(
            (SolarHourAngle().min_radians <= solar_hour_angle_series)
            & (solar_hour_angle_series <= SolarHourAngle().max_radians)
        ):
            out_of_range_values = solar_hour_angle_series[
                ~(
                    (-SolarHourAngle().min_radians <= solar_hour_angle_series)
                    & (solar_hour_angle_series <= SolarHourAngle().max_radians)
                )
            ]
            raise ValueError(
                f"{WARNING_OUT_OF_RANGE_VALUES} "
                f"[{SolarHourAngle().min_degrees}, {SolarHourAngle().max_degrees}] degrees"
                f" in [code]solar_hour_angle_series[/code] : {np.degrees(out_of_range_values)}"
            )
    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_hour_angle_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return SolarHourAngle(
        value=solar_hour_angle_series,
        unit=RADIANS,
        solar_positioning_algorithm=SolarPositionModel.noaa,
        solar_timing_algorithm=true_solar_time_series.timing_algorithm,
    )

solar_time

The true solar time based on NOAA's General Solar Position Calculations.

Functions:

Name Description
calculate_true_solar_time_series_noaa

Calculate the true solar time at a specific geographic locations for a

calculate_true_solar_time_series_noaa

calculate_true_solar_time_series_noaa(
    longitude: Longitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
) -> TrueSolarTime

Calculate the true solar time at a specific geographic locations for a time series.

Calculate the true solar time at a specific geographic locations for a series of timestamps based on NOAA's General Solar Position Calculations [0]_.

The true solar time is the sum of the mean solar time and the equation of time. CONFIRM!?

Parameters:

Name Type Description Default
timestamps DatetimeIndex

The timestamp to calculate offset for

required
timezone ZoneInfo

The timezone for calculation

required
dtype str

The data type for the calculations (the default is 'float32').

DATA_TYPE_DEFAULT
array_backend str

The backend used for calculations (the default is 'NUMPY').

ARRAY_BACKEND_DEFAULT
verbose int

Verbosity level

VERBOSE_LEVEL_DEFAULT
log int

Log level

LOG_LEVEL_DEFAULT

Returns:

Type Description
TrueSolarTime
Notes

The true solar time is the sum of the time offset and the true solar time in minutes.

time_offset = eqtime + 4*longitude – 60*timezone
See also

pysolar

In pysolar the true solar time is calculated as follows :

```
(math.tm_hour(when) * 60 + math.tm_min(when) + 4 * longitude_deg + equation_of_time(math.tm_yday(when)))
```

in which equation the last part is the time offset

```
4 * longitude_deg + equation_of_time(math.tm_yday(when)))
```

Additional notes:

From NOAA's General Solar Position Calculations

"Next, the true solar time is calculated in the following two
equations. First the time offset is found, in minutes, and then the
true solar time, in minutes."

time_offset = eqtime + 4*longitude  60*timezone

where :
    - eqtime is in minutes,
    - longitude is in degrees (positive to the east of the Prime
      Meridian),
    - timezone is in hours from UTC (U.S. Mountain Standard Time = 7
      hours).

tst = hr*60 + mn + sc/60 + time_offset

where :
    - hr is the hour (0 - 23),
    - mn is the minute (0 - 59),
    - sc is the second (0 - 59).
References

.. [0] https://gml.noaa.gov/grad/solcalc/solareqns.PDF

Source code in pvgisprototype/algorithms/noaa/solar_time.py
@log_function_call
@custom_cached
@validate_with_pydantic(CalculateTrueSolarTimeTimeSeriesNOAAInput)
def calculate_true_solar_time_series_noaa(
    longitude: Longitude,  # radians
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
) -> TrueSolarTime:
    """Calculate the true solar time at a specific geographic locations for a
    time series.

    Calculate the true solar time at a specific geographic locations for a
    series of timestamps based on NOAA's General Solar Position Calculations
    [0]_.

    The true solar time is the sum of the mean solar time and the equation of
    time.  CONFIRM!?

    Parameters
    ----------
    timestamps: DatetimeIndex, optional
        The timestamp to calculate offset for

    timezone: str, optional
        The timezone for calculation

    dtype : str, optional
        The data type for the calculations (the default is 'float32').

    array_backend : str, optional
        The backend used for calculations (the default is 'NUMPY').

    verbose: int
        Verbosity level

    log: int
        Log level

    Returns
    -------
    TrueSolarTime

    Notes
    -----
    The true solar time is the sum of the time offset and the true solar time
    in minutes.

        time_offset = eqtime + 4*longitude – 60*timezone

    See also
    --------
    pysolar

    In pysolar the true solar time is calculated as follows :

        ```
        (math.tm_hour(when) * 60 + math.tm_min(when) + 4 * longitude_deg + equation_of_time(math.tm_yday(when)))
        ```

        in which equation the last part is the time offset

        ```
        4 * longitude_deg + equation_of_time(math.tm_yday(when)))
        ```

    Additional notes:

    From NOAA's General Solar Position Calculations

        "Next, the true solar time is calculated in the following two
        equations. First the time offset is found, in minutes, and then the
        true solar time, in minutes."

        time_offset = eqtime + 4*longitude – 60*timezone

        where :
            - eqtime is in minutes,
            - longitude is in degrees (positive to the east of the Prime
              Meridian),
            - timezone is in hours from UTC (U.S. Mountain Standard Time = –7
              hours).

        tst = hr*60 + mn + sc/60 + time_offset

        where :
            - hr is the hour (0 - 23),
            - mn is the minute (0 - 59),
            - sc is the second (0 - 59).

    References
    ----------
    .. [0] https://gml.noaa.gov/grad/solcalc/solareqns.PDF

    """
    time_offset_series = calculate_time_offset_series_noaa(
        longitude=longitude,
        timestamps=timestamps,
        timezone=timezone,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        validate_output=validate_output,
    )
    true_solar_time_series = (
        timestamps - timestamps.normalize()
    ).total_seconds() + time_offset_series.value * 60

    # array_parameters = {
    #     "shape": true_solar_time_series.shape,
    #     "dtype": dtype,
    #     "init_method": "zeros",
    #     "backend": array_backend,
    # }
    # true_solar_time_series_in_minutes = create_array(**array_parameters)
    true_solar_time_series_in_minutes = mod(
        (true_solar_time_series).astype(dtype) / 60, 1440
    )

    if validate_output:
        if not (
            (TrueSolarTime().min_minutes <= true_solar_time_series_in_minutes)
            & (true_solar_time_series_in_minutes <= TrueSolarTime().max_minutes)
        ).all():
            out_of_range_values = true_solar_time_series_in_minutes[
                ~(
                    (TrueSolarTime().min_minutes <= true_solar_time_series_in_minutes)
                    & (true_solar_time_series_in_minutes <= TrueSolarTime().max_minutes)
                )
            ]
            raise ValueError(
                f"{WARNING_OUT_OF_RANGE_VALUES} "
                f"[{TrueSolarTime().min_minutes}, {TrueSolarTime().max_minutes}] minutes"
                f" in [code]true_solar_time_series_in_minutes[/code] : {out_of_range_values}"
            )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=true_solar_time_series_in_minutes,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return TrueSolarTime(
        value=array(true_solar_time_series_in_minutes, dtype=dtype),
        unit=MINUTES,
        timing_algorithm=SolarTimeModel.noaa,
    )

solar_zenith

Functions:

Name Description
adjust_solar_zenith_for_atmospheric_refraction_time_series

Adjust solar zenith for atmospheric refraction for a time series of solar zenith angles

atmospheric_refraction_adjustment

Vectorized calculation of atmospheric refraction adjustment for different solar altitudes.

calculate_solar_zenith_series_noaa

Calculate the solar zenith angle for a location over a time series

adjust_solar_zenith_for_atmospheric_refraction_time_series

adjust_solar_zenith_for_atmospheric_refraction_time_series(
    solar_zenith_series: SolarZenith,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = 0,
    log: int = 0,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
) -> SolarZenith

Adjust solar zenith for atmospheric refraction for a time series of solar zenith angles

Source code in pvgisprototype/algorithms/noaa/solar_zenith.py
@validate_with_pydantic(AdjustSolarZenithForAtmosphericRefractionTimeSeriesNOAAInput)
def adjust_solar_zenith_for_atmospheric_refraction_time_series(
    solar_zenith_series: SolarZenith,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = 0,
    log: int = 0,
    validate_output:bool = VALIDATE_OUTPUT_DEFAULT
) -> SolarZenith:
    """Adjust solar zenith for atmospheric refraction for a time series of solar zenith angles"""
    # Mask
    solar_altitude_series_array = (
        np.radians(90, dtype=dtype) - solar_zenith_series.radians
    )  # in radians

    # Adjust using vectorized operations
    adjustment = atmospheric_refraction_adjustment(solar_altitude_series_array, dtype=dtype)

    adjusted_solar_zenith_series_array = solar_zenith_series.radians - adjustment

    # Validate
    '''
    if not np.all(np.isfinite(adjusted_solar_zenith_series_array)) or not np.all(
        (SolarZenith().min_radians <= adjusted_solar_zenith_series_array)
        & (adjusted_solar_zenith_series_array <= SolarZenith().max_radians)
    ):
        raise ValueError(
            f"The `adjusted_solar_zenith` should be a finite number ranging in [{SolarZenith().min_radians}, {SolarZenith().max_radians}] radians"
        )
    '''
    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    return SolarZenith(
        value=adjusted_solar_zenith_series_array,
        unit=RADIANS,
        solar_positioning_algorithm=solar_zenith_series.solar_positioning_algorithm,
        solar_timing_algorithm=solar_zenith_series.solar_timing_algorithm,
        adjusted_for_atmospheric_refraction=True,  # This is what this function does !
    )

atmospheric_refraction_adjustment

atmospheric_refraction_adjustment(
    solar_altitude: ndarray, dtype: str = "float64"
) -> ndarray

Vectorized calculation of atmospheric refraction adjustment for different solar altitudes. Applies different formulas based on the solar altitude angle.

Source code in pvgisprototype/algorithms/noaa/solar_zenith.py
def atmospheric_refraction_adjustment(
    solar_altitude: np.ndarray,  # radians
    dtype: str = "float64"
) -> np.ndarray:
    """
    Vectorized calculation of atmospheric refraction adjustment for different solar altitudes.
    Applies different formulas based on the solar altitude angle.
    """
    tangent_solar_altitude = np.tan(solar_altitude)

    # Conditions
    mask_high = solar_altitude > np.radians(5, dtype=dtype)
    mask_near = (solar_altitude > np.radians(-0.575, dtype=dtype)) & ~mask_high
    mask_below = solar_altitude <= np.radians(-0.575, dtype=dtype)

    # High solar altitude adjustment
    adjustment_high = (
        58.1 / tangent_solar_altitude
        - 0.07 / (tangent_solar_altitude**3)
        + 0.000086 / (tangent_solar_altitude**5)
    ) / 3600  # 1 degree / 3600 seconds

    # Near horizon adjustment
    solar_altitude_deg = np.degrees(solar_altitude)
    adjustment_near = (
        1735
        + solar_altitude_deg
        * (
            -518.2
            + solar_altitude_deg
            * (103.4 + solar_altitude_deg * (-12.79 + solar_altitude_deg * 0.711))
        )
    ) / 3600  # 1 degree / 3600 seconds

    # Below horizon adjustment
    adjustment_below = (-20.774 / tangent_solar_altitude) / 3600  # 1 degree / 3600 seconds

    # Initialize adjustment array
    adjustment = np.zeros_like(solar_altitude, dtype=dtype)

    # Apply adjustments based on conditions
    adjustment[mask_high] = np.radians(adjustment_high[mask_high])
    adjustment[mask_near] = np.radians(adjustment_near[mask_near])
    adjustment[mask_below] = np.radians(adjustment_below[mask_below])

    return adjustment

calculate_solar_zenith_series_noaa

calculate_solar_zenith_series_noaa(
    longitude: Longitude,
    latitude: Latitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    adjust_for_atmospheric_refraction: bool = False,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = 0,
    log: int = 0,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
) -> SolarZenith

Calculate the solar zenith angle for a location over a time series

Source code in pvgisprototype/algorithms/noaa/solar_zenith.py
@log_function_call
@custom_cached
@validate_with_pydantic(CalculateSolarZenithTimeSeriesNOAAInput)
def calculate_solar_zenith_series_noaa(
    longitude: Longitude,
    latitude: Latitude,  # radians
    timestamps: DatetimeIndex,
    timezone: ZoneInfo,
    # solar_hour_angle_series: SolarHourAngle,
    adjust_for_atmospheric_refraction: bool = False,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = 0,
    log: int = 0,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
) -> SolarZenith:
    """Calculate the solar zenith angle for a location over a time series"""
    solar_declination_series = calculate_solar_declination_series_noaa(
        timestamps=timestamps,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        validate_output=validate_output,
    )
    solar_hour_angle_series = calculate_solar_hour_angle_series_noaa(
        longitude=longitude,
        timestamps=timestamps,
        timezone=timezone,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
        validate_output=validate_output,
    )
    cosine_solar_zenith = sin(latitude.radians) * np.sin(
        solar_declination_series.radians
    ) + cos(latitude.radians) * np.cos(solar_declination_series.radians) * np.cos(
        solar_hour_angle_series.radians
    )
    solar_zenith_series = SolarZenith(
        value=np.arccos(cosine_solar_zenith),  # Important !
        unit=RADIANS,
        solar_positioning_algorithm=solar_declination_series.solar_positioning_algorithm,
        solar_timing_algorithm=solar_hour_angle_series.solar_timing_algorithm,
    )
    if adjust_for_atmospheric_refraction:
        solar_zenith_series = (
            adjust_solar_zenith_for_atmospheric_refraction_time_series(
                solar_zenith_series,
            )
        )
    if validate_output:
        if not np.all(np.isfinite(solar_zenith_series.radians)) or not np.all(
            (SolarZenith().min_radians <= solar_zenith_series.radians)
            & (solar_zenith_series.radians <= SolarZenith().max_radians)
        ):
            raise ValueError(
                f"Solar zenith values should be finite numbers and range in [{SolarZenith().min_radians}, {SolarZenith().max_radians}] radians"
            )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_zenith_series.value,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return solar_zenith_series

time_offset

The time offset based on NOAA's General Solar Position Calculations.

Functions:

Name Description
calculate_time_offset_series_noaa

Calculate the variation of the local solar time within a

calculate_time_offset_series_noaa

calculate_time_offset_series_noaa(
    longitude: Longitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo = ZONEINFO_UTC,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
) -> TimeOffset

Calculate the variation of the local solar time within a given timezone for a time series.

Calculate the variation of the local solar time, also referred to as time offset, within a timezone for NOAA's General Solar Position Calculations [0]_.

The time offset (in minutes) incorporates the Equation of Time and accounts for the variation of the Local Solar Time (LST) within a given timezone due to the longitude variations within the time zone.

Parameters:

Name Type Description Default
longitude Longitude

The longitude for calculation in radians (note: differs from the original equation which expects degrees).

required
timestamps DatetimeIndex

The timestamp to calculate the offset for

required
timezone ZoneInfo

The timezone

ZONEINFO_UTC
dtype str

The data type for the calculations (the default is 'float32').

DATA_TYPE_DEFAULT
array_backend str

The backend used for calculations (the default is 'NUMPY').

ARRAY_BACKEND_DEFAULT
verbose int

Verbosity level

VERBOSE_LEVEL_DEFAULT
log int

Log level

LOG_LEVEL_DEFAULT

Returns:

Name Type Description
TimeOffset TimeOffset

The time offset for a single or a series of timestamps.

Notes

The equation given in NOAA's General Solar Position Calculations [0]_ is

time_offset = eqtime + 4*longitude  60*timezone

where (variable name and units):
- time_offset, minutes
- longitude, degrees
- timezone, hours
- eqtime, minutes
See also

pysolar

In pysolar the true solar time is calculated as follows :

```
(math.tm_hour(when) * 60 + math.tm_min(when) + 4 * longitude_deg + equation_of_time(math.tm_yday(when)))
```

in which equation the time offset is the last part.

```
4 * longitude_deg + equation_of_time(math.tm_yday(when)))
```

Another reference

TC = 4 * (Longitude - LSTM) + EoT

where:

- TC        : Time Correction Factor, minutes
- Longitude : Geographical Longitude, degrees
- LSTM      : Local Standard Time Meridian, degrees * hours
- EoT       : Equation of Time

    where:
    - `LSTM = 15 degrees * ΔTUTC`

        where:
        - ΔTUTC = LT - UTC, hours

            where:
            - ΔTUTC : difference of LT from UTC in hours
            - LT    : Local Time
            - UTC   : Universal Coordinated Time

    - The factor 4 (minutes) comes from the fact that the Earth
      rotates 1° every 4 minutes.

    Examples:

        Mount Olympus is UTC + 2, hence LSTM = 15 * 2 = 30 deg. East

The time offset in minutes ranges in

[ -720 (Longitude) - 720 (Up to -12 TimeZones) - 20 (Equation of Time) = -1460,
  +720 (Longitude) + 840 (Up to +14 TimeZones) + 20 (Equation of Time) = 1580 ]

The valid ranges of the components that contribute to the time offset are:

  • Geographical longitude ranges from west to east in [-180, 180] degrees. A day is approximately 1440 minutes, hence converting the degrees to minutes, the longitude ranges in [-720, 720] minutes.

  • The timezone offset from the Coordinated Universal Time (UTC), considering time zones that are offset by unusual amounts of time from UTC, ranges from west of UTC to east of UTC in [-12, 14] hours or [-720, 840] minutes.

  • The Equation of Time accounts for the variations in the Earth's orbital speed and axial tilt. It varies throughout the year, but is typically within the range of about -20 minutes to +20 minutes.

References

.. [0] https://gml.noaa.gov/grad/solcalc/solareqns.PDF

Source code in pvgisprototype/algorithms/noaa/time_offset.py
@log_function_call
@custom_cached
@validate_with_pydantic(CalculateTimeOffsetTimeSeriesNOAAInput)
def calculate_time_offset_series_noaa(
    longitude: Longitude,
    timestamps: DatetimeIndex,
    timezone: ZoneInfo = ZONEINFO_UTC,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
    validate_output: bool = VALIDATE_OUTPUT_DEFAULT,
) -> TimeOffset:
    """Calculate the variation of the local solar time within a
    given timezone for a time series.

    Calculate the variation of the local solar time, also referred to as _time
    offset_, within a timezone for NOAA's General Solar Position Calculations
    [0]_.

    The time offset (in minutes) incorporates the Equation of Time and
    accounts for the variation of the Local Solar Time (LST) within a given
    timezone due to the longitude variations within the time zone.

    Parameters
    ----------
    longitude: float
        The longitude for calculation in radians (note: differs from the original
        equation which expects degrees).

    timestamps: DatetimeIndex
        The timestamp to calculate the offset for

    timezone: ZoneInfo, optional
        The timezone

    dtype : str, optional
        The data type for the calculations (the default is 'float32').

    array_backend : str, optional
        The backend used for calculations (the default is 'NUMPY').

    verbose: int, optional
        Verbosity level

    log: int, optional
        Log level

    Returns
    -------
    TimeOffset:
        The time offset for a single or a series of timestamps.

    Notes
    -----
    The equation given in NOAA's General Solar Position Calculations [0]_ is

        time_offset = eqtime + 4*longitude – 60*timezone

        where (variable name and units):
        - time_offset, minutes
        - longitude, degrees
        - timezone, hours
        - eqtime, minutes

    See also
    --------
    pysolar

        In pysolar the true solar time is calculated as follows :

        ```
        (math.tm_hour(when) * 60 + math.tm_min(when) + 4 * longitude_deg + equation_of_time(math.tm_yday(when)))
        ```

        in which equation the time offset is the last part.

        ```
        4 * longitude_deg + equation_of_time(math.tm_yday(when)))
        ```

    Another reference

        TC = 4 * (Longitude - LSTM) + EoT

        where:

        - TC        : Time Correction Factor, minutes
        - Longitude : Geographical Longitude, degrees
        - LSTM      : Local Standard Time Meridian, degrees * hours
        - EoT       : Equation of Time

            where:
            - `LSTM = 15 degrees * ΔTUTC`

                where:
                - ΔTUTC = LT - UTC, hours

                    where:
                    - ΔTUTC : difference of LT from UTC in hours
                    - LT    : Local Time
                    - UTC   : Universal Coordinated Time

            - The factor 4 (minutes) comes from the fact that the Earth
              rotates 1° every 4 minutes.

            Examples:

                Mount Olympus is UTC + 2, hence LSTM = 15 * 2 = 30 deg. East

    The time offset in minutes ranges in

        [ -720 (Longitude) - 720 (Up to -12 TimeZones) - 20 (Equation of Time) = -1460,
          +720 (Longitude) + 840 (Up to +14 TimeZones) + 20 (Equation of Time) = 1580 ]

    The valid ranges of the components that contribute to the time offset are:

    - Geographical longitude ranges from west to east in [-180, 180] degrees.
      A day is approximately 1440 minutes, hence converting the degrees to
      minutes, the longitude ranges in [-720, 720] minutes.

    - The timezone offset from the Coordinated Universal Time (UTC),
      considering time zones that are offset by unusual amounts of time from
      UTC, ranges  from west of UTC to east of UTC in [-12, 14] hours or [-720,
      840] minutes.

    - The Equation of Time accounts for the variations in the Earth's orbital
      speed and axial tilt. It varies throughout the year, but is typically
      within the range of about -20 minutes to +20 minutes.

    References
    ----------
    .. [0] https://gml.noaa.gov/grad/solcalc/solareqns.PDF

    """
    local_standard_time_meridian_minutes_series = 0  # in UTC the offest is 0
    if timezone and timezone != ZONEINFO_UTC:
        # We need the .tz attribute to compare with the user-requested timezone !
        if not timestamps.tz:
            timestamps = timestamps.tz_localize(timezone)

        # Explain why this is necessary !-------------- Further Optimisation ?
        unique_timezone_offsets_in_minutes = {
            stamp.tzinfo: stamp.tzinfo.utcoffset(stamp).total_seconds() / 60
            for stamp in timestamps if stamp.tzinfo is not None
        }
        local_standard_time_meridian_minutes_series = numpy.array(
            [
                unique_timezone_offsets_in_minutes[stamp.tzinfo]
                for stamp in timestamps
                if stamp.tzinfo is not None
            ],
            dtype=dtype,
        )
        # # ------------------------------------------- Further Optimisation ?

    equation_of_time_series = calculate_equation_of_time_series_noaa(
        timestamps=timestamps,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        validate_output=validate_output,
    )
    time_offset_series_in_minutes = (
        longitude.as_minutes
        - local_standard_time_meridian_minutes_series
        + equation_of_time_series.minutes
    )

    if validate_output:
        if not numpy.all(
            (TimeOffset().min_minutes <= time_offset_series_in_minutes)
            & (time_offset_series_in_minutes <= TimeOffset().max_minutes)
        ):
            index_of_out_of_range_values = np.where(
                (time_offset_series_in_minutes < TimeOffset().min_minutes)
                | (time_offset_series_in_minutes > TimeOffset().max_minutes)
            )
            out_of_range_values = time_offset_series_in_minutes[
                index_of_out_of_range_values
            ]
            raise ValueError(
                f"{WARNING_OUT_OF_RANGE_VALUES} "
                f"[{TimeOffset().min_minutes}, {TimeOffset().max_minutes}] minutes"
                f" in [code]time_offset_series_in_minutes[/code] : {out_of_range_values}"
            )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=time_offset_series_in_minutes,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return TimeOffset(
        value=time_offset_series_in_minutes,
        unit=MINUTES,
        solar_positioning_algorithm="NOAA",
        solar_timing_algorithm="NOAA",
    )

pelland

Modules:

Name Description
spectral_effect

spectral_effect

Functions:

Name Description
calculate_spectral_factor_pelland

Calculate the spectral factor for each PV technology based on

calculate_spectral_factor_pelland

calculate_spectral_factor_pelland(
    irradiance: DataArray,
    responsivity: DataFrame,
    reference_spectrum: DataFrame,
) -> DataFrame

Calculate the spectral factor for each PV technology based on Pelland 2022.

Notes

Some Python source code shared via personal communication.

Source code in pvgisprototype/algorithms/pelland/spectral_effect.py
@log_function_call
def calculate_spectral_factor_pelland(
    irradiance: DataArray,
    responsivity: DataFrame,
    reference_spectrum: DataFrame,
) -> DataFrame:
    """Calculate the spectral factor for each PV technology based on
    Pelland 2022.

    Notes
    -----
    Some Python source code shared via personal communication.

    """
    # Verify the reference spectrum is valid (no zero or near-zero values) ?
    # if (reference_spectrum <= 0).any():
    #     raise ValueError(
    #         "The reference spectrum contains zero or negative values, which are invalid."
    #     )
    # else:
    #     print(f'Reference spectrum input : {reference_spectrum}')

    # Align wavelengths (columns) between dataframes
    # if not isinstance(irradiance, DataFrame):
    #     irradiance = irradiance.to_dataframe()
    # wavelengths = irradiance.columns.intersection(responsivity.index).intersection(
    #     reference_spectrum.columns
    # )
    import numpy

    logger.debug(
        f"Wavelengths in `irradiance` input : {irradiance.center_wavelength.values}",
    )
    logger.debug(
        f"Wavelengths in `responsivity` input : {responsivity}",
    )
    logger.debug(
        f"Wavelengths in `reference_spectrum` input : {reference_spectrum.columns}"
    )

    # Common wavelength range
    common_wavelengths = numpy.intersect1d(
        irradiance.center_wavelength.values,
        numpy.intersect1d(responsivity.center_wavelength, reference_spectrum.columns),
    )
    logger.debug(
        f"Intersection of wavelengths across input data : {common_wavelengths}"
    )

    # in Pelland : useful reference spectrum > average over the reference spectrum
    responsivity_selected = responsivity.sel(center_wavelength=common_wavelengths)
    logger.debug(f"Selected responsivity data : {responsivity_selected}")

    reference_spectrum_selected = reference_spectrum.loc["global", common_wavelengths]
    logger.debug(f"Selected reference spectrum data : {reference_spectrum_selected}")

    # in Pelland 2022 : useful fraction of reference spectrum
    reference_current_density = (
        responsivity_selected * reference_spectrum_selected
    ).sum() / reference_spectrum_selected.sum()
    logger.debug(
        f"Reference Current Density : {reference_current_density}",
        alt=f"[bold][yellow]Reference[/yellow] current density[/bold] : {reference_current_density}",
    )

    # useful irradiance (time-varying)
    irradiance_selected = irradiance.sel(center_wavelength=common_wavelengths)
    logger.debug(f"Selected irradiance data : {irradiance_selected}")
    sum_of_responsivity_by_irradiance = (
        responsivity_selected * irradiance_selected
    ).sum(dim="center_wavelength")
    logger.debug(
        f"Sum of responsivity * irradiance : {sum_of_responsivity_by_irradiance}"
    )
    sum_of_irradiance = (irradiance_selected).sum(dim="center_wavelength")
    logger.debug(f"Sum of irradiance : {sum_of_irradiance}")

    observed_current_density = (responsivity_selected * irradiance_selected).sum(
        dim="center_wavelength"
    ) / irradiance_selected.sum(dim="center_wavelength")
    logger.debug(f"Current Density of Observed Irradiance : {observed_current_density}")

    spectral_factor = observed_current_density / reference_current_density
    logger.debug(f"Spectral factor = {spectral_factor}")

    components_container = {
        "Metadata": lambda: {},
        "Spectral factor": lambda: {
            TITLE_KEY_NAME: SPECTRAL_FACTOR_NAME,
            SPECTRAL_FACTOR_COLUMN_NAME: spectral_factor.to_numpy(),
        },  # if verbose > 0 else {},
        "Inputs": lambda: {
            "Irradiance": irradiance,
            "Responsivity": responsivity,
            "Reference spectrum": reference_spectrum,
        },
        "Intermediate quantities": lambda: {
            "Common spectral wavelengths": common_wavelengths,
            "Selected spectral responsivity": responsivity_selected,
            "Selected observed irradiance": irradiance_selected,
            "Selected reference spectrum": reference_spectrum_selected,
        },
        "Sum of quantities": lambda: {
            "Sum of Irradiance": sum_of_irradiance,
            "Sum of responsivity by irradiance": sum_of_responsivity_by_irradiance,
            "Sum of Reference spectrum": reference_spectrum.sum(),
        },
        # "Energy" : lambda: {
        #     'Reference energy': total_reference_energy,
        #     'Observed energy': total_observed_energy,
        #     },
        "Current density": lambda: {
            "Reference current": reference_current_density,
            "Observed current": observed_current_density,
        },
        # if verbose > 1
        # else {},
    }
    components = {}
    for _, component in components_container.items():
        components.update(component())

    return SpectralFactorSeries(
        value=spectral_factor.to_numpy(),
        unit=UNITLESS,
        spectral_factor_algorithm="Pelland 2022",
        components=components,
    )

pvlib

Modules:

Name Description
solar_altitude
solar_azimuth
solar_declination
solar_hour_angle
solar_incidence
solar_zenith

solar_altitude

Functions:

Name Description
calculate_solar_altitude_series_pvlib

Calculate the solar altitude (θ)

calculate_solar_altitude_series_pvlib

calculate_solar_altitude_series_pvlib(
    longitude: Longitude,
    latitude: Latitude,
    timestamps: DatetimeIndex,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarAltitude

Calculate the solar altitude (θ)

Source code in pvgisprototype/algorithms/pvlib/solar_altitude.py
@log_function_call
@custom_cached
# @validate_with_pydantic(CalculateSolarAltitudePVLIBInputModel)
def calculate_solar_altitude_series_pvlib(
    longitude: Longitude,  # degrees
    latitude: Latitude,  # degrees
    timestamps: DatetimeIndex,
    # timezone: ZoneInfo,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarAltitude:
    """Calculate the solar altitude (θ)"""
    solar_position = get_solarposition(
        timestamps, latitude.degrees, longitude.degrees
    )
    solar_altitude_series = solar_position["apparent_elevation"].values

    if not numpy.all(numpy.isfinite(solar_altitude_series)) or not numpy.all(
        (SolarAltitude().min_degrees <= solar_altitude_series)
        & (solar_altitude_series <= SolarAltitude().max_degrees)
    ):
        raise ValueError(
            f"Solar altitude values should be finite numbers and range in [{SolarAltitude().min_degrees}, {SolarAltitude().max_degrees}] degrees"
        )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_altitude_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )
    return SolarAltitude(
        value=solar_altitude_series,
        unit=DEGREES,
        solar_positioning_algorithm="pvlib",
        solar_timing_algorithm="pvlib",
    )

solar_azimuth

Functions:

Name Description
calculate_solar_azimuth_series_pvlib

Calculate the solar azimuth (θ)

calculate_solar_azimuth_series_pvlib

calculate_solar_azimuth_series_pvlib(
    longitude: Longitude,
    latitude: Latitude,
    timestamps: DatetimeIndex,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarAzimuth

Calculate the solar azimuth (θ)

Source code in pvgisprototype/algorithms/pvlib/solar_azimuth.py
@log_function_call
@custom_cached
# @validate_with_pydantic(CalculateSolarAzimuthPVLIBInputModel)
def calculate_solar_azimuth_series_pvlib(
    longitude: Longitude,  # degrees
    latitude: Latitude,  # degrees
    timestamps: DatetimeIndex,
    # timezone: ZoneInfo,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarAzimuth:
    """Calculate the solar azimuth (θ)"""
    solar_position = pvlib.solarposition.get_solarposition(
        timestamps, latitude.degrees, longitude.degrees
    )
    azimuth_origin = "North"
    solar_azimuth_series = solar_position["azimuth"].values

    if not numpy.all(numpy.isfinite(solar_azimuth_series)) or not numpy.all(
        (SolarAzimuth().min_degrees <= solar_azimuth_series)
        & (solar_azimuth_series <= SolarAzimuth().max_degrees)
    ):
        raise ValueError(
            f"Solar azimuth values should be finite numbers and range in [{SolarAzimuth().min_degrees}, {SolarAzimuth().max_degrees}] degrees"
        )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_azimuth_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return SolarAzimuth(
        value=solar_azimuth_series,
        unit=DEGREES,
        solar_positioning_algorithm="pvlib",
        solar_timing_algorithm="pvlib",
        origin=azimuth_origin,
    )

solar_declination

Functions:

Name Description
calculate_solar_declination_series_pvlib

Calculate the solar declination in radians

calculate_solar_declination_series_pvlib

calculate_solar_declination_series_pvlib(
    timestamps: DatetimeIndex,
) -> SolarDeclination

Calculate the solar declination in radians

Source code in pvgisprototype/algorithms/pvlib/solar_declination.py
@custom_cached
@validate_with_pydantic(CalculateSolarDeclinationSeriesPVLIBInput)
def calculate_solar_declination_series_pvlib(
    timestamps: DatetimeIndex,
) -> SolarDeclination:
    """Calculate the solar declination in radians"""
    days_of_year = timestamps.dayofyear
    solar_declination_series = pvlib.solarposition.declination_spencer71(days_of_year)
    # solar_declination = pvlib.solarposition.declination_spencer71(doy)
    # if (
    #         not isfinite(solar_declination.degrees)
    #         or not solar_declination.min_degrees <= solar_declination.degrees <= solar_declination.max_degrees
    # ):
    #         raise ValueError(
    #         f"The calculated solar declination angle {solar_declination.degrees} is out of the expected range\
    #         [{solar_declination.min_degrees}, {solar_declination.max_degrees}] degrees"
    #         )
    return SolarDeclination(
        value=solar_declination_series.values,
        unit=RADIANS,
        solar_positioning_algorithm="pvlib (Spencer, 1971)",
        solar_timing_algorithm="pvlib",
    )

solar_hour_angle

Functions:

Name Description
calculate_solar_hour_angle_series_pvlib

Calculate the solar hour angle in radians.

calculate_solar_hour_angle_series_pvlib

calculate_solar_hour_angle_series_pvlib(
    longitude: Longitude,
    timestamps: DatetimeIndex,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarHourAngle

Calculate the solar hour angle in radians.

Source code in pvgisprototype/algorithms/pvlib/solar_hour_angle.py
@custom_cached
@validate_with_pydantic(SolarHourAngleSeriesPVLIBInput)
def calculate_solar_hour_angle_series_pvlib(
    longitude: Longitude,
    timestamps: DatetimeIndex,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarHourAngle:
    """Calculate the solar hour angle in radians."""
    equation_of_time_series = pvlib.solarposition.equation_of_time_spencer71(
        timestamps.dayofyear
    )
    solar_hour_angle_series = pvlib.solarposition.hour_angle(
        timestamps, longitude.degrees, equation_of_time=equation_of_time_series
    ).to_numpy()
    if not numpy.all(
        (SolarHourAngle().min_degrees <= solar_hour_angle_series)
        & (solar_hour_angle_series <= SolarHourAngle().max_degrees)
    ):
        out_of_range_values = solar_hour_angle_series[
            ~(
                (-SolarHourAngle().min_degrees <= solar_hour_angle_series)
                & (solar_hour_angle_series <= SolarHourAngle().max_degrees)
            )
        ]
        raise ValueError(
            f"{WARNING_OUT_OF_RANGE_VALUES} "
            f"[{SolarHourAngle().min_degrees}, {SolarHourAngle().max_degrees}] degrees"
            f" in [code]solar_hour_angle_series[/code] : {numpy.degrees(out_of_range_values)}"
        )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_hour_angle_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )

    return SolarHourAngle(
        value=solar_hour_angle_series,
        unit=DEGREES,
        solar_positioning_algorithm=SolarPositionModel.pvlib,
        solar_timing_algorithm=SolarTimeModel.pvlib + " (Spencer 1971)",
    )

solar_incidence

Functions:

Name Description
calculate_solar_incidence_series_pvlib

Calculate the solar incidence (θ)

calculate_solar_incidence_series_pvlib

calculate_solar_incidence_series_pvlib(
    longitude: Longitude,
    latitude: Latitude,
    surface_orientation: SurfaceOrientation = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt = SURFACE_TILT_DEFAULT,
    timestamps: DatetimeIndex = now_utc_datetimezone(),
    complementary_incidence_angle: bool = COMPLEMENTARY_INCIDENCE_ANGLE_DEFAULT,
    zero_negative_solar_incidence_angle: bool = ZERO_NEGATIVE_INCIDENCE_ANGLE_DEFAULT,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarIncidence

Calculate the solar incidence (θ)

Source code in pvgisprototype/algorithms/pvlib/solar_incidence.py
@log_function_call
@custom_cached
# @validate_with_pydantic(CalculateSolarIncidencePVLIBInputModel)
def calculate_solar_incidence_series_pvlib(
    longitude: Longitude,
    latitude: Latitude,
    surface_orientation: SurfaceOrientation = SURFACE_ORIENTATION_DEFAULT,
    surface_tilt: SurfaceTilt = SURFACE_TILT_DEFAULT,
    timestamps: DatetimeIndex = now_utc_datetimezone(),
    # adjust_for_atmospheric_refraction: bool = ATMOSPHERIC_REFRACTION_FLAG_DEFAULT,
    complementary_incidence_angle: bool = COMPLEMENTARY_INCIDENCE_ANGLE_DEFAULT,
    zero_negative_solar_incidence_angle: bool = ZERO_NEGATIVE_INCIDENCE_ANGLE_DEFAULT,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarIncidence:
    """Calculate the solar incidence (θ)"""
    solar_position = pvlib.solarposition.get_solarposition(
        timestamps, latitude.degrees, longitude.degrees
    )
    solar_zenith_series = calculate_solar_zenith_series_pvlib(
        longitude=longitude,
        latitude=latitude,
        timestamps=timestamps,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
    )
    solar_azimuth_series = calculate_solar_azimuth_series_pvlib(
        longitude=longitude,
        latitude=latitude,
        timestamps=timestamps,
        dtype=dtype,
        array_backend=array_backend,
        verbose=verbose,
        log=log,
    )
    solar_incidence_series_in_degrees = pvlib.irradiance.aoi(
        surface_tilt=surface_tilt.degrees,
        surface_azimuth=surface_orientation.degrees,
        solar_zenith=solar_zenith_series.degrees,
        solar_azimuth=solar_azimuth_series.degrees,
    )
    # solar_incidence_series = solar_position["apparent_elevation"].values

    incidence_angle_definition = SolarIncidence().definition
    incidence_angle_description = SolarIncidence().description
    if complementary_incidence_angle:
        logger.debug(
            f":information: Converting solar incidence angle to {COMPLEMENTARY_INCIDENCE_ANGLE_DEFINITION}...",
            alt=f":information: [bold][magenta]Converting[/magenta] solar incidence angle to {COMPLEMENTARY_INCIDENCE_ANGLE_DEFINITION}[/bold]...",
        )
        solar_incidence_series_in_degrees = 90 - solar_incidence_series_in_degrees
        incidence_angle_definition = SolarIncidence().definition_complementary
        incidence_angle_description = SolarIncidence().description_complementary

    if zero_negative_solar_incidence_angle:
        # set negative or below horizon angles ( == solar zenith > 90 ) to 0 !
        solar_incidence_series_in_degrees[
            (solar_incidence_series_in_degrees < 0) | (solar_zenith_series.degrees > 90)
        ] = NO_SOLAR_INCIDENCE

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_incidence_series_in_degrees,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )
    from pvgisprototype.constants import DEGREES

    return SolarIncidence(
        value=solar_incidence_series_in_degrees,
        unit=DEGREES,
        solar_positioning_algorithm=solar_zenith_series.solar_solar_positioning_algorithm,
        solar_timing_algorithm=solar_zenith_series.solar_timing_algorithm,
        incidence_algorithm=SolarIncidenceModel.pvlib,
        definition=incidence_angle_definition,
        description=incidence_angle_description,
        azimuth_origin=solar_azimuth_series.origin,
    )

solar_zenith

Functions:

Name Description
calculate_solar_zenith_series_pvlib

Calculate the solar azimith (θ) in radians

calculate_solar_zenith_series_pvlib

calculate_solar_zenith_series_pvlib(
    longitude: Longitude,
    latitude: Latitude,
    timestamps: datetime,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarZenith

Calculate the solar azimith (θ) in radians

Source code in pvgisprototype/algorithms/pvlib/solar_zenith.py
@log_function_call
@custom_cached
# @validate_with_pydantic(CalculateSolarZenithPVLIBInputModel)
def calculate_solar_zenith_series_pvlib(
    longitude: Longitude,  # degrees
    latitude: Latitude,  # degrees
    timestamps: datetime,
    # timezone: ZoneInfo,
    dtype: str = DATA_TYPE_DEFAULT,
    array_backend: str = ARRAY_BACKEND_DEFAULT,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
    log: int = LOG_LEVEL_DEFAULT,
) -> SolarZenith:
    """Calculate the solar azimith (θ) in radians"""
    solar_position = pvlib.solarposition.get_solarposition(
        timestamps, latitude.degrees, longitude.degrees
    )
    solar_zenith_series = solar_position["zenith"].values

    if not numpy.all(numpy.isfinite(solar_zenith_series)) or not numpy.all(
        (SolarZenith().min_degrees <= solar_zenith_series)
        & (solar_zenith_series <= SolarZenith().max_degrees)
    ):
        raise ValueError(
            f"Solar zenith values should be finite numbers and range in [{SolarZenith().min_degrees}, {SolarZenith().max_degrees}] degrees"
        )

    if verbose > DEBUG_AFTER_THIS_VERBOSITY_LEVEL:
        debug(locals())

    log_data_fingerprint(
        data=solar_zenith_series,
        log_level=log,
        hash_after_this_verbosity_level=HASH_AFTER_THIS_VERBOSITY_LEVEL,
    )
    return SolarZenith(
        value=solar_zenith_series,
        unit=DEGREES,
        solar_positioning_algorithm="pvlib",
        solar_timing_algorithm="pvlib",
    )

skyfield

Modules:

Name Description
solar_geometry
solar_time
sunrise_and_sunset

solar_geometry

Functions:

Name Description
calculate_solar_altitude_azimuth_skyfield

Calculate sun position

calculate_solar_hour_angle_declination_skyfield

Calculate the hour angle ω'

calculate_solar_position_skyfield

Calculate sun position above the local horizon using Skyfield.

calculate_solar_altitude_azimuth_skyfield

calculate_solar_altitude_azimuth_skyfield(
    longitude: Longitude,
    latitude: Latitude,
    timestamp: Timestamp,
) -> Tuple[SolarAltitude, SolarAzimuth]

Calculate sun position

Source code in pvgisprototype/algorithms/skyfield/solar_geometry.py
@validate_with_pydantic(CalculateSolarAltitudeAzimuthSkyfieldInputModel)
def calculate_solar_altitude_azimuth_skyfield(
    longitude: Longitude,
    latitude: Latitude,
    timestamp: Timestamp,
) -> Tuple[SolarAltitude, SolarAzimuth]:
    """Calculate sun position"""
    solar_position = calculate_solar_position_skyfield(
        longitude=longitude,
        latitude=latitude,
        timestamp=timestamp,
    )
    solar_altitude, solar_azimuth, distance_to_sun = solar_position.altaz()
    solar_altitude = SolarAltitude(
        value=solar_altitude.radians,
        unit=RADIANS,
        solar_positioning_algorithm="Skyfield",
        solar_timing_algorithm="Skyfield",
    )
    solar_azimuth = SolarAzimuth(
        value=solar_azimuth.radians,
        unit=RADIANS,
        solar_positioning_algorithm="Skyfield",
        solar_timing_algorithm="Skyfield",
    )

    if (
        not isfinite(solar_azimuth.degrees)
        or not solar_azimuth.min_degrees
        <= solar_azimuth.degrees
        <= solar_azimuth.max_degrees
    ):
        raise ValueError(
            f"The calculated solar azimuth angle {solar_azimuth.degrees} is out of the expected range\
            [{solar_azimuth.min_degrees}, {solar_azimuth.max_degrees}] degrees"
        )

    if (
        not isfinite(solar_altitude.degrees)
        or not solar_altitude.min_degrees
        <= solar_altitude.degrees
        <= solar_altitude.max_degrees
    ):
        raise ValueError(
            f"The calculated solar altitude angle {solar_altitude.degrees} is out of the expected range\
            [{solar_altitude.min_degrees}, {solar_altitude.max_degrees}] degrees"
        )

    return solar_altitude, solar_azimuth  # , distance_to_sun

calculate_solar_hour_angle_declination_skyfield

calculate_solar_hour_angle_declination_skyfield(
    longitude: Longitude,
    latitude: Latitude,
    timestamp: Timestamp,
    timezone: str = None,
) -> Tuple[SolarHourAngle, SolarDeclination]

Calculate the hour angle ω'

Parameters:

Name Type Description Default
Returns
required
hour_angle

Hour angle is the angle (ω) at any instant through which the earth has to turn to bring the meridian of the observer directly in line with the sun's rays measured in radian.

required
Source code in pvgisprototype/algorithms/skyfield/solar_geometry.py
@validate_with_pydantic(SolarHourAngleSkyfieldInput)
def calculate_solar_hour_angle_declination_skyfield(
    longitude: Longitude,
    latitude: Latitude,
    timestamp: Timestamp,
    timezone: str = None,
) -> Tuple[SolarHourAngle, SolarDeclination]:
    """Calculate the hour angle ω'

    Parameters
    ----------


    Returns
    --------

    hour_angle: float
        Hour angle is the angle (ω) at any instant through which the earth has
        to turn to bring the meridian of the observer directly in line with the
        sun's rays measured in radian.
    """
    solar_position = calculate_solar_position_skyfield(
        longitude=longitude,
        latitude=latitude,
        timestamp=timestamp,
        timezone=timezone,
    )
    hour_angle, solar_declination, distance_to_sun = solar_position.hadec()

    hour_angle = SolarHourAngle(
        value=hour_angle.radians,
        unit=RADIANS,
        solar_positioning_algorithm="Skyfield",
        solar_timing_algorithm="Skyfield",
    )
    solar_declination = SolarDeclination(
        value=solar_declination.radians,
        unit=RADIANS,
        solar_positioning_algorithm="Skyfield",
        solar_timing_algorithm="Skyfield",
    )
    if (
        not isfinite(hour_angle.degrees)
        or not hour_angle.min_degrees <= hour_angle.degrees <= hour_angle.max_degrees
    ):
        raise ValueError(
            f"The calculated solar hour angle {hour_angle.degrees} is out of the expected range\
            [{hour_angle.min_degrees}, {hour_angle.max_degrees}] degrees"
        )
    if (
        not isfinite(solar_declination.degrees)
        or not solar_declination.min_degrees
        <= solar_declination.degrees
        <= solar_declination.max_degrees
    ):
        raise ValueError(
            f"The calculated solar declination angle {solar_declination.degrees} is out of the expected range\
            [{solar_declination.min_degrees}, {solar_declination.max_degrees}] degrees"
        )
    return hour_angle, solar_declination

calculate_solar_position_skyfield

calculate_solar_position_skyfield(
    longitude: Longitude,
    latitude: Latitude,
    timestamp: Timestamp,
)

Calculate sun position above the local horizon using Skyfield.

Returns:

Name Type Description
solar_altitude

Altitude measures the angle above or below the horizon. The zenith is at +90°, an object on the horizon’s great circle is at 0°, and the nadir beneath your feet is at −90°.

solar_azimuth

Azimuth measures the angle around the sky from the north pole: 0° means exactly north, 90° is east, 180° is south, and 270° is west.

Notes

For the implementation, see also: https://techoverflow.net/2022/06/19/how-to-compute-position-of-sun-in-the-sky-in-python-using-skyfield/

Factors influencing the accuracy of the calculation using Skyfield:

  • Skyfield considers:

    • The slight shift in position caused by light speed
    • The very very slight shift in position caused by earth’s gravity
  • Skyfield does not consider:

    • Atmospheric distortions shifting the sun’s position
    • The extent of the sun’s disk causing the sun to emanate not from a point but apparently from an area
Notes

To consider:

Source code in pvgisprototype/algorithms/skyfield/solar_geometry.py
@validate_with_pydantic(CalculateSolarPositionSkyfieldInputModel)
def calculate_solar_position_skyfield(
    longitude: Longitude,
    latitude: Latitude,
    timestamp: Timestamp,
):
    """Calculate sun position above the local horizon using Skyfield.

    Returns
    -------
    solar_altitude:
        Altitude measures the angle above or below the horizon. The
        zenith is at +90°, an object on the horizon’s great circle is at 0°,
        and the nadir beneath your feet is at −90°.

    solar_azimuth:
        Azimuth measures the angle around the sky from the north pole: 0° means
        exactly north, 90° is east, 180° is south, and 270° is west.

    Notes
    -----

    For the implementation, see also:
    https://techoverflow.net/2022/06/19/how-to-compute-position-of-sun-in-the-sky-in-python-using-skyfield/

    Factors influencing the accuracy of the calculation using Skyfield:

    - Skyfield considers:

        - The slight shift in position caused by light speed
        - The very very slight shift in position caused by earth’s gravity

    - Skyfield does not consider:

        - Atmospheric distortions shifting the sun’s position
        - The extent of the sun’s disk causing the sun to emanate not from a
          point but apparently from an area

    Notes
    -----

    To consider:

    - https://rhodesmill.org/skyfield/almanac.html#elevated-vantage-points
    - The `Time` class in Skyfield (i.e. `timescale()`) only uses UTC for input
      and output
    - https://rhodesmill.org/skyfield/time.html#utc-and-your-timezone
    - https://rhodesmill.org/skyfield/time.html#utc-and-leap-seconds
    """
    # # Handle Me during input validation? -------------------------------------
    # try:
    #     timestamp = timezone.localize(timestamp)
    # except Exception:
    #     logging.warning(f'tzinfo already set for timestamp = {timestamp}')
    # # Handle Me during input validation? -------------------------------------
    planets = load("de421.bsp")
    sun = planets["Sun"]
    earth = planets["Earth"]
    location = wgs84.latlon(latitude.degrees, longitude.degrees)
    timescale = load.timescale()
    requested_timestamp = timescale.from_datetime(timestamp)
    # sun position seen from observer location
    # sun position seen from observer location
    solar_position = (earth + location).at(requested_timestamp).observe(sun).apparent()

    return solar_position

solar_time

Functions:

Name Description
calculate_solar_time_skyfield

Calculate the solar time using Skyfield

calculate_solar_time_skyfield

calculate_solar_time_skyfield(
    longitude: Longitude,
    latitude: Latitude,
    timestamp: Timestamp,
    timezone: ZoneInfo,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
) -> Timestamp

Calculate the solar time using Skyfield

The position of the Sun in the sky changes slightly day to day due to the Earth's elliptical orbit and axis tilt around the Sun. In consequence, the length of each solar day varies slightly.

The mean solar time (as "opposed" to the apparent solar time which is measured with a sundial), averages out these variations to create a "mean" or average solar day of 24 hours long. This is the basis of our standard time system, although further adjusted to create the time zones.

A key concept in solar time is the solar noon, the moment when the Sun reaches its highest point in the sky each day. In apparent solar time, solar noon is the moment the Sun crosses the local meridian (an imaginary line that runs from North to South overhead).

Solar time can vary significantly from standard clock time, depending on the time of year and the observer's longitude within their time zone. For example, at the eastern edge of a time zone, the Sun may reach its highest point (solar noon) significantly earlier than noon according to standard clock time.

Source code in pvgisprototype/algorithms/skyfield/solar_time.py
@validate_with_pydantic(CalculateSolarTimeSkyfieldInputModel)
def calculate_solar_time_skyfield(
    longitude: Longitude,
    latitude: Latitude,
    timestamp: Timestamp,
    timezone: ZoneInfo,
    verbose: int = VERBOSE_LEVEL_DEFAULT,
) -> Timestamp:
    """Calculate the solar time using Skyfield

    The position of the Sun in the sky changes slightly day to day due to the
    Earth's elliptical orbit and axis tilt around the Sun. In consequence, the
    length of each solar day varies slightly.

    The mean solar time (as "opposed" to the apparent solar time which is
    measured with a sundial), averages out these variations to create a "mean"
    or average solar day of 24 hours long. This is the basis of our standard
    time system, although further adjusted to create the time zones.

    A key concept in solar time is the solar noon, the moment when the Sun
    reaches its highest point in the sky each day. In apparent solar time,
    solar noon is the moment the Sun crosses the local meridian (an imaginary
    line that runs from North to South overhead).

    Solar time can vary significantly from standard clock time, depending on
    the time of year and the observer's longitude within their time zone. For
    example, at the eastern edge of a time zone, the Sun may reach its highest
    point (solar noon) significantly earlier than noon according to standard
    clock time.
    """
    # Handle Me during input validation? -------------------------------------
    if timezone != timestamp.tzinfo:
        try:
            timestamp = timestamp.astimezone(timezone)
        except Exception as e:
            logger.warning(f"Error setting tzinfo for timestamp = {timestamp}: {e}")
    # Handle Me during input validation? -------------------------------------

    midnight = timestamp.replace(hour=0, minute=0, second=0, microsecond=0)
    next_midnight = midnight + Timedelta(days=1)
    timescale = load.timescale()
    midnight_time = timescale.from_datetime(midnight)
    next_midnight_time = timescale.from_datetime(next_midnight)

    planets = load("de421.bsp")
    sun = planets["Sun"]
    if longitude.degrees > 0:
        location = wgs84.latlon(latitude.degrees * N, longitude.degrees * E)
    if longitude.degrees < 0:
        location = wgs84.latlon(
            latitude.degrees * N, longitude.degrees * W
        )  # Correct ?
    if longitude.degrees > 0:
        location = wgs84.latlon(latitude.degrees * N, longitude.degrees * E)
    if longitude.degrees < 0:
        location = wgs84.latlon(
            latitude.degrees * N, longitude.degrees * W
        )  # Correct ?
    else:
        location = wgs84.latlon(latitude.degrees * N, longitude.degrees)  # Correct ?
        location = wgs84.latlon(latitude.degrees * N, longitude.degrees)  # Correct ?

    f = almanac.meridian_transits(planets, sun, location)

    times, events = almanac.find_discrete(midnight_time, next_midnight_time, f)
    times = times[events == 1]  # select transits instead of antitransits
    if not times:
        raise ValueError("No solar noon found in the given time range")

    next_solar_noon = times[0]  # first in `times` is the _next_ solar noon!
    previous_solar_noon = next_solar_noon - Timedelta(days=1)

    if timestamp < next_solar_noon.utc_datetime().replace(tzinfo=ZoneInfo("UTC")):
        # if morning : calculate hours until next solar noon
        hours_since_solar_noon = (
            timestamp
            - previous_solar_noon.utc_datetime().replace(tzinfo=ZoneInfo("UTC"))
        ).total_seconds() / 3600
    else:
        # if afternoon : calculate hours since last solar noon
        hours_since_solar_noon = (
            timestamp - next_solar_noon.utc_datetime().replace(tzinfo=ZoneInfo("UTC"))
        ).total_seconds() / 3600

    hours = int(hours_since_solar_noon)
    minutes = int((hours_since_solar_noon - hours) * 60)
    seconds = int(((hours_since_solar_noon - hours) * 60 - minutes) * 60)
    local_solar_time = Timestamp(  # NOTE gounaol: Maybe wrong implementation
        year=timestamp.year,
        month=timestamp.month,
        day=timestamp.day,
        hour=int(hours),
        minute=int(minutes),
        second=int(seconds),
        tzinfo=timestamp.tzinfo,
    )
    # local_solar_time = previous_solar_noon.utc_datetime() + Timedelta(hours=hours_since_solar_noon)

    if verbose:
        print(f"Local solar time: {local_solar_time}")

        previous_solar_noon_string = previous_solar_noon.astimezone(timezone).strftime(
            "%Y-%m-%d %H:%M:%S"
        )
        print(f"Previous solar noon: {previous_solar_noon_string}")

        next_solar_noon_string = next_solar_noon.astimezone(timezone).strftime(
            "%Y-%m-%d %H:%M:%S"
        )
        print(f"Next solar noon: {next_solar_noon_string}")

    return local_solar_time

sunrise_and_sunset

usno

Modules:

Name Description
local_hour_angle
sidereal_time
solar_altitude
solar_azimuth

local_hour_angle

Functions:

Name Description
calculate_local_hour_angle

Calculate the Local Hour Angle (local_hour_angle)

calculate_local_hour_angle

calculate_local_hour_angle(gast, alpha, longitude)

Calculate the Local Hour Angle (local_hour_angle)

Examples:

gast_array = np.array([15.0116663, 16.08011407]) alpha_array = np.array([14.261, 14.261]) # Example right ascension in hours longitude_array = np.array([77.5946, 77.5946]) # Example longitude in degrees (east) delta_array = np.array([23.44, 23.44]) # Example declination in degrees latitude_array = np.array([12.9714, 12.9714]) # Example latitude in degrees local_hour_angle_array = calculate_local_hour_angle(gast_array, alpha_array, longitude_array)

Source code in pvgisprototype/algorithms/usno/local_hour_angle.py
def calculate_local_hour_angle(gast, alpha, longitude):
    """Calculate the Local Hour Angle (local_hour_angle)

    Examples
    --------
    gast_array = np.array([15.0116663, 16.08011407])
    alpha_array = np.array([14.261, 14.261])  # Example right ascension in hours
    longitude_array = np.array([77.5946, 77.5946])  # Example longitude in degrees (east)
    delta_array = np.array([23.44, 23.44])  # Example declination in degrees
    latitude_array = np.array([12.9714, 12.9714])  # Example latitude in degrees
    local_hour_angle_array = calculate_local_hour_angle(gast_array, alpha_array, longitude_array)
    """
    return (gast - alpha) * 15 + longitude

sidereal_time

Functions:

Name Description
calculate_apparent_sidereal_time

Calculate the apparent sidereal time (GAST)

calculate_apparent_sidereal_time_time_series

Calculate the apparent sidereal time

calculate_eqeq

Compute the equation of the equinoxes (eqeq)

calculate_eqeq_time_series

Calculate the equation of the equinoxes (eqeq)

calculate_gast

Compute the Greenwich apparent sidereal time (GAST)

calculate_gast_time_series

Calculate the Greenwich apparent sidereal time (GAST)

calculate_gmst

Compute the Greenwich mean sidereal time (GMST)

calculate_gmst_time_series

Calculate the Greenwich mean sidereal time (GMST) using NumPy

calculate_local_sidereal_time

Compute the local mean or apparent sidereal time

calculate_local_sidereal_time_time_series

Calculate the local mean or apparent sidereal time

calculate_apparent_sidereal_time

calculate_apparent_sidereal_time(
    julian_date_universal_time,
    H,
    local_longitude_deg,
    use_precise_formula=True,
)

Calculate the apparent sidereal time (GAST)

Source code in pvgisprototype/algorithms/usno/sidereal_time.py
def calculate_apparent_sidereal_time(
    julian_date_universal_time, H, local_longitude_deg, use_precise_formula=True
):
    """Calculate the apparent sidereal time (GAST)"""
    GMST = calculate_GMST(julian_date_universal_time, H, use_precise_formula)
    DTT = julian_date_universal_time - 2451545.0
    eqeq = calculate_eqeq(DTT)
    GAST = calculate_GAST(GMST, eqeq)
    local_sidereal_time = calculate_local_sidereal_time(GAST, local_longitude_deg)
    return {
        "GMST": GMST,
        "eqeq": eqeq,
        "GAST": GAST,
        "local_sidereal_time": local_sidereal_time,
    }

calculate_apparent_sidereal_time_time_series

calculate_apparent_sidereal_time_time_series(
    julian_date_universal_time,
    H,
    local_longitude_deg,
    use_precise_formula=True,
)

Calculate the apparent sidereal time

Source code in pvgisprototype/algorithms/usno/sidereal_time.py
def calculate_apparent_sidereal_time_time_series(
    julian_date_universal_time, H, local_longitude_deg, use_precise_formula=True
):
    """Calculate the apparent sidereal time"""
    GMST = calculate_GMST_time_series(
        julian_date_universal_time, H, use_precise_formula
    )
    DTT = julian_date_universal_time - 2451545.0
    eqeq = calculate_eqeq_time_series(DTT)
    GAST = calculate_GAST_time_series(GMST, eqeq)
    local_sidereal_time = calculate_local_sidereal_time_time_series(
        GAST, local_longitude_deg
    )
    return {
        "GMST": GMST,
        "eqeq": eqeq,
        "GAST": GAST,
        "local_sidereal_time": local_sidereal_time,
    }

calculate_eqeq

calculate_eqeq(DTT)

Compute the equation of the equinoxes (eqeq)

Source code in pvgisprototype/algorithms/usno/sidereal_time.py
def calculate_eqeq(DTT):
    """Compute the equation of the equinoxes (eqeq)"""
    Omega = 125.04 - 0.052954 * DTT
    L = 280.47 + 0.98565 * DTT
    epsilon = 23.4393 - 0.0000004 * DTT
    delta_psi = -0.000319 * sin(radians(Omega)) - 0.000024 * sin(radians(2 * L))
    eqeq = delta_psi * cos(radians(epsilon))
    return eqeq

calculate_eqeq_time_series

calculate_eqeq_time_series(DTT)

Calculate the equation of the equinoxes (eqeq)

Source code in pvgisprototype/algorithms/usno/sidereal_time.py
def calculate_eqeq_time_series(DTT):
    """Calculate the equation of the equinoxes (eqeq)"""
    Omega = 125.04 - 0.052954 * DTT
    L = 280.47 + 0.98565 * DTT
    epsilon = 23.4393 - 0.0000004 * DTT
    delta_psi = -0.000319 * np.sin(np.radians(Omega)) - 0.000024 * np.sin(
        np.radians(2 * L)
    )
    eqeq = delta_psi * np.cos(np.radians(epsilon))
    return eqeq

calculate_gast

calculate_gast(GMST, eqeq)

Compute the Greenwich apparent sidereal time (GAST)

Source code in pvgisprototype/algorithms/usno/sidereal_time.py
def calculate_gast(GMST, eqeq):
    """Compute the Greenwich apparent sidereal time (GAST)"""
    return fmod(GMST + eqeq, 24)

calculate_gast_time_series

calculate_gast_time_series(GMST, eqeq)

Calculate the Greenwich apparent sidereal time (GAST)

Source code in pvgisprototype/algorithms/usno/sidereal_time.py
def calculate_gast_time_series(GMST, eqeq):
    """Calculate the Greenwich apparent sidereal time (GAST)"""
    return np.mod(GMST + eqeq, 24)

calculate_gmst

calculate_gmst(
    julian_date_universal_time, H, use_precise_formula=True
)

Compute the Greenwich mean sidereal time (GMST)

Source code in pvgisprototype/algorithms/usno/sidereal_time.py
def calculate_gmst(julian_date_universal_time, H, use_precise_formula=True):
    """Compute the Greenwich mean sidereal time (GMST)"""
    difference_universal_time_minus_20000101120000 = (
        julian_date_universal_time - 2451545.0
    )
    T = difference_universal_time_minus_20000101120000 / 36525.0
    if use_precise_formula:
        GMST = fmod(
            6.697375
            + 0.065709824279 * difference_universal_time_minus_20000101120000
            + 1.0027379 * H
            + 0.0000258 * T**2,
            24,
        )
    else:
        GMST = fmod(
            18.697375
            + 24.065709824279 * difference_universal_time_minus_20000101120000,
            24,
        )
    return GMST

calculate_gmst_time_series

calculate_gmst_time_series(
    julian_date_universal_time, H, use_precise_formula=True
)

Calculate the Greenwich mean sidereal time (GMST) using NumPy

Source code in pvgisprototype/algorithms/usno/sidereal_time.py
def calculate_gmst_time_series(julian_date_universal_time, H, use_precise_formula=True):
    """Calculate the Greenwich mean sidereal time (GMST) using NumPy"""
    difference_universal_time_minus_20000101120000 = (
        julian_date_universal_time - 2451545.0
    )
    centuries_since_2000 = difference_universal_time_minus_20000101120000 / 36525.0
    if use_precise_formula:
        GMST = np.mod(
            6.697375
            + 0.065709824279 * difference_universal_time_minus_20000101120000
            + 1.0027379 * H
            + 0.0000258 * centuries_since_2000**2,
            24,
        )
    else:
        GMST = np.mod(
            18.697375
            + 24.065709824279 * difference_universal_time_minus_20000101120000,
            24,
        )
    return GMST

calculate_local_sidereal_time

calculate_local_sidereal_time(GAST, local_longitude_deg)

Compute the local mean or apparent sidereal time

Source code in pvgisprototype/algorithms/usno/sidereal_time.py
def calculate_local_sidereal_time(GAST, local_longitude_deg):
    """Compute the local mean or apparent sidereal time"""
    local_longitude_hours = local_longitude_deg / 15.0
    return fmod(GAST + local_longitude_hours, 24)

calculate_local_sidereal_time_time_series

calculate_local_sidereal_time_time_series(
    GAST, local_longitude_deg
)

Calculate the local mean or apparent sidereal time

Source code in pvgisprototype/algorithms/usno/sidereal_time.py
def calculate_local_sidereal_time_time_series(GAST, local_longitude_deg):
    """Calculate the local mean or apparent sidereal time"""
    local_longitude_hours = local_longitude_deg / 15.0
    return np.mod(GAST + local_longitude_hours, 24)

solar_altitude

Functions:

Name Description
compute_altitude

Function to calculate the Altitude (a)

compute_altitude

compute_altitude(LHA, delta, latitude)

Function to calculate the Altitude (a)

Examples:

GAST_array = np.array([15.0116663, 16.08011407]) alpha_array = np.array([14.261, 14.261]) # Example right ascension in hours longitude_array = np.array([77.5946, 77.5946]) # Example longitude in degrees (east) delta_array = np.array([23.44, 23.44]) # Example declination in degrees latitude_array = np.array([12.9714, 12.9714]) # Example latitude in degrees LHA_array = compute_LHA(GAST_array, alpha_array, longitude_array) azimuth_array = compute_azimuth(LHA_array, delta_array, latitude_array)

Source code in pvgisprototype/algorithms/usno/solar_altitude.py
def compute_altitude(LHA, delta, latitude):
    """Function to calculate the Altitude (a)

    Examples
    --------
    GAST_array = np.array([15.0116663, 16.08011407])
    alpha_array = np.array([14.261, 14.261])  # Example right ascension in hours
    longitude_array = np.array([77.5946, 77.5946])  # Example longitude in degrees (east)
    delta_array = np.array([23.44, 23.44])  # Example declination in degrees
    latitude_array = np.array([12.9714, 12.9714])  # Example latitude in degrees
    LHA_array = compute_LHA(GAST_array, alpha_array, longitude_array)
    azimuth_array = compute_azimuth(LHA_array, delta_array, latitude_array)
    """
    LHA_rad = np.radians(LHA)
    delta_rad = np.radians(delta)
    latitude_rad = np.radians(latitude)
    sin_altitude = np.cos(LHA_rad) * np.cos(delta_rad) * np.cos(latitude_rad) + np.sin(
        delta_rad
    ) * np.sin(latitude_rad)
    return np.degrees(np.arcsin(sin_altitude))

solar_azimuth

Functions:

Name Description
calculate_azimuth

Calculate the Azimuth (A)

calculate_azimuth

calculate_azimuth(local_hour_angle, delta, latitude)

Calculate the Azimuth (A)

Examples:

azimuth_array = calculate_azimuth(local_hour_angle_array, delta_array, latitude_array)

Source code in pvgisprototype/algorithms/usno/solar_azimuth.py
def calculate_azimuth(local_hour_angle, delta, latitude):
    """Calculate the Azimuth (A)

    Examples
    --------
    azimuth_array = calculate_azimuth(local_hour_angle_array, delta_array, latitude_array)
    """
    local_hour_angle_rad = np.radians(local_hour_angle)
    delta_rad = np.radians(delta)
    latitude_rad = np.radians(latitude)
    tan_azimuth = -np.sin(local_hour_angle_rad) / (
        np.tan(delta_rad) * np.cos(latitude_rad)
        - np.sin(latitude_rad) * np.cos(local_hour_angle_rad)
    )
    azimuth = np.degrees(
        np.arctan2(
            -np.sin(local_hour_angle_rad),
            np.tan(delta_rad) * np.cos(latitude_rad)
            - np.sin(latitude_rad) * np.cos(local_hour_angle_rad),
        )
    )

    return np.mod(azimuth + 360, 360)  # Normalize to [0, 360)