pvlib-python icon indicating copy to clipboard operation
pvlib-python copied to clipboard

Function to output exact time, in seconds or microseconds or nanoseconds if possible, of sun reaching specific elevation.

Open ghost opened this issue 1 year ago • 3 comments

Pvlib team, thank you for your amazing work.

First of all, I'm not a developer, I code as a hobby.

Regarding my request, I wanted to implement a function to calculate the time when the sun reaches a certain elevation, I used iterations to do so, but it takes a lot of time to output the time, and even then, the time it outputs is not accurate, it is close to the exact time, but still.

It would be amazing if there were a function that could do this directly without taking up so much time.

My code:

import datetime
import pvlib
import pytz
import requests
response = requests.get('https://ipinfo.io')
data = response.json()
lat, long = map(float, data['loc'].split(','))
timezone_str = data['timezone']
timezone_object = pytz.timezone(timezone_str)
current_time = datetime.datetime.now(timezone_object)
current_time = current_time.replace(hour=0, minute=0, second=0, microsecond=0)
angle = int(input('angle: '))
for h in range(24):
    for m in range(60):
        current_time = current_time.replace(hour=h, minute=m)
        solpos = pvlib.solarposition.get_solarposition(current_time, lat, long)
        elev = solpos.elevation.values[0]
        if abs(angle-elev) < 0.1:
            print(elev, '\t', current_time)

ghost avatar Feb 25 '24 12:02 ghost

A solar position algorithm does this: solar azimuth, solar elevation = function (datetime given (longitude, latitude))

You want the inverse: datetime = function (elevation given (longitude, latitude))

This thread concludes that the solar azimuth would need to be specified somehow. If it is assumed that the ordinal date is known, then it seems possible to arrive at an inverse that would give both times in that date, since the ordinal date corresponds to a specific sun path from the earth's viewpoint.

cwhanse avatar Feb 25 '24 15:02 cwhanse

I think you will get significantly faster results using SciPy Optimize instead of brute force. For example, minimize_scalar. I think you have the following challenges to consider:

  1. You need to convert from datetimes to something continuously variable like seconds since the common epoch (aka UNIX time).
  2. Everyday will have two different times when the elevation is reached, once in the morning and again in the evening.
  3. There are days when the desired elevation may never be reached.

Here's some code that covers the entire year in about 1 minute:

from calendar import timegm
from datetime import datetime, timedelta
from time import gmtime
import pandas as pd
import pytz
from pvlib.solarposition import get_solarposition
import numpy as np
from scipy.optimize import minimize_scalar, root_scalar
import logging
logging.basicConfig()
LOGGER = logging.getLogger()
LOGGER.setLevel(logging.DEBUG)


def sp_from_epoch(epochtime, lat, lon, tz):
    time = pd.Timestamp(*gmtime(epochtime)[:6], tz=tz)
    times = pd.DatetimeIndex([time])  # pvlib expects an array
    return get_solarposition(times, lat, lon)


def objective(epochtime, lat, lon, tz, elev):
    df = sp_from_epoch(epochtime, lat, lon, tz)
    #LOGGER.debug(df)
    residual = df.apparent_elevation.values[0] - elev
    return residual*residual


# CONSTANTS
SP_NAT = {
    'apparent_zenith': np.nan,
    'zenith': np.nan,
    'apparent_elevation': np.nan,
    'elevation': np.nan,
    'azimuth': np.nan,
    'equation_of_time': np.nan}
SP_NAT = pd.DataFrame(SP_NAT, index=pd.DatetimeIndex(['NaT']))
NDAYS = 365
TOL = 1.0  # return NaN if resnorm greater than TOL

def get_elevation_time(day_of_year, latitude, longitude, timezone, elevation, year):
    """
    Get time when solar positon is at specified elevation.

    Parameters
    ----------
    day_of_year : int
        day of year starting at zero for JAN1
    latitude : float
        latitude in degrees, measured from the equator, positive is north
    longitude : float
        longitude in degrees, measured from prime meridian, positive is east
    timezone : pytz.timezone
        Hint: Use 'Etc/GMT+X' format where -X is hours from GMT
    elevation : float
        specify desired elevation
    year : int
        IDK what will happen during leap year

    Returns
    -------
    Dictionary of dataframes of solar positions corresponding to desired apparent
    elevation for morning and evening.
    """
    args = (latitude, longitude, timezone, elevation)
    delta = datetime(year, 1, 1) + timedelta(days=day_of_year)
    # morning
    a = timegm(delta.timetuple())
    b = timegm((delta + timedelta(hours=12)).timetuple())
    #LOGGER.debug(a)
    #LOGGER.debug(b)
    res = minimize_scalar(objective, args=args, bounds=(a, b))
    if res.fun < TOL:
        morning = sp_from_epoch(res.x, *args[:3])
    else:
        morning = SP_NAT
    # evening
    c = timegm((delta + timedelta(hours=24)).timetuple())
    res = minimize_scalar(objective, args=args, bounds=(b, c))
    if res.fun < TOL:
        evening = sp_from_epoch(res.x, *args[:3])
    else:
        evening = SP_NAT
    return {'morning': morning, 'evening': evening}


def get_elevation_times(latitude, longitude, timezone, elevation, year):
    """
    Get times for a full year when solar positon is at specified elevation.

    Parameters
    ----------
    latitude : float
        latitude in degrees, measured from the equator, positive is north
    longitude : float
        longitude in degrees, measured from prime meridian, positive is east
    timezone : pytz.timezone
        Hint: Use 'Etc/GMT+X' format where -X is hours from GMT
    elevation : float
        specify desired elevation
    year : int
        IDK what will happen during leap year

    Returns
    -------
    Dictionary of dataframes of solar positions corresponding to desired apparent
    elevation for mornings and evenings.
    """
    mornings = []
    evenings = []
    args = (latitude, longitude, timezone, elevation, year)
    for d in range(NDAYS):
        res = get_elevation_time(d, *args)
        mornings.append(res['morning'])
        evenings.append(res['evening'])
    mornings = pd.concat(mornings)
    evenings = pd.concat(evenings)
    return {'mornings': mornings, 'evenings': evenings}

Here's a test example:

# test drive
LAT = 38.5
LON = -122.0
TZ = pytz.timezone('Etc/GMT+8')
ELEV = 15  # arcdegrees
YEAR = 2023

res = get_elevation_times(LAT, LON, TZ, ELEV, YEAR)
res['mornings']
res['evenings']

The test output for mornings: image and for evenings: image I've attached the jupyter notebook and html version in a zip file pvlib_gh1981.zip using the following packages:

  • pvlib: 0.9.2
  • pandas: 2.0.3
  • numpy: 1.24.4
  • scipy: 1.11.1
  • python: 3.11.4 | packaged by conda-forge | (main, Jun 10 2023, 18:08:17) [GCC 12.2.0]

I think you can increase the speed even more if you vectorize, perhaps using root_scalar and the Newton method, but IDK.

mikofski avatar Feb 26 '24 02:02 mikofski

Note that pvlib has a function that uses scipy.optimize.brentq with pyephem: pvlib.solarposition.calc_time.

kandersolar avatar Feb 26 '24 13:02 kandersolar