Standard Dose Response
Executive Summary

Executive Summary

How 'Response Signal' responds as 'Dose uM' increases, and where the midpoint sits.

Observations Fitted
66
Dose Levels
11
Midpoint Type
EC50
Midpoint Dose
24.4203
Midpoint CI Low
22.754
Midpoint CI High
26.2087
Hill Slope
1.444
Low Plateau
9.749
High Plateau
99.865
Residual Std Error
2.9788
Variance Explained
0.9933
Best Model By AIC
3PL
Midpoint Extrapolated
no
Across 66 measurements at 11 distinct 'Dose uM' levels, 'Response Signal' rises from a low-dose plateau of 9.749 to a high-dose plateau of 99.87, a total span of 90.12. The EC50 — the 'Dose uM' that delivers half of that span — is 24.42 (95% CI 22.75 to 26.21). The tested doses ran from 0.3900 to 400.00, so the midpoint is bracketed by real observations rather than extrapolated. Both plateaus were reached within the tested range, so the span is measured rather than projected. The Hill slope is 1.444: the turn is gradual, spanning roughly a decade of 'Dose uM'. The curve leaves a residual standard error of 2.979 and accounts for 99.33% of the variation in 'Response Signal', and of the three models compared the baseline-constrained three-parameter logistic has the lowest AIC. The replicate-based lack-of-fit test does not reject (p = 0.141), so the logistic shape is consistent with the replicate scatter. The curve describes how 'Response Signal' is associated with the observed 'Dose uM'; unless the doses were assigned experimentally it does not on its own establish that changing 'Dose uM' causes the change.
Suggested Interpretation

The short answer

Response Signal climbs from 9.749 to 99.87 as Dose uM increases, with the EC50 at 24.42 µM (95% CI 22.75–26.21). Both plateaus were observed within the tested dose range, the Hill slope is 1.444, and the logistic shape fits well (lack-of-fit p = 0.141).

The detail

Across 66 measurements at 11 dose levels, the fitted four-parameter logistic spans 90.12 units of Response Signal. The EC50 is bracketed by real observations (tested range 0.39–400 µM), not extrapolated. Residual standard error is 2.979 and the curve accounts for 99.33% of variation. The Hill slope of 1.444 produces a gradual turn. The baseline-constrained three-parameter logistic has lower AIC (335.7 vs 337.25 for the 4PL), and the nested F test does not reject the constraint (F(1,62)=0.426, p=0.517), so the simpler 3PL is more defensible. The replicate-based lack-of-fit test does not reject (p = 0.141).

What this can't tell you

The curve is fitted to observed data and does not establish causation without experimental dose assignment. Wald intervals are asymptotic and slightly optimistic for a nonlinear model.

Overview

Analysis Overview

Four-parameter logistic fit of 'Response Signal' against 'Dose uM' across 66 rows.

N Observations66
N Dose Levels11
Midpoint24.4203
Hill Slope1.444
Suggested Interpretation

The short answer

Response Signal rises steeply from a low-dose floor of 9.749 to a high-dose ceiling of 99.87 as Dose increases, turning over at an EC50 of 24.42 µM. This midpoint was directly observed within the tested dose range rather than extrapolated, and the fitted curve accounts for 99.33% of the variation in the data.

The detail

A four-parameter logistic fit on log10 Dose uM describes how Response Signal changes across 66 observations at 11 distinct dose levels. The low plateau sits at 9.749 (95% CI 8.25 to 11.25), the high plateau at 99.865 (97.54 to 102.19), and the EC50 at 24.4203 µM (22.754 to 26.2087). The Hill slope of 1.444 indicates a gradual turn spanning roughly a decade of dose. Residual standard error is 2.9788, and the curve explains 99.33% of variance. The tested dose range (0.39 to 400 µM) brackets the EC50, confirming it is interpolated rather than extrapolated.

What this can't tell you

The curve describes association between Response Signal and observed Dose uM. Without experimental assignment of doses, the fit does not establish that changing dose causes the response change.

Data Preparation

Data Quality

Row accounting, dose validity, and control handling.

Initial Rows72
Final Rows72
Rows Removed0
Suggested Interpretation

The short answer

All 72 rows loaded successfully; 66 positive-dose rows at 11 distinct dose levels entered the curve fit with no dropouts for missing or invalid values. Six zero-dose control rows averaging 9.244 on Response Signal were held out of the log-dose fit and serve as the untreated baseline instead.

The detail

Data quality checks found no rows requiring removal. Both Dose uM and Response Signal columns parsed as numbers under the 95% non-blank rule. The 6 zero-dose controls cannot sit on a log-dose axis, so they are excluded from the curve fit itself but used as the reference low-dose plateau (9.749) to validate against their observed mean of 9.244. All 66 positive-dose observations were retained for fitting across the 11 distinct dose levels.

What this can't tell you

Preprocessing does not assess whether the dose-response relationship is causal or whether doses were assigned randomly. It confirms only data completeness and structural validity for curve fitting.

Visualization

Fitted Dose-Response Curve

The 4PL curve drawn over the observed 'Response Signal' values on a log10 'Dose uM' axis.

Suggested Interpretation

The short answer

Points scatter evenly around the fitted logistic curve across the log10-dose axis, with no systematic drift at either end. The vertical reference line marks the EC50 at log10 dose of 1.388 (24.42 µM), and the six zero-dose controls cluster near the fitted low-dose plateau of 9.749.

The detail

The horizontal axis is log10 Dose uM, the scale on which the logistic curve is symmetric. The fitted curve runs from 9.749 to 99.87 with its midpoint at 24.42 µM (log10 1.388), marked by the vertical line. The 6 zero-dose controls are drawn one decade below the lowest tested dose as a separate series, a drawing convention because log10(0) is undefined; their mean Response Signal of 9.244 aligns closely with the fitted low-dose plateau of 9.749. Observed points form a tight upward pattern around the curve with no systematic arc or drift, supporting the logistic shape.

What this can't tell you

Visual scatter around the curve is consistent with the logistic shape but does not prove it is the only shape the data could fit. The lack-of-fit test (p = 0.141) provides the formal verdict on shape adequacy.

Data Table

Curve Parameters

The four fitted parameters with 95% confidence intervals.

ParameterEstimateCI LowCI HighInterpretation
Plateau at low 'Dose uM'9.7498.24811.25The 'Response Signal' the curve settles to as 'Dose uM' approaches zero. The lowest tested doses reach this plateau, so it is observed rather than projected.
Plateau at high 'Dose uM'99.8797.54102.2The 'Response Signal' the curve saturates at as 'Dose uM' grows large. The highest tested doses reach this plateau, so it is observed rather than projected.
EC50 (midpoint dose)24.4222.7526.21The 'Dose uM' at which 'Response Signal' is halfway between the two plateaus. It falls inside the tested range of 0.3900 to 400.00, so it is interpolated from observed doses.
Hill slope (steepness)1.4441.3081.58How sharply 'Response Signal' turns over near the midpoint. A steeper slope means a narrower 'Dose uM' window between little effect and most of the effect; the sign is positive because 'Response Signal' rises with 'Dose uM'.
Span (high plateau minus low plateau)90.12The total achievable change in 'Response Signal' across the fitted curve, from 9.749 to 99.87.
Suggested Interpretation

The short answer

The EC50 of 24.42 µM carries a 1.152-fold confidence interval (22.75–26.21), both plateaus were observed rather than projected, and the Hill slope of 1.444 describes a gradual turn spanning roughly a decade of dose.

The detail

Low plateau: 9.7491 (95% CI 8.2485–11.2497), observed at lowest tested doses. High plateau: 99.8652 (97.5416–102.1887), observed at highest tested doses. EC50: 24.4203 µM (22.754–26.2087), interpolated within the tested range. Hill slope: 1.444 (1.3077–1.5803), positive because Response Signal rises with dose. Span: 90.1161 units from low to high plateau. The EC50 interval is asymmetric around the estimate because it is estimated on the log10 scale and back-transformed—the correct shape for a dose. Wald intervals are asymptotic and run slightly narrow compared with profile-likelihood intervals.

What this can't tell you

Wald intervals are optimistic for nonlinear models. Profile-likelihood intervals would be slightly wider, making the true precision slightly lower than shown here.

Data Table

Model Comparison

4PL against a baseline-constrained 3PL and a plain log-linear model.

ModelParametersResidual SEAicStatusAic Vs Best
4PL (four-parameter logistic)42.979337.2fitted1.55
3PL (baseline held at the untreated control)32.965335.7fitted with the low-dose plateau held at the zero-dose control mean (9.244)0
Log-linear (response on log10 dose)29.98494.9fitted159.2
Suggested Interpretation

The short answer

The three-parameter logistic with the low-dose plateau held at the zero-dose control mean (9.244) has the lowest AIC at 335.7. The fourth parameter in the full 4PL is not supported: the nested F test does not reject the constraint (F(1, 62) = 0.426, p = 0.517), and the 4PL's AIC of 337.25 is 1.55 points higher.

The detail

Three models were compared on AIC. The 3PL (baseline constrained) has AIC 335.7 with residual standard error 2.9651. The 4PL has AIC 337.25 with residual standard error 2.9788, a difference of 1.55 points favoring the simpler model. The log-linear model (two parameters) has AIC 494.95 and residual standard error 9.9801, far worse. The nested F test of the 4PL against the 3PL yields F(1, 62) = 0.426, p = 0.517, meaning the extra parameter does not buy a statistically significant improvement. On sparse designs, four-parameter curves can fit noise; here the 3PL is the more defensible choice.

What this can't tell you

AIC penalizes complexity but does not establish practical equivalence. The 1.55-point difference is within the range where model selection is ambiguous; either model could be defended, but the 3PL is more parsimonious.

Data Table

Group Comparison

Per-group curves and the test of whether the midpoints differ.

Suggested Interpretation

The short answer

No grouping column was mapped, so one curve was fitted across all 66 rows. There are no groups to compare.

The detail

The analysis produced a single dose-response curve across the full dataset. To test whether midpoints differ across groups (compound, channel, treatment arm, or other categorical variable), a grouping column must be supplied. When mapped, the analysis will fit one curve per group, report each EC50 with its interval, and run an extra-sum-of-squares test comparing a model where every group keeps its own midpoint against one where all groups share a single midpoint.

What this can't tell you

Without a grouping variable, the analysis cannot assess whether response curves differ across subsets of the data. Pooling across groups (if they exist) produces an average curve that may not represent any individual group's response.

Visualization

Residual Diagnostics

Residuals against fitted values — the check the fit statistics cannot do.

Suggested Interpretation

The short answer

Residuals scatter evenly around zero with no systematic arc or funnel, consistent with a well-specified logistic curve. The lack-of-fit test does not reject (p = 0.141), and residuals are consistent with normality (Shapiro-Wilk p = 0.388).

The detail

Residuals have a standard error of 2.979 around the zero line. A formless band is what a correct shape produces; a systematic arc (negative at ends, positive in middle, or vice versa) signals the logistic is wrong. Here the residuals show no arc or systematic drift. The replicate-based lack-of-fit test F(7,55)=1.649, p=0.141 is the strongest available evidence that the logistic shape fits: each dose level's mean deviation from the curve is no larger than the scatter of its own replicates. Shapiro-Wilk p=0.388 confirms residuals are consistent with normality, validating the assumption the confidence intervals rest on. No widening funnel appears, so heteroscedasticity is not evident.

What this can't tell you

Residual diagnostics confirm the logistic shape is adequate but do not prove it is the only adequate shape. The lack-of-fit test (p = 0.141) is a weak test of shape (it fails to reject) rather than a strong affirmation.

Data Table

Methods & Disclosure

Exactly how the curve was fitted, and what it can and cannot decide.

ItemDetail
ModelFour-parameter logistic on log10 'Dose uM': response = low plateau + (high plateau - low plateau) / (1 + 10^((log10(EC50) - log10(dose)) x hill)).
EstimationOrdinary nonlinear least squares (base R nls) over 66 rows at 11 distinct positive 'Dose uM' levels; residual degrees of freedom 62, residual standard error 2.979.
Starting valuesDerived from the data, not hard-coded: the plateaus start at the mean 'Response Signal' at the lowest and highest tested dose, and normalising by them linearises the curve on the logit scale, so an ordinary least-squares line supplies the starting hill slope (1.143) and midpoint (16.73).
Confidence intervalsWald intervals, estimate plus or minus 2.00 standard errors on 62 degrees of freedom. These are asymptotic: for a nonlinear model they are slightly optimistic compared with profile-likelihood intervals.
Midpoint intervalThe midpoint is estimated on the log10 scale and back-transformed, so its interval (22.75 to 26.21) is asymmetric around 24.42 — which is the correct shape for a dose.
Zero-dose controls6 zero-dose control row(s) were found, mean 'Response Signal' 9.244. They cannot sit on a log-dose axis, so they are excluded from the curve fit and drawn as their own series one decade below the lowest tested dose — that position is a drawing convention, not a measured dose.
Model comparisonThe 4PL is compared against a three-parameter logistic (fitted with the low-dose plateau held at the zero-dose control mean (9.244)) and a log-linear model on AIC; the baseline-constrained three-parameter logistic has the lowest AIC here.
Lack of fitLack-of-fit F(7, 55) = 1.649, p = 0.141
Convergence policyIf nls does not converge from the self-start, the bounded port algorithm and a grid of perturbed starts are tried. If all fail, the analysis stops and reports the failure with a diagnosis. A linear or log-linear fit is never substituted for the curve.
Causal standingThis is a fitted description of how 'Response Signal' varies with observed 'Dose uM'. Unless the doses were assigned experimentally, the curve is associated with dose and does not by itself establish that changing 'Dose uM' causes the change in 'Response Signal'.
Suggested Interpretation

The short answer

The four-parameter logistic was fitted by ordinary nonlinear least squares on log10 dose, with starting values derived from the data rather than assumed. The curve is: response = low plateau + (high plateau − low plateau) / (1 + 10^((log10(EC50) − log10(dose)) × hill)).

The detail

Estimation was by base-R nonlinear least squares (nls) over 66 rows at 11 distinct positive dose levels, yielding 62 residual degrees of freedom and a residual standard error of 2.979. Starting values were derived from the data: plateaus from the extreme dose levels, and hill slope (1.143) and midpoint (16.73) from an ordinary least-squares line through the logit-transformed response. Confidence intervals are Wald intervals (estimate ± 2.00 standard errors on 62 df), which are asymptotic and slightly optimistic for nonlinear models. The EC50 interval (22.75–26.21) is asymmetric around 24.42 because it is back-transformed from the log10 scale. Six zero-dose controls (mean 9.244) were excluded from the curve fit and plotted separately. The convergence policy: if nls fails to converge, the bounded port algorithm and a grid of perturbed starts are attempted; if all fail, the analysis stops with a diagnosis rather than substituting a linear fit.

What this can't tell you

This is a fitted description of association between Response Signal and observed Dose. Unless doses were experimentally assigned, the curve does not establish causation. Wald intervals are asymptotic; profile-likelihood intervals would be more conservative.

Methodology

Methodology

Statistical methodology and diagnostics for Dose-Response Curve Fitting

Statistical Method

Dose-Response Curve Fitting

Standard-library analysis: how does response change with dose or intensity? Map a dose column and a response column and get a four-parameter logistic (4PL) curve fitted by nonlinear least squares — the low-dose plateau, the high-dose plateau, the Hill slope, and the EC50 (or IC50 when the response falls) as the headline, each with a 95% confidence interval. The fitted curve is drawn over the observed points on a properly handled log-dose axis with zero-dose controls shown separately, backed by residual diagnostics including a replicate-based lack-of-fit test, and compared against a baseline-constrained 3PL and a plain log-linear model so a sparse design is not locked into an over-parameterised curve. If the curve cannot be fitted, the analysis says so with a diagnosis instead of substituting a straight line.

Data
N = 72 observations
Assumptions
  • Each row is one observation of a response measured at a known dose, concentration, spend, or exposure level
  • The response is monotonic in dose — it rises or falls consistently rather than peaking in the middle of the range
  • Doses are positive for the curve fit; zero-dose rows are treated as untreated controls and held out of the log-dose axis
  • Residual scatter is roughly constant across the fitted range, which the residual diagnostics card checks
  • The confidence intervals assume approximately normal errors and are asymptotic (Wald) rather than profile-likelihood
Limitations
  • The analysis refuses rather than guessing: fewer than 12 usable rows, fewer than 4 distinct positive dose levels, a non-monotonic response, or a failure of the nonlinear fit to converge all stop the analysis with a diagnosis — no straight-line result is ever substituted for the curve
  • A four-parameter curve fitted to a sparse design can describe noise rather than shape; the model comparison card is where that shows up, and a simpler model within a couple of AIC points means the reported precision is overstated
  • An EC50 outside the tested dose range is an extrapolation from the fitted shape, not a measurement, and is labelled as such
  • A plateau that the tested doses never reached is a projection, so the span between plateaus can be over- or understated
Software & Citation
MCP Analytics · mcpanalytics.ai
Code Appendix

Analysis Code

Complete R source code for this analysis

Dose-Response Curve Fitting — EC50 / IC50

How does response change as dose, spend, or exposure increases? The analysis fits a four-parameter logistic (4PL) curve by nonlinear least squares on log10 dose:

response = low plateau + (high plateau - low plateau) / (1 + 10 ^ ((log10(EC50) - log10(dose)) * hill))

and reports the four parameters with confidence intervals, the EC50 (or IC50 when the response falls) as the headline with its own interval, the fitted curve drawn over the observed points, residual diagnostics, and a comparison against a baseline-constrained 3PL and a plain log-linear model so a sparse dataset is not locked into an over-parameterised curve.

Why This Method?

A saturating response is not a straight line and not a log line: it has a floor, a ceiling, and a midpoint. The 4PL is the standard description of that shape, and its midpoint parameter — the EC50/IC50 — is the single number practitioners compare across compounds, channels, or campaigns.

What This Analysis Covers

  • 4PL fit by base nls with data-derived self-starting values
  • Every parameter with a 95% interval; EC50/IC50 back-transformed from the

log-dose scale so its interval is properly asymmetric

  • The fitted curve over the observed points on a log-dose axis, with

zero-dose controls shown separately and never fed to the log axis

  • Residual diagnostics including a replicate-based lack-of-fit test
  • 4PL vs 3PL vs log-linear model comparison
  • Parallel curves per group with a formal test of whether the EC50s differ

Honesty

If nls cannot converge the module REFUSES and says why. It never falls back to a straight line and calls it a dose-response curve. If the tested doses do not bracket the midpoint, the EC50 is named as an extrapolation and the observed dose range is printed next to it.

Standard Library

Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {dose, response, group}. All narrative is derived from the user's own column names and computed values.

suppressPackageStartupMessages(library(DT))
suppressPackageStartupMessages(library(htmlwidgets))
suppressPackageStartupMessages(library(arrow))
suppressPackageStartupMessages(library(knitr))
suppressPackageStartupMessages(library(rmarkdown))
suppressPackageStartupMessages(library(dplyr))
suppressPackageStartupMessages(library(tidyr))
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(stringr))
suppressPackageStartupMessages(library(lubridate))
suppressPackageStartupMessages(library(broom))
suppressPackageStartupMessages(library(Matrix))
suppressPackageStartupMessages(library(cluster))
suppressPackageStartupMessages(library(data.table))

Core Analysis Pipeline

Step 1: Resolve the mapped columns, humanized for every sentence

initial_rows <- nrow(df)
  dose_h <- humanize_semantic("dose", col_map)
  resp_h <- humanize_semantic("response", col_map)
  has_group_col <- "group" %in% names(df)
  group_h <- if (has_group_col) humanize_semantic("group", col_map) else "group"
  if (!("dose" %in% names(df)) || !("response" %in% names(df))) {
    stop(sprintf("Dose-response fitting needs both a dose column(&#x27;%s') and a response column ('%s') mapped.",
                 dose_h, resp_h))
  }

Step 2: Coerce both to numeric under the 95% rule

coerce_num <- function(v, label_h, role) {
    if (is.numeric(v)) return(as.numeric(v))
    ch <- as.character(v)
    non_blank <- !is.na(ch) & trimws(ch) != ""
    conv <- suppressWarnings(as.numeric(ch))
    if (sum(non_blank) == 0 ||
        sum(!is.na(conv[non_blank])) < 0.95 * sum(non_blank)) {
      stop(sprintf("The column &#x27;%s' was mapped as the %s but does not look numeric — fewer than 95%% of its values parse as numbers. Map a numeric column.",
                   label_h, role))
    }
    conv
  }
  dose_v <- coerce_num(df$dose, dose_h, "dose")
  resp_v <- coerce_num(df$response, resp_h, "response")
  grp_v <- if (has_group_col) {
    g <- trimws(as.character(df$group))
    g[is.na(g) | g == ""] <- "Missing"
    g
  } else rep("All observations", initial_rows)

Step 3: Drop unusable rows and separate the zero-dose controls

ok <- !is.na(dose_v) & !is.na(resp_v)
  n_drop_na <- sum(!ok)
  dose_v <- dose_v[ok]; resp_v <- resp_v[ok]; grp_v <- grp_v[ok]

  neg <- dose_v < 0
  n_drop_nonpos <- sum(neg)
  dose_v <- dose_v[!neg]; resp_v <- resp_v[!neg]; grp_v <- grp_v[!neg]

  is_zero <- dose_v == 0
  n_zero <- sum(is_zero)
  zero_mean <- if (n_zero > 0) mean(resp_v[is_zero]) else NA_real_
  zero_resp <- resp_v[is_zero]
  zero_grp <- grp_v[is_zero]

  d <- dose_v[!is_zero]; y <- resp_v[!is_zero]; g <- grp_v[!is_zero]
  n <- length(d)
  final_rows <- n + n_zero
  rows_removed <- initial_rows - final_rows

Step 4: Hard guards, each naming the user's own columns

if (n < MIN_ROWS) {
    stop(sprintf("Only %d rows have a positive &#x27;%s' and a usable '%s' value — a four-parameter dose-response curve needs at least %d. Zero-dose control rows (%d here) anchor the baseline but cannot be placed on a log-dose axis, so they do not count toward this minimum.",
                 n, dose_h, resp_h, MIN_ROWS, n_zero))
  }
  n_levels <- length(unique(d))
  if (n_levels < MIN_LEVELS) {
    stop(sprintf("&#x27;%s' has only %d distinct positive value(s) (%s). A four-parameter logistic curve has four unknowns and cannot be determined from fewer than %d distinct dose levels — the fit would be arbitrary rather than estimated.",
                 dose_h, n_levels,
                 paste(fmt_num(sort(unique(d))[1]), "to",
                       fmt_num(sort(unique(d))[n_levels])),
                 MIN_LEVELS))
  }
  if (!isTRUE(stats::var(y) > 0)) {
    stop(sprintf("&#x27;%s' is constant — every value is identical — so there is no response to model against '%s'.",
                 resp_h, dose_h))
  }

  u <- log10(d)
  dose_min <- min(d); dose_max <- max(d)

Step 5: Refuse before fitting when the shape is not a dose-response

A 4PL describes a monotonic saturating curve. When the response does not move consistently with dose there is nothing for the curve to estimate, and reporting some fitted line as a dose-response would be a fabrication.

sp <- suppressWarnings(tryCatch(
    stats::cor.test(u, y, method = "spearman"),
    error = function(e) NULL))
  sp_rho <- if (!is.null(sp)) unname(sp$estimate) else NA_real_
  sp_p <- if (!is.null(sp)) sp$p.value else NA_real_
  if (!is.finite(sp_rho) || !is.finite(sp_p) || abs(sp_rho) < 0.25 || sp_p >= 0.05) {
    stop(sprintf("&#x27;%s' shows no consistent monotonic change across '%s' (Spearman rho %s, %s over %d rows and %d dose levels). A dose-response curve cannot be fitted to a response that does not rise or fall with dose — the shape may be flat, or it may peak in the middle of the range, which this method cannot represent. No curve is reported rather than a straight line dressed up as one.",
                 resp_h, dose_h, r3(sp_rho), fmt_pp(sp_p), n, n_levels))
  }

Step 6: Self-start, then fit the 4PL by nonlinear least squares

st <- dr_selfstart(u, y)
  fit <- dr_fit4pl(u, y, st)
  if (is.null(fit)) {
    diag_bits <- character()
    if (n_levels < 6) {
      diag_bits <- c(diag_bits, sprintf("only %d distinct &#x27;%s' levels were tested, which is thin for a four-parameter curve",
                                        n_levels, dose_h))
    }
    lev <- sort(unique(u))
    mu_lev <- sapply(lev, function(v) mean(y[u == v]))
    k <- length(mu_lev)
    edge <- max(1L, floor(k / 4))
    lowflat <- stats::sd(mu_lev[seq_len(edge + 1)])
    highflat <- stats::sd(mu_lev[(k - edge):k])
    spanobs <- abs(mu_lev[k] - mu_lev[1])
    if (is.finite(lowflat) && is.finite(spanobs) && spanobs > 0 &&
        lowflat > 0.25 * spanobs) {
      diag_bits <- c(diag_bits, sprintf("the response is still moving at the lowest doses, so the lower plateau was never observed"))
    }
    if (is.finite(highflat) && is.finite(spanobs) && spanobs > 0 &&
        highflat > 0.25 * spanobs) {
      diag_bits <- c(diag_bits, sprintf("the response is still moving at the highest doses, so the upper plateau was never observed"))
    }
    if (abs(sp_rho) < 0.6) {
      diag_bits <- c(diag_bits, sprintf("the dose-response trend is weak and noisy(Spearman rho %s)", r3(sp_rho)))
    }
    if (length(diag_bits) == 0) {
      diag_bits <- "the residual surface has no stable minimum from any of the starting values tried"
    }
    stop(sprintf("The four-parameter dose-response curve for &#x27;%s' against '%s' did not converge. Likely cause: %s. Tested doses ran from %s to %s across %d levels and %d rows. No curve, EC50, or fitted parameter is reported — a straight-line fit is NOT substituted for the curve, because it would answer a different question.",
                 resp_h, dose_h,
                 paste(diag_bits, collapse = "; "),
                 fmt_num(dose_min), fmt_num(dose_max), n_levels, n))
  }

Step 7: Extract parameters. h is steepness; the DIRECTION of the

curve is the sign of (high plateau - low plateau), so a negative fitted h is the same curve with the plateaus swapped. Normalise to h > 0 so "low-dose plateau" always means what it says.

cf <- summary(fit)$coefficients
  est <- cf[, 1]; se <- cf[, 2]
  df_res <- stats::df.residual(fit)
  tq <- stats::qt(0.975, max(1, df_res))

  lo <- unname(est["lo"]); hi <- unname(est["hi"])
  le50 <- unname(est["le50"]); h <- unname(est["h"])
  lo_se <- unname(se["lo"]); hi_se <- unname(se["hi"])
  le50_se <- unname(se["le50"]); h_se <- unname(se["h"])
  if (is.finite(h) && h < 0) {
    tmp <- lo; lo <- hi; hi <- tmp
    tmp <- lo_se; lo_se <- hi_se; hi_se <- tmp
    h <- -h
  }
  lo_ci <- c(lo - tq * lo_se, lo + tq * lo_se)
  hi_ci <- c(hi - tq * hi_se, hi + tq * hi_se)
  h_ci  <- c(h - tq * h_se, h + tq * h_se)

  ec50 <- 10^le50
  ec50_lo <- 10^(le50 - tq * le50_se)
  ec50_hi <- 10^(le50 + tq * le50_se)

  increasing <- hi > lo
  ec_label <- if (increasing) "EC50" else "IC50"
  dir_word <- if (increasing) "rises" else "falls"
  span_obs <- hi - lo

Hill slope by the usual sign convention: negative for an inhibition curve.

hill_signed <- if (increasing) h else -h
  hill_ci_signed <- if (increasing) h_ci else rev(-h_ci)

  extrapolated <- ec50 < dose_min || ec50 > dose_max

Step 8: Were the plateaus actually observed, or are they projections?

lev <- sort(unique(u))
  mu_lev <- sapply(lev, function(v) mean(y[u == v]))
  k <- length(mu_lev)
  tol_plateau <- 0.15 * abs(span_obs)
  plateau_low_seen <- is.finite(tol_plateau) && tol_plateau > 0 &&
    abs(mu_lev[1] - lo) <= tol_plateau
  plateau_high_seen <- is.finite(tol_plateau) && tol_plateau > 0 &&
    abs(mu_lev[k] - hi) <= tol_plateau

Step 9: Residual diagnostics

fitted_v <- as.numeric(stats::fitted(fit))
  resid_v <- as.numeric(stats::residuals(fit))
  rss <- sum(resid_v^2)
  tss <- sum((y - mean(y))^2)
  rse <- sqrt(rss / max(1, df_res))
  pseudo_r2 <- if (tss > 0) 1 - rss / tss else NA_real_

Replicate-based lack-of-fit: pure error from repeated doses versus the remaining residual. This is the only honest test of curve shape when the design has replicates, and it is skipped (not faked) when it does not.

lof_p <- NA_real_; lof_note <- ""
  df_pe <- n - n_levels
  df_lof <- n_levels - 4
  if (df_pe >= 1 && df_lof >= 1) {
    ss_pe <- sum(sapply(unique(u), function(v) {
      yy <- y[u == v]; sum((yy - mean(yy))^2)
    }))
    ss_lof <- rss - ss_pe
    if (is.finite(ss_lof) && ss_lof > 0 && ss_pe > 0) {
      f_lof <- (ss_lof / df_lof) / (ss_pe / df_pe)
      lof_p <- stats::pf(f_lof, df_lof, df_pe, lower.tail = FALSE)
      lof_note <- sprintf("Lack-of-fit F(%d, %d) = %s, %s", df_lof, df_pe,
                          r3(f_lof), fmt_pp(lof_p))
    } else {
      lof_note <- "The lack-of-fit test could not be computed from these replicates."
    }
  } else {
    lof_note <- sprintf("No lack-of-fit test: it needs replicate measurements at repeated &#x27;%s' values and more than four distinct levels.", dose_h)
  }

  shapiro_p <- NA_real_
  if (n >= 3 && n <= 5000) {
    shapiro_p <- tryCatch(stats::shapiro.test(resid_v)$p.value,
                          error = function(e) NA_real_)
  }

Step 10: Competing models — a baseline-constrained 3PL and log-linear

The 3PL holds the zero-dose plateau at the UNTREATED CONTROL mean, so it is only defined when the data actually contain zero-dose rows. When they do not, it is reported as not applicable rather than anchored on a number invented for the occasion.

aic4 <- stats::AIC(fit)
  fit3 <- NULL; aic3 <- NA_real_; rss3 <- NA_real_; df3 <- NA_real_
  status3 <- ""
  if (n_zero > 0 && is.finite(zero_mean)) {
    b_fixed <- zero_mean
    d3 <- data.frame(u = u, y = y)
    fit3 <- tryCatch(
      stats::nls(y ~ f4pl(u, b_fixed, hi, le50, h), data = d3,
                 start = list(hi = hi, le50 = le50, h = h),
                 algorithm = "port",
                 lower = c(hi = -Inf, le50 = min(u) - 3, h = 0.05),
                 upper = c(hi = Inf, le50 = max(u) + 3, h = 25),
                 control = stats::nls.control(maxiter = 200, warnOnly = FALSE)),
      error = function(e) NULL, warning = function(w) NULL)
    if (!is.null(fit3)) {
      aic3 <- stats::AIC(fit3)
      rss3 <- sum(stats::residuals(fit3)^2)
      df3 <- stats::df.residual(fit3)
      status3 <- sprintf("fitted with the low-dose plateau held at the zero-dose control mean(%s)",
                         fmt_num(b_fixed))
    } else {
      status3 <- "did not converge with the low-dose plateau held at the zero-dose control mean"
    }
  } else {
    status3 <- sprintf("not applicable — the data contain no zero-dose &#x27;%s' rows to anchor an untreated baseline on",
                       dose_h)
  }

  fit_ll <- tryCatch(stats::lm(y ~ u), error = function(e) NULL)
  aic_ll <- if (!is.null(fit_ll)) stats::AIC(fit_ll) else NA_real_
  rss_ll <- if (!is.null(fit_ll)) sum(stats::residuals(fit_ll)^2) else NA_real_
  r2_ll <- if (!is.null(fit_ll)) summary(fit_ll)$r.squared else NA_real_

Nested F test, 4PL versus 3PL (the 3PL is the 4PL with one plateau fixed).

f_3pl_p <- NA_real_; f_3pl_note <- ""
  if (!is.null(fit3) && is.finite(rss3) && is.finite(df3) && df3 > df_res) {
    num <- (rss3 - rss) / (df3 - df_res)
    den <- rss / df_res
    if (is.finite(num) && is.finite(den) && den > 0 && num >= 0) {
      f_stat <- num / den
      f_3pl_p <- stats::pf(f_stat, df3 - df_res, df_res, lower.tail = FALSE)
      f_3pl_note <- sprintf("F(%d, %d) = %s, %s", df3 - df_res, df_res,
                            r3(f_stat), fmt_pp(f_3pl_p))
    }
  }

  aics <- c(fourpl = aic4, threepl = aic3, loglin = aic_ll)
  aic_ok <- aics[is.finite(aics)]
  best_model <- if (length(aic_ok) > 0) names(aic_ok)[which.min(aic_ok)] else "fourpl"
  best_label <- switch(best_model,
                       fourpl = "the four-parameter logistic",
                       threepl = "the baseline-constrained three-parameter logistic",
                       loglin = "the plain log-linear model")

  models_df <- data.frame(
    model = c("4PL (four-parameter logistic)",
              "3PL (baseline held at the untreated control)",
              "Log-linear(response on log10 dose)"),
    parameters = c(4L, 3L, 2L),
    residual_se = round(c(rse,
                          if (!is.null(fit3)) sqrt(rss3 / max(1, df3)) else NA_real_,
                          if (!is.null(fit_ll)) summary(fit_ll)$sigma else NA_real_), 4),
    aic = round(c(aic4, aic3, aic_ll), 2),
    status = c("fitted", status3,
               if (!is.null(fit_ll)) "fitted" else "did not fit"),
    stringsAsFactors = FALSE
  )
  models_df$aic_vs_best <- round(models_df$aic - min(models_df$aic, na.rm = TRUE), 2)

Step 11: Chart data — fitted curve drawn over the observed points.

The x axis is log10 dose because that is the scale the curve is symmetric on. Zero-dose controls have no log10 and are NOT silently placed at zero: they are drawn as their own series one decade below the lowest tested dose, and the prose says that position is a drawing convention.

set.seed(42)
  obs_idx <- if (n > 900) sample(n, 900) else seq_len(n)
  ctrl_x <- log10(dose_min) - 1
  grid_u <- seq(min(u), max(u), length.out = 100)

  if (has_group_col) {
    keep_groups <- names(sort(table(g), decreasing = TRUE))
  } else {
    keep_groups <- "All observations"
  }
  chart_parts <- list()
  chart_parts[[1]] <- data.frame(
    plot_dose = round(u[obs_idx], 5),
    response = round(y[obs_idx], 5),
    series = if (has_group_col) paste0(g[obs_idx], " (observed)") else "Observed",
    stringsAsFactors = FALSE
  )
  chart_parts[[2]] <- data.frame(
    plot_dose = round(grid_u, 5),
    response = round(f4pl(grid_u, lo, hi, le50, h), 5),
    series = "Fitted 4PL curve",
    stringsAsFactors = FALSE
  )
  if (n_zero > 0) {
    zi <- if (n_zero > 150) sample(n_zero, 150) else seq_len(n_zero)
    chart_parts[[3]] <- data.frame(
      plot_dose = round(rep(ctrl_x, length(zi)), 5),
      response = round(zero_resp[zi], 5),
      series = "Zero-dose control(drawn one decade below the lowest dose)",
      stringsAsFactors = FALSE
    )
  }

Step 12: Per-group curves and a formal test of whether EC50s differ

n_groups <- 0L; group_test_p <- NA_real_; ec_ratio <- NA_real_
  ec_ratio_label <- ""; group_note <- ""; group_excluded <- character(0)
  group_df <- data.frame(
    group = character(0), n = integer(0), dose_levels = integer(0),
    ec50 = numeric(0), ec50_low = numeric(0), ec50_high = numeric(0),
    hill = numeric(0), plateau_low = numeric(0), plateau_high = numeric(0),
    stringsAsFactors = FALSE)

  if (has_group_col) {
    tab <- table(g)
    cand <- names(sort(tab, decreasing = TRUE))
    if (length(cand) > 6) {
      group_excluded <- c(group_excluded, cand[7:length(cand)])
      cand <- cand[1:6]
    }
    rows <- list(); rss_parts <- c(); n_parts <- c(); fit_groups <- character(0)
    for (gg in cand) {
      sel <- g == gg
      ug <- u[sel]; yg <- y[sel]
      if (length(ug) < MIN_ROWS || length(unique(ug)) < MIN_LEVELS ||
          !isTRUE(stats::var(yg) > 0)) {
        group_excluded <- c(group_excluded, gg); next
      }
      stg <- dr_selfstart(ug, yg)
      fg <- dr_fit4pl(ug, yg, stg)
      if (is.null(fg)) { group_excluded <- c(group_excluded, gg); next }
      cfg <- summary(fg)$coefficients
      eg <- cfg[, 1]; sg <- cfg[, 2]
      dfg <- stats::df.residual(fg); tqg <- stats::qt(0.975, max(1, dfg))
      glo <- unname(eg["lo"]); ghi <- unname(eg["hi"])
      gle <- unname(eg["le50"]); gh <- unname(eg["h"])
      gle_se <- unname(sg["le50"])
      if (is.finite(gh) && gh < 0) { tmp <- glo; glo <- ghi; ghi <- tmp; gh <- -gh }
      rows[[length(rows) + 1]] <- data.frame(
        group = gg, n = length(ug), dose_levels = length(unique(ug)),
        ec50 = round(10^gle, 5),
        ec50_low = round(10^(gle - tqg * gle_se), 5),
        ec50_high = round(10^(gle + tqg * gle_se), 5),
        hill = round(if (ghi > glo) gh else -gh, 4),
        plateau_low = round(glo, 4), plateau_high = round(ghi, 4),
        stringsAsFactors = FALSE)
      rss_parts <- c(rss_parts, sum(stats::residuals(fg)^2))
      n_parts <- c(n_parts, length(ug))
      fit_groups <- c(fit_groups, gg)

Each group's own fitted curve joins the chart.

gug <- seq(min(ug), max(ug), length.out = 100)
      chart_parts[[length(chart_parts) + 1]] <- data.frame(
        plot_dose = round(gug, 5),
        response = round(f4pl(gug, glo, ghi, gle, gh), 5),
        series = paste0(gg, " (fitted)"),
        stringsAsFactors = FALSE)
    }
    if (length(rows) > 0) {
      group_df <- do.call(rbind, rows)
      group_df <- group_df[order(group_df$ec50), , drop = FALSE]
      rownames(group_df) <- NULL
      n_groups <- nrow(group_df)
    }
    if (n_groups >= 2) {

Full model = one 4PL per group (fitting them separately is exactly the pooled model with every parameter group-indexed, so the residual sums add). Reduced model = one shared midpoint, everything else free.

sel_all <- g %in% fit_groups
      uu <- u[sel_all]; yy <- y[sel_all]
      gi <- as.integer(factor(g[sel_all], levels = fit_groups))
      K <- n_groups
      n_all <- length(uu)
      rss_full <- sum(rss_parts); df_full <- n_all - 4 * K
      dd <- data.frame(u = uu, y = yy, gi = gi)
      start_red <- list(
        lo = group_df$plateau_low[match(fit_groups, group_df$group)],
        hi = group_df$plateau_high[match(fit_groups, group_df$group)],
        le50 = mean(log10(group_df$ec50)),
        h = abs(group_df$hill[match(fit_groups, group_df$group)]))
      fit_red <- tryCatch(
        stats::nls(y ~ f4pl(u, lo[gi], hi[gi], le50, h[gi]), data = dd,
                   start = start_red,
                   control = stats::nls.control(maxiter = 300, warnOnly = FALSE)),
        error = function(e) NULL, warning = function(w) NULL)
      if (!is.null(fit_red) && df_full >= 1) {
        rss_red <- sum(stats::residuals(fit_red)^2)
        df_red <- n_all - (3 * K + 1)
        num <- (rss_red - rss_full) / (df_red - df_full)
        den <- rss_full / df_full
        if (is.finite(num) && is.finite(den) && den > 0 && num >= 0) {
          f_g <- num / den
          group_test_p <- stats::pf(f_g, df_red - df_full, df_full,
                                    lower.tail = FALSE)
          group_note <- sprintf("Extra-sum-of-squares F(%d, %d) = %s, %s",
                                df_red - df_full, df_full, r3(f_g),
                                fmt_pp(group_test_p))
        }
      }
      if (!is.finite(group_test_p)) {
        group_note <- sprintf("The shared-midpoint comparison model did not converge, so no formal test of whether the %s midpoints differ is reported; compare the per-group intervals in the table instead.",
                              group_h)
      }
      ec_ratio <- max(group_df$ec50) / min(group_df$ec50)
      ec_ratio_label <- sprintf("%s versus %s",
                                group_df$group[which.max(group_df$ec50)],
                                group_df$group[which.min(group_df$ec50)])
    } else {
      group_note <- sprintf("Fewer than two &#x27;%s' levels had enough data to carry their own curve, so no across-group comparison is reported.",
                            group_h)
    }
  } else {
    group_note <- sprintf("No grouping column was mapped, so one curve was fitted across all %s rows.",
                          format(n, big.mark = ","))
  }

  curve_df <- do.call(rbind, chart_parts)
  curve_df <- curve_df[order(curve_df$series, curve_df$plot_dose), , drop = FALSE]
  rownames(curve_df) <- NULL

Step 13: Tables

params_df <- data.frame(
    parameter = c(sprintf("Plateau at low &#x27;%s'", dose_h),
                  sprintf("Plateau at high &#x27;%s'", dose_h),
                  sprintf("%s(midpoint dose)", ec_label),
                  "Hill slope(steepness)",
                  "Span(high plateau minus low plateau)"),
    estimate = round(c(lo, hi, ec50, hill_signed, span_obs), 4),
    ci_low = round(c(lo_ci[1], hi_ci[1], ec50_lo, hill_ci_signed[1], NA_real_), 4),
    ci_high = round(c(lo_ci[2], hi_ci[2], ec50_hi, hill_ci_signed[2], NA_real_), 4),
    interpretation = c(
      sprintf("The &#x27;%s' the curve settles to as '%s' approaches zero. %s",
              resp_h, dose_h,
              if (plateau_low_seen)
                "The lowest tested doses reach this plateau, so it is observed rather than projected."
              else
                sprintf("The lowest tested dose(%s) has not reached this plateau, so this value is a projection beyond the data.",
                        fmt_num(dose_min))),
      sprintf("The &#x27;%s' the curve saturates at as '%s' grows large. %s",
              resp_h, dose_h,
              if (plateau_high_seen)
                "The highest tested doses reach this plateau, so it is observed rather than projected."
              else
                sprintf("The highest tested dose(%s) has not reached this plateau, so this value is a projection beyond the data.",
                        fmt_num(dose_max))),
      sprintf("The &#x27;%s' at which '%s' is halfway between the two plateaus. %s",
              dose_h, resp_h,
              if (extrapolated)
                sprintf("It falls OUTSIDE the tested range of %s to %s, so it is an extrapolation, not a measured midpoint.",
                        fmt_num(dose_min), fmt_num(dose_max))
              else
                sprintf("It falls inside the tested range of %s to %s, so it is interpolated from observed doses.",
                        fmt_num(dose_min), fmt_num(dose_max))),
      sprintf("How sharply &#x27;%s' turns over near the midpoint. A steeper slope means a narrower '%s' window between little effect and most of the effect; the sign is %s because '%s' %s with '%s'.",
              resp_h, dose_h,
              if (increasing) "positive" else "negative", resp_h, dir_word, dose_h),
      sprintf("The total achievable change in &#x27;%s' across the fitted curve, from %s to %s.",
              resp_h, fmt_num(lo), fmt_num(hi))),
    stringsAsFactors = FALSE
  )

  resid_idx <- if (n > 900) obs_idx else seq_len(n)
  residual_df <- data.frame(
    fitted_value = round(fitted_v[resid_idx], 5),
    residual = round(resid_v[resid_idx], 5),
    stringsAsFactors = FALSE
  )
  residual_df <- residual_df[order(residual_df$fitted_value), , drop = FALSE]
  rownames(residual_df) <- NULL

  methods_df <- data.frame(
    item = c("Model", "Estimation", "Starting values", "Confidence intervals",
             "Midpoint interval", "Zero-dose controls", "Model comparison",
             "Lack of fit", "Convergence policy", "Causal standing"),
    detail = c(
      sprintf("Four-parameter logistic on log10 &#x27;%s': response = low plateau + (high plateau - low plateau) / (1 + 10^((log10(%s) - log10(dose)) x hill)).",
              dose_h, ec_label),
      sprintf("Ordinary nonlinear least squares(base R nls) over %s rows at %d distinct positive &#x27;%s' levels; residual degrees of freedom %d, residual standard error %s.",
              format(n, big.mark = ","), n_levels, dose_h, df_res, fmt_num(rse)),
      sprintf("Derived from the data, not hard-coded: the plateaus start at the mean &#x27;%s' at the lowest and highest tested dose, and normalising by them linearises the curve on the logit scale, so an ordinary least-squares line supplies the starting hill slope (%s) and midpoint (%s).",
              resp_h, r3(st$h), fmt_num(10^st$le50)),
      sprintf("Wald intervals, estimate plus or minus %s standard errors on %d degrees of freedom. These are asymptotic: for a nonlinear model they are slightly optimistic compared with profile-likelihood intervals.",
              r2(tq), df_res),
      sprintf("The midpoint is estimated on the log10 scale and back-transformed, so its interval(%s to %s) is asymmetric around %s — which is the correct shape for a dose.",
              fmt_num(ec50_lo), fmt_num(ec50_hi), fmt_num(ec50)),
      if (n_zero > 0)
        sprintf("%s zero-dose control row(s) were found, mean &#x27;%s' %s. They cannot sit on a log-dose axis, so they are excluded from the curve fit and drawn as their own series one decade below the lowest tested dose — that position is a drawing convention, not a measured dose.",
                format(n_zero, big.mark = ","), resp_h, fmt_num(zero_mean))
      else
        sprintf("No zero-dose &#x27;%s' rows were present, so there is no untreated control to anchor a baseline on.", dose_h),
      sprintf("The 4PL is compared against a three-parameter logistic(%s) and a log-linear model on AIC; %s has the lowest AIC here.",
              status3, best_label),
      lof_note,
      "If nls does not converge from the self-start, the bounded port algorithm and a grid of perturbed starts are tried. If all fail, the analysis stops and reports the failure with a diagnosis. A linear or log-linear fit is never substituted for the curve.",
      sprintf("This is a fitted description of how &#x27;%s' varies with observed '%s'. Unless the doses were assigned experimentally, the curve is associated with dose and does not by itself establish that changing '%s' causes the change in '%s'.",
              resp_h, dose_h, dose_h, resp_h)),
    stringsAsFactors = FALSE
  )

  metrics <- list(
    `Observations Fitted`   = n,
    `Dose Levels`           = n_levels,
    `Midpoint Type`         = ec_label,
    `Midpoint Dose`         = round(ec50, 5),
    `Midpoint CI Low`       = round(ec50_lo, 5),
    `Midpoint CI High`      = round(ec50_hi, 5),
    `Hill Slope`            = round(hill_signed, 3),
    `Low Plateau`           = round(lo, 3),
    `High Plateau`          = round(hi, 3),
    `Residual Std Error`    = round(rse, 4),
    `Variance Explained`    = if (is.finite(pseudo_r2)) round(pseudo_r2, 4) else NA_real_,
    `Best Model By AIC`     = switch(best_model, fourpl = "4PL",
                                     threepl = "3PL", loglin = "log-linear"),
    `Midpoint Extrapolated` = if (extrapolated) "yes" else "no"
  )

  extrap_clause <- if (extrapolated) {
    sprintf(" The midpoint lies OUTSIDE the tested &#x27;%s' range of %s to %s, so this %s is an extrapolation beyond the doses actually observed and should be treated as a projection, not a measurement.",
            dose_h, fmt_num(dose_min), fmt_num(dose_max), ec_label)
  } else ""
  group_clause <- if (n_groups >= 2) {
    if (is.finite(group_test_p) && group_test_p < 0.05) {
      sprintf(" Across &#x27;%s', the midpoints differ (%s): %s is a %s-fold shift, so the pooled curve averages over genuinely different curves and should not be read as any one group's response.",
              group_h, group_note, ec_ratio_label, fmt_num(ec_ratio))
    } else if (is.finite(group_test_p)) {
      sprintf(" Across &#x27;%s', the midpoints are not distinguishable (%s), so one shared curve is a fair summary of all groups.",
              group_h, group_note)
    } else {
      paste0(" ", group_note)
    }
  } else ""

  json_output <- list(
    answer = paste0(
      "Four-parameter logistic dose-response fit of &#x27;", resp_h, "' on '",
      dose_h, "&#x27; across ", format(n, big.mark = ","), " rows at ", n_levels,
      " distinct positive dose levels: &#x27;", resp_h, "' ", dir_word, " from a low-dose plateau of ",
      fmt_num(lo), " to a high-dose plateau of ", fmt_num(hi), ", with ",
      ec_label, " = ", fmt_num(ec50), " (95% CI ", fmt_num(ec50_lo), " to ",
      fmt_num(ec50_hi), ") and a Hill slope of ", r3(hill_signed), ".",
      extrap_clause, group_clause,
      " The curve leaves a residual standard error of ", fmt_num(rse),
      " and ", best_label, " has the lowest AIC of the three models compared."
    ),
    cards = lapply(
      c("tldr", "overview", "preprocessing", "dose_response_curve",
        "parameters", "model_comparison", "group_comparison",
        "residual_diagnostics", "methods"),
      function(cid) list(id = cid, metrics = metrics)
    )
  )

  list(
    initial_rows = initial_rows, final_rows = final_rows,
    rows_removed = rows_removed,
    dose_h = dose_h, resp_h = resp_h, group_h = group_h,
    has_group_col = has_group_col,
    n = n, n_levels = n_levels, n_zero = n_zero, zero_mean = zero_mean,
    n_drop_na = n_drop_na, n_drop_nonpos = n_drop_nonpos,
    lo = lo, hi = hi, le50 = le50, hill = h, hill_signed = hill_signed,
    lo_ci = lo_ci, hi_ci = hi_ci, hill_ci = hill_ci_signed,
    ec50 = ec50, ec50_lo = ec50_lo, ec50_hi = ec50_hi,
    ec_label = ec_label, increasing = increasing, dir_word = dir_word,
    span_obs = span_obs, extrapolated = extrapolated,
    dose_min = dose_min, dose_max = dose_max, ctrl_x = ctrl_x,
    plateau_low_seen = plateau_low_seen, plateau_high_seen = plateau_high_seen,
    rse = rse, pseudo_r2 = pseudo_r2, df_res = df_res,
    lof_p = lof_p, lof_note = lof_note, shapiro_p = shapiro_p,
    sp_rho = sp_rho, sp_p = sp_p,
    models_df = models_df, best_model = best_model, best_label = best_label,
    status3 = status3, f_3pl_p = f_3pl_p, f_3pl_note = f_3pl_note,
    curve_df = curve_df, params_df = params_df, residual_df = residual_df,
    group_df = group_df, methods_df = methods_df,
    n_groups = n_groups, group_test_p = group_test_p, group_note = group_note,
    ec_ratio = ec_ratio, ec_ratio_label = ec_ratio_label,
    group_excluded = group_excluded,
    metrics = metrics, json_output = json_output
  )
}
Your data has more stories to tell. Run any analysis on your own data — validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No Signup Sign Up Free

Report an Issue

Tell us what's wrong. You'll get a free re-run of this analysis so you can try again with different parameters. If the re-run still doesn't meet your expectations, we'll refund your credits.

Want to run this analysis on your own data? Upload CSV — Free Analysis See Pricing