diff --git a/fireSense_dataPrepPredict.R b/fireSense_dataPrepPredict.R index c16d46a..b882622 100644 --- a/fireSense_dataPrepPredict.R +++ b/fireSense_dataPrepPredict.R @@ -1,6 +1,8 @@ defineModule(sim, list( name = "fireSense_dataPrepPredict", - description = "", + description = paste( + "Prepares, each year, the covariate tables used by fireSense_IgnitionPredict,", + "fireSense_EscapePredict and fireSense_SpreadPredict."), keywords = "", authors = c( person("Ian", "Eddy", role = c("aut", "cre"), email = "ian.eddy@nrcan-rncan.gc.ca"), @@ -19,7 +21,6 @@ defineModule(sim, list( "data.table", "PredictiveEcology/fireSenseUtils@development (>= 0.1.0)", "terra" - #reproducible@ai ), parameters = rbind( defineParameter("cutoffForYoungAge", "numeric", 15, NA, NA, @@ -29,49 +30,37 @@ defineModule(sim, list( ) ), defineParameter("dataYear", "numeric", 2011, 1985, 2022, - "Used to override the default 'sourceURL' of NTEMS data for objects when not supplied"), - defineParameter("fireTimeStep", "numeric", 1, NA, NA, desc = "time step of fire model"), + paste("Year of the default landcover and stand age maps, and last year of the fires", + "used to initialise `nonForest_timeSinceDisturbance`.")), + defineParameter("fireTimeStep", "numeric", 1, NA, NA, desc = "Interval between events of this module, in years."), defineParameter("forestedLCC", "numeric", c(81, 210, 220, 230, 240), NA, NA, - "forested landcover classes in `rstLCC` - only relevant if `landcoverDT` is not supplied"), + "Forested landcover classes in `rstLCC_RTM`. Only used if `landcoverDT` is not supplied."), defineParameter("flammabilityThreshold", "numeric", 0.1, 0, 1, - paste("Minimum proportion of flammable old pixel needed to define a new pixel - as flammable when upscaling the default flammable maps`.")), + paste("Minimum proportion of flammable pixels for an upscaled pixel to be flammable,", + "when building the default landcover.")), defineParameter("fuelClassCol", "character", "FuelClass", NA, NA, - "the column in sppEquiv that defines unique fuel classes for ignition"), - defineParameter("igAggFactor", "numeric", 4, 1, NA, # was 4 before xgboost, Jun 19, 2025 - "aggregation factor for rasters during ignition prep."), + "Column of `sppEquiv` that defines the fuel classes, for both ignition and spread."), + defineParameter("igAggFactor", "numeric", 4, 1, NA, + paste("Aggregation factor for the ignition and escape covariates.", + "Overwritten in `init` by the value set in other modules.")), defineParameter("nonflammableLCC", "numeric", c(0, 20, 31, 32, 33), NA, NA, desc = paste( - "used to create flammableRTM if unsupplied.", - "The non-flammable LCC in rstLCC layers - which", - "default to water, snow/ice, rock, and barren land in NTEMS LCC" + "Non-flammable landcover classes, used to create `flammableRTM` and the default landcover", + "if not supplied. Defaults are water, snow/ice, rock and barren land in NTEMS LCC." ) ), defineParameter("nonForestCanBeYoungAge", "logical", TRUE, NA, NA, - desc = "update non-forest when burned, to become youngAge"), + desc = "Should burned non-forest pixels be `youngAge` until `cutoffForYoungAge`?"), defineParameter("sppEquivCol", "character", "LandR", NA, NA, - desc = "column name in `sppEquiv` object that defines unique species in `cohortData`"), + desc = "Column of `sppEquiv` with the species names used in `cohortData`."), defineParameter("whichModulesToPrepare", "character", - default = c("fireSense_SpreadPredict", "fireSense_IgnitionPredict", "fireSense_EscapeFit"), + default = c("fireSense_SpreadPredict", "fireSense_IgnitionPredict", "fireSense_EscapePredict"), NA, NA, - desc = "Which fireSense fit modules to prep? defaults to all 3"), - defineParameter(".plotInitialTime", "numeric", NA, NA, NA, - "Describes the simulation time at which the first plot event should occur." - ), - defineParameter( - ".plotInterval", "numeric", NA, NA, NA, - "Describes the simulation time interval between plot events." - ), - defineParameter( - ".runInitialTime", "numeric", start(sim), NA, NA, "time to simulate initial fire" - ), - defineParameter( - ".saveInitialTime", "numeric", NA, NA, NA, - "Describes the simulation time at which the first save event should occur." - ), + desc = paste("Predict modules to prepare covariates for: `fireSense_IgnitionPredict` or", + "`fireSense_EscapePredict` for the ignition/escape table,", + "`fireSense_SpreadPredict` for the spread table. Defaults to all three.")), defineParameter( - ".saveInterval", "numeric", NA, NA, NA, - "This describes the simulation time interval between save events." + ".runInitialTime", "numeric", start(sim), NA, NA, "Time of the first climate and covariate preparation events." ), defineParameter( ".useCache", "logical", FALSE, NA, NA, @@ -83,85 +72,82 @@ defineModule(sim, list( ) ), inputObjects = bindrows( - expectsInput("climateVariablesForFire", "list", - sourceURL = NA, - paste( - "A list detailing which climate variables in `sim$projectedClimateRasters`", - "to use for which fire processes (ignition and spread). If the list is length one,", - "both processes will use the same variables. The default is to use 'MDC'.")), - expectsInput("climateYear", "character", - paste("optional character vector giving year (e.g. 'year2009') for preparing", - "the `currentClimateRasters` object. If unsupplied, `time(sim)` is used.", - "see PredictiveEcology/climateYear")), - expectsInput("currentClimateRasters", "SpatRaster", NA, - "SpatRaster of climate layers at current time of sim; this will be generated ", - "in this module (if this is absent) from projectedClimateRasters"), - expectsInput("cohortData", "data.table", NA, - "table that defines the cohorts by pixelGroup"), - expectsInput("fireSense_IgnitionFitted", "fireSense_IgnitionFit", NA, - "object containing slot `fittingRes` - the spatial resolution at which ignition will be predicted"), - expectsInput("missingLCCgroup", "character", NA, paste( - "if a pixel is forested but is absent from `cohortData`, it will be grouped in this class.", - "It can be estimated if `P(sim)$estimateFuelClasses` is TRUE.", - "If supplied, it must be one of the names in `sim$nonForestedLCCGroups`")), - expectsInput("flammableRTM", "SpatRaster", "Flammable landcover i.e, conditions at start(sim). Taken from last layer of rstLCCs"), - expectsInput("nonForestedLCCGroups", "list", NA, paste( - "a named list of non-forested landcover groups, e.g. `list('wetland' = c(19, 23, 32))`.", - "This is only relevant if `landcoverDT` is not supplied")), - expectsInput("lightningMaps", "SpatRaster", NA, paste( - "A 4-layer SpatRaster of lightning: lightningDays, lightningDensity, positiveCG, positiveCGdensity")), - expectsInput("pixelGroupMap", "SpatRaster", NA, - "SpatRaster that defines the pixelGroups for cohortData table"), - expectsInput("projectedClimateRasters", "list", NA, paste( - "list of projected climate variables in raster stack form", - "named according to variable, with names of individual raster layers", - "following the convention 'year'; this will only be used if `currentClimateRasters` is ", - "not supplied")), - expectsInput("propFlammable", "SpatRaster", NA, paste( - "a conditional object created if rstLCC is also not supplied, ", - "a raster representing the proportion of flammable landcover in a pixel")), - expectsInput("rasterToMatch", "SpatRaster", NA, - "template raster used only to derive `flammableRTM` if the latter is absent"), - expectsInput("rstCurrentBurn", "SpatRaster", "binary raster with 1 representing annual burn"), - expectsInput("rstLCC_RTM", "SpatRaster", "a landcover raster - only used if `landcoverDT` is not supplied"), - expectsInput("sppEquiv", "data.table", "table of LandR species equivalencies"), - # expectsInput("standAgeMaps", "list", sourceURL = NA, - # "list of length 2 of maps of stand age in dataYear[[1]] and dataYear[[2]]", - # " used to create `cohortDatas`. This is only used if standAgeMap is not supplied"), - expectsInput("standAgeMap", "SpatRaster", "stand age map in study area; assumed to be ages at `start(sim)`"), - expectsInput("landcoverDT", "data.table", - "`pixelID` and relevant landcover classes for flammable pixels in each layer, ", - "i.e, conditions at start(sim). Taken from last layer of landcoverDTs"), - + expectsInput("climateVariablesForFire", "list", sourceURL = NA, + desc = paste( + "Named list (`ignition`, `spread`) of the layer names in `currentClimateRasters`", + "used by each process. A length-one list is used for both. Default is 'MDC' for both.")), + expectsInput("climateYear", "character", sourceURL = NA, + desc = paste( + "Optional year used instead of `time(sim)` when checking whether the projected climate", + "covers the current year. See PredictiveEcology/climateYear.")), + expectsInput("currentClimateRasters", "SpatRaster", sourceURL = NA, + desc = paste( + "Climate layers for the current year, one layer per climate variable.", + "If absent, built from `projectedClimateRasters`.")), + expectsInput("cohortData", "data.table", sourceURL = NA, + desc = "Cohorts by `pixelGroup` (LandR)."), + expectsInput("missingLCCgroup", "character", sourceURL = NA, + desc = paste( + "Forested pixels that are absent from `cohortData` are assigned to this class.", + "Must be one of the names of `nonForestedLCCGroups`.")), + expectsInput("flammableRTM", "SpatRaster", sourceURL = NA, + desc = paste( + "Binary raster of flammable pixels at `start(sim)`.", + "If absent, derived from the default landcover and `nonflammableLCC`.")), + expectsInput("nonForestedLCCGroups", "list", sourceURL = NA, + desc = paste( + "Named list of non-forested landcover groups, e.g. `list('wetland' = c(19, 23, 32))`.", + "Only used if `landcoverDT` is not supplied.")), + expectsInput("lightningMaps", "SpatRaster", sourceURL = NA, + desc = paste( + "Lightning layers: lightningDays, lightningDensity, positiveCG, positiveCGdensity.", + "Only `lightningDays` is used.")), + expectsInput("pixelGroupMap", "SpatRaster", sourceURL = NA, + desc = "Map of the `pixelGroup`s in `cohortData`."), + expectsInput("projectedClimateRasters", "list", sourceURL = NA, + desc = paste( + "Named list (one element per climate variable) of SpatRasters whose layers are named", + "'year'. Only used if `currentClimateRasters` is not supplied.")), + expectsInput("propFlammable", "SpatRaster", sourceURL = NA, + desc = paste( + "Proportion of flammable landcover in each pixel.", + "Created with the default landcover when `rstLCCs` is not supplied; not used otherwise.")), + expectsInput("rasterToMatch", "SpatRaster", sourceURL = NA, + desc = "Template raster for the study area."), + expectsInput("rstCurrentBurn", "SpatRaster", sourceURL = NA, + desc = "Binary raster with 1 where the pixel burned this year."), + expectsInput("rstLCC_RTM", "SpatRaster", sourceURL = NA, + desc = paste( + "Landcover raster, only used if `landcoverDT` is not supplied.", + "Defaults to the last layer of `rstLCCs`, else NTEMS landcover for `dataYear`.")), + expectsInput("sppEquiv", "data.table", sourceURL = NA, + desc = "Table of LandR species equivalencies; must have columns `sppEquivCol` and `fuelClassCol`."), + expectsInput("standAgeMap", "SpatRaster", sourceURL = NA, + desc = "Stand age (years) at `start(sim)`."), + expectsInput("landcoverDT", "data.table", sourceURL = NA, + desc = paste( + "`pixelID` plus one binary column per non-forest landcover group, for flammable pixels,", + "at `start(sim)`.")) ), outputObjects = bindrows( - createsOutput("currentClimateRasters", "SpatRaster", - "SpatRaster of climate layers at current time of sim; this will be generated ", - "in this module (if this is absent) from projectedClimateRasters"), - createsOutput("fireSense_igAndEscapePred_Covariates", "data.table", paste( - "data.table of covariates for ignition prediction, with pixelID column", - "corresponding to flammableRTM pixel index")), - createsOutput("fireSense_SpreadCovariates", "data.table", paste( - "data.table of covariates for spread prediction, with pixelID column", - "corresponding to flammableRTM pixel index")), - # createsOutput("flammableRTM", "list", "List of (2) binary SpatRaster of flammable landcover for years given by the list names"), - # createsOutput("landcoverDT", "data.table", - # "data.table with `pixelID` and relevant landcover classes for flammable pixels"), + createsOutput("currentClimateRasters", "SpatRaster", + desc = paste( + "Climate layers for the current year, one layer per climate variable.", + "Built from `projectedClimateRasters` if not supplied.")), + createsOutput("fireSense_igAndEscapePred_Covariates", "data.table", + desc = paste( + "Ignition and escape covariates at the aggregated (`igAggFactor`) resolution;", + "`pixelID` is the cell index of the aggregated raster.")), + createsOutput("fireSense_SpreadCovariates", "data.table", + desc = "Spread covariates; `pixelID` is the cell index of `flammableRTM`."), createsOutput("nonForest_timeSinceDisturbance", "SpatRaster", - desc = "time since burn for non-forest pixels") + desc = "Years since last burn, used to set `youngAge` in non-forest pixels.") ) )) -## event types -# - type `init` is required for initialization - doEvent.fireSense_dataPrepPredict <- function(sim, eventTime, eventType) { switch(eventType, init = { - ### check for more detailed object dependencies: - ### (use `checkObject` or similar) - - # do stuff for this event sim <- Init(sim) sim <- scheduleEvent(sim, time(sim) + 1, "fireSense_dataPrepPredict", "ageNonForest") sim <- scheduleEvent(sim, P(sim)$.runInitialTime, "fireSense_dataPrepPredict", "getClimateRasters") @@ -177,8 +163,6 @@ doEvent.fireSense_dataPrepPredict <- function(sim, eventTime, eventType) { sim <- scheduleEvent(sim, P(sim)$.runInitialTime, "fireSense_dataPrepPredict", "prepSpreadPredictData" ) } - # schedule future event(s) - # sim <- scheduleEvent(sim, P(sim)$.plotInitialTime, "fireSense_dataPrepPredict", "plot", eventPriority = 5.12) }, ageNonForest = { sim$nonForest_timeSinceDisturbance <- ageNonForest( @@ -193,7 +177,6 @@ doEvent.fireSense_dataPrepPredict <- function(sim, eventTime, eventType) { }, getClimateRasters = { sim <- getCurrentClimate(sim) - # sim$currentClimateRasters <- lapply(sim$currentClimateRasters, terra::unwrap) sim <- scheduleEvent( sim, time(sim) + P(sim)$fireTimeStep, "fireSense_dataPrepPredict", "getClimateRasters" @@ -211,6 +194,9 @@ doEvent.fireSense_dataPrepPredict <- function(sim, eventTime, eventType) { "fireSense_dataPrepPredict", "prepSpreadPredictData" ) }, + save = { + message("fireSense_dataPrepPredict: the save event does nothing") + }, warning(paste("Undefined event type: \'", current(sim)[1, "eventType", with = FALSE], "\' in module \'", current(sim)[1, "moduleName", with = FALSE], "\'", sep = "" @@ -219,10 +205,14 @@ doEvent.fireSense_dataPrepPredict <- function(sim, eventTime, eventType) { return(invisible(sim)) } -## event functions -# - keep event functions short and clean, modularize by calling subroutines from section below. - -### template initialization +#' Initialize the module +#' +#' Aligns `standAgeMap` and `rstLCC_RTM` to `rasterToMatch`, builds `landcoverDT` and +#' `nonForest_timeSinceDisturbance` if absent, and expands `climateVariablesForFire`. +#' +#' @param sim A `simList`. +#' +#' @return The `simList`, invisibly. Init <- function(sim) { # force it to be same as the other module's value @@ -240,17 +230,6 @@ Init <- function(sim) { sim$rstLCC_RTM } - # objs <- c(sim$standAgeMap, sim$rstLCC_RTM) - # if (!isInt(objs[[1]]) | !isInt(objs[[2]])) { - # objs <- lapply(objs, LandR::asInt) - # } - # standAgeMap <- objs[[1]] - # rstLCC <- objs[[2]] - - # sim$flammableRTM <- defineFlammable(rstLCC, - # nonFlammClasses = P(sim)$nonflammableLCC, - # to = sim$rasterToMatch) - if (is.null(sim$landcoverDT)) { sim$landcoverDT <- makeLandcoverDT( rstLCC = rstLCC, @@ -263,15 +242,13 @@ Init <- function(sim) { if (is.null(sim$nonForest_timeSinceDisturbance)) { fireYears <- c(P(sim)$dataYear - P(sim)$cutoffForYoungAge):P(sim)$dataYear firePolys <- fireSenseUtils::getFirePolygons( - fun = "sf::st_read", #I think it must be SF? + fun = "sf::st_read", years = fireYears, useInnerCache = FALSE, destinationPath = inputPath(sim), cropTo = sim$rasterToMatch, maskTo = sim$studyArea, - projectTo = sim$rasterToMatch)# |> - # Cache(userTags = c("firePolys", - # paste0(fireYears, collapse = ":"))) + projectTo = sim$rasterToMatch) sim$nonForest_timeSinceDisturbance <- makeTSD( year = P(sim)$dataYear, @@ -293,11 +270,15 @@ Init <- function(sim) { return(invisible(sim)) } -### template for plot events - +#' Set `sim$currentClimateRasters` for the current year +#' +#' If `sim$currentClimateRasters` is absent, takes layer `year` of each element of +#' `sim$projectedClimateRasters`. Stops if the result does not match `sim$pixelGroupMap`. +#' +#' @param sim A `simList`. +#' +#' @return The `simList`. getCurrentClimate <- function(sim) { - ## this function has been rewritten due to an undiagnosed bug involving - ## digest of a file-backed SpatRaster, and restartSpades() if (is.null(sim$currentClimateRasters)) { sim$currentClimateRasters <- sim$projectedClimateRasters[[1]] @@ -334,7 +315,7 @@ getCurrentClimate <- function(sim) { } ) - sim$currentClimateRasters <- terra::rast(thisYearsClimate)#lapply(thisYearsClimate, terra::wrap) + sim$currentClimateRasters <- terra::rast(thisYearsClimate) } if (!compareGeom(sim$pixelGroupMap, sim$currentClimateRasters, stopOnError = FALSE)) { @@ -345,6 +326,14 @@ getCurrentClimate <- function(sim) { return(sim) } +#' Age the time-since-disturbance raster by one year +#' +#' @param TSD `SpatRaster` of years since last burn. +#' @param rstCurrentBurn `SpatRaster` with 1 where the pixel burned this year, or `NULL`. +#' Burned pixels are reset to 0. +#' @param timeStep Not used; `TSD` always increases by 1. +#' +#' @return `TSD`, updated. ageNonForest <- function(TSD, rstCurrentBurn, timeStep) { TSDvals <- as.vector(TSD) TSDvals <- TSDvals + 1 @@ -352,14 +341,20 @@ ageNonForest <- function(TSD, rstCurrentBurn, timeStep) { burnVals <- as.vector(rstCurrentBurn) unburned <- is.na(burnVals) | burnVals == 0 TSDvals[!unburned] <- 0 - # rm(TSDvals, burnVals, unburned) rm(burnVals, unburned) } TSD <- setValues(TSD, TSDvals) - # gc() return(TSD) } +#' Build this year's ignition and escape covariates +#' +#' Creates `sim$fireSense_igAndEscapePred_Covariates` from fuel classes, non-forest landcover, +#' ignition climate and lightning days, aggregated by `igAggFactor`. +#' +#' @param sim A `simList`. +#' +#' @return The `simList`, invisibly. prepare_IgnitionAndEscapePredict <- function(sim) { ## get climate ignitionClimate <- sim$currentClimateRasters[[sim$climateVariablesForFire$ignition]] @@ -370,20 +365,6 @@ prepare_IgnitionAndEscapePredict <- function(sim) { if (!data.table::is.data.table(sim$sppEquiv)) data.table::setDT(sim$sppEquiv) if (is.null(mod$requiredFuelClasses)) mod$requiredFuelClasses <- sim$sppEquiv[[P(sim)$fuelClassCol]] - ## get fuel classes - # There may be fuel classes that disappear because of succession/dispersal limtation etc. - # e.g., Popu_tre disappeared in one example. Need to set up an zero SpatRaster for any that disappear - # requiredFuelClasses <- getRequiredFuelClasses( - # candidates = unique(unlist(sim$fireSense_IgnitionFitted$scaleData$dimnames)), - # climateNames = names(ignitionClimate), - # nonForestedLCCNames = names(sim$nonForestedLCCGroups) - # ) - # requiredFuelClasses <- unique(unlist(sim$fireSense_IgnitionFitted$scaleData$dimnames)) - # nonFuelNames <- c("pixelID", names(ignitionClimate), "youngAge", "year", names(sim$nonForestedLCCGroups)) - # requiredFuelClasses <- setdiff(requiredFuelClasses, nonFuelNames) - # # may be "lightningDays" or other - # requiredFuelClasses <- grep("lightning", value = TRUE, invert = TRUE, requiredFuelClasses) - # if ignition and spread fuel classes are the same, this should use Mod # to avoid doing it twice (in spreadFit, assuming people run both events) fuelCovsCoarse <- prepare_FuelCovsCoarse( @@ -423,115 +404,22 @@ prepare_IgnitionAndEscapePredict <- function(sim) { useCache = FALSE) set(sim$fireSense_igAndEscapePred_Covariates, NULL, "ignitions", NULL) - # sim$lightningMaps <- prepare_LightningData(sim$rasterToMatch, P(sim)$igAggFactor, - # dPath = inputPath(sim)) - # fuelClasses <- cohortsToFuelClasses( - # cohortData = sim$cohortData, - # sppEquiv = sim$sppEquiv, - # sppEquivCol = P(sim)$sppEquivCol, - # pixelGroupMap = sim$pixelGroupMap, - # landcoverDT = sim$landcoverDT, - # flammableRTM = sim$flammableRTM, - # fuelClassCol = P(sim)$fuelClassCol, - # cutoffForYoungAge = P(sim)$cutoffForYoungAge - # ) - - if (FALSE) { - fcs <- setdiff(names(fuelClasses), "youngAge") - fuelClasses <- as.data.table(as.data.frame(fuelClasses, cells = TRUE)) - setnames(fuelClasses, old = "cell", new = "pixelID") - - # make sure join is only landcoverDT - ignitionCovariates <- fuelClasses[sim$landcoverDT, on = c("pixelID")] - - ignitionCovariates[, rowcheck := rowSums(.SD), .SDcols = setdiff(names(ignitionCovariates), "pixelID")] - ## if all rows are 0, it must be a forested LCC absent from cohortData - ignitionCovariates[rowcheck == 0, eval(sim$missingLCCgroup) := 1] - set(ignitionCovariates, NULL, "rowcheck", NULL) - - # this must happen after the missingLC are evaluated - ignitionCovariates <- ignitionCovariates[, eval(fcs) := lapply(.SD, FUN = logMinB), .SDcols = fcs] - - if (P(sim)$nonForestCanBeYoungAge) { - ignitionCovariates[, YA_NF := as.vector(sim$nonForest_timeSinceDisturbance)[ignitionCovariates$pixelID] <= - P(sim)$cutoffForYoungAge] - ignitionCovariates[YA_NF == TRUE, youngAge := 1] - ignitionCovariates[, YA_NF := NULL] - } - - exclusiveCols <- c(fcs, names(sim$landcoverDT)) - exclusiveCols <- setdiff(exclusiveCols, c("pixelID", "youngAge")) - # TODO: I believe this triggers a warning - ignitionCovariates <- makeMutuallyExclusive( - dt = ignitionCovariates, - mutuallyExclusiveCols = list("youngAge" = exclusiveCols) - ) - - # climateCovariates <- rast(ignitionClimate) # it was a list - - climateCovariates <- na.omit(as.data.table(ignitionClimate, cells = TRUE)) - setnames(climateCovariates, new = c("pixelID", names(ignitionClimate))) - ignitionCovariates <- climateCovariates[ignitionCovariates, on = c("pixelID")] - - #put back into raster for aggregate - #TODO: this should be put in fireSenseUtils - covCols <- setdiff(names(ignitionCovariates), "pixelID") - rasObj <- rast(sim$flammableRTM) - rasObj[!is.na(sim$flammableRTM[])] <- 0 #make sure NA is 0 for mean during aggregate - out <- lapply(covCols, FUN = function(cov, ras = rasObj, covDT = ignitionCovariates) { - ras[covDT$pixelID] <- covDT[[cov]] - return(ras) - }) - names(out) <- covCols - ignitionCovariates <- rast(out) - - # now aggregate to match fitting resolution... - mods <- sim$fireSense_IgnitionFitted$modelList - if (!is.null(mods$fittingRes)) { - # following changes to ignitionModel - prediction will now occur at same spatial scale, - # location of predicted ignitions will be randomly drawn from finer scale - igAggFactor <- ceiling(mods$fittingRes / c(res(sim$rasterToMatch)[1])) - ignitionCovariates <- terra::aggregate(ignitionCovariates, fact = igAggFactor) - } - - # Lightning --> was at 1km resolution, so don't add prior to aggregation; add after - a <- postProcess(sim$lightningMaps[[2]], to = ignitionCovariates) - lightningTxt <- grep("lightn", unlist(sim$fireSense_IgnitionFitted$scaleData$dimnames), ignore.case = TRUE, value = TRUE) - names(a) <- lightningTxt - - ignitionCovariates <- c(ignitionCovariates, lightning = a) - - ignitionCovariates <- as.data.table(ignitionCovariates, cells = TRUE) - setnames (ignitionCovariates, old = "cell", new = "pixelID") - - } -# sim$fireSense_igAndEscapePred_Covariates <- ignitionCovariates - - # gc() return(invisible(sim)) } +#' Build this year's spread covariates +#' +#' Creates `sim$fireSense_SpreadCovariates` from fuel classes, non-forest landcover and +#' spread climate, at the resolution of `flammableRTM`. Stops if a non-forest pixel has forest fuel. +#' +#' @param sim A `simList`. +#' +#' @return The `simList`, invisibly. prepare_SpreadPredict <- function(sim) { spreadClimate <- sim$currentClimateRasters[[sim$climateVariablesForFire$spread]] if (is.null(spreadClimate)) stop("spreadClimate is NULL; there is a problem to debug") - # requiredFuelClasses <- getRequiredFuelClasses( - # candidates = names(sim$studyAreaWithSpreadParams$params[[1]]), - # climateNames = names(spreadClimate), - # nonForestedLCCNames = names(sim$nonForestedLCCGroups), - # fuelNames = sim$sppEquiv[[P(sim)$fuelClassCol]] - # ) - # requiredFuelClasses <- names(sim$studyAreaWithSpreadParams$params[[1]]) - # fuelNames <- sim$sppEquiv[[P(sim)$fuelClassCol]] - # nonFuelNames <- c("pixelID", names(spreadClimate), "youngAge", "year", names(sim$nonForestedLCCGroups)) - # nonLogitParams <- unique(c(fuelNames, nonFuelNames)) - # logitParams <- setdiff(requiredFuelClasses, nonLogitParams) - # requiredFuelClasses <- setdiff(requiredFuelClasses, logitParams) - # requiredFuelClasses <- - # # may be "lightningDays" or other --> likely not here because this is spread; just "in case" - # requiredFuelClasses <- grep("lightning", value = TRUE, invert = TRUE, requiredFuelClasses) - if (is.null(mod$requiredFuelClasses)) mod$requiredFuelClasses <- sim$sppEquiv[[P(sim)$fuelClassCol]] @@ -556,71 +444,8 @@ prepare_SpreadPredict <- function(sim) { nonForest_timeSinceDisturbance = sim$nonForest_timeSinceDisturbance, studyAreaName = P(sim)$.studyAreaName, useCache = FALSE # predict is annual, no point in caching - - #fuelClassCol = P(sim)$fuelClassCol, - #sppEquivCol = P(sim)$sppEquivCol, - #cutoffForYoungAge = P(sim)$cutoffForYoungAge, - #missingLCCgroup = sim$missingLCCgroup, - #nonForestedLCCGroups = sim$nonForestedLCCGroups, - #nonForest_timeSinceDisturbance = sim$nonForest_timeSinceDisturbance, - #nonForestCanBeYoungAge = P(sim)$nonForestCanBeYoungAge ) - # fuelClasses <- cohortsToFuelClasses( - # cohortData = sim$cohortData, - # pixelGroupMap = sim$pixelGroupMap, - # flammableRTM = sim$flammableRTM, - # sppEquiv = sim$sppEquiv, - # landcoverDT = sim$landcoverDT, - # fuelClassCol = P(sim)$fuelClassCol, - # sppEquivCol = P(sim)$sppEquivCol, - # cutoffForYoungAge = P(sim)$cutoffForYoungAge - # ) - # - # - # ## make columns for each fuel class - # # fuelClasses <- terra::app(fuelClasses, fun = logMinB) - # # terra app is horrifically slow - # # fcs <- setdiff(names(fuelClasses), "youngAge") - # fuelClasses <- as.data.table(as.data.frame(fuelClasses, cells = TRUE)) - # setnames(fuelClasses, old = "cell", new = "pixelID") - # - # # make sure join is only landcoverDT -- this adds the nonForest that are in sim$landcoverDT - # spreadCovariates <- fuelClasses[sim$landcoverDT, on = c("pixelID")] - # - # - # ## Nov 2023 - there should not be NA values - previously this used nafill - # ## if they return - use x <- as.data.table(nafill(vegData), 0) and setnames(x, names(vegData)) - # spreadCovariates[, rowcheck := rowSums(.SD), .SDcols = setdiff(names(spreadCovariates), "pixelID")] - # if (any(is.na(spreadCovariates$rowCheck))) { - # stop("NA in vegData columns of fireSense_dataPrepPredict... please contact module developers") - # } - # # if all rows are 0, it must be a forested LCC absent from cohortData - # spreadCovariates[rowcheck == 0, eval(sim$missingLCCgroup) := 1] - # set(spreadCovariates, NULL, "rowcheck", NULL) - # - # # Making exclusive has to be prior to logMinB, or else the 0 biomass become -0.59 or so - # # --> they need to stay at the minimum of 3.605 - # exclusiveCols <- c(fcs, names(sim$landcoverDT)) - # exclusiveCols <- setdiff(exclusiveCols, "pixelID") - # spreadCovariates <- makeMutuallyExclusive(dt = spreadCovariates, - # mutuallyExclusive = list("youngAge" = exclusiveCols)) - # - # spreadCovariates <- spreadCovariates[, eval(fcs) := lapply(.SD, FUN = logMinB), .SDcols = fcs] - # - # - # if (P(sim)$nonForestCanBeYoungAge) { - # # this should only alter non-forest - # spreadCovariates[, isNonForest := rowSums(.SD) > 0, .SDcols = names(sim$nonForestedLCCGroups)] - # spreadCovariates[, YA_NF := as.vector(sim$nonForest_timeSinceDisturbance)[spreadCovariates$pixelID] <= P(sim)$cutoffForYoungAge & - # isNonForest == TRUE] - # spreadCovariates[YA_NF == TRUE, youngAge := 1] - # spreadCovariates[, c("YA_NF", "isNonForest") := NULL] - # } - - # exclusiveCols <- c(fcs, names(sim$landcoverDT)) - # exclusiveCols <- setdiff(exclusiveCols, "pixelID") - # TODO: this chunk is untested 18/12/2024 climateCovariates <- spreadClimate |> as.data.frame(cells = TRUE) climateCovariates <- na.omit(climateCovariates) |> as.data.table() @@ -628,9 +453,6 @@ prepare_SpreadPredict <- function(sim) { setnames(climateCovariates, new = c("pixelID", names(spreadClimate))) spreadCovariates <- climateCovariates[spreadCovariates, on = c("pixelID")] - # spreadCovariates <- makeMutuallyExclusive(dt = spreadCovariates, - # mutuallyExclusive = list("youngAge" = exclusiveCols)) - nonFuelNames <- c("pixelID", names(spreadClimate), "youngAge") setcolorder(spreadCovariates, neworder = nonFuelNames) sim$fireSense_SpreadCovariates <- spreadCovariates @@ -640,7 +462,6 @@ prepare_SpreadPredict <- function(sim) { df <- sim$fireSense_SpreadCovariates fcs <- setdiff(colnames(df), c(nonFuelNames, nfCols)) df[df - min(df[[fcs[[1]]]], na.rm = TRUE) == 0] <- NA - # df[df - df[[fcs[[1]]]][1] == 0] <- NA fuelInSamePixelAsNonForest <- any(rowSums(!is.na(df[, ..fcs])) > 0 & (rowSums(df[, ..nfCols]) > 0)) @@ -651,15 +472,19 @@ prepare_SpreadPredict <- function(sim) { } +#' Supply default inputs +#' +#' Defaults for `climateVariablesForFire`, `rstLCC_RTM` (and `propFlammable`), `standAgeMap`, +#' `flammableRTM` and `landcoverDT`. +#' +#' @param sim A `simList`. +#' +#' @return The `simList`, invisibly. .inputObjects <- function(sim) { cacheTags <- c(currentModule(sim), "otherFunctions:.inputObjects") dPath <- asPath(inputPath(sim), 1) message(currentModule(sim), ": using dataPath '", dPath, "'.") - # objectSyns <- list(c("standAgeMap", "standAgeMap2011"), - # c("rstLCC", "rstLCC2011")) - # sim <- objectSynonyms(sim, objectSyns) - if (!suppliedElsewhere("climateVariablesForFire", sim)) { sim$climateVariablesForFire <- list( spread = "MDC", @@ -667,7 +492,6 @@ prepare_SpreadPredict <- function(sim) { ) } - # if (!suppliedElsewhere("rstLCC", sim)) { if (suppliedElsewhere("rstLCCs", sim)) { sim$rstLCC_RTM <- tail(sim$rstLCCs, 1)[[1]] } else { @@ -689,27 +513,15 @@ prepare_SpreadPredict <- function(sim) { sim$propFlammable <- rstLCC$flammableProp } - # } - if (!suppliedElsewhere("standAgeMap", sim)) { - # if (suppliedElsewhere("standAgeMaps", sim)) { - # sim$standAgeMap <- tail(sim$standAgeMaps, 1)[[1]] - # } else { sim$standAgeMap <- Cache(prepInputsStandAgeMap, rasterToMatch = sim$rasterToMatch, studyArea = sim$studyArea, destinationPath = dPath, startTime = P(sim)$dataYear, userTags = c(cacheTags, "prepInputsStandAgeMap2011")) - # } } - if (!suppliedElsewhere("fireSense_IgnitionFitted", sim)) { - if ("fireSense_IgnitionFit" %in% P(sim)$whichModulesToPrepare) { - stop("please supply fireSense_IgnitionFitted object") - } - } - if (!suppliedElsewhere("flammableRTM", sim)) { sim$flammableRTM <- defineFlammable(rstLCC, nonFlammClasses = P(sim)$nonflammableLCC, @@ -729,15 +541,3 @@ prepare_SpreadPredict <- function(sim) { return(invisible(sim)) } - -# -# getRequiredFuelClasses <- function(candidates, climateNames, nonForestedLCCNames, -# fuelNames = NULL) { -# nonFuelNames <- c("pixelID", climateNames, "youngAge", "year", nonForestedLCCNames) -# required <- if (is.null(fuelNames)) { -# setdiff(candidates, nonFuelNames) -# } else { -# intersect(candidates, c(fuelNames, nonFuelNames)) -# } -# grep("lightning", required, value = TRUE, invert = TRUE) -# } diff --git a/fireSense_dataPrepPredict.Rmd b/fireSense_dataPrepPredict.Rmd index 55828e5..f951d9f 100644 --- a/fireSense_dataPrepPredict.Rmd +++ b/fireSense_dataPrepPredict.Rmd @@ -45,18 +45,20 @@ download.file(url = "https://img.shields.io/badge/Made%20with-Markdown-1f425f.pn ### Module summary - -fireSense [@Marchal:2017a; @Marchal:2017b; @Marchal:2019] +Prepares, each year, the covariate tables that the fireSense [@Marchal:2017a; @Marchal:2017b; @Marchal:2019] predict modules use: -Provide a brief summary of what the module does / how to use the module. +- `fireSense_igAndEscapePred_Covariates` for *fireSense_IgnitionPredict* and *fireSense_EscapePredict*: fuel classes, non-forest landcover, `youngAge`, ignition climate and lightning days, aggregated by `igAggFactor`. +- `fireSense_SpreadCovariates` for *fireSense_SpreadPredict*: the same fuel, landcover and `youngAge` columns plus spread climate, at the resolution of `flammableRTM`. -Module documentation should be written so that others can use your module. -This is a template for module documentation, and should be changed to reflect your module. +Fuel classes come from `cohortData` and `pixelGroupMap`, grouped by the `fuelClassCol` column of `sppEquiv`. +The covariates are built by the same *fireSenseUtils* functions that *fireSense_dataPrepFit* uses, so they match the fitted models. ### Module inputs and parameters -Describe input data required by the module and how to obtain it (e.g., directly from online sources or supplied by other modules) -If `sourceURL` is specified, `downloadData("fireSense_dataPrepPredict", "..")` may be sufficient. +`cohortData`, `pixelGroupMap` and `rstCurrentBurn` come from the vegetation and fire modules during the simulation. +`sppEquiv`, `nonForestedLCCGroups`, `missingLCCgroup`, `lightningMaps`, `flammableRTM` and `landcoverDT` should be the ones used by *fireSense_dataPrepFit*. +Climate is either `currentClimateRasters`, or `projectedClimateRasters` with layers named `year`. +`igAggFactor` is overwritten in `init` with the value set in the other modules. Table \@ref(tab:moduleInputs-fireSense-dataPrepPredict) shows the full list of module inputs. @@ -78,18 +80,16 @@ knitr::kable(df_params, caption = "List of (ref:fireSense-dataPrepPredict) param ### Events - -Describe what happens for each event type. +All events after `init`, except `save`, repeat every `fireTimeStep` years. -### Plotting +- `init`: aligns `standAgeMap` and `rstLCC_RTM` to `rasterToMatch`; builds `landcoverDT` if absent; builds `nonForest_timeSinceDisturbance` if absent, from the fire polygons of the `cutoffForYoungAge` years up to `dataYear`. +- `getClimateRasters` (from `.runInitialTime`): if `currentClimateRasters` is absent, takes layer `year` of each element of `projectedClimateRasters`. Stops if it does not match `pixelGroupMap`. +- `prepIgAndEscPredictData` (from `.runInitialTime`): builds `fireSense_igAndEscapePred_Covariates`. Scheduled if `whichModulesToPrepare` has `fireSense_IgnitionPredict` or `fireSense_EscapePredict`. +- `prepSpreadPredictData` (from `.runInitialTime`): builds `fireSense_SpreadCovariates`. Scheduled if `whichModulesToPrepare` has `fireSense_SpreadPredict`. +- `ageNonForest` (from `time(sim) + 1`): adds 1 to `nonForest_timeSinceDisturbance` and resets pixels burned in `rstCurrentBurn` to 0. +- `save`: does nothing except emit a message. The module never schedules it. - -Write what is plotted. - -### Saving - - -Write what is saved. +The module does not plot or save anything. ### Module outputs @@ -103,8 +103,8 @@ knitr::kable(df_outputs, caption = "List of (ref:fireSense-dataPrepPredict) outp ### Links to other modules - -Describe any anticipated linkages to other modules, such as modules that supply input data or do post-hoc analysis. +Runs after *Biomass_borealDataPrep*, *fireSense_dataPrepFit*, *fireSense_IgnitionFit* and *fireSense_SpreadFit*, and supplies *fireSense_IgnitionPredict*, *fireSense_EscapePredict* and *fireSense_SpreadPredict*. +It is normally run as part of the [fireSense](https://github.com/PredictiveEcology/fireSense) module group. ### Getting help diff --git a/tests/testthat/setup-toyLandscape.R b/tests/testthat/setup-toyLandscape.R new file mode 100644 index 0000000..7a89be5 --- /dev/null +++ b/tests/testthat/setup-toyLandscape.R @@ -0,0 +1,127 @@ +## A 4 x 4 toy landscape on which every covariate can be worked out by hand. +## A setup file rather than a helper, so that it shares an environment with `moduleName` +## and `testPaths` from setup.R. +## +## Tests run inside the namespace of the package rendition; tables are handled with base +## subsetting and data.table::set() only, so nothing depends on data.table-awareness there. +## +## The module calls postProcess() and Cache() unqualified but does not list `reproducible` +## in `reqdPkgs`; in a project another module attaches it. It is always installed +## (SpaDES.core imports it), so attach it here. +suppressPackageStartupMessages(library(reproducible)) + +## Cell numbers (row-major) and what is in them: +## +## 1 2 3 4 PG1 PG1 PG2 PG2 pixel groups 1-4 are forest with cohorts +## 5 6 7 8 PG3 PG3 PG4 PG4 +## 9 10 11 12 wet wet grs grs non-forest: wetland, grass +## 13 14 15 16 --- --- wet X 13, 14: forest land cover but no cohorts +## 15: wetland burned 5 y ago; 16: not flammable +## +## PG1: Pice_mar 80 y B 2000 + Pinu_ban 40 y B 1000 -> class1 B = 3000 +## PG2: Popu_tre 50 y B 500 -> class2 B = 500 +## PG3: Pice_mar 10 y B 300 (max age 10 <= cutoff 15) -> youngAge +## PG4: Pice_mar 60 y B 100 + Popu_tre 60 y B 50 -> class1 B = 100, class2 B = 50 +toyRast <- function(vals) { + terra::rast(nrows = 4, ncols = 4, xmin = 0, xmax = 4, ymin = 0, ymax = 4, vals = vals, + crs = "EPSG:3005") +} + +toyLCC <- function() toyRast(c(rep(210, 8), 19, 19, 16, 16, 210, 210, 19, 20)) + +toyCohortData <- function() { + data.table::data.table( + pixelGroup = c(1L, 1L, 2L, 3L, 4L, 4L), + speciesCode = factor(c("Pice_mar", "Pinu_ban", "Popu_tre", "Pice_mar", "Pice_mar", "Popu_tre")), + age = c(80L, 40L, 50L, 10L, 60L, 60L), + B = c(2000L, 1000L, 500L, 300L, 100L, 50L) + ) +} + +toySppEquiv <- function() { + data.table::data.table(LandR = c("Pice_mar", "Pinu_ban", "Popu_tre"), + FuelClass = c("class1", "class1", "class2")) +} + +toyLandcoverDT <- function() { + data.table::data.table(pixelID = 1:15, + wetland = as.integer(1:15 %in% c(9, 10, 15)), + grass = as.integer(1:15 %in% c(11, 12))) +} + +## climate: layer `year` of MDC is (Y - 2000) * 100 + cell number, so both the year that +## was taken and the cell it came from can be read off any value; `Tmax` is its negative +toyClimate <- function(years = 2001:2003) { + mk <- function(sign) { + r <- terra::rast(lapply(years, function(y) toyRast(sign * ((y - 2000) * 100 + 1:16)))) + names(r) <- paste0("year", years) + r + } + list(MDC = mk(1), Tmax = mk(-1)) +} + +toyObjects <- function() { + list( + rasterToMatch = toyRast(1), + studyArea = terra::as.polygons(terra::ext(toyRast(1)), crs = "EPSG:3005"), + flammableRTM = toyRast(c(rep(1, 15), 0)), + pixelGroupMap = toyRast(c(1, 1, 2, 2, 3, 3, 4, 4, rep(NA, 8))), + cohortData = toyCohortData(), + sppEquiv = toySppEquiv(), + landcoverDT = toyLandcoverDT(), + nonForestedLCCGroups = list(wetland = 19L, grass = 16L), + missingLCCgroup = "grass", + ## years since fire: 30 everywhere except the wetland cell 15 and the forest cell 1 + nonForest_timeSinceDisturbance = toyRast(c(5, rep(30, 13), 5, 30)), + standAgeMap = toyRast(50), + ## `rstLCCs` is NOT in the metadata, so simInit() does not put it in the simList -- but + ## `suppliedElsewhere("rstLCCs", sim)` in `.inputObjects` (l.509) still sees it, which is + ## the only way to take the branch that does not try to download NTEMS landcover. The + ## branch then sets `sim$rstLCC_RTM <- tail(sim$rstLCCs, 1)[[1]]`, and since `sim$rstLCCs` + ## really is NULL by then, `sim$rstLCC_RTM` ends up NULL. See test-inputObjects.R. + ## Supplying `landcoverDT` is what keeps `Init()` from needing `rstLCC_RTM` at all. + rstLCCs = list(toyLCC()), + projectedClimateRasters = toyClimate(), + climateVariablesForFire = list(ignition = "MDC", spread = "MDC"), + lightningMaps = { + r <- c(toyRast(1:16), toyRast(1001:1016)) + names(r) <- c("lightningDays", "lightningDensity") + r + } + ) +} + +toyPrepSim <- function(objects = toyObjects(), params = list(), + times = list(start = 2001, end = 2001)) { + params <- utils::modifyList(list(igAggFactor = 2), params) + sim <- SpaDES.core::simInit( + times = c(times, timeunit = "year"), + modules = moduleName, + params = stats::setNames(list(params), moduleName), + objects = objects, + paths = testPaths + ) + ## Work around a module defect, deliberately and visibly: `.inputObjects` (l.509-510) takes + ## the `suppliedElsewhere("rstLCCs", sim)` branch -- which is the only branch that does not + ## try to download NTEMS landcover -- and sets `sim$rstLCC_RTM <- tail(sim$rstLCCs, 1)[[1]]`. + ## `rstLCCs` is not a declared input, so it is not in the simList at that point and the + ## result is NULL. `Init()` (l.241) then calls `.compareRas(rasterToMatch, NULL)`, which + ## errors with "subscript out of bounds", so the module cannot reach `init` at all. + ## Restoring it here lets the covariate tests exercise the code past that point; the defect + ## itself is pinned, unblessed, in test-inputObjects.R. + if (is.null(sim$rstLCC_RTM)) sim$rstLCC_RTM <- toyLCC() + sim +} + +toyPrepRun <- function(...) SpaDES.core::spades(toyPrepSim(...), debug = FALSE) + +## a covariate table as a data.frame ordered by pixelID +covDF <- function(dt) { + df <- as.data.frame(dt) + df[order(df$pixelID), , drop = FALSE] +} + +evOf <- function(dt, type) { + df <- as.data.frame(dt) + df[df$moduleName == "fireSense_dataPrepPredict" & df$eventType == type, , drop = FALSE] +} diff --git a/tests/testthat/test-ageNonForest.R b/tests/testthat/test-ageNonForest.R new file mode 100644 index 0000000..93279ed --- /dev/null +++ b/tests/testthat/test-ageNonForest.R @@ -0,0 +1,39 @@ +## `ageNonForest()` is the one helper in this module with no dependencies at all: it takes +## two rasters and returns one. Every value below is worked out by hand from the toy TSD. + +test_that("ageNonForest adds one year everywhere when nothing burned", { + TSD <- toyRast(c(5, rep(30, 13), 5, 30)) + out <- ageNonForest(TSD = TSD, rstCurrentBurn = NULL, timeStep = 1) + expect_s4_class(out, "SpatRaster") + ## hand-computed: every cell + 1 + expect_identical(as.vector(terra::values(out)), c(6, rep(31, 13), 6, 31)) + ## geometry must survive: the values are written back with setValues() + expect_true(terra::compareGeom(out, TSD, stopOnError = FALSE)) +}) + +test_that("ageNonForest resets burned pixels to zero and ages the rest", { + TSD <- toyRast(c(5, rep(30, 13), 5, 30)) + ## cell 2 burned (1); cell 3 is NA (no burn data, i.e. did not burn); cell 16 burned + burn <- toyRast(c(0, 1, NA, rep(0, 12), 1)) + out <- ageNonForest(TSD = TSD, rstCurrentBurn = burn, timeStep = 1) + ## hand-computed: 5+1; burned -> 0; NA is unburned so 30+1; cell 15 (TSD 5, unburned) -> 6; + ## cell 16 burned -> 0 + expect_identical(as.vector(terra::values(out)), c(6, 0, rep(31, 12), 6, 0)) +}) + +test_that("ageNonForest treats NA and 0 in rstCurrentBurn identically", { + TSD <- toyRast(rep(10, 16)) + allNA <- toyRast(rep(NA_real_, 16)) + allZero <- toyRast(rep(0, 16)) + expect_identical(as.vector(terra::values(ageNonForest(TSD, allNA, 1))), rep(11, 16)) + expect_identical(as.vector(terra::values(ageNonForest(TSD, allNA, 1))), + as.vector(terra::values(ageNonForest(TSD, allZero, 1)))) +}) + +test_that("ageNonForest ignores timeStep, as documented", { + ## `timeStep` is accepted but the raster always advances by exactly one year. Asserted so + ## that honouring it becomes a deliberate, visible change rather than a silent one. + TSD <- toyRast(rep(10, 16)) + expect_identical(as.vector(terra::values(ageNonForest(TSD, NULL, timeStep = 1))), + as.vector(terra::values(ageNonForest(TSD, NULL, timeStep = 10)))) +}) diff --git a/tests/testthat/test-covariates.R b/tests/testthat/test-covariates.R new file mode 100644 index 0000000..1b401e9 --- /dev/null +++ b/tests/testthat/test-covariates.R @@ -0,0 +1,180 @@ +## The two covariate-assembly events. The structural facts -- which pixels appear, which +## climate layer and which year they carry, the column order, the non-forest columns -- are +## worked out by hand from the toy landscape. The fuel-class magnitudes come out of +## fireSenseUtils and are PINNED FROM AN OBSERVED RUN of this suite; they are identical on +## `origin/development` and on this branch (verified by running this file against both), but +## they are a regression pin on fireSenseUtils' internals, not a hand-derived truth. + +test_that("prepSpreadPredictData builds one row per flammable pixel with this year's climate", { + sim <- toyPrepRun() + df <- covDF(sim$fireSense_SpreadCovariates) + + ## the 16th toy pixel is non-flammable, so exactly 15 rows at flammableRTM resolution + expect_identical(df$pixelID, 1:15) + ## the declared column order: pixelID, the climate layers, youngAge, then the fuels + expect_identical(names(df)[1:3], c("pixelID", "MDC", "youngAge")) + expect_setequal(names(df), c("pixelID", "MDC", "youngAge", "class1", "class2", + "wetland", "grass")) + + ## hand-computed: spread climate is MDC, and toy MDC for 2001 is 100 + cellNumber, so a + ## value of 100 + pixelID proves both the right variable and the right year were taken + expect_identical(df$MDC, 100 + 1:15) + + ## hand-computed from the toy map: cells 9, 10, 15 are wetland. + expect_identical(which(df$wetland == 1L), c(9L, 10L, 15L)) + ## grass is 11 and 12 from the map, PLUS 13 and 14: those two are forested landcover (210) + ## with no cohort in `pixelGroupMap`, and `missingLCCgroup` is "grass", so the module + ## assigns them there. That is the documented behaviour of `missingLCCgroup` + ## (metadata l.106-109), not an accident, so it is asserted as correct. + expect_identical(which(df$grass == 1L), c(11L, 12L, 13L, 14L)) + + ## hand-computed: youngAge is set where max cohort age <= cutoffForYoungAge (pixel group 3, + ## cells 5 and 6, whose only cohort is 10 years old) and in non-forest pixels burned within + ## the cutoff (cell 15, TSD 5). Nowhere else. + expect_identical(which(df$youngAge == 1), c(5L, 6L, 15L)) +}) + +test_that("spread fuel classes follow the sppEquiv fuel column (regression pin)", { + ## REGRESSION PIN: these are the values observed from + ## fireSenseUtils:::fireSenseCovariatesCreate() when this suite was written. They are + ## identical on origin/development and on this branch (verified by running this file + ## against both), but they are pinned observations of that package's internals, NOT + ## hand-derived truths: only the structural assertions below are hand-checked. They are + ## recorded so that a change in how biomass is mapped to fuel classes is visible here + ## rather than downstream in a fitted model. + sim <- toyPrepRun() + df <- covDF(sim$fireSense_SpreadCovariates) + expect_equal(df$class1[1:4], c(8.006368, 8.006368, 3.605170, 3.605170), tolerance = 1e-6) + expect_equal(df$class2[1:4], c(3.605170, 3.605170, 6.214608, 6.214608), tolerance = 1e-6) + ## structural, hand-checked, and independent of the pinning: pixel group 1 (cells 1-2) is + ## the only group with two class1 cohorts, so it must carry the largest class1 value, and + ## pixel group 2 (cells 3-4) is pure class2, so its class2 must exceed pixel group 1's + expect_true(all(df$class1[1:2] > df$class1[3:4])) + expect_true(all(df$class2[3:4] > df$class2[1:2])) + ## a forested pixel with no cohorts (13, 14) gets the baseline, not NA + expect_false(anyNA(df$class1)) + expect_false(anyNA(df$class2)) +}) + +test_that("non-forest pixels never carry forest fuel (the module's own sanity check)", { + sim <- toyPrepRun() + df <- covDF(sim$fireSense_SpreadCovariates) + nonForest <- df$wetland == 1L | df$grass == 1L + baseline <- min(df$class1) + expect_true(all(df$class1[nonForest] == baseline)) + expect_true(all(df$class2[nonForest] == min(df$class2))) +}) + +test_that("prepIgAndEscPredictData aggregates to the igAggFactor grid", { + sim <- toyPrepRun() + df <- covDF(sim$fireSense_igAndEscapePred_Covariates) + + ## igAggFactor 2 over a 4 x 4 grid gives a 2 x 2 grid, i.e. four coarse cells; the fourth + ## contains the non-flammable pixel 16 and drops out, leaving three rows + expect_identical(df$pixelID, 1:3) + ## the year column carries time(sim), not the layer index + expect_identical(unique(df$year), 2001) + expect_setequal(names(df), c("pixelID", "MDC", "youngAge", "wetland", "grass", + "class1", "class2", "year", "lightningDays")) + ## `ignitions` belongs to the Fit table and is dropped here + expect_false("ignitions" %in% names(df)) + + ## hand-computed: coarse cell 1 is fine cells 1, 2, 5, 6, whose MDC is 101, 102, 105, 106, + ## mean 103.5; coarse cell 2 is 3, 4, 7, 8 -> 105.5; coarse cell 3 is 9, 10, 13, 14 -> 111.5 + expect_equal(df$MDC, c(103.5, 105.5, 111.5)) + ## hand-computed: youngAge is the mean over the coarse cell; cells 5 and 6 of four are + ## young in coarse cell 1 -> 0.5; none in coarse cell 2; none in coarse cell 3 (cell 15 is + ## in coarse cell 4, which is dropped) + expect_equal(df$youngAge, c(0.5, 0, 0)) + ## hand-computed: coarse cell 3 covers wetlands 9, 10 and grasses 11? no -- fine cells + ## 9, 10, 13, 14: 9 and 10 are wetland -> 0.5 wetland + expect_equal(df$wetland, c(0, 0, 0.5)) +}) + +test_that("igAggFactor 1 leaves the ignition covariates at flammableRTM resolution", { + sim <- toyPrepRun(params = list(igAggFactor = 1)) + df <- covDF(sim$fireSense_igAndEscapePred_Covariates) + ## no aggregation: the same 15 flammable pixels as the spread table, with the same climate + expect_identical(df$pixelID, 1:15) + expect_equal(df$MDC, 100 + 1:15) + expect_identical(which(df$wetland == 1), c(9L, 10L, 15L)) +}) + +test_that("a different ignition climate variable selects a different layer", { + ## Tmax is the negative of MDC in the toy climate, so the sign of the aggregated value + ## alone proves which variable the ignition table used. + objs <- toyObjects() + objs$climateVariablesForFire <- list(ignition = "Tmax", spread = "MDC") + sim <- toyPrepRun(objs) + ig <- covDF(sim$fireSense_igAndEscapePred_Covariates) + sp <- covDF(sim$fireSense_SpreadCovariates) + expect_true("Tmax" %in% names(ig)) + expect_false("MDC" %in% names(ig)) + expect_equal(ig$Tmax, c(-103.5, -105.5, -111.5)) + ## and the spread table is unaffected + expect_identical(sp$MDC, 100 + 1:15) +}) + +test_that("a climate variable that is not in currentClimateRasters is an error", { + objs <- toyObjects() + objs$climateVariablesForFire <- list(ignition = "notAVariable", spread = "MDC") + ## terra's own subsetting rejects it before the module's NULL guard can fire; asserted as + ## an error with a message, so that silently returning an empty table would fail here + expect_error(toyPrepRun(objs), "invalid name") +}) + +test_that("KNOWN BUG: the covariate tables do NOT advance with simulation time", { + ## Intended behaviour: each year's covariates carry that year's climate. They do not. + ## `getCurrentClimate()` (module source l.296) only builds `sim$currentClimateRasters` when + ## it `is.null()`. The `getClimateRasters` event reschedules itself every year, but from the + ## second year on the object is already populated, so the whole build is skipped and the + ## climate stays frozen at `start(sim)`. Every later year's covariates are silently stale. + ## + ## The toy climate encodes the year in the value (year MDC cell n = (Y-2000)*100 + n), + ## so this is unambiguous: after running 2001 -> 2003 the table still holds year2001 values. + ## Below, the INTENDED assertions are stated and recorded as currently failing. When the + ## staleness is fixed, `expect_failure()` will itself fail and these must be unwrapped. + sim <- toyPrepRun(times = list(start = 2001, end = 2003)) + sp <- covDF(sim$fireSense_SpreadCovariates) + ig <- covDF(sim$fireSense_igAndEscapePred_Covariates) + + ## INTENDED: the final year is 2003, so MDC should be 300 + pixelID + expect_failure(expect_identical(sp$MDC, 300 + 1:15)) + expect_failure(expect_equal(ig$MDC, c(303.5, 305.5, 311.5))) + + ## What actually happens, recorded so the staleness is visible and the fix is detectable: + ## the year2001 layer, unchanged after three simulated years. + expect_identical(sp$MDC, 100 + 1:15) + + ## and the giveaway that this really is staleness rather than a mislabelled table: the + ## `year` column DOES track time(sim), so the table claims 2003 while carrying 2001 climate + expect_identical(unique(ig$year), 2003) +}) + +test_that("KNOWN BUG: lightningDays is NaN in the ignition table", { + ## `lightningMaps` is supplied with a `lightningDays` layer holding 1:16, so the aggregated + ## mean over each 2 x 2 coarse cell is finite and easily computed by hand (5.5, 7.5, 13.5). + ## What comes back is NaN for every row. The layer is selected correctly -- single-bracket + ## `sim$lightningMaps["lightningDays"]` (module source l.416) returns the right one-layer + ## raster with the right values, checked directly -- so the loss happens inside + ## `fireSenseUtils::mergePreparedCovs()`. Not diagnosed further here. + ## + ## The INTENDED assertion is stated and recorded as failing. This test does NOT assert that + ## NaN is correct; it asserts that the column is present and that the intended value is not + ## yet produced. + sim <- toyPrepRun() + ig <- covDF(sim$fireSense_igAndEscapePred_Covariates) + expect_true("lightningDays" %in% names(ig)) + ## INTENDED: the mean of the fine-cell lightningDays over each coarse cell + expect_failure(expect_equal(ig$lightningDays, c(5.5, 7.5, 13.5))) + ## recorded actual state, so that producing ANY finite value here will trip this test + expect_true(all(is.nan(ig$lightningDays))) +}) + +test_that("ageNonForest runs as an event and ages the TSD raster each year", { + sim <- toyPrepRun(times = list(start = 2001, end = 2002)) + ## hand-computed: the toy TSD starts at c(5, 30 x 13, 5, 30) and the event fires once, + ## at 2002, with no rstCurrentBurn supplied + expect_identical(as.vector(terra::values(sim$nonForest_timeSinceDisturbance)), + c(6, rep(31, 13), 6, 31)) +}) diff --git a/tests/testthat/test-getCurrentClimate.R b/tests/testthat/test-getCurrentClimate.R new file mode 100644 index 0000000..878f9fb --- /dev/null +++ b/tests/testthat/test-getCurrentClimate.R @@ -0,0 +1,71 @@ +## `getCurrentClimate()` picks this year's layer out of each element of +## `projectedClimateRasters`. The toy climate encodes both the year and the cell in the +## value -- layer `year` of MDC holds `(Y - 2000) * 100 + cellNumber` -- so an assertion +## on a value proves exactly which layer of which variable was taken. + +test_that("getCurrentClimate takes the layer for time(sim), one per climate variable", { + sim <- toyPrepSim(times = list(start = 2002, end = 2002)) + sim <- getCurrentClimate(sim) + + ## one layer per element of projectedClimateRasters, named for the variable not the year + expect_identical(names(sim$currentClimateRasters), c("MDC", "Tmax")) + ## hand-computed from the toy encoding: year2002 MDC cell n = 200 + n; Tmax is its negative + expect_identical(as.vector(terra::values(sim$currentClimateRasters[["MDC"]])), 200 + 1:16) + expect_identical(as.vector(terra::values(sim$currentClimateRasters[["Tmax"]])), -(200 + 1:16)) +}) + +test_that("getCurrentClimate leaves a supplied currentClimateRasters untouched", { + objs <- toyObjects() + ## a raster that matches pixelGroupMap's geometry but holds a value no toy year produces + objs$currentClimateRasters <- toyRast(rep(999, 16)) + names(objs$currentClimateRasters) <- "MDC" + sim <- toyPrepSim(objs, times = list(start = 2002, end = 2002)) + sim <- getCurrentClimate(sim) + expect_identical(names(sim$currentClimateRasters), "MDC") + expect_identical(as.vector(terra::values(sim$currentClimateRasters)), rep(999, 16)) +}) + +test_that("getCurrentClimate errors when the climate does not match pixelGroupMap", { + objs <- toyObjects() + ## a 2 x 2 pixelGroupMap over the same extent: same extent and CRS, different resolution + objs$pixelGroupMap <- terra::rast(nrows = 2, ncols = 2, xmin = 0, xmax = 4, + ymin = 0, ymax = 4, vals = 1, crs = "EPSG:3005") + sim <- toyPrepSim(objs) + expect_error(getCurrentClimate(sim), "mismatch in resolution detected") +}) + +test_that("getCurrentClimate errors on a supplied currentClimateRasters of the wrong geometry", { + ## the second compareGeom() guard, on the branch where nothing is built + objs <- toyObjects() + objs$currentClimateRasters <- terra::rast(nrows = 2, ncols = 2, xmin = 0, xmax = 4, + ymin = 0, ymax = 4, vals = 1, crs = "EPSG:3005") + sim <- toyPrepSim(objs) + expect_error(getCurrentClimate(sim), "mismatch in resolution detected") +}) + +test_that("getCurrentClimate messages when the requested year is beyond the projection", { + ## toy climate covers 2001-2003; asking for 2005 must warn the user that a layer is reused + objs <- toyObjects() + objs$projectedClimateRasters <- toyClimate(2001:2003) + sim <- toyPrepSim(objs, times = list(start = 2005, end = 2005)) + expect_message(try(getCurrentClimate(sim), silent = TRUE), + "re-using projected climate layers from") +}) + +test_that("KNOWN BUG: climateYear does not override time(sim) for layer selection", { + ## Intended behaviour (module source, ~lines 314-318): `currentYear` is `sim$climateYear` + ## when supplied, and `currentYear` chooses the climate layer. It does not: the inner + ## lapply's formal `currentYear = time(sim)` shadows the outer `currentYear`, so the layer + ## is always `year` and `climateYear` only reaches the "beyond the projection" + ## message. This test states the INTENDED assertion and records that it currently fails; + ## when the shadowing is fixed, `expect_failure()` will itself fail and this test must be + ## unwrapped. It deliberately does not assert that the present behaviour is correct. + objs <- toyObjects() + objs$climateYear <- 2003 + sim <- toyPrepSim(objs, times = list(start = 2001, end = 2001)) + sim <- getCurrentClimate(sim) + expect_failure( + ## year2003 MDC cell n = 300 + n; what is actually returned is year2001, i.e. 100 + n + expect_identical(as.vector(terra::values(sim$currentClimateRasters[["MDC"]])), 300 + 1:16) + ) +}) diff --git a/tests/testthat/test-init.R b/tests/testthat/test-init.R new file mode 100644 index 0000000..581d4da --- /dev/null +++ b/tests/testthat/test-init.R @@ -0,0 +1,144 @@ +## `Init()` and the `init` event: what gets built, and what gets scheduled. + +test_that("init produces every declared output on the toy landscape", { + sim <- toyPrepRun() + md <- SpaDES.core::moduleMetadata(module = moduleName, path = modulePath) + for (obj in md$outputObjects$objectName) { + expect_false(is.null(sim[[obj]]), info = obj) + } + expect_s4_class(sim$currentClimateRasters, "SpatRaster") + expect_s4_class(sim$nonForest_timeSinceDisturbance, "SpatRaster") + expect_s3_class(sim$fireSense_igAndEscapePred_Covariates, "data.table") + expect_s3_class(sim$fireSense_SpreadCovariates, "data.table") +}) + +test_that("Init expands a length-one climateVariablesForFire to ignition and spread", { + objs <- toyObjects() + objs$climateVariablesForFire <- list("MDC") + sim <- SpaDES.core::spades(toyPrepSim(objs), events = "init", debug = FALSE) + expect_identical(sim$climateVariablesForFire, list(ignition = list("MDC"), spread = list("MDC"))) +}) + +test_that("Init leaves a two-element climateVariablesForFire alone", { + objs <- toyObjects() + objs$climateVariablesForFire <- list(ignition = "Tmax", spread = "MDC") + sim <- SpaDES.core::spades(toyPrepSim(objs), events = "init", debug = FALSE) + expect_identical(sim$climateVariablesForFire, list(ignition = "Tmax", spread = "MDC")) +}) + +test_that("Init builds landcoverDT from rstLCC_RTM when it is not supplied", { + ## toy land cover: cells 9, 10, 15 are wetland (19), cells 11, 12 are grass (16), + ## cells 1-8, 13, 14 are forest (210) and cell 16 is class 20, which is non-flammable + ## in flammableRTM, so it is dropped. Every value below follows from that map. + ## + ## `rstLCC_RTM` is set directly on the simList rather than passed to `simInit()`, because + ## `.inputObjects` destroys any supplied value (see test-inputObjects.R). Calling `Init()` + ## directly keeps this test about `Init()`'s landcover logic rather than about that defect. + ## `landcoverDT` must still be supplied to `simInit()`: without it `.inputObjects` (l.552) + ## calls `makeLandcoverDT()` with the NULL `rstLCC_RTM` and errors before `Init()` is + ## reached. It is cleared afterwards so that `Init()` is the thing that builds it. + sim <- toyPrepSim(toyObjects()) + sim$landcoverDT <- NULL + sim <- SpaDES.core::spades(sim, events = "init", debug = FALSE) + dt <- covDF(sim$landcoverDT) + expect_identical(sort(names(dt)), c("grass", "pixelID", "wetland")) + ## only the 15 flammable pixels, in cell order + expect_identical(dt$pixelID, 1:15) + expect_identical(which(dt$wetland == 1L), c(9L, 10L, 15L)) + expect_identical(which(dt$grass == 1L), c(11L, 12L)) + ## no pixel is in two groups at once + expect_true(all(dt$wetland + dt$grass <= 1L)) +}) + +test_that("KNOWN BUG: Init resamples landcover continuously, destroying the class codes", { + ## `Init()` (l.241-245) realigns `rstLCC_RTM` with `postProcess(..., to = rasterToMatch)` + ## when the geometries differ. `postProcess()` defaults to bilinear resampling, which is + ## wrong for a *categorical* raster: averaging class codes 19, 16, 210 and 20 produces + ## values like 186.08 and 43.91, which are not landcover classes at all. `makeLandcoverDT()` + ## then matches none of them, so every non-forest column comes back NA -- silently, with + ## the right number of rows and the right column names. + ## + ## Verified directly: postProcess(disagg(toyLCC(), 2), to = rasterToMatch) returns + ## c(210, 210, 210, 210, 186.125, ...) rather than the original integer codes. + ## The fix is nearest-neighbour (`method = "near"`), as `disagg()` uses here. + ## + ## INTENDED: realigning a land-cover map that merely differs in resolution must give the + ## same landcoverDT as the aligned one. Stated and recorded as failing. + sim <- toyPrepSim(toyObjects()) + sim$landcoverDT <- NULL + sim$rstLCC_RTM <- terra::disagg(toyLCC(), fact = 2, method = "near") + sim <- SpaDES.core::spades(sim, events = "init", debug = FALSE) + dt <- covDF(sim$landcoverDT) + + ## the shape is right, which is what makes this dangerous + expect_identical(dt$pixelID, 1:15) + expect_identical(sort(names(dt)), c("grass", "pixelID", "wetland")) + + ## INTENDED: the same groups as the matching-resolution case above + expect_failure(expect_identical(which(dt$wetland == 1L), c(9L, 10L, 15L))) + expect_failure(expect_identical(which(dt$grass == 1L), c(11L, 12L))) + + ## recorded actual state: the classes are gone entirely + expect_true(all(is.na(dt$wetland))) + expect_true(all(is.na(dt$grass))) +}) + +test_that("init schedules exactly the events implied by whichModulesToPrepare", { + evs <- function(which) { + sim <- SpaDES.core::spades( + toyPrepSim(params = list(whichModulesToPrepare = which)), events = "init", debug = FALSE) + sort(as.data.frame(SpaDES.core::events(sim))$eventType) + } + ## ageNonForest and getClimateRasters are unconditional; the two prep events are not + expect_identical(evs("fireSense_SpreadPredict"), + c("ageNonForest", "getClimateRasters", "prepSpreadPredictData")) + expect_identical(evs("fireSense_IgnitionPredict"), + c("ageNonForest", "getClimateRasters", "prepIgAndEscPredictData")) + ## the ignition/escape table is shared: EscapePredict alone schedules it too + expect_identical(evs("fireSense_EscapePredict"), + c("ageNonForest", "getClimateRasters", "prepIgAndEscPredictData")) + expect_identical(evs(c("fireSense_SpreadPredict", "fireSense_IgnitionPredict")), + c("ageNonForest", "getClimateRasters", "prepIgAndEscPredictData", + "prepSpreadPredictData")) + ## a Fit module name is not a Predict module name: no covariate table is prepared + expect_identical(evs("fireSense_EscapeFit"), c("ageNonForest", "getClimateRasters")) +}) + +test_that("the default whichModulesToPrepare is the three Predict modules doEvent tests for", { + ## The default used to include `fireSense_EscapeFit`, which `doEvent` never tests for. + md <- SpaDES.core::moduleMetadata(module = moduleName, path = modulePath) + default <- md$parameters$default[[which(md$parameters$paramName == "whichModulesToPrepare")]] + expect_setequal(default, c("fireSense_IgnitionPredict", "fireSense_EscapePredict", + "fireSense_SpreadPredict")) + + ## with the default, init schedules both covariate events + sim <- SpaDES.core::spades(toyPrepSim(), events = "init", debug = FALSE) + expect_identical(sort(as.data.frame(SpaDES.core::events(sim))$eventType), + c("ageNonForest", "getClimateRasters", "prepIgAndEscPredictData", + "prepSpreadPredictData")) +}) + +test_that("the save event does nothing", { + sim <- SpaDES.core::spades(toyPrepSim(), events = "init", debug = FALSE) + before <- sapply(ls(sim), function(nm) reproducible::.robustDigest(sim[[nm]])) + queued <- as.data.frame(SpaDES.core::events(sim)) + + sim <- SpaDES.core::scheduleEvent(sim, SpaDES.core::start(sim), moduleName, "save") + expect_message( + expect_no_warning(sim <- SpaDES.core::spades(sim, events = "save", debug = FALSE)), + "the save event does nothing") + + expect_true("save" %in% evOf(SpaDES.core::completed(sim), "save")$eventType) + expect_identical(sapply(ls(sim), function(nm) reproducible::.robustDigest(sim[[nm]])), before) + ## nothing new is scheduled + expect_identical(as.data.frame(SpaDES.core::events(sim)), queued) +}) + +test_that("events reschedule themselves one fireTimeStep ahead", { + sim <- toyPrepRun(times = list(start = 2001, end = 2001)) + queued <- as.data.frame(SpaDES.core::events(sim)) + expect_setequal(queued$eventType, + c("ageNonForest", "getClimateRasters", + "prepIgAndEscPredictData", "prepSpreadPredictData")) + expect_identical(unique(queued$eventTime), 2002) +}) diff --git a/tests/testthat/test-inputObjects.R b/tests/testthat/test-inputObjects.R new file mode 100644 index 0000000..5247ecf --- /dev/null +++ b/tests/testthat/test-inputObjects.R @@ -0,0 +1,60 @@ +## `.inputObjects()` supplies defaults. Two of its branches are broken in ways that make the +## module unusable without a workaround, so they are pinned here -- as recorded defects, not +## as correct behaviour -- to make a fix detectable. + +test_that("KNOWN BUG: .inputObjects leaves rstLCC_RTM NULL, so Init() cannot run", { + ## `.inputObjects` (module source l.509-510) branches on `suppliedElsewhere("rstLCCs", sim)`. + ## That branch is the only one that does not try to download NTEMS landcover, but it does + ## `sim$rstLCC_RTM <- tail(sim$rstLCCs, 1)[[1]]`, and `rstLCCs` is not a declared input + ## (it is absent from `inputObjects`, l.89-147), so it is not in the simList when + ## `.inputObjects` runs. `tail(NULL, 1)[[1]]` is NULL, so `rstLCC_RTM` is left NULL. + ## + ## INTENDED: taking the `rstLCCs` branch should produce a usable `rstLCC_RTM`. + ## Stated and recorded as failing. This does not assert that NULL is correct. + objs <- toyObjects() # supplies rstLCCs, does not supply rstLCC_RTM + sim <- SpaDES.core::simInit( + times = list(start = 2001, end = 2001, timeunit = "year"), + modules = moduleName, + params = stats::setNames(list(list(igAggFactor = 2)), moduleName), + objects = objs, + paths = testPaths + ) + expect_failure(expect_s4_class(sim$rstLCC_RTM, "SpatRaster")) + ## recorded actual state + expect_null(sim$rstLCC_RTM) + + ## and the consequence: `Init()` calls `.compareRas(rasterToMatch, rstLCC_RTM)` at l.241, + ## which errors on NULL, so the `init` event cannot complete and the module is unusable as + ## shipped. This is why `toyPrepSim()` restores `rstLCC_RTM` after `simInit()` for every + ## other test in this suite. + expect_error(SpaDES.core::spades(sim, events = "init", debug = FALSE), + "subscript out of bounds") +}) + +test_that("KNOWN BUG: .inputObjects cannot build flammableRTM when it is not supplied", { + ## `.inputObjects` l.546 calls `defineFlammable(rstLCC, ...)`. `rstLCC` is a local created + ## only in the *other* branch (l.512), and `defineFlammable` is not imported by the module, + ## so with `rstLCCs` supplied and `flammableRTM` absent this is a guaranteed error. + ## The correct construction exists in `Init()` (~l.241-253). Recorded, not fixed here. + objs <- toyObjects() + objs$flammableRTM <- NULL + objs$landcoverDT <- NULL + expect_error( + SpaDES.core::simInit( + times = list(start = 2001, end = 2001, timeunit = "year"), + modules = moduleName, + params = stats::setNames(list(list(igAggFactor = 2)), moduleName), + objects = objs, + paths = testPaths + ), + "defineFlammable|could not find function|object 'rstLCC' not found" + ) +}) + +test_that(".inputObjects supplies the default climateVariablesForFire", { + ## the one branch of `.inputObjects` that works as documented (l.502-507) + objs <- toyObjects() + objs$climateVariablesForFire <- NULL + sim <- toyPrepSim(objs) + expect_identical(sim$climateVariablesForFire, list(spread = "MDC", ignition = "MDC")) +}) diff --git a/tests/testthat/test-metadata.R b/tests/testthat/test-metadata.R index 6c8028b..5a8f1bd 100644 --- a/tests/testthat/test-metadata.R +++ b/tests/testthat/test-metadata.R @@ -21,7 +21,6 @@ test_that("inputs are the expected names and classes", { climateYear = "character", cohortData = "data.table", currentClimateRasters = "SpatRaster", - fireSense_IgnitionFitted = "fireSense_IgnitionFit", flammableRTM = "SpatRaster", landcoverDT = "data.table", lightningMaps = "SpatRaster", @@ -54,8 +53,7 @@ test_that("parameters are the expected names", { md <- SpaDES.core::moduleMetadata(module = moduleName, path = modulePath) expect_identical( sort(md$parameters$paramName), - sort(c(".plotInitialTime", ".plotInterval", ".runInitialTime", ".saveInitialTime", - ".saveInterval", ".useCache", "cutoffForYoungAge", "dataYear", + sort(c(".runInitialTime", ".useCache", "cutoffForYoungAge", "dataYear", "fireTimeStep", "flammabilityThreshold", "forestedLCC", "fuelClassCol", "igAggFactor", "nonflammableLCC", "nonForestCanBeYoungAge", "sppEquivCol", "whichModulesToPrepare"))