Skip to content

Benchmark

Jakub Ziolkowski edited this page Oct 14, 2026 · 7 revisions

Benchmark

Accuracy and throughput measurements for the DiGi.Solar irradiance layer and shading solvers.

Three kinds of workload are covered:

  • Accuracy — the irradiance components validated hour by hour against PVGIS v5.2, the photovoltaic geographical information system published by the European Commission Joint Research Centre. This is an external reference, not a restatement of how DiGi.Solar computes the values.
  • Throughput — the per-call cost of the irradiance and power factories, separated from the cost of the sun position they consume.
  • Shading solver engines — the CPU ShadingSolver against the ComputeSharp solver on a hardware GPU, over building grids of 5 to 720 surfaces, with the results checked for agreement before timing.

Every figure on this page is produced by a committed [Fact] in DiGi.Test/DiGi.Solar.xUnit. Each fact writes its own figures to DiGi.Test/user files/reports/, so any number here can be regenerated by running the named test.


Test machine spec

Component Specification
CPU AMD Ryzen 9 9950X, 16 cores / 32 threads
GPU NVIDIA GeForce RTX 5090
RAM 61.4 GB
OS Windows 11 Pro (10.0.26200)
.NET SDK 10.0.401
Build config Release

Numbers are machine-specific — re-run benchmarks on your own hardware before drawing comparative conclusions. Accuracy figures are deterministic and machine-independent; only the throughput section varies with hardware.


Reference data set

File: DiGi.Test/files/PVGIS_Poznan_2020.csv

Property Value
Source PVGIS v5.2, https://re.jrc.ec.europa.eu/api/v5_2/seriescalc
Radiation database PVGIS-SARAH2
Location Poznań, Poland — 52.4064 N, 16.9252 E, 81 m
Period 2020, the 5th and 20th of each month
Rows 236 daylight hours with GHI ≥ 50 W/m²
Planes horizontal (slope 0°), and slope 35° azimuth 0° (south facing)
Size 12 KB

The endpoint is called with components=1, which splits the output into Gb(i), Gd(i) and Gr(i) — the direct beam, sky diffuse and ground-reflected irradiance on the inclined plane — plus H_sun, the solar elevation. That is the same decomposition DiGi.Solar produces, so each component is validated separately rather than only their sum.

The data is committed rather than fetched at run time, so the suite stays deterministic and works offline. The source URL and retrieval date are recorded in the file header; re-fetching is a matter of re-running that URL.

Two properties of the source set the floor on any comparison below: PVGIS stamps each hour at :11 past in UTC (its satellite observation time), and it rounds the horizontal columns to whole W/m² while leaving the tilted columns fractional.


Solar position accuracy — IrradianceResult_PVGIS_SolarElevation

File: Facts/IrradianceResultReference.cs

Methods compared:

  • DiGi.Solar.Query.SunDirection(DiGi.Core.Classes.Coordinates, DiGi.Core.Enums.UTC, System.DateTime, bool)
  • PVGIS H_sun column

Description: For each of the 236 reference hours, the solar elevation is recovered from the SunDirection vector as asin(-z) of its unit form and compared against the elevation PVGIS publishes for the same instant. This validates the sun position model — and therefore the beam projection built on it — against an independent authority.

Results (deviation from PVGIS, degrees):

Statistic Value
Rows compared 236
Median 0.0822
99th percentile 0.2789
Maximum 0.2801

Analysis:

  • Agreement is well inside a third of a degree across all seasons.
  • The residual is consistent with PVGIS publishing elevation to two decimals and stamping the hour at its satellite observation time rather than at the hour boundary.
  • SunDirection returns the ray propagation direction, pointing away from the sun, so the sine of the elevation is the negated Z ordinate. Getting that backwards is what the beam comparison below detects.

Direct beam accuracy — IrradianceResult_PVGIS_BeamComponent

File: Facts/IrradianceResultReference.cs

Methods compared:

  • DiGi.Solar.Create.IrradianceResult(DiGi.Geometry.Spatial.Classes.Vector3D, DiGi.Geometry.Spatial.Classes.Vector3D, double, double, double, double) — the Beam component
  • PVGIS Gb(i) column, slope 35° azimuth 0°

Description: Direct normal radiation is reconstructed from the horizontal series as Gb(0) / sin(elevation), then projected onto the tilted plane by the factory. The beam component is pure geometry once the direct normal radiation is known, so this isolates the incidence cosine and with it the sun direction sign convention.

Editable knobs: rows are skipped below 10° solar elevation or below 50 W/m² of beam irradiance on either plane, where the reconstructed direct normal radiation is dominated by PVGIS's rounding of its published horizontal beam column.

Results (deviation from PVGIS, percent of the PVGIS value):

Statistic Value
Rows compared 135
Median 0.2028
99th percentile 1.2940
Maximum 1.5112

Analysis:

  • A median of 0.20 % against an independent implementation confirms the incidence cosine and the frame convention (X east, Y north, Z up).
  • The bound is a percentile plus a per-item maximum. The percentile carries bulk agreement; the maximum guarantees that no individual hour drifted into being a different answer.
  • This comparison is the sharpest detector of a sign error on the page. Computing the incidence cosine as a plain dot product rather than a negated one moves the median deviation to 100.0000 % — a sign error against real data is total, not subtle.

Ground-reflected accuracy — IrradianceResult_PVGIS_GroundComponent

File: Facts/IrradianceResultReference.cs

Methods compared:

  • DiGi.Solar.Create.IrradianceResult(DiGi.Geometry.Spatial.Classes.Vector3D, DiGi.Geometry.Spatial.Classes.Vector3D, double, double, double, double) — the Ground component
  • PVGIS Gr(i) column, slope 35° azimuth 0°

Description: Global horizontal radiation is taken as Gb(0) + Gd(0) from the horizontal series and the ground-reflected component computed for the tilted plane at a ground reflectance of 0.2.

Results (deviation from PVGIS, W/m²):

Statistic Value
Rows compared 236
Median 0.0288
99th percentile 0.7010
Maximum 1.4234

Analysis:

  • This is an exact check, not an approximate one. PVGIS computes the ground-reflected component with the same isotropic formula, GHI · ρ · (1 − cos β) / 2, and the same default ground reflectance of 0.2. A median of 0.029 W/m² is agreement to the limit of PVGIS's own rounding.
  • It therefore confirms both the formula and the value of DiGi.Solar.Constants.Albedo.Default.
  • Swapping the sky and ground view factors moves the median deviation to 41.79 W/m².

Sky diffuse model difference — IrradianceResult_PVGIS_DiffuseModelDifference

File: Facts/IrradianceResultReference.cs

Methods compared:

  • DiGi.Solar.Create.IrradianceResult(DiGi.Geometry.Spatial.Classes.Vector3D, DiGi.Geometry.Spatial.Classes.Vector3D, double, double, double, double) — the Diffuse component, Liu and Jordan isotropic sky
  • PVGIS Gd(i) column, slope 35° azimuth 0° — anisotropic Muneer model

Description: DiGi.Solar implements the isotropic sky model, which treats sky radiance as uniform across the dome. PVGIS uses an anisotropic model that adds circumsolar and horizon brightening. This section is not a correctness check — it quantifies and records a deliberate model difference so that a future anisotropic implementation has a measured baseline to improve on.

Editable knobs: rows below 20 W/m² of diffuse irradiance on the tilted plane are skipped, where a relative comparison is dominated by rounding.

Results (PVGIS less the isotropic value, percent of the PVGIS value — positive means the isotropic model reads low):

Statistic Value
Rows compared 236
1st percentile −14.7653
Median 12.3220
99th percentile 59.2340

Analysis:

  • The isotropic model reads low by a median 12.3 % on a sun-facing 35° plane, which is the expected direction: it ignores the circumsolar brightening that a plane pointed near the sun collects.
  • The spread is wide and sky-condition dependent — negative under overcast skies where the isotropic assumption holds well and the tilt factor slightly over-credits, strongly positive under clear skies with a bright circumsolar region.
  • The fact asserts only the direction of the difference and a broad band, deliberately. Bounding this tightly would be asserting that an isotropic model is something it is not.
  • Sky View Factor weighting per shaded region, listed as out of scope in DiGi.Solar#1, is the work this baseline exists to measure.

Throughput — IrradianceResult_Performance

File: Facts/IrradianceResultPerformance.cs

Methods compared:

  • DiGi.Solar.Create.IrradianceResult(DiGi.Geometry.Spatial.Classes.Vector3D, DiGi.Geometry.Spatial.Classes.Vector3D, double, double, double, double)
  • DiGi.Solar.Create.SolarPowerResult(DiGi.Solar.Classes.IrradianceResult, double, double)
  • DiGi.Solar.Query.SunDirection(DiGi.Core.Classes.Coordinates, DiGi.Core.Enums.UTC, System.DateTime, bool)

Description: Three paths are measured over the same evaluation count — the irradiance factory alone against a cached sun direction, the irradiance factory followed by the power factory, and the same work with the sun direction recomputed on every evaluation. The comparison shows what a caller pays for a sun direction it could have cached.

Editable knobs:

  • IrradiancePerformanceCount = 1000000 — evaluations per path.
  • IrradiancePerformanceHourCount = 8760 — distinct timestamps, one year of hourly data.

Correctness is asserted before timing: the cached and recomputed sun direction paths must produce the same total, and both power factories must produce the same power.

Results (Release, each [Fact] run alone, three runs):

Path Total (ms) Per call (µs) Relative
Create.IrradianceResult 59 – 62 0.0600 – 0.0621 1×
Create.IrradianceResult + Create.SolarPowerResult 54 – 58 0.0547 – 0.0581 ~1×
Query.SunDirection + both factories 48 750 – 49 525 48.7501 – 49.5252 ~830×

Sun direction accounts for 99.9 % of the full pipeline in all three runs.

Analysis:

  • Cache the sun direction per timestamp. At ~49 µs per call, Query.SunDirection costs roughly 830× the entire irradiance and power layer. A year of hourly values for one surface is ~0.4 s of sun position against ~0.5 ms of irradiance arithmetic.
  • ShadingSolver already does this — it evaluates sun directions once per timestamp and groups them by angle through DiGi.Solar.Query.GroupDirections(Dictionary<DateTime, Vector3D>, double). Callers assembling their own hourly loop should follow the same pattern.
  • The cost sits in Innovative.SolarCalculator.SolarTimes, which computes a full solar-time set including sunrise and sunset for every call, not in SunDirection's own trigonometry.
  • Noise transparency: the first two rows are within measurement noise of each other, and the two-factory path measured marginally faster than the single-factory path in two of the three runs. The correct reading is that Create.SolarPowerResult costs less than this benchmark can resolve, not that adding it is free of cost.
  • Both factories allocate one small object per call. Neither performs I/O, iteration, or cancellation-sensitive work, which is why neither takes a CancellationToken.

Shading solver engines — ShadingSolver_Benchmark_Receivers, ShadingSolver_Benchmark_Surroundings

File: Facts/ShadingSolverBenchmark.cs (parity helper AssertSameShadingFactors in Facts/ShadingSolverCPU.cs)

Methods compared:

  • DiGi.Solar.Classes.ShadingSolver.Solve(): CPU solver, no GPU or ComputeSharp dependency
  • DiGi.Solar.ComputeSharp.Classes.ShadingSolver.Solve() with ComputeDeviceType = DiGi.Solar.ComputeSharp.Enums.ComputeDeviceType.Hardware: ComputeSharp on the GPU

Both solvers compute the same shadows. For each receiver face and sun direction, every caster triangle is clipped to the part between the sun and the receiver plane and projected onto that plane. The CPU solver does this in a loop. The GPU solver does it in DiGi.ComputeSharp.Spatial.Classes.Triangle3ShadowProjectionComputeShader, which appends only the shadows found. Both then merge the shadows and clip them to the receiver face on the CPU, with the shared DiGi.Solar.Query.ShadedFaces(DiGi.Geometry.Planar.Classes.PolygonalFace2D?, System.Collections.Generic.IEnumerable<DiGi.Geometry.Planar.Classes.PolygonalFace2D>?) (DiGi.Solar#11).

The ComputeSharp row goes through the device selection of DiGi.Solar.ComputeSharp.Create.GraphicsDevice(DiGi.Solar.ComputeSharp.Enums.ComputeDeviceType), which accepts only devices passing DiGi.Solar.ComputeSharp.Query.IsSupported(ComputeSharp.GraphicsDevice?): hardware-accelerated, with double precision.

Description: A square grid of box buildings, 10 × 10 m footprint, 12 m high, with 8 m streets. Each building has five surfaces: four walls and a flat roof. The model is at 50.0 N, 20.0 E (UTC+1), solved for 2026-06-26 from 04:00 to 21:00 in hourly steps, which gives 16 daylight timestamps. Two configurations are swept over 1, 4, 16, 64 and 144 buildings, that is 5 to 720 surfaces:

  • Receivers: every surface is a receiver, so the buildings shade each other and themselves. The work grows with receivers × all triangles.
  • Surroundings: only the building at the centre of the grid receives (5 surfaces). Every other building is shading-only, so it casts shadows but gets no results. This is the configuration of a per-building solar analysis with neighbours, as planned for DiGi.GIS.WebAPI.UI#55.

Each measured solve runs on a fresh copy of the model, made before the stopwatch starts. Every engine is warmed up on the one-building model first, which excludes JIT and GPU pipeline creation.

Editable knobs:

  • ShadingSolverBenchmarkGridSizes = [1, 2, 4, 8, 12]: buildings per side of the grid.
  • ShadingSolverBenchmarkRepeatSurfaceCount = 720: timed solves per grid are Math.Max(1, 720 / surfaces), and the tables report the mean per solve.
  • ShadingSolverBenchmarkStepMinutes = 60: step between timestamps.
  • ShadingSolverBenchmarkDroppedFraction = 0: largest allowed fraction of dropped shadows (see the correctness notes below).

Correctness is asserted before timing. On every grid, the GPU solver must give each receiver the same shading factor as the CPU solver at every daylight timestamp, including samples with the sun behind the receiver (40 % of the samples on these grids). The 99th percentile of the differences must stay below 1e-12 and every sample below 1e-9. Neither engine may report a shadow the other one misses. Measured on all grids: 96–100 % of the samples are exactly equal, the 99th percentile is 1.1e-16, the largest difference is 2.2e-16, and there are no dropped shadows.

Results, receivers (Release, each [Fact] run alone, three runs, before and after DiGi.Solar#11 interleaved):

Buildings Surfaces Solves CPU (ms / solve) GPU before #11 (ms / solve) GPU (ms / solve) GPU / CPU
1 5 144 9.7 – 10.0 42.3 – 46.6 37.0 – 38.8 3.68 – 3.92
4 20 36 16.1 – 16.7 66.2 – 66.5 50.1 – 51.2 3.09 – 3.10
16 80 9 91.3 – 103.2 309.9 – 317.3 126.3 – 134.9 1.27 – 1.35
64 320 2 623.8 – 692.6 2 108.7 – 2 136.2 632.3 – 686.5 0.99 – 1.01
144 720 1 1 941.6 – 2 187.5 6 491.5 – 6 590.0 1 976.6 – 2 087.3 0.94 – 1.00

Results, one receiving building among shading-only surroundings (Release, each [Fact] run alone, three runs, interleaved as above):

Buildings Surfaces Receivers Casters Solves CPU (ms / solve) GPU before #11 (ms / solve) GPU (ms / solve) GPU / CPU
1 5 5 0 144 9.6 – 10.2 37.7 – 45.3 29.9 – 36.8 3.12 – 3.78
4 20 5 15 36 6.6 – 7.9 46.2 – 77.0 41.0 – 42.9 5.25 – 6.49
16 80 5 75 9 10.6 – 13.1 87.9 – 89.8 43.2 – 46.0 3.63 – 4.36
64 320 5 315 2 15.8 – 19.1 101.4 – 110.0 53.4 – 59.6 3.03 – 3.55
144 720 5 715 1 26.4 – 35.0 119.8 – 158.5 48.8 – 59.7 1.52 – 2.22

Analysis:

  • Since DiGi.Solar#11 the GPU solver matches the CPU solver on large receiver grids and is faster than its previous path at every size: 3.1–3.4× at 320 and 720 surfaces, and 2.0–3.2× with one receiving building among 715 shading-only surfaces (1.0–1.5× on the single building, within noise). The previous path copied back an N × (N + M) matrix of 152-byte intersection records per direction group, then merged 213 k re-triangulated fragments at 720 surfaces, about 70 % of its solve. The GPU now reads back only the shadows found, 80 bytes each, and the merge gets the same input as the CPU solver.
  • The CPU solver is still as fast or faster everywhere. The merge of the shadows stays on the CPU in both engines and is about two thirds of the CPU solver's work, so the GPU can only save the clip-and-project loop. On the receiver grids the saving and the GPU overheads cancel out (GPU / CPU 0.94–1.01 at 320 and 720 surfaces).
  • Decision: the GPU solver is kept as is (DiGi.Solar#12). It is at parity on large all-receiver models and stays optional: the CPU solver is the default engine, and ComputeDeviceType.Default falls back to it on a host with no FP64-capable GPU. Its agreement with the CPU solver is enforced by the parity assertions above, which run before every timing. Batching the direction groups into one ComputeContext submission was not pursued, since the merge on the CPU would remain the floor.
  • Fixed GPU overhead dominates small models: about 2 ms per direction group for the synchronous dispatch and its readback, plus the buffer allocations, which is 30–40 ms per solve on these 16-timestamp days. A one-building analysis among 715 shading-only surfaces costs 26–35 ms on the CPU against 49–60 ms on the GPU. The counter and records are staged through persistent UploadBuffer/ReadBackBuffers. A copy from or to a managed array costs 0.4–0.55 ms each, and staging cut 50 ms to 37.5 ms on the one-building model.
  • Scaling. With every surface a receiver, both engines grow a little faster than linearly in the surface count: ×6.7–6.8 (CPU) and ×5.0–5.1 (GPU) from 80 to 320 surfaces, ×3.1–3.2 (CPU) and ×3.0–3.1 (GPU) from 320 to 720. With one receiving building, the CPU cost grows only from 11–13 to 26–35 ms while the surroundings grow from 75 to 715 surfaces. A realistic neighbourhood therefore costs tens of milliseconds per building and day.
  • WARP is not measured: it was withdrawn. On WARP, the Direct3D 12 software rasteriser, the first solve in a process spent many minutes creating the double-precision pipeline, and WARP lost a large share of the shadows cast between buildings, where the hardware GPU lost none on the same models. Its fact was removed with it; the last measurements are recorded in DiGi.Solar#10. ComputeDeviceType.Software is now [Obsolete] and gets no device, so a solve requesting it returns false. ComputeDeviceType.Default no longer falls back to WARP on a machine without a hardware adapter; it falls back to the CPU solver.
  • Back-facing receivers. When the sun is behind a surface, both solvers report it shaded by whatever lies in front of its plane, so a wall of a closed building reads fully shaded. The previous GPU path decided per receiver triangle from the centroid of each intersection piece and read 0.5 to 0.93 there (DiGi.Solar#9). These samples are now compared like all others.
  • Dropped shadows. A merge of shadows that failed used to read as full sun: one east wall of the 720-surface grid at a 3° sun read 0 on the GPU against 0.9367 on the CPU. It was fixed by a snap-rounding retry in the merge (DiGi.Geometry#8) and by a clip-and-cap fallback when the merge still fails (DiGi.Solar#8). The benchmark now allows none and reports the count per grid.
  • Noise transparency: absolute times drift by 10–20 % between sessions on this machine (the CPU 720-surface solve measured 1.6–2.0 s in an earlier session of the same day). The before and after columns are therefore interleaved in one session, and the ratios are more stable than the times. Figures under 60 ms are averaged over 36–144 solves and move by up to 8 ms between runs.

Adding a new benchmark

  • Add the [Fact] under Facts/ in DiGi.Solar.xUnit, following Coding - Automatic Tests.md.
  • Expose any sweep size as a const or static readonly field at the top of the class.
  • Call every measured path once on small input before starting Stopwatch, to exclude JIT.
  • Assert that the compared implementations agree before timing them.
  • Write the measured figures to Core.xUnit.Query.ReportsDirectory(Assembly.GetExecutingAssembly()).
  • Build the library first — dotnet build DiGi.Solar/DiGi.Solar.csproj -c Release -m:1.
  • Run the fact in Release, filtered to that test alone, three times: dotnet test DiGi.Solar.xUnit/DiGi.Solar.xUnit.csproj -c Release -m:1 --filter "FullyQualifiedName~<TestName>". A number read off a full-suite run is contaminated by xUnit's parallel collections.
  • Capture the machine specification with Get-CimInstance and dotnet --version.
  • Add or overwrite the section here — title, test name, file, fully-qualified signatures, knobs, result table, analysis. Do not append a second table for a re-run; replace the existing one.
  • Confirm the bound can fail: mutate the implementation, watch the fact go red, then restore.
  • Commit and push to master on the wiki clone, and verify the link in Home.

Clone this wiki locally