Skip to content

Commit 2c18aae

Browse files
authored
Merge pull request #2 from OMOPHub/develop
Release version 1.6.0 with FHIR-to-OMOP concept resolution features
2 parents 322ecd5 + 85645e6 commit 2c18aae

20 files changed

Lines changed: 1227 additions & 6 deletions

.github/workflows/R-CMD-check.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ jobs:
1111
# Tests R release and devel on ubuntu-latest for quick feedback
1212
R-CMD-check-core:
1313
runs-on: ubuntu-latest
14+
timeout-minutes: 20
1415
name: ubuntu-latest (R ${{ matrix.r }})
1516

1617
strategy:
@@ -39,6 +40,7 @@ jobs:
3940
with:
4041
extra-packages: any::rcmdcheck, any::urlchecker
4142
needs: check
43+
cache-version: 2
4244

4345
- name: Check URLs
4446
if: matrix.r == 'release'
@@ -68,6 +70,7 @@ jobs:
6870
R-CMD-check-extended:
6971
if: github.event_name == 'push'
7072
runs-on: ${{ matrix.config.os }}
73+
timeout-minutes: 25
7174
name: ${{ matrix.config.os }} (${{ matrix.config.r }})
7275
needs: [R-CMD-check-core] # Run after core completes
7376

@@ -100,6 +103,7 @@ jobs:
100103
with:
101104
extra-packages: any::rcmdcheck
102105
needs: check
106+
cache-version: 2
103107

104108
- uses: r-lib/actions/check-r-package@v2
105109
with:

.github/workflows/integration-tests.yaml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ jobs:
1212
# Only run on push to main/develop, not on PRs (saves ~7-8 min per PR)
1313
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
1414
runs-on: ubuntu-latest
15+
timeout-minutes: 10
1516
env:
1617
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
1718
OMOPHUB_API_KEY: ${{ secrets.OMOPHUB_API_KEY }}
@@ -25,10 +26,16 @@ jobs:
2526
r-version: 'release'
2627
use-public-rspm: true
2728

29+
# Install only the runtime + test dependencies the integration tests
30+
# actually need. The default `needs: check` pulls in all Suggests
31+
# (rmarkdown, knitr, etc.) which require heavy system libraries
32+
# (pandoc, libxml2-dev, cmake, libharfbuzz-dev, ...) and take 10+
33+
# minutes to install. Integration tests only need httr2 + testthat.
2834
- uses: r-lib/actions/setup-r-dependencies@v2
2935
with:
3036
extra-packages: any::testthat, any::devtools
31-
needs: check
37+
dependencies: '"hard"'
38+
cache-version: 2
3239

3340
- name: Run integration tests
3441
run: |

.github/workflows/pkgdown.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ name: pkgdown
1010
jobs:
1111
pkgdown:
1212
runs-on: ubuntu-latest
13+
timeout-minutes: 15
1314
concurrency:
1415
group: pkgdown-${{ github.event_name != 'pull_request' || github.run_id }}
1516
env:
@@ -31,6 +32,7 @@ jobs:
3132
with:
3233
extra-packages: any::pkgdown, local::.
3334
needs: website
35+
cache-version: 2
3436

3537
- name: Build site
3638
run: pkgdown::build_site_github_pages(new_process = FALSE, install = FALSE)

.github/workflows/test-coverage.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ jobs:
1111
# Only run on push to main/develop, not on PRs (saves ~5-7 min per PR)
1212
if: github.event_name == 'push'
1313
runs-on: ubuntu-latest
14+
timeout-minutes: 20
1415
env:
1516
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
1617
OMOPHUB_API_KEY: ${{ secrets.OMOPHUB_API_KEY }}
@@ -26,6 +27,7 @@ jobs:
2627
with:
2728
extra-packages: any::covr
2829
needs: coverage
30+
cache-version: 2
2931

3032
- name: Test coverage
3133
run: |

DESCRIPTION

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
Package: omophub
22
Title: R Client for the 'OMOPHub' Medical Vocabulary API
3-
Version: 1.5.0
3+
Version: 1.6.0
44
Authors@R: c(
55
person("Alex", "Chen", email = "alex@omophub.com", role = c("aut", "cre", "cph")),
66
person("Observational Health Data Science and Informatics", role = c("cph"))
77
)
88
Description: Provides an R interface to the 'OMOPHub' API for accessing
99
'OHDSI ATHENA' standardized medical vocabularies. Supports concept search,
1010
semantic search using neural embeddings, concept similarity, vocabulary
11-
exploration, hierarchy navigation, relationship queries, and concept
12-
mappings with automatic pagination and rate limiting.
11+
exploration, hierarchy navigation, relationship queries, concept
12+
mappings, and FHIR-to-OMOP concept resolution with automatic pagination.
1313
License: MIT + file LICENSE
1414
URL: https://github.com/omopHub/omophub-R,
1515
https://docs.omophub.com,

NEWS.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
1+
# omophub 1.6.0
2+
3+
## New Features
4+
5+
* **FHIR-to-OMOP Concept Resolver** (`client$fhir`): Translate FHIR coded
6+
values into OMOP standard concepts, CDM target tables, and optional Phoebe
7+
recommendations in a single API call.
8+
9+
- `resolve()`: Resolve a single FHIR `Coding` (system URI + code) or
10+
text-only input via semantic search fallback. Returns the standard
11+
concept, target CDM table, domain alignment check, and optional mapping
12+
quality signal.
13+
14+
- `resolve_batch()`: Batch-resolve up to 100 FHIR codings per request with
15+
inline per-item error reporting.
16+
17+
- `resolve_codeable_concept()`: Resolve a FHIR `CodeableConcept` with
18+
multiple codings. Automatically picks the best match per OHDSI vocabulary
19+
preference (SNOMED > RxNorm > LOINC > CVX > ICD-10). Falls back to the
20+
`text` field via semantic search when no coding resolves.
21+
22+
## Tests
23+
24+
* Improved test coverage
25+
126
# omophub 1.5.0
227

328
## New Features

R/client.R

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,14 @@ OMOPHubClient <- R6::R6Class(
142142
private$.mappings <- MappingsResource$new(private$.base_req)
143143
}
144144
private$.mappings
145+
},
146+
147+
#' @field fhir Access to FHIR-to-OMOP Concept Resolver operations.
148+
fhir = function() {
149+
if (is.null(private$.fhir)) {
150+
private$.fhir <- FhirResource$new(private$.base_req)
151+
}
152+
private$.fhir
145153
}
146154
),
147155
private = list(
@@ -159,6 +167,7 @@ OMOPHubClient <- R6::R6Class(
159167
.domains = NULL,
160168
.hierarchy = NULL,
161169
.relationships = NULL,
162-
.mappings = NULL
170+
.mappings = NULL,
171+
.fhir = NULL
163172
)
164173
)

R/fhir.R

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
#' FHIR-to-OMOP Concept Resolver
2+
#'
3+
#' @description
4+
#' R6 class providing access to the FHIR-to-OMOP Concept Resolver endpoints.
5+
#' Translates FHIR coded values (system URI + code) into OMOP standard
6+
#' concepts, CDM target tables, and optional Phoebe recommendations.
7+
#'
8+
#' @details
9+
#' Access via the `fhir` active binding on an `OMOPHubClient`:
10+
#' ```r
11+
#' client <- OMOPHubClient$new(api_key = "oh_xxx")
12+
#' result <- client$fhir$resolve(
13+
#' system = "http://snomed.info/sct",
14+
#' code = "44054006",
15+
#' resource_type = "Condition"
16+
#' )
17+
#' result$data$resolution$target_table
18+
#' # "condition_occurrence"
19+
#' ```
20+
#'
21+
#' @keywords internal
22+
FhirResource <- R6::R6Class(
23+
"FhirResource",
24+
public = list(
25+
#' @description
26+
#' Create a new FhirResource.
27+
#' @param base_req Base httr2 request object.
28+
initialize = function(base_req) {
29+
private$.base_req <- base_req
30+
},
31+
32+
#' @description
33+
#' Resolve a single FHIR Coding to an OMOP standard concept.
34+
#'
35+
#' Provide at least one of (`system` + `code`), (`vocabulary_id` + `code`),
36+
#' or `display`.
37+
#'
38+
#' @param system FHIR code system URI (e.g. `"http://snomed.info/sct"`).
39+
#' @param code Code value from the FHIR Coding.
40+
#' @param display Human-readable text (semantic search fallback).
41+
#' @param vocabulary_id Direct OMOP vocabulary_id, bypasses URI resolution.
42+
#' @param resource_type FHIR resource type (e.g. `"Condition"`, `"Observation"`).
43+
#' @param include_recommendations Logical. Include Phoebe recommendations. Default `FALSE`.
44+
#' @param recommendations_limit Integer. Max recommendations (1-20). Default `5L`.
45+
#' @param include_quality Logical. Include mapping quality signal. Default `FALSE`.
46+
#'
47+
#' @returns A list with `input` and `resolution` containing source/standard
48+
#' concepts, target CDM table, and optional enrichments.
49+
resolve = function(system = NULL,
50+
code = NULL,
51+
display = NULL,
52+
vocabulary_id = NULL,
53+
resource_type = NULL,
54+
include_recommendations = FALSE,
55+
recommendations_limit = 5L,
56+
include_quality = FALSE) {
57+
body <- compact_list(
58+
system = system,
59+
code = code,
60+
display = display,
61+
vocabulary_id = vocabulary_id,
62+
resource_type = resource_type
63+
)
64+
if (isTRUE(include_recommendations)) {
65+
body$include_recommendations <- TRUE
66+
body$recommendations_limit <- as.integer(recommendations_limit)
67+
}
68+
if (isTRUE(include_quality)) {
69+
body$include_quality <- TRUE
70+
}
71+
72+
perform_post(private$.base_req, "fhir/resolve", body = body)
73+
},
74+
75+
#' @description
76+
#' Batch-resolve up to 100 FHIR Codings.
77+
#'
78+
#' Failed items are reported inline without failing the batch.
79+
#'
80+
#' @param codings A list of coding lists, each with optional elements
81+
#' `system`, `code`, `display`, `vocabulary_id`.
82+
#' @param resource_type FHIR resource type applied to all codings.
83+
#' @param include_recommendations Logical. Default `FALSE`.
84+
#' @param recommendations_limit Integer. Default `5L`.
85+
#' @param include_quality Logical. Default `FALSE`.
86+
#'
87+
#' @returns A list with `results` (per-item) and `summary`
88+
#' (total/resolved/failed).
89+
resolve_batch = function(codings,
90+
resource_type = NULL,
91+
include_recommendations = FALSE,
92+
recommendations_limit = 5L,
93+
include_quality = FALSE) {
94+
stopifnot(is.list(codings), length(codings) >= 1, length(codings) <= 100)
95+
if (!all(vapply(codings, is.list, logical(1)))) {
96+
cli::cli_abort(c(
97+
"{.arg codings} must be a list of coding lists.",
98+
"i" = "Each element should be a list with {.field system}, {.field code}, etc.",
99+
"i" = "Example: {.code list(list(system = \"http://snomed.info/sct\", code = \"44054006\"))}"
100+
))
101+
}
102+
103+
body <- list(codings = codings)
104+
if (!is.null(resource_type)) body$resource_type <- resource_type
105+
if (isTRUE(include_recommendations)) {
106+
body$include_recommendations <- TRUE
107+
body$recommendations_limit <- as.integer(recommendations_limit)
108+
}
109+
if (isTRUE(include_quality)) body$include_quality <- TRUE
110+
111+
perform_post(private$.base_req, "fhir/resolve/batch", body = body)
112+
},
113+
114+
#' @description
115+
#' Resolve a FHIR CodeableConcept with vocabulary preference.
116+
#'
117+
#' Picks the best match per OHDSI preference order
118+
#' (SNOMED > RxNorm > LOINC > CVX > ICD-10). Falls back to `text`
119+
#' via semantic search if no coding resolves.
120+
#'
121+
#' @param coding A list of coding lists, each with `system`, `code`,
122+
#' and optional `display`.
123+
#' @param text Optional CodeableConcept.text for semantic fallback.
124+
#' @param resource_type FHIR resource type.
125+
#' @param include_recommendations Logical. Default `FALSE`.
126+
#' @param recommendations_limit Integer. Default `5L`.
127+
#' @param include_quality Logical. Default `FALSE`.
128+
#'
129+
#' @returns A list with `best_match`, `alternatives`, and `unresolved`.
130+
resolve_codeable_concept = function(coding,
131+
text = NULL,
132+
resource_type = NULL,
133+
include_recommendations = FALSE,
134+
recommendations_limit = 5L,
135+
include_quality = FALSE) {
136+
stopifnot(is.list(coding), length(coding) >= 1, length(coding) <= 20)
137+
if (!all(vapply(coding, is.list, logical(1)))) {
138+
cli::cli_abort(c(
139+
"{.arg coding} must be a list of coding lists.",
140+
"i" = "Each element should be a list with {.field system} and {.field code}.",
141+
"i" = "Example: {.code list(list(system = \"http://snomed.info/sct\", code = \"44054006\"))}"
142+
))
143+
}
144+
145+
body <- list(coding = coding)
146+
if (!is.null(text)) body$text <- text
147+
if (!is.null(resource_type)) body$resource_type <- resource_type
148+
if (isTRUE(include_recommendations)) {
149+
body$include_recommendations <- TRUE
150+
body$recommendations_limit <- as.integer(recommendations_limit)
151+
}
152+
if (isTRUE(include_quality)) body$include_quality <- TRUE
153+
154+
perform_post(private$.base_req, "fhir/resolve/codeable-concept", body = body)
155+
}
156+
),
157+
private = list(
158+
.base_req = NULL
159+
)
160+
)
161+
162+
163+
#' Helper to remove NULL entries from a named list.
164+
#' @param ... Named arguments.
165+
#' @returns A list with NULL entries removed.
166+
#' @keywords internal
167+
compact_list <- function(...) {
168+
args <- list(...)
169+
args[!vapply(args, is.null, logical(1))]
170+
}

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,48 @@ results <- client$search$bulk_semantic(list(
134134
), defaults = list(threshold = 0.5, page_size = 10))
135135
```
136136

137+
## FHIR-to-OMOP Resolution
138+
139+
Resolve FHIR coded values to OMOP standard concepts in one call:
140+
141+
```r
142+
# Single FHIR Coding -> OMOP concept + CDM target table
143+
result <- client$fhir$resolve(
144+
system = "http://snomed.info/sct",
145+
code = "44054006",
146+
resource_type = "Condition"
147+
)
148+
result$resolution$target_table
149+
# [1] "condition_occurrence"
150+
151+
# ICD-10-CM -> traverses 'Maps to' automatically
152+
result <- client$fhir$resolve(
153+
system = "http://hl7.org/fhir/sid/icd-10-cm",
154+
code = "E11.9"
155+
)
156+
result$resolution$standard_concept$vocabulary_id
157+
# [1] "SNOMED"
158+
159+
# Batch resolve up to 100 codings
160+
batch <- client$fhir$resolve_batch(list(
161+
list(system = "http://snomed.info/sct", code = "44054006"),
162+
list(system = "http://loinc.org", code = "2339-0"),
163+
list(system = "http://www.nlm.nih.gov/research/umls/rxnorm", code = "197696")
164+
))
165+
cat(sprintf("Resolved %d/%d\n", batch$summary$resolved, batch$summary$total))
166+
167+
# CodeableConcept with vocabulary preference (SNOMED wins over ICD-10)
168+
result <- client$fhir$resolve_codeable_concept(
169+
coding = list(
170+
list(system = "http://snomed.info/sct", code = "44054006"),
171+
list(system = "http://hl7.org/fhir/sid/icd-10-cm", code = "E11.9")
172+
),
173+
resource_type = "Condition"
174+
)
175+
result$best_match$resolution$source_concept$vocabulary_id
176+
# [1] "SNOMED"
177+
```
178+
137179
## Use Cases
138180

139181
### ETL & Data Pipelines
@@ -226,6 +268,7 @@ concepts_df %>%
226268
| `mappings` | Cross-vocabulary mappings | `get()`, `map()` |
227269
| `vocabularies` | Vocabulary metadata | `list()`, `get()`, `stats()` |
228270
| `domains` | Domain information | `list()`, `get()`, `concepts()` |
271+
| `fhir` | FHIR-to-OMOP resolution | `resolve()`, `resolve_batch()`, `resolve_codeable_concept()` |
229272

230273
## Pagination
231274

0 commit comments

Comments
 (0)