--- title: "Building route-specific RxCUI and NDC lists" author: "Steven Smith" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Building route-specific RxCUI and NDC lists} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} # Vignettes use precomputed example data by default. # To rebuild examples with live RxNorm/RxClass API calls, set: # Sys.setenv(RXREF_BUILD_VIGNETTES_ONLINE = "true") online_env <- identical( tolower(Sys.getenv("RXREF_BUILD_VIGNETTES_ONLINE")), "true" ) has_net <- tryCatch({ requireNamespace("curl", quietly = TRUE) && curl::has_internet() }, error = function(e) FALSE) run_live <- online_env && has_net knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) library(rxref) library(dplyr) read_rxref_example <- function(file) { path <- system.file("extdata", file, package = "rxref") if (!nzchar(path)) { stop( "The example data file '", file, "' was not found. ", "Reinstall rxref or rebuild the vignette with ", "RXREF_BUILD_VIGNETTES_ONLINE=true." ) } readRDS(path) } ``` ## Overview A common use case for `rxref` is to develop a list of RxNorm product concepts and National Drug Codes (NDCs) for a drug class. For example, suppose we want to identify product RxCUIs and active NDCs for oral beta-blockers. This is not as simple as searching for beta-blocker ingredients and mapping every product to NDCs, because some beta-blockers are available through multiple routes. For example: - `metoprolol`, `atenolol`, `labetalol`, `propranolol`, and `sotalol` may have oral and injectable products; - `esmolol` is injectable only; - `timolol`, `betaxolol`, and related drugs may have ophthalmic products. For many pharmacoepidemiologic studies, we may want oral outpatient-use products only. This vignette walks through a tidy workflow for: 1. resolving drug names to ingredient RxCUIs; 2. expanding ingredient RxCUIs to product RxCUIs; 3. filtering product RxCUIs by route; and 4. mapping the filtered product RxCUIs to active NDCs. The examples use precomputed data by default so the vignette can be built without querying the live RxNorm API. To rebuild the examples with live API calls, set: ```{r eval = FALSE} Sys.setenv(RXREF_BUILD_VIGNETTES_ONLINE = "true") ``` ## Define a beta-blocker ingredient list In this example, we start with a curated list of beta-blocker ingredient names. Users can also use RxClass-related helpers, such as `find_classes()` or `get_class_members()`, when a suitable source vocabulary is available. However, curated ingredient lists remain useful because class membership may not perfectly match a study-specific exposure definition. ```{r define} beta_blocker_names <- c( "acebutolol", "atenolol", "betaxolol", "bisoprolol", "carvedilol", "labetalol", "metoprolol", "nadolol", "nebivolol", "penbutolol", "pindolol", "propranolol", "sotalol", "timolol" ) ``` ## Resolve drug names to ingredient RxCUIs First, use `find_ingredients()` to resolve each drug name to an RxNorm ingredient concept. ```{r resolve} if (run_live) { bb_ingredients <- find_ingredients(beta_blocker_names) |> filter(tty == "IN") |> distinct( input, ingredient_rxcui = rxcui, ingredient_name = name, ingredient_tty = tty ) } else { bb_ingredients <- read_rxref_example("bb_ingredients.rds") } bb_ingredients ``` The resulting table contains one row per resolved ingredient. These ingredient RxCUIs are the starting point for product expansion. ## Expand ingredients to product RxCUIs Next, use `products_for_ingredients()` to identify product-level RxNorm concepts containing each ingredient. In this example, we use active RxNorm concepts and include combination products. ```{r expand} if (run_live) { bb_products <- products_for_ingredients( bb_ingredients$ingredient_rxcui, ttys = product_ttys("default"), include_combos = TRUE, concept_status = "active" ) |> left_join(bb_ingredients, by = "ingredient_rxcui") } else { bb_products <- read_rxref_example("bb_products.rds") } bb_products ``` Because `include_combos = TRUE`, combination products are retained. For beta-blockers, this means products such as beta-blocker/thiazide combinations may be included. To exclude combination products, set `include_combos = FALSE`. ```{r exclude-option} if (run_live) { bb_single_ingredient_products <- products_for_ingredients( bb_ingredients$ingredient_rxcui, ttys = product_ttys("default"), include_combos = FALSE, concept_status = "active" ) |> left_join(bb_ingredients, by = "ingredient_rxcui") } else { bb_single_ingredient_products <- read_rxref_example( "bb_single_ingredient_products.rds" ) } bb_single_ingredient_products ``` ## Active versus historical RxNorm concepts For many current medication list workflows, `concept_status = "active"` is a good default. This limits product expansion to active RxNorm concepts. For studies covering older calendar periods, users may want to include historical RxNorm concepts as well. ```{r historical-products, eval = FALSE} bb_products_historical <- products_for_ingredients( bb_ingredients$ingredient_rxcui, ttys = product_ttys("default"), include_combos = TRUE, concept_status = "active_and_historical" ) ``` Historical concepts can be useful when reconstructing medication exposure during older study periods. However, historical product concepts may have less complete clinical attribute information than active concepts. Route filtering should therefore be reviewed carefully when `concept_status = "active_and_historical"` is used. ## Why route filtering matters At this point, the product list may include products from multiple routes. For example, some beta-blockers have oral tablets, injectable products, or ophthalmic solutions. We can use `get_clinical_attributes()` to inspect route and dose-form information for the product RxCUIs. ```{r filtering-matters} if (run_live) { bb_attrs <- get_clinical_attributes( unique(bb_products$product_rxcui) ) |> rename(product_rxcui = rxcui) } else { bb_attrs <- read_rxref_example("bb_attrs.rds") } bb_attrs |> count(route, dose_form_group, sort = TRUE) ``` This helps verify whether the product list includes routes that are outside the intended use case. ## Filter to oral products To keep only orally available products, use `filter_products_by_route()`. ```{r filter} if (run_live) { bb_oral_products <- bb_products |> filter_products_by_route(route = "ORAL") } else { bb_oral_products <- read_rxref_example("bb_oral_products.rds") } bb_oral_products ``` The returned table keeps product-level metadata and appends summarized route and dose-form information. ```{r filter-summary} bb_oral_products |> count(route, dose_form_group, sort = TRUE) ``` This should retain products such as oral tablets, capsules, and oral solutions, while excluding injectable or ophthalmic products. Route filtering relies on clinical attribute information available through RxNorm. If a product concept lacks route or dose-form information, it may not be retained by route-specific filters. This is especially important when historical concepts are included. ## Map oral product RxCUIs to active NDCs Once the product list is restricted to oral products, use `map_rxcui_to_ndc()` to map the product RxCUIs to NDCs. ```{r map} if (run_live) { bb_oral_ndc_map <- map_rxcui_to_ndc( unique(bb_oral_products$product_rxcui), status = "ACTIVE" ) bb_oral_ndcs <- bb_oral_ndc_map |> left_join( bb_oral_products, by = c("rxcui" = "product_rxcui") ) |> rename( product_rxcui = rxcui, ) |> distinct() } else { bb_oral_ndcs <- read_rxref_example("bb_oral_ndcs.rds") } bb_oral_ndcs ``` The output is a flat tibble with one row per product RxCUI/NDC pair. Product metadata is retained alongside the NDC. ```{r map-select} bb_oral_ndcs |> select( ingredient_name, product_rxcui, name, tty, route, dose_form, ndc11, ndc_status ) ``` The `status = "ACTIVE"` argument controls the NDC status returned by `map_rxcui_to_ndc()`. This is distinct from `concept_status`, which controls whether active or historical RxNorm concepts are included during product expansion. ## Summarize the resulting list For quality control, it is useful to summarize the number of products and NDCs by ingredient. ```{r summarize-products} bb_oral_products |> count(ingredient_name, sort = TRUE, name = "n_product_rxcuis") ``` ```{r summarize-ndcs} bb_oral_ndcs |> count(ingredient_name, sort = TRUE, name = "n_active_ndcs") ``` You may also want to inspect combination products separately. ```{r summarize-combinations} bb_oral_products |> filter(n_ingredients > 1) |> select( ingredient_name, product_rxcui, name, tty, n_ingredients, route, dose_form ) ``` Depending on the scientific question, combination products may be appropriate to include or exclude. For example, beta-blocker/thiazide combination products may be relevant for antihypertensive exposure definitions but not for studies focused on beta-blocker monotherapy. ## A shortcut using `search_drug()` The same core workflow can be run more compactly with `search_drug()`. This function combines ingredient searching, product expansion, optional route filtering, and optional NDC mapping. To return oral product RxCUIs only: ```{r alt-search-rxcui} if (run_live) { bb_oral_rxcuis <- search_drug( beta_blocker_names, return = "rxcui", route = "ORAL", include_combos = TRUE, concept_status = "active" ) } else { bb_oral_rxcuis <- read_rxref_example("bb_oral_rxcuis_search.rds") } bb_oral_rxcuis ``` To return a flat table of oral product RxCUIs and active NDCs: ```{r alt-search-ndc} if (run_live) { bb_oral_ndcs_search_raw <- search_drug( beta_blocker_names, return = "ndc", route = "ORAL", ndc_status = "ACTIVE", include_combos = TRUE, concept_status = "active" ) bb_oral_ndcs_search <- bb_oral_ndcs_search_raw if (!"ingredient_name" %in% names(bb_oral_ndcs_search)) { bb_oral_ndcs_search <- bb_oral_ndcs_search |> left_join( bb_ingredients |> select(ingredient_rxcui, ingredient_name), by = "ingredient_rxcui" ) } product_cols <- c( "name", "tty", "route", "dose_form", "dose_form_group" ) missing_product_cols <- setdiff(product_cols, names(bb_oral_ndcs_search)) if (length(missing_product_cols) > 0) { bb_oral_ndcs_search <- bb_oral_ndcs_search |> left_join( bb_oral_products |> select( ingredient_rxcui, product_rxcui, all_of(missing_product_cols) ), by = c("ingredient_rxcui", "product_rxcui") ) } bb_oral_ndcs_search <- bb_oral_ndcs_search |> distinct() } else { bb_oral_ndcs_search <- read_rxref_example("bb_oral_ndcs_search.rds") } bb_oral_ndcs_search ``` The raw `search_drug(return = "ndc")` output is intentionally compact. In this vignette, we join it back to the ingredient and product tables so that the displayed example includes readable ingredient names, product names, routes, and dose forms. ## Returning both products and NDCs If you want both the unique product RxCUI table and the expanded NDC table, use `return = "both"`. ```{r products-and-ndcs} if (run_live) { bb_oral_both <- search_drug( beta_blocker_names, return = "both", route = "ORAL", ndc_status = "ACTIVE", include_combos = TRUE, concept_status = "active" ) } else { bb_oral_both <- read_rxref_example("bb_oral_both_search.rds") } names(bb_oral_both) ``` This returns a list because the two tables have different grains: - `products`: one row per product RxCUI; - `ndcs`: one row per product RxCUI/NDC pair. ```{r products-and-ndcs-products} bb_oral_both$products ``` ```{r products-and-ndcs-ndcs} bb_oral_both$ndcs ``` ## Suggested QC checks When developing a study-specific medication list, it is good practice to review the resulting concepts before finalizing the cohort definition. Even if the package is working correctly, RxNorm concepts and source data may occasionally contain surprises for a specific study use case. The following are some examples. ### Check whether any non-oral product names remain ```{r qc-nonoral-names} bb_oral_products |> filter(grepl( "Injection|Injectable|Ophthalmic|Topical|Transdermal|Nasal|Inhalation", name, ignore.case = TRUE )) ``` ### Check the observed routes and dose-form groups ```{r qc-routes} bb_oral_products |> count(route, dose_form_group, sort = TRUE) ``` ### Check combination products ```{r qc-combinations} bb_oral_products |> filter(n_ingredients > 1) |> count(ingredient_name, sort = TRUE) ``` ### Check products without active NDCs Some product RxCUIs may not map to active NDCs. ```{r qc-without-ndcs} bb_oral_products |> anti_join( bb_oral_ndcs |> distinct(product_rxcui), by = "product_rxcui" ) |> select( ingredient_name, product_rxcui, name, tty, route, dose_form ) ``` ## Summary This vignette demonstrated how to build a route-specific medication list, with oral beta-blockers as an example, using `rxref`. The key steps are: 1. `find_ingredients()` to resolve drug names to ingredient RxCUIs; 2. `products_for_ingredients()` to expand ingredients to product RxCUIs; 3. `filter_products_by_route()` to retain products for the route of interest; 4. `map_rxcui_to_ndc()` to obtain NDCs; or 5. `search_drug()` for a compact end-to-end workflow. For drug classes with products available through multiple routes, route filtering is an important quality-control step before mapping to NDCs or using the resulting list in pharmacoepidemiologic analyses. Users should also distinguish between two separate choices: - `concept_status` controls whether active or historical RxNorm concepts are included. - `status` or `ndc_status` controls which NDC status categories are returned. For reproducible research, save the final product list, NDC list, package version, and API-derived outputs used in the analytic workflow.