# tests/testthat/test-cross-price.R # Test suite for cross-price NLS models per EQUATIONS_CONTRACT.md # ============================================================================== # Helper functions for simulating cross-price demand data # ============================================================================== #' Simulate cross-price demand data for exponentiated equation #' Per EQUATIONS_CONTRACT.md: #' y = Q_alone * 10^(I * exp(-beta * x)) simulate_cp_exponentiated <- function( n = 50, qalone = 10, I = 1.5, beta = 0.05, sigma = 0.5, seed = 42 ) { set.seed(seed) x <- runif(n, min = 1, max = 50) # True model: y = qalone * 10^(I * exp(-beta * x)) y_true <- qalone * 10^(I * exp(-beta * x)) # Add multiplicative noise y <- y_true * exp(rnorm(n, 0, sigma)) data.frame(x = x, y = y) } #' Simulate cross-price demand data for exponential equation #' Per EQUATIONS_CONTRACT.md: #' log10(y) = log10(Q_alone) + I * exp(-beta * x) simulate_cp_exponential <- function( n = 50, qalone = 10, I = 1.5, beta = 0.05, sigma = 0.2, seed = 42 ) { set.seed(seed) x <- runif(n, min = 1, max = 50) # True model on log10 scale: log10(y) = log10(qalone) + I * exp(-beta * x) log10_y_true <- log10(qalone) + I * exp(-beta * x) # Add noise on log10 scale log10_y <- log10_y_true + rnorm(n, 0, sigma) y <- 10^log10_y data.frame(x = x, y = y) } #' Simulate cross-price demand data for additive equation #' Per EQUATIONS_CONTRACT.md: #' y = Q_alone + I * exp(-beta * x) simulate_cp_additive <- function( n = 50, qalone = 5, I = 10, beta = 0.05, sigma = 1, seed = 42 ) { set.seed(seed) x <- runif(n, min = 1, max = 50) # True model: y = qalone + I * exp(-beta * x) y_true <- qalone + I * exp(-beta * x) # Add additive noise y <- y_true + rnorm(n, 0, sigma) y <- pmax(y, 0.1) # Ensure positive data.frame(x = x, y = y) } # ============================================================================== # Test: Basic fitting functionality # ============================================================================== test_that("fit_cp_nls fits exponentiated equation", { skip_if_not_installed("nls.multstart") data <- simulate_cp_exponentiated(n = 30, qalone = 10, I = 1.5, beta = 0.05, seed = 123) fit <- fit_cp_nls(data, equation = "exponentiated", return_all = TRUE, iter = 50) expect_s3_class(fit, "cp_model_nls") expect_equal(fit$equation, "exponentiated") expect_false(is.null(fit$model)) # Check coefficient names match new parameterization coefs <- coef(fit$model) expect_true("log10_qalone" %in% names(coefs)) expect_true("I" %in% names(coefs)) expect_true("log10_beta" %in% names(coefs)) }) test_that("fit_cp_nls fits exponential equation", { skip_if_not_installed("nls.multstart") data <- simulate_cp_exponential(n = 30, qalone = 10, I = 1.5, beta = 0.05, seed = 123) fit <- fit_cp_nls(data, equation = "exponential", return_all = TRUE, iter = 50) expect_s3_class(fit, "cp_model_nls") expect_equal(fit$equation, "exponential") expect_false(is.null(fit$model)) # Check coefficient names match new parameterization coefs <- coef(fit$model) expect_true("log10_qalone" %in% names(coefs)) expect_true("I" %in% names(coefs)) expect_true("log10_beta" %in% names(coefs)) }) test_that("fit_cp_nls fits additive equation", { skip_if_not_installed("nls.multstart") data <- simulate_cp_additive(n = 30, qalone = 5, I = 10, beta = 0.05, seed = 123) fit <- fit_cp_nls(data, equation = "additive", return_all = TRUE, iter = 50) expect_s3_class(fit, "cp_model_nls") expect_equal(fit$equation, "additive") expect_false(is.null(fit$model)) # Check coefficient names match new parameterization coefs <- coef(fit$model) expect_true("log10_qalone" %in% names(coefs)) expect_true("I" %in% names(coefs)) expect_true("log10_beta" %in% names(coefs)) }) # ============================================================================== # Test: Zero handling for exponential equation # ============================================================================== test_that("fit_cp_nls removes zeros with warning for exponential equation", { skip_if_not_installed("nls.multstart") # Create data with some zeros data <- simulate_cp_exponential(n = 30, qalone = 10, I = 1.5, beta = 0.05, seed = 123) data$y[c(1, 5, 10)] <- 0 # Add some zeros # Should warn about removing zeros expect_warning( fit <- fit_cp_nls(data, equation = "exponential", return_all = TRUE, iter = 50), "Removing 3 observation" ) # Model should still fit on remaining data expect_s3_class(fit, "cp_model_nls") expect_equal(nrow(fit$data), 27) # 30 - 3 zeros }) test_that("fit_cp_nls errors with insufficient data after zero removal", { # Create data that's mostly zeros data <- data.frame(x = 1:10, y = c(rep(0, 8), 1, 2)) expect_error( fit_cp_nls(data, equation = "exponential", return_all = TRUE), "Insufficient data" ) }) test_that("fit_cp_nls does not remove zeros for exponentiated equation", { skip_if_not_installed("nls.multstart") # Create data with some zeros data <- simulate_cp_exponentiated(n = 30, qalone = 10, I = 1.5, beta = 0.05, seed = 123) data$y[c(1, 5, 10)] <- 0.1 # Small values, no zeros (exponentiated naturally handles near-zero) # Should not warn expect_no_warning( fit <- fit_cp_nls(data, equation = "exponentiated", return_all = TRUE, iter = 50) ) expect_s3_class(fit, "cp_model_nls") }) # ============================================================================== # Test: Parameter recovery # ============================================================================== test_that("fit_cp_nls recovers parameters for exponentiated equation", { skip_if_not_installed("nls.multstart") # True parameters true_qalone <- 10 true_I <- 1.5 true_beta <- 0.05 # Simulate with low noise for parameter recovery data <- simulate_cp_exponentiated( n = 100, qalone = true_qalone, I = true_I, beta = true_beta, sigma = 0.1, # Low noise seed = 42 ) fit <- fit_cp_nls(data, equation = "exponentiated", return_all = TRUE, iter = 100) coefs <- coef(fit$model) est_qalone <- 10^coefs["log10_qalone"] est_I <- coefs["I"] est_beta <- 10^coefs["log10_beta"] # Check parameters are within reasonable bounds (20% tolerance) expect_true(abs(est_qalone - true_qalone) / true_qalone < 0.2) expect_true(abs(est_I - true_I) / abs(true_I) < 0.2) expect_true(abs(est_beta - true_beta) / true_beta < 0.2) }) test_that("fit_cp_nls recovers parameters for exponential equation", { skip_if_not_installed("nls.multstart") # True parameters true_qalone <- 10 true_I <- 1.5 true_beta <- 0.05 # Simulate with low noise for parameter recovery data <- simulate_cp_exponential( n = 100, qalone = true_qalone, I = true_I, beta = true_beta, sigma = 0.1, # Low noise seed = 42 ) fit <- fit_cp_nls(data, equation = "exponential", return_all = TRUE, iter = 100) coefs <- coef(fit$model) est_qalone <- 10^coefs["log10_qalone"] est_I <- coefs["I"] est_beta <- 10^coefs["log10_beta"] # Check parameters are within reasonable bounds (20% tolerance) expect_true(abs(est_qalone - true_qalone) / true_qalone < 0.2) expect_true(abs(est_I - true_I) / abs(true_I) < 0.2) expect_true(abs(est_beta - true_beta) / true_beta < 0.2) }) # ============================================================================== # Test: Equation relationship (exponentiated is 10^ of exponential) # ============================================================================== test_that("exponentiated and exponential equations are mathematically related", { skip_if_not_installed("nls.multstart") # Use same underlying parameters qalone <- 10 I <- 1.5 beta <- 0.05 # Generate test x values x_test <- c(1, 5, 10, 20, 30) # Exponential equation: log10(y) = log10(qalone) + I * exp(-beta * x) log10_y_exp <- log10(qalone) + I * exp(-beta * x_test) # Exponentiated equation: y = qalone * 10^(I * exp(-beta * x)) y_expo <- qalone * 10^(I * exp(-beta * x_test)) # The exponentiated should be 10^(exponential) expect_equal(y_expo, 10^log10_y_exp, tolerance = 1e-10) }) # ============================================================================== # Test: Summary and predict methods # ============================================================================== test_that("summary.cp_model_nls works with log10 parameterization", { skip_if_not_installed("nls.multstart") data <- simulate_cp_exponentiated(n = 30, qalone = 10, I = 1.5, beta = 0.05, seed = 123) fit <- fit_cp_nls(data, equation = "exponentiated", return_all = TRUE, iter = 50) summ <- summary(fit) expect_s3_class(summ, "summary.cp_model_nls") expect_true(!is.null(summ$derived_metrics)) expect_true("qalone" %in% names(summ$derived_metrics)) expect_true("beta" %in% names(summ$derived_metrics)) expect_true("log10_qalone" %in% names(summ$derived_metrics)) expect_true("log10_beta" %in% names(summ$derived_metrics)) expect_true("qalone_se" %in% names(summ$derived_metrics)) expect_true("beta_se" %in% names(summ$derived_metrics)) expect_true("log10_qalone_se" %in% names(summ$derived_metrics)) expect_true("log10_beta_se" %in% names(summ$derived_metrics)) # Check that natural-scale values are back-transformed correctly expect_equal( summ$derived_metrics$qalone, 10^summ$derived_metrics$log10_qalone, tolerance = 1e-10 ) expect_equal( summ$derived_metrics$beta, 10^summ$derived_metrics$log10_beta, tolerance = 1e-10 ) # Delta-method SE for back-transforms (natural scale) expect_equal( summ$derived_metrics$qalone_se, log(10) * summ$derived_metrics$qalone * summ$derived_metrics$log10_qalone_se, tolerance = 1e-10 ) expect_equal( summ$derived_metrics$beta_se, log(10) * summ$derived_metrics$beta * summ$derived_metrics$log10_beta_se, tolerance = 1e-10 ) }) test_that("predict.cp_model_nls works with log10 parameterization", { skip_if_not_installed("nls.multstart") data <- simulate_cp_exponentiated(n = 30, qalone = 10, I = 1.5, beta = 0.05, seed = 123) fit <- fit_cp_nls(data, equation = "exponentiated", return_all = TRUE, iter = 50) newdata <- data.frame(x = c(1, 5, 10, 20)) preds <- predict(fit, newdata = newdata) expect_true("x" %in% names(preds)) expect_true("y_pred" %in% names(preds)) expect_equal(nrow(preds), 4) expect_true(all(preds$y_pred > 0)) # Predictions should be positive }) test_that("predict.cp_model_nls returns log10 and natural scale for exponential", { skip_if_not_installed("nls.multstart") data <- simulate_cp_exponential(n = 30, qalone = 10, I = 1.5, beta = 0.05, seed = 123) fit <- fit_cp_nls(data, equation = "exponential", return_all = TRUE, iter = 50) newdata <- data.frame(x = c(1, 5, 10, 20)) preds <- predict(fit, newdata = newdata) expect_true("y_pred" %in% names(preds)) expect_true("y_pred_log10" %in% names(preds)) # y_pred is returned on the natural y scale; y_pred_log10 is the internal scale expect_equal(preds$y_pred, 10^preds$y_pred_log10, tolerance = 1e-10) }) # ============================================================================== # Test: Formula consistency with EQUATIONS_CONTRACT.md # ============================================================================== test_that("equation_text in summary matches EQUATIONS_CONTRACT.md", { skip_if_not_installed("nls.multstart") data <- simulate_cp_exponentiated(n = 30, qalone = 10, I = 1.5, beta = 0.05, seed = 123) # Exponentiated fit_expo <- fit_cp_nls(data, equation = "exponentiated", return_all = TRUE, iter = 50) summ_expo <- summary(fit_expo) expect_true(grepl("10\\^log10_qalone", summ_expo$equation_text)) expect_true(grepl("10\\^\\(I", summ_expo$equation_text)) expect_true(grepl("10\\^log10_beta", summ_expo$equation_text)) # Exponential data_exp <- simulate_cp_exponential(n = 30, qalone = 10, I = 1.5, beta = 0.05, seed = 123) fit_exp <- fit_cp_nls(data_exp, equation = "exponential", return_all = TRUE, iter = 50) summ_exp <- summary(fit_exp) expect_true(grepl("log10\\(y\\)", summ_exp$equation_text)) expect_true(grepl("log10_qalone", summ_exp$equation_text)) # Additive data_add <- simulate_cp_additive(n = 30, qalone = 5, I = 10, beta = 0.05, seed = 123) fit_add <- fit_cp_nls(data_add, equation = "additive", return_all = TRUE, iter = 50) summ_add <- summary(fit_add) expect_true(grepl("10\\^log10_qalone", summ_add$equation_text)) }) # ============================================================================== # Test: Edge cases # ============================================================================== test_that("fit_cp_nls handles single price point gracefully", { data <- data.frame(x = rep(5, 10), y = runif(10, 1, 10)) # Should either fail gracefully or fit with warning result <- tryCatch( fit_cp_nls(data, equation = "exponentiated", return_all = TRUE, iter = 20), error = function(e) e ) # Either error or failed fit is acceptable for degenerate data expect_true(inherits(result, "error") || inherits(result, "cp_model_nls")) }) test_that("fit_cp_nls handles negative I (substitutes)", { skip_if_not_installed("nls.multstart") # Simulate data with negative I (substitute relationship) set.seed(42) n <- 50 x <- runif(n, min = 1, max = 50) qalone <- 20 I <- -0.5 # Negative: alternative price increase decreases target consumption beta <- 0.05 y_true <- qalone * 10^(I * exp(-beta * x)) y <- y_true * exp(rnorm(n, 0, 0.1)) data <- data.frame(x = x, y = y) fit <- fit_cp_nls(data, equation = "exponentiated", return_all = TRUE, iter = 50) # I should be estimated as negative coefs <- coef(fit$model) expect_true(coefs["I"] < 0) }) # ============================================================================== # Tests for *_var argument mapping (new in 0.3.0) # ============================================================================== test_that("fit_cp_nls works with non-default x_var and y_var", { skip_if_not_installed("nls.multstart") data_canon <- simulate_cp_exponentiated(n = 50, seed = 1) # Rename canonical columns to custom names data_custom <- data_canon names(data_custom)[names(data_custom) == "x"] <- "price" names(data_custom)[names(data_custom) == "y"] <- "qty" fit_canon <- fit_cp_nls( data_canon, equation = "exponentiated", return_all = TRUE, iter = 50 ) fit_custom <- fit_cp_nls( data_custom, equation = "exponentiated", x_var = "price", y_var = "qty", return_all = TRUE, iter = 50 ) expect_s3_class(fit_custom, "cp_model_nls") # Canonical names in stored data expect_true("x" %in% names(fit_custom$data)) expect_true("y" %in% names(fit_custom$data)) # Coefficients should be approximately equal expect_equal( coef(fit_custom$model), coef(fit_canon$model), tolerance = 1e-4 ) }) test_that("fit_cp_nls emits deprecation warning for start_vals", { skip_if_not_installed("nls.multstart") skip_if_not_installed("lifecycle") data <- simulate_cp_exponentiated(n = 50, seed = 42) sv <- list(log10_qalone = 1, I = 1.5, log10_beta = -2) lifecycle::expect_deprecated( fit_cp_nls( data, equation = "exponentiated", start_vals = sv, return_all = TRUE ) ) }) test_that("fit_cp_nls accepts start_values without warning", { skip_if_not_installed("nls.multstart") data <- simulate_cp_exponentiated(n = 50, seed = 42) sv <- list(log10_qalone = 1, I = 1.5, log10_beta = -2) expect_no_warning( fit_cp_nls( data, equation = "exponentiated", start_values = sv, return_all = TRUE ) ) }) test_that("fit_cp_nls errors on column collision", { data <- simulate_cp_exponentiated(n = 20, seed = 1) # data already has x and y; add a "price" col and request x_var = "price" # but ALSO have "x" present -> collision data$price <- data$x * 2 expect_error( fit_cp_nls(data, x_var = "price"), regexp = "x_var.*price.*column named.*x", perl = TRUE ) }) test_that("fit_cp_linear works with non-default *_var mapping (fixed)", { skip_if_not_installed("lme4") # Build data with canonical names, then rename set.seed(99) n <- 40 canon <- data.frame( id = rep(1:4, each = 10), x = runif(n, 1, 50), y = runif(n, 0, 20), group = rep(c("A", "B"), n / 2), target = "alt" ) custom <- canon names(custom)[names(custom) == "x"] <- "price" names(custom)[names(custom) == "y"] <- "qty" names(custom)[names(custom) == "group"] <- "grp" names(custom)[names(custom) == "target"] <- "tgt" fit <- fit_cp_linear( custom, type = "fixed", x_var = "price", y_var = "qty", group_var = "grp", target_var = "tgt", return_all = TRUE ) expect_s3_class(fit, "cp_model_lm") # Canonical names in stored data expect_true(all(c("x", "y") %in% names(fit$data))) expect_false("price" %in% names(fit$data)) expect_false("qty" %in% names(fit$data)) }) test_that("fit_cp_linear explicit filter_target and target_level behave like defaults", { set.seed(7) n <- 30 data_with_target <- data.frame( x = runif(n, 1, 50), y = runif(n, 0, 20), target = sample(c("alt", "own"), n, replace = TRUE) ) fit_default <- fit_cp_linear( data_with_target, type = "fixed", return_all = TRUE ) fit_explicit <- fit_cp_linear( data_with_target, type = "fixed", filter_target = TRUE, target_level = "alt", return_all = TRUE ) expect_identical(fit_default$data, fit_explicit$data) }) test_that("fit_cp_linear errors on formula conflict with non-default *_var", { set.seed(42) data <- data.frame( price = runif(20, 1, 50), qty = runif(20, 0, 20), target = "alt" ) expect_error( fit_cp_linear( data, type = "fixed", x_var = "price", y_var = "qty", formula = qty ~ price ), regexp = "Custom formulas must use canonical column names" ) }) test_that("S3 methods work after fitting with non-default *_var", { skip_if_not_installed("nls.multstart") data_custom <- simulate_cp_exponentiated(n = 50, seed = 5) names(data_custom) <- c("alt_price", "target_cons") fit <- fit_cp_nls( data_custom, equation = "exponentiated", x_var = "alt_price", y_var = "target_cons", return_all = TRUE, iter = 50 ) expect_s3_class(fit, "cp_model_nls") # coef() should work without error expect_no_error(coef(fit$model)) # predict() via S3 method (if available) expect_no_error({ newdata <- data.frame(x = c(5, 10, 20)) predict(fit, newdata = newdata) }) })