# pkpdsim > The building blocks of physiologically based pharmacokinetic and pharmacodynamic (PBPK/PD) models in SBML The complete documentation from https://matthiaskoenig.github.io/pkpdsim, one section per page. --- # pkpdsim: building blocks of PBPK/PD models [![GitHub Actions CI/CD Status](https://github.com/matthiaskoenig/pkpdsim/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/matthiaskoenig/pkpdsim/actions/workflows/ci-cd.yml) [![Documentation](https://img.shields.io/badge/docs-pkpdsim-3f51b5.svg)](https://matthiaskoenig.github.io/pkpdsim) [![Version](https://img.shields.io/pypi/v/pkpdsim.svg)](https://pypi.org/project/pkpdsim/) [![Python Versions](https://img.shields.io/pypi/pyversions/pkpdsim.svg)](https://pypi.org/project/pkpdsim/) [![MIT License](https://img.shields.io/pypi/l/pkpdsim.svg)](https://opensource.org/licenses/MIT) `pkpdsim` is a python package with the building blocks shared by physiologically based pharmacokinetic and pharmacodynamic (PBPK/PD) models in [SBML](https://sbml.org). The source code is available from [https://github.com/matthiaskoenig/pkpdsim](https://github.com/matthiaskoenig/pkpdsim). ## Background The models of [pkdb_models](https://github.com/matthiaskoenig/pkdb_models) describe the pharmacokinetics and pharmacodynamics of a substance in the whole body: one SBML model per tissue, composed into a whole-body model, written in python with the factory of [sbmlutils](https://github.com/matthiaskoenig/sbmlutils), simulated with [sbmlsim](https://github.com/matthiaskoenig/sbmlsim) against the studies curated in [PK-DB](https://pk-db.com). `pkpdsim` holds what these models share, so that the folder of a model imports nothing but itself and released packages and can live in a repository of its own. ## Features - **[Templates and annotations](templates.md)** - the creators and the terms of use of a model, the annotations of the compartments which recur in the models (liver, kidneys, gut, blood, urine, ...) with the terms of FMA, BTO, NCIT, UBERON and GO. - **[Study data](data.md)** - the study folders of PK-DB in a checkout of pkdb_data, found through the environment variable `PKDB_DATA`, and the data of a model collected from them into its own folder. - **[Initial values](initial_values.md)** - the same initial concentration or amount of a substance in every tissue of a model, which removes the distribution kinetics. - **[Licenses](licenses.md)** - the texts of CC BY-SA 4.0 and MIT for the COMBINE archive of a model, written byte for byte the same on every platform. - **[Models and their commands](models.md)** - the model object and the commands of a model (`run`, `fit`, `report`, `identifiability`), the tests of a model as functions and the verification of a change, also across versions of sbmlsim (`pkpdsim-verify`). - **[Experiments and their metadata](experiments.md)** - the base class of the simulation experiments of a model (the SIds of its keys, the units of its data), the presets of the impaired organ function, the shared vocabulary of the metadata of a fit mapping and the filters which select the mappings of a fit by it. - **[Scans and sensitivity analyses](scans.md)** - the timecourse of a point of a scan as sbmlsim 0.8 gave it and the table of the parameters and the distributions of a sensitivity analysis. ## Quickstart ```python from pathlib import Path from pkpdsim.annotations import compartments from pkpdsim.licenses import License, write_license from pkpdsim.templates import creators print(creators[0].givenName, creators[0].familyName) print([resource for _, resource in compartments["li"]]) results = Path("results") results.mkdir() print(write_license(results, License.MIT, copyright="2026 Matthias König").name) ``` ```text Matthias König ['fma/FMA:7197', 'bto/BTO:0000759', 'ncit/C12392'] mit.txt ``` Continue with [Installation](installation.md) and the [Templates and annotations](templates.md) of a model. ## How to cite [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23297325.svg)](https://doi.org/10.5281/zenodo.23297325) If you use `pkpdsim` please cite the archived software on [Zenodo](https://doi.org/10.5281/zenodo.23297325): > König, M. (2026). *pkpdsim: building blocks of physiologically based pharmacokinetic and pharmacodynamic models in SBML* (Version 0.5.1) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.23319918 ## License - Source Code: [MIT](https://opensource.org/license/MIT) - Documentation: [CC BY-SA 4.0](http://creativecommons.org/licenses/by-sa/4.0/) ## Funding Matthias König is supported by the German Research Foundation (DFG) within the Research Unit Programme FOR 5151 "QuaLiPerF (Quantifying Liver Perfusion-Function Relationship in Complex Resection - A Systems Medicine Approach)" by grant number 436883643 and by grant number 465194077 (Priority Programme SPP 2311, Subproject SimLivA). Matthias König was supported by the Federal Ministry of Education and Research (BMBF, Germany) within the research network Systems Medicine of the Liver (**LiSyM**, grant number 031L0054). --- # Installation `pkpdsim` requires python >= 3.13 and is available from [pypi](https://pypi.org/project/pkpdsim/). It is tested on Linux, macOS and Windows with python 3.13 and 3.14. It simulates with sbmlsim 0.9 (`sbmlsim>=0.9.3,<0.10`, installed with it); pkpdsim 0.4 is the version for models on sbmlsim 0.8. ## With uv [uv](https://docs.astral.sh/uv/) is the recommended way to install the package. In a project it is added as a dependency: ```bash uv add pkpdsim ``` Into an existing virtual environment it is installed through the pip interface of uv: ```bash uv venv --python 3.14 uv pip install pkpdsim ``` ## With pip ```bash pip install pkpdsim ``` ## Development version The current state of the `develop` branch is installed directly from GitHub: ```bash uv add "pkpdsim @ git+https://github.com/matthiaskoenig/pkpdsim.git@develop" ``` or, with pip, ```bash pip install git+https://github.com/matthiaskoenig/pkpdsim.git@develop ``` To work on the repository itself, with the test and documentation tooling, see [Development](development.md). ## Dependencies | package | used for | | --- | --- | | [sbmlutils](https://github.com/matthiaskoenig/sbmlutils) | the factory of the models (creators, units, compartments), the qualifiers of the annotations and the console | | [libroadrunner](https://libroadrunner.org) | the simulator, the initial values are selections of a roadrunner model | libroadrunner ships binary wheels for Linux (x86_64), macOS and Windows. Its extension links against `libpython`: an interpreter which uv installs keeps `libpython` in its `lib` directory, where the loader of Linux does not look, and `import roadrunner` fails with `libpython3.14.so.1.0: cannot open shared object file`. Put the directory on the library search path, e.g. in a project managed by uv: ```bash export LD_LIBRARY_PATH="$(uv run python -c 'import sys; print(sys.base_prefix)')/lib" ``` An interpreter of the system or of conda is not affected, nor are macOS and Windows. ## Logging `pkpdsim` does not configure logging; it logs to the loggers below the `pkpdsim` logger and leaves handlers, levels and formatting to the application. --- # Models and their commands A model package describes itself once, in its `__init__.py`, as a `PKPDModel`. The commands, the verification and the tests of pkpdsim work with this object; what stays in the package is what is the model's own: its factory, the registry of its experiments, its fit definitions and parameters, the filters of its mapping collections and its tolerances. The base class of its experiments, the vocabulary of their metadata and the filters of the fits are described in [Experiments and their metadata](experiments.md). ```python # not executed from pathlib import Path from pkpdsim.model import FigureSettings, PKPDModel, SimulatorSettings HCTZ_PATH = Path(__file__).parent MODEL_BASE_PATH = HCTZ_PATH / "models" / "results" / "models" MODEL_PATH = MODEL_BASE_PATH / "hctz_body_flat.xml" DATA_PATHS = [HCTZ_PATH / "data" / "hydrochlorothiazide"] MODEL = PKPDModel( name="hctz", package=__name__, title="HCTZ PBPK/PD model", path=HCTZ_PATH, model_path=MODEL_PATH, data_paths=DATA_PATHS, archive_path=MODEL_BASE_PATH.parent / "hctz_model.omex", factory=".models.factory:create_hctz_models", experiments=".simulations:EXPERIMENTS", fit_definitions=".fitting.fitting:FIT_DEFINITIONS", simulator=SimulatorSettings(absolute_tolerance=1e-10, relative_tolerance=1e-10), figures=FigureSettings(formats=("svg",), savefig_dpi=300, legend_fontsize=10), fit_identifiability=True, ) ``` `PKPDModel`, `FigureSettings`, `SimulatorSettings` and `ExtraAction` take keyword arguments only, so the order of their fields is not API. A list of `data_paths` is stored as a tuple; a model is hashable unless its absolute tolerance is an `AbsoluteTolerance` of sbmlsim. ## References `factory`, `experiments` and `fit_definitions` are references `"module:attribute"`, resolved when a command uses them. The experiments of a model import its `__init__.py` for the paths, so the model object cannot import them; a module which starts with a dot is relative to `package`. | field | the reference names | | --- | --- | | `factory` | a function which creates the models in a directory | | `experiments` | the registry: groups of experiment classes by name, with the group `all` | | `fit_definitions` | the fit problems by name (`sbmlsim.fit.cli.FitDefinition`) | ## Settings - `SimulatorSettings`: the absolute tolerance (one value, or an `AbsoluteTolerance` of sbmlsim per kind and state), the relative tolerance and the processes of a scan, `n_workers`. A run creates an sbmlsim `Simulator` from them. - `FigureSettings`: sbmlsim's `FigureSettings` (the resolution, the sizes of the panels, the fonts and the legends, with the defaults of sbmlsim) and the two fields of a model, the `formats` of the figures (`svg`, `png`, `html`) and the resolution of the static images (`savefig_dpi`). A run hands the settings of sbmlsim to every figure; an experiment (`figure_settings`) and a figure (`Figure(settings=...)`) override them. Nothing is applied to the process, so nothing has to be restored after a run. - `reduced_selections`, `save_results`, `fit_identifiability`: what a run simulates, whether it stores its results, whether a fit analyses the identifiability of its parameters by default. The simulations run serially by default (`n_workers=1`): a pool needs the guard `if __name__ == "__main__":` in the script which runs the model (the processes of the pool import it again), and the memory of a run grows with every process. `n_workers=4` runs a pool of four processes for a scan, `n_workers=None` leaves the choice to sbmlsim (a pool of every CPU for a scan of 256 points or more, serially below): ```python from pkpdsim.model import FigureSettings, SimulatorSettings serial = SimulatorSettings(absolute_tolerance=1e-10, relative_tolerance=1e-10) assert serial.n_workers == 1 pool = SimulatorSettings(n_workers=4) sbmlsim_chooses = SimulatorSettings(n_workers=None) try: SimulatorSettings(n_workers=0) except ValueError as err: print(err) figures = FigureSettings(formats=("svg", "png"), savefig_dpi=300, legend_fontsize=10) print(figures.sbmlsim_settings().legend_fontsize) ``` ## Commands `pyproject.toml` of the repository of the model registers the commands with the model object: ```toml [project.scripts] run_hctz = "pkdb_models.models.hctz:MODEL.run_main" fit_hctz = "pkdb_models.models.hctz:MODEL.fit_main" report_hctz = "pkdb_models.models.hctz:MODEL.report_main" identifiability_hctz = "pkdb_models.models.hctz:MODEL.identifiability_main" ``` ```bash run_hctz --action factory # the SBML, the ODEs, the archive run_hctz --action list_experiments # the groups and experiments run_hctz --action simulate --experiments "misc,Anderson1961" --open run_hctz --action all --results-dir /path/to/results --formats svg,png run_hctz --action simulate --experiments all --workers 4 fit_hctz --subset=PK --runs=10 --cores=10 --max-points=30 report_hctz --subset=PK results/fit/PK_/parameters.json identifiability_hctz --subset=PK results/fit/PK_/parameters.json ``` `--workers` sets the processes of the scans for this run: a number, `auto` (sbmlsim's choice, a pool of every CPU for a scan of 256 points or more) or `model` (the `n_workers` of the model object, the default). An extra action gets the settings of the simulator of the run as `options.simulator` (the `SimulatorSettings` of the model with the processes of `--workers`); an action which simulates passes them on, `model.run_experiments(..., simulator=options.simulator)` or `pkpdsim.run.create_simulator(options.simulator)` for a runner of its own, otherwise `--workers` does not reach it. An option of the model named `--simulator` is refused (`run_` raises a `ValueError` naming it before the action runs), because it would be replaced by `options.simulator`. A run which fails is not the end of the command. An experiment which cannot be initialized or whose figure fails is reported, the other experiments run, the report and the figures are written, and `run_` then runs its remaining actions, prints every failure and exits with 1. `MODEL.run_experiments` raises `sbmlsim.experiment.ExperimentRunError` after the report is written, so a script does not carry on after a failed run. The command of a model without experiments (`experiments=None`) offers only `--action factory`, `--action all` (which runs only the factory and the extra actions with `in_all`) and its extra actions, and none of `--experiments`, `--open` and `--formats`; `simulate` and `list_experiments` are a usage error for it. A model adds actions of its own to `run_` with `ExtraAction` and their options with `extra_arguments`, e.g. analyses which are not simulation experiments. In a script or the `__main__` block of an experiment: ```python # not executed from pkdb_models.models.hctz import MODEL from pkdb_models.models.hctz.experiments.studies import Anderson1961 MODEL.run_experiments(Anderson1961, output_dir=Anderson1961.__name__) ``` ## Tests `pkpdsim.testing` holds the tests of a model as functions: the registry is complete (`check_registry`), the model object belongs to its package and its references resolve (`check_model`), the experiments initialize (`initialize_experiments`), all experiments run (`run_all_experiments`) and every fit starts (`start_fits`). A function which checks several things raises one `AssertionError` which names every failure, not only the first. The known exceptions of a model (experiments which are not registered, modules which do not import, experiments which run one at a time) are its `checks`, see [Experiments and their metadata](experiments.md#exceptions-of-the-tests). `check_model` also checks that the name of the model is the last component of its package (or that component ends with `_`), that `model_path`, the factory directory and `archive_path` lie below the path of the model, and that the data paths exist. ## Verification of a change `pkpdsim-verify` dumps the experiments of a model in two checkouts and compares them, writes the fit definitions and the settings of a run as json, and compares figures and SBML: ```bash pkpdsim-verify dump --root ../base --model pkdb_models.models.hctz --out base.pkl --atol model pkpdsim-verify dump --root . --model pkdb_models.models.hctz --out new.pkl --order base.pkl --atol model pkpdsim-verify compare base.pkl new.pkl pkpdsim-verify definitions --root . --model pkdb_models.models.hctz --out new.json pkpdsim-verify settings --root ../base --model pkdb_models.models.hctz --out base.json pkpdsim-verify settings --root . --model pkdb_models.models.hctz --out new.json diff base.json new.json pkpdsim-verify png old/results/simulation new/results/simulation pkpdsim-verify sbml old.xml new.xml ``` `--atol model` and `--rtol model` take the tolerances of the model object; any other value which is not a number exits with 2. A package without a model object exits with 2, and `compare --renames FILE` applies the expected renames of a migration to the old dump (see [Experiments and their metadata](experiments.md#renames-in-a-verification) and [Verification across versions of sbmlsim](#verification-across-versions-of-sbmlsim)). An experiment which cannot be initialized is recorded in the dump as an error and the command exits with 1 after the other experiments are stored. `settings` captures what a run of the experiments hands to sbmlsim, bound to the signatures: the arguments of `Simulator` (its tolerances and `n_workers`), of `ExperimentRunner` (without the simulator) and of the run, with its `figure_settings` (without the output path), the matplotlib `rc` settings a run changes and the counts of the calls. It exits with 2 for a run which did not complete. The json has `run.figure_settings`; the part `figure` of pkpdsim 0.4 (the class attributes of sbmlsim's `Figure`) is gone. ## Verification across versions of sbmlsim A dump records the versions of sbmlsim and pkpdsim it was made with, and `compare` prints them. A dump of pkpdsim 0.4 (sbmlsim 0.8) and one of pkpdsim 0.5 (sbmlsim 0.9) differ in their layout, which `compare` takes into account: - sbmlsim 0.8 puts the time first, `(_point, *dims)`, sbmlsim 0.9 last, `(*dims, time)`. `compare` moves the time of every array first and matches the other dimensions by name. A dimension of size 1 which only one array has is squeezed (a scan of one point is a simulation now), different names of the dimensions are an infinite difference. - sbmlsim 0.9 keeps the state before and after a change at the time of the change (a second dose), sbmlsim 0.8 only the state after it. Two dumps with the same time vectors are compared row by row. Otherwise the rows of a repeated time are matched from the last one, the k-th last old row with the k-th last new row, so the state after a change is compared with the state after it, and only the rows which one dump has more of at a time are left out (the states before a change of a dump of sbmlsim 0.9 compared with one of sbmlsim 0.8; two dumps of sbmlsim 0.9 with two rows at a time compare both): the summary counts them and names the experiments with `changes: rows`. - The rows of every point of a scan are matched by the times of that point. The points of a grid share their times; in the ragged layout of sbmlsim 0.9 (`_point`, every point with its own times, padded with NaN) and in a scan of sbmlsim 0.8 a point is matched and its times are compared on its own. - A row at a time which only the old dump has is always a difference, and no pattern of `expected` hides it. A row at a time which only the new dump has (a new output time of a task) is a difference as well, which is printed per task (`times of /: rows only in new, at `) and which the pattern `experiment/task/time` of `expected` names. The specification of a figure (its attributes as json) changed as well. A figure which the old dump has from sbmlsim 0.8 and the new dump from sbmlsim 0.9 is compared in the form of sbmlsim 0.9, and `compare` prints how many (`figures across versions of sbmlsim: `): - The settings. sbmlsim 0.8 kept a setting which a figure set (`fig.legend_position = "outside"`) as an attribute of the figure, sbmlsim 0.9 keeps it in `_settings` (`fig.settings = {"legend_position": "outside"}`). The attributes of the old figure which are fields of `FigureSettings` move into its `_settings`, which are compared with the settings the new figure is rendered with apart from the settings of the run: the `figure_settings` of its experiment overridden by its own `settings`. Another value, a setting which only one figure has and one which moved into the experiment are compared by value. - The size. sbmlsim 0.8 computed the size of a figure when it was created (`_width` and `_height`: the explicit size, or the number of panels times the panel size), sbmlsim 0.9 keeps only the explicit size (`width` and `height`, `None` for the size of the panels) and computes the size when it renders the figure. The old size is compared with the size the new figure is rendered with apart from the settings of the run, which a dump of pkpdsim 0.5 stores with the settings (`__resolved__` of the specification, `pkpdsim.verify.figure_spec`), so an explicit size and the size of the panels are compared by value. A plot which is added to a figure gets its share of the size of the figure, from sbmlsim 0.8 always and from sbmlsim 0.9 only for an explicit size; such a size of a plot is left out on both sides, a size of its own is compared. A figure whose size was set after its plots were added gave them its earlier size in sbmlsim 0.8, which then counts as a size of their own and differs. A size computed from a setting which sbmlsim 0.8 read from the class `Figure` (`Figure.panel_width`; a dump of pkpdsim 0.4.1 ran without the settings of the model and saw sbmlsim's default) and which sbmlsim 0.9 reads from the figure settings of the model differs whenever the model changes that setting; the figure is the same. - The fields which only sbmlsim 0.9 has, the selection of a `Data` (`sel`), the dimensions of the lines of a `Curve` (`over`) and the bands of a `Plot` (`_bands`), have no counterpart in sbmlsim 0.8: they are left out of the comparison, and every one which is not empty is listed for the review of the migration, `new fields: /
: = ` (the path as in a difference, e.g. `subplots[0].plot._curves[1].y.sel`). Two figures of one version of sbmlsim are compared as they are, `sel`, `over` and `_bands` included. Neither dump has the settings of the run (the `FigureSettings` of the model, which a run hands to every figure); `settings` compares them. The ids of sbmlsim 0.9 are SIds (see [Keys](experiments.md#keys)). The migration says so in the renames file instead of weakening the comparison, which then passes with exit code 0 or fails: | part | content | | --- | --- | | `ids` | an old id to its new id: the task of a result, the dataset, the figure and mapping keys and the references to them in a figure | | `sids` | `true`: every id of the old dump becomes `pkpdsim.experiment.to_sid(id)` (after `ids`) | | `figure_fields` | the keys of the specification of a figure which are left out of both dumps, at the level of the figure only (not in its plots, curves or axes), in the form in which the figures are compared: a field of a figure which `compare` cannot map. The specification is the attributes of sbmlsim's `Figure` and its objects as json (`pkpdsim.verify.serialize`, `figure_spec`), so the fields are those of the version of sbmlsim which made the dump. The port from sbmlsim 0.8 to 0.9 needs none | | `expected` | a part (`results`, `datasets`, `mappings`, `figures`, `ranges`) to the patterns of the keys of the differences which are expected | | `metadata`, `strings` | as in [Experiments and their metadata](experiments.md#renames-in-a-verification) | ```json { "sids": true, "expected": { "results": ["Anderson1961/task_po_2p5/time"], "mappings": ["Anderson1961/Fig1_hctz"] } } ``` The patterns of `expected` are `fnmatch` patterns of the keys: | part | key | | --- | --- | | `results` | `experiment/task/variable` | | `datasets` | `experiment/dataset/column` | | `mappings`, `ranges` | `experiment/mapping` | | `figures` | `experiment/figure` | `*` and `?` are wildcards, a bracket is literal: `Anderson1961/task_po_2p5/[Cve_hctz]` names the variable `[Cve_hctz]` of the task `task_po_2p5` (the base names the task of a simulation `task_`). A difference which matches is printed as expected and not counted, a pattern which matches no difference is counted, so the file says exactly what changes. Two old ids with one new id, a `strings` rename which merges two keys of a part and two mappings of one experiment with one key are errors (exit code 2). The renames are applied to the content of the parts of the old dump, never to the names of the parts, and the loaded dump is not changed. `png` compares the figures of two directories by their file names. After the renames to SIds the old file `A_fig 2.5.png` is `A_fig_2p5.png`: `png --sids` pairs an old figure with the new figure of the SId of its name (the stem through `to_sid`) and exits with 2 when two old figures have one SId: ```bash pkpdsim-verify png --sids old/results/simulation new/results/simulation ``` --- # Experiments and their metadata An experiment of a model is a published study as code: it loads the digitized data of the study, encodes its dosing protocol as simulations and pairs the data with the observables of the model (the fit mappings). pkpdsim holds what the experiments of the models share: the base class, the impairment presets, the vocabulary which describes a mapping and the filters which select the mappings of a fit by that vocabulary. ## The base of the experiments The base of the experiments of a model derives from `PKPDSimulationExperiment` and binds it to the model object with the class attribute `pkpd_model`; the experiments of the model derive from that base: ```python # not executed from typing import ClassVar, NamedTuple from pkpdsim.experiment import PKPDSimulationExperiment from sbmlsim import Q from sbmlsim.units import Quantity from pkdb_models.models.hctz import MODEL class MolecularWeights(NamedTuple): hctz: Quantity class HctzSimulationExperiment(PKPDSimulationExperiment): pkpd_model = MODEL selections = ("time", "[Cve_hctz]", "Aurine_hctz") Mr: ClassVar[MolecularWeights] = MolecularWeights(hctz=Q(297.74, "g/mole")) labels = {"[Cve_hctz]": "Hydrochlorothiazide"} units = {"[Cve_hctz]": "µmole/l"} ``` The class attributes are the conventions of the experiments: | attribute | meaning | | --- | --- | | `pkpd_model` | the model object, required: an experiment of a base without it raises a `TypeError` | | `selections` | the selections added to every task, e.g. `("time", "[Cve_hctz]")` | | `Mr` | the molecular weights of the substances for the conversion of the data, a `NamedTuple` of the model | | `labels`, `units` | the labels and units of the observables by id | The base provides what is the same in every model: `models` (the SBML model of the model object, without changes), `tasks` (a task per simulation), `default_changes` (none, a fitted parameter is part of the model and must not be set by a simulation), `data` (the selections for every task), `load_dataframe(fig_id, study=None, *, na_values=None)` and `load_dataframes_by_substance(fig_id, study=None)` (the digitized data of the figure or table `Fig1` of the study, by default that of the experiment, read from the `data_path` of the experiment, a path or paths) and `figures_by_sid(*figures)`. A model overrides what it does differently, e.g. `default_changes` for a model whose simulations set a parameter. ## Keys The keys of an experiment (its datasets, simulations, tasks, figures and fit mappings) are SIds, which sbmlsim checks when the experiment initializes: letters, digits and `_`, not starting with a digit, and unique across the kinds. Keys are often built from the texts of the data (a dose, a label of a legend), so `pkpdsim.experiment.to_sid(text)` maps a text to an SId. A decimal point between two digits becomes `p`, every other character an SId does not allow becomes `_`, and one character becomes one character. The function maps characters, so a part of a key gives the same part of the SId of the key (`f"task_po_{to_sid(dose)}"`). Whether a whole key is valid (not empty, not starting with a digit) is not checked by `to_sid`, because the text of a part of a key may start with a digit; sbmlsim checks every key at initialize and names all invalid ones: ```python from pkpdsim.experiment import to_sid assert to_sid("po 2.5 mg") == "po_2p5_mg" assert to_sid("0.5mg") == "0p5mg" assert to_sid("µg/ml") == "_g_ml" assert to_sid("Cve_hctz") == "Cve_hctz" assert to_sid("v1.2.3") == "v1p2p3" assert f"task_po_{to_sid('2.5 mg')}" == "task_po_2p5_mg" ``` Two texts of an experiment can give one SId (`po 2.5` and `po-2.5`), and the second key would silently replace the first entry of a dict. The method `to_sid` of the base gives the same SId and raises a `ValueError` naming both texts, so use it for the keys of an experiment: ```python # not executed def simulations(self) -> dict[str, Simulation]: return { self.to_sid(f"po {dose}"): Simulation(...) # "po 2.5" and "po-2.5" are an error for dose in (2.5, 5.0) } ``` ## Units of the data The data of a study can use units which sbmlsim does not define. An international unit (IU) is specific to a substance (the activity of an enzyme, the effect of a coagulation factor), so it has no value without it. `data_units` of an experiment maps the unit as the data write it to the definition of a unit of its own, which the base defines in the registry of the experiment before the data are read and which `load_dataframe` and `load_dataframes_by_substance` rename in the unit columns of the data (`unit` and every column ending in `_unit`). The name carries the model and the substance (`IU_ramipril_renin`, not `IU`): the registry of sbmlsim exists once per process (pytest runs all models in one) and ignores a redefinition, so two models which defined `IU` with different values would silently get the first one. `define_unit` therefore raises for a name which is defined with another value: ```python from pkpdsim.experiment import define_unit, rename_units from sbmlsim.units import UnitRegistry import pandas as pd ureg = UnitRegistry() name = define_unit(ureg, "IU_ramipril_renin = 0.6 * microgram") assert name == "IU_ramipril_renin" assert define_unit(ureg, "IU_ramipril_renin = 0.6 * microgram") == name try: define_unit(ureg, "IU_ramipril_renin = 1.0 * microgram") except ValueError as err: print(err) df = pd.DataFrame( {"unit": ["IU/ml", "µIU/ml", "IU_per_ml"], "mean_unit": ["IU", "2IU", None]} ) print(rename_units(df, {"IU": name})) ``` `rename_units` replaces a name where it is a unit, alone or after a metric prefix (`IU/ml` and `µIU/ml`), and leaves it where it is part of another name (`IU_per_ml`, `2IU`). In the experiment: ```python # not executed from types import MappingProxyType class RamiprilSimulationExperiment(PKPDSimulationExperiment): pkpd_model = MODEL # a read-only mapping, which ruff (RUF012) asks of a class attribute data_units = MappingProxyType({"IU": "IU_ramipril_renin = 0.6 * microgram"}) ``` `initialize` defines the units before it reads the data, and a loader which is called outside of `initialize` defines them on its first call. A definition which pint cannot evaluate (an unknown unit in the expression, a syntax error) or which `define_unit` refuses is a `ValueError` naming the unit, which fails the initialization of that experiment only: a run reports it with its traceback, runs the other experiments and raises at the end, and `pkpdsim.testing.initialize_experiments` names it with every other failure. ## Impairment presets The classes of impaired organ function with the factors of their parameters and the colors of the figures are the presets of `pkpdsim.impairment`; the base has the renal function (`RENAL_FUNCTION`, `RENAL_COLORS`), the cirrhosis (`CIRRHOSIS`, `CIRRHOSIS_COLORS`) and the cardiac function (`CARDIAC`, `CARDIAC_COLORS`) as its maps (`renal_map`, `renal_colors`, `cirrhosis_map`, `cirrhosis_colors`, `cardiac_map`, `cardiac_colors`), and the labels and units of the pharmacokinetic parameters (`PK_LABELS`, `PK_UNITS` of `pkpdsim.pk`) as `pk_labels` and `pk_units`. A model which classifies otherwise chooses another preset in its base: ```python from pkpdsim.impairment import ( RENAL_COLORS, RENAL_COLORS_END_STAGE, RENAL_FUNCTION, RENAL_FUNCTION_90, ) # the presets with the end stage class have their own colors assert set(RENAL_FUNCTION_90) - set(RENAL_COLORS) == {"End stage renal disease"} assert set(RENAL_FUNCTION_90) == set(RENAL_COLORS_END_STAGE) # a model without end stage data uses the classes of a preset without that class renal_map = { name: factor for name, factor in RENAL_FUNCTION_90.items() if name != "End stage renal disease" } assert list(renal_map) == list(RENAL_FUNCTION) # a preset plus a class of the model renal_map = {**RENAL_FUNCTION, "Hemodialysis": 0.05} ``` ```python # not executed class HctzSimulationExperiment(PKPDSimulationExperiment): pkpd_model = MODEL renal_map = renal_map renal_colors = {**RENAL_COLORS, "Hemodialysis": "#444444"} ``` ## The vocabulary of the metadata Every fit mapping carries metadata which says what its curve is. The fits select their data by these values, so the models describe their studies with the same words. `pkpdsim.metadata` holds the closed vocabularies as `StrEnum`s, shared by all models: `Tissue`, `Route`, `Dosing`, `Fasting`, `ApplicationForm`, `DataType` and `PKPDData`. The open vocabularies (the health of the subjects, the coadministered substances, the genotypes) are the model's own `StrEnum`s, which grow with its studies. Their conventional members are `HEALTHY`, `NONE` (nothing coadministered) and `NR` (not reported), with the same name and value in every model. A model derives the metadata of its mappings from `PKPDMappingMetaData`, which has the fields `tissue`, `route`, `application_form` (default `NR`), `dosing` and `fasting` (default `NR`), and adds its own fields. All fields are keyword-only, so the model's fields without a default may follow the defaults: ```python from dataclasses import dataclass from enum import StrEnum, unique from pkpdsim.metadata import Dosing, PKPDMappingMetaData, Route, Tissue @unique class Health(StrEnum): HEALTHY = "healthy" RENAL_IMPAIRMENT = "renal impairment" NR = "not reported" @unique class Coadministration(StrEnum): NONE = "none" PROBENECID = "probenecid" @dataclass(kw_only=True) class HctzMappingMetaData(PKPDMappingMetaData): health: Health coadministration: Coadministration = Coadministration.NONE metadata = HctzMappingMetaData( tissue=Tissue.PLASMA, route=Route.PO, dosing=Dosing.SINGLE, health=Health.HEALTHY, ) print(metadata.to_dict()) ``` `to_dict` is what the tables of sbmlsim show: the member names of the enums (`PO`, not `po`), the string of any other value, in the order of the fields with those of `PKPDMappingMetaData` first. No code reads the values, which are readable text. ## Filters A `fitting/mapping_collections.py` selects the mappings of a fit by what a study is, not by listing names. `metadata_filter(metadata_type, *, excluded=None, **allowed)` builds a filter on the fields of the metadata: a mapping passes when every allowed field has one of its values and no excluded field has one of its values. `observable_filter(observables)` selects by the index of the data of the observable (`observable_id`, i.e. `Data.index` of the `y` of the mapping: the selection of the model without its brackets and without the task, `Cve_hctz` for the selection `[Cve_hctz]`; a filter of `[Cve_hctz]` selects nothing). Both are callables of sbmlsim's `MappingFilter` and have a readable `repr`. An unknown field, a class which is no dataclass or is an instance, a filter without clauses, an empty set of allowed values (it selects nothing) or of excluded values (it excludes nothing) and values which are no collection raise when the module of the filters is imported. A single value is an error, not a set of its characters: write `route={Route.IV}`, not `route=Route.IV`: ```python from types import SimpleNamespace from pkpdsim.fit import metadata_filter filter_control = metadata_filter( HctzMappingMetaData, route={Route.PO, Route.IV}, coadministration={Coadministration.NONE}, excluded={"dosing": {Dosing.MULTIPLE}}, ) print(filter_control) mapping = SimpleNamespace(metadata=metadata) # stands for a FitMapping assert filter_control("Fig1", mapping) try: metadata_filter(HctzMappingMetaData, rout={Route.PO}) except TypeError as err: print(err) try: metadata_filter(HctzMappingMetaData, route=Route.PO) except TypeError as err: print(err) ``` `select_mappings(model, experiment_classes, filters=(), outliers=(), validation=())` selects the data of a fit with the paths of the model object (`path` and `data_paths`) and returns the fit mapping collections by experiment id; it prints the counts of the mappings per experiment and kind. The filters select the training data, `outliers` are the keys of mappings which are not usable and `validation` are keys or a filter of the validation data: ```python # not executed from pkpdsim.fit import metadata_filter, observable_filter, select_mappings from pkdb_models.models.hctz import MODEL from pkdb_models.models.hctz.experiments.studies import Anderson1961, Barbhaiya1982 PK = observable_filter({"Cve_hctz", "Aurine_hctz"}) CONTROL = metadata_filter( HctzMappingMetaData, route={Route.PO, Route.IV}, coadministration={Coadministration.NONE}, ) collections = select_mappings( MODEL, [Anderson1961, Barbhaiya1982], filters=[PK, CONTROL], outliers=["Barbhaiya1982_Fig2"], ) ``` ## Exceptions of the tests The tests of `pkpdsim.testing` read the known exceptions of a model from `PKPDModel.checks`, a `CheckSettings`. Every exception carries its reason, and an exception which no longer applies (a class which is registered now, a module which is gone) fails the test, so the list cannot go stale: | field | meaning | | --- | --- | | `not_registered` | experiment classes which are not in the group `all` of the registry on purpose, class name to reason | | `not_importable` | modules below the package which do not import in the test environment (an optional dependency), name relative to the package to reason | | `one_at_a_time` | the reason why the registered experiments run one at a time (the memory a run of all of them needs), `None` to run them together; an empty reason is an error | ```python from pkpdsim.model import CheckSettings checks = CheckSettings( not_registered={"Anderson1961Scan": "a scan which takes hours"}, not_importable={"experiments.zonation.plot_pyvista": "needs pyvista"}, one_at_a_time="a run of all experiments needs 20 GB", ) print(checks) ``` ```python # not executed MODEL = PKPDModel( name="hctz", ..., checks=checks, ) ``` ## Renames in a verification A migration of the metadata renames fields and members, and `pkpdsim-verify compare` would report every mapping as changed. `compare --renames FILE` applies the expected renames to the old dump first, so that only the unexpected changes remain: ```bash pkpdsim-verify compare base.pkl new.pkl --renames renames.json ``` The file is a json object with the parts `metadata` and `strings` (and, across versions of sbmlsim, `ids`, `sids`, `figure_fields` and `expected`, see [Verification across versions of sbmlsim](models.md#verification-across-versions-of-sbmlsim)); an unknown part or a part of the wrong type is an error (exit code 2): ```json { "metadata": { "fields": {"tissue_type": "tissue"}, "members": {"route": {"ORAL": "PO", "INTRAVENOUS": "IV"}}, "added": {"fasting": "NR"} }, "strings": {"Anderson 1961": "Anderson1961"} } ``` - `metadata.fields`: the old name of a field to its new name, - `metadata.members`: per (new) field, the old member name to the new one, - `metadata.added`: a field the old metadata did not have to its member name, set where the old mapping has no such field, - `strings`: an old text to the new one, in every key and string of the old dump (legend keys, labels); the arrays are untouched. A rename which gives two keys of a part one key is an error. The renamed mappings are sorted like a dump, so the comparison does not depend on the order the renames change. --- # Scans and sensitivity analyses Two modules hold what the scripts of the models around scans and sensitivity analyses share. Both build on the API of sbmlsim 0.9, where a design of `sbmlsim.simulation.sampling` is a dimension of a `Scan` and its result is a `ScanResult`. ## The timecourse of a point of a scan sbmlsim 0.9 returns a scan as a `ScanResult` with the layout `(*dims, time)`, and at the time of a change after the start (a second dose) it keeps the state before and after the change. The pharmacokinetic parameters of a scan (`_pk.py`) and the figures of a model read the timecourse of one point as sbmlsim 0.8 gave it, with the state after the change only, so that their numbers and figures stay the same. `pkpdsim.scan.single_dimension(res)` gives the id of the dimension of a scan of one dimension and `point_timecourse(res, key, /, **point)` the time points and the values of a variable at the point `point` (the position along every dimension, e.g. `dim_dose=2`; `res` and `key` are positional only, so a dimension may have any name); a result without dimension needs none: ```python # not executed from pkpdsim.scan import point_timecourse, single_dimension dim = single_dimension(res) for k in range(res.ds.sizes[dim]): time, values = point_timecourse(res, "[Cve_hctz]", **{dim: k}) ``` A scan with several dimensions, a point which misses a dimension or names an unknown one, and a variable which is no timecourse raise a `ValueError`. The padding of a result whose simulations have different times is removed. ## The parameters of a sensitivity analysis sbmlsim 0.9 analyses the sensitivity of a model on the result of a scan, with a design (`local`, `sobol`, `fast`, `lhs`) as a dimension of the scan; it has no table of the parameters of an analysis any more, which the sensitivity scripts of the models write next to their results. `pkpdsim.sensitivity` is that table with the behavior of sbmlsim 0.8, so the tables of the scripts stay the same, and the distributions the global designs need: ```python # not executed from pkpdsim.sensitivity import ( ParameterType, distributions, free_parameters, parameters_from_sbml, sample_size, set_bounds, set_relative_bounds, write_parameters, ) from sbmlsim.simulation.sampling import sobol parameters = parameters_from_sbml(MODEL_PATH, exclude_ids={"Mr_hctz"}) set_bounds(parameters, [("f_renal_function", 0.85, 1.15, ParameterType.SCALING)]) set_relative_bounds(parameters, 0.15) write_parameters(parameters, MODEL.results_dir / "sensitivity") # a design must not vary what the simulation of a group sets free = free_parameters(parameters, [simulation]) design = sobol(distributions(free), n=sample_size(4096), seed=1234, model=MODEL_PATH) ``` - `parameters_from_sbml` takes the constant parameters, compartments and species of a model in its order, with their values after the initialization, and leaves out those without a value, those close to zero and `exclude_ids`. - `set_bounds` sets the bounds and the type of parameters by id (an unknown id is logged and skipped), `set_relative_bounds` lets every parameter without bounds vary by `value * (1 +- fraction)`. For a negative value these bounds are reversed (the lower bound `value * (1 - fraction)` is above the upper one), as in the table of sbmlsim 0.8, which stays the same; such a parameter gets its bounds with `set_bounds` before a global analysis. - `write_parameters` writes `parameters.tsv` and the LaTeX table `parameters.tex` (`jinja2` is needed for the table), `parameters_to_df` gives the table as a `DataFrame` with the columns `uid`, `name`, `value`, `lower_bound`, `upper_bound`, `unit`, `type` and `reference`, sorted by type and id. - `distributions` gives the uniform distribution of every parameter in its bounds, the input of a global design; a parameter without bounds or with a lower bound above the upper one raises a `ValueError` naming it. - `free_parameters(parameters, simulations)` gives the parameters which none of the simulations sets, in their order, in its `preinit_changes`, in the `preinit_changes` of its presimulation or in a `Change`. A design replaces its targets wherever a simulation sets them, so a parameter of a group (e.g. `f_renal_function` of the degree of renal impairment) would take the values of the design in every group and the groups would be one analysis; sbmlsim 0.8.5 left such a parameter out ("Removing fixed parameter"), sbmlsim 0.9's designs do not. The ids are compared as written, plain ids as in sbmlsim 0.8.5: a target given as a selection (`[S]`) does not match the parameter `S`. Every parameter left out is logged at level info. - `sample_size(n)` is `n`, or the value of the environment variable `PKPDSIM_SENSITIVITY_N` for a quick run of the scripts in a verification. The distributions do not need a model: ```python from pkpdsim.sensitivity import ( ParameterType, SensitivityParameter, distributions, parameters_to_df, set_bounds, set_relative_bounds, ) parameters = [ SensitivityParameter(uid="Ka", name="absorption", value=2.0, unit="1/hr"), SensitivityParameter(uid="f_renal_function", name="renal function", value=1.0), ] set_bounds(parameters, [("f_renal_function", 0.85, 1.15, ParameterType.SCALING)]) set_relative_bounds(parameters, 0.5) print(parameters_to_df(parameters)[["uid", "lower_bound", "upper_bound", "type"]]) print(distributions(parameters)) ``` A parameter which a simulation sets is left out of the design: ```python from sbmlsim import Q from sbmlsim.simulation import Simulation from pkpdsim.sensitivity import SensitivityParameter, free_parameters parameters = [ SensitivityParameter(uid="Ka", name="absorption", value=2.0), SensitivityParameter(uid="f_renal_function", name="renal function", value=1.0), ] group = Simulation( end=24, steps=100, preinit_changes={"f_renal_function": 0.5, "PODOSE": Q(10, "mg")}, ) print([p.uid for p in free_parameters(parameters, [group])]) ``` --- # Templates and annotations Every model of [pkdb_models](https://github.com/matthiaskoenig/pkdb_models) names its creators, carries the terms of use under which it is published and annotates its compartments with the terms of ontologies, so that a reader of the SBML, a person or a program, knows who made it, how it may be used and what a compartment is. `pkpdsim.templates` holds the creators and the terms of use, `pkpdsim.annotations` the annotations of the compartments which recur in the models: the liver `li`, the kidneys `ki`, the gut `gu`, the venous and arterial blood `ve` and `ar`, the urine, the feces and more. ## Usage A model of the liver with its creators, the terms of use behind its description and an annotated compartment, created with the factory of [sbmlutils](https://github.com/matthiaskoenig/sbmlutils): ```python from pathlib import Path import libsbml from sbmlutils.factory import Compartment, Model, ModelUnits, Units, create_model from pkpdsim.annotations import compartments from pkpdsim.templates import creators, terms_of_use class U(Units): """The units of the model, `liter` and `second` are predefined.""" model = Model( sid="liver", name="Model of the liver", notes=""" A model of the liver with a single compartment. """ + terms_of_use, creators=creators, units=U, model_units=ModelUnits(time=U.second, volume=U.liter), compartments=[ Compartment( "Vli", 1.5, unit=U.liter, name="liver", annotations=compartments["li"] ), ], ) result = create_model(model, filepath=Path("liver.xml"), sbml_level=3, sbml_version=2) liver = libsbml.readSBMLFromFile(str(result.sbml_path)).getModel() history = liver.getModelHistory() print(history.getCreator(0).getGivenName(), history.getCreator(0).getFamilyName()) compartment = liver.getCompartment("Vli") for k in range(compartment.getNumCVTerms()): term = compartment.getCVTerm(k) for m in range(term.getNumResources()): print(term.getResourceURI(m)) ``` ```text Matthias König https://identifiers.org/FMA:7197 https://identifiers.org/BTO:0000759 https://identifiers.org/ncit:C12392 ``` ## Creators `creators` is a list of `sbmlutils.factory.Creator`; sbmlutils writes it as the model history of the SBML. A model with further creators, e.g. the students who built it, extends the list: ```python # not executed: the creators of a model with a further creator from sbmlutils.factory import Creator from pkpdsim.templates import creators model_creators = [ *creators, Creator(familyName="Doe", givenName="Jane", email="jane.doe@example.org"), ] ``` ## Terms of use `terms_of_use` is the markdown of the terms of use, which the models append to the notes behind their description: the copyright of the current year, the [Creative Commons Attribution-ShareAlike 4.0](https://creativecommons.org/licenses/by-sa/4.0/) license of the model, whose text the COMBINE archive of the model carries (see [Licenses](licenses.md)), and the disclaimer. The year is the one of the import, so a model created in a new year carries the new year. The terms are indented by four spaces, like the description of a model written as an indented triple quoted string, see the usage above. sbmlutils removes the indentation which all lines of the notes have in common before it converts the markdown into the XHTML of the SBML, and lines which keep an indentation of four spaces are a code block: behind a description which is not indented, e.g. `"A model of the liver.\n" + terms_of_use`, the terms of use would show as code with their HTML escaped. ## Annotations `compartments` maps the abbreviation of a compartment to its annotation, a list of `(qualifier, resource)` pairs: the qualifier is a biological qualifier of `sbmlutils.metadata.BQB` (`IS`, `IS_PART_OF`, `IS_VERSION_OF`) and the resource the term of an ontology as `collection/term` of [identifiers.org](https://identifiers.org), e.g. `fma/FMA:7197` for the liver in the Foundational Model of Anatomy. A model passes the list as the `annotations` of its compartment, or of a species which lives in the compartment, and sbmlutils resolves the terms and writes the RDF of the element. Every resource is a term of a collection of the identifiers.org registry and matches the pattern of its collection, which the tests check for every annotation. --- # Study data The studies against which the models are fitted and validated are curated in [PK-DB](https://pk-db.com), in a checkout of [pkdb_data](https://github.com/matthiaskoenig/pkdb_data): its folder `studies` holds a folder per substance and in it a folder per study, with the publication, its figures and tables and the digitized data as hidden `.tsv` files (`.Boulton1996a_Fig1.tsv`). A model keeps the studies it uses in its own `data/` folder, so that its simulations run without the checkout; `pkpdsim.data` copies them there. ## Finding the studies `studies_dir()` returns the folder `studies` of the checkout. The checkout is the path of the environment variable `PKDB_DATA`; without it the checkout is `../pkdb_data` relative to the working directory, i.e. a clone next to the repository a script runs in. A folder which does not exist is an error which names both: ```python import os from pathlib import Path from pkpdsim.data import PKDB_DATA, studies_dir # a checkout of pkdb_data with one study of albuterol study = Path("checkouts/pkdb_data/studies/albuterol/Boulton1996a") study.mkdir(parents=True) (study / ".Boulton1996a_Fig1.tsv").write_text("time\tmean\n0.0\t0.0\n") (study / "Boulton1996a_Fig1.png").write_bytes(b"") os.environ[PKDB_DATA] = "checkouts/pkdb_data" print(studies_dir().relative_to(Path.cwd()).as_posix()) os.environ[PKDB_DATA] = "missing/pkdb_data" try: studies_dir() except FileNotFoundError as error: print(type(error).__name__) ``` ```text checkouts/pkdb_data/studies FileNotFoundError ``` ## Collecting the data of a model `collect_tsv_files(source_dir, target_dir, filter_study=None)` copies the data files of every study folder of `source_dir` into a folder of the same name in `target_dir`; a study without data files is left out, as are the figures, the tables and the publication, and `filter_study` selects studies by the name of their folder. Every copy is reported on the console. Continuing the snippet above: ```python from pkpdsim.data import collect_tsv_files os.environ[PKDB_DATA] = "checkouts/pkdb_data" collect_tsv_files(source_dir=studies_dir() / "albuterol", target_dir=Path("data")) print(sorted(path.as_posix() for path in Path("data").rglob("*"))) ``` The console shows the study folder and every copy, from the absolute path of the source to the target, then the snippet prints the data folder: ```text ['data/Boulton1996a', 'data/Boulton1996a/.Boulton1996a_Fig1.tsv'] ``` A model calls it from the script `data/collect_data.py` of its folder: ```python # not executed: data/collect_data.py of a model, which needs a checkout of pkdb_data from pkdb_models.models.albuterol import DATA_PATH_ALBUTEROL from pkpdsim.data import collect_tsv_files, studies_dir collect_tsv_files( source_dir=studies_dir() / "albuterol", target_dir=DATA_PATH_ALBUTEROL ) ``` --- # Initial values A substance which is given before a study starts, or which the body produces itself, is present in every tissue at the start of a simulation. Setting its initial value in one compartment, e.g. the venous blood, leaves the model to distribute it first, and the simulation starts with distribution kinetics which the study never saw. `pkpdsim.initial_values` sets the same initial value in every tissue at once, so that the simulation starts in (or close to) the steady state of the distribution. The species of a substance are found by the naming pattern of the ids of the models: `C_` for a concentration and `A_` for an amount, e.g. `Cve_len`, `Cli_plasma_len` and `Aurine_len` for the venous blood, the plasma of the liver and the urine of lenvatinib (`len`). The urine collects what the body excretes and is left alone. ## Usage A model with a substance `x` in the venous blood, the liver and the urine, simulated with [libroadrunner](https://libroadrunner.org): ```python from pathlib import Path import roadrunner from sbmlutils.factory import Compartment, Model, Species, create_model from pkpdsim.initial_values import init_concentrations_changes model = Model( sid="distribution", compartments=[ Compartment("Vve", 5.0), Compartment("Vli", 1.5), Compartment("Vurine", 1.0), ], species=[ Species("Cve_x", "Vve", initialConcentration=0.0), Species("Cli_plasma_x", "Vli", initialConcentration=0.0), Species("Aurine_x", "Vurine", initialAmount=0.0, hasOnlySubstanceUnits=True), ], ) create_model(model, filepath=Path("distribution.xml"), validate=False) r = roadrunner.RoadRunner("distribution.xml") changes = init_concentrations_changes(r, "x", 2.0) print(changes) ``` ```text {'[Cve_x]': 2.0, '[Cli_plasma_x]': 2.0} ``` The keys are selections of roadrunner: `[sid]` the concentration of a species and `sid` its amount (`init_amounts_changes`). The changes are applied to the initial values of the model before the simulation starts: a `Simulation` of [sbmlsim](https://github.com/matthiaskoenig/sbmlsim) takes them as its `preinit_changes`, here they are set in roadrunner directly: ```python for key, value in changes.items(): r.setValue(f"init({key})", value) r.reset() print(r.getValue("[Cve_x]"), r.getValue("[Cli_plasma_x]"), r.getValue("Aurine_x")) ``` ```text 2.0 2.0 0.0 ``` `get_species_keys(skey, species_ids)` is the pattern alone; it does not see the species of the submodels of a composed model, only the ones of the top model, which a flattened model has all. --- # Licenses A model is published under two licenses: the model and its documentation under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), the source code which creates and simulates it under [MIT](https://opensource.org/license/MIT). The texts of both licenses travel with the model in its [COMBINE archive](https://combinearchive.org), the `.omex` file which packs the SBML, the documentation and the figures of a model into one file. `pkpdsim.licenses` holds the texts as resources of the package and writes them into a directory. The text of CC BY-SA 4.0 is the same for every model. The text of MIT starts with the copyright, which names the years and the authors of the model, e.g. `Copyright (c) 2024-2025 Matthias König`; it is the only part in which the MIT texts of two models differ, so it is the argument `copyright` of the functions, without the words `Copyright (c)`, which the text adds. ## Usage ```python from pathlib import Path from pkpdsim.licenses import License, license_text, write_license results = Path("results") results.mkdir() cc_by_sa = write_license(results, License.CC_BY_SA_4_0) mit = write_license(results, License.MIT, copyright="2026 Matthias König") print(cc_by_sa.name, mit.name) print(mit.read_text(encoding="utf-8").splitlines()[0]) print(license_text(License.CC_BY_SA_4_0).splitlines()[0]) ``` ```text cc-by-sa-4.0.txt mit.txt Copyright (c) 2026 Matthias König Attribution-ShareAlike 4.0 International ``` The files are named after the license, `cc-by-sa-4.0.txt` and `mit.txt`, and are written in UTF-8 with line feeds as line endings on every platform, so a model creates the same files byte for byte on Linux, macOS and Windows. A license is given as a `License` or as its value (`"mit"`); MIT without a copyright, CC BY-SA 4.0 with one, and a copyright which is empty, spans several lines or starts with `Copyright` are errors. ## The COMBINE archive of a model The factory of a model writes the texts next to its models and adds them to the entries of the archive, here with [pymetadata](https://github.com/matthiaskoenig/pymetadata): ```python # not executed: a fragment of the factory of a model from pymetadata.omex import EntryFormat, ManifestEntry from pkpdsim.licenses import License, write_license entries = { license.value: { "path": write_license(results_dir, license, copyright=copyright), "entry": ManifestEntry( location=f"./{license.filename}", format=EntryFormat.TXT, master=False ), } for license, copyright in [ (License.CC_BY_SA_4_0, None), (License.MIT, "2024-2025 Matthias König"), ] } ``` --- # API reference The API reference is generated from the docstrings of the package. ## pkpdsim | module | description | | --- | --- | | [pkpdsim](pkpdsim.md) | the package and its version | | [templates](templates.md) | `creators` and `terms_of_use` of a model, see [Templates and annotations](../templates.md) | | [annotations](annotations.md) | `compartments`, the annotations of the recurring compartments, see [Templates and annotations](../templates.md) | | [data](data.md) | `studies_dir`, `collect_tsv_files`: the study data of a model from the study folders of PK-DB, see [Study data](../data.md) | | [initial_values](initial_values.md) | `init_concentrations_changes`, `init_amounts_changes`, `get_species_keys`: the initial values of a substance in every tissue, see [Initial values](../initial_values.md) | | [licenses](licenses.md) | `License`, `license_text`, `write_license`: the texts of CC BY-SA 4.0 and MIT, see [Licenses](../licenses.md) | | [model](model.md) | `PKPDModel`, `SimulatorSettings`, `FigureSettings`, `CheckSettings`, `ExtraAction`, `resolve`: the description of a model, see [Models and their commands](../models.md) | | [run](run.md) | `run_experiments`, `run_simulation_experiments`: the run of the simulation experiments of a model | | [cli](cli.md) | `run_command`, `fit_command`, `report_command`, `identifiability_command`: the commands of a model | | [fit](fit.md) | `default_fit_settings`, `mapping_metadata`, `observable_id`, `metadata_filter`, `observable_filter`, `select_mappings`: the shared fit settings, the filters and the selection of the mappings of a fit, see [Experiments and their metadata](../experiments.md) | | [metadata](metadata.md) | `Tissue`, `Route`, `Dosing`, `Fasting`, `ApplicationForm`, `DataType`, `PKPDData`, `PKPDMappingMetaData`: the vocabulary of the metadata of a fit mapping, see [Experiments and their metadata](../experiments.md) | | [experiment](experiment.md) | `PKPDSimulationExperiment`, `to_sid`, `define_unit`, `rename_units`: the base of the simulation experiments of a model, the SIds of its keys and the units of its data, see [Experiments and their metadata](../experiments.md) | | [impairment](impairment.md) | the presets of the renal function, the cirrhosis and the cardiac function with their colors | | [pk](pk.md) | `PK_LABELS`, `PK_UNITS`: the labels and units of the pharmacokinetic parameters | | [scan](scan.md) | `single_dimension`, `point_timecourse`: the dimension of a scan and the timecourse of a point of its result, see [Scans and sensitivity analyses](../scans.md) | | [sensitivity](sensitivity.md) | `parameters_from_sbml`, `set_bounds`, `parameters_to_df`, `write_parameters`, `distributions`, `sample_size`: the parameters of the sensitivity analyses of a model, see [Scans and sensitivity analyses](../scans.md) | | [testing](testing.md) | `check_model`, `check_registry`, `initialize_experiments`, `run_all_experiments`, `start_fits`: the tests of a model as functions | | [verify](verify.md) | `pkpdsim-verify`: the verification of a change of a model | --- # pkpdsim pkpdsim: the building blocks of physiologically based PK/PD models in SBML. The models of a substance, e.g. the models of [pkdb_models](https://github.com/matthiaskoenig/pkdb_models), are written in python with the factory of sbmlutils and simulated with sbmlsim. What these models share, and what is specific to the simulation of pharmacokinetics and pharmacodynamics, lives in pkpdsim, so that the folder of a model imports nothing but itself and released packages. Every topic is a module of its own, which a model imports directly: `pkpdsim.templates` (the creators and the terms of use of a model), `pkpdsim.annotations` (the annotations of the recurring compartments), `pkpdsim.data` (the study data of a model from the study folders of PK-DB), `pkpdsim.initial_values` (the initial values of a substance in every tissue) and `pkpdsim.licenses` (the license texts of the COMBINE archive of a model). `import pkpdsim` imports none of them. --- # pkpdsim.templates The creators and the terms of use of a model. A model sets them in its `sbmlutils.factory.Model`: the creators as `creators`, the terms of use appended to its `notes`, behind the description of the model. The terms are indented by four spaces, and so has to be the description: sbmlutils removes the indentation all lines of the notes have in common before it converts the markdown, and lines which keep four spaces are a code block. A model with further creators extends the list, e.g. `creators=[*creators, Creator(...)]`. --- # pkpdsim.annotations The annotations of the recurring compartments of the models. An annotation is a list of `(qualifier, resource)` pairs: the qualifier is a biological qualifier of `sbmlutils.metadata.BQB` and the resource a term of an ontology, written as `collection/term` of identifiers.org. sbmlutils writes them as the RDF of the element they are given to, e.g. `Compartment("Vli", ..., annotations=compartments["li"])` for the liver. --- # pkpdsim.data The study data of a model, collected from the study folders of PK-DB. The studies of [PK-DB](https://pk-db.com) are curated in a checkout of [pkdb_data](https://github.com/matthiaskoenig/pkdb_data): the folder `studies` holds a folder per substance and in it a folder per study, with the digitized data as hidden `.tsv` files next to the figures and tables they were read from. A model keeps the subset of the studies it uses in its own `data/` folder, so its simulations run without the checkout; `collect_tsv_files` copies them there from the folder `studies_dir()` finds. ## function `collect_tsv_files(source_dir: pathlib.Path, target_dir: pathlib.Path, filter_study: collections.abc.Callable[[str], bool] | None = None) -> None` Copy the data of the studies of a substance into the data folder of a model. The data files of a study folder of pkdb_data are its hidden `.tsv` files (`.Boulton1996a_Fig1.tsv`); the figures, the tables and the publication are left behind. Every study folder of `source_dir` which passes `filter_study` and has data files becomes a folder of the same name in `target_dir` with copies of them, and every copy is reported on the console. Args: source_dir: the folder of the studies of a substance, e.g. `studies_dir() / "albuterol"`. target_dir: the data folder of the model, created if it does not exist (its parent has to). filter_study: decides by the name of a study folder whether the study is collected; all studies by default. ## function `studies_dir() -> pathlib.Path` Get the folder of the PK-DB studies, `studies` of a checkout of pkdb_data. The checkout is the path of the environment variable `PKDB_DATA`; without the variable it is `../pkdb_data` relative to the working directory, i.e. a checkout next to the repository a script runs in. Returns: The absolute path of the folder `studies` of the checkout. Raises: FileNotFoundError: if the folder does not exist; the message names the environment variable and the default location. --- # pkpdsim.initial_values The initial values of a substance in every tissue of a model. A substance which is transported in the body is initialized in every tissue to the same value, which removes the distribution kinetics: the simulation starts in (or close to) the steady state of the distribution. The functions return the changes of a simulation, `[sid]` for the concentration and `sid` for the amount of a species, and leave the urine alone. The species of a substance are found by the naming pattern of the ids of the models, `C_` for a concentration and `A_` for an amount (`Cve_len`, `Cli_plasma_len`, `Aurine_len`), see `get_species_keys`. ## function `get_species_keys(skey: str, species_ids: list[str]) -> list[str]` Get keys of substance in given list of ids. Relies on naming patterns of ids. This does not get the species ids of the submodels, but only of the top model. Args: skey: substance key. species_ids: list of species ids to filter from. Returns: Species ids of the substance. ## function `init_amounts_changes(r: roadrunner.roadrunner.RoadRunner, skey: str, value: float) -> dict[str, float]` Get changes to set initial amounts for skey. Args: r: roadrunner model. skey: substance key, e.g., `len` for the species `Cve_len`. value: initial amount in model units. Returns: Changes for the model, species selection to value. ## function `init_concentrations_changes(r: roadrunner.roadrunner.RoadRunner, skey: str, value: float) -> dict[str, float]` Get changes to set initial concentrations for skey. Args: r: roadrunner model. skey: substance key, e.g., `len` for the species `Cve_len`. value: initial concentration in model units. Returns: Changes for the model, species selection to value. --- # pkpdsim.licenses The license texts of a model, e.g. for its COMBINE archive. A model is published under two licenses: the models and the documentation under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), the source code under [MIT](https://opensource.org/license/MIT). The texts are resources of the package; the text of CC BY-SA 4.0 is the same for every model, the text of MIT starts with the copyright of the model, e.g. `Copyright (c) 2024-2025 Matthias König`, which is the only part in which the MIT texts of two models differ. `write_license` writes a text as `cc-by-sa-4.0.txt` or `mit.txt` into a directory, from where the factory of a model adds it to the COMBINE archive. ## class `License(*values)` A license of a model, its value is the name of its file without `.txt`. ## function `license_text(license: pkpdsim.licenses.License | str, copyright: str | None = None) -> str` Get the text of a license. Args: license: the license, or its value (`"mit"`). copyright: the year or years and the holders of the copyright, e.g. `2024-2025 Matthias König`, which make the first line of the MIT text `Copyright (c) 2024-2025 Matthias König`. Required for MIT, not allowed for CC BY-SA 4.0, whose text has no copyright line. Returns: The text of the license, every line ends with a line feed. Raises: ValueError: for a missing or a superfluous copyright, or a copyright which is empty, spans several lines or starts with `Copyright` itself. ## function `write_license(directory: pathlib.Path, license: pkpdsim.licenses.License | str, copyright: str | None = None) -> pathlib.Path` Write the text of a license into a directory. The file is named after the license (`cc-by-sa-4.0.txt`, `mit.txt`) and is written in UTF-8 with line feeds as line endings on every platform, so it is the same file byte for byte wherever a model is created; an existing file is replaced. Args: directory: the directory of the file, which has to exist, e.g. the folder whose files the factory of a model packs into its COMBINE archive. license: the license, or its value (`"mit"`). copyright: the year or years and the holders of the copyright, required for MIT, see `license_text`. Returns: The path of the written file. --- # pkpdsim.model The description of a PBPK/PD model, which the commands of pkpdsim work with. A model package defines one `PKPDModel` in its `__init__.py`, next to its path constants: ```python # not executed MODEL = PKPDModel( name="hctz", package=__name__, title="HCTZ PBPK/PD model", path=HCTZ_PATH, model_path=MODEL_PATH, data_paths=tuple(DATA_PATHS), factory=".models.factory:create_hctz_models", experiments=".simulations:EXPERIMENTS", fit_definitions=".fitting.fitting:FIT_DEFINITIONS", ) ``` The factory, the registry of the experiments and the fit definitions are references `"module:attribute"`, resolved when they are used: the experiments import the `__init__.py` of their model, so the model object cannot import them, and importing a model package does not import its experiments. A module which starts with a dot is relative to `package`. `FigureSettings` is the `sbmlsim.plot.FigureSettings` of sbmlsim with the formats of the figures and the resolution of the static images; the module therefore imports sbmlsim's plotting. ## class `CheckSettings(*, not_registered: 'Mapping[str, str]' = , not_importable: 'Mapping[str, str]' = , one_at_a_time: 'str | None' = None) -> None` The known exceptions of the tests of a model, each with its reason. All fields are keyword-only. The mappings are copied, so the settings do not change with the dictionaries they were created from, and the settings are hashable. Attributes: not_registered: experiment classes which are defined but not in the group `all` of the registry on purpose, by class name. not_importable: modules below the package which do not import in the test environment (an optional dependency), by their name relative to the package, e.g. `experiments.zonation.plot_pyvista`. one_at_a_time: why the tests run the registered experiments one at a time (the memory a run of all of them needs), `None` to run them together. ## class `ExtraAction(*, name: 'str', help: 'str', function: 'str', in_all: 'bool' = False) -> None` An action a model adds to its `run` command, e.g. analyses of its own. All fields are keyword-only. Attributes: name: the value of `--action`. help: what the action does, shown by `--help`. function: reference to the function which runs the action; it is called with the model, the parsed arguments and the directory of the results. The arguments have the settings of the simulator of the run as `simulator` (`SimulatorSettings`, those of the model with the processes of `--workers`), which an action that simulates passes on, e.g. `model.run_experiments(..., simulator=options.simulator)` or `pkpdsim.run.create_simulator(options.simulator)`. in_all: run the action with `--action all`, after the simulations. ## class `FigureSettings(fig_dpi: 'float' = 72, fig_facecolor: 'str' = 'white', fig_subplots_wspace: 'float' = 0.3, fig_subplots_hspace: 'float' = 0.3, panel_width: 'float' = 7.0, panel_height: 'float' = 5.0, fig_titlesize: 'float' = 25, fig_titleweight: 'str' = 'bold', axes_titlesize: 'float' = 20, axes_titleweight: 'str' = 'bold', axes_labelsize: 'float' = 18, axes_labelweight: 'str' = 'bold', xtick_labelsize: 'float' = 15, ytick_labelsize: 'float' = 15, legend_fontsize: 'float' = 13, legend_position: 'str' = 'inside', legend_loc: 'str' = 'best', *, formats: 'Sequence[str]' = ('svg', 'png'), savefig_dpi: 'float | None' = None) -> None` The figures of the simulation experiments of a model. The settings of sbmlsim's figures (`sbmlsim.plot.FigureSettings`: the resolution, the sizes of the panels, the fonts and the legends, with the defaults of sbmlsim) and what a model adds: the formats of the figures and the resolution of the static images. A run hands the settings of sbmlsim to every figure (`sbmlsim_settings`); an experiment (`figure_settings`) and a figure (`Figure(settings=...)`) override them. `savefig_dpi` is the `savefig.dpi` of matplotlib while the figures of a run are drawn (`rc_params`). The fields of sbmlsim can be given by position, as in sbmlsim; `formats` and `savefig_dpi` only by keyword. Attributes: formats: the formats of the figures, static images drawn by matplotlib (`svg`, `png`) and interactive pages drawn by plotly (`html`). savefig_dpi: the resolution of the static images, `None` keeps the one of matplotlib. An interactive page has 72 pixels per inch. ### `FigureSettings.rc_params(self) -> 'dict[str, float]'` Get the settings of matplotlib for a run, the resolution of the images. ### `FigureSettings.sbmlsim_settings(self) -> 'SbmlsimFigureSettings'` Get the settings of sbmlsim, without the formats and the resolution. A plain `sbmlsim.plot.FigureSettings`, which sbmlsim compares with its defaults (a run with the defaults sets nothing). ## class `PKPDModel(*, name: 'str', package: 'str', title: 'str', path: 'Path', model_path: 'Path', factory: 'str', experiments: 'str | None' = None, fit_definitions: 'str | None' = None, data_paths: 'Sequence[Path]' = (), results_path: 'Path | None' = None, factory_path: 'Path | None' = None, archive_path: 'Path | None' = None, simulator: 'SimulatorSettings' = , figures: 'FigureSettings' = , reduced_selections: 'bool' = True, save_results: 'bool' = False, fit_identifiability: 'bool' = False, extra_actions: 'Sequence[ExtraAction]' = (), extra_arguments: 'str | None' = None, checks: 'CheckSettings' = ) -> None` A PBPK/PD model: what the commands, the verification and the tests need. All fields are keyword-only, so their order is not API. The sequences (`data_paths`, `extra_actions` and the formats of the figures) are stored as tuples, so a list can be passed and the model is hashable, unless the absolute tolerance of the simulator is an `AbsoluteTolerance`, which is not hashable. Attributes: name: the short name, the suffix of the commands (`run_`). package: the dotted name of the model package, `__name__` in its `__init__.py`; relative references are relative to it. title: the title of the model in the output of the commands. path: the directory of the model package, the base path of its experiments. model_path: the SBML model the experiments simulate. factory: reference to the function which creates the models; it is called with the output directory. experiments: reference to the registry of the experiments, groups of experiment classes by name with the group `all`. fit_definitions: reference to the fit problems by name (`sbmlsim.fit.cli.FitDefinition`). data_paths: the directories of the study data. results_path: the directory of the results, `/results` by default. factory_path: the output directory of the factory, the directory of `model_path` by default. archive_path: the COMBINE archive the factory creates, linked by the `factory` action. simulator: the integrator settings of the experiments. figures: the formats and settings of the figures. reduced_selections: simulate only the selections the experiments need. save_results: store the results of the simulations by default. fit_identifiability: the fit command analyses the identifiability of the fitted parameters by default. extra_actions: actions the model adds to its `run` command. extra_arguments: reference to a function which adds the options of the extra actions to the parser of the `run` command. checks: the known exceptions of the tests of the model, see `pkpdsim.testing`. ### `PKPDModel.create_models(self, output_dir: 'Path | None' = None) -> 'Path'` Create the models with the factory. Args: output_dir: the output directory, `factory_dir` by default. Returns: The output directory. ### `PKPDModel.fit_main(self, args: 'Sequence[str] | None' = None) -> 'None'` The command `fit_`, see `pkpdsim.cli.fit_command`. Returns nothing: a console script exits with the return value of the function it calls, and 1 for anything which is not `None` or an int. ### `PKPDModel.identifiability_main(self, args: 'Sequence[str] | None' = None) -> 'None'` The command `identifiability_`, see `pkpdsim.cli`. ### `PKPDModel.load_experiments(self) -> 'Mapping[str, list[type[SimulationExperiment]]]'` Get the registry of the experiments. ### `PKPDModel.load_factory(self) -> 'Callable[[Path], object]'` Get the function which creates the models. ### `PKPDModel.load_fit_definitions(self) -> 'Mapping[str, FitDefinition]'` Get the fit problems by name. ### `PKPDModel.report_main(self, args: 'Sequence[str] | None' = None) -> 'None'` The command `report_`, see `pkpdsim.cli.report_command`. ### `PKPDModel.run_experiments(self, experiment_classes: 'type[SimulationExperiment] | Sequence[type[SimulationExperiment]]', output_dir: 'str | Path', *, save_results: 'bool | None' = None, show_report: 'bool' = False, figure_formats: 'Sequence[str] | None' = None, simulator: 'SimulatorSettings | None' = None) -> 'Path'` Run simulation experiments and create their report. See `pkpdsim.run.run_experiments`; a run with a failed experiment or figure raises `sbmlsim.experiment.ExperimentRunError` after its report is written. ### `PKPDModel.run_main(self, args: 'Sequence[str] | None' = None) -> 'None'` The command `run_`, see `pkpdsim.cli.run_command`. ### `PKPDModel.run_simulation_experiments(self, experiment_classes: 'Sequence[type[SimulationExperiment]]', output_dir: 'str | Path', *, show_report: 'bool' = False, figure_formats: 'Sequence[str] | None' = None, simulator: 'SimulatorSettings | None' = None) -> 'Path'` Run simulation experiments and collect their figures. See `pkpdsim.run.run_simulation_experiments`. ## class `SimulatorSettings(*, absolute_tolerance: 'float | AbsoluteTolerance' = 1e-10, relative_tolerance: 'float' = 1e-10, n_workers: 'int | None' = 1) -> None` The integrator settings of the simulation experiments of a model. All fields are keyword-only. Attributes: absolute_tolerance: the absolute tolerance, one value for every state or an `AbsoluteTolerance` of sbmlsim with tolerances per kind and state. relative_tolerance: the relative tolerance. n_workers: the processes of a scan: `1` runs serially, a number runs a pool of that many processes, `None` lets sbmlsim choose (a pool of every CPU for a scan of `sbmlsim.parallel.POOL_THRESHOLD` points or more, serially otherwise). A pool needs the guard `if __name__ == "__main__":` in a script which runs the model. ## function `check_reference(reference: 'str') -> 'None'` Check that a reference has the form `"module:attribute"`. The module may start with dots (relative to a package), the attribute may be dotted (an attribute of an attribute). Args: reference: the reference to check. Raises: ValueError: if the reference does not have the form. ## function `normalize_formats(formats: 'Sequence[str]') -> 'tuple[str, ...]'` Get figure formats as a lower-case tuple. Raises: TypeError: if the formats are a single string, e.g. `"svg"` instead of `("svg",)`; a string is a sequence of characters. ## function `resolve(reference: 'str', package: 'str | None' = None) -> 'Any'` Resolve a reference `"module:attribute"` to the object it names. Args: reference: the reference; a module which starts with a dot is relative to `package`. package: the package of relative references. Returns: The object. Raises: ValueError: if the reference is malformed or relative without a package. AttributeError: if the module has no such attribute; the message names the reference. --- # pkpdsim.run Running the simulation experiments of a model and collecting their figures. This is the `helpers.run_experiments` and the `run_simulation_experiments` of `simulations.py` which every model of pkdb_models had as a copy: the experiments run with the simulator settings of the model, the figures are drawn with its figure settings, the HTML report is written, and `run_simulation_experiments` copies the figures into `_figures`. Every experiment runs, also after another one failed: sbmlsim logs a failure with its traceback and records it. Once the report (and the collected figures) are written, a run with a failure raises `ExperimentRunError`, whose `failures` name every failed experiment and figure. ## function `create_simulator(settings: 'SimulatorSettings') -> 'Simulator'` Get the simulator of sbmlsim with the settings of a model. Args: settings: the tolerances and the processes of the simulations. Returns: The simulator. ## function `run_experiments(model: 'PKPDModel', experiment_classes: 'type[SimulationExperiment] | Sequence[type[SimulationExperiment]]', output_dir: 'str | Path', *, save_results: 'bool | None' = None, show_report: 'bool' = False, figure_formats: 'Sequence[str] | None' = None, simulator: 'SimulatorSettings | None' = None) -> 'Path'` Run simulation experiments of a model and create their report. sbmlsim does not configure logging, so the messages of the package are shown on the rich console explicitly. Args: model: the model whose experiments run. experiment_classes: the experiment class or classes to run. output_dir: directory of the results; a relative directory is below the simulation results of the model (`model.simulation_dir`). save_results: store the results of the simulations, `model.save_results` by default. show_report: open the report in a web browser. figure_formats: the formats of the figures, those of the model by default; an empty sequence falls back to the formats of the model as well. simulator: the settings of the simulator, those of the model by default, e.g. with more processes. Returns: The directory the results were written to. Raises: TypeError: if `figure_formats` is a single string. ExperimentRunError: after the report is written, if an experiment or a figure failed; its `failures` name them, the log of the run has the tracebacks. ## function `run_simulation_experiments(model: 'PKPDModel', experiment_classes: 'Sequence[type[SimulationExperiment]]', output_dir: 'str | Path', *, show_report: 'bool' = False, figure_formats: 'Sequence[str] | None' = None, simulator: 'SimulatorSettings | None' = None) -> 'Path'` Run simulation experiments and collect their figures. The static figures of all experiments (`COLLECTED_FORMATS`) are copied into `_figures` of the output directory, also those of a run with failures. Args: model: the model whose experiments run. experiment_classes: the experiments to run. output_dir: directory of the results, see `run_experiments`. show_report: open the report in a web browser. figure_formats: the formats of the figures, those of the model by default. simulator: the settings of the simulator, those of the model by default. Returns: The directory of the collected figures. Raises: ExperimentRunError: after the figures are collected, if an experiment or a figure failed. --- # pkpdsim.cli The commands of a model. A model package registers its commands in `[project.scripts]` with its model object, e.g. for hctz: ```toml run_hctz = "pkdb_models.models.hctz:MODEL.run_main" fit_hctz = "pkdb_models.models.hctz:MODEL.fit_main" report_hctz = "pkdb_models.models.hctz:MODEL.report_main" identifiability_hctz = "pkdb_models.models.hctz:MODEL.identifiability_main" ``` `run_` creates the models with the factory and runs the simulation experiments: ```bash run_hctz --action factory run_hctz --action list_experiments run_hctz --action simulate --experiments "misc,Anderson1961" --open run_hctz --action all --results-dir /path/to/results ``` A single group is written into the directory of the group, any other selection into `custom_selection`, both in the `simulation` folder of the results. The output ends with the links to the report and to the figures. `fit_` fits the parameters of a fit problem and, with `--identifiability` (the default of a model with `fit_identifiability`), analyses the identifiability of the fitted parameters: the profile likelihood of every fitted parameter and the Fisher information around the best parameter set, which end up in the identifiability section of the report of the fit. `report_` and `identifiability_` work on the `parameters.json` of an earlier fit (`sbmlsim.fit.cli.report_cli`, `identifiability_cli`). All write into the results of the model unless `--output_dir` is given. ## class `Action(*values)` The actions of the `run` command, `pkpdsim.model.RUN_ACTIONS`. ## function `analyse_identifiability(run: 'FitRun', options: 'argparse.Namespace') -> 'tuple[IdentifiabilityResult | None, FisherInformation | None]'` Analyse the identifiability of the parameters of a finished fit. The profile likelihood is the exact answer and the Fisher information the cheap local one next to it; both are computed around the best parameter set of the fit and end up in the identifiability section of its report. Args: run: finished fit whose best parameter set is analysed. options: command line arguments of `fit_parser`. Returns: The profiles and the Fisher information, `None` for what was skipped. ## function `fit_command(model: 'PKPDModel', args: 'Sequence[str] | None' = None) -> 'dict[str, FitRun]'` Run a fit of a model and analyse the identifiability of its parameters. Args: model: the model. args: command line arguments, `sys.argv[1:]` by default. Returns: The finished fits by optimization id. ## function `fit_parser(model: 'PKPDModel', definitions: 'Mapping[str, FitDefinition]') -> 'argparse.ArgumentParser'` Get the parser of the `fit` command of a model. The fit arguments are those of `sbmlsim.fit.cli.fit_cli`, the identifiability arguments are the settings of the profile likelihood which follows the fit. ## function `identifiability_command(model: 'PKPDModel', args: 'Sequence[str] | None' = None) -> 'Path'` Analyse the identifiability of stored parameters of a model. See `sbmlsim.fit.cli.identifiability_cli`. Args: model: the model. args: command line arguments, `sys.argv[1:]` by default. Returns: The directory of the report. ## function `parse_formats(text: 'str') -> 'list[str]'` Get the figure formats of a comma separated list. Args: text: the formats, e.g. `"svg, PNG"`. Returns: The formats in lower case, without blanks and empty entries. ## function `parse_workers(text: 'str', model: 'PKPDModel') -> 'int | None'` Get the processes of a run from the option `--workers`. Args: text: `model` for the setting of the model, `auto` for the choice of sbmlsim (`None`: a pool of every CPU for a scan of 256 points or more), or a positive number. model: the model. Returns: The number of processes, `None` for the choice of sbmlsim. Raises: ValueError: for any other text. ## function `report_command(model: 'PKPDModel', args: 'Sequence[str] | None' = None) -> 'Path'` Report stored parameter sets of a model (`sbmlsim.fit.cli.report_cli`). Args: model: the model. args: command line arguments, `sys.argv[1:]` by default. Returns: The directory of the report. ## function `resolve_experiments(registry: 'Mapping[str, Sequence[type[SimulationExperiment]]]', names: 'Sequence[str]') -> 'tuple[list[type[SimulationExperiment]], list[str]]'` Resolve the names of groups and experiments to the experiment classes. Args: registry: the groups of experiments of a model. names: names of groups or of experiment classes. Returns: The experiment classes, every class once in the order of the names, and the names which are neither a group nor an experiment. ## function `run_command(model: 'PKPDModel', args: 'Sequence[str] | None' = None) -> 'None'` Run the action of the command line of `run_`. Args: model: the model. args: the command line arguments, `sys.argv[1:]` by default. A failure of the simulations does not stop the command: the remaining actions run, then the failures are printed and the command exits with 1. An extra action gets the settings of the simulator of the run as `options.simulator`: those of the model with the processes of `--workers`. ## function `run_parser(model: 'PKPDModel') -> 'argparse.ArgumentParser'` Get the parser of the `run` command of a model. ## function `with_output_dir(args: 'Sequence[str]', output_dir: 'Path') -> 'list[str]'` Add the output directory of the model to command line arguments. The commands of `sbmlsim.fit.cli` write into the working directory by default, a model writes into its results instead. Args: args: command line arguments without the program name. output_dir: output directory used if the arguments do not name one. Returns: The arguments with the output directory in front of them. argparse keeps the last value of an option, so an output directory in the arguments, also an abbreviation like `--out`, wins. --- # pkpdsim.fit The fit settings, the metadata filters and the selection of the mappings. The models of pkdb_models share their fit settings apart from the integrator tolerances (`default_fit_settings`). Every `mapping_collections.py` checks the type of the metadata of a mapping (`mapping_metadata`), reads the observable of a mapping (`observable_id`) and selects the training data by the values of the metadata (`metadata_filter`) or the observable (`observable_filter`). `select_mappings` selects the data of a fit with the paths of the model object, see `sbmlsim.fit.helpers.FitMappings.select` for the filters, outliers and validation data. ## class `MetadataFilter(metadata_type: 'type[MappingMetaData]', allowed: 'tuple[tuple[str, frozenset[Hashable]], ...]', excluded: 'tuple[tuple[str, frozenset[Hashable]], ...]') -> None` A filter of fit mappings by the values of their metadata. A mapping passes when every allowed field has one of its values and no excluded field has one of its values. Build it with `metadata_filter`. Attributes: metadata_type: the metadata class of the model. allowed: the fields with the values a mapping must have. excluded: the fields with the values a mapping must not have. ## class `ObservableFilter(observables: 'frozenset[str]') -> None` A filter of fit mappings by their observable, see `observable_id`. Attributes: observables: the ids of the observables a mapping may have, the index of the data (`Data.index`). ## function `default_fit_settings(absolute_tolerance: 'float | AbsoluteTolerance', relative_tolerance: 'float' = 1e-06) -> 'FitSettings'` Get the settings of the fits of a model. Normalized residuals with a linear loss; the curves are weighted by the weights of their mappings and by their number of points, the points by their errors (a mapping without errors is weighted with a CV of 0.5); the integrator takes variable steps; a data point at the time of a change after the start is compared with the state before the change (`data_at_change`), sbmlsim's default, written out so that a change of the default does not change the fits. Args: absolute_tolerance: the absolute tolerance of the integrator, one value or an `AbsoluteTolerance` per kind and state. relative_tolerance: the relative tolerance of the integrator. Returns: The settings, stored with the result of a fit and read back by its report. ## function `mapping_metadata(fit_mapping: 'FitMapping', metadata_type: 'type[M]') -> 'M'` Get the metadata of a fit mapping, checked against the type of the model. Args: fit_mapping: the fit mapping. metadata_type: the metadata class of the model. Returns: The metadata. Raises: TypeError: if the mapping has no metadata of the type. ## function `metadata_filter(metadata_type: 'type[MappingMetaData]', *, excluded: 'Mapping[str, Collection[Hashable]] | None' = None, **allowed: 'Collection[Hashable]') -> 'MetadataFilter'` Build a filter of fit mappings by the values of their metadata. ```python # not executed filter_control = metadata_filter( HctzMappingMetaData, route={Route.PO, Route.IV}, coadministration={Coadministration.NONE}, excluded={"dosing": {Dosing.MULTIPLE}}, ) ``` Args: metadata_type: the metadata class of the model, a dataclass. excluded: the fields with the values a mapping must not have. **allowed: the fields with the values a mapping must have. Returns: The filter, called by the selection of the mappings of a fit. Raises: TypeError: if the metadata class is no dataclass (or an instance of one), has no field of a given name, or the values of a field are not a collection (a single value, also a string or `None`), i.e. when the module of the filters is imported. ValueError: for a filter without clauses, with an empty set of allowed values, which would select nothing, or with an empty set of excluded values, which would exclude nothing. ## function `observable_filter(observables: 'Collection[str]') -> 'ObservableFilter'` Build a filter of fit mappings by their observable, e.g. the PK data. Args: observables: the ids of the observables, the index of the data (`Data.index`), e.g. `Cve_hctz`. Returns: The filter. Raises: TypeError: if the observables are a single id or not a collection. ValueError: for no observables, which would select nothing. ## function `observable_id(fit_mapping: 'FitMapping') -> 'str'` Get the observable of a fit mapping, the index of its data. The index of the y data of the observable (`sbmlsim.data.Data.index`): the selection without the brackets of a concentration, `Cve_hctz` for `[Cve_hctz]`, `Aurine_hctz` for `Aurine_hctz`. sbmlsim 0.9 writes the concentration into the id of the data (`__conc__Cve_hctz`), so the id is no longer read. Args: fit_mapping: the fit mapping. Returns: The id of the observable, e.g. `Cve_hctz`. ## function `select_mappings(model: 'PKPDModel', experiment_classes: 'Iterable[type[SimulationExperiment]]', filters: 'MappingFilter | Iterable[MappingFilter]' = (), outliers: 'Iterable[str]' = (), validation: 'Iterable[str] | MappingFilter' = ()) -> 'dict[str, list[FitMappingCollection]]'` Select the data of a fit of a model, see `sbmlsim.fit.helpers.FitMappings`. The experiments are created with the paths of the model object; the counts of the selected mappings per experiment and kind are printed. Args: model: the model object. experiment_classes: the experiments with the fit mappings. filters: the filters of the training data. outliers: the keys of the training data which is not usable. validation: the keys or a filter of the validation data. Returns: The fit mapping collections of all kinds by experiment id. --- # pkpdsim.metadata The vocabulary with which the fit mappings of the models are described. Every fit mapping of an experiment carries metadata which says what its curve is: the tissue, the route, the application form, the dosing, the fasting, the health of the subjects and what was coadministered. The fits select their data by these values (see `pkpdsim.fit.metadata_filter`), so a model describes its studies with the same words as the other models: - the closed vocabularies are shared, the enums of this module; - the open vocabularies (the health of the subjects, the coadministered substances, the genotypes) are the model's own `StrEnum`s, which grow with its studies. Their conventional members are `HEALTHY`, `NONE` (nothing coadministered) and `NR` (not reported), and a member has the same name and value in every model. A model derives the metadata of its mappings from `PKPDMappingMetaData` and adds its own fields: ```python # not executed @dataclass(kw_only=True) class HctzMappingMetaData(PKPDMappingMetaData): health: Health coadministration: Coadministration = Coadministration.NONE ``` The tables of sbmlsim show the metadata by member name (`to_dict`); no code reads the values, which are readable text. ## class `ApplicationForm(*values)` The pharmaceutical form of the administered substance. ## class `DataType(*values)` What the values of a curve are. ## class `Dosing(*values)` The dosing of a substance. ## class `Fasting(*values)` The fasting state of the subjects at the administration. ## class `PKPDData(*values)` Whether a curve is pharmacokinetic or pharmacodynamic data. ## class `PKPDMappingMetaData(*, tissue: 'Tissue', route: 'Route', application_form: 'ApplicationForm' = , dosing: 'Dosing', fasting: 'Fasting' = ) -> None` The metadata of a fit mapping: the facets every model describes. The metadata describes the curve; what a fit does with it is decided by the filters of the fits. The fields are keyword-only, so a model adds fields without a default after the defaults of this class. Attributes: tissue: the tissue of the measurement. route: the route of the administration. application_form: the pharmaceutical form, not reported by default. dosing: the dosing. fasting: the fasting state, not reported by default. ### `PKPDMappingMetaData.to_dict(self) -> 'dict[str, str]'` Get the metadata by field: the member names of the enums. Any other value is its string (`"None"` for `None`). The order is that of the fields, the fields of this class first. ## class `Route(*values)` The route of the administration of a substance. Not `pkpdutils.Route` (`ORAL`, `IV_BOLUS`), the route of a dose in the non-compartmental analysis; a module which needs both imports one under an alias. ## class `Tissue(*values)` The tissue of a measurement. --- # pkpdsim.experiment The base class of the simulation experiments of a model. The base of the experiments of a model derives from `PKPDSimulationExperiment` and binds it to the model object; the experiments of the model derive from that base: ```python # not executed from sbmlsim import Q class HctzSimulationExperiment(PKPDSimulationExperiment): pkpd_model = MODEL selections = ("time", "[Cve_hctz]", "Aurine_hctz") Mr: ClassVar[MolecularWeights] = MolecularWeights(hctz=Q(297.74, "g/mole")) ``` The base simulates the model of the model object (`models`), creates a task per simulation (`tasks`), adds the selections to every task (`data`), loads the digitized data of the study (`load_dataframe`) and has the classes of impaired organ function (`pkpdsim.impairment`) and the labels of the pharmacokinetic parameters (`pkpdsim.pk`) as defaults. A model overrides what it does differently. The keys of an experiment are SIds (sbmlsim checks them): `to_sid` gives the SId of a text, e.g. of a label of the data, and the method `to_sid` of the base checks that no two texts of an experiment give the same SId. A unit which the data use and sbmlsim does not define (an international unit is specific to a substance) is defined under a name of the model and the substance, `data_units`. ## class `PKPDSimulationExperiment(sid: 'str | None' = None, base_path: 'Path | None' = None, data_path: 'Path | Iterable[Path] | None' = None, ureg: 'UnitRegistry | None' = None, **kwargs: 'object') -> 'None'` The base of the simulation experiments of a model. Attributes: pkpd_model: the model object of the model, set once in the base of its experiments. selections: the selections of every task, see `data`. Mr: the molecular weights of the substances, for the conversion of the data (a `NamedTuple` of the model). labels: the labels of the observables by id. units: the units of the observables by id. renal_map: the factor of the renal function by class. renal_colors: the color of a class of the renal function. cirrhosis_map: the degree of cirrhosis by class. cirrhosis_colors: the color of a class of the cirrhosis. cardiac_map: the factor of the cardiac output by class. cardiac_colors: the color of a class of the cardiac function. pk_labels: the label of a pharmacokinetic parameter. pk_units: the unit of a pharmacokinetic parameter. data_units: the units of the data which the experiment reads under a name of its own, see `define_unit`. ### `PKPDSimulationExperiment.data(self) -> 'dict[str, Data]'` Add the selections to every task; returns no further data. ### `PKPDSimulationExperiment.default_changes(self) -> 'Mapping[str, Quantity | float]'` Get the changes of every simulation of the experiments, none. The parameters are part of the model and are not changed in the simulations: a parameter which is fitted must not be set by a simulation, it would override the value of the fit. ### `PKPDSimulationExperiment.figures_by_sid(*figures: 'Figure') -> 'dict[str, Figure]'` Get figures by their id, which every figure of the experiments has. Raises: ValueError: for a figure without an id. ### `PKPDSimulationExperiment.initialize(self) -> 'None'` Define the units of the data and initialize the experiment. The units of `data_units` are defined before the data are read, see `SimulationExperiment.initialize`. Raises: ValueError: for a definition of `data_units` which `define_unit` refuses; the runner reports the experiment as failed. ### `PKPDSimulationExperiment.load_dataframe(self, fig_id: 'str', study: 'str | None' = None, *, na_values: 'Sequence[str] | Mapping[str, Sequence[str]] | None' = None) -> 'pd.DataFrame'` Load the digitized data of a figure or table of a study. Args: fig_id: the id of the figure or table in the study, e.g. `Fig1`. study: the study, the id of the experiment by default. na_values: additional strings of missing values, for every column or by column, e.g. `{"time": ["NR"]}`, see `pandas.read_csv`. Raises: ValueError: if the experiment has no `data_path` or a definition of `data_units` is refused. ### `PKPDSimulationExperiment.load_dataframes_by_substance(self, fig_id: 'str', study: 'str | None' = None) -> 'dict[str, pd.DataFrame]'` Load the digitized data of a figure or table by substance. Args: fig_id: the id of the figure or table in the study, e.g. `Fig1`. study: the study, the id of the experiment by default. Raises: ValueError: if the experiment has no `data_path` or a definition of `data_units` is refused. ### `PKPDSimulationExperiment.models(self) -> 'dict[str, AbstractModel | Path]'` Get the SBML model of the model object, without changes. ### `PKPDSimulationExperiment.tasks(self) -> 'dict[str, Task]'` Get a task of the model for every simulation. `initialize` creates the simulations before the tasks, so they are not created a second time. ### `PKPDSimulationExperiment.to_sid(self, text: 'str') -> 'str'` Get the SId of a text as a key of the experiment, see `to_sid`. The keys of an experiment are unique across its datasets, simulations, tasks, figures and fit mappings, so two texts which give one SId in an experiment are an error: one entry would replace the other. Args: text: the text, e.g. a label of the data. Returns: The SId. Raises: ValueError: if another text of the experiment gave the same SId. ## function `define_unit(ureg: 'UnitRegistry', definition: 'str') -> 'str'` Define a unit in a registry once, e.g. the international unit of a substance. The registry of sbmlsim, one per process, ignores a redefinition, so a second model which defined a name with another value would silently get the first one. A name which is defined already must therefore have the same value (compared in base units); a name of the model and the substance, e.g. `IU_ramipril_renin`, keeps the models apart. Args: ureg: the unit registry, the one of the experiments. definition: `" = "` in the syntax of pint, e.g. `"IU_ramipril_renin = 0.6 * microgram"` or `"IU_apixaban_antixa = count"`. Returns: The name of the unit. Raises: ValueError: if the definition is not ` = ` with an identifier as the name, pint cannot evaluate the expression (e.g. an unknown unit in it, pint's `UndefinedUnitError`) or the name is defined with another value. ## function `rename_units(df: 'pd.DataFrame', names: 'Mapping[str, str]') -> 'pd.DataFrame'` Rename units in the unit columns of a table of the data. A name is replaced where it is a unit of a unit expression, alone or after a metric prefix: with `{"IU": "IU_x"}`, `IU/ml` is `IU_x/ml` and `µIU/ml` is `µIU_x/ml`; `IU_per_ml` and `2IU` stay. The unit columns are `unit` and every column whose name ends with `_unit`; a missing value stays missing. Args: df: the table, changed in place. names: the name in the data -> the new name. Returns: The table. ## function `to_sid(text: 'str') -> 'str'` Get the SId of a text, e.g. of a label of the data, as a key of an experiment. A decimal point between two digits becomes `p` (`0.5mg` is `0p5mg`) and every other character which an SId does not allow becomes `_` (`po 2.5 mg` is `po_2p5_mg`, `µg/ml` is `_g_ml`). One character becomes one character, and an SId stays as it is. The function maps characters, so a part of a key gives the same part of the SId of the key (`f"task_po_{to_sid(dose)}"`). Whether a whole key is a valid SId, i.e. not empty and not starting with a digit, is not checked here (the text of a part of a key may start with a digit): sbmlsim checks every key when the experiment initializes and names all invalid ones. Args: text: the text. Returns: The SId. --- # pkpdsim.impairment The classes of impaired organ function: the factors of their parameters and colors. The models scale a parameter of an organ by these factors, the renal function by `KI__f_renal_function`, the cirrhosis by `f_cirrhosis` (the degree of the liver which is cirrhotic) and the cardiac function by the cardiac output; the colors draw a class in every figure of the model alike. The base of the experiments of a model (`pkpdsim.experiment.PKPDSimulationExperiment`) has `RENAL_FUNCTION`, `CIRRHOSIS` and `CARDIAC` as its maps; a model which defines the classes otherwise assigns another preset in its base. --- # pkpdsim.pk The labels and units of the pharmacokinetic parameters in the figures. The keys are the parameters of the non-compartmental analysis of pkpdutils (`nca_single`); the base of the experiments of a model has them as `pk_labels` and `pk_units`. --- # pkpdsim.scan The results of the scans of a model: the dimension and the timecourse of a point. sbmlsim 0.9 returns a scan as a `ScanResult` with the layout `(*dims, time)` and, at the time of a change after the start, the states before and after it. The PK of the scans of a model (`_pk.py`) and its own figures read the timecourse of one point of a scan as sbmlsim 0.8 gave it, with the state after a change only, so that their numbers and figures stay the same: ```python # not executed dim = single_dimension(res) for k in range(res.ds.sizes[dim]): time, values = point_timecourse(res, "[Cve_hctz]", **{dim: k}) ``` ## function `point_timecourse(res: 'ScanResult', key: 'str', /, **point: 'int') -> 'tuple[np.ndarray, np.ndarray]'` Get the timecourse of a variable at one point of a scan, as sbmlsim 0.8 gave it. At the time of a change after the start the timecourse has the state after the change only (`sbmlsim.result.timecourse.without_states_before`), and a result whose simulations have different times loses its padding. Args: res: the result of the scan, positional only. key: the variable, a timecourse, positional only, so that a dimension of any name, also `res` or `key`, is a position. **point: the position of the point along every dimension of the scan, e.g. `dim_dose=2`; the result of a simulation needs none. Returns: The time points and the values. Raises: ValueError: if a dimension of the scan has no position, a position names no dimension of the scan, or the variable is no timecourse. ## function `single_dimension(res: 'ScanResult') -> 'str'` Get the dimension of a scan of one dimension. Args: res: the result of the scan. Returns: The id of the dimension. Raises: ValueError: if the scan has no dimension or several. --- # pkpdsim.sensitivity The parameters of the sensitivity analyses of a model. sbmlsim 0.9 analyses the sensitivity of a model on the result of a scan: a design of `sbmlsim.simulation.sampling` (`local`, `sobol`, `fast`, `lhs`) is a dimension of a `Scan`, and `sbmlsim.sensitivity` computes the indices on its result. It dropped the table of the parameters of an analysis (`sbmlsim.sensitivity.parameters` of sbmlsim 0.8), which the sensitivity scripts of the models write next to their results. This module is that table, with the behavior of sbmlsim 0.8, so the tables of the scripts stay the same, and the distributions of the global designs: ```python # not executed parameters = parameters_from_sbml(MODEL_PATH, exclude_ids={"Mr_hctz"}) set_bounds(parameters, [("f_renal_function", 0.85, 1.15, ParameterType.SCALING)]) set_relative_bounds(parameters, 0.15) write_parameters(parameters, MODEL.results_dir / "sensitivity") design = sobol(distributions(parameters), n=sample_size(4096), seed=1234, model=MODEL_PATH) ``` ## class `ParameterType(*values)` Where the value and the bounds of a parameter of an analysis come from. ## class `SensitivityParameter(*, uid: 'str', name: 'str', value: 'float' = nan, lower_bound: 'float' = nan, upper_bound: 'float' = nan, unit: 'str | None' = None, type: 'ParameterType' = , reference: 'str' = '') -> None` A parameter of a sensitivity analysis. Attributes: uid: the id in the model. name: the name in the model, the id if it has none. value: the value in the model after its initialization. lower_bound: the lower bound of the analysis. upper_bound: the upper bound of the analysis. unit: the unit in the model. type: where the value and the bounds come from. reference: the references of the value, e.g. a LaTeX citation. ## function `distributions(parameters: 'Sequence[SensitivityParameter]') -> 'dict[str, Uniform]'` Get the distributions of the parameters of a global analysis, uniform in their bounds. Args: parameters: the parameters, with bounds. Returns: The distribution of every parameter, by its id. Raises: ValueError: naming the parameters without bounds, or with a lower bound above the upper one (the relative bounds of a negative value, see `set_relative_bounds`). ## function `free_parameters(parameters: 'Sequence[SensitivityParameter]', simulations: 'Iterable[Simulation]') -> 'list[SensitivityParameter]'` Get the parameters an analysis varies: those which none of its simulations sets. A design replaces its targets wherever a simulation sets them, so a parameter which the simulation of a group sets (in its `preinit_changes`, the `preinit_changes` of its presimulation or in a `Change`, e.g. `f_renal_function` of the degree of renal impairment) would take the values of the design in every group, and the groups would be one analysis. sbmlsim 0.8.5 left such a parameter out of an analysis, sbmlsim 0.9's designs do not, so a script takes the parameters of a design from here. The ids are compared as written, plain ids as sbmlsim 0.8.5 did: a target given as a selection (`[S]`) does not match the parameter `S`. Every parameter left out is logged ("Removing fixed parameter"), as sbmlsim 0.8.5 printed it. Args: parameters: the parameters of the analysis. simulations: the simulations of the groups of the analysis. Returns: The parameters which no simulation sets, in their order. ## function `parameters_from_sbml(sbml_path: 'Path', exclude_ids: 'Iterable[str] | None' = None, exclude_na: 'bool' = True, exclude_zero: 'bool' = True) -> 'list[SensitivityParameter]'` Get the parameters of a model which an analysis varies, as sbmlsim 0.8 did. The constant parameters, the constant compartments and the constant or boundary species, in the order of the model, with their values after the initialization (the concentration of a species with only substance units). A parameter whose value is not a number (`exclude_na`) or is close to zero (`exclude_zero`), and every id of `exclude_ids`, is left out. Args: sbml_path: the SBML model. exclude_ids: the ids to leave out. exclude_na: leave out a parameter without a value. exclude_zero: leave out a parameter whose value is close to zero. Returns: The parameters, without bounds. ## function `parameters_to_df(parameters: 'Sequence[SensitivityParameter]', sort: 'bool' = True) -> 'pd.DataFrame'` Get the table of the parameters. Args: parameters: the parameters. sort: sort the rows by the type and the id, as sbmlsim 0.8 did. Returns: One row per parameter, the columns `COLUMNS`, the type as its value. ## function `sample_size(n: 'int') -> 'int'` Get the number of samples of a global analysis, `n` unless a smoke run sets another. The environment variable `PKPDSIM_SENSITIVITY_N` (a positive int) replaces `n`, e.g. `PKPDSIM_SENSITIVITY_N=16` for a quick run of the scripts in a verification. Args: n: the number of samples of the analysis. Returns: The number of samples. Raises: ValueError: if the variable is set and is no positive int. ## function `set_bounds(parameters: 'Iterable[SensitivityParameter]', bounds: 'Iterable[tuple[str, float, float, ParameterType]]') -> 'None'` Set the bounds and the type of parameters. Args: parameters: the parameters, changed in place. bounds: `(uid, lower bound, upper bound, type)`; a uid which is no parameter is logged and skipped, as sbmlsim 0.8 printed it. ## function `set_relative_bounds(parameters: 'Iterable[SensitivityParameter]', fraction: 'float') -> 'None'` Let every parameter without bounds vary by `value * (1 +- fraction)`. The bounds of a negative value are reversed, the lower bound `value * (1 - fraction)` is above the upper bound `value * (1 + fraction)`, as in the table of sbmlsim 0.8, which stays the same; `distributions` refuses such a parameter, naming it, so a model sets its bounds with `set_bounds`. A value of 0 has the bounds 0 and 0. Args: parameters: the parameters, changed in place. fraction: the relative half width. ## function `write_parameters(parameters: 'Sequence[SensitivityParameter]', directory: 'Path') -> 'pd.DataFrame'` Write the table of the parameters as `parameters.tsv` and `parameters.tex`. Args: parameters: the parameters. directory: the directory, created if it does not exist. Returns: The table. --- # pkpdsim.testing The tests of a model as functions: the registry, the experiments, the fits. A repository of models calls them from its own tests, so that a model which is exported into a repository of its own keeps them (`check_model` checks that the model object belongs to its package): ```python # not executed @pytest.mark.parametrize("model", MODELS) def test_experiments_registered(model: PKPDModel) -> None: testing.check_registry(model) ``` The known exceptions of the tests (experiments which are not registered on purpose, modules which do not import, the reason to run the experiments one at a time) are the `checks` of the model object, each with its reason. The functions raise `AssertionError` with a message which names every problem, also for experiments which fail to initialize or to run. ## function `check_model(model: 'PKPDModel') -> 'None'` Check that a model object belongs to its package and its references hold. A slip in `package` or `path` of a model resolves the references of another model, or names the paths wrongly, without an error otherwise. The name is the last component of the package (or the component ends with `_`), `model_path`, the factory directory and `archive_path` lie below `path`, and the data paths exist. Raises: AssertionError: naming every problem. ## function `check_registry(model: 'PKPDModel') -> 'None'` Check that every experiment a model defines is in its registry. Every module below `experiments` imports, every experiment is registered in `all` once, and the known exceptions of `model.checks` are still exceptions (a listed class is defined and not registered, a listed module exists). Args: model: the model. Raises: AssertionError: naming every problem. ## function `defined_experiments(modules: 'Sequence[ModuleType]') -> 'set[type[SimulationExperiment]]'` Get the experiments defined in modules, without the base experiments. A base experiment is a class from which another of the found classes derives. ## function `experiment_modules(model: 'PKPDModel') -> 'tuple[list[ModuleType], dict[str, str]]'` Import the modules below `experiments` of a model. The modules in `model.checks.not_importable` are skipped. Args: model: the model. Returns: The imported modules and the traceback of every module which does not import, by its name relative to the package of the model. ## function `initialize_experiments(model: 'PKPDModel') -> 'dict[str, SimulationExperiment]'` Initialize the registered experiments with their data, figures, mappings. Every experiment is initialized, also after one which cannot be. Returns: The experiments by name. Raises: AssertionError: naming every experiment which cannot be initialized with its error; the tracebacks are in the log of sbmlsim. ## function `registered_experiments(model: 'PKPDModel') -> 'list[type[SimulationExperiment]]'` Get the experiments of the group `all` of the registry of a model. ## function `run_all_experiments(model: 'PKPDModel', output_dir: 'Path') -> 'None'` Run the registered experiments, as `run_` does. Every experiment runs, also after one which failed. Args: model: the model. output_dir: the directory of the results. With `model.checks.one_at_a_time` every experiment runs on its own, into a directory named after it: the memory of a run of all experiments of a large model exceeds that of a runner of a continuous integration. Raises: AssertionError: naming every failed experiment and figure, and every run which wrote no report. ## function `start_fits(model: 'PKPDModel', output_dir: 'Path', max_nfev: 'int' = 2) -> 'dict[str, dict[str, FitRun]]'` Start a fit of every fit definition: one start with a few evaluations. Args: model: the model. output_dir: the directory of the results of the fits. max_nfev: evaluations of the cost per start. Returns: The runs of every fit definition. Raises: AssertionError: if a fit definition did not run. --- # pkpdsim.verify Verify that a change of a model changes only what it is meant to change. A refactoring of a model, an update of sbmlsim or a refresh of the study data should leave the simulations of the model as they were, and a fix should change them only where it is meant to. The subcommands make this checkable: - `dump` runs the simulation experiments of a model in a checkout and stores the results, the datasets, the fit mappings and the figures in a pickle, - `compare` compares two dumps: the simulations on the matched rows of their output times, relative to the scale of every array (a time which only one dump has is a difference), everything else exactly; with `--renames FILE` the expected renames of a migration (metadata fields and members, added fields, texts such as legend keys, ids) and the expected differences are applied to the old dump first, see `load_renames`, - `ranges` lists the fit mappings of a dump whose data lie outside of the simulated range, e.g. data times beyond the end of the simulation, - `fits` builds the optimization problem of every fit definition of a model and runs a minimal fit, - `definitions` writes the fit definitions of a model as json: the settings, the parameters and the mappings of the collections, so that two checkouts are compared with `diff`, - `settings` writes what a run of the experiments of a model hands to sbmlsim, so that the settings of two checkouts are compared with `diff`, - `png` compares the PNG figures of two directories pixel by pixel, - `sbml` compares SBML models by their elements, ignoring the order and the annotations. A model before and after a change, with tight tolerances so that the solver does not hide a difference: ```bash git worktree add --detach ../base develop # the experiments simulate the model at `MODEL_PATH`, which is generated: # create it with the factory of each checkout right before its dump (cd ../base && uv run python pkdb_models/models/hctz/models/factory.py) uv run python pkdb_models/models/hctz/models/factory.py uv run pkpdsim-verify dump --root ../base --model pkdb_models.models.hctz --out base.pkl --atol 1e-16 --rtol 1e-12 uv run pkpdsim-verify dump --root . --model pkdb_models.models.hctz --out new.pkl --order base.pkl --atol 1e-16 --rtol 1e-12 uv run pkpdsim-verify compare base.pkl new.pkl ``` `--root` is the directory which is put first on the python path, e.g. a checkout of the repository of the model, so that its packages are imported. `--model` is the dotted name of a model package, which defines its model object `MODEL` (`pkpdsim.model.PKPDModel`) which describes it; a package without a model object `MODEL` is a usage error (exit code 2). The command only needs the python packages of the environment it runs in, so a checkout which needs another version of sbmlsim is dumped with the python of an environment with that version, as long as it is one which pkpdsim supports. Across versions of sbmlsim (a dump of pkpdsim 0.4 with sbmlsim 0.8 and one of 0.5 with 0.9) `compare` moves the time of every array first and matches the other dimensions by name, matches the rows of a repeated time from the last one, so that the state after a change, which is all sbmlsim 0.8 gave, is compared, and counts the rows of a time which only one dump has as differences (a new output time of a task is expected with the pattern of its variable `time`), and compares a figure of sbmlsim 0.8 with the one of sbmlsim 0.9 in the form of sbmlsim 0.9: the settings and the size it is rendered with, without the fields which only sbmlsim 0.9 has, which it lists (`align_figures`). The renames of a port are the ids which became SIds (`sids`) and the differences which are expected (`expected`). ## class `AlignedFigures(old: pkpdsim.verify.Dump, new: pkpdsim.verify.Dump, across: int, new_fields: list[tuple[str, str, str, object]])` The figures of two dumps in the form in which they are compared. ## class `Columns(old: numpy.ndarray, new: numpy.ndarray, old_times: numpy.ndarray, new_times: numpy.ndarray)` A variable of a task in two dumps, one column per point of a scan. The time is the first dimension, the points are the other dimensions of the variable, matched by name and flattened in the order of the old dump. ## class `Dump` Everything `dump` stores of the experiments of a model. ## class `MetadataRenames` The expected changes of the metadata of the fit mappings. ## class `ModelInfo(package: str, base_path: pathlib.Path, model_path: pathlib.Path, data_path: pathlib.Path | list[pathlib.Path] | None, experiments: collections.abc.Mapping[str, collections.abc.Sequence[type['SimulationExperiment']]] | None, absolute_tolerance: object | None, relative_tolerance: object | None, run_experiments: collections.abc.Callable[..., object] | None, fit_definitions: collections.abc.Mapping[str, 'FitDefinition'] | None) -> None` What the verification needs of a model package. ## class `PointMatch(points: numpy.ndarray, old: numpy.ndarray, new: numpy.ndarray, match: pkpdsim.verify.RowMatch)` The rows of points of a task which are compared, see `point_matches`. ## class `Renames` The expected renames and differences between two dumps. ## class `RowMatch(old: numpy.ndarray, new: numpy.ndarray, ignored_old: int, ignored_new: int, unmatched: int, unmatched_new: int, times_old: numpy.ndarray, times_new: numpy.ndarray)` The rows of the output of a task in two dumps which are compared. ## function `align_figures(old: pkpdsim.verify.Dump, new: pkpdsim.verify.Dump) -> pkpdsim.verify.AlignedFigures` Bring a figure of sbmlsim 0.8 and the one of sbmlsim 0.9 into one form. sbmlsim 0.9 moved the settings of a figure from its attributes (`legend_position`) into `_settings` and its size from `_width` and `_height`, which sbmlsim 0.8 computed when the figure was created, into `width` and `height`, which are `None` for the size of the panels at the time it is rendered; a plot gets a size from its figure only for an explicit one. A figure which is in both dumps, of sbmlsim 0.8 in the old and of sbmlsim 0.9 in the new one, is compared in the form of sbmlsim 0.9, with the settings and the size it is rendered with apart from the settings of the run (`RESOLVED` of the dump, see `figure_spec`), and without the size of a plot which its figure gave it. The selection of a `Data`, the dimensions of the lines of a `Curve` and the bands of a `Plot` (`NEW_FIGURE_FIELDS`) have no counterpart in sbmlsim 0.8: they are left out and listed. Two figures of one version are compared as they are. Returns: The two dumps (new ones, the given ones are not changed), the number of figures compared across versions and the listed fields. ## function `apply_renames(dump: pkpdsim.verify.Dump, renames: pkpdsim.verify.Renames) -> pkpdsim.verify.Dump` Apply the expected renames to a dump, the old one of a comparison. The strings first, then the ids (`ids`, `sids`), then the metadata. Returns: A new dump; the given one is not changed. Raises: ValueError: if two keys or ids get the same new key or id, or a mapping description cannot be renamed. ## function `capture_settings(run_experiments: collections.abc.Callable[..., object], experiment: type['SimulationExperiment'], root: pathlib.Path) -> dict[str, typing.Any]` Capture the settings a run of an experiment hands to sbmlsim. The run is intercepted where it reaches sbmlsim: the arguments of the simulator (`Simulator`), of the runner (without the simulator) and of the run (without the output path; with the figure settings of the run), bound to the signatures, so positional and keyword calls agree, and every matplotlib `rcParams` entry which differs from the value before the run, at the time of the run. `calls` counts the intercepted calls. The simulator is the one created before the runner: the designs of an experiment (`sbmlsim.simulation.sampling.parameters_of`, `local`) create simulators of their own while the runner initializes it, which are neither recorded nor counted. Nothing is simulated and no report is written. Args: run_experiments: the run of a model, `MODEL.run_experiments`. experiment: the experiment class the run gets. root: the checkout; it is replaced by `` in the paths. Returns: The settings as json values. Raises: RuntimeError: if the run does not reach the simulator, the runner and the run of the experiments of sbmlsim. ## function `columns(old: pkpdsim.verify.Dump, new: pkpdsim.verify.Dump, key: tuple[str, str, str]) -> pkpdsim.verify.Columns | None` Get a variable of a task in two dumps with the times of every value. Returns: The columns, or `None` if the dimensions of the variables differ or a dump has no times of the task which fit its variable. ## function `compare(args: argparse.Namespace) -> int` Compare two dumps; the exit code is 1 if they differ beyond the threshold. An expected difference (`expected` of the renames) is printed and not counted; an expected difference which does not occur is counted. A row at a time which only one dump has is a difference: one of the new dump is expected with the pattern of the variable `time` of its task (`experiment/task/time`, a changed output grid), one of the old dump always counts. ## function `defined_experiments(package: str, errors: dict[str, str]) -> list[type['SimulationExperiment']]` Get the experiments defined in the modules of `experiments/` of a model. Every subclass of `SimulationExperiment` defined in a module below `.experiments` counts, except the modules `base*` and `metadata` and the classes from which another found class derives (the base experiments). Args: package: the dotted name of the model package. errors: the traceback of every module which does not import is added here, as `import:`. Returns: The experiment classes in the order of module name and definition. ## function `definitions(args: argparse.Namespace) -> int` Write the fit definitions of a model as json. The settings, the parameters and the mappings of the collections of every fit definition, so that the definitions of two checkouts are compared with `diff`. The checkout directory is replaced by ``. ## function `describe(package: str) -> pkpdsim.verify.ModelInfo` Describe a model package by its model object `MODEL`. Args: package: the dotted name of the model package. Returns: What the verification needs of the model. Raises: ValueError: if the package defines no model object. ## function `describe_mapping(key: str, mapping: 'FitMapping') -> str` Describe a fit mapping by its data, its observable, weight and metadata. ## function `diff_lines(a: object, b: object) -> list[str]` Describe the difference of two lists or dicts line by line. The values of a dict are compared exactly, json values (the specification of a figure) by the paths of their elements which differ. ## function `drop_figure_fields(dump: pkpdsim.verify.Dump, fields: collections.abc.Collection[str]) -> pkpdsim.verify.Dump` Leave out keys of the specifications of the figures, of the figure only. A key is left out at the level of the figure, not inside its plots, curves or axes: a plot has a `width` and a `height` of its own. Returns: A new dump; the given one is not changed. ## function `dump(args: argparse.Namespace) -> int` Run the experiments of a model and store everything in a pickle. ## function `equal(a: object, b: object) -> bool` Check that two values (arrays, lists, dicts) are equal, NaN equal to NaN. ## function `figure_spec(figure: 'Figure', experiment: 'SimulationExperiment') -> str` Get the specification of a figure, its attributes as json. `RESOLVED` adds the settings of the experiment and of the figure (the `figure_settings` of the experiment overridden by the `settings` of the figure) and the width and height the figure is rendered with, as sbmlsim resolves them without the settings of a run. A dump of sbmlsim 0.8 has them as attributes of the figure (`_width`, `_height` and a setting such as `legend_position`), so that `compare` compares them across versions. ## function `fits(args: argparse.Namespace) -> int` Run a minimal fit (one start, a few evaluations) of every fit definition. ## function `json_paths(a: object, b: object, path: str = '') -> list[str]` Get the paths of the elements in which two json values differ. A key which only one object has is named with its value, also a `null` one (a field which one version of sbmlsim added to a figure). ## function `load(path: str) -> pkpdsim.verify.Dump` Load a dump. ## function `load_renames(path: str) -> pkpdsim.verify.Renames` Load the expected renames from a json file. Raises: ValueError: for a part which is not known. ## function `main() -> None` The command `pkpdsim-verify`. ## function `mapping_range(mapping: 'FitMapping') -> tuple[float, float, float, float, str] | str` Get the range of the x values of the data and of the simulation of a mapping. The data are converted into the unit of the simulation. ## function `matched_rows(old_times: numpy.ndarray, new_times: numpy.ndarray) -> pkpdsim.verify.RowMatch` Match the rows of the output times of a task in two dumps. The times are matched relative to the largest time 1e-6. The rows of a time which an output has more than once (a change: the state before it, then the state after it) are matched from the last one, the k-th last old row with the k-th last new row, and the surplus rows of the output with more of them are left out. So the state after a change is compared across versions (sbmlsim 0.8 had only this one, sbmlsim 0.9 has the states before and after it), and two dumps of sbmlsim 0.9 compare every row of their common times. Args: old_times: the output times of the old dump. new_times: the output times of the new dump. Returns: The rows which are compared and the rows which are not. ## function `metadata_names(metadata: object) -> dict[str, str]` Get the metadata of a fit mapping as {field: name}. Enum members are stored by name, so a change of their values or of the enum type (`StrEnum`) does not count as a change. ## function `parser() -> argparse.ArgumentParser` Create the parser of the command line. ## function `png(args: argparse.Namespace) -> int` Compare the PNG figures of two directories pixel by pixel. The directories are searched recursively and the figures are paired by the path relative to the directory; with `--sids` an old figure is paired with the new figure of the SId of its file name (`pkpdsim.experiment.to_sid`). Returns: 0 if all figures are identical, 1 if some differ or exist on one side only, 2 if a directory does not exist, no figure was found or two old figures have the same SId. ## function `point_matches(old_times: numpy.ndarray, new_times: numpy.ndarray) -> list[pkpdsim.verify.PointMatch]` Match the rows of every point of a task by its own times. The points of a grid have the same times and one match; a point of a ragged result is matched by its own times, without the NaN which pad it to the longest point. Points with the same times in both dumps share a match. Args: old_times: the times of the old dump, (rows, points). new_times: the times of the new dump, (rows, points). Returns: The matches. ## function `point_times(dump: pkpdsim.verify.Dump, sid: str, task: str, dims: collections.abc.Sequence[str], shape: tuple[int, ...]) -> numpy.ndarray | None` Get the output time of every value of a variable of a task, in its layout. A grid of sbmlsim 0.9 has one vector of times (`("time",)`) for every point of a scan; sbmlsim 0.8 and the ragged layout of sbmlsim 0.9 (`_point`) have the times of every point, over the dimensions of the variable, padded with NaN to the longest point. Args: dump: the dump. sid: the experiment. task: the task. dims: the dimensions of the variable. shape: the shape of the variable. Returns: The times in the shape of the variable, or `None` if the task has no times or they do not fit the variable. ## function `ranges(args: argparse.Namespace) -> int` List the fit mappings whose data lie outside of the simulated range. ## function `registered_experiments(info: pkpdsim.verify.ModelInfo) -> list[type['SimulationExperiment']] | None` Get the experiments of the registry of a model. Returns: The experiment classes, those of `GROUPS` first, without duplicates, or `None` if the model has no registry. ## function `relative_difference(old: pkpdsim.verify.Dump, new: pkpdsim.verify.Dump, key: tuple[str, str, str]) -> float` Get the difference of a result array on the matched rows. The time is moved first, the other dimensions are matched by name (a dimension of size 1 which only one array has is left out), and the rows of every point are matched by its own times (`point_matches`). The difference is max|new - old| / max|old|; other dimensions and NaN at different positions are an infinite difference. An array without a time is compared exactly; the variable `time` is compared as the output times of the points of the task. ## function `rename_ids(dump: pkpdsim.verify.Dump, rename: collections.abc.Callable[[str], str]) -> pkpdsim.verify.Dump` Rename the ids of the tasks, datasets, figures and fit mappings of a dump. Returns: A new dump; the given one is not changed. Raises: ValueError: if two ids of a part have the same new id, or a mapping description cannot be renamed. ## function `rename_mapping(description: str, renames: pkpdsim.verify.MetadataRenames) -> str` Apply the expected renames to the metadata of a mapping description. ## function `sbml(args: argparse.Namespace) -> int` Compare SBML models: two files, or the files of the same path in two dirs. The directories are searched recursively and the files are paired by the path relative to the directory. Returns: 0 if the models are equal, 1 if they differ or exist on one side only, 2 if a directory does not exist or no file was found. ## function `sbml_elements(path: pathlib.Path) -> dict[str, object] | None` Get the elements of an SBML model which determine its simulation. Returns: The elements by kind and id, `None` if the file has no SBML model (the layouts the factories write next to the models are no SBML). ## function `serialize(obj: object, depth: int = 0) -> object` Serialize the attributes of an object (a figure) into json values. References back to the experiment and to parents are replaced by their type, so that the specification of a figure only contains the figure. ## function `settings(args: argparse.Namespace) -> int` Write the settings of a run of the experiments of a model as json. ## function `store_experiment(result: pkpdsim.verify.Dump, sid: str, experiment: 'SimulationExperiment') -> None` Store the results, datasets, fit mappings and figures of a run experiment. The time of a grid is a coordinate of a result of sbmlsim 0.9; it is stored as the variable `time` over `("time",)`, which the comparison reads. ## function `task_columns(old: pkpdsim.verify.Dump, new: pkpdsim.verify.Dump, sid: str, task: str) -> pkpdsim.verify.Columns | None` Get the output times of every point of a task in two dumps. The times are those of a variable of the task (the first by name), so that the points are matched by the names of their dimensions as the variables are; a task with the times only compares their arrays. Returns: The times as the columns (values and times), or `None`. ## function `time_axis(dims: collections.abc.Sequence[str]) -> int | None` Get the position of the time (or scan point) dimension. ## function `verify(args: collections.abc.Sequence[str] | None = None) -> int` Run a subcommand. Args: args: the command line arguments, `sys.argv[1:]` by default. Returns: The exit code, 1 if the subcommand found a difference. --- # Development Contributions are welcome. The repository is [matthiaskoenig/pkpdsim](https://github.com/matthiaskoenig/pkpdsim); development happens against the `develop` branch via pull requests. ## Branch model Two branches are permanent: - **`develop`** is the default branch and the branch everything is integrated into. The documentation on [matthiaskoenig.github.io/pkpdsim](https://matthiaskoenig.github.io/pkpdsim) is published from it. - **`main`** tracks the latest published release. It is fast-forwarded to the released commit by the `sync-main` job of the `CI-CD` workflow after the package went to pypi, so `main` and the newest version on pypi always agree. Nothing is developed on `main` and nothing is merged into it by hand. Work happens on short lived branches off `develop`, which GitHub deletes after the merge. Releases are tagged on `develop`, see [Release](#release). ## Pull requests Neither branch accepts a direct push, every change goes through a pull request against `develop`. This includes the maintainer, there is no bypass. A pull request can only be merged once the four required checks are green: | check | workflow | content | | ------- | ------------- | -------------------------------------------------------------------- | | `tests` | `ci-cd.yml` | the test matrix, python 3.14 on linux, macos and windows | | `ruff` | `ruff.yml` | `ruff check` and `ruff format --check` | | `ty` | `ty.yml` | `tox r -e ty` | | `docs` | `docs.yml` | the zensical build including the api reference and the agent files | `tests` aggregates the test matrix into a single job, so the name of the required check stays the same when the matrix changes. Continuous integration is kept small: every workflow cancels its running build when a newer commit of the same branch or pull request arrives, uv caches the packages and the interpreters between runs, dependabot proposes its updates once a month, and the matrix tests only python 3.14. Python 3.13 is tested locally, see [Testing](#testing), before a pull request is opened. Further rules of a pull request: - conversations have to be resolved before the merge - an approval is dismissed when new commits are pushed - the history stays linear, i.e., a pull request is merged with squash or rebase; merge commits are disabled - the maintainer is the code owner of the repository (`.github/CODEOWNERS`) and is requested for review on every pull request. A pull request of a contributor is therefore reviewed and merged by the maintainer, who has the only write access. The rulesets themselves do not require an approval: on a personal repository a ruleset cannot ask for an approval only from somebody else, and requiring one would block the pull requests of the maintainer, who cannot approve their own. Once a second person has write access, a ruleset requiring an approving review of a code owner can be added [Auto-merge](https://docs.github.com/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request) is enabled for the repository, so a pull request can be queued and is merged as soon as the checks pass and the required approval is there. ### Repository policies { #repository-policies } The protection is implemented with [repository rulesets](https://docs.github.com/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets). They are part of the repository in `.github/rulesets/` instead of only living in the web interface, so a change to a policy is reviewed like any other change: | ruleset | applies to | rules | | ----------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `develop.json` | `develop` | pull request required, the four checks above, resolved conversations, linear history, no force push, no deletion. **No bypass, for anybody.** | | `main.json` | `main` | no force push, no deletion, no bypass. The fast-forward of the release workflow needs none, only a force push would be rejected | | `tags.json` | all tags | a tag cannot be deleted or moved, so a release tag keeps pointing at what was released | Changing a policy means changing the json and applying it: ```bash .github/rulesets/apply.sh ``` The script is idempotent: it updates the rulesets which exist and creates the missing ones. It also sets the merge settings of the repository, i.e., auto-merge, delete branch on merge, and squash and rebase as the only merge methods. It needs the [github cli](https://cli.github.com) authenticated as a user with admin permission on the repository. ## Setup development environment Development needs [uv](https://docs.astral.sh/uv/) and a checkout of the repository: ```bash git clone https://github.com/matthiaskoenig/pkpdsim.git cd pkpdsim ``` A single sync creates the virtual environment in `.venv`, installs `pkpdsim` into it in editable mode and adds the complete tooling: ```bash uv sync --extra dev ``` The environment is resolved from `uv.lock`, which is committed, so local development, including `uv run ty check`, and the documentation build use the same versions. The tox environments, i.e. the test matrix and the `ty` check of continuous integration, resolve from `pyproject.toml` and do not use the lock; the lower bounds in `pyproject.toml` are what a user of the library installs against. After changing a dependency in `pyproject.toml` run `uv lock`: the `documentation` workflow syncs with `uv sync --locked`, which fails when `pyproject.toml` and the lock disagree. `uv lock --upgrade` moves the lock to the newest releases. Dependabot bumps the locked dependencies as one grouped monthly pull request, with `versioning-strategy: lockfile-only`, so the lower bounds in `pyproject.toml` are never raised by it and only ever changed by hand. The `dev` extra contains everything used below, i.e., pytest, ruff, ty, tox, pre-commit, zensical and bump-my-version, so nothing has to be installed separately. The python version is taken from `.python-version` (currently 3.14); to work against the oldest supported version instead use `uv sync --extra dev --python 3.13`, which replaces the environment. The tools are then run either with `uv run `, which uses the environment without activating it, or from the activated environment: ```bash source .venv/bin/activate # Linux and macOS .venv\Scripts\activate # Windows ``` The commands in this document are written without the `uv run` prefix; prepend it if the environment is not activated. The last step installs the git hook: ```bash uv run pre-commit install # install the hook, once per checkout uv run pre-commit run --all-files # check the current state of the repository ``` From now on every commit is checked with ruff (lint and format) and ty, i.e., the same checks that run in continuous integration. On a commit only the changed files are looked at, `--all-files` checks the whole repository and is what a newly added hook should be tried with. ### libroadrunner on Linux The interpreters of uv keep `libpython`, which the extension of libroadrunner links against, outside the search path of the loader on Linux, see [Installation](installation.md#dependencies). Before running the tests: ```bash export LD_LIBRARY_PATH="$(uv run python -c 'import sys; print(sys.base_prefix)')/lib" ``` The workflows do the same in `.github/actions/setup`. ## Testing The tests are written with pytest, tox runs them against every supported python version. The tox environments are named after the interpreter (`py3.13` and `py3.14`, see `envlist` in `tox.ini`), a single one is run with ```bash tox r -e py3.14 ``` and the complete matrix, including the `ty` environment, in parallel with ```bash tox run-parallel ``` This needs the interpreters to be available, which uv installs with `uv python install 3.13 3.14`. The tox environments build the wheel and install it, so the tests run against the package as it is published. It is the complete test and is run before a pull request is opened: continuous integration runs only `py3.14`, on linux, macos and windows, as `uvx --with tox-uv tox -e py3.14 -- --durations=15`, which lists the slowest tests in the log. To run the tests directly against the development environment use ```bash pytest # the full suite pytest -n 0 # in one process, e.g. for --pdb pytest tests/test_package.py # a single module pytest tests/test_package.py::test_version # a single test ``` The tests run in parallel, `addopts = "-n auto"` in `pyproject.toml` gives pytest-xdist one worker per core; `-n 0` on the command line runs everything in one process, which the debugger needs. ## Linting and formatting Linting and formatting use [ruff](https://docs.astral.sh/ruff/): ```bash ruff check # lint ruff format # format ``` The `ruff` workflow installs the ruff version locked in `uv.lock`, so continuous integration and `uv run ruff` agree. The docstring rules (`D`) are enforced for the package, not for `tests/` and `scripts/`, see `.ruff.toml`; the annotation rules (`ANN`) hold everywhere. ## Type checking Type checking is performed with [ty](https://docs.astral.sh/ty/): ```bash tox r -e ty ``` Or directly in the working tree: ```bash uv run ty check ``` The configuration lives in `[tool.ty]` in `pyproject.toml`. Warnings are treated as errors, so the codebase is kept free of diagnostics. Suppress an unavoidable diagnostic with a rule specific `# ty: ignore[rule-name]` rather than a blanket comment. ## Documentation The documentation is built with [Zensical](https://zensical.org/), the static site generator of the Material for MkDocs authors. The sources are markdown files in `docs/`, the site is configured in `zensical.toml` in the repository root. Nothing rendered is committed: the site is built by the `documentation` workflow on every push and published to [matthiaskoenig.github.io/pkpdsim](https://matthiaskoenig.github.io/pkpdsim) from the `develop` branch. Build the site into `site/`: ```bash uv run zensical build --clean --strict ``` The build is strict: a broken link or a missing page fails it, in continuous integration as well. For writing, the preview rebuilds on save: ```bash uv run zensical serve ``` The API reference is rendered from the docstrings by [mkdocstrings](https://mkdocstrings.github.io/); a page in `docs/api/` only contains the module directive: ```markdown # pkpdsim ::: pkpdsim ``` Docstrings are therefore the place to document functions and classes, the markdown files provide the narrative around them. Adding a module to the reference means adding such a page, an entry to `nav` in `zensical.toml` and a row to `docs/api/index.md`. ### Snippets of the documentation Every ` ```python ` block of the pages follows two rules: - **It runs.** The first block of a page is self-contained (its imports, its data, the call and the output it prints) and runs from the root of the repository with warnings as errors. A later block of the same page may be a fragment, but then it names in a comment or in the sentence before it where every object it uses comes from. The output a snippet prints is shown below it as a `text` block and is pasted from a run, never written by hand. `tests/docs/test_snippets.py` keeps this honest: it runs the blocks of every page in the order they appear and in one namespace per page, in a temporary working directory, in a subprocess with `-W error`. A fragment which needs what a page cannot provide (a checkout of pkdb_data, a model of a reader) carries the comment `# not executed` as its first line and is skipped; every other block has to run. - **It is formatted.** `ruff format` formats the code blocks of the markdown files as well, so `ruff format --check` covers the documentation and a snippet is written the way ruff would write it. ### Files for agents { #files-for-agents } Agents and language models read markdown, not rendered html. `scripts/llms_txt.py` writes the files of the [llms.txt convention](https://llmstxt.org/) into the built site, i.e., [llms.txt](https://matthiaskoenig.github.io/pkpdsim/llms.txt) as an annotated index of all pages, [llms-full.txt](https://matthiaskoenig.github.io/pkpdsim/llms-full.txt) with the complete documentation in a single file, and the markdown of every page next to its html (`/installation.md` for `/installation/`). The markdown of the API reference is generated from the docstrings with `inspect`, since the pages themselves only contain the mkdocstrings directive. ```bash uv run zensical build --clean --strict uv run python scripts/llms_txt.py ``` The `documentation` workflow runs both steps, so the files are regenerated with every push. `docs/robots.txt` points crawlers at the sitemap and at these files. Zensical will provide agent context files itself at some point, then this script can go. ## Release A release is made from `develop`. Since `develop` only accepts pull requests, the release is prepared on a branch and tagged once that pull request is merged: 1. branch off `develop`: `git switch -c release/x.y.z develop` 2. write the release notes for the version in `release-notes/x.y.z.md` 3. make sure everything passes: `tox run-parallel`, `ruff check`, `tox r -e ty` 4. check the version bump: `uvx bump-my-version bump [dev|major|minor|patch] --dry-run -vv`. A development version (`x.y.z.devN`) is finalized with `uvx bump-my-version bump dev`, which drops the `.devN` suffix (`0.2.0.dev0` becomes `0.2.0`); `major`, `minor` and `patch` start the next development cycle instead (`0.2.0.dev0` becomes `1.0.0.dev0`, `0.3.0.dev0`, `0.2.1.dev0`) 5. bump the version: `uvx bump-my-version bump [dev|major|minor|patch]`, which updates `src/pkpdsim/__init__.py`, `CITATION.cff`, `tests/test_package.py` and `.bumpversion.toml` (its own `current_version`) and commits. Use `dev` to release the current development version and `major|minor|patch` to open the next one. It does not create the tag; a squash or rebase merge would rewrite the commit and leave the tag behind on a commit which is not part of `develop` 6. push the branch, open the pull request against `develop` and merge it once the checks are green 7. tag the merged commit on `develop` and push the tag: ```bash git switch develop git pull git tag x.y.z git push origin x.y.z ``` This starts the `CI-CD` workflow, which runs the test matrix, publishes to [pypi](https://pypi.org/project/pkpdsim/) through the trusted publisher of the GitHub environment `pypi`, creates the GitHub release from `release-notes/x.y.z.md` and fast-forwards `main` to the tagged commit. Zenodo archives the GitHub release. Check the version before pushing, a tag cannot be moved or deleted afterwards. 8. test the installation from pypi in a fresh environment: ```bash uv venv --python 3.14 uv pip install pkpdsim ``` 9. once Zenodo has archived the release, update the citation information, i.e., `date-released` and `doi` in `CITATION.cff` and the version, date and DOI of the release in the citation of `README.md` and `docs/index.md`. `bump-my-version` only updates the version, not the date and the DOI, which are only known after the release. These changes go in through a pull request like everything else