Release notes¶
Concise user-facing summary of changes. For the full per-version history of every
commit, see qgis_plugin/solweig_qgis/metadata.txt
and individual git commits.
0.1.0b92 — 2026-07-09¶
Performance and GPU-utilisation work. Validation is unchanged (31/31 pass) and output is byte-identical except where noted. The main change: the per-tile ground view factor (GVF) geometry precompute, previously a CPU-only step that left the GPU idle for roughly 0.6 to 1.2 s per tile, now runs on the GPU (about 2.7× faster), matching the CPU result to ~6e-8.
- GVF geometry on the GPU. A new wgpu compute kernel does the GVF geometry
precompute, with a CPU fallback and an
enable/disable/is_gvf_precompute_gpu_enabledtoggle, running as a distinct labelled phase per tile. Precompute and the per-timestep GVF share one GPU context, so the geometry is computed once into resident buffers and read in place across all of a tile's timesteps (no readback/re-upload). Byte-identical: GPU and CPU Tmrt match exactly. - Empty-tile compute skip. Tiles that fall entirely outside the valid data (irregular extents) skip the whole shadow/GVF/aniso/radiation compute. Output is byte-identical on valid pixels; the only change is that shadow over nodata is now NaN rather than a spurious "sunlit" 1.0.
- Async and overlapped I/O. Tiled GeoTIFF writes run on a background thread,
the next tile's data is prefetched while the current tile computes, and the
summary GeoTIFF export is written in parallel (about 5× faster, concurrency
bounded by grid size so large rasters don't spike memory). All byte-identical;
disable with
SOLWEIG_ASYNC_OUTPUT=0. - Night-shadow skip. With the sun below the horizon there are no cast shadows, so the shadow ray-march is skipped (byte-identical).
- Fix. Isotropic runs over an irregular extent could panic in the side-radiation kernel (a non-contiguous cropped valid mask); the mask is now made contiguous at the FFI boundary.
0.1.0b91 — 2026-07-08¶
Large-raster memory reductions. No numerical change: output is byte-identical, and golden tests and validation site numbers are unchanged from b90 (validation 31/31 pass). Measured cold Madrid (507 megapixels, isotropic): prepare peak 35.6 → 27.3 GB, overall peak RSS 38.1 → 32.9 GB.
- Base-layer load and alignment in
prepare()processes one layer at a time through disk-backed storage instead of holding the whole layer stack as resident RAM. findwallsuses pairwisenp.maximuminstead of stacking the four cardinal neighbour grids, removing an ~8 GB whole-raster temporary (the largest hard-RAM spike). Element-wise max and NaN handling are identical, so wall outputs are unchanged.- The tiled-SVF output arrays and the tiled-calc summary grids no longer pre-fill the whole memmap up front and are flushed per tile. The tiles cover every pixel, so this is byte-identical while keeping resident memory to the active tile.
- Fixed a latent macOS hazard:
np.asarray(memmap)returns a plain-ndarray view, so anisinstancecheck missed disk-backed layers and a save over a live mapping could wedge the process in uninterruptible kernel state. Disk-backed layers are now detected by walking the array's base chain.
Known reproducibility note: large-raster calculate() output is deterministic
for a given tile size but shifts by ~0.029 °C mean (a small near-threshold tail
up to ~3 °C) across different tile sizes, because the compute kernels round
differently for different tile array shapes. This is floating-point-class, not
a correctness issue; pin tile_size for bit-reproducible large-raster runs.
0.1.0b90 — 2026-07-07¶
Opt-in UMEP 2026a ground-surface scheme (use_ground_scheme /
use_outgoing_longwave), Python API only, not exposed in the plugin UI.
Defaults off, so model output is unchanged from b89 (golden output
byte-identical, validation 31/31 pass with b89 numbers). The scheme requires a
land-cover grid, rejects tiled processing, and currently runs warm against the
validation sites; a question about its radiation composition is open with the
upstream developers. See VALIDATION.md for the field comparison.
0.1.0b89 — 2026-07-06¶
Correctness release from a full-repository review. Numerical output
changes for two classes of runs; see VALIDATION.md for the audit
trail.
Physics fixes¶
- Clearness index pressure unit:
Weather.pressure(hPa) was scaled as if it were kPa inside the Crawford & Duchon transmission formula, underestimating clear-sky irradiance by ~20–25 % and disabling the Ldown cloud correction for hazy skies. EPW/values-driven runs change; validation-site numbers moved marginally (sites use measured radiation). - GVF source area at non-1 m pixels: the metres→pixels conversion
multiplied by pixel size where UMEP divides. At 2 m pixels the ground
view factor integrated over 144 m instead of 36 m (at 0.5 m, 9 m). A
new parity gate pins GVF against UMEP's
gvf_2018aat 0.5/1/2 m. 1 m runs are unchanged; the 2 m validation sites now reproduce UMEP's own output. - Cached vs uncached GVF: the per-timestep cached path counted partially sunlit walls in its sunwall mask; all paths now share UMEP's fully-sunlit semantics.
Reliability fixes¶
- Tiled SVF preprocessing crashed with
NameErrorafter computing all tiles; it now completes and is covered by tiled-vs-single-shot parity tests. - Cancelling SVF preprocessing (QGIS) no longer persists a partial SVF
cache or a prepare fingerprint without SVF; cancellation raises the
new
solweig.ComputationCancelled, which the plugin handles as a graceful stop. - EPW parsing uses the per-field missing codes from the EnergyPlus spec
(RH 99 % and GHI/DNI/DHI 999 W/m² are real data again; dry-bulb 99.9
and pressure 999999 are recognized as missing).
Weather.from_epwwith no dates now loads the whole first day as documented. ModelConfig.from_jsonactually reads legacy parameter files (theValuenesting was skipped, so user values silently fell back to defaults), normalizing legacy units (Height in cm, Sex as text).- A stale precomputed shadow-matrix cache from a different patch option
raises
ValueErrorinstead of aborting the process; SVF caches are keyed on the trunk layer (TDSM/trunk_ratio) so trunk changes invalidate; land-cover nodata maps to the 255 sentinel instead of becoming "paved"; geographic-CRS inputs are rejected as documented.
QGIS plugin¶
- Loads cleanly when solweig is not installed (per-algorithm import isolation) and the anisotropic-sky hint points at an algorithm that exists in the toolbox.
Docs¶
- Tutorials are published as Markdown exported from the notebooks;
mkdocs-jupyter retired. Chunked-
calculate()guidance replaced (it dropped thermal state); dev-setup commands fixed; specs corrected (water-code quirk documented, GVF distance formula, GPU/CPU parity claim).
0.1.0b88 — 2026-05-27¶
Internal-only release — no library, plugin, or runtime change.
Performance-timing benchmarks moved off CI (where they fired false
positives on GPU-less runners) to a local-only gpu_perf_gate
marker. The hardware-stable memory benchmark stays on CI. Run the
full perf suite locally via poe test_gpu_perf_gate.
0.1.0b87 — 2026-05-27¶
No numerical change — golden tests byte-identical, validation site numbers unchanged from b82 baseline (31/31 validation tests pass).
Breaking¶
The top-level re-exports of geospatial helpers
(solweig.extract_bounds, solweig.intersect_bounds,
solweig.resample_to_grid, solweig.namespace_to_dict,
solweig.pixel_size_tag, solweig.compute_max_tile_pixels,
solweig.looks_like_relative, solweig.wallalgorithms) — deprecated
in b85 with a DeprecationWarning shim — have been removed.
Migrate by importing from solweig.geospatial instead:
# Before
from solweig import extract_bounds, resample_to_grid
# After
from solweig.geospatial import extract_bounds, resample_to_grid
Accessing the old names now raises AttributeError. The
solweig.geospatial submodule (added in b85) is unchanged.
GPU / CPU surface¶
solweig.disable_gpu()now toggles every GPU path. Pre-b87 it only flipped the shadow path; anisotropic sky and GVF kept running on GPU. A single call now genuinely produces a CPU-only run — the basis for the new parity tests below.solweig.enable_gpu()— new, mirrorsdisable_gpu(). Re-enables all three Rust GPU paths in a single call.- Lazy-init fix. First call to
is_gpu_available()used to unconditionally re-enable shadow GPU, silently overriding any priordisable_gpu(). Fixed:_ensure_gpu_initializednow only acts whenSOLWEIG_NO_GPU=1is set. - GPU metrics surface (new):
solweig.gpu_dispatch_count(),solweig.gpu_fallback_count(),solweig.reset_gpu_metrics(). Thread-safe atomic counters incremented at every shadow / SVF / aniso / GVF dispatch site in Rust. Lets tests assert "the GPU path actually ran" (paired withgpu_fallback_count() == 0).
New parity / benchmark coverage¶
- Shadow + SVF GPU-CPU parity tests (
tests/spec/test_gpu_cpu_parity.py). Mirrors the existing aniso parity pattern for the two remaining GPU paths. - GPU-CPU runtime ratio benchmark (
tests/benchmarks/test_gpu_cpu_benchmark.py). Runs the same scenario back-to-back with GPU on/off, asserts the ratio is above a hard 0.5 floor, warns below 1.0. Appends a row totests/benchmarks/logs/gpu_cpu_ratio_history.md(gitignored).
Known difference (documented, not a regression)¶
The new SVF parity test characterises a small known difference between the CPU and GPU vegetation-SVF kernels:
- Building SVF fields (
svf*): byte-identical between paths. - Veg-blocks-building (
svf_aveg*): byte-identical. - Vegetation-only fields (
svf_veg*): up to ~0.042 absolute drift in <1% of pixels, exclusively at canopy-edge pixels.
The drift values are integer multiples of the per-patch SVF weight
(~0.012) — i.e. 1–2 patches at the visible/blocked boundary disagree
between the two implementations. Both are correct to f32 precision;
the propagated Tmrt difference is below 0.5 °C (verified by
test_shadow_field_gpu_vs_cpu_match), well under any physically
meaningful threshold.
0.1.0b86 — 2026-05-27¶
Internal tidy-and-tighten pass following b85. No numerical change — golden tests pass byte-identical, validation site numbers unchanged from b82 baseline.
- Hot-file decomposition (continued). Five Python modules split below the 700-line audit threshold; every public symbol re-exported from its original module so no caller breaks:
models/surface.py3,016 → 1,731 (extractedsurface_loading,surface_compute,surface_svf_tiled,surface_serialization,surface_views)io.py1,259 → 678 (extractedio_epw,io_preview)summary.py935 → 493 (extractedgrid_accumulator)models/weather.py848 → 642 (extractedmodels/location)models/precomputed.py804 → 560 (extractedmodels/shadow_arrays) Audit hot-file count: 16 → 11 (remaining are Rust +surface.py+physics/sun_position.py).- Test coverage 75% → 81%. 117 new focused unit tests across
models/results,models/location,models/config,components/gvf,components/svf_resolution,io_epw,models/weather(UMEP met parser),models/shadow_arrays,models/precomputed(zip + memmap fixtures),io(bbox helpers, preview, north-up guards), anderrors. Coverage axis flips green for the first time. ThermalStatetop-level export. Now reachable atsolweig.ThermalState— previously only atsolweig.models.state.ThermalState. Promoted to a top-level export for advanced/internal use; thermal-state threading is handled internally by the timeseries loop (calculate()takes nostateargument).- QGIS plugin: 349 LOC of dead code removed. Deleted
create_surface_from_parametersand three helpers (the fossilised pre-prepare()loader). The production path already usedSurfaceData.prepare()directly; the dead function was only kept alive by its own tests. Plugin metadata changelog also trimmed from 254 → 96 lines. - Public docs reshaped.
- Physics nav now renders the 8 component specs inline (SVF, shadows,
GVF, radiation, ground temperature, Tmrt, UTCI, PET) via
mkdocs-include-markdown-plugin.specs/remains the canonical source so parity tests intests/spec/and the public docs share one file. - Developer-focused docs (
PRINCIPLES.md,INVARIANTS.md,ARCHITECTURE.md) moved to repo root; the public site's Development section is now just Contributing. Thedocs/development/audit.mdwrapper page was deleted (AUDIT.mdis already generated at root). - API docs gain
SurfaceData.prepare, the four GPU helpers (is_gpu_available,get_compute_backend,disable_gpu,get_gpu_limits), and theSettingsdataclass entry. - Audit script gains a 9th axis.
axis_canonical_docsvalidates that the 10 repo-root canonical docs are present, have inbound references from other canonical docs (no orphans), and that every relative.mdlink inside them resolves to an existing file. Catches the kind of drift thedocs/development/principles.md→PRINCIPLES.mdmove would otherwise have created. - Benchmark gates tightened.
tests/benchmarks/test_performance_matrix_benchmark.pyabsolute budgets cut from 1.5–4.0 s to 0.5–0.85 s (observed medians are 0.10–0.15 s on M-series Mac; ~4× headroom locally, ~8× in CI underSOLWEIG_PERF_BUDGET_SCALE=2.0). Ratio caps tightened too: aniso/iso 5→2, tiled/non-tiled 4→2.5, plugin/api 6→2. The old values would have masked any regression under ~10×. - Plugin wrapper cleanup. Inlined the 10-line
_looks_like_relative_heightswrapper; callers now importsolweig.geospatial.looks_like_relativedirectly.
0.1.0b85 — 2026-05-26¶
Architecture stabilisation pass. No numerical change — golden tests pass byte-identical, validation site numbers unchanged.
- Rust FFI bundling.
pipeline.compute_timestepsignature drops from 43 positional arguments to 18 via two new PyO3 classes: pipeline.SvfBundlegroups the 17 SVF / SVF-veg / SVF-aveg / svfbuveg / svfalfa rasterspipeline.StateBundlegroups the 9 thermal-state fields and carries an FFI version field (pipeline.STATE_BUNDLE_VERSION). The bundle constructor raisesValueErroron version mismatch so a stale Python/Rust pairing fails loudly instead of silently mis-mapping fields.solweig.geospatialsubmodule. The geospatial helpers previously promoted to the top level (extract_bounds,intersect_bounds,resample_to_grid,pixel_size_tag,compute_max_tile_pixels,looks_like_relative,namespace_to_dict,wallalgorithms) have moved tosolweig.geospatial. Top-level access still works for backwards compatibility but emits aDeprecationWarning(removal target: 0.1.0b88 or first 0.2.x).Settingsdataclass. New typedsolweig.models.settings.Settingswith explicit override semantics (per-call kwargs >ModelConfig> bundled defaults). Replaces the 50-line override block that used to live incalculate(). No public-API change.SurfaceDatatyped views. New read-only viewssurface.geometry,surface.optical,surface.auxiliarygroup the SurfaceData fields by concern. Used internally by the production compute path; existing attribute access (surface.dsm,surface.svf, …) is unchanged.- Cache-key hardening.
computation._arr_keynow includes witness bytes from the array's first/middle/last element, catching in-place mutations that the previous(ctypes.data, shape)key missed. The documented invariant remains "don't mutate surface arrays after passing them tocalculate()" — see INVARIANTS.md. - Foundation docs (repo-only): PRINCIPLES.md (what the library is for, the four identities it serves, the architectural rules) and INVARIANTS.md (the seven load-bearing assumptions the code relies on but does not always enforce).
- Repository audit script.
poe auditruns eight measured signals and writesAUDIT.mdat the repo root. Wired into CI as an informational job. Tracks Rust panic surface, type strictness, test coverage, CI/poe gap, public-API discipline, docstring coverage, hot files, and dependency freshness. - Rust panic hardening (continued).
vegetation.rspanic surface reduced from 44 to 14 sites via a documentedas_slice_checkedhelper that captures the contiguity invariant in one place. Repository-wide Rust panic rate drops to 3.15 / kloc (under the 5.0 threshold). - CI: new
auditandtest-slowjobs;test-specnow includes slow spec tests (UMEP parity, anisotropic pipeline) so drift doesn't sneak through the fast gate.
0.1.0b84 — 2026-04-16¶
Wall-aspect kernel refactor + small public-API tidy-up. No physics changes.
- Wall-aspect kernel now uses
f64internals and banker's rounding to match numpy'snp.round, bringing the Rust kernel closer to UMEP numerics. The Pythonwallalgorithms.pyGoodwin fallback was deleted — the Rust kernel is the single code path for both pip and QGIS users. - Public API surface explicitly exposed on
solweig/__init__.py:extract_bounds,intersect_bounds,resample_to_grid,namespace_to_dict,pixel_size_tag,compute_max_tile_pixels,looks_like_relative,wallalgorithms. The QGIS plugin no longer reaches into internals. - QGIS plugin error wrapping:
QgsProcessingExceptionmessages now exposeSolweigErrorstructured attributes (field,expected,got,reason, …).
0.1.0b83 — 2026-04-13¶
Documentation-only fix.
- PVGIS TMY reference period corrected in docs: 2005–2023 (v5.3 release), not 2005–2020. Clarified that TMY row timestamps legitimately span multiple years (each month is a real historical month).
0.1.0b82 — 2026-04-11¶
Shadow scale-convention fix (correctness) + cache and tile-sizing improvements.
- Shadow caster (Rust): Fixed silent scale-convention inversion in the
tan_altitude_by_scaleoperator. Before this release, shadow length was physically wrong at non-1 m pixel sizes. Validation RMSE improved dramatically at Gustav Adolfs (9.3–18.9 °C → 5.7–7.5 °C) and GVC (11.5–15.6 °C → 1.5–6.1 °C). - DEM stair-step smoothing with automatic int16 1 m quantization detection.
prepare()warm-run fast-path — ~100× speedup on cache hit with detailed mismatch logs.- Tile sizer no longer emits a spurious "buffer exceeds limit" warning.
GridAccumulator.update()optimised with in-place numpy ufuncs.
Known systematic bias (all versions)¶
Modelled downwelling longwave (L↓) is consistently +18 to +55 W/m² above
observations across all validation sites. This is a formulation issue
inherited from UMEP (Jonsson et al. 2006: non-sky hemisphere filled with
wall emissions at air temperature, while real shaded walls are cooler) and
is not run- or sun-position-dependent. See
VALIDATION.md § Ldown positive bias.