Interpolated Solar Position
Dense time series are the common case in solar energy work: a year of positions at one minute resolution is over half a million queries, and SPA spends about 2 µs on each. The Interpolated algorithm removes almost all of that cost by precomputing the slow part once.
The sun's geocentric coordinates change smoothly on annual and monthly timescales, so Interpolated samples them on a uniform grid and fits cubic B-splines. Everything fast or observer dependent, sidereal time, the hour angle, parallax, and the conversion to azimuth and elevation, stays closed form and runs through the exact same code as SPA itself. The interpolation error is around 1e-10 degrees at the default one hour grid, about seven orders of magnitude below the accuracy of SPA.
Construction requires Interpolations.jl, which is a weak dependency, so load it first:
using SolarPosition
using Dates
using Interpolations
alg = Interpolated(SPA(); tspan = (DateTime(2024, 1, 1), DateTime(2025, 1, 1)))Interpolated(SPA(67.0, 101325.0, 12.0, 0.5667); tspan = (Dates.DateTime("2024-01-01T00:00:00"), Dates.DateTime("2025-01-01T00:00:00")), step = 1 hour, out_of_range = :error)The result is a drop in replacement for any other algorithm:
obs = Observer(52.35888, 4.88185; altitude = 100.0)
dt = DateTime(2024, 6, 21, 12, 30)
solar_position(obs, dt, alg)ApparentSolPos(azimuth=202.0115982806275°, elevation=59.68058938611418°, zenith=30.31941061388582°,
apparent_elevation=59.69043015502362°, apparent_zenith=30.30956984497638°Accuracy
The interpolant reproduces the wrapped algorithm to well below its own accuracy. One day of positions at minute resolution against direct SPA:
times = collect(DateTime(2024, 6, 21):Minute(1):DateTime(2024, 6, 22))
exact = solar_position(obs, times, SPA(), NoRefraction())
fast = solar_position(obs, times, alg, NoRefraction())
maximum(abs.(fast.elevation .- exact.elevation))9.382716825712123e-12Speed
Construction samples the geocentric state of SPA about 8800 times for a one year span at the default step = Hour(1), a few milliseconds of work that is threaded over the available Julia threads. Each query afterwards costs a few spline evaluations plus the closed form reconstruction:
using BenchmarkTools
@btime solar_position($obs, $dt, $(SPA()), $(NoRefraction()));
@btime solar_position($obs, $dt, $alg, $(NoRefraction())); 2.361 μs (0 allocations: 0 bytes)
211.970 ns (0 allocations: 0 bytes)On the machine that built these docs this is roughly a 10x speedup per query, so the construction cost is repaid after a few thousand queries. Below that, direct SPA is the better tool. Two properties make the interpolant attractive beyond raw speed:
- It is observer independent. The splines capture the sun as seen from the Earth's center, so one instance serves every site in a simulation grid.
- It is immutable, so evaluation is thread safe by construction and composes with the OhMyThreads extension.
Rate of change
Because evaluation is cheap, derivatives come almost for free. solar_rate returns the rate of change of azimuth and elevation in degrees per hour, which is what tracker control loops and slew rate limits need:
solar_rate(obs, dt, alg)(dazimuth_dt = 26.397097178727336, delevation_dt = -3.4331710998571907)Out of range queries
Queries outside tspan throw by default, because silently falling back to the exact algorithm would be a hard to notice 10x slowdown:
try
solar_position(obs, DateTime(2026, 1, 1), alg)
catch err
println(err.msg)
end2026-01-01T00:00:00 is outside the interpolated span 2024-01-01T00:00:00 to 2025-01-01T00:00:00. Widen tspan or use out_of_range = :fallback.Pass out_of_range = :fallback to get the wrapped algorithm outside the span instead. This is convenient when a handful of stray timestamps should not fail a whole pipeline:
alg_fb = Interpolated(
SPA();
tspan = (DateTime(2024, 1, 1), DateTime(2025, 1, 1)),
out_of_range = :fallback,
)
solar_position(obs, DateTime(2026, 1, 1), alg_fb).elevation-60.495925861234056