Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/R-CMD-check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
OMOPHUB_API_KEY: ${{ secrets.OMOPHUB_API_KEY }}

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- uses: r-lib/actions/setup-pandoc@v2

Expand Down Expand Up @@ -89,7 +89,7 @@ jobs:
OMOPHUB_API_KEY: ${{ secrets.OMOPHUB_API_KEY }}

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- uses: r-lib/actions/setup-pandoc@v2

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/integration-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
TEST_API_KEY: ${{ secrets.TEST_API_KEY }}

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- uses: r-lib/actions/setup-r@v2
with:
Expand Down Expand Up @@ -61,7 +61,7 @@ jobs:

- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: integration-test-failures
path: tests/testthat/
4 changes: 2 additions & 2 deletions .github/workflows/pkgdown.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
contents: write

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- uses: r-lib/actions/setup-pandoc@v2

Expand All @@ -40,7 +40,7 @@ jobs:

- name: Deploy to GitHub pages
if: github.event_name != 'pull_request'
uses: JamesIves/github-pages-deploy-action@v4.5.0
uses: JamesIves/github-pages-deploy-action@v4.8.0
with:
clean: false
branch: gh-pages
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test-coverage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
OMOPHUB_API_KEY: ${{ secrets.OMOPHUB_API_KEY }}

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- uses: r-lib/actions/setup-r@v2
with:
Expand Down Expand Up @@ -48,7 +48,7 @@ jobs:

- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: coverage-test-failures
path: ${{ runner.temp }}/package
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Package: omophub
Title: R Client for the 'OMOPHub' Medical Vocabulary API
Version: 1.7.0
Version: 1.8.0
Authors@R: c(
person("Alex", "Chen", email = "alex@omophub.com", role = c("aut", "cre", "cph")),
person("Observational Health Data Science and Informatics", role = c("cph"))
Expand Down
25 changes: 25 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,28 @@
# omophub 1.8.0

## New Features

* **FHIR Value-as-Concept** — the `resolve_batch(as_tibble = TRUE)` tibble now
includes `value_as_concept_id` and `value_as_concept_name` columns, populated
when the resolver decomposes a composite concept via the `Maps to value`
relationship (HL7 FHIR-to-OMOP IG Value-as-Concept pattern — e.g. "Allergy to
penicillin" yields a standard "Allergy to drug" plus a value "Penicillin G").

* **`on_unmapped` for FHIR resolution** — `resolve()`, `resolve_batch()`, and
`resolve_codeable_concept()` gained an `on_unmapped` argument (`"error"`
default / `"sentinel"`). With `"sentinel"` the resolver returns a
`concept_id` 0 record instead of a 404 when nothing resolves, so ETL
pipelines always get a row (matches the Python SDK).

## Behavior Changes

* **Unmapped rows in the batch tibble** — a coding that resolves to a source
concept but has no standard `Maps to` target now reports `status = "unmapped"`
(with `standard_concept_id = 0`) instead of `"resolved"`, matching the OMOP /
FHIR-to-OMOP IG convention that an unmapped concept is `concept_id 0`. Code
that filters `status == "resolved"` will no longer treat these sentinels as
successful mappings.

# omophub 1.7.0

## New Features
Expand Down
45 changes: 39 additions & 6 deletions R/fhir.R
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ FhirResource <- R6::R6Class(
#' @param include_recommendations Logical. Include Phoebe recommendations. Default `FALSE`.
#' @param recommendations_limit Integer. Max recommendations (1-20). Default `5L`.
#' @param include_quality Logical. Include mapping quality signal. Default `FALSE`.
#' @param on_unmapped Optional. `"error"` (default) returns a 404 when no
#' concept resolves; `"sentinel"` returns a `concept_id` 0 record instead.
#'
#' @returns A list with `input` and `resolution` containing source/standard
#' concepts, target CDM table, and optional enrichments.
Expand All @@ -53,13 +55,15 @@ FhirResource <- R6::R6Class(
resource_type = NULL,
include_recommendations = FALSE,
recommendations_limit = 5L,
include_quality = FALSE) {
include_quality = FALSE,
on_unmapped = NULL) {
body <- compact_list(
system = system,
code = code,
display = display,
vocabulary_id = vocabulary_id,
resource_type = resource_type
resource_type = resource_type,
on_unmapped = on_unmapped
)
if (isTRUE(include_recommendations)) {
body$include_recommendations <- TRUE
Expand All @@ -83,6 +87,8 @@ FhirResource <- R6::R6Class(
#' @param include_recommendations Logical. Default `FALSE`.
#' @param recommendations_limit Integer. Default `5L`.
#' @param include_quality Logical. Default `FALSE`.
#' @param on_unmapped Optional `"error"` (default) / `"sentinel"`; see
#' `resolve()`.
#' @param as_tibble Logical. When `TRUE`, returns a [tibble::tibble]
#' with one row per input coding and flat columns for the source
#' concept, standard concept, target CDM table, mapping type, and
Expand All @@ -99,6 +105,7 @@ FhirResource <- R6::R6Class(
include_recommendations = FALSE,
recommendations_limit = 5L,
include_quality = FALSE,
on_unmapped = NULL,
as_tibble = FALSE) {
stopifnot(is.list(codings), length(codings) >= 1, length(codings) <= 100)
if (!all(vapply(codings, is.list, logical(1)))) {
Expand All @@ -116,6 +123,7 @@ FhirResource <- R6::R6Class(
body$recommendations_limit <- as.integer(recommendations_limit)
}
if (isTRUE(include_quality)) body$include_quality <- TRUE
if (!is.null(on_unmapped)) body$on_unmapped <- on_unmapped

result <- perform_post(private$.base_req, "fhir/resolve/batch", body = body)

Expand All @@ -139,14 +147,17 @@ FhirResource <- R6::R6Class(
#' @param include_recommendations Logical. Default `FALSE`.
#' @param recommendations_limit Integer. Default `5L`.
#' @param include_quality Logical. Default `FALSE`.
#' @param on_unmapped Optional `"error"` (default) / `"sentinel"`; see
#' `resolve()`.
#'
#' @returns A list with `best_match`, `alternatives`, and `unresolved`.
resolve_codeable_concept = function(coding,
text = NULL,
resource_type = NULL,
include_recommendations = FALSE,
recommendations_limit = 5L,
include_quality = FALSE) {
include_quality = FALSE,
on_unmapped = NULL) {
stopifnot(is.list(coding), length(coding) >= 1, length(coding) <= 20)
if (!all(vapply(coding, is.list, logical(1)))) {
cli::cli_abort(c(
Expand All @@ -164,6 +175,7 @@ FhirResource <- R6::R6Class(
body$recommendations_limit <- as.integer(recommendations_limit)
}
if (isTRUE(include_quality)) body$include_quality <- TRUE
if (!is.null(on_unmapped)) body$on_unmapped <- on_unmapped

perform_post(private$.base_req, "fhir/resolve/codeable-concept", body = body)
}
Expand Down Expand Up @@ -238,6 +250,8 @@ fhir_batch_to_tibble <- function(result, codings) {
standard_concept_name = NA_character_,
standard_vocabulary_id = NA_character_,
domain_id = NA_character_,
value_as_concept_id = NA_integer_,
value_as_concept_name = NA_character_,
target_table = NA_character_,
mapping_type = NA_character_,
similarity_score = NA_real_,
Expand All @@ -248,21 +262,40 @@ fhir_batch_to_tibble <- function(result, codings) {

src <- resolution$source_concept %||% list()
std <- resolution$standard_concept %||% list()
# Value concept from `Maps to value` decomposition (FHIR-to-OMOP IG
# Value-as-Concept pattern); NA when the source is not composite.
val <- resolution$value_as_concept %||% list()

# A resolved-but-unmapped row carries concept_id 0 (OMOP "no matching
# concept"). Surface it as status "unmapped" so callers filtering
# `status == "resolved"` don't mistake the sentinel for a real mapping.
std_id <- std$concept_id %||% NA_integer_
# Only an explicit concept_id 0 is the OMOP "no matching concept" sentinel.
# A missing/NA id signals a malformed or partial response (not an unmapped
# code), so it stays "resolved" with an NA id rather than being mislabeled
# "unmapped" with a misleading "concept_id 0" detail.
unmapped <- !is.na(std_id) && std_id == 0L

tibble::tibble(
source_system = input_coding$system %||% NA_character_,
source_code = input_coding$code %||% NA_character_,
source_concept_id = src$concept_id %||% NA_integer_,
source_concept_name = src$concept_name %||% NA_character_,
standard_concept_id = std$concept_id %||% NA_integer_,
standard_concept_id = std_id,
standard_concept_name = std$concept_name %||% NA_character_,
standard_vocabulary_id = std$vocabulary_id %||% NA_character_,
domain_id = std$domain_id %||% NA_character_,
value_as_concept_id = val$concept_id %||% NA_integer_,
value_as_concept_name = val$concept_name %||% NA_character_,
target_table = resolution$target_table %||% NA_character_,
mapping_type = resolution$mapping_type %||% NA_character_,
similarity_score = resolution$similarity_score %||% NA_real_,
status = "resolved",
status_detail = NA_character_
status = if (unmapped) "unmapped" else "resolved",
status_detail = if (unmapped) {
"no standard concept (concept_id 0)"
} else {
NA_character_
}
)
}

Expand Down
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,24 @@ result$best_match$resolution$source_concept$vocabulary_id
# [1] "SNOMED"
```

The resolver also follows the [HL7 FHIR-to-OMOP IG](https://hl7.org/fhir/uv/omop/INFORMATIVE1/en/): it resolves FHIR administrative codes via the IG ConceptMaps, decomposes composite concepts (`Maps to value`), honors `Coding.userSelected`, and can return a `concept_id` 0 sentinel instead of an error.

```r
# Administrative gender -> person (via IG ConceptMap)
client$fhir$resolve(system = "http://hl7.org/fhir/administrative-gender", code = "male")

# A user-selected coding wins over vocabulary preference
client$fhir$resolve_codeable_concept(coding = list(
list(system = "http://snomed.info/sct", code = "44054006"),
list(system = "http://hl7.org/fhir/sid/icd-10-cm", code = "E11.9", user_selected = TRUE)
))

# on_unmapped = "sentinel" -> a concept_id 0 record instead of an error (one row per input)
client$fhir$resolve(system = "http://snomed.info/sct", code = "00000000", on_unmapped = "sentinel")
```

Composite concepts (e.g. "Allergy to penicillin") additionally surface `resolution$value_as_concept` (the IG Value-as-Concept pattern). `on_unmapped` is accepted by `resolve()`, `resolve_batch()`, and `resolve_codeable_concept()`.

### Tibble Output for Batch Resolution

Pass `as_tibble = TRUE` to get a flat [`tibble`](https://tibble.tidyverse.org/) with one row per input coding - ready to pipe into `dplyr` / `tidyr`:
Expand Down Expand Up @@ -209,7 +227,7 @@ tbl |>
#> 3 J45.909 Asthma condition_occurrence
```

The tibble columns are `source_system`, `source_code`, `source_concept_id`, `source_concept_name`, `standard_concept_id`, `standard_concept_name`, `standard_vocabulary_id`, `domain_id`, `target_table`, `mapping_type`, `similarity_score`, `status`, and `status_detail`. Failed rows stay in-place with `status = "failed"` and the API error text in `status_detail`. The batch summary (`total` / `resolved` / `failed`) is attached as `attr(tbl, "summary")`.
The tibble columns are `source_system`, `source_code`, `source_concept_id`, `source_concept_name`, `standard_concept_id`, `standard_concept_name`, `standard_vocabulary_id`, `domain_id`, `value_as_concept_id`, `value_as_concept_name`, `target_table`, `mapping_type`, `similarity_score`, `status`, and `status_detail`. Failed rows stay in-place with `status = "failed"` and the API error text in `status_detail`; a coding that resolves but has no standard target gets `status = "unmapped"` (`standard_concept_id = 0`). The batch summary (`total` / `resolved` / `failed`) is attached as `attr(tbl, "summary")`.

Default `as_tibble = FALSE` still returns the legacy `list(results, summary)` shape.

Expand Down Expand Up @@ -453,7 +471,7 @@ The package includes comprehensive examples in `inst/examples/`:
| `navigate_hierarchy.R` | Hierarchy navigation - ancestors, descendants |
| `map_between_vocabularies.R` | Cross-vocabulary mapping |
| `error_handling.R` | Error handling patterns |
| `fhir_resolver.R` | FHIR Concept Resolver - single / batch / CodeableConcept, quality, recommendations |
| `fhir_resolver.R` | FHIR Concept Resolver - single / batch / CodeableConcept, quality, recommendations, administrative codes, `user_selected`, `on_unmapped` |
| `fhir_interop.R` | 1.7.0 interop - tibble batch output, standalone wrappers, `omophub_fhir_url()` |

Run an example:
Expand Down
75 changes: 73 additions & 2 deletions inst/examples/fhir_resolver.R
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
#' - Mapping quality signal
#' - Batch resolution
#' - CodeableConcept resolution with OHDSI vocabulary preference
#' - Administrative codes via the IG ConceptMap layer (gender, ...)
#' - user_selected coding precedence
#' - on_unmapped = "sentinel" (concept_id 0 instead of a 404)
#' - Error handling
#'
#' For tibble-shaped batch output, standalone wrapper functions, and the
Expand Down Expand Up @@ -284,10 +287,78 @@ if (!is.null(best)) {
cat(" Failed codings:", length(result$unresolved), "\n\n")

# ============================================================================
# 12. Error handling
# 12. Administrative code via the IG ConceptMap layer (gender, encounter, ...)
# ============================================================================

cat("12. Error handling\n")
cat("12. Administrative gender (IG ConceptMap)\n")
cat("-----------------------------------------\n")

result <- client$fhir$resolve(
system = "http://hl7.org/fhir/administrative-gender",
code = "male",
resource_type = "Patient"
)

res <- result$resolution
cat(sprintf(" male -> %s (%s)\n",
res$standard_concept$concept_name,
res$standard_concept$concept_id))
cat(" Target table: ", res$target_table, "\n")
cat(" Via IG ConceptMap:", res$concept_map_id %||% "N/A", "\n")
# Composite source concepts also surface value_as_concept (IG 'Maps to value').
if (!is.null(res$value_as_concept)) {
cat(" Value concept: ", res$value_as_concept$concept_name, "\n")
}
cat("\n")

# ============================================================================
# 13. CodeableConcept with user_selected (overrides vocabulary preference)
# ============================================================================

cat("13. CodeableConcept with user_selected\n")
cat("--------------------------------------\n")

result <- client$fhir$resolve_codeable_concept(
coding = list(
# SNOMED would normally win; the user-selected ICD-10-CM coding wins.
list(system = "http://snomed.info/sct", code = "44054006"),
list(
system = "http://hl7.org/fhir/sid/icd-10-cm",
code = "E11.9",
user_selected = TRUE
)
),
resource_type = "Condition"
)

best <- result$best_match
if (!is.null(best)) {
cat(" best_match source:", best$resolution$source_concept$vocabulary_id, "\n")
}
cat("\n")

# ============================================================================
# 14. on_unmapped = "sentinel" (concept_id 0 record instead of a 404)
# ============================================================================

cat("14. on_unmapped = 'sentinel'\n")
cat("----------------------------\n")

result <- client$fhir$resolve(
system = "http://snomed.info/sct",
code = "00000000",
on_unmapped = "sentinel"
)

res <- result$resolution
cat(" Mapping type: ", res$mapping_type, "\n")
cat(" Standard concept_id:", res$standard_concept$concept_id, "\n\n")

# ============================================================================
# 15. Error handling
# ============================================================================

cat("15. Error handling\n")
cat("------------------\n")

# Restricted vocabulary (CPT4) -> 403
Expand Down
Loading
Loading