diff --git a/NAMESPACE b/NAMESPACE index c59c727..4fc63ef 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,5 +1,6 @@ # Generated by roxygen2: do not edit by hand +export(generate_selectivity_curve) export(get_functional_groups) export(load_csv_environmental_data) export(load_csv_ewe) diff --git a/R/fit_selectivity_helpers.R b/R/fit_selectivity_helpers.R new file mode 100644 index 0000000..3e26f46 --- /dev/null +++ b/R/fit_selectivity_helpers.R @@ -0,0 +1,233 @@ +#' Fit logistic selectivity parameters +#' +#' @description +#' Fit logistic curve parameters to a vector of normalized values. The logistic +#' function is defined as: S(x) = 1 / (1 + exp(-slope * (x - inflection_point))) +#' where x is the index position (1, 2, 3, ...). +#' +#' @param f_values A numeric vector of normalized values (0-1) for each +#' group, in the order they should appear on the x-axis. These values +#' represent relative selectivity, maturity, or any other quantity that +#' follows a logistic pattern. +#' +#' @return A tibble with one row per parameter, containing the standard FIMS +#' parameter format columns. +#' +#' @details +#' The function uses nonlinear least squares (nls) to fit a logistic curve to +#' the normalized values. The x-axis index is simply 1, 2, 3, ... based on +#' the position in the values vector. +#' +#' If the optimization fails, the function falls back to a simple heuristic +#' method to estimate the inflection point and slope. +#' +#' @keywords internal +fit_logistic_selectivity <- function(f_values) { + # Create index (1, 2, 3, ...) + index_values <- seq_along(f_values) + + # Define logistic function + logistic <- function(index, inflection_point, slope) { + 1 / (1 + exp(-slope * (index - inflection_point))) + } + + # Initial parameter guesses + # Inflection point: index where value is around 0.5 + midpoint_index <- which.min(abs(f_values - 0.5)) + initial_inflection <- if (length(midpoint_index) > 0) index_values[midpoint_index] else mean(index_values) + + # Slope: positive value, start with 1 + initial_slope <- 1 + + # Fit using nonlinear least squares + fit_result <- tryCatch( + { + stats::nls( + f_values ~ logistic(index_values, inflection_point, slope), + start = list(inflection_point = initial_inflection, slope = initial_slope), + control = stats::nls.control(maxiter = 100, warnOnly = TRUE) + ) + }, + error = function(e) { + # Fallback: use simple heuristic + NULL + } + ) + + if (!is.null(fit_result)) { + params <- stats::coef(fit_result) + inflection_point <- params["inflection_point"] + slope <- params["slope"] + } else { + # Fallback method: find inflection as midpoint of rise + # and estimate slope from the steepest part of the curve + inflection_point <- initial_inflection + + # Estimate slope from differences + if (length(index_values) > 1) { + diffs <- diff(f_values) / diff(index_values) + slope <- max(abs(diffs), 0.1) * 4 # Scale up since max derivative of logistic is slope/4 + } else { + slope <- 1 + } + } + + # Create output tibble in FIMS format + tibble::tibble( + model_family = "catch_at_age", + module_name = "Selectivity", + module_type = "Logistic", + label = c("inflection_point", "slope"), + distribution_link = NA_character_, + age = NA_real_, + length = NA_real_, + time = NA_real_, + value = c(inflection_point, slope), + estimation_type = "fixed_effects", + distribution_type = NA_character_, + distribution = NA_character_ + ) +} + + +#' Fit double logistic selectivity parameters +#' +#' @description +#' Fit double logistic (dome-shaped) curve parameters to a vector of normalized +#' values. The double logistic function combines ascending and descending logistic +#' curves: S(x) = 1 / (1 + exp(-slope_asc * (x - inflection_asc))) * +#' (1 - 1 / (1 + exp(-slope_desc * (x - inflection_desc)))) +#' +#' @param f_values A numeric vector of normalized values (0-1) for each +#' group, in the order they should appear on the x-axis. These values +#' represent relative selectivity, maturity, or any other quantity that +#' follows a dome-shaped pattern. +#' +#' @return A tibble with one row per parameter, containing the standard FIMS +#' parameter format columns. +#' +#' @details +#' The function attempts to fit a dome-shaped curve. If the data +#' appears monotonic (no dome shape), it will still fit the parameters but may +#' produce parameters that effectively reduce to a simple logistic. +#' +#' The fitting uses nonlinear least squares with constraints to ensure: +#' \itemize{ +#' \item Ascending inflection point comes before descending +#' \item Slopes are positive +#' \item The curve shape is biologically reasonable +#' } +#' +#' @keywords internal +fit_double_logistic_selectivity <- function(f_values) { + # Create index (1, 2, 3, ...) + index_values <- seq_along(f_values) + + # Define double logistic function + double_logistic <- function(index, inflection_point_ascending, slope_ascending, inflection_point_descending, slope_descending) { + ascending_component <- 1 / (1 + exp(-slope_ascending * (index - inflection_point_ascending))) + descending_component <- 1 - 1 / (1 + exp(-slope_descending * (index - inflection_point_descending))) + ascending_component * descending_component + } + + # Initial parameter guesses + # Find the peak of the values + peak_idx <- which.max(f_values) + peak_index <- index_values[peak_idx] + + # Ascending parameters: before peak + if (peak_idx > 1) { + ascending_f <- f_values[1:peak_idx] + ascending_indices <- index_values[1:peak_idx] + midpoint_ascending_index <- which.min(abs(ascending_f - 0.5)) + initial_inflection_point_ascending <- if (length(midpoint_ascending_index) > 0) ascending_indices[midpoint_ascending_index] else peak_index - 1 + } else { + initial_inflection_point_ascending <- peak_index - 1 + } + + # Descending parameters: after peak + if (peak_idx < length(index_values)) { + descending_f <- f_values[peak_idx:length(index_values)] + descending_indices <- index_values[peak_idx:length(index_values)] + # For descending, look for where it drops below peak/2 + midpoint_descending_index <- which.min(abs(descending_f - max(f_values) / 2)) + initial_inflection_point_descending <- if (length(midpoint_descending_index) > 0) descending_indices[midpoint_descending_index] else peak_index + 1 + } else { + initial_inflection_point_descending <- peak_index + 1 + } + + # Ensure descending inflection is after ascending + if (initial_inflection_point_descending <= initial_inflection_point_ascending) { + initial_inflection_point_descending <- initial_inflection_point_ascending + 1 + } + + initial_slope_ascending <- 1 + initial_slope_descending <- 1 + + # Fit using nonlinear least squares + fit_result <- tryCatch( + { + stats::nls( + f_values ~ double_logistic(index_values, inflection_point_ascending, slope_ascending, inflection_point_descending, slope_descending), + start = list( + inflection_point_ascending = initial_inflection_point_ascending, + slope_ascending = initial_slope_ascending, + inflection_point_descending = initial_inflection_point_descending, + slope_descending = initial_slope_descending + ), + control = stats::nls.control(maxiter = 200, warnOnly = TRUE), + algorithm = "port", + lower = c( + inflection_point_ascending = min(index_values), + slope_ascending = 0.01, + inflection_point_descending = initial_inflection_point_ascending + 0.5, + slope_descending = 0.01 + ), + upper = c( + inflection_point_ascending = max(index_values), + slope_ascending = 10, + inflection_point_descending = max(index_values) + 10, + slope_descending = 10 + ) + ) + }, + error = function(e) { + NULL + } + ) + + if (!is.null(fit_result)) { + params <- stats::coef(fit_result) + inflection_point_ascending <- params["inflection_point_ascending"] + slope_ascending <- params["slope_ascending"] + inflection_point_descending <- params["inflection_point_descending"] + slope_descending <- params["slope_descending"] + } else { + # Fallback to initial guesses + inflection_point_ascending <- initial_inflection_point_ascending + slope_ascending <- initial_slope_ascending + inflection_point_descending <- initial_inflection_point_descending + slope_descending <- initial_slope_descending + } + + # Create output tibble in FIMS format + tibble::tibble( + model_family = "catch_at_age", + module_name = "Selectivity", + module_type = "DoubleLogistic", + label = c( + "inflection_point_asc", + "slope_asc", + "inflection_point_desc", + "slope_desc" + ), + distribution_link = NA_character_, + age = NA_real_, + length = NA_real_, + time = NA_real_, + value = c(inflection_point_ascending, slope_ascending, inflection_point_descending, slope_descending), + estimation_type = "fixed_effects", + distribution_type = NA_character_, + distribution = NA_character_ + ) +} diff --git a/R/generate_selectivity_curve.R b/R/generate_selectivity_curve.R new file mode 100644 index 0000000..0f10c37 --- /dev/null +++ b/R/generate_selectivity_curve.R @@ -0,0 +1,223 @@ +utils::globalVariables(c("functional_group", "year", "month", "time_step")) + +#' Generate selectivity curve parameters from ecosystem model F values +#' +#' @description +#' This function takes fishing mortality (F) values by functional group from an +#' ecosystem model and fits selectivity curve parameters based on a specified +#' functional form. The function is modular and can be extended to support +#' additional selectivity curve types. +#' +#' @param ewe_output A tibble containing ecosystem model output, typically from +#' [load_model()] with type = "ewe". Must contain columns: functional_group, +#' type, value, year, and month. The function will filter for type == "mortality" +#' and generate selectivity parameters for each year-month combination. +#' @param functional_groups A character vector of functional group names in the +#' order desired for the x-axis of the selectivity curve. These should match +#' names in the functional_group column of ewe_output. +#' @param functional_form A character string specifying the selectivity curve +#' type. Currently supports "logistic" and "double_logistic". Additional +#' forms can be added by creating new helper functions. +#' @param fleet_name A character string specifying the fleet name to use in the +#' output tibble. Default is "fleet1". +#' +#' @return A tibble with one row per parameter per time step. Contains the following columns: +#' \itemize{ +#' \item model_family: Always "catch_at_age" +#' \item module_name: Always "Selectivity" +#' \item fleet_name: Fleet name as specified in the input +#' \item module_type: The selectivity curve type (e.g., "Logistic", "DoubleLogistic") +#' \item label: Parameter name (e.g., "inflection_point", "slope") +#' \item distribution_link: Always NA +#' \item age: Always NA +#' \item length: Always NA +#' \item time: Time step identifier (year-month combination) +#' \item value: The fitted parameter value +#' \item estimation_type: Always "fixed_effects" +#' \item distribution_type: Always NA +#' \item distribution: Always NA +#' } +#' +#' @details +#' The function extracts fishing mortality values for the specified functional +#' groups for each year-month combination and uses a helper function specific to +#' the chosen functional form to fit selectivity parameters. For each time step, +#' the F values are normalized to be between 0 and 1 before fitting, representing +#' relative selectivity. This results in time-varying selectivity parameters. +#' +#' New functional forms can be added by creating helper functions with the +#' naming pattern `fit_