Getting started with framecpp

framecpp reimplements the FRaME forest-flammability model (Zylstra et al.) in C++/Rcpp. It is a faithful port of classic, discrete-strata FRaME: given the same inputs it reproduces the original frame package’s outputs bit-for-bit (see vignette("reference-tracking")), while running far faster — no JVM. This vignette walks through the everyday workflow: run the model on a plant community and read its outputs.

library(framecpp)
library(dplyr)
library(tidyr)
library(tibble)

An example community

The package ships golden references in inst/extdata/ — complete frame model runs captured from frame v0.5.3, kept as the validation corpus. Here we use one purely as convenient input: the community shipped as FRaME’s own worked example — four strata of eucalypt-forest plants (all flat-leaved) — under two weather regimes:

  • mild — control weather (wind 5 km/h, temp 15 °C, dead-fuel moisture 0.10)
  • extreme — severe weather (wind 60 km/h, temp 40 °C, dead-fuel moisture 0.04)

Everything here maps to original FRaME. The parameter table is exactly what frame::paramBuilder() produces, and the outputs below match a real frame model run to floating-point precision. vignette("reference-tracking") shows that validation explicitly, across the whole reference corpus.

read_ref <- function(tag) {
  readRDS(system.file("extdata", paste0("ref_", tag, ".rds"), package = "framecpp"))
}
mild_params    <- read_ref("mild")$params
extreme_params <- read_ref("extreme")$params

The parameter table is the long format frame::paramBuilder() produces — one row per stratum × species × parameter:

mild_params |> as_tibble() |> head(8)
#> # A tibble: 8 × 5
#>   stratum species param            value          units
#>   <chr>   <chr>   <chr>            <chr>          <chr>
#> 1 1       1       name             Poa sieberiana <NA> 
#> 2 1       1       clumpDiameter    0.26           m    
#> 3 1       1       clumpSeparation  0              m    
#> 4 1       1       composition      50             <NA> 
#> 5 1       1       deadLeafMoisture 0.1            <NA> 
#> 6 1       1       hc               0              m    
#> 7 1       1       he               0              m    
#> 8 1       1       hp               0.26           m

Running the model

run_fire_model() runs the entire single-site model in-process: it derives species and stratum properties, grows the surface fire, runs the ignition-path model for every plant and stratum, assembles flames, and computes rate of spread.

mild    <- run_fire_model(mild_params)
extreme <- run_fire_model(extreme_params)

Rate of spread per stratum

ros_tbl <- function(res, regime) {
  tibble(level = names(res$run1$ros), ros = unname(res$run1$ros), regime = regime)
}

bind_rows(ros_tbl(mild, "mild"), ros_tbl(extreme, "extreme")) |>
  pivot_wider(names_from = regime, values_from = ros)
#> # A tibble: 5 × 3
#>   level           mild extreme
#>   <chr>          <dbl>   <dbl>
#> 1 Surface      0.00682  0.0972
#> 2 NearSurface  0.0123   0.198 
#> 3 Elevated     0        0.133 
#> 4 MidStorey    0        0     
#> 5 Canopy      NA       NA

The same forest barely creeps under control weather but spreads through the near-surface and elevated strata under severe weather.

Why is the Canopy row NA? The per-stratum run1$ros map reports spread within the standing vegetation (surface → mid-storey). Canopy rate of spread is a separate quantity: it comes from FRaME’s two-run crown-fire calculation and lives in res$canopyROS (with res$hasSecondRun and res$run2 describing the second pass). When there is no crown fire, canopyROS is 0 and the Canopy entry of run1$ros is flagged absent — reported as NA, which is deliberately distinct from a genuine zero. This matches frame, whose rate-of- spread table likewise has no canopy row here. Neither community crowns:

tibble(
  regime       = c("mild", "extreme"),
  canopyROS    = c(mild$canopyROS,    extreme$canopyROS),
  hasSecondRun = c(mild$hasSecondRun, extreme$hasSecondRun)
)
#> # A tibble: 2 × 3
#>   regime  canopyROS hasSecondRun
#>   <chr>       <dbl> <lgl>       
#> 1 mild            0 FALSE       
#> 2 extreme         0 FALSE

Flame summaries per stratum

Flame length, tilt angle (radians) and vertical height (m). The mid-storey flame develops only under the extreme regime:

flame_tbl <- function(res) {
  fs <- res$run1$flameSummaries
  tibble(
    level  = names(fs),
    length = vapply(fs, `[[`, numeric(1), "flameLength"),
    angle  = vapply(fs, `[[`, numeric(1), "flameAngle"),
    height = vapply(fs, `[[`, numeric(1), "flameHeight")
  )
}

flame_tbl(extreme)
#> # A tibble: 4 × 4
#>   level       length angle height
#>   <chr>        <dbl> <dbl>  <dbl>
#> 1 NearSurface  0.503 0.139 0.0697
#> 2 Elevated     3.27  0.413 2.33  
#> 3 MidStorey    4.37  0.284 7.23  
#> 4 Canopy       0     0     0

The result object

run_fire_model() returns a fire_result object: the full run. Its $tables element holds all seven frame result tables’ worth as tidy data frames — Sites, Runs, Strata, FlameSummaries, SurfaceResults, IgnitionPaths, ROS — while $run1 / $run2 give a per-run convenience view (surface detail, flameSummaries, a ros map, ignition paths and combined flames), and $canopyROS / $hasSecondRun are the crown-fire scalars. run2 is the crown-fire second pass (empty when there is no crown fire).

extreme
#> <fire_result>
#>   canopyROS: 0 m/s   crown-fire second run: no
#>   strata: NearSurface, Elevated, MidStorey, Canopy
#>   run 1 surface ROS: 0.0972 m/s   ROS levels: Surface, NearSurface, Elevated, MidStorey
#>   tables: Sites, Runs, Strata, FlameSummaries, SurfaceResults, IgnitionPaths, ROS
#>   ignition-path segments: 64 (run 1) + 0 (run 2)

The ignition-path detail — every ignited segment, per stratum and species — is now surfaced too. Species are identified by the engine’s own speciesId (names are a data-source concern that lives with the consumer):

head(extreme$tables$IgnitionPaths)
#>   repId runIndex       level pathType speciesId speciesRank segIndex     x0 y0
#> 1     1        1 NearSurface    plant         1           0        0 -0.235  0
#> 2     1        1 NearSurface    plant         1           0        1 -0.235  0
#> 3     1        1 NearSurface    plant         2           1        0 -0.235  0
#> 4     1        1 NearSurface    plant         2           1        1 -0.235  0
#> 5     1        1 NearSurface    plant         2           1        2 -0.235  0
#> 6     1        1 NearSurface    plant         2           1        3 -0.235  0
#>         x1       y1   length flameLength
#> 1 0.180608 0.259988 0.490228    0.626976
#> 2 0.180608 0.259988 0.490228    0.626976
#> 3 0.180608 0.259988 0.490228    0.627113
#> 4 0.180608 0.259988 0.490228    0.627113
#> 5 0.180608 0.259988 0.490228    0.627113
#> 6 0.180608 0.259988 0.490228    0.627113

The compiled core reports its version with framecpp_version():

framecpp_version()
#> [1] "framecpp 0.1.0.9005 — FRaME mechanics, reborn"

Building inputs yourself

run_fire_model() is a thin wrapper. scenario_from_params() converts the frame parameter table into the scenario the compiled model consumes (applying FRaME’s unit conventions — wind km/h → m/s, fuel load t/ha → kg/m², slope deg → rad), and parse_fire_result() assembles the fire_result from the compiled run. Calling them separately lets you inspect or adjust the scenario:

scn <- scenario_from_params(mild_params)
scn$wind             # site wind speed (m/s), converted from the table's km/h
#> [1] 1.38889
length(scn$strata)   # number of strata
#> [1] 4

See vignette("reference-tracking") for how the parameter table is produced from frame and how framecpp is validated against it.

Under the hood: the mechanistic primitives

The model is assembled from many small, individually validated primitives; a curated set is exported as a documented low-level API.

Crown geometry. crown_polygon() turns the five crown height/width parameters into the convex polygon FRaME represents a plant crown as:

crown <- crown_polygon(hc = 0, he = 0.05, ht = 0.5, hp = 0.6, w = 0.5)
crown$area
#> [1] 0.2625
crown$vertices
#>       [,1] [,2]
#> [1,] -0.25 0.05
#> [2,] -0.25 0.50
#> [3,]  0.00 0.60
#> [4,]  0.25 0.50
#> [5,]  0.25 0.05
#> [6,]  0.00 0.00
#> [7,] -0.25 0.05

crown_ray_intersection() returns the chord a ray cuts through that crown — the core operation of the ignition-path geometry (here a vertical ray from below):

crown_ray_intersection(0, 0.05, 0.5, 0.6, 0.5,
                       origin_x = 0, origin_y = -1, angle = pi / 2)
#> [1] 0.0 0.0 0.0 0.6

Surface fire. surface_fire() gives head rate of spread and flame length from slope, moisture, fuel load and wind:

sf <- surface_fire(slope = 0, dead_fuel_moisture = 0.1, fuel_load = 0.5,
                   mean_fuel_diameter = 0.005, mean_fineness_leaves = 0.0004,
                   wind_speed = 3)
sf[c("ros", "flameLength")]
#> $ros
#> [1] 0.0972
#> 
#> $flameLength
#> [1] 2

Flame geometry. flame_angle() gives the wind/slope tilt of a flame:

flame_angle(flame_length = 2, wind_speed = 5, slope = 0, fireline_length = 100)
#> [1] 0.166672

Species properties. species_derived() computes the leaf-trait quantities FRaME derives per species (live proportion, leaf area, moisture, ignitability, leaves per clump, leaf-area index):

species_derived(hc = 0, he = 0.05, ht = 0.5, hp = 0.6, w = 0.5,
                live_moisture = 1.0, dead_moisture = 0.1, prop_dead = 0.1,
                ignition_temp = 260, leaf_form = "Flat",
                leaf_thickness = 0.0002, leaf_width = 0.003, leaf_length = 0.05,
                leaf_separation = 0.01, stem_order = 5,
                clump_diameter = 0.1, clump_separation = 0.2)
#> $propLive
#> [1] 0.9
#> 
#> $leafArea
#> [1] 7.5e-05
#> 
#> $leafMoisture
#> [1] 0.91
#> 
#> $ignitabilityCoef
#> [1] 9.1
#> 
#> $leavesPerClump
#> [1] 88.9749
#> 
#> $leafAreaIndex
#> [1] 0.236013

Where this is going

Everything here is classic, discrete-strata FRaME, ported exactly. The larger goal is to generalise it to continuous plant size distributions while keeping this behaviour as an exact special case — run_fire_model() is the classic-mode entry point that generalisation must reduce to. See the code-derived model description in vignettes/articles/frame-model.qmd and the roadmap in dev/plan.md. ```