New optional spread link "powerLaw", off by default: p = pc - (pc - floor) * exp(-x / gamma) with x the linear predictor (powerLawLink(), exported), pc fitted in the maxAsymptote slot, floor = lowerSpreadProb and gamma fixed (spreadPowerLawGamma, 1.9, from a percolation calibration; it is not fitted because it is confounded with the coefficient scale). p below 0 is set to 0. The per-year random effect (yearSpreadSD) is added to x, not to the logit of p, so no year takes p past pc. Selected with link = "powerLaw" and powerLawGamma in logisticAll(), spreadProbFromCovs(), .objfunSpreadFit(), spreadProbGates(), spreadFitValidationData() and runDEoptim(); powerLawGamma is left out of runDEoptim()'s cache keys and re-score arguments unless link = "powerLaw" (powerLawOmitArgs()), so calls with the other links are what they were and "ceiling" output is bit-identical. spreadLinkOptions gains "powerLaw"; new spreadFitPowerLawGammaTxt (ledger column powerLawGamma_spread) and spreadPowerLawPcBounds (bounds of pc: spreadProbFloor + 0.01 to spreadProbCeiling). Not combined with climateRef.
The tangled vignettes/*.R and rendered vignettes/*.html files are no longer tracked (and are git-ignored): R CMD build regenerates them from the .Rmd. The tracked Firesense_LCC_flammability.R was the live-download version from before the vignette moved to fixtures (96b1014), so R CMD check --no-build-vignettes (which sources the existing .R instead of re-tangling) ran the old code and failed in sf::st_crop() ("Unexpected EOF parsing WKB"); CI builds vignettes (R CMD check then skips running the tangled code), so it never saw it. The same step showed that the non-running fireSense-tutorial.Rmd only set eval = FALSE globally in its setup chunk, which knitr 1.52 does not apply when tangling, so a fresh tangle ran its code; every chunk now says eval = FALSE itself.
New optional spread link with climate outside the maxAsymptote ceiling, off by default. spreadProbFromCovs() (now exported) is the one place the spread probability is computed from the covariates, for the objective, its gates, the validation and fireSense_spreadPredict. With climateRef = NULL (default) it is logisticAll(), bit-identical to before: climate is a covariate inside the logistic, under the ceiling, so where fuel puts a pixel near maxAsymptote a dry year adds almost nothing (hindcasts 2026-10: 59-77% of pixels within 0.01 of the ceiling every year). With climateRef a named numeric (one reference value per climate covariate, from the new spreadClimateRef(): their means over the fit's pixel-years, on the design scale), p0 = f + (maxAsymptote - f) * plogis(x_fuel) is the logistic of every other covariate and p = plogis(qlogis(p0) + beta_clim * (climate - climateRef)), uncapped; the year effect eps_Y is added on the same logit afterwards. .objfunSpreadFit(), runDEoptim() (fit, re-score, profile and simulations), spreadProbGates() and spreadFitValidationData() gain climateRef = NULL; NULL leaves every call and cache key as it was. With climateRef, the objective's gates and its saturation summary (linkSaturation()) are applied to p0, the spread probability at the reference climate, since under this link a dry year legitimately takes the landscape past maxFireSpread. New spreadClimateCols() (the formula's annual, non-indicator covariates) and constants spreadLinkTxt, spreadFitClimateRefTxt (the ledger columns fireSense_spreadFit writes for such a fit) and spreadLinkOptions. spreadCovCentre() and spreadClimateRef() share one loop (spreadCovMeans()). objFunInner()'s body changes, so fits cached before this re-key once (the module keys on it).
hindcastObserved() also returns points, the natural ignition points inside the core that counts$ignitions counts (CRS of the core, with escaped), so the hindcast can write the observed ignition locations. They come from ELFfireCounts(), which has a new argument returnPoints (default FALSE, result unchanged) that also returns the counted points. Used with PredictiveEcology/fireSense_summary (hindcast ignition outputs).
spreadFitFilenameFor() and ignitionFitFilenameFor() gain suffix (default ""), put after the model tag, and latestSpreadFits() and latestIgnitionFits() gain suffix too: they read only files with that suffix. A test run with fitFileSuffix = "_boundfix_rep81" (new parameter of fireSense_spreadFit and fireSense_ignitionFit) therefore writes and reads its own ledger files and never the production ones; "" is today's behaviour. Before, the ignition ledger had no suffix, so test fits were written to the production file and served by "latest".
New exported xgbObjective(), the objective an xgboost model (or each booster of a list, e.g. the folds of a fit) was fitted with, read with xgboost::xgb.config() and then attr(, "params"); xgboost >= 3 boosters have no $params. fireSense_ignitionFit and fireSense_ignitionPredict each had their own copy. New constant ignitionObjectiveTxt ("count:poisson"), the objective the ignition and escape models are fitted with.
runDEoptim() gains convergence (default NULL), the DEoptim early-stop settings (window, minGenerations, se, sdChange), passed to clusters::DEoptimIterative(). When not NULL it is part of the fit's cache key, and clusters (>= 0.0.80) keeps it out of the per-generation keys, so a fit that stopped early continues from its cached generations under a stricter rule. NULL leaves the key unchanged. Depends on PredictiveEcology/clusters#55.
latestSpreadFits() and latestIgnitionFits() no longer fail with "ALTLIST classes must provide a Set_elt method" when the latest fits come from more than one ledger file. The rows were combined with rbindlist(), which assigns into the list columns; the xgboost models in the ignition ledger, once read back, are ALTREP lists that cannot be assigned into. The rows are now combined with rbind() of data.frames, which leaves the models untouched.
spreadProbFromIntegerCovs() now reads, for a fire year, the vegetation table of the latest data year strictly before it, the rule fireSense_dataPrepFit builds the tables with; it took the last table not after the fire year, so a fire year equal to a data year (1990, 1995, ...) found most buffer pixels missing and the spread objective failed with a quantile() error in the first DEoptim generation. It now stops, naming the fire year and the table, if any pixel has no covariates. New exported dataYearBefore() (in ?dataYearFor) is the strictly-before rule, and dataYearElement(before = TRUE) names the list element it picks; dataYearFor() keeps "not after" for prediction and forecast. indexNonAnnual is no longer used; nonAnnualDTx1000 must be named by data year.
defaultFireYears() starts in 1986, not 1985. SCANFI vegetation of year Y already shows year Y's burns, so fireSense_dataPrepFit now fits each fire year against the latest data year strictly before it, and 1985 (the first SCANFI year) has none. fireSense_ELFs counts fires to merge ELFs over the same years, so its counts no longer include 1985. Used with PredictiveEcology/fireSense_dataPrepFit#74.
New exported resolveDoFit() and mustFit(): the one place that decides whether a FireSense fit module (fireSense_spreadFit, fireSense_ignitionFit) fits, from its three-valued parameter doFit (NA: fit only if the ledger has no usable fit; TRUE: always; FALSE: never, stop if none) and the deprecated aliases refitExisting and stopIfNoPreRunFit.
runDEoptim()'s default population (nCoresNeeded = NULL, also when a caller passes NULL) is the new exported deoptimPopulationSize(length(lower)): 20 + 10 * floor((n + 2) / 5), i.e. 30 for 4-7 parameters, 40 for 8-12 and 50 for 13-15, instead of 10 per parameter. Tests of 10 per parameter against other sizes found 40 a good middle with little gain above; ledgers have 10-12 spread parameters, so every current ELF gets 40, as production already passes. nCoresNeeded is resolved to an integer before the cluster is built, so the fit's Cache() key (which sees the cluster control, not nCoresNeeded) is that of an explicit 40L.
ELFtemplateRaster() has a new url argument, by default the SCANFI v2 Douglas-fir 2020 file on Arbutus (same extent, 30 m resolution and CRS as the v1.2 file it replaced; 163 MB vs 226 MB), and returns a 1/NA mask of where the aggregated source has data instead of Douglas-fir cover. The Cache() key includes url. Breaking: at fact = 8 v1.2 has 156,026,794 non-NA cells and v2 156,482,565 (460,121 cells NA only in v1.2, 4,350 only in v2), so ELF maps and rasterToMatch change by about 0.3% of cells; fireSense_ELFs rebuilds them once.
New exported spreadFitLedgerFolder() and ignitionFitLedgerFolder(), the Google Drive folder of the shared fit ledgers. Set options(fireSenseUtils.spreadFitLedgerFolder = ) (and fireSenseUtils.ignitionFitLedgerFolder, which follows the spread option when unset) to use another folder; the modules' *GoogleDriveFolder defaults and the cloudFolderID default of latestSpreadFits() and latestIgnitionFits() take it from these.
latestSpreadFits(), latestIgnitionFits() and fireSenseCloudParameters() record each Google Drive file they read in reproducible's download ledger (prepInputsLog(), and urlLog(sim) in a SpaDES run) with reproducible::logUrlAccess(); before, a ledger file that was already on disk was read without any record, and fireSenseCloudParameters() was never recorded. Needs reproducible >= 3.2.1.9072. Depends-on: PredictiveEcology/reproducible#647.
makeFireSenseLCC() no longer passes writeTo to the land-cover builder. The builder wrote its native-resolution raster to file.path(destinationPath, writeTo), the path of the final result, and the function read it block by block; a second job sharing destinationPath (hindcast years of one ELF) that restored the cached final file meanwhile gave "[classify] too few values for writing: 0 < N". Only the final land cover and _propFlam files are written to writeTo; results are unchanged. Remaining risk: the final writeRaster(overwrite = TRUE) into a shared folder can still collide with a concurrent cache restore of the same file.
runELFs() runs the ELFs module without a Cache() wrapper, and no longer reads the remote hash of urlELFresults, sets hashSpreadFitRemoteFile, or uploads ELF_polygons.png to Google Drive. fireSense_ELFs now caches the national ELF list itself, keyed on its ELFversion parameter alone and shared through the cloud cache, so the run is cheap. .elfOutputFile(), the drive_update and SpaDES.project imports are removed; urlELFresults is kept in the signature but unused. Depends-on: PredictiveEcology/fireSense_ELFs#29.
New dataYearFor() and dataYearElement(): the one rule for matching a year to a vegetation or land-cover data year (the data year closest to, and not after, it), for fireSense_dataPrepFit (fire years, the fit's structure decisions) and fireSense_dataPrepPredict (the vegetation of time(sim)), so a fit and every prediction read the same data for the same year.
climateRasterToDataTable() merges any number of climate variables. With three or more it stopped with "by.x and by.y must be of same length" (the third table was matched to by.x), which broke the ignition refit of ELFs whose spread variables, with their neighbours', number three or more.
cohortsToFuelClasses() treats a fuel class of NA as "not a fuel". A species with no cohorts at fit time has FuelClass = NA in the spread ledger, and fireSense_dataPrepPredict passes that in requiredFuelClasses; the result had a layer named NA, and fireSenseCovariatesCreate() stopped with ".SDcols missing at the following indices" (ELFs 6.5 and 6.3.2 failed every prediction). Species with an NA class are dropped like species absent from sppEquiv; results without NA are unchanged.
Breaking: ignitionFitFileTag is now "_xgboostPoisson_20261013" (xgboost Poisson ignition and escape fits, count:poisson). ignitionFitFilenameFor() writes, and latestIgnitionFits() reads, only fireSenseIgnitionParams_<years>_xgboostPoisson_20261013.rds; the Tweedie ledgers tagged "_xgboost" are no longer read, so "latest" cannot reuse their rows. Likewise spreadFitFileTag is now "_linearFuel_esc50_20260925" (same model, dated 2026-09-25 when it started), so the undated "_linearFuel_esc50" files are no longer read; the production ledger was copied to the new name. Suffixed experiment files (..._20260925_repStab_rep3.rds) stay excluded from "latest".
New hindcastObserved(), hindcastSimulated() and plotHindcastDistributions(), plotHindcastSizes(), plotHindcastMap() for the hindcast validation. hindcastObserved() gives the natural-cause ignitions, escapes (escapedFires()), core-clipped area burned (natural-cause and all-cause polygons, from ELFfireCounts() and ELFfireOverlay()) and fire sizes of one year in an ELF core; hindcastSimulated() gives the core-clipped sizes of a simulated year's fires; the plots set the simulated rep-years beside the observed year.
Per-year prediction is faster with identical results. .fuelClassVectors() (used by cohortsToFuelClasses()) no longer joins sppEquiv onto every cohort row, and takes one grouped max and one grouped sum on an integer key instead of a join, split() and a match() per class (4.4 to 1.6 s on 10M cohort rows). prepare_ignitionClimate() and fireSenseCovariatesCreate() compute the .robustDigest() for the cache key only when useCache is not FALSE.
fireSenseCloudParametersMap() no longer calls makeELFs() a second time for an object it never used. plotELFs(labels = "name") is tested on merged ELFs (11.1_2, 3.1.1_2, 10.3.1_2): they already take their ecozone name.
runELFs() uploads only ELF_polygons.png to Google Drive. .elfOutputFile() matched every file with "ELF" in its path, so once fireSense_ELFs also saved ELFs_postMerge.png the upload failed with two files; it now returns exactly ELF_polygons.png and stops on none or several.
makeELFs() no longer depends on things outside its arguments. It reads the ecostratification as a SpatVector whatever reproducible.shapefileRead is (an sf gave a 4999.933 x 5001.110 m grid instead of 5000 m), and mergeAndSplitRas() sets its own RNG for the random split of oversized ecoprovinces, so the ELF maps no longer change with the caller's seed, RNG kind or cache hits. The seed reproduces the ELFs used since 2026-10-05. New suggested package digest (tests).
New ELFfireOverlay() clips the fire polygons to every ELF once (fitting area and core); ELFfireCounts() and ELFregionStats() read it instead of each intersecting all polygons (the NBAC polygons have about 98 million vertices, and each pass took 20 minutes or more). Only polygons crossing a zone's edge are intersected; the others keep their own area. Counts and burn rates are unchanged (burn rate to 4e-16).
fewFireELFs(), combinePlans(), fireRecordShapefile() and sizeRuleLandCover() move here from fireSense_ELFs, exported, so the plan code has one copy. fewFireELFs() reads the NFDB and NBAC archives with reproducible::prepInputs() (cached on the release, fireYears and the study area) and caches the clipped-polygon table on the ELF maps, the NBAC release and fireYears, so a change to the plan rules re-runs in seconds. It takes nfdbUrl, nbacUrl and destinationPath instead of shapefile paths. New import fireregimetools.
plotELFs() draws the ELFs after the merges: new plan (e.g. sim$ELFmerges; computed with fewFireELFs() if NULL), merged ELFs under their merged names, fewFires (default: the plan's skipped ELFs) filled with fewFiresFill, a legend, and ELFs to pass in the ELF maps.
cohortsToFuelClasses() gives a required fuel class with no cohorts 0 on every flammable pixel, as it does a class that has cohorts. It was NA on flammable pixels with no pixelGroup, so fireSenseCovariatesCreate() stopped with "NA in vegData columns" when such a pixel was in no non-forest land-cover group (ELF 14.3, Betu_pap with no 2020 cohorts, 33 pixels).
New ELFregionStats() (core area, land-cover shares and burn rate of each region) and ELFsizePlan(): regions whose core is smaller than minAreaKm2 merge with a neighbour of the same group (default: the ELF base) whose land-cover distance is at most maxLandCoverDist and burn-rate ratio at most maxBurnRatio. Each step takes the merge that leaves the fewest small regions without a partner, so the most similar pair is passed over when it would strand a third region; it repeats until no candidate is left. The plan is in ELFmergePlan()'s format and is applied by mergeELFs().
ELFs with many small fires but almost no large ones are now merged like other thin ELFs. The escape and spread fits use only escaped fires (natural ignitions of at least escapeSizeHa and larger than a pixel), but the gate counted all ignitions, so ELF 7.3 passed it and then failed the escape fit ("Too few escapes to fit 5 cross-validation folds: 1 positive observation(s)"). ELFfireCounts() has escapeSizeHa = 50 and a new escapes column, ELFfitStatus() and ELFmergePlan() have minEscapes = 5 (an ELF with fewer escapes is "few", and a merged ELF must reach it), and ELFmergePlan() returns escapes. ELFfitStatus() and ELFmergePlan() need the escapes column. escapedFires() moves here from fireSense_dataPrepFit and is exported.
runDEoptim() passes runName to clusters::clusterSetup(), which uses it to place workers by the memory per worker this fit used last time, and sets options(clusters.workerMemoryGB) to the new spreadFitWorkerMemoryGB (14 GB, measured 2026-10-09) for the fit unless the user set it, so a fit's first cluster is capped by free memory before it has a record. Needs clusters >= 0.0.75 (PredictiveEcology/clusters#47).
New defaultFireYears(): 1985 to the latest year with historical climate (1985L:climateData::latestHistoricalYear()). fireSense_dataPrepFit and fireSense_ELFs take their fireYears default from it, so the ELF merging and the fit count the same years. climateData is now an import.
runDEoptim() has a progressFile argument, passed to clusters::DEoptimIterative(), which writes a CSV of each generation's progress and a FINISHED row. NULL (default) is DEoptimProgress_<runName>.csv in visualizeDEoptim; FALSE writes none. It is left out of the cache key. Needs clusters >= 0.0.73 (PredictiveEcology/clusters, branch feat/deoptim-progress-file).
plotELFs() gets labels ("code" default, "name" for the ecozone name, "none"), labelWhich ("all" default, or "highlighted" to label only the which ELFs), buffers (FALSE draws the ELF cores without the buffer rings) and axes ("m" default, "longlat" for a graticule in degrees on the same projection, "none"). Overlapping labels are moved apart, with a leader line. New exported elfLabels() gives the label text. The ELF codes are ecoprovince codes, which carry no names in the source shapefile, so "name" is the ecozone name (the part before the first .); two ELFs in one zone get their code added. The defaults reproduce the previous plot.
mergePreparedCovs() no longer fails when a fuel class is present in some data years and absent in others (ELF 11.2: Popu_tre only in 2020, "Item 5 has 14 columns, inconsistent with item 1 which has 13 columns"). The year tables are bound with fill = TRUE and the classes missing from a year get 0 cover; other NAs (climate) are untouched.
bufferToArea() is much faster for large fires. Each buffer grew one ring per iteration by re-spreading every cell of every fire over the whole grid (SpaDES.tools::spread2()); it now expands only from the newest ring, so the work per iteration no longer grows with the buffer. Every fire keeps its size, and a fire whose target ends on a complete ring (or that fills the landscape) keeps its exact cells. The cells of a last, partial ring, and a cell that two fires reach in the same iteration, are still drawn at random, with a different random stream, so those cells can differ between runs of the old and new code for the same seed.
bufferToArea() keeps a fire whose buffer cannot reach its target size. Such a fire (every large fire when areaMultiplier is 10 * multiplier(size)) never outgrew its target within the old limit of 100 iterations, or ran out of landscape first, and was dropped without a message, so spread fits ignored the largest fires (ELF 5.4 lost its 7 largest, 4.5-75 kha). It now gets the largest buffer the raster allows, up to the raster's size in iterations. harmonizeFireData() counts removed fires from the fire polygons, so a fire lost in the buffer step shows in its message. Buffers of fires that reached their target are unchanged.
New runawayBufferMultiple in .objfunSpreadFit() and runDEoptim() (fit, re-score, profile and simulation diagnostics): with penaliseRunaways = TRUE, a runaway replicate counts in the size-based terms ("adTest", yearAreaWeight, areaDistWeight, "mad") as this multiple of its own fire's buffer size, not the size it burned up to the early stop. A full-landscape hindcast of ELF 5.4 showed fits that matched observed area inside the buffers ran away on the whole landscape. NULL (default) keeps the previous behaviour; giving both it and runawaySize is an error.
Covariate-consistency changes for the spread model. Fits change, and cached fits re-key.
climateCovRanges: one table of fixed climate ranges (units documented; cumMDC is a unitless index). The values stay 0-100 and are provisional. fireSense_spreadFit uses it as its default covFixedRange and stops on a climate covariate that has no entry.climateRasterToDataTable() no longer rounds to whole numbers; the x1000 integer storage is the only rounding, as in fireSense_spreadPredict, so a climate value is the same stored integer in the fit and the prediction.assessFuelClasses(): missingForest is matched to its group by exact code (10 no longer matches a group holding 100). The k-means of the non-forest classes is seeded from a digest of the coefficients it clusters (withr::with_seed(), global RNG untouched), so identical data give identical groups. No other step of the fuel-class estimation is random.spreadIndicatorCols() and spreadIndicatorRanges(): youngAge, nfLCC_* and the species-mode treedWetland are 0/1 and always get the range c(0, 1), so a constant one no longer rescales by 0 / 0. youngAgeExclusiveCols() uses the same definition.emptySpreadCovariates(): all-zero covariates, and fuel columns that sit on the logMinB() floor everywhere, are empty (a fuel class with no biomass was never dropped).spreadProbCeiling (0.276) and spreadProbFloor (0.13) are the one ceiling and floor of maxAsymptote/maxFireSpread and lowerSpreadProb; the default maxFireSpread was 0.28.maxAsymptote is the ceiling in a typical year; the year random effect acts on the logit after the link, so a year's p can pass it.Optional free intercept with centred covariates in the spread model, off by default. A spread formula with an intercept (~ 1 + ...) adds a coefficient named spreadInterceptTxt ("(Intercept)"), first among the covariate coefficients, multiplying a column of 1s that is never rescaled or centred (spreadDesignCols(), spreadCovCols()); .objfunSpreadFit(), spreadProbGates(), spreadFitValidationData() and plotSpreadFitResponse() all build the linear predictor from it. spreadCovCentre() gives the covariate means to pass as covCentre, and runDEoptim(covCentre =) now passes it to the fit, the final re-score and the profile and simulation diagnostics. spreadFitCovCentreTxt ("covCentre_spread") names the ledger column that holds it. With a ~ 0 + ... formula and covCentre = NULL nothing changes, and omitNullArgs() keeps the cache key of runDEoptim()'s calls as it was. A formula such as ~ a now has an intercept, as stats::terms() says, where it used to be dropped silently.
inflectionPoint1 (the spread link's Richards exponent) is fixed at 1, not fitted, as hillSlope1 already is, so the link is the plain logistic par1 + (maxAsymptote - par1) * plogis(x). In the 2026-10-04 fits its best values were bimodal (11 of 29 at the lower bound 0.1, 11 at 2-4), it spanned 0.1-3.9 inside a final population while the objective hardly changed, and the coefficients explained a median 50% of its variance (up to 90%). The internal fixHillSlope1() is now fixLogisticPars(): .objfunSpreadFit(), spreadFitValidationData() and spreadProbGates() insert hillSlope1 = 1 and inflectionPoint1 = 1 right after maxAsymptote, so par, lower and upper have maxAsymptote, then upperTail1 if used, then the covariates. A named par that already has either (a stored full parameter set) is not given a second one, fixedLogisticPars (new, exported) holds the two fixed values; logisticAll() and logisticParamNames are unchanged. Callers must drop inflectionPoint1 from lower/upper; fits made with it need redoing.
lccFlammableShare() gives each class's share of an ELF's flammable pixels. assessFuelClasses(lccShare =, minCovariateProp = 0.05) leaves a non-forest class below that share out of the k-means and puts it in the group of the class with the nearest burn coefficient, as missingForest already was; if every non-forest class is that rare they form one group. fireSenseCovariatesCreate(treedWetland = FALSE) makes no treed-wetland covariate and leaves that tree AGB in the fuel columns. The defaults keep the previous behaviour. Treed wetland is under 5% of the flammable pixels in 7 of the 10 fitted ELFs (13.1, 14.3, 14.4, 4.1, 5.2.1, 5.3.1, 5.3.2).runDEoptim(): the final-population rescore, profile and simulations use clusters::currentCluster(). Rebalancing during the fit stops the workers it moves, so they ran on closed connections and failed with "invalid connection" after the fit converged..objfunSpreadFit(): a runaway replicate (penaliseRunaways = TRUE) is censored in the per-fire size likelihood only. The AD, year-area, area-distribution and MAD terms now score the size it burned; runawaySize no longer defaults to the landscape's non-NA pixel count and applies only when given. With that default, one runaway replicate (typically 1-3 times its observed fire's size) counted as burning the whole landscape, so fits chose parameters that rarely reach a buffer edge and under-burned: held-out simulated/observed area 1.33 -> 0.54 over 10 ELFs (2026-10-01). Fits made under the old default should be redone.ignitionFitFilenameFor() and latestIgnitionFits(), parallel to spreadFitFilenameFor()/
latestSpreadFits(): name and read the shared, geo-keyed ledger of fireSense_IgnitionFit
fits (ignition and escape together), so fireSense_ELFs can assemble per-ELF fit lists for a
multi-ELF study area the same way it does for spread fits.ignitionFitAdditionalColNamesTxt ("fireSense_IgnitionFitted",
"fireSense_EscapeFitted"): the list-column names of an ignition-fit ledger row.k distinct pixels of its fire's buffer edge ring, k = max(runawayEdgeMin, ceiling(runawayEdgeFrac * ring size)) (defaults 0.01 and 3; k is at most the ring size), not when it touches one. New arguments runawayEdgeFrac and runawayEdgeMin of .objfunSpreadFit() and runDEoptim(); censoring and runawaySize are unchanged. With penaliseRunaways = TRUE (and not returnSims), a fire now also stops burning once it is a runaway: the objective passes each fire's edge-ring cells and the same k to SpaDES.tools::spreadCpp(stopCells, stopEvent, stopAt), since nothing it burns afterwards is scored. Needs SpaDES.tools >= 2.1.3.9013.spreadProbGates() tells, without running any spread, whether a parameter set (or a list of them) passes the objective's spreadProb gates ("Too burny a landscape", "Not spread out enough", median out of range) in the first block of years. The gate test itself is now spreadProbGateTest(), which .objfunSpreadFit() calls, so the objective and the screen cannot disagree; the objective's results are unchanged. fireSense_spreadFit uses it to draw its threshold-calibration trials from the logistic's active range..objfunSpreadFit() no longer caps a simulated fire at multiplier() of its observed size; spread is bounded only by the year's buffers. A fire is a runaway, and censored as the cap-hit rule did, when it burns any pixel of the edge ring of its own buffer: a buffer pixel with a queen neighbour outside that fire's buffer (another fire's buffer, a removed non-flammable pixel, NA, the raster edge). penaliseRunaways = TRUE (default) replaces penaliseCapHits; capSizes and penaliseCapHits are deprecated and ignored with a warning, and runDEoptim() takes penaliseRunaways. The SNLL log line reports the runaway share. Without the cap the first-block SNLL has a different scale, so thresh values calibrated before this change will differ, and a calibration must use this objective. spreadFitValidationData(capSizes =) is removed (validation never capped).bufferEdge() and addBufferEdge() compute the edge ring. runDEoptim() adds an edge column to fireBufferedListDT once, before the tables go to the workers (about 0.09 s per 90,000 buffer pixels; the check per evaluation is about 8 ms for 200,000 burned pixels); a table without the column gets it computed on every call..objfunSpreadFit(returnTerms = TRUE) also returns firstBlockSNLL, the first block's SNLL per year as thresh is compared with it, and bailed (1/0), whether a year of that block was refused as "too burny" or "not spread out enough" or the block was above the threshold. Both are available with thresh = Inf.cohortsToFuelClasses() builds the fuel classes on cell vectors instead of one SpatRaster per class, and has asTable = TRUE to return the pixelID table that fireSenseCovariatesCreate() makes from the stack, so that function no longer builds the rasters and converts them back (about 6 s of its 10 s on 6.7M cells). The raster and the table are identical to before.fireSenseCovariatesCreate() takes fuelClassTable, the result of cohortsToFuelClasses(..., asTable = TRUE), so a caller making the "species" and "domSecWetland" covariates from the same cohorts builds the fuel classes once. The table is copied, not modified.isYoungAge(age, cutoffForYoungAge) is the one place the youngAge rule lives (age <= cutoffForYoungAge, NA not young); makeTSD(), castCohortData(), cohortsToFuelClasses(), youngAgeAtYear(), calcYoungAge(), calcNonForestYoungAge() and fireSenseCovariatesCreate() all call it. calcNonForestYoungAge() used <, so prediction differed from fitting for non-forest pixels whose age equals the cutoff. castCohortData() now gives youngAge = 0 (was NA) for an NA stand age, and the deprecated calcYoungAge() no longer counts an NA age as young.cohortsToFuelClasses() no longer fails when terra is not attached: as.int is now imported from terra..objfunSpreadFit() treats a simulated fire that reaches its cap (multiplier() of the observed size) as censored, a runaway, instead of a fire of the capped size. Its size becomes runawaySize (default: the landscape's non-NA pixels) in the adTest, annual-area and mad terms, and in the per-fire size likelihood it has no density at the observed size (the likelihood is that of the other replicates times their share). penaliseCapHits = FALSE restores the old scoring; capSizes = FALSE and returnSims are unchanged. The per-fire likelihood is still floored at minLik, and thresh values calibrated before this change will differ. The SNLL log line now reports the capHit share. runDEoptim() takes penaliseCapHits (default TRUE) and passes it to the fit and the re-score; it changes the DEoptim cache key.youngAge can be resolved per fire year again. New youngAgeAtYear() gives it for any set of pixels: time since disturbance at the data year, aged to the fire year and reset by every fire since (all fires, not only the fitted buffers); young is <= cutoffForYoungAge and NA time since disturbance is never young (the old calcYoungAge() called it young). firePixelsByYear() makes its per-year fire pixel lists from polygons or a fire-year raster. calcYoungAge() is deprecated.fireSenseCovariatesCreate() has youngAge = TRUE. With FALSE it builds no youngAge column and zeroes no fuel, non-forest land cover or treed wetland; the default keeps the previous behaviour.prepare_FuelCovsCoarseByYear() builds the coarse ignition fuel rasters with youngAge (and the clearing it implies) per fire year; stackAndExtract() and mergePreparedCovs() accept these per-year lists. Fits made with youngAge fixed at the data year need refitting.other_agb spread covariate is removed, and fuelCovariates = "domSecOther" is renamed "domSecWetland": fireSenseCovariatesCreate() now builds only dom_agb_<class>, sec_agb_<class> and (with rstLCC) treedWetland_agb. treedWetland_agb still holds all tree AGB on treed-wetland pixels, including classes that are neither dom nor sec. Spread fits made with other_agb need refitting. collapseFuelClassesToDomSecOther() is now collapseFuelClassesToDomSec().latestSpreadFits() downloads a ledger file with reproducible::preProcess() (into a temporary folder, then placed in destinationPath) instead of googledrive::drive_download(), which wrote the file in place: a second job on the same ELF, sharing destinationPath, could find it missing or half-written. A local file whose MD5 differs from Drive's is fetched again with purge = 7.runELFs() finds fireSense_ELFs among the project's modules or the children of any listed parent module
(e.g. PredictiveEcology/fireSense@development), through the new internal .findModuleInProject(). It used
grep("ELFs", modules), which matched nothing once a parent was listed, so no module ran and the Drive upload
failed with "missing value where TRUE/FALSE needed". runELFs() now stops naming the modules searched when there
is no ELFs module, and stops with "No ELF output file" before drive_update() when the run saved none. Needs
SpaDES.core (>= 3.2.1.9027) for moduleMetadata(defineModuleListItems = "childModules").runDEoptim()'s default logPath is named runDEoptim_<time>_*.log (was fireSense_SpreadFit_<time>_*.log),
so no package code names a module; the module passes its own logPath. Documentation and the tutorial use the
new module names (fireSense_spreadFit, fireSense_ignitionFit, fireSense_spreadPredict,
fireSense_ignitionPredict).runDEoptim() names the fitted parameters from names(lower), grouped as logistic parameters,
formula covariates and the year effect, e.g. "Fitting 11 parameters: logistic: maxAsymptote,
inflectionPoint1; covariates: CMD, youngAge, ...; year effect: yearSpreadSD". It called
termsInDEoptim(), which counted every non-formula parameter as a "logit" term and so reported
yearSpreadSD as a third logistic parameter. Requires clusters >= 0.0.54, whose progress figures are
labelled from names(lower) too.termsInDEoptim() is deprecated and will be removed in the next release.runDEoptim()'s cache of the whole fit now includes the data the objective runs on (the digest
clusterSetup() makes of the objects it ships to the workers). Before, two fits that differed only in
that data, such as the two held-out folds of an ELF, shared one fit; clusters 0.0.53 fixes the same gap in
the per-generation cache. Calls clusters::DEoptimIterative(), the new name of DEoptimIterative2().runDEoptim() gains plotEvery (default 25), passed to clusters::DEoptimIterative2() (exported
since clusters 0.0.51; it was called with :::). The DEoptim progress figures are drawn every
plotEvery generations and at the end, not after every generation, which took 16% of a fit's wall
time. plotEvery is left out of the fit's cache key, so changing it does not refit. Needs clusters
= 0.0.52.
spreadFitValidationData(), plotSpreadFitValidation() and plotSpreadFitResponse()
(?spreadFitValidation). A response curve of logit(p) against a covariate looks right by
construction, because its points are the model's own predictions. These compare the fit with the
data instead: the share of pixel-years that burned against the share that burned in the fit's own
simulations, binned by each covariate. The response curves are kept as a second figure, titled as
the model's response. Both plot functions take the data first and return a ggplot, for
SpaDES.core::Plots()..objfunSpreadFit(returnBurned = TRUE) records the pixels burned in each simulated
replicate (attr(, "burned")). Off by default; the objective's value is unchanged.fireSense_dataPrepFit and fireSense_dataPrepPredict
(fireSenseForestedLCC, fireSenseYoungAgeCutoff, fireSenseNonForestCanBeYoungAge,
fireSenseFlammabilityThreshold, fireSenseFuelClassCol, fireSenseIgAggFactor,
fireSenseSCANFIVersion; see ?fireSenseSharedDefaults). The modules take their parameter
defaults from them, so a fit and its predictions cannot silently use different values. The
functions here with the same arguments (makeTSD(), calcYoungAge(), castCohortData(),
cohortsToFuelClasses(), makeFireSenseLCC()) default to them too. Values are unchanged.makeFireSenseLCC() gains scanfiVersion (default "V3", SCANFI's annual 1985-2025
land cover), forwarded to LandR::prepInputs_SCANFI_LCC_FAO(dataVersion = ). LandR's own
dataVersion check silently treats anything other than "V1" as "V2", so makeFireSenseLCC()
now checks the installed LandR for "V3" support itself and stops with a clear message instead
of building V2 land cover under a "V3" request.assessFuelClasses()/makeLandcoverDT() already group whatever
non-forest codes are handed to them, so an unrecognized flammable non-forest code is kept and
clustered like any other, not dropped. Added a test confirming this for a synthetic code.30, from LandR::convert_SCANFI_LCC_codes())
was missing from makeFireSenseLCC()'s and ELFflammableArea()'s nonflammableLCC default,
so rock entered fits as flammable non-forest. New exported constant fireSenseNonflammableLCC
is the single source of truth for the non-flammable codes of the FireSense land cover;
makeFireSenseLCC() and ELFflammableArea() now default to it.compareClimate(), compareMDC(), burnClassGenerator(), burnClassSummary(), burnClassPredict(), burnProbFromClass(), bufferIgnitionPoints(), and the old ignition-fit objective functions pw(), oom(), extractSpecial(), .objFunIgnition(), .objFunIgnitionPW(), objNlminb(). mclust and pROC are no longer imported. To use one, install fireSenseUtils 0.2.3.9058.getFirePolygons() gave POLY_HA in m2, not ha, for sf polygons (sf::st_area() has no unit argument).rescaleCovariates() failed for non-xgboost models (inRange() was not imported).stackAndExtract() gave NA ignitions, not 0, when fires is NULL.fireSenseCovariatesCreate(fuelCovariates = "domSecOther")
collapses the per-species fuel-class columns to exactly four AGB terms, dom_agb_<class> and
sec_agb_<class> (the ELF's two fuel classes with the most total treed AGB), other_agb (the
rest, pooled) and treedWetland_agb (all tree AGB on treed-wetland pixels, removed from the
other three there). New chooseDomSecFuelClasses() picks domClass/secClass once per ELF, so
a prediction can be told the same classes the fit used instead of re-choosing them from its own
area. The default remains fuelCovariates = "species" (the previous per-species columns),
unchanged.treedWetlandAgbTxt ("treedWetland_agb").DEoptimIterative(): unused since runDEoptim() switched to clusters:::DEoptimIterative2(). Its plotting helpers go with it; DEoptim moves to Suggests.ELFsInStudyArea() failed when the study area overlapped a single ELF.ELFsInStudyArea() labels a buffer cell shared by several ELFs with the ELF whose core is nearest, rather than the one listed last.harmonizeFireData() lost the fire polygons of later years when a whole year was dropped for lying outside the study area.harmonizeFireDataDeps(): the functions harmonizeFireData() calls, for a cached call's .cacheExtra.cleanUpSpreadFirePoints() matched points by row number instead of fire ID, so ignition points on non-flammable pixels were never moved and fires with no flammable pixel were never dropped.hillSlope1 (the spread link's slope) is fixed at 1, not fitted. With the linear predictor
x = covariates %*% beta, hillSlope1 enters logistic3p()/logistic3pUpper() only as
hillSlope1 * x, so scaling every covariate coefficient by k and dividing hillSlope1 by k
leaves every prediction unchanged: it was never identifiable, and letting DEoptim fit it let every
coefficient drift along that ridge. .objfunSpreadFit() now calls the new internal
fixHillSlope1() on every par it receives, reinserting hillSlope1 = 1 as the 2nd logistic
parameter, so callers (fireSense_SpreadFit) no longer include it in lower/upper.makeMutuallyExclusive() recomputed the rows to zero from the key column after each
pattern, so once the key column itself matched one of its own patterns (as youngAge could) it
was zeroed and every later pattern zeroed nothing. Rows are now fixed once, before any zeroing,
and a pattern can no longer match the key column itself.fireSenseCovariatesCreate() applied makeMutuallyExclusive() before youngAge was
finalized for non-forest pixels, so those pixels kept their nfLCC_* land-cover value. The
exclusivity step now runs after youngAge is final, and also zeroes treedWetland: a burned bog
is no longer treated as "still wet" once it is young.youngAgeExclusiveCols(), exported, builds the youngAge mutually-exclusive column list
from a covariate name vector and a caller-supplied fuel-column list (fireSense_SpreadPredict
uses this; fireSense_SpreadFit derives its own list from its non-annual covariate table).plotELFs() gains which and fill, to fill the named ELFs, e.g. plotELFs("inputs", which = c("13.1", "4.1"), fill = "green").spreadFitFileTag is now "_linearFuel_esc50": fits made with the 50-ha escape and the new objective terms go
to a new ledger file (fireSenseParams_<years>_linearFuel_esc50.rds), and "latest" no longer reads the
_linearFuel files of the previous model. An ELF with only an old fit counts as unfitted and is fitted again..objfunSpreadFit() and runDEoptim() gain options, off by default:
jumpTries, jumpMeanDist: with escapeSizeHa, passed to SpaDES.tools::spreadCpp() so a fire stuck
below the escape size can jump to nearby burnable land.yearAreaWeight: scores each fit year's observed area burned against its simulated totals with the per-fire
size likelihood (yearAreaNLL()); a year the objective refused to simulate gets minLik. "auto" weights
it equally with the per-fire term.areaDistWeight: compares simulated and observed sizes by the share of area burned they make up
(areaWeightedCvM()), which weights the large fires. "auto" uses the AD term's weight.returnTerms = TRUE returns each term separately.escapeSizeHa, a fire's size cap is raised to the escape size where it was smaller, as spreadCpp()
needs minSize <= maxSize.adTest change.escapeSizeHa in .objfunSpreadFit() and runDEoptim() (default NULL, the old behaviour). When
set, the spread model is fitted as "given the fire escaped": only observed fires of at least
escapeSizePixels(escapeSizeHa, landscape) pixels are fitted, and every simulated fire burns its first that
many cells regardless of spread probability (SpaDES.tools::spreadCpp(minSize =)) before spreading normally.
Before, an escaped fire was any fire over 1 pixel, and 11-16% of simulated fires never left their first pixel.
New exported helper escapeSizePixels(), so fireSense uses the same threshold.
Needs SpaDES.tools >= 2.1.3.9009 (spreadCpp(minSize =)).latestSpreadFits() reads the shared SpreadFit ledger when a module's spreadFitFilename is "latest": for
each polygon, its rows from the most recently modified fireSenseParams_*_linearFuel.rds file in the Drive folder
that has it, reading newest first and stopping once the polygons asked for are found. Files without the
spreadFitFileTag ("_linearFuel") hold log-fuel fits and are never read. spreadFitFilenameFor(fireYears)
names the file a fit is written to.LandR >= 1.2.0.9022 (was >= 1.1.0.9066). The default land cover, SCANFI with wetland classes,
calls LandR::prepInputs_CWIM() and LandR::wetlandToLCC(), which arrived in 1.2.0.9022
(PredictiveEcology/LandR#228). With an older LandR the package installed and the run stopped at the land
cover instead.yearSpreadSD
(was fireSpreadSD; fitYearSpreadSD was fitFireSpreadSD). It is a seasonal departure: each replicate draws
one eps ~ N(0, yearSpreadSD^2) for the year, and every pixel of that year spreads with
plogis(qlogis(p) + eps), so all of a year's fires are bigger or smaller together. fireSense does the same in a
forecast. yearSpreadSD = 0 is the model without it, exactly.weighted = "sqrt") only shifted the whole distribution. With
fireSpreadSD as the last parameter, each fire draws eps ~ N(0, fireSpreadSD^2) per replicate and every pixel
of its buffer spreads with plogis(qlogis(p) + eps); spreadCpp() is unchanged. .objfunSpreadFit() uses it
when par names it (fitFireSpreadSD = NULL); runDEoptim() when lower names it. fireSpreadSD = 0 is the
previous model exactly.?fitDiagnostics), the checks made by hand on the phase-2 fits, as functions:
simulateFireSizes() (the observed fires simulated without the size cap or the "too burny" gate),
scoreFireSizes() (per-fire and per-year error, quantiles, AD), linkSaturation() (share of pixel-years at the
spread-probability ceiling), coefIdentifiability(), profileCoefficients() and identifiedInIsolation() (is
each covariate's coefficient pinned by the population, and does dropping it worsen the fit), fitConvergence()..objfunSpreadFit(returnSims = TRUE) returns the simulated fires instead of the objective; capSizes = FALSE
lifts the size cap. These replace the trace() used for held-out validation.runDEoptim() gains profileReps and simulateMembers (both off by default), which run the profile and the
uncapped simulations on the fit's workers after the re-score, as attr(DE, "profile") and attr(DE, "fitSims").logistic3pUpper(): logistic3p() with Stukel's (1988) generalized-logistic upper tail, one
extra parameter upperTail1. In logistic3p() the curve approaches its ceiling at a rate set by the slope alone,
so no parameter could change the upper end without moving everything else; in fitted models most spreadable
pixels sat pressed against the ceiling (72% of pixel-years in ELF 5.2.1). upperTail1 < 0 slows that approach,
> 0 speeds it, and 0 is logistic3p() exactly. logisticAll() chooses it when the logistic parameters
include upperTail1 or when link = "logistic3pUpper"; .objfunSpreadFit() and runDEoptim() pass link
through to the fit and its re-score. Checked against sirt::pgenlogis().runDEoptim() gains sizeLik, sizeLikDf, weighted and adWeight, passed to .objfunSpreadFit() both in
the fit and in the re-score of the final population. Before this, every fit and every re-score used the
objective's defaults whatever the caller wanted -- weighted = TRUE (a log(size) weight) with sizeLik = "kde".
The defaults here are the objective's, so a caller who sets nothing sees no change.SpaDES.tools::spreadCpp() instead of spread(). It follows the same rules
but makes its own random draws, so results change and fits made before this are not comparable
with fits made after. A full objective evaluation is 2.3-2.5x faster; end to end through DEoptim it saves
a median ~9.5 s per evaluation on both ELFs tested. Requires SpaDES.tools (>= 2.1.3.9008)..objfunSpreadFit() gains adWeight: what the "adTest" statistic is multiplied by before it is
added to the fire-size SNLL. It defaults to "auto", the new adWeightAuto() = c * sqrt(nFires),
which holds the AD term's share of the influence on the objective to 0.36-0.68 across six ELFs and
the four sizeLik x weighted combinations. This changes the default objective: it was a fixed
50, which gave 0.16-0.94 over the same range, balancing the two terms only for sizeLik = "kde"
with weighted = FALSE. Pass adWeight = 50 for the old behaviour.adStatistic() (0.2.3.9031) multiplied integer counts, which overflow past about 46,000 simulated
fires (e.g. 1,800 fires at Nreps = 50): the adTest term became NA with a warning, and DEoptim
stops on a non-finite objective. It now computes in doubles. (9032 and 9033 are #73 and #74.)weighted = TRUE did not weight. It computed log(lik * log(size)), which is
log(lik) + log(log(size)): an offset, under which every fire's likelihood moved the SNLL by the
same amount whatever its size. The weight now multiplies the log-likelihood. weighted takes
FALSE, TRUE or "log" (log(size)), or "sqrt" (sqrt(size)); weights are divided by their
mean over the fitted fires, so the SNLL keeps its scale. Objective values change when
weighted is not FALSE..objfunSpreadFit() and objFunInner() gain sizeLik and sizeLikDf. sizeLik = "t" takes a
fire's size likelihood from a Student-t on the square-root scale (mean and standard deviation of
its simulated sizes, sizeLikDf degrees of freedom, default 5) instead of their kernel density.
The kernel density is zero away from the simulated sizes, so a fire they never reach scores the
minLik floor however far off it is; the t has no floor and penalises a miss by its distance.
The default, "kde", is unchanged. (0.2.3.9031 is the adTest fix, #72.)"adTest" term of .objfunSpreadFit() compared observed fires with each simulated fire's
mean size over Nreps, which has a much shorter tail than single fires: on ELF 4.3 a model
compared with one of its own replicates scored 50 * AD = 3260. It now gets every replicate's
fire (42 on the same comparison). Objective values change.adStatistic() computes the Anderson-Darling statistic directly. kSamples::ad.test()
also standardises it, in time quadratic in the sample: 4 s per evaluation with the larger sample,
against 0.003 s. kSamples moves to Suggests.objFunInner(): the fire-size likelihood compared the observed size with the square root of the
simulated sizes (demp(x = size, obs = sqrt(N)), since 2021-02-10). Simulated fires are capped at
multiplier(size), so any fire above ~12 pixels scored the minLik floor whatever the parameters;
only the smallest fires informed a fit. Both are now square-rooted. Objective values change, so
the early-bail thresh of an existing fit no longer applies.fuelLogToLinear(), fuelLinearRange and isLinearFuelRange(): the spread model now takes fuel
biomass on the linear scale, divided by a fixed 1e4. fireSenseCovariatesCreate() still returns it
as logMinB(), because it also builds the ignition covariates and its output is cached for every
fitted polygon; fireSense_SpreadFit and fireSense_SpreadPredict both undo the log with
fuelLogToLinear(). fuelLinearRange (c(0, 1e4)) is the covMinMax that performs the division,
and isLinearFuelRange() recognises a linear fit from its stored covMinMax_spread, so parameters
fitted on the log scale keep predicting as before. (0.2.3.9028 is covCentre, #67.).objfunSpreadFit(), objFunInner() and spreadProbFromIntegerCovs() gain covCentre: values
subtracted from the rescaled covariates. NULL (default) changes nothing. Centring is applied after
the mutual-exclusivity step and the covariate range assertion.objFunInner() runs SpaDES.tools::spread() on the bounding box of each fire year's pixels
instead of the whole landscape (new internal cropToCells()). spread() allocates
landscape-length state on every call, and it is called Nreps times per fire year. With a
one-cell margin the crop draws the same random numbers, so results do not change: identical
objective values with identical seeds on ELFs 5.3.1, 5.3.2 and 13.1, and an evaluation 1.6-2.7x,
2.6-3.8x and 1.4-1.7x faster.objFunInner() no longer scans the whole landscape once per fire year. The spreadProb values its
bail tests summarise were recovered with cells[cells > a | cells > b], four passes over a
landscape-length vector that is zero except at that year's pixels (6.0M cells for ~41k pixels on
ELF 5.3.1); they are now read from the spreadProb column. The vector is filled only when
spread() will run, and is allocated numeric so filling it does not coerce it. Identical
objective values with identical seeds; an evaluation is 1.1-1.4x faster on ELF 5.3.1.makeFireSenseLCCDeps(): the functions whose code affects what makeFireSenseLCC() returns,
to pass as a cached call's .cacheExtra. reproducible::Cache() digests only the called
function's own code, so callers have to name the functions it calls -- and which those are depends
on lccSource, a run-time option. A caller that writes the list out is therefore wrong for the
other source and goes stale when the default moves. That already happened: fireSense_dataPrepFit
pinned LandR::prepInputs_NTEMS_LCC_FAO() and kept it after the default became SCANFI, so every
SCANFI and CWIM change was invisible to the cache while NTEMS changes invalidated it for nothing.makeFireSenseLCC() gains lccSource, "SCANFI" by default (options(fireSense.lccSource = )), with
"NTEMS" still available and unchanged. Biomass_borealDataPrep's default land cover is moving from NTEMS
to SCANFI, and the fire models must be fitted on the land cover the simulation predicts with. SCANFI has
no wetland classes, so they are added exactly as that module does: LandR::prepInputs_CWIM() for the
wetland site layer and LandR::wetlandToLCC() to recode it, wet treed pixels to 81 and other wet
flammable pixels to 80. Non-flammable (0) and water are never recoded. Checked on ELF 5.3.2's fit grid
(8.1 M cells): the NTEMS path gives byte-identical output to 0.2.3.9022.
The two LandR wetland functions are looked up at run time (PredictiveEcology/LandR#228), so this package
installs and checks against a LandR without them; lccSource = "SCANFI" then stops with a message naming
what is missing.
fireSenseCovariatesCreate() gains rstLCC (and treedWetlandLCC, default 81). Given it, the covariates
carry treedWetland (new constant treedWetlandTxt), 1 on treed wetland. Class 81 is a forested class, so
those pixels otherwise reach the fire models only through their fuel biomass and look like upland forest.
It is a site attribute, not a fuel state, so it is added after the youngAge exclusivity -- a burned bog is
still wet -- and mergePreparedCovs() leaves it out of the ignition data's all-cover-is-zero filter.
Without rstLCC nothing changes, so existing callers and their caches are untouched.
runDEoptim() re-scores the final population after the fit: each member is evaluated rescoreReps
(default 10) more times, on the same workers, with the early stop off, and the result is attached as
attr(DE, "finalRescore"). New rescorePopulation() and bestByReplicatedMean() do the work, the
second picking the n distinct members with the lowest replicated means. DEoptim evaluates a
surviving member only once, so the values it holds are partly luck: in eight FireSense fits its best
member ranked 1st to 6th of 60 by replicated mean, and the fitted-parameter ledger's "5 best" were five
copies of that one member. The re-score costs about ten generations' worth of evaluations.
spreadFitAdditionalColNamesTxt gains covMinMax_spread: prediction cannot rescale covariates as the
fit did without it, and the ledger never stored it. Ledger readers must tolerate rows that predate it
(see fireSense_dataPrepFit).
rasterFireBufferDT() drew its per-year seeds from the session's random stream, so two calls with
identical inputs built different buffers unless the caller happened to have set the same seed --
and nothing upstream did. fireSense_dataPrepFit caches this call, so its spread-fit buffers,
spread points and covariates changed between runs of the same study area, and cached entries holding
different data were kept apart only by an accident of their cache keys. #57 made the buffers the same
across forked workers; this makes them the same across sessions. The new seed argument defaults to
a digest of the inputs, the draws run under withr::with_seed(), and the session's random stream is
no longer advanced. Buffers differ from those built by earlier versions, so cached buffers are rebuilt..objfunSpreadFit() gains pruneAbove (default Inf), which makes its existing early-bail
test adaptive. After the first of its two batches of fire years it compares the accumulated
SNLL against thresh * numYrsDone -- a bound fixed for the whole fit, which therefore cannot
tighten as the population improves. The test is now min(thresh * numYrsDone, pruneAbove), so a
caller running one DEoptim generation per call can pass the worst value the current population
would accept. This is exact rather than heuristic: the second batch contributes a non-negative
SNLL, so the first batch's value is a lower bound on the total, and any trial above pruneAbove
would have been rejected by selection anyway -- the search trajectory is unchanged. It is worth
doing because a DEoptim generation is synchronous: its wall time is the slowest of its NP
evaluations, so the tail sets the clock.runDEoptim() asks for about 10 workers per estimated parameter (nCoresNeeded, default
10 * length(lower)) instead of a fixed 100, and DEoptim's NP is the number of workers the
cluster was built with (clusters >= 0.0.33). It passes only the settings it chooses to
clusters:::DEoptimIterative2(), whose defaults fill the rest; a complete DEoptim.control()
list would have overridden them.runDEoptim() passes DEoptim settings to DEoptim. .c was sent to the objective function, which
ignores it, so DEoptim always used its default c; it is now DEoptim's c. The new
DEoptimControl list carries any other DEoptim.control() setting (CR, F, p, reltol, ...)
through clusters::clusterSetup(controlArgs = ).runDEoptim() no longer fails with "missing value where TRUE/FALSE needed" where R has no
OpenMP, as in the CRAN macOS builds: RhpcBLASctl::omp_get_max_threads() returns NA there.ELFmergePlan() no longer fails with "non-character argument" when an ELF with too few fires has no
neighbour at all, such as an ELF whose core touches no other ELF. That ELF is not fitted, as when its
neighbours share no base with it.ELFneighbours() measures the core border each
pair of ELFs shares. ELFmergePlan() takes every ELF that ELFfitStatus() calls "zero" or "few"
and pairs it with the neighbouring ELF of the same base and depth (another piece of the same split
ecoprovince, or another whole ecoprovince of the same ecozone) that shares the longest border. If the two
together reach both thresholds they merge; otherwise neither is fitted. mergeELFs() applies the plan to
the ELF maps, ELFmergedName() names a merged ELF by its shared base and members' last parts (3.2.1 with
3.2.4 is "3.2.1_4"), ELFsSkipped() lists the ELFs not fitted and ELFrunName() maps a merged member
to its merged ELF.ELFfireCounts(), ELFfitStatus(), ELFsExcluded() and ELFflammableArea() are exported. They were
documented as exported in 0.2.3.9014, but NAMESPACE was not regenerated, so no other package or module
could call them.bufferToArea() and rasterFireBufferDT() give the same buffers for the same seed. They pick buffer
pixels at random, in forked workers when cores > 1, and each forked child seeded itself
independently: the same call gave different buffers on every run, and different ones again with one
core (61 of 5,000 buffer pixels differed between two runs of one year of ELF 11.2). Each polygon set,
or year, now runs under a seed drawn from the session's stream, forked or not. Spread-fit data built
with these functions change once as a result.getFirePoints_NFDB() and getFirePoints_NFDB_V2() download the National Fire Database
points from .../current_version/NFDB_point_shp.zip. CFS renamed the archive from
NFDB_point.zip, which now returns HTTP 404, so no release after the copy already on disk
(fires to 2024) could be fetched. The URL is in the internal nfdbPointUrl(), with a test.parallel is now declared in Imports (it was imported in NAMESPACE only). covr records
coverage in forked children only for packages that declare parallel, so the forked code in
bufferToArea() and rasterFireBufferDT() was reported as never run.bufferToArea() and rasterFireBufferDT() with cores > 1 no longer hang. GDAL keeps one
worker-thread pool per process, created at the first multi-threaded raster write. A forked
worker inherited that pool but none of its threads, and waited for them forever with 0 CPU.
Forked workers now write rasters single-threaded (terraOptions(threads = 1)).fireSenseCloudParameters() now downloads the shared parameter file from Google
Drive on every call. It used prepInputs(purge = 7, overwrite = TRUE), which never
downloads again once a copy on disk matches CHECKSUMS.txt (purge only rebuilds
those entries; overwrite only affects the written output), so a changed file on
Drive was not seen. url may now also be the folder containing targetFile;
useCache is ignored.objFunInner() no longer wraps its spread() replicate loop in
system.time(). system.time() defaults to gcFirst = TRUE, so every fire year
forced a full garbage collection, and the result was assigned to a variable that
was never read. Measured on ELF 12.4 (1,737,132 cells, 18 fire years, Nreps = 25,
in a 20-40 GB process), three consecutive objective-function evaluations went from
82.7 / 84.0 / 83.9 s to 9.8 / 8.7 / 8.8 s -- about 9.5x -- with warm caches and no
other change. In the profile the forced collections were 109.96 s of a 134 s
evaluation, 82% of self time, against 6.44 s for all the spread() calls they were
timing. It also explains why replicate count used to make no difference: the
collection is once per fire year, outside the replicate loop. With it gone,
Nreps = 5 takes 3.2 s against 8.8 s at 25, so roughly 80% of an evaluation now
scales with replicates as it always should have.objFunInner() returned ret unconditionally but built it only inside
if (isTRUE(doFitting)). With no test selected -- which is how
fireSense_SpreadFit's mode = "debug" calls the chain, passing tests = ""
-- the branch is skipped and the call failed with object 'ret' not found,
making debug mode unusable. ret is now initialised before the branch, so the
function returns an empty list when nothing was asked of it.objFunInner() now asks SpaDES.tools::spread() to skip its input checks by
the name that function actually uses. It passed skipChecks = TRUE, which
belongs to spread3(); spread() has no such argument, so it landed in ...
and did nothing. Every call therefore re-validated the whole per-cell
spreadProb vector -- na.omit() copies it, inRange() scans it -- which is
work proportional to the number of cells in the landscape, independent of how
much actually burns, and this function calls spread() Nreps times per fire
year. Measured on synthetic landscapes, with identical output:
| landscape | before | after | speedup | | --- | --- | --- | --- | | 1M cells | 0.030 s | 0.016 s | 1.9x | | 4M cells | 0.074 s | 0.022 s | 3.4x | | 9M cells | 0.183 s | 0.036 s | 5.1x |
objFunSpread(): the Anderson-Darling test now compares fire size distributions
drawn from the same years on both sides. Years are simulated in two batches --
the 2 with the largest total area burned, then the rest -- and the adTest block
was gated on ii == 2, reading results$fireSizes after results had been
overwritten by the second batch. So the simulated sample held only the
small-fire years, while the observed sample used historicalFiresAboveMin
unsubset, i.e. every year. Since the omitted years are selected as
largest-area-burned, the observed sample retained an upper tail the simulated
sample structurally could not have; ad.test is tail-sensitive and its result is
multiplied by 50, so the only way DEoptim could shrink it was to inflate
simulated fire sizes in ordinary years, biasing the fit toward over-prediction.
Simulated sizes are now pooled across both batches (they were already being
computed and discarded) and the test runs once after the loop, via the new
internal pooledFireSizes(). Behaviour on the early-bail path is unchanged: a
parameter set that fails the first batch still skips adTest entirely.
makeELFs(): only run the NA-hole-filling focal() step when x is a
SpatRaster. When x is (or defaults to) an sf of fire regime polygons there
are no NA slivers to fill and focal() has no method for it, failing with
no method found for signature sf, data.frame.
mergeAndSplitRas(): write each call's per-ecoprovince .tif files into their own
ELFs_<digest> subdirectory of destinationPath, keyed on ecopRseg, ecopLCC,
maxArea and field. They previously went straight into destinationPath named
only by the province code (4.1.tif), scattering ~36 anonymous files through a
shared inputs directory and letting successive calls overwrite one another. Note
this protects destinationPath only: reproducible's cache-restore path collapses
these to cachePath/<basename>.tif, dropping the cacheId, so distinct calls still
collide there -- that is a reproducible issue this cannot fix.
fireSenseCloudParametersMap() and plotELFs(): use ELFs$poly rather than
treating the makeELFs() return value as a SpatVector. makeELFs() returns a
list of rasters plus a poly element (ELFsInStudyArea() already unwraps it this
way), so terra::plot(ELFs), ELFs$buffer, ELFs$ID and ELFs[keep, ] were all
operating on the list. Both call sites are fixed.
Documentation regenerated with roxygen2 8.1.0 (was 8.0.0). Mostly formatting:
8.1.0 emits one multi-symbol importFrom() per package instead of one line per
symbol, so NAMESPACE shrinks considerably with no change to what is imported.
One API change falls out of the upgrade: spreadFitAdditionalColNames
(R/objDefaults.R) carries @export in its source but was not being exported --
roxygen2 8.0.0 silently dropped it, and 8.1.0 honours it. It is now exported and
documented, alongside the existing spreadFitAdditionalColNamesTxt.
getFirePoints_NFDB() no longer drops columns. It previously subset to
c("YEAR", fireSizeColName) and renamed those to date/size_ha, discarding
CAUSE among everything else -- the reason the scfm modules explicitly avoid
fireSenseUtils (## NOTE: do not use fireSenseUtils - it removes the cause column). All source columns are now retained, matching
getFirePoints_NFDB_V2() and scfmutils::getFirePoints_NFDB_scfm(). The
derived size column (fire size in pixels) is still added when rasterToMatch
is supplied. Breaking: the date and size_ha output names are gone; use
YEAR and SIZE_HA (#32).
getFirePoints_NFDB_V2() now uses fun to load already-downloaded data, not
just downloaded data. The cached branch hardcoded st_read(), so a caller
passing fun = "terra::vect" (as fireSense_dataPrepFit does) got a
SpatVector on the first run and an sf on every run after. The return class
is now the same in both branches (#32).
getFirePoints_NFDB() and getFirePoints_NFDB_V2(): fix inverted
redownloadIn. The staleness threshold was 365 / redownloadIn, so
redownloadIn = 0.5 gave a 730-day tolerance rather than the documented
"redownload data older than 6 months". Now 365 * redownloadIn. Only the
default of 1 was unaffected (#32).
getFirePoints_NFDB() and getFirePoints_NFDB_V2(): the staleness check
errored with "the condition has length > 1" when NFDB_pointPath held more
than one NFDB_point*.shp; now wrapped in any() (#32).
getFirePoints_NFDB_V2(): restore the NFDB_pointPath non-NULL check, so the
NULL default fails with a clear message instead of inside Checksums() (#32).
getFirePoints_NFDB(): pass useSAcrs = TRUE to postProcess() in the
already-downloaded branch, so the CRS no longer depends on whether a download
happened; restore reproducible.cacheSaveFormat to its previous value on exit
instead of overwriting it; drop the dead SpaDES.core requirement check (the
package is only in Suggests and nothing in the function uses it) (#32).
Replace the single ggpubr::ggarrange() call in visualizeDE() with
cowplot::plot_grid(), and move cowplot from Suggests to Imports (it was
already used, behind a requireNamespace() guard, in plot_summaries.R).
ggpubr is dropped from Imports. This removes 14 packages from the
dependency tree -- ggpubr, car, carData, pbkrtest, doBy, Deriv, lme4,
quantreg, SparseM, MatrixModels, nloptr, minqa, abind and Formula.
It also fixes R-CMD-check on the oldrel-2 leg. Deriv 4.3.0 (published
2026-07-23) uses R_ClosureFormals, an R >= 4.5 C-API entry point, but
declares no minimum R version, so it fails to compile on R 4.4.x with
'R_ClosureFormals' was not declared in this scope. The dependency install
then aborted and rcmdcheck died at loadVignetteBuilder(), which made the
failure look like a vignette problem.
makeTSD() is now general purpose: the pixels to age from fire history and the
flammable mask can be supplied directly via the new pixToUpdate and
flammablePixels arguments, instead of only through lcc (the landcoverDT
from fireSense_dataPrepFit). lcc is now optional and, when supplied, still
derives both vectors (the fireSense_dataPrepFit-specific behaviour is
preserved behind an if); explicit arguments take precedence. Fully backwards
compatible (#18).
Firesense_LCC_flammability vignette: probe the LCC (ftp.maps.canada.ca) and
fire-polygon (cwfis.cfs.nrcan.gc.ca) data servers up front and skip the live
download/analysis chunks when either is unreachable, degrading to
documentation-only (same as the existing macOS/archive path). This stops
R-CMD-check from failing when the servers are unreachable from CI runners.
Adds curl to Suggests.
makeFireSenseLCC(): remove commented-out dead code (an unused majority-NA
block aggregation path) from the aggregation step.
import(data.table) in favour of
explicit importFrom declarations; remove importFrom(purrr, transpose) which
was silently overriding data.table::transpose (all call sites already use
purrr::transpose() with explicit namespace).importFrom(data.table, setorder) used in DEoptimIterative.