diff --git a/NEWS.md b/NEWS.md index 81f341d..ca0f55a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,6 @@ # fireSense_spreadFit (development version) +- New parameter `censoredRunaways` (default `FALSE`, nothing changes; a `FALSE` is left out of the fit's and the threshold calibration's cache keys). `TRUE` passes `fireSenseUtils::.objfunSpreadFit(censoredRunaways = TRUE)` to the fit, its re-score and the calibration: a simulated fire that reaches its buffer's edge ring is right-censored at the size it burned (it keeps burning inside its buffer, so its size is buffer-bounded exactly as the observed fire is; the size likelihood fits the replicates with it as 'at least this big'; the Anderson-Darling, annual-area and area-distribution terms score the size it burned, and `runawayBufferMultiple` is not used). Why: scoring a runaway as 2 x its buffer made the objective prefer `yearSpreadSD = 0` whatever the true year effect (known-sigma test on ELF 14.3, 2026-10-17). Needs fireSenseUtils with `censoredRunaways`. - New parameter `fitFileSuffix` (default `""`), the same in `fireSense_ignitionFit`, `fireSense_dataPrepFit` and `fireSense_ELFs` (checked with `paramCheckOtherMods()`): with `spreadFitFilename = "latest"` the fit is written to `fireSenseUtils::spreadFitFilenameFor(fireYears, fitFileSuffix)`, so a test run (`.globals = list(fitFileSuffix = "_boundfix_rep81")`) never writes to the production ledger file. `""` is unchanged. Needs fireSenseUtils (>= 0.2.3.9104). - New parameter `convergence` (default `NULL`): the DEoptim early-stop rule, a list with any of `window`, `minGenerations`, `se`, `sdChange`. It is resolved once to the full list (the parameter, else the `clusters.deoptim*` options, else clusters' defaults) and passed to `runDEoptim()` inside the fit's `Cache()` call, so changing the parameter or the options changes the fit's cache key; clusters keeps it out of each generation's key, so a fit that stopped early continues from its cached generations under a stricter rule. Fits cached before this re-key once. Floors `fireSenseUtils` 0.2.3.9102 and `clusters` 0.0.80 (Depends-on: PredictiveEcology/fireSenseUtils#162, PredictiveEcology/clusters#55). - The fit's and the threshold calibration's cache keys now include `spreadProbFromIntegerCovs()`, `spreadProbCovsOfYear()` and `dataYearBefore()`, so a change in which vegetation table a fire year reads is a cache miss and a threshold cached as `NA` under the old rule is not reused. A calibration whose every trial errored now stops with the first trial's error (`stopIfAllTrialsErrored()`) instead of returning `NA` (then `Inf`) to be cached. `fireSenseUtils` floor 0.2.3.9100, whose objective reads the table of the latest data year strictly before the fire year, as `fireSense_dataPrepFit` builds them (Depends-on: PredictiveEcology/fireSenseUtils#159). Spread-fit input tables must be named by data year. diff --git a/R/fitSpread.R b/R/fitSpread.R index f67ca11..3459147 100644 --- a/R/fitSpread.R +++ b/R/fitSpread.R @@ -83,6 +83,7 @@ fitSpread <- function(sim, covs, thresh, runName, diagnostics = TRUE) { runawayEdgeFrac = P(sim)$runawayEdgeFrac, runawayEdgeMin = P(sim)$runawayEdgeMin, runawayBufferMultiple = runawayMultiple, + censoredRunaways = censoredRunawaysOrNULL(P(sim)$censoredRunaways), profileReps = if (diagnostics) P(sim)$profileReps else 0L, simulateMembers = if (diagnostics) P(sim)$simulateMembers else 0L), .functionName = fnName, @@ -93,10 +94,11 @@ fitSpread <- function(sim, covs, thresh, runName, diagnostics = TRUE) { ## runawayEdgeFrac/Min only change how quickly DEoptim moves away from an unlucky draw, not what a ## fit means, so fits cached under the 1-cell rule stay valid and must not rerun. omitArgs = c(".verbose", "cores", "paths", "logPath", "plotEvery", "progressFile", "runawayEdgeFrac", "runawayEdgeMin", - ## NULL without an intercept (or with runawayBufferMultiple NA): omitted then, so fits - ## cached before it existed are found + ## NULL without an intercept (or with runawayBufferMultiple NA, censoredRunaways FALSE): omitted + ## then, so fits cached before it existed are found fireSenseUtils::omitNullArgs(covCentre = sim$covCentre_spread, - runawayBufferMultiple = runawayMultiple)), + runawayBufferMultiple = runawayMultiple, + censoredRunaways = censoredRunawaysOrNULL(P(sim)$censoredRunaways))), useCache = P(sim)$useCache_DE ) ## 1e6 is the objective's fail value (fireSenseUtils::.objfunSpreadFit). A population that is all @@ -126,6 +128,10 @@ escapeSizeHaOrNULL <- function(x) if (length(x) && !is.na(x)) x ## burned) for NULL or NA runawayBufferMultipleOrNULL <- escapeSizeHaOrNULL +## `censoredRunaways` as `runDEoptim()` and the calibration take it: TRUE, or NULL (not FALSE) so that it is left out +## of their cache keys and fits made before it existed are found +censoredRunawaysOrNULL <- function(x) if (isTRUE(x)) TRUE + #' Turn the fit's re-score, profile and simulations into `sim` objects #' #' @param sim a `simList`. diff --git a/R/runSpreadWithoutDEoptim.R b/R/runSpreadWithoutDEoptim.R index b2b32d4..a9ae1cb 100644 --- a/R/runSpreadWithoutDEoptim.R +++ b/R/runSpreadWithoutDEoptim.R @@ -34,7 +34,7 @@ #' @param mode character; if it includes "debug", the debug branch runs. #' @param seed integer or NULL; `set.seed()` value. NULL draws one at random. #' @param escapeSizeHa passed to `.objfunSpreadFit()`: fit escaped fires only (NULL: any fire over 1 pixel). -#' @param sizeLik,sizeLikDf,adWeight,link,jumpTries,jumpMeanDist,yearAreaWeight,areaDistWeight,penaliseRunaways,runawayEdgeFrac,runawayEdgeMin,runawayBufferMultiple passed to +#' @param sizeLik,sizeLikDf,adWeight,link,jumpTries,jumpMeanDist,yearAreaWeight,areaDistWeight,penaliseRunaways,runawayEdgeFrac,runawayEdgeMin,runawayBufferMultiple,censoredRunaways passed to #' `.objfunSpreadFit()`, so the threshold is calibrated on the objective the fit uses. The defaults are #' that function's. #' @return the calibrated threshold (numeric, or NA if no trial was usable); NULL in "debug" mode. @@ -53,6 +53,7 @@ runSpreadWithoutDEoptim <- function(iterThresh, lower, upper, fireSense_spreadFo runawayEdgeFrac = fireSenseUtils::fireSenseRunawayEdgeFrac, runawayEdgeMin = fireSenseUtils::fireSenseRunawayEdgeMin, runawayBufferMultiple = NULL, + censoredRunaways = NULL, thresholdMargin = 2, maxDrawsPerTrial = 100) { ## The threshold this returns becomes `thresh` in runDEoptim(), so it is part of every cached ## DEoptim generation's key. With a seed drawn here, a single cache miss on the estimateThreshold @@ -115,6 +116,7 @@ runSpreadWithoutDEoptim <- function(iterThresh, lower, upper, fireSense_spreadFo penaliseRunaways = penaliseRunaways, runawayEdgeFrac = runawayEdgeFrac, runawayEdgeMin = runawayEdgeMin, runawayBufferMultiple = runawayBufferMultiple, + censoredRunaways = censoredRunaways, plot.it = plot.it ) } @@ -189,6 +191,7 @@ runSpreadWithoutDEoptim <- function(iterThresh, lower, upper, fireSense_spreadFo yearAreaWeight = yearAreaWeight, areaDistWeight = areaDistWeight, penaliseRunaways = penaliseRunaways, runawayBufferMultiple = runawayBufferMultiple, + censoredRunaways = censoredRunaways, plot.it = FALSE) ) }) diff --git a/fireSense_spreadFit.R b/fireSense_spreadFit.R index 4b91170..2e2a1ca 100644 --- a/fireSense_spreadFit.R +++ b/fireSense_spreadFit.R @@ -194,6 +194,17 @@ defineModule(sim, list( "not capped at a size; spread is bounded by the buffers. FALSE scores the simulated", "size in the likelihood too. Passed to", "`fireSenseUtils::runDEoptim()`; the threshold calibration uses the same setting.")), + defineParameter("censoredRunaways", "logical", default = FALSE, + desc = paste("With `penaliseRunaways`, TRUE treats a runaway replicate as right-censored at the size", + "it burned (`fireSenseUtils::.objfunSpreadFit(censoredRunaways =)`): it keeps burning", + "inside its buffer, so its size is buffer-bounded exactly as the observed fire is; the", + "size likelihood fits the replicates with it as 'at least this big' (censored-normal", + "maximum likelihood, `sizeLik = 't'` only); the Anderson-Darling, annual-area and", + "area-distribution terms score the size it burned, and `runawayBufferMultiple` is not", + "used. FALSE (default) keeps the scoring above. Why: scoring a runaway as 2 x its buffer", + "made the objective prefer no year effect whatever the true one (known-sigma test, ELF", + "14.3, 2026-10-17). Passed to `fireSenseUtils::runDEoptim()` and the threshold", + "calibration; left out of their cache keys when FALSE.")), defineParameter("runawayBufferMultiple", "numeric", default = 2, desc = paste("With `penaliseRunaways`, a runaway counts in the size-based terms of the objective", "(Anderson-Darling, annual area, area-weighted size distribution) as this multiple of", @@ -981,6 +992,7 @@ estimateSNLLThresholdPostLargeFires <- function(sim, covs) { penaliseRunaways = P(sim)$penaliseRunaways, runawayEdgeFrac = P(sim)$runawayEdgeFrac, runawayEdgeMin = P(sim)$runawayEdgeMin, runawayBufferMultiple = runawayMultiple, + censoredRunaways = censoredRunawaysOrNULL(P(sim)$censoredRunaways), thresholdMargin = P(sim)$thresholdMargin, maxFireSpread = P(sim)$maxFireSpread) |> ## Nothing is omitted from the key, because both of the arguments that used to @@ -1011,11 +1023,12 @@ estimateSNLLThresholdPostLargeFires <- function(sim, covs) { spreadProbGates = deparse(fireSenseUtils::spreadProbGates), spreadProbGateTest = deparse(fireSenseUtils::spreadProbGateTest), objective = objectiveBodies()), - ## covCentre is NULL without an intercept, runawayBufferMultiple with NA: omitted then, so - ## thresholds cached before they existed are found + ## covCentre is NULL without an intercept, runawayBufferMultiple with NA, censoredRunaways with + ## FALSE: omitted then, so thresholds cached before they existed are found omitArgs = c("runawayEdgeFrac", "runawayEdgeMin", fireSenseUtils::omitNullArgs(covCentre = sim$covCentre_spread, - runawayBufferMultiple = runawayMultiple))) + runawayBufferMultiple = runawayMultiple, + censoredRunaways = censoredRunawaysOrNULL(P(sim)$censoredRunaways)))) } else { P(sim)$SNLL_FS_thresh } diff --git a/tests/testthat/test-metadata.R b/tests/testthat/test-metadata.R index f5337c0..e424bd8 100644 --- a/tests/testthat/test-metadata.R +++ b/tests/testthat/test-metadata.R @@ -69,7 +69,7 @@ test_that("parameters are the expected names", { "heldOutFold", "initialpop", "iterDEoptim", "iterThresh", "thresholdMargin", "libPathDEoptim", "link", "lower", "maxFireSpread", "mode", "mutuallyExclusiveCols", "nCoresNeeded", "objFunCoresInternal", "objfunFireReps", ".rep", "adWeight", "profileReps", - "simulateMembers", "sizeLik", "sizeLikDf", "escapeSizeHa", "yearAreaWeight", "areaDistWeight", "penaliseRunaways", "runawayEdgeFrac", "runawayEdgeMin", "runawayBufferMultiple", + "simulateMembers", "sizeLik", "sizeLikDf", "escapeSizeHa", "yearAreaWeight", "areaDistWeight", "penaliseRunaways", "runawayEdgeFrac", "runawayEdgeMin", "runawayBufferMultiple", "censoredRunaways", "jumpTries", "jumpMeanDist", "upperTailBounds", "weighted", "yearSpreadSDBounds", "refitExisting", "rescaleAll", "SNLL_FS_thresh", "fitFileSuffix", "spreadFitFilename", "spreadFitGoogleDriveFolder", "stopIfNoPreRunFit", "strategy", "trace", diff --git a/tests/testthat/test-metadataContract.R b/tests/testthat/test-metadataContract.R index 8c80313..1cd6fcb 100644 --- a/tests/testthat/test-metadataContract.R +++ b/tests/testthat/test-metadataContract.R @@ -26,7 +26,7 @@ test_that("parameter classes", { link = "character", profileReps = "integer", simulateMembers = "integer", sizeLik = "character", sizeLikDf = "numeric", escapeSizeHa = "numeric", weighted = "logical|character", adWeight = "character|numeric", yearAreaWeight = "numeric|character", - areaDistWeight = "numeric|character", penaliseRunaways = "logical", runawayEdgeFrac = "numeric", runawayEdgeMin = "integer", runawayBufferMultiple = "numeric", jumpTries = "numeric", jumpMeanDist = "numeric", + areaDistWeight = "numeric|character", penaliseRunaways = "logical", runawayEdgeFrac = "numeric", runawayEdgeMin = "integer", runawayBufferMultiple = "numeric", censoredRunaways = "logical", jumpTries = "numeric", jumpMeanDist = "numeric", upperTailBounds = "numeric", yearSpreadSDBounds = "numeric", verbose = "numeric", visualizeDEoptim = "Path")) ) @@ -71,6 +71,7 @@ test_that("the runaway rule's parameters exist with their defaults", { expect_identical(def$runawayEdgeFrac, 0.01) expect_identical(def$runawayEdgeMin, 3L) expect_identical(def$runawayBufferMultiple, 2) + expect_identical(def$censoredRunaways, FALSE) }) test_that("numeric defaults", { diff --git a/tests/testthat/test-objectiveSettings.R b/tests/testthat/test-objectiveSettings.R index f84684d..96bcc18 100644 --- a/tests/testthat/test-objectiveSettings.R +++ b/tests/testthat/test-objectiveSettings.R @@ -117,3 +117,32 @@ test_that("penaliseRunaways = FALSE reaches the fit, the calibration and the obj logical(1)))) expect_true(all(vapply(seen, function(a) identical(a$penaliseRunaways, FALSE), logical(1)))) }) + +test_that("censoredRunaways reaches the fit, the calibration and the objective as TRUE; FALSE arrives as NULL (cache keys unchanged)", { + for (value in list(TRUE, FALSE)) { + rec <- new.env(); fitRec <- new.env() + sim <- toySim(list(SNLL_FS_thresh = NULL, censoredRunaways = value)) + mockFitAndLedger(sim, fitRec) + mockInModule(sim, runSpreadWithoutDEoptim = recordingRSWD(rec, 777)) + sim <- suppressMessages(SpaDES.core::spades(sim)) + expected <- if (value) TRUE else NULL + expect_identical(rec$args$censoredRunaways, expected) + expect_identical(fitRec$deArgs$censoredRunaways, expected) + } + expect_null(formals(runSpreadWithoutDEoptim)$censoredRunaways) + seen <- list() + local_mocked_bindings(.objfunSpreadFit = function(par, thresh, ...) { + seen[[length(seen) + 1L]] <<- list(...) + 1000 + thresh + }) + land <- terra::rast(nrows = 10, ncols = 10, xmin = 0, xmax = 2400, ymin = 0, ymax = 2400) + fires <- list(year2001 = data.frame(size = c(5L, 12L), date = "year2001", ids = 11:12, cells = c(3L, 56L))) + suppressMessages(utils::capture.output(runSpreadWithoutDEoptim( + iterThresh = 3L, lower = c(a = 0), upper = c(a = 1), fireSense_spreadFormula = "~ 0 + a", + flammableRTM = land, annualDTx1000 = list(), nonAnnualDTx1000 = list(), fireBufferedListDT = list(), + historicalFires = fires, covMinMax = NULL, objfunFireReps = 2L, maxFireSpread = 0.28, + tests = "SNLL_FS", formulaToFit = "~ 0 + a", mode = "debug", seed = 1L, escapeSizeHa = 50, doObjFunAssertions = FALSE, + censoredRunaways = TRUE))) + expect_true(length(seen) > 0) + expect_true(all(vapply(seen, function(a) isTRUE(a$censoredRunaways), logical(1)))) +})