Skip to contents

landisutils 0.0.161

  • loss_from_stats() forms the observed annual area burned as lambda_obs * mean(fire_sizes_ha) rather than sum(fire_sizes_ha) / n_years. The two agree only when the size sample is every fire in the area and years the counts were taken over, and a payload may deliberately break that: per-fire sizes are scarce where fire counts are not, so a small study area may borrow its size sample from a wider region while counting ignitions only within itself. The summed form then returns the wider region’s annual area – on one such payload, 181.6 ha/yr against a landscape that burns 22.4, an 8.1x target that would have dragged the fitted ignition rate up to match an area the landscape does not contain. Where the two pools do agree the forms are numerically identical, so a payload whose sizes and counts come from the same fires is unaffected to the last digit. The component no longer reads primary$n_years, and its coherence warning now fires on an empty fire_sizes_ha rather than a missing year count. area_burned was released at weight 0 in 0.0.160 and no calibration is known to have weighted it, so no fitted result changes.

  • The roxygen for area_burned now says to compute the count-versus-area_burned steepness factor for your own record rather than assuming it is large. It is roughly 3.4 on a record averaging 27.8 fires per year with a standard deviation of 18.9, but only 1.6 on a sparse record averaging 0.87 – a record whose annual counts are nearly Poisson has a small standard deviation to divide by, so the sparser the record, the less headroom this component has before it starts competing with the fire-count target.

landisutils 0.0.160

  • loss_from_stats() gains an area_burned component, |log10(area_sim / area_obs)| on annual area burned. Nothing in the loss scored how much area burns: area_fuel scores how burned area is distributed across base fuel types, count and size score the number of fires and the shape of their size distribution, and a parameter set can satisfy all of them while burning several times too much or too little. On a range-of-variation study that is the quantity the exercise turns on, because annual area burned is what drives the seral-stage distribution. It is also the one area quantity measured to separate candidate fire-size tables: across two landscapes no distributional size statistic separated two real candidate tables by as much as one standard error, while annual area burned separated them by 3.4. A log10 ratio keeps the component scale-free, so one weight means the same thing on study areas whose burn rates differ by orders of magnitude. Simulated area is summed from each replicate’s events over the years count scores, so the two components always divide by the same denominator, and converted with observed$pixel_area_ha; a replicate set that burns nothing scores 3.0 rather than the infinite value log10(0) would give, which DEoptim could not rank. The default weight is 0, so an existing calibration is unchanged until its weights ask for the component, and calibrate_dynamic_fire() warns when a non-zero weight meets a payload that cannot supply an annual rate or is missing pixel_area_ha. Note that area_burned and count both move with the number of fires and so compete for the same lever: count is normalised by the observed year-to-year standard deviation, which makes it steeper by roughly lambda_obs / sd(n_fires_obs) * ln(10), and above that ratio of weights the fitted fire count is pulled off its own target to compensate for a fire-size distribution the search cannot change.

landisutils 0.0.159

  • parse_dynamic_fire_logs() takes each fire’s size from the event log’s SitesChecked instead of DamagedSites, and rescales mean_severity onto the same cells. Dynamic Fire logs DamagedSites as one more than the cells a fire burned, on every event: across 9,106 events it was never below 2, the 1,118 fires that burned only their ignition cell all logged 2, and on 372 timesteps with a single fire each the severity map’s burned cells matched SitesChecked exactly and DamagedSites minus one. Every simulated fire was therefore scored one cell too large, so a calibration could never produce a one-cell fire while the record it was fitted to was full of them; the size loss was inflated most at the small-fire end, where one cell is a large fraction of a fire. MeanSeverity is divided by the same inflated count, which put a one-cell fire at severity 3 into severity class 1 and moved 130 of 928 checked fires into the wrong class. total_sites_burned falls by one cell per fire. sim_landis() now converts cells to hectares with the cell area of the scenario it runs, read from scenario.txt’s CellLength. Neither calibrate_dynamic_fire() nor run_calibration_validation() passed pixel_area_ha, so it defaulted to 1 and every simulated fire on a grid other than 100 m was scored in cells against observed hectares. A caller-supplied value that disagrees with CellLength is now an error, and both drivers refuse observed targets built on a different cell size. parse_dynamic_fire_logs() warns if a log breaks DamagedSites == SitesChecked + 1, the relation the correction was verified on. Direct callers of parse_dynamic_fire_logs() still get its default pixel_area_ha = 1, so pass the cell area on a grid other than 100 m. Losses computed before this version are not comparable with losses computed after it, and eval_fp does not include the package version, so a checkpoint from an earlier version must not be resumed.

landisutils 0.0.158

  • calibration_par_names() gains NumFiresMultiplier, which scales each fire ecoregion’s own ignition rate by a common factor instead of replacing every rate with one value. NumFires, added in 0.0.154, is right where the rate is a property of the landscape as a whole, but patch_fire_config() writes that scalar into every ecoregion row, so a landscape whose ecoregions carry markedly different rates – 0.57 ignitions per year in one and 17 in another is a real case – loses that structure entirely by being calibrated. The correction being fitted is the gap between a count of observed FIRES and a count of ignitions, and that gap applies to every ecoregion alike, so a common factor expresses it where a common value does not. patch_fire_config() and apply_calibrated_num_fires() share the scaling through one internal helper, so a calibration trial and the production table it is applied to cannot drift apart. The two forms are alternatives, not companions: supplying both is an error, raised where a caller first passes them – the bounds in calibrate_dynamic_fire()’s config, a trial’s parameter vector, or a production parameter vector.

  • ForCS’s MapOutputInterval now accepts 0, which writes no ForCS spatial maps – the only way to turn them off, and a value ForCS itself accepts. The setter required a positive value, so a configuration that asked for no maps could not be written at all: write() never ran, and a pipeline wiring the ForCS config into a scenario file failed there instead. The four ForCSOutput log intervals are unchanged, because ForCS rejects 0 for those; a log can only be thinned, for instance to 9999.

landisutils 0.0.157

  • Every extension’s log-file default is now a full path rather than a bare relative one, so the defaults are usable as written. SummaryLogFile, EventLogFile, EventLog and LogFile are stored through active bindings that apply .relPath(value, self$path), so a default of "wind/summary-log.csv" was made relative a SECOND time: against a scenario directory of LANDIS-II/ForCS_wind it became ../../wind/summary-log.csv, two levels above the replicate, and the extension wrote its log outside the run. Nothing caught it because no caller relied on the defaults – BiomassHarvest already built full paths in initialize(), and callers of the other extensions worked around it one call site at a time. Ten extensions and fourteen parameters were affected: BiomassBrowse, ClimateBDA, DynamicFire, EDA, Hurricane, LinearWind, OriginalFire, OriginalWind, OutputLandscapeHabitat and RootRot. A caller that already passes a full path is unaffected, and the written config is unchanged for every such caller.

landisutils 0.0.156

  • calibration_par_names() gains DamageAgeMultiplier, and patch_fire_config() scales the FireDamageTable’s cohort-age column by it, so the damage table can be calibrated. The paired severity-minus-tolerance column is deliberately left alone: the user guide (2.16.3) requires an integer there, which gives only a few reachable mortality levels and cannot be fitted, while the age column is a percentage of longevity (2.16.2) and scales smoothly. Scaled values are rounded to whole percentages and forced strictly increasing, since a row that does not exceed its predecessor can never be reached.

  • New apply_calibrated_damage_age() applies a calibrated DamageAgeMultiplier to a production fire damage table, so a fitted table reaches the simulations it was fitted for; it leaves the table alone when the vector does not carry one. It and patch_fire_config() share the scaling, so the calibration trial and the production config cannot drift apart.

  • landis_overstory_mortality_share() no longer scores mortality on cells the extension burned without damaging any cohort. The severity map writes 2 for those cells and severity + 2 for damaged ones (user guide 3.1), so treating the value as severity + 2 throughout gave them a severity of 0 and then asked the damage table what a severity of 0 kills – inventing mortality against the extension’s own statement that nothing was killed. Such cells remain in the denominator, matching an observed reference that counts every assessed pixel inside a fire perimeter.

landisutils 0.0.155

  • DynamicFuels’s DisturbanceConversionTable now takes the three columns the Dynamic Fuels user guide (2.8) specifies – fuel type index, duration in years, and either a harvest prescription name or a FireSeverityN / WindSeverityN keyword – instead of four. The four-column form came from reading the written header’s “Fuel Type” as two columns, and survived because the table had only ever been empty: the first project to populate it could not write a config at all.

landisutils 0.0.154

  • New loss component mortality, with landis_overstory_mortality_share() behind it: the share of a replicate’s burned cells whose dominant cohort was killed, from the severity maps, the initial communities, the species tolerances and the fire damage table. It exists because the severity component compares quantities on different scales – an observed burn-severity class measures vegetation change, while the extension’s severity classes are crown fraction burned, and the two disagree wherever fire kills the canopy from the ground without crowning. save_observed_fire_targets() gains mortality_share to carry the observed side. Existing calibrations are unaffected: without an observed share the component contributes 0.

  • calibration_par_names() gains NumFires, and patch_fire_config() writes it into the fire-size table, so the ignition rate can be calibrated. The Dynamic Fire System defines NumFires as the mean number of IGNITIONS per year, each of which becomes a fire only if the initiation probability of the fuel on its cell allows it, so a rate taken from a count of observed fires is systematically low as a count of ignitions. New apply_calibrated_num_fires() applies a calibrated rate to a production fire-size table, and leaves the table alone when the vector does not carry one.

  • New observed_fire_sizes() returns one size per ignition point: the point’s own SIZE_HA, or the SIZE_HA of a same-year perimeter polygon that contains it. This is the rule save_observed_fire_targets() already used for the calibration’s size target, now exported so that a fire-size distribution fitted from the same record uses the same sizes. Fitting to points and polygons bound as separate rows counts every mapped fire twice.

landisutils 0.0.153

  • run_calibration_validation() now fails with a message that names the failed replicates when a validation replicate’s process dies. parallel::mclapply() reports a replicate that raised an R error as a try-error, but one whose child process was killed comes back as NULL with only a warning; the guard tested for the first and not the second, so a dead replicate reached loss_from_stats() and surfaced there as “missing value where TRUE/FALSE needed”, naming neither the replicate nor the cause. The error now distinguishes the two modes and points at an OOM kill, a segfault or a container fault rather than at an R error. loss_from_stats() also rejects a reps list holding a non-list element, so any caller that maps replicates in parallel gets the same clear failure.

landisutils 0.0.152

  • fps_run_docker() no longer fails on every non-empty FPS_log.txt. FPSM writes one benign message there, a missing substitution factor, which moves no carbon between pools; it is now reported without failing the run. Every other message still fails, including one this package does not recognise, so a message added by a future FPSM release cannot be waved through. The previous behaviour would have failed the first harvesting run against parameters whose substitution table does not cover every primary product, with an error saying carbon had been lost when none had.

landisutils 0.0.151

  • New script inst/scripts/verify_candidate_loss.R rescores a calibrating project’s stored run_calibration_validation() result under a candidate landisutils branch, tag or sha beside the installed version, and reports which loss components moved. The candidate is installed into a temporary library and each version is scored in its own R process, so neither the project library nor any checkout changes. Use it to measure what a loss-code change does to a calibration before adopting the release.

landisutils 0.0.150

  • tar_landis()’s replicate input hash no longer depends on the collation locale of the R process computing it. It sorted dependency paths with sort(), which collates with ICU in UTF-8 locales and by bytes under C/POSIX, so a worker in a different locale from the one that wrote log/input_hash.json re-simulated a finished replicate. The hash and the skip check now live in the exported landis_input_hash() and landis_rep_is_current(); hashes written by earlier versions are still accepted, so upgrading does not re-run completed replicates (it does change every tar_landis() command once, so targets re-dispatches each replicate, which then skips). Reproducing an ICU-sorted legacy hash unsets the LC_ALL environment variable and sets LC_COLLATE for the duration of the sort, because R collates by bytes while either pins C, whatever Sys.setlocale() says.

landisutils 0.0.149

  • plot_growth_structures() gains regen_flags, which marks the panels whose composition holds a species whose extra regeneration pathway cannot operate in the runs being plotted, and names the mechanism in the caption. LANDIS-II arms serotiny and resprouting ONLY from a disturbance-caused cohort death: CheckForPostFireRegen() and CheckForResprouting() are reachable only where the succession extension sees disturbanceType != null, and a cohort dying of longevity passes null. A calibration landscape that registers no disturbance extension therefore runs a serotinous or sprouting species with that pathway permanently inert, which is invisible in a carbon trajectory – the reader sees a species that “should” come back after fire, and no fire – and was being carried in the calling project’s prose, which a figure shared on its own does not travel with. Matching is on species_set and never on composition, which is a display label that would read Hw x2 as a species named Hw x2; without that column the flags are ignored with a warning rather than mis-applied. Only the flagged species a figure actually shows are named, so the caption cannot send a reader looking for a panel that is not there.

landisutils 0.0.148

  • loss_from_stats() validates weights instead of trusting it, closing the same class of silent wrong answer as the lambda_obs guard in 0.0.147. Passing weights = NULL left the internal weight vector at its zero initialisation – w[names(NULL)] <- NULL is a no-op – so the weighted total collapsed to 0 while every component was computed correctly, with no error and no warning. An unknown weight name was worse: it GREW the weight vector past the component vector, and the multiply then recycled across mismatched pairs, applying one component’s weight to another. Both now error, as does an unnamed or NA weight. The known component names are defined once as an internal constant shared with calibrate_dynamic_fire()’s cfg$weights check, so the two cannot drift apart – they did once before, when size_tail existed for four releases without being whitelisted and was silently stripped from every cfg$weights.

landisutils 0.0.147

  • loss_from_stats() no longer counts the Dynamic Fire summary log’s initial timestep as a simulated year. The log reports Time = 0 – the landscape’s initial state, written before any disturbance – which necessarily carries zero fires, so n_fires_by_year had sim_years + 1 rows and every simulated annual rate was understated by sim_years / (sim_years + 1). On a 10-year trial that is 9%, and it lands directly on the count component: measured against a downstream calibration’s 20 validation replicates, the simulated rate went from 25.373 to 27.910 against an observed 27.760, and L_count fell from 0.1264 to 0.0079 – a sixteenfold inflation that had made a well-matched ignition rate look like a 9% shortfall and pushed the search to pin both ignition multipliers at their bounds. Loss values are not comparable across this change, and getting a clean slate needs more than deleting the checkpoint. The evaluation fingerprint digests the calibration’s INPUTS – parameters, weights, replicate count, seed, simulator, image, observed targets, scenario template – but not the loss code and not the package version. This fix therefore leaves it byte-identical, so a checkpoint and its memoized trial-trace rows stay fingerprint-valid and are ACCEPTED into a post-fix run, serving pre-fix losses silently alongside correctly computed ones. Removing checkpoint.rds does not help, because the memoized losses live in the trial-trace and worker_*.csv files, which are folded in separately and recursively from out_dir. Start the next calibration with resume = "never", which skips that step entirely.
  • parse_dynamic_fire_logs() drops that row at the source, so it never reaches any consumer. The correction is also applied defensively where the row is read, so summaries cached before this release are corrected on read rather than silently scoring wrong.
  • plot_calibration_fire_counts() was affected by the same row and is fixed with it. One spurious zero per replicate both shifts the plotted mean down and, where annual counts are large, collapses the apparent spread – a zero is an extreme outlier there. On the same data the plotted standard deviation was 9.5 against a true 5.2. The distortion scales with how far a zero sits from the mean, so it is worst exactly where the fire regime is most active.
  • loss_from_stats() validates observed$primary$lambda_obs instead of trusting it. When it was absent, L_count evaluated to numeric(0) and c(count = numeric(0), size = ...) silently DROPPED the element, so components came back one short, its names shifted by one, and the weighted total was computed from mismatched pairs – a wrong answer behind nothing louder than a recycling warning.
  • apply_calibrated_ignprob() documents that multipliers above 1 / default are inert. The product is clamped to LANDIS-II’s required [0, 1], and the defaults are 1.0 for every base but Deciduous (0.5), so a pinned multiplier is saturation rather than an estimate wanting a wider bound – widening it is a no-op.

landisutils 0.0.146

  • A family of diagnostic plots for a Dynamic Fire calibration, built on the objects the package already produces: plot_calibration_convergence(), plot_calibration_loss(), plot_calibration_fire_sizes(), plot_calibration_fire_counts(), plot_calibration_area_by_fuel(), plot_calibration_severity() and plot_calibration_severity_by_size(). Each returns a ggplot object rather than writing a file, and the titles are descriptive rather than conclusory – what a given gap means is a property of the landscape being calibrated, not of this package, so the interpretation stays with the caller. ggplot2 remains in Suggests; the plots check for it and fail with a clear message when it is absent.
  • calibration_events() pools the per-replicate fire-event logs into one data frame and adds each event’s burned area in hectares. It also documents that the event log’s MeanSeverity and DamagedSites are not on the same denominator, which is why the former can fall below 1.
  • calibration_plot_palette() exposes the colour roles the plots use, with checked per-role overrides (an unknown role is an error rather than a silent no-op). The defaults are separated for colour vision against each other and against the plot surface.
  • plot_calibration_severity() gains weight_by, because the choice is not cosmetic. The objective’s severity term counts fire EVENTS – one vote per fire, whatever its size – while an observed severity reference is almost always a share of burned AREA. Where severity rises with fire size, the two summaries of the same simulation can disagree about the DIRECTION of the error and not merely its size, so weight_by = "area" is the like-for-like comparison against an area-weighted reference; "events" is the default because it reproduces what was actually scored. Neither recovers within-fire variation, since both summarise a fire by its mean.
  • plot_calibration_severity_by_size() plots each simulated fire’s size against its severity and returns the per-size-class share of fires, share of area and mean severity as a size_summary attribute – the evidence for whether weight_by matters on a given landscape.

landisutils 0.0.145

  • plot_growth_candidate() labels only the aesthetics a layer actually maps. size, shape and linetype were labelled unconditionally while all three are mapped conditionally – size on a binned series carrying n, shape on leading species outside density mode, linetype on reference curves being present – so ggplot2 reported “Ignoring unknown labels” on the builds that omit one. The mapped set is read off the layers rather than restated, so it cannot drift from the conditions that add them.

landisutils 0.0.144

  • plot_growth_candidate() omits the binned points’ white outline in density mode rather than passing colour = NULL. A NULL fixed aesthetic still reaches the layer, so ggplot2 reported “Ignoring empty aesthetic: colour” on every density-mode build; the series is mapped on colour there and wants no fixed outline at all.

landisutils 0.0.143

  • growth_structure_cell_curves() orders the composition label by the OLDEST cohort rather than alphabetically, so Hw+Ba and Ba+Hw are different mixtures instead of the same one. They are not interchangeable: measured on the Gitanyow landscape, the Ba+Hw pool carried 14,555 communities against 1,256 for Hw+Ba, so the single alphabetical panel was an average over two populations differing more than tenfold in extent. Ties break alphabetically, so equal starting ages give a deterministic order.
  • A new oldest_species column records which species the cell’s oldest cohort belongs to, carried through growth_structure_summary() and growth_structure_cohort_table().
  • plot_growth_structures() selects a species’ panels on oldest_species instead of on mere presence, so a structure appears in exactly ONE species’ figure and the focal species always leads its own labels. Selecting on presence drew every mixture in both species’ figures as the same panel, which read as a duplicate. A summary built before oldest_species existed still plots, with the previous behaviour.

landisutils 0.0.142

  • new FPSM output helpers read_fps_raw_out(), fps_pools(), fps_stocks_by_pool(), write_fps_raw_out_parquet() and open_fps_raw_out_dataset(), the counterparts to the ForCS log_Summary helpers and following the same read / atomic-publish / union-dataset shape.
  • fps_pools() drops the terminal simulation year by default. FPSM writes its annual end-of-year stock reports as types 4 and 5 up to the second-to-last year, then a different and partial residual set for the final year (types 1 and 2, decaying pools only), so carrying the final year into a stock series draws a collapse that did not happen. The cut is derived from the data – the last year carrying a type 4 or 5 report – not hard-coded.
  • rlang is declared in Suggests. tests/testthat/test-growth_structures.R calls rlang::as_label(), which R CMD check reports as an unstated dependency; the check workflow runs with error_on = "warning", so this had been failing CI since 0.0.140 (9789252a, 2026-09-02). Unrelated to the FPSM work, fixed here because it blocks the same build.
  • all FPSM amounts are reported as tonnes of carbon. FPSM sums gC/m^2 * cell_length^2 * 1e-6 over cells with no division by area, so its output is an absolute landscape total despite the user guide labelling the columns tC/ha; the two coincide only for a 100 m cell. It reports carbon, never CO2e.

landisutils 0.0.141

  • new fps_run_docker() and fps_output_files(), for running the Forest Product Sector Module (FPSM) over a directory holding an FPSM configuration and the two ForCS flux logs it names. FPSM is not a running LANDIS-II extension – its PlugIn.Run() is an empty stub and the work happens in a console entry point – so it gets plain functions rather than a LandisExtension subclass, and the runner omits the version assertion, startup jitter, output streaming and post-completion watchdog that landis_run_docker() needs. It keeps the image-digest capture, because that is what identifies the bytes that ran.
  • fps_run_docker() performs three pre-flight checks, each guarding a failure that is otherwise silent or obscure: the configured input filenames must exist case-exactly (the shipped FPSM examples name log_fluxDOM.csv beside log_FluxDOM.csv, which is harmless on Windows and fatal elsewhere); each flux log’s header must still match the column positions FPSM indexes, since it validates no headers and would read a reordered column as the wrong quantity; and a non-empty FPS_log.txt fails the run by default, because that file collects unallocated carbon among other non-fatal problems.

landisutils 0.0.140

  • growth_structure_summary() gains start_age_breaks, summarising WITHIN starting-age classes instead of pooling over them. A composition’s cells differ in two ways at once, the parameter combination and the ages the initial-communities map gave their cohorts, and the second dominates: measured on a 14,375-cell structure sweep, starting age explains 92 to 99 percent of the within-composition variance against 0.2 to 3.3 percent for the swept parameters. A band taken over the pool is therefore roughly ten times the parameter effect it is read as showing, and is mostly stand age wearing the parameters’ name. NULL (the default) pools as before.
  • growth_structure_cell_curves() carries start_age, the oldest cohort the cell begins with, which is what start_age_breaks bins. Existing callers are unaffected: it is an added column, and every downstream summarise names its grouping explicitly.
  • plot_growth_structures() colours by start_class when the summary carries one, on a viridis ramp that DARKENS with starting age. Within a panel the previous kind colouring was constant, since a panel is one composition, so it only restated the facet strip. The ramp is truncated at 0.75 because viridis ends in a yellow that sits at 1.23:1 against a light page; multi-hue is a deliberate departure from one-hue-sequential, since eight steps of a single hue are not separable at panel size, and lightness stays monotone so the ordering survives in greyscale and under colour-vision deficiency. Ribbons are suppressed when stratified, as they already were for two variants.

landisutils 0.0.139

  • plot_growth_structures() gains max_panels, keeping only the compositions with the most cells behind them. A facet per composition is right when cells hold at most a couple of cohorts and a species appears in a handful of them; where cells carry ten to thirty age classes a species appears in over a hundred, and the panel renders as unreadable slivers with truncated facet strips and overlapping axis labels. NULL (the default) keeps all, so existing callers are unchanged. What was dropped is stated in the subtitle rather than left implied.

landisutils 0.0.138

  • tar_landis() stages the RIGHT branch’s input files. When a pattern maps over scenario_dir but not over the dependency target, every branch receives all branches’ files, and the basename deduplication then has to choose; files under the branch’s own scenario_dir were ordered first so they would win. That prioritisation compared a path_abs() spelling against a path_real() one, so in any project reaching its LANDIS-II tree through a symlink the prefix test never matched, the ordering became a no-op, and EVERY branch staged the first-listed branch’s inputs. A two-batch run produced byte-identical outputs from two different landscapes while each branch’s own correct inputs sat unused on disk; the failure needs at least two branches to appear, so single-branch runs cannot surface it. Both sides are now resolved with path_real().
  • landis_dep_files() is the resolution, pulled out of the bquote() target command so it can be tested. The bug survived precisely because it was inline there, where no test could reach it. Exported (@keywords internal) because generated target code cannot reach an unexported name without :::; not part of the user-facing API.

landisutils 0.0.137

  • validate_landis_scenario() scopes the initial-communities map-code check to cells the ecoregion map calls ACTIVE. A map code is only reachable where the cell is active, since LANDIS-II never resolves a community for an inactive one, so testing the whole raster read deliberate non-vegetated land-cover codes as missing communities and rejected input that runs. landisbc writes distinct herb, shrub, bryoid, exposed-land and water codes into inactive cells and none of them has CSV rows, because none of them is a community: measured on an 890,400-cell landscape, 140,597 cells carry four such codes and not one is ecoregion-active, while both scenarios staged from that map run to completion. Without a readable ecoregions map it falls back to the whole raster, which can over-report but never under-report.
  • This fix was written against 0.0.132 and stranded on an unpushed commit while the package moved on to 0.0.136, so it was absent from every released version. Restored here unchanged apart from a comment.

landisutils 0.0.136

  • write_growth_review_bundle() documents density_min_plots, density_bins and density_points_max. They were added with the hex density and left undocumented, which is an R CMD check WARNING; CI runs with error_on = "warning" and had been failing on it since 2026-08-25.
  • Declares count as a global. It is computed by stat_binhex() and reached through after_stat(), so it never exists as a binding R CMD check can see.

landisutils 0.0.135

  • growth_structure_cell_curves() COUNTS a repeated species in composition rather than repeating its name: Hw for one cohort, Hw x3 for three. The repeated form was written for a design holding at most two cohorts; a structure design is capped on SPECIES, so a landscape whose cells carry a dozen age classes produced 255-character labels, unusable as a facet strip and near-unique per cell. The counted form has the same equivalence classes, so nothing regroups.
  • A species_set column carries the species actually present, and plot_growth_structures() matches and counts species on it instead of parsing composition. The label carries counts now, so a bare species code no longer matches it; a summary without the column still falls back to composition.
  • growth_structure_summary() and growth_structure_cohort_table() carry species_set through. It is a function of composition, so no grouping changes.

landisutils 0.0.134

  • growth_structure_cell_curves() gains biomass, because what aboveground_c_mg_ha MEANS differs by extension and cannot be detected from the data. A per-cell output log reports a whole-cell total that the per-cohort join then repeats, so it is de-duplicated ("cell", the default, unchanged). A per-cohort community output reports each cohort’s own biomass, which has to be SUMMED ("cohort"). Getting it wrong is quiet rather than loud: de-duplicating per-cohort values keeps one row per DISTINCT VALUE, so a cell of 16 cohorts carrying 4 distinct biomasses reduces to 4 rows that are neither a total nor a trajectory.
  • plot_growth_structures() no longer labels its categories in terms of two. A structure design is capped on SPECIES, not cohorts, so a one-species cell can carry a dozen age classes; the labels are now single cohort, one species, multiple cohorts and multiple species. The previous wording was correct only for a design that also happened to hold at most two cohorts.

landisutils 0.0.133

  • growth_structure_cell_curves(), growth_structure_summary(), growth_structure_cohort_table(), plot_growth_structures() and read_landscape_cohort_structures() read a structure factorial back. growth_structure_design() and growth_calibration_partition() already BUILT the batched design here while every consumer reduced it downstream, which is what let the map_code renumbering be a trap rather than an invariant: the partitioner numbers map_code from 1 WITHIN each batch, so grouping on it alone merges one cell per batch into a single fictional stand carrying more cohorts than any structure holds. growth_structure_cell_curves() keys on (batch, map_code) and asserts batch is present, so the mistake is now an error in the package that creates the condition rather than a guard each caller has to rediscover. A single-batch run cannot surface it.
  • A structure run’s curves carry one row per (cell, timestep, COHORT) because the design they join against is per cohort, while the biomass column is a whole-cell total, so a multi-cohort cell arrives with its trajectory repeated. growth_structure_cell_curves() reduces that to one row per (cell, timestep) and attaches the cell’s composition as a sorted +-separated label, keeping a repeated species – two cohorts of one species is an age structure, not a monoculture, and collapsing to unique species would label it identically to a single-cohort cell.
  • growth_fitting_windows() gains caps and cap_label, an optional per-species upper bound on mature_to – e.g. the age at which an in-use reference curve plateaus, beyond which it carries no shape left to fit. The three sources now apply in increasing order of authority (derived, then caps, then scoring), because a hand-set bound is a constraint rather than a preference and must win over a cap as well as over the derived window. window_source reports which one the returned window came from. Previously a caller wanting a cap had to wrap this function and re-implement the entire scoring override, which is how one downstream copy came to carry a duplicate of it.
  • plot_growth_structures() labels linetype only where a linetype is actually mapped. Labelling an aesthetic no layer uses makes ggplot2 report “Ignoring unknown labels” on every build.

landisutils 0.0.132

  • plot_growth_candidate() gives a legend key only to series it actually draws. The colour scale carries breaks and limits so the fill and colour legends merge into one, and the cost of that is that a label left in the palette with no layer behind it renders as a key with no glyph. Both such labels can now be absent – density = TRUE replaces the per-plot points with a hex density carrying its own scale, and binned = NULL drops the binned series – so a bundle drawn both ways showed two legend entries for things not on the panel.
  • .growth_series_key() builds the per-key styling FROM THE KEYS PRESENT rather than from a fixed order. With a shorter key set the fixed-length vectors failed inside the legend drawing with “replacement has 5 rows, data has 4”, which names neither layer nor scale.
  • simplifyCohorts() CONSERVES biomass when it merges pixel groups. Each merged community now takes, per species, the mean of the stand totals of the pixel groups it pools, and divides that among the retained age classes in proportion to age. Previously every age class received its own scaled copy of the mean COHORT biomass, so a community’s biomass grew with the number of age classes pooled into it instead of being conserved; the signature was exact, with sum(CohortBiomass) equal to max(CohortBiomass) * sum(age / max(age)) in 100% of species-in-cell cases. Measured on one landscape whose cohortData carries a median stand biomass of 119 t/ha against 116 t/ha observed, the initial communities built from it carried a median 440 t/ha and exceeded the succession extension’s own maxB by four to thirteen times; after the fix the same landscape gives 148 t/ha. Shares are computed over the DISTINCT retained age classes because prepInitialCommunities() deduplicates its rows. Conservation is per merged community and the mean over pooled pixel groups is unweighted, so landscape biomass is conserved only up to that weighting.

landisutils 0.0.131

  • write_growth_review_bundle() writes a README describing what it ACTUALLY DREW. The panels vary with the arguments – points or a hex density, a binned series or none – and a fixed description drifts from them silently. It had been describing diamonds a caller could switch off by passing reference_curves = NULL, and a fitting-window rule of “0.45 x longevity” that stopped being how the cap is computed in 0.0.124, where it became the species’ own onset of age-related mortality.
  • Where the binned series is not drawn, the README says so and points at review-summary.csv for the residual, rather than omitting it: the series is still what the score is computed against whether or not it is on the panel.

landisutils 0.0.130

  • plot_growth_candidate(density = TRUE) renders. 0.0.128 introduced the density and could not draw a bundle at all; 0.0.129 misdiagnosed the cause and moved the hexes to alpha, which did not fix it. The contended aesthetic is FILL: stat_binhex() maps fill = after_stat(count) through its DEFAULT aes, which passing a fixed fill argument does not unset, while the binned-median series maps fill to a discrete label so that its key merges with the colour legend. With both present ggplot2 applies the discrete scale to continuous counts and reports “continuous value supplied to a discrete scale”, naming neither layer. In density mode the binned series now takes a fixed fill and maps colour instead, which keeps its key and leaves fill to the hexes.
  • The regression test BUILDS the plot rather than only constructing it. Both earlier releases passed their tests: a scale collision surfaces at render time, so a test that stops at the ggplot object cannot see it.

landisutils 0.0.129

  • Attempted fix for the 0.0.128 render failure by mapping the hex density on alpha. It did not work; see 0.0.130 for the actual cause. Superseded.

landisutils 0.0.128

  • plot_growth_candidate() gains density, drawing the ground-plot cloud as a WEIGHTED hexagonal density with the best-matched plots kept over it. write_growth_review_bundle() turns it on per species at density_min_plots (500 by default), since a bundle routinely spans two orders of magnitude in plot count and the panels stop being readable well before the data run out: one landscape’s lodgepole pine carries 1,639 plots and the cloud is solid ink with a shape key per leading species. The density carries every plot AT ITS WEIGHT, via the plot_weight column, so nothing leaves the figure and the shading reads as evidence rather than as sampling effort; without that column it falls back to counts. NOTE this release could not render; fixed in 0.0.130.
  • density_points_max replaces density_point_weight in plot_growth_calibration(), and is the rule in plot_growth_candidate() too. A fraction of the maximum weight does not control how many points get drawn, because the weight distribution differs by species: at 60% of maximum – the old default – one species keeps 989 of its plots and another keeps 65, and the first buries the density it was meant to annotate. A count draws the same number whatever the shape of the distribution. This is a breaking rename for callers that set the old argument.
  • The review bundle’s README names Biomass Succession rather than ForCS. It was a copied label; nothing in the bundle was ever ForCS-specific.

landisutils 0.0.127

  • read_landis_raster() asks terra how it reads an ungeoreferenced file instead of assuming, which fixes LANDIS-II output rasters being returned VERTICALLY MIRRORED under terra 1.9-46 and later. LANDIS-II writes no geotransform, GDAL then reports the identity transform with a pixel height of +1, and every terra up to 1.9-34 honoured that by placing the first file row at the bottom – so this package flipped the rows back. terra 1.9-46 (2026-08-22) stopped doing so and returns those rows in file order, and the unconditional flip then introduced the very inversion it existed to remove. Nothing in the package had changed; the correction had simply become a corruption.
  • The new behaviour is detected rather than gated on a version. Which terra release changed is not something this package can know, and a boundary guessed wrong fails identically but is harder to find. A 15-row LANDIS-II community map whose codes run 3 to 17 down the file ships in inst/testdata/ and is read once per session; the direction the values come back in is the answer. An explicit south-up geotransform is unaffected and still flips, which terra 1.9-46 did not change.
  • The row-order test no longer asserts which direction terra reads in. That is terra’s business, not this package’s, and asserting it meant a terra release failed the suite of a package whose own behaviour was unchanged. The test now asserts the invariant that matters: what comes back is the order LANDIS-II wrote.

landisutils 0.0.126

  • plot_growth_calibration() gains density, drawing the ground-plot cloud as a WEIGHTED hexagonal density with individual points kept only for the best-matched plots, instead of one point per plot. Off by default, so nothing changes for existing callers. It exists because the panels stop being readable well before the data run out: one landscape’s lodgepole pine carries 3,106 plots and 57 BEC subzone legend keys, and the legend alone took a third of the figure while the cloud was solid ink. The density carries every plot, weighted, so nothing is dropped from view – what changes is that a thousand plots stop competing for the same ink and for a legend key each. Worth it only where the cloud is dense: a species with a hundred plots gets a sparse, blocky grid that says less than the points did, which is why the switch is the caller’s and not automatic.
  • density_bins and density_point_weight control the grid resolution and which plots are still drawn individually (default: those at 60% of the species’ maximum weight or better). The per-plot colour and shape legends are dropped in density mode along with the points that fed them, and the manual shape scale with them, since a manual scale with no layer behind it warns about levels it cannot find.
  • Requires ‘hexbin’, declared in Suggests and checked at call time.

landisutils 0.0.125

  • growth_bin_observations() DROPS a bin whose observations all carry zero weight, instead of emitting it with an NA value. The weighted quantile of an empty effective sample is NA, and such a bin is not a thin bin but an absent one – every observation in it was excluded by the weighting. Carrying it overstated n_bins, and because growth_reference_curves() takes the ground-plot LEVEL as a max() over the binned values inside the window, one valueless bin made the level NA and dropped every plot-scored series for that species. Latent until a window widened far enough to admit one: a species scored on plots alone then returned n_series = 0 and an all-NA row, with nothing to say why.
  • growth_reference_curves() computes that max() with na.rm = TRUE and returns NA_real_ rather than -Inf when nothing survives, so a caller supplying its own binned series cannot reintroduce the same failure.

landisutils 0.0.124

  • growth_auto_window() and growth_fitting_windows() accept mort_shp and close each species’ window at that species’ own onset of age-related mortality, instead of at one fraction of longevity shared by every species. Biomass Succession defines MortalityCurve as a POSITION in the lifespan (v7 User Guide 2.12.4), so where a species leaves its plateau varies nearly twofold across the documented range – measured on one calibration, the departure from 95% of peak biomass ran 0.43-0.48 x longevity at MortalityCurve 10, 0.63-0.70 at 15 and 0.82-0.84 at 25. A single fraction cannot separate a species that breaks up early from one that holds its stand almost to the end, which is the distinction the parameter exists to make. The senescence_frac = 0.45 fallback remains for callers that do not supply mort_shp, but its default was calibrated against a parameterisation giving every species a MortalityCurve near 23 and does not generalise: on a set carrying 10s the earliest 95%-of-peak departure falls to 0.433, below the cap itself.
  • growth_mortality_onset_frac() is new and exported: it inverts the User Guide’s definition of MortalityCurve to the fraction of lifespan at which age-related mortality begins. Callers were open-coding the same arithmetic.
  • growth_auto_window() no longer loses a year to floating-point representation error. 0.10 + (10 - 5) / 20 * 0.75 is 0.28749999999999998, so an onset landing mathematically on a whole year came out a hair below it and the inward rounding took the year before – a cap of 114 where 115 was meant.