Everything below is relative to 1.0.0 (published on CRAN 2026-08-07). The major bump is warranted: three exported functions are removed, and several defaults change the result of a fit or a comparison, so the same script can get a different answer. Both are under "Breaking changes" — read that section before upgrading a running analysis.
These notes describe what changed for a user of 1.0.0. A good deal of this release was written after 1.0.0 and then revised before shipping; defects that existed only between those points are not listed, since no released version behaved that way. The commit history has that record in full.
Throughout, raises a warning means a genuine R warning() — one
tryCatch(warning = ) catches, suppressWarnings() suppresses and
options(warn = 2) escalates. Logs a warning means a logger message in the
"spatialkit" namespace, which none of those touch.
Each item below was measured, and the measurement is quoted so you can judge whether it affects an analysis you have already run.
-
residual_morans_i()no longer puts weight on a point's own residual.FNN::get.knn()reports a point's OWN index among its neighbours whenever exact duplicate coordinates are present, which put1/kon the diagonal of a matrix Moran's I is only defined for with a zero diagonal. On 40 sites x 4 repeats with a response carrying no spatial structure, 120 of 160 rows gained a self-weight, mean I came out at +0.086 against E[I] = -0.0063, and 77% of samples were "significant" at p < 0.05 against a nominal 5%. Repeat observations at one site are exactly whatmake_folds(method = "leave_location_out")is for, so this was a mainstream input. The dense fallback never had the fault, so the statistic also depended silently on whether FNN happened to be installed; the two paths now share one neighbour lookup and agree exactly.Requesting
k + 1neighbours and dropping self is not sufficient on its own — the slot self occupied displaced a genuine co-located neighbour and left a farther point standing in for it (75 of 400 retained pairs sat at distance 121 where a neighbour at distance 0 existed). Duplicate coordinates are now grouped and answered exactly. -
residual_morans_i()gains anullargument, defaulting to"auto". Model residuals are not exchangeable — they are orthogonal to the design matrix — so the classical randomisation moments are wrong for them. At n = 120 with six smooth covariates and independent errors, OLS residuals had mean I = -0.031 against the exchangeable E[I] = -0.008, and the z-score averaged -0.54 with sd 0.90 instead of 0 and 1. The Cliff & Ord (1981, sec. 8.3) regression-residual moments restore mean z = -0.09, sd 1.03 and a 4.3% rejection rate against a nominal 5%, and agree withspdep::lm.morantest()to machine precision (verified at 1e-16 through the public function)."auto"applies them only when the fit's residuals really are the OLS residuals on the rebuilt design, which a forest's and a working GWR's are not; the null actually used is reported in the return value. -
summarize_by_cell()standard errors under a design effect were too small.s / sqrt(n / deff)corrects the mean's variance for clustering but leavess^2biased low by the same clustering: for exchangeable correlation rho,E[s^2] = sigma^2 (n - deff)/(n - 1). The two errors compound. Measured 95% CI coverage at n = 20: 0.905 at rho = 0.3, 0.796 at rho = 0.6, 0.632 at rho = 0.8; after rescaling bysqrt((n-1)/(n-deff)), 0.952 / 0.952 / 0.953. Applies todeff = "kish",deff = "variogram"and a fixed numericdeff. The defaultdeff = 1path is bit-identical to before. -
determine_optimal_levels()ranks model-aware candidates on the standardised deviate, not on |Moran's I|. E[I] and Var(I) both depend on the cell count, so |I| shrinks as k grows whether or not the finer tessellation captures anything. Over 300 replicates of a response with no spatial structure, mean |I| fell monotonically from 0.114 at k = 10 to 0.050 at k = 60 — an |I| ranking prefers the largest candidate for arithmetic reasons alone. Candidates are now ordered by |z| using the Cliff & Ord residual moments (exact here, since the cell-level residuals are OLS residuals by construction); over the same runs z had mean ~0, sd ~1 and a 5% rejection rate of 0.040-0.057 at every k. The"diagnostics"attribute now carriesmoran_zalongsidemoran_i. -
estimate_sac_range()sweeps four azimuths, not two. A +/-22.5 degree window around 0 and 90 covers exactly 90 of the 180 distinct azimuths — every direction between 23 and 67 degrees, and between 113 and 157, fell into neither. On simulated fields with 3:1 anisotropy and a true major-axis range of 300, the estimate came back at 255 and 249 for major axes at 0 and 90 degrees but 151 and 147 at 45 and 135. Sincemake_folds(auto_range = TRUE)sizes blocks from this number, a diagonally oriented field silently got blocks half as wide as the correlation they were meant to separate.c(0, 45, 90, 135)tiles all 180 azimuths; the same fields now return 255 / 245 / 249 / 228. A direction whose variogram never reaches a sill is excluded rather than taken as a long range, and thedirectionalattribute now has four named entries. -
fit_bayesian_spatial_model()'s calibrated length-scale prior never reached Stan.brms::set_prior(spec, class = "lscale")with nocoefis a global prior, and brms applies a global prior only to coefficients with no individual prior of their own — everylscalecoefficient always has one. brms dropped it with a note and Stan received brms's defaults, which madegp_lengthscale_bounds(), the tail calibration and$info$gp_lscale_priordead weight. Confirmed withbrms::make_stancode(): the requested prior is absent under the global form and present under the coefficient-level form, which is now used.$info$gp_lscale_prioris read back frombrms::validate_prior(), so it records what brms will actually use. -
The GP basis was sized against the wrong domain measure. brms builds the boundary as
choose_L(x, c) = c * max(1, max(x) - min(x))over the pooled, column-centred covariates — the full range, not the per-axis half-range in which Riutort-Mayol et al. state their inequalities. Recovering the boundary frommake_standata()'s eigenvalues confirmsL = c * full rangeexactly at everyc, so the old convention built a boundary twice as wide asgp_kwas sized for: the GP was under-resolved, and$info$gp_ell_min— the diagnostic meant to catch exactly that — was twice too lenient to fire. Thecfloor is now brms's own default 1.25 rather than 1.2. -
fitted()on agwr_fitcould return a coefficient surface. The search for GWmodel's fitted-value column matched the whole candidate name vector with%in%and took the first hit in the SDF's column order — and the local coefficients come first. A predictor namedfit,pred,prediction,fittedoryhattherefore returned its own coefficient column, silently: executed in-sample R^2 was -1.18 against a true 0.981, andresiduals(),summary(),model_metrics(),compare_models()and everycv_gwr()fold consumed it without a warning. The search now runs in preference order and excludes any candidate that is also a model term; all five colliding names now give R^2 = 0.981, identical to the renamed control. -
coef.gwr_fit()returned GWmodel's whole SDF data slot — 15 columns for a two-predictor fit, of which 3 are coefficients and the rest are standard errors, t-values, the response, the fitted values, residuals andLocal_R2. It now returns the model terms only; reach forobject$engine$SDFfor the rest. -
estimate_sac_range()is reproducible, and no longer disturbs the caller's RNG.seednow defaults to123Lrather thanNULL. Then_maxsubsample is an internal approximation, not part of the answer, and leaving it unseeded made the returned range differ between runs on identical input (19531 / 19589 / 19605 on three calls) while silently advancing the caller's stream — andmake_folds(auto_range = TRUE)sizes its blocks from that number. Passseed = NULLfor the old behaviour. -
estimate_sac_range()rejects a non-numeric response.as.numeric()on a factor returns its level codes, so a factor response produced a variogram of an arbitrary integer relabelling of the categories and the estimated range changed when the levels were reordered (3700 against 2497 on the same data). Factors and character columns are now an error naming the column; logicals are read as 0/1. -
Fold sets built from a different dataset are refused. Fold splits are lists of
..row_idvalues, and row IDs areseq_len(nrow())unless supplied, so passingcv_gwr()afoldsobject built from another dataset of the same size applied cleanly — every ID matched, every fold was populated, and the model was scored on splits describing other observations.make_folds()now records a small projection-invariant row fingerprint inparams$row_probe, andcv_gwr(),cv_bayes(),cv_spatial()andcv_rf()error rather than proceed. Fold objects from earlier versions carry no fingerprint and are passed through unchecked. -
evaluate_insample()rejects duplicated names infits.modelis the keycompare_models()joins its metric and Moran's I tables on, so two fits called"GWR"produced a 2x2 cross-join: four rows, every one carrying the first fit's numbers, with the second fit never scored at all. -
fit_gwr_model()rejects a non-numeric predictor.gwr.basic()expands contrasts viamodel.matrix()and fits, butgwr.predict()does not and fails, so the model appeared to fit and then silently predicted allNA. -
fit_gwr_model()no longer rejects a two-valued continuous response. The "binary" error is now gated on the response being integer-like. A left-censored or saturated measurement (every observation at a detection limit or a ceiling) has two distinct values and is perfectly continuous; it now warns instead. The guard also runs once per fold insidecv_gwr(), where a small training fold can legitimately hold only two distinct values. -
fitted()returning the wrong length, or nothing, is now an error insummary()andmodel_metrics()rather than a plausible row count over an all-NAcomparison.new_spatial_fit()is the documented extension point, so a subclass with a missing or mis-sizedfitted()method is user-reachable. -
The cached
fitted()on abayesian_fitis stamped with thenand a digest of the data it was computed from. The cache environment has reference semantics — which is what makes it survive copy-on-modify — sofit2 <- fitgave both objects the same cache, and assigning different data to the copy returned the original's values at the original's length. -
make_folds()drops rows with empty or non-finite coordinates, with a logged warning naming the count, rather than letting anEMPTYPOINT reachblock_kfold's nearest-block rescue and die with "replacement has length zero". -
When every fold fails, the warning now names the first underlying error. Previously "all 5 folds failed" was the whole diagnosis even when the cause was simply that brms or GWmodel was not installed.
-
make_folds()records the CRS the folds were built in asparams$crs. Geographic input is projected byensure_projected()to a CRS the caller never chose, andblock_size,sac_rangeandbufferare lengths in that CRS. -
.morans_i_for_k()returnsNAat or below nine cells, where every cell neighbours every other and Moran's I collapses to exactly-1/(k-1)for any residual vector — a function of the cell count alone. -
residual_morans_i(fit, k = 1)works on machines without FNN.apply()simplified the length-1 result to a vector, making the neighbour index a 1 x n matrix and every row after the first out of bounds.
Three reviewers were set the task of making the package fail or silently misbehave on valid input, with their own reproductions. Every finding below was reproduced here before it was touched; the figures quoted are from those reproductions.
-
summarize_by_cell(deff = "kish")under-estimated the predictor ICC by about a factor ofm. The pooled one-way ANOVA grouped themz-scored predictor columns under the same cell label, so independent per-column cell effects averaged away in the shared cell mean and the between-cell sum of squares shrank by ~1/m. Measured at true rho = 0.5 with m = 4: pooled ICC 0.12 against 0.495 per column, so every predictor SE was ~44% too small. The pooled group is now (variable, cell), which recovers 0.49. -
Design effects are built from what a column actually observes. A cell of 10 rows with 2 finite responses had its response SE formed at the 10-row design effect, then applied to a 2-observation mean: adding 8 NA-response rows moved the SE from 8.46 to 26.38. Each column's design effect now uses its own non-missing count, with the mean pairwise correlation recomputed over the observed locations when a cell has NAs;
cell_weightis the effective count of the primary variable, not of rows (nstill counts rows). -
CRS-less coordinates get ONE interpretation, wherever they enter.
prep_model_data()assumed EPSG:4326 for CRS-less data that looked like lon/lat and projected it, while everypredict()method passed the fit's CRS as a target — a branch that stamped it onto the raw numbers. The same rows sat in two different places, andpredict(fit, newdata = training rows)disagreed withfitted(fit)by up to one response SD (R² 0.98 in-sample, 0.64 via newdata). The heuristic is now a single function used by both branches; a fit records the assumption it was built under and replays it on CRS-lessnewdata. Two further symptoms of the same split — CRS-less LINESTRINGs aborting incoerce_to_points()("crs not found"), and hex/squarebuild_tessellation()refusing input voronoi accepted — are fixed with it. The assumption is now announced with a real R warning. -
residual_morans_i()refuses a malformedweightsmatrix instead of silently substituting the default k-NN(8) matrix (I = 0.874 returned for four malformed shapes against 0.805 for the weights actually supplied). -
The fold-provenance fingerprint no longer refuses the caller's own data. Three defects in the version introduced last pass: a character
..row_idwas coerced to all-NA(and matched row 1 everywhere); coordinates were compared as"%.7g"strings and flipped on the ~1 in 5000 that a reprojection moved by 5e-9°; and polygon input was probed after pointization, so a differentpointizein the cv call read as different data. Both sides now probe the geometry as supplied, keep IDs in their own type, and compare numerically within 1e-6°. -
n_folds_attemptedcounts the folds supplied. A fold whose test rows were all removed as incomplete vanished before fitting and was absent from both counts, so five supplied folds reported4/4. It is announced with a real warning. -
determine_optimal_levels()'s elbow uses the signed deviation below the chord.abs()let a concave bump above the chord — a k where k-means fell into a worse local optimum — win with the same magnitude. -
estimate_sac_range()returnsNAfor a constant variable (an exactly explained response, or a constant one) instead of a fitted "range" of 168 or 673 from a variogram that is identically zero.make_folds(auto_range = TRUE)no longer re-opens the unseeded subsample by forwarding its ownseed = NULL. -
fit_bayesian_spatial_model()attaches a user-supplied globallscaleprior at coefficient level, the same way it does its own, soset_prior(..., class = "lscale")reaches Stan instead of being discarded. -
fitted()on agwr_fitreturns the prediction when a predictor is namedprediction. The model-term exclusion added last pass was applied togwr.predict()'s SDF too, where coefficients are suffixed_coefand the column literally namedpredictionis the prediction;predict()returned allNA. -
Smaller: a logical response meets the same binary-response guard as
0/1;model_metrics()errors on a non-numeric response instead of returningn = 0;fitted.bayesian_fit()errors when the posterior cannot be drawn instead of returning silentNA;create_grid_polygons()refuses a grid abovemax_cells(default 1e6) up front;make_folds(method = "buffered_loo")states its guard in bytes (splits are ~4n² bytes; the old n = 20000 cap admitted 1.6 GB);-0and0are the same coordinate in the duplicate-aware k-NN;.morans_i_for_k()returns theNApair whenever the moments are unavailable. -
Documented warnings are now R warnings. Eight paths the manual described as warning only wrote a logger line, invisible to
tryCatch(warning = ),expect_warning()andoptions(warn = 2):residual_morans_i()returningNULL, the CRS assumption inensure_projected()and stamping inharmonize_crs(), GWR collinearity, seed clamping invoronoi_seeds_kmeans()/get_voronoi_seeds(), dropped rows inensure_stable_poly_id(), and the dropped column inassign_features_to_polygons(). Three deliberate methodological cautions (include_coords = TRUE,random_kfoldfeature selection, non-standardised Moran weights) stay logged and their documentation now says so.
Seven reviewers were set the same task as the fourth pass, one per area of the package, each with their own reproductions. Every finding below was reproduced here before it was touched, and the figures quoted are from those reproductions.
-
predict()on a Bayesian GP fit depended on which other rows shared the call. brms 2.x storesXgp,dmaxandcmeansin a fit's GP basis but not the Hilbert-space boundaryL, sobrms:::.data_gp()recomputes it from whatever rowspredict()is handed. Every eigenfunction of the approximation therefore moved with the newdata bounding box while the fitted basis coefficients stayed put. Measured:Lwas 5.57 at fit time, 4.02 for a five-rownewdataand 3.63 for one row;predict_surface()on the same 10,000-cell grid differed by 1.78 betweenchunk_size = 5000(the documented default) and a single call; andcv_bayes(), which predicts each test fold separately, scored every fold against a basis the model was never fitted with.predict.bayesian_fit()now pins the boundary by appending the training coordinate extrema and dropping them again, so the chunked, fold-wise and single-call answers are identical. -
A predictor whose name is not a syntactic R name fitted a different model. Every backend builds its formula from these names, so a column called
"B5-B4"was fitted asB5 - B4— a different model, silently, with$predictor_varsstill reporting"B5-B4"— and"band 4"died insidestr2lang()with a parser error naming no column.prep_model_data()now refuses such names up front and says which column and what to do about it. (Backticking is not a fix: thespcoercion GWmodel needs runs the names throughmake.names()anyway, after which the formula and the data disagree.) -
summarize_by_cell(deff = "kish")weighted the response with a predictor's ICC. The fallback to the predictor ICC is for the case where no response was supplied; applying it whenever the response's own ICC came out non-positive meant that merely adding a predictor to the call changed the response's regression weight by a factor of 20, while the response SE was (correctly) left atdeff = 1. -
A column literally named
nwas summarised as the row count.dplyr::summarise()makes each new column visible to the expressions after it, so the row count shadowed a response or predictor namedn:resp_mean_ncame back equal ton, withsdandseNA, and no message. -
Variogram models are fitted with a nugget. A nugget-free model forces the curve through the origin, and gstat's default
N/h^2weights buy that constraint by collapsing the range: with a 50% nugget the fitted range came back at about 0.45 of the truth, somake_folds(auto_range = TRUE)built blocks less than half the correlation length it reported. -
The directional maximum is used only when anisotropy is established. Splitting 180 degrees four ways leaves each directional variogram about a quarter of the point pairs, and the maximum of four noisy estimates is biased upward: on isotropic simulated fields the returned range ran about 40% above the truth and the "notable anisotropy" warning fired on the majority of them. An omnidirectional variogram is now fitted alongside and is the default answer; the directional maximum is used when all four directions fit, their ratio exceeds 1.5, and the widest stands more than 1.5x above the all-pairs estimate. On the package's own test field (true range 80) the old rule returned 248 and the new one returns 84; genuine 3:1 anisotropy is still recovered.
-
predict()replays the CRS decision the fit was made under, including a negative one. When CRS-less training data were passed through as planar, nothing recorded that, so everypredict()re-ran the lon/lat heuristic onnewdataalone — and a subset of those same training rows, whose own bounding box sits inside the lon/lat envelope, was judged differently from the whole: taken for degrees, reprojected, and predicted about 1e6 m from where it was fitted.predict(fit, training_subset)disagreed withfitted(fit)[subset]by more than the response's standard deviation whilepredict(fit, full_data)agreed exactly. -
Antimeridian data are no longer flattened onto EPSG:3857. A bounding box cannot distinguish global coverage from a layer straddling +/-180 degrees, but the coordinates can (one very large gap in the sorted longitudes). Web Mercator splits such a layer: two stations 41 km apart came out 40,068 km apart, destroying every distance downstream. Only genuinely global coverage now falls back to EPSG:3857.
-
The projection for a wide extent is chosen by measurement, not by rule of thumb. Albers standard parallels came from the bounding box while the conic/azimuthal branch came from the centroid latitude, so a trans-equatorial extent put the parallels either side of the equator: at
lat_1 = -lat_2PROJ refused the string outright andensure_projected(),make_folds()andbuild_tessellation()all aborted with an internal-looking "invalid crs", and just short of it the projection distorted distances by 15.6% against the single UTM zone's 1.65%. The candidates are now scored by projecting a sample of the data's own points and comparing planar with geodesic distances; the message reports both figures. -
fitted.gwr_fit()could return a coefficient surface. Which GWmodel call produced an SDF was inferred from its column names, so a predictor ending in_coefmade agwr.basic()SDF look like agwr.predict()one and switched off the model-term exclusion; with a second predictor namedyhat,fitted()then returned that predictor's local coefficient surface (R2 -2.28 against 0.986) andresiduals(),summary()andmodel_metrics()followed it. The mode is now passed explicitly. -
A logical predictor supplied as text was silently mis-coded.
is.numeric,is.factorandis.characterare allFALSEfor a logical, so the type guard skipped it entirely: a"TRUE"/"FALSE"character column — what a CSV round trip produces — was factor-coded 1/2 against splits built on 0/1, sending every row to theTRUEside (correlation 0.21 with the correct predictions). -
compare_models_cv()scores every model on identical folds. The documented guarantee only held whenfoldswas supplied: withfolds = NULLeach backend built its own fromk,seed,block_sizeand the rest, so a per-modelblock_sizeput 104 of 150 rows in different folds for GWR and RF, andseed = NULLdid the same with no overrides at all. The folds are now built once, and the arguments that decide the split are protected. -
User weights with a non-zero diagonal. Every moment of Moran's I assumes no observation is its own neighbour. A self-inclusive row-standardised kNN matrix — an easy thing to build by hand — rejected the null on 75% of white-noise residuals at a nominal 5%, with no condition raised. The diagonal is now zeroed with a warning.
-
build_tessellation()$indexno longer snaps outside points to the nearest cell. Points outside every cell were assigned to whichever was closest, so a summary built from the index counted all 40 points of a layer whose study area held 10, whileassign_features_to_polygons()on the same cells correctly reported 30 misses. Only a point within a thousandth of a cell width is snapped now; the rest areNA, and the count is logged. -
ensure_stable_poly_id()is stable across projections again. The sort key was the raw double centroid, so cells sharing an exactxin one CRS differed by ~1e-11 degrees after a round trip through another: 14 of 16 cells got a different ID depending on which CRS the layer arrived in — the exact failure the function exists to prevent. The key is now rounded to 7 decimals (about a centimetre). -
A numeric
deffis applied as the uniform inflation it is documented to be. TheE[s^2]correction the estimated design effects apply is derived from within-cell correlation and is unjustified for a constant the caller chose:deff = 2doubled a 3-point cell's SE instead of multiplying it bysqrt(2), and returnedNAfor every cell withn <= deff. -
The Kish ICC guard matches its documentation. The docs promise "at least 2 cells with 2+ observations; falls back to
deff = 1otherwise", and the check was onlyk >= 2, N >= 4: all-singleton cells give a within-group sum of squares of 0 and therefore an ICC of exactly 1 — a design effect ofnfrom data carrying no within-cell information at all. -
gp_lengthscale_bounds()and the GP basis use two dimensions and unique locations.stats::dist()uses every column, so POINT Z geometry gave 3-D length-scale bounds in mixed units; andbrms::gp()defaults togr = TRUEand reduces its covariates to unique rows before taking the boundary, so replicated locations made the package'sS— and with itgp_cand the basis-adequacy thresholdgp_ell_min— wrong by that factor (0.68x with one heavily-sampled station). -
estimate_sac_range()andmake_folds()drop the Z dimension.gstat::variogram()andsf::st_distance()use every coordinate dimension, so an XYZ layer had its elevation folded into each lag: the returned "range" was a length in 3-D (413.6 against 136.8 for the same stations) while the block grid, the buffered-LOO buffer, NNDM's neighbour distances andsummarize_by_cell()all work in 2-D map distance.
-
estimate_sac_range()returnsNAinstead of a number when the fitted range runs past the longest observed lag, or the optimiser stopped at its iteration limit. Such a range is unidentified, not long — the empirical variogram never reached a sill — and sizing blocks or design effects with it is worse than declining to. The refusal carriesrejected_rangeandrejected_reasonattributes and keeps the fitted variogram, soplot(type = "variogram")can draw exactly the case worth looking at; the result is classedsac_range, so it prints as a bareNArather than dumping the fit to the console. Callers that fed the old number straight intomake_folds(block_size = )now need to handleNA— that is the point. -
Removed the legacy wrappers
evaluate_models(),evaluate_models_cv()andphi_prior_bounds(). Usecompare_models(),compare_models_cv()andgp_lengthscale_bounds(). -
compare_models_cv()gains an"RF"branch and anrf_argsargument, so arangerforest can be compared against GWR and the Bayesian GP on identical folds. Unrecognised model names now raise a warning and are dropped, and a request with nothing recognised left is an error. Previously a bareintersect()discarded anything outsidec("GWR", "Bayesian")and fell back to GWR, somodels = "RF"silently ran GWR and reported it as the answer. -
coef()on aspatial_fitnow either returns coefficients or errors; it never returnsNULL.coef.gwr_fit()andcoef.bayesian_fit()used to returnNULLon failure, which is indistinguishable from "this model has no fixed effects", solapply(fits, coef)quietly produced a short answer.coef.rf_fit()errors as before — a forest has no coefficients; usefit$info$importance. See?new_spatial_fit. -
Every
predict()method errors when the number of predictions does not match the number of rows that survived cleaning. It used to recycle silently: two predictions for four clean rows produced a four-row answer. -
create_grid_polygons_cached()'s defaulttypeis now"square", matchingcreate_grid_polygons(). The two disagreed, so cached and uncached calls built different grids from the same arguments. -
build_tessellation()keepscell_idon"hex"and"square"grids instead of deleting it after indexing, so all four methods return the same ID column andplot_tessellation_map(fill_col = "cell_id")works on a grid.poly_idis retained alongside it.params$expandnow echoes the value passed rather than a hard-coded0; the grid and triangle methods still ignoreexpand, which is now documented. -
clip_target_for()projects lon/lat input before applyingexpand. The fraction-of-extent form was computed from a bounding box in degrees and handed tosf::st_buffer(), which readsdistas metres. The returned clip target is therefore in the projected CRS, not the input CRS, and says so. -
Voronoi, grid and triangle clipping union the boundary first. Against a multi-feature boundary,
st_intersection()split every straddling cell into one row per boundary feature and grafted the boundary's attribute columns onto the result. -
predict.bayesian_fit(newdata = NULL)honourssummary,typeanddraws. It short-circuited tofitted(), which caches epred column means and nothing else, sosummary = "median"returned means andtype = "predict"returned expected values — silently. -
prep_model_data()acceptspredictor_vars = character(0), making intercept-only spatial GP models reachable fromfit_bayesian_spatial_model().fit_gwr_model()andfit_rf_model()reject an empty set explicitly. -
fit_bayesian_spatial_model(control = )is merged over the package defaults (adapt_delta = 0.9,max_treedepth = 12) rather than replacing them. Passinglist(max_treedepth = 15)silently droppedadapt_delta— the setting the divergence warning tells you to raise. -
cv_bayes()'spredictive_coverageis averaged across folds weighted by each fold'sn_pred. The per-fold values are means over that fold's test rows, so an unweighted average is not the pooled quantity once fold sizes differ — andblock_kfoldtolerates a 3:1 imbalance before it even logs a warning. -
The three seeding functions (
get_voronoi_seeds(),voronoi_seeds_kmeans(),voronoi_seeds_random()) all emitseed_idandmethodcolumns, so they are drop-in interchangeable.get_voronoi_seeds()returns seeds in the boundary's CRS for every method; per-branch alignment had made the final alignment block unreachable. -
Geometry-type checks require every geometry to be an accepted type, not merely one of them. A mixed POINT/POLYGON layer passed a POINT-only check.
-
ensure_projected()errors on atarget_crsthat does not resolve to a usable CRS, rather than returning the input unchanged and letting unprojected coordinates flow into distance and area computations. -
fit_bayesian_spatial_model()derives the GP basis count (gp_k) and boundary factor (gp_c) from the ratio of the estimated length-scale to the domain size, rather than from the number of observations.brms::gp()builds a full tensor grid, sogp(..x, ..y, k = gp_k)carriesgp_k^2basis functions —gp_kis the count per dimension. The previous rule reduced tomax(15, floor(sqrt(n)))for anynabove 45, makinggp_k^2identicallyn: at n = 10,000 the model carried 10,000 basis functions and an n × n design matrix, at which point the approximation was no longer approximating anything.Across the scenarios in
dev/baseline-structural.rdsthe derived value is 22–24 per dimension and largely independent ofn: at n = 2,000 the basis count falls from 1,936 to 576, at n = 10,000 from 10,000 to 529, and at n = 200 it rises, 15 to 23 — a correction, not an optimisation, so results move in both directions.gp_cwas hard-coded at 1.5, too small whenever the length-scale exceeds roughly half the domain half-range; the derived value ranges 2.85–3.59 over the same scenarios. Passgp_kandgp_cexplicitly to restore the old behaviour. For scale, a 4-fold cross-validated fit at n = 2,000 with 2 chains × 1,000 iterations takes 1,186 s on the reference machine at cross-validated R² 0.927 (dev/baseline-accuracy.rds, 2026-08-20); no comparable timing was captured for 1.0.0, so none is quoted. -
The GP term is built with
scale = FALSE, changing the default result of every Bayesian fit.brms::gp()otherwise rescales its covariates so the maximum pairwise distance is 1 and reportslscalein that space, while this package standardises the coordinates itself and expresses the length-scale prior,gp_cand the basis adequacy threshold in those units. The two normalisations differed by roughly the maximum pairwise distance (~4.9 for standardised 2D coordinates), leaving the automatic prior about five times too diffuse — a likely contributor to divergent transitions and rejected initial values. There is now exactly one coordinate scaling. -
The GP fits one length-scale per coordinate axis (
gp_iso = FALSE), a second change to default Bayesian results. Coordinates are standardised per axis, so a single shared length-scale made the kernel anisotropic in the original CRS by the ratiosd(X)/sd(Y)— a property of the sampling layout, not of the process. Passgp_iso = TRUEfor the previous behaviour; cost is unchanged, since the tensor grid isgp_k^2either way. -
The automatic GP length-scale prior is a calibrated inverse-gamma rather than
normal(0, sd), a third change to default Bayesian results. A half-normal on a positive parameter puts its mode at zero, so most of its mass sat at length-scales shorter than the basis can resolve — where the Hilbert-space approximation develops a funnel and the sampler diverges. The replacement pins 1% of its mass below the estimated lower bound and 1% above the upper. Where the bounds are too wide or degenerate to calibrate against, the half-normal is used and the fallback is logged. The prior applied is recorded in$info$gp_lscale_prior. -
ensure_projected()no longer forces continental-extent data into a single UTM zone. Transverse Mercator scale error grows quadratically with distance from the central meridian, so data spanning the contiguous United States carried distance errors of roughly 7.5% near the extent edge, propagating silently intoestimate_sac_range(),make_folds(block_kfold)block sizing, GWR bandwidth selection and the GP length-scale. Extents reaching more than 5° from the candidate zone's central meridian now receive an equal-area projection centred on the data, with a logged explanation. Only longitude offset triggers the switch, sincecos(lat)shrinks the distance from the central meridian and a tall narrow north-south extent is UTM's design case. (Which equal-area projection is no longer decided by latitude band — see The projection for a wide extent is chosen by measurement under the fifth audit pass below, which also replaced the EPSG:3857 fallback for wide bounding boxes with antimeridian detection.) Passtarget_crsto override.
-
prep_model_data()now drops rows whose geometry is empty or whose coordinates are not finite, and counts them in its existing log line.st_geometry_type()calls an EMPTY POINT a "POINT", so nothing ever looked at the coordinates: such a row reached GWmodel as a rawspcoercion error naming no row,ranger(include_coords = TRUE)as "Missing data in columns", and brms at predict time as an infinite GP boundary that made every basis function on the surfaceNaN. It also refuses a response listed among its own predictors, which was leakage in the forest, a silently reduced model in GWR, and duplicated rows plus a phantom<none>entry in the GWR selection table. -
make_folds()validatesk. A non-integerktruncated insiderep(floor(n/k), k): onlyfloor(k)folds were built, the last rows of the permutation landed in no test set, andkwas echoed back unchanged, solength(folds) != k. -
make_folds(block_kfold)refuses a grid it cannot build. There was no cap onnx * ny, soblock_sizein the wrong unit asked for 1e8-1e11 cells and exhausted memory; the message now names the implied cell count, the extent and the CRS units, ascreate_grid_polygons()already did.predict_surface()gained the same guard oncell_sizeandn_cells. -
Hand-built
foldsare checked. Train and test overlapping is not cross-validation — the model is fitted and scored on the same rows and the result is reported as a CV score (RMSE 0.50 against 0.97 for the same data properly split) — and is now refused; fold IDs that name no row were dropped silently byna.omit(match())and are now counted and logged. -
The fold provenance probe tolerates a row it could not measure. It was taken before the empty-geometry filter, so it carried
NaNcoordinates and every latercv_*()call on the same data died with R's internal "missing value where TRUE/FALSE needed". -
estimate_sac_range()drops rows with unusable coordinates instead of returningNAfor the whole layer under the message "variogram model fit failed", which blamed the fit rather than the row and disagreed withmake_folds()on the same data. -
GWR's local collinearity spot-check now runs after the bandwidth is chosen, includes the intercept column, and treats a non-finite condition number as extreme. On the default path (
bandwidth = NULL) it used a stand-in window, so the documented warning never fired; without the intercept it could not see an indicator constant inside a window; andis.finite(cn) && cn > 1e6discarded exactly singular designs. A new post-fit warning counts local regressions that returned non-finite coefficients — previouslyfitted(),summary()andmodel_metrics()silently reported metrics computed from the survivors (n = 18 of 200, R2 = 0.96). -
fit_gwr_model()warns when a fixed bandwidth is implausibly small for the data's extent. The bandwidth is a distance in the CRS the fit runs in, whichprep_model_data()may have chosen: 0.2 supplied for lon/lat data is 0.2 metres, and every local window came back empty with nothing raised. The argument's documentation now says so. -
summary.spatial_fit()applies the same response-type guard asmodel_metrics(). A character response still producedn = 0and all-NAmetrics there, and a factor died insideabs(). -
print.spatial_fit()prints the CRS (which its documentation has always promised) and oneFormulaline.sprintf()vectorised over a multi-elementdeparse(), so everybayesian_fitprinted its formula as two mangled fields — thegp()term this package builds is always long. -
summarize_by_cell()warns whencells_sfcarries duplicated IDs (each summary row is then repeated per matching cell, sosum(n)exceeds the number of points), and realigns every per-cell vector indeff_appliedafter the join, not onlydeff:$rbarwas left in pre-join order, sodeff[i]andrbar[i]described different cells. -
assign_features_to_polygons()warns when no feature falls in any polygon instead of returning an empty layer silently. -
create_grid_polygons(target_cells = )builds square cells fortype = "square".cellsize = c(w/nx, h/ny)forced an exact bbox tiling, so the cells were rectangles (aspect 10.5 on a 1000:1 strip). Fortype = "hex"a differingcellsize[2]is now collapsed with a warning before themax_cellsestimate, which used both components and was therefore off by their ratio. -
create_grid_polygons_cached()'s renumbering is documented: it appliesensure_stable_poly_id()andcreate_grid_polygons()does not, so the same cell carries a differentpoly_idfrom the two builders. -
get_voronoi_seeds(method = "kmeans")clusters in two dimensions and drops rows with unusable coordinates, matchingvoronoi_seeds_kmeans(). It used every column ofst_coordinates(), so POINT Z geometry was clustered in 3-D with elevation dominating, and an EMPTY POINT crashed insidekmeans(). -
determine_optimal_levels()refuses a factor or character response, whichas.numeric()silently turned into level codes: re-ordering the levels of the same factor changed the chosen number of levels and everymoran_z. -
plot_tessellation_map()brings CRS-less layers into the plot's CRS instead of passing them through to fail insideggplot_build()at print time with sf's message, naming no layer; and it borrows a CRS from an overlay when the tessellation itself has none. -
Documentation corrected where it did not match behaviour:
ensure_projected()states how it chooses a projection for a wide extent (by centroid latitude at the time; by measured distortion since the fifth audit pass below) and that the choice is announced rather than silent;.looks_like_lonlat()'s two tests are a disjunction and the extent test decides first, so a small planar survey inside the lon/lat envelope IS taken for degrees — the trade and its reasoning are now stated;harmonize_crs()no longer claims to matchensure_projected()while doing something else;summarize_by_cell()states which estimand its standard errors are for (the grand mean, where measured coverage is 0.95, not the cell's own mean, where the naive SE is the better estimate) and that the variogram path applies one correlation function to every column.
-
Data carrying no CRS works again throughout.
ensure_projected()now rejects atarget_crsthat does not resolve to a usable CRS (previously a typo silently made the call a no-op), but internal callers derive that target from another object —st_crs(training_data)— and that object is allowed to have no CRS. PassingNA_crs_through turned every CRS-less workflow into a hard error:predict()on all three backends,make_folds(method = "nndm"),make_folds(boundary = ),prep_model_data(boundary = )andpredict_surface().cv_rf()was worse than an error — the per-foldpredict()threw, so every fold "failed" and$overallcame back withn_pred = 0andRMSE = NAbehind a generic warning. Internal call sites now passNULL("choose one automatically") when the source has no CRS; the user-facing validation is unchanged. -
build_tessellation(crs = ),create_voronoi_polygons(crs = )andcreate_grid_polygons(crs = )errored with sf's "cannot transform sfc object with missing crs" whenever the input had no CRS — exactly the users most likely to passcrs =. Reprojection is impossible there, but assumption is not: the target CRS is now stamped on with a loud warning, matching whatensure_projected()already documents. Input that does carry a CRS is still reprojected, not relabelled. -
compare_models_cv()built its argument list withc(list(...), rf_args), so anygwr_args/rf_argsentry whose name collided with one the function sets itself produced two entries of that name anddo.call()died with "formal argument 'seed' matched by multiple actual arguments". Sincecv_rf()has bothkandseedas formals,rf_args = list(seed = 3)— straight from the documented usage — was enough to trigger it. Extras now replace base entries by name.data_sf,response_var,predictor_varsandfoldsare protected and dropped with a warning, because a per-model override of those would silently make the models incomparable. -
compare_models()given a single barespatial_fitreported all-NAMoran's I columns and logged "'fit' is not a spatial_fit object" once per component. Aspatial_fitis itself a list, so it passed theis.list()check and the loops then iterated the fit's own components as though they were models. It is now wrapped into a one-element named list, exactly asevaluate_insample()already did. -
residual_morans_i()failed on its own documented fast path. WithFNNandMatrixinstalled the weights are a sparseMatrix, andbase::crossprod()does not S4-dispatch on thedgeMatrixthatW %*% residproduces, so the call died with "requires numeric/complex matrix/vector arguments" — takingcompare_models(), which calls it automatically, down with it. Rewritten assum(resid_c * (W %*% resid_c)), which is numerically identical and uses only dispatching primitives.determine_optimal_levels()carried the same bug. -
residual_morans_i()no longer errors on constant non-zero residuals: the degeneracy guard tested the raw sum of squares where Moran's I is a function of the centred residuals, soVIcame outNaNandif (VI > 0)raised "missing value where TRUE/FALSE needed". A non-finiteVIis handled too. -
.build_knn_weights()'s n > 5,000 guard tests for bothFNNandMatrix. Keyed onFNNalone, an unbounded dense n × n allocation went through wheneverFNNwas present butMatrixwas not. -
assign_features_to_polygons()drops columns offeatures_sfthat would collide with the polygon ID column, with a logged warning.sf::st_join()suffixed them (poly_id.x/poly_id.y), which defeated the rename afterwards and left the result with no rows — reachable simply by re-assigning already-assigned points. A join that still fails to produce the ID column now errors and names the columns it did produce. -
summarize_by_cell()keeps the"deff_applied"attribute whencells_sfis supplied;dplyr::left_join()rebuilds attributes from itsxtemplate and dropped it. The per-cell vector is remapped onto the joined row order,NAfor cells holding no observations. -
summarize_by_cell()joins on the native ID type when both sides agree. Coercing unconditionally made the returned ID type depend on an unrelated argument and turned integer IDs into"1","10","2", …. A genuine class mismatch still coerces both to character and logs why. -
summarize_by_cell()coerces non-POINT geometry before computing a variogram-based design effect.sf::st_coordinates()returns one row per vertex, so a POLYGON or multi-vertex MULTIPOINT feature misaligned the coordinate matrix with the data and fed the wrong points into every cell. -
make_folds(method = "buffered_loo")errors when the buffer excludes so much of the data that no fold retains two training points. Those folds used to sail through and be dropped one at a time inside the CV loop, so the only symptom was a generic "all folds failed" warning at the very end. -
make_folds(method = "block_kfold")refuses a block size yielding a single block covering the whole extent — one fold with an empty training set, reported as a run that merely happened to scoreNA. An accepted autocorrelation range could trigger it:estimate_sac_range()rejects ranges above half the bounding-box diagonal while block construction needs half the width. -
make_folds()coerces MULTIPOINT geometry rather than merely accepting it, for thest_coordinates()reason above; every fold was misaligned silently. -
Cross-validation no longer renumbers folds.
.remap_folds()dropped unusable folds from a list, shifting every later fold's index, sofold_metrics$foldandpredictions$foldstopped lining up withmake_folds()$assignment$fold. The original index is carried through. Folds left with fewer than two training rows are detected there and logged, instead of failing one at a time deeper in. -
cv_spatial()rejects afit_fnwhosepredict()returns the wrong number of values. Both the metric computation and the prediction frame recycled silently, so two predictions against four test rows yielded a four-row frame with metrics computed against fabricated pairs. -
Cross-validation under
parallel = TRUEreports a fold that died in a worker.parallel::mclapply()returns atry-errorrather thanNULL, which theNULLfilter kept, and the failure surfaced as "subscript out of bounds".conditionMessage()has no method for atry-error, so the diagnostic branch itself threw; the condition is now taken from the object's attribute. -
Cross-validation under
parallel = TRUEis reproducible fromseedand gives results identical toparallel = FALSE..cv_run_folds()calledparallel::mclapply()without seeding the fork streams, so each worker seeded itself from the clock and process ID. One seed per fold is now drawn in the parent, making each fold's stream a function of(seed, fold index)alone. -
estimate_sac_range()rejects a singular variogram fit.gstat::fit.variogram()signals failure by settingattr(., "singular")and returning normally, so testing only for atry-errormade the spherical fallback unreachable and let a singular fit'srangeflow out as the estimated autocorrelation range — whichmake_folds(auto_range = TRUE)then sizes spatial blocks from. -
.extract_gwr_values()requires every model-matrix column to match a column of GWmodel'sSDFbefore multiplying the local coefficients through. A partial match reconstructed a linear predictor missing one or more terms and returned it as the fitted value — plausible numbers that were simply wrong, feedingfitted(),residuals(),summary()and every metric with no warning. A non-numeric coefficient column is refused rather than coerced. -
fit_gwr_model()separates the three degenerate response cases. Folded together, an all-dropped dataset was reported as "binary (0 unique values)" and a constant response as "binary (1 unique value)", while a genuinely binary non-integer response (1.5 / 2.5) failed the integer-like gate and passed unremarked. -
fit_gwr_model()andgwr_model_selection()validatebandwidth. Unvalidated,NAgave "missing value where TRUE/FALSE needed", a length-2 vector gave "the condition has length > 1", and withadaptive = FALSEa zero or negative distance reached GWmodel untouched. -
fit_gwr_model()'s local-collinearity spot-check no longer disturbs the caller's RNG. It sampled from the global stream and fires only whenn > 30with at least two numeric predictors, so the same script produced different fold assignments depending on how many predictors a model happened to carry.cv_gwr()calls it once per fold. -
predict.gwr_fit()returns an all-NAvector when every row ofnewdatais dropped as incomplete, matching the other two backends, rather than surfacing a raw sf-to-Spatialcoercion error. -
predict.bayesian_fit()transformsnewdatato the training CRS before cleaning it, and derives the surviving rows from one sentinel column instead of a second, separately-maintained copy of the cleaning rules. It errors when a predictor standardised at fit time is absent fromnewdataor has arrived as character — silently skipping it handed brms an unscaled column against a model fitted on a scaled one. Its failure path returns a matrix whendraws = TRUE, honouring the documented return shape. -
plot()on aspatial_fiterrors when there are no finite residuals, instead of producing a uniformly grey map fromlimits = c(Inf, -Inf). A perfect fit is handled too: all-zero residuals gavelimits = c(0, 0), a degenerate diverging scale whose breaks collapse onto one value. -
plot_tessellation_map()logs a warning for afill_colthat is not present, instead of drawing an unfilled outline map with nothing to say anything had gone wrong — a mistypedlabel_colalready warned.xlim/ylimare validated, and thethemedefault moved out of the formals so a Suggests package never appears in an exported function's default arguments. -
harmonize_crs()announces when it stamps a CRS rather than reprojecting.sf::st_set_crs()only relabels; the coordinates do not move.ensure_projected()already made that assumption loudly. -
coerce_to_points()rejects an EMPTY LINESTRING rather than misaligning the result.st_line_sample()yields no midpoint for one (and segfaults in sf 1.0.x), so the sampled midpoints stopped corresponding 1:1 with the rows they are scattered back into. A count check backstops any other divergence. -
evaluate_insample()errors on an unnamed list. The loop is overnames(fits), so an unnamed list iterated zero times and returnedNULLsilently;compare_models()then died inseq_len(nrow(...))nowhere near the cause. -
determine_optimal_levels()coerces MULTIPOINT geometry rather than admitting it, and errors on a factor or character predictor by name instead of dying insidecolMeans()with "'x' must be numeric". -
create_grid_polygons()passes bothcellsizeandntosf::st_make_grid()when both are known;st_make_grid()does not ignorenin the presence ofcellsizefor square grids, and omitting it made sf recomputenx = ceiling(w / cellsize), which floating-point division pushes one past the intended count.nis parsed and validated once, up front, instead of being silently coerced toNULLin one branch and erroring in the other. -
The grid cache key no longer truncates
target_cells.as.integer()made 25.2 and 25.7 collide on one key, so the second call silently received the first one's grid, and aNULLtarget_cellscollapsedpaste0()tocharacter(0), crashing the lookup. -
build_tessellation()normalises a CRS-lesspoints_sftoNULLrather thanNA_crs_, which is a list and so was not treated as "no CRS supplied" downstream. Hex and square grids are built in the points' CRS, so the grid and the points no longer end up in different CRSs and break the point-to-cell index. -
build_tessellation(method = "triangles")triangulates the point set whengeometryis unavailable, viasf::st_triangulate()on the unioned points. The fallback previously triangulated the convex hull polygon, discarding every interior point. The result is still the Delaunay triangulation of the input; only the resolution of degenerate configurations can differ from qhull's, and the logged warning now says so. -
ensure_stable_poly_id()logs a warning naming the geometry types when it drops non-polygonal rows, which it silently did before. -
voronoi_seeds_kmeans()andvoronoi_seeds_random()validate their inputs (voronoi_seeds_random()also accepts thesfcits documentation always promised), and clampingkto the number of distinct positions is logged.get_voronoi_seeds(method = "provided")logs a warning whenndisagrees withnrow(seeds), which it ignores. -
gp_lengthscale_bounds()validatescoords_xyandq_small. A vectorcoords_xyfailed inside.safe_dist()with "argument is of length zero" and an out-of-rangeq_smallinsidequantile(), neither naming the argument. -
fit_bayesian_spatial_model()validates the response before handing it to Stan, where nothing points back at the column, and validatesgp_k,gp_candcontrol. The inverse-gamma prior is written with%.10grather than%.6f: a small scale rounded to the literal"0.000000"and Stan rejectedinv_gamma(a, 0)from deep inside the model block. Tightly clustered coordinates get there. The half-normal fallback's scale is guarded the same way. -
compare_models_cv()names, in a warning, anygwr_argsentry it drops.cv_gwr()has no..., so entries meant forfit_gwr_model()alone (e.g.longlat) were discarded silently and simply had no effect. -
.compute_reg_metrics()errors on ay_train_meanthat is neither a scalar baseline nor one value per observation, instead of recycling it against the filtered response and silently distorting R². -
create_grid_polygons()no longer truncates the grid whencellsizeandnare both supplied. This changes results.sf::st_make_grid()does not ignorenwhencellsizeis given: for square grids it takes the cell dimensions fromcellsizeand the counts fromnx = n[1],ny = n[2], anchored at the bounding-box corner.cellsize = 25withn = 2on a 100 × 100 boundary therefore produced 4 cells covering 2,500 of 10,000 square units and silently left three quarters of the study area with no cells at all — and becauseclip = TRUEhad nothing outside the boundary to discard, the result looked like an ordinary, complete grid.cellsizenow wins,nis dropped with a logged warning naming what it would have done, and the same call returns 16 cells covering the whole boundary.nis still forwarded when the package derivedcellsizefrom it or fromtarget_cells, which is what the original code was written for: omitting it there lets sf recomputeceiling(w / cellsize)and floating-point division pushes the count one past the intended value. -
fit_gwr_model()no longer refuses a continuous response that happens to take two values. This changes results: fits that used to error now run. The guard rejected any response with exactly two distinct finite values as "binary" and pointed atGWmodel::ggwr.basic(family = "binomial"). Two distinct values is not the same thing as binary: a measurement censored at a detection limit or saturated at a ceiling (0.0031 / 12.7401) is perfectly continuous, Gaussian GWR on it is a well-defined least-squares problem, and the advice to switch to a binomial family is nonsense for such values. The hard stop is now gated on the response also being integer-like, which is what the surrounding code already used to separate coded categories from measurements. A two-valued non-integer response raises awarning()naming the two values and asking you to confirm it is genuinely continuous, then fits. This also mattered insidecv_gwr(), where the guard runs once per fold and a small training fold can legitimately hold only two distinct values. -
determine_optimal_levels()no longer reports a Moran's I that is arithmetically fixed. This changes which cell counts it returns..morans_i_for_k()builds amin(8, n_cells - 1)-nearest-neighbour weight matrix, so at nine cells or fewer every cell neighbours every other one. The row-standardised matrix is then complete,W %*% e = -e/(n - 1)for any mean-zero residual vector, and Moran's I collapses to exactly-1/(n_cells - 1)whatever the data are. That is not merely uninformative:|I| = 1/(n_cells - 1)falls monotonically in the number of cells, socriterion = "morans_i"ranked the largest evaluated candidate first every time, and"combined"carried the same tilt at half weight. Candidates below the floor now returnNA_real_and are excluded from the model-aware ranking; when none clears it — the usual outcome at the defaultmax_levels = 12, since the search evaluates a window around the elbow — the call falls back to the geometric ranking and logs a warning. Raisemax_levelsabove roughly 10 for the model-aware criteria to contribute at all.predictor_varsalso accepts logical columns now, read as 0/1, matchingfit_rf_model()/cv_rf()/predict(); factor and character predictors are still refused by name. -
residual_morans_i(fit, k = 1)no longer errors with "subscript out of bounds" on a machine withoutFNN. In the dense fallback the inner function returns a scalar atk = 1, soapply()simplified the neighbour table to a length-n vector andt()made it a 1 × n matrix; indexingnn_idx[i, ]then failed for everyi > 1. The result is now forced ton × k. -
make_folds()no longer dies on an empty or non-finite geometry.st_coordinates()yields one all-NArow per EMPTY POINT rather than zero rows, so a row-count check let them through:block_kfold'sst_intersects()returnedinteger(0),..block_idwentNA, and the nearest-block rescue aborted with "replacement has length zero". Unusable rows are now dropped with a warning naming the count, after..row_idis stamped so the survivors keep their original row identities, and for every method rather than justblock_kfold—random_kfoldwould otherwise put an unplottable point in a fold, andnndmandbuffered_looboth feed the coordinates to distance code. The rescue itself usesvapply()rather thanapply(), so a point whose distances are allNAkeeps itsNAinstead of collapsing the assignment.points_sfwith no usable coordinates at all is an error naming that, not a downstream one. -
Every cross-validation wrapper names the cause when folds fail.
.cv_run_folds()returns each fold's error text rather than a bareNULL, andcv_gwr(),cv_bayes()andcv_spatial()appendFirst error: ...to both the logged and the R-level "all N folds failed" message. Runningcv_bayes()withoutbrmsinstalled previously produced fivefold N fit failedwarnings and an all-NA$overallwithn_pred = 0in which the word "brms" never appeared.
-
New
fit_rf_model()andcv_rf(): arangerrandom forest as a first-class backend, returning anrf_fitthat works withcv_spatial(),predict_surface(),area_of_applicability()andplot()like any other model. Three defaults are opinionated:include_coords = FALSE(a forest given the coordinates memorises location and fails wherever it has not been — Meyer et al. 2019, doi:10.1016/j.ecolmodel.2019.108815 — and random CV does not catch it);fitted()returns out-of-bag predictions, sosummary()on anrf_fitis not comparable with the other backends and says so ($info$fitted_are_oob); and importance defaults to permutation rather than impurity, which is biased toward continuous and high-cardinality predictors (Strobl et al. 2007, doi:10.1186/1471-2105-8-25). Compare backends withcompare_models_cv(), which now has an RF branch.predict()on anrf_fitrefuses the type confusionsrangerwould otherwise absorb silently: a numeric-at-fit predictor supplied as text (which ranger factor-codes, then applies numeric split thresholds to the codes), a logical-at-fit predictor supplied as text, and a categorical level the forest was never grown with — the level set is not enough, since a spatial fold holding out a whole class leaves a level with no training rows. Arguments that make ranger return a matrix (predict.all = TRUE,type = "quantiles") are rejected rather than flattened column-major. A constant seed is supplied to ranger's predict unless the caller passes one, so prediction does not consume the global RNG andpredict_surface(chunk_size = )— a performance knob — cannot shift later random draws.cv_rf(seed = )reaches the forest in every fold, and gainspointize. Passing ranger's own spelling of an argument the wrapper already sets (num.trees,min.node.size,num.threads,mtry,importance,seed) through...is an error naming the wrapper argument to use, rather than reachingranger()twice. See?fit_rf_model. -
New
area_of_applicability(), implementing the dissimilarity index of Meyer & Pebesma (2021, doi:10.1111/2041-210X.13650). Predictors are centred and scaled on the training data's own statistics, optionally weighted by variable importance — by the importance itself, not its square root, matchingCAST. A prediction point's DI is its distance to the nearest training point in that space over the mean pairwise training distance, and the threshold is the outlier-removed maximum of the training data's own DI. Pass themake_folds()result you actually validated with — the area is defined relative to a performance estimate, and a blocked estimate is a claim about predicting further away.A model fitted with
include_coords = TRUEis measured in coordinate space, since an index that ignores location would report a point far outside the training extent as inside on ordinary covariate values alone; weights for the two coordinate columns default to the mean of those supplied, as the caller has never seen them. Non-POINTnewdatais reduced to points, and a CRS present on one side is applied to the other. The zero-variance test is relative to each column's magnitude rather than an absolute tolerance, so a predictor is not dropped for the unit it was recorded in. Categorical predictors are refused rather than dummy-coded; logicals are read as 0/1. Amake_folds()result is resolved by its..row_idvalues, which coincide with row positions only when the input carried no prior IDs. See?area_of_applicability. -
New
select_features_forward(): greedy forward feature selection with spatially blocked inner folds, which is the whole point of having it. Random inner folds inside blocked outer folds select variables that look predictive only because nearby points leak between train and test, and the outer loop then reports honest-looking numbers for a dishonestly chosen feature set.methoddefaults to"block_kfold"and logs a warning if set to"random_kfold". The empty set is scored first where the backend can fit it, so the first variable is judged against a null-model baseline rather than accepted unconditionally, andhistorycarries that baseline as astep = 0row. Every candidate set is scored on the same observations — the completeness filter matchesprep_model_data()exactly, finiteness test included, so a candidate carrying a singleInfcannot be preferred for having an easier subset — and the inner folds are built once, before the sweep, rather than rebuilt per candidate. Amax_fitsbudget guards against nesting a sweep inside leave-one-out outer folds. -
New
gwr_model_selection(): wrapsGWmodel::gwr.model.selection()(Lu et al. 2014, doi:10.1080/10095020.2014.917453) and returns a ranked table instead of two loosely-coupled lists. It is the fast, in-sample counterpart toselect_features_forward()— the same forward search scored by AICc, read from the documentedc(bandwidth, AIC, AICc, RSS)layout of GWmodel'sGWR.df, which carries no column names; the result records whether the table arrived in that shape. Candidates must be numeric, anddmat_max_n = Infmeans always precompute the distance matrix. Both limitations are documented rather than papered over: one bandwidth is shared by every candidate (which is what makes the criteria comparable), and the null model is never evaluated, so the result always names at least one predictor. When it disagrees with the blocked estimate, believe the blocked estimate. See?gwr_model_selection. -
New
predict_surface(): builds a regular grid over the training extent (or a grid you supply), joins covariates, predicts in chunks and returnssf. Supportsboundaryclipping,cell_sizeor approximaten_cells, andse = TRUEfor a posterior-SD surface where the backend exposes draws. -
New
plot()method forspatial_fit, withtype = "residuals","observed_predicted"and"variogram"(the empirical residual variogram with the fitted model and effective range overlaid, so the fit can be judged rather than trusted). The variogram's distance axis is labelled in the units of the CRS it was actually fitted in — metres of an auto-chosen zone for a lon/lat fit, not the caller's degrees — it names the azimuth when a single direction is drawn, and a fit that did not converge says so in the caption. Newplot_folds()maps a fold scheme, which is the fastest way to see whether spatial blocks separate the data or are smaller than the autocorrelation range and therefore leaking. -
make_folds()gainsmethod = "leave_location_out", which keeps every observation from a location (named by the newgroup_var) in the same fold. Repeated measurements at one site were previously unrepresentable: random k-fold splits them across folds, so the model is scored partly on sites it trained on. -
make_folds()gainsmethod = "nndm", implementing the distance-matching principle of Milà et al. (2022, doi:10.1111/2041-210X.13851), as inCAST::nndm(). Rather than choosing abufferwith nothing to justify it, the exclusion around each held-out point is sized so the training-to-test distance distribution reproduces the distances from your actual prediction locations (the newprediction_points) to the training data. The procedure follows the paper's iterative exclusion removal for removal and is deterministic: no random numbers are drawn, so the caller's RNG is untouched.params$target_median,params$realised_medianandparams$max_ecdf_excessrecord how close the match came, andmin_train(default 0.5) andphicontrol it. Matching is as close as the training configuration permits — the achievable distances are discrete order statistics. When prediction locations sit no further from the training data than training points sit from each other, plain leave-one-out already reproduces the target and nothing is excluded; that is the correct outcome. A non-POINTprediction_pointslayer is reduced to points first, since point-to-polygon distances are zero for any cell containing a training point and would collapse the scheme towards plain LOO. -
summarize_by_cell()gainsdeff = "variogram", computing a per-cell design effect from a fitted variogram rather than one pooled intra-class correlation. Fornpoints in a cell with correlation matrixRthe effective sample size of the mean isn^2 / sum(R), sodeff = sum(R) / n. This generalises the Kish option — a constant off-diagonal correlation recovers1 + (n - 1) * rhoexactly — but lets correlation decay with distance, which is what having fitted a variogram is for. Pass the fit via the newsacargument, or it is estimated whenresponse_varis supplied. Large cells are subsampled atdeff_max_n(default 500), with the correlation scaled back to the cell's own size. Asac_rangewhose fit was rejected carries no usable correlation function, so both the supplied and the internally estimated path fall back todeff = 1and say so rather than saturating the correlation at every within-cell distance. One correlation function is fitted and applied to every numeric column, response and predictors alike, because a variogram is a property of the field rather than of a variable type. -
fit_bayesian_spatial_model()supports intercept-only models (predictor_vars = character(0)): the response is explained by the intercept and the spatial GP alone, the natural null for asking how much of a surface is spatial structure rather than covariate effect. -
fit_bayesian_spatial_model()checks the posterior length-scale against the smallest scale the chosen basis can resolve and logs a warning when more than 10% of the posterior mass falls below it — the adequacy diagnostic recommended by Riutort-Mayol et al. (2023, doi:10.1007/s11222-022-10167-2), and what makes the smaller defaultgp_ksafe rather than merely cheaper.$infogainsgp_c,gp_n_basis,gp_ell_minandgp_lengthscale_bounds, andprint()on abayesian_fitandcv_bayes()'sfold_metricsreport the total basis count alongside the per-dimension rank. -
cv_spatial()raises a condition when folds fail, matchingcv_gwr()andcv_bayes(); an all-failingfit_fnpreviously returned an all-NAoveralland an emptyfold_metricswith nothing at R condition level. The result recordsn_folds_attemptedandn_folds_succeeded— compare them before trustingoverall. -
make_folds()records the CRS the folds were built in asparams$crs("EPSG:32632", an input string, or a WKT).block_sizeandsac_rangeare lengths in that CRS, which is not necessarily the one the caller passed: geographic input is projected byensure_projected()to a CRS chosen for the extent. Without the label the units of a recorded block size were not recoverable from the result. -
spatialkit_quiet()is a new exported helper. Bothlogger::log_appender()andlogger::log_threshold()default toindex = 1, which is the temp-file trace, so the two-line recipe in the README could not redirect or quieten the console echo (index 2) — there was no documented way to silence the package. The README now says so too.
-
estimate_sac_range()documents its three return shapes (a range, a rejected range, and no fit at all) and which attributes each carries. -
make_folds()documents thatkis not always honoured:buffered_looandnndmalways returnk = n, andblock_kfoldandleave_location_outlower it when the geometry or the grouping cannot support the request. Readfolds$k. -
new_spatial_fit()documents thecoef()contract;summary.spatial_fit()andmodel_metrics()document that their metrics are in-sample for agwr_fitand abayesian_fitbut out-of-bag for anrf_fit;prep_model_data()documents that the projected CRS is not an unconditional guarantee, sinceensure_projected()passes a CRS-less dataset through unchanged when its coordinates do not look like lon/lat. -
The vignette and
inst/scripts/example_nc_demo.Rread fit quality fromfit$metrics$r_squaredand CV results fromcv$summary$rmse. Neither field has ever existed. Becausesprintf()returnscharacter(0)when any argument has length zero, the reporting lines printed nothing rather than erroring, so the shipped vignette silently omitted every number it claimed to show. Both now usemodel_metrics()and$overall. -
The demo's Voronoi tessellation was built from all 300 observations rather than from the 40 k-means seeds it computed one line earlier — one cell per observation, a nearest-neighbour interpolation rather than an aggregation, compared side by side against two ~50-cell grids. The seeds are now used.
-
The vignette builds as
rmarkdown::html_vignetterather thanhtml_document, guards itsggplot2andgeometryuse, demonstratessummarize_by_cell()instead of reimplementing it withgroup_by()/summarise(), and adds a spatial cross-validation section contrastingblock_kfoldagainstrandom_kfoldon the same data. -
The package-level help page (
?spatialkit) gains "The pipeline, in order" and "Where to start" sections, sohelp(package = "spatialkit")leads somewhere rather than presenting 40 exports in alphabetical order. -
Every exported function's description now says when to reach for it rather than only what it does, and
@family/@seealsolinks connect each step of the pipeline to the one before and after it —assign_features_to_polygons()tosummarize_by_cell(),determine_optimal_levels()tobuild_tessellation(),new_spatial_fit()tocv_spatial(), and the two seeding functions to each other.create_voronoi_polygons()versuscreate_grid_polygons(), andvoronoi_seeds_kmeans()versusvoronoi_seeds_random(), each say which to pick and why. -
build_tessellation()documents thatboundaryis required formethod = "hex"andmethod = "square"— the grid methods have no extent of their own — and optional for"voronoi"and"triangles", which derive one from the points. The error existed; the requirement was not written down anywhere. -
create_grid_polygons()documents thattarget_cells,cellsizeandnare three ways of sizing one grid and that exactly one should be supplied, thatcellsizeis in the units of the working CRS, and thatcellsizetakes precedence overn. -
determine_optimal_levels()documents the nine-cell resolution floor on the model-aware criteria, why it exists, and that the whole call falls back to the geometric ranking when no candidate clears it. -
compare_models_cv()documents that dropping every requested backend is an error ("no viable models.") rather than an empty comparison, and that the returned frame carries only the models that actually ran, so callers should check which names are present rather than assuming one row per request. -
new_spatial_fit()documents the two obligations on a custom backend: return an object built by the constructor, and define apredict.<subclass>()method —cv_spatial()scores folds through thepredict()generic, so without one every fold fails. -
README. A new "Your own data" section shows both entry points —
st_read()for a spatial file andread.csv()+st_as_sf()for a table of coordinates — using thenc.shpdemo shapefile shipped withsfso it runs anywhere. The README previously manufactured every example inline with a hard-codedcrs = 32632and never showed data entering the package at all. A companion "CRS: what the numbers are in" subsection states that block sizes, buffers, bandwidths, variogram ranges andexpanddistances are in the units of the working CRS; that geographic input is projected automatically to a CRS chosen for the extent; and how to pin one. -
README. New guidance where none existed: how to choose among the four tessellation methods, how
kandblock_sizetrade off against the autocorrelation range, what to do whenestimate_sac_range()returnsNA, how to read a design effect, which model backend to reach for (with the recorded cost of each), and a "Troubleshooting" section covering the errors a new user actually hits first. A worked hex-grid example replaces the previous picture-only coverage of the grid methods. -
README. Three corrections. The
estimate_sac_range()example showed a rejected range printing its attributes, whichprint.sac_range()has not done since the attribute dump was removed; it now shows the bareNAand reads the attributes explicitly. Thedetermine_optimal_levels()passage claimed the residual-autocorrelation criterion was doing work at cell counts where it is arithmetically degenerate. The test-suite paragraph said "exactly one" test guards onbrms; six do, five of them additionally gated behindSPATIALKIT_TEST_BRMSso they never run in the matrix. -
inst/scripts/example_nc_demo.Rsaid EPSG:2264 was projected "so distances are metric". Its unit is the US survey foot, which is what the script's own "Autocorrelation range: %.0f ft" line reports. The comment now says planar, and names the unit every distance, bandwidth and block size in the script is in. -
Vignette.
print(rf_fit)andsummary(rf_fit)report the same OOB RMSE but different R² (0.4733 against 0.4715). The vignette now explains why:print.rf_fit()echoesranger'sr.squared(1 - MSE/var(y), unbiased n − 1 variance) whilesummary()recomputes1 - SS_res/SS_totfrom the same out-of-bag predictions with an n denominator, so the unexplained fractions differ by exactly n/(n − 1).
First CRAN release, published 2026-08-07.
- CRS management:
ensure_projected(),harmonize_crs(),coerce_to_points(),prep_model_data(). - Voronoi, hexagonal, square and Delaunay tessellation
(
build_tessellation()and thecreate_*_polygons()functions), with boundary clipping, stable reproducible cell IDs (ensure_stable_poly_id()) and a memoised grid builder (create_grid_polygons_cached()). - Seeding (
get_voronoi_seeds(),voronoi_seeds_kmeans(),voronoi_seeds_random()) and resolution selection (determine_optimal_levels()). - Feature-to-polygon assignment (
assign_features_to_polygons()) and cell-level aggregation with design-effect-corrected standard errors (summarize_by_cell()). - GWR (
fit_gwr_model()) and Bayesian spatial Gaussian process (fit_bayesian_spatial_model()) backends behind a commonspatial_fitS3 class. - Spatial cross-validation:
make_folds()withrandom_kfold,block_kfoldandbuffered_loo;estimate_sac_range();cv_gwr(),cv_bayes()andcv_spatial(). - Model comparison and diagnostics:
compare_models(),compare_models_cv(),evaluate_insample(),residual_morans_i(). - Tessellation mapping (
plot_tessellation_map()) and scoped logging.