Skip to main content

Terminology Service

The terminology service abstraction (fhir4ds.cql.terminology) is the seam between fhir4ds and any FHIR R4 terminology provider. By default it ships disabledimport fhir4ds pulls no terminology dependencies. Opt in via an install extra and an environment variable, and the CQL translator, the auto-coder, and the closure-table builder all start resolving ValueSets and subsumption relationships against your chosen terminology server.

The reference backend is medterm4ds — a UMLS-backed FHIR R4 terminology server with $expand, $lookup, $validate-code, $translate, $subsumes, $closure, plus a custom $search operation for lexical/hybrid/semantic concept discovery.

1. Install

pip install 'fhir4ds-v2[terminology]'

The extra pulls httpx>=0.24 and medterm4ds>=0.0.1. fhir4ds itself never imports either at module load — both are imported lazily inside the factory when you opt into a non-disabled mode.

2. Configuration

The factory fhir4ds.cql.terminology.factory.get_terminology_endpoint(config=None) reads from explicit config or environment variables:

Env varPurposeDefault
FHIR4DS_TERMINOLOGY_MODEdisabled | http | in_processdisabled
FHIR4DS_TERMINOLOGY_URLFHIR root URL for HTTP mode (e.g. http://127.0.0.1:8001/fhir)
FHIR4DS_TERMINOLOGY_TIMEOUTHTTP timeout in seconds5.0
FHIR4DS_TERMINOLOGY_DBPath to medterm4ds DuckDB file (in_process mode)medterm4ds default
FHIR4DS_TERMINOLOGY_SEARCH_INDEX_DIRPrebuilt search-index directory (in_process mode)
FHIR4DS_TERMINOLOGY_PROBEtrue to log a startup health probefalse

Env vars are read lazily inside the factory body, not at module import. Passing an explicit TerminologyConfig overrides env vars.

from fhir4ds.cql.terminology import (
TerminologyConfig,
get_terminology_endpoint,
)

# Via explicit config
endpoint = get_terminology_endpoint(
TerminologyConfig(
mode="http",
url="http://127.0.0.1:8001/fhir",
timeout_seconds=10.0,
)
)

# Or via env vars: FHIR4DS_TERMINOLOGY_MODE=http FHIR4DS_TERMINOLOGY_URL=...
endpoint = get_terminology_endpoint()

3. HTTP mode

HTTP mode is the right choice when you already run a terminology server (medterm4ds sidecar, HL7 FHIR server, Aperture, etc.). The adapter speaks standard FHIR R4 operations: $expand, $expand with valueSet body (intensional), $lookup, $subsumes, $closure, plus medterm4ds's custom $search.

from fhir4ds.cql.terminology import TerminologyConfig, get_terminology_endpoint

endpoint = get_terminology_endpoint(
TerminologyConfig(
mode="http",
url="http://127.0.0.1:8001/fhir",
)
)

# Expand a ValueSet by canonical URL (with fhir_vs shorthand)
refs = endpoint.expand("http://snomed.info/sct?fhir_vs=isa/73211009")
# → [CodeRef(system="http://snomed.info/sct", code="73211009", display="Diabetes mellitus (disorder)"), ...]

4. In-process mode

In-process mode embeds medterm4ds directly — no HTTP hop. The factory calls medterm4ds.connect(db_path=...) to build a Terminology facade whose .engine is a DiscoveryEngine we can drive.

from fhir4ds.cql.terminology import TerminologyConfig, get_terminology_endpoint

endpoint = get_terminology_endpoint(
TerminologyConfig(
mode="in_process",
medterm4ds_db_path="/path/to/lookup.duckdb",
)
)

# Same surface as HTTP mode
refs = endpoint.expand("http://snomed.info/sct?fhir_vs=isa/73211009")
results = endpoint.search_text("diabetes", category="condition", mode="hybrid")

Known limitation: expand_intensional is HTTP-only on the published medterm4ds 0.0.1 wheel — there is no programmatic entry point. The in-process adapter logs a WARNING and returns []; if you need intensional ValueSet expansion, switch this endpoint to HTTP mode.

5. Endpoint surface

All three modes (disabled returns None) expose the same protocol (fhir4ds.cql.terminology.endpoint.TerminologyEndpoint):

MethodReturnsDescription
expand(valueset_url)list[CodeRef]Expand a ValueSet canonical URL (supports fhir_vs shorthand and filter forms).
expand_intensional(value_set)list[CodeRef]Expand an intensional ValueSet (compose-walk). HTTP-only on published medterm4ds.
search_text(query, category, *, mode)list[SearchResult]Lexical/semantic/hybrid search over concept names.
search_batch(queries, *, mode)list[list[SearchResult]]Batched search_text.
is_healthy()boolProbe the underlying engine / server without raising. Never raises.

6. Circuit breaker

Both adapters ship with a per-instance circuit breaker. After breaker_threshold consecutive failures (default 5), subsequent calls short-circuit to [] for breaker_cooldown_seconds (default 60.0). A successful call resets the counter. The breaker is not thread-safe — if you share an endpoint across threads, wrap it in an external lock.

from fhir4ds.cql.terminology.in_process_adapter import InProcessTerminologyEndpoint

endpoint = InProcessTerminologyEndpoint(
breaker_threshold=10,
breaker_cooldown_seconds=120.0,
)

The "not supported" path on expand_intensional (in-process mode, published medterm4ds 0.0.1) intentionally does not trip the breaker — it's a permanent gap rather than a transient failure, and tripping would collateral-damage expand and search_text.

7. System URL normalization

medterm4ds returns source mnemonics from UMLS (SNOMEDCT_US, RXNORM, LNC, ICD10CM, CPT). fhir4ds expands these to their FHIR canonical URLs before returning CodeRefs, so downstream joins against valueset_codes rows that use http://snomed.info/sct succeed:

UMLS mnemonicFHIR canonical URL
SNOMEDCT_UShttp://snomed.info/sct
RXNORMhttp://www.nlm.nih.gov/research/umls/rxnorm
LNChttp://loinc.org
ICD10CMhttp://hl7.org/fhir/sid/icd-10-cm
CPThttp://www.ama-assn.org/go/cpt

The map lives in fhir4ds.cql.terminology.system_mappings.SOURCE_MNEMONIC_TO_URL. A second pass through SystemResolver.normalize reduces OID and SNOMED module URL variants (http://snomed.info/sct/731000124108) to their canonical base.

8. Wiring it into the loader and the translator

Once you have an endpoint, pass it through the loader and the CQL evaluator:

import fhir4ds
from fhir4ds.cql import FHIRDataLoader
from fhir4ds.cql.terminology import TerminologyConfig, get_terminology_endpoint

con = fhir4ds.create_connection()
endpoint = get_terminology_endpoint(TerminologyConfig(mode="http", url="http://127.0.0.1:8001/fhir"))

loader = FHIRDataLoader(con, terminology_endpoint=endpoint)
loader.load_ndjson("conditions.ndjson")

# CQL retrieves against [ValueSet: <canonical>] now resolve via the endpoint.

For auto-coding of text-only CodeableConcepts, see Auto-Coding. For clinical-notes NER (deriving Conditions from free text), see Notes Pipeline.