Benchmarking
This page provides comprehensive benchmarks of the solar position algorithms available in SolarPosition.jl, comparing their computational performance and accuracy. The SPA algorithm is used as the reference "gold standard" due to its high precision (±0.0003°).
using SolarPosition
using CairoMakie
using Dates
using DataFrames
using Statistics
using BenchmarkToolsFor available solar position algorithms, see the Positioning Guide.
Accuracy Analysis
To evaluate accuracy, we compare each algorithm against the gold standard SPA across a full year of hourly timestamps at various geographic locations.
# Test locations representing different latitudes
locations = [
(name = "Equator", obs = Observer(0.0, 0.0, 0.0)),
(name = "Mid-latitude (London)", obs = Observer(51.5074, -0.1278, 11.0)),
(name = "High-latitude (Oslo)", obs = Observer(59.9139, 10.7522, 23.0)),
(name = "Southern hemisphere (Sydney)", obs = Observer(-33.8688, 151.2093, 58.0)),
]
# Generate hourly timestamps for a full year
times = collect(DateTime(2024, 1, 1):Hour(1):DateTime(2024, 12, 31, 23))8784-element Vector{Dates.DateTime}:
2024-01-01T00:00:00
2024-01-01T01:00:00
2024-01-01T02:00:00
2024-01-01T03:00:00
2024-01-01T04:00:00
2024-01-01T05:00:00
2024-01-01T06:00:00
2024-01-01T07:00:00
2024-01-01T08:00:00
2024-01-01T09:00:00
⋮
2024-12-31T15:00:00
2024-12-31T16:00:00
2024-12-31T17:00:00
2024-12-31T18:00:00
2024-12-31T19:00:00
2024-12-31T20:00:00
2024-12-31T21:00:00
2024-12-31T22:00:00
2024-12-31T23:00:00Accuracy comparison
using Statistics: quantile
"""
Compare algorithm accuracy against SPA reference.
Returns DataFrame with error statistics including percentiles.
"""
function compare_accuracy(obs::Observer, times::Vector{DateTime}, algo)
# Get SPA reference positions
spa_pos = solar_position(obs, times, SPA())
# Get algorithm positions
algo_pos = solar_position(obs, times, algo)
# Calculate errors for all positions
elev_errors = abs.(algo_pos.elevation .- spa_pos.elevation)
azim_errors = abs.(algo_pos.azimuth .- spa_pos.azimuth)
# Handle azimuth wraparound (0° and 360° are the same)
azim_errors = min.(azim_errors, 360.0 .- azim_errors)
return (
elevation_mean = mean(elev_errors),
elevation_p2_5 = quantile(elev_errors, 0.025),
elevation_p97_5 = quantile(elev_errors, 0.975),
elevation_max = maximum(elev_errors),
azimuth_mean = mean(azim_errors),
azimuth_p2_5 = quantile(azim_errors, 0.025),
azimuth_p97_5 = quantile(azim_errors, 0.975),
azimuth_max = maximum(azim_errors),
n_samples = length(times),
)
endData collection
# Algorithms to compare (excluding SPA which is the reference)
algorithms = [
("PSA", PSA()),
("NOAA", NOAA()),
("Walraven", Walraven()),
("USNO", USNO()),
]
# Collect accuracy data
accuracy_results = DataFrame(
Algorithm = String[],
Location = String[],
Elevation_Mean_Error = Float64[],
Elevation_P2_5 = Float64[],
Elevation_P97_5 = Float64[],
Elevation_Max_Error = Float64[],
Azimuth_Mean_Error = Float64[],
Azimuth_P2_5 = Float64[],
Azimuth_P97_5 = Float64[],
Azimuth_Max_Error = Float64[],
)
for (algo_name, algo) in algorithms
for loc in locations
stats = compare_accuracy(loc.obs, times, algo)
push!(accuracy_results, (
Algorithm = algo_name,
Location = loc.name,
Elevation_Mean_Error = stats.elevation_mean,
Elevation_P2_5 = stats.elevation_p2_5,
Elevation_P97_5 = stats.elevation_p97_5,
Elevation_Max_Error = stats.elevation_max,
Azimuth_Mean_Error = stats.azimuth_mean,
Azimuth_P2_5 = stats.azimuth_p2_5,
Azimuth_P97_5 = stats.azimuth_p97_5,
Azimuth_Max_Error = stats.azimuth_max,
))
end
end
accuracy_results| Row | Algorithm | Location | Elevation_Mean_Error | Elevation_P2_5 | Elevation_P97_5 | Elevation_Max_Error | Azimuth_Mean_Error | Azimuth_P2_5 | Azimuth_P97_5 | Azimuth_Max_Error |
|---|---|---|---|---|---|---|---|---|---|---|
| String | String | Float64 | Float64 | Float64 | Float64 | Float64 | Float64 | Float64 | Float64 | |
| 1 | PSA | Equator | 0.00121745 | 4.46789e-5 | 0.00334261 | 0.0038712 | 0.00157786 | 2.61068e-5 | 0.00831938 | 0.0681224 |
| 2 | PSA | Mid-latitude (London) | 0.000689003 | 1.86942e-5 | 0.00252155 | 0.003289 | 0.00145074 | 6.25941e-5 | 0.00408151 | 0.00666008 |
| 3 | PSA | High-latitude (Oslo) | 0.000581013 | 1.91489e-5 | 0.0022497 | 0.00295758 | 0.00144994 | 6.91808e-5 | 0.00382428 | 0.00551483 |
| 4 | PSA | Southern hemisphere (Sydney) | 0.000940508 | 3.10488e-5 | 0.00299724 | 0.00378125 | 0.0014582 | 4.02358e-5 | 0.00560808 | 0.0142766 |
| 5 | NOAA | Equator | 0.00371466 | 0.000131678 | 0.0109585 | 0.0128709 | 0.00406937 | 4.94055e-5 | 0.0247847 | 0.157852 |
| 6 | NOAA | Mid-latitude (London) | 0.00274283 | 0.000166422 | 0.00698196 | 0.00998113 | 0.00401022 | 0.000126333 | 0.0118543 | 0.0175856 |
| 7 | NOAA | High-latitude (Oslo) | 0.00258248 | 0.000203615 | 0.00607827 | 0.00882385 | 0.0040052 | 0.000137447 | 0.0109518 | 0.0149296 |
| 8 | NOAA | Southern hemisphere (Sydney) | 0.00303157 | 0.00012467 | 0.00930871 | 0.012174 | 0.00404034 | 9.39007e-5 | 0.0159962 | 0.0392285 |
| 9 | Walraven | Equator | 0.00292731 | 0.000122914 | 0.00706936 | 0.00835027 | 0.0053801 | 0.000219938 | 0.018372 | 0.133819 |
| 10 | Walraven | Mid-latitude (London) | 0.00295305 | 9.02431e-5 | 0.00783931 | 0.00908632 | 0.00314946 | 0.00014002 | 0.0077489 | 0.0113622 |
| 11 | Walraven | High-latitude (Oslo) | 0.00296743 | 9.30684e-5 | 0.00772813 | 0.00896714 | 0.00305173 | 0.000160852 | 0.00676189 | 0.00926879 |
| 12 | Walraven | Southern hemisphere (Sydney) | 0.00304675 | 0.000163423 | 0.00714855 | 0.00809611 | 0.00369838 | 0.000183722 | 0.0118405 | 0.029953 |
| 13 | USNO | Equator | 0.00251801 | 0.000120657 | 0.00681849 | 0.00853729 | 0.00352438 | 0.000150705 | 0.0136207 | 0.0714088 |
| 14 | USNO | Mid-latitude (London) | 0.00268801 | 8.72288e-5 | 0.00658313 | 0.00795245 | 0.00238829 | 9.20632e-5 | 0.00663273 | 0.00976728 |
| 15 | USNO | High-latitude (Oslo) | 0.00274458 | 9.02397e-5 | 0.00634479 | 0.00760349 | 0.00228672 | 9.7486e-5 | 0.00613286 | 0.00820491 |
| 16 | USNO | Southern hemisphere (Sydney) | 0.00231139 | 7.92274e-5 | 0.00570601 | 0.00721132 | 0.00283971 | 0.000130919 | 0.00969949 | 0.0199054 |
The following plots show the mean error with 95% confidence intervals (2.5th to 97.5th percentile) for each algorithm compared to SPA.
Visualization
# Aggregate results by algorithm (mean across all locations)
algo_stats = combine(
groupby(accuracy_results, :Algorithm),
:Elevation_Mean_Error => mean => :Elev_Mean,
:Elevation_P2_5 => mean => :Elev_P2_5,
:Elevation_P97_5 => mean => :Elev_P97_5,
:Azimuth_Mean_Error => mean => :Azim_Mean,
:Azimuth_P2_5 => mean => :Azim_P2_5,
:Azimuth_P97_5 => mean => :Azim_P97_5,
)
# Sort by algorithm order
algo_order = ["PSA", "NOAA", "Walraven", "USNO"]
algo_stats = algo_stats[sortperm([findfirst(==(algo), algo_order) for algo in algo_stats.Algorithm]), :]
fig = Figure(size = (900, 400), backgroundcolor = :transparent, fontsize = 12, textcolor = "#f5ab35")
# Elevation error plot with error bars
ax1 = Axis(fig[1, 1],
title = "Elevation Error vs SPA (95% CI)",
xlabel = "Algorithm",
ylabel = "Error (degrees)",
xticks = (1:4, algo_stats.Algorithm),
backgroundcolor = :transparent,
)
# Error bars showing 95% interval
errorbars!(ax1, 1:4, algo_stats.Elev_Mean,
algo_stats.Elev_Mean .- algo_stats.Elev_P2_5,
algo_stats.Elev_P97_5 .- algo_stats.Elev_Mean,
color = :steelblue, linewidth = 2, whiskerwidth = 10)
scatter!(ax1, 1:4, algo_stats.Elev_Mean, color = :steelblue, markersize = 12)
# Azimuth error plot with error bars
ax2 = Axis(fig[1, 2],
title = "Azimuth Error vs SPA (95% CI)",
xlabel = "Algorithm",
ylabel = "Error (degrees)",
xticks = (1:4, algo_stats.Algorithm),
backgroundcolor = :transparent,
)
errorbars!(ax2, 1:4, algo_stats.Azim_Mean,
algo_stats.Azim_Mean .- algo_stats.Azim_P2_5,
algo_stats.Azim_P97_5 .- algo_stats.Azim_Mean,
color = :coral, linewidth = 2, whiskerwidth = 10)
scatter!(ax2, 1:4, algo_stats.Azim_Mean, color = :coral, markersize = 12)
PSA Error Over Time
To better understand how errors vary throughout the year, we compare the PSA algorithm against SPA at hourly resolution for a full year at a single location.
# Generate hourly timestamps for a full year (reduces memory usage vs minute resolution)
hourly_times = collect(DateTime(2024, 1, 1):Hour(1):DateTime(2024, 12, 31, 23))
obs_london = Observer(51.5074, -0.1278, 11.0)
# Calculate positions
spa_positions = solar_position(obs_london, hourly_times, SPA())
psa_positions = solar_position(obs_london, hourly_times, PSA())
# Calculate errors
elev_errors = psa_positions.elevation .- spa_positions.elevation
azim_errors = psa_positions.azimuth .- spa_positions.azimuth
# Handle azimuth wraparound
azim_errors = [abs(e) > 180 ? e - sign(e) * 360 : e for e in azim_errors]
println("PSA vs SPA at hourly resolution ($(length(hourly_times)) samples):")
println(" Elevation: mean=$(round(mean(abs.(elev_errors)), digits=6))°, max=$(round(maximum(abs.(elev_errors)), digits=4))°")
println(" Azimuth: mean=$(round(mean(abs.(azim_errors)), digits=6))°, max=$(round(maximum(abs.(azim_errors)), digits=4))°")PSA vs SPA at hourly resolution (8784 samples):
Elevation: mean=0.000689°, max=0.0033°
Azimuth: mean=0.001451°, max=0.0067°Visualization
fig_err = Figure(size = (900, 500), backgroundcolor = :transparent, fontsize = 12, textcolor = "#f5ab35")
# Convert to day of year for x-axis
day_of_year = [Dates.dayofyear(t) for t in hourly_times]
ax1 = Axis(fig_err[1, 1],
title = "PSA Elevation Error vs SPA (2024, London)",
xlabel = "Day of Year",
ylabel = "Error (degrees)",
backgroundcolor = :transparent,
)
scatter!(ax1, day_of_year, elev_errors, markersize = 1.5, color = (:steelblue, 0.5))
hlines!(ax1, [0.0], color = :gray, linestyle = :dash)
ax2 = Axis(fig_err[2, 1],
title = "PSA Azimuth Error vs SPA (2024, London)",
xlabel = "Day of Year",
ylabel = "Error (degrees)",
backgroundcolor = :transparent,
)
scatter!(ax2, day_of_year, azim_errors, markersize = 1.5, color = (:coral, 0.5))
hlines!(ax2, [0.0], color = :gray, linestyle = :dash)
Error Distribution by Location
Visualization
fig2 = Figure(size = (900, 500), backgroundcolor = :transparent, fontsize = 11, textcolor = "#f5ab35")
for (i, loc) in enumerate(locations)
row = (i - 1) ÷ 2 + 1
col = (i - 1) % 2 + 1
ax = Axis(fig2[row, col],
title = loc.name,
xlabel = "Algorithm",
ylabel = "Mean Elevation Error (°)",
xticks = (1:4, [a[1] for a in algorithms]),
backgroundcolor = :transparent,
)
loc_data = filter(r -> r.Location == loc.name, accuracy_results)
barplot!(ax, 1:4, loc_data.Elevation_Mean_Error, color = :teal)
end
Label(fig2[0, :], "Elevation Error by Location", fontsize = 14, font = :bold)
As we can see the error distribution is relatively consistent across different geographic locations, with PSA consistently providing the lowest mean error compared to SPA.
Performance Benchmarks
We benchmark the computational performance of each algorithm across different input sizes, from single timestamp calculations to bulk operations with 10,000 timestamps.
Single benchmark
# Single position benchmarks
obs = Observer(51.5074, -0.1278, 11.0) # London
dt = DateTime(2024, 6, 21, 12, 0, 0)
single_benchmarks = DataFrame(
Algorithm = String[],
Time_ns = Float64[],
Allocations = Int[],
)
for (name, algo) in [("PSA", PSA()), ("NOAA", NOAA()), ("Walraven", Walraven()),
("USNO", USNO()), ("SPA", SPA())]
b = @benchmark solar_position($obs, $dt, $algo) samples=100 evals=10
push!(single_benchmarks, (
Algorithm = name,
Time_ns = median(b.times),
Allocations = b.allocs,
))
end
# Add relative timing
single_benchmarks.Time_μs = single_benchmarks.Time_ns ./ 1000
spa_time = filter(row -> row.Algorithm == "SPA", single_benchmarks).Time_ns[1]
single_benchmarks.Relative_to_SPA = single_benchmarks.Time_ns ./ spa_time
single_benchmarks[:, [:Algorithm, :Time_μs, :Allocations, :Relative_to_SPA]]| Row | Algorithm | Time_μs | Allocations | Relative_to_SPA |
|---|---|---|---|---|
| String | Float64 | Int64 | Float64 | |
| 1 | PSA | 0.1622 | 0 | 0.0413865 |
| 2 | NOAA | 0.4256 | 0 | 0.108595 |
| 3 | Walraven | 0.1981 | 0 | 0.0505467 |
| 4 | USNO | 0.37165 | 0 | 0.0948292 |
| 5 | SPA | 3.91915 | 0 | 1.0 |
Vector benchmark
# Vector benchmarks for different sizes
sizes = [100, 1_000, 10_000]
vector_benchmarks = DataFrame(
Algorithm = String[],
N = Int[],
Time_ms = Float64[],
Throughput = Float64[], # positions per second
)
for n in sizes
times_vec = collect(DateTime(2024, 1, 1):Hour(1):(DateTime(2024, 1, 1) + Hour(n-1)))
for (name, algo) in [("PSA", PSA()), ("NOAA", NOAA()), ("Walraven", Walraven()),
("USNO", USNO()), ("SPA", SPA())]
b = @benchmark solar_position($obs, $times_vec, $algo) samples=10 evals=1
time_ms = median(b.times) / 1e6
push!(vector_benchmarks, (
Algorithm = name,
N = n,
Time_ms = time_ms,
Throughput = n / (time_ms / 1000),
))
end
end
# Pivot for display
vector_pivot = unstack(vector_benchmarks, :Algorithm, :N, :Time_ms)
vector_pivot| Row | Algorithm | 100 | 1000 | 10000 |
|---|---|---|---|---|
| String | Float64? | Float64? | Float64? | |
| 1 | PSA | 0.017848 | 0.177215 | 1.81228 |
| 2 | NOAA | 0.0421975 | 0.414718 | 4.13388 |
| 3 | Walraven | 0.02064 | 0.203496 | 2.05593 |
| 4 | USNO | 0.0397615 | 0.386769 | 3.87371 |
| 5 | SPA | 0.38324 | 3.88718 | 38.9071 |
Visualization
fig3 = Figure(size = (900, 400), backgroundcolor = :transparent, fontsize = 12, textcolor = "#f5ab35")
# Scaling plot (log-log)
ax1 = Axis(fig3[1, 1],
title = "Computation Time vs Input Size",
xlabel = "Number of Timestamps",
ylabel = "Time (ms)",
xscale = log10,
yscale = log10,
backgroundcolor = :transparent,
)
colors = [:blue, :orange, :green, :purple, :red]
algo_names = ["PSA", "NOAA", "Walraven", "USNO", "SPA"]
for (i, algo) in enumerate(algo_names)
data = filter(r -> r.Algorithm == algo, vector_benchmarks)
lines!(ax1, data.N, data.Time_ms, label = algo, color = colors[i], linewidth = 2)
scatter!(ax1, data.N, data.Time_ms, color = colors[i], markersize = 8)
end
axislegend(ax1, position = :rb, framevisible = false, labelsize = 10)
# Throughput plot
ax2 = Axis(fig3[1, 2],
title = "Throughput at N=10,000",
xlabel = "Algorithm",
ylabel = "Positions per Second",
xticks = (1:5, algo_names),
backgroundcolor = :transparent,
)
throughput_10k = filter(r -> r.N == 10_000, vector_benchmarks)
barplot!(ax2, 1:5, throughput_10k.Throughput ./ 1e6, color = colors)
ax2.ylabel = "Million Positions / Second"
Comparison with solposx (Python)
The solposx package is a Python library that implements the same solar position algorithms. This section compares the performance of SolarPosition.jl against solposx to demonstrate the benefits of using Julia.
The benchmarks below use PythonCall.jl to call solposx from within Julia. We have also benchmarked solposx directly in a pure Python environment (without PythonCall.jl overhead) and found no significant difference in the results.
Setup
First, we install and import solposx using PythonCall.jl and CondaPkg.jl:
using CondaPkg
CondaPkg.add_pip("solposx")
CondaPkg.add_pip("pandas")
using PythonCall
# Import Python modules
sp = pyimport("solposx.solarposition")
pd = pyimport("pandas")Python: <module 'pandas' from '/home/runner/work/SolarPosition.jl/SolarPosition.jl/docs/.CondaPkg/.pixi/envs/default/lib/python3.13/site-packages/pandas/__init__.py'>Configuration
For fair comparison, we use the same test conditions for both libraries:
- Observer: London (51.5074°N, 0.1278°W, 11m elevation)
- Timestamps: Hourly data from January 1, 2024
- Algorithms: PSA, NOAA, Walraven, USNO, SPA
function create_pandas_times(n::Int)
pd.date_range(start="2024-01-01 00:00:00", periods=n, freq="h", tz="UTC")
end
solposx_algorithms = Dict(
"PSA" => (sp.psa, (coefficients = 2020,)),
"NOAA" => (sp.noaa, NamedTuple()),
"Walraven" => (sp.walraven, NamedTuple()),
"USNO" => (sp.usno, NamedTuple()),
"SPA" => (sp.spa, NamedTuple()),
)
lat, lon = 51.5074, -0.1278(51.5074, -0.1278)Running the Benchmarks
We benchmark both libraries across different input sizes:
Benchmark code
# Benchmark sizes
sizes = [100, 1_000, 10_000]
# Results storage
comparison_results = DataFrame(
Algorithm = String[],
N = Int[],
Julia_ms = Float64[],
Python_ms = Float64[],
Speedup = Float64[],
)
for n in sizes
# Create time vectors
julia_times_vec = collect(DateTime(2024, 1, 1):Hour(1):(DateTime(2024, 1, 1) + Hour(n-1)))
py_times_idx = create_pandas_times(n)
for (algo_name, algo) in [("PSA", PSA()), ("NOAA", NOAA()), ("Walraven", Walraven()),
("USNO", USNO()), ("SPA", SPA())]
# Julia benchmark
julia_bench = @benchmark solar_position($obs, $julia_times_vec, $algo) samples=5 evals=1
julia_time_ms = median(julia_bench.times) / 1e6
# Python benchmark
py_func, py_kwargs = solposx_algorithms[algo_name]
# Benchmark Python function using BenchmarkTools
if isempty(py_kwargs)
py_bench = @benchmark $py_func($py_times_idx, $lat, $lon) samples=5 evals=1
else
py_bench = @benchmark $py_func($py_times_idx, $lat, $lon; $py_kwargs...) samples=5 evals=1
end
python_time_ms = median(py_bench.times) / 1e6
speedup = python_time_ms / julia_time_ms
push!(comparison_results, (
Algorithm = algo_name,
N = n,
Julia_ms = round(julia_time_ms, digits=3),
Python_ms = round(python_time_ms, digits=3),
Speedup = round(speedup, digits=1),
))
end
end
comparison_results| Row | Algorithm | N | Julia_ms | Python_ms | Speedup |
|---|---|---|---|---|---|
| String | Int64 | Float64 | Float64 | Float64 | |
| 1 | PSA | 100 | 0.018 | 2.625 | 144.7 |
| 2 | NOAA | 100 | 0.043 | 3.59 | 84.0 |
| 3 | Walraven | 100 | 0.02 | 1.138 | 55.6 |
| 4 | USNO | 100 | 0.04 | 2.783 | 69.5 |
| 5 | SPA | 100 | 0.383 | 3.033 | 7.9 |
| 6 | PSA | 1000 | 0.177 | 2.784 | 15.7 |
| 7 | NOAA | 1000 | 0.409 | 4.321 | 10.6 |
| 8 | Walraven | 1000 | 0.204 | 1.349 | 6.6 |
| 9 | USNO | 1000 | 0.387 | 6.345 | 16.4 |
| 10 | SPA | 1000 | 3.888 | 6.872 | 1.8 |
| 11 | PSA | 10000 | 1.787 | 5.869 | 3.3 |
| 12 | NOAA | 10000 | 4.114 | 10.216 | 2.5 |
| 13 | Walraven | 10000 | 2.058 | 3.75 | 1.8 |
| 14 | USNO | 10000 | 3.867 | 43.069 | 11.1 |
| 15 | SPA | 10000 | 38.881 | 44.193 | 1.1 |
Visualization
fig5 = Figure(size = (600, 750), backgroundcolor = :transparent, fontsize = 12, textcolor = "#f5ab35")
# Group by algorithm for plotting
algo_names = ["PSA", "NOAA", "Walraven", "USNO", "SPA"]
colors_julia = [:blue, :green, :purple, :orange, :red]
ax1 = Axis(fig5[1, 1],
title = "Computation Time: Julia vs Python",
xlabel = "Number of Timestamps",
ylabel = "Time (ms)",
xscale = log10,
yscale = log10,
backgroundcolor = :transparent,
)
# Store line objects for legends
julia_lines = []
python_lines = []
for (i, algo) in enumerate(algo_names)
data = filter(r -> r.Algorithm == algo, comparison_results)
# Julia times (solid lines)
l1 = lines!(ax1, data.N, data.Julia_ms,
color = colors_julia[i],
linewidth = 2)
scatter!(ax1, data.N, data.Julia_ms, color = colors_julia[i], markersize = 6)
push!(julia_lines, l1)
# Python times (dashed lines)
l2 = lines!(ax1, data.N, data.Python_ms,
color = colors_julia[i],
linewidth = 2,
linestyle = :dash)
scatter!(ax1, data.N, data.Python_ms, color = colors_julia[i], markersize = 6,
marker = :utriangle)
push!(python_lines, l2)
end
# Algorithm legend (colors)
leg1 = Legend(fig5[1, 2][1, 1], julia_lines, algo_names, "Algorithm",
framevisible = true, backgroundcolor = :transparent, labelsize = 10, titlesize = 11)
# Line style legend (solid vs dashed)
style_lines = [LineElement(color = :gray, linewidth = 2, linestyle = :solid),
LineElement(color = :gray, linewidth = 2, linestyle = :dash)]
leg2 = Legend(fig5[1, 2][2, 1], style_lines, ["Julia", "Python"], "Library",
framevisible = true, backgroundcolor = :transparent, labelsize = 10, titlesize = 11)
# Speedup bar chart
ax2 = Axis(fig5[2, 1:2],
title = "Julia vs Python at N=1,000",
xlabel = "Algorithm",
ylabel = "Speedup Factor (×)",
xticks = (1:5, algo_names),
backgroundcolor = :transparent,
)
speedup_1k = filter(r -> r.N == 1_000, comparison_results)
barplot!(ax2, 1:5, speedup_1k.Speedup, color = colors_julia)
hlines!(ax2, [1.0], color = :gray, linestyle = :dash)
Accuracy vs Performance Trade-off
When selecting a solar position algorithm, there is often a trade-off between accuracy and computational performance. The following Pareto plot visualizes this trade-off by plotting mean elevation error against computation time at N=1,000 timestamps, comparing both Julia and Python implementations.
Pareto Analysis
# Combine accuracy and performance data for Pareto analysis at N=1000
# Get mean elevation error across all locations for each algorithm
pareto_data = combine(
groupby(accuracy_results, :Algorithm),
:Elevation_Mean_Error => mean => :Mean_Error
)
# Add SPA as reference (error = 0)
push!(pareto_data, (Algorithm = "SPA", Mean_Error = 0.0))
# Get Julia timing at N=1000
julia_times_1k = filter(r -> r.N == 1_000, comparison_results)
pareto_julia = leftjoin(pareto_data,
select(julia_times_1k, :Algorithm, :Julia_ms),
on = :Algorithm)
pareto_julia.Library = fill("Julia", nrow(pareto_julia))
# Get Python timing at N=1000
pareto_python = leftjoin(pareto_data,
select(julia_times_1k, :Algorithm, :Python_ms),
on = :Algorithm)
rename!(pareto_python, :Python_ms => :Julia_ms) # Reuse column name for convenience
pareto_python.Library = fill("Python", nrow(pareto_python))
# Combine both
pareto_combined = vcat(pareto_julia, pareto_python)
rename!(pareto_combined, :Julia_ms => :Time_ms)
# Sort by error then time
sort!(pareto_combined, [:Mean_Error, :Time_ms])
pareto_combined| Row | Algorithm | Mean_Error | Time_ms | Library |
|---|---|---|---|---|
| String | Float64 | Float64? | String | |
| 1 | SPA | 0.0 | 3.888 | Julia |
| 2 | SPA | 0.0 | 6.872 | Python |
| 3 | PSA | 0.000856994 | 0.177 | Julia |
| 4 | PSA | 0.000856994 | 2.784 | Python |
| 5 | USNO | 0.0025655 | 0.387 | Julia |
| 6 | USNO | 0.0025655 | 6.345 | Python |
| 7 | Walraven | 0.00297363 | 0.204 | Julia |
| 8 | Walraven | 0.00297363 | 1.349 | Python |
| 9 | NOAA | 0.00301788 | 0.409 | Julia |
| 10 | NOAA | 0.00301788 | 4.321 | Python |
Visualization
fig_pareto = Figure(size = (700, 550), backgroundcolor = :transparent, fontsize = 12, textcolor = "#f5ab35")
ax = Axis(fig_pareto[1, 1],
title = "Algorithm Selection: Accuracy vs Performance (N=1,000)",
xlabel = "Computation Time (ms)",
ylabel = "Mean Elevation Error vs SPA (°)",
backgroundcolor = :transparent,
)
# Color scheme for algorithms
colors_pareto = Dict(
"PSA" => :steelblue,
"NOAA" => :orange,
"Walraven" => :green,
"USNO" => :purple,
"SPA" => :red
)
# Marker scheme for library
markers_lib = Dict(
"Julia" => :circle,
"Python" => :utriangle
)
# Plot points
julia_points = []
python_points = []
for row in eachrow(pareto_combined)
marker = markers_lib[row.Library]
color = colors_pareto[row.Algorithm]
s = scatter!(ax, [row.Time_ms], [row.Mean_Error],
color = color,
marker = marker,
markersize = 15)
if row.Library == "Julia"
push!(julia_points, s)
else
push!(python_points, s)
end
end
# Add algorithm labels for Julia implementations
for row in eachrow(filter(r -> r.Library == "Julia", pareto_combined))
text!(ax, row.Time_ms, row.Mean_Error,
text = " " * row.Algorithm,
align = (:left, :center),
fontsize = 10)
end
# Add algorithm labels for Python implementations
for row in eachrow(filter(r -> r.Library == "Python", pareto_combined))
text!(ax, row.Time_ms, row.Mean_Error,
text = " " * row.Algorithm,
align = (:left, :center),
fontsize = 9,
color = (:gray40, 0.8))
end
# Connect Julia points to show the trade-off curve
julia_data = sort(filter(r -> r.Library == "Julia", pareto_combined), :Time_ms)
lines!(ax, julia_data.Time_ms, julia_data.Mean_Error,
color = (:steelblue, 0.3), linestyle = :dash, linewidth = 1.5, label = "Julia Frontier")
# Connect Python points
python_data = sort(filter(r -> r.Library == "Python", pareto_combined), :Time_ms)
lines!(ax, python_data.Time_ms, python_data.Mean_Error,
color = (:coral, 0.3), linestyle = :dot, linewidth = 1.5, label = "Python Frontier")
# Set y-axis limits for better discrimination
ylims!(ax, nothing, 0.005)
# Add guidelines for accuracy levels
hlines!(ax, [0.001, 0.002], color = (:gray, 0.2), linestyle = :dot, linewidth = 1)
# Create legend for library types
library_elements = [
MarkerElement(marker = :circle, color = :gray, markersize = 12),
MarkerElement(marker = :utriangle, color = :gray, markersize = 12)
]
Legend(fig_pareto[1, 2], library_elements, ["Julia", "Python"], "Library",
framevisible = true, backgroundcolor = :transparent, labelsize = 10)
Summary
The benchmarks demonstrate that SolarPosition.jl offers significant performance advantages over the solposx Python library across all tested algorithms and input sizes.
SolarPosition.jl can leverage Julia's native multi-threading capabilities (see Parallel Computing) for further performance improvements on large datasets. The benchmarks above were conducted using a single thread for fair comparison with solposx, but enabling multi-threading can yield even greater speedups in practical applications.