Skip to content

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

algorithms

Modules:

Name Description
degree_days
hargreaves
jenco
milne1921
noaa
pvgis
pvlib
skyfield
usno

solar_declination

Functions:

Name Description
calculate_solar_declination_hargreaves

Approximate the solar declination based on the Hargreaves formula.

calculate_solar_declination_hargreaves

calculate_solar_declination_hargreaves(
    timestamp: Timestamp = partial(now(tz=TIMEZONE_UTC)),
) -> SolarDeclination

Approximate the solar declination based on the Hargreaves formula.

             ⎛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 pvgis/algorithms/hargreaves/solar_declination.py
@validate_with_pydantic(CalculateSolarDeclinationHargreavesInputModel)
def calculate_solar_declination_hargreaves(
    timestamp: Timestamp = partial(Timestamp.now(tz=TIMEZONE_UTC)),
) -> SolarDeclination:
    """Approximate the solar declination based on the Hargreaves formula.

                     ⎛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.
    """
    days_in_year = get_days_in_year(timestamp.year)
    day_of_year = timestamp.timetuple().tm_yday
    declination_value_in_degrees = 23.45 * sin(
        radians(
            360
            / days_in_year
            * (
                284
                + day_of_year
                + 0.4 * sin(radians(360 / days_in_year * (day_of_year - 100)))
            )
        )
    )
    solar_declination = SolarDeclination(
        value=declination_value_in_degrees,
        unit=DEGREES,
        position_algorithm="Hargreaves",
        timing_algorithm="Hargreaves",
    )
    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 solar_declination

jenco

Modules:

Name Description
solar_altitude
solar_azimuth
solar_declination
solar_incidence

milne1921

Modules:

Name Description
solar_time

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.

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.

function_models

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 pvgis/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,
        position_algorithm=SolarPositionModel.noaa,
    )

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 pvgis/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,
    )

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 pvgis/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,
        position_algorithm="NOAA",
        timing_algorithm="NOAA",
    )

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 pvgis/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,
    )

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,
    refracted_solar_zenith: RefractedSolarZenith,
    apply_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:

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
refracted_solar_zenith RefractedSolarZenith

Refracted solar zenith angle in radians.

required
apply_atmospheric_refraction bool

Whether to apply atmospheric refraction corrections (default is False).

False
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 pvgis/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,
    refracted_solar_zenith: RefractedSolarZenith,  # radians
    apply_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.
    refracted_solar_zenith : RefractedSolarZenith
        Refracted solar zenith angle in radians.
    apply_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,
        event="noon",
        refracted_solar_zenith=refracted_solar_zenith,
        apply_atmospheric_refraction=apply_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

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.

Returns:

Type Description
SolarHourAngle

Time series of solar hour angles in radians.

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 pvgis/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.

    Returns
    -------
    SolarHourAngle
        Time series of solar hour angles in radians.

    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,
        position_algorithm=SolarPositionModel.noaa,
        timing_algorithm=true_solar_time_series.timing_algorithm,
    )

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,
    refracted_solar_zenith: RefractedSolarZenith,
    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

Geographic latitude for the calculation.

required
timestamps DatetimeIndex

Timestamps aligned with refracted_solar_zenith.

required
refracted_solar_zenith RefractedSolarZenith

Refracted solar zenith series (radians). NOAA uses ~90.833° at sunrise/set including refraction and solar disk size.

required
dtype str

Array dtype.

DATA_TYPE_DEFAULT
array_backend str

Array backend identifier.

ARRAY_BACKEND_DEFAULT
verbose int

Verbosity level.

VERBOSE_LEVEL_DEFAULT
log int

Logging level.

LOG_LEVEL_DEFAULT

Returns:

Type Description
EventHourAngle

Event hour angle time series in radians.

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 pvgis/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,
    refracted_solar_zenith: RefractedSolarZenith,
    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
        Geographic latitude for the calculation.
    timestamps : DatetimeIndex
        Timestamps aligned with ``refracted_solar_zenith``.
    refracted_solar_zenith : RefractedSolarZenith
        Refracted solar zenith series (radians). NOAA uses ~90.833° at sunrise/set
        including refraction and solar disk size.
    dtype : str, optional
        Array dtype.
    array_backend : str, optional
        Array backend identifier.
    verbose : int, optional
        Verbosity level.
    log : int, optional
        Logging level.

    Returns
    -------
    EventHourAngle
        Event hour angle time series in radians.

    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(refracted_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, dtype=dtype)
    )  # 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 pvgis/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.
    """
    # Use float64 for intermediate arithmetic to reduce timestamp drift while
    # preserving public model dtype behavior elsewhere.
    equation_of_time_minutes = numpy.asarray(
        equation_of_time.minutes, dtype=numpy.float64
    )
    longitude_minutes = numpy.asarray(longitude.as_minutes, dtype=numpy.float64)
    event_hour_angle_minutes = numpy.asarray(
        event_hour_angle.as_minutes, dtype=numpy.float64
    )
    base = 720.0 - equation_of_time_minutes + float(timezone_offset_hours_utc) * 60.0

    # 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}")

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],
    refracted_solar_zenith: RefractedSolarZenith = REFRACTED_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
refracted_solar_zenith float

The refracted solar zenith angle in radians. Default is the cosine of 90.833 degrees.

REFRACTED_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 pvgis/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],
    refracted_solar_zenith: RefractedSolarZenith = REFRACTED_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
    ----------
    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.
    refracted_solar_zenith : float, optional
        The refracted solar zenith angle in radians. Default is the cosine of 90.833 degrees.
    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 == [None]:
        return EventTime(
            value=None,
            event=None,
        )

    # Keep one representative timestamp per day while preserving its original
    # time-of-day. Using normalized midnight timestamps can shift declination
    # and equation-of-time enough to miss strict second-level test tolerances.
    unique_days = DatetimeIndex(
        timestamps.to_series(index=timestamps)
        .groupby(timestamps.normalize())
        .first()
        .to_numpy()
    )
    event_hour_angle_series = calculate_event_hour_angle_series_noaa(
        latitude=latitude,
        timestamps=unique_days,
        refracted_solar_zenith=refracted_solar_zenith,
        dtype=dtype,
    )
    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 pvgis.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=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 pvgis/algorithms/noaa/event_time.py
def match_event_times_to_timestamps(
    solar_event_series: dict,
    timestamps: DatetimeIndex,
    array_backend: str,
):
    """ """
    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")

        # Assign event to nearest timestamp index.
        for idx, event_time in zip(event_indexer, event_times):
            if idx >= 0:  # Valid index
                event_timestamps[idx] = event_time.to_numpy()
                event_types[idx] = (
                    event_type.name if hasattr(event_type, "name") else str(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/

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 pvgis/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)
    )

    if validate_output:
        if not np.all(
            (SolarDeclination().min_radians <= solar_declination_series)
            & (solar_declination_series <= SolarDeclination().max_radians)
        ):
            index_of_out_of_range_values = np.where(
                (solar_declination_series < SolarDeclination().min_radians)
                | (solar_declination_series > SolarDeclination().max_radians)
            )
            out_of_range_values = solar_declination_series[index_of_out_of_range_values]
            raise ValueError(
                f"{WARNING_OUT_OF_RANGE_VALUES} "
                f"[{SolarDeclination().min_degrees}, {SolarDeclination().max_degrees}] degrees"
                f" in [code]solar_declination_series[/code] : {np.degrees(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,
        position_algorithm=fractional_year_series.position_algorithm,
    )

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 pvgis/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,
        position_algorithm=solar_zenith_series.position_algorithm,
        timing_algorithm=solar_zenith_series.timing_algorithm,
    )

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 pvgis/algorithms/noaa/solar_zenith.py
def atmospheric_refraction_adjustment(
    solar_altitude: np.ndarray, dtype: str = "float64"  # radians
) -> 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,
    apply_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 pvgis/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,
    apply_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
    )
    # NOTE Ensure values are within [1, 1]
    cosine_solar_zenith = np.clip(cosine_solar_zenith, -1, 1)
    solar_zenith_series = SolarZenith(
        value=np.arccos(cosine_solar_zenith),  # Important !
        unit=RADIANS,
        position_algorithm=solar_declination_series.position_algorithm,
        timing_algorithm=solar_hour_angle_series.timing_algorithm,
    )
    if apply_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"
            )

    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  # This is a SolarZenith data class !

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,
    apply_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 pvgis/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,
    apply_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,
        apply_atmospheric_refraction=apply_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

    if validate_output:
        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,
        position_algorithm=solar_zenith_series.position_algorithm,
        timing_algorithm=solar_zenith_series.timing_algorithm,
    )

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,
    apply_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
apply_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], pysolar [3], Skyfield [4]_ and suncalc [5]_, 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/pingswept/pysolar

.. [4] https://github.com/skyfielders/python-skyfield/

.. [5] https://github.com/kylebarron/suncalc-py

Examples:

>>> from math import radians
>>> from pvgis.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 pvgis.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"),
... apply_atmospheric_refraction=True
... )
>>> print(solar_azimuth_series)
    value=array([0.20324741, 0.681624  , 1.0347459 , 1.295689  , 1.505737  ,
           1.691547  , 1.8700209 , 2.06125   , 2.250216  , 2.466558  ,
           2.708641  , 2.9751856 , 3.2346206 , 3.50884   , 3.7576592 ,
           3.9807858 , 4.1786404 , 4.3685203 , 4.547683  , 4.73073   ,
           4.933111  , 5.178298  , 5.5035386 , 5.9491134 , 0.19983122],
          dtype=float32) unit='radians' position_algorithm='NOAA' timing_algorithm='NOAA' min_radians=0 max_radians=6.283185307179586 min_degrees=0 max_degrees=360
>>> print(solar_azimuth_series.degrees)
    [ 11.645218  39.054176  59.28657   74.2375    86.27237   96.9185
     107.144295 118.100914 128.92787  141.32335  155.1937   170.46558
     185.3301   201.04172  215.298    228.08221  239.41844  250.29776
     260.56302  271.05084  282.64642  296.6946   315.32953  340.85907
      11.449485]
Source code in pvgis/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,
    apply_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.
    apply_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]_,
        pysolar [3]_, Skyfield [4]_ and suncalc [5]_, 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/pingswept/pysolar

    .. [4] https://github.com/skyfielders/python-skyfield/

    .. [5] https://github.com/kylebarron/suncalc-py

    Examples
    --------
    >>> from math import radians
    >>> from pvgis.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 pvgis.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"),
    ... apply_atmospheric_refraction=True
    ... )
    >>> print(solar_azimuth_series)
        value=array([0.20324741, 0.681624  , 1.0347459 , 1.295689  , 1.505737  ,
               1.691547  , 1.8700209 , 2.06125   , 2.250216  , 2.466558  ,
               2.708641  , 2.9751856 , 3.2346206 , 3.50884   , 3.7576592 ,
               3.9807858 , 4.1786404 , 4.3685203 , 4.547683  , 4.73073   ,
               4.933111  , 5.178298  , 5.5035386 , 5.9491134 , 0.19983122],
              dtype=float32) unit='radians' position_algorithm='NOAA' timing_algorithm='NOAA' min_radians=0 max_radians=6.283185307179586 min_degrees=0 max_degrees=360

    >>> print(solar_azimuth_series.degrees)
        [ 11.645218  39.054176  59.28657   74.2375    86.27237   96.9185
         107.144295 118.100914 128.92787  141.32335  155.1937   170.46558
         185.3301   201.04172  215.298    228.08221  239.41844  250.29776
         260.56302  271.05084  282.64642  296.6946   315.32953  340.85907
          11.449485]

    """
    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,
        apply_atmospheric_refraction=apply_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,
        position_algorithm=SolarPositionModel.noaa,
        timing_algorithm=SolarTimeModel.noaa,
        origin="North",
    )

pvgis

Modules:

Name Description
average_photon_energy
direct
extraterrestrial
fractional_year
ground_reflected
power
read
shading
solar_altitude
solar_azimuth
solar_declination
solar_hour_angle
solar_incidence
spectral_factor