349 lines
11 KiB
Python
Executable File
349 lines
11 KiB
Python
Executable File
# Copyright 2008-2024 pydicom authors. See LICENSE file for details.
|
|
"""Access code dictionary information"""
|
|
|
|
from itertools import chain
|
|
import inspect
|
|
from typing import cast, Any
|
|
from collections.abc import KeysView, Iterable
|
|
|
|
from pydicom.sr.coding import Code
|
|
from pydicom.sr._concepts_dict import concepts as CONCEPTS
|
|
from pydicom.sr._cid_dict import name_for_cid, cid_concepts as CID_CONCEPTS
|
|
|
|
|
|
# Reverse lookup for cid names
|
|
cid_for_name = {v: k for k, v in name_for_cid.items()}
|
|
|
|
|
|
def _filtered(source: Iterable[str], filters: Iterable[str]) -> list[str]:
|
|
"""Return a sorted list of filtered str.
|
|
|
|
Parameters
|
|
----------
|
|
source : Iterable[str]
|
|
The iterable of str to be filtered.
|
|
filters : Iterable[str]
|
|
An iterable containing patterns for which values are to be included
|
|
in the results.
|
|
|
|
Returns
|
|
-------
|
|
List[str]
|
|
A sorted list of unique values from `source`, filtered by including
|
|
case-insensitive partial or full matches against the values
|
|
in `filters`.
|
|
"""
|
|
if not filters:
|
|
return sorted(set(source))
|
|
|
|
filters = [f.lower() for f in filters]
|
|
|
|
return sorted(
|
|
set(val for val in source if any((f in val.lower()) for f in filters))
|
|
)
|
|
|
|
|
|
CIDValueType = dict[str, tuple[str, list[int]]]
|
|
ConceptsType = dict[str, CIDValueType]
|
|
SnomedMappingType = dict[str, dict[str, str]]
|
|
|
|
|
|
class Collection:
|
|
"""Interface for a collection of concepts, such as SNOMED-CT, or a DICOM CID.
|
|
|
|
.. versionadded:: 3.0
|
|
"""
|
|
|
|
repr_format = "{} = {}"
|
|
|
|
def __init__(self, name: str) -> None:
|
|
"""Create a new collection.
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
The name of the collection, should either be a key in the
|
|
``sr._concepts_dict.concepts`` :class:`dict` or a CID name for
|
|
a CID in ``sr._cid_dict.cid_concepts`` such as ``"CID1234"``.
|
|
"""
|
|
if not name.upper().startswith("CID"):
|
|
self._name = name
|
|
# dict[str, dict[str, tuple(str, list[int])]]
|
|
# {'ACEInhibitor': {'41549009': ('ACE inhibitor', [3760])},
|
|
self._scheme_data = CONCEPTS[name]
|
|
else:
|
|
self._name = f"CID{name[3:]}"
|
|
# dict[str, list[str]]
|
|
# {'SCT': ['Pericardium', 'Pleura', 'LeftPleura', 'RightPleura']}
|
|
self._cid_data = CID_CONCEPTS[int(name[3:])]
|
|
|
|
self._concepts: dict[str, Code] = {}
|
|
|
|
@property
|
|
def concepts(self) -> dict[str, Code]:
|
|
"""Return a :class:`dict` of {SR identifiers: codes}"""
|
|
if not self._concepts:
|
|
self._concepts = {name: getattr(self, name) for name in self.dir()}
|
|
|
|
return self._concepts
|
|
|
|
def __contains__(self, item: str | Code) -> bool:
|
|
"""Checks whether a given code is a member of the collection.
|
|
|
|
Parameters
|
|
----------
|
|
item : pydicom.sr.coding.Code | str
|
|
The code to check for as either the code or the corresponding
|
|
keyword.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
Whether the collection contains the `code`
|
|
"""
|
|
if isinstance(item, str):
|
|
try:
|
|
code = getattr(self, item)
|
|
except AttributeError:
|
|
return False
|
|
else:
|
|
code = item
|
|
|
|
return code in self.concepts.values()
|
|
|
|
def __dir__(self) -> list[str]:
|
|
"""Return a list of available concept keywords.
|
|
|
|
List of attributes is used, for example, in auto-completion in editors
|
|
or command-line environments.
|
|
"""
|
|
meths = {v[0] for v in inspect.getmembers(type(self), inspect.isroutine)}
|
|
props = {v[0] for v in inspect.getmembers(type(self), inspect.isdatadescriptor)}
|
|
sr_names = set(self.dir())
|
|
|
|
return sorted(props | meths | sr_names)
|
|
|
|
def dir(self, *filters: str) -> list[str]:
|
|
"""Return an sorted list of concept keywords based on a partial match.
|
|
|
|
Parameters
|
|
----------
|
|
filters : str
|
|
Zero or more string arguments to the function. Used for
|
|
case-insensitive match to any part of the SR keyword.
|
|
|
|
Returns
|
|
-------
|
|
list of str
|
|
The matching keywords. If no `filters` are used then all
|
|
keywords are returned.
|
|
"""
|
|
# CID_CONCEPTS: Dict[int, Dict[str, List[str]]]
|
|
if self.is_cid:
|
|
return _filtered(chain.from_iterable(self._cid_data.values()), filters)
|
|
|
|
return _filtered(self._scheme_data, filters)
|
|
|
|
def __getattr__(self, name: str) -> Code:
|
|
"""Return the :class:`~pydicom.sr.Code` corresponding to `name`.
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
A camel case version of the concept's code meaning, such as
|
|
``"FontanelOfSkull" in the SCT coding scheme.
|
|
|
|
Returns
|
|
-------
|
|
pydicom.sr.Code
|
|
The :class:`~pydicom.sr.Code` corresponding to `name`.
|
|
"""
|
|
if self.name.startswith("CID"):
|
|
# Try DICOM's CID collections
|
|
matches = [
|
|
scheme
|
|
for scheme, keywords in self._cid_data.items()
|
|
if name in keywords
|
|
]
|
|
if not matches:
|
|
raise AttributeError(
|
|
f"No matching code for keyword '{name}' in {self.name}"
|
|
)
|
|
|
|
if len(matches) > 1:
|
|
# Shouldn't happen, but just in case
|
|
raise RuntimeError(
|
|
f"Multiple schemes found to contain the keyword '{name}' in "
|
|
f"{self.name}: {', '.join(matches)}"
|
|
)
|
|
|
|
scheme = matches[0]
|
|
identifiers = cast(CIDValueType, CONCEPTS[scheme][name])
|
|
|
|
if len(identifiers) == 1:
|
|
code, val = list(identifiers.items())[0]
|
|
else:
|
|
cid = int(self.name[3:])
|
|
_matches = [
|
|
(code, val) for code, val in identifiers.items() if cid in val[1]
|
|
]
|
|
if len(_matches) > 1:
|
|
# Shouldn't happen, but just in case
|
|
codes = ", ".join(v[0] for v in _matches)
|
|
raise RuntimeError(
|
|
f"Multiple codes found for keyword '{name}' in {self.name}: "
|
|
f"{codes}"
|
|
)
|
|
|
|
code, val = _matches[0]
|
|
|
|
return Code(value=code, meaning=val[0], scheme_designator=scheme)
|
|
|
|
# Try concept collections such as SCT, DCM, etc
|
|
try:
|
|
entries = cast(CIDValueType, self._scheme_data[name])
|
|
except KeyError:
|
|
raise AttributeError(
|
|
f"No matching code for keyword '{name}' in scheme '{self.name}'"
|
|
)
|
|
|
|
if len(entries) > 1:
|
|
# val is {"code": ("meaning", [cid1, cid2, ...], "code": ...}
|
|
code_values = ", ".join(entries.keys())
|
|
raise RuntimeError(
|
|
f"Multiple codes found for keyword '{name}' in scheme '{self.name}': "
|
|
f"{code_values}"
|
|
)
|
|
|
|
code = list(entries.keys())[0] # get first and only
|
|
meaning, cids = entries[code]
|
|
|
|
return Code(value=code, meaning=meaning, scheme_designator=self.name)
|
|
|
|
@property
|
|
def is_cid(self) -> bool:
|
|
"""Return ``True`` if the collection is one of the DICOM CIDs"""
|
|
return self.name.startswith("CID")
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
"""Return the name of the collection."""
|
|
return self._name
|
|
|
|
def __repr__(self) -> str:
|
|
"""Return a representation of the collection."""
|
|
concepts = [
|
|
self.repr_format.format(name, concept)
|
|
for name, concept in self.concepts.items()
|
|
]
|
|
|
|
return f"{self.name}\n" + "\n".join(concepts)
|
|
|
|
@property
|
|
def scheme_designator(self) -> str:
|
|
"""Return the scheme designator for the collection."""
|
|
return self.name
|
|
|
|
def __str__(self) -> str:
|
|
"""Return a string representation of the collection."""
|
|
len_names = max(len(n) for n in self.concepts.keys()) + 2
|
|
len_codes = max(len(c[0]) for c in self.concepts.values()) + 2
|
|
len_schemes = max(len(c[1]) for c in self.concepts.values()) + 2
|
|
|
|
# Ensure each column is at least X characters wide
|
|
len_names = max(len_names, 11)
|
|
len_codes = max(len_codes, 6)
|
|
len_schemes = max(len_schemes, 8)
|
|
|
|
if self.is_cid:
|
|
fmt = f"{{:{len_names}}} {{:{len_codes}}} {{:{len_schemes}}} {{}}"
|
|
|
|
s = [self.name]
|
|
s.append(fmt.format("Attribute", "Code", "Scheme", "Meaning"))
|
|
s.append(fmt.format("---------", "----", "------", "-------"))
|
|
s.append(
|
|
"\n".join(
|
|
fmt.format(name, *concept)
|
|
for name, concept in self.concepts.items()
|
|
)
|
|
)
|
|
else:
|
|
fmt = f"{{:{len_names}}} {{:{len_codes}}} {{}}"
|
|
|
|
s = [f"Scheme: {self.name}"]
|
|
s.append(fmt.format("Attribute", "Code", "Meaning"))
|
|
s.append(fmt.format("---------", "----", "-------"))
|
|
|
|
s.append(
|
|
"\n".join(
|
|
fmt.format(name, concept[0], concept[2])
|
|
for name, concept in self.concepts.items()
|
|
)
|
|
)
|
|
|
|
return "\n".join(s)
|
|
|
|
def trait_names(self) -> list[str]:
|
|
"""Return a list of valid names for auto-completion code.
|
|
|
|
Used in IPython, so that data element names can be found and offered
|
|
for autocompletion on the IPython command line.
|
|
"""
|
|
return dir(self)
|
|
|
|
|
|
class Concepts:
|
|
"""Management class for the available concept collections.
|
|
|
|
.. versionadded:: 3.0
|
|
"""
|
|
|
|
def __init__(self, collections: list[Collection]) -> None:
|
|
"""Create a new concepts management class instance.
|
|
|
|
Parameters
|
|
----------
|
|
collections : list[Collection]
|
|
A list of the available concept collections.
|
|
"""
|
|
self._collections = {c.name: c for c in collections}
|
|
|
|
@property
|
|
def collections(self) -> KeysView[str]:
|
|
"""Return the names of the available concept collections."""
|
|
return self._collections.keys()
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
"""Return the concept collection corresponding to `name`.
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
The scheme designator or CID name for the collection to be returned.
|
|
"""
|
|
if name.upper().startswith("CID"):
|
|
name = f"CID{name[3:]}"
|
|
|
|
if name in self._collections:
|
|
return self._collections[name]
|
|
|
|
raise AttributeError(
|
|
f"'{type(self).__name__}' object has no attribute '{name}'"
|
|
)
|
|
|
|
def schemes(self) -> list[str]:
|
|
"""Return a list of available scheme designations."""
|
|
return [c for c in self._collections.keys() if not c.startswith("CID")]
|
|
|
|
def CIDs(self) -> list[str]:
|
|
"""Return a list of available CID names."""
|
|
return [c for c in self._collections.keys() if c.startswith("CID")]
|
|
|
|
|
|
# Named concept collections like SNOMED-CT, etc
|
|
_collections = [Collection(designator) for designator in CONCEPTS]
|
|
# DICOM CIDs
|
|
_collections.extend(Collection(f"CID{cid}") for cid in name_for_cid)
|
|
|
|
codes = Concepts(_collections)
|