12  Natural Forest Management (NFM) Timber Harvest Plan

As in Chapter 11, this chapter also outlines the proposed baseline harvest plan but for NFM coupes. The key difference for the NFM coupes is that the cutting limit is 40 cm DBM and therefore, the 40 cm Merchantable Volume map is used to extract harvestable volume in this case.

Table 12.1: Table of extracted timber volumes from NFM strata types for each year. Reported values for Extracted timber volume include a 24% reduction to account for protected tree species, see Chapter 10. Area values are reported following a 1.67% reduction to align with the legal definition of the site area.

The following map depicts the spatial sequencing of the baseline timber harvest plan for the NFM strata. Note that the baseline for NFM strata begins in 2024.

Natural Forest Management timber harvest plan and corresponding timber volumes

12.1 Code

NFM Timber Harvest Plan Code R/TimberHarvestPlan_NFM_Coupes.R
#' NFM Timber Harvest Plan
#'
#' @description This function creates the timber harvest plan for the NFM
#' Plantings coupes.
#'
#' @param boundary_src path to the boundary polygon
#' @param NFMCoupes_src path to the NFMCoupes spatial polygon
#' @param dbh40_src path to the 31cm DBH Merchantable Volume raster
#' @param dbh10_src path to the 10cm DBH Merchantable Volume raster
#' @param acd_src path to the Above Ground Carbon (ACD) raster
#' @param setup logging plot setup size in hectares (31ha was average in
#' INFAPRO area see Philipon 2020)
#' @param ReEntry default is 10. Re-entry logging in number of years
#' @param R2ReEntry default is 15. Second round of Re-entry logging in number
#' of years
#' @param sp_conserve_factor derived from `R/protected-trees-stats.R`
#' @return A list containg the following: parcel_csv_list: a table containing
#' the parcel-level data, nfm_src: a gridded coupes dataset for NFM,
#' nfm_ggplots: a ggplot object with a map of the harvest plan, nfm_vol31_spv:
#' A spatial vector containing the timber volume data.
timber_harvest_plan_NFM <- function(boundary_src, NFMCoupes_src,
                                    dbh40_src, dbh10_src, acd_src,
                                    setup = 31, ReEntry = 10, R2ReEntry = 15,
                                    sp_conserve_factor) {
  # init values...
  ha <- (100 * 100) # 1 hectare is 100m x 100m
  setup_SqM <- setup * ha # gives size in square meters
  ploty <- sqrt(setup_SqM)
  plotx <- ploty

  Boundary <- sf::read_sf(boundary_src)


  ## Area for Improved Forest Management
  NFMCoupes <- sf::read_sf(NFMCoupes_src) |>
    mutate(
      BLOCK_N = case_when(
        is.na(BLOCK_N) ~ "SE_heal",
        TRUE ~ BLOCK_N
      ),
      YearEnd = case_when(
        YearEnd == 0 ~ max(YearEnd),
        TRUE ~ YearEnd
      )
    )

  # Run separately for three raster options (40cm cutting limit for NFM NFM),
  # 10cm (all for residual damage), and ACD for comparison
  fileName_list <- list(
    "40cmDBH" = dbh40_src,
    "10cmDBH" = dbh10_src,
    "ACD_10cm" = acd_src
  )

  # Convert Rasters from volume/ha to absolute volume per cell.
  # function in : 'R/coupes_helpers.R'
  abs_vol_list <- fileName_list |>
    purrr::imap(~ create_abs_volume(.x, .y, Boundary, NFMCoupes))

  create_NFM_grid <- function(strata, type_name, out_path) {
    RentryYear <- max(as.integer(strata$YearEnd)) + ReEntry

    # Make the grid of individual setups to be logged
    grid <- sf::st_make_grid(strata, cellsize = c(plotx, ploty))

    gridmask <- sf::st_intersection(grid, strata)

    # join attribute tables
    strata_grid <- sf::st_join(
      sf::st_as_sf(gridmask),
      sf::st_as_sf(strata),
      join = sf::st_intersects, largest = TRUE
    )

    strata_grid_poly <- sf::st_collection_extract(
      x = strata_grid,
      type = "POLYGON"
    )
    strata_grid <- strata_grid_poly |>
      mutate(id = row_number()) |>
      mutate(
        LogYear = case_when(
          BLOCK_N %in% c("YL3/04", "YS2/09") ~ RentryYear,
          BLOCK_N %in% c("YS4/04", "YS3/09", "YL4/03(2)") ~ RentryYear + 1,
          BLOCK_N %in% c("YS4/05(2)") ~ RentryYear + 2,
          BLOCK_N %in% c("YL4/03(1)", "YS4/05(1)", "YT1/06(I)")
          ~ RentryYear + 3,
          BLOCK_N %in% c("YLH5/06(A)", "YLH5/06(B)", "YLH1/08", "SE_heal")
          ~ RentryYear + 4,
          TRUE ~ -9999
        ),
        area_ha = units::set_units(
          sf::st_area(strata_grid_poly),
          value = hectare
        )
      ) |>
      select(!Area_ha) # dropping old area column.

    if (nrow(strata_grid |> dplyr::filter(LogYear == -9999)) > 0) {
      stop("Check the Block Names!")
    }

    region_list <- list(gridmask, strata, strata_grid)

    areas <- purrr::map_dbl(region_list, ~ get_areas(.x))
    purrr::walk(areas, ~ testthat::expect_equal(.x, areas[1]))

    sf::write_sf(strata_grid, out_path, delete_dsn = TRUE)

    return(strata_grid)
  }

  NFM_polygon_out_dir <- dirname(NFMCoupes_src)
  NFM_polygon_out_shp <- file.path(
    NFM_polygon_out_dir, "NFMCoupes_THP_Grid.shp"
  )
  NFM_grid <- create_NFM_grid(
    strata = NFMCoupes, type_name = "NFM-PLANTED",
    out_path = NFM_polygon_out_shp
  )

  # extract values for strata
  # function in : 'R/coupes_helpers.R'
  NFM_strat_ext <- purrr::map(.x = abs_vol_list, ~ extract_strata(.x, NFM_grid))

  # make plots for each logging year
  nfm_ggplots <- purrr::imap(
    .x = NFM_strat_ext,
    ~ plot_setups(.x, .y, "NFM-PLANTED", outline_width = 0.6)
  )


  ParcelsIntoFuture <- purrr::imap(
    .x = NFM_strat_ext,
    ~ summarise_parcels_nfm(.x, .y,
      sp_conserve_factor =
        sp_conserve_factor,
      R2ReEntry = R2ReEntry
    )
  )

  parcel_csv_list <- purrr::imap(
    ParcelsIntoFuture,
    ~ save_parcels(.x, .y, "NFM_THP_Parcels")
  )

  return(c(
    parcel_csv_list,
    list(nfm_src = NFM_polygon_out_shp),
    list(nfm_ggplots = nfm_ggplots),
    list(nfm_vol31_spv = NFM_strat_ext$`40cmDBH`)
  ))
}
Additional Timber Harvest Plan Functions R/coupes_helpers.R
#' convert raster density to absolute values.
#'
#' @param ras_src raster source relative to data folder
#' @param ras_name the desired name of the raster
#' @param aoi the area of interest (spatial polygon or sf)
#' @param overlay coupes areas for plot vis
#' @param scale.factor default is 10000 i.e number of square meters in a ha.
#'
#' @return raster with absolute values.
#' @details
#' This function converts the raster density values to absolute values.
#' The raster density values are per hectare, so we need to convert them to
#' absolute values. The scale factor is the number of square meters in a
#' hectare.
#'
create_abs_volume <- function(
    ras_src, ras_name, aoi, overlay, scale.factor = 1e4) {
  V_BSL <- terra::rast(ras_src)[[1]]
  V_BSL * ((terra::res(V_BSL)[1] * terra::res(V_BSL)[2]) / scale.factor)
}


#' Extract sum
#'
#' Simple function to extract sum of raster within coupes.
#'
#' @param V_BSLpPix a raster object. Either Timber Volume or ACD.
#' @param strata_grid a gridded spatial vector of the strata of interest. sf object.
#'
#' @return sf object with sum of V_BSL per setup.
#' @details
#' extract sum of V_BSL per setup using MosaicGrid ( will be == V_EX when back
#' to per ha) UNITS should be sum per pixel
extract_strata <- function(V_BSLpPix, strata_grid) {
  strata_grid |>
    mutate(VolSum = exactextractr::exact_extract(
      V_BSLpPix, strata_grid, "sum"
    )) # use {exactextractr} for speed.
}



#' plot ACD or Timber Volume
#'
#' @param setups
#' @param dbh
#' @param mosaic_type
#' @param outline_width
#'
#' @return ggplot object.
plot_setups <- function(setups, dbh, mosaic_type, outline_width = 0.03, .ncol = 2) {
  p <- setups |>
    ggplot() +
    aes(fill = VolSum) +
    geom_sf(
      data = sf::st_geometry(dplyr::summarise(setups)), colour = "grey70", fill = "transparent",
      inherit.aes = FALSE, lwd = 0.1
    ) +
    geom_sf(data = setups, colour = "grey30", fill = "transparent", lwd = outline_width) +
    geom_sf(colour = NA) +
    scale_fill_viridis_c() +
    theme_light() +
    facet_wrap(~LogYear, ncol = .ncol) +
    labs(title = dbh) +
    theme(
      axis.title.x = element_blank(),
      axis.text.x = element_blank(),
      axis.ticks.x = element_blank(),
      axis.title.y = element_blank(),
      axis.text.y = element_blank(),
      axis.ticks.y = element_blank(),
      panel.grid.major = element_blank(),
      panel.grid.minor = element_blank()
    )
}


#' save parcel csv
#'
#' @param parcel_df the parcel summary df to save.
#' @param dbh one of: c("ACD_10cm", "40cmDBH", "31cmDBH", "10cmDBH")
#' @param save_pref character prefix for the file path
#'
#' @return file path (character) of the saved csv.
save_parcels <- function(parcel_df, dbh, save_pref) {
  # Save Parcels using 30cm DBH, 10cm DBH (or ACD as a check)
  if (dbh != "ACD_10cm") {
    what <- paste0("V_EX_", dbh)
  } else {
    what <- dbh
  }
  # Save each of the three Parcel files to csv

  parcels_save_path <- set_path(paste0(
    "RoutputProducts/",
    save_pref,
    what, ".csv"
  ))
  write.table(parcel_df, parcels_save_path)

  return(parcels_save_path)
}


#' helper function for initial parcel defs
#'
#' @param setups
#'
#' @return sf object of summarised parcels.
parcel_setup <- function(setups, dbh) {
  Parc1 <- setups |>
    group_by(LogYear) |>
    dplyr::summarise(VolSum = sum(VolSum, na.rm = TRUE))
  Parc <- Parc1 |>
    mutate(Area_ha = units::set_units(sf::st_area(Parc1), ha)) |>
    sf::st_drop_geometry()

  # For Asner Map version ACD, for merchantable, name is V_EX
  if (dbh == "ACD_10cm") {
    Parc <- mutate(Parc, ACD = round(VolSum / as.numeric(Area_ha)))
  } else {
    Parc <- mutate(Parc, V_EX = round(VolSum / as.numeric(Area_ha))) # -> V_EX
  }
  return(Parc)
}

#' function to create mosaic parcels
#'
#' @param setups
#' @param dbh
#' @param Coupe_planted
#'
#' @return sf object of summarised parcels.
summarise_parcels_mos <- function(setups, dbh, Coupe_planted = TRUE,
                                  sp_conserve_factor, Yrs2Plant, Rotation,
                                  P_ha) {
  ### Here we add up the volume of all setups into the yearly parcels.
  Parcels <- parcel_setup(setups, dbh)

  # In the Mosaics we will assume a baseline of extracting only 70% of all
  # merchantable timber this is a more conservative way to account for species
  # that shouldn't be logged.

  if (dbh == "31cmDBH") {
    #  # Don't extract all merchantable volume based on sp_conserve_factor
    Parcels <- mutate(Parcels, V_EX = round(V_EX * sp_conserve_factor, 1))
  }
  strata_def <- function(plant_type) {
    paste("Mosaic", plant_type, sep = "-")
  }
  Parcels <- Parcels |>
    mutate(
      Strata = case_when(
        isTRUE(Coupe_planted) ~ strata_def("planted"),
        TRUE ~ strata_def("unplanted")
      ),
      Plant = case_when(
        isTRUE(Coupe_planted) ~ "planted",
        TRUE ~ "unplanted"
      ),
      Rotation = "First"
    )

  #------------ Future rounds of logging ------------------------------------
  # For transparency and simplicity We keep predictions for Future logging
  # periods as straightforward as possible as these will be revisited
  #-------------------------------------------------------------------------#
  ## Modelling overall growth rate based on planting rate and rotation
  plantingArea <- as.numeric(P_ha)
  AReaPlantPA <- round(plantingArea / Yrs2Plant)

  ParcelsNextRotation <- data.frame(
    LogYear = seq(from = (2017 + Rotation), length.out = Yrs2Plant),
    VolSum = rep(NA, length.out = Yrs2Plant),
    Area_ha = rep(AReaPlantPA, length.out = Yrs2Plant),
    V_EX = rep(95 * sp_conserve_factor, length.out = Yrs2Plant),
    Strata = paste("Mosaic-planted"),
    Plant = paste("planted"),
    Rotation = paste("Second")
  ) |>
    mutate(VolSum = Area_ha * V_EX)

  # colname for ACD carbon map check
  if (dbh == "ACD_10cm") {
    colnames(ParcelsNextRotation)[4] <- "ACD"
  }

  # If the area is planted, there will be a future rotation
  # (according to above), if not planted, then not during this baseline period
  if (isTRUE(Coupe_planted)) {
    return(rbind(Parcels, ParcelsNextRotation))
  } else {
    return(Parcels)
  }
}


#' Function to create Natural Forest Management Parcels.
#'
#' @param setups
#' @param dbh
#'
#' @return sf object of summarised parcels.
summarise_parcels_nfm <- function(setups, dbh, sp_conserve_factor, R2ReEntry) {
  Parcels <- parcel_setup(setups, dbh)

  # In # the NFM we will assume a baseline of extracting only 70% of all
  # merchantable timber. This is a more conservative way to account for
  # species that shouldn't be logged and would fit with 'selective logging'.

  if (dbh == "40cmDBH") {
    # Don't extract all merchantable volume based on sp_conserve_factor
    Parcels <- mutate(Parcels, V_EX = round(V_EX * sp_conserve_factor, 1))
  }

  Parcels$Strata <- "NFM"
  Parcels$Plant <- "unplanted"
  Parcels$Rotation <- "First"

  # Future rounds of logging:
  # For transparency and simplicity We keep predictions for Future logging
  # periods as straightforward as possible as these will be revisited
  ParcelsNextRentry <- Parcels |>
    mutate(
      LogYear = LogYear + R2ReEntry,
      Rotation = "Second"
    )

  # given we have a conservative baseline extraction rate, we estimate the same
  # volume could and would be extracted after a further 15 years
  rbind(Parcels, ParcelsNextRentry)
}