EphCore.Events.Almanac (eph_core v0.1.0)

Copy Markdown View Source

Full-year rise / transit / set almanac for one topocentric observer.

For each body and each crossing it computes, to one-second accuracy:

  • rise / set — the apparent altitude of the body's centre crossing the USNO horizon threshold (going up / down).
  • transit — upper meridian crossing (local hour angle = 0), recording the apparent altitude and azimuth.

Conventions (match Skyfield's almanac)

Apparent altitude/azimuth and hour angle come from EphCore.Corrections.ApparentPlace (light-time + annual aberration + topocentric parallax on top of the validated IAU 2006/2000A precession-nutation frame). The horizon thresholds encode refraction + semidiameter rather than modelling them (Skyfield's build_horizon_function):

BodyCentre altitude at rise/set
Sun-50' (-0.8333°) — 34' refraction + 16' limb
Moon-34' - asin-free (R_moon / range) (distance-dep)
Planets-34' (-0.5667°)

Method (analytic-seed root-find)

For each UTC day the algorithm:

  1. Computes one ApparentPlace.frame at noon UTC, capturing the slow-varying nutation, Earth velocity, and sidereal-time context.
  2. Evaluates ApparentPlace.look for every body at that frame to obtain the body's apparent RA/Dec.
  3. Uses the Meeus closed-form hour-angle formula (cos H₀ = −tan φ tan δ) to predict rise, set, and transit UTC times analytically from the noon RA/Dec and local sidereal time via the standard Meeus hour-angle formula.
  4. Places a narrow bracket (±@bracket_minutes) around each predicted time and confirms a sign change (altitude crossing threshold, or HA crossing zero) with two precise ApparentPlace.look evaluations.
  5. Refines within the confirmed bracket with a bracketed interpolation solver to ≤ @refine_tolerance_us (250 ms; the inverse-interpolation step converges well inside that, so the residual error stays ~0.5 s against Skyfield — comfortably inside the 1 s contract and the second-granularity UTC output), reusing the day's nutation and Earth velocity throughout.

A full year for eight bodies therefore costs ~365 noon frames plus ~10 evaluations per event, instead of a dense hourly grid, while staying in sub-second agreement with reference almanac implementations.

Summary

Functions

Example observer at Chicago (41.8781°N, 87.6298°W, 181 m elevation).

Compute every rise/transit/set event for bodies over year, flat and sorted by tt_jd. See module doc for the conventions and method.

Convenience wrapper returning %{meta: ..., events: ...} matching SPEC §6.

USNO horizon threshold (centre altitude, degrees) for body at topocentric range_km. Only the Moon is range-dependent.

Body atoms for a named preset (:classic7 / :classic8 / :modern10).

All presets as a map.

The half-open UTC span [YEAR-01-01, (YEAR+1)-01-01) for year.

Types

event()

@type event() :: %{
  :body => String.t(),
  :event => String.t(),
  :utc => String.t(),
  :tt_jd => float(),
  optional(:az_deg) => float(),
  optional(:alt_deg) => float()
}

observer()

@type observer() :: %{optional(atom()) => float()}

Functions

chicago()

@spec chicago() :: observer()

Example observer at Chicago (41.8781°N, 87.6298°W, 181 m elevation).

events(observer, year, bodies, opts \\ [])

@spec events(observer(), integer(), [atom()], keyword()) :: [event()]

Compute every rise/transit/set event for bodies over year, flat and sorted by tt_jd. See module doc for the conventions and method.

Options

  • :step_seconds — ignored (kept for API compat with the old dense-grid path).

generate(opts \\ [])

@spec generate(keyword()) :: %{meta: map(), events: [event()]}

Convenience wrapper returning %{meta: ..., events: ...} matching SPEC §6.

Options

  • :year (default 2026), :bodies (atoms or a preset atom, default :classic8), :observer (default Chicago at 181 m), :kernel (label only, default "de440s.bsp"), plus any events/4 options.

horizon_threshold_deg(arg1, range_km)

@spec horizon_threshold_deg(atom(), float()) :: float()

USNO horizon threshold (centre altitude, degrees) for body at topocentric range_km. Only the Moon is range-dependent.

preset(name)

@spec preset(atom()) :: [atom()]

Body atoms for a named preset (:classic7 / :classic8 / :modern10).

presets()

@spec presets() :: %{required(atom()) => [atom()]}

All presets as a map.

span(year)

@spec span(integer()) :: {DateTime.t(), DateTime.t()}

The half-open UTC span [YEAR-01-01, (YEAR+1)-01-01) for year.