Changelog
Source:NEWS.md
spatialkit (development version)
New features
fold_separation()measures what blocked cross-validation is for: the distance from every held-out point to its nearest training point, summarised per fold. Until now the only evidence a fold scheme worked was a proxy — the block size compared against the estimated autocorrelation range, whichmake_folds()warns about. That is a statement about the design; this is the result, and the two can disagree in the direction that matters. Measured on 300 points over a 1000-unit square with a fitted range of 292.5: blocks of 343 units are wider than the range, somake_folds()raises no warning at all, and yet 76 percent of the held-out points still sit closer to a training point than the range, the nearest of them 23.3 units away. Blocks wider than the range leak wherever a test point sits near a block edge with training data just across it, which in a fine grid is most of them. The returned table carriesn_train,n_test,n_blocks,min_dist,median_distandwithin_range(the share inside the range), and itsprint()method closes with what that share means for the score. Nothing is estimated: the distances come from the geometry and the range is the one the folds already carry or the one you pass. A range that records its CRS (the folds’ ownparams$sac_rangeandparams$crs, or anestimate_sac_range()result) is compared with distances measured in that CRS, so a copy of the layer in other units gives the same answer (a US-foot copy used to report 19 percent of the hold-out inside a metre range where the metre layer reported 89);sacand the distances are in the units of the CRS thecrsattribute names, metres for lon/lat input.foldis thefold_idacv_*()result’s$foldscarries, so a join on it pairs each fold’s error with its own distances (it was the list position, which after a dropped fold paired one fold’s error with another’s distances). Aunitsobject forsacis refused. As incv_*(), amake_folds()result whose recorded rows sit at other locations indata_sfis refused. Folds built onprep_model_data()’s 200 points and measured on the 195 thatassign_features_to_polygons()kept used to be matched by position, and reported 100 percent of the hold-out inside the range against 23–48 percent on the right layer. The location check is skipped when one layer is POINT and the other is not, so folds built on polygons still measure their pointized copy. Theprint()verdict now depends on the fold scheme: “widen the blocks” is said only of blocked folds, buffered leave-one-out is told to widen the buffer, and random, leave-location-out and hand-made splits are told to use blocked or buffered folds. NNDM folds are no longer called optimistic: they are built to reproduce the prediction-to-data distances, so the share describes the prediction task. The range line gives the CRS’s unit (“in metres”), where it used to print the CRS code as if it were a unit (“in EPSG:32617 units”).summary()on aresolution_profile()puts every criterion’s pick in one table: the level each prefers, the flat region around it, whether a ladder bound is doing the choosing, and the levels that lie in every band. Reading the criteria off a profile took oneselect_resolution()call per criterion, and the comparison had to be assembled by hand. The intersection of the bands is often empty, which is a result rather than a failure: it says the field has no single resolution that satisfies every way of asking. Nothing in the table chooses, and the help page says so. Each region comes back in full on the"bands"attribute.Every
cv_*()result now says what became of each fold.fold_statusis a data.frame with one row per fold supplied —fold,status,message— wherestatusis"ok","error"(the fit or itspredict()threw;messageis the error text),"skipped"(nothing scorable: too few matched rows, a prediction of the wrong length, no finite observed/predicted pair),"dropped"(an empty test set or fewer than two training rows once incomplete rows were removed, so the fold never reached the fitter) or"worker_error"(a parallel worker died). The per-fold error text was already collected and thrown away except when every fold failed, so a partial failure — “3 of 5 folds produced predictions” — left its causes only in console scrollback, which a script, acallrjob or a knitted document does not keep. This is worth most where a run is expensive: acv_bayes()fold whose sampler failed now names the reason in the returned object. Beside it,orphan_rowsholds the row IDs no fold names (they enter no training set and are never scored — non-empty only when the folds were built on a different or subsetted layer),n_unknown_idscounts fold entries naming rows the data does not have, andn_droppedthe rowsprep_model_data()removed before any fold was fitted. The four together account for every row and every fold, son_folds_attempted - n_folds_succeedednever has to be explained from the log. A parallel worker that was killed outright (for lack of memory, say) is a"worker_error"too and enters the first-error text; it used to show as"skipped"(“no result returned”) and was left out of that text. An"ok"row carries amessagewhen the fold’sfold_info_fnfailed.prep_model_data()records what it removed.attr(x, "dropped")is a list withn,n_geometry,which(positions in the input),row_id(when the layer carries..row_id) andreason, one of"geometry","missing"or"non_finite"per dropped row. The three masks behind that decision were already computed and collapsed into a log line; the row identities reached nothing, and through the eight-plus internal call sites even the count was invisible, so a fit’s$nwas the post-cleaning row count with nothing saying how many rows were lost or why. Every fit now carries the count as$info$n_dropped, and everycv_*()result asn_dropped. Silently losing a third of the rows is a classic cause of a suspiciously good score. The layer carries the class"spatialkit_rows"after"sf"(c("sf", "spatialkit_rows", "data.frame")):[returns a plain layer without the record, anddplyr::bind_rows(),vctrs::vec_rbind()anddplyr::union_all()return a plainsf(with the class ahead of"sf"they failed on two layers with the same record, ‘attr(obj, “sf_column”) does not point to a geometry column’).dplyr::filter(),slice(),arrange()anddistinct()now drop the record as[does (they kept it, sofilter()down to 3 of 5 rows still reported the parent’s counts). Each record carriesn_rows, the number of rows it was made for.sf::st_drop_geometry()keeps the rows and the record; binding such data frames keeps the first one’s record, whosen_rowsthen no longer matches, and the package’s readers ignore it.make_folds(method = "block_kfold")returns the block design it built the folds from.assignmentgains a third column,block_id;paramsgainsblocks(ansflayer of the block polygons in the CRS the folds were built in, numbered to match),block_sizes(points per block, indexed byblock_id, so a block kept empty bydrop_empty_blocks = FALSEshows as a zero) andfold_blocks(which blocks were packed into each fold).blocks$source_rowis the row each block came from in the layer it originated in — a cell’s index in the fullgrid_nxbygrid_nygrid, or the row of theblocksargument — because dropping the empty blocks renumbers the rest: nine supplied blocks of which three are empty come back as six rows numbered 1 to 6, and a join by row position would mis-attribute every block after the first gap.blocks[params$blocks$source_row, ]recovers them with their own columns and in their own order. The folds account for every block exactly once, empty ones included, so a fold’s territory on the map is all of its blocks rather than only those holding points; andblocks_usedis the number of blocks the design has, which equalsnrow(params$blocks)andlength(params$block_sizes), withsum(params$block_sizes > 0)giving how many of them hold points. The grid is the design of a blocked cross-validation: without it a user could not draw the blocks over their data, see that 40 of 64 blocks were empty, or tell whether a fold is one contiguous region or several.plot_folds()now draws those outlines under the points when the folds carry them, and takesblocks = FALSEto suppress them. Its subtitle states the parameter that decides whether the scheme leaks (the block size, the buffer, the number of location groups or the median NNDM exclusion), with the units of the folds’ CRS; folds built from points without a CRS get no units rather than “(NA units)”.estimate_sac_range()’s returns are uniform. The four directional variograms and their fits run unconditionally on every call, and the rejected-range paths used to discard them — precisely where a user most needs to know whether the field is anisotropic. Every classed return now carriesdirectional,anisotropyandanisotropy_used, and three new attributes report the sweep rather than collapsing it:directional_status(per azimuth, why that direction isNAindirectional—"ok","over_cutoff","not_converged"or"no_fit", which were indistinguishable before),directional_fitted(the range each direction’s fit reported whether or not it was usable, so a refused directional range — the most informative number in an anisotropic failure — stays recoverable) and, underkeep_directional_fits = TRUE,directional_fits(each direction’s empirical variogram and fitted model). Those four objects are off by default because they dominate the result when present — 42.2 KB of a 58.8 KB object at n = 400, against 16.6 KB without them — andmake_folds(auto_range = TRUE)calls this on every build.print()names the reason and the refused value for a direction it cannot use.A roster of quantities the package already computed and dropped are now returned.
summarize_by_cell()attaches the Kish ICCs it estimated asattr(, "icc")whether or not either was large enough to apply — the case with no"deff_applied"is exactly the one where a user wants to know what the ICC came out as — and the"variogram"path addsdeff_rows, the per-cell design effect at the cell’s row count that the log line reduced to a median and a max. Asummarize_by_cell()call that requests a design effect (anydeffother than 1) also returns a logicaldeff_appliedcolumn,TRUEon every row when the correction was applied andFALSEwhen it fell back to the uncorrected standard errors; unlike the"deff_applied"attribute it survivesrbind()anddplyr::bind_rows()of many results. The default frame is unchanged.assign_features_to_polygons()reports the features that matched more than one polygon and had thetie_breakrule decide for them, asattr(, "ties")and a log line; a tie-break firing on a third of the features means the polygon layer overlaps and every cell count built from it is suspect. The layer carries the"spatialkit_rows"class after"sf", asprep_model_data()’s does, so binding such layers returns a plainsf. The"ties"record is dropped by the samedplyrrow verbs asprep_model_data()’s, and withlargest = TRUEit also counts features that overlap two or more polygons by exactly the same area.ensure_projected()attachescrs_choice, the projections it considered with each one’s measured worst-case distance error. Every path that picks a local projection reports what it picked, the two that compare nothing included: a UTM zone on a local extent, and the equal-area projection chosen for a layer straddling the antimeridian.residual_morans_i()returns the residualkurtosisthe randomisation variance conditions on, the design rankpbehind the residual moments,exact, whether those moments are exact for these residuals, andweights_summary, a description of the weight matrix it used:n,storage(the matrix class),neighbours(the smallest and largest number of neighbours any row has,NAfor a dense matrix, where counting them would allocate a second one),keptanddesc, the lineprint()shows. The matrix itself comes back asweightsunderkeep_weights = TRUEand isNULLotherwise, because it is n by n: at n = 500 it is 50.3 KB of a 53.5 KB object in its sparse form, 112.7 KB at n = 120 when the dense fallback is taken (going sparse needs both FNN and Matrix, so a no-Suggests install always falls back), and 191 MB at the n = 5000 that fallback is capped at — against the 3.2 KB everything else occupies — and scoring a list of fits would hold one matrix per fit. The result is classed"morans_i"and prints through aprint()method that shows the statistic, its null and the weights line;[drops the class, and$,[[andunlist()read the result exactly as for a plain list.fit_gwr_model()keepsinfo$nonfinite_coef, the per-row, per-term mask behindn_local_singular, andfit_bayesian_spatial_model()keepsrhat_failed/neff_failed, the parameters that failed each convergence check by name — “max R-hat 1.09” is not actionable where “sdgp_gp..x..yhas R-hat 1.09” is.determine_optimal_levels()’s model-aware diagnostics gainknee_kandfailed_k(whose interpolated WSS entries are not measurements).build_tessellation()records the points whose cell assignment was repaired by nearest-cell snapping, and how far outside each sat, asparams$snapped— a comment had long said it should.area_of_applicability()returns thescaling(per-predictor training centre and SD) the dissimilarity index is computed in, without which a location’s DI cannot be traced to the predictor that put it outside, andn_outliers, the training DI values the threshold’s fence set aside.get_voronoi_seeds(method = "kmeans")returns the clustering asattr(, "kmeans")— which cloud points fed which seed, the cluster sizes and the within-cluster sums of squares.gwr_model_selection()reportscriterion_by_name,criterion_columnandcriterion_verified, so a script can gate on the case its log calls “unverified” instead of reading the label.estimate_sac_range()’s result gains aplot()method.plot(estimate_sac_range(pts, "z"))draws the empirical variogram, with the fitted model and the effective range overlaid where a range was identified, and a subtitle saying why not where it was not: the variogram never reached a sill, both model fits were singular, the optimiser halted, or arange_fracbelow 1 refused a range inside the fitted lags. Nothing is recomputed — the plot reads the attributes the estimate already carries — and the same drawing routine now servesplot.spatial_fit(type = "variogram"), so the two pictures agree. The “attached for inspection” messagesestimate_sac_range()logs when it returnsNAused to point atplot(type = "variogram"), which is the method for a fitted model and could not take the estimate; they now point atplot()on the returned value.-
summarize_by_cell()gainsconf_level. Withconf_level = 0.95, every numeric response and predictor column gets four more columns beside its..sd_*and..se_*:..neff_*, the column’s effective sample size in the cell (its non-missing count over its design effect — the per-column version ofcell_weight);..df_*, the degrees of freedom the interval uses; and..ci_lo_*/..ci_hi_*, a t interval for the cell mean as an estimate of the grand mean, built on the design-effect-corrected standard error. The defaultconf_level = NULLreturns exactly the frame it always did. The degrees of freedom aren - 1atdeff = 1, for a numericdeffand fordeff = "kish", because the interval’s spread is estimated from the within-cell variance, whosen - 1df survive exchangeable correlation whatever the design effect (measured 95% coverage on the Kish path at an ICC of 0.2 / 0.6 / 0.9: 0.954 / 0.953 / 0.952; an effective-sample-size df ofneff - 1gives 0.992 / 1.000 / 1.000 and is not used). Underdeff = "variogram"the df are the Satterthwaite- moment-matched df of the within-cell variance under the fitted correlation, a fraction of
n - 1that shrinks with the range: 0.960 and 0.958 coverage at exponential ranges of 150 and 400 on a 1000-unit domain, against 0.931 and 0.918 withn - 1. The help page’s “Confidence intervals” section has the reasoning and the numbers...neff_*, like..df_*and the interval, isNAfor a column with a single non-missing value in the cell, wherecell_weightstill counts it.
- moment-matched df of the within-cell variance under the fitted correlation, a fraction of
compare_models_cv()gainsblock_sizeandauto_range, and now handsresponse_varandpredictor_varstomake_folds()when it builds the shared fold set. With the defaults the folds are the same geometric blocks as before, so an existing comparison does not move; what changes is that the fold-leakage diagnostic (above) can now fire for the one function that compares models, which until now was the one whose folds could never be checked against the range.select_features_forward()gainsauto_rangefor its inner folds for the same reason, and records it in$params. A shared fold set that cannot be built is an error. It used to be a log line and a fall-back to each backend’s default blocks, which were given neither argument:block_size = 1e6(one block, whichcv_rf()refuses) returned a five-fold comparison on geometric blocks, andauto_range = TRUEthe small blocks it exists to prevent, with no R condition.compare_models_cv()$overallcarries the Bayesian backend’s calibration when a Bayesian model ran:coverage_50,coverage_80,coverage_95andmean_CRPS, the same fold-weighted summarycv_bayes()returns aspredictive_coverage, withNAon the GWR and RF rows. A model that predicts well on average and covers badly (Heaton et al. 2019) is now visible in the table a user picks from, not only in$bayes_cv.modelstays the last column.fit_rf_model()gainsreplaceandsample_fraction, which reachranger::ranger()asreplaceandsample.fraction; both were already accepted through..., but are now recorded in$infoand printed with the fit (“Sampling: bootstrap, with replacement (100.0% of rows per tree)”). The defaults are ranger’s, so no forest changes. The help page carries Strobl et al.’s (2007) case forreplace = FALSE. Passing the ranger spellings through...is now refused like the other arguments the wrapper sets.replace = FALSEwithsample_fraction = 1grows every tree on every row, so no row is out of bag. The fit now warns thatfitted(), the OOB error and the permutation importance are allNaN(they wereNaNin silence), andprint()on such a fit says the OOB error and the permutation importance are undefined, where it dropped the OOB line and printed the importance line empty.area_of_applicability(weights = pmax(imp, 0))failed on that importance with a hint to use the verypmax()it had been given, becausepmax()keepsNaN; it now names the predictors whose weight isNaN, says why (no row is out of bag), and says what to do instead: refit with out-of-bag rows, or passweights = NULL. With permutation importance, the fit’s own warning now says as much:area_of_applicability()cannot be weighted by that importance, so passweights = NULL.area_of_applicability()records the method of the folds its threshold came from as$params$folds_method("block_kfold","random_kfold", … from amake_folds()result;"labels"or"splits"when the input cannot say) and prints it, because the threshold is a statistic of a cross-validated hold-out and pairs with the CV error from the same kind of hold-out. An AOA built onrandom_kfoldfolds logs a caution saying it does not belong beside a blockedcv_*()result.-
estimate_sac_range()gainsdetrend = c("ols", "reml")andreml_max_n. A variogram fitted to least-squares residuals underestimates the range, because the trend fit absorbs part of the long-wavelength variation (Lark, Cullis and Welham 2006). Measured for this estimator on simulated fields (n = 300, true effective range 300), as the median ratio to the estimate from the true field: a white-noise covariate 1.00; a spatially smooth covariate 0.97; a linear trend in the coordinates 0.92; a quadratic one 0.75.detrend = "reml"fits the trend and an exponential-plus-nugget covariance together by REML withnlme::gls()and returns the REML range — 0.95 to 1.06 on the same designs — with the empirical variogram of the REML residuals attached for inspection. It is cubic inn, so it runs on at mostreml_max_n- points; a fit that does not converge falls back to OLS with a warning. The default stays
"ols", so nothing changes unless asked; the help page’s new section carries the numbers. Iterating GLS trend fits against variogram refits (Neuman and Jacobson 1984) was measured too and recovers only part of the bias (0.80 in the quadratic case), so it was not added.nlmejoins Suggests. A fitted range too short for enough pairs of points to lie inside it is refused, withrejected_reason = "fitted range is below the shortest lag fitted"and the bound it fell short of as therange_floorattribute. For the least-squares fits the bound is the shortest lag the empirical variogram resolves (the mean separation in its first bin); it did not fire on white noise or on fields with a 60 m range. The REML range is fitted to the point pairs, not to those bins, so its bound is the distance within which 30 pairs of the points the REML fit used lie, or that first lag when it is shorter. On white noise (n = 300, 30 draws) the REML fit returned ranges of 0.18–23.6 m in 19 draws (one of 0.27 m sized a 3642 x 3676 block grid, refused as a unit mistake); the 16 of them up to 12.5 m are refused and the estimate is finite in 13 of 30. REML estimates of a true 30 m range that the first lag alone would have refused (14.5–28.8 m, in 10 of 20 draws at n = 400 and 6 of 15 at n = 300) are returned, and at n = 400 the same holds atcutoff = 0.5and0.1. The bound is about identification, not a test for spatial structure. A refused REML result keeps itsremllist.
- points; a fit that does not converge falls back to OLS with a warning. The default stays
sac_nugget()returns the nugget variance behind anestimate_sac_range()result, and every classed result — identified or rejected — now carries it as anuggetattribute (NAwhen no model could be fitted). It was reachable before only by reading theNugrow of the attachedgstatmodel. Results also recorddetrend_method("ols","reml"orNA), and withdetrend = "reml"aremllist (n_used,subsampled,nugget_prop,sigma2).estimate_sac_range()refuses a range fitted through an empirical variogram that decreases with distance over its shorter lags: a net fall of more than 15% of the mean semivariance there, weighted by pairs. A model that rises to a sill has nothing to identify on such a curve, and the result isNAwithrejected_reason = "empirical variogram decreases with distance", the refused value inrejected_range, and the variogram attached;plot()captions it. The shape is what a periodic (hole-effect) structure or a variance that differs between a dense cluster and the rest of the layer produces — measured on 60 draws each: 98% of fields with a periodic component and 100% of the clustered case are flagged, against 0% of an exponential field with a range of 100 or more on a 1000-unit extent, 2–3% at very short ranges, 2% of white noise — and not what a trend produces, which is a variogram that rises without a sill and is refused as before. The one other path that returned a bareNAfrom inside a completed fit (a non-positive fitted range) now returns the classed, inspectable shape too.resolution_profile()andselect_resolution(): the number of cells, scored on every criterion at once.resolution_profile()runs a log-spaced ladder of level counts from a floor the autocorrelation range implies (ceiling(area / range^2)) to a ceiling the support implies (floor(n / min_cell_n), wherenis every point in the layer, not thesample_nsubsample the k-means runs on), fits each level as the best of 25 k-means++ restarts, and returns a data.frame with the WSS elbow statistic (read on log-log axes, andNAwhen the points have no cluster structure to bend the curve), Mallows’ C_p of the piecewise-constant approximation of the response (or of its residuals on the predictors, fitted on the rows with a complete response and predictors, with the nugget from the fitted variogram as the noise variance and the penalty set for the whole layer), the standardised residual Moran’s z of the cell means, and an analytic reliability of the cell means — the share of their spread that is between-cell signal rather than sampling noise, from the variogram alone via Krige’s additivity relation (Cressie 1996), the shrinkage factor of Fay and Herriot (1979) — plus the cell-support and cell-diameter columns and the between-restart spread. A floor above the ceiling is reported as a finding rather than resolved silently.select_resolution()reads a level off one criterion together with its flat region and says when a bound, not the criterion, is choosing. The flat region is a set, not an interval: the criterion curves are not monotone, so a region can skip a rung of the ladder, and it is printed as the runs the criterion accepts (“19 to 21, 26, 31 to 33”) rather than a range that would quietly include the levels it rejected. Two things measured before this shipped, both on the help page: on smooth fields with a small nugget C_p descends to the support ceiling (every replicate at effective ranges 90–900 with nugget 0.3 on a unit sill; interior only at nugget 2), and the reliability optimum agrees with the empirical one from true block means on simulated fields but is broad — flat to within 2 percent over a factor of 3–6 in the number of cells. Read the flat region.determine_optimal_levels()is unchanged in shape and keeps its integer-vector interface. A suppliedsacwhose range was refused (anNAwith arejected_reason) is not read as accepted:cpkeeps that fit’s nugget and warns, naming the reason,reliabilityisNA(it needs the range), and a fit that did not converge, or whose range is below the shortest lag fitted, gives neither its nugget nor its range (the whole refused fit used to be used, and a correlation function whose range could be many times the extent pinned the reliability optimum to the first level, with no R warning). A range below the shortest lag means the structure cannot be told from a nugget, so the nugget is not identified either: on white noise detrended by REML it was 6e-7 on a sill of 0.99, and C_p, with no penalty, ran to the support ceiling (33 cells). A nugget of 0 (under 1e-4 of the sill, since a REML fit stops short of its bound: 6e-7 passed a test for exactly 0), on which C_p has no penalty and falls to the ceiling, is warned about, and so is asacwhosedetrendedflag does not match the variable scored (a residual variogram on the raw response moved the C_p pick from 2–4 cells to the ceiling of 44 in five of five simulated fields);attr(x, "variogram")recordsdetrended. When nosacis passed, these warnings name the variogram the profile estimated (kept inattr(x, "sac")), not asacargument the caller never gave. A suppliedsacis read in its own CRS: the points are transformed toattr(sac, "crs")first, assummarize_by_cell()does (a range in US feet put the floor of a metre layer at 2 where the same range in metres put it at 8). Asacgiven as aunitsobject (set_units(1.5, "km")) or a character string is refused by name; aunitsobject used to be read as a number in the CRS units, so 1.5 km became a range of 1.5 m and a floor of 41 million cells. A plain number is taken as the range alone: it sets the floor, andreliabilityisNA. Wherever the variogram gives no nugget (none was fitted, its fit did not converge, its range is below the shortest lag, orsacis a range alone),cptakes Mallows’ own noise variance, the residual mean square RSS / (m - L) of the finest level whose cells hold two scored rows each on average, and the profile warns;attr(x, "cp_noise")records which variance was used, and the print says so. It counts the structure within those cells as noise too, so on average it is no smaller than the nugget and errs towards fewer cells.cpused to beNAat every level in all four cases, so a workflow that reads C_p by default (build_tessellation(approx_n_cells = <profile>),summary()) fell through to another criterion with a log line at most. Reliability’s domain term is taken over the convex hull the area is measured on, not the bounding box: on a 3000 x 120 strip the reliability pick is 6 cells whether the strip lies axis-aligned or rotated by 45 degrees (it was 8 and 2), and 10 either way on a square (it was 10 and 8), and reliability values shift slightly on every profile. Row order does not change the profile (see thedetermine_optimal_levels()item under Bug fixes); over permutations of one 2000-point layer, WSS used to move by up to 2.6 percent and the C_p pick across its flat region (222, 173 and 135 cells). The ceiling reaches the number of distinct locations when locations repeat (and explicitlevelsup to it are kept) instead of stopping one short; with no location repeated, the print says the ceiling is one short of the points rather than crediting the distinct locations. Points with empty or non-finite coordinates are dropped with an R warning, as indetermine_optimal_levels(); it was a log line alone.range_floor = FALSEstarts the ladder at 2 whatever the range and only reports the floor, so profiles whose range estimates differ (cross-validation folds) have comparable ladders; with the floor applying on one fold and not the next, reliability and the elbow moved by a factor of 10–15 between folds. The default,range_floor = TRUE, keeps the floor, and the help page describes that regime switch. Underselect_on = "split"a suppliedsacis flagged in the log, since it must be fitted on the selection half, and the help page shows the two-call workflow.select_on = c("all", "split")ondetermine_optimal_levels(),resolution_profile()andselect_features_forward(). Whenever a selection reads the response — a level count chosen withresponse_varandpredictor_vars, or a predictor set chosen by a forward sweep — what is estimated afterwards on the result is post-selection, and its standard errors are descriptive rather than at nominal coverage (Gao, Bien and Witten 2022).select_on = "split"is sample splitting: the layer is cut into two spatially blocked halves (make_folds(k = 2, method = "block_kfold")), the selection reads the response on the first only, and the row positions of both come back (as a"split"attribute on the first two functions, as$spliton the third) so the estimation can be done on the half the selection never saw. The positions index the layer as passed, for all three functions (select_features_forward()’s were positions after its completeness filter, so with seven incomplete rowspts[fs$split$estimation, ]held 47 rows of the selection half and 4 of the dropped ones). When the default six-block split leaves fewer than 10 points in a half (a small layer, or a small group far from the rest), it is retried on grids of about 16, 36 and 100 blocks, with a warning, instead of stopping, and the split recordsgrid,n_blocks,balance(a 2:1 split on clustered layers used to pass silently) and each half’sextent. The halves are not independent: blocking reduces the dependence across their shared border but does not remove it (85 percent of the estimation points of the package’s test layer lie within the fitted range of a selection point), and the seed decides only which side selects, not where the cut falls. The level-count functions still draw their cells on every point, so the count they return is a count for the whole layer it will be applied to.select_features_forward()also returnsscore_holdout: the selected set fitted on the selection half and scored on the other, the honest number its selection-internalscoreis not. The cost is precision — half the points estimate, and a contiguous spatial half is less efficient than an exchangeable one (García Rasines and Young 2023). Data thinning (Neufeld et al. 2024) and data fission (Leiner et al. 2023) keep the whole sample and are noted on the help page, not implemented. The help page also now says plainly that supplying bothresponse_varandpredictor_varsupgrades the level-selection criterion to"combined", so the selection depends on the response without that having been asked for.make_folds()gainsblocks: a polygon layer (sforsfc) to use as the blocks ofmethod = "block_kfold"in place of the grid it would otherwise build — the$cellsof abuild_tessellation()result, hexagons, watersheds, administrative units, the$blocksofblockCV::cv_spatial(). Each point takes the block that contains it and the blocks are assigned to folds exactly as grid cells are, so the fold builder can now consume every shape the tessellation half of the package produces. The grid-sizing arguments andboundaryare ignored with a log line,auto_rangecompares the estimated range against the blocks instead of resizing them, and the leakage warning uses the median over blocks of the side of the square with the block’s area (params$block_scale). Points inside no block are assigned to the nearest one with a warning that counts them, except points within a millionth of the extent of a block — an edge that reprojection moved by a rounding error; points inside more than one block take the first, with a warning when the blocks concerned overlap in area rather than share an edge.paramsgainsn_blocks(before empties were dropped; for a grid,grid_nx * grid_ny, the cells aboundaryclips away included),blocks_suppliedandblock_scaleon everyblock_kfoldresult;grid_nx/grid_nyareNAfor supplied blocks. Ablockslayer without a CRS is brought into the points’ CRS with an R warning naming it, asboundaryis.make_folds()gainsbalance_tol, the largest-to-smallest fold size ratio above whichblock_kfoldreports its folds as imbalanced. The check was always there at a hard-coded 3:1, and it was a log line only; it is now an R warning (Infdisables it), the ratio achieved is returned asparams$balance_ratio, and the help page says which methods balance what: onlyblock_kfoldbalances point counts, by packing blocks largest first into the fold with the fewest points so far. No search over packings was added, because the packing is not where the imbalance comes from. Measured against the optimum by enumeration (two folds, up to ten blocks, heavy-tailed sizes), the greedy packing is optimal in 72 percent of cases and within two points of optimal on average; a local search with 30 random restarts moved the ratio by 0.004 on average over 480 block-size vectors and never brought one of the 91 above 3:1 below it. The remedy for an imbalance past the tolerance is the block design — the warning now says so — andblocksis how to supply one: on clustered layouts where the geometric grid exceeded 3:1 in 22 percent of draws (median 1.8, worst 6.3), Voronoi cells around 15get_voronoi_seeds(method = "kmeans")seeds never exceeded 1.3 (median 1.09). With the default tolerance the folds of every existing call are unchanged.metricsoncv_spatial(),cv_gwr(),cv_bayes(),cv_rf()andcompare_models_cv(): a scoring function of your own,function(y, yhat)returning a named numeric vector (a named list of scalars or a one-row data frame also serve), applied the way the built-in metrics are — once per fold, so each name becomes a column offold_metrics, and once to the pooled out-of-sample predictions, so each name becomes a column ofoverall— on the same finite pairsRMSEuses. This is the way to score what the Gaussian set cannot: a Poisson deviance, a log score on a probability, a weighted loss. The contract is strict where it should be (every element named, names unique and not a built-in column, one number per name — anything else is an error, because a scoring function of the wrong shape is a mistake to surface) and forgiving where it should be (a function that throws on a fold is logged and its columns areNAthere; a fold is never dropped for it). Nor may a name reuse a per-fold extra (CRPS,coverage_*,gp_k,gp_n_basis,n_draws,bandwidth, or a name thefold_info_fnreturns) ormean_CRPS: each used to replace the package’s value silently, andcv_bayes()$predictive_coveragethen reported the user’s number. The empty frames of a run where every fold failed carry the columns, typed, when the function can be called on zero-length input.compare_models_cv()hands one function to every backend and protects it like the fold arguments, so the columns of itsoverallare comparable across rows.fold_info_fnis documented as the per-fold half of the same mechanism, with access to the fitted object and the held-out layer.ensure_projected()gainspurpose = c("distance", "area"). The default is what it always did: for lon/lat input, the candidate that distorts distances least. With"area"— densities or rates per cell are going to be computed — the choice is made among equal-area projections only (a Lambert azimuthal centred on the data, or an Albers conic where its parallels do not degenerate, whichever distorts distances less), which a UTM zone never enters, and global coverage gets Equal Earth rather than Web Mercator. Already-projected input is still returned untouched, but its area distortion over the extent is now measured — the spread of planar-to-geodesic area ratios over probe polygons — and logged as a warning above 1 percent. Measured: a UTM zone edge to edge 0.25 percent, a 2.5-degree extent inside one 0.04 percent, the conterminous United States forced into one zone 14 percent, Web Mercator over 2.5 degrees of latitude at 48N 4 percent, an equal-area projection a few tenths of a percent (the sphere the geodesic areas are computed on against the ellipsoid). The measurement works whethersf_use_s2()is on or off (with it off and no lwgeom it returnedNA, sosummarize_by_cell(area = TRUE)refused every grid andpurpose = "area"skipped its warning), and its probe polygons are densified before their geodesic area is taken, so an equal-area grid over a near-global extent no longer measures 10 percent. Withpurpose = "area", a single polygon’s candidates are scored rather than falling back to the Lambert azimuthal unmeasured.summarize_by_cell()gainsarea = TRUE: withcells_sf, the result carriescell_area(planar, in the squared units of the cells’ CRS; for lon/lat cells the geodesic area in square metres, not a planar area in squared degrees) andn_per_area, a point density; a rate of anything else is itsagg_funssum overcell_area. The request is refused with an error — not answered with a number — when the cells’ CRS distorts areas across them by more than 1 percent by the measurement above, because a density is a comparison between cells and means nothing where the map scale differs from one cell to the next; the message names the CRS, the figure and the remedy. A cell with no observations getsNA, not zero. Lon/lat cells pass the distortion check by construction; with s2 switched off their area needs lwgeom, and the request is refused without it. The measured spread is attached asattr(, "area_error"). Acells_sfwith no ID column the summaries can be joined on is an error underarea = TRUE, since the area columns cannot be produced.build_tessellation(approx_n_cells = )andget_voronoi_seeds(n = )accept what the level-selection step returned: the integer vector of ranked candidates fromdetermine_optimal_levels()(its first element is used), aselect_resolution()result (its$best), or aresolution_profile()(read withselect_resolution()at its default criterion). Both were hard errors before, so nothing that worked changes; the count used and where it came from are recorded asparams$approx_n_cells/params$approx_n_cells_fromand asattr(seeds, "n_from"). The two functions the pipeline documents as a pair are now connected:get_voronoi_seeds(n = determine_optimal_levels(pts))needs no number carried between the calls by hand. A hex or square lattice recordsparams$cells_occupiedandparams$cells_empty, and a count taken from a profile or a selection warns when fewer than three quarters of it end up occupied: the count is of k-means cells, all of them occupied, and on clustered points a lattice leaves about half its cells empty.-
Five diagnostic plots that show the curve behind a chosen point, the folds behind a pooled number, or the distribution behind a count. None recomputes anything; each draws what the result already carries.
-
plot_cv_metrics(cv, metric): one point per fold, sized by the held-out rows it contributed, with the pooled value fromoverallas a dashed line; acompare_models_cv()result gets one panel per model on a shared scale. Any column offold_metricscan be drawn, including backend extras and columns ametricsfunction added; a column that isNAin every fold is refused with the reason (Adj_R2withoutp, coverage without draws) rather than drawn empty, and a per-fold extra with no pooled counterpart draws without the line and says so. So does a count (n_pred,n_MAPE,n_SMAPE), whoseoverallvalue is the total over the folds; it was drawn as the pooled line, at 150 against folds of 30. A model with no finite per-fold value gets no panel and no pooled line, and the caption names it, whether or notoverallhas a value for it (RF’sbandwidthwas dropped without a mention). Acompare_models_cv()result keeps its model strip when only one model is left to draw. -
plot.aoa(): the dissimilarity index of the prediction locations against the training DI — cross-validated over thefoldspassed, or else each point’s distance to its nearest other training point; the legend says which — as ECDFs, or a histogram with the training curve, threshold marked, with the share outside and how close the inside ones run to the edge in the subtitle, and whether the threshold came from cross-validated folds in the caption. Prediction locations outside on a predictor dropped for having no training variance (DI = Inf) count in the prediction curve, which then tops out below 1, and the caption says how many are off the axis. It counts theDI = NArows (a missing predictor) as well. The curve used to leave theInfrows out: it read 0.97 inside at the threshold while the subtitle counted 11 of 40 outside. A result with every row atDI = Infwas refused as “every row had a missing or non-finite predictor”, and the error now gives the true reason. -
plot.spatial_fit(type = "variogram")overlays the response’s own variogram (hollow points, dashed fit) on the residual variogram, on the same points and lags, so the structure the model absorbed is the gap between the two curves. The response curve is whichever variogramestimate_sac_range()returns for the response, which is its widest single direction when the all-pairs fit is unusable (a response with a trend, whose residuals are fine). A single-direction response curve is labelled with its azimuth, and the caption compares the sills only when both ranges were identified and the two curves cover the same point pairs (such a curve used to be labelled plainly as the response, its sill set against the all-pairs residual curve’s: “Residual sill is 45% of the response sill” from a quarter of the pairs).response = FALSErestores the residual curve alone. -
plot_calibration(cv): observed against nominal coverage ofcv_bayes()’s posterior predictive intervals, pooled (blue) and per fold (grey), with the diagonal and a one-line verdict. Each level is drawn at the nominal valuecv_bayes()records incoverage_levels(0.995 was drawn at 1.00), socoverage_levels = seq(0.1, 0.9, by = 0.1)gives a full curve; the default three levels are unchanged. Its error for all-NAcoverage namescompute_pred_intervals = FALSEas well as failed draws. - One sweep drawer behind three methods:
plot.resolution_profile()(a panel per criterion, the level each selects marked, its flat region shaded, a note when a bound rather than the criterion is choosing);plot.feature_selection()(the accepted variable’s score at each step as the path, every other candidate faint, the stop in red, the best candidate at the step after the stop, which was scored and not added, hollow and labelled “not added” rather than drawn like the accepted variables, the hold-out score as a separate mark whenselect_on = "split"computed one — and a caption saying whether the intercept-only model was scored, since for the RF and GWR backends it usually is not, so the path starts at the first variable);plot.gwr_model_selection()(every model’s AICc against its size, the best of each size joined, the winner marked, its lead over the runner-up in the subtitle).select_features_forward()’s result now carries class"feature_selection"soplot()finds the method; it is the same list otherwise.
-
-
cv_block_size_sweep(): the same cross-validation at a ladder of block sizes, with random folds as the leaky reference, returned as a table with the fold-to-fold spread at each size and the estimated autocorrelation range alongside;plot()draws the curve with the range marked. Blocks smaller than the range leak, so the curve rises from the random-fold value towards the range and plateaus beyond it, and the height of the rise is what the random-fold number overstated — measured on simulated fields with a 100–130-unit range and a random forest with coordinates, the plateau begins at one to two times the estimated range. Each size is a fullcv_spatial(), so a fit budget (max_fits, default- refuses to start rather than run past it. The default ladder runs up to the largest size, at most half the shorter side, whose grid still holds
kcells. At the defaultk = 5half the side is a 2 x 2 grid of four cells on any extent less than 1.5 times as long as it is wide, so that rung used to be dropped every time.n_sizes = 6then ran five cross-validations, the last at 0.30 of the side, and missed ranges up to a third of it that a 3 x 3 grid reaches. The top is now about a third of the side on such an extent. Sizes the caller passes inblock_sizeswhose grid holds fewer thankcells are not run, and a warning names them and the largest size that giveskblocks; they were dropped with only a log line. Aunitsobject forblock_sizesis refused by name. On clustered datamake_folds()can still lowerkat the top sizes, which thekcolumn shows. The default ladder also skips sizes whose grid would exceed the 1,000,000 blocksmake_folds()builds (a 6 km x 2 m transect used to abort with an error aboutblock_size), runs along the line for points on one axis-parallel line (they were refused as having “no extent”), and warns when every rung is below the estimated range while longer blocks would still fitktimes along the longer side (a 10 km x 100 m corridor: rungs of 4 to 50 m against a 1.7 km range, a flat curve that read as no leakage). On a roughly square extent atk = 5that warning also fired, as in the block-size tour script on a 996 m square, with two wrong numbers. It called the top rung it had run (300) “half the shorter side” (498). It said blocks only up to the longer side overk(199, smaller than rungs already run) still gavekblocks, when blocks up to 332 did. It now fires only where a longer block would still givekblocks, and it names the top rung run and the largest size that giveskblocks. User-suppliedblock_sizesover the grid cap are refused before any fit, and asacestimated in another CRS is converted to the sweep’s units with a warning (a metre range on a US-foot axis was drawn 3.3 times too short). The plot’s caption reads everycoverage_*column as “closer to the nominal level is better” (onlycoverage_50,coverage_80andcoverage_95were known, as higher-is-better, socoverage_97.5fromcv_bayes()’s full-precision names was captioned “lower is better”).
- refuses to start rather than run past it. The default ladder runs up to the largest size, at most half the shorter side, whose grid still holds
fit_gwr_model()keeps its local collinearity survey. Every fitting window — not a sample of 30 — has scaled condition indices of its kernel-weighted local design computed, the way Wheeler and Tiefelsdorf diagnose GWR collinearity, and the fit carries them asinfo$local_collinearity(one row per observation: coordinates, window size, and two condition indices:cn, Belsley’s uncentred index with the intercept, andcn_slopes, the predictors centred in the window and scaled by their study-area standard deviation), withinfo$n_local_collinear,info$n_local_singularand the globalinfo$condition_index(on the centred predictors) besideAICc.n_local_collinearand the warning count the windows whose slopes are collinear (cn_slopesabove 30, orcnabove 1e6), so a predictor’s origin (degrees C or kelvin) does not change the verdict. The warning is now the exact fraction of locations rather than a sampled one, and its thresholds (a quarter of the locations, or any) are unchanged; above a quarter it now says that an exactly singular window stops the fit, instead of promising non-finite coefficients. The weighted survey sees what the unweighted spot-check could not: a bisquare window’s edge points contribute almost nothing to the fit, so they contribute almost nothing to its conditioning. The survey also runs with a single numeric predictor, and its kernel weights equalGWmodel::gw.weight()exactly: a boxcar keeps a point at the kernel’s edge, and a zero-width adaptive kernel givesNaN, counted as singular, instead of weight 1 at the co-located points. (A grid with a boxcar bandwidth equal to its spacing used to be reported collinear at every location while GWmodel fitted windows of 3 to 5 points.)plot(fit, type = "coefficients")for a GWR fit maps one local coefficient (term) at the training locations, which is the reason to fit GWR at all — and masks the locations where it is not to be believed: a collinear local design (for a slope, the slope condition index above 30 or singular; for the intercept, the condition index with the intercept above 30) or a non-finite coefficient is drawn hollow and grey, counted in the subtitle, because the smooth surface a naive map draws over them is the picture of an unstable estimate.mask = FALSEdraws them anyway and says how many it is drawing. A fit with a duplicated predictor name is drawn rather than refused as “every location is masked”. A slope map in kelvin is drawn like the one in degrees C; before, every location was masked and the map refused.kriging_adequacy(): what a block-kriging aggregator would deliver on a set of cells, computed beside the plain means and changing none of them. Per cell, from a fitted variogram (estimate_sac_range()’s, or estimated here): the block-kriging estimate and variance, that variance as a share of the variance the cell’s mean would have with no data at all (kr_ratio, the coverage score — near 1 the data tell the cell nothing; a share of the point sill never came near 1 for cells larger than the range), whether it exceeds the design-baseds^2/nof the plain mean (kr_exceeds_design), and the kriged-minus-plain shift in standard errors (kr_shift); plus the variance of the standardised errors from blocked cross-validation (attr(, "cv")$zscore_var), which is about 1 when the kriging variance is right. Measured on simulated exponential fields: 0.93–1.07 with the true variogram, 0.85–1.01 with the estimated one; under blocked folds it checks the sill and range rather than the nugget (0.95–1.24 with the nugget understated tenfold), and random folds are the instrument for the nugget. The comparison the function exists for: under uniform sampling kriged and plain means differed by more than one standard error in 11–24 percent of cells; under clustered sampling in 34–63 percent, with 3–27 of 16–64 cells empty and kriged anyway. Repeat visits to one location are kriged from their mean, with the part of the nugget that varies between visits divided by their count; kriging them as separate rows made every kriging system singular and returnedNAeverywhere. Any cell or held-out location gstat still cannot solve is counted in a warning. This is the first kriging path in the package (gstat::krige()); its model families are the ones the package interprets elsewhere, and any other is refused by name. The cross-validation runs eachmake_folds()split on its own training set, so"buffered_loo"and"nndm"keep their exclusion zones (reduced to fold labels they ran as plain leave-one-out: with a 250 m buffer, a standardised-error variance of 1.02 and RMSE 0.757, against 0.873 and 0.944 with the buffer kept), andprint()names the scheme instead of calling every one “blocked CV”. A cell holding locations that thenmaxnearest its centre leave out is kriged from all of its own locations plus thenmaxnearest outside it (a cell of 1,500 points was otherwise estimated from its middle 50: 0.43 off against 0.07), and the columnkr_n_usedsays how many locations each cell was kriged from.max_neighbours(default 2000) leaves out, with a warning, a cell that would need a larger kriging system, andmax_box_ratio(default 1000) a cell whose bounding box exceeds its area that many times, because gstat discretises over the whole box (+592 MB for one strip at 7,072); both are counted inattr(, "cells_left_out")and byprint(). The points are put in the CRS the variogram was fitted in (attr(sac, "crs")), assummarize_by_cell()does: asacfitted in metres used on points in km gave a CV statistic of 3.05 against 0.85. Asacwhose variogram is of residuals (detrended = TRUE) is used with a warning that the response is kriged without its predictors and the variances come out too small (4.3–5.2 against 0.67–1.53).attr(, "rejected_reason")records why asac’s range was refused, and the warning andprint()say it instead of “sill never reached” for every refusal. The cell ID is found and matched assummarize_by_cell()finds it (cells keyed byidorgrid_idtoo, and a double ID of 1e5 matches an integer 100000, where it used to leave that cell with n = 0), a point whose ID matches no cell is counted in a warning, and a layer with no CRS is taken to be in the other’s. Folds whose recorded rows sit at other locations inassigned_points_sfare refused, as incv_*(). Folds built beforeassign_features_to_polygons()dropped points used to be applied by position, holding out the wrong points (cross-validation RMSE 1.82 against 2.11 with folds built on the assigned layer), with nothing said.MAPEandSMAPEnow say how many rows they were averaged over. Every metrics frame —model_metrics(),summary(),evaluate_insample(),compare_models(), and theoverallandfold_metricsofcv_gwr(),cv_bayes(),cv_spatial(),cv_rf()andcompare_models_cv()— gains two trailing integer columns,n_MAPEandn_SMAPE: the rows each percentage error actually used once those where its denominator is zero were dropped (yfor MAPE,|y| + |yhat|for SMAPE, zero meaning no larger than 100 machine epsilons times the data’s own magnitude). They equaln(n_predin the CV frames) when nothing was dropped, and are0in an empty frame. The values themselves are unchanged, apart from what now counts as zero (see Bug fixes): a MAPE over 58 of 120 rows is the same number 2.0.0 reported, but it now arrives labelled, where before nothing in the frame recorded that it was a subset average.print(summary(fit))appends “(over k of n rows)” to its SMAPE line when the two differ. The columns sit afterAdj_R2so code addressing the seven metric columns by position is unaffected; code pinning the exact column set needs the two names added.voronoi_seeds_kmeans()gainsnstart, the number ofstats::kmeans()starts (default 10, as before).cv_bayes()andcompare_models()say when a Bayesian fit did not converge. Both scored such a fit like any other, and only the fit’s WARN log lines (whichtryCatch()and knitr never see, andspatialkit_quiet()hides) said that its posterior was not to be trusted.cv_bayes()’sfold_metricsgainsconvergence_ok:TRUEorFALSEasfit_bayesian_spatial_model()judged that fold’s sampler (R-hat, effective sample size, divergences), andNAwhenfit_argssetscheck_convergence = FALSE. A run with anyFALSEraises one warning naming those folds.compare_models()gains the same column and warns once for each model that did not converge. Code that pins the exact column set of either table needs the new name added.select_resolution()returns$seeds, the centres of the partition the profile scored at the chosen level, andget_voronoi_seeds(method = "kmeans")andvoronoi_seeds_kmeans()return those centres when given the selection or the profile, instead of running a k-means of their own. A k-means partition is the Voronoi partition of its centres, so these seeds rebuild the very cells the criteria judged. The fresh k-means the seeding functions ran (Hartigan-Wong, 10 starts, on whatever cloud they were given) was a different partition: on 300 points it put 5–16 percent of them in a different cell from the one they were scored in, and on 2000 points (profiled on a 1500-point subsample) 26–29 percent, so the count was defended on cells nobody built. The profile keeps every level’s centres inattr(x, "centres")(withattr(x, "centres_crs")), the seeds come back in the CRS of the boundary or points they are for, andattr(seeds, "kmeans")assignssample_pointsto them, each point to its nearest seed. A count passed as a number (n = sel$best) still runs a fresh k-means, as does a selection made before seeds were kept.voronoi_seeds_kmeans()did not accept a selection or profile askbefore, and the advice inbuild_tessellation()’s occupancy warning now points at the selection rather than the bare count.
Bug fixes
A clipped cell that also touched the boundary from outside was dropped, and its points left with no cell.
st_intersection()returns such a cell as a GEOMETRYCOLLECTION of its area and a line or point (on an L-shaped boundary, the cell over the inner corner), and the Voronoi, grid and Delaunay builders kept only POLYGON and MULTIPOLYGON rows, so the whole cell went, area and all. On a 4 x 4 L with square cells of side 2, ten of thirty points had no cell and the cells covered 8 of the boundary’s 10 square units. Each collection is now reduced to its polygonal part, one row per cell still; a piece with no area is dropped as before.coerce_to_points()crashed R on an empty line feature. sf’sst_cast()turns an empty MULTILINESTRING into one empty LINESTRING, not zero parts, andst_line_sample()on it segfaulted and took the session with it. A null geometry in a line layer loads exactly like this from a GeoPackage or a shapefile, andprep_model_data(), everycv_*()function andfold_separation()go through this path. GEOS’sst_point_on_surface()crashed the same way on a line feature holding an empty part beside real ones, whichensure_projected()reached on ordinary lon/lat input. Empty parts are now removed before either call, and an empty feature becomes an empty POINT in its own row, whichprep_model_data()andmake_folds()drop like any empty geometry. An empty LINESTRING used to raise an error; it now gives an empty POINT like every other geometry type.cv_gwr(parallel = n)hung forever once a GWR had been fitted in the session. GWmodel is built with OpenMP, and GNU libgomp is not fork-safe: afterfit_gwr_model(), a sequentialcv_gwr()or a bareGWmodel::bw.gwr(), the forkedmclapply()workers blocked on a futex and never returned, with no timeout. That is the ordinary fit-then-validate order on Linux.cv_gwr()now runs its folds one after another wheneverparallelwould fork, and says so in a warning. This gives up the speed-up parallel folds had in a fresh session; the othercv_*()functions still fork, since ranger does not use libgomp. Acv_spatial()fit_fnthat calls GWmodel can still hang withparallel, which its help page now says.When some folds failed,
overallquietly left them out. A partial failure was only logged, sooverallpooled the surviving folds with no R condition, and the folds that fail are usually the hardest to predict (a region or a factor level no training fold covers).cv_gwr(),cv_bayes(),cv_spatial()andcv_rf()now warn, naming each failed fold and how many rowsoverallcovers. Folds dropped before fitting are already warned about and are not counted twice.select_features_forward()could pick a variable for making folds fail. Each candidate was scored on whatever rows its CV run predicted, so a set whose fit failed on a fold (a factor level found in one block, an ordinary case under block folds) was scored on fewer, easier rows. A pure-noise factor beat the true driver: RMSE 2.35 on 192 rows against 2.63 on 250, although the driver scored 1.73 on those same 192 rows. Every candidate is now scored on one fixed row set (the rows the null model predicts, or, with no null model, the rows the step-1 sets predict), a set that leaves any of them unpredicted scoresNAwith a warning, andhistorygainsn_pred.compare_models_cv()ranked models scored on different rows. Shared folds guarantee the same splits, not the same scored rows: when one backend lost a fold or returnedNApredictions itsoverallrow pooled a subset, and the help page called the columns comparable. A fixed-bandwidth GWR scored on 158 of 200 rows ranked above a random forest scored on all 200, although the forest was 45 percent better on the 158 rows both predicted. When the predicted row sets differ, every model is now rescored on the rows all of them predicted, with a warning,overallcarriesn_pred, and the all-rows numbers stay inattr(overall, "all_rows"). The per-fold table and the Bayesian coverage columns are not rescored.determine_optimal_levels()read an elbow into points that have none. The elbow was the level furthest below the chord of the WSS curve on linear axes. For points with no cluster structure WSS falls likec / k, and the furthest point below that chord is exactlysqrt(a * b)for a ladder fromatob, so the answer was set by the ladder’s ends: 1,500 uniform points gave 4, 4, 7, 9 and 13 formax_levelsof 12, 20, 40, 80 and 160. At the defaultmax_levels = 12it also missed well-separated clusters (four clusters came back as 3). The elbow is now read on log-log axes, wherec / kis a straight line, and counts only when the curve sags clearly below it. Two, three, four, five, eight and ten well-separated clusters are now recovered at the default on every seed tried (six came back as five on one seed in five). With no elbow the function still returns its linear-axis answer, but warns that the ladder chose it;build_tessellation()andget_voronoi_seeds()refuse a geometry-onlyresolution_profile()with no elbow instead of drawing a count from it. A ladder of two levels (max_levelsof 1 or 2, or three points) has no line to test. The warning now says the ladder is too short to read an elbow from and names what ended it. It used to say the curve fell in a straight line “as it does for points with no cluster structure”, which two clusters 90 m apart atmax_levels = 2were told.One invalid polygon changed the assignment rule for the whole layer.
assign_features_to_polygons()wrappedst_join(largest = TRUE)in a retry withoutlargeston any error. The comment blamed a predicate that cannot takelargest, but sf never calls the predicate on that path; the retry fired when GEOS threw on an invalid ring, and every straddling feature was then assigned bytie_breakinstead: 95 of 200 buffered parcels changed cell, and a polygon 91 percent inside one cell went to its neighbour, with no warning. Invalid features and cells are now repaired withsf::st_make_valid()for the join only, with a warning counting them, and a join that still fails stops and nameslargest = FALSE.Lon/lat polygons were assigned to projected cells on bent cell edges.
assign_features_to_polygons()moved the cells into the features’ CRS, so lon/lat features pulled the package’s own projected cells into lon/lat, and with s2 the largest-overlap join failed and fell into the silent retry above: 55 of North Carolina’s 100 counties went to a cell other than their largest overlap on a 36-cell grid. The join now runs in the cells’ CRS whenever it is projected, and the features come back with the geometry they arrived with. Lon/lat points near a cell edge can change cell as a result; they now agree with a join done in the projected CRS.build_tessellation(method = "triangles")dropped most points at UTM coordinates. qhull lifts each point onto x^2 + y^2, and at projected magnitudes (a northing near 5e6) the lift had no precision left to separate points a few metres apart, so they never became vertices: 200 points over 100 m gave 26 triangles instead of 386, and the help page’s own example lost 8 of its 20 points. The points are now centred before triangulation. Triangles that were already right are the same triangles, but qhull returns them in a different order, so trianglecell_idvalues change.Data around a pole were projected to Web Mercator. A layer spanning more than 180 degrees of longitude with no gap was treated as global coverage. Antarctic stations came out with worst-case distance errors near 20,000 percent (the South Pole at y = -2.4e8 m), and Voronoi cells put 9.5 percent of Arctic locations in a station’s cell that was not their nearest. A layer that lies wholly on one side of the equator now gets a Lambert azimuthal equal-area projection centred on its pole whenever that measures a smaller distance error than the global fallback: about 2 percent on the same stations.
GWR mixed elevation into its distances.
prep_model_data()kept the Z (and M) coordinate of POINT Z input, which GPS layers andst_as_sf(coords = c("x", "y", "z"))produce.predict.gwr_fit()then handed GWmodel three coordinate columns, which it reshaped into two, scrambling the prediction locations with no warning;gwr_model_selection()ranked models on 3-D distances; andfit_gwr_model()andcv_gwr()failed. Z and M are now dropped inprep_model_data()and again where the data are handed to GWmodel.predict.gwr_fit()lost every prediction to one bad location. It went throughGWmodel::gwr.predict(), which returns nothing for any row if one location’s window is empty or singular, so fixed-bandwidth block CV failed every fold. It also never assigns its distance matrix once training and new rows together exceed 10,000 (a 100 by 100 prediction grid came back allNA), and it built the full training hat matrix for a variance it then discarded, which is cubic in the training size. Predictions now come fromgwr.basic(regression.points = )in chunks, a failing chunk is redone location by location, and only a truly singular location isNA, with a warning counting them. Where the old path worked, the values are identical.On brms 2.17 to 2.22, one far-off row moved every Bayesian prediction in the call. brms rebuilt the Hilbert-space GP’s boundary from the rows being predicted, and the package’s padding rows could only widen it, so a single row outside the training envelope changed the basis for every row, interior ones included: a 40 by 40 grid padded 15 percent moved the in-bbox cells by a mean of 22 percent of the surface’s standard deviation, and
predict_surface()depended onchunk_size. On those versions the GP term’s boundary factor is now rescaled per call so the boundary stays at its fitted value, and a row beyond the boundary, where the basis means nothing, isNAwith a warning. brms 2.23 stores the boundary itself; there only theNArule applies. The help page no longer says the boundary “has to grow”.A
bayesian_fit’s cached fitted values could come from another model. The cache lives in an environment, so it is shared by every copy of a fit, and the entry was stamped with the row count and a digest of the training data only. Two fits over the same data hash identically however different their engines are, sorefit <- fit; refit$engine <- <re-estimated>madefitted(),residuals(),model_metrics()andsummary()on either object return the other’s numbers — andclear_fitted_cache(), which the help page offers for exactly this case, could not fix it, because clearing through one copy cleared the one shared entry and the next call re-wrote it. The entry now carries the environment rstan and brms create for each sampling run (@.MISCon the stanfit), which tells engines apart as well as the engine itself does and is written once, and is used only for the engine it came from (identical(), which settles the common case by pointer, so nothing is slower and no memory is held that the fit did not already hold, in a session or on disk). A stale entry is no longer deleted on a miss either: the fit that wrote it still wants it.new_spatial_fit()now always builds a fresh cache rather than adopting one that arrived ininfo, andsummary()no longer carries.cacheat all — a summary was not a value snapshot (its contents changed when anyone later calledfitted()on the fit),clear_fitted_cache()on a summary emptied the fit’s cache, andsaveRDS()on one serialised an environment holding the full n-vector.Every CRPS was
NAabove 46,340 posterior draws..crps_energy()formed its weights withm * m, wheremisnrow(draws)and therefore an integer, so the product overflowed and tookCRPS,mean_CRPSand the wholepredictive_coveragesummary toNAbehind one"NAs produced by integer overflow"warning. 48,000 draws is an ordinarycv_bayes(fit_args = list(chains = 4, iter = 13000))run. The arithmetic is now done in double precision; the values it produces were, and remain, exact against the closed-form Gaussian CRPS.Fold numbers depended on the machine’s collation. Character fold labels are turned into a factor, whose levels
as.factor()sorts underLC_COLLATE, and a fold’s number is its level’s position. Labels differing only in case or punctuation —"north"and"North"— therefore landed in a different order underCthan underen_US, sofold_metrics$fold,predictions$foldandfold_status$foldnamed different groups on different machines from the same data and the same seed. The partition was never affected, so pooled scores were right. Character levels are now sorted withmethod = "radix", which is always C collation, making the numbering a property of the labels alone. The C order applies to character labels only: numeric labels are numbered in numeric order, as in 2.0.0, and a factor by its own level order (an earlier development build sorted numbers as strings too, so with ten or more numeric labels fold 2 was the user’s label 10).area_of_applicability()given a vector of fold labels now numbers them by the same rule; it sorted character labels withas.factor()under the session’s collation, so a message could name a different fold from the onecv_*()named for the same label. Its partition and threshold were never affected.residual_morans_i(k = )silently answered a different question.kreached the weight builder unvalidated, wheremin()collapses a vector:k = c(4, 8)built thek = 4matrix and returned a statistic for neighbours the caller never asked for, with no condition raised, andk = NAaborted on"missing value where TRUE/FALSE needed".kis now checked. Two neighbouring gaps are closed with it: a user-suppliedweightsmatrix holding anyNA,NaNorInfis refused by name instead of aborting inside a guard after a log line reading"row sums range from NA to NA", and a row whose geometry is empty is now dropped with a count, asmake_folds()andestimate_sac_range()already do — its residual is perfectly finite, so it used to survive intoFNN::get.knn()and abort with"Data include NAs".A scalar argument that reaches
as.integer()is validated. Seven exported functions took a count or a distance and passed it straight intoas.integer()or anif ()test, whereNA,Inf, a length-2 vector or any value above.Machine$integer.maxaborts with"missing value where TRUE/FALSE needed"or"'length = 2' in coercion to 'logical(1)'"— errors that name nothing the caller passed.fit_gwr_model(bandwidth = )underadaptive = TRUE,predict_surface(chunk_size = )(Infbeing the natural way to ask for one chunk),voronoi_seeds_kmeans(k = ),voronoi_seeds_random(k = )and the count resolved bybuild_tessellation()/get_voronoi_seeds()now report the argument and the bound.voronoi_seeds_kmeans(k = 0)and a negativekused to return one seed in silence, which “at mostk” does not describe.expandwas ignored rather than refused.clip_target_for(expand = c(0.05, 0.05))returned a clip target byte-identical toexpand = 0with no condition raised, andcreate_voronoi_polygons()did the same for a non-numericexpand, because both testedis.numeric(expand) && expand > 0and short-circuited to FALSE. A malformedexpandis now an error naming the argument; the same values on a degenerate bounding box used to abort inside the expansion helper instead.summarize_by_cell(deff = "variogram")aborted when given no value column. With neitherresponse_varnorpredictor_varsthe internal primary column isNULLby design, anddf[[NULL]]raised"attempt to select less than one element in get1index". The variogram design effect is a function of the cell’s coordinates and the fitted correlation, not of any column’s values, so every point in the cell now counts and the call returns its counts andcell_weightas documented.compare_models()aborted on a list holding nospatial_fit.evaluate_insample()warns and skips a non-fit, and returnedNULLwhen every element was skipped (it is now an error, below); theNULLthen became a bare list andseq_len(nrow(NULL))raised"argument must be coercible to non-negative integer". It now says which argument is wrong and what belongs there.fit_rf_model(include_coords = TRUE)’s caveat said “once per session” and was not..log_warn_once()records the key in a package-level environment, and undercv_rf(parallel = )that write happens inside a forked worker and dies with it: the paragraph printed once per worker, the parent’s registry stayed empty, and the next sequential fit printed it again.cv_rf()now raises it in the parent before dispatching any fold, so each worker inherits the already-warned flag and stays quiet. Measured on two workers: three occurrences before, one after.The grid cache’s order registry outlived the environments it described. Insertion order is kept in a package-level environment keyed by the cache environment’s printed address, and
clear_grid_cache()removes only the entry for the environment it is handed. Passing a freshcache_envper call therefore left one permanent character vector per call, for environments that had since been garbage-collected and could no longer be named. A finalizer now removes an entry with its environment.plot(fit, type = "variogram")no longer runs its subtitle off the edge of the figure. ggplot2 clips a label that is wider than the plot instead of wrapping it, and the sentence saying why no range was identified is up to 150 characters, so on a six-inch figure it was cut mid-word. Labels built from a fit’s own numbers are now wrapped at draw time.ensure_stable_poly_id()could not give IDs to a tessellation this package had just built. It repaired the geometry in the layer’s own CRS and then transformed it to the sort CRS, but validity is a property of the geometry in the CRS it is measured in: two vertices a centimetre apart in a projected CRS can land on one longitude and latitude, and s2 calls the ring degenerate. On a clipped hex tessellation of North Carolina, 2 of 18 cells that are valid projected are invalid once transformed, andst_centroid()on one of them aborted the call with “Loop 0 is not valid: Edge 1 is degenerate”. The sort copy is now repaired after the transform as well. The geometry returned is still the caller’s own, and the same cell gets the same ID whether the layer arrives projected, in lon/lat or in Web Mercator, except where two fine cells’ centres lie within the sort key’s rounding step of the same longitude (36 of 2,500 100 m cells straddling a UTM central meridian changed ID via EPSG:3035); join such layers on geometry.select_features_forward()now says whenfit_fnis ignoring the variables it is handed. The learner it takes is a function of(train_sf, predictor_vars), but the onecv_spatial()takes is a function oftrain_sfalone, and a learner written for that —function(train_sf, ...)— swallows the second argument and fits the same model every time. Nothing errored, because the training layer still carries every column: each candidate scored exactly what the intercept-only model scored, no candidate improved on it, and the result was an emptyselectedand anNAscorewith no word about why. Two different predictor sets do not produce the same cross-validated metric to the last digit, so that pattern is now recognised at the first step and reported as a warning naming the fix. The result is still returned.create_grid_polygons()warns when bothcellsizeandtarget_cellsare supplied. It already did forcellsizeandn, and the documentation says to supply exactly one of the three, buttarget_cellswas dropped in silence whencellsizewas present. Throughbuild_tessellation()that meantmethod = "hex", approx_n_cells = 25, cellsize = 10returned however many cells a 10-unit lattice holds and said nothing about the 25.cellsizestill wins; the override is now logged like its sibling.build_tessellation()warns whenapprox_n_cellsorcellsizeis supplied withmethod = "voronoi"or"triangles". Both arguments size the hex and square lattices and nothing else — Voronoi grows one cell per input point and Delaunay one triangle per neighbouring triple — and both used to be dropped in silence. Sobuild_tessellation(pts, method = "voronoi", approx_n_cells = 25)returned one cell per observation: the degenerate nearest-neighbour case, where every cell holds a single point, there is no within-cell variation and every standard error isNA. The warning names the argument and points atget_voronoi_seeds(), which is where a Voronoi cell count is actually set. It adds thatparamsdoes not record the request either, so a saved result carries no sign of it: the Voronoi branch returnscreate_voronoi_polygons()’s own list, which has no slot for the argument, and the triangles branch no longer echoesapprox_n_cellsback (below). The warning fires underquiet = TRUE, which gates this function’smessage()s and is documented not to silence R warnings.determine_optimal_levels()fits each k as the best of 25 k-means++ restarts (Arthur and Vassilvitskii 2007; Fränti and Sieranoja 2019; Steinley 2003) instead ofstats::kmeans(nstart = 5). The WSS curve is read for its shape, and with a handful of random restarts it carried optimisation noise: on eight-cluster layouts a sweep over k = 1..30 rose at one or two steps in three of five draws, and an earlier form of the elbow rule once selected such a bump. With the new budget the same sweeps rose at no step. A curve that still rises is now logged as a warning naming the number of rising steps, and the model-aware diagnostics carry it aswss_bumpsbesidewss_spread(the relative spread of WSS across restarts at each k) andnstart. A selection made on a curve that had a bump can differ from before — those were the cases that were wrong; a clean curve gives the same answer. Under a model-aware criterion the function also now warns before the sweep whenmax_levelsleaves no k above the nine-cell floor, rather than fitting every k first and falling back afterwards. The seeding draws each centre by inverting the cumulative squared distance rather than withsample.int(prob = ): the same law, and aresolution_profile()of 3000 points takes 10 s instead of 35 s (determine_optimal_levels()on 3000 points, 4 s instead of 13 s), but it gives different centres for the same seed than earlier development builds did.make_folds(method = "block_kfold")can now raise its “block dimension < autocorrelation range” warning. The comparison was always there, but the range it compared against was estimated only underauto_range = TRUE, the one setting in which the blocks had already been sized from that range and the warning could never fire; on every default call the diagnostic was dead code. Withauto_range = FALSE(the default) and aresponse_varto hand — always the case when acv_*()function builds the folds — a range is now estimated for the diagnostic alone. It sizes nothing: the blocks are the same geometric blocks as before and the folds do not change. A hand-setblock_sizebelow the range raises the same warning. The estimate’s own log lines stay off the console, and the check is skipped (with an INFO log line saying so) whengstatis not installed or there are fewer than 30 points. Only a grid dimension that is split is compared with the range, because a single row (or column) of blocks borders no other block across its width. On points along a line the comparison was with 0, so the warning fired on every call that had a response: a 10 km line had 15 blocks 667 m long against a 499 m range. Its advice,block_size = 499, made the blocks shorter (19 of 523 m). A 10 km x 100 m corridor was compared with its 100 m width in the same way. Aresponse_varthat names no column ofpoints_sfis now an error forblock_kfold, whether or notauto_rangeis set. Withauto_rangeoff, a misspelt name used to switch the check off silently.fit_rf_model(include_coords = TRUE)logs its caution once per session rather than once per fit. Inside a five-foldcv_rf()or a twenty-fitcv_block_size_sweep()the same paragraph printed on every fit, which reads as twenty problems rather than one decision; the message now says it will not repeat.fit_rf_model()reports what ranger actually objected to. ranger diagnoses a bad argument in its C++ layer, writes the diagnosis straight to stderr and then throws “User interrupt or internal error.” — somtry = 99on a two-predictor forest printed “mtry can not be larger than number of variables in data. Ranger will EXIT now.” to the console and raised an error naming neither the argument nor the problem. That line is not an R condition, sosuppressMessages(),withCallingHandlers()andtryCatch()all missed it: it escaped every handler to the console (and into CI logs, where it reads as an error from a test that is passing), whilecv_*()recorded the placeholder as the fold’s cause. The message stream is now diverted for the duration of the call, so the diagnosis becomes the reported reason —fold_status$messageincluded — and nothing is printed behind the caller’s back. Output from a call that succeeds is passed through unchanged, and when the stream is already diverted (under testthat, knitr orcapture.output(type = "message"), where only one sink is permitted) the call runs exactly as before. So does a call made after the session temp directory has been deleted, when there is nowhere to divert the stream to; it used to fail with “cannot open the connection”.One empty point made
determine_optimal_levels()return 1. The function reduced every feature to a point and projected it but never dropped an empty or non-finite one, so a singlePOINT EMPTYmade thek = 1WSSNA, k-means failed at everyk, and the failure handler shrank the sweep to nothing: two well-separated clusters that gave2 1 3gave1once one empty row was added, with only a log line about interpolating the WSS. Such rows are now dropped with a warning that gives their number, and underselect_on = "split"the returned positions still index the layer as passed.A few missing predictor values took Moran’s I away from
determine_optimal_levels(). A cell mean over a row with one missing predictor wasNAand dropped the whole cell, and how many cells that removed depended on the points per cell, sozwas computed on a different subset of cells at eachk. Three missing values in 400 rows made everyzin the evaluated windowNA, and the call fell back to the geometric ranking with a warning that did not mention missing values. Rows with a missing or non-finite response or predictor now stay in the WSS sweep and the cells and are left out of Moran’s I, with a logged count, so every cell mean uses the same rows.A misspelt
response_varorpredictor_varsindetermine_optimal_levels()silently changed the criterion. A column that was not there counted as “no model variables”: with the default criterion a typo kept"geometric"where supplying both variables upgrades to"combined"(on one 400-point layer7 6 8instead of11 10 7, with no warning and no diagnostics), and under"morans_i"or"combined"the one log line said the variables were required although both had been supplied. A named column that is not in the layer is now an error, as it is inresolution_profile(), and a model-aware criterion given nopredictor_vars(or noresponse_var) falls back with an R warning that says which is missing, rather than a log line.determine_optimal_levels()stopped one level short when locations repeat. The sweep was capped one short of the number of distinct locations, the boundstats::kmeans()needs only when no location repeats: five stations visited thirty times each could not reachk = 5. The cap is now the number of distinct locations, and still one short of the number of points. Akwhose WSS is 0 (to within 1e-12 of the total: a cell on every location) is left out of the log-log elbow line, and when the rest of the curve has no elbow, the fall to zero is the elbow. Leaving the level out and reading the rest made the call warn that the five stations had no cluster structure and return3 2 4(one metre of jitter gave5 4 6); it now returns5 4. Two stations visited thirty times each give2 1, not1 2. Zero is relative because k-means leaves floating-point residue: two groups of ten stations visited ten times each have a WSS of 7.8e-17 atk = 20, which dragged the whole line down and, atmax_levels = 30, reported no cluster structure (still answering 2).resolution_profile()reads itselbowcolumn the same way. The no-elbow warning names the bound that ended the ladder (the distinct locations, the points ormax_levels); it namedmax_levelswhichever bound it was.The same layer with its rows in another order gave
determine_optimal_levels()another answer. The subsample and every k-means start index rows, so a permutation of the input moved the WSS curve and could move the count. The rows are now put in coordinate order (response and predictors breaking ties) before either, so any permutation of a layer gives the same result. Results for a given seed differ from 2.0.0’s for that reason too.determine_optimal_levels()blamed the wrong cause when its model-aware criteria had nothing to score, and its help page understated the fix. The model-aware pass scores only the elbow’s neighbourhood, and on points with no cluster structure the elbow sits nearsqrt(max_levels), so every candidate stayed at or below the nine-cell floor untilmax_levelswas about 40 (measured on 1000 uniform points: 12, 20 and 30 all fell back, and 40 scoredk= 10 and 11 alone). The help page said “above roughly 10”, and the fallback was logged as “Moran’s I could not be computed”. The logged warning now names the window and the floor and points toresolution_profile(), and the help page gives the measured numbers. Which candidates are scored is unchanged.build_tessellation()could not tessellate CRS-less planar points inside a CRS-less boundary.ensure_projected()marks such pointscrs_assumed = "none"(“planar, leave alone”), andbuild_tessellation()read that mark as a CRS name:st_crs("none")failed with “invalid crs: none” for every method, including the documentedboundary = clip_target_for(pts), so CRS-less planar data could not be gridded at all. Only a real assumption (EPSG:4326) is now given to the boundary, which is refused if its coordinates cannot be degrees (below); with no assumption both stay in the same unnamed space.When only one of the points and the boundary had a CRS, the tessellation builders stopped on sf’s bare “st_crs(x) == st_crs(y) is not TRUE”. UTM points read from a CSV with a UTM boundary failed in all four methods of
build_tessellation()and increate_voronoi_polygons(); projected points with a boundary that had lost its.prjfailed increate_voronoi_polygons()andmethod = "triangles", andclip_target_for()returned a target with no CRS — for lon/lat points with a CRS-less lon/lat boundary, in degrees, withexpand = 20buffering by 20 degrees. The side without a CRS is now interpreted in the other’s, asharmonize_crs()does, with a warning: lon/lat-looking coordinates are reprojected from EPSG:4326, others are stamped. CRS-less points that do not look like lon/lat are refused, with a message saying what to do, when the boundary is geographic, because stamping degrees on them would be wrong; a CRS-less boundary beside geographic points is read as lon/lat or refused (below).A
boundarywithout a CRS got a log line inmake_folds(),cv_*()andpredict_surface(), where every other function raises an R warning, andcv_rf()warned twice about one lon/lat boundary. With projected points,build_tessellation(),clip_target_for(),plot_folds()and the rest said “boundaryhas no CRS … stamping” as an R warning, whilemake_folds(),cv_*()andpredict_surface()stamped it with a “WARN ensure_projected(): input has no CRS” log line thattryCatch()and knitr never see andspatialkit_quiet()hides, naming neither the function nor the argument. A boundary whose coordinates looked like lon/lat got two R warnings from onecv_rf()call, one fromprep_model_data()and one frommake_folds(), both namingensure_projected(). All three now warn as the others do, naming themselves andboundary(make_folds()alsoprediction_points,predict_surface()alsogridandcovariates), and acv_*()call warns once, naming thecv_*()function. Which CRS the layer ends up in is unchanged. Forpredict_surface()this matters most ongridandcovariates: a layer stamped with the wrong CRS puts every covariate lookup in the wrong place. The stamping warning of every function now names the CRS it stamps (“stamping the target CRS (‘EPSG:32632’) WITHOUT reprojection”) instead of “the suppliedcrs”, an argument most of them do not have.A geographic
crsmade every tessellation method work in degrees.build_tessellation(),create_voronoi_polygons()andcreate_grid_polygons()tookcrs = 4326as the CRS to compute in, so Voronoi cells stopped being a nearest-point partition (150 points at 53-57N: 20 percent of sampled locations lay in another point’s cell), grid cells were neither square nor equal-area, and clipping them under s2 stopped with “Edge 0 is degenerate” (hex) or left a point inside the boundary with anNAindex (square). A geographiccrsis now the CRS the result is returned in: the cells are built and indexed in the local projected CRSensure_projected()picks, then transformed with long edges densified. A hex or square grid sized by an explicitcellsizeis still laid in degrees, since that is the unitcellsizeis in.ensure_projected()stopped on lon/lat polygons that s2 rejects, and chose its UTM zone differently whensf_use_s2()was off. The centre that places the zone wasst_centroid(st_union()): with s2 on, a polygon with a repeated vertex (valid for GEOS, common in shapefiles) stoppedensure_projected(),create_grid_polygons()andprep_model_data(boundary =)with “Edge 1 is degenerate (duplicate vertex)”; with s2 off it was planar in degrees, so two clusters at 0N and 60N near -78 got UTM zone 17 in one session and zone 18 in another, and sf’s warning and message about it got pastquiet = TRUE. The centre is now always taken on the sphere, a geometry s2 rejects is repaired first, and a mean of unit vectors is the last resort.A single study-area polygon was never scored, so
ensure_projected()kept a UTM zone at any extent. A polygon layer is scored on one point per feature, and one point makes no pair: every candidate scoredNAand the selector fell back to the zone. A CONUS outline stayed in UTM zone 15 (12.8 percent worst-case distance error) where a Lambert azimuthal scores 2.1 percent, andprep_model_data(boundary =)moved the whole analysis into the zone with it. Layers with fewer than 40 features are now scored on their outline’s vertices too.create_grid_polygons()laid a grid over a near-global lon/lat boundary in Web Mercator. The cells were equal on the map and not on the ground: the true areas of whole cells differed nearly five-fold. When the CRS picked for distances distorts areas across the boundary by more than 1 percent, the grid is now laid in the equal-area CRSensure_projected(purpose = "area")picks (Equal Earth here), with a logged warning; a local extent keeps its UTM zone.create_grid_polygons_cached()makes the same choice.With
sf_use_s2(FALSE)the package needed lwgeom, which it does not depend on. The stable-ID sort key is measured in lon/lat, so every Voronoi tessellation — projected ones included, and the examples ofcreate_voronoi_polygons()andbuild_tessellation()— failed with “package lwgeom required”, as didensure_stable_poly_id(),create_grid_polygons_cached()on a cache miss, and random and k-means seeding on a lon/lat boundary. These measurements now run on the sphere (s2) whatever the session’s setting, which is restored afterwards; the IDs are the ones s2-on sessions always got.-
Hex cell size depended on which way the boundary lay.
st_make_grid()builds hexagons fromcellsize[1]alone, and that was the box’s width over a rounded column count: a 1 x 1000 strip attarget_cells = 9got 1734 hexagons where the same strip lying flat got- The size is now counted along the longer side, which leaves every boundary at least as wide as it is tall with exactly the grid it had.
Voronoi with
expand > 0returned the boundary before it was grown. The cells are clipped to the grown boundary, so they covered 1.93 km^2 against a returnedboundaryof 1 km^2, and points up toexpandoutside it were indexed.create_voronoi_polygons()andbuild_tessellation(method = "voronoi")now return the grown boundary, and the documentation saysexpandgrows the study area too.Collinear points gave an empty Delaunay triangulation.
build_tessellation(method = "triangles")on a transect returned no cells and an index ofNAs, after logging thatdelaunayn()had failed, which it had not. It now stops with a message that says the points are collinear and points tomethod = "voronoi"; the fallback’s log line names the reason that applies.k-means seeding clustered CRS-less lon/lat points as if degrees were metres.
voronoi_seeds_kmeans()andget_voronoi_seeds(method = "kmeans")projected only when a CRS said lon/lat, so 800 CRS-less points 89 km wide and 111 km tall were split east-west where the same points tagged EPSG:4326 were split north-south. They now apply the lon/lat heuristicensure_projected()applies (with its warning) and return the seeds in the input’s own coordinates.voronoi_seeds_random()returned the same seeding on every call. Its defaultset_seed = 456reset the random-number stream inside the call, so five calls underset.seed(1)toset.seed(5)gave one draw, and the sensitivity comparison its help page recommends compared a seeding with itself. The default is nowNULL, as inget_voronoi_seeds(): the draw comes from the session’s stream. Passset_seedfor a fixed seeding. This changes the default.harmonize_crs()refused a layer astarget_crs. A multi-row sf failed with “the condition has length > 1” and a one-row one with “cannot create a crs from an object of class sf”, whereensure_projected()accepts both. An sf or sfc target now means its CRS.summarize_by_cell()failed ondeff = NAand mis-recordeddeff = Inf. The check on a numericdeffcame outNAforNA_real_andNaN, so the call stopped with “missing value where TRUE/FALSE needed” instead of the documented warning and fallback to 1.Infpassed the check: the standard errors were the uncorrected ones, yetcell_weightwas 0 in every cell anddeff_appliedrecordeddeff = Inf. Anydeffthat is not a single finite number of at least 1 now falls back to 1 with the warning.summarize_by_cell(deff = "variogram")fell back to uncorrected standard errors with no R warning when no model could be fitted. With predictors only and nosac, without gstat, or whenestimate_sac_range()returned no fit (fewer than 30 points), the only signal was a log line: notryCatch(),withCallingHandlers()orwarnings()saw it, andspatialkit_quiet()hid it, while the standard errors came out about 5 times smaller than the corrected ones in one check. It is now a warning that names the reason. Every design-effect fallback (a refuseddeff, a rejected or unsupported variogram, no model) is raised with class"spatialkit_deff_fallback", so a loop over many summaries can catch exactly that case. A rejectedsacthat a variogram estimated fromresponse_varreplaces is not a fallback: it gets a plain warning, and the classed warning is raised only when nothing replaces it, once per call, naming every reason. A pure-nugget model (no structured component) implies that distinct observations are uncorrelated, so it is applied as a design effect of 1 in every cell, withdeff_applied = TRUE; it was reported as “the supplied model could not be read”, with the fallback warning.An empty point switched off
summarize_by_cell(deff = "variogram")for its cell. Its missing coordinates made the cell’s mean correlationNA, and the cell silently got a design effect of 1: the standard error of that cell was a quarter of the corrected one in one check. (In the development version the same input stopped the call with “missing value where TRUE/FALSE needed”.) Points with empty or non-finite coordinates now count towards their cell’s values but not towards its correlation, with a warning.summarize_by_cell(deff = "variogram")read an anisotropic variogram as isotropic. Onlymodel,psillandrangewere read, sovgm(0.8, "Exp", 300, 0.2, anis = c(0, 0.2))gave a correlation of 0.677 at 50 m east-west where gstat’s is 0.348, and a median design effect of 14.1 against 7.7: standard errors too wide by a factor of 1.7. A 2-D geometric anisotropy (ang1,anis1) is now applied as gstat applies it. (resolution_profile(), which uses the same correlation function on distances alone, still reads the major range.)A misspelt or non-numeric
response_varwas dropped in silence.summarize_by_cell()reported it through a progress message, which the defaultquiet = TRUEsuppresses, and returned a frame with noresp_*column andcell_weightequal ton. A missing or non-numericresponse_varor predictor is now a warning, and aresponse_varof more than one name is an error instead of “the condition has length > 1”.A double cell ID of 100000 lost its cell in
summarize_by_cell(cells_sf = ). When the points’ and the cells’ ID columns had different classes (an integerpoly_idagainst a double from a GeoPackage Integer64 field or a CSV), both were converted withas.character(), which writes1e+05for the double and100000for the integer. That cell came back withNAsummaries and its points left the result: 11 of 30 points in one check, with no R warning. Whole numbers are now written out in full, and a summarised ID that matches no cell is reported with a warning.summarize_by_cell(cells_sf = )did not read the ID columnsassign_features_to_polygons()writes from. Cells keyed byidorgrid_id(common in shapefiles) were assigned cleanly and then summarised to a plain table with no geometry,cell_areaorn_per_area, and a layer withidand a differently numberedcell_idwas joined oncell_id, putting 89 percent of the summaries on the wrong polygons in one check. The cells are now searched in the order the assignment used, and acells_sfthat cannot be joined is a warning (an error witharea = TRUE) rather than a log line.agg_funs = medianor"median"is honoured instead of being replaced by the mean. A single function is named after the expression passed, sostats::mediangivesresp_median_*asmediandoes (it gaveresp_agg1_*).assign_features_to_polygons(largest = TRUE)assigned polygon features that only touch the cells. sf keeps the largest intersection piece without checking its area, so under GEOS a feature sharing only an edge or a corner with the cell layer went to that cell with zero overlap, while under s2 (lon/lat) the same feature was unassigned: sf’s nc counties against 50 of them as cells gave 70 rows projected and 50 in lon/lat. A feature with no overlap area is now unassigned in every CRS.tie_break = "smallest_area"depended on the row order after all. Candidates of equal area — the cells of any regular grid, for a point on a shared edge — fell through to the first row, so reversing the cells’ rows moved every edge point to the neighbouring cell (points at x = 100 went to cells 1, 4 and 7, or to 2, 5 and 8), and on a cached grid, whose IDs do not run row by row, one cell could take both of its edges. Equal areas (to 9 digits) are now decided by the lowest, then leftmost, bounding-box centre. On acreate_grid_polygons()square grid that is the cell row order already picked (1,681 of 1,681 lattice points unchanged); on a hex grid 3 of 56 shared vertices move.create_grid_polygons_cached()could return another site’s grid. The cache key used the CRS’sinputname, which is"unknown"for any custom CRS read back from a GeoPackage or shapefile, so two site-centred CRSs with the same local boundary coordinates shared an entry, and the second site received the first one’s grid 11,000 km away. The key now hashes the CRS’s WKT; the only cost is a rebuild when one CRS arrives written two ways.target_cellsnow defaults toNULL, as increate_grid_polygons(), socellsize =orn =work without it.ensure_stable_poly_id()could number cells differently with s2 off. The sort key’s centroid was taken planar in degrees whensf::sf_use_s2(FALSE), which moves it by far more than the key’s rounding step, so near-tied cells swapped IDs between s2-on and s2-off sessions (4 of 2,000 Voronoi cells), and without lwgeom the s2-off call stopped at the area. The key is now taken on the sphere for the sort copy only, whatever the session setting.summarize_by_cell(deff = "variogram")built each cell’s correlation matrix up to five times per column. The same call with two partly missing columns andconf_levelnow builds 15 where it built 45.estimate_sac_range()returned the widest direction that reached a sill when the all-pairs variogram had run past the fitted lags. Under an unremoved trend the pooled variogram rises without a sill, which the help page said the fitted-lag bound catches; but when two of the four directional variograms (those across the slope) did reach one, their maximum came back as the range withanisotropy_used = TRUEand nothing on the console. On an exponential field of range 150 with an east-west trend, 12 of 30 draws returned 131–596 this way and 16 othersNA, somake_folds(auto_range = TRUE)switched between a 2 x 2 grid of 430 m and geometric blocks from one draw to the next. The directions that reach a sill are the shorter ones, so their maximum is a lower bound, not an estimate. A converged all-pairs fit past the fitted lags is now refused whatever the directions found (rejected_reason = "fitted range exceeds the largest lag fitted", the directional ranges still attached); the directional maximum stands in only for an all-pairs fit that is singular or did not converge. On stationary fields whose range is close to the cutoff this also turns a few draws from a directional maximum intoNA(3 of 30 at an effective range of 570 on a 1000 m square).print()on anestimate_sac_range()result showed a bare number. For lon/lat input the number is in metres of a CRS the estimate picked, and withpredictor_varsit is the range of the residuals rather than of the response; neither showed, so a mismatch with the layer it was about to be used on could not be seen. A last line now names the unit and the CRS (in metres of EPSG:32617) and whether the variogram is of the response or of its residuals (and by whichdetrendmethod). For a layer with no CRS the line says the range is in that layer’s own coordinate units (in the coordinate units of a layer with no CRS), still with what was modelled; it used to be left out.estimate_sac_range(predictor_vars = )fitted its variogram to the raw response, trend and all, when a single predictor or response value was infinite.lm()’sna.excludedropsNAbut notInf, so oneInfamong 200 rows stopped the detrending (“NA/NaN/Inf in ‘x’”), and the function fell back to the raw response with a warning. The range came out at 2950 instead of 2168 (the answer with that row removed), andmake_folds(auto_range = TRUE, predictor_vars = )built blocks 37 percent larger.resolution_profile(), which hides that warning, then warned that the variogram it had estimated itself was of the raw response. Withdetrend = "reml", a-Infpredictor (alog(0)covariate) first gave a false warning that the REML fit “did not converge”, and then the OLS fallback failed the same way. Rows with a missing or non-finite response or predictor are now left out of the detrending fit and the variogram, with a logged count, asprep_model_data()andresolution_profile()already do. OneInfnow gives the same range as that row set toNA(2167.5 detrended on the example; 1598.4 under REML).make_folds(drop_empty_blocks = FALSE)could return folds with no test points.kwas lowered only when the highest block id holding a point was below it, and with empty blocks kept that id says nothing about how many blocks hold points: two clusters on a 4 x 4 grid gave id 16, sok = 5was kept for 2 occupied blocks and three folds came back empty, with no warning (the imbalance check skipped an empty fold). Thecv_*()functions then ran on the two folds that had points andarea_of_applicability(folds =)failed.kis now lowered to the number of blocks that hold points, as?make_foldsalready said, the log line names the numbers, and every point in a single one of several blocks is the single-block error it always was with the default.The automatic block grid gave an east-west corridor more than twice as many blocks as the same corridor running north-south. Only the row count was capped, so a layer more than about
block_multiplier * ktimes as wide as it is tall gotround(sqrt(15 * w/h))columns atk = 5: 39 x 1 on a 10 km x 100 m corridor against 1 x 15 turned on its side, blocks less than half as long, and a scheme drifting towards random k-fold (1-NN CV RMSE 0.77 against 0.94 on the same values; lower in 16 of 20 seeds). Points on one horizontal line were treated as a square and got a 4 x 4 grid that collapsed onto the line, loweringkfrom 5 to 4. The column count is now capped atblock_multiplier * kas the row count was, so both orientations and both lines get 15 blocks atk = 5. Folds change only for extents more than aboutblock_multiplier * k + 1times as wide as they are tall.make_folds()ignoredblock_nxorblock_nygiven alone, and accepted invalid ones. Giving one dimension sent the call to the automatic grid without a word (block_nx = 10alone gave a 3 x 4 grid); 0, a negative,NAor a vector failed inside sf or base R, and 2.7 was truncated to 2. The dimension given is now used and the other derived from the extent’s aspect ratio (roughly square blocks), and each must be a single whole number >= 1.With a non-rectangular
boundary,make_folds(block_kfold)kept zero-area slivers as blocks. Where the boundary only touches a grid cell at a corner or along an edge, the clipped cell is a POINT or a LINESTRING, and it was kept as a block: packed into a fold underdrop_empty_blocks = FALSE(3 of 13 blocks under a triangular boundary), and able to catch a data point lying exactly on the boundary as a one-point block of its own. Only the areal part of the grid is kept now.make_folds()failed with R’s own errors on a missingkor an invalidbuffer.k = NULL(or nok) reachedif (k < 2)and failed with “argument is of length zero”; abufferofNA,numeric(0)or length 2 failed with “missing value where TRUE/FALSE needed” or a length error, and anNAis exactly whatestimate_sac_range()returns when no range is identified. Both are now refused by name (the leave-one-out methods still need nok), and so is aunitsobject passed asbuffer,block_sizeorblock_nx/block_ny, which used to fail inside the units package without naming the argument.make_folds(method = "buffered_loo")said nothing when the buffer excluded no neighbour. The buffer is in the units of the CRS the folds are built in, which for lon/lat input is metres, so a buffer in degrees (0.1) excluded nothing and the scheme was plain leave-one-out: 79 of 79 training points in every fold of an 80-point layer, no condition raised. It now warns when no fold excludes any neighbour, and?make_foldssays what unitbufferis in.NNDM folds could be far more optimistic than their target with only a log-file line to say so. When
min_trainstops the matching — samples clustered well inside the prediction domain, the layout NNDM is meant for — the realised distances stay short: one cluster predicted onto a 20 km grid kept a median of 1171 m against a target of 6704 m, 96 of 100 folds held at the floor, while?make_foldssaid the result is never more optimistic than the target.make_folds()now warns when the floor leaves more than one point’s worth of excess belowphi, recordsparams$n_at_min_train, and the documentation states the guarantee only where neitherphinormin_trainbinds. The warning names the statistic that fires it, the largest excess of the realised nearest-neighbour ECDF over the target at distances up tophi, and gives the two medians only as context: they summarise all the distances, and the realised median can sit above the target’s while the short distances are over-represented.make_folds(auto_range = TRUE)fell back to geometric blocks with only a log line. When no range was identified (an unremoved trend, a range past the fitted lags, fewer than 30 points, gstat missing), the blocks the caller asked to be sized from the data were not, and under knitr,spatialkit_quietortryCatch()nothing showed it. This is now a warning that gives the rejection reason.estimate_sac_range()can give up before it fits anything: fewer than 30 points, fewer than 30 finite values, a constant response (or residuals, when the predictors explain the response exactly), points with no extent, or gstat missing. It used to return a bareNAthen, with the reason only in a log line, and the warning said only “estimate_sac_range() returned NA”. ThatNAnow carries arejected_reasonattribute saying which (it is still unclassed, with no other attribute). The warning quotes it, as dokriging_adequacy()’s no-model error andsummarize_by_cell()’s fallback warning.NNDM fold construction releases FNN’s copy of the neighbour tables as soon as it has them, so a second
nxn/2pair is no longer held through the sweep and the construction of the folds. The peak insideget.knn()is unchanged.cv_bayes()rounded coverage levels to a whole percent, so close levels overwrote each other. Coverage columns were namedsprintf("coverage_%.0f", 100 * level):coverage_levels = c(0.5, 0.975, 0.985, 0.995)gave three columns for four levels,coverage_98holding the 0.985 value and the 0.975 value lost, with no condition raised. Levels given as percentages,c(50, 80, 95), made every fold throw away its CRPS,n_drawsand coverage in silence. Columns are now named at full precision (coverage_97.5; the default 50/80/95 names are unchanged), a level outside (0, 1) or given twice is an error that suggests dividing by 100 where that fits, and the result carriescoverage_levels, the nominal level of each column.An error in a
fold_info_fnthrew away all of that fold’s extras without a word.cv_spatial()caught it and dropped the whole list, so the columns wereNAwithfold_status"ok"and nothing logged; incv_bayes()one failing quantile costgp_k,n_draws, CRPS and every coverage column. The failure is now logged and named infold_status$message, andcv_bayes()computes coverage and CRPS in a step of their own, sogp_k,n_drawsandyhat_sdsurvive it. Afold_info_fnthat returns the wrong shape (a vector where a value belongs, an unnamed or duplicated element, or a namefold_metricsalready has, such asRMSE) is an error that says so;RMSE = -5used to overwrite the fold’s real RMSE.cv_spatial()failed after fitting every fold when..per_rowcame back from some folds only. The prediction rows were stacked withrbind(), which died on “numbers of columns of arguments do not match” once every fold had been fitted, so the work was lost and the message did not point at the cause. A fold without..per_row(returned conditionally, of the wrong length, or from afold_info_fnthat threw) now getsNAin those columns, and one of the wrong length is logged.Parallel cross-validation discarded folds that shared a core with a failure.
mclapply()ran prescheduled, handing each core a chunk of folds: an error that escaped one fold was copied to every fold of its chunk, and a worker killed for lack of memory took all of its folds with it. With four folds on two cores, a failure on fold 2 lost fold 4 as well, andoverallpooled 40 of 80 rows. Each fold now runs in its own worker, so a failure costs that fold only, and an error that stops a sequential run (ametricsorfold_info_fnreturn value of the wrong shape) stops a parallel one too, naming the fold. A run in which nothing fails gives the same numbers as before, since the per-fold seeds are drawn before forking.model_metrics(newdata = )measured R-squared against a different baseline from everycv_*()function. It took the total sum of squares about the new rows’ own mean, where cross-validation takes it about the training mean, so the same predictions on a split across a trend scored R-squared -0.89 here and 0.35 fromcv_spatial().model_metrics(),evaluate_insample()andcompare_models()withnewdatanow use the training mean too, the out-of-sample convention, and the help pages say which baseline R-squared uses. In-sample numbers do not change.R-squared and MAPE depended on the units of the response. The thresholds below which a total sum of squares or a percentage-error denominator counted as zero were absolute, so a response with standard deviation below about 1.5e-8 got R-squared
NAwhile RMSE and MAE were fine (andselect_features_forward(metric = "R2")selected nothing), and one on a 1e-15 scale lost MAPE and SMAPE as well. “Zero” is now 100 machine epsilons of the data’s own magnitude for every metric: a rescaled response gets the same R-squared and MAPE, a constant one still getsNA, and on a response spanning many orders of magnitude a row whose denominator is no larger than 100 epsilons times the largest (such as 1e-9 against 1e6) no longer enters MAPE.compare_models()set out-of-bag random-forest metrics beside in-sample ones without saying so. Withoutnewdataanrf_fit’s fitted values are out-of-bag and every other backend’s are in-sample, and the table could rank the models the wrong way round: GWR RMSE 0.77 in-sample against RF 0.82 out-of-bag, where the forest’s in-sample RMSE was 0.40.evaluate_insample()andcompare_models()now carry ametric_basiscolumn ("in-sample","out-of-bag"or"newdata"),compare_models()logs a note when a table mixes them, and both help pages say what the metrics are computed on.compare_models()put LOOIC and AICc side by side for fits on different rows. Both are sums over the rows a model was fitted to, so a model that lost 20 rows to a predictor’s missing values showed LOOIC 32.2 against 54.8 for the model on all 70, and looked 22.6 better while it was worse on the rows they share. A column whose models were fitted to different rows is now set toNA, with a warning naming each model’sn. The same happens to fits of the same rows with different responses (a response and its log, say), since an information criterion compares models of one response only, and the warning now says so. It used to say they were “fitted to different rows (raw: n = 80, logged: n = 80)” and to “Refit them on the same rows”.compare_models()read significantly negative residual autocorrelation as missed spatial structure. The caution fired on a two-sided p-value whatever the sign, so the alternating in-sample residuals of a GP or a small-bandwidth GWR (Moran’s I -0.13, p = 0.02) were logged as “may not fully capture the spatial structure”, the opposite diagnosis. A negative z is now logged as what it usually means, a model tracking its data closely.compare_models_cv()placed polygon rows in its shared blocks by a different point than every model is fitted at. The shared folds reduced polygons and lines to their point-on-surface whateverpointizesaid, while each backend fitted them at thepointizepoint: withpointize = "centroid", 119 of 150 L-shaped parcels were in a different fold from a standalonecv_gwr()run. The shared blocks now usepointize; with the default"auto"nothing changes.Saved folds on lon/lat polygons were refused after
sf_use_s2()was toggled. The provenance check located each probed row by its centroid, which on a geographic CRS is spherical with s2 on and planar with it off; the two differ by up to 5e-4 degrees on county polygons, 500 times the tolerance. Folds built beforesf_use_s2(FALSE)(a common workaround for invalid polygons), or saved and read in a session set the other way, were rejected by everycv_*()as “built from different data”. The probe now always takes the planar centroid; folds saved by an older version are checked the way they were made.residual_morans_i()gave the wrong reason when it could not use a fit’s residuals. An error fromresiduals()was thrown away, and it and a fit with noresiduals()method (the?new_spatial_fitexample has none) were both reported as “could not extract enough residuals (n < 4)”, on a 100-row fit; a residual vector of the wrong length was reported as “coordinate extraction failed”. Each now has its own warning, quoting the error where there is one, except that a fit with noresiduals()method is now scored on the response minusfitted()instead (below).A GWR whose local regressions interpolate the data won on AICc. GWmodel’s AICc is defined only while the effective number of parameters, tr(S), is below n - 2; past that its penalty turns negative. A small adaptive bandwidth reached it, and so did any bandwidth raised to the old floor, which for the bisquare and tricube kernels (they give the farthest neighbour in a window weight 0) fitted every window exactly. On 100 points
compare_models()listed R2 = 1 and an AICc of -15033 against 292 for the automatic bandwidth, and at n = 60gwr_model_selection()selected the real predictor plus three noise variables with -66689.fit_gwr_model()now reports such an AICc asNAandgwr_model_selection()ranks such models last, each with a warning giving tr(S). The adaptive floor is one neighbour higher for bisquare and tricube, and raising a supplied bandwidth to it is now a warning, not a log line.bandwidth = NULLwas not affected above 20 points. The raised floor is enough unless several neighbours tie at the kernel’s edge (a regular grid); the warning now says so. Where an adaptive bandwidth is already every observation, the undefined-AICc warning suggests fewer predictors, more observations or a gaussian or exponential kernel instead of a larger bandwidth.fit_gwr_model()never checked a one-predictor model for local collinearity. The check ran only with two or more numeric predictors, but every local design includes the intercept, and a predictor nearly constant inside a window is collinear with it. A regional covariate nearly constant within each of four clusters gave local slopes from -97 to 221 around a true 3 with no warning, while adding a noise predictor to the same data warned at every location. One numeric predictor is now enough.GWR said a singular window came back as
NaNcoefficients; it stops the fit. GWmodel’s matrix inverse throws on an exactly singular window, so a 0/1 indicator constant within clusters failed the whole fit with a bare “inv(): matrix is singular”, while the help page and the collinearity warning promised maskedNaNcoefficients. The non-finite coefficients GWmodel does return come from co-located points, where an adaptive bandwidth no larger than the number of observations at a site gives the kernel zero width (160 of 160 at 40 sites of 4 observations, 4 neighbours), and the warning blamed singular windows for those. The fit error now says a window is singular and how many the collinearity check found, and the non-finite warning names co-located points when they are the cause.An adaptive GWR bandwidth above the number of points was capped in silence.
bandwidth = 1500, meant as metres withadaptiveleft atTRUE, became a 200-neighbour, near-global fit on 200 points without a word; its local slopes varied less than half as much as the intended fixed-distance fit’s.fit_gwr_model()andgwr_model_selection()now warn, naming n and pointing toadaptive = FALSE.cv_gwr()repeats the warning in each fold whose training set is smaller than the bandwidth.Below 20 points,
bandwidth = NULLdid not fit at the bandwidthbw.gwr()chose. GWmodel searches adaptive bandwidths from 20 neighbours up to n, so with fewer points its choice exceeds n (18 for 12 points) and was capped at n, a different kernel with a worse AICc (18.3 against 9.7), without a word. The cap stays and now raises a warning infit_gwr_model()andgwr_model_selection(); supplybandwidthfor data this small.A GWR predictor named twice raised false collinearity warnings.
fit_gwr_model(predictor_vars = c("a", "b", "a"))fitted correctly, but its collinearity checks ran on the duplicated column and warned “exactly singular” and “100% of locations collinear”, once in everycv_gwr()fold, and the doubled count raised the bandwidth floor. Names are now collapsed on entry, asgwr_model_selection()already did.A
gp_cyou set did not change thegp_kderived for it. The basis count was always sized for the boundary factor the package would have chosen, so a wider boundary got the same number of basis functions and could no longer resolve the lower length-scale bound it was sized for. The advice undergp_cis to raise it for a long-range surface, which is exactly the case that coarsened the basis. On 200 uniform points,gp_c = 3fitted withgp_k = 23where the rule gives 43, andgp_c = 5with 23 where the rule gives 70 (capped at 50); the cap warning could never fire on this path. Withgp_k = NULLthe derivedgp_kis now sized for thegp_cactually used, and a capped value is logged. An explicitgp_kstill passes through untouched.fit_bayesian_spatial_model()could not fitbrms::categorical()or amixture()family. Those families give each distributional parameter its own GP under the same coefficient names, and the automatic length-scale prior kept only the names, so every coefficient got two identical rows and brms stopped with “Duplicated prior specifications are not allowed” before sampling. A user’slscaleprior restricted to onedparfailed the same way, because it was copied onto every category’s coefficients. The prior now carries each coefficient’sdpar,nlparandresp, and a global ordpar-levellscaleprior is expanded only onto the coefficients it addresses and never over a coefficient-level one the user already gave. Withstandardize_predictors = TRUEthey still failed, on the automaticnormal(0, 5)slope prior, which carried nodparand so matched no slope of either family (brms: “The following priors do not correspond to any model parameter: b ~ normal(0, 5)”, a prior the user never wrote). That prior is now set on each distributional parameter’s slopes, as the length-scale prior is; a family with onemugets the same single row as before.A two-level factor response under
brms::bernoulli()fitted, and then nothing could score it. The response check refused a non-numeric response only under gaussian, and the gaussian refusal itself pointed atbernoulli(). brms fits the factor, butresiduals()came back allNA,summary(),model_metrics()andcompare_models()stopped on “response is factor”, andcv_bayes()ran a full fold of MCMC before aborting in the fold scoring (withparallel = 2, every fold ended asworker_error). A factor or character response is now refused, before anything is compiled, under every family exceptcategorical()and the ordinal ones (cumulative, sratio, cratio, acat), with a message saying to convert it to 0/1. Numeric and logical 0/1 responses are unaffected.predict()on an ordinal or categoricalbayesian_fitreturned allNAas a “posterior draw failed”. brms returnsposterior_epred()for those families as a draws x rows x categories array, which the method took for a failed draw: a realcumulative()fit returnedNAfor all five new rows, with only a log line, whilefitted(),summary()andmodel_metrics()said merely that they got an array.predict()under its defaulttype = "epred"andfitted()now stop, saying the family has a probability per category and pointing attype = "predict", draws = TRUE, whose share of draws in each category estimates its probability for any rows, and atbrms::posterior_epred(<fit>$engine)for the training rows (posterior_epred(<fit>$engine, newdata = ), which the message used to suggest, refuses new rows without the scaled coordinates the method builds). The message now counts the caller’s rows, where it counted the two GP-boundary rows as well (“150 x 7 x 3” for five rows).type = "predict"withoutdraws = TRUEon abrms::categorical()fit, which returned the mean of unordered category indices (1.46, 1.97, …), is now an error; for an ordinal family it is the expected category index, as documented. A genuinely failed draw still returnsNAas documented, and the log line now carries the cause.predict()on anrf_fitturned every ranger error into an all-NAvector.type = "quantiles"on a forest grown withoutquantreg = TRUE, andtype = "se"withoutkeep.inbag = TRUE, which the help page says are rejected, returned tenNAs for ten rows with no R condition, andmodel_metrics(newdata =, type = "se")then reportedn = 0. A failure in ranger’s predict method is now an error naming ranger’s reason; thecv_*()fold loop records it as the fold’s cause andpredict_surface()stops naming the rows, as they already did for other backends. Anewdatawith no complete row still returns allNAwith a log line, as for the other backends, rather than reaching ranger as a zero-row frame; that had made apredict_surface()chunk outside the covariates’ coverage abort the whole surface.check_convergence = FALSEreturnedconvergence_ok = TRUE. The flag started outTRUE, so a fit whose checks never ran (its max R-hat was 1.28) claimed to have passed them over an empty diagnostics list, andprint()had nothing to caveat. It is nowNAwhen nothing was checked, andprint()on the fit and on itssummary()says “Convergence: NOT CHECKED”;summary()’s printout also repeats the “Convergence warnings present” flag, which it carried and never showed. A failed PSIS-LOO is now logged with its cause instead of “LOO computation failed.” alone, which had leftcompare_models()showingLOOICNAwith nothing saying why.The convergence check raised dozens of “The ESS has been capped” warnings.
brms::neff_ratio()runs posterior’s ESS over every GP basis weight, and posterior warns once per well-mixed one: ann = 80fit raised 44 R warnings, 41 of them this one. R keeps only the first 50 warnings, so a warning that mattered and came later, loo’s Pareto-k among them, could be dropped. That one message is now muffled around the R-hat and ESS accessors; every other warning passes through, and the ratios are unchanged.A saved
rf_fitorbayesian_fitcarried its engine twice. The model formula was built in the fitting function’s frame and so captured it, and that frame holds the forest or thebrmsfititself; a formula serialises its environment, sosaveRDS()wrote the engine a second time (1.62 MB for a 100-tree forest of 0.72 MB; about 80 MB for a 40 MBbrmsfit, which brms’s own copy of the formula doubled even insaveRDS(fit$engine)). The formulas now carry the global environment, as a formula typed at the console does.A forest with rows out of every tree’s bag said nothing. ranger returns
NaNas the out-of-bag prediction of a row every tree sampled, so withnum_trees = 520 of 200 rows hadNaNfitted values andsummary()printed “n = 200” over an R-squared computed on 180.fit_rf_model()now warns with the count, andsummary()prints “(computed on 180 of 200 rows …)” when its metrics use fewer rows than the fit has.cv_rf()does not use its fold forests’ out-of-bag predictions, so it warns once per run with the number of fold forests affected, instead of once per fold (each of which told the user to score the forest withcv_rf()).area_of_applicability()counted rows that differ on a dropped zero-variance predictor as inside the AOA. A predictor constant in the training data — a land-cover dummy absent from the training region — is dropped from the distance, so new rows taking another value there were judged on the other predictors alone: 37 of 40 urban rows came out inside, where a single urban training row would have kept the predictor and put 1 inside. Such rows now getDI = Inf(their scaled distance along that predictor is infinite), are counted outside, and a warning gives the count;print()says how many.area_of_applicability()returned a threshold of 0 from duplicated training rows without saying why. Each training row’s reference is its nearest other row, so exact duplicates in predictor space — repeat visits to a site with static covariates, covariates from a raster coarser than the sampling — have a training DI of 0; past about three quarters of the rows the threshold is 0 and only exact copies count as inside (30 sites visited four times: 0 of 200 new points inside, against 197 after deduplication). The rule is unchanged; the result is now logged with the remedy (leave-location-out folds, or deduplication) andprint()shows the count of zero training DI.area_of_applicability()refused all-zero weights, which the advicepmax(importance, 0)produces whenever the model found no useful predictor. A one-predictor forest gave all-zero weights in 9 fits of 20 when its predictor carried no signal, so the AOA was lost in the folds where it mattered most. With one predictor the weight cannot change the index (it is scale-invariant) and zero is accepted silently; with several, all are weighted equally, asweights = NULLwould, with a warning.A fractional
chunk_sizeinarea_of_applicability()marked extrapolation as inside the AOA. On the dense path (use_fnn = FALSE, or FNN not installed) block starts became fractional and the rows between blocks kept an initial DI of 0: withchunk_size = 2.5, 5 of 25 far-out points were reported inside and 16 training DI of 0 moved the threshold.chunk_sizeis now validated by name and truncated to whole rows.area_of_applicability(model = fit, folds = folds)stopped with “fold 1 refers to rows outside 1:n” wheneverprep_model_data()had dropped a row, for the same foldscv_*()accepted. One missing response in 200 rows was enough:make_folds()numbers the rows of the layer it is given, the fit keeps only the 199 rowsprep_model_data()returned, and the fold IDs were read as positions in those. The documented workflow (“pass the samemake_folds()result you passed tocv_spatial()”) therefore failed on any layer with a missing or non-finite modelling value or an empty geometry. The rows the fit’s"dropped"record names are now taken out of the folds, ascv_*()take them out, with a log line giving the count; a label vector with one label per row of the layer fitted from loses those labels. The threshold is the one you get by removing the rows from the folds by hand. Folds built on the model’s own training data (theprep_model_data()output) are still read as positions in it, and a fold ID naming a row the data never had is still an error.area_of_applicability()applied folds built on other rows without a word. Fold splits are row IDs, andcv_*()compare the sample of row locationsmake_folds()records against the data, refusing folds built on another layer.area_of_applicability()did not: a model fitted on the same rows in another order took the folds anyway and moved the threshold (0.2432 against 0.2404). It now makes the same check on the training data and refuses such folds with thecv_*()message. The check is skipped (logged) when the folds were built on polygons and the training data are the points a fit reduced them to, so a model fitted on polygons with folds built on those polygons keeps working.predict_surface()filled a polygon grid with covariates from an arbitrary point inside each cell.st_nearest_feature()returns the first zero-distance match the spatial index yields, so acreate_grid_polygons()grid got covariates that did not match the location predicted at (predictions off by up to 2.6 on a 0-30 response) and that changed with the row order ofcovariates(by up to 4.7), and the result was a polygon layer where the manual promises points. The grid is now reduced to one representative point per cell first.predict_surface(..., draws = TRUE)flattened the draw matrix into.pred. The argument was forwarded topredict(), and a backend that honours it returned ann_draws x nmatrix that became.predcolumn by column (correlation with the right values: 0.03); withse = TRUEthe duplicated argument was reported as “backend does not expose posterior draws”.drawsis now refused by name, and apredict()returning the wrong number of values is an error.predict_surface()’s automatic grid could lose a whole column or row. When the extent is an exact multiple of the cell size,floor()of the ratio landed one short through rounding (0.3 / 0.1 gives 2 cells), leaving a cell-wide strip uncovered; at the defaultn_cellsthis hit 1197 of 10000 random squares. The cell count now has a relative tolerance, so an exact multiple gets exactly that many cells.predict_surface()kept a reused grid’s old.pred_se. Passing an earlier surface asgridleft that model’s.pred_sebeside the new.predunless this call replaced it, even after logging “returning predictions only”. It is now removed unless this call computes it.A
loggerconfiguration made before loading the package could still abort its functions, and received its log lines.loggerseeds a new namespace by copying every index of the user’s global configuration, and 2.0.0 pinned the formatter on index 1 only. A user with two global indices set up beforelibrary(spatialkit)gotformatter_sprintforformatter_glueon the console echo, so a%or a{in a message ("fold 2 skipped: object 'cov_{x' not found") aborted the function that logged it and the R warning the manual promises never arrived; a third global index kept the user’s own appender and received spatialkit’s WARN and INFO lines in the user’s log file. Both indices now have formatter, layout, appender and threshold pinned, every message is markedlogger::skip_formatter(), and copied indices beyond the second are deleted (onlogger0.2.2, which cannot delete one, switched off). The global configuration is still never touched.Deleting the session temp directory made every function that logs fail until the package was reloaded. The trace file’s path was fixed in
tempdir()at load time, so after an OS cleaner orunlink(tempdir())every log call failed with “cannot open the connection”, and a documented R warning (ensure_projected()’s CRS assumption, say) became that error;tempdir(check = TRUE), R’s own recovery, did not help. The trace now resolves its path when a line is written, recreates the directory if it has gone, and drops a line it cannot write; no logging failure aborts the caller any more, so the warning always arrives.Logged cautions were missing from knitted documents. The console echo wrote to stderr, which knitr does not capture, so an R Markdown, Quarto or pkgdown document showed the package’s R warnings but none of its logged cautions (
compare_models()’s significant residual autocorrelation, for one). While knitr is running the line is now also sent as an R message, so it appears in the output andmessage = FALSEhides it; a line that is raised as a warning too is not repeated. stderr gets exactly what it got before, and nothing changes outside knitr.plot_tessellation_map(labels = TRUE)drew no labels on any layer this package builds.label_coldefaulted to"grid_id", a column no function produces: Voronoi and Delaunay cells carrycell_id, gridspoly_idandcell_id, andsummarize_by_cell()outputpoly_id, so the map came back unlabelled with only a log line to say why.label_colnow defaults toNULL, which takes the first ofgrid_id,cell_id,poly_id,polygon_idandidthe layer has;grid_idstays first, so a layer that has one is labelled as before, and naming a column still works.plot_tessellation_map()failed at print on a units, Date, POSIXct or difftime fill column. The fill scale was chosen withis.numeric(), so Date, POSIXct and difftime columns got a discrete scale (“Continuous value supplied to a discrete scale”), and anst_area()column (class units) passed the test and then broke the viridis scale’s arithmetic. The function returned normally and the error came only when the plot was drawn. Date and POSIXct now get the continuous scale on a date or time axis, and units and difftime columns are drawn as numbers with the unit in the legend title ("area [m^2]") unlesslegend_titleis given.plot_folds()failed at print when its layers disagreed on having a CRS. A CRS-less boundary beside projected points, or the reverse, aborted insidecoord_sf()with sf’s “cannot transform sfc object with missing crs”. Sinceplot_folds()began drawing the block outlines it also failed on the very layer the folds were built from:make_folds()projects CRS-less lon/lat points to a UTM zone, and stamps CRS-less points with a boundary’s CRS, so the stored blocks carry a CRS the points do not. A CRS-less layer is now brought into the points’ CRS, or failing that the folds’ own, or the first layer’s that has one: reprojected when its coordinates look like lon/lat, and stamped with a warning otherwise, asmake_folds()does.plot()on a customspatial_fitwithout aresiduals()method stopped with “could not extract residuals”.?new_spatial_fitcalls that method optional, butresiduals.default()returnsNULLfor aspatial_fit, so the residual map, the observed-against-predicted plot and the residual variogram all refused a backend that had the requiredfitted()method. The residuals are now the response minusfitted(), which is what the built-in backends return; without afitted()method the error names the method to define.citation("spatialkit")gave the year it was called in, not the year of the release. DESCRIPTION has noDatefield, soinst/CITATIONfell back toSys.Date(). CRAN installs got the right year only by accident:meta$Datepartially matchedDate/Publication. The year is now looked up the wayutils::citation()does it, by exact field name: the CRAN publication date, thenDate, then the dateR CMD buildpackaged the source (which a GitHub install via remotes or pak has). Only an install straight from a source directory records none of these, and only then does the current year appear.The plots’ size arguments did nothing on ggplot2 older than 3.4.0. The line layers pass
linewidth =, which ggplot2 3.4.0 introduced; older versions warn “Ignoring unknown parameters” and draw at the default width, sooutline_size,boundary_sizeand the other size arguments were silently ignored. Suggests now asks forggplot2 (>= 3.4.0).The test suite calls
local_mocked_bindings(), which testthat added in 3.1.7, but Suggests allowed 3.1.5. On 3.1.5 or 3.1.6 every test that mocks a function failed with “could not find function”. Suggests now asks fortestthat (>= 3.1.7).determine_optimal_levels(criterion = "combined")could put first a cell count that Moran’s I never scored, chosen by the rule the elbow had stopped using. On eight separated clusters (800 points,max_levels = 40, four seeds) it returned10 7 6,10 7 6,6 5 10and6 10 7. The geometric axis was still the chord on linear axes across the elbow’s window, which ranked 6 or 7 above the elbow of 8. The candidates below the nine-cell floor, which Moran’s I cannot score, shared an average rank that shrank as more of them went unscored (6 of 9 for seven of them), although the help page said they ranked last. The geometric axis is now the log-log sag the elbow is read from, and it is flat when the curve has no elbow, so Moran’s z alone orders the window. Every unscored candidate takes the last place on the Moran axis, and exact ties go to theknearest the elbow. And an elbow below ten cells, a count Moran’s I cannot score, is no longer ranked against the counts it can: ranking it put ten, the smallest count Moran’s I scores, first whatever the response did (a response of noise and one varying by cluster gave the same answer). There"combined"returns the geometric ranking, logs why, and records it in the diagnostics (criterion = "geometric",fallback). The same layers now give8 7 9,8 7 9,7 6 8and8 7 9, the geometric answer, and 800 uniform points, which have no elbow and are ordered by Moran’s z,10 11 7where they gave5 4 10. Supplying bothresponse_varandpredictor_varsselects this criterion by default.build_tessellation(method = "hex")or"square"laid its lattice over a near-global lon/lat boundary in Web Mercator. The points’ CRS is chosen for distances, and handed on as the grid’s CRS it skipped the area checkcreate_grid_polygons()makes: on a boundary from 170W to 170E and 60S to 70N, the full hexagons differed 5.75-fold in true area, wherecreate_grid_polygons()on the same boundary used Equal Earth (0.7 percent). The lattice is now laid wherecreate_grid_polygons()lays it: in the CRS picked for the points unless that CRS distorts areas across the boundary by more than 1 percent, and otherwise in the equal-area CRSensure_projected(purpose = "area")picks for the boundary, with a logged warning, the points indexed in the same CRS. This applies with nocrsand with a geographic one; a local extent keeps its UTM zone.A CRS-less study area given with lon/lat points could be read as a one-metre square.
build_tessellation()resolved a boundary without a CRS against the UTM zone picked for the points, after projecting them, so a one-degree tile with integer corners (which the lon/lat heuristic declines) was stamped with that zone: one Voronoi cell, or 27 hexagons, and all 50 points indexedNA. A boundary in British National Grid metres was stamped with the UTM zone too. Such a boundary is now read in the points’ own CRS when its coordinates fit the lon/lat envelope, with a warning, and refused with an error naming both layers when they do not;create_voronoi_polygons()andclip_target_for()read it the same way.CRS-less lon/lat points with a CRS-less boundary in metres failed with “
boundarymust be polygonal”.build_tessellation()stamped EPSG:4326 on the boundary without looking at its coordinates, so a UTM polygon was transformed to nothing and the error named its geometry type. It now stops with an error saying the two layers cannot be placed in one space.create_voronoi_polygons()tessellated CRS-less lon/lat points in degrees, silently. It projected only when a CRS said lon/lat, so 60 CRS-less points at 55N got cells in which 17.7 percent of sampled locations were not nearest to their cell’s point, whilebuild_tessellation()on the same points warned, took them as EPSG:4326 and projected them. It now applies the same lon/lat heuristic, with its warning, and returns whatbuild_tessellation()returns (0.1 percent, at the cell edges).?build_tessellationno longer says a CRS-less pair “stays in the same unnamed planar space” whatever its coordinates.Delaunay triangles returned in a geographic
crsdid not contain their own points.build_tessellation(method = "triangles", crs = 4326)on 150 points left 9 of them touching no returned triangle, so a spatial join on the result did not reproduceindex. The corners of the returned triangles are now put back on the input points after the round trip through the working projection, and every point lies in its indexed triangle.The tessellation builders failed on an sf layer as
crs.build_tessellation(),create_voronoi_polygons()andcreate_grid_polygons()stopped with an error from sf (“the condition has length > 1”, or “cannot create a crs from an object of class sf”). They now take the layer’s CRS, asensure_projected(target_crs =)andharmonize_crs()do.Random and k-means seeding on a lon/lat boundary warned “install package lwgeom” on every call. sf raises “coordinate ranges not computed along great circles” for each lon/lat draw when lwgeom, which this package does not depend on, is absent: one R warning per
voronoi_seeds_random()orget_voronoi_seeds(method = "random")call and two per k-means call. That warning is muffled, and the draw is unchanged. The boundary’s union is taken on the sphere as well, so withsf_use_s2(FALSE)sf no longer prints its planarst_union()message and the seeds are the ones an s2-on session gets.A transect with sub-millimetre scatter got a sliver study area and a 166,536-cell grid for
approx_n_cells = 25.clip_target_for()called a bounding box degenerate only when its two ends were equal to rounding, so 30 points along 1000 m with a y scatter of 1e-6 got a 1000 x 9e-7 rectangle, over which the square grid had 166,536 cells (16 seconds) and the hexagonal one 154,980 (28 seconds); a little thinner, andcreate_grid_polygons()stopped atmax_cellstelling the user to check the units of acellsizethey had not passed. A box whose short side is below a millionth of the long side is now degenerate too (a buffer around the points: 34 squares or 45 hexagons for 25), and themax_cellserror names the argument the size came from,target_cells(approx_n_cells),norcellsize.A whole
build_tessellation()result passed asboundaryfailed with sf’sno applicable method for 'st_geometry' applied to an object of class "list".build_tessellation(),create_voronoi_polygons()andclip_target_for()now say that the object looks like abuild_tessellation()result and to pass its$boundary, asmake_folds(blocks = )already did for$cells;create_grid_polygons(),create_grid_polygons_cached()andensure_stable_poly_id()add the same hint to their type errors.build_tessellation(method = "triangles")recorded anapprox_n_cellsit had ignored.params$approx_n_cellsheld the ignored count where the help page says the count used is kept; it is nowNULL, as for Voronoi, and the warning says so for both methods.summarize_by_cell(deff = "variogram")said it was “Falling back to deff = 1” for a rejectedsac, and then applied a design effect. Asacwhose fitestimate_sac_range()had rejected was set aside with that warning, after which a variogram estimated fromresponse_varwas fitted and applied: in one check every row came back corrected, with a median design effect of 5.2, and in the development version the warning carried the fallback class, sotryCatch(spatialkit_deff_fallback = )threw the corrected result away. When the estimate was rejected too, the one fallback raised two R warnings. Asacthat an estimate replaces now gets a plain warning saying what replaced it, and the fallback warning is raised once per call, only when the standard errors really are the uncorrected ones, naming every reason, the rejectedsacincluded.summarize_by_cell(deff = "variogram")ignored asacthat carried no variogram model without saying so. A plain number, orunits::set_units(1.5, "km"), was passed over and the design effect came from a variogram estimated fromresponse_var, with no condition; the caller could not tell that the value given had not been used. Such asacis now set aside with a warning, as a rejectedsacis: a plain warning when a variogram is estimated instead, and the classedspatialkit_deff_fallbackwarning, naming it, whendefffalls back to 1.summarize_by_cell(deff = "kish")recorded no correction when only the predictor standard errors were corrected. The"deff_applied"attribute followed the primary variable’s ICC alone, so with an unclustered response (ICC 0) and a clustered predictor (ICC 0.82) no attribute was attached, and in the development version every row saiddeff_applied = FALSE, while the predictor standard errors had been inflated elevenfold. The attribute is now attached whenever either ICC is positive; itsdeffstays the primary variable’s (all 1 in that case).summarize_by_cell()corrected the response’s standard errors with a residual variogram without a word. Asacfromestimate_sac_range(..., predictor_vars = )describes what the predictors leave unexplained, a weaker correlation than the response’s own: the response standard errors came out at 0.60 of those from the response variogram in one check, whilekriging_adequacy()warned about the same object. Such asacis still used as given, as documented, but a warning now says the response standard errors are understated.assign_features_to_polygons()dropped the features’ ownidcolumn when the cells were keyed byid. The polygons’ ID went through the spatial join under its own name, so a siteidcollided with the cells’idand was dropped, with a warning about a collision the result never had: it only gainspolygon_id_col. Only a column namedpolygon_id_colis replaced now.assign_features_to_polygons(largest = TRUE)let the polygon row order decide an exact tie in overlap. sf keeps the first of equal largest overlaps, so a 40 m square split evenly across the edge of cells 1 and 2 went to cell 1, or to cell 2 with the polygon rows reversed. An exact tie (areas equal to 9 significant digits) is now decided bytie_breakamong the equally largest polygons, and counted inattr(, "ties").Every largest-overlap assignment of polygon features raised sf’s “attribute variables are assumed to be spatially constant” warning.
sf::st_join(largest = TRUE)adds grouping columns of its own before intersecting, so the warning came with every ordinary call (the reporting vignette hid it withwarning = FALSE), said nothing about the data, and could not be avoided withst_agr(). That warning alone is now muffled.summarize_by_cell(deff = "variogram")accepted adeff_max_nof less than 2. A value of 1 or 0 subsampled every cell to one point or none, so the mean correlation came outNaN, the design effect 1 and the standard errors uncorrected (4.1 times smaller than with the default in one check) with nothing to say so;NAstopped the call with “missing value where TRUE/FALSE needed”. It must now be a single number of at least 2, and anything else is an error that names it.The rows with no cell ID got a
cell_weightof 0. A layer assigned withkeep_unassigned = TRUEis summarised with its unassigned rows as a group whose ID isNA, andsummarize_by_cell()lost that group’s count of non-missing values:cell_weightwas 0 besiden = 5and a finite standard error. The group is now counted like any other.attr(, "deff_applied")$deffturned into a vector ofNAs for a fixeddeffand one populated cell. Withcells_sf, the realignment to the joined rows took a fixeddeff = 2for a per-cell vector whenever exactly one cell was summarised, and recordedc(2, NA, NA, ...). A fixed design effect is now recorded as the number.make_folds(method = "block_kfold")’s refusal of a grid above 1,000,000 blocks told the caller to check ablock_sizethey never passed, and ablock_sizehundreds of orders of magnitude too small slipped past it. Withblock_nx = 2000, block_ny = 1000(orblock_multiplier = 1e6) the error read “Check thatblock_size(unset) is expressed in the data’s CRS units”. Withblock_size = 1e-200the cell count overflowed toInf, which the guard let through, andst_make_grid()failed with “result would be too long a vector”. The message now names what produced the grid:block_size, the rangeauto_rangeestimated,block_nx/block_ny, orblock_multiplierxk. A count too large to represent is refused like any other.make_folds()accepted anyblock_multiplier, and aunitsobject forphiormin_trainfailed with an error that named no argument.block_multiplier = NAdied insidest_make_grid()with “‘length.out’ must be a non-negative number”.c(1, 3)silently used 3, andunits::set_units(100, m)was read as 100, giving a 32 x 16 grid aimed at 500 blocks.phi = units::set_units(100, m)failed inside the units package with ‘both operands of the expression should be “units” objects’.block_multipliermust now be a single positive number, andphiandmin_trainrefuse aunitsobject by name, asblock_size,bufferandblock_nx/block_nyalready did.residual_morans_i()andcompare_models()had no residual Moran’s I for a custom fit without aresiduals()method.?new_spatial_fitsaysresiduals()is optional, and such a fit falls through toresiduals.default()and getsNULL, soresidual_morans_i()returnedNULLwith a warning andcompare_models()reported all-NAresid_morans_*columns. On 80 points with an east-west trend the predictor could not explain, that hid a residual Moran’s I of 0.754 (z = 15.5, p = 4e-54). Such a fit is now scored on the observed response minusfitted(), which is what the built-in backends’ residuals are and whatplot()already used for it.NULLis returned only when that cannot be formed either, and the warning quotes the reason.Re-using a
cv_*()result’s$foldsrenumbered every fold after a dropped one. The result’s$foldsholds the splits that survived, each carrying thefold_idit was reported under, so five folds with fold 3 dropped are labelled 1, 2, 4 and 5. Handed to a secondcv_*()call, to score another learner on the same splits, they were numbered by position as 1, 2, 3 and 4, so the second run’s fold 3 was the first run’s fold 4 (RMSE 2.90 in both), and a join onfoldwith the first run or withfold_separation()paired different folds. Splits that carry a distinct whole-numberfold_idnow keep it. Splits without one are still numbered by position, as are splits whose ids repeat.A
fold_info_fnwhose..per_rowreused apredictionscolumn name corruptedpredictions. A..per_rowcolumn calledyhat(ory,fold,..row_idory_train_mean) was bound on beside the original, andpredictionscame back with twoyhatcolumns. In the development version,dplyr::bind_rows()then renamed both (yhat...4,yhat...6) with only a message, sooverallfound noyhatand was allNAwithn_pred = 0, beside per-fold RMSEs of 2.4 to 3.4 and no warning. Such a name is now an error naming the column, as a clashing scalar extra already was. Duplicated or empty..per_rownames are errors too, in a parallel run as in a sequential one.A
fold_info_fnthat returned a named vector lost its values without a word.c(slope = 1.9)in place oflist(slope = 1.9)(the shapemetricsaccepts) added no column, andfold_statusread"ok"with an empty message on every fold. A named vector is now taken as the list it stands for. Any other return value that is not a list orNULL(a function, an environment) is now an error.Folds built on a pointized copy of a polygon layer were refused as “built from different data”.
make_folds(coerce_to_points(parcels))applied toparcelsitself, with the same rows and IDs, was refused on 80 L-shaped parcels because “64 of 64 checked row IDs sit at a different location here”. The error blamed “folds from another dataset”. The provenance check compares each polygon’s centroid with the pointcoerce_to_points()gave it, and under"auto"that isst_point_on_surface(), which differs for every non-convex feature. The folds’params$row_probenow records whether they were built on POINT geometry. When that differs from the data, the error says so and tells you to build the folds withmake_folds()on the layer passed, which reduces polygons to points itself. Folds from data that really differs get the old message.evaluate_insample()returnedNULLwhen no element offitswas aspatial_fit. Its help promises a data frame with one row per model, and the only sign that every element had been skipped was a log line per element, whichspatialkit_quiet()hides andtryCatch(warning = )never sees. It is now an error that namesfits.cv_rf(parallel = )printed its core-count message twice when asked for more workers than the machine has.cv_rf(parallel = 16)on a 4-core machine printed “cv parallel: 16 workers requested on a machine with 4 cores; using 4.” twice. It resolved the count once for its thread policy and once more when the folds ran. It now prints the message once.fit_gwr_model()called local regressions unstable because of where a predictor’s units start. A temperature field in kelvin beside a second predictor drew “global design (intercept + predictors) has scaled condition index 230 … (collinearity risk)” and “100% of 30 sampled locations have a collinear local design … Local regressions there are unstable”, while the same field in degrees C drew no global warning and 6 of 30; the local slopes of the two fits agree to 1e-10. Both indices were computed on the uncentred design, where a predictor far from 0 against its spread (kelvin, a year, elevation in feet) is collinear with the intercept. That makes the local intercept an extrapolation to 0, but a slope’s precision does not depend on where the predictor’s origin is. The global index is now computed on the centred predictors, so it is 1 for a single predictor and a change of origin does not move it. The local survey keeps Belsley’s uncentred index with the intercept asinfo$local_collinearity$cnand addscn_slopes: the predictors centred at their weighted mean in the window, each divided by its standard deviation over the study area. A window’s slopes count as collinear whencn_slopesis above 30 or singular, or whencnis above 1e6, where GWmodel’s uncentred solve starts losing precision in the slopes.n_local_collinearand the warning count those windows. The kelvin and degrees C fits now get the same verdict (0 of 200 locations), and a regional covariate nearly constant within clusters is still flagged everywhere (200 of 200). A window where onlycnis above 30 is logged, not warned about.gwr_model_selection()checked an adaptive bandwidth less strictly thanfit_gwr_model().bandwidth = 3e9withadaptive = TRUE, a distance passed as a neighbour count, failed with “NAs introduced by coercion to integer range” and then a bare “missing value where TRUE/FALSE needed”.bandwidth = 0.5was rounded to 0 neighbours and quietly raised to the floor, wherefit_gwr_model()refuses it.gwr_model_selection()now runsfit_gwr_model()’s check before it prepares anything: an adaptive count must be at least 1 and at most R’s largest integer.cv_gwr()runs the same check once, up front, instead of failing it in every fold and returning “all folds failed”.gwr_model_selection()with a fixed bandwidth in the wrong units failed with a bare “inv(): matrix is singular”. On lon/lat data projected to EPSG:32617,bandwidth = 0.2, adaptive = FALSEis 0.2 metres against an extent of 44720 metres, so every window is empty.fit_gwr_model()warned about exactly this and explained the singular window, but the sweep said nothing. It now raises the same “less than a ten-thousandth of the data’s extent” warning, and its error explains a singular window asfit_gwr_model()’s does.print()on a GWR fit showed a fixed bandwidth in scientific notation and without its unit. A fixed bandwidth of 122372 m printed as “Bandwidth: 1.224e+05 (fixed, bisquare kernel)”, so the value was rounded to four digits and its unit was missing, although the help page tells the reader to check it. It now prints “122,372 metre (fixed, bisquare kernel)”, and an adaptive one as “42 neighbours (adaptive, …)”.fit_gwr_model()did not refuse a character or factor response, as the README says every model function does. A response read from a CSV as text went intoGWmodel::bw.gwr(), which failed twice with “Not compatible with requested type”. That drew the arbitrary-fallback bandwidth warning, and the fit then stopped with “‘x’ must contain finite values only”, naming neither the column nor the cause. It now stops first with “response ‘zc’ is not numeric (it is character)”, asfit_rf_model()andfit_bayesian_spatial_model()do.cv_bayes()under an ordinal or categorical family sampled every fold and then scored none of them. Such a family predicts a probability per response category, and cross-validation scores one number per row, so each fold compiled and sampled a full model and was then discarded: underbrms::cumulative(),k = 2on 50 rows took 4.35 minutes to return “all 2 folds failed to produce predictions”.cv_bayes()now refusesbrms::categorical()and the ordinal families (cumulative, sratio, cratio, acat) before fitting anything, with a message naming the family, and the “Which metrics survive a non-Gaussian response” section (in?model_metrics,?cv_bayesand?compare_models_cv) no longer calls CRPS and interval coverage meaningful for “any family the backend accepts”.predict_surface()’s automatic grid left up to a cell of the training extent uncovered on the east and north. It tookfloor(extent / cell_size)cells from the lower-left corner, so whenever the extent was not a whole number of cells the rest of it got no prediction.cell_size = 100on a 980 x 956 extent covered 900 x 900: 13.5 percent of the box was uncovered, and 14 of 120 training points lay in no cell.cell_size = 334gave 2 x 2 cells and left 65 of the 120 points out. The grid now has enough cells to cover the box and is centred on it. It overhangs the box by less than one cell, split evenly between the two sides, so every cell centre still lies inside the box. An extent that is an exact multiple of the cell size gets the same grid as before. At the defaultn_cells, a grid usually gains one column or row (102 x 99 cells instead of 101 x 98 on the extent above), and its centres move by less than half a cell.plot_tessellation_map(labels = TRUE)warned at print on every lon/lat layer, and failed at print on a units label column. The label points were computed withst_point_on_surface(), andgeom_sf_text()ran it again on those points when the plot was drawn. On longitude/latitude cells that raised “st_point_on_surface may not give correct results for longitude/latitude data”, although nothing was wrong. Alabel_colholdingst_area()values (class units) failed with “units package is not attached”, as a units fill column did. The label points are now used as computed, and a units or difftime label is drawn formatted, with its unit ("15073.393 [m^2]"). Afill_colorlabel_colnaming more than one column, orNA, is now refused by name. It used to fail with R’s “‘length = 2’ in coercion to ‘logical(1)’”.
Documentation
Every figure in the vignettes carries alt text, which is what a screen reader announces and the only thing a reader gets when an image fails to load. Each one states what the picture shows and what it is there to demonstrate, rather than naming the axes.
Every help page now ends with a “See also” that leads somewhere; more than a third had none, every S3 method among them. Four families are new: spatial data preparation (
ensure_projected(),harmonize_crs(),coerce_to_points(),clip_target_for(),prep_model_data()), package options and caches (spatialkit_quiet(), the two cache clearers,create_grid_polygons_cached()), methods on a fitted model (thepredict(),fitted(),residuals(),coef(),print()andsummary()methods of the three backends, which document one contract between them) and print methods for the other result objects. The seed generators, the cached grid constructor andensure_stable_poly_id()join tessellation,model_metrics()joins model evaluation,sac_nugget()joins cross-validation,gp_lengthscale_bounds()joins model fitting, andarea_of_applicability()is now in prediction as well as cross-validation. The website’s reference index moves the two cache clearers into the same group, so it and the help pages agree.Ten numbered scripts are installed with the package, in
system.file("scripts", package = "spatialkit").00-run-all.Rruns them in order; each of01-to10-is self-contained and covers one topic: tessellations, resolution, fold schemes, block sizing, fitting and diagnosing, model comparison, prediction surfaces and the area of applicability, feature selection, GWR and the Bayesian GP. They run on a simulated field with known structure, print what they are doing, and state what to look for in a figure before drawing it. No script asserts a number it did not compute, so a run that finishes is one whose claims held on the machine that ran it.SPATIALKIT_TOUR_OUTPUTwrites the figures to a folder instead of the device;SPATIALKIT_TOUR_PAUSE = "no"skips the per-figure pause. A script skips the part that needs an absent package with a message naming it, and scripts 02, 09 and 10 skip themselves when gstat, GWmodel or brms is absent.Four vignettes join
spatialkit_nc_demo:getting-started(installing, what the coordinates are in, the pipeline from points to a scored model and a map, and a glossary of the terms that recur),resolution(the ladder, the four criteria and why they disagree),spatial-cross-validation(the five fold schemes, block sizing, and reading a CV result down to the last row) anddiagnostics(residual autocorrelation, aggregation standard errors, kriging adequacy, area of applicability, and two ways to leak). Each is executed at build time and gates itself on the optional packages it needs.The README is generated from
README.Rmd, so its figures and every number in it are computed when it is built rather than pasted in. It is about half its previous length: the material that had accumulated in it moved to the vignettes above, and what remains is what the package is for, a quick start that shows a real cross-validation gap, the troubleshooting list, and pointers to the rest.New vignette
reporting: what leaves the session at the end of a run. The first half is the regions as a file someone else can use. Grouping a layer you already have withassign_features_to_polygons(largest = TRUE)(100 North Carolina counties into a hex grid of 18 regions, 12 of them populated, one row per county), answering whether a location falls in one withkeep_unassigned = TRUE, IDs that survive a reprojection, and why the aggregates go into a GeoPackage instead of a shapefile: of the 10 columnssummarize_by_cell()produces, 2 come back from a shapefile with their names intact. The second half is a worked report of six numbers, each read out of an object the run already produced, with what each one is there to stop a reader believing (Roberts et al. 2017; Meyer and Pebesma 2021; Heaton et al. 2019). Its example reports a run that fails its own checks, which is the case the section exists for.The documentation is published as a website at https://elkronos.github.io/gis_modeling_toolkit/: the README, every help page grouped by pipeline step, the six vignettes as articles, and this changelog. It is rebuilt from
mainon every push, so it describes the development version; the “development version” heading at the top of this file lists what the CRAN release does not have yet.select_features_forward()now says what$scoreis: the cross-validated metric of the winning set at the final step, which is the selection criterion and is optimistically biased by the selection itself (Cawley and Talbot 2010) — not a performance estimate of the selected model. The honest estimate comes from running the selection insidecv_spatial()’sfit_fn, which the page now spells out.fit_bayesian_spatial_model()documents whichbrmsfamiliesfamily =takes — zero-inflated and hurdle counts, negative binomial, Bernoulli, beta, ordinal, categorical and mixture families all reachbrms::brm()with the spatial GP term intact, and a factor response is taken only undercategorical()and the ordinal families — and gains a worked zero-inflated Poisson example. The same page now records a trap: a family objectbrmscannot name skips the response-type check entirely rather than falling back to the gaussian rule, so a malformedfamilybuys less validation, not more.fit_bayesian_spatial_model()gains a “Spatial confounding” section: a coefficient estimated beside a spatial random effect is a different estimand from the non-spatial one (Zimmerman and Ver Hoef 2022), can shrink toward zero when the response is smoother than the covariate (Bolin and Wallin 2025), and the honest diagnostic is to report both side by side. The section names both sides of the restricted-spatial-regression dispute (Hughes and Haran 2013; Hanks et al. 2015; Khan and Calder 2022) and the remedies expressible in abrmsformula (Marques, Kneib and Klein 2022; Guan et al. 2023).model_metrics()gains a “Which metrics survive a non-Gaussian response” section, inherited bycv_bayes()andcompare_models_cv(): RMSE and MAE are defined for any numeric response; MAPE, SMAPE and R-squared are Gaussian-shaped; CRPS and interval coverage fromcv_bayes()are the proper scores for a count or bounded response. It also records that the all-folds-failedfold_metricsframe omits thecoverage_*columns.estimate_sac_range()documents what a count or other mean-variance-linked response does to the variogram, why detrending withpredictor_varshelps but does not fix it, and what to do instead.?spatialkitnow reads its nine-step pipeline as an argument — steps 1 to 4 are the claim, steps 5, 7 and 9 the evidence that makes it checkable — and gains a “Defaults and their sources” section listing which defaults rest on a citation and which were chosen, so the two are not mistaken for each other.cv_spatial()documents its name collision withblockCV::cv_spatial(), which builds folds where this one runs them, and thatblockCV’s$folds_idsis accepted directly asfoldseverywhere.?determine_optimal_levelsno longer calls the Cliff and Ord moments behind Moran’s z exact: they assume cell means of equal variance, and with single-point cells beside cells of 70 or more pointszran slightly high (mean 0.2–0.34, 7–8% rejection at 5%);resolution_profile()sayscell_diam_medianis about 0.8 of the side of an equal-area square cell, not a width (andvignette("resolution")no longer calls it one); the Post-selection inference section and the vignette say that the estimation rows of a split leave cells in the selection half empty and cells across the border estimated from part of their points (coverage 0.88 against 0.97 in simulations), and how to find the cells to trust;sac_nugget()says the nugget is extrapolated from the first lag bin (aboutmax_dist / 30at the default cutoff), not observed below the closest pair, and can be exactly 0 at the fit’s bound.The tessellation help pages are corrected:
clip_target_for()returns the points’ bounding box, not their convex hull, and is not the targetbuild_tessellation(method = "voronoi")derives;create_voronoi_polygons()andbuild_tessellation()say that a multi-vertex MULTIPOINT feature gets one cell per vertex and thatkeep_duplicateshas no effect;voronoi_seeds_random()tops a short draw up to exactlyk;voronoi_seeds_kmeans()andget_voronoi_seeds()say that theirstats::kmeans()partition is not the k-means++ oneresolution_profile()scored, and no longer promise equal counts per cell.Documentation corrected:
summarize_by_cell()’ssacno longer identifies a rejected fit by astatusattribute nosac_rangecarries (it is anNAvalue with arejected_reason), and says a rejectedsacis set aside and the variogram estimated as if none had been given;ensure_stable_poly_id()and the reporting vignette say IDs are the same across projections except for fine cells within the rounding step of one longitude (36 of 2,500 100 m cells via EPSG:3035), not always;summarize_by_cell()’s “Confidence intervals” says..neff_*isNAfor a single observation; the resolution vignette says the blocked split reduces the leak between halves rather than removing it.?estimate_sac_rangenow says that the exponential model is kept whenever it converges and so overestimates the range on smoother fields (about 1.8–2.1 times a Gaussian practical range, 1.3–1.4 times a spherical one), and why the family is not chosen by fit (that biases exponential fields low, to 0.82); that 15 lag bins make a range spanning one or two of them come out long (60 m returned 89–102 m); that nothing tests for spatial structure (white noise gave a finite range in 8 of 30 draws); that the “decreases with distance” refusal also fires on small samples of ordinary fields (7–9 of 60 at n = 30, 0–1 at n = 100), which its warning now says below 100 points; that the anisotropy note goes to the log file at INFO, not the console, and that the longest directional range is read fromdirectional_fittedafter checkingdirectional_status, notmax(attr(, "directional")), which isNAexactly when the major axis ran past the fitted lags (the note itself, the nc_demo vignette and the help now say so); that an accepted range can still exceed half the width of the layer, leavingmake_folds(auto_range = TRUE)room for one block (range_frac); and a flat variogram’s refusal message no longer claims the data “never reached a sill” without mentioning that a structureless variogram ends there too. The README counts six refusals, not five.?make_folds: the NNDM details now say how the procedure differs fromCAST::nndm()(a strict removal rule, one point more conservative per distance value, and ties broken by coordinates rather than row index) instead of calling it the same; the n > 5000 refusal gives the worst-case cost, O(n^3) time wheremin_trainbinds (about nine minutes at n = 3000), not O(n^2);drop_empty_blocks,boundaryandblock_multipliersay what they do on the cases above.?cv_rfno longer says aseedpassed through...overrides the per-fold forest seed (seedis the function’s own argument and never reaches...);?cv_bayessaysparallel = ncompilesnStan models at once, at several GB each, and what a fold killed for memory reports;?residual_morans_iand?compare_modelssay their methodological cautions are logged, not raised as warnings.?fit_gwr_model’s “Collinearity diagnostics” section described the 30-location unweighted spot check the survey replaced, a global index on the predictors alone, and a caveat that only a subset of locations is examined; it now describes the code (every location, kernel-weighted, a global index on the centred predictors, and at each location one index with the intercept and one for the slopes alone).?fit_gwr_modeland?gwr_model_selectionnow state the adaptive bandwidth’s floor and cap.?fit_bayesian_spatial_model:check_convergencesays the checks write WARN log lines and set$info$convergence_okrather than “issue warnings”, and that under cmdstanr nothing is raised as an R warning; the basis adequacy check is described as logged, and as changing neitherconvergence_oknorprint()(the argument’s text listed it among the checks that setconvergence_oktoFALSE); thefamilyargument and the non-Gaussian section no longer promise that any response type brms can fit works here; the spatial confounding section warns that coefficients understandardize_predictors = TRUEare per standard deviation before comparing withlm().?coef.bayesian_fitgains a section on standardised predictors, andprint()on such a fit names them.?fitted.bayesian_fitno longer says a failed posterior draw returnsNA(it has been an error since before this release).?gp_lengthscale_boundssays its bounds are the prior’s calibration range and do not shrink withn.?area_of_applicabilitystates the outlier rule (type-7 quartiles) and how the threshold differs from CAST’s (the fence itself, capped at the largest training DI, which is larger whenevern_outliers > 0), with thethreshold =value that reproduces it; the internal note that CAST usesboxplot.stats()was out of date, and the package page no longer implies the threshold matches CAST.n_newis documented as every row ofnewdata(n_inside + n_outside + n_na), not the rows that passed the finite-value filter.?predict_surfaceand the README say thatse = TRUEon abayesian_fitgives the SD of the mean surface and thattype = "predict"gives the predictive SD.The diagnostics vignette’s area-of-applicability figure alt text no longer calls the training curve cross-validated (no folds are passed there);
?plot.aoaand?plot.feature_selectiondescribe the training DI and the rejected last step as drawn.The examples that stamp EPSG:32632 (UTM zone 32N) on simulated points now put the points inside that zone. The README quick start, the
fold_separation()example, the resolution, diagnostics and spatial cross-validation vignettes, and the fixture the scripts ininst/scripts/share placed them at x and y between 0 and 1000, which is on the equator about 4.5 degrees east, outside the zone. They are now offset by 500000 m east and 5000000 m north, as the other examples already were. Every printed result is unchanged, since the package works in planar units; only the coordinates themselves, and the graticule on the maps, differ.The examples of
resolution_profile(),select_resolution(),summary()andplot()on a profile useset.seed(4), on which their comments hold (C_p interior at 28 cells, reliability on the range floor); withset.seed(2)C_p had moved to the support ceiling. Theplot()example no longer promises four panels on points that have no elbow.Tour script 02 runs to the end again: step 02.6 drew the elbow’s pick, which its evenly spread points no longer have, and now draws the C_p pick. It prints the bound each criterion’s optimum sits on, points to
min_cell_nandrange_floorrather thann_levelsfor moving one, and computes what theselect_on = "split"comparison shows (both picks on the range floor) instead of calling the difference tuning. Script 08 no longer calls its held-out half untouched or its score gap the selection effect, and says its block size of 300 is under the range of about 357.The README’s resolution figure labels its middle count as the one
resolution_profile()rates most reliable, with the width of that criterion’s flat region; it wasdetermine_optimal_levels()’s count, which on those evenly spread sites the function now says the ladder chose. The troubleshooting entry quotes the fallback messagesdetermine_optimal_levels()now gives, with their causes.The resolution vignette’s criteria table describes the elbow as the log-log sag it is,
NAon points with no cluster structure, and its introduction saysdetermine_optimal_levels()still returns a count there, with a warning.?coerce_to_points(tmp_project) states the rule a CRS-less layer is read by: the lon/lat heuristic ofensure_projected(), not merely lying inside the lon/lat envelope.?summarize_by_cellnow gives the derivation and the measured coverage of the small-sample rescaling that goes with every data-derived design effect, which the package page said were there; the package page lists it among the defaults that were chosen, not cited.?estimate_sac_rangenow says when the directional maximum is logged (only when it stands in for a singular or non-converged all-pairs fit, at WARN only above a ratio of 1.5, naming the directional ranges), and that a range shorter than the first lag bin can come back several times too long (a true 24 m returned 93–479 m at n = 1500, 19–32 m atcutoff = 0.1), so a variogram at its sill in the first one or two bins calls for a smallercutoffwhatever range was fitted.?make_foldsno longer saysauto_range“fits directional variograms to account for anisotropy”. It sizes blocks from the omnidirectional range, and for a field known to be anisotropic the page now points todirectional_fitted. An accepted range too wide for two blocks makesmake_folds()stop with an error, which the page now says; it had claimed the grid “does not collapse to a single block”. The log line announcing a loweredkjust before that error is gone. The page no longer says NNDM never pushes a point’s nearest-neighbour distance pastphi: the last exclusion can take it past by one neighbour step, as inCAST::nndm(). The description lists all five methods. The page now says thatk = 1is raised to 2 by the three k-fold methods, and that onlyblock_kfoldreturnsparams$blocks_suppliedandparams$boundary_supplied.The README’s entry for “response ‘y’ is not numeric” notes that
fit_bayesian_spatial_model()takes a factor or character response underbrms::categorical()and the ordinal families.The README,
?summarize_by_celland the getting-started, diagnostics, reporting and North Carolina vignettes now say which estimand the design-effect-corrected standard error is for. It is the standard error of a cell mean as an estimate of the population mean. For a cell’s own mean, which is what a map reports, the defaultdeff = 1standard error is the right one when the points are spread through the cell. Several of these pages had presented the correction as the right standard error for the cells themselves, and there it is too wide bysqrt(deff / (1 - rho))(4.6 at 20 points a cell andrho = 0.5). The getting-started pipeline, which maps the cells, now aggregates atdeff = 1.Smaller corrections: the README’s troubleshooting list adds
"compare_models_cv(): no recognised model requested.", which is the error when no requested name is recognised, and says that"no viable models."means every recognised backend is uninstalled. The getting-started install table no longer sayspatchworkis needed for theplot_*()functions. The North Carolina vignette explains why some local designs of its GWR fit have a high condition index with the intercept (an uncentredelevation, nearly collinear with the intercept inside each window), and why the fit raises no collinearity warning: the slope index stays below 30, so the slopes are well determined and only the local intercepts are not. Its fold-map alt text now describes each blocked fold as whole blocks in separate parts of the state, not as one contiguous area.vignette("diagnostics"): the “Two ways to leak” example of selection inside the folds leaked itself (blocks about 250 m across against an autocorrelation range of about 330 m) and its learner could not fit the intercept-only model, sotoldid not apply to the first variable. It now passesblock_size = 400, fitsz ~ 1for an empty predictor set, and showssel$history.?fit_rf_modelrecommendedarea_of_applicability(weights = pmax(fit$info$importance, 0))without condition. It now says this works only when that importance is finite, which it is not when no row is out of bag.
spatialkit 2.0.0
CRAN release: 2026-09-11
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.
Breaking changes
Corrections that change results
Each item below was measured and independently reproduced before it was touched; the figures quoted are from those reproductions, so you can judge whether an item 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.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.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 — WGS84 ellipsoidal distances (Vincenty), notsf::st_distance()’s s2 sphere, whose 0.24–0.56% gap from the ellipsoid is the size of the errors being ranked and mis-ordered the candidates on 16 of 40 random wide extents; 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.determine_optimal_levels()returns the elbow first. Under the geometric criterion — the default, and the fallback every model-aware call takes below the nine-cell floor — the candidates came back sorted ascending, so the knee sat in the middle:k[1]andtop_n = 1, both documented as “the top-ranked candidate”, returned knee − 1 on every such call, and the help example answered1for two clearly separated clusters. The vector is now knee first, then its lower and upper neighbours, so position 1 means the same thing on both paths. The quick-start data’s answer moves from3 4 5to4 3 5. Same code in 1.0.0.-
estimate_sac_range()is invariant to rotating the layer. Two things were not. The lag cutoff was a fraction of the bounding-box diagonal, a property of the axes rather than of the points, which grows by up to √2 when the same layer is rotated 45°; it is now a fraction of the farthest pair (found on the convex hull), as the documentation always said. And the directional maximum is never preferred over a usable all-pairs fit. The 1.0.0 code returned the widest of two axis-aligned directions; the three hurdles later put in front of it (all four directions fitted, ratio above 1.5, maximum above 1.5× the all-pairs fit) still let the noise through — on one isotropic field rotated in 10° steps they “established” anisotropy in 14 of 18 orientations and returned ranges from 225 to 529, a 2.35× spread produced by nothing but the direction the axes pointed. The four directional ranges are still reported, as a diagnostic, in thedirectionalattribute;anisotropy_usedisTRUEonly when the all-pairs fit itself failed. A field known to be anisotropic should size its blocks frommax(attr(range, "directional"))explicitly, and the log line says so.The variogram fit itself no longer depends on gstat’s single starting value.
fit.variogram()starts the optimiser at a third of the longest lag; for a field whose range is a small fraction of the extent that start is ten times too long, and whether the iteration landed or collapsed to a singular model depended on floating-point details — the same 250-point field fitted on Linux and came back singular on an arm64 Mac, where the estimate then fell through to the directional maximum (315 against a true range of 80). Shorter and longer starting ranges are tried as well, the converged, non-singular fit with the smallest weighted sum of squares (gstat’s own criterion) is kept, and a converged spherical fit is preferred over an exponential one that did not converge. When no model fits at all — a flat, nugget-only variogram — theNAnow carries the empirical variogram, soplot(fit, type = "variogram")draws it and says why there is no range instead of refusing. residual_morans_i()splits tied neighbour distances. The k-nearest neighbour weights broke ties at the k-th distance by whichever point came first — in row order on the dense path, in kd-tree order under FNN — so the same data in a different row order gave a different I, z and p whenever observations shared a location (repeat visits), and on gridded data the answer depended on whether FNN was installed. Points tied at the k-th distance now share the remaining weight equally, on both paths, which is the only rule that is a function of the geometry alone; the matrix is row-standardised as before. Where no distances tie the weights equalspdep’s k-NN weights exactly; on repeat-visit data every co-located twin is retained with its share, and the statistic no longer changes when the rows are shuffled or when FNN is installed. Same code in 1.0.0.**
fit_gwr_model()‘s collinearity diagnostic is the scaled condition index, thresholded at 30.** It waskappa()on the raw, unscaled predictor matrix at 1e6 — a number that depends on the predictors’ units, so rescaling a column changed it and the threshold was a threshold on nothing: a design with a scaled condition index of 1322, whose local coefficients ran from −86 to +150 around a true value of 2, raised nothing. Belsley’s index (every column scaled to unit length, intercept included, the ratio of the largest to the smallest singular value) is now computed for the global design and for each sampled local window, and the conventional 30 is the threshold in both. Expect the warning on designs that were silent before.
API and default changes
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. The two tail conditions have one exact solution — a one-dimensional root in the shape — and that is how it is found, so the calibration succeeds for any bounds withupper > lower; degenerate bounds fall back to the half-normal with a logged note. 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 below, which also replaced the EPSG:3857 fallback for wide bounding boxes with antimeridian detection.) Passtarget_crsto override.Core counts follow the session’s
mc.coresopt-in, and are capped.fit_bayesian_spatial_model()’s documented default wascores = max(1L, parallel::detectCores() - 1L), andcv_*(parallel = TRUE)auto-detected the same way with no cap — 63 workers on a 64-core host, and a hard error wherever_R_CHECK_LIMIT_CORES_is set. The Bayesian default is nowgetOption("mc.cores", 1L); the auto-detect path is capped by that option when it is set; and every worker count, explicit or not, is capped at the machine’s core count (with a message) and at two underR CMD check.
Guards, messages and stricter input handling
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 when that entry was written, by measured distortion in this release) 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.A misspelt
newdatais an error, not an in-sample answer.model_metrics(),evaluate_insample()andcompare_models()forward...topredict(), which checks it only on the out-of-sample branch, somodel_metrics(fit, newdta = hold)silently took the in-sample branch and returned an RMSE of 1.086 where the held-out answer was 25.24, with the same return shape. Arguments in...with nonewdataare now refused by name.predict()on agwr_fitor abayesian_fitlikewise refuses unknown arguments instead of swallowing them;predict.rf_fit()accepts only ranger’s own predict arguments through....predict()enforces onenewdatacontract on all three fit classes. A baresfcdied inside two of them with R’s “argument must be coercible to non-negative integer”; a numeric-at-fit predictor that arrived as character (a CSV round-trip) was refused by name byrf_fitandbayesian_fitand returned all-NAwith a generic backend warning fromgwr_fit; a missing column was reported by two different functions in two wordings. All three now run the same check first, so the message is the same whichever fit is behind it.make_folds()validatesblock_sizethe way it validatesk: a single finite positive number, else an error naming the argument.NAand a length-2 vector used to die as internal R errors, and a negative, zero or character value was silently ignored — yet echoed back inparams$block_sizeas if it had been used. The grid-size guard also formats its own message: ablock_sizein the wrong unit could ask for a grid past 2³¹ cells on a side, which%drefused with “invalid format” instead of the documented refusal.estimate_sac_range()says which variable it modelled. A predictor name absent from the data was dropped silently byintersect(), and with every name unknown the raw response was modelled — somake_folds(auto_range = TRUE)sized blocks from a range of 88.5 instead of the residual range of 362.2 with nothing said. An unknown predictor is now an error, as it is everywhere else; a detrending fit that fails (an all-NAcolumn) raises a warning and falls back to the raw response; and a newdetrendedattribute records which was used.make_folds(block_kfold, boundary = )refuses a boundary containing none of the points — almost always two layers in different places, a CRS that could only be stamped — and raises a warning counting the points that fall outside a boundary that contains some, since the region is silently extended to cover them. The single-block error names what produced the grid (the block size,block_nx/block_ny, or the automatic grid) rather than always blamingblock_nx/block_ny, which the caller may never have passed.Rows that no fold names are reported. The
folds↔︎ data guard was one-directional: fold IDs naming no row were counted and dropped, but rows in the data that appear in no fold’s train or test set passed with no condition at any level — afoldsobject built onsite[1:45, ]and applied to all 90 rows scored 45 of them and reportedn_folds_attempted = n_folds_succeeded = 3. Everycv_*()now raises a warning with the count and an example row ID.User-facing conditions name the function the user called. The cross- validation path leaked two internal names into ordinary console output —
.remap_folds():and.cv_run_folds():— andcv_rf(), a wrapper aroundcv_spatial(), reported every message, warning and error incv_spatial()’s name. Thesf-input assertion shared by the tessellation and seeding functions said “Expected an sf object” with no function named; it now names the caller and, when handed a wholebuild_tessellation()result, says to pass its$cells.coerce_to_points(mode = "line_midpoint")’s MULTILINESTRING refusal is prefixed like every other.clear_grid_cache(cache_env = )removes only its own entries. It removed every binding in the environment it was handed and counted them all as “entries removed”, so a user who passed a project environment lost unrelated objects. Cache keys now carry aspatialkit_grid::prefix and nothing else is touched.summarize_by_cell(deff = "variogram")now uses every structured component of a nested variogram model, each weighted by its partial sill, which is the correlation the model implies (1 - gamma(h) / sill). It read the single largest component, so a user-builtNug + Exp + Sphmodel gave a correlation of 0.108 at 200 m wheregstat::variogramLine()implies 0.197, and the design effects and standard errors with it. Models fromestimate_sac_range()are single-component and are unaffected. A model of a family the function does not implement (Matern, power, circular, …) was silently read as exponential; it now falls back todeff = 1with a warning that names the family. Exponential, spherical and Gaussian are supported.cv_bayes()$predictions$yhat_sdis the posterior predictive standard deviation of each held-out row, from the same draws that give the coverage columns. It was an unconditionalNAplaceholder. It staysNAwhencompute_pred_intervals = FALSEor the draws failed for a fold, and it is documented.fit_gwr_model()refusesn <= p + 1observations with one error, before touching the backend. Such a fit cannot have a residual degree of freedom in any window;n = 2used to warn three times (“only 2 observations”, “fallback bandwidth”, “2 of 2 local regressions singular”) and return a fit whose fitted values were allNA.create_grid_polygons_cached()gainsmax_entries(default 50): once the cache holds that many grids, adding one evicts the earliest-added. It never evicted, so a loop over a thousand boundaries held every grid (about 2 MB per 2,500 cells) for the life of the session. Nothing but the grids is written into a caller-suppliedcache_env; the insertion order lives inside the package. The cache key also hashes the package version, so a cache that outlives an upgrade cannot serve a grid built by an oldercreate_grid_polygons().spatialkit_quiet()accepts aloggerthreshold as well asTRUE/FALSE, and the value it returns can be passed back:old <- spatialkit_quiet(); spatialkit_quiet(old)restores exactly the level that was in force.spatialkit_quiet(FALSE)put back the package default (WARN) whatever had been set, and the returned value was refused as “must be TRUE or FALSE”..onUnload()disarms bothloggerappenders, so thespatialkitlogger namespace no longer keeps pointing at the session’s temp-file path afterunloadNamespace("spatialkit")..onLoad()re-registers them.residual_morans_i()documents the second reason"residual"(and"auto") falls back to the randomisation null: fewer than four residual degrees of freedom, where the residual variance formula divides by(n - p)(n - p + 2)."residual"logs a warning when it does,"auto"does not, anddfis thenn - 1.estimate_sac_range()states what “effective range” is for each model – three times the range parameter for the exponential fit (95% of the sill), the range parameter itself for the spherical fallback (100%) – and its return-value documentation matches the code: the no-model case (both fits singular) returns the classedNAwithrejected_reasonset, and only the cannot-even-start cases (nogstat, too few values, no variance, a degenerate extent) return a bareNA. Its example now demonstrates a fitted range on a simulated field with a known one (3 x 100 = 300), and a refusal on a field whose range the data cannot pin down.-
make_folds()documents whatblock_multiplierdoes (the automatic grid aims forblock_multiplier * kblocks, so each fold holds out about that many; 3 is a compromise, not a published constant), cites Roberts et al.- and
blockCV(Valavi et al. 2019) for sizing blocks from the autocorrelation range, and notes thatblockCVtakes the fitted variogram’s range parameter whereauto_rangetakes the effective range – three times that parameter for an exponential fit, so larger blocks.phiformethod = "nndm"is explained as Mila et al. (2022) define it: the autocorrelation range beyond which matching is unnecessary, whichestimate_sac_range()supplies.
- and
summarize_by_cell(deff = "kish")says which ICC estimator it is (ANOVA with Donner’sn0, not REML) and how far the two can differ on an unbalanced draw, so the difference is not read as a defect;area_of_applicability()’s training-DI sentence now says what the code does (each fold’s actual training rows, not “everything outside the fold”);?spatialkitno longer listscoef()among the methods all three backends share (a forest has none); the internal elbow helper no longer claims to match Kneedle, which it does not on shouldered curves; the GP basis diagnostic’s code comment attributes its 10% posterior-mass trigger to this package rather than to Riutort-Mayol et al. (2023).summary()on a fit printsR^2andAdj R^2in ASCII with aligned labels (the superscript two rendered asR<U+00B2>on non-UTF-8 consoles, andAdj R²=had no space);print()on a random forest likewise; the six console messages that carried an em dash use--.coef()on anrf_fitprefixes its errorcoef.rf_fit():like its siblings.get_voronoi_seeds(method = "kmeans")sizes its candidate cloud in double precision;50L * as.integer(n)overflowed toNAabove 42,949,672 seeds.README: the opening leakage example says it needs
ranger; the no-viable-models example is assigned so that it does not print every fold table on a machine that has the backends; theauto_rangeandcv_bayes()failure examples show every line the console actually prints; the installation table no longer suggests installinglooseparately (brmsinstalls and calls it); the “logged note” fromdetermine_optimal_levels()is identified as an INFO-level line in the session log file, not console output; the roxygen2 sentence no longer names a version or aRoxygenNotefield.The memory-guard test for
make_folds(block_kfold)stubs outsf::st_make_grid()for the two refused calls, so a regression of the guard fails fast by name instead of attempting an 8000 x 8000 grid.Every
cv_*()refuses a fold list whose splits carry notrain/testelement, with an error that names the problem. It readf$train/f$teststraight, gotNULLfor both, and built empty folds – so the row-coverage warning fired and blamed folds “built on a different or subsetted layer”, which was not the cause, and the run returned an all-NAoverallwithn_folds_succeeded = 0.area_of_applicability()had always refused the same input by name; the two now agree. When the splits look positional (two unnamed vectors each) the error says so and points at the fold label vector instead.The
foldsargument of everycv_*()documents all three shapes it has accepted since the label vector was added earlier in this pass – amake_folds()result, a list oflist(train =, test =)splits, or a vector of fold labels – where the help listed only the first two. The label vector is what makes folds from another package usable directly:blockCV::cv_spatial()returns one as$folds_ids(its$folds_listholds two unnamed vectors per fold, which is the shape now refused by name).MAPEandSMAPEare documented as what they are: averages over the rows whose denominator is non-zero. Both have a denominator that can vanish –MAPEwhere the observation is zero,SMAPEwhere observation and prediction are both zero – and each drops those rows rather than returningInf, which is the right arithmetic but was reported nowhere. On a response taking exact zeros (counts, rainfall, abundance) the consequence is material: with 62 zeros out of 120,MAPEis an average over 58 rows presented as though it covered 120, andSMAPEdrops precisely the rows a well-fitted model got right, so it reads worse than the fit deserves. The new “Percentage errors on responses with zeros” section onmodel_metrics()– inherited byevaluate_insample(),compare_models(),compare_models_cv(),summary()and all fourcv_*()– says so, notes that thencolumn is the finite-pair count and not the row count either percentage error used, and points at RMSE/MAE/R-squared (and, for a Bayesian fit, CRPS and interval coverage) as the metrics unaffected by it. No computed value changes; returning the per-metric row count would alter the metric frame’s column set and is deferred.
Bug fixes
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 touches the RNG at all. It sampled its 30 locations 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. The 30 locations are now evenly spaced ranks of the observations ordered by x, then y – reproducible, independent of the row order, and drawing no random numbers.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.The package’s log lines no longer depend on the user’s global
loggerconfiguration.loggerseeds a new namespace from the global one, so the"spatialkit"namespace inherited whatever formatter the user had set before loading — and every logging helper handsloggeran already formatted string. Under a user’sformatter_sprintf, every package message containing a literal%— the CRS distortion figures inensure_projected(), the local collinearity percentage infit_gwr_model()— hard-errored with “too few arguments”, and because the helper logs before it raises the R warning, the warning the manual promises died with it. Under the defaultformatter_gluea{...}inside a fold error was re-evaluated. The namespace’s formatter is now pinned toformatter_paste, so the message logged is the message written.fit_bayesian_spatial_model(backend = "auto")chooses cmdstanr only when a CmdStan build is actually available. It chose it whenever the cmdstanr package could be loaded — a thin interface that is often installed without the toolchain it drives — so on such a machine every fit died inside the sampler with “CmdStan path has not been set yet. See ?set_cmdstan_path”. The package’s own weeklycheck-brmsjob was one such machine: it installs cmdstanr to satisfy Suggests and never builds CmdStan, and every scheduled run since the Stan smoke tests landed failed there. “auto” now falls back to rstan, which brms always brings, and logs the choice; an explicitbackend = "cmdstanr"with no usable build is an error that says to runcmdstanr::install_cmdstan().cv_bayes(seed = )reaches the sampler.fit_bayesian_spatial_model()carriesseed = 123and the per-foldfit_argsnever set it, so every fold of every run sampled from Stan seed 123 and changingseedchanged nothing on fixed folds. Each fold now draws its own sampler seed from the fold’s seeded stream, ascv_rf()does for the forest; aseedinfit_argsstill overrides it for every fold.cv_*(seed = NULL)is reproducible fromset.seed()underparallel > 1, as the README promised without qualification. Withseed = NULLno per-fold seeds were drawn, so each forked worker was seeded bymclapply()from the clock and the process ID — three runs after the sameset.seed(777)gave 0.5687, 0.5649 and 0.5623 while the sequential call was reproducible. The per-fold seeds are now drawn from the caller’s current stream (advancing it, as any RNG-consuming call would), so the sequential and parallel paths are the same function of the stateset.seed()left.Warnings raised inside a fold reach the caller from the parallel path. R conditions do not cross a fork, so under
parallel > 1every warning raised by the model — includingfit_gwr_model()’s documented integer-response warning, raised in every fold — reached nobody, while the numbers came back identical and the run looked like a clean version of the same analysis. The worker now collects them and the parent re-raises each distinct message once.cv_spatial()(and thereforecv_rf()) returns the same typed, zero-rowfold_metricsframe ascv_gwr()andcv_bayes()when every fold failed, sosubset(fold_metrics, RMSE < 5)works instead of erroring on a missing column.residual_morans_i()refusesklarge enough to make the neighbour matrix dense. The only size guard (n > 5000) applied to the dense fallback; with FNN and Matrix present,k >= n - 1allocatedn (n − 1)pairs unguarded. Requests above 2e7 pairs are now an error namingkandn.
New features
-
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, https://doi.org/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, https://doi.org/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.num_threadsdefaults togetOption("mc.cores", 1L)— one thread unless the session has opted in — for both the fit andpredict(), rather than ranger’s own default of every core on the machine, andcv_rf(parallel = )runs each forked fold’s forest on one thread unless told otherwise, so the worker count is never multiplied by a thread count. See?fit_rf_model. Every
cv_*()andcompare_models_cv()acceptfoldsas a vector of fold labels, one per row —make_folds()$assignment$fold, the object most naturally to hand — in addition to amake_folds()result and a list oftrain/testsplits, the three shapesarea_of_applicability()already took. The label vector used to fail with R’s “$ operator is invalid for atomic vectors”.-
New
area_of_applicability(), implementing the dissimilarity index of Meyer & Pebesma (2021, https://doi.org/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. Where the backend cannot fit the empty set at all —fit_rf_model()andfit_gwr_model()both refuse a zero-lengthpredictor_vars— the probe is silent on the console: its per-fold failures go to the file trace only, rather than printing the same lines a genuinely failed run prints.New
gwr_model_selection(): wrapsGWmodel::gwr.model.selection()(Lu et al. 2014, https://doi.org/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, https://doi.org/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, and ties in the nearest-neighbour distance — every mutual-nearest-neighbour pair, all of a regular grid — are broken by the point’s position rather than by its row index, so identical data give identical folds whatever order the rows arrive in (CASTbreaks them by row).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 — on the response, not on OLS residuals, even whenpredictor_varsare listed: the..se_resp_*columns estimate the SE of the cell mean as an estimate of the response’s grand mean, so the correlation to correct for is the response’s own (measured grand-mean coverage 0.93 with the response variogram against 0.51 with the residual one on a field with a smooth predictor). Pass a residual variogram throughsacif that is the field you want. 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, https://doi.org/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.
Documentation
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).Every exported function has runnable examples: the eleven that shipped without any —
clear_fitted_cache(),clear_grid_cache(),clip_target_for(),compare_models(),create_grid_polygons_cached(),ensure_stable_poly_id(),evaluate_insample(),harmonize_crs(),model_metrics(),voronoi_seeds_kmeans()andvoronoi_seeds_random()— gained one, and the two\dontrun{}blocks say why they cannot be run (a Stan toolchain and minutes of MCMC).residual_morans_i()’s default null is described correctly:null = "auto"uses the Cliff & Ord regression-residual moments whenever the residuals are OLS residuals on the rebuilt design, and the randomisation null otherwise; the README said “the randomisation variance” without qualification. The type-I error of a random forest’s residual test is attributed to what the package actually feeds it — out-of-bag residuals, which are honest out-of-sample errors with their own spatial structure — not to “shrunk in-sample residuals”.determine_optimal_levels()gives the real reason for its nine-cell floor (the standardised deviate is 0/0 there, so the criterion carries no information) rather than an argument from|I|that its own details section had just called wrong.summarize_by_cell()notes that the “use the naive SE for the cell’s own mean” advice is calibrated under uniform within-cell sampling.cv_gwr(bandwidth = )states its units and semantics like its siblings.quietis documented as “suppress this function’s progress messages” everywhere, with a pointer tospatialkit_quiet()for the console log echo it does not touch.README: the square-grid call returns 36 cells, not 32; the installation section no longer promises a specific version from CRAN; the resolution figure and the quick-start output are regenerated for the elbow-first ordering (
4 3 5,k = 4).-
The DESCRIPTION now cites the methods it implements – Lu et al. (2014) for
GWmodel, Riutort-Mayol et al. (2023) for the Hilbert space Gaussian process, Strobl et al. (2007) for the permutation importance, Mila et al.- for NNDM folds and Meyer and Pebesma (2021) for the area of applicability – each with its DOI, and quotes only software names. The Riutort-Mayol reference on
fit_bayesian_spatial_model()’s help page gives the article number (33, 17) rather than “33, 1”.
- for NNDM folds and Meyer and Pebesma (2021) for the area of applicability – each with its DOI, and quotes only software names. The Riutort-Mayol reference on
No example is wrapped in
\donttest{}any more: the fifteen that were – every fit, cross-validation, comparison, plotting and surface example that needs a Suggests package – run unconditionally behind theirrequireNamespace()guards, the slowest in under 2 s. The two Stan examples (fit_bayesian_spatial_model(),cv_bayes()) keep\dontrun{}because they need a C++ toolchain and minutes of MCMC, and say so in a leading comment.
spatialkit 1.0.0
CRAN release: 2026-08-07
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.