Skip to content

Commit b6c74dd

Browse files
author
alex-omophub
committed
Extending mapping method with source_codes option
1 parent 88cb440 commit b6c74dd

2 files changed

Lines changed: 160 additions & 17 deletions

File tree

src/omophub/resources/mappings.py

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,29 +47,50 @@ def get(
4747

4848
def map(
4949
self,
50-
source_concepts: list[int],
5150
target_vocabulary: str,
5251
*,
52+
source_concepts: list[int] | None = None,
53+
source_codes: list[dict[str, str]] | None = None,
5354
mapping_type: str | None = None,
5455
include_invalid: bool = False,
5556
vocab_release: str | None = None,
5657
) -> dict[str, Any]:
5758
"""Map concepts to a target vocabulary.
5859
5960
Args:
60-
source_concepts: List of OMOP concept IDs to map
61-
target_vocabulary: Target vocabulary ID (e.g., "ICD10CM", "SNOMED")
62-
mapping_type: Mapping type (direct, equivalent, broader, narrower)
61+
target_vocabulary: Target vocabulary ID (e.g., "ICD10CM", "SNOMED", "RxNorm")
62+
source_concepts: List of OMOP concept IDs to map. Use this OR source_codes,
63+
not both.
64+
source_codes: List of vocabulary/code pairs to map, e.g.,
65+
[{"vocabulary_id": "SNOMED", "concept_code": "387517004"}].
66+
Use this OR source_concepts, not both.
67+
mapping_type: Mapping type filter (direct, equivalent, broader, narrower)
6368
include_invalid: Include invalid mappings
6469
vocab_release: Specific vocabulary release version (e.g., "2025.1")
6570
6671
Returns:
6772
Mapping results with summary
73+
74+
Raises:
75+
ValueError: If neither or both source_concepts and source_codes are provided
6876
"""
77+
# Validate: exactly one of source_concepts or source_codes required
78+
has_concepts = source_concepts is not None and len(source_concepts) > 0
79+
has_codes = source_codes is not None and len(source_codes) > 0
80+
81+
if not has_concepts and not has_codes:
82+
raise ValueError("Either source_concepts or source_codes is required")
83+
if has_concepts and has_codes:
84+
raise ValueError("Cannot use both source_concepts and source_codes")
85+
6986
body: dict[str, Any] = {
70-
"source_concepts": source_concepts,
7187
"target_vocabulary": target_vocabulary,
7288
}
89+
90+
if source_concepts:
91+
body["source_concepts"] = source_concepts
92+
if source_codes:
93+
body["source_codes"] = source_codes
7394
if mapping_type:
7495
body["mapping_type"] = mapping_type
7596
if include_invalid:
@@ -123,29 +144,50 @@ async def get(
123144

124145
async def map(
125146
self,
126-
source_concepts: list[int],
127147
target_vocabulary: str,
128148
*,
149+
source_concepts: list[int] | None = None,
150+
source_codes: list[dict[str, str]] | None = None,
129151
mapping_type: str | None = None,
130152
include_invalid: bool = False,
131153
vocab_release: str | None = None,
132154
) -> dict[str, Any]:
133155
"""Map concepts to a target vocabulary.
134156
135157
Args:
136-
source_concepts: List of OMOP concept IDs to map
137-
target_vocabulary: Target vocabulary ID (e.g., "ICD10CM", "SNOMED")
138-
mapping_type: Mapping type (direct, equivalent, broader, narrower)
158+
target_vocabulary: Target vocabulary ID (e.g., "ICD10CM", "SNOMED", "RxNorm")
159+
source_concepts: List of OMOP concept IDs to map. Use this OR source_codes,
160+
not both.
161+
source_codes: List of vocabulary/code pairs to map, e.g.,
162+
[{"vocabulary_id": "SNOMED", "concept_code": "387517004"}].
163+
Use this OR source_concepts, not both.
164+
mapping_type: Mapping type filter (direct, equivalent, broader, narrower)
139165
include_invalid: Include invalid mappings
140166
vocab_release: Specific vocabulary release version (e.g., "2025.1")
141167
142168
Returns:
143169
Mapping results with summary
170+
171+
Raises:
172+
ValueError: If neither or both source_concepts and source_codes are provided
144173
"""
174+
# Validate: exactly one of source_concepts or source_codes required
175+
has_concepts = source_concepts is not None and len(source_concepts) > 0
176+
has_codes = source_codes is not None and len(source_codes) > 0
177+
178+
if not has_concepts and not has_codes:
179+
raise ValueError("Either source_concepts or source_codes is required")
180+
if has_concepts and has_codes:
181+
raise ValueError("Cannot use both source_concepts and source_codes")
182+
145183
body: dict[str, Any] = {
146-
"source_concepts": source_concepts,
147184
"target_vocabulary": target_vocabulary,
148185
}
186+
187+
if source_concepts:
188+
body["source_concepts"] = source_concepts
189+
if source_codes:
190+
body["source_codes"] = source_codes
149191
if mapping_type:
150192
body["mapping_type"] = mapping_type
151193
if include_invalid:

tests/unit/resources/test_mappings.py

Lines changed: 108 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ def test_map_concepts(self, sync_client: OMOPHub, base_url: str) -> None:
7979
)
8080

8181
result = sync_client.mappings.map(
82-
source_concepts=[{"concept_id": 201826}],
8382
target_vocabulary="ICD10CM",
83+
source_concepts=[201826],
8484
)
8585

8686
assert "mappings" in result
@@ -97,18 +97,72 @@ def test_map_concepts_with_options(
9797
)
9898

9999
sync_client.mappings.map(
100-
source_concepts=[
101-
{"concept_id": 201826},
102-
{"vocabulary_id": "SNOMED", "concept_code": "44054006"},
103-
],
104100
target_vocabulary="ICD10CM",
101+
source_concepts=[201826, 4329847],
105102
mapping_type="equivalent",
106103
include_invalid=True,
107104
)
108105

109106
# Verify POST body
110107
assert route.calls[0].request.content
111108

109+
@respx.mock
110+
def test_map_concepts_with_source_codes(
111+
self, sync_client: OMOPHub, base_url: str
112+
) -> None:
113+
"""Test mapping concepts using source_codes parameter."""
114+
import json
115+
116+
map_response = {
117+
"success": True,
118+
"data": {
119+
"mappings": [
120+
{
121+
"source_concept_id": 4306040,
122+
"source_concept_name": "Acetaminophen",
123+
"target_concept_id": 1125315,
124+
"target_vocabulary_id": "RxNorm",
125+
}
126+
],
127+
},
128+
}
129+
route = respx.post(f"{base_url}/concepts/map").mock(
130+
return_value=Response(200, json=map_response)
131+
)
132+
133+
result = sync_client.mappings.map(
134+
target_vocabulary="RxNorm",
135+
source_codes=[
136+
{"vocabulary_id": "SNOMED", "concept_code": "387517004"},
137+
{"vocabulary_id": "SNOMED", "concept_code": "108774000"},
138+
],
139+
)
140+
141+
assert "mappings" in result
142+
# Verify request body contains source_codes
143+
body = json.loads(route.calls[0].request.content)
144+
assert "source_codes" in body
145+
assert len(body["source_codes"]) == 2
146+
assert body["source_codes"][0]["vocabulary_id"] == "SNOMED"
147+
148+
def test_map_concepts_requires_source(self, sync_client: OMOPHub) -> None:
149+
"""Test that map() raises error when neither source_concepts nor source_codes provided."""
150+
with pytest.raises(
151+
ValueError, match="Either source_concepts or source_codes is required"
152+
):
153+
sync_client.mappings.map(target_vocabulary="ICD10CM")
154+
155+
def test_map_concepts_rejects_both_sources(self, sync_client: OMOPHub) -> None:
156+
"""Test that map() raises error when both source_concepts and source_codes provided."""
157+
with pytest.raises(
158+
ValueError, match="Cannot use both source_concepts and source_codes"
159+
):
160+
sync_client.mappings.map(
161+
target_vocabulary="ICD10CM",
162+
source_concepts=[201826],
163+
source_codes=[{"vocabulary_id": "SNOMED", "concept_code": "44054006"}],
164+
)
165+
112166

113167
class TestAsyncMappingsResource:
114168
"""Tests for the asynchronous AsyncMappings resource."""
@@ -157,8 +211,8 @@ async def test_async_map_concepts(
157211
)
158212

159213
result = await async_client.mappings.map(
160-
source_concepts=[{"concept_id": 201826}],
161214
target_vocabulary="ICD10CM",
215+
source_concepts=[201826],
162216
)
163217

164218
assert "mappings" in result
@@ -174,10 +228,57 @@ async def test_async_map_concepts_with_options(
174228
)
175229

176230
await async_client.mappings.map(
177-
source_concepts=[{"concept_id": 201826}],
178231
target_vocabulary="ICD10CM",
232+
source_concepts=[201826],
179233
mapping_type="direct",
180234
include_invalid=True,
181235
)
182236

183237
assert route.calls[0].request.content
238+
239+
@pytest.mark.asyncio
240+
@respx.mock
241+
async def test_async_map_concepts_with_source_codes(
242+
self, async_client: omophub.AsyncOMOPHub, base_url: str
243+
) -> None:
244+
"""Test async mapping concepts using source_codes."""
245+
import json
246+
247+
route = respx.post(f"{base_url}/concepts/map").mock(
248+
return_value=Response(200, json={"success": True, "data": {"mappings": []}})
249+
)
250+
251+
result = await async_client.mappings.map(
252+
target_vocabulary="RxNorm",
253+
source_codes=[
254+
{"vocabulary_id": "SNOMED", "concept_code": "387517004"},
255+
],
256+
)
257+
258+
assert "mappings" in result
259+
body = json.loads(route.calls[0].request.content)
260+
assert "source_codes" in body
261+
262+
@pytest.mark.asyncio
263+
async def test_async_map_requires_source(
264+
self, async_client: omophub.AsyncOMOPHub
265+
) -> None:
266+
"""Test async map() raises error without sources."""
267+
with pytest.raises(
268+
ValueError, match="Either source_concepts or source_codes is required"
269+
):
270+
await async_client.mappings.map(target_vocabulary="ICD10CM")
271+
272+
@pytest.mark.asyncio
273+
async def test_async_map_rejects_both_sources(
274+
self, async_client: omophub.AsyncOMOPHub
275+
) -> None:
276+
"""Test async map() raises error with both sources."""
277+
with pytest.raises(
278+
ValueError, match="Cannot use both source_concepts and source_codes"
279+
):
280+
await async_client.mappings.map(
281+
target_vocabulary="ICD10CM",
282+
source_concepts=[201826],
283+
source_codes=[{"vocabulary_id": "SNOMED", "concept_code": "44054006"}],
284+
)

0 commit comments

Comments
 (0)