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 SolarPositionusing CairoMakieusing Datesusing DataFramesusing Statisticsusing BenchmarkTools

For 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 latitudeslocations = [    (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 yeartimes = 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:00
Accuracy 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),    )end
Data collection
# Algorithms to compare (excluding SPA which is the reference)algorithms = [    ("PSA", PSA()),    ("NOAA", NOAA()),    ("Walraven", Walraven()),    ("USNO", USNO()),]# Collect accuracy dataaccuracy_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,        ))    endendaccuracy_results
16×10 DataFrame
RowAlgorithmLocationElevation_Mean_ErrorElevation_P2_5Elevation_P97_5Elevation_Max_ErrorAzimuth_Mean_ErrorAzimuth_P2_5Azimuth_P97_5Azimuth_Max_Error
StringStringFloat64Float64Float64Float64Float64Float64Float64Float64
1PSAEquator0.001217454.46789e-50.003342560.00387120.001577862.61127e-50.008319410.0681224
2PSAMid-latitude (London)0.0006890031.87086e-50.002521560.003288960.001450746.25502e-50.004081510.00666008
3PSAHigh-latitude (Oslo)0.0005810131.91363e-50.002249710.002957550.001449946.91868e-50.003824260.00551475
4PSASouthern hemisphere (Sydney)0.0009405083.10586e-50.002997220.003781290.00145824.01961e-50.005608240.0142763
5NOAAEquator0.003714660.0001316240.01095860.01287090.004069374.94078e-50.02478470.157852
6NOAAMid-latitude (London)0.002742830.0001664510.006981940.009981170.004010220.0001263530.01185430.0175856
7NOAAHigh-latitude (Oslo)0.002582480.0002036150.006078250.008823850.00400520.0001374660.01095180.0149296
8NOAASouthern hemisphere (Sydney)0.003031570.0001246720.009308670.0121740.004040349.38894e-50.01599620.0392282
9WalravenEquator0.002927310.0001229610.007069340.008350270.00538010.0002199380.0183720.133819
10WalravenMid-latitude (London)0.002953059.02578e-50.007839320.009086320.003149460.0001400230.007748880.0113622
11WalravenHigh-latitude (Oslo)0.002967439.30524e-50.007728140.008967120.003051730.0001608030.006761890.00926871
12WalravenSouthern hemisphere (Sydney)0.003046750.0001634040.007148570.008096060.003698370.0001837590.01184040.0299528
13USNOEquator0.002518010.0001206570.006818490.008537290.003524380.0001507050.01362070.0714088
14USNOMid-latitude (London)0.002688018.72288e-50.006583130.007952450.002388299.20636e-50.006632730.00976729
15USNOHigh-latitude (Oslo)0.002744589.02397e-50.006344790.007603490.002286729.74859e-50.006132860.00820491
16USNOSouthern hemisphere (Sydney)0.002311397.92275e-50.005706010.007211320.002839710.0001309190.009699490.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 orderalgo_order = ["PSA", "NOAA", "Walraven", "USNO"]algo_stats = algo_stats[sortperm([findfirst(==(algo), algo_order) for algo in algo_stats.Algorithm]), :]xs = 1:nrow(algo_stats)fig = Figure(size = (900, 400), backgroundcolor = :transparent, fontsize = 12, textcolor = "#f5ab35")# Elevation error plot with error barsax1 = Axis(fig[1, 1],    title = "Elevation Error vs SPA (95% CI)",    xlabel = "Algorithm",    ylabel = "Error (degrees)",    xticks = (xs, algo_stats.Algorithm),    backgroundcolor = :transparent,)# Error bars showing 95% intervalerrorbars!(ax1, xs, 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, xs, algo_stats.Elev_Mean, color = :steelblue, markersize = 12)# Azimuth error plot with error barsax2 = Axis(fig[1, 2],    title = "Azimuth Error vs SPA (95% CI)",    xlabel = "Algorithm",    ylabel = "Error (degrees)",    xticks = (xs, algo_stats.Algorithm),    backgroundcolor = :transparent,)errorbars!(ax2, xs, 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, xs, algo_stats.Azim_Mean, color = :coral, markersize = 12)
Example block output

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 positionsspa_positions = solar_position(obs_london, hourly_times, SPA())psa_positions = solar_position(obs_london, hourly_times, PSA())# Calculate errorselev_errors = psa_positions.elevation .- spa_positions.elevationazim_errors = psa_positions.azimuth .- spa_positions.azimuth# Handle azimuth wraparoundazim_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-axisday_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)
Example block output

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:length(algorithms), [a[1] for a in algorithms]),        backgroundcolor = :transparent,    )    loc_data = filter(r -> r.Location == loc.name, accuracy_results)    barplot!(ax, 1:length(algorithms), loc_data.Elevation_Mean_Error, color = :teal)endLabel(fig2[0, :], "Elevation Error by Location", fontsize = 14, font = :bold)
Example block output

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 benchmarksobs = Observer(51.5074, -0.1278, 11.0)  # Londondt = 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 timingsingle_benchmarks.Time_μs = single_benchmarks.Time_ns ./ 1000spa_time = filter(row -> row.Algorithm == "SPA", single_benchmarks).Time_ns[1]single_benchmarks.Relative_to_SPA = single_benchmarks.Time_ns ./ spa_timesingle_benchmarks[:, [:Algorithm, :Time_μs, :Allocations, :Relative_to_SPA]]
5×4 DataFrame
RowAlgorithmTime_μsAllocationsRelative_to_SPA
StringFloat64Int64Float64
1PSA0.1757500.0553247
2NOAA0.398600.125476
3Walraven0.212300.0668304
4USNO0.373600.117606
5SPA3.176701.0
Vector benchmark
# Vector benchmarks for different sizessizes = [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),        ))    endend# Pivot for displayvector_pivot = unstack(vector_benchmarks, :Algorithm, :N, :Time_ms)vector_pivot
5×4 DataFrame
RowAlgorithm100100010000
StringFloat64?Float64?Float64?
1PSA0.0173560.1744781.80707
2NOAA0.0414610.4097194.17097
3Walraven0.0210010.2105452.11418
4USNO0.03772050.3723443.79621
5SPA0.3109493.1978632.5279
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)endaxislegend(ax1, position = :rb, framevisible = false, labelsize = 10)# Throughput plotax2 = Axis(fig3[1, 2],    title = "Throughput at N=10,000",    xlabel = "Algorithm",    ylabel = "Positions per Second",    xticks = (1:length(algo_names), algo_names),    backgroundcolor = :transparent,)throughput_10k = filter(r -> r.N == 10_000, vector_benchmarks)barplot!(ax2, 1:length(algo_names), throughput_10k.Throughput ./ 1e6, color = colors)ax2.ylabel = "Million Positions / Second"
Example block output

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.

Benchmarking Methodology

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 CondaPkgCondaPkg.add_pip("solposx")CondaPkg.add_pip("pandas")using PythonCall# Import Python modulessp = pyimport("solposx.solarposition")pd = pyimport("pandas")
Python: <module 'pandas' from '/home/runner/work/SolarPosition.jl/SolarPosition.jl/docs/.CondaPkg/.pixi/envs/default/lib/python3.14/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")endsolposx_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 sizessizes = [100, 1_000, 10_000]# Results storagecomparison_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),        ))    endendcomparison_results
15×5 DataFrame
RowAlgorithmNJulia_msPython_msSpeedup
StringInt64Float64Float64Float64
1PSA1000.0172.163125.2
2NOAA1000.0413.08675.9
3Walraven1000.0210.91343.3
4USNO1000.0382.49465.8
5SPA1000.3223.0119.4
6PSA10000.1762.46314.0
7NOAA10000.4013.6319.0
8Walraven10000.2121.1925.6
9USNO10000.3736.63117.8
10SPA10003.2187.6152.4
11PSA100001.7435.7933.3
12NOAA100004.0059.6032.4
13Walraven100002.1513.721.7
14USNO100003.80850.07113.1
15SPA1000032.56550.3991.5
Visualization
fig5 = Figure(size = (600, 750), backgroundcolor = :transparent, fontsize = 12, textcolor = "#f5ab35")# Group by algorithm for plottingalgo_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 legendsjulia_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 chartax2 = Axis(fig5[2, 1:2],    title = "Julia vs Python at N=1,000",    xlabel = "Algorithm",    ylabel = "Speedup Factor (×)",    xticks = (1:length(algo_names), algo_names),    backgroundcolor = :transparent,)speedup_1k = filter(r -> r.N == 1_000, comparison_results)barplot!(ax2, 1:length(algo_names), speedup_1k.Speedup, color = colors_julia)hlines!(ax2, [1.0], color = :gray, linestyle = :dash)
Example block output

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 algorithmpareto_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=1000julia_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=1000pareto_python = leftjoin(pareto_data,    select(julia_times_1k, :Algorithm, :Python_ms),    on = :Algorithm)rename!(pareto_python, :Python_ms => :Julia_ms)  # Reuse column name for conveniencepareto_python.Library = fill("Python", nrow(pareto_python))# Combine bothpareto_combined = vcat(pareto_julia, pareto_python)rename!(pareto_combined, :Julia_ms => :Time_ms)# Sort by error then timesort!(pareto_combined, [:Mean_Error, :Time_ms])pareto_combined
10×4 DataFrame
RowAlgorithmMean_ErrorTime_msLibrary
StringFloat64Float64?String
1SPA0.03.218Julia
2SPA0.07.615Python
3PSA0.0008569940.176Julia
4PSA0.0008569942.463Python
5USNO0.00256550.373Julia
6USNO0.00256556.631Python
7Walraven0.002973640.212Julia
8Walraven0.002973641.192Python
9NOAA0.003017880.401Julia
10NOAA0.003017883.631Python
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 algorithmscolors_pareto = Dict(    "PSA" => :steelblue,    "NOAA" => :orange,    "Walraven" => :green,    "USNO" => :purple,    "SPA" => :red)# Marker scheme for librarymarkers_lib = Dict(    "Julia" => :circle,    "Python" => :utriangle)# Plot pointsjulia_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)    endend# Add algorithm labels for Julia implementationsfor 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 implementationsfor 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 curvejulia_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 pointspython_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 discriminationylims!(ax, nothing, 0.005)# Add guidelines for accuracy levelshlines!(ax, [0.001, 0.002], color = (:gray, 0.2), linestyle = :dot, linewidth = 1)# Create legend for library typeslibrary_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)
Example block output

Summary

The benchmarks demonstrate that SolarPosition.jl offers significant performance advantages over the solposx Python library across all tested algorithms and input sizes.

Multi-threading

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.