New landweb_resample_psp() and landweb_growth_trait_frequency() (1.0.3.9048), to measure how much the shared growth-curve fit depends on which plots were measured: the fit is repeated on bootstrap samples of whole plots, drawn with replacement from those inside the fitting area (a plot drawn twice counts as two plots), and the second function counts how often the refits chose each species' set of traits, marking the set the fit to all plots chose.
Growth curves fitted once for all of LandWeb (1.0.3.9047). landweb_lthfc() and landweb_area() build the LandWeb area as LandWeb_preamble did (moved here so the preamble and a shared pipeline target use one implementation); landweb_anpp_area() selects the ecozones or ecoprovinces that touch an area; landweb_growth_sppEquiv() counts hybrid spruce as Engelmann spruce so that BC plot records in the hybrid zone are relabelled; landweb_bec_zones() fetches BC's BEC zones for that relabelling and, unlike the module's own fetch, stops if it fails; landweb_growth_traits() and landweb_unit_growth_traits() give each study area's units the traits of their member with most cover among those fitted by the shared fit (the dominant member's means if none was), using the new ranked column of landweb_species_units()'s units: a fir unit dominated by unfitted balsam fir takes subalpine fir's curve. Imports nngeo; suggests bcdata.
New landweb_damage_codes(): the BC, Alberta, Saskatchewan and NFI damage agent codes of bark beetles and defoliators (Saskatchewan records only death by insects), which LandWeb's shared growth-curve fit excludes from its plots (through PSPclean::getPSP()'s codesToExclude).
polygonClean(type = "LandWeb") renames the fire cycle column with names<- instead of dplyr::rename(), which on a SpatVector found its method only when tidyterra was loaded: a caller that had not loaded it stopped with UseMethod("rename"). A polygon layer with both an LTHFC and an LTHRC column now stops with a message.
landweb_species_units() now fits a species unit's growth curve on that species' own plot trees (1.0.3.9046): merged minor members (hybrid spruce included) and western redcedar and western hemlock get a blank Latin_full, so Biomass_speciesParameters counts their trees only in plot biomass. A group unit still pools its members. On WesternAlbertaUpland, Engelmann and hybrid spruce plots from the montane ecozone had made white spruce's pooled curve unidentifiable.
New sppEquiv_groups() and recode_cohorts() (1.0.3.9045) group a simulation's species for a vegetation-type map: the group of each simulated code, the one-row-per-group table LandR::vegTypeMapGenerator() takes, and the cohorts recoded to their groups. They stop on a code in two groups or a group of both conifers and broadleaves, which the map would get wrong without an error.
New landmine_fuel_types() and a fuelTypes argument to landmine_fire_ros(): LandMine can type fuel from the leading reporting group, looking each map label up in an explicit table instead of matching species names. Larch, a spruce fuel with black spruce there, no longer falls through to the mature-spruce rate at every age. Without fuelTypes the function is unchanged.
New landweb_species_sppEquiv(), landweb_species_units() and landweb_sum_layers() let LandWeb simulate species on their own (1.0.3.9044). landweb_species_sppEquiv() gives each SCANFI species its own LandR code in the LandWeb column, keeping today's merged group in LandWebGroup. landweb_species_units() then decides, for one study area, which species are common enough to simulate alone: a species holding at least threshold percent of the area's tree cover, measured as Biomass_borealDataPrep will see it (after its 5% per-cohort floor), runs alone; the rest of its group stays together, joins the group's largest member, or is dropped. Hybrid spruce joins whichever parent has more cover; western redcedar and western hemlock always join the largest fir unit. It returns the area's species table (each unit with its dominant member's traits), colours, and the map from species layers to units that landweb_sum_layers() uses to sum them. landweb_species_map() and landweb_sppEquiv() are unchanged.
New landweb_report_groups(), landweb_report_map(), landweb_add_report_column() and landweb_report_colours(): the species groups LandWeb reports leading vegetation and large patches by, separate from what it simulates (1.0.3.9043). They are the original LandWeb current-condition groups (White Spruce, Black Spruce with the larches, Pine, Fir, Deciduous), with Douglas-fir reported on its own. LandR::vegTypeMapGenerator() decides a pixel's leading type from the species codes it is given, summing biomass only within a code, so a stand of 40% black spruce and 40% tamarack is Black Spruce leading only if the codes are recoded to their group first; relabelling a species-level map afterwards does not give that. The test fixture gains LandR's Type column.
New landweb_member_cover() and landweb_dominant_sppEquiv() give each merged LandWeb species group the traits of its member with the most SCANFI cover in the study area (1.0.3.9042). LandR::prepSpeciesTable() and LandR::speciesTableUpdate() take the minimum of each trait across a group's LANDIS_traits codes, so Pice_gla (white, Engelmann and hybrid spruce) had Engelmann spruce's 30 m effective seed dispersal although it is mostly white spruce (100 m). The new function keeps only the dominant member's LANDIS_traits code in each group and blanks the others; rows sharing that code (the hybrid shares Engelmann's) keep it. The test fixture gains LandR's LANDIS_traits column.
landweb_sppEquiv() now keeps a row that has no SCANFI code when another row of the same species (LandR code) maps, and puts it in that row's group. With LandR 1.2.0.9047 these are Pinus contorta, Populus trichocarpa and Populus balsamifera v. balsamifera. Biomass_speciesParameters assigns PSP trees to groups by Latin_full through sppEquiv and drops trees it cannot match, and the NFI records lodgepole pine as Pinus contorta and black cottonwood as Populus trichocarpa. Neither species was used to fit growth curves, and on WesternAlbertaUpland Pinu_spp had too few plots to fit and was given averaged traits. The added rows have no SCANFI code and come after the mapped rows, so species layers and each group's labels and colours are unchanged. Generic Pinus contorta is mapped through var. latifolia (PINU_CON_LAT, interior lodgepole pine), not whichever variety comes first, and its missing colorHex is copied from that row (1.0.3.9041), because one blank colour makes LandR::sppColors() drop the standard colours for a generic palette. Their LANDIS_traits codes add no traits: PINU.CON is not in LandR's default trait table, and POPU.BAL is already in Popu_spp. The input now needs a LandR column.
buildReportingPolygons() and buildCrossedReportingPolygons() now merge reporting units smaller than min_area_km2 (default 1 km^2; all features sharing a Name count as one unit) into the neighbouring unit of the same layer that they share the longest border with, via spatialutils::eliminate_slivers(). Edge slivers were becoming reporting units of their own, each with an NRV envelope and figures: WesternAlbertaUpland had 7 crossed units under 1 km^2, down to 0.02 km^2 of a BC biogeoclimatic zone along the Alberta border. A small unit with no neighbour, or a layer made up only of small units, is dropped with a message. buildReportingPolygons() gains the min_area_km2 argument; 0 disables the step in both.
landmine_reburn_budget() now threads fire identity: when tooSmallByPoly carries fireID, attempt and targetSize, they are returned as fireIDs/attempts/targetSizes, positionally paired with fireSizesInPixels by the same single na.omit(). A caller supplying no identity columns gets NULL and is unaffected.
new landmine_attach_identity(): joins fire identity onto the clusterDT a burn returns, keyed on the start cell (spread2() does not return clusters in the order start cells were handed to it). A burned cluster with no ignition record is an error rather than a silent NA, which would corrupt per-fire attainment while leaving sum(size) == sum(maxSize) intact.
new landmine_fire_attainment(): collapses a fireSizes table to one row per fire -- burned, target, attempts, reached. This is the only way to see a fire that fell short: size == maxSize holds on every recorded row by construction, so a shortfall cannot appear in a row-wise comparison. by must name every identifying column (fireID is issued per burn year, so a multi-year table needs c("year", "fireID")).
new landweb_require_species(): stops when a species input handed between pipeline stages (sppEquiv, speciesLayers, cohortData) is empty, and otherwise returns it unchanged so it can wrap the reference inline. The upstream Biomass_* modules and LandR now treat a study area with no tree species as valid, skipping their work without error, so a broken species mapping would make a LandWeb run "succeed" with no vegetation dynamics.
landweb_sppEquiv() now errors when no row of the table maps to a LandWeb species group, instead of returning an empty table.
new landmine_ros_table(): Andison's (1996) Table 3.2 rates of spread by age class and fuel type. LandWeb_preamble and LandMine each defined it inline; both now call this. It is tested identical to the inline table and to cover every fuel type landmine_known_species() produces, plus mixed.
new landweb_species_map(): the single definition of which SCANFI species merge into each of LandWeb's seven species groups, previously written out inline in LandWeb_preamble and again by hand in the LandWeb project's SCANFI cover summary. A test checks that every group has a LandMine fuel type.
new landweb_sppEquiv(), promoted from LandWeb_preamble's InitSpecies(): adds the LandWeb column to LandR::sppEquivalencies_CA and relabels the groups that merge species. It is tested identical to the module's inline code on LandR's real table, kept as a fixture so LandR stays out of the package's dependencies, with one deliberate change: Abie_spp is now labelled "Fir". It had no group label, so each of its rows kept its own species' name, and Biomass_core's leading-vegetation maps, which read a group's label from its first row, called the whole group (subalpine fir, western redcedar and western hemlock included) "Balsam fir". Unlike the module, it copies its input rather than modifying the lazy-loaded LandR table by reference.
polygonClean(type = "LandWeb") now keeps fire-return intervals equal to minFRI, dropping only those strictly below it, as .cleanLandWebStudyArea() has always documented. It used <=, which at the default of 40 also dropped every FRI-40 polygon -- 27 of them, 91,797 km2 of LTHFC v10, much of it NW Alberta. LandWeb v3 passes minFRI = 25 and v10 has no FRI-25 polygons, so v3 runs are unaffected; anything using the default gains those polygons. These are the function's first tests.
new cc_age_composite(), promoted from LandWeb_preamble: crops a current-condition age raster to the study area, clears its NoData sentinel, and fills the cells it leaves empty from a second source aged forward to the same epoch. The fill never overwrites the primary source (a cell-wise minimum would drag inventory ages younger). Sentinels are cleared by value with terra::classify() rather than NAflag<-, because for an in-memory raster NAflag<- converts the values but NAflag() still reports NaN, so the module's assertion on the flag would fail on correct data.
new cc_age_pct_missing(), promoted from LandWeb_preamble's ccAgeMaxMissing gate: the percent of forest in the study area with no age. Each of the three wrong denominators it replaced is pinned by a test -- the raster's bounding box (WesternAlbertaUpland read 71.4% missing against a true 3.9%), the whole study area (lakes counted as missing age), and a coarser grid (which loosened the figure 3-20x). The forest mask uses terra::classify() rather than ifel(x %in% classes): %in% only reaches terra's SpatRaster method when terra is attached, and resolves to base %in% inside a package namespace.
new lcc2020_classes(): the single definition of which Land Cover of Canada 2020 codes LandWeb treats as forest, non-flammable, urban, wetland, cropland and shrub/grass/moss, previously repeated as literals across LandWeb_preamble.
new lcc2020_remap_table(), promoted verbatim from LandWeb_preamble: the overlayLCCs() remap table for combining the simulation land cover with LCC 2020, preserving v2's rule order. Equivalence with the module's inline code is tested over 200 randomised class sets. Its first rule (LCC 0/20/30 to NA) is overwritten for every code in the LCC 2020 legend and only applies to codes outside it; that is documented and pinned rather than changed.
new landmine_estimate_kBest() and landmine_area_share(), promoted from LandMine's EstimateTruncPareto(). The fit is to Dave Andison's "95% of the area is in 5% of the fires" rule of thumb, deliberately not to NBAC/NFDB, and that is unchanged. The module carried the two superseded versions of the rule as commented-out code inside the objective; they are recorded in the documentation instead, where they read as history rather than as options;
landmine_area_share() is separated out because it is the property the fit exists to produce, and is therefore what a test should check: the suite fits k and then verifies the area share on a fresh draw, rather than re-checking the objective the optimiser just minimised. It uses a strict > against the quantile, which matters for a rounded truncated Pareto, where most draws tie at 1 and the selected set is much smaller than 1 - topQuantile of the fires;
landmine_fire_ros() now gives mixedwood stands the ROSTable's mixed rates. mixed is the one fuel type invented for attribute-table entries matching no species rather than derived from landmine_known_species(), so the species join left its leading value NA and the join onto ROSTable dropped it -- mixedwood fell through to ROSother, the mature-spruce rate at every age, and both mixed rows of Andison's Table 3.2 were dead entries. On WesternAlbertaUpland all 126,850 mixedwood pixels moved from 30 to 12 (young/immature) or 17 (mature). This changes simulated fire regimes.
a related defect is deliberately not fixed and is pinned by a test: the pattern-based fuel typing can disagree with landmine_known_species(). Lari_spp matches none of Pice/Pinu/Popu so it is typed softwood, while knownSpecies calls larch decid; they disagree, the join finds no match, and larch also falls through to ROSother. The mixedwood fix does not sweep it up, because there the pattern-derived value would override the authoritative mapping. Larch is 3 pixels on WesternAlbertaUpland;
youngFilter now defaults to "always", applying the young class's species filter unconditionally as its two siblings already do. This makes no difference on a landscape containing pine (0 of 4,458,168 WesternAlbertaUpland pixels differ) but prevents a pine-free study area from silently assigning every young stand ROSother instead of its immature_young rate. "legacy" remains available to reproduce older runs;
landmine_ignition_budget() gains studyArea. LandMine masks both its rate-of-spread map and spreadProb to sim$studyArea, so a fire ignited outside that polygon burns its own start cell and spreads no further -- but the fire return interval raster is not masked and routinely overhangs it. Those pixels were inflating each zone's area and therefore its expected fires per year, and, because the start-cell pool is built from the same raster, were being drawn as ignition points. On WesternAlbertaUpland the budget was 2,918 fires per year against a correct 2,015 -- 31% of every year's ignitions aimed at ground the model cannot burn -- with one zone, 99.95% outside the polygon, over-allocated by a factor of 2,008 (45.6 fires per year against 0.023);
the masked raster is what the module then uses as its start-cell pool, so this also stops ignitions being drawn outside the burnable area. The ordering contract the reburn loop consumes positionally is unchanged and is tested against the masking, including the case where a zone disappears from the study area entirely;
landmine_fri_summary() gains studyArea, so the fix for the denominator lives at the source rather than only in landmine_fri_metrics(). Callers that do not pass it keep their previous (wrong) numbers until updated deliberately;
landmine_plot_fri_zones() now builds its two panels with patchwork and gives each its own fill scale. Faceting forced a single shared scale, on which log-ratios spanning about one unit collapsed entirely against target intervals spanning 170 years -- panel B rendered as a flat block of colour. Panel A is also now a sequential single-hue ramp (it encodes a magnitude) rather than a diverging one, and the caption wraps instead of running off the page;
landmine_plot_fri_drivers() uses a reserved status palette -- neutral for on-target, amber for outside tolerance, red for severe -- rather than an arbitrary hue cycle that coloured "ok" red;
new landmine_fri_metrics(), landmine_fri_verdict(), landmine_plot_fri_zones() and landmine_plot_fri_drivers(): per-FRI-zone attainment diagnostics, so a user does not have to read a simulation log to learn that a zone missed its target, nor mount a bespoke investigation to find out why;
these immediately found that there was no attainment problem at all. landmine_fri_summary() divides burns by every flammable pixel carrying a fire return interval, but LandMine masks both its rate-of-spread map and its spread probability to sim$studyArea, and the interval raster is not similarly masked. Pixels outside the polygon cannot be ignited or spread into, yet each one inflates its zone's achieved interval. On WesternAlbertaUpland 988,437 of 3,361,240 flammable zone pixels (29.4%) were outside, burning at a mean rate of 0.51 against 16.59 inside; zone 170 had 73 burnable pixels out of 146,627. Uncorrected, two zones appeared to under-burn by 3.5x and 12.2x. With the denominator restricted to the burnable area, every zone falls between 0.90 and 1.07 -- the fire model has been hitting its targets throughout;
landmine_fri_metrics() therefore takes a studyArea and reports both figures, ratio over the burnable area and ratioUnmasked the way the module's own summary computes it, plus pctInStudyArea, so the discrepancy is visible rather than mistaken for a fire-model failure. landmine_fri_verdict() names it explicitly in its one-line summary;
the structural columns (pctFlam, nPatches, largestPatchPct, pctNoCohorts, pctTreedLCC, cohortGap, pctCohortsLost) exist to tell the remaining failure modes apart: porosity from fragmentation, genuinely non-forest land from forest that was never populated with cohorts, and land that never had cohorts from land that lost them;
a zone that is entirely non-flammable is now reported rather than silently dropped, as landmine_fri_summary() does by masking before it enumerates zones;
every raster argument is checked against one grid. Reading rasters of different lengths into one table recycles the shorter one and yields a complete set of plausible, wrong numbers -- and this is a live hazard here, not a hypothetical one: the dataPrep land-cover and stand-age rasters are written on the larger biomassParam extent while the rest of the stage's outputs are on the simulation grid;
new landmine_fire_ros() and landmine_known_species(), promoted from LandMine's fireROS() -- the last and largest of the module's untested internals, and the surviving hypothesis for why FRI zones 55 and 170 under-burn. It was previously reachable only through a simulation, because it read eight values off sim/mod/P(sim); it is now a pure function of rasters and tables, so per-zone rate-of-spread distributions can be compared in seconds rather than hours;
correctness is established by equivalence testing against the module's inline implementation over 200 randomised landscapes (varying species mixes, stand ages including NA, flammability including NA, and both ROStype values), plus a sweep in which each fuel type in turn is absent from the landscape;
documents, and pins by test, two behaviours that are almost certainly bugs but are deliberately not fixed here, because either would change the fire regime and both sit in the code path currently under investigation:
ROSTable's mixed rates. mixed is the one fuel type invented for attribute-table entries matching no species, rather than derived from landmine_known_species(), so the species join leaves its leading value NA and the subsequent join onto ROSTable drops it. Mixedwood therefore falls through to ROSother -- 30 rather than 12 or 17 with the default table, i.e. the mature-spruce rate at every age -- and both mixed rows of the table are dead entries;"mature" in its condition while assigning to the young class; the follow-up commit repaired the subset key but not the dropped !). The new youngGuard argument selects "legacy" or "symmetric" so the difference can be measured rather than argued about;the stand-age class boundaries are now the ageCutoffs argument instead of 40 and 120 written three times. They are the only place the ROSTable's age labels acquire a numeric meaning, since the table itself carries no ages;
the runtime self-test is kept (as assertions, still defaulting to getOption("LandR.assertions", TRUE)) so development-mode runs are still checked mid-simulation, and the two classes of pixel it silently skipped -- tsf == 0 and tsf past its 999-year bin, both dropped by a right-closed cut() -- are covered by tests instead;
inputs are now validated rather than producing a plausible-looking map: a vegetation map whose attribute table was dropped (terra::writeRaster() stores it in an .aux.xml sidecar), an attribute table that is not 1..n (the lookup uses table row positions as raster values), rasters on different grids, and a ROSTable missing the rows the ROSother and "burny" paths need;
ROSother's second assertion no longer compares a value against 5% of itself;
new landmine_reburn_budget(), promoted from both branches of LandMine's reburn loop -- the densest concentration of positional contracts in that module, and the one its own comments warn is order-sensitive. It rebuilds the per-zone fire counts and target sizes for the next round, for phase 1 (each too-small fire keeps its full original target) and phase 2 (new fires sized to the remaining shortfall);
three contracts are now tested, none of which errored when broken -- they simply produced a plausible but wrong fire regime: the right join is driven by friByPolygon, so numFiresThisPeriod comes out zones-ascending with the NA row last, matching the [.GRP] indexing at the call site; na.omit() on the whole table is load-bearing, dropping both zero-shortfall zones and the NA-FRI row so fireSizesInPixels stays positionally paired with the start cells; and remainingSize is assigned by position onto the too-small rows;
correctness is established by equivalence testing against the original inline logic over 200 randomised multi-zone inputs (interleaved zones, both phases), rather than against hand-computed expectations -- the failure mode is subtle enough that hand-derived cases would not be convincing;
the input table is no longer modified by reference, which the inline := version did;
new landmine_ignition_budget(), promoted from LandMine's Init(). It masks zero-FRI and non-flammable pixels out of the fire-return-interval raster, tabulates pixels per FRI zone, and converts that to expected fires per year ((area / meanFireSize) / FRI). Verified behaviour-neutral against the previous inline block on the real WesternAlbertaUpland rasters: the masked raster, the per-zone FRI vector and numFiresPerYear (including names and order) are all identical;
the promotion exists to pin an ordering contract that is consumed positionally downstream. The reburn loop indexes per-zone fire counts with numFiresThisPeriod[.GRP], which is valid only because (1) terra::freq() returns rows ascending by value, (2) the value = NA row is appended last, and (3) dividing by the NA return interval makes that entry's value NA so a later na.omit() drops it -- an NA name alone would not. Break any one and zones silently receive each other's fire counts, with no error. All three are now tested at the point where the contract is created, including with a deliberately unsorted input raster;
also dropped dead code in the same block: a value = seq_len(NROW(.)) column that was then order()ed by, a trivially-identity permutation since the column is the row index;
new landmine_fri_summary(), landmine_area_burned_by_zone(), landmine_draw_num_fires(), landmine_sizes_to_pixels() and landmine_reburn_ceiling(), promoted from the LandMine module so they can be unit-tested. The module has no working test suite -- its tests/ are the untouched SpaDES stub, with hardcoded c:/Eliot/... paths and a call to a function that does not exist -- so this logic was previously exercised only by running a 1000-year simulation, where each failure mode below produces plausible numbers rather than an error;
landmine_area_burned_by_zone() fixes a bug: burned pixels were counted with unname(table(currBurn[ids])[2]), positional indexing into a table() that assumes the levels are always {0, 1}. A zone that burns completely has only one level, so [2] is NA, and the module's following npix[is.na(npix)] <- 0 -- whose comment says it handles the no-burn case -- silently turned a total burn into zero hectares burned. Now counted with sum(vals == 1, na.rm = TRUE), with tests for the fully-burned, partly-burned and unburned cases;
landmine_fri_summary() removes a verbatim duplication: the same 20 lines appeared in both the single- and multi-mode summary functions, so the two could silently diverge. Two edge cases are deliberately preserved rather than "fixed", and pinned by tests so any future change is deliberate: a zone that never burns yields Inf, and the un-na.rm'd sum() makes a zone NA if a burn pixel is NA where lthfc is not (an implicit contract that the two NA masks agree);
landmine_draw_num_fires() restores the zone names that rnbinom() drops -- the counts are indexed by zone downstream, so losing them would assign zones each other's fire counts;
landmine_sizes_to_pixels() is tested for the property that justifies it existing at all: probabilistic rounding preserves E[pixels], so sub-pixel fires are not systematically lost;
New landmine_plot_calibConvergence() plots DEoptim's best value against iteration and annotates where improvement stopped, so it is visible whether a run converged or was still improving when it hit itermax. On the first 240 m/120 m calibrations the last improvement came at iterations 149 and 128 of 200, with no gain in the final 50 -- i.e. iteration count was not the binding constraint, objective noise was;
landmine_optim_fitAndison() pins the RNG kind when crnSeed is given, and restores the caller's generator on exit. set.seed() reseeds whichever generator is current, and cluster workers run L'Ecuyer-CMRG (from clusterSetRNGStream()) while a plain session runs Mersenne-Twister -- so the same parameters and the same crnSeed gave objective values differing by ~8x (0.260 vs 2.117) depending on where they were evaluated, and a calibration could not be reproduced outside the cluster that produced it;
New landmine_optim_islandWeight(), used by default to scale the remnant-island term by resolution. Andison rasterised at 50 m (quarter-hectare) pixels, and his Table 3.6 puts 65% of island area in the 2-5 ha class for fires under 1,000 ha. At 240 m a single pixel is already 5.76 ha, so that entire class is unreachable by construction and no parameter choice can meet the target. The weight is min(1, 50 / pixelSize): 1.0 at 50 m, 0.42 at 120 m, 0.21 at 240 m. The weights used are recorded on the result's "weights" attribute;
landmine_optim_fireSizes() converts fire sizes from hectares to pixels for a given pixelSize, and landmine_optim_calibrate() now takes fireSizesHa. The calibration targets are defined in hectares, but the model takes pixels, so a fixed pixel-count vector meant a different physical fire at every resolution: c(10, ..., 1e5) px is 10-100,000 ha at 100 m but 57.6-576,000 ha at 240 m. The v3 move to 240 m therefore put nearly the whole calibration outside the 10-3,000 ha range Andison fitted, and made calibrating at several pixel sizes uninterpretable -- the runs were fitting different physical fires, so agreement could not demonstrate that parameters transfer across resolutions. Warns for areas outside the fitted range, or under 5 pixels;
landmine_optim_clusterSetup() no longer requires nnodes >= 70 and a multiple of 7. Those are constraints on DEoptim's population size, not the worker count; conflating them made the calibration unrunnable on a 64-core host and tied both NP and the per-worker RNG streams to availableCores(), i.e. to the machine and its load. It also gains a seed argument, since it previously drew its RNG stream seed from ambient state. landmine_optim_calibrate() defaults NP to 70 independently of the worker count;
landmine_optim_params_append() records the objective a fit was made against, because parameter values are only comparable within one objective;
BREAKING (fire behaviour): landmine_burn1()'s second spawn rule now tests numActive >= nActiveCells1[2] rather than >. With >, the numActive == nActiveCells1[2] case matched none of the three rules and fell through to spawnNewActive[1] -- the value intended for a barely-active fire -- so the highest spawn probability was applied to one of the most active states. A further gap is documented but deliberately not patched: when numActive >= nActiveCells1[2] and size >= sizeCutoffs[1] no rule applies, and choosing a value there needs a re-fit. These thresholds are LandWeb-derived, not Andison's: the thesis has no firelet-parameter table and states that its calibration was manual;
BREAKING (calibration): the "did the fire reach its target size" penalty in landmine_optim_fitSN() and landmine_optim_fitSN2() was dead code. burnedMap stores initialPixels (the ignition cell index) at each burned cell, so sum(burnedMap[], na.rm = TRUE) summed pixel IDs (~5e9) rather than counting cells, and the comparison against a target of at most 1e5 was never true. It now uses the new nBurned column. Parameter rows in LandMine_DEoptim_params.csv fitted before this are not comparable to rows fitted after;
New landmine_optim_fitAndison() fits against the statistics Andison (1996) actually reported, replacing the single constant perimeter-to-area target: the shape-vs-area relationship SHAPE = 1.770 + 0.041*log10(AREA)^4 (§3.3.1), a continuous size shortfall rather than a step penalty, and the remnant-island fractions of Table 3.5. Supporting functions: landmine_optim_shapeIndex(), landmine_optim_shapeTarget(), landmine_optim_islands(), landmine_optim_islandTarget(). It accepts crnSeed for common random numbers. Note the defaults use the empirical column of each Andison table (shape intercept 1.770, island fractions 4.0/6.0/9.0); the other column in each is that model's own output, and targeting it would be circular;
landmine_optim_calibrate() gains objective ("andison", the new default, or "sn" to reproduce historical fits), crnSeed, and replicates;
landmine_optim_burnFun() returns nBurned and shapeIndex in its LM table, and passes spreadProbRel to spread2() as a numeric vector -- bit-identical, and about twice as fast per objective evaluation;
New landmine_optim_unpack(), landmine_optim_params_read(), landmine_optim_params_append(), landmine_optim_landscape() and landmine_optim_calibrate() move the LandMine fire-spread calibration out of LandMine.Rmd, which is knitted with eval = FALSE and had drifted: it referenced the removed raster package API, called terra::cellFromRowCol() with raster's argument names, and used an invalid read.csv(file, row.names = FALSE). The 10^ parameter convention had been repeated at eight call sites and the CSV schema at four, two of which disagreed (one appended a row, the other overwrote the file). Behaviour is unchanged -- the existing calibration rows remain valid and comparable;
landmine_burn1() accepts numeric vectors for spreadProb and spreadProbRel and forwards them to spread2(). Prefer these to rasters: spread2() re-materialises a raster in full on every iteration, so a raster costs one O(ncell) read per spread step, and landmine_burn1() steps one iteration at a time. Numeric spreadProbRel requires SpaDES.tools >= 2.1.2.9000 (PredictiveEcology/SpaDES.tools#106), now declared in Imports;
reportingPolygonLayers() no longer includes Parks. Reporting units are studyAreaReporting intersected by tenure, and protected areas are carved out of forest tenures, so a parks layer has almost nothing to report against: for WesternAlbertaUpland, 81 of 732 parks merely abut the FMAs and the entire intersection is 0.05 km^2 (against 6,538 km^2 for the buffered studyArea). v2 did not report parks either. Meaningful parks reporting needs a wider domain than one study area -- and wider than one province, since provincial parks sit within a province but national parks cross provincial boundaries -- so the layer is excluded pending that review;
fix: .caribouNameFixes() had the Snake-Sahtaneh entry backwards, rewriting British Columbia's correct spelling into ECCC's typo. CGNDB has Sahtaneh River (BC) and no match for "Sahtahneh", so the federal layer is the one in error; the entry is removed and BC's name kept. Alberta's East Side Athabasca / West Side Athabasca are likewise left alone: both are official enumerated domain values in Alberta's published metadata ("Local Population Ranges" / "Population Subunit", authority Wildlife Management, Alberta Environment and Parks), so dropping "River" is Alberta nomenclature rather than a truncation — and not a field-width artifact either, since LOCALRANGE is 25 characters wide, its longest value is 19, and "West Side Athabasca River" is exactly 25. Bischto → Bistcho stands (CGNDB: Bistcho Lake; no such place as "Bischto");
correct the Manitoba caribou provenance note: the layer was provided by the Government of Manitoba in 2018 and is current (the _2015 in the filename is the delineation year, not the delivery date). It was previously described as a 2015 copy worth re-requesting, which understated it -- Manitoba has published nothing since, so it is the authoritative version;
breaking: caribou reporting ranges are now assembled from the six jurisdictional sources (caribouRangeLayers() + buildCaribouRanges()) instead of fetched as one pre-combined file. reportingPolygonLayers() carries a single Caribou row with the new source = "assembly", dispatched to a named assembler. This replaces both the hand-combined v10 layer — whose manual combine had dropped the Northwest Territories ranges entirely (0 of its 74 features intersect either NWT tenure) and left range names scattered across five per-jurisdiction columns — and the interim GNWT-only supplement that patched the NWT hole. Reporting goes to jurisdictional partners, who each want their own management-unit names, so the jurisdictional sources are authoritative; the ECCC national layer is retained only as a documented comparison in scripts/make_caribou_reference.R, not as a reporting layer. Caribou reporting-unit names therefore change from v2, which labelled its boreal units from ECCC (Bistcho (BIS), Saskatchewan Boreal Plains (BPL), …); v2↔v3 caribou comparisons need a crosswalk rather than matching on name;
locally extirpated herds are excluded from the caribou ranges. Two sources flag it in different vocabulary — AB STATUS == "Extirp" (the Banff range, lost to an avalanche in 2009, and one of AB's mountain ranges) and BC HERD_STATUS == "Extirpated". statusCols is a candidate list because BC's field is HERD_STATUS over its WFS but truncated to HERD_STAT in a shapefile export;
British Columbia is now fetched live from the BC Data Catalogue WFS rather than a cached order-based BCGW export, whose URL was not reproducible and whose content was stale: the WFS release has 55 features against the old export's 72, and flags 5 extirpated herds where the old one flagged 1;
caribou range names are corrected where a source is demonstrably wrong (Bischto → Bistcho). This matters beyond cosmetics: the summaries group by name rather than by feature, so a range two provinces spell differently appears as two reporting units — the same mechanism that stitches Chinchaga, Narraway and Redrock-Prairie Creek back into single units across the AB/BC border. The bar is evidence of an error, not disagreement between sources, each entry verified against NRCan's Canadian Geographical Names Database (CGNDB);
Alberta is labelled on SUBUNIT before LOCALRANGE — subunit granularity throughout, decided deliberately rather than to match v2, since no single setting does. v2 reported Alberta's mountain ranges at subunit level (A La Peche, Narraway and Redrock-Prairie Creek were three separate v2 units, all subunits of the one LOCALRANGE "West Central") but its boreal ranges whole, because it sourced AB boreal from ECCC at range level. SUBUNIT therefore matches v2 for the mountain ranges and reports finer than v2 for exactly one boreal range: East Side Athabasca River resolves into its 7 subunits (Agnes, Algar, Bohn, Christina, Egg-Pony, Wandering, Wiau). Every other Alberta range is 1:1 with both v2 and ECCC, and the finer breakdown is kept as an improvement;
Ontario is included. It is easy to omit and its loss is silent: RANGE_NAME was populated for exactly the ON and MB features of the old combined layer, so leaving Ontario out cost every ON tenure × Caribou reporting unit;
.fetchVectorFile() factors the three transports out of buildReportingPolygons() so the assembler and the layer builder fetch identically;
breaking: reporting-polygon layers are now keyed by a curated NAME_SHORT rather than their long descriptive name, and output keys come from refCodeFor() (= .slug(NAME_SHORT)) rather than abbreviate(name, minlength = 8). The old keying gave no cross-unit uniqueness guarantee -- only 327 of the 399 crossed FMA x ANSR names abbreviated uniquely, so 68 units silently overwrote each other's parquet aggregates and figures. Existing _aggregates/ directories and figure folders are named under the old scheme and will be rebuilt;
tenureShortNames() supplies the curated, partner-facing short name for each member tenure of the v10 boundary layer, and validateShortNames() enforces the invariants the keying depends on (present, filesystem-safe, <= 16 characters, unique) when the tables load; build_studyarea_crosswalk() gains a name_short column and errors on an uncurated member rather than falling back to a collision-prone long name;
buildCrossedReportingPolygons() restores the v2 tenure x sub-region reporting units that v3 was not producing: v2 clipped each sub-region layer to a single FMA, whereas v3 clips to studyArea, which is now an ecoregion group unioning several tenures. Each tenure is crossed with the layers marked cross in reportingPolygonLayers() (Caribou ranges, Alberta Natural Subregions, BC biogeoclimatic zones, NWT/national ecoregions, national ecozones), with its own active/passive landbase coverage(s), and -- where both exist -- with the two together (the tenure x landbase x Caribou triple). Crossing happens before the name-grouping, so two tenures' shares of the same sub-region are no longer pooled;
reporting-layer labels come from labelCols, a comma-separated priority list coalesced left to right, replacing the single labelCol. The v10 layers were assembled by merging per-jurisdiction sources, and the Caribou ranges populate RANGE_NAME for only 21 of 74 features (Ontario and Manitoba) -- British Columbia uses HERD_NAME, Alberta LOCALRANGE, Saskatchewan CONUNIT/RNGEUNIT. Labelling on RANGE_NAME alone left 53 ranges unnamed, and unnamed features are dropped, so no tenure x Caribou reporting unit existed outside ON/MB; placeholder values ("Not Applicable" and similar) are also treated as missing;
a reporting layer may now be assembled from several sources: rows of reportingPolygonLayers() sharing a NAME_SHORT are built independently and merged (reduced to the common Name), and the new where column keeps only the features a source is meant to contribute. This carries the NWT caribou supplement: the v10 Caribou layer has no Northwest Territories ranges (0 of its 74 features intersect either NWT tenure), which had cost the FMANWT FP Caribou / FMANWT FR Caribou units v2 produced. The gap is filled from GNWT Boreal Caribou Range Planning Regions (MapServer layer 97), which subdivides NT1 into 6 named planning regions the way LOCALRANGE/HERD_NAME subdivide AB/BC -- so an NWT tenure gets a real sub-region breakdown rather than the single whole-territory polygon an earlier stopgap supplied. Labelled on REGION rather than NAME: both exist, but NAME carries diacritics and apostrophes (Sahtú, Wek'èezhìi, Gwich'in) and this label is slugged into the refCode that keys parquet aggregates and figure filenames. No where filter is needed -- the layer is NWT-only by construction, and its Yukon region simply does not intersect a study area that stops short of Yukon;
reportingPolygonLayers() gains a third source type, "geojson", for a URL that returns GeoJSON directly (an ArcGIS REST query endpoint) rather than a zipped shapefile: it is fetched with no archive-extraction step, and named from the layer key since basename() of a query URL is not a usable filename. Unknown source values now error when the table loads -- previously anything that was not "drive" fell through to the zipped-URL path, so a typo surfaced later as an opaque "no vector file found";
buildCrossedReportingPolygons() gains members, restricting the crossings to the study area's own tenures. v10 tenure polygons overlap, so clipping to a study-area group boundary also catches neighbouring tenures the crosswalk assigned to a different group (e.g. NorthernAlbertaUplands, whose sole member is FortNelson_TSA, clips in Tolko_Norbord_LC); crossing those split one partner's numbers across two study-area reports;
a crossing that fails now warns immediately as REPORTING UNIT MISSING: crossing '<unit>' failed, naming the unit that was lost rather than reporting a bare geometry error;
joinReportingPolygons() is ported off the retired sp (returned a Spatial* object) to sf/SpatVector, returning the same class it was given; it repairs geometries with spatialutils::repair_geoms() instead of reproducible::fixErrors(), since sf::st_make_valid() collapses the reversed ring-winding-order polygons in the v10 FMA layer. Its Name.1/Name.2 re-concatenation and ACTIVE|PASSIVE de-duplication are preserved (both are required by the triple crossings). The intersection now runs through terra::intersect() rather than sf::st_intersection(), which threw GEOS TopologyException: side location conflict on some already-repaired tenure x Caribou pairs (Dawson Creek TSA lost its Caribou units to this, despite 17,405 km^2 of genuine overlap across 9 ranges); one engine also keeps the result deterministic rather than dependent on which pairs happen to trip GEOS;
the tenure reporting layer is now labelled from .fmaMemberIdentity() (the coalesced per-jurisdiction identity) rather than FMA_NAME alone, which is NA for every non-Alberta member and so left the tenure layer empty for the BC/SK/ON/MB study-area groups;
buildCrossedReportingPolygons() skips tenures contributing less than min_area_km2 (default 1 km^2) to the study area: a neighbouring tenure that merely grazes the study-area boundary leaves a sliver in the clipped layer, and crossing it would mint a full set of reporting units -- each with its own refCode, parquet aggregate directory, and figures -- over a handful of pixels;
buildLandbasePolygons() gates on the tenures present in the study area (tenures=) instead of a regex matched against the study-area name (applies_to/studyAreaName=). The old gating silently stopped matching anything once study areas became ecoregion groups ("WesternAlbertaUpland" matches no company name), so no landbase was ever fetched;
generated download directory and file names now use underscores instead of dots (e.g. National_Ecozones, not National.Ecozones), which is friendlier on Windows; previously cached downloads under the old dotted names will be re-fetched once, or can be renamed in place;
buildReportingPolygons() now locates extracted vector files recursively, fixing layers whose shapefile unpacks into a subdirectory (the national ecoregion and ecozone archives) being dropped as though they did not intersect the study area; a layer whose file cannot be found now warns instead of being dropped silently;
prepEcoregionLayer() and prepEcoprovinceLayer() likewise search recursively, and now fail with an informative error rather than a subscript-out-of-bounds when no shapefile is found;
drop support for R 4.2 and 4.3 due to changes in dependency packages;
add package dependencies: purrr, scales, sf, stats, terra, withr;
remove package dependencies sp, raster;
use native R pipe and remove magrittr dependency;
replace use of orphaned SDMTools package with landscapemetrics;
use parallelly to setup cluster in landmine_optim_clusterSetup();
add plot functions from LandWeb_preamble module;
add study area creation / extraction functions from LandWeb_preamble module;
use usethis approach to managing package imports;
improve documentation;
buildReportingPolygons() and reportingPolygonLayers() assemble the reporting-polygon list using spatialutils::prep_vector() and the workflowtools *_once download helpers, replacing the per-study-area hardcoding;
build_studyarea_crosswalk() (with studyAreaCrosswalk()) groups the LandWeb FMA/TSA/FML boundary polygons into ecologically-coherent study areas by assigning each tenure to its dominant ecological unit (an ecoregion by default; supply any eco* layer for a different granularity) via verified spatial overlap, emitting a crosswalk with company and province columns so licensee-driven reruns still map cleanly; reversed-winding polygons are repaired via spatialutils::repair_geoms() rather than sf::st_make_valid() (which collapses them);
extractFMA() falls back to matching LandWebStudyAreas$Description against an FMA_NAME column when the canonical short names are absent (the updated FMA boundaries layer);
prepEcoregionLayer() and prepEcoprovinceLayer() download and prepare the AAFC ecostrat ecoregion/ecoprovince layers for use as the study-area grouping layer (named with a Layer suffix so they do not shadow LandR::prepEcoregions(), which Biomass_borealDataPrep calls);
prepStudyArea() now resolves an ecoregion study-area group to the union of its member FMAs via the grouping crosswalk, while still handling Alberta FMU names, the random test area, and legacy LandWebStudyAreas names;
drop the LandR dependency by vendoring the equivalentName() name-lookup helpers;
add package dependencies: googledrive, spatialutils, workflowtools;
fs to Imports;findOldSimFiles() to find old simulation files;landmine_burn1() for "burny" scenarios;ggplot2 and rasterVis to Imports;LandMine diagnostic plots from module;LandMine module functions;findSimFile() to check previously- and currently-used simulation file names;LeadingVegTypeByAgeClass() and LargePatches();LandR package;future.apply and purrr to Imports;rasterListByPoly();analysesOutputTimes();.ageClassCutOffs as integer;LeadingVegTypeByAgeClass() and LargePatches();crop2poly to LargePatches();LargePatches() to handle VTM extents smaller than TSF;LargePatches() for LBstatus;simFile() to package SpaDES.core;dplyr to Imports;PredictiveEcology/map@development;covr to Suggests;cleanAreaName();updateSpeciesTable() to LandR package;PredictiveEcology/map@LandWeb and PredictiveEcology/LandR@LandWeb;updateSpeciesTable();allCombos in LeadingVegTypeByAgeClass();ext to simFile() to allow files other than .rds;.csv files exists when creating plots;.ageClassCutOffs;data.table and SpaDES.core to Imports;LandR, map and pemisc Remotes;simFile() to find simulation output files;highDispersal scenario dispersal distances;updateSpeciesTable() from Biomass_borealDataPrep module;.csv;LargePatches();map package;