Skip to content

Build an interval specification from a description of the analysis - #622

Merged
billdenney merged 4 commits into
mainfrom
claude/pknca-interval-table
Aug 26, 2026
Merged

Build an interval specification from a description of the analysis#622
billdenney merged 4 commits into
mainfrom
claude/pknca-interval-table

Conversation

@billdenney

Copy link
Copy Markdown
Member

Closes #157.

pknca_interval_table() takes when the interval runs, how the drug was given,
how often, and what was collected, and returns the interval specification that
describes it:

pknca_interval_table(0, 24, dosing = "single", route = "extravascular")
#>   auclast aucinf.obs aucpext.obs cl.obs cmax tmax count_conc half.life tlag
#>   impute = start_predose_conc0
Context Parameters Imputation
single, extravascular AUClast, AUCinf.obs, %extrap, CL, Cmax, tmax, count, t½, tlag start_predose_conc0
single, IV bolus same minus tlag, plus c0 on its own row start_predose_conc0; c0 gets none
single, IV infusion plus ceoi start_predose_conc0
single, continuous infusion no ceoi start_predose_conc0
steady state AUCint.last, AUCint.inf.obs, CL.int.inf.obs, Ctrough, … start_predose
multiple, not steady state same start_cmin
urine Ae, Fe, CLr, volume none
sparse sparse AUClast + SE none

pknca_presets() gives named argument sets (bioequivalence, first_in_human,
mass_balance, …); explicit arguments override a preset.

Why a specification can have more than one row

For an IV bolus, c0 is calculated without imputation while everything else
uses it. Before the first dose a predose measurement is contamination rather
than the concentration at the dose, and pk.calc.c0() returns an observed
nonzero concentration at the dose time in preference to back-extrapolating. An
end-to-end test asserts c0 from the generated specification equals c0
calculated with no imputation at all.

The imputation pairing rules replace the per-parameter impute_conflict field
the plan originally called for. An experiment across every parameter and every
imputation method (1180 interactions) found that almost none of the conflicts
are context-independent — c0 with a shifted predose is correct at steady
state and wrong for a first dose, and so on. Since this function chooses both
the parameters and the imputation, it can simply not pair them, which handles
the context-dependent cases correctly and needs no new registry field.

Three classification errors this exposed

None were caught by the 50 existing classification checks:

  • aucint.inf.obs/aucint.inf.pred were single-dose only. The "inf" names the
    extrapolation used for the tail, not the end of the interval — over a bounded
    interval they give AUCtau. Steady state was left with no interval AUC at all.
  • sparse_auc_se and the other diagnostics produced alongside a sparse AUC were
    not flagged sparse in the registry, because pk.nca() only needs the flag to
    route a calculation. They leaked into every dense context. Now classified
    through what they depend on.
  • The AUMC parameters had no basis to be built on, because the basis named only
    areas, so the mean residence times and steady-state volumes were unreachable.

Regression tests added for the first two.

Reachability

174 of 207 parameters are reachable across the 96-context grid. The 33 that are
not are the AUCall family, pinned as an exact list: whether AUCall differs from
AUClast depends on values below the limit of quantification after the last
measurable one, which is a property of the data rather than of the analysis, so
no context chooses it. include = "aucall" reaches it, and a test shows that.

The sweep needed a cache to run at all — 96 contexts × 28 dependency-closure
walks timed out. Basis families are cached alongside the classification and
dropped together when the registry changes.

80 new tests; full suite passes.

🤖 Generated with Claude Code

billdenney and others added 3 commits August 26, 2026 09:08
pknca_interval_table() takes when the interval runs, how the drug was
given, how often, and what was collected, and returns the interval
specification that describes it.  Given only a start, an end, dosing, and
route it chooses the parameters usually reported for that context, the
AUC they are built on, and the imputation to calculate them from.

Three things follow from the context rather than being asked for.  Which
AUC a bounded interval uses:  a single dose is summarized by AUClast and
the extrapolation to infinity, a repeated dose by the AUCint family,
which interpolates at both interval boundaries.  Whether concentrations
are back-extrapolated to the time of a bolus.  And which imputation
applies, together with the parameters that must be kept out of it.

That last is why a specification can have more than one row per interval.
For an intravenous bolus, c0 is calculated without imputation while
everything else uses it:  before the first dose a predose measurement is
contamination rather than the concentration at the dose, and c0 returns
an observed nonzero concentration at the dose time in preference to
back-extrapolating.

Fixes three classification errors that building on it exposed, none of
which the existing checks caught:

* aucint.inf.obs and aucint.inf.pred were single-dose only.  The "inf" in
  the name is the extrapolation used for the tail, not the end of the
  interval; over a bounded interval they give AUCtau, so they apply to
  any dosing pattern.  Steady state was left with no interval AUC.
* The standard errors and degrees of freedom produced alongside a sparse
  AUC were not flagged sparse in the registry, because pk.nca() only
  needs the flag to route a calculation.  They are now classified through
  what they depend on.
* The AUMC parameters had no basis to be built on, because the basis
  named only areas, so the mean residence times and steady-state volumes
  were unreachable.

174 of 207 parameters are reachable across the context grid.  The 33 that
are not are the AUCall family:  whether AUCall differs from AUClast
depends on values below the limit of quantification after the last
measurable one, which is a property of the data rather than of the
analysis, so no context chooses it and it is reached by name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bioavailability compares two administrations, and renal clearance needs
an amount excreted from one matrix together with a plasma AUC from
another.  One interval cannot supply what either needs, so neither should
be chosen automatically for it.

`selection` gains a `secondary` element, declared on f and the three
renal clearances and propagated through the dependency walk to their
dose-normalized forms.  pknca_interval_table() leaves them out of what a
context gives while `include` still reaches them by name, so a urine
interval now offers the amount excreted, the fraction excreted, and the
collected volume, and renal clearance is asked for.

Fraction excreted is deliberately not marked:  it needs the amount
excreted and the dose, both of which come from one profile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread R/interval-table.R Outdated
interval_starts <- rep(start, length.out = max(length(start), length(end)))
interval_ends <- rep(end, length.out = max(length(start), length(end)))
long <-
do.call(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is overly nested. Simplify it.

Comment thread R/interval-table.R Outdated
first_in_human =
list(
dosing = "single", route = "extravascular",
include = c("clast.obs", "tlast", "span.ratio", "lambda.z.n.points")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove lambda.z.n.points from here

Comment thread R/interval-table.R Outdated
mass_balance =
list(
dosing = "single", route = "extravascular", sample_type = "interval",
include = c("excretion_rate")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the extra c()

Comment thread R/interval-table.R Outdated
#' pknca_presets()$bioequivalence
#' @family Interval specifications
#' @export
pknca_presets <- function() {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move pknca_preset_definitions to just be this function unless there is a clear reason to have two functions.

* Build the long form with rep() rather than stacking a data frame per
  interval through do.call/lapply/rbind.  The parameters are the same for
  every interval, so there is nothing to iterate over, and the row index
  interval_wider() groups on becomes a rep() instead of a match() on
  pasted keys.
* Drop lambda.z.n.points from the first-in-human preset.
* Drop a c() around a single string.
* Fold pknca_preset_definitions() into pknca_presets().  The two existed
  only because the internal accessor was written first and then wrapped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@billdenney
billdenney merged commit 1af72f5 into main Aug 26, 2026
8 checks passed
@billdenney
billdenney deleted the claude/pknca-interval-table branch August 26, 2026 14:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Simplify interval definitions

1 participant