API Reference

Module

Pipeline Entry Point

InVitroInVivoCorrelation.run_ivivc — Function
run_ivivc(pk_input, diss_input; kwargs...)

Top-level IVIVC pipeline runner.

Accepted inputs:

  • pk_input: either AbstractDataFrame or Vector{TimeValueProfile}.
  • diss_input: one of:
    • AbstractDataFrame (default linear interpolation),
    • DissolutionFit from fit_dissolution (parametric smoothing of the data),
    • AbstractDict mapping formulation => (times, values_or_callable): your own curves, any vector callable returning fractions.

The correlation fit is controlled by correlation_model, parameter_specs, param, constantcoef, n_starts, rng and multistart_cube, passed on to fit_ivivc; validation_formulation holds one formulation out for external validation.

Returns

A NamedTuple with the fitted model, each stage's objects and tables, and the prediction-error summaries:

  • model: the fitted IVIVCModel — coef, coeftable, predict_rate.
  • doses: the resolved DoseTable; times: the prediction grid.
  • uir_profiles, absorption_profiles, absorption_source, predicted_profiles: the stage objects — one unit impulse response per subject, the fraction-absorbed profiles, their mean curve per formulation (a Tables.jl source), and the predicted absorption per formulation.
  • pk_df, dissolution_df, uir_df, absorption_df, absorption_mean_df, predicted_df, predicted_conc_df, observed_mean_conc_df, predicted_vs_observed_conc_df: the same as long-format DataFrames, in the canonical column names below.
  • pe_summary_df, pe_calibration_df: %PE on AUC and Cmax for the held-out formulation and for the calibration formulations.
  • skipped: one (; id, stage, reason) record for every subject a stage excluded.

DataFrame dissolution input gets a default linear interpolant per formulation (Interpolations.jl, with linear extrapolation). Dissolution values are fractions dissolved (0–1): percent data must be divided by 100 first (values above 1.5 are rejected).

Input column names (id_col, formulation_col, pk_time_col, …) only describe the input frames. The result's DataFrames always use the canonical column names :id and :formulation (plus :time and per-frame value columns such as :conc, :diss, :fraction_absorbed), so the plot recipes work on any result without threading the input names through.

observed_mean_conc_df — and the %PE frames built from it — is the mean of the per-subject interpolated concentration profiles on the pooled grid, inside the window every subject covers (the same definition as the absorption mean). Subjects sampled at their own actual times are therefore averaged correctly instead of per time point.

Units

Inputs may carry units (Unitful.jl or DynamicQuantities.jl). Units are captured once at profile/curve construction, the pipeline computes on plain magnitudes, and the result DataFrames are re-attached in the units the data arrived in. Commensurable mixes convert (a times grid in minutes against hour data, a dose in grams among milligrams); incommensurable units and bare/united mixes error rather than compare raw magnitudes. Within one curve source every formulation must share one time unit and one value unit, and dissolution and absorption times must both carry units or both be bare.

Doses

Supply doses per row via dose_col and/or per formulation via doses. Both sources are unioned into one DoseTable (returned as result.doses; result.pk_df gains a :dose column with the resolved per-row values): overlapping entries must agree — there is no precedence, and a contradiction throws. Subjects may carry different doses within a formulation (weight-based dosing); each subject is scaled by their own reference-to-formulation ratio, in deconvolution and prediction alike. Where one formulation-level dose is needed — a subject without a reference dose of their own (mean_uir_fallback), or a subject predicted at a formulation they never received — it must be unanimous across subjects; differing doses are an ArgumentError, never averaged. Doses recorded in different units that agree to within 1e-9 relative count as equal. When any dose information is present, coverage must be total: an unresolvable dose (including a predict-only formulation without a doses entry) is an ArgumentError naming the holes, never a silent ratio of 1. With no dose information anywhere, the run is unscaled and fractions are relative to the reference-dose input.

Data Input / Output

InVitroInVivoCorrelation.profiles_from_dataframe — Function
profiles_from_dataframe(df; id_col, time_col, value_col, formulation_col, label) -> Vector{TimeValueProfile}

Convert a long-format DataFrame into per-subject profiles, grouped by (id_col, formulation_col). Rows with missing values in any key column are dropped with a warning, except for missing ids, which error.

UIR Fitting

InVitroInVivoCorrelation.fit_uir — Function
fit_uir(profiles; fast_formulation, n_exp = :auto, strip_ka = false, time_lag = false) -> Vector{<:NamedTuple}

Batch-fit a unit impulse response (UIR) for each subject from a collection of concentration–time profiles.

For each unique subject ID, the profile matching fast_formulation (typically IV bolus or instant-release) is fit to the poly-exponential model

\[h(t) = \sum_{m=1}^{M} A_m e^{-\alpha_m t}, \qquad t \ge 0,\]

by minimising the sum of squared residuals against the observed concentrations (box-constrained BFGS via Optim.jl with exact ForwardDiff.jl gradients), yielding a PolyExponential. Subjects without a matching profile are skipped silently and fitting failures emit a warning; pass a skipped sink to receive a record of every excluded subject.

Arguments

  • profiles::AbstractVector{<:TimeValueProfile}: Concentration–time profiles (multiple subjects, multiple formulations).

Keyword arguments

  • fast_formulation: Name of the reference (fast-release) formulation to fit against.
  • n_exp::Union{Int,Symbol} = :auto: Number of exponential terms; pass an integer to fix it, or :auto to select from 1–3 terms by small-sample corrected AIC (AICc). A candidate with k ≥ N - 1 parameters on N samples is inadmissible and skipped; if that leaves no candidate (very short profiles), the fit fails asking for an explicit n_exp.
  • strip_ka::Bool = false: If true, fit an oral absorption model (with a first-order absorption rate constant Ka) and strip Ka to return only the disposition UIR.
  • time_lag::Bool = false: If true (requires strip_ka = true; enforced with an ArgumentError), include an absorption lag time parameter.
  • skipped::Union{Nothing,AbstractVector} = nothing: Optional sink; every skipped subject pushes a (; id, stage, reason) record, so callers can account for exclusions instead of relying on log warnings.
Flip-flop kinetics

strip_ka breaks the absorption/disposition symmetry by assuming absorption is the faster process (Ka above every disposition rate constant), which holds for a true immediate-release reference. Under flip-flop kinetics (Ka < ke) the terminal slope instead reflects absorption, and strip_ka returns the absorption rate as if it were the disposition UIR, corrupting the subsequent deconvolution and prediction. Flip-flop is not identifiable from a single oral profile (the model is symmetric in Ka and the disposition rate); diagnose it from non-parallel oral/IV terminal slopes, and prefer an IV bolus/infusion reference (or a known disposition) when it is suspected.

Dose scaling

Concentrations scale linearly with dose. Fitting to IV bolus concentrations at a reference dose $D_{\mathrm{ref}}$ (not unit-normalised) yields $h_{\mathrm{ref}}(t) = D_{\mathrm{ref}}\, h(t)$; deconvolve against it estimates an input rate per unit of $D_{\mathrm{ref}}$, so when the extravascular dose equals $D_{\mathrm{ref}}$ the cumulative input is directly the fraction absorbed. This pairing is the explicit contract behind the DoseTable: the fitted kernel's amplitudes are at the subject's own reference dose, and the table's reference_formulation names exactly that administration, so deconvolve can rescale each subject's cumulative input by their own $D_{\mathrm{ref}} / D_{\mathrm{formulation}}$ ratio.

Returns

A Vector of (; id, formulation, uir) NamedTuples, one per successfully fitted subject.

See also: PolyExponential, deconvolve

InVitroInVivoCorrelation.PolyExponential — Type
PolyExponential(coefficients, exponents, concentration_unit = 1.0, time_unit = 1.0)

A sum-of-exponentials unit impulse response, $h(t) = \sum_m A_m e^{-\alpha_m t}$, stored as two equal-length vectors: coefficients (amplitudes $A_m$) and exponents (rate constants $\alpha_m \ge 0$). Produced by fit_uir and consumed by convolve / deconvolve, which document the convolution model and dose handling. The amplitudes and rate constants are plain magnitudes; concentration_unit and time_unit are the unit witnesses of the data the UIR was fit to (1.0 for unitless data), so exponents are per time_unit and convolve can convert an incoming time grid into the kernel's own unit and reattach concentration_unit on the way out.

uir = PolyExponential([10.0, 5.0], [0.5, 0.1])   # h(t) = 10e^{-0.5t} + 5e^{-0.1t}

Deconvolution & Convolution

InVitroInVivoCorrelation.deconvolve — Function
deconvolve(profiles, uir_profiles; fast_formulation, kwargs...) -> (results, mean_source)

Extract cumulative fraction-absorbed profiles by numerical deconvolution.

For each non-reference concentration profile, the function solves a linear system to recover the piecewise-linear input rate that, when convolved with the subject's UIR, reproduces the observed concentrations. The cumulative input (fraction absorbed) is obtained by trapezoidal integration of the recovered rate.

When dose information is present (profile doses and/or the doses keyword, resolved through one DoseTable), the deconvolved cumulative input is scaled by the subject's own D_ref / D_formulation ratio to produce true fraction absorbed. A dose that cannot be resolved where one is needed is an ArgumentError — partial dose information is never silently treated as a ratio of 1.

Arguments

  • profiles::AbstractVector{<:TimeValueProfile}: Concentration–time profiles for all subjects and formulations.
  • uir_profiles: Fitted UIRs (one (; id, formulation, uir) per subject), typically from fit_uir.

Keyword arguments

  • fast_formulation: Reference formulation name — profiles with this formulation are skipped (they were used to fit the UIR).
  • times::Union{Nothing,AbstractVector} = nothing: Time grid on which the deconvolved fraction-absorbed is reported. nothing uses each profile's observed times; an explicit vector is bounds-checked against the observed range.
  • mean_uir_fallback::Bool = false: If true, subjects without a subject-specific UIR use the mean UIR (terms ordered by exponent, averaged over all available UIRs with the most common number of exponential terms). Useful for parallel study designs where not all subjects have IV data.
  • skipped::Union{Nothing,AbstractVector} = nothing: Optional sink; every skipped (subject, formulation) pair pushes a (; id, stage, reason) record, so callers can account for exclusions instead of relying on log warnings. A subject without a fitted UIR is logged only when no sink is given.
  • doses::Union{Nothing,AbstractDict,DoseTable} = nothing: Dose bookkeeping for the fraction-absorbed scaling. A DoseTable is used as-is (run_ivivc passes its table so both consumers share one bookkeeping); an AbstractDict of formulation-level doses is UNIONED with the doses carried by the profiles through the same constructor (overlaps must agree — there is no precedence); nothing uses the profile doses alone. A dose that cannot be resolved where one is needed is an ArgumentError — partial dose information is never silently treated as a ratio of 1.

Returns

A tuple (results, mean_source):

  • results::Vector{TimeValueProfile} — one fraction-absorbed profile per (subject, formulation) pair.
  • mean_source — the mean fraction-absorbed curve per formulation: an opaque object to hand to fit_ivivc and the plots. It is a Tables.jl table (formulation, time, value), so DataFrame(mean_source) writes it out.

See also: fit_uir, convolve, PolyExponential

InVitroInVivoCorrelation.convolve — Function
convolve(time::AbstractVector{<:Number}, input_rate::AbstractVector{<:Number}, uir::PolyExponential) -> AbstractVector{<:Number}

Predict concentrations from an input rate profile via convolution.

Model

Let (I(t)) be an input rate (amount/time) and let the impulse response be a poly-exponential:

\[h(t) = \sum_{m=1}^{M} A_m e^{-\alpha_m t}, \qquad t \ge 0.\]

The concentration at time (T) is modeled by the convolution integral:

\[C(T) = \int_0^T I(\tau)\, h(T-\tau)\, d\tau = \sum_{m=1}^{M} A_m \int_0^T I(\tau) e^{-\alpha_m (T-\tau)}\, d\tau.\]

Discretization / evaluation

This function assumes (I(t)) is piecewise linear on the provided grid time. For each interval ([tk,t{k+1}]) and each exponential term, the segment contribution can be written as:

\[\int_{t_k}^{t_{k+1}} I(\tau)\, A_m e^{-\alpha_m (T-\tau)}\, d\tau = c_s I_k + c_e I_{k+1},\]

where ((cs,ce)) are the closed-form coefficients for a piecewise-linear input and an exponential kernel (see deconvolve for explicit formulas). Summing over segments and exponential terms yields (C(T)).

Arguments

  • time::AbstractVector{<:Number}: Time points (ti) (expected sorted, nonnegative); may carry units, in which case they are CONVERTED into the kernel's own `uir.timeunit` (a grid in a different unit of time is converted; a bare grid against a unit-fitted kernel — or vice versa — is an error, never a raw-magnitude mixup).
  • input_rate::AbstractVector{<:Number}: Input rate values (I(t_i)).
  • uir::PolyExponential: Impulse response parameters ((Am,\alpham)).

Returns

  • AbstractVector{<:Number}: Predicted concentrations (C(ti)), carrying the `inputrate × uir.timeunit × uir.concentrationunit` unit when the inputs are unit-aware (so a rate per unit dose convolved with a unit-carrying UIR lands back in concentration units).

Example

uir = PolyExponential([10.0, 5.0], [0.5, 0.1])
times = [0.0, 0.5, 1.0, 2.0, 4.0, 8.0]
input_rate = [0.0, 50.0, 40.0, 20.0, 5.0, 0.0]
predicted_conc = convolve(times, input_rate, uir)

See also: deconvolve (the inverse operation), PolyExponential.

IVIVC Model Fitting & Prediction

InVitroInVivoCorrelation.fit_ivivc — Function
fit_ivivc(diss, abs_source, correlation_model; kwargs...) -> IVIVCModel

Fit a correlation model relating dissolution to absorption by multi-start box-constrained BFGS, minimising the sum of squared residuals between predicted and observed absorption across the shared formulations.

diss is the dissolution as you hold it: a long-format DataFrame with formulation, time and diss columns, a Dict(formulation => (times, values_or_callable)) of your own curves, or a DissolutionFit. abs_source is the mean absorption curves returned by deconvolve.

correlation_model acts only as a form tag — its field values are ignored; initial values and bounds come from default_parameter_specs(correlation_model) (or parameter_specs), adjusted by param and constantcoef. The returned IVIVCModel wraps a fitted instance of the same type whose fields hold the fitted values.

Keyword arguments

  • parameter_specs: Override the model's default_parameter_specs by name: a partial list replaces only the parameters it names, verbatim, under the unit rule of param (parameter_specs = [ParameterSpec(:time_scale, 1.0, 0.5, 2.0)]). A spec's fixed flag holds the parameter constant.
  • param::NamedTuple = (;): Initial values by parameter name, e.g. param = (; time_scale = 0.8); unnamed parameters keep their default start. time_shift carries the absorption data's time unit when the data do ((; time_shift = 0.5u"hr")) and is a bare number otherwise; dimensionless parameters are always bare numbers. A value outside the parameter's bounds is an error.
  • constantcoef::Tuple = (): Parameters held constant at their initial value (default or param), by name, e.g. constantcoef = (:time_shift,). The tuple is the full set; parameter_specs entries with fixed = true are constant as well.
  • times::Union{Nothing,AbstractVector} = nothing: Time grid for residuals (nothing uses the observed absorption times; an explicit vector is bounds-checked).
  • validation_formulation: Hold this formulation out of fitting for leave-one-out external validation.
  • n_starts::Int = 5: Number of optimizer starts: the first from the spec initials, the rest sampled within the bounds — except time-dimensioned parameters (time_shift), restarted within a tenth of the observed time span around their start, and optimized in span-normalized coordinates, so the default fit does not depend on the unit of time.
  • rng::AbstractRNG, multistart_cube: Control the multi-start sampling; a multistart_cube entry (Dict(:time_shift => (-1.0, 1.0))) replaces the default range, under the unit rule of param ((-1u"hr", 1u"hr") on unit-carrying data).

See also: IVIVCModel, predict_rate.

InVitroInVivoCorrelation.IVIVCModel — Type
IVIVCModel

A fitted IVIVC correlation, returned by fit_ivivc.

Fields

  • correlation_model::AbstractCorrelationModel: The fitted correlation model; its fields hold the fitted parameter values (magnitudes in the units of the fitting data).
  • used_formulations::Vector{String}: Formulations used to calibrate the model.
  • constant::Vector{Symbol}: Parameters held constant during the fit.
  • absorption_unit: Unit witness reattached to predicted fractions by predict_rate.
  • time_unit: Unit witness of the absorption time grids the model was fitted against — the unit its time-dimensioned parameters (and predicted times) are expressed in.

Read the fitted values with coef(model) and coeftable(model); they are also reachable directly as properties (e.g. model.time_scale), forwarded to the underlying correlation_model. Use predict_rate to predict in vivo absorption for new formulations.

StatsAPI.coef — Method
coef(model::IVIVCModel) -> NamedTuple
coeftable(model::IVIVCModel) -> DataFrame

The fitted correlation parameters: by field name, or as a table with columns parameter, constant (held fixed during the fit) and estimate, the layout of Pumas' coeftable. Time-dimensioned parameters (time_parameters; time_shift for the affine model) carry the absorption data's time unit when the data did.

StatsAPI.coeftable — Method
coeftable(fit::DissolutionFit) -> DataFrame

One row per formulation: formulation, then one column per parameter of the fitted model in field order (finf, mdt, b, int, tlag for a Weibull). The parameters in fit.constant were held fixed.

InVitroInVivoCorrelation.predict_rate — Function
predict_rate(model::IVIVCModel, diss; formulation = nothing) -> Vector{<:NamedTuple}
predict_rate(model::IVIVCModel, diss_t, diss_f) -> (times, rate, frac_absorbed)

Predict in vivo absorption from dissolution using a fitted IVIVCModel.

The first form predicts for every formulation in diss — a DataFrame, a Dict of curves or a DissolutionFit, as accepted by fit_ivivc — that was not used to fit model (or only formulation, when given), returning one (; formulation, time, pred_rate, pred_fraction_absorbed) NamedTuple per formulation — an empty vector when every dissolution formulation was used for calibration. The second, lower-level form operates on raw dissolution time/fraction vectors and returns the predicted (times, rate, frac_absorbed).

The predicted rate is convolved with a subject UIR (via convolve) to obtain a predicted concentration profile.

See also: fit_ivivc.

Custom Correlation Models

InVitroInVivoCorrelation.AbstractCorrelationModel — Type
AbstractCorrelationModel

Abstract supertype for IVIVC correlation models. A correlation model defines the functional relationship between in vitro dissolution and in vivo absorption through a time mapping and a dissolution-to-absorption mapping.

Concrete subtypes store their parameters as struct fields and implement default_parameter_specs, correlation_vivo_to_vitro_time, correlation_vitro_to_vivo_time, and correlation_dissolution_to_absorption; a model with a time-dimensioned parameter also declares it through time_parameters. A subtype needs a no-argument constructor; an instance passed to fit_ivivc is a form tag (its field values are placeholders), and fitting returns an instance of the same type whose fields hold the fitted values.

See also: AffineTimeLinearModel, fit_ivivc.

InVitroInVivoCorrelation.AffineTimeLinearModel — Type
AffineTimeLinearModel(abs_scale, abs_intercept, time_scale, time_shift)
AffineTimeLinearModel()

Level A correlation model with an affine in vivo→in vitro time mapping and a linear dissolution-to-absorption relationship:

  • time mapping: t_vitro = time_scale · t_vivo + time_shift
  • absorption mapping: f_abs = abs_scale · f_diss + abs_intercept

The four parameters are stored as fields and hold the fitted values on the instance returned by fit_ivivc. An instance passed into fitting is only a form tag — its field values are placeholders; initial values and bounds come from default_parameter_specs (or an explicit parameter_specs). The no-argument constructor fills the standard placeholder initials.

InVitroInVivoCorrelation.ParameterSpec — Type
ParameterSpec(name, init, lower = -Inf, upper = Inf; fixed = false)

Specification for one model parameter during fitting: its starting value and box.

Arguments

  • name::Symbol: Parameter name; must match a field of the model.
  • init: Initial value; must lie within [lower, upper].
  • lower, upper: Box constraints applied by the optimizer.
  • fixed::Bool: If true, the parameter is held at init and not optimized. When no explicit bounds are given, a fixed parameter is pinned to init (lower = upper = init).

Values carry the data's units, under the same rule as a times grid: a time-dimensioned parameter (mdt, tmax, tlag, time_shift, …) of unit-carrying data takes quantities — ParameterSpec(:mdt, 5u"hr", 0u"hr", 10u"hr") — and a bare number is an error there; bare data take bare numbers; dimensionless parameters (finf, b, time_scale, …) are always bare numbers. The three values may be in different units of one dimension; they are stored in the unit of init.

See also: default_parameter_specs, fit_ivivc, fit_dissolution.

InVitroInVivoCorrelation.default_parameter_specs — Function
default_parameter_specs(model) -> Vector{ParameterSpec}
default_parameter_specs(model, abs_source) -> Vector{ParameterSpec}
default_parameter_specs(model, t, y) -> Vector{ParameterSpec}

Return the ParameterSpec list (initial values and bounds) a fit starts from when no parameter_specs are given. The specs name the model's fields, in field order.

The one-argument form serves correlation models (AbstractCorrelationModel), whose defaults do not depend on the data. For AffineTimeLinearModel they are abs_scale = 1, abs_intercept = 0, time_scale = 1 in [0.1, 10] (a dimensionless vivo-to-vitro ratio: a Level A correlation presumes the dissolution test and the body run on comparable clocks, so a fit that lands on either bound is a warning about the data rather than a reason to widen the box, which parameter_specs nevertheless can) and time_shift = 0, unbounded; fit_ivivc restarts time_shift within a tenth of the observed time span. Custom correlation models add a method (AbstractCorrelationModel). The two-argument form takes the absorption object deconvolve returns and hands the time-dimensioned specs (time_parameters) back in its time unit, so on unit-carrying data the list can be edited and passed back as parameter_specs; the one-argument form is bare.

Dissolution models (AbstractDissolutionModel) read their starting values off the profile being fitted, so they take that profile's observed times t and fractions y and return exactly the specs fit_dissolution uses for it; with unit-carrying t the time-dimensioned specs come back in that unit. Their bounds carry no fixed scale: fractions in [0, 1], the plateau up to the curve-source ceiling of 1.5, characteristic times and the lag positive with no upper bound, shapes in [0.1, 10].

InVitroInVivoCorrelation.time_parameters — Function
time_parameters(model) -> Vector{Symbol}

The parameters of model that carry the time dimension of the data: [:time_shift] for AffineTimeLinearModel; mdt, mdt1, mdt2, tmax and tlag for the dissolution models. A fit converts these between the data's time unit and magnitudes (the unit rule of param and parameter_specs), normalizes their optimizer coordinates by the observed time span and draws their restarts relative to it, so the result does not depend on whether the data arrived in hours or minutes. time_scale is not one: a vivo-to-vitro ratio, dimensionless as long as both axes share a unit.

A custom correlation model with a time-dimensioned parameter adds a method (time_parameters(::MyModel) = [:my_shift]); the default is none.

Dissolution Models

InVitroInVivoCorrelation.AbstractDissolutionModel — Type
AbstractDissolutionModel

Abstract supertype for parametric dissolution models: HillDissolution, WeibullDissolution, DoubleWeibullDissolution and MakoidBanakarDissolution.

A model instance is callable on a vector (or scalar) of plain time magnitudes and returns fractions dissolved, so a fitted model drops into the formulation => (times, callable) dictionary every entry point accepts — that is what fit_dissolution builds on. Parameters are stored as struct fields and hold the fitted values on the models fit_dissolution returns; an instance passed into fitting is only a form tag (its field values are placeholders — the no-argument constructor fills the standard ones), with initial values and bounds coming from default_parameter_specs or an explicit parameter_specs.

All four models share two optional parameters, held constant at zero by default (constantcoef = (:int, :tlag) in fit_dissolution): int, the intercept (fraction already dissolved at $t = 0$), and tlag, a lag time applied as the step $\tau = \max(t - t_{\mathrm{lag}}, 0)$. Time magnitudes are in whatever unit the model was fitted in.

InVitroInVivoCorrelation.HillDissolution — Type
HillDissolution(finf, mdt, b, int, tlag)
HillDissolution()

Hill (sigmoid $E_{\max}$) dissolution model,

\[F(t) = \mathrm{int} + (F_\infty - \mathrm{int})\,\frac{\tau^{b}}{\mathrm{MDT}^{b} + \tau^{b}}, \qquad \tau = \max(t - t_{\mathrm{lag}}, 0),\]

with finf the fraction dissolved at infinite time, mdt the time at which half of it has dissolved, b the slope factor, and the shared int/tlag of AbstractDissolutionModel. Fit it with fit_dissolution.

InVitroInVivoCorrelation.WeibullDissolution — Type
WeibullDissolution(finf, mdt, b, int, tlag)
WeibullDissolution()

Weibull dissolution model,

\[F(t) = \mathrm{int} + (F_\infty - \mathrm{int})\left(1 - e^{-(\tau / \mathrm{MDT})^{b}}\right), \qquad \tau = \max(t - t_{\mathrm{lag}}, 0),\]

with finf the fraction dissolved at infinite time, mdt the time at which 63.2 % of it ($1 - 1/e$) has dissolved, b the shape factor, and the shared int/tlag of AbstractDissolutionModel. Fit it with fit_dissolution.

InVitroInVivoCorrelation.DoubleWeibullDissolution — Type
DoubleWeibullDissolution(finf, frac1, mdt1, b1, mdt2, b2, int, tlag)
DoubleWeibullDissolution()

Sum of two Weibull terms for biphasic release,

\[F(t) = \mathrm{int} + (F_\infty - \mathrm{int})\left[ f_1 \left(1 - e^{-(\tau / \mathrm{MDT}_1)^{b_1}}\right) + (1 - f_1) \left(1 - e^{-(\tau / \mathrm{MDT}_2)^{b_2}}\right)\right], \qquad \tau = \max(t - t_{\mathrm{lag}}, 0),\]

with frac1 the fraction of the release attributed to the first term, mdt1/b1 and mdt2/b2 the two terms' scale and shape, finf the fraction dissolved at infinite time, and the shared int/tlag of AbstractDissolutionModel. The two terms are interchangeable (swapping them with frac1 ↦ 1 - frac1 gives the same curve), so compare fitted curves rather than individual parameters. Fit it with fit_dissolution.

InVitroInVivoCorrelation.MakoidBanakarDissolution — Type
MakoidBanakarDissolution(fmax, tmax, b, int, tlag)
MakoidBanakarDissolution()

Makoid-Banakar dissolution model,

\[F(t) = \begin{cases} \mathrm{int} + (F_{\max} - \mathrm{int})\,(\tau / T_{\max})^{b}\, e^{\,b\,(1 - \tau / T_{\max})} & \tau \le T_{\max} \\ F_{\max} & \tau > T_{\max} \end{cases}, \qquad \tau = \max(t - t_{\mathrm{lag}}, 0),\]

which rises to fmax at time tmax and stays there; b is the slope factor, and int/tlag are the shared parameters of AbstractDissolutionModel. Fit it with fit_dissolution.

InVitroInVivoCorrelation.fit_dissolution — Function
fit_dissolution(diss_df, model; formulation_col = :formulation, time_col = :time, value_col = :diss, kwargs...) -> DissolutionFit

Smooth dissolution data by fitting the parametric model — a form tag such as WeibullDissolution() — to each formulation by least squares. The returned DissolutionFit holds one fitted model per formulation and the fitted curves on their reporting grid. Pass the fit to run_ivivc (or fit_ivivc) in place of the raw data; read the parameters back with coef, coeftable or dissolution_model, and the goodness of fit with rss.

Each formulation is fitted separately to its observed (time, fraction) points by multi-start box-constrained BFGS (Optim.jl, exact ForwardDiff.jl gradients). Every observation counts: replicates at one time point are fitted, not averaged first, so rss and nobs describe the observations. A formulation needs more distinct time points than free parameters. The fitted curves are reported on the distinct times.

Keyword arguments

  • param::NamedTuple = (;): Initial values by parameter name, e.g. param = (; mdt = 6.0). Time-dimensioned values follow the data's units like a times grid does — a quantity ((; mdt = 6u"hr")) for unit-carrying data, a bare number for bare data — and dimensionless ones (finf, b, …) are bare numbers. Unnamed parameters start from default_parameter_specs(model, t, y): the plateau (finf/fmax) at the observed maximum, the characteristic time (mdt, mdt1/mdt2, tmax) read off the profile, b = 1, int = tlag = 0. A value outside the parameter's bounds is an error.
  • constantcoef::Tuple = (:int, :tlag): Parameters held constant at their initial value (default or param), by name. The tuple is the full set: constantcoef = (:finf, :int, :tlag) also holds the plateau — at its default start, the observed maximum, unless param = (; finf = 1.0) says otherwise — and constantcoef = (:int,) estimates the lag.
  • parameter_specs: Override the defaults by name with ParameterSpecs — the way to change bounds. A partial list touches only the parameters it names (parameter_specs = [ParameterSpec(:finf, 1.0, 0.0, 1.2)]), verbatim, under the unit rule of param; a spec's fixed flag holds it constant, and a named parameter drops out of the default constant set unless constantcoef lists it.
  • times: Grid the fitted curves are reported on (their observed_times, which predict_rate and the plots evaluate). Defaults to each formulation's own observed times. Unlike everywhere else in the package the grid is not bounds-checked: a parametric curve may deliberately be sampled past the data.
  • n_starts = 5, rng: Multi-start control. Restarts are drawn around each start — times within a factor 4, shapes between 0.3 and 3, the plateau within 20 % — never over the (partly unbounded) box.

Units

mdt/tmax/tlag come back from coef and coeftable in the unit of the table's time column — as quantities when the column carries units — so coef(fit, f) can be passed back as param. The fitted model structs and the fit's curves hold magnitudes in that unit; values are bare fractions.

See also: AbstractDissolutionModel, DissolutionFit.

InVitroInVivoCorrelation.DissolutionFit — Type
DissolutionFit{M<:AbstractDissolutionModel}

The result of fit_dissolution: one fitted model of type M per formulation, carrying the fitted curves on their reporting grid. Pass the fit wherever dissolution goes in — run_ivivc, fit_ivivc, predict_rate, the plots. It is also a Tables.jl table of its curves (formulation, time, diss), so DataFrame(fit) writes them out in the form the pipeline reads. Public fields:

  • formulations: sorted formulation names — the order of every vector below;
  • models: the fitted model per formulation;
  • constant: the parameters held constant during the fit;
  • rss, nobs, converged: residual sum of squares, number of observations fitted (replicates included) and optimizer convergence per formulation.

Read it back with coef, coeftable, rss, nobs (the StatsAPI functions, as in Pumas) and dissolution_model.

StatsAPI.coef — Method
coef(fit::DissolutionFit, formulation) -> NamedTuple
coef(fit::DissolutionFit) -> NamedTuple

The fitted parameters of one formulation, by field name (finf, mdt, b, …). Time- dimensioned parameters (mdt, tlag, …) carry the data's time unit when the data did, so the result goes straight back into a fit as param. The one-argument form is for a fit with a single formulation.

StatsAPI.coeftable — Method
coeftable(fit::DissolutionFit) -> DataFrame

One row per formulation: formulation, then one column per parameter of the fitted model in field order (finf, mdt, b, int, tlag for a Weibull). The parameters in fit.constant were held fixed.

StatsAPI.rss — Method
rss(fit::DissolutionFit) -> Vector{Float64}
rss(fit::DissolutionFit, formulation) -> Float64

Residual sum of squares of the fitted curve against the data — per formulation, in fit.formulations order, or for one formulation. The number to compare model forms by.

StatsAPI.nobs — Method
nobs(fit::DissolutionFit) -> Vector{Int}
nobs(fit::DissolutionFit, formulation) -> Int

Number of (time, fraction) points each formulation was fitted to.

InVitroInVivoCorrelation.dissolution_model — Function
dissolution_model(fit::DissolutionFit, formulation) -> AbstractDissolutionModel

The fitted model for formulation: a callable mapping times to fractions dissolved (see AbstractDissolutionModel). Its fields are magnitudes in the fit's time unit; coef returns the same values with that unit attached.

Dissolution Metrics

InVitroInVivoCorrelation.f1 — Function
f1(reference, test)

Compute the f1 difference factor between a reference and test dissolution profile.

Values of f1 close to zero indicate similarity; the FDA considers f1 < 15 as evidence of profile similarity.

Formula

\[f_1 = \frac{\sum_{i=1}^{n} |R_i - T_i|}{\sum_{i=1}^{n} R_i} \times 100\]

where $R_i$ and $T_i$ are percent dissolved at time point $i$ for the reference and test formulations, respectively.

Arguments

Both reference and test can be:

  • Vector{<:Real} — matched dissolution values at the same time points,
  • TimeValueProfile — dissolution values are extracted from the profiles. Both profiles must have identical time grids, as required by FDA guidance.
InVitroInVivoCorrelation.f2 — Function
f2(reference, test)

Compute the f2 similarity factor between a reference and test dissolution profile.

An f2 value between 50 and 100 indicates that the two dissolution profiles are similar. An f2 of 50 corresponds to a 10% mean difference at all time points; f2 = 100 means the profiles are identical.

Formula

\[f_2 = 50 \times \log_{10}\!\left\{ \left[1 + \frac{1}{n}\sum_{i=1}^{n}(R_i - T_i)^2\right]^{-0.5} \times 100 \right\}\]

where $R_i$ and $T_i$ are percent dissolved at time point $i$ for the reference and test formulations, respectively.

FDA conditions for valid f2 comparison

The FDA guidance requires:

  • At least 3 dissolution time points (excluding zero).
  • No more than one time point beyond 85% dissolved for either profile.
  • The same time points for reference and test.

This implementation warns when these conditions are violated but still computes the value, since users may have legitimate reasons to use f2 outside these constraints.

Arguments

Both reference and test can be:

  • Vector{<:Real} — matched dissolution values at the same time points,
  • TimeValueProfile — dissolution values are extracted from the profiles. Both profiles must have identical time grids, as required by FDA guidance.

Validation

InVitroInVivoCorrelation.compute_prediction_errors — Function
compute_prediction_errors(df; kwargs...) -> DataFrame

Compute prediction error percentages (%PE) on AUC and Cmax for each formulation by comparing predicted and observed concentration profiles via NCA.

%PE follows the FDA convention (observed - predicted) / observed × 100, so a positive value means the model under-predicts. Values are returned signed to preserve the bias direction; the FDA IVIVC acceptance criterion applies to absolute values (mean |%PE| ≤ 10% and individual |%PE| ≤ 15% for AUC and Cmax).

Unit-carrying columns are converted into the PREDICTED column's unit before the comparison (a differently-united observed column converts; incommensurable units error), NCA runs on plain magnitudes, and the AUC/Cmax output columns are re-attached in the input units. pe_auc/pe_cmax are plain numbers.

Returns a DataFrame with columns: formulation, auc_predicted, auc_observed, pe_auc, cmax_predicted, cmax_observed, pe_cmax.

Data Types

InVitroInVivoCorrelation.TimeValueProfile — Type
TimeValueProfile{TU,VU}

A single time-value profile for one subject and formulation (e.g., a plasma concentration curve or a dissolution profile).

This struct is the package's unit boundary: input data may carry units from Unitful.jl or DynamicQuantities.jl, but the constructor converts every element into the unit of the FIRST element of its column and stores plain Float64 magnitudes plus one unit witness per axis. Everything downstream computes on the magnitudes; units re-enter only where results are written out (via addunit with the stored witnesses).

Fields

  • id: Subject or replicate identifier (untyped — accepts String, Int, Symbol, etc.). Use a single id type per dataset; mixing types (e.g. Int and String ids in one study) is unsupported.
  • formulation::String: Formulation name.
  • time::Vector{Float64}: Sorted, deduplicated time magnitudes, in time_unit.
  • value::Vector{Float64}: Measured value magnitudes, in value_unit.
  • dose::Union{Nothing, Number}: Dose amount for this formulation (may carry units), or nothing if unspecified. Kept as given — dose bookkeeping normalizes doses jointly, against one study-wide dose unit, not per profile.
  • time_unit::TU, value_unit::VU: Unit witnesses (oneunit quantities; 1.0 for bare input).

The outer constructor accepts unsorted input with duplicates, non-finite entries, and mixed-but-commensurable units; incommensurable elements within one column are an error.

A profile is a Tables.jl table with columns id, formulation, time and value (units re-attached), so DataFrame(profile) and CSV.write(path, profile) work; a vector of profiles is written with reduce(vcat, DataFrame.(profiles)).

Dose Bookkeeping

InVitroInVivoCorrelation.DoseTable — Type
DoseTable

The single owner of dose information in the pipeline. Built once by _build_dose_table from the doses carried on profiles (fed by dose_col) and the formulation-level doses keyword; every consumer (deconvolution scaling, predicted-concentration scaling) resolves doses through the same accessors.

Doses are stored as plain Float64 magnitudes in one study-wide dose_unit witness, so every dose ratio is a plain division — two magnitudes in the table are commensurable by construction.

Semantics (no precedence, no fallbacks):

  • Per-subject entries ((id, formulation), from profiles) and formulation-level entries (from the doses keyword) are UNIONED; where both cover the same cell they must agree, or construction throws.
  • Subjects sharing a formulation may carry different doses (weight-based dosing); each subject is resolved individually.
  • A dose that cannot be resolved where one is needed is an ArgumentError naming the exact holes — never a silent ratio of 1. A study with no dose information anywhere has no table (nothing) and no scaling.
  • Dose agreement is decided with a relative tolerance of 1e-9: two magnitudes converted from different units (0.005 g and 5 mg) may differ by an ulp.
  • Where ONE formulation-level dose is needed — a subject without a reference dose of their own (mean-UIR fallback), or a subject predicted at a formulation they never received — it must be unanimous across subjects. Differing subject doses are an ArgumentError; no mean is ever taken.
InVitroInVivoCorrelation.dose_ratio — Function
dose_ratio(t::DoseTable, id, formulation)::Float64

The reference-to-formulation dose ratio that scales subject id's deconvolved cumulative input (or predicted concentration) — reference_dose / dose(id, formulation) — as a plain Float64. Throws an ArgumentError naming the hole when either dose cannot be resolved: partial dose information is an error, never a silent ratio of 1.

InVitroInVivoCorrelation.prediction_dose_ratio — Function
prediction_dose_ratio(t::DoseTable, id, formulation)::Float64

The formulation-to-reference dose ratio that scales subject id's predicted concentration — dose(id, formulation) / reference_dose (the inverse of dose_ratio's deconvolution sense). Predictions are counterfactual — a subject need not have received the formulation — so a subject without their own recorded dose uses the formulation-level dose, which must be unanimous across subjects (differing subject doses are an ArgumentError; no mean is taken). A formulation with no dose anywhere is still an ArgumentError naming the hole.

InVitroInVivoCorrelation.reference_dose — Function
reference_dose(t::DoseTable, id)

The dose of subject id's own reference administration. A subject without a reference profile of their own (the mean-UIR-fallback case) uses the study's formulation-level reference dose, which must be unanimous across subjects: when subjects' reference doses differ there is no single dose to pair the mean UIR with, and an ArgumentError is thrown — no mean is taken. (The former mean was the mean of the DISTINCT values, i.e. 150 mg for nine subjects at 100 mg and one at 200 mg.)

Unit Conversion

InVitroInVivoCorrelation.value_in — Function
value_in(witness, x)::Float64

The magnitude of x expressed in the unit of witness — a oneunit quantity, or 1.0 for bare data. This is the package's single unit-conversion primitive: it converts commensurable units, and it throws for incommensurable dimensions and for bare/united mixes. It never relabels a magnitude; every combination of two possibly-differently-united values must go through it rather than through raw-magnitude stripping + arithmetic (which is correct only when the units happen to match).

The vector method returns a Vector{Float64} (the bare same-typed vector is passed through unchanged when the witness is 1.0).

Plots

InVitroInVivoCorrelation.dissolution_profiles — Function
dissolution_profiles(diss_df; formulation_col = :formulation, time_col = :time, value_col = :diss)
dissolution_profiles(diss_curves)
dissolution_profiles(diss_fit)
dissolution_profiles(result)

Plot fraction dissolved against time, one colored scatter-line per formulation.

Accepts a long-format DataFrame, a Dict(formulation => (times, values_or_callable)) of curves, a DissolutionFit (its fitted curves on their reporting grid), or a run_ivivc result (which plots result.dissolution_df). Times must be strictly increasing within each formulation; replicate vessels measured at shared nominal times must be averaged into one profile first.

InVitroInVivoCorrelation.absorption_profiles — Function
absorption_profiles(abs_source)
absorption_profiles(abs_df; formulation_col = :formulation, time_col = :time, value_col = :fraction_absorbed, id_col = nothing)
absorption_profiles(result; kind = :mean, id_col = :id)

Plot fraction absorbed against time, one colored scatter-line per formulation (and per subject when an id column is in play).

For a run_ivivc result, kind selects the source: :mean (the per-formulation mean curves), :individual (per-subject curves from result.absorption_df, grouped by id_col), :predicted (model-predicted fraction absorbed), or :predicted_rate (the predicted absorption rate).

InVitroInVivoCorrelation.concentration_profiles — Function
concentration_profiles(profiles)
concentration_profiles(pk_df; id_col = :id, formulation_col = :formulation, time_col = :time, value_col = :conc)
concentration_profiles(result; kind = :observed, formulation = nothing)

Plot concentration–time curves, one line per (formulation, subject) series; reference formulations ("IV", "IR") are drawn in black.

Accepts a vector of TimeValueProfile, a long-format DataFrame, or a run_ivivc result. For a result, kind selects :observed (the input PK data), :predicted (model-predicted concentrations), or :validation (observed and predicted overlaid per formulation, optionally restricted to one formulation).

InVitroInVivoCorrelation.deconvolution_fit — Function
deconvolution_fit(observed_profiles, calculated_profiles)

Overlay observed concentrations (scatter) with the concentrations reconstructed from the deconvolved input (lines), one facet per formulation and one line per subject. Profiles are matched on (subject id, formulation); observed profiles without a calculated counterpart are dropped.

InVitroInVivoCorrelation.ivivc_correlation — Function
ivivc_correlation(diss, abs_source; model = nothing, times = nothing, show_identity = true)
ivivc_correlation(diss_df, abs_df; abs_id_col = nothing, kwargs...)
ivivc_correlation(result; abs_kind = :mean, kwargs...)

diss is the dissolution as you hold it — a long-format DataFrame (formulation, time, diss), a Dict(formulation => (times, values_or_callable)) of curves or a DissolutionFit — and abs_source the mean absorption curves from deconvolve.

Scatter fraction absorbed against fraction dissolved per formulation, with the fitted correlation line (when a model is given) and the identity line.

With a model, absorption times are mapped through the fitted time transformation before the dissolution curve is read. Passing abs_id_col (df form) or abs_kind = :individual (result form) draws every subject's points instead of the per-formulation mean.

InVitroInVivoCorrelation.levy_plot — Function
levy_plot(diss, abs_source; percentages = [0.25, 0.50, 0.75, 0.90])
levy_plot(diss_df, abs_df; abs_id_col = nothing, kwargs...)
levy_plot(result; abs_kind = :mean, kwargs...)

diss is the dissolution as you hold it — a long-format DataFrame (formulation, time, diss), a Dict(formulation => (times, values_or_callable)) of curves or a DissolutionFit — and abs_source the mean absorption curves from deconvolve.

Lévy plot: for each requested percentage, the time to reach it in vitro against the time to reach it in vivo, per formulation, with the identity line. Points on the identity indicate a 1:1 time scale between dissolution and absorption.

Passing abs_id_col (df form) or abs_kind = :individual (result form) draws one point set per subject instead of the per-formulation mean.

InVitroInVivoCorrelation.prediction_vs_observed — Function
prediction_vs_observed(...; show_identity = true, show_bounds = true)

Scatter predicted against observed concentrations per formulation, with the identity line and ±10% / ±20% acceptance bounds. Points inside the bounds indicate predictions within the corresponding relative error.

Accepts prediction/observation pairs as vectors, DataFrames, or a run_ivivc result (which plots result.predicted_vs_observed_conc_df).