skyfielders / python-skyfield

Elegant astronomy for Python
MIT License
1.41k stars 211 forks source link

How to compute solar terms #273

Closed brunobord closed 5 years ago

brunobord commented 5 years ago

The library workalendar is using (py)ephem to compute solar terms[^1], used in Hong Kong, Taiwan and potentially other calendars. The code used to calculate the Solar term was based on https://answers.launchpad.net/pyephem/+question/110832

For the record, here it is:

def solar_term(year, degrees, timezone='UTC'):
    """
    Returns the date of the solar term for the given longitude
    and the given year.

    Solar terms are used for Chinese and Taiwanese holidays
    (e.g. Qingming Festival in Taiwan).

    More information:
    - https://en.wikipedia.org/wiki/Solar_term
    - https://en.wikipedia.org/wiki/Qingming

    This function is adapted from the following topic:
    https://answers.launchpad.net/pyephem/+question/110832
    """
    twopi = 2 * pi
    tz = pytz.timezone(timezone)

    # Find out the sun's current longitude.
    sun = ephem.Sun(ephem.Date(str(year)))
    # > Both hlon and hlat have a special meaning for the Sun and Moon.
    # > For a Sun body, they give the Earth's heliocentric longitude and
    # > latitude.
    current_longitude = sun.hlon - pi

    # Find approximately the right time of year.

    target_longitude = degrees * ephem.degree
    difference = (target_longitude - current_longitude) % twopi
    t0 = ephem.Date(str(year)) + 365.25 * difference / twopi

    # Zero in on the exact moment.

    def f(t):
        sun.compute(t)
        longitude = sun.hlon - pi
        return ephem.degrees(target_longitude - longitude).znorm

    d = ephem.Date(ephem.newton(f, t0, t0 + ephem.minute))
    solar_term = d.datetime() + tz.utcoffset(d.datetime())

    return solar_term.date()

Unfortunately, I've tried to convert this function using Skyfield and as I felt it was almost ready, it started to fall apart.

Here are some example test functions that the new function should be able to pass:

(please note that the "ephem" function returns a 10/10 card)

def test_qingming_festivals(solar_term):
    assert solar_term(2001, 15) == date(2001, 4, 4)
    assert solar_term(2001, 15, 'Asia/Taipei') == date(2001, 4, 5)
    assert solar_term(2011, 15) == date(2011, 4, 5)
    assert solar_term(2014, 15) == date(2014, 4, 4)
    assert solar_term(2016, 15) == date(2016, 4, 4)
    assert solar_term(2017, 15) == date(2017, 4, 4)

def test_qingming_festivals_hk(solar_term):
    # src: https://www.timeanddate.com/holidays/hong-kong/ching-ming-festival
    assert solar_term(2000, 15, 'Asia/Hong_Kong') == date(2000, 4, 4)
    assert solar_term(2001, 15, 'Asia/Hong_Kong') == date(2001, 4, 5)
    assert solar_term(2002, 15, 'Asia/Hong_Kong') == date(2002, 4, 5)
    assert solar_term(2003, 15, 'Asia/Hong_Kong') == date(2003, 4, 5)
    # Error here, strangely set to April 3rd.
    # Removing this from the test suite, since it looks like an exception
    # assert solar_term(2004, 15, 'Asia/Hong_Kong') == date(2004, 4, 3)
    assert solar_term(2005, 15, 'Asia/Hong_Kong') == date(2005, 4, 5)
    assert solar_term(2006, 15, 'Asia/Hong_Kong') == date(2006, 4, 5)
    assert solar_term(2007, 15, 'Asia/Hong_Kong') == date(2007, 4, 5)
    assert solar_term(2008, 15, 'Asia/Hong_Kong') == date(2008, 4, 4)
    assert solar_term(2009, 15, 'Asia/Hong_Kong') == date(2009, 4, 4)
    assert solar_term(2010, 15, 'Asia/Hong_Kong') == date(2010, 4, 5)
    assert solar_term(2011, 15, 'Asia/Hong_Kong') == date(2011, 4, 5)
    assert solar_term(2012, 15, 'Asia/Hong_Kong') == date(2012, 4, 4)
    assert solar_term(2013, 15, 'Asia/Hong_Kong') == date(2013, 4, 4)
    assert solar_term(2014, 15, 'Asia/Hong_Kong') == date(2014, 4, 5)
    assert solar_term(2015, 15, 'Asia/Hong_Kong') == date(2015, 4, 5)
    assert solar_term(2016, 15, 'Asia/Hong_Kong') == date(2016, 4, 4)
    assert solar_term(2017, 15, 'Asia/Hong_Kong') == date(2017, 4, 4)
    assert solar_term(2018, 15, 'Asia/Hong_Kong') == date(2018, 4, 5)
    assert solar_term(2019, 15, 'Asia/Hong_Kong') == date(2019, 4, 5)
    assert solar_term(2020, 15, 'Asia/Hong_Kong') == date(2020, 4, 4)

References:

[^1]: Solar terms - https://en.wikipedia.org/wiki/Solar_term

brunobord commented 5 years ago

I think I'm going a bit crazy. I think I've managed to convert from the "ephem" function to a "skyfield" function, but unfortunately, my function fails at the year 2009, for example, while the "ephem" function passes all the tests.

Here it is (please forgive its eventual clunky look, it's the result of hours of try/fail attempts):

def solar_term_skyfield(year, degrees, timezone='UTC'):
    # Target angle as Astropy unit
    target_angle = (degrees * u.degree).to('rad').value

    load = Loader(get_skyfield_data_path())
    planets = load('de421.bsp')
    earth = planets['earth']
    sun = planets['sun']
    ts = load.timescale()
    tz = pytz.timezone(timezone)

    jan_first = ts.utc(date(year, 1, 1))
    current_longitude = current_longitude_skyfield(jan_first, earth, sun)

    # Find approximately the right time of year.
    difference = (target_angle - current_longitude) % twopi
    # Here we have an approximation of the number of julian days to go
    date_delta = 365.25 * difference / twopi
    # convert to "tt" and reconvert it back to a Time object
    t0 = ts.tt_jd(jan_first.tt + date_delta)

    def f(t):
        # We've got a float which is the `tt`
        sky_tt = ts.tt_jd(t)
        longitude = current_longitude_skyfield(sky_tt, earth, sun)
        result = target_angle - longitude
        if result > pi:
            result = result - pi
        elif result < -pi:
            result = result + pi
        return result

    # Using datetimes to compute the next step date
    t0_plus_one_minute = t0.utc_datetime() + timedelta(minutes=1)
    # Back to Skyfield Time objects
    t0_plus_one_minute = ts.utc(t0_plus_one_minute)

    # Julian day for the starting date
    t0 = t0.tt
    # Adding one minute to have a second boundary
    t0_plus_one_minute = t0_plus_one_minute.tt
    # Newton method to converge towards the target angle
    t = newton(f, t0, t0_plus_one_minute, newton_precision_ephem)
    # Here we have a float to convert to julian days.
    t = ts.tt_jd(t)
    # To convert to datetime
    t = t.utc_datetime()
    # Convert in the timezone
    result = t.astimezone(tz)
    return result.date()

The function current_longitude_skyfield is, in my opinion, the key, here. Using the same units (radians), what ephem gives is (slightly) different from what my function generates.

def current_longitude_skyfield(current_date, earth, sun):
    """
    Return the ecliptic longitude, in radians.
    """
    astrometric = earth.at(current_date).observe(sun)
    latitude, longitude, _ = astrometric.ecliptic_latlon()
    return longitude.radians

I thought this was the equivalent of the following code:

def current_longitude_ephem(year):
    # Find out the sun's current longitude.
    sun = ephem.Sun(ephem.Date(str(year)))
    return sun.hlong - pi

The angle is almost the same, but it's not. I've got hours of delta when calculating the solar term using my method, and when you're close to midnight, the consequence is here: I'm wrong.

Unfortunately, I'm unable to find what's wrong in this function. I genuinely thought it was the right way to calculate the ecliptic angle at a given date...

If anyone can help me, please, I'll be delighted.

GammaSagittarii commented 5 years ago

Hi @brunobord, since you say that vaules are close, my guess is that all you need to change is calling astrometric.ecliptic_latlon() with epoch='date'. If astrometric.ecliptic_latlon(epoch='date') still is not close enough try astrometric.apparent().ecliptic_latlon(epoch='date') .

brunobord commented 5 years ago

it... worked... oh boy I'm so moved I can't speak. I need coffee...

thanks a million @GammaSagittarii, you're my hero!

GammaSagittarii commented 5 years ago

@brunobord that is great to hear, I am really glad I was able to help.

brandon-rhodes commented 5 years ago

Thanks for realizing that epoch was the problem here, @GammaSagittarii! Great job supporting another programmer :)