Agricultural sun & moon calendar — pure Elixir, zero dependencies.
From a geolocation, a date (or a date range) and optionally an elevation, GreenCal computes everything a gardening / market-farming calendar needs:
- sunrise, solar noon, sunset, day length, civil dawn/dusk
- moonrise, moonset, phase, illuminated fraction, distance
- the three independent lunar cycles used by agricultural calendars: waxing/waning (illumination), ascending/descending (declination — the one biodynamic sowing calendars care about), and perigee/apogee (distance)
- lunar node crossings (β = 0), the days biodynamic calendars mark as unfavorable
- the sidereal constellation the Moon stands in, its element and the plant organ traditionally associated with it
Quick start
day = GreenCal.day({48.8566, 2.3522}, ~D[2026-06-21])
day.sun.rise #=> ~U[2026-06-21 03:46:57Z]
day.moon.phase #=> :first_quarter
day.moon.trend #=> :descending
GreenCal.calendar({48.8566, 2.3522}, Date.range(~D[2026-07-01], ~D[2026-07-31]))Everything the day holds that happens at an instant is also available as one sorted list — sunrise and moonset next to the exact full moon:
for e <- day.events, do: {e.family, e.type, e.at}See GreenCal.Day for the shape and guarantees of that list, and
lunar_timeline/2 for the same idea over a whole range without a
location.
Options
Accepted by day/3 and calendar/3:
:elevation— meters above sea level (default0.0). Deepens the apparent horizon dip: about one minute of earlier sunrise per 100 m. Applies to sun and moon rise/set only — twilights are defined by the Sun's geometric altitude and are deliberately unaffected (a higher vantage point sees the Sun sooner, but the sky's illumination geometry does not change).:time_zone— IANA zone name (e.g."Europe/Paris"). The civil day then runs from local midnight to local midnight and everyDateTimeis returned in that zone. Requires a configured time zone database (e.g.tzdata); the default"Etc/UTC"needs none.In zones that change offset at midnight (Cuba, Chile, Lord Howe…) local midnight can be missing or happen twice a year. Where it is missing, the day starts at the first instant after the jump — 01:00 local for a one-hour spring forward, and the day is 23 h long. Where it happens twice, the day starts at the first of the two, before the clocks go back, and is 25 h long. Days therefore always tile the year: no instant belongs to two civil days, and none to neither.
:twilight— altitude used for dawn/dusk::civil(default),:nautical,:astronomical, or degrees.:boundaries— constellation convention::equal_sidereal(default) or:iau(seeconstellation_of/3).:delta_t— override ΔT in seconds (seeGreenCal.Astro.Time).
A warning about the interpretive layer
Rise/set times, phases and declination trends are astronomy: they are computed here to the minute and validated against published references. Elements, organs and "sowing days" are tradition, not science — GreenCal computes the underlying astronomy faithfully and labels the traditional mapping for what it is.
Note on constellations: the default is the sidereal zodiac with a Lahiri
ayanamsa and equal 30° sectors. Printed biodynamic calendars (Maria Thun
et al.) use the unequal IAU constellation boundaries instead — pass
boundaries: :iau to match them (13 sectors, Ophiuchus included).
Summary
Types
Latitude and longitude in degrees, East positive.
Functions
One GreenCal.Day per date of the range (or any enumerable of dates).
Constellation occupied by a tropical ecliptic longitude.
Everything about one civil day at one location.
lunar_timeline/2 grouped by family — the four lists a printed lunar
calendar keeps apart.
Geocentric lunar events over a date range, as one chronology.
Types
Functions
@spec calendar(location(), Enumerable.t(), keyword()) :: [GreenCal.Day.t()]
One GreenCal.Day per date of the range (or any enumerable of dates).
Days are independent computations: pass parallel: true to spread them
over the schedulers with Task.async_stream/3. Order is preserved either
way, and exceptions stay rescuable in both modes.
GreenCal.calendar(loc, Date.range(~D[2026-07-01], ~D[2026-07-31]))
GreenCal.calendar(loc, Date.range(~D[2026-01-01], ~D[2026-12-31]), parallel: true)Every day is computed exactly as day/3 would compute it alone, so the
two functions agree bit for bit. Sharing one geocentric search across the
range was tried and dropped: it measured within noise of the per-day
searches (the cost sits in the bisections, whose count depends on how
many events exist, not on how the range is cut), and it made
calendar/3 disagree with day/3 by a few milliseconds under a
:time_zone, where a DST day is 23 or 25 hours long and the sampling
grids stop lining up.
Constellation occupied by a tropical ecliptic longitude.
Two boundary conventions, chosen with the :boundaries option:
:equal_sidereal(default) — sidereal zodiac, twelve equal 30° sectors, Lahiri ayanamsa. The convention of Indian ephemerides.:iau— the real (unequal) IAU constellation boundaries along the ecliptic, thirteen sectors including Ophiuchus. This is what printed biodynamic calendars (Maria Thun et al.) use; Ophiuchus carries:water, as those calendars fold it into Scorpius.
Both drift together against the tropical zodiac by ~50.3″/yr (precession), computed continuously — no jump at January 1st.
Returns {name, element}.
iex> GreenCal.constellation_of(45.0, ~D[2026-01-01])
{"Aries", :fire}
iex> GreenCal.constellation_of(250.0, ~D[2026-01-01], boundaries: :iau)
{"Ophiuchus", :water}
@spec day(location(), Date.t(), keyword()) :: GreenCal.Day.t()
Everything about one civil day at one location.
See GreenCal.Day for the returned struct and the module doc for the
option list.
The :sun and :moon structs carry a :state field: :normal, or
:always_above / :always_below when the body never crosses the horizon
that day (polar day/night for the Sun; for the Moon this legitimately
happens about one day per month — a nil moonrise is not a bug).
@spec lunar_events( Date.Range.t(), keyword() ) :: %{phases: [map()], apsides: [map()], nodes: [map()], standstills: [map()]}
lunar_timeline/2 grouped by family — the four lists a printed lunar
calendar keeps apart.
:phases— new moon, quarters, full moon, with the:eclipsescreening flag (seeGreenCal.Astro.phase_events/3):apsides— perigees and apogees, with distances:nodes— ascending / descending node crossings:standstills— northernmost / southernmost declination, i.e. the exact flips between ascending and descending Moon
This is a view over lunar_timeline/2, not a second computation, so the
two cannot report different instants. Each list is chronological; reach
for lunar_timeline/2 when you want the families interleaved instead.
GreenCal.lunar_events(Date.range(~D[2026-08-01], ~D[2026-08-31]))
#=> %{phases: [%{family: :phase, type: :new_moon,
# at: ~U[2026-08-12 17:36:40Z], eclipse: :likely}, ...],
# apsides: [...], nodes: [...], standstills: [...]}
@spec lunar_timeline( Date.Range.t(), keyword() ) :: [map()]
Geocentric lunar events over a date range, as one chronology.
No location involved: these instants are the same everywhere on Earth.
Everything a printed lunar calendar marks with a symbol, in the order it
happens, each entry tagged with its :family:
GreenCal.lunar_timeline(Date.range(~D[2026-08-01], ~D[2026-08-31]))
#=> [%{family: :phase, type: :last_quarter, at: ~U[2026-08-06 02:21:49Z],
# eclipse: :none},
# %{family: :standstill, type: :northernmost,
# at: ~U[2026-08-08 23:38:08Z], declination: 28.107933344152},
# %{family: :apsis, type: :perigee, at: ~U[2026-08-10 11:16:35Z],
# distance_km: 363285.244284283},
# %{family: :phase, type: :new_moon, at: ~U[2026-08-12 17:36:40Z],
# eclipse: :likely},
# ...]| Family | Types | Extra keys |
|---|---|---|
:phase | :new_moon, :first_quarter, :full_moon, :last_quarter | :eclipse (see GreenCal.Astro.phase_events/3) |
:apsis | :perigee, :apogee | :distance_km |
:node | :ascending, :descending | — |
:standstill | :northernmost, :southernmost | :declination |
Unlike GreenCal.Day :events, entries here carry their family's extra
keys: there is no struct behind this list to hold them.
The four families are independent searches over the same range, so
parallel: true runs them on separate schedulers. The result is
identical either way, down to the last bit — same range, same sampling
grid, same bisection.
Times are UTC DateTimes, or local ones with the :time_zone option.