From 43fe46aeadbd0907820abd042f426c2c3a7aaaa3 Mon Sep 17 00:00:00 2001 From: Eliot McIntire Date: Wed, 14 Oct 2026 06:35:21 -0700 Subject: [PATCH] One ELF rescales with its ledger row's covMinMax_spread, not sim$covMinMax_spread The single-ELF path used sim$covMinMax_spread, which fireSense_spreadFit rebuilds in every run from that run's covariate tables; in a hindcast those follow a formula rebuilt for another year, so a fitted term could have no range and silently stay on its raw scale. ledgerCovMinMax() takes the row's ranges (the fit's), like ledgerCovCentre(); a row without them (an older ledger) still uses sim$covMinMax_spread. A fitted covariate with no range stops. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CwcjqqK59FmTJscyi7xUqv --- NEWS.md | 2 ++ fireSense_spreadPredict.R | 32 +++++++++++++++++++++++--- tests/testthat/test-ledgerCovMinMax.R | 33 +++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 tests/testthat/test-ledgerCovMinMax.R diff --git a/NEWS.md b/NEWS.md index 073eed9..654b4db 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,7 @@ # fireSense_spreadPredict (development version) +- A single fitted ELF now rescales its covariates with the `covMinMax_spread` of its ledger row, as several ELFs already did, not with `sim$covMinMax_spread`, which `fireSense_spreadFit` rebuilds in every run from that run's tables (in a hindcast, from a formula rebuilt for another year, which can lack a fitted term). A row without ranges (an older ledger) still uses `sim$covMinMax_spread`. A fitted covariate with no range now stops, naming it; before, it silently stayed on its raw scale. + - A single fitted ELF now predicts with the covariates its fitted parameters name, as several ELFs already did, not the terms of `sim$fireSense_spreadFormula`. A hindcast whose formula had a term the fit did not (e.g. `treedWetland_agb`) stopped with "not found in data objects". Predictions where the formula matched the fit do not change. A fitted coefficient with no covariate still stops, naming it. # fireSense_spreadPredict 1.2.0 diff --git a/fireSense_spreadPredict.R b/fireSense_spreadPredict.R index 161c4e4..c7889c3 100644 --- a/fireSense_spreadPredict.R +++ b/fireSense_spreadPredict.R @@ -126,7 +126,7 @@ doEvent.fireSense_spreadPredict <- function(sim, eventTime, eventType, debug = F #' `paramSetForRep()`), and writes it to `sim$fireSense_SpreadPredicted`. Parameter sets are not averaged. #' #' With one fitted ELF (one row of `sim$studyAreaWithSpreadParams`) every pixel uses its parameters and -#' `sim$covMinMax_spread`. With several, each ELF's model (its parameter set, its `covMinMax_spread`, and +#' its `covMinMax_spread` (`ledgerCovMinMax()`; `sim$covMinMax_spread` only when the row has none). With several, each ELF's model (its parameter set, its `covMinMax_spread`, and #' only the covariates it was fitted with) predicts its own pixels, from `sim$rasterToMatchLargeELF`, and #' those within `ELFblendWidth` of them; overlapping predictions are averaged with the weights of #' `ELFblendWeights()`. @@ -151,7 +151,7 @@ spreadPredictRun <- function(sim) { if (NROW(sa) == 1L) { params1 <- paramSetForRep(sa$params[[1]], P(sim)$.rep) - pred <- spreadProbOneELF(covs, params = params1, covMinMax = sim$covMinMax_spread, + pred <- spreadProbOneELF(covs, params = params1, covMinMax = ledgerCovMinMax(sa, 1L, sim$covMinMax_spread), yr = time(sim), maxFireSpread = P(sim)$maxFireSpread, lowerSpreadProb = P(sim)$lowerSpreadProb, covCentre = ledgerCovCentre(sa, 1L)) @@ -239,7 +239,8 @@ ELFblendWeights <- function(elfRas, template, pixelID, ids, width) { #' makes them; fuel biomass logged). It can hold more than the fit used (every ELF's covariates, or a term the #' spread formula has but the fit does not); only the covariates named in `params` are used. #' @param params one-row `data.frame`: the ELF's parameter set for this replicate (`paramSetForRep()`). -#' @param covMinMax the ELF's `covMinMax_spread`. +#' @param covMinMax the ELF's `covMinMax_spread` (`ledgerCovMinMax()`): the ranges the fit rescaled its +#' covariates with. Every fitted covariate must be a column of it, or it is left unscaled. #' @param yr,maxFireSpread,lowerSpreadProb as for `fireSenseUtils::spreadProbFromIntegerCovs()` and #' `fireSenseUtils::logisticAll()`. #' @param covCentre the ELF's `covCentre_spread` (`ledgerCovCentre()`): the covariate means the fit subtracted @@ -287,6 +288,13 @@ spreadProbOneELF <- function(covs, params, covMinMax, yr, maxFireSpread, lowerSp ## the covariates this ELF was fitted with, not the spread formula's terms: they can differ (a hindcast ## rebuilds the formula from another year's land cover), and `covs` can hold more than this ELF's covs <- covs[, c("pixelID", fitted), with = FALSE] + ## spreadProbFromIntegerCovs() rescales only the columns of covMinMax: a fitted covariate it lacks would + ## silently stay on its raw scale (x1000) + noRange <- setdiff(fitted, names(covMinMax)) + if (length(noRange)) + stop(moduleName, "> covMinMax_spread has no range for the fitted covariate(s) ", + paste(noRange, collapse = ", "), "; it has ", paste(names(covMinMax), collapse = ", "), + ". The ranges must be the fit's (its ledger row).") # integers x 1000, the form `spreadProbFromIntegerCovs` expects shortAnnDTx1000 <- toX1000(list(covs))[[1]] |> setDT() @@ -331,6 +339,24 @@ spreadProbOneELF <- function(covs, params, covMinMax, yr, maxFireSpread, lowerSp yearSpreadSDTxt <- "yearSpreadSD" +#' The covariate ranges of one ledger row +#' +#' A fit rescales its covariates with fixed ranges and stores them in its ledger row (`covMinMax_spread`). +#' Prediction rescales with the same values, so it never depends on `sim$covMinMax_spread`, which +#' `fireSense_spreadFit` rebuilds in every run from that run's own covariate tables (in a hindcast, from a +#' formula rebuilt for another year, which can lack a fitted term). +#' +#' @param sa `sim$studyAreaWithSpreadParams`, one row per ELF. +#' @param i integer; the row. +#' @param default what to return for a row without ranges (an older ledger row): `sim$covMinMax_spread`. +#' @return The row's `covMinMax_spread` (a `data.table`, 2 rows: min, max), else `default`. +ledgerCovMinMax <- function(sa, i, default = NULL) { + if (!"covMinMax_spread" %in% names(sa)) return(default) + cmm <- sa[["covMinMax_spread"]][[i]] + if (is.null(cmm) || !NROW(cmm)) return(default) + cmm +} + #' The covariate centres of one ledger row #' #' A fit with an intercept centres its covariates and stores the centres in the ledger row diff --git a/tests/testthat/test-ledgerCovMinMax.R b/tests/testthat/test-ledgerCovMinMax.R new file mode 100644 index 0000000..4be4467 --- /dev/null +++ b/tests/testthat/test-ledgerCovMinMax.R @@ -0,0 +1,33 @@ +## A single fitted ELF rescales its covariates with the ranges its ledger row stores +## (`covMinMax_spread`), not with `sim$covMinMax_spread`: fireSense_spreadFit rebuilds that object in +## every run from the run's own tables, and in a hindcast those come from a formula rebuilt for another +## year, so it need not have the fit's columns. The row's ranges are the fit's. + +test_that("one ELF rescales with its ledger row's covMinMax_spread, not sim$covMinMax_spread", { + ins <- toyInputs() + ledger <- data.table::copy(ins$covMinMax_spread) + ledger$MDC <- c(100, 300) + ins$studyAreaWithSpreadParams$covMinMax_spread <- list(ledger) + ## as in test-spreadProb-values.R "shifts as well as scales", but the range is in the row only: + ## cell 4: MDC 200 -> (200 - 100) / 200 = 0.5 -> x = 0.75 + 1 = 1.75; cell 7: 150 -> 0.25 -> 0.375 + v <- predVals(toyRun(ins)) + expect_equal(v[c(4, 7)], handLogistic3(c(1.75, 0.375)), tolerance = 1e-7) + expect_false(isTRUE(all.equal(v, predVals(toyRun(toyInputs()))))) +}) + +test_that("a row without ranges (an older ledger) falls back to sim$covMinMax_spread", { + expect_identical(predVals(toyRun(toyInputs())), predVals(toyRun(toyInputs()))) + sa <- data.frame(polygonID = c("A", "B")) + cmm <- data.table::data.table(MDC = c(0, 200)) + sa$covMinMax_spread <- list(cmm, NULL) + expect_identical(ledgerCovMinMax(sa, 1L, "default"), cmm) + expect_identical(ledgerCovMinMax(sa, 2L, "default"), "default") + expect_identical(ledgerCovMinMax(data.frame(polygonID = "A"), 1L, "default"), "default") + expect_null(ledgerCovMinMax(data.frame(polygonID = "A"), 1L)) +}) + +test_that("a fitted covariate with no range stops instead of staying unscaled", { + ins <- toyInputs() + ins$covMinMax_spread$fuelA <- NULL + expect_error(toyRun(ins), "no range for the fitted covariate\\(s\\) fuelA") +})