How to calculate Easter (and other holidays) in Erlang

On a whim, I created an Erlang module for calculating various holidays, and things were going well until it came to Easter. Have you ever tried to calculate Easter? It's surprisingly difficult! Here's my solution...

How to calculate Easter (and other holidays) in Erlang

On a whim, I created an Erlang module for calculating various holidays, and things were going well until it came to Easter. Have you ever tried to calculate Easter? It's surprisingly difficult.

Easter doesn't occur on the same day of the month, or on the Xth Sunday, or anything that simple like most holidays. It's based on the occurrence of a particular full moon, among other things. I started by trying to recreate this explanation, which is much nicer to read than what I ended up with, but trying to recreate it in code got pretty ugly pretty quick.

What I ended up doing was converting an algorithm in C#, which was a conversion from c++, which was in turn converted from Pascal code. This is why tests are important folks! 😅

The Solution

Here's what I came up with in Erlang, but it's only the Catholic (aka western) date. The Orthodox (aka eastern) date is a completely different calculation (which I implemented with the help of Meeus's Julian algorithm). There's a heavy use of div instead of / because the former behaves similar to integer arithmetic in C#, whereas the latter behaves like floating point arithmetic.

For example, 7 div 4 == 1 but 7 / 4 == 1.75

-spec get_easter(atom(), pos_integer()) -> {pos_integer(), pos_integer(), pos_integer()}.
get_easter(catholic, Year) ->
    G = trunc(math:fmod(Year,19)),
    C = Year div 100,
    H = trunc(math:fmod(C - (C div 4) - ((8 * C + 13) div 25) + 19 * G + 15, 30)),
    I = H - (H div 28) * (1 - (H div 28) * trunc(29 / (H + 1)) * ((21 - G) div 11)),
    Day = trunc(I - math:fmod((Year + (Year div 4)) + I + 2 - C + (C div 4), 7) + 28),
    case Day of
        _ when Day > 31 ->
            {Year, 4, Day - 31};
        _ ->
            {Year, 3, Day}
    end

get_easter_test_() ->
    [
        ?_assertEqual({2019,4,21}, holidays:get_easter(catholic, 2019)),
        ?_assertEqual({2020,4,12}, holidays:get_easter(catholic, 2020)),
        ?_assertEqual({2021,4,4}, holidays:get_easter(catholic, 2021)),
        ?_assertEqual({2022,4,17}, holidays:get_easter(catholic, 2022)),
        ?_assertEqual({2023,4,9}, holidays:get_easter(catholic, 2023)),
        ?_assertEqual({2024,3,31}, holidays:get_easter(catholic, 2024)),
        ?_assertEqual({2025,4,20}, holidays:get_easter(catholic, 2025)),
        ?_assertEqual({2026,4,5}, holidays:get_easter(catholic, 2026)),
        ?_assertEqual({2027,3,28}, holidays:get_easter(catholic, 2027)),
        ?_assertEqual({2028,4,16}, holidays:get_easter(catholic, 2028)),
        ?_assertEqual({2029,4,1}, holidays:get_easter(catholic, 2029)),
        ?_assertEqual({2030,4,21}, holidays:get_easter(catholic, 2030))
    ].

The Importance of Tests

I've got a lot of tests around this particular function, so I'm reasonably sure it's behaving. Even when your code seems simple, test it - unless you're absolutely sure it's absolutely correct and it'll absolutely never change or be consumed by any person or application that'll ever do anything you didn't anticipate when you wrote it. So... just test it.

The only other thing to mention is that, since you can pass functions around in Erlang, I added a function that lets you pass a date and a list of holidays to test it against.

-spec is_holiday(atom(), date_timestamp(), [fun()]) -> boolean().
is_holiday(CountryCode, Date, Holidays) ->
    lists:any(fun(Holiday) -> Holiday(CountryCode, Date) =:= true end, Holidays).

MyDate = {{2019, 12, 25}, {0, 0, 0}},
holidays:is_holiday(us, MyDate, [fun holidays:is_easter/2,
                                 fun holidays:is_thanksgiving/2,
                                 fun holidays:is_new_years/2]).   % returns false

If you use Erlang and you need to know if a date is a holiday, check out the module - and if you have your own holidays to add, open an issue or PR, or just leave a comment below. Contributions welcome!