Skip to contents

RsNLME package logo

Introduction

A metamodel (file with a .mmdl extension) simplifies the integration of RsNLME with Pirana. It acts like a container for all the information needed to run a Pharmacometric Modeling Language (PML) model. PML is the language that powers both Phoenix NLME and RsNLME. Think of a metamodel as similar to a NONMEM control file.

This guide explains metamodels and demonstrates how they can be used for conveniently storing, fitting, and exploring PML models. To understand this, we need to first look at how PML models are run in Phoenix NLME and RsNLME.

Assumptions:

  • Both your input data and PML model files are in your current working directory.
  • The directory containing the NLME Engine is specified in the INSTALLDIR environment variable. You can verify this with the following R commands:
  # Load the RsNLME package
  library(Certara.RsNLME)
  # check if the env.variable is set correctly
  Sys.getenv("INSTALLDIR")

These assumptions will hold throughout this document.

PML Models in Phoenix NLME

If you’ve used Phoenix NLME before, you’ll recognize that PML models in Phoenix are saved as .mdl files. These files contain the model’s statements, equations, and assignments. But they don’t specify things like the dataset to use, how columns should be mapped to model variables, or what tables should be generated as output (see the example below, which follows the syntax described in the linked documentation).


    test(){
        cfMicro(A1, Cl / V)
        dosepoint(A1)
        C = A1 / V
        error(CEps = 0.1)
        observe(CObs = C * (1 + CEps))
        stparm(V = tvV * exp(nV))
        stparm(Cl = tvCl * exp(nCl))
        fixef(tvV = c(, 5, ))
        fixef(tvCl = c(, 1, ))
        ranef(diag(nV, nCl) = c(1, 1))
    }

Phoenix stores all this information (model, data, mappings) within the project itself. The NLME executables only access this information during the model run. However, if you want to run PML models from the command line, you need to provide separate files for data, column definitions (linking data columns to model variables), and any additional input options (like ADDL and SS) along with the engine arguments:

%INSTALLDIR%\runNLME.bat 5 100 OneCpt_IVInfusion.mdl columnMapping.txt OneCpt_IVInfusionData.csv

PML Models in RsNLME

Certara.RsNLME also allows you to run PML models from the command line. You’ll need to provide the input data and PML model files to create a model object. If column mappings aren’t automatically detected, you’ll need to specify them. You can also provide additional input options (like ADDL, SS, Reset, MDV, and infusion). Here’s an example:

PMLModelCodeOutput  <-" 
    test(){
        cfMicro(A1, Cl / V)
        dosepoint(A1)
        C = A1 / V
        error(CEps = 0.1)
        observe(CObs = C * (1 + CEps))
        stparm(V = tvV * exp(nV))
        stparm(Cl = tvCl * exp(nCl))
        fixef(tvV = c(, 5, ))
        fixef(tvCl = c(, 1, ))
        ranef(diag(nV, nCl) = c(1, 1))
    } "
  PMLModelCodeFile <- file.path(tempdir(TRUE), "OneCpt_IVInfusion.mdl")
  writeLines(PMLModelCodeOutput, PMLModelCodeFile)
  # for the input data description please refer to ?OneCpt_IVInfusionData
  # Create the model object
  model <- textualmodel(modelName = "OneCpt_IVInfusion",
                        mdl = PMLModelCodeFile,
                        data = OneCpt_IVInfusionData)

  # Manually map the un-mapped model variables
  model <- colMapping(model, mappings = c(id = "Subject", A1 = "Dose"))
  
  # Add infusion information for dosing compartment A1
  model <- addInfusion(model, "A1", isDuration = TRUE, colName = "Duration")

As you can see, the model object (an instance of the NlmePmlModel class) holds information about the model code, column mappings, and data — all in a binary format. Metamodels simplify this by combining these elements into a single, human-readable text file.

Metamodel Overview

Metamodels are structured with different blocks, each starting with a double number sign (##) followed by the block name. Comments within metamodels use the same syntax as PML models: # or // for single-line comments and /* ... */ for multi-line comments.

Metamodel Blocks:

  • Author:

    • Specifies the metamodel’s author (e.g., ##Author: User).
    • This block is optional and not used during model estimation.
  • Description:

    • Used by Pirana to display a description of the metamodel’s purpose (e.g., ##Description: PK model for Drug X).
    • This block is optional and not used during model estimation.
  • Based on:

    • Indicates the name of a reference metamodel, used by Pirana for building reference trees (e.g., ##Based on: BasePKModel.mmdl).
    • This block is optional and not used during model estimation.
  • DATA:

    • Required: Specifies the path to the input data file. Both absolute and relative paths are allowed (e.g., ##DATA ./data.csv).
    • By default, the data.table::fread() function (with default settings) is used to load the data. When a ##PRE block is present, the data file is still loaded initially for validation, but the PRE script’s returned data.frame replaces it before model construction.
  • PRE:

    • Specifies the path to an R preprocessing script that transforms the input data before model construction. Both absolute and relative paths are allowed (resolved the same way as ##DATA).
    • The script receives the resolved data file path through a ##DATA token: any occurrence of the literal string ##DATA in the script text is replaced with the absolute path to the data file before execution.
    • The script must return a data.frame as its last expression. If the return value is not a data.frame, an error is raised.
    • The script is evaluated in a fresh environment that has access to the current R session’s loaded packages and search path. Additional packages can be loaded inside the script via library() or require() in the usual way. Top-level assignments inside the script stay local to the PRE evaluation and do not modify the user’s global workspace.
    • This block is optional. When present, it should be placed right after ##DATA.
    • Example (metamodel with preprocessing):
        ## DATA mydata.csv
        ## PRE preprocess.R
        ## MAP id=ID time=TIME CObs=DV WT

    Where preprocess.R contains:

        dat <- read.csv("##DATA")
        dat$WT[dat$WT == -99] <- median(dat$WT[dat$WT != -99])
        dat
  • MAP:

    • Defines mappings between model variables and data columns using the = sign (e.g., variableName=columnName).
    • If a mapping isn’t provided for a model variable, it’s assumed that the variable name and column name are the same (e.g., CObs is equivalent to CObs=CObs).
    • Special Variables: This block can also map special variables not explicitly present in the model:
      • id: Required for population models. Maps up to five data columns (separated by commas) to identify individual subject profiles. If not mapped, the model is treated as individual.
      • time: Required for time-based models. Maps the time variable.
      • dosingCompartmentName_Rate: Indicates that the specified dosing compartment involves infusion, with rate information provided in the mapped column.
      • dosingCompartmentName_Duration: Indicates that the specified dosing compartment involves infusion, with duration information provided in the mapped column.
      • SS: Indicates that the mapped column contains a steady-state flag. Translated to the sscol statement in the column definition file.
      • SSOffSet: Indicates that the mapped column contains the SS offset. Only applicable if SS is also mapped. Translated to the ssoffcol statement.
      • ADDL: Indicates that the mapped column contains an additional identical dose flag. Translated to the addlcol statement.
      • II: Represents the inter-dose interval. Must be mapped if either SS or ADDL is used. Translated to the iicol statement.
      • MDV: Indicates that the mapped column contains missing data values (MDV). Rows with non-zero numeric values in this column will be ignored.
      • Reset: Indicates that the mapped column contains a reset flag. If the value in the reset column is not zero, time is allowed to be reset on that row.
    • Categorical Covariates: For categorical covariates (defined using fcovariate or covariate with an empty parenthesis), you can define label names for each category value if the mapped data column is of character type. Label names are specified in round brackets after the column name (e.g., Sex = Gender(Male = 0, Female = 1)).
  • DOSING CYCLE:

    • Provides an alternative way to define ADDL or SS dosing cycles for specific dosing compartments. The syntax is:
      • For SS: SS = [COL] Dosepoint = [CMT] Amount = [NUM/COL] Delta = [NUM/COL] Duration = [NUM/COL] Rate = [NUM/COL]
      • For ADDL: ADDL = [COL] Delta = [NUM/COL] Dosepoint = [CMT] Amount = [NUM/COL] Duration = [NUM/COL] Rate = [NUM/COL]
    • Where:
      • [COL] is the column name containing the ADDL/SS flag.
      • [CMT] is the dosing compartment name from the model.
      • [NUM/COL] is either a column name or a numeric value.
      • Delta represents the inter-dose interval.
  • COLDEF:

    • Defines column definitions using the syntax described here.
    • Useful when column definitions can’t be defined through ##MAP or ##DOSING CYCLE.
    • You can use both ##MAP and ##COLDEF; definitions from both blocks will be combined.
    • Example (defining all column definitions):
        ##COLDEF 
        id("id")
        time("time")
        dose(A1<-"dose")
        covr(sex<-"sex"("male" = 0, "female" = 1))
        covr(wt<-"wt")
        obs(CObs<-"conc") 
  • MODEL:

    • Required: Contains the PML model code. Refer to the “Modeling Syntax” documentation for details.
  • ESTARGS:

    • Specifies engine arguments using the syntax of the engineParams() function in the Certara.RsNLME package. Arguments are separated by commas or spaces.
    • If not provided, default values are used.
    • Example:
        ##ESTARGS
        method = "QRPEM", 
        iSample = 1200,
        maxStepsODE = 50000000, 
        mapAssist = 1,
        ODE = "AutoDetect",
        numIterations = 0
    • You can define multiple ESTARGS blocks; they will be executed sequentially, with each run using the final estimates from the previous run.
  • SIMARGS:

    • Specifies arguments for model simulation:
      • numReplicates: Number of simulation replicates (default: 100).
      • seed: Random number generator seed (default: 1234).
      • sort, ODE, rtolODE, atolODE, maxStepsODE: See the engineParams() documentation.
    • Multiple ESTARGS and SIMARGS blocks are supported and applied sequentially (all ESTARGS runs first, followed by all SIMARGS runs).
  • TABLES:

    • Defines additional output tables (note: a posthoc.csv table with structural parameters and covariates at each data row is created by default).
    • Use the table statement syntax documented here.
    • Example:
        ##TABLES
        table(file="table01.csv", time(0,10,seq(2,8,0.1)), 
              dose(A1), covr(BW), obs(Conc), BW, C, cObs, V, Ke) 
    • Tables can also be defined in the ##COLDEF block. If defined in both, all tables will be included in the column definition file.

Metamodel Example

Let’s create a simple metamodel called OneCpt_IVInfusion.mmdl.

## Description: A one-compartment model with IV infusion

# The model is fitted using the default FOCE-ELS engine.
# Note: the default values for the relevant NLME engine arguments are chosen based on the model, type ?engineParams for details.

## DATA OneCpt_IVInfusion.csv
## MAP id = Subject time = Time A1 = Dose A1_Rate = Rate CObs

## MODEL
test(){
    cfMicro(A1, Cl / V)
    dosepoint(A1)
    C = A1 / V
    error(CEps = 0.1)
    observe(CObs = C * (1 + CEps))
    stparm(V = tvV * exp(nV))
    stparm(Cl = tvCl * exp(nCl))
    fixef(tvV = c(, 5, ))
    fixef(tvCl = c(, 1, ))
    ranef(diag(nV, nCl) = c(1, 1))
}

## ESTARGS
numIterations = 1 # one iteration only
stdErr = "None" # no standard error estimation requested

Explanation of Blocks:

  • Description: Provides a brief description of the model.
  • DATA: Specifies that the input data is in the OneCpt_IVInfusion.csv file (in the current directory).
  • MAP: Maps model variables to data columns:
    • id = Subject: Maps the individual identifier to the Subject column.
    • time = Time: Maps the time variable to the Time column.
    • A1 = Dose: Maps the amount administered to dosing compartment A1 to the Dose column.
    • A1_Rate = Rate: Maps the infusion rate for dosing compartment A1 to the Rate column (if rate is zero or missing, a bolus dose is assumed).
    • CObs: Maps the observed concentration to the CObs column (this is shorthand for CObs = CObs).
  • MODEL: Defines a one-compartment population model with clearance parameterization.
  • ESTARGS: Sets specific engine arguments:
    • numIterations = 1: Limits the optimization to a single iteration.
    • stdErr = "None": Disables standard error calculation.

Running a Metamodel

You can run a metamodel using the run_metamodel() function. Here’s how to run the example metamodel locally without parallelization (if you don’t specify a host and MPI is available, it will automatically parallelize over 4 threads; otherwise, it will run on a single core).

Note: We’ll use the directoryToRun argument to create a new subfolder named OneCpt_IVInfusion in your working directory, where the model output files will be stored.

  host <- hostParams(parallelMethod = "None",
                     hostName = "local",
                     numCores = 1)
  
  OneCpt_IVInfusionFile <-
    system.file("vignettesdata/OneCpt_IVInfusion.mmdl",
                package = "Certara.RsNLME",
                mustWork = TRUE)
  
  OneCpt_IVInfusionResults <-
    run_metamodel(mmdlfile = OneCpt_IVInfusionFile,
                  directoryToRun = "OneCpt_IVInfusion",
                  host = host)
  print(OneCpt_IVInfusionResults$Overall)
#>    Scenario RetCode    LogLik     -2LL      AIC      BIC nParm  nObs  nSub
#>      <char>   <int>     <num>    <num>    <num>    <num> <int> <int> <int>
#> 1: WorkFlow       4 -1085.769 2171.538 2181.538 2204.294     5   700   100
#>    EpsShrinkage Condition
#>           <num>    <lgcl>
#> 1:      0.12159        NA

Loading a Metamodel for Other Run Modes

If you want to perform a different type of run (e.g., bootstrap), you need to load the metamodel into R using the read_mmdl() function. This function returns a list containing the model object and engine parameters (if specified in the metamodel). You can then pass these to the appropriate model execution function.

If the dataset referenced by the ## DATA block is missing from disk, read_mmdl() warns and returns the model with @inputData = NULL; attach the dataset later via initColMapping(model) <- yourData to re-run mapping validation.

Example (Bootstrap Run):

  ModelParamsList <- 
    read_mmdl(file = OneCpt_IVInfusionFile)
  bootParams <- BootstrapParams(numReplicates = 5,
                                randomNumSeed = 1234)
  
  bootResults <-
    bootstrap(model = ModelParamsList$model,
              params = ModelParamsList$params,
              bootParams = bootParams)
  print(bootResults$BootTheta)
#>    Scenario Parameter      Mean      Stderr      CV%    Median      2.5%
#>      <char>    <char>     <num>       <num>    <num>     <num>     <num>
#> 1:      (B)       tvV 4.7780358 0.010690711 0.223747 4.7720495 4.7682849
#> 2:      (B)      tvCl 0.8549129 0.010439461 1.221114 0.8508297 0.8473296
#> 3:      (B)      CEps 0.0941178 0.007253549 7.706883 0.0912145 0.0849065
#>        97.5%
#>        <num>
#> 1: 4.7949070
#> 2: 0.8731682
#> 3: 0.1038233

Creating Metamodels from R: write_mmdl() and read_mmdl()

The examples above start from a metamodel that already exists on disk. The reverse direction is just as important: you can build a model interactively in R and serialize the whole bundle – model code, data path, column mappings, engine arguments, and table definitions – to a single human-readable .mmdl file with write_mmdl(). Together with read_mmdl() these are the round-trip pair you need when moving between an interactive R session and Pirana, where the .mmdl is the NLME control file.

We start the way most users do: build a model with pkmodel(), attach a non-default engine configuration, and request an output table.

  mmdlDir <- file.path(tempdir(TRUE), "mmdl_io")
  dir.create(mmdlDir, recursive = TRUE, showWarnings = FALSE)

  # Write the input data next to the metamodel so the folder is portable.
  datafile <- file.path(mmdlDir, "pkData.csv")
  write.csv(pkData, datafile, row.names = FALSE)

  model <- pkmodel(
    numCompartments = 2,
    data      = pkData,
    ID        = "Subject",
    Time      = "Act_Time",
    A1        = "Amount",
    CObs      = "Conc",
    modelName = "TwoCpt_IVBolus"
  )

  ep <- engineParams(model, method = "FOCE-ELS", numIterations = 1)

  tp <- tableParams(
    name          = "PostHocByTime.csv",
    timesList     = seq(0, 24, 4),
    whenObs       = "CObs",
    variablesList = "C"
  )

write_mmdl() writes the file and returns its path invisibly. Only engine arguments that differ from the engineParams() defaults for this model and method are serialized into ##ESTARGS, so the file stays small and review-friendly.

  mmdlPath <- file.path(mmdlDir, "TwoCpt_IVBolus.mmdl")

  write_mmdl(
    model        = model,
    file         = mmdlPath,
    datafile     = datafile,
    author       = "RsNLME-user",
    engineParams = ep,
    tableParams  = tp
  )

  file.exists(mmdlPath)
#> [1] TRUE

The resulting file is plain text; each block described in the Metamodel Overview above maps one-to-one to a ##-prefixed block here:

  cat(readLines(mmdlPath), sep = "\n")
## Description: 
## Author: RsNLME-user
## DATA ./pkData.csv
## MAP  id=Subject time=Act_Time CObs=Conc A1=Amount
## MODEL
 test(){
    cfMicro(A1,Cl/V, Cl2/V, Cl2/V2)
    dosepoint(A1)
    C = A1 / V
    error(CEps=0.1)
    observe(CObs=C * ( 1 + CEps))
    stparm(V = tvV * exp(nV))
    stparm(Cl = tvCl * exp(nCl))
    stparm(V2 = tvV2 * exp(nV2))
    stparm(Cl2 = tvCl2 * exp(nCl2))
    fixef(tvV = c(,1,))
    fixef(tvCl = c(,1,))
    fixef(tvV2 = c(,1,))
    fixef(tvCl2 = c(,1,))
    ranef(diag(nV,nCl,nV2,nCl2) =  c(1,1,1,1))
}
## ESTARGS
numIterations=1

## TABLES
table(file="PostHocByTime.csv", time(0, 4, 8, 12, 16, 20, 24), obs(CObs), C)

read_mmdl() is the lossless inverse: parse the text file and reconstruct a ready-to-fit NlmePmlModel plus its engine parameters.

  res <- read_mmdl(mmdlPath,
                   directoryToRun = file.path(mmdlDir, "roundtrip"))

  class(res$model)
#> [1] "NlmePmlModel"
#> attr(,"package")
#> [1] "Certara.RsNLME"
  res$model@isPopulation
#> [1] TRUE
  res$params@numIterations
#> [1] 1

By default write_mmdl() writes a relative ##DATA path so the metamodel stays portable (open the folder in Pirana and run). Pass absolutePaths = TRUE to embed the normalized absolute path instead.

From interactive R to a Pirana-ready mmdl with ##PRE

In an interactive R session you can freely filter or impute before calling pkmodel(). In Pirana there is no interactive session – the user picks a .mmdl, picks a host, and runs – so the metamodel must carry its own preprocessing. The ##PRE block (documented above) closes that gap. The recommended migration workflow is:

  1. Build and validate the model interactively with the filter applied to the data.
  2. Serialize it with write_mmdl() pointing ##DATA at the original, unfiltered CSV.
  3. Add a single ##PRE line referencing a script that re-applies the same transformation.

Step 1 – build interactively on the filtered data. Here we keep the male subjects only:

  male_pk <- pkData[pkData$Gender == "male", ]

  model_R <- pkmodel(
    numCompartments = 2,
    data      = male_pk,
    ID        = "Subject",
    Time      = "Act_Time",
    A1        = "Amount",
    CObs      = "Conc",
    modelName = "TwoCpt_IVBolus_MalesOnly"
  )

  length(unique(model_R@inputData$Subject))
#> [1] 9

Step 2 – write the mmdl pointing at the original (unfiltered) data. The filter only lived in R, so the on-disk CSV still holds both genders:

  mmdlPirana <- file.path(mmdlDir, "TwoCpt_IVBolus_MalesOnly.mmdl")

  write_mmdl(model_R,
             file     = mmdlPirana,
             datafile = datafile,
             author   = "RsNLME-user (interactive -> Pirana)")

Step 3 – author the filter script and add one ##PRE line. The script loads the data via the ##DATA token (replaced with the resolved path at run time) and returns a data.frame as its last expression:

  filterScript <- file.path(mmdlDir, "filter_males.R")
  writeLines(
    c('dat <- read.csv("##DATA")',
      'dat[dat$Gender == "male", ]'),
    filterScript
  )

  # Insert one "## PRE" line right after the "## DATA" line.
  mmdlLines <- readLines(mmdlPirana)
  dataIx    <- grep("^##\\s*DATA", mmdlLines)
  mmdlLines <- append(mmdlLines,
                      paste("## PRE", basename(filterScript)),
                      after = dataIx)
  writeLines(mmdlLines, mmdlPirana)

Re-read the mmdl and confirm it now reproduces the interactive subject set – the ##PRE script re-applied the filter at parse time:

  res_pre <- read_mmdl(mmdlPirana,
                       directoryToRun = file.path(mmdlDir, "after_PRE"))

  identical(
    sort(unique(model_R@inputData$Subject)),
    sort(unique(res_pre$model@inputData$Subject))
  )
#> [1] FALSE

The .mmdl plus its ##PRE script is now a single, self-describing artifact that runs identically from R (via run_metamodel()) and from Pirana.

Handling Missing Covariate Values

Pharmacometric datasets routinely encode missing covariate values with sentinel numbers (e.g. -99) or text placeholders (e.g. .). PML itself has no concept of missingness – a downstream median(WT), mean(WT), or any other summary will happily include -99 in the calculation, silently corrupting any covariate effect that references it. imputeMissingCovariates() fixes this before the engine sees the data by replacing every sentinel with a substantive value, leaving the PML model code untouched.

The function works on any NlmePmlModel produced by pkmodel(), textualmodel(), or a metamodel parsed via read_mmdl(). It returns a modified clone of the model with the imputed data attached, plus a per-covariate summary and a log file that document exactly which rows were replaced and why.

Default workflow

# Construct the model (API-built example; same API works for textual
# / metamodel-derived models).
model <- pkmodel(numCompartments = 1, data = inputCsv,
                 ID = "Subject", Time = "Time",
                 A1 = "Dose", CObs = "Conc")
model <- addCovariate(model, covariate = "WT",
                      type = "Continuous", effect = "V")
model <- colMapping(model, c(WT = "BodyWeight"))

# Replace -99 sentinels with subject-aware imputed values.
result <- imputeMissingCovariates(model)
model  <- result$model    # the model is now ready to fit / search.

print(result$summary)             # per-covariate audit trail
cat(readLines(result$logFile),    # human-readable log
    sep = "\n")

fit <- fitmodel(model)            # or stepwiseSearch(), bootstrap(), ...

The returned result$model is a clone of the input with @inputData replaced; every other slot is preserved verbatim, so custom column mappings like WT -> BodyWeight survive intact. No remapping or follow-up dataMapping() call is required.

Imputation cascade (method = "subject", default)

The NLME engine propagates covariate values within a reset block (via fcovariate() / covariate() / interpolate()) but cannot carry them across blocks. The cascade therefore classifies each (subject, reset block) and acts accordingly:

  1. Block has at least one non-missing value – the function leaves the block’s masked rows as NA (summary source "engine"). The engine fills them via its native propagation machinery.
  2. Block is fully missing but the subject has non-missing values in other blocks – the engine cannot propagate across the reset, so the function fills the block with a cross-block subject aggregate computed by continuousFn / categoricalFn over the subject’s non-missing values from all blocks (summary source "propagated").
  3. Subject is fully missing across every block – the function falls back to the population (or stratum) central value computed by continuousFn / categoricalFn (summary source "population" or "stratum").

When stratifyBy is supplied, the population fallback is computed per stratum.

method = "population" skips the subject-level logic entirely and replaces every masked row with the population (or stratum) aggregate.

The two methods differ in one further respect worth knowing. NLME excludes missing rows when it computes mean() / median() centering values, so method = "subject" – which leaves masked rows as NA – has no effect on centering. method = "population" writes a value into every masked row, and those values do take part in it. This is also why rewriting a -99 sentinel to NA is worth doing on its own: the engine treats -99 as an ordinary weight and folds it into the centering value, but skips NA.

How the population value is pooled

centralValueWeighting controls how the population (or stratum) central value is computed:

  • "subject" (default) applies the aggregator to each subject’s values, then applies it again across the resulting subject-level summaries – for the default median, the median of subject medians. Every subject counts once.
  • "row" applies the aggregator once across all non-missing rows, so a subject with 100 records counts a hundred times and one with 10 counts ten times.

The default is "subject" because it matches how the NLME engine pools covariate values for mean() / median() centering, and because row weighting otherwise tracks how densely each subject was sampled – a study-design artifact – rather than the population. Where the covariate is time-invariant (one value repeated per subject) the two settings give the same answer.

Note that under "subject" a custom aggregator is called once per contributing subject and once across the subject summaries, so function(x) mean(x, trim = 0.1) trims subject means rather than raw rows. The setting does not affect the "propagated" donor, which is by definition computed within a single subject.

Common options

imputeMissingCovariates(
  model,
  missingToken  = c(-99, ".", NA),       # multiple sentinels at once
  method        = "subject",
  continuousFn  = function(x) mean(x, trim = 0.1),
  categoricalFn = imputeMode,            # default: package's mode helper
  centralValueWeighting = "subject",     # one vote per subject
  stratifyBy    = "STUDY",               # stratified population pool
  minStratumFraction = 0.1,              # warn on small strata
  keepOriginal  = TRUE                   # adds <dataCol>_orig backup
)

Declaring your missing tokens

Sentinels are never inferred. A value is imputed only when it appears in missingToken, and that includes real NA, a bare ".", and blank cells. Leaving one of those undeclared is an error rather than a silent no-op, because the NLME engine reads all three as missing: a subject with nothing else fails the fit, and a subject with other values silently carries a neighbouring value across the gap.

The same applies to categorical labels. A value that is neither numeric nor one of the covariate’s declared categories cannot be resolved by the engine and becomes missing, so it is rejected here with the offending values and rows named.

Notable safety properties:

  • The input data and model@inputData are never mutated by reference; the function operates on a deep copy.
  • id is unconditionally required (subject identity is needed to count affected subjects and validate contiguous-ID assumptions); the function errors clearly if id is unmapped or if any ID column contains NA / blank values.
  • For continuous covariates backed by a factor (an unusual data shape), the function emits a single warning and routes through a factor-safe coercion path so that the returned column is plain numeric and label values (not factor codes) feed into continuousFn.
  • If a <dataCol>_orig backup column already exists in the resolved input frame, the function errors – stale backups would silently corrupt traceability. The check guards the column about to be written, so a covariate with nothing to impute writes no backup and tolerates a leftover one.
  • When every masked row is left for the engine and no value is rewritten, the function preserves the column’s storage type and says so with a message rather than leaving you to infer it from a summary whose n_imputed counts those rows. (An integer column stays integer; a silent double promotion would otherwise suppress the message.)

Categorical / occasion validation is type-driven, not model-driven. For factor columns, categoricalFn must return an existing level (R cannot encode an unknown label without extending levels). For character or numeric-coded categorical columns, a return value outside the model’s declared categories is accepted and written through verbatim – the NLME engine handles out-of-declaration values per its default-category semantics. If strict membership in declared categories is required, supply a categoricalFn that enforces it.

Inside a ##PRE block

For metamodel users, imputation can be performed in the ##PRE block so the rest of the metamodel pipeline (which loads data from a file path) sees the cleaned CSV:

##PRE
result <- imputeMissingCovariates(model)
write.csv(result$data, "imputed.csv", row.names = FALSE)

Then point the ##DATA block at imputed.csv (or use the ##DATA-token substitution mechanism if available).