Repository navigation
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.Solarcomputes 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
ShadingSolveragainst 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.
| 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.
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.
File: Facts/IrradianceResultReference.cs
Methods compared:
DiGi.Solar.Query.SunDirection(DiGi.Core.Classes.Coordinates, DiGi.Core.Enums.UTC, System.DateTime, bool)- PVGIS
H_suncolumn
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.
-
SunDirectionreturns 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.
File: Facts/IrradianceResultReference.cs
Methods compared:
-
DiGi.Solar.Create.IrradianceResult(DiGi.Geometry.Spatial.Classes.Vector3D, DiGi.Geometry.Spatial.Classes.Vector3D, double, double, double, double)— theBeamcomponent - 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.
File: Facts/IrradianceResultReference.cs
Methods compared:
-
DiGi.Solar.Create.IrradianceResult(DiGi.Geometry.Spatial.Classes.Vector3D, DiGi.Geometry.Spatial.Classes.Vector3D, double, double, double, double)— theGroundcomponent - 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².
File: Facts/IrradianceResultReference.cs
Methods compared:
-
DiGi.Solar.Create.IrradianceResult(DiGi.Geometry.Spatial.Classes.Vector3D, DiGi.Geometry.Spatial.Classes.Vector3D, double, double, double, double)— theDiffusecomponent, 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.
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.SunDirectioncosts 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. -
ShadingSolveralready does this — it evaluates sun directions once per timestamp and groups them by angle throughDiGi.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 inSunDirection'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.SolarPowerResultcosts 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.
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()withComputeDeviceType = 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 areMath.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.Defaultfalls 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 oneComputeContextsubmission 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.Softwareis now[Obsolete]and gets no device, so a solve requesting it returnsfalse.ComputeDeviceType.Defaultno 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.
- Add the
[Fact]underFacts/inDiGi.Solar.xUnit, followingCoding - Automatic Tests.md. - Expose any sweep size as a
constorstatic readonlyfield 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-CimInstanceanddotnet --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
masteron the wiki clone, and verify the link in Home.