From c038c9c1873c95a57c65bd2b4c05e73c3030c793 Mon Sep 17 00:00:00 2001 From: James Briant Date: Fri, 23 Oct 2026 16:32:22 +0100 Subject: [PATCH 1/7] Define demo 2 configuration --- data/vqa-demo1-min/params.yaml | 2 - data/vqa-demo2-min/params.yaml | 40 ++++--- params.yaml | 38 ++++++- schema/params.schema.json | 193 ++++++++++++++++++++++++++++++++- 4 files changed, 252 insertions(+), 21 deletions(-) diff --git a/data/vqa-demo1-min/params.yaml b/data/vqa-demo1-min/params.yaml index 0120a13..dc29f34 100644 --- a/data/vqa-demo1-min/params.yaml +++ b/data/vqa-demo1-min/params.yaml @@ -18,6 +18,4 @@ active_indicators: - PCGF - GC - PCESS -indicator_defaults: - scale_abundance: yes source_plot_data_filename: vqa-demo1_data.accdb diff --git a/data/vqa-demo2-min/params.yaml b/data/vqa-demo2-min/params.yaml index f77a185..817e89c 100644 --- a/data/vqa-demo2-min/params.yaml +++ b/data/vqa-demo2-min/params.yaml @@ -1,21 +1,31 @@ project: vqa-demo2-min - -# Validation-only manifest for now. -# This is a declarative subset of params/params.vqa-demo2.R. data_type: mixed -qh_method: empirical -td_only: false -include_plots_nodata: true - -active_indicators: - - SR - - TS - - PCES - - PCGF - - BA - - ASC - +bootstrap_reps: 10 +min_plots: 4 +omit_quality_hectares: false +functional_group_group_by: indicator_group +functional_group_method: generalized_mean +overall_quality_method: mixed +nodata_sd: 4 +aggregate_indicators: [PCGF, ASC] +active_indicators: [SR, TS, PCES, PCGF, BA, ASC] +source_plot_data_filename: Demo_data_main.xlsx +indicator_settings: + SR: + data_type: cover + test_tail: both + ASC: + distribution: gamma +stem_sampling: + min_dbh_cm: 2.5 + split_dbh_cm: 10 + small_plot_area_m2: 48 + large_plot_area_m2: 192 assessments: offset_baseline: active_indicators: [] + aggregate_indicators: [] qh_method: assume.0 + source_plot_data_filename: Demo_data_offsets.xlsx + offset_current: + source_plot_data_filename: Demo_data_offsets.xlsx diff --git a/params.yaml b/params.yaml index a686e97..ebd7b52 100644 --- a/params.yaml +++ b/params.yaml @@ -79,11 +79,13 @@ indicator_defaults: convert_percent: no logit: no remove_zero_cover_plots: no - scale_abundance: no + scale_abundance: yes td_multiplier: ~ td_multiplier_omit: [] exclude_exotics: no na_to_zero: yes + multiplier: 1 + normalize_area_m2: false indicator_settings: SR: name: Species Richness @@ -119,12 +121,45 @@ indicator_settings: has_stratum: yes test_tail: upper benchmark_value: 0 + TS: + name: Taxonomic Similarity + distribution: Bet + quality_method: empirical + has_stratum: no + test_tail: lower + PCES: + name: Percent Cover Exotic Species + distribution: Bet + quality_method: fixed + has_stratum: no + test_tail: upper + benchmark_value: 0 + BA: + name: Basal Area (m2/ha) + distribution: gamma + quality_method: empirical + has_stratum: no + test_tail: both + data_type: ind + normalize_area_m2: 10000 + ASC: + name: Abundance by Size Class + distribution: NBin + quality_method: empirical + has_stratum: yes + test_tail: both + data_type: ind + na_to_zero: no indicator_groups: SR: composition TD: composition PCGF: structure GC: function PCESS: integrity + TS: composition + PCES: integrity + BA: structure + ASC: structure nmds: verbose: no seed_enabled: yes @@ -133,3 +168,4 @@ nmds: dimensions: 3 max_iterations: 200 missing_exotic_status_assume_native: true +pcess_herbs_only: false diff --git a/schema/params.schema.json b/schema/params.schema.json index 86ef8a9..a66a358 100644 --- a/schema/params.schema.json +++ b/schema/params.schema.json @@ -520,7 +520,11 @@ "TD", "PCGF", "GC", - "PCESS" + "PCESS", + "TS", + "PCES", + "BA", + "ASC" ] }, "uniqueItems": true @@ -596,6 +600,25 @@ "title": "Na to zero", "description": "Treat missing observations as zero for the indicator.", "type": "boolean" + }, + "multiplier": { + "title": "Multiplier", + "description": "Scalar applied to indicator observations.", + "type": "number", + "exclusiveMinimum": 0 + }, + "normalize_area_m2": { + "title": "Normalize area in square metres", + "description": "Common area for basal-area normalization; false keeps the observed area.", + "anyOf": [ + { + "const": false + }, + { + "type": "number", + "exclusiveMinimum": 0 + } + ] } }, "additionalProperties": false, @@ -609,7 +632,9 @@ "td_multiplier", "td_multiplier_omit", "exclude_exotics", - "na_to_zero" + "na_to_zero", + "multiplier", + "normalize_area_m2" ] }, "indicator_settings": { @@ -641,6 +666,26 @@ "title": "Percent Cover Exotic Species", "description": "Calculation settings for Percent Cover Exotic Species.", "$ref": "#/definitions/indicator" + }, + "TS": { + "title": "Taxonomic Similarity", + "description": "Calculation settings for Taxonomic Similarity.", + "$ref": "#/definitions/indicator" + }, + "PCES": { + "title": "Percent Cover Exotic Species", + "description": "Calculation settings for Percent Cover Exotic Species.", + "$ref": "#/definitions/indicator" + }, + "BA": { + "title": "Basal Area (m2/ha)", + "description": "Calculation settings for Basal Area (m2/ha).", + "$ref": "#/definitions/indicator" + }, + "ASC": { + "title": "Abundance by Size Class", + "description": "Calculation settings for Abundance by Size Class.", + "$ref": "#/definitions/indicator" } }, "additionalProperties": false @@ -704,6 +749,50 @@ "function", "integrity" ] + }, + "TS": { + "title": "Ts", + "description": "Functional group for Taxonomic Similarity.", + "type": "string", + "enum": [ + "composition", + "structure", + "function", + "integrity" + ] + }, + "PCES": { + "title": "Pces", + "description": "Functional group for Percent Cover Exotic Species.", + "type": "string", + "enum": [ + "composition", + "structure", + "function", + "integrity" + ] + }, + "BA": { + "title": "Ba", + "description": "Functional group for Basal Area (m2/ha).", + "type": "string", + "enum": [ + "composition", + "structure", + "function", + "integrity" + ] + }, + "ASC": { + "title": "Asc", + "description": "Functional group for Abundance by Size Class.", + "type": "string", + "enum": [ + "composition", + "structure", + "function", + "integrity" + ] } }, "additionalProperties": false @@ -769,6 +858,44 @@ "title": "Missing exotic status assumes native", "description": "Record whether missing exotic status was treated as native during data preparation.", "type": "boolean" + }, + "stem_sampling": { + "title": "Stem sampling", + "description": "Sampling geometry used to normalize basal area by stem size.", + "type": "object", + "properties": { + "min_dbh_cm": { + "title": "Minimum dbh cm", + "description": "Minimum measured stem diameter included in basal-area normalization.", + "type": "number", + "minimum": 0 + }, + "split_dbh_cm": { + "title": "Split dbh cm", + "description": "Stem diameter separating the two sampling areas.", + "type": "number", + "exclusiveMinimum": 0 + }, + "small_plot_area_m2": { + "title": "Small plot area m2", + "description": "Total sampled area for stems below the diameter split.", + "type": "number", + "exclusiveMinimum": 0 + }, + "large_plot_area_m2": { + "title": "Large plot area m2", + "description": "Total sampled area for stems at or above the diameter split.", + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": [ + "min_dbh_cm", + "split_dbh_cm", + "small_plot_area_m2", + "large_plot_area_m2" + ], + "additionalProperties": false } }, "required": [ @@ -851,7 +978,8 @@ "indicator_settings", "indicator_groups", "nmds", - "missing_exotic_status_assume_native" + "missing_exotic_status_assume_native", + "pcess_herbs_only" ], "definitions": { "indicator": { @@ -960,6 +1088,35 @@ "title": "Na to zero", "description": "Treat missing observations as zero for the indicator.", "type": "boolean" + }, + "data_type": { + "title": "Data type", + "type": "string", + "enum": [ + "cover", + "ind", + "mixed" + ], + "description": "Species observation data available to preprocessing and analysis." + }, + "multiplier": { + "title": "Multiplier", + "description": "Scalar applied to indicator observations.", + "type": "number", + "exclusiveMinimum": 0 + }, + "normalize_area_m2": { + "title": "Normalize area in square metres", + "description": "Common area for basal-area normalization; false keeps the observed area.", + "anyOf": [ + { + "const": false + }, + { + "type": "number", + "exclusiveMinimum": 0 + } + ] } }, "additionalProperties": false, @@ -1005,6 +1162,36 @@ "source_plot_data_filename" ] } + }, + { + "if": { + "properties": { + "project": { + "enum": [ + "vqa-demo2", + "vqa-demo2-min" + ] + } + }, + "required": [ + "project" + ] + }, + "then": { + "required": [ + "data_type", + "bootstrap_reps", + "omit_quality_hectares", + "functional_group_group_by", + "functional_group_method", + "overall_quality_method", + "nodata_sd", + "aggregate_indicators", + "active_indicators", + "source_plot_data_filename", + "stem_sampling" + ] + } } ] } From b0ad3cf583ed5e596f4f06b44fe1400e7f13b412 Mon Sep 17 00:00:00 2001 From: James Briant Date: Fri, 23 Oct 2026 16:51:32 +0100 Subject: [PATCH 2/7] Support mixed-data script parameters --- analysis/legacy-params.R | 43 +++++++-- tests/testthat/fixtures-demo2-params.R | 128 +++++++++++++++++++++++++ tests/testthat/test-demo2-config.R | 111 +++++++++++++++++++++ 3 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 tests/testthat/fixtures-demo2-params.R create mode 100644 tests/testthat/test-demo2-config.R diff --git a/analysis/legacy-params.R b/analysis/legacy-params.R index 53b88ab..5e5d56f 100644 --- a/analysis/legacy-params.R +++ b/analysis/legacy-params.R @@ -89,22 +89,30 @@ legacy_indicator_symbols <- c( prepare_raw = "prepare.raw", convert_percent = "convert.percent", logit = "logit", remove_zero_cover_plots = "remove.zero.cover.plots", scale_abundance = "scale.abund", td_multiplier = "td.multiplier", td_multiplier_omit = "td.multiplier.omit", - exclude_exotics = "exclude.exotics", na_to_zero = "NA.TO.ZERO" + exclude_exotics = "exclude.exotics", na_to_zero = "NA.TO.ZERO", + multiplier = "ei.multiplier", normalize_area_m2 = "normalize.m2" ) legacy_project_params <- function(config) { values <- config[names(legacy_project_symbols)] names(values) <- unname(legacy_project_symbols) - active <- unlist(config$active_indicators, use.names = FALSE) + active <- as.character(unlist(config$active_indicators, use.names = FALSE)) if (config$td_only) active <- "TD" values$EI.vec <- active values$EI.F.GROUPS <- cbind( EI = active, - f.group = unname(unlist(config$indicator_groups[active])) + f.group = as.character(unlist(config$indicator_groups[active], use.names = FALSE)) ) values$df.EI.FG <- as.data.frame(values$EI.F.GROUPS) values$EI.list <- data.frame(fname = active) - values$EI.agg.list <- data.frame(fname = unlist(config$aggregate_indicators, use.names = FALSE)) + values$EI.agg.list <- data.frame( + fname = as.character( + unlist( + config$aggregate_indicators, + use.names = FALSE + ) + ) + ) nodata <- unlist(config$vegetation_without_data, use.names = FALSE) values$veg.nodata <- if (length(nodata)) nodata else "" values$ei.params <- function(ei_code) { @@ -117,10 +125,17 @@ legacy_project_params <- function(config) { if (is.null(parameters$td.multiplier)) parameters$td.multiplier <- NA omit <- unlist(parameters$td.multiplier.omit, use.names = FALSE) parameters$td.multiplier.omit <- if (length(omit)) omit else "" - parameters$ei.data.type <- if (ei_code %in% c("SR", "TD")) config$data_type else "cover" + parameters$ei.data.type <- if (!is.null(settings$data_type)) { + settings$data_type + } else if (ei_code %in% c("SR", "TD", "TS")) { + config$data_type + } else { + "cover" + } inputs <- c( - SR = "speciesCover", TD = "speciesCover", PCGF = "coverByGrowthForm", - GC = "groundCover", PCESS = "exoticCoverByStratum" + SR = "speciesCover", TD = "speciesCover", TS = "speciesCover", PCGF = "coverByGrowthForm", + GC = "groundCover", PCESS = "exoticCoverByStratum", PCES = "exoticCoverByStratum", + BA = "speciesStems", ASC = "speciesStems" ) parameters$df.input <- if (parameters$ei.data.type == "ind") { "speciesStems" @@ -139,5 +154,19 @@ legacy_project_params <- function(config) { names(parameters) <- unname(symbols) c(list(land.cover = land_cover), parameters) } + if (!is.null(config$stem_sampling)) { + values$dbh.ba.scaled <- function(dbh, target_area_m2 = NULL) { + sampling <- config$stem_sampling + if (is.null(target_area_m2)) target_area_m2 <- 10000 + dplyr::case_when( + dbh >= sampling$min_dbh_cm & dbh < sampling$split_dbh_cm ~ + pi * ((dbh / 2)^2) / 10000 * target_area_m2 / sampling$small_plot_area_m2, + dbh >= sampling$split_dbh_cm ~ + pi * ((dbh / 2)^2) / 10000 * target_area_m2 / sampling$large_plot_area_m2, + is.na(dbh) ~ NA, + .default = NA + ) + } + } values } diff --git a/tests/testthat/fixtures-demo2-params.R b/tests/testthat/fixtures-demo2-params.R new file mode 100644 index 0000000..996e6b4 --- /dev/null +++ b/tests/testthat/fixtures-demo2-params.R @@ -0,0 +1,128 @@ +list(assessments = list(project_baseline = list( + DATA.TYPE = "mixed", + N.MIN.ABS = 4, RAW_PLOTDATA_FILENAME = "Demo_data_main.xlsx", + IS.EXOTIC.MISSING.ASSUME.NATIVE = TRUE, force.prepare.raw = TRUE, + TD.ONLY = FALSE, REMOVE.TD.OUTLIERS = TRUE, TD.OUTLIER.STDEVS = 4, + PLOT.FIGS.ONLY = FALSE, VQA.SUMMARY.ONLY = FALSE, QH.OMIT = FALSE, + Q.FG.GROUP.BY = "indicator_group", Q.FG.METHOD = "generalized_mean", + Q.OVERALL.METHOD = "mixed", INCLUDE.PLOTS.NODATA = TRUE, + NODATA.SD = 4, boot.reps = 10, QH.METHOD = "empirical", FIG.TYPE = "png", + plot.legends = TRUE, LEGEND.NO.FB = FALSE, show.n = FALSE, + legend.combined = FALSE, plot.dists_fitted_grouped_rescaled = FALSE, + plot.dists_fitted_rescaled = TRUE, pcess.herbs.only = FALSE, + veg.nodata = "" +), project_current = list( + DATA.TYPE = "mixed", + N.MIN.ABS = 4, RAW_PLOTDATA_FILENAME = "Demo_data_main.xlsx", + IS.EXOTIC.MISSING.ASSUME.NATIVE = TRUE, force.prepare.raw = TRUE, + TD.ONLY = FALSE, REMOVE.TD.OUTLIERS = TRUE, TD.OUTLIER.STDEVS = 4, + PLOT.FIGS.ONLY = FALSE, VQA.SUMMARY.ONLY = FALSE, QH.OMIT = FALSE, + Q.FG.GROUP.BY = "indicator_group", Q.FG.METHOD = "generalized_mean", + Q.OVERALL.METHOD = "mixed", INCLUDE.PLOTS.NODATA = TRUE, + NODATA.SD = 4, boot.reps = 10, QH.METHOD = "empirical", FIG.TYPE = "png", + plot.legends = TRUE, LEGEND.NO.FB = FALSE, show.n = FALSE, + legend.combined = FALSE, plot.dists_fitted_grouped_rescaled = FALSE, + plot.dists_fitted_rescaled = TRUE, pcess.herbs.only = FALSE, + veg.nodata = "" +), offset_baseline = list( + DATA.TYPE = "mixed", + N.MIN.ABS = 4, RAW_PLOTDATA_FILENAME = "Demo_data_offsets.xlsx", + IS.EXOTIC.MISSING.ASSUME.NATIVE = TRUE, force.prepare.raw = TRUE, + TD.ONLY = FALSE, REMOVE.TD.OUTLIERS = TRUE, TD.OUTLIER.STDEVS = 4, + PLOT.FIGS.ONLY = FALSE, VQA.SUMMARY.ONLY = FALSE, QH.OMIT = FALSE, + Q.FG.GROUP.BY = "indicator_group", Q.FG.METHOD = "generalized_mean", + Q.OVERALL.METHOD = "mixed", INCLUDE.PLOTS.NODATA = TRUE, + NODATA.SD = 4, boot.reps = 10, QH.METHOD = "assume.0", FIG.TYPE = "png", + plot.legends = TRUE, LEGEND.NO.FB = FALSE, show.n = FALSE, + legend.combined = FALSE, plot.dists_fitted_grouped_rescaled = FALSE, + plot.dists_fitted_rescaled = TRUE, pcess.herbs.only = FALSE, + veg.nodata = "" +), offset_current = list( + DATA.TYPE = "mixed", + N.MIN.ABS = 4, RAW_PLOTDATA_FILENAME = "Demo_data_offsets.xlsx", + IS.EXOTIC.MISSING.ASSUME.NATIVE = TRUE, force.prepare.raw = TRUE, + TD.ONLY = FALSE, REMOVE.TD.OUTLIERS = TRUE, TD.OUTLIER.STDEVS = 4, + PLOT.FIGS.ONLY = FALSE, VQA.SUMMARY.ONLY = FALSE, QH.OMIT = FALSE, + Q.FG.GROUP.BY = "indicator_group", Q.FG.METHOD = "generalized_mean", + Q.OVERALL.METHOD = "mixed", INCLUDE.PLOTS.NODATA = TRUE, + NODATA.SD = 4, boot.reps = 10, QH.METHOD = "empirical", FIG.TYPE = "png", + plot.legends = TRUE, LEGEND.NO.FB = FALSE, show.n = FALSE, + legend.combined = FALSE, plot.dists_fitted_grouped_rescaled = FALSE, + plot.dists_fitted_rescaled = TRUE, pcess.herbs.only = FALSE, + veg.nodata = "" +)), indicators = list( + SR = list( + ei.name = "Species Richness", + distn = "NBin", q.method = "empirical", ei.data.type = "cover", + has.stratum = FALSE, test.tail = "both", bm.val = NA, df.input = "speciesCover", + source.file = "speciesCover.csv", prepare.raw = FALSE, convert.percent = FALSE, + logit = FALSE, remove.zero.cover.plots = FALSE, scale.abund = TRUE, + td.multiplier = NA, td.multiplier.omit = "", exclude.exotics = FALSE, + ei.multiplier = 1, normalize.m2 = FALSE, NA.TO.ZERO = TRUE + ), + TS = list( + ei.name = "Taxonomic Similarity", distn = "Bet", + q.method = "empirical", ei.data.type = "mixed", has.stratum = FALSE, + test.tail = "lower", bm.val = NA, df.input = "speciesCover", + source.file = "speciesCover.csv", prepare.raw = FALSE, + convert.percent = FALSE, logit = FALSE, remove.zero.cover.plots = FALSE, + scale.abund = TRUE, td.multiplier = NA, td.multiplier.omit = "", + exclude.exotics = FALSE, ei.multiplier = 1, normalize.m2 = FALSE, + NA.TO.ZERO = TRUE + ), PCES = list( + ei.name = "Percent Cover Exotic Species", + distn = "Bet", q.method = "fixed", ei.data.type = "cover", + has.stratum = FALSE, test.tail = "upper", bm.val = 0, + df.input = "exoticCoverByStratum", source.file = "exoticCoverByStratum.csv", + prepare.raw = FALSE, convert.percent = FALSE, logit = FALSE, + remove.zero.cover.plots = FALSE, scale.abund = TRUE, + td.multiplier = NA, td.multiplier.omit = "", exclude.exotics = FALSE, + ei.multiplier = 1, normalize.m2 = FALSE, NA.TO.ZERO = TRUE + ), + PCGF = list( + ei.name = "Percent Cover by Growth Form", distn = "Bet", + q.method = "empirical", ei.data.type = "cover", has.stratum = TRUE, + test.tail = "both", bm.val = NA, df.input = "coverByGrowthForm", + source.file = "coverByGrowthForm.csv", prepare.raw = FALSE, + convert.percent = FALSE, logit = FALSE, remove.zero.cover.plots = FALSE, + scale.abund = TRUE, td.multiplier = NA, td.multiplier.omit = "", + exclude.exotics = FALSE, ei.multiplier = 1, normalize.m2 = FALSE, + NA.TO.ZERO = TRUE + ), BA = list( + ei.name = "Basal Area (m2/ha)", + distn = "gamma", q.method = "empirical", ei.data.type = "ind", + has.stratum = FALSE, test.tail = "both", bm.val = NA, + df.input = "speciesStems", source.file = "speciesStems.csv", + prepare.raw = FALSE, convert.percent = FALSE, logit = FALSE, + remove.zero.cover.plots = FALSE, scale.abund = TRUE, + td.multiplier = NA, td.multiplier.omit = "", exclude.exotics = FALSE, + ei.multiplier = 1, normalize.m2 = 10000, NA.TO.ZERO = TRUE + ), + ASC = list( + ei.name = "Abundance by Size Class", distn = "gamma", + q.method = "empirical", ei.data.type = "ind", has.stratum = TRUE, + test.tail = "both", bm.val = NA, df.input = "speciesStems", + source.file = "speciesStems.csv", prepare.raw = FALSE, + convert.percent = FALSE, logit = FALSE, remove.zero.cover.plots = FALSE, + scale.abund = TRUE, td.multiplier = NA, td.multiplier.omit = "", + exclude.exotics = FALSE, ei.multiplier = 1, normalize.m2 = FALSE, + NA.TO.ZERO = FALSE + ) +), groups = structure(c( + "SR", "TS", + "PCES", "PCGF", "BA", "ASC", "composition", "composition", "integrity", + "structure", "structure", "structure" +), dim = c(6L, 2L), dimnames = list( + NULL, c("EI", "f.group") +)), basal_area = list(dbh = c( + -1, + 0, 2.499, 2.5, 9.999, 10, 25, NA, NaN, Inf +), per_hectare = c( + NA, + NA, NA, 0.102265385859043, 1.6359189408724, 0.409061543436171, + 2.55663464647607, NA, NA, Inf +), per_100_m2 = c( + NA, NA, NA, 0.00102265385859043, + 0.016359189408724, 0.00409061543436171, 0.0255663464647607, NA, + NA, Inf +))) diff --git a/tests/testthat/test-demo2-config.R b/tests/testthat/test-demo2-config.R new file mode 100644 index 0000000..892b69f --- /dev/null +++ b/tests/testthat/test-demo2-config.R @@ -0,0 +1,111 @@ +testthat::test_that("demo 2 assessments resolve the expected analysis parameters", { + expected <- dget("fixtures-demo2-params.R") + root <- fixture_root("vqa-demo2") + on.exit(unlink(root, recursive = TRUE)) + old_directory <- getwd() + on.exit(setwd(old_directory), add = TRUE) + setwd(.params_root) + for (assessment in names(expected$assessments)) { + context <- load_preprocessing_context(root, assessment, seed = 7L) + testthat::expect_no_error(validate_analysis_params(context$params)) + env <- prepare_legacy_environment(context, context$config, 7L) + settings <- expected$assessments[[assessment]] + testthat::expect_equal(mget(names(settings), env), settings) + testthat::expect_false(exists("scope", envir = env, inherits = FALSE)) + if (context$params$qh_method == "empirical") { + testthat::expect_equal(env$EI.F.GROUPS, expected$groups) + testthat::expect_identical(env$EI.agg.list$fname, c("PCGF", "ASC")) + for (code in env$EI.vec) { + parameters <- env$ei.params(code) + testthat::expect_equal( + parameters[ + names(expected$indicators[[code]]) + ], + expected$indicators[[code]] + ) + } + } else { + testthat::expect_identical(env$EI.vec, character()) + testthat::expect_identical(dim(env$EI.F.GROUPS), c(0L, 2L)) + testthat::expect_equal(nrow(env$EI.agg.list), 0L) + } + } +}) + +testthat::test_that("basal-area normalization preserves sampling boundaries and missing values", { + expected <- dget("fixtures-demo2-params.R")$basal_area + root <- fixture_root("vqa-demo2") + on.exit(unlink(root, recursive = TRUE)) + context <- load_preprocessing_context(root, "project_current", seed = 7L) + parameters <- legacy_project_params(context$config) + testthat::expect_equal( + parameters$dbh.ba.scaled(expected$dbh), + expected$per_hectare, + tolerance = 1e-12 + ) + testthat::expect_equal( + parameters$dbh.ba.scaled(expected$dbh, 100), + expected$per_100_m2, + tolerance = 1e-12 + ) + changed <- load_preprocessing_context(root, "project_current", + seed = 7L, + explicit = list(stem_sampling = list(small_plot_area_m2 = 96)) + ) + changed_parameters <- legacy_project_params(changed$config) + testthat::expect_equal(changed_parameters$dbh.ba.scaled(2.5), parameters$dbh.ba.scaled(2.5) / 2) + testthat::expect_equal(changed_parameters$dbh.ba.scaled(10), parameters$dbh.ba.scaled(10)) +}) + +testthat::test_that("assumed quality and source labels are selected by configuration", { + root <- fixture_root("vqa-demo2") + on.exit(unlink(root, recursive = TRUE)) + project <- load_project_config(root)$data + project$assessments$renamed_assessment <- project$assessments$offset_baseline + yaml::write_yaml(project, file.path(root, "params.yaml")) + testthat::expect_true(file.rename( + file.path(root, "offset_baseline"), + file.path(root, "renamed_assessment") + )) + old_directory <- getwd() + on.exit(setwd(old_directory), add = TRUE) + setwd(.params_root) + context <- load_preprocessing_context(root, "renamed_assessment", seed = 7L) + env <- prepare_legacy_environment(context, context$config, 7L) + testthat::expect_identical(env$QH.METHOD, "assume.0") + testthat::expect_identical(env$RAW_PLOTDATA_FILENAME, "Demo_data_offsets.xlsx") + testthat::expect_length(env$EI.vec, 0L) + testthat::expect_no_error(run_vqa_analysis(context)) + changed <- load_preprocessing_context(root, "renamed_assessment", + seed = 7L, + explicit = list( + qh_method = "empirical", active_indicators = project$active_indicators, + aggregate_indicators = project$aggregate_indicators + ) + ) + testthat::expect_identical( + prepare_legacy_environment(changed, changed$config, 7L)$QH.METHOD, + "empirical" + ) +}) + +testthat::test_that("demo 2 rejects incomplete or inconsistent indicator and sampling settings", { + root <- fixture_root("vqa-demo2") + on.exit(unlink(root, recursive = TRUE)) + for (invalid in list( + list(bootstrap_reps = NULL), list(active_indicators = list()), + list(aggregate_indicators = "TD"), list(unknown_setting = TRUE), + list(stem_sampling = NULL), list(stem_sampling = list(small_plot_area_m2 = 0)), + list(stem_sampling = list(split_dbh_cm = 2.5)), + list(stem_sampling = list(unknown_setting = TRUE)), + list(indicator_settings = list(PCES = list(benchmark_value = NULL))), + list(indicator_settings = list(BA = list(normalize_area_m2 = TRUE))), + list(indicator_settings = list(ASC = list(multiplier = 0))), + list(indicator_settings = list(SR = list(data_type = "unknown"))) + )) { + testthat::expect_error(load_preprocessing_context(root, "project_current", + seed = 7L, + explicit = invalid + )) + } +}) From b66dbcb9739c3d2931ff9e609bdab84402c77de1 Mon Sep 17 00:00:00 2001 From: James Briant Date: Fri, 23 Oct 2026 16:52:41 +0100 Subject: [PATCH 3/7] Run demo 2 from resolved configuration --- analysis/legacy.R | 2 +- tests/refactor/testthat/helper-refactor.R | 12 +++--------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/analysis/legacy.R b/analysis/legacy.R index 699d4bc..d83cf04 100644 --- a/analysis/legacy.R +++ b/analysis/legacy.R @@ -40,7 +40,7 @@ prepare_legacy_environment <- function(context = NULL, config = list(), seed = N run_env$VQA_COMMON_PARAMS <- legacy_common_params( if (is.null(context)) load_default_params() else config ) - if (!is.null(context) && identical(project, "vqa-demo1")) { + if (!is.null(context) && project %in% c("vqa-demo1", "vqa-demo2")) { run_env$VQA_PROJECT_PARAMS <- legacy_project_params(config) } sys.source("params.R", envir = run_env, toplevel.env = run_env) diff --git a/tests/refactor/testthat/helper-refactor.R b/tests/refactor/testthat/helper-refactor.R index acba4c8..adfcf0f 100644 --- a/tests/refactor/testthat/helper-refactor.R +++ b/tests/refactor/testthat/helper-refactor.R @@ -74,24 +74,18 @@ copy_analysis_application <- function() { ) ) testthat::expect_true(all(file.copy(files, application_root, recursive = TRUE))) - testthat::expect_false(file.exists( - file.path(application_root, "params", "params.vqa-demo1.R") - )) + testthat::expect_false(dir.exists(file.path(application_root, "params"))) application_root } run_refactor_workflow <- function(project, assessment, seed = 20260810L) { data_root <- copy_refactor_project(project) old_directory <- getwd() - application_root <- if (identical(project, "vqa-demo1")) { - copy_analysis_application() - } else { - refactor_project_root - } + application_root <- copy_analysis_application() on.exit( { setwd(old_directory) - if (identical(project, "vqa-demo1")) unlink(application_root, recursive = TRUE) + unlink(application_root, recursive = TRUE) }, add = TRUE ) From 5e9c09fbd3822119c0a0c08040314e14a2a476cf Mon Sep 17 00:00:00 2001 From: James Briant Date: Fri, 23 Oct 2026 16:53:11 +0100 Subject: [PATCH 4/7] Document mixed-data analysis configuration --- docs/common-config-inventory.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/common-config-inventory.md b/docs/common-config-inventory.md index 1bf54be..550be29 100644 --- a/docs/common-config-inventory.md +++ b/docs/common-config-inventory.md @@ -76,7 +76,7 @@ counts as a consumer because it decides which calculations or outputs run. ## Project analysis configuration -Demo 1 uses root defaults plus `data/vqa-demo1-min/params.yaml` for both assessments. +Both demo projects use root defaults plus their project `params.yaml` files. `legacy_project_symbols` and `legacy_indicator_symbols` in `analysis/legacy-params.R` map resolved settings to script parameters. The adapter derives indicator lists, groups, input filenames, and the `ei.params()` and `nmds.params()` functions from that configuration. @@ -89,15 +89,28 @@ input filenames, and the `ei.params()` and `nmds.params()` functions from that c | `figure_type`, `plot_legends`, `legend_no_focal_benchmark`, `show_sample_sizes`, `legend_combined`, `plot_dists_fitted_grouped_rescaled`, `plot_dists_fitted_rescaled` | Distribution figures. | | `indicator_defaults`, `indicator_settings`, `nmds`, `pcess_herbs_only`, `vegetation_without_data` | Indicator scripts, NMDS fitting, and workbook indicator metadata. | | `min_plots`, `legacy_summary_area_ha`, `source_plot_data_filename`, `missing_exotic_status_assume_native` | Workbook metadata and preprocessing summaries. | +| `stem_sampling` and indicator `normalize_area_m2` | Basal-area normalization using the sampled area for each DBH size class. | Both demo 1 assessments share the same settings. Their input tables determine the assessment differences. Root defaults contain application settings and indicator definitions; project YAML supplies data type, bootstrap count, aggregation choices, -aggregate indicators, herbs-only exotic cover, and abundance scaling. +aggregate indicators, and herbs-only exotic cover. Abundance scaling is a shared default, +with a separate setting for taxonomic distance. -Demo 2 analysis settings are supplied by `params/params.vqa-demo2.R`. Standalone commands -such as `import.R` and `qh.net.R` load project parameter scripts through `params.R`. -Callable demo 1 analysis supplies resolved project settings directly to that wrapper. +Demo 2 uses mixed data: species richness selects cover observations, taxonomic similarity +uses the project data type, and basal area and abundance by size class select stem records. +Basal area uses configured DBH sampling geometry; abundance by size class counts individuals +from standardized stem records. Indicator settings select the distributions, transformations, +and handling of missing observations. + +The `offset_baseline` override explicitly selects `assume.0`, with no active or aggregate +indicators. Other assessments use empirical quality. Source-data labels for the offset +assessments are explicit overrides. Analysis settings do not depend on assessment-name +patterns. Baseline/offset relationships for net-quality calculations are not read by the +assessment analysis workflow. + +Standalone commands such as `import.R` and `qh.net.R` load project parameter scripts through +`params.R`. Callable analysis supplies resolved project settings directly to that wrapper. ## Runtime and file conventions @@ -105,7 +118,7 @@ Runtime arguments select the data root, assessment, and seed. Analysis operation RNG seeds individually; NMDS has its own seed settings. Paths, input and output filenames, and indicator lists are constructed in code. `params.R` defines application file conventions. -Raw filenames, import filters, include-file replacement, taxonomy/TNRS settings, and +Raw-data loading, import filters, include-file replacement, taxonomy/TNRS settings, and net-quality calculations are separate from assessment analysis. Parameters such as `allow.discrete`, `plot.mean`, `remove.grid`, and `VQA.XLS.NO.AREA` have no reads in the active scripts and are retained for standalone caller compatibility. From 56e345620ca3689220a52207b53ceae063f8fe5f Mon Sep 17 00:00:00 2001 From: James Briant Date: Fri, 23 Oct 2026 16:54:56 +0100 Subject: [PATCH 5/7] plugin demo2 config --- pipeline/params.R | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pipeline/params.R b/pipeline/params.R index b146a64..84eb408 100644 --- a/pipeline/params.R +++ b/pipeline/params.R @@ -35,7 +35,7 @@ validate_params_schema <- function(params) { validate_analysis_params <- function(params) { validate_params_schema(params) - if (isTRUE(params$project %in% c("vqa-demo1", "vqa-demo1-min"))) { + if (isTRUE(params$project %in% c("vqa-demo1", "vqa-demo1-min", "vqa-demo2", "vqa-demo2-min"))) { active <- unlist(params$active_indicators, use.names = FALSE) if ((identical(params$qh_method, "empirical") && !length(active)) || !all(active %in% names(params$indicator_settings)) || @@ -58,6 +58,13 @@ validate_analysis_params <- function(params) { call. = FALSE ) } + if (code == "BA" && !isFALSE(settings$normalize_area_m2) && is.null(params$stem_sampling)) { + stop("Basal-area normalization requires stem sampling geometry.", call. = FALSE) + } + } + if (!is.null(params$stem_sampling) && + params$stem_sampling$min_dbh_cm >= params$stem_sampling$split_dbh_cm) { + stop("The stem diameter split must exceed the minimum measured diameter.", call. = FALSE) } } invisible(TRUE) From 026698674a12e96c7aa8484932aa0e9476e20249 Mon Sep 17 00:00:00 2001 From: James Briant Date: Fri, 23 Oct 2026 16:57:48 +0100 Subject: [PATCH 6/7] switch offset_baseline from assumed-zero to empirical analysis using an explicit override --- tests/testthat/test-runtime-config.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testthat/test-runtime-config.R b/tests/testthat/test-runtime-config.R index dea9805..e57f26f 100644 --- a/tests/testthat/test-runtime-config.R +++ b/tests/testthat/test-runtime-config.R @@ -92,7 +92,7 @@ testthat::test_that("context resolution applies assessment overrides and derives testthat::expect_identical(context$config$qh_method, "assume.0") override <- load_preprocessing_context(root, "offset_baseline", seed = 7L, - explicit = list(qh_method = "empirical") + explicit = list(qh_method = "empirical", active_indicators = "SR") ) testthat::expect_identical(override$params$qh_method, "empirical") testthat::expect_identical(override$config$qh_method, "empirical") From f9f3e47e816d2175867c13fe4b5fd1e7f98edb09 Mon Sep 17 00:00:00 2001 From: James Briant Date: Mon, 26 Oct 2026 10:54:24 +0000 Subject: [PATCH 7/7] lint fix --- tests/testthat/fixtures-common-params.R | 54 +++++++++++++------------ 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/tests/testthat/fixtures-common-params.R b/tests/testthat/fixtures-common-params.R index bfca66f..25680ca 100644 --- a/tests/testthat/fixtures-common-params.R +++ b/tests/testthat/fixtures-common-params.R @@ -1,27 +1,29 @@ # Expected script parameters shared by the six assessment fixtures. -list(LIB.LOAD.SILENT = TRUE, LOGFILE.BASENAME = "log_vqa.batch", - REPLACE.LOG = FALSE, beta.algorithm = "mixed", use.q.diff.raw = TRUE, - discount.method = "scaled", wt.method = "u.beta.buffered", - buffer = 0.01, test.diff.boot = FALSE, perm.reps = 1000, - infer.missing.cls = FALSE, infer.missing.cls.strict = TRUE, - TD.OUTLIERS.APPEND = TRUE, TD.OUTLIER.UPPER.THRESHOLD = 500, - TD.BM.PLOTS.DEDUPLICATE = TRUE, REMOVE.BA.OUTLIERS = TRUE, - BA.OUTLIERS.STDEVS = 4, BA.OUTLIERS.APPEND = TRUE, BA.OUTLIERS.UPPER.THRESHOLD = 500, - q.tr.logit.inverse = FALSE, logit.inverse.beta = 2, zero_to_one = TRUE, - plot.overlap = FALSE, plot.hist = TRUE, quality.legend.only = TRUE, - Q.LEGEND.PERCENT = FALSE, boot.graph.subsample = TRUE, show.overlap = TRUE, - plot.boot.pdfs = TRUE, plot.pdf = TRUE, no.fit.keep.separate = FALSE, - GROUP.MTEXT.LINE = 1, VQA.XLS.DROP.NOTES = TRUE, - VQA.XLS.REMOVE.Q.NA = TRUE, VQA.XLS.DROP.INCLUDE.FALSE = TRUE, - VQA.SUMMARY.PLURALIZE.STRATA = FALSE, plot.dists_fitted = FALSE, - plot.dists_fitted_bm = FALSE, plot.dists_fitted_bm_grouped = FALSE, - plot.dists_fitted_bm_grouped_nofit = FALSE, plot.dists_fitted_bm_grouped_rescaled = FALSE, - plot.dists_fitted_bm_grouped_rescaled_nofit = FALSE, plot.dists_fitted_bm_nofit = FALSE, - plot.dists_fitted_bm_rescaled = FALSE, plot.dists_fitted_bm_rescaled_nofit = FALSE, - plot.dists_fitted_focal = FALSE, plot.dists_fitted_focal_grouped = FALSE, - plot.dists_fitted_focal_grouped_nofit = FALSE, plot.dists_fitted_focal_grouped_rescaled = FALSE, - plot.dists_fitted_focal_grouped_rescaled_nofit = FALSE, plot.dists_fitted_focal_nofit = FALSE, - plot.dists_fitted_focal_rescaled = FALSE, plot.dists_fitted_focal_rescaled_nofit = FALSE, - plot.dists_fitted_grouped = FALSE, plot.dists_fitted_grouped_nofit = FALSE, - plot.dists_fitted_grouped_rescaled_nofit = FALSE, plot.dists_fitted_nofit = FALSE, - plot.dists_fitted_rescaled_nofit = FALSE) +list( + LIB.LOAD.SILENT = TRUE, LOGFILE.BASENAME = "log_vqa.batch", + REPLACE.LOG = FALSE, beta.algorithm = "mixed", use.q.diff.raw = TRUE, + discount.method = "scaled", wt.method = "u.beta.buffered", + buffer = 0.01, test.diff.boot = FALSE, perm.reps = 1000, + infer.missing.cls = FALSE, infer.missing.cls.strict = TRUE, + TD.OUTLIERS.APPEND = TRUE, TD.OUTLIER.UPPER.THRESHOLD = 500, + TD.BM.PLOTS.DEDUPLICATE = TRUE, REMOVE.BA.OUTLIERS = TRUE, + BA.OUTLIERS.STDEVS = 4, BA.OUTLIERS.APPEND = TRUE, BA.OUTLIERS.UPPER.THRESHOLD = 500, + q.tr.logit.inverse = FALSE, logit.inverse.beta = 2, zero_to_one = TRUE, + plot.overlap = FALSE, plot.hist = TRUE, quality.legend.only = TRUE, + Q.LEGEND.PERCENT = FALSE, boot.graph.subsample = TRUE, show.overlap = TRUE, + plot.boot.pdfs = TRUE, plot.pdf = TRUE, no.fit.keep.separate = FALSE, + GROUP.MTEXT.LINE = 1, VQA.XLS.DROP.NOTES = TRUE, + VQA.XLS.REMOVE.Q.NA = TRUE, VQA.XLS.DROP.INCLUDE.FALSE = TRUE, + VQA.SUMMARY.PLURALIZE.STRATA = FALSE, plot.dists_fitted = FALSE, + plot.dists_fitted_bm = FALSE, plot.dists_fitted_bm_grouped = FALSE, + plot.dists_fitted_bm_grouped_nofit = FALSE, plot.dists_fitted_bm_grouped_rescaled = FALSE, + plot.dists_fitted_bm_grouped_rescaled_nofit = FALSE, plot.dists_fitted_bm_nofit = FALSE, + plot.dists_fitted_bm_rescaled = FALSE, plot.dists_fitted_bm_rescaled_nofit = FALSE, + plot.dists_fitted_focal = FALSE, plot.dists_fitted_focal_grouped = FALSE, + plot.dists_fitted_focal_grouped_nofit = FALSE, plot.dists_fitted_focal_grouped_rescaled = FALSE, + plot.dists_fitted_focal_grouped_rescaled_nofit = FALSE, plot.dists_fitted_focal_nofit = FALSE, + plot.dists_fitted_focal_rescaled = FALSE, plot.dists_fitted_focal_rescaled_nofit = FALSE, + plot.dists_fitted_grouped = FALSE, plot.dists_fitted_grouped_nofit = FALSE, + plot.dists_fitted_grouped_rescaled_nofit = FALSE, plot.dists_fitted_nofit = FALSE, + plot.dists_fitted_rescaled_nofit = FALSE +)