Mix.install([
{:green_cal, path: Path.join(__DIR__, "..")},
{:kino, "~> 0.14"},
{:kino_vega_lite, "~> 0.1"}
])
alias VegaLite, as: VlPick a location
Change these three values and re-run everything below.
lat_input = Kino.Input.number("Latitude (°, North positive)", default: 45.90)
lon_input = Kino.Input.number("Longitude (°, East positive)", default: -0.83)
elev_input = Kino.Input.number("Elevation (m)", default: 40)
Kino.Layout.grid([lat_input, lon_input, elev_input], columns: 3)loc = {Kino.Input.read(lat_input), Kino.Input.read(lon_input)}
elevation = Kino.Input.read(elev_input) * 1.0
opts = [elevation: elevation]
:okOne day, in full
Everything GreenCal knows about a single civil day: sun, twilight, moon, the three lunar cycles, and the sidereal constellation.
day = GreenCal.day(loc, Date.utc_today(), opts)A nil moonrise is not a bug: about one day per month the Moon simply does
not rise (it rises ~50 minutes later every day, and eventually skips a civil
day). The :state fields make polar cases explicit too: :always_above
(midnight sun) and :always_below (polar night).
What happens today, in order
day.events is the same instants the struct already holds, sorted and
tagged. Every entry has the same three keys — the azimuth of a sunrise or
the distance of a perigee stays in its structured field, so nothing here
needs pattern matching on optional keys.
for e <- day.events do
%{
"time" => Calendar.strftime(e.at, "%H:%M:%S"),
"family" => to_string(e.family),
"event" => to_string(e.type)
}
end
|> Kino.DataTable.new(name: "#{day.date}, in order")The families split in two. :sun, :twilight and :moon are
topocentric — move, and they move. :phase, :apsis, :node and
:standstill are geocentric: the same instants everywhere on Earth.
Computing a set of plots, only the first three change.
An absent entry is not a non-event: no {:sun, :rise} above the Arctic
circle in June means the Sun never set, and day.sun.state is where that
is written. The flat list cannot tell polar day from polar night — the
struct can.
A month at a glance
today = Date.utc_today()
month = GreenCal.calendar(loc, Date.range(today, Date.add(today, 29)), opts)
fmt = fn
nil -> "—"
%DateTime{} = dt -> Calendar.strftime(dt, "%H:%M")
end
month
|> Enum.map(fn d ->
%{
"date" => Calendar.strftime(d.date, "%a %d %b"),
"sunrise" => fmt.(d.sun.rise),
"sunset" => fmt.(d.sun.set),
"day (h)" => d.sun.day_length_minutes && Float.round(d.sun.day_length_minutes / 60, 1),
"moonrise" => fmt.(d.moon.rise),
"moonset" => fmt.(d.moon.set),
"phase" => d.moon.phase,
"moon %" => round(d.moon.illuminated_fraction * 100),
"cycle" => d.moon.trend,
"constellation" => d.constellation,
"organ" => d.organ,
"node" => (d.moon.node && "#{d.moon.node.type} #{fmt.(d.moon.node.at)}") || ""
}
end)
|> Kino.DataTable.new(name: "Times are UTC")Day length over the year
The envelope of the growing season: photoperiod drives bolting, dormancy and harvest windows.
year = GreenCal.calendar(loc, Date.range(~D[2026-01-01], ~D[2026-12-31]), opts)
day_length_data =
for d <- year, d.sun.day_length_minutes do
%{"date" => Date.to_iso8601(d.date), "hours" => d.sun.day_length_minutes / 60}
end
Vl.new(width: 640, height: 260, title: "Day length — 2026 (hours)")
|> Vl.data_from_values(day_length_data)
|> Vl.mark(:line, color: "#2a78d6", stroke_width: 2, tooltip: true)
|> Vl.encode_field(:x, "date", type: :temporal, title: nil, axis: [grid: false])
|> Vl.encode_field(:y, "hours",
type: :quantitative,
title: nil,
axis: [grid_color: "#eeeeee", tick_count: 6]
)The two cycles people conflate
Waxing/waning follows the illumination (synodic month, 29.5 days). Ascending/descending follows the declination (tropical month, 27.3 days). They drift against each other — which is why "sow on a waxing, descending moon" needs both curves, and why implementations that derive one from the other are wrong.
two_months = GreenCal.calendar(loc, Date.range(today, Date.add(today, 59)), opts)
illumination =
for d <- two_months do
%{"date" => Date.to_iso8601(d.date), "value" => d.moon.illuminated_fraction * 100}
end
declination =
for d <- two_months do
%{"date" => Date.to_iso8601(d.date), "value" => d.moon.declination}
end
illum_chart =
Vl.new(width: 640, height: 180, title: "Illuminated fraction (%) — waxing / waning")
|> Vl.data_from_values(illumination)
|> Vl.mark(:line, color: "#2a78d6", stroke_width: 2, tooltip: true)
|> Vl.encode_field(:x, "date", type: :temporal, title: nil, axis: [grid: false])
|> Vl.encode_field(:y, "value", type: :quantitative, title: nil, axis: [grid_color: "#eeeeee"])
decl_chart =
Vl.new(width: 640, height: 180, title: "Declination (°) — ascending / descending")
|> Vl.data_from_values(declination)
|> Vl.mark(:line, color: "#eb6834", stroke_width: 2, tooltip: true)
|> Vl.encode_field(:x, "date", type: :temporal, title: nil, axis: [grid: false])
|> Vl.encode_field(:y, "value", type: :quantitative, title: nil, axis: [grid_color: "#eeeeee"])
Kino.Layout.grid([illum_chart, decl_chart], columns: 1)Two separate charts on purpose: percent and degrees are different scales, and a dual-axis chart would invite reading intersections that mean nothing.
The printed-calendar symbols, as exact instants
Everything a paper lunar calendar marks with a glyph — phases, perigee and
apogee, node crossings, standstills — with the exact time, not just the day.
The eclipse field on new/full moons is a screening flag: :likely means
an eclipse happens somewhere on Earth around that instant.
lunar_timeline/2 returns them as one chronology, already sorted, each
entry carrying its family's own extra key. (lunar_events/2 is the same
data grouped into four lists, when that shape suits you better.)
for e <- GreenCal.lunar_timeline(Date.range(today, Date.add(today, 60))) do
extra =
case e do
%{eclipse: ecl} when ecl != :none -> " ⚠ eclipse #{ecl}"
%{distance_km: d} -> " #{round(d)} km"
%{declination: d} -> " δ = #{Float.round(d, 1)}°"
_ -> ""
end
%{
"when (UTC)" => Calendar.strftime(e.at, "%Y-%m-%d %H:%M"),
"event" => "#{e.family}: #{e.type}#{extra}"
}
end
|> Kino.DataTable.new(name: "Lunar events, next 60 days")Matching a printed biodynamic calendar
The default constellation mapping is the equal-sector sidereal zodiac. Printed calendars (Maria Thun et al.) use the real, unequal IAU boundaries — 13 constellations including Ophiuchus. Compare:
for d <- GreenCal.calendar(loc, Date.range(today, Date.add(today, 13))) do
{iau, _} =
GreenCal.constellation_of(
GreenCal.Astro.moon(GreenCal.Astro.Time.julian_day(d.date) + 0.5).longitude,
d.date,
boundaries: :iau
)
%{
"date" => Calendar.strftime(d.date, "%a %d %b"),
"equal sidereal" => d.constellation,
"IAU (Thun)" => iau
}
end
|> Kino.DataTable.new(name: "Two constellation conventions")Performance
Pure arithmetic, no cache, no NIF. Days are independent, so a year spreads over the schedulers; the four geocentric searches are independent of each other, so they parallelize too — and identically, down to the last bit.
range = Date.range(~D[2026-01-01], ~D[2026-12-31])
time = fn fun -> "#{div(elem(:timer.tc(fun), 0), 1000)} ms" end
%{
"calendar/3, 365 days" => time.(fn -> GreenCal.calendar(loc, range) end),
"calendar/3, parallel: true" => time.(fn -> GreenCal.calendar(loc, range, parallel: true) end),
"lunar_timeline/2, one year" => time.(fn -> GreenCal.lunar_timeline(range) end),
"lunar_timeline/2, parallel: true" => time.(fn -> GreenCal.lunar_timeline(range, parallel: true) end)
}Computing many places at once, note that only the topocentric families
change: get the geocentric instants once from GreenCal.lunar_timeline/2
rather than re-deriving them per place.
Validation — the library against published references
The test suite anchors the code to published values; here are two you can re-run interactively.
Meeus, Astronomical Algorithms, example 47.a — the Moon on 1992 April 12 at 0h TD (JDE 2448724.5):
m = GreenCal.Astro.Moon.position(2_448_724.5)
%{
longitude: {Float.round(m.longitude, 5), published: 133.167265},
latitude: {Float.round(m.latitude, 5), published: -3.229126},
distance_km: {Float.round(m.distance_km, 1), published: 368_409.7}
}The full moon of 2026 January 3, 10:03 UTC (USNO/timeanddate):
jd = GreenCal.Astro.Time.julian_day(~U[2026-01-03 10:03:00Z])
GreenCal.Astro.phase(jd)Elongation within half a degree of 180° at the published instant — the phase machinery, ΔT handling and both ephemerides all have to be right at once for that to happen.
Why ΔT matters
The ephemeris series run on Terrestrial Time; sunrise runs on Earth's
rotation (UT). The gap — ΔT, about 69 s today — grows over decades. Skip it
and every lunar longitude is ~38″ off; apply it on the wrong side and rise
times shift by a minute. GreenCal applies it in exactly one place
(GreenCal.Astro), and you can override it:
jd = GreenCal.Astro.Time.julian_day(~U[2026-07-28 12:00:00Z])
%{
delta_t_seconds: Float.round(GreenCal.Astro.Time.delta_t(jd), 1),
moon_longitude: GreenCal.Astro.moon(jd).longitude,
moon_longitude_without_delta_t: GreenCal.Astro.moon(jd, delta_t: 0).longitude
}