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 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 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: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 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
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.003342610.00387120.001577862.61068e-50.008319380.0681224
2PSAMid-latitude (London)0.0006890031.86942e-50.002521550.0032890.001450746.25941e-50.004081510.00666008
3PSAHigh-latitude (Oslo)0.0005810131.91489e-50.00224970.002957580.001449946.91808e-50.003824280.00551483
4PSASouthern hemisphere (Sydney)0.0009405083.10488e-50.002997240.003781250.00145824.02358e-50.005608080.0142766
5NOAAEquator0.003714660.0001316780.01095850.01287090.004069374.94055e-50.02478470.157852
6NOAAMid-latitude (London)0.002742830.0001664220.006981960.009981130.004010220.0001263330.01185430.0175856
7NOAAHigh-latitude (Oslo)0.002582480.0002036150.006078270.008823850.00400520.0001374470.01095180.0149296
8NOAASouthern hemisphere (Sydney)0.003031570.000124670.009308710.0121740.004040349.39007e-50.01599620.0392285
9WalravenEquator0.002927310.0001229140.007069360.008350270.00538010.0002199380.0183720.133819
10WalravenMid-latitude (London)0.002953059.02431e-50.007839310.009086320.003149460.000140020.00774890.0113622
11WalravenHigh-latitude (Oslo)0.002967439.30684e-50.007728130.008967140.003051730.0001608520.006761890.00926879
12WalravenSouthern hemisphere (Sydney)0.003046750.0001634230.007148550.008096110.003698380.0001837220.01184050.029953
13USNOEquator0.002518010.0001206570.006818490.008537290.003524380.0001507050.01362070.0714088
14USNOMid-latitude (London)0.002688018.72288e-50.006583130.007952450.002388299.20632e-50.006632730.00976728
15USNOHigh-latitude (Oslo)0.002744589.02397e-50.006344790.007603490.002286729.7486e-50.006132860.00820491
16USNOSouthern hemisphere (Sydney)0.002311397.92274e-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 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)
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 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)
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: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)
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 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]]
5×4 DataFrame
RowAlgorithmTime_μsAllocationsRelative_to_SPA
StringFloat64Int64Float64
1PSA0.162200.0413865
2NOAA0.425600.108595
3Walraven0.198100.0505467
4USNO0.3716500.0948292
5SPA3.9191501.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
5×4 DataFrame
RowAlgorithm100100010000
StringFloat64?Float64?Float64?
1PSA0.0178480.1772151.81228
2NOAA0.04219750.4147184.13388
3Walraven0.020640.2034962.05593
4USNO0.03976150.3867693.87371
5SPA0.383243.8871838.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"
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 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
15×5 DataFrame
RowAlgorithmNJulia_msPython_msSpeedup
StringInt64Float64Float64Float64
1PSA1000.0182.625144.7
2NOAA1000.0433.5984.0
3Walraven1000.021.13855.6
4USNO1000.042.78369.5
5SPA1000.3833.0337.9
6PSA10000.1772.78415.7
7NOAA10000.4094.32110.6
8Walraven10000.2041.3496.6
9USNO10000.3876.34516.4
10SPA10003.8886.8721.8
11PSA100001.7875.8693.3
12NOAA100004.11410.2162.5
13Walraven100002.0583.751.8
14USNO100003.86743.06911.1
15SPA1000038.88144.1931.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)
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 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
10×4 DataFrame
RowAlgorithmMean_ErrorTime_msLibrary
StringFloat64Float64?String
1SPA0.03.888Julia
2SPA0.06.872Python
3PSA0.0008569940.177Julia
4PSA0.0008569942.784Python
5USNO0.00256550.387Julia
6USNO0.00256556.345Python
7Walraven0.002973630.204Julia
8Walraven0.002973631.349Python
9NOAA0.003017880.409Julia
10NOAA0.003017884.321Python
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)
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.