---
title: "Ramping assays"
vignette: >
  %\VignetteIndexEntry{Ramping assays}
  %\VignetteEngine{quarto::html}
  %\VignetteEncoding{UTF-8}
format:
  html:
    toc: true
    toc-depth: 3
    toc-title: "Contents"
    html-math-method: mathml
    include-in-header:
      text: |
        <style>
        /* The contents list beside the text, in view while scrolling. It applies
           only to the plain page the package build makes, on a window wide enough
           to hold it; a themed Quarto page places its own contents list. */
        @media (min-width: 1380px) {
          body:not(:has(#quarto-content)) nav#TOC {
            position: fixed;
            top: 2em;
            left: calc(50% - 670px);
            width: 250px;
            max-height: 90vh;
            overflow-y: auto;
            font-size: 0.9em;
          }
          body:not(:has(#quarto-content)) nav#TOC ul { padding-left: 1.1em; }
        }
        /* An estimate and its interval stay on one line of a table; only the first
           column, which holds names, may wrap. */
        table td:not(:first-child), table th:not(:first-child) { white-space: nowrap; }
        </style>
knitr:
  opts_chunk:
    collapse: true
    comment: "#>"
    fig.width: 7
    fig.height: 4
---

```{r}
#| label: setup
#| include: false
results <- readRDS("results-02.rds")
# The static assay of the first vignette, for the comparisons with it.
static  <- readRDS("results-01.rds")

# An interval as "lower–upper", for the text.
interval <- function(x, digits = 1, scale = 1) {
  sprintf(paste0("%.", digits, "f–%.", digits, "f"), scale * x$lower, scale * x$upper)
}
```

In a ramping assay, individuals are warmed at a constant rate until they fail, and the temperature at which each individual fails is recorded as its critical thermal maximum (CTmax, or dCTmax *sensu* [Jørgensen et al. 2021](https://doi.org/10.1038/s41598-021-92004-6){target="_blank"}). The mean CTmax of a group of individuals is the most widely reported measure of thermal tolerance. However, it depends on the temperature at which the ramp started and on the ramp rate, because individuals accrue hazard throughout the ramp ([Kingsolver and Umbanhowar 2018](https://doi.org/10.1242/jeb.167858){target="_blank"}; [Jørgensen et al. 2021](https://doi.org/10.1038/s41598-021-92004-6){target="_blank"}). `rTFT` therefore treats a ramping assay as it treats a static one: it fits a model of the failure rate to the times of failure, given the temperature each individual was exposed to through time, and derives the CTmax expected under any ramp from the fitted model.

In this vignette, we fit a model to a ramping assay run at three rates (0.1, 0.2 and 0.3°C per min) and derive from it: (i) the dynamic critical thermal maximum (dCTmax; i.e., the CTmax expected of a ramping trial) under the ramps of the assay and under ramps that were not run, (ii) the static critical thermal maximum (sCTmax), (iii) the thermal death time curves and (iv) the thermal tolerance landscape. We then show that what the model can estimate from a ramping assay depends on the ramp rates that were run, and mainly on how much they differ (i.e., on the ratio between the fastest and the slowest rate). For that, we fit the same model to an assay of the same size in which the slowest rate was 0.025°C per min, and compare what the two assays allow the model to estimate and predict. We also describe two steps that are specific to ramping assays, which are how the temperature of each ramp is given to the package and how closely the ramp is followed when the model is fitted. The model and the steps shared with a static assay are described in more detail in the vignette [*Getting started with rTFT*](getting-started.html).

```{r}
#| label: libraries
#| message: false
library(rTFT)
library(ggplot2)
```

## The ramping assay

The mock dataset `ramp_assay` holds the times to loss of equilibrium of 320 juvenile salmonids warmed from 14°C at a constant rate until they failed or the water reached 29.5°C, in trials of ten fish. Four rates (0.025, 0.1, 0.2 and 0.3°C per min) were each run in four trials in normoxia (10 mg/L of dissolved oxygen) and four in hypoxia (5 mg/L). The fish were watched throughout the ramp, so the time of each failure is known (to the nearest 0.1 min). We start with the 120 fish warmed at the three faster rates in normoxia, and use the trials at the slow rate in the section [*The choice of ramp rates*](#sec-rates). The effect of dissolved oxygen is the subject of the vignette [*Temperature and a co-stressor*](temperature-and-a-co-stressor.html).

::: {.callout-note}
The data were simulated from the same model as the static assay of the vignette [*Getting started with rTFT*](getting-started.html) (see `?ramp_assay`), so we can compare what we estimate against the values used in the simulation.
:::

```{r}
#| label: data
ramp <- subset(ramp_assay, do == 10 & rate %in% c(0.1, 0.2, 0.3))
head(ramp)
```

Each row is a fish. The column `event` is 1 for a fish that failed and 0 for a survivor, and `t_event` is the time (min) at which the fish failed, or at which the ramp ended for a survivor. The column `path_id` links each fish to the ramp it was exposed to. The CTmax of a fish is the temperature that the ramp had reached when the fish failed, which follows from the starting temperature, the rate and the time of failure.

```{r}
#| label: data-summary
ramp$ctmax <- 14 + ramp$rate * ramp$t_event
observed <- ramp |>
  dplyr::summarise(
    n_fish       = dplyr::n(),
    n_failed     = sum(event),
    median_time  = median(t_event),
    median_ctmax = median(ctmax),
    .by = rate
  )
observed
```

All fish failed at the two slower rates, and `r observed$n_fish[3] - observed$n_failed[3]` of the `r observed$n_fish[3]` fish warmed at 0.3°C per min reached 29.5°C without failing. The survivors are counted at 29.5°C in the table. This does not change the median, because fewer than half of the fish survived and all of them outlasted the fish that failed, so the median is the CTmax of fish that failed. The median CTmax increased with the ramp rate, from `r sprintf("%.1f", observed$median_ctmax[1])`°C at 0.1°C per min to `r sprintf("%.1f", observed$median_ctmax[3])`°C at 0.3°C per min, although the fish of the three groups were simulated with the same thermal tolerance. The fish in the slowest ramp had been exposed for more than twice as long when they failed (`r sprintf("%.0f", observed$median_time[1])` min against `r sprintf("%.0f", observed$median_time[3])` min), and they failed at a lower temperature because they had more time to accrue hazard before each temperature was reached.

## The temperature of each ramp

The function `build_hazard_data()` requires the temperature that each group of fish was exposed to through time, as a table of times and temperatures for each `path_id`. It interpolates the table linearly, so a ramp at a constant rate needs only its first and last points.

### Nominal ramps

The function `ramp_path()` builds the table from the starting temperature, the rate and the duration of each ramp. Each ramp of the assay spanned 15.5°C (from 14 to 29.5°C), so its duration is 15.5°C divided by its rate.

```{r}
#| label: path
rates <- c(0.1, 0.2, 0.3)
path  <- ramp_path(c("r0.1", "r0.2", "r0.3"), T_start = 14, rate = rates,
                   t_max = 15.5 / rates)
path
```

### Logged temperatures

When the temperature was logged during a trial, the logged record itself can be given as the table of temperatures (with one row per reading), and `build_hazard_data()` interpolates between the readings. A logged record should be preferred to the nominal ramp whenever the two differ, because the failure rate increases steeply with temperature and a small error in temperature therefore results in a large error in the failure rate.

The function `assess_ramp_linearity()` compares a logged record with a straight line. As an example, we simulate the logged record of a trial set to 0.3°C per min, in which the water warmed slightly slower than intended and the temperature was read every 30 s with a small error.

```{r}
#| label: logged
set.seed(1)
logged <- data.frame(path_id = "r0.3", time = seq(0, 51.5, by = 0.5))
logged$temp <- 14 + 0.29 * logged$time + rnorm(nrow(logged), mean = 0, sd = 0.05)
linearity <- assess_ramp_linearity(logged, nominal = c(r0.3 = 0.3))
as.data.frame(linearity)
```

```{r}
#| label: logged-text
#| include: false
# How much cooler than the nominal ramp the water was after 50 min.
cooler <- round(abs(linearity$rate_bias) * 50, 1)
```

The logged record was very close to a straight line ($R^2$ = `r sprintf("%.4f", linearity$r_squared)`; largest deviation of `r sprintf("%.2f", linearity$max_abs_dev)`°C), but the rate of that line was `r sprintf("%.3f", linearity$rate)`°C per min, and not the 0.3°C per min that was set. The difference appears small, but after 50 min the water was `r cooler`°C cooler than the nominal ramp indicates. With a thermal sensitivity of 1.7 per °C (the value used to simulate the data of this vignette), the failure rate at the nominal temperature is exp(1.7 × `r cooler`) = `r sprintf("%.1f", exp(1.7 * cooler))` times the failure rate at the temperature the fish were actually exposed to. In a case like this one, the nominal ramp should not be used. The model can be fitted with the logged record itself, or with a straight ramp built by `ramp_path()` from the starting temperature and the rate that were realised (the columns `T_start` and `rate` above). The straight ramp follows a linear logged record closely with far fewer rows, but it should only replace the logged record when the logged record deviates little from a straight line (the help page of `assess_ramp_linearity()` describes how little).

In the remainder of this vignette we use the nominal ramps, which are the temperatures the simulated fish were exposed to.

## The table of intervals at risk

The model is fitted to a table with one row for every interval during which a fish was at risk of failing. For a static assay with periodic checks, the intervals are those between consecutive checks. When each failure was observed at a known time (a known-time failure; `observation = "continuous"`), there are no checks, and `build_hazard_data()` divides the exposure of each fish into intervals itself.

```{r}
#| label: long
long <- build_hazard_data(
  ramp, path,
  observation = "continuous",
  covariates  = c("mass", "trial")
)
```

The message printed by the function refers to how closely the ramp is followed. The model takes the hazard as constant within each interval, at the temperature of the midpoint of the interval. This approximation is accurate when the temperature changes little within an interval. In a ramp the temperature changes continuously and the failure rate increases steeply with it: with a thermal sensitivity of 1.7 per °C, the failure rate at the end of an interval over which the water warms by 0.5°C is exp(1.7 × 0.5) = 2.3 times the failure rate at its start. The hazard accrued over such an interval, taken at its midpoint, is understated by about (1.7 × 0.5)^2^ / 24 = 3% (the error grows with the square of the change in temperature within the interval), which biases the estimates slightly. The function therefore divides the exposure so that the temperature changes by at most 0.1°C within an interval (the argument `grid_dT`), where that error is below 0.1%, which gave `r format(nrow(long), big.mark = ",")` rows for the 120 fish.

```{r}
#| label: long-tail
first_fish <- long[long$id == long$id[1], c("id", "tau", "exposure", "Ta", "failed")]
# tau is the midpoint of each interval and exposure its length
first_fish$start <- first_fish$tau - first_fish$exposure / 2
first_fish$end   <- first_fish$tau + first_fish$exposure / 2
tail(first_fish, 3)
```

The table does not hold the start and the end of each interval, but its midpoint (`tau`) and its length (`exposure`), from which we computed them above. The last row of each fish that failed is a very short interval (about a hundredth of a minute here) that ends at its time of failure. A failure at a known time contributes to the model through the hazard at the moment it happened, and without that row the hazard would be taken at the midpoint of a longer interval, when the water was cooler. This is the larger of the two errors: in a simulation of eight assays of 3,000 fish warmed at 0.1–0.3°C per min and followed in intervals of 1 min without the closing row, the thermal sensitivity was underestimated by about 4% (1.63 against 1.70); with the closing row and the default grid, it was recovered (1.71 ± 0.01) whatever the length of the intervals.

We recommend leaving `grid_dT` at its default. A finer grid makes the table longer and the model slower to fit, but a coarser one biases the estimates (see the section *The time grid* in `?build_hazard_data`). When the fish of a ramping trial are checked periodically instead of watched (`observation = "interval"`), the interval in which a fish failed cannot be divided, because where in it the fish failed is not known. In that case, the function gives a warning when the temperature changes by more than 0.5°C within the interval in which a fish was found to have failed. The intervals that a fish survived are divided as described above, so they need no warning.

As in a static assay, two columns hold the constants that anchor the model. The reference temperature `Tr` defaults to the median temperature at which failures were observed (`r sprintf("%.1f", unique(long$Tr))`°C here) and the reference time `tau_ref` to the median time at which failures were observed (`r sprintf("%.1f", unique(long$tau_ref))` min).

## Fitting

We fit the model of the vignette [*Getting started with rTFT*](getting-started.html): an exponential failure rate, $M(T) = a \exp\{b (T - T_r)\}$, with a Weibull baseline whose shape $k$ allows the hazard to change with the time under exposure, and $\log a$ depending on body mass (centred at 15 g) and varying among trials.

```{r}
#| label: fit
#| eval: false
formulas <- list(loga = ~ 1 + I(mass - 15) + (1 | trial))
fit <- fit_hazard(
  long,
  m_form        = "exp",
  baseline      = "weibull",
  dpar_formulas = formulas,
  chains = 4, iter = 2000, warmup = 1000, cores = 4, seed = 20261006,
  control = list(adapt_delta = 0.95), backend = "cmdstanr"
)
```

The sampler settings, including `adapt_delta`, are those chosen in the vignette [*Getting started with rTFT*](getting-started.html) and were not tuned for this model. The code above was run once and its results were saved with the package (see the note at the end of this vignette).

## Convergence

```{r}
#| label: convergence-code
#| eval: false
posterior::summarise_draws(posterior::as_draws_array(fit),
                           "median", "sd", "rhat", "ess_bulk", "ess_tail")
```

```{r}
#| label: convergence
#| echo: false
knitr::kable(results$convergence$table, digits = c(0, 3, 3, 3, 0, 0))
```

All values of $\hat{R}$ were below 1.01, all effective sample sizes were above 400 and there were `r results$convergence$n_divergent` divergent transitions in `r format(results$convergence$n_draws, big.mark = ",")` draws.

## Fit of the model to the data

We check the model against the data with the two functions described in the vignette [*Getting started with rTFT*](getting-started.html). The first compares the proportion of fish observed to survive through time in each ramp with the same proportion in datasets simulated from the fitted model.

```{r}
#| label: check-code
#| eval: false
set.seed(20261006)
chk <- check_survival(fit)
```

```{r}
#| label: check-load
#| include: false
chk <- results$check
```

```{r}
#| label: fig-check
#| fig-cap: "Proportion of fish observed to survive (dark lines) and proportion surviving in datasets simulated from the fitted model (blue lines are medians and bands are 95% intervals), in each ramp of the assay."
#| fig-height: 3
plot_check(chk, time_unit = "min")
```

The second gives one residual for each fish (i.e., the fraction of fish that the model predicts to have failed by the time that fish did), which is uniformly distributed between 0 and 1 when the model describes the data.

```{r}
#| label: residuals-code
#| eval: false
set.seed(20261006)
res <- hazard_residuals(fit)
```

```{r}
#| label: residuals-load
#| include: false
res <- results$residuals
# the fish that outlasted their ramp, the only ones whose residual is drawn at random
n_survived <- sum(res$event == 0)
```

```{r}
#| label: fig-residuals
#| fig-cap: "Residuals of the fitted model. (a) Residuals against the values expected of a uniform distribution, with the dashed line showing equality. (b) Residuals by trial, with the dashed line at 0.5."
#| fig-subcap:
#|   - ""
#|   - ""
#| layout-ncol: 2
#| fig-width: 4
#| fig-height: 3.6
plot_residuals(res)
plot_residuals(res, against = "trial") +
  theme(axis.text.x = element_text(angle = 90, vjust = 0.5))
```

In each of the three ramps, the observed proportion was within the simulated band (@fig-check). Both proportions remained at 1 for most of the exposure, because fish rarely fail while the water is still well below the temperatures at which failures were observed, and decreased steeply towards the end of the ramp. The residuals followed the uniform distribution closely and showed no trial whose fish consistently failed earlier or later than predicted (@fig-residuals). Every failure here was seen at its time, so only the `r n_survived` of the `r nrow(res)` fish that outlasted their ramp have residuals drawn at random, and a single draw is nearly the whole answer; in the static assay of the vignette [*Getting started with rTFT*](getting-started.html), where every failure is known only to the interval between two checks, every residual is drawn, and the residuals are drawn `r length(unique(static$residuals$randomisation))` times. As in a static assay, the residuals should not be examined against the time of failure. In a ramping assay the same applies to the temperature at failure (i.e., the CTmax of each fish), which is the time of failure expressed on another scale (see `?hazard_residuals`).

## Thermal tolerance derived from the fit

```{r}
#| label: derive
#| eval: false
tol <- derive_tdt_ttl(fit, newdata = data.frame(mass = 15), t_ref = 60,
                      Ta = seq(25.5, 29.5, by = 0.1))
```

```{r}
#| label: derive-load
#| include: false
tol <- results$tolerance
```

The argument `Ta` sets the temperatures at which the thermal death time curves are computed. By default, `derive_tdt_ttl()` computes them across the temperatures in the table of intervals at risk, which in a ramping assay start at the temperature at which the ramps began (14°C here). The fish of this assay failed at `r sprintf("%.1f", min(ramp$ctmax[ramp$event == 1]))`–`r sprintf("%.1f", max(ramp$ctmax[ramp$event == 1]))`°C, so we restricted the curves to those temperatures.

```{r}
#| label: params-text
#| include: false
param    <- function(q) tol$params[tol$params$quantity == q, ]
k_static <- static$tolerance$params[static$tolerance$params$quantity == "shape", ][1, ]
```

### Parameters

```{r}
#| label: tbl-params
#| tbl-cap: "Posterior medians and 95% credible intervals of the parameters for a fish of 15 g. The amplitude $a$ and the thermal sensitivity $b$ are on their natural scale (the model estimates their logarithms)."
knitr::kable(tol$params[c("quantity", "median", "lower", "upper")], digits = 2)
```

The amplitude $a$ is the cumulative hazard reached over the reference time (`r sprintf("%.1f", unique(long$tau_ref))` min) at the reference temperature (`r sprintf("%.1f", unique(long$Tr))`°C) (@tbl-params). The thermal sensitivity $b$ was `r sprintf("%.2f", param("b")$median)` per °C (95% credible interval: `r interval(param("b"), 2)`), which corresponds to a failure rate that increases `r sprintf("%.1f", exp(param("b")$median))`-fold per degree (i.e., $\exp(`r sprintf("%.2f", param("b")$median)`)$ = `r sprintf("%.1f", exp(param("b")$median))`, because $b$ multiplies the temperature inside the exponential of $M(T) = a \exp\{b (T - T_r)\}$).

The shape $k$ of the baseline was estimated with less precision than $b$ (95% credible interval: `r interval(param("shape"), 2)`; the posterior probability that it is greater than 1 was `r sprintf("%.2f", results$p_shape_above_1)`). For comparison, the static assay of the vignette [*Getting started with rTFT*](getting-started.html), which was simulated from the same model, gave a 95% credible interval of `r interval(k_static, 2)` for $k$. The difference follows from how each kind of assay informs the baseline. In a static trial the temperature is constant, so any change in the hazard during the trial can only be due to the time under exposure. Within a ramp, the temperature and the time under exposure increase together, so a single ramp separates their effects on the hazard very poorly (see `?h0_catalogue`). The two effects are separated mostly by the comparison among ramp rates, because the fish in a slower ramp have been exposed for longer when the water reaches a given temperature. The rates of this assay differ only threefold, which limits that comparison (see the section [*The choice of ramp rates*](#sec-rates)). The uncertainty in $k$ carries over to $z$ (i.e., the increase in temperature that shortens the time to failure tenfold, $z = k \ln(10) / b$).

### Dynamic critical thermal maximum

The function `derive_dctmax()` gives the CTmax expected under a ramp (dCTmax; [Jørgensen et al. 2021](https://doi.org/10.1038/s41598-021-92004-6){target="_blank"}), which is the temperature at which a given fraction of the individuals have failed. By default that fraction is half (the argument `p`), which corresponds to the median CTmax of a trial. We first derive it for the three ramps of the assay and compare it with the median CTmax that was observed.

```{r}
#| label: dctmax-code
#| eval: false
dct <- derive_dctmax(fit, rate = rates, T_start = 14,
                     newdata = data.frame(mass = 15))
```

```{r}
#| label: dctmax-load
#| include: false
dct <- results$dctmax
dct_static <- static$dctmax
```

```{r}
#| label: tbl-dctmax
#| tbl-cap: "Median CTmax (°C) observed in each ramp of the assay, and the dynamic critical thermal maximum of a 15 g fish derived from the model fitted to the ramping assay and from the model fitted to the static assay of the vignette [*Getting started with rTFT*](getting-started.html) (posterior medians and 95% credible intervals)."
# dct_static holds the same quantity from the model of the vignette
# Getting started with rTFT (see its section on the dynamic critical thermal maximum)
estimate   <- function(x) sprintf("%.1f (%.1f–%.1f)", x$median, x$lower, x$upper)
knitr::kable(data.frame(
  `Ramp rate (°C per min)`    = dct$rate,
  `Observed`                  = sprintf("%.1f", observed$median_ctmax),
  `Derived, ramping assay`    = estimate(dct),
  `Derived, static assay`     = estimate(dct_static),
  check.names = FALSE
), align = "rrrr")
```

The dCTmax derived from the ramping assay was within `r sprintf("%.1f", max(abs(dct$median - observed$median_ctmax)))`°C of the median CTmax observed in each ramp (@tbl-dctmax). The two are not expected to be equal, because the dCTmax refers to a fish of 15 g in a typical trial and the observed median to 40 fish of different masses in four trials.

The last column of the table holds the dCTmax derived in the vignette [*Getting started with rTFT*](getting-started.html) from the model fitted to the static assay, in which no fish was exposed to a ramp or to a temperature above 27°C. Those values were `r sprintf("%.1f", min(dct_static$median - dct$median))`–`r sprintf("%.1f", max(dct_static$median - dct$median))`°C higher than the values derived from the ramping assay, and the credible intervals of the two overlapped at the three rates.

Because the model describes the failure rate at each temperature and not the outcome of a particular ramp, it can also be asked what CTmax a ramp that was not run would give (@fig-dctmax).

```{r}
#| label: dctmax-rates-code
#| eval: false
dct_rates <- derive_dctmax(fit, rate = seq(0.01, 1, by = 0.01),
                           T_start = 14, newdata = data.frame(mass = 15))
```

```{r}
#| label: dctmax-rates-load
#| include: false
dct_rates <- results$dctmax_rates
```

```{r}
#| label: fig-dctmax
#| fig-cap: "Dynamic critical thermal maximum of a 15 g fish under ramps starting at 14°C, against the ramp rate. The line is the posterior median and the band is the 95% credible interval. Points are the CTmax of the fish that failed in the three ramps of the assay."
ggplot(dct_rates, aes(x = rate)) +
  geom_ribbon(aes(ymin = lower, ymax = upper), fill = "#225EA8", alpha = 0.2) +
  geom_line(aes(y = median), colour = "#225EA8") +
  geom_point(data = subset(ramp, event == 1), aes(y = ctmax),
             colour = "grey20", alpha = 0.5) +
  labs(x = "Ramp rate (°C per min)", y = "CTmax (°C)") +
  theme_light()
```

```{r}
#| label: dctmax-rates-text
#| include: false
slowest <- dct_rates[which.min(dct_rates$rate), ]
fastest <- dct_rates[which.max(dct_rates$rate), ]
# The dCTmax at one rate, and how much it rises when a rate is doubled.
at_rate   <- function(r) dct_rates$median[which.min(abs(dct_rates$rate - r))]
doubling  <- function(r) sprintf("%.1f", at_rate(2 * r) - at_rate(r))
width   <- dct_rates$upper - dct_rates$lower
```

The curve rises steeply among the slow ramps and flattens among the fast ones, but it does not approach a maximum (@fig-dctmax). The dCTmax rises by a similar amount each time the rate is doubled (`r doubling(0.05)`°C from 0.05 to 0.1°C per min, `r doubling(0.25)`°C from 0.25 to 0.5°C per min and `r doubling(0.5)`°C from 0.5 to 1°C per min), so under the fitted model it keeps increasing with the rate, more and more slowly. A curve of this kind shows how much two values of CTmax may differ because of the ramp rate alone (i.e., with no change in the thermal tolerance of the individuals). Under the fitted model, the same fish have a CTmax `r sprintf("%.1f", dct$median[3] - dct$median[1])`°C higher at 0.3°C per min than at 0.1°C per min.

In this example, the dCTmax is predicted to increase from `r sprintf("%.1f", slowest$median)`°C (95% credible interval: `r interval(slowest)`°C) at 0.01°C per min to `r sprintf("%.1f", fastest$median)`°C (`r interval(fastest)`°C) at 1°C per min. The credible interval was narrowest near the rates of the assay (`r sprintf("%.1f", min(width))`°C wide at `r sprintf("%.2f", dct_rates$rate[which.min(width)])`°C per min) and widened towards the slower and the faster ramps, which the assay did not include (`r sprintf("%.1f", width[which.min(dct_rates$rate)])`°C wide at 0.01°C per min). At the fastest ramps the dCTmax was above 29.5°C, the warmest temperature of the assay, and `derive_dctmax()` prints a message when that is the case.

The curve also provides an expectation against which the CTmax observed at other rates can be compared. In a slow ramp, a CTmax lower than expected is consistent with stress accumulated over the long exposure, and a CTmax higher than expected with acclimation during it ([Kingsolver and Umbanhowar 2018](https://doi.org/10.1242/jeb.167858){target="_blank"}). However, a departure may also result from a failure rate function or a baseline of the wrong form, so it should not be taken as a measure of stress or of acclimation without evidence that excludes those alternatives.

### Static critical thermal maximum and thermal death time curves

```{r}
#| label: sctmax
knitr::kable(tol$sctmax[c("mass", "p", "t_ref", "median", "lower", "upper")], digits = 2)
```

```{r}
#| label: sctmax-text
#| include: false
sct <- tol$sctmax[tol$sctmax$p == 0.5, ]
```

Half of the fish of 15 g are expected to fail within 60 min at a constant temperature of `r sprintf("%.1f", sct$median)`°C (95% credible interval: `r interval(sct)`°C). This sCTmax is lower than the dCTmax under the three ramps of the assay (`r sprintf("%.1f", min(dct$median))`–`r sprintf("%.1f", max(dct$median))`°C), in which the fish spend only a few minutes near each temperature (see the vignette [*Getting started with rTFT*](getting-started.html)).

```{r}
#| label: fig-tdt
#| fig-cap: "Thermal death time curves for a fish of 15 g, derived from the ramping assay. Lines are posterior medians and bands are 95% credible intervals."
plot_tdt(tol, time_unit = "min")
```

```{r}
#| label: tdt-text
#| include: false
exposures <- range(ramp$t_event[ramp$event == 1])
tdt_half  <- tol$tdt_curves[tol$tdt_curves$p == 0.5, ]
informed  <- range(tdt_half$Ta[tdt_half$median >= exposures[1] & tdt_half$median <= exposures[2]])
warmest   <- tdt_half[which.max(tdt_half$Ta), ]
```

A thermal death time curve gives the time to failure at a constant temperature, whereas the fish of this assay failed in ramps, after `r sprintf("%.0f", exposures[1])`–`r sprintf("%.0f", exposures[2])` min of exposure. The curves are best estimated at the temperatures at which a fish held at a constant temperature would take about that long to fail (`r sprintf("%.1f", informed[1])`–`r sprintf("%.1f", informed[2])`°C for half of the fish), and the credible bands are narrowest there (@fig-tdt). At `r sprintf("%.1f", warmest$Ta)`°C, half of the fish held at a constant temperature would fail within `r sprintf("%.0f", warmest$median)` min, which is much sooner than any fish failed in the ramps. No ramp provides data on failures after such a short exposure, so the model obtains those times from what it estimated about the baseline (i.e., from $k$), and the bands are wide there because $k$ is uncertain.

### Thermal tolerance landscape

The thermal tolerance landscape ([Rezende et al. 2014](https://doi.org/10.1111/1365-2435.12268){target="_blank"}) is derived in the same way as from a static assay (@fig-ttl). Although no fish of this assay was held at a constant temperature, the fitted model describes the failure rate at each temperature and the baseline, and the probability of surviving any combination of constant temperature and exposure time follows from the two.

```{r}
#| label: fig-ttl
#| fig-cap: "Thermal tolerance landscape for a fish of 15 g, derived from the ramping assay. Colours show the posterior median of the probability of survival and lines are contours at the labelled probabilities."
plot_ttl(tol, time_unit = "min")
```

The landscape covers the temperatures at which the fish failed in the ramps and exposures up to the longest after which a fish failed (`r sprintf("%.0f", exposures[2])` min). As for the thermal death time curves, the part of the landscape for high temperatures and exposures of a few minutes depends on the baseline extrapolated to exposures shorter than those after which the fish failed, and so would any extension of the landscape to longer exposures. The figure shows the posterior median, and the table `ttl_surface` returned by `derive_tdt_ttl()` also holds the 95% credible interval of the probability of survival at each temperature and exposure time.

## Comparison with the values used in the simulation

The estimates can be set against the values used in the simulation, which are stored with the dataset. As in the vignette [*Getting started with rTFT*](getting-started.html), the simulated $a$ (defined at 26°C with a Weibull scale of 60 min) is first converted to the reference temperature and reference time of the fitted model. The simulated dCTmax has no closed form under a Weibull baseline, so it is obtained by finding the time at which the hazard accrued along each ramp reaches $-\ln(1 - p)$, with $p$ = 0.5.

```{r}
#| label: tbl-truth
#| tbl-cap: "Values used in the simulation and their estimates for a fish of 15 g (posterior median and 95% credible interval)."
truth   <- attr(ramp_assay, "truth")
Tr      <- unique(long$Tr)
tau_ref <- unique(long$tau_ref)

# The temperature at which half of the fish have failed under a ramp from 14°C.
simulated_dctmax <- function(rate, T_start = 14, p = 0.5) {
  hazard <- function(t) {
    exp(truth$log_a + truth$b * (T_start + rate * t - truth$T_ref)) *
      (truth$shape / truth$scale) * (t / truth$scale)^(truth$shape - 1)
  }
  accrued <- function(t) integrate(hazard, lower = 0, upper = t)$value + log(1 - p)
  T_start + rate * uniroot(accrued, interval = c(1, 20 / rate), tol = 1e-8)$root
}

simulated <- c(
  # a at the reference temperature and time of the fitted model
  exp(truth$log_a + truth$b * (Tr - truth$T_ref)) * (tau_ref / truth$scale)^truth$shape,
  truth$b, truth$shape, truth$shape * log(10) / truth$b,
  # sCTmax for p = 0.5 and 60 min: the temperature at which M(T) H0(60) = log(2)
  truth$T_ref + (log(log(2)) - truth$log_a - truth$shape * log(60 / truth$scale)) / truth$b,
  vapply(rates, simulated_dctmax, numeric(1))
)

keep <- c("median", "lower", "upper")
comparison <- data.frame(
  quantity  = c("a", "b", "k", "z", "sCTmax (60 min)",
                paste0("dCTmax (", rates, "°C per min)")),
  simulated = simulated,
  rbind(tol$params[keep], tol$sctmax[tol$sctmax$p == 0.5, keep], dct[keep])
)
knitr::kable(comparison, digits = 2, row.names = FALSE)
```
```{r}
#| label: covered
#| include: false
covered <- comparison$simulated >= comparison$lower & comparison$simulated <= comparison$upper
missed  <- comparison$quantity[!covered]
```

The 95% credible intervals include the data generating values of `r if (all(covered)) paste("all", length(covered)) else paste(sum(covered), "of the", length(covered))` quantities (@tbl-truth)`r if (length(missed)) paste0(", the exception being ", paste(missed, collapse = ", ")) else ""`. One assay says little about how often the intervals include those values. In 40 assays of 120 fish simulated from the same model and warmed at rates between 0.1 and 0.3°C per min (not shown), the 95% credible intervals of $b$, $k$, sCTmax and dCTmax included the data generating values in 92.5–100% of the assays, and the biases of the posterior medians were within their Monte Carlo errors.

## The choice of ramp rates {#sec-rates}

### The span of the rates

The shape $k$ was the parameter that the assay informed least due to the rates that were used. The effect of the time under exposure on the hazard can only be estimated by comparing fish that reached the same temperature after different exposure times. In ramps that start at the same temperature, the fish warmed at 0.1°C per min have been exposed three times as long as the fish warmed at 0.3°C per min when the water reaches any given temperature. The two groups have the same failure rate $M(T)$ at that temperature, so their hazards differ only through the baseline. The Weibull baseline is proportional to the time under exposure raised to $k - 1$ (i.e., $h_0(\tau) \propto \tau^{k - 1}$), so an exposure three times as long gives a hazard $3^{k - 1}$ times as high (i.e., 1.7 times for $k$ = 1.5). The ratio between the fastest and the slowest rate (hereafter, the span of the rates) therefore sets the size of the difference from which $k$ is estimated. With rates of 0.025 and 0.3°C per min, the span is 12 and the same factor is $12^{k - 1}$ (3.5 for $k$ = 1.5).

What matters for $k$ is therefore how far apart the rates are, and not how slow the slowest one is: rates of 0.1 and 1.2°C per min give the same 12-fold difference in exposure time at any temperature as rates of 0.025 and 0.3°C per min. The comparison that informs $k$ the most is that between the slowest and the fastest rates, so a rate between them is expected to add much less than a wider span does. With a single rate there is no such comparison, and the effects of temperature and of the time under exposure cannot be estimated reliably (see `?h0_catalogue`). Likewise, [Jørgensen et al. (2021)](https://doi.org/10.1038/s41598-021-92004-6){target="_blank"} indicates that ramping trials of at least two ramp rates are required to estimate the parameters of their model, and recommend a broad span of rates.

### Two assays of the same size

The trials at 0.025°C per min allow us to compare two assays that differ only in the span of their rates. Replacing the four trials at 0.2°C per min with the four trials at 0.025°C per min gives an assay of the same size (120 fish in 12 trials, eight of them shared with the first assay) whose rates span 12-fold instead of 3-fold. The same model is fitted to it with the same code.

```{r}
#| label: wide-fit
#| eval: false
rates_wide <- c(0.025, 0.1, 0.3)
ramp_wide  <- subset(ramp_assay, do == 10 & rate %in% rates_wide)
path_wide  <- ramp_path(c("r0.025", "r0.1", "r0.3"), T_start = 14, rate = rates_wide,
                        t_max = 15.5 / rates_wide)
long_wide  <- build_hazard_data(ramp_wide, path_wide, observation = "continuous",
                                covariates = c("mass", "trial"))
fit_wide   <- fit_hazard(
  long_wide,
  m_form        = "exp",
  baseline      = "weibull",
  dpar_formulas = formulas,
  chains = 4, iter = 2000, warmup = 1000, cores = 4, seed = 20261006,
  control = list(adapt_delta = 0.95), backend = "cmdstanr"
)
```

This model also converged (all $\hat{R}$ below 1.01, all effective sample sizes above 400 and `r results$wide$convergence$n_divergent` divergent transitions), and the two checks of fit gave the same result as for the first model: the proportion of fish observed to survive was within the simulated band throughout the three ramps, and the residuals followed the uniform distribution with no trial standing out (not shown). From each of the two models we derive the quantities above and predict the survival of a 15 g fish along the 30 days of `stream_record`, as in the vignette [*Getting started with rTFT*](getting-started.html).

```{r}
#| label: wide-predict
#| eval: false
pred      <- predict_survival(fit, stream_record, newdata = data.frame(mass = 15))
pred_wide <- predict_survival(fit_wide, stream_record, newdata = data.frame(mass = 15))
```

The data were simulated, so the predictions can be compared with the survival that the model used to simulate them gives along the same record, which is obtained from the hazard accrued between consecutive readings.

```{r}
#| label: simulated-survival
# Survival under the model used to simulate the data, with the hazard accrued
# between consecutive readings taken at the temperature midway between them.
truth    <- attr(ramp_assay, "truth")
n        <- nrow(stream_record)
midpoint <- (stream_record$temp[-1] + stream_record$temp[-n]) / 2
accrued  <- exp(truth$log_a + truth$b * (midpoint - truth$T_ref)) *
  diff((stream_record$time / truth$scale)^truth$shape)
simulated_survival <- data.frame(time = stream_record$time,
                                 survival = c(1, exp(-cumsum(accrued))))
```

```{r}
#| label: wide-load
#| include: false
wide        <- results$wide
pred        <- results$prediction
pred_wide   <- wide$prediction
pred_static <- static$prediction
```

```{r}
#| label: tbl-span
#| echo: false
#| tbl-cap: "Values used in the simulation and their estimates (posterior median and 95% credible interval) from two assays of 120 fish that differ in the span of their ramp rates. All quantities are for a fish of 15 g."
# One row of estimates from each fit, as "median (lower–upper)".
both <- function(narrow, wide, digits = 2, scale = 1) {
  f <- paste0("%.", digits, "f (%.", digits, "f–%.", digits, "f)")
  c(sprintf(f, scale * narrow$median, scale * narrow$lower, scale * narrow$upper),
    sprintf(f, scale * wide$median, scale * wide$lower, scale * wide$upper))
}
param_wide <- function(q) wide$tolerance$params[wide$tolerance$params$quantity == q, ]
sct_wide   <- wide$tolerance$sctmax[wide$tolerance$sctmax$p == 0.5, ]
span_table <- rbind(
  both(param("b"), param_wide("b")),
  both(param("shape"), param_wide("shape")),
  both(param("z"), param_wide("z")),
  both(sct, sct_wide),
  both(dct[1, ], wide$dctmax[1, ]),
  both(dct[3, ], wide$dctmax[3, ]),
  both(pred$mortality, pred_wide$mortality, digits = 0, scale = 100),
  sprintf("%.0f", 100 * c(pred$mortality$mean, pred_wide$mortality$mean))
)
truth_mortality <- 1 - simulated_survival$survival[nrow(simulated_survival)]
knitr::kable(data.frame(
  Quantity  = c("b (per °C)", "k", "z (°C)", "sCTmax for 60 min (°C)",
                "dCTmax at 0.1°C per min (°C)", "dCTmax at 0.3°C per min (°C)",
                "Mortality along the stream record (%)",
                "Mortality along the stream record, posterior mean (%)"),
  Simulated = c(sprintf("%.2f", simulated[c(2:5, 6, 8)]), sprintf("%.0f", 100 * truth_mortality),
                sprintf("%.0f", 100 * truth_mortality)),
  `0.1, 0.2, 0.3 (3-fold)`   = span_table[, 1],
  `0.025, 0.1, 0.3 (12-fold)` = span_table[, 2],
  check.names = FALSE
), align = "lrrr")
```


```{r}
#| label: span-text
#| include: false
ci_width <- function(x) x$upper - x$lower
percent <- function(x) sprintf("%.0f", 100 * x)
mortality_static <- static$prediction$mortality
```

The 95% credible interval of $k$ was `r sprintf("%.1f", ci_width(param("shape")) / ci_width(param_wide("shape")))` times narrower with the 12-fold span than with the 3-fold span (@tbl-span), and so was that of $z$, which depends on $k$. The quantities that the ramps inform directly changed little: the intervals of $b$, of the sCTmax for 60 min and of the dCTmax had similar widths in the two assays.

The difference between the assays is largest for the prediction along the stream record, which extrapolates the model to exposures of days and therefore depends strongly on $k$ (@fig-span). The model used to simulate the data gives a mortality of `r percent(truth_mortality)`% along the record for a fish of 15 g in a typical trial. With the 3-fold span, the model predicted `r percent(pred$mortality$median)`% (95% credible interval: `r percent(pred$mortality$lower)`–`r percent(pred$mortality$upper)`%), and its credible band has survival decreasing from the first week of the record, when the stream was below 21°C and almost no fish fail under the model used to simulate the data. With the 12-fold span, it predicted `r percent(pred_wide$mortality$median)`% (`r percent(pred_wide$mortality$lower)`–`r percent(pred_wide$mortality$upper)`%), and survival decreased during the heat wave of the third week, as it does under that model. These values are posterior medians. The posterior means, which are the fractions of fish expected to fail with the uncertainty in the parameters taken into account, were `r percent(pred$mortality$mean)`% and `r percent(pred_wide$mortality$mean)`% (@tbl-span). The mean and the median differ most where the posterior is wide and skewed, as with the 3-fold span (see the vignette [*Comparing and averaging models*](comparing-and-averaging-models.html)). For comparison, the model fitted to the static assay in the vignette [*Getting started with rTFT*](getting-started.html) predicted `r percent(mortality_static$median)`% (`r percent(mortality_static$lower)`–`r percent(mortality_static$upper)`%).

```{r}
#| label: fig-span
#| fig-cap: "Stream temperature (top) and the probability of survival of a 15 g fish along it, predicted by the model fitted to the static assay of the vignette [*Getting started with rTFT*](getting-started.html) and by the models fitted to two ramping assays that differ in the span of their rates. Solid lines are posterior medians and bands are 95% credible intervals. Dashed lines show survival under the model used to simulate the data."
#| fig-height: 8
# pred_static is the prediction of the vignette Getting started with rTFT (there, pred)
panels <- c("Stream temperature (°C)",
            "Survival: static assay",
            "Survival: ramps at 0.1, 0.2 and 0.3°C per min (3-fold span)",
            "Survival: ramps at 0.025, 0.1 and 0.3°C per min (12-fold span)")
temperature <- data.frame(panel = panels[1], time = stream_record$time,
                          temp = stream_record$temp)
predicted <- rbind(
  data.frame(panel = panels[2], pred_static$survival),
  data.frame(panel = panels[3], pred$survival),
  data.frame(panel = panels[4], pred_wide$survival)
)

# Survival under the model used to simulate the data is drawn in each of the three
# panels of survival.
simulated_survival <- merge(data.frame(panel = panels[2:4]), simulated_survival)

# The three panels of survival share an axis from 0 to 1.
axis_ends <- data.frame(panel = rep(panels[2:4], each = 2), time = 0, survival = c(0, 1))

# The panels share one order in the four tables.
temperature$panel        <- factor(temperature$panel, levels = panels)
predicted$panel          <- factor(predicted$panel, levels = panels)
simulated_survival$panel <- factor(simulated_survival$panel, levels = panels)
axis_ends$panel          <- factor(axis_ends$panel, levels = panels)

ggplot(mapping = aes(x = time / 1440)) +
  geom_blank(data = axis_ends, aes(y = survival)) +
  geom_line(data = temperature, aes(y = temp), colour = "grey20") +
  geom_ribbon(data = predicted, aes(ymin = lower, ymax = upper),
              fill = "#225EA8", alpha = 0.2) +
  geom_line(data = predicted, aes(y = median), colour = "#225EA8") +
  geom_line(data = simulated_survival, aes(y = survival), linetype = "dashed",
            colour = "grey20") +
  facet_wrap(vars(panel), ncol = 1, scales = "free_y") +
  labs(x = "Time (days)", y = NULL) +
  theme_light()
```

This comparison rests on one pair of assays that share eight of their twelve trials, so the difference between them comes from the four trials that were replaced, and part of it may be due to chance. The direction of the difference is nevertheless the one expected from the argument above: a wider span informs $k$ better, and predictions for long exposures depend on $k$.

A wide span has a cost when it is obtained with a slow ramp. At 0.025°C per min from 14°C, half of the fish fail after `r sprintf("%.1f", (simulated_dctmax(0.025) - 14) / 0.025 / 60)` h (against `r sprintf("%.1f", (simulated_dctmax(0.3) - 14) / 0.3 / 60)` h at 0.3°C per min) under the model used to simulate the data, and an exposure of several hours to rising temperatures may allow individuals to acclimate during the trial.

### Temperatures and exposure times informed by a ramping assay

```{r}
#| label: informed-text
#| include: false
failed      <- ramp[ramp$event == 1, ]
slow        <- subset(ramp_assay, do == 10 & rate == 0.025 & event == 1)
slow$ctmax  <- 14 + slow$rate * slow$t_event
```

A wide span improves the predictions for long exposures, but it does not make them precise. Even with the 12-fold span, the 95% credible interval of the mortality along the stream record was `r percent(pred_wide$mortality$lower)`–`r percent(pred_wide$mortality$upper)`% (@tbl-span).

The reason is that a ramping assay exposes every individual to a wide range of temperatures but informs the model over a much narrower one. The fish warmed at the three faster rates started at 14°C, failed at `r sprintf("%.1f", min(failed$ctmax))`–`r sprintf("%.1f", max(failed$ctmax))`°C and had been exposed for `r sprintf("%.0f", min(failed$t_event))`–`r sprintf("%.0f", max(failed$t_event))` min when they failed. The fish warmed at 0.025°C per min failed at `r sprintf("%.1f", min(slow$ctmax))`–`r sprintf("%.1f", max(slow$ctmax))`°C after `r sprintf("%.1f", min(slow$t_event) / 60)`–`r sprintf("%.1f", max(slow$t_event) / 60)` h. For cooler temperatures, either assay shows only that the fish did not fail during the time that they spent at them. The stream record never exceeded `r sprintf("%.1f", max(stream_record$temp))`°C and lasted 30 days, so a prediction along it extrapolates the failure rate function to temperatures below those at which most fish failed and the baseline to exposures far longer than the ramps. The static assay, in contrast, held fish at 23°C for five days, and therefore included both the temperatures of the heat wave and exposures of days.

`predict_survival()` reports both kinds of extrapolation in the table `coverage`, and prints a message when either occurs.

```{r}
#| label: tbl-coverage
#| tbl-cap: "Coverage of the stream record by the two ramping assays: the temperatures at which fish failed in each assay, the share of the record (by duration and by the risk accrued along it) below those temperatures, the longest exposure in the assay, and the share of the record that comes after it."
coverage <- rbind(pred$coverage, pred_wide$coverage)
knitr::kable(data.frame(
  `Ramp rates (°C per min)` = c("0.1, 0.2, 0.3", "0.025, 0.1, 0.3"),
  coverage[c("Ta_failed_min", "Ta_failed_max", "below", "risk_below",
             "tau_max", "beyond", "risk_beyond")],
  check.names = FALSE
), digits = 2)
```

The record lay entirely below the temperatures at which fish failed with the 3-fold span, and `r percent(pred_wide$coverage$below)`% of it (with `r percent(pred_wide$coverage$risk_below)`% of the predicted risk) lay below them with the 12-fold span, in which the slowest ramp had fish failing at lower temperatures (@tbl-coverage). In both assays nearly all of the record, and of the risk predicted along it, came after the longest exposure of the assay (`r sprintf("%.0f", pred$coverage$tau_max)` and `r sprintf("%.0f", pred_wide$coverage$tau_max)` min, respectively).

The two kinds of extrapolation are smaller for a warm period than for the whole record. Predictions are more often needed for such a period (e.g., a heat wave), and a part of the record is given to `predict_survival()` in the same way, with its times counted from its first reading so that the exposure starts with the period.

```{r}
#| label: warm-code
#| eval: false
heat_wave      <- subset(stream_record, day %in% 16:20)
heat_wave$time <- heat_wave$time - heat_wave$time[1]
predict_survival(fit, heat_wave, newdata = data.frame(mass = 15))$mortality
```

```{r}
#| label: tbl-warm
#| echo: false
#| tbl-cap: "Probability (%) that a 15 g fish fails by the end of the whole stream record, of the five days of its heat wave (days 16 to 20) and of its warmest day (day 18), predicted by the models fitted to the static assay and to the two ramping assays (posterior medians and 95% credible intervals, with posterior means in brackets), with the value given by the model used to simulate the data. For the two shorter periods, the exposure is counted from their first reading."
# Mortality under the model used to simulate the data over the days given, with the
# exposure counted from the first reading.
simulated_mortality <- function(days) {
  part     <- stream_record[stream_record$day %in% days, ]
  n        <- nrow(part)
  midpoint <- (part$temp[-1] + part$temp[-n]) / 2
  elapsed  <- part$time - part$time[1]
  accrued  <- exp(truth$log_a + truth$b * (midpoint - truth$T_ref)) *
    diff((elapsed / truth$scale)^truth$shape)
  1 - exp(-sum(accrued))
}
warm <- results$warm_periods
warm_static <- static$warm_periods$weibull
cell <- function(x) {
  sprintf("%.0f\u00a0(%.0f–%.0f)\u00a0[%.0f]", 100 * x$median, 100 * x$lower, 100 * x$upper,
          100 * x$mean)
}
knitr::kable(data.frame(
  Assay = c("Static", "Ramps at 0.1, 0.2, 0.3 (3-fold)", "Ramps at 0.025, 0.1, 0.3 (12-fold)",
            "Model used to simulate the data"),
  `30 days` = c(cell(pred_static$mortality), cell(pred$mortality), cell(pred_wide$mortality),
                sprintf("%.0f", 100 * simulated_mortality(1:30))),
  `Heat wave, 5 days` = c(cell(warm_static$heat_wave), cell(warm$narrow$heat_wave),
                          cell(warm$wide$heat_wave),
                          sprintf("%.0f", 100 * simulated_mortality(16:20))),
  `Warmest day, 24 h` = c(cell(warm_static$warmest_day), cell(warm$narrow$warmest_day),
                          cell(warm$wide$warmest_day),
                          sprintf("%.0f", 100 * simulated_mortality(18))),
  check.names = FALSE
), align = "lrrr")
```

```{r}
#| label: warm-text
#| include: false
day_medians  <- c(warm_static$warmest_day$median, warm$narrow$warmest_day$median,
                  warm$wide$warmest_day$median)
wave_medians <- c(warm_static$heat_wave$median, warm$narrow$heat_wave$median,
                  warm$wide$heat_wave$median)
all_medians  <- c(pred_static$mortality$median, pred$mortality$median,
                  pred_wide$mortality$median)
range_of <- function(x) sprintf("%.0f–%.0f", 100 * min(x), 100 * max(x))
```

The three assays agreed better, and were closer to the value given by the model used to simulate the data, the shorter the period (@tbl-warm). Their predictions were `r range_of(all_medians)`% for the 30 days (`r percent(simulated_mortality(1:30))`% under the model used to simulate the data), `r range_of(wave_medians)`% for the heat wave (`r percent(simulated_mortality(16:20))`%) and `r range_of(day_medians)`% for the warmest day (`r percent(simulated_mortality(18))`%). The span of the rates mattered for the shorter periods too. For the warmest day, the 95% credible interval was `r interval(warm$wide$warmest_day, 0, 100)`% with the 12-fold span and `r interval(warm$narrow$warmest_day, 0, 100)`% with the 3-fold span, for which even one day is about ten times longer than the longest exposure of the assay.

A prediction for a part of the record counts the exposure from the start of that part, and so assumes that the days before it left no effect on the fish. Like the predictions for the whole record, it also assumes that there is no recovery during the cooler hours of each night. The model used to simulate the data makes the same assumption, so the agreement of a prediction with the simulated value says nothing about whether the assumption holds (see the vignette [*Getting started with rTFT*](getting-started.html)).

### Recommendations

When a ramping assay is intended to support more than the comparison of CTmax at the rates that were run, we recommend: (i) choosing the slowest and the fastest ramp rates as far apart as is practical, because the span of the rates determines how well the effect of the time under exposure is estimated; and (ii) adding static trials at the temperatures and durations that the intended predictions refer to, because a ramping assay alone leaves predictions for long exposures at lower temperatures imprecise even with a wide span. Static and ramping trials can be analysed in a single model, with the individuals of both in one table (see `observation = "mixed"` in `?build_hazard_data`).

::: {.callout-note}
## Precomputed results

The models in this vignette were fitted on `r results$stamp$date` with the development version of `rTFT` that became 0.1.0 (`r results$stamp$version`), by the script `vignettes/_precompute.R` in the package's repository. The vignette displays the saved summaries, and all other code runs when the vignette is built. The values for the static assay are those saved for the vignette [*Getting started with rTFT*](getting-started.html) (fitted on `r static$stamp$date` with version `r static$stamp$version`).
:::

## References

Jørgensen, L. B., Malte, H., Ørsted, M., Klahn, N. A. and Overgaard, J. (2021). A unifying model to estimate thermal tolerance limits in ectotherms across static, dynamic and fluctuating exposures to thermal stress. *Scientific Reports* 11, 12840. [https://doi.org/10.1038/s41598-021-92004-6](https://doi.org/10.1038/s41598-021-92004-6){target="_blank"}

Kingsolver, J. G. and Umbanhowar, J. (2018). The analysis and interpretation of critical temperatures. *Journal of Experimental Biology* 221, jeb167858. [https://doi.org/10.1242/jeb.167858](https://doi.org/10.1242/jeb.167858){target="_blank"}

Rezende, E. L., Castañeda, L. E. and Santos, M. (2014). Tolerance landscapes in thermal ecology. *Functional Ecology* 28, 799–809. [https://doi.org/10.1111/1365-2435.12268](https://doi.org/10.1111/1365-2435.12268){target="_blank"}
